chore(gui-client): generate TypeScript interfaces from Rust (#9353)

The frontend of the GUI client is written in TypeScript and communicates
with the backend via event listeners. Currently, we only have
type-safety within either of those parts of the codebase but not across
it. The payloads of these events are JSON-encoded. Any change to this
interface therefore needs to be applied on either end.

To avoid this, we add `tslink` to the GUI client which generates
TypeScript interfaces from Rust structs. We still check those into Git
into order to make local builds easy (otherwise every dev would have to
set `TSLINK_BUILD=true` on their machine). Our Tauri CI build already
has a check to ensure the Git workspace isn't modified after building so
any changes to these generated files will fail CI.

This adds a bit more type-safety to the codebase and makes refactorings
on the GUI client easier.
This commit is contained in:
Thomas Eizinger
2025-06-02 09:56:06 +08:00
committed by GitHub
parent aa86250d17
commit 499a67f44b
14 changed files with 72 additions and 28 deletions

View File

@@ -11,6 +11,7 @@ defaults:
env:
RUSTFLAGS: "-Dwarnings"
RUSTDOCFLAGS: "-D warnings"
TSLINK_BUILD: true
jobs:
update-release-draft:

29
rust/Cargo.lock generated
View File

@@ -1242,6 +1242,15 @@ 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"
@@ -1615,7 +1624,7 @@ version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case",
"convert_case 0.4.0",
"proc-macro2",
"quote",
"rustc_version",
@@ -2240,6 +2249,7 @@ dependencies = [
"tracing-journald",
"tracing-log",
"tracing-subscriber",
"tslink",
"url",
"uuid",
"windows",
@@ -7851,6 +7861,23 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tslink"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6cbdb11a5a2f2c51118b6a0e4ffde3b547402970d16a80cf7b3ca29d1c7fea4"
dependencies = [
"convert_case 0.6.0",
"lazy_static",
"proc-macro2",
"quote",
"serde",
"syn 2.0.101",
"thiserror 1.0.69",
"toml",
"uuid",
]
[[package]]
name = "tun"
version = "0.1.0"

View File

@@ -226,10 +226,10 @@ deny = [
# this is set there is no point setting `deny`
#exact = true
# Certain crates/versions that will be skipped when doing duplicate detection.
skip = [
"base64",
"bitflags",
"convert_case",
"core-foundation",
"core-graphics",
"core-graphics-types",
@@ -283,7 +283,7 @@ skip = [
"winreg",
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
]
] # Certain crates/versions that will be skipped when doing duplicate detection.
# Similarly to `skip` allows you to skip certain crates during duplicate
# detection. Unlike skip, it also includes the entire tree of transitive
# dependencies starting at the specified crate, up to a certain depth, which is

View File

@@ -17,14 +17,12 @@ 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 Diagnostics from "./DiagnosticsPage";
import Overview from "./OverviewPage";
import SettingsPage, { Settings } from "./SettingsPage";
export interface Session {
account_slug: string;
actor_name: string;
}
import SettingsPage from "./SettingsPage";
import { FileCount } from "../generated/FileCount";
import { AdvancedSettingsViewModel as Settings } from "../generated/AdvancedSettingsViewModel";
import { Session } from "../generated/Session";
export default function App() {
let [session, setSession] = useState<Session | null>(null);

View File

@@ -1,6 +1,7 @@
import React from "react";
import { ShareIcon, TrashIcon } from "@heroicons/react/16/solid";
import { Button } from "flowbite-react";
import { FileCount } from "../generated/FileCount";
interface DiagnosticsPageProps {
logCount: FileCount | null;
@@ -8,11 +9,6 @@ interface DiagnosticsPageProps {
clearLogs: () => void;
}
export interface FileCount {
files: number;
bytes: number;
}
export default function Diagnostics({
logCount,
exportLogs,

View File

@@ -1,14 +1,6 @@
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;
}
import { AdvancedSettingsViewModel as Settings } from "../generated/AdvancedSettingsViewModel";
interface SettingsPageProps {
settings: Settings | null;

View File

@@ -0,0 +1,8 @@
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

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

View File

@@ -0,0 +1,4 @@
# Generated TypeScript interfaces
The interfaces in this directory are automatically generated by `tslink`.
To regenerate them, run `TSLINK_BUILD=true cargo build`.

View File

@@ -0,0 +1,4 @@
export interface Session {
account_slug: string;
actor_name: string;
}

View File

@@ -59,6 +59,7 @@ tokio-util = { workspace = true, features = ["codec"] }
tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
tslink = "0.3.0"
url = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true, features = ["deflate", "time"] }

View File

@@ -82,6 +82,7 @@ pub(crate) struct Response {
pub(crate) state: SecretString,
}
#[tslink::tslink(target = "./gui-client/src-frontend/generated/Session.ts")]
#[derive(Default, Clone, Deserialize, Serialize)]
pub struct Session {
pub(crate) account_slug: String,

View File

@@ -204,6 +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)]
pub struct FileCount {
bytes: u64,

View File

@@ -66,11 +66,12 @@ pub struct GeneralSettings {
pub internet_resource_enabled: Option<bool>,
}
#[tslink::tslink(target = "./gui-client/src-frontend/generated/AdvancedSettingsViewModel.ts")]
#[derive(Clone, Serialize)]
pub struct AdvancedSettingsViewModel {
pub auth_url: Url,
pub auth_url: String,
pub auth_url_is_managed: bool,
pub api_url: Url,
pub api_url: String,
pub api_url_is_managed: bool,
pub log_filter: String,
pub log_filter_is_managed: bool,
@@ -83,8 +84,14 @@ 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_url),
api_url: mdm_settings.api_url.unwrap_or(advanced_settings.api_url),
auth_url: mdm_settings
.auth_url
.unwrap_or(advanced_settings.auth_url)
.to_string(),
api_url: mdm_settings
.api_url
.unwrap_or(advanced_settings.api_url)
.to_string(),
log_filter: mdm_settings
.log_filter
.unwrap_or(advanced_settings.log_filter),