fix(desktop,credentials,ui-settings-account): configure embedded deployment and disable label selection

This commit is contained in:
lsdsjy committed 2026-09-20 11:31:40 +08:00
1 parent 3402c95291
commit b73a2c8d2d
15 files changed
+53 -13

No files matched your search

+4 -1
View File
@@ -38,6 +38,7 @@ export class DesktopPlatformView {
/** @param next - private Host credential snapshot; replacement invalidates the current document. */
setSession(next: PlatformSession | null): void {
if (next?.token === this.account?.token && next?.origin === this.account?.origin
&& next?.embeddedPageDist === this.account?.embeddedPageDist
&& JSON.stringify(next?.requestHeaders) === JSON.stringify(this.account?.requestHeaders)) return
this.close()
this.account = next
@@ -103,7 +104,9 @@ export class DesktopPlatformView {
owner.contentView.addChildView(view)
view.setBounds(bounds)
try {
await view.webContents.loadURL(new URL(page === 'usage' ? '/usage' : '/top_up', account.origin).href)
const url = new URL(page === 'usage' ? '/usage' : '/top_up', account.origin)
if (account.embeddedPageDist) url.searchParams.set('dist', account.embeddedPageDist)
await view.webContents.loadURL(url.href)
if (generation === this.generation && this.view === view) view.setVisible(true)
} catch (error) {
if (generation === this.generation) this.close()
+2 -2
View File
@@ -184,12 +184,12 @@ describe('desktop host process', () => {
})
it('carries Platform credentials over private IPC and clears them on shutdown', async () => {
const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", "process.send({ type: 'platform-session', session: { origin: 'https://platform.deepseek.com', token: 'fixture-secret' } }); process.send({ type: 'ready'"))
const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", "process.send({ type: 'platform-session', session: { origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test' } }); process.send({ type: 'ready'"))
const changed = vi.fn()
const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, undefined, undefined, 'link', undefined, changed)
hosts.push(host)
await host.start()
expect(changed).toHaveBeenCalledWith({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' })
expect(changed).toHaveBeenCalledWith({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test' })
await host.stop()
expect(changed).toHaveBeenLastCalledWith(null)
})
+14
View File
@@ -155,3 +155,17 @@ it('injects deployment headers only at the Platform origin and excludes them fro
.toEqual({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' })
manager.close()
})
it.each(['usage', 'top-up'] as const)('selects the configured frontend deployment for %s', async (page) => {
const { manager, owner } = setup()
manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test&other=value' })
await manager.open(owner, page, bounds)
const url = new URL(view().webContents.loadURL.mock.calls[0]![0] as string)
expect(url.origin).toBe('https://platform.deepseek.com')
expect(url.pathname).toBe(page === 'usage' ? '/usage' : '/top_up')
expect([...url.searchParams]).toEqual([['dist', 'feat/test&other=value']])
const previous = view().webContents
manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'another' })
expect(previous.close).toHaveBeenCalledOnce()
manager.close()
})
+2
View File
@@ -612,6 +612,8 @@ Requires: `credentials` · `authorization`
export interface Config {
/** Platform origin serving auth-api and browser pages. */
platformOrigin?: string
/** Optional frontend deployment selector for embedded Usage and Top-up pages. */
embeddedPageDist?: string
/** Allow HTTP only on loopback for the development Mock. */
allowLoopbackHttp?: boolean
/** Map authorization and completion pages to platformOrigin for private development proxies. */
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md
README.md: 496a130dcc27425dc1669eb1fe3ff04d781c3e7d
README.zh.md: c8639f3df3f4029e2421acdc279588eb840afb1d
README.md: 02e1054cd5d3c1b77f49b86682dd6bfaff5708b0
README.zh.md: dbc20df87be97362682ae4445b91cab1b817d0ea
@@ -38,7 +38,7 @@ The Account section appears first in Settings only while signed in; it is hidden
The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. Browser login preopens a tab on the click and navigates it only when that attempt receives its authorization URL; blocked popups retain the copy-link route. The original tab retains the authorization window handle and closes it on failure, timeout, or cancellation. Login feedback stays in the original tab; callbacks never navigate to a second Web UI.
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. Browser login preopens a tab on the click and navigates it only when that attempt receives its authorization URL; blocked popups retain the copy-link route. The original tab retains the authorization window handle and closes it on failure, timeout, or cancellation. Login feedback stays in the original tab; callbacks never navigate to a second Web UI.
<a id="model-experience"></a>
## Model Experience
@@ -38,7 +38,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮仍可使用。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
<a id="model-experience"></a>
## 模型体验
@@ -1,6 +1,6 @@
.root { flex: 1; min-width: 0; }
.anchor { width: 100%; }
.trigger { display: flex; align-items: center; width: 100%; gap: 8px; padding: 6px; border: 0; border-radius: 12px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; cursor: pointer; }
.trigger { user-select: none; display: flex; align-items: center; width: 100%; gap: 8px; padding: 6px; border: 0; border-radius: 12px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; cursor: pointer; }
.trigger:hover { background: var(--dsw-alias-interactive-bg-hover); }
.avatar { display: flex; align-items: center; justify-content: center; flex: none; width: 32px; height: 32px; border-radius: 16px; background: var(--dsw-alias-bg-skeleton); color: var(--dsw-alias-label-tertiary); }
.label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1,4 +1,4 @@
.dialog { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-inverted); }
.dialog { user-select: none; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-inverted); }
.content { display: flex; flex-direction: column; width: 100%; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 22px 24px 12px; }
.title { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary); }
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md
README.md: 522a98cd359f677409090823a5465be88f1fac37
README.zh.md: b33c64404b4477214762eabccf616102a0ff84d8
README.md: 67824c36dd4dd42405e31d5ac57d4b5c2c795458
README.zh.md: da999fb2ee6fd9a70955ec2b35f5c646cf67148c
@@ -82,3 +82,5 @@ The [desktop login decision](../../../.agents/notes/implemented/architecture/202
The first profile read after sign-in uses the sanitized user returned by auth_exchange. A null or malformed user falls back to current; subsequent refreshes and Host restarts also query current. Exchange registers the submitted device information.
accountRequestHeaders overlays requestHeaders for current, balance and embedded Platform page/API requests. Cookie pairs merge by name so routing overrides retain other deployment cookies. Authorization initialization, exchange, cancellation and logout keep requestHeaders. Both maps remain private to Host and Electron main; renderer bootstrap receives only origin and token.
The account provider’s `embeddedPageDist` configuration adds a `dist` query parameter to embedded Usage and Top-up URLs. Its default is empty; private frontend branch selectors belong in the local profile patch. It does not change API URLs or credential delivery.
@@ -82,3 +82,5 @@ Host 诊断通过标准输出及 Host 调试器 Console 输出,前缀为 `[dee
登录后首次读取资料使用 auth_exchange 返回并经筛选的 user。user 为 null 或格式无效时回退到 current;后续刷新及 Host 重启也查询 current。exchange 负责登记提交的设备信息。
accountRequestHeaders 覆盖 requestHeaders,供 current、余额以及内嵌 Platform 页面/API 请求使用。Cookie 按名称合并,路由覆盖保留其他部署 Cookie。授权初始化、兑换、取消和退登保持使用 requestHeaders。两组请求头仅供 Host 和 Electron 主进程使用;渲染层 bootstrap 仅接收 origin 和 token。
账号提供者的 `embeddedPageDist` 配置为内嵌用量和充值页面 URL 添加 `dist` 查询参数。默认值为空;私有前端分支选择值应写在本地 profile patch 中。此配置不改变 API 地址或凭证传递方式。
@@ -23,6 +23,8 @@ const device = z.object({ id: z.uuid() })
export interface Config {
/** Platform origin serving auth-api and browser pages. */
platformOrigin?: string
/** Optional frontend deployment selector for embedded Usage and Top-up pages. */
embeddedPageDist?: string
/** Allow HTTP only on loopback for the development Mock. */
allowLoopbackHttp?: boolean
/** Map authorization and completion pages to platformOrigin for private development proxies. */
@@ -43,6 +45,7 @@ export interface Config {
/** Validated deployment choices. */
export const Config = Schema.object({
platformOrigin: Schema.string().default('https://platform.deepseek.com'),
embeddedPageDist: Schema.string().default(''),
allowLoopbackHttp: Schema.boolean().default(false),
rewriteBrowserOrigin: Schema.boolean().default(false),
requestHeaders: Schema.dict(Schema.string().role('secret')).default({}),
@@ -72,6 +75,7 @@ export class PlatformAccount extends DeepSeekAccount {
static inject = ['credentials', 'authorization']
static Config = Config
private readonly origin: string
private readonly embeddedPageDist: string
private readonly rewriteBrowserOrigin: boolean
private readonly requestHeaders: Record<string, string>
private readonly accountRequestHeaders: Record<string, string>
@@ -90,6 +94,7 @@ export class PlatformAccount extends DeepSeekAccount {
constructor(ctx: Context, config: Config = {}) {
super(ctx)
const resolved = Config(config)
this.embeddedPageDist = resolved.embeddedPageDist
this.origin = platformOrigin(resolved.platformOrigin, resolved.allowLoopbackHttp)
this.rewriteBrowserOrigin = resolved.rewriteBrowserOrigin
this.requestHeaders = platformHeaders(resolved.requestHeaders)
@@ -177,6 +182,7 @@ export class PlatformAccount extends DeepSeekAccount {
if (!parsed.success) throw new PlatformAuthError('storage')
if (parsed.data.issuer !== this.origin) throw new PlatformAuthError('protocol')
return { origin: this.origin, token: parsed.data.token,
...(this.embeddedPageDist ? { embeddedPageDist: this.embeddedPageDist } : {}),
...(Object.keys(this.accountRequestHeaders).length ? { requestHeaders: { ...this.accountRequestHeaders } } : {}) }
}
@@ -18,7 +18,7 @@ afterEach(async () => { while (cleanups.length) await cleanups.pop()!() })
async function fixture(contact: { email: string; mobile?: string; mobile_number?: string } = {
email: 't***@example.invalid', mobile: '138****5678',
}, requestHeaders: Record<string, string> = {}, rewriteBrowserOrigin = false, accountRequestHeaders: Record<string, string> = {}) {
}, requestHeaders: Record<string, string> = {}, rewriteBrowserOrigin = false, accountRequestHeaders: Record<string, string> = {}, embeddedPageDist = '') {
const home = await mkdtemp(join(tmpdir(), 'dsh-account-'))
cleanups.push(() => rm(home, { recursive: true, force: true }))
let init: Record<string, string> = {}
@@ -116,7 +116,8 @@ async function fixture(contact: { email: string; mobile?: string; mobile_number?
const authorization = ctx.plugin(AuthorizationService)
await authorization
const provider = ctx.plugin(PlatformAccount, {
platformOrigin: origin, allowLoopbackHttp: true, requestHeaders, accountRequestHeaders, rewriteBrowserOrigin, logoutRetryDelayMs: 1,
platformOrigin: origin, embeddedPageDist, allowLoopbackHttp: true, requestHeaders, accountRequestHeaders,
rewriteBrowserOrigin, logoutRetryDelayMs: 1,
})
await provider
cleanups.push(async () => { await provider.dispose(); await authorization.dispose(); await credentials.dispose(); await web.dispose() })
@@ -611,3 +612,11 @@ it('overlays account cookies without changing authorization or logout routing',
}
expect(JSON.stringify(await f.account.getState())).not.toContain('private')
})
it('carries the configured embedded frontend selector in the private Platform session', async () => {
const f = await fixture(undefined, {}, false, {}, 'feat/test')
await f.account.startSignIn('en', f.callbackOrigin, 'desktop')
await f.wait('waiting-browser')
await fetch(f.callback(), { redirect: 'manual' })
expect(await f.account.getPlatformSession()).toEqual({ origin: f.origin, token: 'dsh_mock_test', embeddedPageDist: 'feat/test' })
})
@@ -13,6 +13,8 @@ declare module '@deepseek-ai/cordis' {
export interface PlatformSession {
readonly origin: string
readonly token: string
/** Optional dist query value selecting the embedded frontend deployment. */
readonly embeddedPageDist?: string
/** Private deployment headers for native requests; excluded from renderer bootstrap. */
readonly requestHeaders?: Readonly<Record<string, string>>
}