diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 090eeae..0a1e24b 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -37,6 +37,11 @@ import { registerObservedRequestRendererTarget, } from './network-observer' import { getPlaywrightCDPPort, startHiddenPlaywrightReaper } from './playwright-cdp' +import { + clearSavedLoginCredentials, + getSavedLoginCredentials, + saveLoginCredentials, +} from './login-credentials' import { initProcessMetricsSampler } from './process-metrics' import { registerRendererDevtoolsProxyTarget } from './renderer-devtools-proxy' import { @@ -925,6 +930,16 @@ function registerBridgeHandlers(): void { return null }) ipcMain.handle('desktop:get-app-settings', () => getDesktopAppSettings()) + ipcMain.handle('desktop:get-login-credentials', () => getSavedLoginCredentials()) + safeHandle( + 'desktop:save-login-credentials', + (_event, credentials: { identifier?: unknown; password?: unknown }) => + saveLoginCredentials({ + identifier: typeof credentials?.identifier === 'string' ? credentials.identifier : '', + password: typeof credentials?.password === 'string' ? credentials.password : '', + }), + ) + ipcMain.handle('desktop:clear-login-credentials', () => clearSavedLoginCredentials()) safeHandle( 'desktop:set-app-setting', async (_event, key: DesktopAppSettingKey, value: boolean) => { diff --git a/apps/desktop-client/src/main/login-credentials.ts b/apps/desktop-client/src/main/login-credentials.ts new file mode 100644 index 0000000..e235e68 --- /dev/null +++ b/apps/desktop-client/src/main/login-credentials.ts @@ -0,0 +1,87 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { app, safeStorage } from 'electron/main' + +export interface DesktopLoginCredentials { + identifier: string + password: string +} + +interface PersistedLoginCredentials { + version?: number + identifier?: unknown + encryptedPassword?: unknown +} + +function credentialsPath(): string { + return join(app.getPath('userData'), 'login-credentials.json') +} + +function normalizeCredentials(input: unknown): PersistedLoginCredentials { + if (!input || typeof input !== 'object') { + return {} + } + return input as PersistedLoginCredentials +} + +export function getSavedLoginCredentials(): DesktopLoginCredentials | null { + try { + if (!existsSync(credentialsPath())) { + return null + } + + const persisted = normalizeCredentials(JSON.parse(readFileSync(credentialsPath(), 'utf8'))) + const identifier = typeof persisted.identifier === 'string' ? persisted.identifier : '' + const encryptedPassword = + typeof persisted.encryptedPassword === 'string' ? persisted.encryptedPassword : '' + + if (!identifier || !encryptedPassword || !safeStorage.isEncryptionAvailable()) { + return null + } + + const password = safeStorage.decryptString(Buffer.from(encryptedPassword, 'base64')) + return password ? { identifier, password } : null + } catch (error) { + console.warn('[login-credentials] read failed', { + message: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +export function saveLoginCredentials(credentials: DesktopLoginCredentials): DesktopLoginCredentials { + const identifier = credentials.identifier.trim() + if (!identifier || !credentials.password) { + clearSavedLoginCredentials() + return { identifier: '', password: '' } + } + + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('login_credentials_encryption_unavailable') + } + + const encryptedPassword = safeStorage.encryptString(credentials.password).toString('base64') + writeFileSync( + credentialsPath(), + JSON.stringify({ + version: 1, + identifier, + encryptedPassword, + updatedAt: new Date().toISOString(), + }), + 'utf8', + ) + return { identifier, password: credentials.password } +} + +export function clearSavedLoginCredentials(): null { + try { + rmSync(credentialsPath(), { force: true }) + } catch (error) { + console.warn('[login-credentials] clear failed', { + message: error instanceof Error ? error.message : String(error), + }) + } + return null +} diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index f784538..8561253 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -33,6 +33,11 @@ interface DesktopAppSettings { keepRunningInBackground: boolean } +interface DesktopLoginCredentials { + identifier: string + password: string +} + const rendererProxyRequestChannel = 'desktop:renderer-devtools-proxy:request' const rendererProxyResponseChannel = 'desktop:renderer-devtools-proxy:response' @@ -137,6 +142,14 @@ const desktopBridge = { openSettingsWindow: () => ipcRenderer.invoke('desktop:open-settings-window') as Promise, getAppSettings: () => ipcRenderer.invoke('desktop:get-app-settings') as Promise, + getLoginCredentials: () => + ipcRenderer.invoke('desktop:get-login-credentials') as Promise, + saveLoginCredentials: (credentials: DesktopLoginCredentials) => + ipcRenderer.invoke('desktop:save-login-credentials', credentials) as Promise< + DesktopLoginCredentials + >, + clearLoginCredentials: () => + ipcRenderer.invoke('desktop:clear-login-credentials') as Promise, setAppSetting: (key: keyof DesktopAppSettings, value: boolean) => ipcRenderer.invoke('desktop:set-app-setting', key, value) as Promise, syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) => diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index e35b50a..dcc7fa7 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -17,6 +17,11 @@ declare global { keepRunningInBackground: boolean } + interface DesktopLoginCredentials { + identifier: string + password: string + } + interface Window { desktopBridge: { app: { @@ -54,6 +59,11 @@ declare global { openExternalUrl(url: string): Promise openSettingsWindow(): Promise getAppSettings(): Promise + getLoginCredentials(): Promise + saveLoginCredentials( + credentials: DesktopLoginCredentials, + ): Promise + clearLoginCredentials(): Promise setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise rotateRuntimeClientToken(): Promise diff --git a/apps/desktop-client/src/renderer/views/LoginView.vue b/apps/desktop-client/src/renderer/views/LoginView.vue index 95a8c2b..7ef4211 100644 --- a/apps/desktop-client/src/renderer/views/LoginView.vue +++ b/apps/desktop-client/src/renderer/views/LoginView.vue @@ -7,15 +7,15 @@ const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession() const menuOpen = ref(false) const showServerDialog = ref(false) const showPassword = ref(false) -const rememberAccount = ref(!!localStorage.getItem('geo_rankly_saved_identifier')) -const savedIdentifier = +const legacySavedIdentifier = localStorage.getItem('geo_rankly_saved_identifier') ?? localStorage.getItem('geo_rankly_saved_email') ?? '' +const rememberCredentials = ref(!!legacySavedIdentifier) const form = reactive({ apiBaseURL: apiBaseURL.value, - identifier: savedIdentifier, + identifier: legacySavedIdentifier, password: '', }) @@ -25,8 +25,11 @@ function toggleMenu() { menuOpen.value = !menuOpen.value } -function onRememberChange(event: Event) { - rememberAccount.value = (event.target as HTMLInputElement).checked +async function onRememberChange(event: Event) { + rememberCredentials.value = (event.target as HTMLInputElement).checked + if (!rememberCredentials.value) { + await clearSavedCredentials() + } } function handleOutsideClick(event: MouseEvent) { @@ -53,22 +56,58 @@ function saveServerSettings() { } async function submitLogin() { - localStorage.removeItem('geo_rankly_saved_password') - if (rememberAccount.value) { - localStorage.setItem('geo_rankly_saved_identifier', form.identifier) - localStorage.removeItem('geo_rankly_saved_email') - } else { - localStorage.removeItem('geo_rankly_saved_identifier') - localStorage.removeItem('geo_rankly_saved_email') - } - await login({ identifier: form.identifier, password: form.password, }) + await persistCredentialsPreference() await window.desktopBridge?.app?.setWindowMode?.('main', { source: 'login' }) } +async function hydrateSavedCredentials() { + try { + const saved = await window.desktopBridge?.app?.getLoginCredentials?.() + if (!saved) return + form.identifier = saved.identifier + form.password = saved.password + rememberCredentials.value = true + } catch (err) { + console.warn('[login-view] load saved credentials failed', err) + } +} + +async function clearSavedCredentials() { + localStorage.removeItem('geo_rankly_saved_identifier') + localStorage.removeItem('geo_rankly_saved_email') + localStorage.removeItem('geo_rankly_saved_password') + try { + await window.desktopBridge?.app?.clearLoginCredentials?.() + } catch (err) { + console.warn('[login-view] clear saved credentials failed', err) + } +} + +async function persistCredentialsPreference() { + if (!rememberCredentials.value) { + await clearSavedCredentials() + return + } + + const identifier = form.identifier.trim() + localStorage.setItem('geo_rankly_saved_identifier', identifier) + localStorage.removeItem('geo_rankly_saved_email') + localStorage.removeItem('geo_rankly_saved_password') + + try { + await window.desktopBridge?.app?.saveLoginCredentials?.({ + identifier, + password: form.password, + }) + } catch (err) { + console.warn('[login-view] save credentials failed', err) + } +} + const lockCountdown = ref(0) let countdownTimer: ReturnType | null = null @@ -103,6 +142,7 @@ const displayError = computed(() => { onMounted(() => { document.addEventListener('mousedown', handleOutsideClick) + void hydrateSavedCredentials() }) onBeforeUnmount(() => { @@ -178,8 +218,8 @@ onBeforeUnmount(() => {