refactor(gui-client): merge all windows into a single view (#9295)

This PR refactors the GUI clients frontend into a single window with a
sidebar. The functionality remains the same but we do make minor UI
improvements on the way. To pull the entire refactor off, we now use
`react` and `flowbite-react` for the GUI. All the communication with the
backend is moved towards one-way commands and events. That means, the
flow is always:

- Backend emits events to update frontend
- Frontend triggers actions in the backend that may or may not result in
further events

This allows us to decouple the GUI from knowing about which side-effects
change what parts of the state. Instead, it simply updates whenever it
receives an event.

- The previous "Advanced Settings" screen is now split into two parts:
Settings and Diagnostics. Later, we will add a "General settings" page
here.
- The tray menu remains identical to the current one. When the user
clicks "Settings" or "About", we open the window and navigate to the
corresponding page.
- The app and git version are now directly embedded in the frontend,
simplifying the interaction between the frontend and the backend
further.

|Before|After|
|---|---|

|![](https://github.com/user-attachments/assets/7e8039c8-d589-495e-92b4-1742f9eb01b2)|![](https://github.com/user-attachments/assets/363184b3-4fcf-45c2-82d2-c466902759ef)|

|![](https://github.com/user-attachments/assets/88163522-cafc-4ad4-90cd-be2e77073b7f)|![](https://github.com/user-attachments/assets/106ef921-38a7-4603-add9-8b0875064737)|

|![](https://github.com/user-attachments/assets/a4bef4b0-5b29-43dd-aea6-0babd3b4ec9e)|![](https://github.com/user-attachments/assets/b84f1b51-c35c-48cc-9335-c653eee597ff)

|![](https://github.com/user-attachments/assets/f0473a0a-cdba-4a15-af98-d97ef422dbc5)|![](https://github.com/user-attachments/assets/ddced01b-6f44-4241-80ea-038a4740915b)|
This commit is contained in:
Thomas Eizinger
2025-05-31 11:57:40 +10:00
committed by GitHub
parent 02638582fe
commit 56ff469f03
36 changed files with 1851 additions and 1297 deletions

View File

@@ -0,0 +1,2 @@
class-list.json
pid

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://unpkg.com/flowbite-react/schema.json",
"components": [],
"dark": false,
"prefix": "",
"path": "src-frontend/*",
"tsx": true,
"rsc": true
}

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<link rel="stylesheet" href="src-frontend/main.css" />
<script type="module" src="src-frontend/main.tsx"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap");
</style>
<title>Firezone</title>
</head>
<body id="root" />
</html>

View File

@@ -12,19 +12,24 @@
"dev": "run-script-os",
"dev:win32": "call dev.bat",
"dev:darwin:linux": "./dev.sh",
"tauri": "tauri"
"tauri": "tauri",
"postinstall": "flowbite-react patch"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@tailwindcss/cli": "^4.1.7",
"@tauri-apps/api": "^2.5.0",
"flowbite": "^3.1.2"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.1.2",
"@tailwindcss/vite": "^4.1.7",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.15.3",
"http-server": "^14.1.1",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
"flowbite": "^3.1.2",
"flowbite-react": "^0.11.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.6.1",
"run-script-os": "^1.1.6",
"tailwindcss": "^4.1.7",
"tslib": "^2.8.1",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
import React from "react";
import logo from "../logo.png";
export default function AboutPage() {
return (
<div className="w-full h-full max-w-sm flex flex-col justify-center items-center mx-auto">
<img src={logo} alt="Firezone Logo" className="w-20 h-20 mb-6" />
<p className="text-neutral-600 mb-1">Version</p>
<p className="text-2xl font-bold mb-1">
<span>{__APP_VERSION__}</span>
</p>
<p className="text-neutral-400 text-sm mb-6">
(<span>{__GIT_VERSION__?.substring(0, 8)}</span>)
</p>
<a
href="https://docs.firezone.dev"
target="_blank"
rel="noopener noreferrer"
className="text-accent-450 hover:underline text-sm"
>
Documentation
</a>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import {
CogIcon,
DocumentMagnifyingGlassIcon,
HomeIcon,
InformationCircleIcon,
SwatchIcon,
} from "@heroicons/react/24/solid";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
Sidebar,
SidebarItem,
SidebarItemGroup,
SidebarItems,
} from "flowbite-react";
import React, { useEffect, useState } from "react";
import { NavLink, Route, Routes } from "react-router";
import About from "./AboutPage";
import ColorPalette from "./ColorPalettePage";
import Diagnostics, { FileCount } from "./DiagnosticsPage";
import Overview from "./OverviewPage";
import SettingsPage, { Settings } from "./SettingsPage";
export interface Session {
account_slug: string;
actor_name: string;
}
export default function App() {
let [session, setSession] = useState<Session | null>(null);
let [logCount, setLogCount] = useState<FileCount | null>(null);
let [settings, setSettings] = useState<Settings | null>(null);
useEffect(() => {
const signedInUnlisten = listen<Session>("signed_in", (e) => {
let session = e.payload;
console.log("signed_in", { session });
setSession(session);
});
const signedOutUnlisten = listen<void>("signed_out", (_e) => {
console.log("signed_out");
setSession(null);
});
const settingsChangedUnlisten = listen<Settings>(
"settings_changed",
(e) => {
let settings = e.payload;
console.log("settings_changed", { settings });
setSettings(settings);
}
);
const logsRecountedUnlisten = listen<FileCount>("logs_recounted", (e) => {
let file_count = e.payload;
console.log("logs_recounted", { file_count });
setLogCount(file_count);
});
invoke<void>("update_state"); // Let the backend know that we (re)-initialised
return () => {
signedInUnlisten.then((unlistenFn) => unlistenFn());
signedOutUnlisten.then((unlistenFn) => unlistenFn());
settingsChangedUnlisten.then((unlistenFn) => unlistenFn());
logsRecountedUnlisten.then((unlistenFn) => unlistenFn());
};
}, []);
const isDev = import.meta.env.DEV;
return (
<div className="h-screen bg-neutral-50 flex flex-row">
<Sidebar
aria-label="Sidebar"
className="w-52 flex-shrink-0 border-r border-neutral-200"
>
<SidebarItems>
<SidebarItemGroup>
<NavLink to="/overview">
{({ isActive }) => (
<SidebarItem active={isActive} icon={HomeIcon} as="div">
Overview
</SidebarItem>
)}
</NavLink>
<NavLink to="/settings">
{({ isActive }) => (
<SidebarItem active={isActive} icon={CogIcon} as="div">
Settings
</SidebarItem>
)}
</NavLink>
<NavLink to="/diagnostics">
{({ isActive }) => (
<SidebarItem active={isActive} icon={DocumentMagnifyingGlassIcon} as="div">
Diagnostics
</SidebarItem>
)}
</NavLink>
<NavLink to="/about">
{({ isActive }) => (
<SidebarItem
active={isActive}
icon={InformationCircleIcon}
as="div"
>
About
</SidebarItem>
)}
</NavLink>
</SidebarItemGroup>
{isDev && (
<SidebarItemGroup>
<NavLink to="/colour-palette">
{({ isActive }) => (
<SidebarItem active={isActive} icon={SwatchIcon} as="div">
Color Palette
</SidebarItem>
)}
</NavLink>
</SidebarItemGroup>
)}
</SidebarItems>
</Sidebar>
<main className="flex-grow overflow-auto">
<Routes>
<Route
path="/overview"
element={
<Overview
session={session}
signIn={() => invoke("sign_in")}
signOut={() => invoke("sign_out")}
/>
}
/>
<Route
path="/settings"
element={
<SettingsPage
settings={settings}
saveSettings={(settings) =>
invoke("apply_advanced_settings", { settings })
}
resetSettings={() => invoke("reset_advanced_settings")}
/>
}
/>
<Route
path="/diagnostics"
element={
<Diagnostics
logCount={logCount}
exportLogs={() => invoke("export_logs")}
clearLogs={async () => {
await invoke("clear_logs");
let logCount = await invoke<FileCount>("count_logs");
setLogCount(logCount);
}}
/>
}
/>
<Route path="/about" element={<About />} />
<Route path="/colour-palette" element={<ColorPalette />} />
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import React from "react";
const primaryColors = [
"bg-primary-50",
"bg-primary-100",
"bg-primary-200",
"bg-primary-300",
"bg-primary-400",
"bg-primary-450",
"bg-primary-500",
"bg-primary-600",
"bg-primary-700",
"bg-primary-800",
"bg-primary-900",
];
const accentColors = [
"bg-accent-50",
"bg-accent-100",
"bg-accent-200",
"bg-accent-300",
"bg-accent-400",
"bg-accent-450",
"bg-accent-500",
"bg-accent-600",
"bg-accent-700",
"bg-accent-800",
"bg-accent-900",
];
const neutralColors = [
"bg-neutral-50",
"bg-neutral-100",
"bg-neutral-200",
"bg-neutral-300",
"bg-neutral-400",
"bg-neutral-500",
"bg-neutral-600",
"bg-neutral-700",
"bg-neutral-800",
"bg-neutral-900",
];
export default function ColorPalettePage() {
return (
<div className="p-6 max-w-full mx-auto">
<ColorSection title="Primary Colors" colors={primaryColors} />
<ColorSection title="Accent Colors" colors={accentColors} />
<ColorSection title="Neutral Colors" colors={neutralColors} />
</div>
);
}
function ColorSwatch({ colorClass }: { colorClass: string }) {
return (
<div className="bg-white rounded-lg shadow overflow-hidden border border-neutral-200">
<div className={`h-14 ${colorClass}`}></div>
<div className="p-3 text-xs">{colorClass}</div>
</div>
);
}
function ColorSection({ title, colors }: { title: string; colors: string[] }) {
return (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 pb-2 border-b border-neutral-200">
{title}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{colors.map((color) => (
<ColorSwatch key={color} colorClass={color} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import React from "react";
import { ShareIcon, TrashIcon } from "@heroicons/react/16/solid";
import { Button } from "flowbite-react";
interface DiagnosticsPageProps {
logCount: FileCount | null;
exportLogs: () => void;
clearLogs: () => void;
}
export interface FileCount {
files: number;
bytes: number;
}
export default function Diagnostics({
logCount,
exportLogs,
clearLogs,
}: DiagnosticsPageProps) {
let bytes = logCount?.bytes ?? 0;
let files = logCount?.files ?? 0;
const megabytes = Math.round(bytes / 100000) / 10;
return (
<div className="container mx-auto p-4">
<div className="mb-4 pb-2">
<h2 className="text-xl font-semibold mb-4">Diagnostic Logs</h2>
</div>
<div className="p-4 rounded-lg">
<div className="mt-8 flex justify-center">
<p className="mr-1">Log directory size:</p>
<p id="log-count-output">{`${files} files, ${megabytes} MB`}</p>
</div>
<div className="mt-8 flex justify-center gap-4">
<Button
onClick={exportLogs}
color="alternative"
>
<ShareIcon className="mr-2 h-5 w-5" />
Export Logs
</Button>
<Button
onClick={clearLogs}
color="alternative"
>
<TrashIcon className="mr-2 h-5 w-5" />
Clear Logs
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import React from "react";
import logo from "../logo.png";
import { Session } from "./App";
import { Button } from "flowbite-react";
interface OverviewPageProps {
session: Session | null;
signOut: () => void;
signIn: () => void;
}
export default function Overview({
session,
signOut,
signIn,
}: OverviewPageProps) {
return (
<div className="flex flex-col items-center justify-center gap-4 min-h-screen">
<img src={logo} alt="Firezone Logo" className="w-40 h-40" />
<h1 className="text-6xl font-bold">Firezone</h1>
{!session ? (
<div id="signed-out">
<div className="flex flex-col items-center gap-4">
<p className="text-center">
You can sign in by clicking the Firezone icon in the taskbar or by
clicking 'Sign in' below.
</p>
<Button id="sign-in" onClick={signIn}>
Sign in
</Button>
<p className="text-xs text-center">
Firezone will continue running after this window is closed.
<br />
It is always available from the taskbar.
</p>
</div>
</div>
) : (
<div id="signed-in">
<div className="flex flex-col items-center gap-4">
<p className="text-center">
You are currently signed into&nbsp;
<span className="font-bold" id="account-slug">
{session.account_slug}
</span>
&nbsp;as&nbsp;
<span className="font-bold" id="actor-name">
{session.actor_name}
</span>
.<br />
Click the Firezone icon in the taskbar to see the list of
Resources.
</p>
<Button id="sign-out" onClick={signOut}>
Sign out
</Button>
<p className="text-xs text-center">
Firezone will continue running in the taskbar after this window is
closed.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
import React, { useEffect, useState } from "react";
import { Button, TextInput, Label, TextInputProps, Tooltip } from "flowbite-react";
export interface Settings {
auth_url: string;
auth_url_is_managed: boolean;
api_url: string;
api_url_is_managed: boolean;
log_filter: string;
log_filter_is_managed: boolean;
}
interface SettingsPageProps {
settings: Settings | null;
saveSettings: (settings: Settings) => void;
resetSettings: () => void;
}
export default function SettingsPage({
settings,
saveSettings,
resetSettings,
}: SettingsPageProps) {
// Local settings can be edited without affecting the global state.
const [localSettings, setLocalSettings] = useState<Settings>(
settings ?? {
api_url: "",
api_url_is_managed: false,
auth_url: "",
auth_url_is_managed: false,
log_filter: "",
log_filter_is_managed: false,
}
);
useEffect(() => {
setLocalSettings(
settings ?? {
api_url: "",
api_url_is_managed: false,
auth_url: "",
auth_url_is_managed: false,
log_filter: "",
log_filter_is_managed: false,
}
);
}, [settings]);
return (
<div className="container mx-auto p-4">
<div className="pb-2">
<h2 className="text-xl font-semibold mb-4">Advanced Settings</h2>
</div>
<p className="text-neutral-900 mb-6">
<strong>WARNING</strong>: These settings are intended for internal
debug purposes <strong>only</strong>. Changing these is not supported
and will disrupt access to your resources.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
saveSettings(localSettings);
}}
className="max-w mx-auto flex flex-col gap-2"
>
<div>
<Label className="text-neutral-600" htmlFor="auth-base-url-input">Auth Base URL</Label>
<ManagedTextInput
name="auth_base_url"
id="auth-base-url-input"
managed={localSettings.auth_url_is_managed}
value={localSettings.auth_url}
onChange={(e) =>
setLocalSettings({
...localSettings,
auth_url: e.target.value,
})
}
required
/>
</div>
<div>
<Label className="text-neutral-600" htmlFor="api-url-input">API URL</Label>
<ManagedTextInput
name="api_url"
id="api-url-input"
managed={localSettings.api_url_is_managed}
value={localSettings.api_url}
onChange={(e) =>
setLocalSettings({
...localSettings,
api_url: e.target.value,
})
}
required
/>
</div>
<div>
<Label className="text-neutral-600" htmlFor="log-filter-input">Log Filter</Label>
<ManagedTextInput
name="log_filter"
id="log-filter-input"
managed={localSettings.log_filter_is_managed}
value={localSettings.log_filter}
onChange={(e) =>
setLocalSettings({
...localSettings,
log_filter: e.target.value,
})
}
required
/>
</div>
<div className="flex justify-end gap-4 mt-4">
<Button
type="reset"
onClick={resetSettings}
color="alternative"
>
Reset to Defaults
</Button>
<Button type="submit">Apply</Button>
</div>
</form>
</div>
);
}
function ManagedTextInput(props: TextInputProps & { managed: boolean }) {
let { managed, ...inputProps } = props;
if (managed) {
return <Tooltip content="This setting is managed by your organisation." clearTheme={{target: true}}>
<TextInput {...inputProps} disabled={true} />
</Tooltip>
} else {
return <TextInput {...inputProps} />
}
}

View File

@@ -0,0 +1,4 @@
declare module "*.png" {
const value: string;
export default value;
}

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,13 +1,12 @@
@import "tailwindcss";
/*
* This file serves as the main entry point for your Tailwind CSS installation.
* Generate the CSS from this with `pnpm tailwindcss -i src/input.css -o src/output.css`
* This has been added to the build script in package.json for convenience.
*/
@plugin "flowbite-react/plugin/tailwindcss";
@plugin 'flowbite/plugin';
@source "../.flowbite-react/class-list.json";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-50: #fff9f5;
--color-primary-100: #fff1e5;
@@ -43,6 +42,8 @@
--color-neutral-700: #766a60;
--color-neutral-800: #4c3e33;
--color-neutral-900: #1b140e;
--default-font-family: "Source Sans 3";
}
/*

View File

@@ -0,0 +1,36 @@
import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import App from "./components/App";
import { BrowserRouter } from "react-router";
import { createTheme, ThemeProvider } from "flowbite-react";
const customTheme = createTheme({
sidebar: {
root: { inner: "rounded-none bg-white" }
},
button: {
color: {
default: "bg-accent-450 hover:bg-accent-700 text-white",
alternative: "text-neutral-900 border border-neutral-200 hover:bg-neutral-300 hover:text-neutral-900",
},
},
textInput: {
field: {
input: {
colors: {
gray: "focus:ring-accent-500 focus:border-accent-500"
}
}
}
}
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<BrowserRouter>
<ThemeProvider theme={customTheme}>
<App />
</ThemeProvider>
</BrowserRouter>
</StrictMode>
);

View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __GIT_VERSION__: string;

View File

@@ -1,23 +0,0 @@
//! Everything related to the About window
#[tauri::command]
pub(crate) fn get_cargo_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[tauri::command]
pub(crate) fn get_git_version() -> String {
option_env!("GITHUB_SHA").unwrap_or("unknown").to_owned()
}
#[cfg(test)]
mod tests {
#[test]
fn version() {
let cargo = super::get_cargo_version();
assert!(cargo != "Unknown", "{}", cargo);
assert!(cargo.starts_with("1."));
assert!(cargo.len() >= 2, "{}", cargo);
}
}

View File

@@ -1,62 +0,0 @@
use anyhow::{Context, Result};
use std::{io::ErrorKind::NotFound, path::Path};
/// Deletes all `.log` files in `path`.
pub async fn clear_logs(path: &Path) -> Result<()> {
let mut dir = match tokio::fs::read_dir(path).await {
Ok(x) => x,
Err(error) => {
if matches!(error.kind(), NotFound) {
// In smoke tests, the Tunnel service runs in debug mode, so it won't write any logs to disk. If the Tunnel service's log dir doesn't exist, we shouldn't crash, it's correct to simply not delete the non-existent files
return Ok(());
}
// But any other error like permissions errors, should bubble.
return Err(error.into());
}
};
let mut result = Ok(());
// If we can't delete some files due to permission errors, just keep going
// and delete as much as we can, then return the most recent error
while let Some(entry) = dir
.next_entry()
.await
.context("Failed to read next dir entry")?
{
if entry
.file_name()
.to_str()
.is_none_or(|name| !name.ends_with("log"))
{
continue;
}
if let Err(e) = tokio::fs::remove_file(entry.path()).await {
result = Err(e);
}
}
result.context("Failed to delete at least one file")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn only_deletes_log_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("first.log"), "log file 1").unwrap();
std::fs::write(dir.path().join("second.log"), "log file 1").unwrap();
std::fs::write(dir.path().join("not_a_logfile.tmp"), "something important").unwrap();
clear_logs(dir.path()).await.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("not_a_logfile.tmp")).unwrap(),
"something important"
);
}
}

View File

@@ -2,7 +2,8 @@ use crate::{
auth, deep_link,
gui::{self, system_tray},
ipc::{self, SocketId},
logging, service,
logging::{self, FileCount},
service,
settings::{self, AdvancedSettings, GeneralSettings, MdmSettings},
updates, uptime,
};
@@ -63,12 +64,6 @@ pub struct Controller<I: GuiIntegration> {
}
pub trait GuiIntegration {
fn set_welcome_window_visible(
&self,
visible: bool,
current_session: Option<&auth::Session>,
) -> Result<()>;
fn notify_signed_in(&self, session: &auth::Session) -> Result<()>;
fn notify_signed_out(&self) -> Result<()>;
fn notify_settings_changed(
@@ -76,6 +71,7 @@ pub trait GuiIntegration {
mdm_settings: MdmSettings,
advanced_settings: AdvancedSettings,
) -> Result<()>;
fn notify_logs_recounted(&self, file_count: &FileCount) -> Result<()>;
/// Also opens non-URLs
fn open_url<P: AsRef<str>>(&self, url: P) -> Result<()>;
@@ -85,12 +81,14 @@ pub trait GuiIntegration {
fn show_notification(&self, title: &str, body: &str) -> Result<()>;
fn show_update_notification(&self, ctlr_tx: CtlrTx, title: &str, url: url::Url) -> Result<()>;
fn show_settings_window(
fn set_window_visible(&self, visible: bool) -> Result<()>;
fn show_overview_page(&self, current_session: Option<&auth::Session>) -> Result<()>;
fn show_settings_page(
&self,
mdm_settings: MdmSettings,
advanced_settings: AdvancedSettings,
settings: AdvancedSettings,
) -> Result<()>;
fn show_about_window(&self) -> Result<()>;
fn show_about_page(&self) -> Result<()>;
}
pub enum ControllerRequest {
@@ -106,6 +104,7 @@ pub enum ControllerRequest {
Fail(Failure),
SignIn,
SignOut,
UpdateState,
SystemTrayMenu(system_tray::Event),
UpdateNotificationClicked(Url),
}
@@ -288,8 +287,7 @@ impl<I: GuiIntegration> Controller<I> {
self.refresh_system_tray_menu();
if !ran_before::get().await? {
self.integration
.set_welcome_window_visible(true, self.auth.session())?;
self.integration.show_overview_page(self.auth.session())?;
}
while let Some(tick) = self.tick().await {
@@ -506,6 +504,8 @@ impl<I: GuiIntegration> Controller<I> {
// Refresh the menu in case the favorites were reset.
self.refresh_system_tray_menu();
self.integration.show_notification("Settings saved", "")?
}
ClearLogs(completion_tx) => {
if self.clear_logs_callback.is_some() {
@@ -541,8 +541,7 @@ impl<I: GuiIntegration> Controller<I> {
self.integration
.open_url(url.expose_secret())
.context("Couldn't open auth page")?;
self.integration
.set_welcome_window_visible(false, self.auth.session())?;
self.integration.set_window_visible(false)?;
}
SystemTrayMenu(system_tray::Event::AddFavorite(resource_id)) => {
self.general_settings.favorite_resources.insert(resource_id);
@@ -593,8 +592,8 @@ impl<I: GuiIntegration> Controller<I> {
}
SystemTrayMenu(system_tray::Event::ShowWindow(window)) => {
match window {
system_tray::Window::About => self.integration.show_about_window()?,
system_tray::Window::Settings => self.integration.show_settings_window(
system_tray::Window::About => self.integration.show_about_page()?,
system_tray::Window::Settings => self.integration.show_settings_page(
self.mdm_settings.clone(),
self.advanced_settings.clone(),
)?,
@@ -630,6 +629,19 @@ impl<I: GuiIntegration> Controller<I> {
.open_url(&download_url)
.context("Couldn't open update page")?;
}
UpdateState => {
self.integration.notify_settings_changed(
self.mdm_settings.clone(),
self.advanced_settings.clone(),
)?;
match self.auth.session() {
Some(session) => self.integration.notify_signed_in(session)?,
None => self.integration.notify_signed_out()?,
};
let file_count = logging::count_logs().await?;
self.integration.notify_logs_recounted(&file_count)?;
}
}
Ok(())
}
@@ -644,6 +656,9 @@ impl<I: GuiIntegration> Controller<I> {
};
tx.send(result)
.map_err(|_| anyhow!("Couldn't send `ClearLogs` result to Tauri task"))?;
let file_count = logging::count_logs().await?;
self.integration.notify_logs_recounted(&file_count)?;
}
service::ServerMsg::ConnectResult(result) => {
self.handle_connect_result(result).await?;
@@ -738,8 +753,7 @@ impl<I: GuiIntegration> Controller<I> {
}
},
gui::ClientMsg::NewInstance => {
self.integration
.set_welcome_window_visible(true, self.auth.session())?;
self.integration.show_overview_page(self.auth.session())?;
}
}
@@ -943,7 +957,7 @@ impl<I: GuiIntegration> Controller<I> {
self.mdm_settings
.auth_url
.as_ref()
.unwrap_or(&self.advanced_settings.auth_base_url)
.unwrap_or(&self.advanced_settings.auth_url)
}
fn api_url(&self) -> &Url {

View File

@@ -4,11 +4,11 @@
//! The real macOS Client is in `swift/apple`
use crate::{
about, auth,
auth,
controller::{Controller, ControllerRequest, CtlrTx, Failure, GuiIntegration},
deep_link,
ipc::{self, ClientRead, ClientWrite, SocketId},
logging,
logging::FileCount,
settings::{
self, AdvancedSettings, AdvancedSettingsLegacy, AdvancedSettingsViewModel, MdmSettings,
},
@@ -55,17 +55,19 @@ struct TauriIntegration {
}
impl TauriIntegration {
fn show_window(&self, label: &str) -> Result<()> {
let win = self
.app
.get_webview_window(label)
.with_context(|| format!("Couldn't get handle to `{label}` window"))?;
fn main_window(&self) -> Result<tauri::WebviewWindow> {
self.app
.get_webview_window("main")
.context("Couldn't get handle to window")
}
// Needed to bring shown windows to the front
// `request_user_attention` and `set_focus` don't work, at least on Linux
win.hide()?;
// Needed to show windows that are completely hidden
win.show()?;
fn navigate(&self, path: &str) -> Result<()> {
let window = self.main_window()?;
let mut url = window.url()?;
url.set_path(path);
window.navigate(url)?;
Ok(())
}
@@ -80,32 +82,6 @@ impl Drop for TauriIntegration {
}
impl GuiIntegration for TauriIntegration {
fn set_welcome_window_visible(
&self,
visible: bool,
current_session: Option<&auth::Session>,
) -> Result<()> {
let win = self
.app
.get_webview_window("welcome")
.context("Couldn't get handle to Welcome window")?;
if visible {
win.show().context("Couldn't show Welcome window")?;
win.set_focus().context("Failed to focus window")?;
} else {
win.hide().context("Couldn't hide Welcome window")?;
}
// Ensure state in frontend is up-to-date.
match current_session {
Some(session) => self.notify_signed_in(session)?,
None => self.notify_signed_out()?,
};
Ok(())
}
fn notify_signed_in(&self, session: &auth::Session) -> Result<()> {
self.app
.emit("signed_in", session)
@@ -137,6 +113,14 @@ impl GuiIntegration for TauriIntegration {
Ok(())
}
fn notify_logs_recounted(&self, file_count: &FileCount) -> Result<()> {
self.app
.emit("logs_recounted", file_count)
.context("Failed to send `logs_recounted` event")?;
Ok(())
}
fn open_url<P: AsRef<str>>(&self, url: P) -> Result<()> {
tauri_plugin_opener::open_url(url, Option::<&str>::None)?;
@@ -159,19 +143,51 @@ impl GuiIntegration for TauriIntegration {
os::show_update_notification(&self.app, ctlr_tx, title, url)
}
fn show_settings_window(
&self,
mdm_settings: MdmSettings,
advanced_settings: AdvancedSettings,
) -> Result<()> {
self.show_window("settings")?;
self.notify_settings_changed(mdm_settings, advanced_settings)?; // Ensure settings are up to date in GUI.
fn set_window_visible(&self, visible: bool) -> Result<()> {
let win = self.main_window()?;
if visible {
// Needed to bring shown windows to the front
// `request_user_attention` and `set_focus` don't work, at least on Linux
win.hide()?;
// Needed to show windows that are completely hidden
win.show()?;
} else {
win.hide().context("Couldn't hide window")?;
}
Ok(())
}
fn show_about_window(&self) -> Result<()> {
self.show_window("about")
fn show_overview_page(&self, current_session: Option<&auth::Session>) -> Result<()> {
// Ensure state in frontend is up-to-date.
match current_session {
Some(session) => self.notify_signed_in(session)?,
None => self.notify_signed_out()?,
};
self.navigate("overview")?;
self.set_window_visible(true)?;
Ok(())
}
fn show_settings_page(
&self,
mdm_settings: MdmSettings,
advanced_settings: AdvancedSettings,
) -> Result<()> {
self.notify_settings_changed(mdm_settings, advanced_settings)?; // Ensure settings are up to date in GUI.
self.navigate("settings")?;
self.set_window_visible(true)?;
Ok(())
}
fn show_about_page(&self) -> Result<()> {
self.navigate("about")?;
self.set_window_visible(true)?;
Ok(())
}
}
@@ -265,17 +281,7 @@ pub fn run(
api.prevent_close();
}
})
.invoke_handler(tauri::generate_handler![
about::get_cargo_version,
about::get_git_version,
logging::clear_logs,
logging::count_logs,
logging::export_logs,
settings::apply_advanced_settings,
settings::reset_advanced_settings,
crate::welcome::sign_in,
crate::welcome::sign_out,
])
.invoke_handler(crate::view::generate_handler())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_shell::init())

View File

@@ -1,10 +1,8 @@
#![cfg_attr(test, allow(clippy::unwrap_used))]
mod about;
mod clear_logs;
mod updates;
mod uptime;
mod welcome;
mod view;
// TODO: See how many of these we can make private.
pub mod auth;
@@ -17,8 +15,6 @@ pub mod logging;
pub mod service;
pub mod settings;
pub use clear_logs::clear_logs;
/// The Sentry "release" we are part of.
///
/// Tunnel service and GUI client are always bundled into a single release.

View File

@@ -1,89 +1,17 @@
//! Everything for logging to files, zipping up the files for export, and counting the files
use crate::gui::Managed;
use anyhow::{Context as _, Result, bail};
use firezone_bin_shared::known_dirs;
use firezone_logging::{FilterReloadHandle, err_with_src};
use firezone_logging::FilterReloadHandle;
use serde::Serialize;
use std::{
fs,
io::{self, ErrorKind::NotFound},
path::{Path, PathBuf},
};
use tauri_plugin_dialog::DialogExt as _;
use tokio::task::spawn_blocking;
use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt};
use super::controller::{ControllerRequest, CtlrTx};
#[tauri::command]
pub(crate) async fn clear_logs(managed: tauri::State<'_, Managed>) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
if let Err(error) = managed.ctlr_tx.send(ControllerRequest::ClearLogs(tx)).await {
// Tauri will only log errors to the JS console for us, so log this ourselves.
tracing::error!(
"Error while asking `Controller` to clear logs: {}",
err_with_src(&error)
);
return Err(error.to_string());
}
if let Err(error) = rx.await {
tracing::error!(
"Error while awaiting log-clearing operation: {}",
err_with_src(&error)
);
return Err(error.to_string());
}
Ok(())
}
#[tauri::command]
pub(crate) async fn export_logs(
app: tauri::AppHandle,
managed: tauri::State<'_, Managed>,
) -> Result<(), String> {
show_export_dialog(&app, managed.ctlr_tx.clone()).map_err(|e| e.to_string())
}
#[tauri::command]
pub(crate) async fn count_logs() -> Result<FileCount, String> {
count_logs_imp().await.map_err(|e| e.to_string())
}
/// Pops up the "Save File" dialog
fn show_export_dialog(app: &tauri::AppHandle, ctlr_tx: CtlrTx) -> Result<()> {
let now = chrono::Local::now();
let datetime_string = now.format("%Y_%m_%d-%H-%M");
let stem = PathBuf::from(format!("firezone_logs_{datetime_string}"));
let filename = stem.with_extension("zip");
let Some(filename) = filename.to_str() else {
bail!("zip filename isn't valid Unicode");
};
tauri_plugin_dialog::FileDialogBuilder::new(app.dialog().clone())
.add_filter("Zip", &["zip"])
.set_file_name(filename)
.save_file(move |file_path| {
let Some(file_path) = file_path else {
return;
};
let path = match file_path.clone().into_path() {
Ok(path) => path,
Err(e) => {
tracing::warn!(%file_path, "Invalid file path: {}", err_with_src(&e));
return;
}
};
// blocking_send here because we're in a sync callback within Tauri somewhere
if let Err(e) = ctlr_tx.blocking_send(ControllerRequest::ExportLogs { path, stem }) {
tracing::warn!("Failed to send `ExportLogs` command: {e}");
}
});
Ok(())
}
/// If you don't store `Handles` in a variable, the file logger handle will drop immediately,
/// resulting in empty log files.
#[must_use]
@@ -282,15 +210,51 @@ pub struct FileCount {
files: u64,
}
/// Delete all files in the logs directory.
///
/// This includes the current log file, so we won't write any more logs to disk
/// until the file rolls over or the app restarts.
///
/// If we get an error while removing a file, we still try to remove all other
/// files, then we return the most recent error.
pub async fn clear_gui_logs() -> Result<()> {
crate::clear_logs(&known_dirs::logs().context("Can't compute GUI log dir")?).await
clear_logs(&known_dirs::logs().context("Can't compute GUI log dir")?).await
}
pub async fn clear_service_logs() -> Result<()> {
clear_logs(&known_dirs::tunnel_service_logs().context("Can't compute service logs dir")?).await
}
/// Deletes all `.log` files in `path`.
async fn clear_logs(path: &Path) -> Result<()> {
let mut dir = match tokio::fs::read_dir(path).await {
Ok(x) => x,
Err(error) => {
if matches!(error.kind(), NotFound) {
// In smoke tests, the Tunnel service runs in debug mode, so it won't write any logs to disk. If the Tunnel service's log dir doesn't exist, we shouldn't crash, it's correct to simply not delete the non-existent files
return Ok(());
}
// But any other error like permissions errors, should bubble.
return Err(error.into());
}
};
let mut result = Ok(());
// If we can't delete some files due to permission errors, just keep going
// and delete as much as we can, then return the most recent error
while let Some(entry) = dir
.next_entry()
.await
.context("Failed to read next dir entry")?
{
if entry
.file_name()
.to_str()
.is_none_or(|name| !name.ends_with("log") && name != "latest")
{
continue;
}
if let Err(e) = tokio::fs::remove_file(entry.path()).await {
result = Err(e);
}
}
result.context("Failed to delete at least one file")
}
/// Exports logs to a zip file
@@ -361,7 +325,7 @@ fn add_dir_to_zip(
}
/// Count log files and their sizes
async fn count_logs_imp() -> Result<FileCount> {
pub async fn count_logs() -> Result<FileCount> {
// I spent about 5 minutes on this and couldn't get it to work with `Stream`
let mut total_count = FileCount::default();
for log_path in log_paths()? {
@@ -373,7 +337,12 @@ async fn count_logs_imp() -> Result<FileCount> {
}
async fn count_one_dir(path: &Path) -> Result<FileCount> {
let mut dir = tokio::fs::read_dir(path).await?;
let mut dir = match tokio::fs::read_dir(path).await {
Ok(dir) => dir,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(FileCount::default()),
Err(e) => return Err(anyhow::Error::new(e)),
};
let mut file_count = FileCount::default();
while let Some(entry) = dir.next_entry().await? {
@@ -398,3 +367,36 @@ fn log_paths() -> Result<Vec<LogPath>> {
},
])
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn only_deletes_log_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("first.log"), "log file 1").unwrap();
std::fs::write(dir.path().join("second.log"), "log file 1").unwrap();
std::fs::write(dir.path().join("not_a_logfile.tmp"), "something important").unwrap();
clear_logs(dir.path()).await.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("not_a_logfile.tmp")).unwrap(),
"something important"
);
}
#[tokio::test]
async fn non_existing_path_is_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_owned();
drop(dir);
let file_count = count_one_dir(path.as_path()).await.unwrap();
assert_eq!(file_count.bytes, 0);
assert_eq!(file_count.files, 0);
}
}

View File

@@ -1,4 +1,7 @@
use crate::ipc::{self, SocketId};
use crate::{
ipc::{self, SocketId},
logging,
};
use anyhow::{Context as _, Result, bail};
use atomicwrites::{AtomicFile, OverwriteBehavior};
use backoff::ExponentialBackoffBuilder;
@@ -362,11 +365,7 @@ impl<'a> Handler<'a> {
async fn handle_ipc_msg(&mut self, msg: ClientMsg) -> Result<()> {
match msg {
ClientMsg::ClearLogs => {
let result = crate::clear_logs(
&firezone_bin_shared::known_dirs::tunnel_service_logs()
.context("Can't compute logs dir")?,
)
.await;
let result = logging::clear_service_logs().await;
self.send_ipc(ServerMsg::ClearedLogs(result.map_err(|e| e.to_string())))
.await?
}
@@ -528,7 +527,7 @@ impl<'a> Handler<'a> {
}
pub fn run_debug(dns_control: DnsControlMethod) -> Result<()> {
let log_filter_reloader = crate::logging::setup_stdout()?;
let log_filter_reloader = logging::setup_stdout()?;
tracing::info!(
arch = std::env::consts::ARCH,
version = env!("CARGO_PKG_VERSION"),
@@ -555,7 +554,7 @@ pub fn run_smoke_test() -> Result<()> {
use anyhow::{Context as _, bail};
use firezone_bin_shared::{DnsController, device_id};
let log_filter_reloader = crate::logging::setup_stdout()?;
let log_filter_reloader = logging::setup_stdout()?;
if !elevation_check()? {
bail!("Tunnel service failed its elevation check, try running as admin / root");
}

View File

@@ -1,18 +1,14 @@
//! Everything related to the Settings window, including
//! advanced settings and code for manipulating diagnostic logs.
use crate::gui::Managed;
use anyhow::{Context as _, Result};
use connlib_model::ResourceId;
use firezone_bin_shared::known_dirs;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use std::{collections::HashSet, path::PathBuf};
use url::Url;
use super::controller::ControllerRequest;
#[cfg(target_os = "linux")]
#[path = "settings/linux.rs"]
pub(crate) mod mdm;
@@ -27,33 +23,6 @@ pub(crate) mod mdm;
pub use mdm::load_mdm_settings;
#[tauri::command]
pub(crate) async fn apply_advanced_settings(
managed: tauri::State<'_, Managed>,
settings: AdvancedSettings,
) -> Result<(), String> {
if managed.inner().inject_faults {
tokio::time::sleep(Duration::from_secs(2)).await;
}
managed
.ctlr_tx
.send(ControllerRequest::ApplySettings(Box::new(settings)))
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) async fn reset_advanced_settings(
managed: tauri::State<'_, Managed>,
) -> Result<(), String> {
apply_advanced_settings(managed, AdvancedSettings::default()).await?;
Ok(())
}
/// Defines all configuration options settable via MDM policies.
///
/// Configuring Firezone via MDM is optional, therefore all of these are [`Option`]s.
@@ -84,7 +53,7 @@ pub struct AdvancedSettingsLegacy {
#[derive(Clone, Deserialize, Serialize)]
pub struct AdvancedSettings {
pub auth_base_url: Url,
pub auth_url: Url,
pub api_url: Url,
pub log_filter: String,
}
@@ -114,9 +83,7 @@ impl AdvancedSettingsViewModel {
api_url_is_managed: mdm_settings.api_url.is_some(),
log_filter_is_managed: mdm_settings.log_filter.is_some(),
auth_url: mdm_settings
.auth_url
.unwrap_or(advanced_settings.auth_base_url),
auth_url: mdm_settings.auth_url.unwrap_or(advanced_settings.auth_url),
api_url: mdm_settings.api_url.unwrap_or(advanced_settings.api_url),
log_filter: mdm_settings
.log_filter
@@ -160,7 +127,7 @@ impl GeneralSettings {
impl Default for AdvancedSettings {
fn default() -> Self {
Self {
auth_base_url: Url::parse(defaults::AUTH_BASE_URL).expect("static URL is a valid URL"),
auth_url: Url::parse(defaults::AUTH_BASE_URL).expect("static URL is a valid URL"),
api_url: Url::parse(defaults::API_URL).expect("static URL is a valid URL"),
log_filter: defaults::LOG_FILTER.to_string(),
}
@@ -185,7 +152,7 @@ pub async fn migrate_legacy_settings(
let general_settings = load_general_settings();
let advanced = AdvancedSettings {
auth_base_url: legacy.auth_base_url,
auth_url: legacy.auth_base_url,
api_url: legacy.api_url,
log_filter: legacy.log_filter,
};
@@ -278,7 +245,7 @@ mod tests {
"log_filter": "info"
}"#;
let actual = serde_json::from_str::<AdvancedSettings>(s).unwrap();
let actual = serde_json::from_str::<AdvancedSettingsLegacy>(s).unwrap();
// Apparently the trailing slash here matters
assert_eq!(actual.auth_base_url.to_string(), "https://example.com/");
assert_eq!(actual.api_url.to_string(), "wss://example.com/");

View File

@@ -0,0 +1,148 @@
use std::{path::PathBuf, time::Duration};
use anyhow::{Context as _, Result, bail};
use firezone_logging::err_with_src;
use tauri::{Wry, ipc::Invoke};
use tauri_plugin_dialog::DialogExt as _;
use crate::{
controller::{ControllerRequest, CtlrTx},
gui::Managed,
settings::AdvancedSettings,
};
pub fn generate_handler() -> impl Fn(Invoke<Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![
clear_logs,
export_logs,
apply_advanced_settings,
reset_advanced_settings,
sign_in,
sign_out,
update_state,
]
}
#[tauri::command]
async fn clear_logs(managed: tauri::State<'_, Managed>) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
if let Err(error) = managed.ctlr_tx.send(ControllerRequest::ClearLogs(tx)).await {
// Tauri will only log errors to the JS console for us, so log this ourselves.
tracing::error!(
"Error while asking `Controller` to clear logs: {}",
err_with_src(&error)
);
return Err(error.to_string());
}
if let Err(error) = rx.await {
tracing::error!(
"Error while awaiting log-clearing operation: {}",
err_with_src(&error)
);
return Err(error.to_string());
}
Ok(())
}
#[tauri::command]
async fn export_logs(
app: tauri::AppHandle,
managed: tauri::State<'_, Managed>,
) -> Result<(), String> {
show_export_dialog(&app, managed.ctlr_tx.clone()).map_err(|e| e.to_string())
}
#[tauri::command]
async fn apply_advanced_settings(
managed: tauri::State<'_, Managed>,
settings: AdvancedSettings,
) -> Result<(), String> {
if managed.inner().inject_faults {
tokio::time::sleep(Duration::from_secs(2)).await;
}
managed
.ctlr_tx
.send(ControllerRequest::ApplySettings(Box::new(settings)))
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn reset_advanced_settings(managed: tauri::State<'_, Managed>) -> Result<(), String> {
apply_advanced_settings(managed, AdvancedSettings::default()).await?;
Ok(())
}
/// Pops up the "Save File" dialog
fn show_export_dialog(app: &tauri::AppHandle, ctlr_tx: CtlrTx) -> Result<()> {
let now = chrono::Local::now();
let datetime_string = now.format("%Y_%m_%d-%H-%M");
let stem = PathBuf::from(format!("firezone_logs_{datetime_string}"));
let filename = stem.with_extension("zip");
let Some(filename) = filename.to_str() else {
bail!("zip filename isn't valid Unicode");
};
tauri_plugin_dialog::FileDialogBuilder::new(app.dialog().clone())
.add_filter("Zip", &["zip"])
.set_file_name(filename)
.save_file(move |file_path| {
let Some(file_path) = file_path else {
return;
};
let path = match file_path.clone().into_path() {
Ok(path) => path,
Err(e) => {
tracing::warn!(%file_path, "Invalid file path: {}", err_with_src(&e));
return;
}
};
// blocking_send here because we're in a sync callback within Tauri somewhere
if let Err(e) = ctlr_tx.blocking_send(ControllerRequest::ExportLogs { path, stem }) {
tracing::warn!("Failed to send `ExportLogs` command: {e}");
}
});
Ok(())
}
#[tauri::command]
async fn sign_in(managed: tauri::State<'_, Managed>) -> Result<(), String> {
managed
.ctlr_tx
.send(ControllerRequest::SignIn)
.await
.context("Failed to send `SignIn` command")
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn sign_out(managed: tauri::State<'_, Managed>) -> Result<(), String> {
managed
.ctlr_tx
.send(ControllerRequest::SignOut)
.await
.context("Failed to send `SignOut` command")
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn update_state(managed: tauri::State<'_, Managed>) -> Result<(), String> {
managed
.ctlr_tx
.send(ControllerRequest::UpdateState)
.await
.context("Failed to send `UpdateState` command")
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -1,30 +0,0 @@
//! Everything related to the Welcome window
use crate::gui::Managed;
use anyhow::Context;
use super::controller::ControllerRequest;
#[tauri::command]
pub(crate) async fn sign_in(managed: tauri::State<'_, Managed>) -> Result<(), String> {
managed
.ctlr_tx
.send(ControllerRequest::SignIn)
.await
.context("Failed to send `SignIn` command")
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) async fn sign_out(managed: tauri::State<'_, Managed>) -> Result<(), String> {
managed
.ctlr_tx
.send(ControllerRequest::SignOut)
.await
.context("Failed to send `SignOut` command")
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -64,33 +64,13 @@
},
"windows": [
{
"label": "about",
"title": "About Firezone",
"url": "src/about.html",
"label": "main",
"title": "Firezone",
"url": "src-frontend/index.html",
"fullscreen": false,
"resizable": false,
"width": 400,
"height": 300,
"visible": false
},
{
"label": "settings",
"title": "Settings",
"url": "src/settings.html",
"fullscreen": false,
"resizable": true,
"width": 640,
"height": 480,
"visible": false
},
{
"label": "welcome",
"title": "Welcome",
"url": "src/welcome.html",
"fullscreen": false,
"resizable": false,
"width": 800,
"height": 450,
"width": 900,
"height": 500,
"visible": false
}
]

View File

@@ -1,30 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<link rel="stylesheet" href="input.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>About Firezone</title>
<script type="module" src="about.ts" defer></script>
</head>
<body class="bg-neutral-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-sm flex flex-col items-center">
<img src="logo.png" alt="Firezone Logo" class="w-20 h-20 mb-6" />
<p class="text-neutral-600 mb-1">Version</p>
<p class="text-2xl font-bold mb-1">
<span id="cargo-version"></span>
</p>
<p class="text-neutral-400 text-sm mb-6">
(<span id="git-version"></span>)
</p>
<a
href="https://docs.firezone.dev"
target="_blank"
class="text-accent-450 hover:underline text-sm"
>Documentation</a
>
</div>
</body>
</html>

View File

@@ -1,34 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import "flowbite"
const cargoVersionSpan = <HTMLSpanElement>(
document.getElementById("cargo-version")
);
const gitVersionSpan = <HTMLSpanElement>document.getElementById("git-version");
function get_cargo_version() {
try {
invoke("get_cargo_version").then((cargoVersion: unknown) => {
cargoVersionSpan.innerText = cargoVersion as string;
});
} catch (e) {
cargoVersionSpan.innerText = "Unknown";
console.error(e);
}
}
function get_git_version() {
try {
invoke<string>("get_git_version").then((gitVersion) => {
gitVersionSpan.innerText = gitVersion.substring(0, 8); // Trim Git hash
});
} catch (e) {
gitVersionSpan.innerText = "Unknown";
console.error(e);
}
}
document.addEventListener("DOMContentLoaded", () => {
get_cargo_version();
get_git_version();
});

View File

@@ -1,181 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<link rel="stylesheet" href="input.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings</title>
<script type="module" src="settings.ts" defer></script>
</head>
<body class="bg-neutral-100 text-neutral-900">
<div class="container mx-auto">
<div class="mb-4 border-b border-neutral-300">
<ul
class="justify-center flex flex-wrap -mb-px text-sm font-medium text-center"
id="tabs"
data-tabs-toggle="#tab-content"
role="tablist"
>
<li class="me-2" role="presentation">
<button
class="inline-block p-4 border-b-2 rounded-t-lg"
id="advanced-tab"
data-tabs-target="#advanced"
type="button"
role="tab"
aria-controls="advanced"
aria-selected="false"
>
Advanced
</button>
</li>
<li class="me-2" role="presentation">
<button
class="inline-block p-4 border-b-2 rounded-t-lg hover:text-neutral-700 hover:border-neutral-400"
id="logs-tab"
data-tabs-target="#logs"
type="button"
role="tab"
aria-controls="logs"
aria-selected="false"
>
Diagnostic Logs
</button>
</li>
</ul>
</div>
<div id="tab-content">
<div
class="hidden p-4 rounded-lg"
id="advanced"
role="tabpanel"
aria-labelledby="advanced-tab"
>
<p class="mx-8 text-neutral-900">
<strong>WARNING</strong>: These settings are intended for internal
debug purposes <strong>only</strong>. Changing these is not
supported and will disrupt access to your resources.
</p>
<form id="advanced-settings-form" class="max-w-md mt-12 mx-auto">
<div class="relative z-0 w-full mb-5 group">
<input
name="auth-base-url"
id="auth-base-url-input"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer disabled:text-neutral-400 disabled:cursor-not-allowed"
placeholder=" "
required
/>
<label
for="auth-base-url"
class="peer-focus:font-medium absolute text-sm text-neutral-600 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:start-0 peer-focus:rtl:translate-x-1/4 peer-focus:rtl:left-auto peer-focus:text-accent-600 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
>Auth Base URL</label
>
</div>
<div class="relative z-0 w-full mb-5 group">
<input
name="api-url"
id="api-url-input"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer disabled:text-neutral-400 disabled:cursor-not-allowed"
placeholder=" "
required
/>
<label
for="api-url"
class="peer-focus:font-medium absolute text-sm text-neutral-600 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:start-0 peer-focus:rtl:translate-x-1/4 peer-focus:text-accent-600 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
>API URL</label
>
</div>
<div class="relative z-0 w-full mb-5 group">
<input
name="log-filter"
id="log-filter-input"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer disabled:text-neutral-400 disabled:cursor-not-allowed"
placeholder=" "
required
/>
<label
for="log_filter"
class="peer-focus:font-medium absolute text-sm text-neutral-600 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:start-0 peer-focus:rtl:translate-x-1/4 peer-focus:text-accent-600 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
>Log Filter</label
>
</div>
<div class="inline-flex w-full justify-between">
<button
id="reset-advanced-settings-btn"
type="button"
class="bg-neutral-300 border-neutral-400 border text-neutral-900 hover:bg-neutral-400 font-medium rounded-sm text-sm w-full sm:w-auto px-5 py-2.5 text-center"
>
Reset to Defaults
</button>
<button
id="apply-advanced-settings-btn"
type="submit"
class="text-white bg-accent-450 hover:bg-accent-700 font-medium rounded-sm text-sm w-full sm:w-auto px-5 py-2.5 text-center"
>
Apply
</button>
</div>
</form>
</div>
<div
class="hidden p-4 rounded-lg bg-neutral-50"
id="logs"
role="tabpanel"
aria-labelledby="logs-tab"
>
<div class="mt-16 flex justify-center">
<p class="mr-1">Log directory size:</p>
<p id="log-count-output">Calculating...</p>
</div>
<div class="mt-8 flex justify-center">
<button
id="export-logs-btn"
type="button"
class="mr-4 inline-flex items-center border border-neutral-400 bg-neutral-300 text-neutral-900 hover:bg-neutral-400 font-medium rounded-sm text-sm px-5 py-2.5 text-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 mr-2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"
/>
</svg>
Export Logs
</button>
<button
id="clear-logs-btn"
type="button"
class="inline-flex items-center border border-neutral-400 bg-neutral-300 text-neutral-900 hover:bg-neutral-400 font-medium rounded-sm text-sm px-5 py-2.5 text-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 mr-2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
Clear Logs
</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,193 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import "flowbite"
// Custom types
interface Settings {
auth_url: string;
auth_url_is_managed: boolean;
api_url: string;
api_url_is_managed: boolean;
log_filter: string;
log_filter_is_managed: boolean;
}
interface FileCount {
files: number;
bytes: number;
}
// DOM elements
const form = <HTMLFormElement>document.getElementById("advanced-settings-form");
const authBaseUrlInput = <HTMLInputElement>(
document.getElementById("auth-base-url-input")
);
const apiUrlInput = <HTMLInputElement>document.getElementById("api-url-input");
const logFilterInput = <HTMLInputElement>(
document.getElementById("log-filter-input")
);
const logCountOutput = <HTMLParagraphElement>(
document.getElementById("log-count-output")
);
const resetAdvancedSettingsBtn = <HTMLButtonElement>(
document.getElementById("reset-advanced-settings-btn")
);
const applyAdvancedSettingsBtn = <HTMLButtonElement>(
document.getElementById("apply-advanced-settings-btn")
);
const exportLogsBtn = <HTMLButtonElement>(
document.getElementById("export-logs-btn")
);
const clearLogsBtn = <HTMLButtonElement>(
document.getElementById("clear-logs-btn")
);
const logsTabBtn = <HTMLButtonElement>document.getElementById("logs-tab");
// Rust bridge functions
// Lock the UI when we're saving to disk, since disk writes are technically async.
function lockAdvancedSettingsForm() {
authBaseUrlInput.disabled = true;
apiUrlInput.disabled = true;
logFilterInput.disabled = true;
resetAdvancedSettingsBtn.disabled = true;
applyAdvancedSettingsBtn.disabled = true;
resetAdvancedSettingsBtn.textContent = "Updating...";
applyAdvancedSettingsBtn.textContent = "Updating...";
}
function unlockAdvancedSettingsForm() {
authBaseUrlInput.disabled = false;
apiUrlInput.disabled = false;
logFilterInput.disabled = false;
resetAdvancedSettingsBtn.disabled = false;
applyAdvancedSettingsBtn.disabled = false;
resetAdvancedSettingsBtn.textContent = "Reset to Defaults";
applyAdvancedSettingsBtn.textContent = "Apply";
}
function lockLogsForm() {
exportLogsBtn.disabled = true;
clearLogsBtn.disabled = true;
}
function unlockLogsForm() {
exportLogsBtn.disabled = false;
clearLogsBtn.disabled = false;
}
async function applyAdvancedSettings() {
console.log("Applying advanced settings");
lockAdvancedSettingsForm();
try {
await invoke("apply_advanced_settings", {
settings: {
auth_base_url: authBaseUrlInput.value,
api_url: apiUrlInput.value,
log_filter: logFilterInput.value,
},
});
} catch (e) {
console.error(e);
} finally {
unlockAdvancedSettingsForm();
}
}
async function resetAdvancedSettings() {
console.log("Resetting advanced settings");
lockAdvancedSettingsForm();
try {
await invoke("reset_advanced_settings")
} catch (e) {
console.error(e);
} finally {
unlockAdvancedSettingsForm();
}
}
async function exportLogs() {
console.log("Exporting logs");
lockLogsForm();
try {
await invoke("export_logs");
} catch (e) {
console.error(e);
} finally {
unlockLogsForm();
}
}
async function clearLogs() {
console.log("Clearing logs");
lockLogsForm();
try {
await invoke("clear_logs");
} catch (e) {
console.error(e);
} finally {
countLogs();
unlockLogsForm();
}
}
async function countLogs() {
try {
let fileCount = (await invoke("count_logs")) as FileCount;
console.log(fileCount);
const megabytes = Math.round(fileCount.bytes / 100000) / 10;
logCountOutput.innerText = `${fileCount.files} files, ${megabytes} MB`;
} catch (e) {
let error = e as Error;
console.error(e);
logCountOutput.innerText = `Error counting logs: ${error.message}`;
}
}
// Setup event listeners
form.addEventListener("submit", (e) => {
e.preventDefault();
applyAdvancedSettings();
});
resetAdvancedSettingsBtn.addEventListener("click", (_e) => {
resetAdvancedSettings();
});
exportLogsBtn.addEventListener("click", (_e) => {
exportLogs();
});
clearLogsBtn.addEventListener("click", (_e) => {
clearLogs();
});
logsTabBtn.addEventListener("click", (_e) => {
countLogs();
});
listen<Settings>('settings_changed', (e) => {
let settings = e.payload;
authBaseUrlInput.value = settings.auth_url;
apiUrlInput.value = settings.api_url;
logFilterInput.value = settings.log_filter;
authBaseUrlInput.disabled = settings.auth_url_is_managed
apiUrlInput.disabled = settings.api_url_is_managed;
logFilterInput.disabled = settings.log_filter_is_managed;
if (settings.auth_url_is_managed) {
authBaseUrlInput.dataset['tip'] = "This setting is managed by your organization."
}
if (settings.api_url_is_managed) {
apiUrlInput.dataset['tip'] = "This setting is managed by your organization."
}
if (settings.log_filter_is_managed) {
logFilterInput.dataset['tip'] = "This setting is managed by your organization."
}
})

View File

@@ -1,61 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<link rel="stylesheet" href="input.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to Firezone</title>
<script type="module" src="welcome.ts" defer></script>
</head>
<body
class="bg-neutral-100 min-h-screen flex items-center flex-col justify-center gap-4"
>
<img src="logo.png" alt="Firezone Logo" class="w-40 h-40" />
<h1 class="text-6xl font-bold">Firezone</h1>
<div id="signed-out">
<div class="flex flex-col items-center gap-4">
<p class="text-center">
You can sign in by clicking the Firezone icon the taskbar or by
clicking 'Sign-in' below.
</p>
<button
class="text-white bg-accent-450 hover:bg-accent-700 font-medium rounded-md text-md px-5 py-1.5"
id="sign-in"
>
Sign in
</button>
<p class="text-xs text-center">
Firezone will continue running after this window is closed.<br />
It is always available from the taskbar.
</p>
</div>
</div>
<div id="signed-in">
<div class="flex flex-col items-center gap-4">
<p class="text-center">
You are currently signed into&nbsp;<span
class="font-bold"
id="account-slug"
></span
>&nbsp;as&nbsp;<span class="font-bold" id="actor-name"></span>.<br />
Click the Firezone icon in the taskbar to see the list of Resources.
</p>
<button
class="text-white bg-accent-450 hover:bg-accent-700 font-medium rounded-md text-md px-5 py-1.5"
id="sign-out"
>
Sign out
</button>
<p class="text-xs text-center">
Firezone will continue running in the taskbar after this window is
closed.
</p>
</div>
</div>
</body>
</html>

View File

@@ -1,48 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from '@tauri-apps/api/event';
import "flowbite"
interface Session {
account_slug: string,
actor_name: string,
}
const signInBtn = <HTMLButtonElement>document.getElementById("sign-in");
const signOutBtn = <HTMLButtonElement>document.getElementById("sign-out");
const signedInDiv = <HTMLDivElement>document.getElementById("signed-in");
const signedOutDiv = <HTMLDivElement>document.getElementById("signed-out");
const accountSlugSpan = <HTMLSpanElement>document.getElementById("account-slug");
const actorNameSpan = <HTMLSpanElement>document.getElementById("actor-name");
// Initial state is to assume we are signed out.
signedOutDiv.style.display = "block";
signedInDiv.style.display = "none";
signInBtn.addEventListener("click", async (_e) => {
console.log("Signing in...");
await invoke("sign_in");
});
signOutBtn.addEventListener("click", async (_e) => {
console.log("Signing in...");
await invoke("sign_out");
});
listen<Session>('signed_in', (e) => {
let session = e.payload;
accountSlugSpan.textContent = session.account_slug;
actorNameSpan.textContent = session.actor_name;
signedOutDiv.style.display = "none";
signedInDiv.style.display = "block";
})
listen<void>('signed_out', (_e) => {
accountSlugSpan.textContent = "";
actorNameSpan.textContent = "";
signedOutDiv.style.display = "block";
signedInDiv.style.display = "none";
})

View File

@@ -5,6 +5,7 @@
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
/* Bundler mode */
"moduleResolution": "bundler",
@@ -12,13 +13,16 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"moduleDetection": "force",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["./src/**/*"],
"include": ["./src-frontend/**/*", "vite.config.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,28 +1,24 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { resolve } from "path";
import tailwindcss from '@tailwindcss/vite'
import flowbiteReact from "flowbite-react/plugin/vite";
import typescript from 'vite-plugin-typescript';
import { execSync } from "child_process";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
const gitVersion = process.env.GITHUB_SHA ?? execSync('git rev-parse --short HEAD')
.toString();
// https://vitejs.dev/config/
export default defineConfig(async () => ({
build: {
rollupOptions: {
input: {
about: resolve(__dirname, "src/about.html"),
settings: resolve(__dirname, "src/settings.html"),
welcome: resolve(__dirname, "src/welcome.html"),
},
},
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), flowbiteReact(), tailwindcss(), typescript()],
define: {
// mark:next-gui-version
'__APP_VERSION__': JSON.stringify("1.5.0"),
'__GIT_VERSION__': JSON.stringify(gitVersion),
},
plugins: [
tailwindcss(),
typescript(),
],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
@@ -44,4 +40,4 @@ export default defineConfig(async () => ({
ignored: ["**/src-tauri/**"],
},
},
}));
});

View File

@@ -138,6 +138,7 @@ function gui() {
find website -type f -name "route.ts" -exec sed "${SEDARG[@]}" -e '/mark:current-gui-version/{n;s/[0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}/'"${current_gui_version}"'/g;}' {} \;
find .github -type f -exec sed "${SEDARG[@]}" -e '/mark:next-gui-version/{n;s/[0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}/'"${next_gui_version}"'/g;}' {} \;
find rust -path rust/gui-client/node_modules -prune -o -path rust/target -prune -o -name "Cargo.toml" -exec sed "${SEDARG[@]}" -e '/mark:next-gui-version/{n;s/[0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}/'"${next_gui_version}"'/;}' {} \;
find rust -path rust/gui-client/node_modules -prune -o -path rust/target -prune -o -name "vite.config.ts" -exec sed "${SEDARG[@]}" -e '/mark:next-gui-version/{n;s/[0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}/'"${next_gui_version}"'/;}' {} \;
cargo_update_workspace
}