From ed38721feb559a0ace93b72373da328c14c27a6f Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 30 Apr 2026 20:56:03 +0800 Subject: [PATCH] feat(desktop-client): give login and main views their own BrowserWindow Spawn a separate window per mode keyed by a ?window= query param so each surface keeps its size, devtools, and renderer state independently. Logout now persists the cleared session and switches windows before awaiting the runtime release, avoiding a stale main-window flash on the way out. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop-client/src/main/bootstrap.ts | 223 ++++++++++++------ apps/desktop-client/src/preload/bridge.ts | 9 +- apps/desktop-client/src/renderer/App.vue | 50 +++- .../src/renderer/components/DesktopShell.vue | 1 + .../renderer/composables/useDesktopSession.ts | 71 +++++- apps/desktop-client/src/renderer/env.d.ts | 5 +- .../src/renderer/views/LoginView.vue | 1 + 7 files changed, 265 insertions(+), 95 deletions(-) diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 1f1c628..f9f0f92 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -66,14 +66,29 @@ process.on("uncaughtException", (error) => { }); let mainWindow: ElectronBrowserWindow | null = null; +let loginWindow: ElectronBrowserWindow | null = null; let mainRendererContents: ElectronWebContents | null = null; let quitReleaseInFlight = false; type WindowMode = "login" | "main"; +type WindowModeSource = "auth-state" | "login" | "logout"; -const WINDOW_SIZES: Record = { - login: { width: 340, height: 540, resizable: false }, - main: { width: 1320, height: 860, resizable: true }, +interface WindowSizePreset { + width: number; + height: number; + minWidth: number; + minHeight: number; + resizable: boolean; +} + +interface WindowModeRequest { + source?: WindowModeSource; + animate?: boolean; +} + +const WINDOW_SIZES: Record = { + login: { width: 340, height: 540, minWidth: 340, minHeight: 540, resizable: false }, + main: { width: 1320, height: 860, minWidth: 900, minHeight: 600, resizable: true }, }; function windowModePath(): string { @@ -102,39 +117,20 @@ function persistWindowMode(mode: WindowMode): void { } let currentWindowMode: WindowMode = "login"; -let windowModeApplied = false; -function applyWindowMode(window: ElectronBrowserWindow, mode: WindowMode): void { - const isFirstApply = !windowModeApplied; - const sameMode = windowModeApplied && currentWindowMode === mode; - - if (!sameMode) { - currentWindowMode = mode; - persistWindowMode(mode); - - const target = WINDOW_SIZES[mode]; - window.setResizable(true); - window.setMinimumSize(1, 1); - window.setMaximumSize(0, 0); - window.setSize(target.width, target.height, false); - window.center(); - - if (mode === "login") { - window.setMinimumSize(target.width, target.height); - window.setMaximumSize(target.width, target.height); - window.setResizable(false); - } else { - window.setMinimumSize(800, 600); - window.setResizable(true); - } +function normalizeWindowModeRequest(input: unknown): WindowModeRequest { + if (!input || typeof input !== "object") { + return {}; } - windowModeApplied = true; - - if (isFirstApply || !window.isVisible()) { - window.show(); - window.focus(); - } + const candidate = input as { source?: unknown; animate?: unknown }; + return { + source: + candidate.source === "login" || candidate.source === "logout" || candidate.source === "auth-state" + ? candidate.source + : undefined, + animate: candidate.animate === true, + }; } function currentMainWindow(): ElectronBrowserWindow | null { @@ -143,12 +139,34 @@ function currentMainWindow(): ElectronBrowserWindow | null { } if (mainWindow.isDestroyed()) { mainWindow = null; - windowModeApplied = false; return null; } return mainWindow; } +function currentLoginWindow(): ElectronBrowserWindow | null { + if (!loginWindow) { + return null; + } + if (loginWindow.isDestroyed()) { + loginWindow = null; + return null; + } + return loginWindow; +} + +function currentActiveWindow(): ElectronBrowserWindow | null { + return currentMainWindow() ?? currentLoginWindow(); +} + +function closeWindowAfterIpcReturn(window: ElectronBrowserWindow): void { + setTimeout(() => { + if (!window.isDestroyed()) { + window.close(); + } + }, 50); +} + function captureRuntimeSnapshot() { const snapshot = createRuntimeSnapshot(); applyDesktopHealthIndicatorFromSnapshot(snapshot, currentMainWindow()); @@ -164,17 +182,29 @@ async function ensureMainWindow(): Promise { if (existing) { return existing; } - const window = await createMainWindow(); + const window = await createAppWindow("main"); mainWindow = window; return window; } -async function revealMainWindow(): Promise { - const window = await ensureMainWindow(); +async function ensureLoginWindow(): Promise { + const existing = currentLoginWindow(); + if (existing) { + return existing; + } + const window = await createAppWindow("login"); + loginWindow = window; + return window; +} + +async function ensureWindow(mode: WindowMode): Promise { + return mode === "main" ? ensureMainWindow() : ensureLoginWindow(); +} + +async function revealWindow(window: ElectronBrowserWindow): Promise { if (window.isMinimized()) { window.restore(); } - applyWindowMode(window, currentWindowMode); if (!window.isVisible()) { window.show(); } @@ -182,12 +212,34 @@ async function revealMainWindow(): Promise { app.focus({ steal: true }); } -function revealMainWindowSafely(source: string): void { - void revealMainWindow().catch((error) => { - console.error("[desktop-main] reveal main window failed", { source, error }); +async function revealActiveWindow(): Promise { + const window = currentActiveWindow() ?? await ensureWindow(currentWindowMode); + await revealWindow(window); +} + +function revealActiveWindowSafely(source: string): void { + void revealActiveWindow().catch((error) => { + console.error("[desktop-main] reveal active window failed", { source, error }); }); } +async function switchWindowMode(mode: WindowMode, _request: WindowModeRequest = {}): Promise { + currentWindowMode = mode; + persistWindowMode(mode); + + const target = await ensureWindow(mode); + const previous = mode === "main" ? currentLoginWindow() : currentMainWindow(); + + if (!target.isVisible()) { + target.center(); + } + await revealWindow(target); + + if (previous && previous !== target && !previous.isDestroyed()) { + closeWindowAfterIpcReturn(previous); + } +} + function rendererURL(): string | null { return process.env.ELECTRON_RENDERER_URL ?? null; } @@ -196,40 +248,57 @@ function preloadPath(): string { return join(__dirname, "../preload/bridge.cjs"); } -async function mountRendererWebContents(window: ElectronBrowserWindow): Promise { - const contents = window.webContents; - - contents.setWindowOpenHandler(() => ({ action: "deny" })); - registerRendererDevtoolsProxyTarget(contents); - registerObservedRequestRendererTarget(contents); - mainRendererContents = contents; - window.on("closed", () => { - if (mainRendererContents === contents) { - mainRendererContents = null; - } - }); - +async function loadRenderer(window: ElectronBrowserWindow, mode: WindowMode): Promise { const devServerURL = rendererURL(); if (devServerURL) { - await contents.loadURL(devServerURL); - contents.openDevTools({ mode: "detach" }); + const url = new URL(devServerURL); + url.searchParams.set("window", mode); + await window.webContents.loadURL(url.toString()); + if (mode === "main") { + window.webContents.openDevTools({ mode: "detach" }); + } return; } - await contents.loadFile(join(__dirname, "../renderer/index.html")); + await window.webContents.loadFile(join(__dirname, "../renderer/index.html"), { + query: { window: mode }, + }); } -async function createMainWindow(): Promise { - const initial = WINDOW_SIZES[currentWindowMode]; +async function mountRendererWebContents(window: ElectronBrowserWindow, mode: WindowMode): Promise { + const contents = window.webContents; + + contents.setWindowOpenHandler(() => ({ action: "deny" })); + if (mode === "main") { + registerRendererDevtoolsProxyTarget(contents); + registerObservedRequestRendererTarget(contents); + mainRendererContents = contents; + window.on("closed", () => { + if (mainRendererContents === contents) { + mainRendererContents = null; + } + }); + } + + await loadRenderer(window, mode); +} + +async function createAppWindow(mode: WindowMode): Promise { + const initial = WINDOW_SIZES[mode]; const window = new BrowserWindow({ width: initial.width, height: initial.height, + minWidth: initial.minWidth, + minHeight: initial.minHeight, show: false, title: "GEO Rankly Desktop", icon: createDesktopHealthIcon("normal"), titleBarStyle: "hiddenInset", trafficLightPosition: { x: 12, y: 14 }, - backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#eaf2ff", + resizable: initial.resizable, + maximizable: initial.resizable, + fullscreenable: initial.resizable, + backgroundColor: mode === "login" ? "#eaf2ff" : nativeTheme.shouldUseDarkColors ? "#15191a" : "#f0f2f5", webPreferences: { preload: preloadPath(), sandbox: true, @@ -238,16 +307,17 @@ async function createMainWindow(): Promise { }, }); - const fallbackReveal = setTimeout(() => { - if (!windowModeApplied && !window.isDestroyed()) { - applyWindowMode(window, currentWindowMode); - } - }, 1500); + if (!initial.resizable) { + window.setMinimumSize(initial.minWidth, initial.minHeight); + window.setMaximumSize(initial.width, initial.height); + } + window.once("closed", () => { - clearTimeout(fallbackReveal); - if (mainWindow === window) { + if (mode === "main" && mainWindow === window) { mainWindow = null; - windowModeApplied = false; + } + if (mode === "login" && loginWindow === window) { + loginWindow = null; } }); @@ -271,7 +341,7 @@ async function createMainWindow(): Promise { }); }); - await mountRendererWebContents(window); + await mountRendererWebContents(window, mode); return window; } @@ -391,17 +461,16 @@ function registerBridgeHandlers(): void { await releaseRuntimeSession({ revoke: Boolean(revoke) }); return null; }); - ipcMain.handle("desktop:set-window-mode", (_event, mode: WindowMode) => { - const window = currentMainWindow(); - if (window && (mode === "login" || mode === "main")) { - applyWindowMode(window, mode); + ipcMain.handle("desktop:set-window-mode", async (_event, mode: WindowMode, request?: unknown) => { + if (mode === "login" || mode === "main") { + await switchWindowMode(mode, normalizeWindowModeRequest(request)); } return null; }); } const hasSingleInstanceLock = initSingleInstance(() => { - revealMainWindowSafely("single-instance"); + revealActiveWindowSafely("single-instance"); }); if (!hasSingleInstanceLock) { @@ -425,9 +494,9 @@ if (!hasSingleInstanceLock) { } mainRendererContents.send("desktop:runtime-invalidated", event); }); - await ensureMainWindow(); + await switchWindowMode(currentWindowMode); initTray(() => { - revealMainWindowSafely("tray"); + revealActiveWindowSafely("tray"); }); startDesktopHealthIndicator(() => currentMainWindow()); }).catch((error) => { @@ -435,7 +504,7 @@ if (!hasSingleInstanceLock) { }); app.on("activate", () => { - revealMainWindowSafely("activate"); + revealActiveWindowSafely("activate"); }); app.on("window-all-closed", () => { diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index d643c26..12993a5 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -22,6 +22,11 @@ interface DesktopClientIDScope { user_id: number; } +interface WindowModeRequest { + source?: "auth-state" | "login" | "logout"; + animate?: boolean; +} + const rendererProxyRequestChannel = "desktop:renderer-devtools-proxy:request"; const rendererProxyResponseChannel = "desktop:renderer-devtools-proxy:response"; @@ -119,8 +124,8 @@ const desktopBridge = { ipcRenderer.invoke("desktop:runtime-session-sync", session) as Promise, releaseRuntimeSession: (revoke?: boolean) => ipcRenderer.invoke("desktop:runtime-session-release", revoke) as Promise, - setWindowMode: (mode: "login" | "main") => - ipcRenderer.invoke("desktop:set-window-mode", mode) as Promise, + setWindowMode: (mode: "login" | "main", request?: WindowModeRequest) => + ipcRenderer.invoke("desktop:set-window-mode", mode, request) as Promise, onRuntimeInvalidated: ( listener: (event: { reason: "account-health"; at: number; accountId?: string }) => void, ) => { diff --git a/apps/desktop-client/src/renderer/App.vue b/apps/desktop-client/src/renderer/App.vue index b930a7b..82ac63b 100644 --- a/apps/desktop-client/src/renderer/App.vue +++ b/apps/desktop-client/src/renderer/App.vue @@ -1,14 +1,23 @@ diff --git a/apps/desktop-client/src/renderer/components/DesktopShell.vue b/apps/desktop-client/src/renderer/components/DesktopShell.vue index afd5cad..e631f0c 100644 --- a/apps/desktop-client/src/renderer/components/DesktopShell.vue +++ b/apps/desktop-client/src/renderer/components/DesktopShell.vue @@ -477,6 +477,7 @@ h1 { .content { flex: 1; + min-width: 0; height: 100vh; padding: 24px 32px; overflow-y: auto; diff --git a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts index c3f292d..84051d5 100644 --- a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts +++ b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts @@ -39,6 +39,7 @@ const apiBaseURL = shallowRef(readStoredApiBaseURL()); const pending = shallowRef(false); const error = shallowRef(null); let loginPromise: Promise | null = null; +let logoutPromise: Promise | null = null; function readStoredSession(): DesktopSession | null { if (typeof window === "undefined") { @@ -132,6 +133,39 @@ function createAuthenticatedClient() { }); } +function createSessionBoundAuthenticatedClient(source: DesktopSession) { + let accessToken = source.accessToken; + let refreshToken = source.refreshToken; + + return createApiClient({ + baseURL: apiBaseURL.value, + timeoutMs: 15000, + auth: { + getAccessToken: () => accessToken, + getRefreshToken: () => refreshToken, + setTokens: (tokens: AuthTokens) => { + accessToken = tokens.accessToken; + refreshToken = tokens.refreshToken; + }, + clearTokens: () => { + accessToken = null; + refreshToken = null; + }, + async refresh(refreshTokenValue: string): Promise { + const next = await createPublicClient().post( + "/api/auth/refresh", + { refresh_token: refreshTokenValue }, + ); + + return { + accessToken: next.access_token, + refreshToken: next.refresh_token, + }; + }, + }, + }); +} + function resolveFallbackPlatform(): string { const currentNavigator = globalThis.navigator as Navigator & { userAgentData?: { platform?: string }; @@ -416,10 +450,7 @@ async function enterPreview(): Promise { }); } -async function logout(): Promise { - error.value = null; - const current = session.value; - +async function releaseSessionForLogout(current: DesktopSession | null): Promise { if (current?.mode === "authenticated") { try { await window.desktopBridge?.app?.releaseRuntimeSession?.(true); @@ -428,13 +459,43 @@ async function logout(): Promise { } try { - await createAuthenticatedClient().post>("/api/auth/logout", {}); + await createSessionBoundAuthenticatedClient(current).post>("/api/auth/logout", {}); } catch (err) { console.warn("[desktop-session] auth logout failed", err); } } +} + +async function transitionWindowForLogout(): Promise { + try { + await window.desktopBridge?.app?.setWindowMode?.("login", { source: "logout", animate: true }); + } catch (err) { + console.warn("[desktop-session] logout window transition failed", err); + } +} + +async function performLogout(): Promise { + error.value = null; + const current = session.value; + const releasePromise = releaseSessionForLogout(current); persistSession(null); + await transitionWindowForLogout(); + + void releasePromise.catch((err) => { + console.warn("[desktop-session] background logout release failed", err); + }); +} + +async function logout(): Promise { + if (logoutPromise) { + return logoutPromise; + } + + logoutPromise = performLogout().finally(() => { + logoutPromise = null; + }); + return logoutPromise; } if (typeof window !== "undefined") { diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index 7de9e04..bcad1c3 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -42,7 +42,10 @@ declare global { openExternalUrl(url: string): Promise; syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise; releaseRuntimeSession(revoke?: boolean): Promise; - setWindowMode(mode: "login" | "main"): Promise; + setWindowMode(mode: "login" | "main", request?: { + source?: "auth-state" | "login" | "logout"; + animate?: boolean; + }): Promise; onRuntimeInvalidated( listener: (event: { reason: "account-health"; at: number; accountId?: string }) => void, ): () => void; diff --git a/apps/desktop-client/src/renderer/views/LoginView.vue b/apps/desktop-client/src/renderer/views/LoginView.vue index c2e9a8c..b0dabe6 100644 --- a/apps/desktop-client/src/renderer/views/LoginView.vue +++ b/apps/desktop-client/src/renderer/views/LoginView.vue @@ -61,6 +61,7 @@ async function submitLogin() { email: form.email, password: form.password, }); + await window.desktopBridge?.app?.setWindowMode?.("main", { source: "login" }); } onMounted(() => {