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
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:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user