diff options
| author | 2026-03-26 09:02:10 +0800 | |
|---|---|---|
| committer | 2026-03-26 09:02:10 +0800 | |
| commit | 94b0d8e208363c802c12b56d8bdbef574dd1fb91 (patch) | |
| tree | e86c8d46e73262c67c1755aaf4202cbcd1f8f844 /packages/ui/src/models | |
| parent | 7d0e92e6d3b172adfe552ffae9b97f8dad6f63ae (diff) | |
| parent | 3a31d3004b2814cd8a26d49a0f8a96636411dcd2 (diff) | |
| download | DropOut-94b0d8e208363c802c12b56d8bdbef574dd1fb91.tar.gz DropOut-94b0d8e208363c802c12b56d8bdbef574dd1fb91.zip | |
Add game lifecycle management and instance import/export tools (#117)
## Summary by Sourcery
Add centralized game process and instance lifecycle management, shared
cache-aware path resolution, and instance import/export/repair
capabilities across backend and UI.
New Features:
- Track a single running game process in the backend, expose stop-game
control, and emit structured game-exited events with instance and
version context.
- Introduce instance path resolution that supports shared caches for
versions, libraries, and assets, and use it across game start, install,
and version management APIs.
- Add import, export, and repair operations for instances, including
zip-based archive support and automatic recovery of on-disk instances.
- Expose new instance lifecycle and repair APIs to the frontend and wire
them through the client and instance store.
- Add per-instance start/stop controls in the instances view and
instance selection in the bottom bar for launching games.
Enhancements:
- Guard instance operations with per-instance locks and track active
operations such as launch, install, delete, and import/export.
- Improve handling of Microsoft login errors and polling status, with
clearer user feedback and safer interval management.
- Simplify config mutation during shared cache migration and centralize
instance directory resolution in the backend.
- Initialize a game lifecycle listener at app startup to keep UI state
in sync with backend game exit events.
Build:
- Configure the Vite dev server to use a fixed localhost host and port
for the UI dev environment.
Diffstat (limited to 'packages/ui/src/models')
| -rw-r--r-- | packages/ui/src/models/auth.ts | 87 | ||||
| -rw-r--r-- | packages/ui/src/models/instance.ts | 82 |
2 files changed, 130 insertions, 39 deletions
diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts index 10b2a0d..9c814d2 100644 --- a/packages/ui/src/models/auth.ts +++ b/packages/ui/src/models/auth.ts @@ -13,6 +13,10 @@ import { } from "@/client"; import type { Account, DeviceCodeResponse } from "@/types"; +function getAuthErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export interface AuthState { account: Account | null; loginMode: Account["type"] | null; @@ -68,36 +72,78 @@ export const useAuthStore = create<AuthState>((set, get) => ({ 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 }); + try { + 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 = Math.max(1, Number(deviceCode.interval) || 5) * 1000; + const interval = setInterval(() => { + _pollLoginStatus(deviceCode.deviceCode, onSuccess); + }, ms); + + set({ + _pollingInterval: interval, + deviceCode, + statusMessage: deviceCode.message ?? "Waiting for authorization...", + }); + } catch (error) { + const message = getAuthErrorMessage(error); + console.error("Failed to start Microsoft login:", error); + set({ loginMode: null, statusMessage: `Failed to start login: ${message}` }); + toast.error(`Failed to start Microsoft login: ${message}`); + } }, _pollLoginStatus: async (deviceCode, onSuccess) => { const { _pollingInterval, _mutex: mutex, _progressUnlisten } = get(); if (mutex.isLocked) return; - mutex.acquire(); + + await 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"); + set({ + account, + loginMode: "microsoft", + deviceCode: null, + _pollingInterval: null, + _progressUnlisten: null, + statusMessage: "Login successful", + }); + } catch (error: unknown) { + const message = getAuthErrorMessage(error); + + if (message.includes("authorization_pending")) { + set({ statusMessage: "Waiting for authorization..." }); + return; + } + + if (message.includes("slow_down")) { + set({ statusMessage: "Microsoft asked to slow down polling..." }); + return; } + + clearInterval(_pollingInterval ?? undefined); + _progressUnlisten?.(); + + set({ + loginMode: null, + deviceCode: null, + _pollingInterval: null, + _progressUnlisten: null, + statusMessage: `Login failed: ${message}`, + }); + + console.error("Failed to poll login status:", error); + toast.error(`Microsoft login failed: ${message}`); } finally { mutex.release(); } @@ -111,6 +157,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({ } set({ loginMode: null, + deviceCode: null, _pollingInterval: null, statusMessage: null, _progressUnlisten: null, diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts index b1b463e..e1eb7c1 100644 --- a/packages/ui/src/models/instance.ts +++ b/packages/ui/src/models/instance.ts @@ -4,10 +4,13 @@ import { createInstance, deleteInstance, duplicateInstance, + exportInstance, getActiveInstance, getInstance, + importInstance, listInstances, - setActiveInstance, + repairInstances, + setActiveInstance as setActiveInstanceCommand, updateInstance, } from "@/client"; import type { Instance } from "@/types"; @@ -22,6 +25,9 @@ interface InstanceState { update: (instance: Instance) => Promise<void>; setActiveInstance: (instance: Instance) => Promise<void>; duplicate: (id: string, newName: string) => Promise<Instance | null>; + exportArchive: (id: string, archivePath: string) => Promise<void>; + importArchive: (archivePath: string, newName?: string) => Promise<Instance | null>; + repair: () => Promise<void>; get: (id: string) => Promise<Instance | null>; } @@ -30,14 +36,20 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ activeInstance: null, refresh: async () => { - const { setActiveInstance } = get(); try { const instances = await listInstances(); - const activeInstance = await getActiveInstance(); + let activeInstance = await getActiveInstance(); + + if ( + activeInstance && + !instances.some((instance) => instance.id === activeInstance?.id) + ) { + activeInstance = null; + } if (!activeInstance && instances.length > 0) { - // If no active instance but instances exist, set the first one as active - await setActiveInstance(instances[0]); + await setActiveInstanceCommand(instances[0].id); + activeInstance = instances[0]; } set({ instances, activeInstance }); @@ -51,35 +63,27 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ const { refresh } = get(); try { const instance = await createInstance(name); + await setActiveInstanceCommand(instance.id); await refresh(); toast.success(`Instance "${name}" created successfully`); return instance; } catch (e) { console.error("Failed to create instance:", e); - toast.error("Error creating instance"); + toast.error(String(e)); return null; } }, delete: async (id) => { - const { refresh, instances, activeInstance, setActiveInstance } = get(); + const { refresh } = 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"); + toast.error(String(e)); } }, @@ -96,7 +100,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ }, setActiveInstance: async (instance) => { - await setActiveInstance(instance.id); + await setActiveInstanceCommand(instance.id); set({ activeInstance: instance }); }, @@ -104,16 +108,56 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ const { refresh } = get(); try { const instance = await duplicateInstance(id, newName); + await setActiveInstanceCommand(instance.id); await refresh(); toast.success(`Instance duplicated as "${newName}"`); return instance; } catch (e) { console.error("Failed to duplicate instance:", e); - toast.error("Error duplicating instance"); + toast.error(String(e)); + return null; + } + }, + + exportArchive: async (id, archivePath) => { + try { + await exportInstance(id, archivePath); + toast.success("Instance exported successfully"); + } catch (e) { + console.error("Failed to export instance:", e); + toast.error(String(e)); + } + }, + + importArchive: async (archivePath, newName) => { + const { refresh } = get(); + try { + const instance = await importInstance(archivePath, newName); + await setActiveInstanceCommand(instance.id); + await refresh(); + toast.success(`Instance "${instance.name}" imported successfully`); + return instance; + } catch (e) { + console.error("Failed to import instance:", e); + toast.error(String(e)); return null; } }, + repair: async () => { + const { refresh } = get(); + try { + const result = await repairInstances(); + await refresh(); + toast.success( + `Repair completed: restored ${result.restoredInstances}, removed ${result.removedStaleEntries}`, + ); + } catch (e) { + console.error("Failed to repair instances:", e); + toast.error(String(e)); + } + }, + get: async (id) => { try { return await getInstance(id); |