fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
598 lines
20 KiB
TypeScript
598 lines
20 KiB
TypeScript
/**
|
||
* user-account-ipc.ts — 用户账号会话的 IPC 边界(§21 DB-T1/DB-T2,U-1/U-3/U-5)。
|
||
*
|
||
* token 只活在 main 进程(内存 + safeStorage 落盘),renderer 永远拿不到
|
||
* access/refresh token——login 之后 renderer 只能拿到 profile 与状态。
|
||
*
|
||
* 401 反应式刷新:任何带令牌调用 401 → 用 refresh token 续一次重试;
|
||
* refresh 也被拒(session_inactive / refresh_reuse_or_invalid)= 服务端判定的
|
||
* 硬终点(U-3):**不清本地会话**,只上报 sessionExpired——在线时 renderer 照旧
|
||
* 落登录页,离线时本地会话仍放行(离线优先,2026-09-24 用户裁定)。真正的清零
|
||
* 只发生在 logout / unbind。
|
||
*
|
||
* 离线登录(同一裁定):断网时 login 回落到本地校验记住的凭据,把已在盘上的
|
||
* 会话解锁回登录态。它只是"别砖在门口"的临时通路,不是第二套运行模式——
|
||
* 不新建会话,也不放行登录以外的任何操作。
|
||
*
|
||
* U-5 机器-用户锁:登录成功当场校验本机 installation ∈ 该用户的列表;
|
||
* 不符立即销毁会话并返回 machine_bound_to_other(附绑定者邮箱,便于
|
||
* 认领/联系解绑——2026-09-19 用户裁定容许显示)。绑定状态文件损坏
|
||
* fail-closed:锁无法判定时不许登录。
|
||
*
|
||
* 登出≠解绑:logout 只清用户会话槽,机器绑定(H/R)完全不经过这里。
|
||
*
|
||
* DB-T6 自动绑定(2026-09-19 裁定):登录成功 / 恢复会话后若本机未绑定,
|
||
* main 自动发起一次免口令绑定,无需用户点击。结果(pending 确认码 / 失败
|
||
* 码)留在 main 内存,binding-status 随时回查;绑定失败绝不阻塞登录。
|
||
*/
|
||
|
||
import { loadMachineBinding, MachineBindingError } from './machine-binding'
|
||
import {
|
||
canonicalizeRelaySite,
|
||
type FetchLike,
|
||
relayAccountBindingOwner,
|
||
relayAccountDeleteInstallation,
|
||
RelayAccountError,
|
||
relayAccountFetchMe,
|
||
relayAccountListInstallationIds,
|
||
relayAccountLogin,
|
||
relayAccountLogout,
|
||
relayAccountRefresh,
|
||
relayAccountRegisterResend,
|
||
relayAccountRegisterStart,
|
||
relayAccountResetConfirm,
|
||
relayAccountResetRequest,
|
||
type RelayAccountSession,
|
||
relayAccountUpdateProfile
|
||
} from './relay-account'
|
||
import {
|
||
loadRememberedLogin,
|
||
loadUserAccount,
|
||
persistRememberedLogin,
|
||
persistUserAccount,
|
||
type StoredUserAccount,
|
||
type UserAccountStoreIo
|
||
} from './user-account-store'
|
||
|
||
export const USER_ACCOUNT_CLIENT_ID = 'hermes-desktop'
|
||
|
||
/** 插件管理面(/api/plugins/mercury-relay/*)失败的结构化形态。 */
|
||
export class PluginApiError extends Error {
|
||
readonly code: string
|
||
readonly status: number
|
||
|
||
constructor(code: string, status: number) {
|
||
super(code)
|
||
this.code = code
|
||
this.status = status
|
||
}
|
||
}
|
||
|
||
export interface UserAccountIpcDeps {
|
||
ipcMain: { handle: (channel: string, fn: (event: any, payload?: any) => Promise<any>) => void }
|
||
io: UserAccountStoreIo
|
||
fetcher: FetchLike
|
||
/** 插件 state.json 原文;ENOENT 抛错视为未绑定。 */
|
||
readMachineBindingState: () => string
|
||
/**
|
||
* 调本机 gateway 的插件管理 API(路径如 'identity/login')。
|
||
* 非 2xx 必须抛 PluginApiError(detail 即 code);gateway 不可达抛
|
||
* PluginApiError('plugin_unreachable', 0)。method 默认 POST;GET 时
|
||
* body 被忽略(如 'status' 查机器码)。
|
||
*/
|
||
callPluginApi: (path: string, body: Record<string, unknown>, method?: 'GET' | 'POST') => Promise<any>
|
||
/** 绑定时的 suggested_name(本机显示名)。 */
|
||
machineName: string
|
||
}
|
||
|
||
/** 预期内的失败走结构化返回(renderer 按 code 上文案);意外仍抛。 */
|
||
function failure(error: unknown): { ok: false; code: string } {
|
||
if (error instanceof RelayAccountError || error instanceof PluginApiError) {
|
||
return { ok: false, code: error.code }
|
||
}
|
||
|
||
throw error
|
||
}
|
||
|
||
export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
|
||
const { ipcMain, io, fetcher, readMachineBindingState, callPluginApi, machineName } = deps
|
||
let cached: StoredUserAccount | null = null
|
||
/**
|
||
* 最近一次绑定尝试的结果(DB-T6 自动绑定):pending 确认码 / 失败码,
|
||
* 供 binding-status 随时回查。只在 main 内存里,随进程退出消失
|
||
* (pending 码本就 10 分钟 TTL,重启后重试即可)。
|
||
*/
|
||
let lastBind: { pending?: { code: string; expiresAt: string }; error?: string } | null = null
|
||
let autoBindAttempted = false
|
||
|
||
function current(): StoredUserAccount | null {
|
||
if (cached) {return cached}
|
||
cached = loadUserAccount(io)
|
||
|
||
return cached
|
||
}
|
||
|
||
function store(account: StoredUserAccount | null): void {
|
||
cached = account
|
||
persistUserAccount(account, io)
|
||
}
|
||
|
||
/**
|
||
* 401 → refresh 一次重试;refresh 也被拒 = 服务端判定的硬终点,往上报
|
||
* session_expired,但**不清本地会话**——离线时它仍是放行的凭证(2026-09-24
|
||
* 用户裁定"离线优先")。真正的清零只发生在 logout / unbind。
|
||
*/
|
||
async function withSession<T>(fn: (session: RelayAccountSession) => Promise<T>): Promise<T> {
|
||
const account = current()
|
||
|
||
if (!account) {throw new RelayAccountError('not_logged_in', 0)}
|
||
|
||
try {
|
||
return await fn(account.session)
|
||
} catch (error) {
|
||
if (!(error instanceof RelayAccountError) || error.status !== 401) {throw error}
|
||
}
|
||
|
||
let refreshed: RelayAccountSession
|
||
|
||
try {
|
||
refreshed = await relayAccountRefresh(account.session, fetcher)
|
||
} catch (error) {
|
||
if (error instanceof RelayAccountError && error.status === 401) {
|
||
throw new RelayAccountError('session_expired', 401)
|
||
}
|
||
|
||
throw error
|
||
}
|
||
|
||
store({ ...account, session: refreshed })
|
||
|
||
return fn(refreshed)
|
||
}
|
||
|
||
/**
|
||
* U-5:登录成功后当场校验。返回 null 放行;否则结构化失败(会话已销毁)。
|
||
* 绑定状态损坏 = 锁无法判定,fail-closed 拒绝登录(同样销毁新会话)。
|
||
*/
|
||
async function enforceMachineLock(session: RelayAccountSession): Promise<Record<string, unknown> | null> {
|
||
let binding
|
||
|
||
try {
|
||
binding = loadMachineBinding(readMachineBindingState)
|
||
} catch (error) {
|
||
if (error instanceof MachineBindingError) {
|
||
await relayAccountLogout(session, fetcher)
|
||
|
||
return { ok: false, code: 'binding_state_invalid' }
|
||
}
|
||
|
||
throw error
|
||
}
|
||
|
||
if (!binding) {return null}
|
||
|
||
if (binding.site === session.site) {
|
||
const owned = await relayAccountListInstallationIds(session.site, session.accessToken, fetcher)
|
||
|
||
if (owned.includes(binding.installationId)) {return null}
|
||
}
|
||
|
||
// 他账号/他站:当场销毁会话(U-5),再尽力取绑定者邮箱供锁态提示。
|
||
const binderEmail = await relayAccountBindingOwner(session.site, session.accessToken, binding.installationId, fetcher).catch(
|
||
() => null
|
||
)
|
||
|
||
await relayAccountLogout(session, fetcher)
|
||
|
||
return { ok: false, code: 'machine_bound_to_other', binderEmail }
|
||
}
|
||
|
||
/**
|
||
* 断网回落:本地校验记住的凭据,命中即放行(离线优先,2026-09-24 用户裁定)。
|
||
* 前提是"这台机器上登录过这个账号"——记住槽只在登录成功时写、只在校验失败时
|
||
* 清(unbind 全清),所以这里放行的账号,其会话与锁定在创建时都过了服务端。
|
||
*
|
||
* 不新增暴露面:口令本来就以 safeStorage 加密存在本地供表单预填,这里只是把
|
||
* 它当凭据用。能离线做的 U-5 复核照做(绑定状态损坏 fail-closed、绑定的站
|
||
* 不符即拒);安装归属要联网查,离线判不了。
|
||
*/
|
||
async function offlineLogin(site: string, email: string, password: string) {
|
||
// 没有会话就没有可解锁的东西:离线登入只把盘上已有的会话放回来,不新建。
|
||
const account = current()
|
||
|
||
if (!account) {return { ok: false, code: 'network' }}
|
||
|
||
let binding
|
||
|
||
try {
|
||
binding = loadMachineBinding(readMachineBindingState)
|
||
} catch (error) {
|
||
if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }}
|
||
|
||
throw error
|
||
}
|
||
|
||
if (binding && binding.site !== site) {
|
||
return { ok: false, code: 'machine_bound_to_other', binderEmail: null }
|
||
}
|
||
|
||
const remembered = loadRememberedLogin(io)
|
||
|
||
// 本地没有这个账号的凭据:离线无解,维持 network 文案等用户联网。
|
||
if (!remembered || remembered.site !== site || remembered.email !== email) {
|
||
return { ok: false, code: 'network' }
|
||
}
|
||
|
||
if (remembered.password !== password) {
|
||
return { ok: false, code: 'invalid_credentials' }
|
||
}
|
||
|
||
// 会话已在盘上(含被服务端判过期的):用缓存 profile 恢复登录态,不落盘。
|
||
return { ok: true, site, profile: account.profile, offline: true }
|
||
}
|
||
|
||
ipcMain.handle('hermes:account:login', async (_event, payload) => {
|
||
let site: string
|
||
|
||
try {
|
||
site = canonicalizeRelaySite(String(payload?.site ?? ''))
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
|
||
const email = String(payload?.email ?? '').trim()
|
||
const password = String(payload?.password ?? '')
|
||
|
||
if (!email || !password) {
|
||
return { ok: false, code: 'invalid_login_request' }
|
||
}
|
||
|
||
try {
|
||
const session = await relayAccountLogin(site, email, password, USER_ACCOUNT_CLIENT_ID, fetcher)
|
||
const locked = await enforceMachineLock(session)
|
||
|
||
if (locked) {return locked}
|
||
|
||
const profile = await relayAccountFetchMe(site, session.accessToken, fetcher)
|
||
store({ session, profile, profileAsOf: new Date().toISOString() })
|
||
// 记住凭据:登录成功即刷新 remembered 槽(登出不清,下次启动预填)。
|
||
persistRememberedLogin({ site, email, password }, io)
|
||
// DB-T6:登录成功即自动绑定本机(未绑定时);失败不阻塞登录。
|
||
await autoBind()
|
||
|
||
return { ok: true, site, profile }
|
||
} catch (error) {
|
||
if (error instanceof RelayAccountError && error.code === 'network') {
|
||
return offlineLogin(site, email, password)
|
||
}
|
||
|
||
return failure(error)
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:status', async () => {
|
||
const account = current()
|
||
|
||
if (!account) {return { loggedIn: false }}
|
||
|
||
try {
|
||
const profile = await withSession(session => relayAccountFetchMe(session.site, session.accessToken, fetcher))
|
||
store({ ...current()!, profile, profileAsOf: new Date().toISOString() })
|
||
|
||
// DB-T6:恢复出的会话若尚未尝试过自动绑定(如进程重启后),补一次。
|
||
if (!autoBindAttempted) {await autoBind()}
|
||
|
||
return { loggedIn: true, site: account.session.site, offline: false, profile }
|
||
} catch (error) {
|
||
if (error instanceof RelayAccountError && error.code === 'session_expired') {
|
||
return { loggedIn: false, sessionExpired: true }
|
||
}
|
||
|
||
if (error instanceof RelayAccountError && error.code === 'network') {
|
||
// U-3:relay 不可达但有本地会话 → 标离线进入,展示缓存数据 + 数据时间。
|
||
return {
|
||
loggedIn: true,
|
||
site: account.session.site,
|
||
offline: true,
|
||
profile: account.profile,
|
||
profileAsOf: account.profileAsOf
|
||
}
|
||
}
|
||
|
||
throw error
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:me', async () => {
|
||
const profile = await withSession(session => relayAccountFetchMe(session.site, session.accessToken, fetcher))
|
||
store({ ...current()!, profile, profileAsOf: new Date().toISOString() })
|
||
|
||
return { profile }
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:update-profile', async (_event, payload) => {
|
||
// 只放行服务端 PROFILE_FIELDS 同口径三字段;null = 清空。其余键(含
|
||
// email)一律丢弃,绝不转发——renderer 按码上文案,不需要 email_not_editable。
|
||
const patch: Record<string, string | null> = {}
|
||
|
||
for (const field of ['username', 'nickname', 'phone'] as const) {
|
||
const value = payload?.[field]
|
||
|
||
if (value === null) {patch[field] = null}
|
||
|
||
if (typeof value === 'string') {patch[field] = value}
|
||
}
|
||
|
||
if (Object.keys(patch).length === 0) {
|
||
return { ok: false, code: 'nothing_to_update' }
|
||
}
|
||
|
||
try {
|
||
const updated = await withSession(session => relayAccountUpdateProfile(session.site, session.accessToken, patch, fetcher))
|
||
const account = current()!
|
||
const profile = { ...account.profile, ...updated }
|
||
|
||
store({ ...account, profile, profileAsOf: new Date().toISOString() })
|
||
|
||
return { ok: true, profile }
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:binding-status', async () => {
|
||
const machineId = await queryMachineId()
|
||
let binding
|
||
|
||
try {
|
||
binding = loadMachineBinding(readMachineBindingState)
|
||
} catch (error) {
|
||
if (error instanceof MachineBindingError) {
|
||
return { bound: false, stateInvalid: true, machineId, pending: null, bindError: lastBind?.error ?? null }
|
||
}
|
||
|
||
throw error
|
||
}
|
||
|
||
if (!binding) {
|
||
return { bound: false, machineId, pending: lastBind?.pending ?? null, bindError: lastBind?.error ?? null }
|
||
}
|
||
|
||
return { bound: true, site: binding.site, installationId: binding.installationId, machineId }
|
||
})
|
||
|
||
/**
|
||
* U-1/RL-A1 免口令绑定主体:用当前用户会话 access token 让本机插件完成
|
||
* 绑定(口令永远不再经手)。插件 401(会话失效)并入 withSession 的
|
||
* 刷新重试链。binding_pending(双因素)原样透传给 UI。
|
||
*/
|
||
async function performBind(): Promise<
|
||
| { ok: true; state: 'bound' }
|
||
| { ok: true; state: 'binding_pending'; code: string; expiresAt: string }
|
||
| { ok: false; code: string }
|
||
> {
|
||
let existing
|
||
|
||
try {
|
||
existing = loadMachineBinding(readMachineBindingState)
|
||
} catch (error) {
|
||
if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }}
|
||
|
||
throw error
|
||
}
|
||
|
||
if (existing) {return { ok: false, code: 'already_bound' }}
|
||
|
||
try {
|
||
const result = await withSession(async session => {
|
||
try {
|
||
return await callPluginApi('identity/login', {
|
||
site: session.site,
|
||
session_token: session.accessToken,
|
||
suggested_name: machineName
|
||
})
|
||
} catch (error) {
|
||
if (error instanceof PluginApiError && error.status === 401) {
|
||
throw new RelayAccountError(error.code, 401)
|
||
}
|
||
|
||
throw error
|
||
}
|
||
})
|
||
|
||
if (result?.state === 'binding_pending') {
|
||
return {
|
||
ok: true,
|
||
state: 'binding_pending',
|
||
code: typeof result.code === 'string' ? result.code : '',
|
||
expiresAt: typeof result.expires_at === 'string' ? result.expires_at : ''
|
||
}
|
||
}
|
||
|
||
if (result?.state !== 'bound') {return { ok: false, code: 'server' }}
|
||
|
||
return { ok: true, state: 'bound' }
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
}
|
||
|
||
function recordBind(result: Awaited<ReturnType<typeof performBind>>): void {
|
||
if (result.ok === false) {
|
||
lastBind = result.code === 'already_bound' ? null : { error: result.code }
|
||
|
||
return
|
||
}
|
||
|
||
if (result.state === 'bound') {
|
||
lastBind = null
|
||
|
||
return
|
||
}
|
||
|
||
lastBind = { pending: { code: result.code, expiresAt: result.expiresAt } }
|
||
}
|
||
|
||
/**
|
||
* DB-T6 自动绑定:登录成功 / 恢复会话后若本机未绑定则自动发起一次。
|
||
* 绝不阻塞或逆转登录——任何意外都只落成 lastBind.error 供界面展示。
|
||
*/
|
||
async function autoBind(): Promise<void> {
|
||
autoBindAttempted = true
|
||
|
||
try {
|
||
recordBind(await performBind())
|
||
} catch {
|
||
lastBind = { error: 'server' }
|
||
}
|
||
}
|
||
|
||
/** 机器码(relay allowlist 展示口径):插件不可达时回 null,不影响状态查询。 */
|
||
async function queryMachineId(): Promise<string | null> {
|
||
try {
|
||
const status = await callPluginApi('status', {}, 'GET')
|
||
|
||
return typeof status?.relay_machine_id === 'string' ? status.relay_machine_id : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
ipcMain.handle('hermes:account:bind-machine', async () => {
|
||
try {
|
||
const result = await performBind()
|
||
recordBind(result)
|
||
|
||
return result
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* U-6 解绑 = 全清:服务端撤销 installation(尽力,404 幂等;会话死/
|
||
* 网络断不阻塞)→ 插件 purge(清绑定含 R + 抹 H,机器回裸态)→
|
||
* 删除所有会话(本地用户槽清零 + 尽力服务端吊销)。插件不可达时
|
||
* 结构化失败、会话保留,用户可重试——不留下"半清"的确定态假象。
|
||
*/
|
||
ipcMain.handle('hermes:account:unbind-machine', async () => {
|
||
let binding
|
||
|
||
try {
|
||
binding = loadMachineBinding(readMachineBindingState)
|
||
} catch (error) {
|
||
if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }}
|
||
|
||
throw error
|
||
}
|
||
|
||
if (!binding) {return { ok: false, code: 'not_bound' }}
|
||
|
||
try {
|
||
await withSession(session => relayAccountDeleteInstallation(session.site, session.accessToken, binding.installationId, fetcher))
|
||
} catch {
|
||
// 尽力撤销:本地清零照旧
|
||
}
|
||
|
||
try {
|
||
await callPluginApi('identity/purge', {})
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
|
||
const account = current()
|
||
|
||
if (account) {
|
||
await relayAccountLogout(account.session, fetcher)
|
||
store(null)
|
||
}
|
||
|
||
// U-6 全清含记住的凭据——机器回裸态后登录页不再预填本机旧账号。
|
||
persistRememberedLogin(null, io)
|
||
|
||
lastBind = null
|
||
autoBindAttempted = false
|
||
|
||
return { ok: true }
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:logout', async () => {
|
||
const account = current()
|
||
|
||
if (account) {
|
||
await relayAccountLogout(account.session, fetcher)
|
||
store(null)
|
||
}
|
||
|
||
lastBind = null
|
||
autoBindAttempted = false
|
||
|
||
return { ok: true }
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:register-start', async (_event, payload) => {
|
||
try {
|
||
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
|
||
await relayAccountRegisterStart(site, String(payload?.email ?? '').trim(), String(payload?.password ?? ''), fetcher)
|
||
|
||
return { ok: true }
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:register-resend', async (_event, payload) => {
|
||
try {
|
||
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
|
||
await relayAccountRegisterResend(site, String(payload?.email ?? '').trim(), fetcher)
|
||
|
||
return { ok: true }
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:reset-request', async (_event, payload) => {
|
||
try {
|
||
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
|
||
await relayAccountResetRequest(site, String(payload?.email ?? '').trim(), fetcher)
|
||
|
||
return { ok: true }
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:account:reset-confirm', async (_event, payload) => {
|
||
try {
|
||
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
|
||
const email = String(payload?.email ?? '').trim()
|
||
await relayAccountResetConfirm(
|
||
site,
|
||
email,
|
||
String(payload?.code ?? '').trim(),
|
||
String(payload?.password ?? ''),
|
||
fetcher
|
||
)
|
||
// A successful reset invalidates the last successful login's password.
|
||
const remembered = loadRememberedLogin(io)
|
||
|
||
if (remembered?.site === site && remembered.email.toLowerCase() === email.toLowerCase()) {
|
||
persistRememberedLogin(null, io)
|
||
}
|
||
|
||
return { ok: true }
|
||
} catch (error) {
|
||
return failure(error)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 登录表单预填:返回最近一次成功登录记住的 {site, email, password}。
|
||
* login 成功写入;reset 成功及 unbind 清除旧凭据——renderer 无写口。
|
||
*/
|
||
ipcMain.handle('hermes:account:remembered:load', async () => {
|
||
return loadRememberedLogin(io)
|
||
})
|
||
}
|