fix(settings-account): 撤权时丢弃迟到的帧并清空登录状态
This commit is contained in:
1 parent
a07e889dc1
commit
31ebfa7aa9
8 files changed
+141
-14
No files matched your search
@@ -15,8 +15,8 @@
|
||||
en: c168de20989cf9a4
|
||||
zh: bf3b0361beea083d
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration:
|
||||
en: 6f30d13790e8792f
|
||||
zh: fffeaac334b10e6a
|
||||
en: 73577ed282c656f3
|
||||
zh: 2f60a6824218e693
|
||||
/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
|
||||
en: ddc432b693608163
|
||||
zh: 620294d8812b0a31
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface WhenAccess {
|
||||
| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` |
|
||||
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` |
|
||||
| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`; the stream's disposer also ends this generation's bonus notices, which a frame of that stream begins, and the notice controller stays outside and begins again under the next grant | `account/watch` |
|
||||
| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`. On revocation the stream's disposer synchronously invalidates the account reads this grant started, closes the pending authorization tab, clears the account view, details, picture, and sign-in dialog state, ends this generation's bonus notices, which a frame of that stream begins, and then closes the stream; a frame delivered before the revocation but not yet handled is dropped. The notice controller stays outside and begins again under the next grant; the account section appears once the new stream's first frame arrives | `account/watch` |
|
||||
| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` |
|
||||
| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — |
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface WhenAccess {
|
||||
| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` |
|
||||
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` |
|
||||
| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`;账号流的 disposer 同时结束本轮奖励提示(提示由该流的帧开始),提示控制器留在外面,下次授权时重新开始 | `account/watch` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`。撤权时账号流的 disposer 同步作废这一轮发出的账号读取,关闭待授权的浏览器标签页,清空账号视图、详情、头像与登录对话框状态,结束本轮奖励提示(提示由该流的帧开始),再关闭流;撤权前已交付但尚未处理的帧被丢弃。提示控制器留在外面,下次授权时重新开始;账号分区等新流的第一帧到达后才出现 | `account/watch` |
|
||||
| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` |
|
||||
| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — |
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 45aa7b44d5424d9b
|
||||
zh: 20c7d344c4e9bce0
|
||||
/deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding:
|
||||
en: 3e5371e2bf7bd577
|
||||
zh: 91b1586963d9df58
|
||||
en: 7ae4e68f9491b068
|
||||
zh: f7478269bf8d28b3
|
||||
/deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation:
|
||||
en: 186b2046cfb5ef76
|
||||
zh: 57b042a618fb612e
|
||||
|
||||
@@ -63,7 +63,7 @@ The Desktop preload marker enables the introduction after account credentials ar
|
||||
|
||||
The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and re-reads profile and balance in the background after any read already running, whether loading or payment succeeded. That read does not recompute the funded-mode choice, so a completed recharge leaves the buttons as first entered while continuing still evaluates the refreshed balance. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies standard process, compact performance/usage, and disabled Coding Tools. This flow creates no workspace or demonstration task.
|
||||
|
||||
Access: the account stream, quota notice, settings launcher, account settings section, and models sign-in entry exist only while the current Peer may call `account/watch`; Desktop onboarding and the platform page are not gated. The owner always has access.
|
||||
Access: the account stream, quota notice, settings launcher, account settings section, and models sign-in entry exist only while the current Peer may call `account/watch`; Desktop onboarding and the platform page are not gated. Losing that access discards the account reads that grant started, closes a pending authorization tab, and clears the account view, details, picture, and sign-in dialog state; a later grant shows the account section again once its first frame arrives. The owner always has access.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
@@ -63,7 +63,7 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本
|
||||
|
||||
欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在等待任何已在进行的读取结束后于后台重读资料与余额,不受页面加载或支付结果影响。该读取不会重新计算该选择,因此充值成功后按钮仍保持首次进入时的状态,而继续前行仍按刷新后的余额判断。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭代码工作工具的默认值。此流程不创建工作区或示例任务。
|
||||
|
||||
访问控制:账号状态流、额度提醒、设置启动器、账号设置分区与模型登录入口只在当前 Peer 可以调用 `account/watch` 时存在;桌面引导与平台页面不受此控制。owner 始终有权限。
|
||||
访问控制:账号状态流、额度提醒、设置启动器、账号设置分区与模型登录入口只在当前 Peer 可以调用 `account/watch` 时存在;桌面引导与平台页面不受此控制。失去该权限时,这一轮发出的账号读取作废,待授权的浏览器标签页关闭,账号视图、详情、头像与登录对话框状态清空;之后再次授权时,账号分区等新流的第一帧到达后才重新出现。owner 始终有权限。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
@@ -122,23 +122,29 @@ export function apply(ctx: Context): void {
|
||||
const stream = ctx.remote.$stream<AccountView>({
|
||||
name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'),
|
||||
})
|
||||
let disposed = false
|
||||
// Withdrawing access invalidates the reads this grant started and clears its
|
||||
// account view, synchronously, so the next grant starts from no account
|
||||
// Read across closures: the disposer ends this grant while the frame loop awaits.
|
||||
const grant = { ended: false }
|
||||
// Withdrawing access invalidates the reads this grant started, closes the
|
||||
// pending authorization tab, and clears the account view and the sign-in
|
||||
// dialog state, synchronously, so the next grant starts from no account
|
||||
// state. `notices.end()` disposes this generation's notice lifetime, which a
|
||||
// frame of this stream begins; the notice controller stays outside and
|
||||
// begins again under the next grant.
|
||||
yield () => {
|
||||
disposed = true
|
||||
grant.ended = true
|
||||
revision++
|
||||
refreshing = undefined
|
||||
avatar.reset()
|
||||
publish({ ...snapshot, view: undefined, details: undefined, avatar: undefined, failed: false })
|
||||
browser.dispose()
|
||||
const { loginFailed: _loginFailed, ...current } = snapshot
|
||||
publish({ ...current, view: undefined, details: undefined, avatar: undefined, failed: false, loginVisible: false })
|
||||
notices.end()
|
||||
return stream.dispose()
|
||||
}
|
||||
void (async () => {
|
||||
for await (const frame of stream) {
|
||||
// A frame the stream delivered before the revocation is not this grant's to apply.
|
||||
if (grant.ended) return
|
||||
revision++
|
||||
refreshing = undefined
|
||||
avatar.reset()
|
||||
@@ -161,7 +167,7 @@ export function apply(ctx: Context): void {
|
||||
})()
|
||||
void refresh()
|
||||
}
|
||||
})().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) })
|
||||
})().catch(() => { if (!grant.ended) publish({ ...snapshot, failed: true }) })
|
||||
})
|
||||
const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform
|
||||
// One request channel for the single native Platform view. It always exists
|
||||
|
||||
@@ -189,3 +189,124 @@ it('drops reads a revoked grant started, and the next grant shows no account sec
|
||||
for (const dispose of lifetime.reverse()) dispose()
|
||||
}
|
||||
})
|
||||
|
||||
/** Stream behaviour of one grant's account stream in {@link revocationHarness}. */
|
||||
type StreamScript = (yieldFrame: () => void) => void
|
||||
|
||||
/**
|
||||
* The account plugin over hand-built services whose account streams follow `scripts`, one per grant.
|
||||
* @param scripts - per-grant hooks run right before that stream delivers its first frame.
|
||||
* @returns the plugin's access guards, Remote spies, slot counts, and teardown.
|
||||
*/
|
||||
function revocationHarness(scripts: StreamScript[]) {
|
||||
const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' }
|
||||
const signedIn: AccountView = { status: 'credential-stored', links, attempt: null }
|
||||
const guards: Guard[] = []
|
||||
const registered = new Map<string, number>()
|
||||
const accepted: Promise<undefined>[] = []
|
||||
const lifetime: (() => void)[] = []
|
||||
let operations: AccountSectionInjected | undefined
|
||||
const getProfile = vi.fn(async () => ({ ok: true, value: null }))
|
||||
const getUnnotifiedBonuses = vi.fn(async () => ({ ok: true, value: null }))
|
||||
const startSignIn = vi.fn<() => Promise<{ ok: false }>>()
|
||||
const ctx = {
|
||||
effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) },
|
||||
locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) },
|
||||
configForms: {
|
||||
get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }),
|
||||
describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }),
|
||||
},
|
||||
theme: { getTheme: () => ({ active: { colorScheme: 'light' } }) },
|
||||
slots: {
|
||||
inject: (_name: string, install: () => (() => void) | undefined) => {
|
||||
const dispose = install()
|
||||
return () => { dispose?.() }
|
||||
},
|
||||
register: (entry: { readonly name: string; readonly inject?: () => AccountSectionInjected }) => {
|
||||
if (entry.name === 'settings.launcher') operations = entry.inject?.()
|
||||
registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1)
|
||||
return () => { registered.set(entry.name, registered.get(entry.name)! - 1) }
|
||||
},
|
||||
},
|
||||
remoteAccess: {
|
||||
whenAccess: (action: string, install: () => AccessEffect) => {
|
||||
guards.push({ action, install, installed: undefined })
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
$on: () => () => {},
|
||||
session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) },
|
||||
account: {
|
||||
getProfile, getBalance: async () => ({ ok: true, value: null }), getUnnotifiedBonuses, startSignIn,
|
||||
},
|
||||
$stream: () => {
|
||||
const abort = new AbortController()
|
||||
const delivered = Promise.withResolvers<undefined>()
|
||||
const script = scripts[accepted.length]!
|
||||
accepted.push(delivered.promise)
|
||||
return {
|
||||
signal: abort.signal,
|
||||
dispose: () => { abort.abort() },
|
||||
async *[Symbol.asyncIterator]() {
|
||||
await Promise.resolve()
|
||||
script(() => {})
|
||||
yield { value: signedIn, accept: () => { delivered.resolve(undefined) } }
|
||||
await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) })
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
apply(ctx as never)
|
||||
return {
|
||||
guards, registered, accepted, getProfile, getUnnotifiedBonuses, startSignIn,
|
||||
operations: () => operations!,
|
||||
dispose: () => { revoke(guards); for (const dispose of lifetime.reverse()) dispose() },
|
||||
}
|
||||
}
|
||||
|
||||
it('ignores a frame the stream delivered just before the revocation', async () => {
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test')
|
||||
// The stream hands out its first frame after access is already withdrawn.
|
||||
const b = revocationHarness([() => { revoke(b.guards) }])
|
||||
try {
|
||||
grant(b.guards)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.operations().hooks.account.getSnapshot().view).toBeUndefined()
|
||||
expect(b.registered.get('settings.section') ?? 0).toBe(0)
|
||||
expect(b.getProfile).not.toHaveBeenCalled()
|
||||
expect(b.getUnnotifiedBonuses).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
b.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes the sign-in dialog, its failure, and the pending authorization tab on revocation', async () => {
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test')
|
||||
const popup = { opener: null, closed: false, close: vi.fn(), location: { replace: vi.fn() } }
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(popup as never)
|
||||
const b = revocationHarness([() => {}, () => {}])
|
||||
try {
|
||||
grant(b.guards)
|
||||
await b.accepted[0]
|
||||
b.startSignIn.mockResolvedValueOnce({ ok: false })
|
||||
await expect(b.operations().start()).rejects.toThrow('account start failed')
|
||||
expect(b.operations().hooks.account.getSnapshot()).toMatchObject({ loginVisible: true, loginFailed: true })
|
||||
revoke(b.guards)
|
||||
expect(b.operations().hooks.account.getSnapshot().loginVisible).toBe(false)
|
||||
expect(b.operations().hooks.account.getSnapshot().loginFailed).toBeUndefined()
|
||||
|
||||
grant(b.guards)
|
||||
await b.accepted[1]
|
||||
popup.close.mockClear()
|
||||
b.startSignIn.mockReturnValueOnce(new Promise(() => {}))
|
||||
void b.operations().start()
|
||||
expect(open).toHaveBeenCalledTimes(2)
|
||||
revoke(b.guards)
|
||||
expect(popup.close).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
b.dispose()
|
||||
open.mockRestore()
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user