refactor(gui-client): replace tslink with tauri-specta (#10031)

Despite still being in development, the `tauri-specta` project already
proves to be quite useful. It allows us to generate TypeScript bindings
for our commands and events, creating a type-safe contract between the
frontend and the backend.

For example, this ensures that the TypeScript code calls a command
actually with the required parameters and thus avoids runtime failures.

Similarly, the frontend can listen on type-safe events without having to
use any magic strings.
This commit is contained in:
Thomas Eizinger
2025-07-28 21:37:24 +00:00
committed by GitHub
parent 71c3450f98
commit 1317bbb9e2
20 changed files with 409 additions and 168 deletions

View File

@@ -10,3 +10,6 @@ shfmt 3.9.0
# Used to lint Bash scripts
shellcheck 0.9.0
# GUI client
pnpm 10.13.1

106
rust/Cargo.lock generated
View File

@@ -1454,15 +1454,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -1852,7 +1843,7 @@ version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case 0.4.0",
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
@@ -2460,6 +2451,8 @@ dependencies = [
"serde",
"serde_json",
"serde_variant",
"specta",
"specta-typescript",
"strum",
"subtle",
"tauri",
@@ -2469,6 +2462,7 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-shell",
"tauri-runtime",
"tauri-specta",
"tauri-utils",
"tauri-winrt-notification",
"tempfile",
@@ -2480,7 +2474,6 @@ dependencies = [
"tracing-journald",
"tracing-log",
"tracing-subscriber",
"tslink",
"url",
"uuid",
"windows",
@@ -6934,6 +6927,51 @@ dependencies = [
"system-deps",
]
[[package]]
name = "specta"
version = "2.0.0-rc.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
dependencies = [
"paste",
"specta-macros",
"thiserror 1.0.69",
"url",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "specta-serde"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
dependencies = [
"specta",
"thiserror 1.0.69",
]
[[package]]
name = "specta-typescript"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
dependencies = [
"specta",
"specta-serde",
"thiserror 1.0.69",
]
[[package]]
name = "spin"
version = "0.9.8"
@@ -7295,6 +7333,7 @@ dependencies = [
"serde_json",
"serde_repr",
"serialize-to-javascript",
"specta",
"swift-rs",
"tauri-build",
"tauri-macros",
@@ -7543,6 +7582,34 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.12",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "tauri-utils"
version = "2.5.0"
@@ -8225,23 +8292,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tslink"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "944e85e01601d4e4b3a8b0a127c3812aef1457cdf8161c6bc8be9956af047718"
dependencies = [
"convert_case 0.6.0",
"lazy_static",
"proc-macro2",
"quote",
"serde",
"syn 2.0.104",
"thiserror 1.0.69",
"toml 0.8.22",
"uuid",
]
[[package]]
name = "tun"
version = "0.1.0"

View File

@@ -153,6 +153,8 @@ smoltcp = { version = "0.12", default-features = false }
snownet = { path = "connlib/snownet" }
socket-factory = { path = "connlib/socket-factory" }
socket2 = { version = "0.5" }
specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
static_assertions = "1.1.0"
str0m = { version = "0.9.0", default-features = false, features = ["sha1"] }
strum = { version = "0.27.1", features = ["derive"] }
@@ -170,6 +172,7 @@ tauri-plugin-notification = "2.3.0"
tauri-plugin-opener = "2.4.0"
tauri-plugin-shell = "2.3.0"
tauri-runtime = "2.5.0"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
tauri-utils = "2.2.0"
tempfile = "3.20.0"
test-case = "3.3.1"

View File

@@ -229,7 +229,6 @@ deny = [
skip = [
"base64",
"bitflags",
"convert_case",
"core-foundation",
"core-graphics",
"core-graphics-types",

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useId, useState } from "react";
import { Button, Label } from "flowbite-react";
import { AdvancedSettingsViewModel } from "../generated/AdvancedSettingsViewModel";
import { ManagedTextInput } from "./ManagedInput";
import { AdvancedSettingsViewModel } from "../generated/bindings";
interface Props {
settings: AdvancedSettingsViewModel | null;

View File

@@ -7,8 +7,6 @@ import {
SwatchIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/solid";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
Sidebar,
SidebarCollapse,
@@ -17,9 +15,6 @@ import {
} from "flowbite-react";
import React, { useEffect, useState } from "react";
import { Route, Routes } from "react-router";
import { AdvancedSettingsViewModel } from "../generated/AdvancedSettingsViewModel";
import { FileCount } from "../generated/FileCount";
import { SessionViewModel } from "../generated/SessionViewModel";
import About from "./AboutPage";
import AdvancedSettingsPage from "./AdvancedSettingsPage";
import ReactRouterSidebarItem from "./ReactRouterSidebarItem";
@@ -27,7 +22,14 @@ import ColorPalette from "./ColorPalettePage";
import Diagnostics from "./DiagnosticsPage";
import GeneralSettingsPage from "./GeneralSettingsPage";
import Overview from "./OverviewPage";
import { GeneralSettingsViewModel } from "../generated/GeneralSettingsViewModel";
import {
AdvancedSettingsViewModel,
commands,
events,
FileCount,
GeneralSettingsViewModel,
SessionViewModel,
} from "../generated/bindings";
export default function App() {
const [session, setSession] = useState<SessionViewModel | null>(null);
@@ -38,14 +40,13 @@ export default function App() {
useState<AdvancedSettingsViewModel | null>(null);
useEffect(() => {
const sessionChanged = listen<SessionViewModel>("session_changed", (e) => {
const sessionChangedUnlisten = events.sessionChanged.listen((e) => {
const session = e.payload;
console.log("session_changed", { session });
setSession(session);
});
const generalSettingsChangedUnlisten = listen<GeneralSettingsViewModel>(
"general_settings_changed",
const generalSettingsChangedUnlisten = events.generalSettingsChanged.listen(
(e) => {
const generalSettings = e.payload;
@@ -53,28 +54,26 @@ export default function App() {
setGeneralSettings(generalSettings);
}
);
const advancedSettingsChangedUnlisten = listen<AdvancedSettingsViewModel>(
"advanced_settings_changed",
(e) => {
const advancedSettingsChangedUnlisten =
events.advancedSettingsChanged.listen((e) => {
const advancedSettings = e.payload;
console.log("advanced_settings_changed", {
settings: advancedSettings,
});
setAdvancedSettings(advancedSettings);
}
);
const logsRecountedUnlisten = listen<FileCount>("logs_recounted", (e) => {
});
const logsRecountedUnlisten = events.logsRecounted.listen((e) => {
const file_count = e.payload;
console.log("logs_recounted", { file_count });
setLogCount(file_count);
});
invoke("update_state"); // Let the backend know that we (re)-initialised
commands.updateState(); // Let the backend know that we (re)-initialised
return () => {
sessionChanged.then((unlistenFn) => unlistenFn());
sessionChangedUnlisten.then((unlistenFn) => unlistenFn());
generalSettingsChangedUnlisten.then((unlistenFn) => unlistenFn());
advancedSettingsChangedUnlisten.then((unlistenFn) => unlistenFn());
logsRecountedUnlisten.then((unlistenFn) => unlistenFn());
@@ -131,8 +130,8 @@ export default function App() {
element={
<Overview
session={session}
signIn={() => invoke("sign_in")}
signOut={() => invoke("sign_out")}
signIn={commands.signIn}
signOut={commands.signOut}
/>
}
/>
@@ -141,10 +140,8 @@ export default function App() {
element={
<GeneralSettingsPage
settings={generalSettings}
saveSettings={(settings) =>
invoke("apply_general_settings", { settings })
}
resetSettings={() => invoke("reset_general_settings")}
saveSettings={commands.applyGeneralSettings}
resetSettings={commands.resetGeneralSettings}
/>
}
/>
@@ -153,10 +150,8 @@ export default function App() {
element={
<AdvancedSettingsPage
settings={advancedSettings}
saveSettings={(settings) =>
invoke("apply_advanced_settings", { settings })
}
resetSettings={() => invoke("reset_advanced_settings")}
saveSettings={commands.applyAdvancedSettings}
resetSettings={commands.resetAdvancedSettings}
/>
}
/>
@@ -165,13 +160,8 @@ export default function App() {
element={
<Diagnostics
logCount={logCount}
exportLogs={() => invoke("export_logs")}
clearLogs={async () => {
await invoke("clear_logs");
const logCount = await invoke<FileCount>("count_logs");
setLogCount(logCount);
}}
exportLogs={commands.exportLogs}
clearLogs={commands.clearLogs}
/>
}
/>

View File

@@ -1,7 +1,7 @@
import React from "react";
import { ShareIcon, TrashIcon } from "@heroicons/react/16/solid";
import { Button } from "flowbite-react";
import { FileCount } from "../generated/FileCount";
import { FileCount } from "../generated/bindings";
interface DiagnosticsPageProps {
logCount: FileCount | null;
@@ -17,7 +17,7 @@ export default function Diagnostics({
const bytes = logCount?.bytes ?? 0;
const files = logCount?.files ?? 0;
const megabytes = Math.round(bytes / 100000) / 10;
const megabytes = Math.round(Number(bytes / 100000)) / 10;
return (
<div className="container mx-auto p-4">

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useId, useState } from "react";
import { Button, Label, ToggleSwitch } from "flowbite-react";
import { GeneralSettingsViewModel } from "../generated/GeneralSettingsViewModel";
import { ManagedToggleSwitch, ManagedTextInput } from "./ManagedInput";
import { GeneralSettingsViewModel } from "../generated/bindings";
interface Props {
settings: GeneralSettingsViewModel | null;

View File

@@ -1,7 +1,7 @@
import React from "react";
import logo from "../logo.png";
import { SessionViewModel } from "../generated/SessionViewModel";
import { Button, Spinner } from "flowbite-react";
import { SessionViewModel } from "../generated/bindings";
interface OverviewPageProps {
session: SessionViewModel | null;

View File

@@ -1,8 +0,0 @@
export interface AdvancedSettingsViewModel {
auth_url: string;
auth_url_is_managed: boolean;
api_url: string;
api_url_is_managed: boolean;
log_filter: string;
log_filter_is_managed: boolean;
}

View File

@@ -1,4 +0,0 @@
export interface FileCount {
bytes: number;
files: number;
}

View File

@@ -1,8 +0,0 @@
export interface GeneralSettingsViewModel {
start_minimized: boolean;
start_on_login: boolean;
connect_on_start: boolean;
connect_on_start_is_managed: boolean;
account_slug: string;
account_slug_is_managed: boolean;
}

View File

@@ -1,4 +1,4 @@
# Generated TypeScript interfaces
The interfaces in this directory are automatically generated by `tslink`.
To regenerate them, run `TSLINK_BUILD=true cargo build`.
The interfaces in this directory are automatically generated by `specta`.
To regenerate them, run the Firezone GUI client as a debug build, i.e. `cargo run --bin firezone-gui-client`.

View File

@@ -1,9 +0,0 @@
export type SessionViewModel =
{
SignedIn: {
account_slug: string;
actor_name: string
}
} |
"Loading" |
"SignedOut";

View File

@@ -0,0 +1,175 @@
/* eslint-disable */
/* tslint:disable */
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
/** user-defined commands **/
export const commands = {
async clearLogs() : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("clear_logs") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async exportLogs() : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("export_logs") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async applyAdvancedSettings(settings: AdvancedSettings) : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("apply_advanced_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async resetAdvancedSettings() : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reset_advanced_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async applyGeneralSettings(settings: GeneralSettingsForm) : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("apply_general_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async resetGeneralSettings() : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reset_general_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async signIn() : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("sign_in") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async signOut() : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("sign_out") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async updateState() : Promise<Result<null, Error>> {
try {
return { status: "ok", data: await TAURI_INVOKE("update_state") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
/** user-defined events **/
export const events = __makeEvents__<{
advancedSettingsChanged: AdvancedSettingsChanged,
generalSettingsChanged: GeneralSettingsChanged,
logsRecounted: LogsRecounted,
sessionChanged: SessionChanged
}>({
advancedSettingsChanged: "advanced-settings-changed",
generalSettingsChanged: "general-settings-changed",
logsRecounted: "logs-recounted",
sessionChanged: "session-changed"
})
/** user-defined constants **/
/** user-defined types **/
export type AdvancedSettings = { auth_url: string; api_url: string; log_filter: string }
export type AdvancedSettingsChanged = AdvancedSettingsViewModel
export type AdvancedSettingsViewModel = { auth_url: string; auth_url_is_managed: boolean; api_url: string; api_url_is_managed: boolean; log_filter: string; log_filter_is_managed: boolean }
export type Error = string
export type FileCount = { bytes: number; files: number }
export type GeneralSettingsChanged = GeneralSettingsViewModel
export type GeneralSettingsForm = { start_minimized: boolean; start_on_login: boolean; connect_on_start: boolean; account_slug: string }
export type GeneralSettingsViewModel = { start_minimized: boolean; start_on_login: boolean; connect_on_start: boolean; connect_on_start_is_managed: boolean; account_slug: string; account_slug_is_managed: boolean }
export type LogsRecounted = FileCount
export type SessionChanged = SessionViewModel
export type SessionViewModel = { SignedIn: { account_slug: string; actor_name: string } } | "Loading" | "SignedOut"
/** tauri-specta globals **/
import {
invoke as TAURI_INVOKE,
Channel as TAURI_CHANNEL,
} from "@tauri-apps/api/core";
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
type __EventObj__<T> = {
listen: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
export type Result<T, E> =
| { status: "ok"; data: T }
| { status: "error"; error: E };
function __makeEvents__<T extends Record<string, any>>(
mappings: Record<keyof T, string>,
) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindow__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindow__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg),
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case "listen":
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case "once":
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case "emit":
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
},
});
},
},
);
}

View File

@@ -49,6 +49,8 @@ semver = { workspace = true, features = ["serde"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_variant = { workspace = true }
specta = { workspace = true, features = ["url"] }
specta-typescript = { workspace = true }
strum = { workspace = true }
subtle = { workspace = true }
tauri = { workspace = true, features = ["tray-icon", "image-png"] }
@@ -57,6 +59,7 @@ tauri-plugin-notification = { workspace = true }
tauri-plugin-opener = { workspace = true }
tauri-plugin-shell = { workspace = true }
tauri-runtime = { workspace = true }
tauri-specta = { workspace = true }
tauri-utils = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["signal", "time", "macros", "rt", "rt-multi-thread"] }
@@ -65,7 +68,6 @@ tokio-util = { workspace = true, features = ["codec"] }
tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
tslink = "0.4.2"
url = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true, features = ["deflate", "time"] }

View File

@@ -13,13 +13,17 @@ use crate::{
GeneralSettingsViewModel, MdmSettings,
},
updates,
view::SessionViewModel,
view::{
AdvancedSettingsChanged, GeneralSettingsChanged, LogsRecounted, SessionChanged,
SessionViewModel,
},
};
use anyhow::{Context, Result, bail};
use firezone_logging::err_with_src;
use futures::SinkExt as _;
use std::time::Duration;
use tauri::{Emitter, Manager};
use tauri::Manager;
use tauri_specta::Event;
use tokio::{runtime::Runtime, sync::mpsc};
use tokio_stream::StreamExt;
use tracing::instrument;
@@ -103,9 +107,9 @@ impl Drop for TauriIntegration {
impl GuiIntegration for TauriIntegration {
fn notify_session_changed(&self, session: &SessionViewModel) -> Result<()> {
self.app
.emit("session_changed", session)
.context("Failed to send `session_changed` event")
SessionChanged(session.clone())
.emit(&self.app)
.context("Failed to emit `session_changed` event")
}
fn notify_settings_changed(
@@ -114,26 +118,27 @@ impl GuiIntegration for TauriIntegration {
general_settings: GeneralSettings,
advanced_settings: AdvancedSettings,
) -> Result<()> {
self.app
.emit(
"general_settings_changed",
GeneralSettingsViewModel::new(mdm_settings.clone(), general_settings),
)
.context("Failed to send `general_settings_changed` event")?;
self.app
.emit(
"advanced_settings_changed",
AdvancedSettingsViewModel::new(mdm_settings, advanced_settings),
)
.context("Failed to send `advanced_settings_changed` event")?;
GeneralSettingsChanged(GeneralSettingsViewModel::new(
mdm_settings.clone(),
general_settings,
))
.emit(&self.app)
.context("Failed to emit `general_settings_changed` event")?;
AdvancedSettingsChanged(AdvancedSettingsViewModel::new(
mdm_settings,
advanced_settings,
))
.emit(&self.app)
.context("Failed to emit `advanced_settings_changed` event")?;
Ok(())
}
fn notify_logs_recounted(&self, file_count: &FileCount) -> Result<()> {
self.app
.emit("logs_recounted", file_count)
.context("Failed to send `logs_recounted` event")?;
LogsRecounted(file_count.clone())
.emit(&self.app)
.context("Failed to emit `logs_recounted` event")?;
Ok(())
}
@@ -360,6 +365,46 @@ pub fn run(
anyhow::Ok(ctrl_task)
});
let tauri_specta_builder = tauri_specta::Builder::<tauri::Wry>::new()
.events(tauri_specta::collect_events![
crate::view::SessionChanged,
crate::view::GeneralSettingsChanged,
crate::view::AdvancedSettingsChanged,
crate::view::LogsRecounted,
])
.commands(tauri_specta::collect_commands![
crate::view::clear_logs,
crate::view::export_logs,
crate::view::apply_advanced_settings,
crate::view::reset_advanced_settings,
crate::view::apply_general_settings,
crate::view::reset_general_settings,
crate::view::sign_in,
crate::view::sign_out,
crate::view::update_state,
])
.typ::<crate::view::Error>();
#[cfg(debug_assertions)]
{
let bindings_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../src-frontend/generated/bindings.ts")
.canonicalize()
.context("Failed to create absolute path to bindings file")?;
tracing::debug!(path = %bindings_path.display(), "Exporting TypeScript bindings");
tauri_specta_builder
.export(
specta_typescript::Typescript::default()
.bigint(specta_typescript::BigIntExportBehavior::Number)
.header("/* eslint-disable */\n/* tslint:disable */\n")
.formatter(specta_typescript::formatter::prettier),
bindings_path,
)
.context("Failed to export TypeScript bindings")?;
}
tauri::Builder::default()
.manage(Managed {
req_tx,
@@ -377,7 +422,12 @@ pub fn run(
api.prevent_close();
}
})
.invoke_handler(crate::view::generate_handler())
.invoke_handler(tauri_specta_builder.invoke_handler())
.setup(move |app| {
tauri_specta_builder.mount_events(app);
Ok(())
})
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_shell::init())

View File

@@ -204,8 +204,7 @@ fn system_layer() -> Result<tracing_subscriber::layer::Identity> {
Ok(tracing_subscriber::layer::Identity::new())
}
#[tslink::tslink(target = "./gui-client/src-frontend/generated/FileCount.ts")]
#[derive(Clone, Default, Serialize)]
#[derive(Clone, Default, Serialize, specta::Type)]
pub struct FileCount {
bytes: u64,
files: u64,

View File

@@ -52,7 +52,7 @@ pub struct AdvancedSettingsLegacy {
pub log_filter: String,
}
#[derive(Clone, Deserialize, Serialize)]
#[derive(Clone, Deserialize, Serialize, specta::Type)]
pub struct AdvancedSettings {
pub auth_url: Url,
pub api_url: Url,
@@ -79,8 +79,7 @@ fn start_minimized_default() -> bool {
true
}
#[tslink::tslink(target = "./gui-client/src-frontend/generated/GeneralSettingsViewModel.ts")]
#[derive(Clone, Serialize)]
#[derive(Clone, Serialize, specta::Type)]
pub struct GeneralSettingsViewModel {
pub start_minimized: bool,
pub start_on_login: bool,
@@ -109,8 +108,7 @@ impl GeneralSettingsViewModel {
}
}
#[tslink::tslink(target = "./gui-client/src-frontend/generated/AdvancedSettingsViewModel.ts")]
#[derive(Clone, Serialize)]
#[derive(Clone, Serialize, specta::Type)]
pub struct AdvancedSettingsViewModel {
pub auth_url: String,
pub auth_url_is_managed: bool,

View File

@@ -3,12 +3,16 @@ use std::{path::PathBuf, time::Duration};
use anyhow::Context as _;
use firezone_logging::err_with_src;
use serde::Serialize;
use tauri::{Wry, ipc::Invoke};
use tauri_plugin_dialog::DialogExt as _;
use crate::{controller::ControllerRequest, gui::Managed, settings::AdvancedSettings};
use crate::{
controller::ControllerRequest,
gui::Managed,
logging::FileCount,
settings::{AdvancedSettings, AdvancedSettingsViewModel, GeneralSettingsViewModel},
};
#[derive(Clone, serde::Deserialize)]
#[derive(Clone, serde::Deserialize, specta::Type)]
pub struct GeneralSettingsForm {
pub start_minimized: bool,
pub start_on_login: bool,
@@ -16,8 +20,7 @@ pub struct GeneralSettingsForm {
pub account_slug: String,
}
#[tslink::tslink(target = "./gui-client/src-frontend/generated/SessionViewModel.ts")]
#[derive(Clone, serde::Serialize)]
#[derive(Clone, serde::Serialize, specta::Type)]
pub enum SessionViewModel {
SignedIn {
account_slug: String,
@@ -27,22 +30,21 @@ pub enum SessionViewModel {
SignedOut,
}
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,
apply_general_settings,
reset_general_settings,
sign_in,
sign_out,
update_state,
]
}
#[derive(Clone, serde::Serialize, specta::Type, tauri_specta::Event)]
pub struct SessionChanged(pub SessionViewModel);
#[derive(Clone, serde::Serialize, specta::Type, tauri_specta::Event)]
pub struct GeneralSettingsChanged(pub GeneralSettingsViewModel);
#[derive(Clone, serde::Serialize, specta::Type, tauri_specta::Event)]
pub struct AdvancedSettingsChanged(pub AdvancedSettingsViewModel);
#[derive(Clone, serde::Serialize, specta::Type, tauri_specta::Event)]
pub struct LogsRecounted(pub FileCount);
#[tauri::command]
async fn clear_logs(managed: tauri::State<'_, Managed>) -> Result<()> {
#[specta::specta]
pub async fn clear_logs(managed: tauri::State<'_, Managed>) -> Result<()> {
let (tx, rx) = tokio::sync::oneshot::channel();
managed
@@ -57,14 +59,16 @@ async fn clear_logs(managed: tauri::State<'_, Managed>) -> Result<()> {
}
#[tauri::command]
async fn export_logs(app: tauri::AppHandle, managed: tauri::State<'_, Managed>) -> Result<()> {
#[specta::specta]
pub async fn export_logs(app: tauri::AppHandle, managed: tauri::State<'_, Managed>) -> Result<()> {
show_export_dialog(&app, managed.inner().clone())?;
Ok(())
}
#[tauri::command]
async fn apply_general_settings(
#[specta::specta]
pub async fn apply_general_settings(
managed: tauri::State<'_, Managed>,
settings: GeneralSettingsForm,
) -> Result<()> {
@@ -80,7 +84,8 @@ async fn apply_general_settings(
}
#[tauri::command]
async fn apply_advanced_settings(
#[specta::specta]
pub async fn apply_advanced_settings(
managed: tauri::State<'_, Managed>,
settings: AdvancedSettings,
) -> Result<()> {
@@ -96,14 +101,16 @@ async fn apply_advanced_settings(
}
#[tauri::command]
async fn reset_advanced_settings(managed: tauri::State<'_, Managed>) -> Result<()> {
#[specta::specta]
pub async fn reset_advanced_settings(managed: tauri::State<'_, Managed>) -> Result<()> {
apply_advanced_settings(managed, AdvancedSettings::default()).await?;
Ok(())
}
#[tauri::command]
async fn reset_general_settings(managed: tauri::State<'_, Managed>) -> Result<()> {
#[specta::specta]
pub async fn reset_general_settings(managed: tauri::State<'_, Managed>) -> Result<()> {
managed
.send_request(ControllerRequest::ResetGeneralSettings)
.await?;
@@ -148,21 +155,24 @@ fn show_export_dialog(app: &tauri::AppHandle, managed: Managed) -> Result<()> {
}
#[tauri::command]
async fn sign_in(managed: tauri::State<'_, Managed>) -> Result<()> {
#[specta::specta]
pub async fn sign_in(managed: tauri::State<'_, Managed>) -> Result<()> {
managed.send_request(ControllerRequest::SignIn).await?;
Ok(())
}
#[tauri::command]
async fn sign_out(managed: tauri::State<'_, Managed>) -> Result<()> {
#[specta::specta]
pub async fn sign_out(managed: tauri::State<'_, Managed>) -> Result<()> {
managed.send_request(ControllerRequest::SignOut).await?;
Ok(())
}
#[tauri::command]
async fn update_state(managed: tauri::State<'_, Managed>) -> Result<()> {
#[specta::specta]
pub async fn update_state(managed: tauri::State<'_, Managed>) -> Result<()> {
managed.send_request(ControllerRequest::UpdateState).await?;
Ok(())
@@ -170,20 +180,11 @@ async fn update_state(managed: tauri::State<'_, Managed>) -> Result<()> {
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
struct Error(anyhow::Error);
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&format!("{:#}", self.0))
}
}
#[derive(Debug, specta::Type, Serialize)]
pub struct Error(String);
impl From<anyhow::Error> for Error {
fn from(value: anyhow::Error) -> Self {
Self(value)
fn from(error: anyhow::Error) -> Self {
Self(format!("{error:#}"))
}
}