feat(desktop): remember login credentials
Desktop Client Build / Resolve Build Metadata (push) Successful in 49s
Frontend CI / Frontend (push) Successful in 5m47s
Desktop Client Build / Build Desktop Client (push) Successful in 42m28s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 2m38s

This commit is contained in:
2026-05-14 21:49:27 +08:00
parent 639516f374
commit 5bbbbc5cf1
5 changed files with 181 additions and 16 deletions
+15
View File
@@ -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) => {
@@ -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
}
+13
View File
@@ -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<null>,
getAppSettings: () =>
ipcRenderer.invoke('desktop:get-app-settings') as Promise<DesktopAppSettings>,
getLoginCredentials: () =>
ipcRenderer.invoke('desktop:get-login-credentials') as Promise<DesktopLoginCredentials | null>,
saveLoginCredentials: (credentials: DesktopLoginCredentials) =>
ipcRenderer.invoke('desktop:save-login-credentials', credentials) as Promise<
DesktopLoginCredentials
>,
clearLoginCredentials: () =>
ipcRenderer.invoke('desktop:clear-login-credentials') as Promise<null>,
setAppSetting: (key: keyof DesktopAppSettings, value: boolean) =>
ipcRenderer.invoke('desktop:set-app-setting', key, value) as Promise<DesktopAppSettings>,
syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) =>
+10
View File
@@ -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<null>
openSettingsWindow(): Promise<null>
getAppSettings(): Promise<DesktopAppSettings>
getLoginCredentials(): Promise<DesktopLoginCredentials | null>
saveLoginCredentials(
credentials: DesktopLoginCredentials,
): Promise<DesktopLoginCredentials>
clearLoginCredentials(): Promise<null>
setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise<DesktopAppSettings>
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>
rotateRuntimeClientToken(): Promise<DesktopClientRotateResponse>
@@ -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<typeof setInterval> | null = null
@@ -103,6 +142,7 @@ const displayError = computed(() => {
onMounted(() => {
document.addEventListener('mousedown', handleOutsideClick)
void hydrateSavedCredentials()
})
onBeforeUnmount(() => {
@@ -178,8 +218,8 @@ onBeforeUnmount(() => {
<div class="options-row">
<label class="check-label">
<input type="checkbox" :checked="rememberAccount" @change="onRememberChange" />
<span>记住账号</span>
<input type="checkbox" :checked="rememberCredentials" @change="onRememberChange" />
<span>记住账号和密码</span>
</label>
</div>