aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/models
diff options
context:
space:
mode:
author苏向夜 <46275354+fu050409@users.noreply.github.com>2026-02-25 02:06:07 +0800
committerGitHub <noreply@github.com>2026-02-25 02:06:07 +0800
commit78ac61904d78d558d092eff08c9f261cbdb187e8 (patch)
tree96f68d1f1554ee3a0532793afaaa52b0c73dcbec /packages/ui/src/models
parent8ff3af6cb908fd824b512379dd21ed4f595ab6bb (diff)
parent329734b23957b84cde2af459fa61c7385fb5b5f1 (diff)
downloadDropOut-78ac61904d78d558d092eff08c9f261cbdb187e8.tar.gz
DropOut-78ac61904d78d558d092eff08c9f261cbdb187e8.zip
feat(ui): partial react rewrite (#77)
## Summary by Sourcery Export backend data structures to TypeScript for the new React-based UI and update CI to build additional targets. New Features: - Generate TypeScript definitions for core backend structs and enums used by the UI. - Now use our own Azure app(_DropOut_) to finish the authorize process. Enhancements: - Annotate existing Rust models with ts-rs metadata to control exported TypeScript shapes, including tagged enums and opaque JSON fields. Build: - Add ts-rs as a dependency for generating TypeScript bindings from Rust types. CI: - Extend the Semifold CI workflow to run on the dev branch and build additional Linux musl and Windows GNU targets using cross where needed.
Diffstat (limited to 'packages/ui/src/models')
-rw-r--r--packages/ui/src/models/auth.ts142
-rw-r--r--packages/ui/src/models/instances.ts135
-rw-r--r--packages/ui/src/models/settings.ts75
3 files changed, 352 insertions, 0 deletions
diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts
new file mode 100644
index 0000000..10b2a0d
--- /dev/null
+++ b/packages/ui/src/models/auth.ts
@@ -0,0 +1,142 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
+import { Mutex } from "es-toolkit";
+import { toString as stringify } from "es-toolkit/compat";
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ completeMicrosoftLogin,
+ getActiveAccount,
+ loginOffline,
+ logout,
+ startMicrosoftLogin,
+} from "@/client";
+import type { Account, DeviceCodeResponse } from "@/types";
+
+export interface AuthState {
+ account: Account | null;
+ loginMode: Account["type"] | null;
+ deviceCode: DeviceCodeResponse | null;
+ _pollingInterval: number | null;
+ _mutex: Mutex;
+ statusMessage: string | null;
+ _progressUnlisten: UnlistenFn | null;
+
+ init: () => Promise<void>;
+ setLoginMode: (mode: Account["type"] | null) => void;
+ loginOnline: (onSuccess?: () => void | Promise<void>) => Promise<void>;
+ _pollLoginStatus: (
+ deviceCode: string,
+ onSuccess?: () => void | Promise<void>,
+ ) => Promise<void>;
+ cancelLoginOnline: () => Promise<void>;
+ loginOffline: (username: string) => Promise<void>;
+ logout: () => Promise<void>;
+}
+
+export const useAuthStore = create<AuthState>((set, get) => ({
+ account: null,
+ loginMode: null,
+ deviceCode: null,
+ _pollingInterval: null,
+ statusMessage: null,
+ _progressUnlisten: null,
+ _mutex: new Mutex(),
+
+ init: async () => {
+ try {
+ const account = await getActiveAccount();
+ set({ account });
+ } catch (error) {
+ console.error("Failed to initialize auth store:", error);
+ }
+ },
+ setLoginMode: (mode) => set({ loginMode: mode }),
+ loginOnline: async (onSuccess) => {
+ const { _pollLoginStatus } = get();
+
+ set({ statusMessage: "Waiting for authorization..." });
+
+ try {
+ const unlisten = await listen("auth-progress", (event) => {
+ const message = event.payload;
+ console.log(message);
+ set({ statusMessage: stringify(message), _progressUnlisten: unlisten });
+ });
+ } catch (error) {
+ console.warn("Failed to attch auth-progress listener:", error);
+ toast.warning("Failed to attch auth-progress listener");
+ }
+
+ const deviceCode = await startMicrosoftLogin();
+ navigator.clipboard?.writeText(deviceCode.userCode).catch((err) => {
+ console.error("Failed to copy to clipboard:", err);
+ });
+ open(deviceCode.verificationUri).catch((err) => {
+ console.error("Failed to open browser:", err);
+ });
+ const ms = Number(deviceCode.interval) * 1000;
+ const interval = setInterval(() => {
+ _pollLoginStatus(deviceCode.deviceCode, onSuccess);
+ }, ms);
+ set({ _pollingInterval: interval, deviceCode });
+ },
+ _pollLoginStatus: async (deviceCode, onSuccess) => {
+ const { _pollingInterval, _mutex: mutex, _progressUnlisten } = get();
+ if (mutex.isLocked) return;
+ mutex.acquire();
+ try {
+ const account = await completeMicrosoftLogin(deviceCode);
+ clearInterval(_pollingInterval ?? undefined);
+ _progressUnlisten?.();
+ onSuccess?.();
+ set({ account, loginMode: "microsoft" });
+ } catch (error) {
+ if (error === "authorization_pending") {
+ console.log("Authorization pending...");
+ } else {
+ console.error("Failed to poll login status:", error);
+ toast.error("Failed to poll login status");
+ }
+ } finally {
+ mutex.release();
+ }
+ },
+ cancelLoginOnline: async () => {
+ const { account, logout, _pollingInterval, _progressUnlisten } = get();
+ clearInterval(_pollingInterval ?? undefined);
+ _progressUnlisten?.();
+ if (account) {
+ await logout();
+ }
+ set({
+ loginMode: null,
+ _pollingInterval: null,
+ statusMessage: null,
+ _progressUnlisten: null,
+ });
+ },
+ loginOffline: async (username: string) => {
+ const trimmedUsername = username.trim();
+ if (trimmedUsername.length === 0) {
+ throw new Error("Username cannot be empty");
+ }
+
+ try {
+ const account = await loginOffline(trimmedUsername);
+ set({ account, loginMode: "offline" });
+ } catch (error) {
+ console.error("Failed to login offline:", error);
+ toast.error("Failed to login offline");
+ }
+ },
+ logout: async () => {
+ try {
+ await logout();
+ set({ account: null });
+ } catch (error) {
+ console.error("Failed to logout:", error);
+ toast.error("Failed to logout");
+ }
+ },
+}));
diff --git a/packages/ui/src/models/instances.ts b/packages/ui/src/models/instances.ts
new file mode 100644
index 0000000..f434c7c
--- /dev/null
+++ b/packages/ui/src/models/instances.ts
@@ -0,0 +1,135 @@
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ createInstance,
+ deleteInstance,
+ duplicateInstance,
+ getActiveInstance,
+ getInstance,
+ listInstances,
+ setActiveInstance,
+ updateInstance,
+} from "@/client";
+import type { Instance } from "@/types";
+
+interface InstancesState {
+ // State
+ instances: Instance[];
+ activeInstance: Instance | null;
+
+ // Actions
+ refresh: () => Promise<void>;
+ create: (name: string) => Promise<Instance | null>;
+ delete: (id: string) => Promise<void>;
+ update: (instance: Instance) => Promise<void>;
+ setActiveInstance: (instance: Instance) => Promise<void>;
+ duplicate: (id: string, newName: string) => Promise<Instance | null>;
+ getInstance: (id: string) => Promise<Instance | null>;
+}
+
+export const useInstancesStore = create<InstancesState>((set, get) => ({
+ // Initial state
+ instances: [],
+ activeInstance: null,
+
+ // Actions
+ refresh: async () => {
+ const { setActiveInstance } = get();
+ try {
+ const instances = await listInstances();
+ const active = await getActiveInstance();
+
+ if (!active && instances.length > 0) {
+ // If no active instance but instances exist, set the first one as active
+ await setActiveInstance(instances[0]);
+ }
+
+ set({ instances });
+ } catch (e) {
+ console.error("Failed to load instances:", e);
+ toast.error("Error loading instances");
+ }
+ },
+
+ create: async (name) => {
+ const { refresh } = get();
+ try {
+ const instance = await createInstance(name);
+ await refresh();
+ toast.success(`Instance "${name}" created successfully`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ toast.error("Error creating instance");
+ return null;
+ }
+ },
+
+ delete: async (id) => {
+ const { refresh, instances, activeInstance, setActiveInstance } = get();
+ try {
+ await deleteInstance(id);
+ await refresh();
+
+ // If deleted instance was active, set another as active
+ if (activeInstance?.id === id) {
+ if (instances.length > 0) {
+ await setActiveInstance(instances[0]);
+ } else {
+ set({ activeInstance: null });
+ }
+ }
+
+ toast.success("Instance deleted successfully");
+ } catch (e) {
+ console.error("Failed to delete instance:", e);
+ toast.error("Error deleting instance");
+ }
+ },
+
+ update: async (instance) => {
+ const { refresh } = get();
+ try {
+ await updateInstance(instance);
+ await refresh();
+ toast.success("Instance updated successfully");
+ } catch (e) {
+ console.error("Failed to update instance:", e);
+ toast.error("Error updating instance");
+ }
+ },
+
+ setActiveInstance: async (instance) => {
+ try {
+ await setActiveInstance(instance.id);
+ set({ activeInstance: instance });
+ toast.success("Active instance changed");
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ toast.error("Error setting active instance");
+ }
+ },
+
+ duplicate: async (id, newName) => {
+ const { refresh } = get();
+ try {
+ const instance = await duplicateInstance(id, newName);
+ await refresh();
+ toast.success(`Instance duplicated as "${newName}"`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to duplicate instance:", e);
+ toast.error("Error duplicating instance");
+ return null;
+ }
+ },
+
+ getInstance: async (id) => {
+ try {
+ return await getInstance(id);
+ } catch (e) {
+ console.error("Failed to get instance:", e);
+ return null;
+ }
+ },
+}));
diff --git a/packages/ui/src/models/settings.ts b/packages/ui/src/models/settings.ts
new file mode 100644
index 0000000..9f4119c
--- /dev/null
+++ b/packages/ui/src/models/settings.ts
@@ -0,0 +1,75 @@
+import { toast } from "sonner";
+import { create } from "zustand/react";
+import { getConfigPath, getSettings, saveSettings } from "@/client";
+import type { LauncherConfig } from "@/types";
+
+export interface SettingsState {
+ config: LauncherConfig | null;
+ configPath: string | null;
+
+ /* Theme getter */
+ get theme(): string;
+ /* Apply theme to the document */
+ applyTheme: (theme?: string) => void;
+
+ /* Refresh settings from the backend */
+ refresh: () => Promise<void>;
+ /* Save settings to the backend */
+ save: () => Promise<void>;
+ /* Update settings in the backend */
+ update: (config: LauncherConfig) => Promise<void>;
+ /* Merge settings with the current config without saving */
+ merge: (config: Partial<LauncherConfig>) => void;
+}
+
+export const useSettingsStore = create<SettingsState>((set, get) => ({
+ config: null,
+ configPath: null,
+
+ get theme() {
+ const { config } = get();
+ return config?.theme || "dark";
+ },
+ applyTheme: (theme?: string) => {
+ const { config } = get();
+ if (!config) return;
+ if (!theme) theme = config.theme;
+ let themeValue = theme;
+ if (theme === "system") {
+ themeValue = window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ }
+ document.documentElement.classList.remove("light", "dark");
+ document.documentElement.setAttribute("data-theme", themeValue);
+ document.documentElement.classList.add(themeValue);
+ set({ config: { ...config, theme } });
+ },
+
+ refresh: async () => {
+ const { applyTheme } = get();
+ try {
+ const settings = await getSettings();
+ const path = await getConfigPath();
+ set({ config: settings, configPath: path });
+ applyTheme(settings.theme);
+ } catch (error) {
+ console.error("Failed to load settings:", error);
+ toast.error("Failed to load settings");
+ }
+ },
+ save: async () => {
+ const { config } = get();
+ if (!config) return;
+ await saveSettings(config);
+ },
+ update: async (config) => {
+ await saveSettings(config);
+ set({ config });
+ },
+ merge: (config) => {
+ const { config: currentConfig } = get();
+ if (!currentConfig) throw new Error("Settings not loaded");
+ set({ config: { ...currentConfig, ...config } });
+ },
+}));