diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 46abf37515..d417b4dfbb 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -15,8 +15,8 @@ en: 31cbb7452be8e1ad zh: 502ec556e2ec301e /deepseek-harness-desktop/installation-ownership: - en: d186d3cfc9597688 - zh: 5f7094355c5f562c + en: 8d686d07b0809596 + zh: 9ca7658daf583a66 /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: en: 0d11af8639977a6d zh: 86b086b12d2210d3 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 38b91f5373..bb2295986b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -80,7 +80,7 @@ Welcome loads the shared Toast palette and shadow tokens, with system typography Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. Only the main application window enables ``. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 0c6a9ca276..40683dfd85 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -82,7 +82,7 @@ Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。 +应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。 只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 diff --git a/apps/desktop/src/device-info.ts b/apps/desktop/src/device-info.ts new file mode 100644 index 0000000000..3bbd4423e1 --- /dev/null +++ b/apps/desktop/src/device-info.ts @@ -0,0 +1,34 @@ +/** Local machine description sent with the Desktop feedback questionnaire. */ +import { cpus, totalmem } from 'node:os' + +/** + * Read the local machine description attached to a Desktop feedback submission. + * Fields are `name=value` pairs separated by `; `, in platform, os, app_arch, cpu and + * memory_gib order; memory is total physical memory in GiB with one decimal. + * @returns the machine description, with unavailable fields omitted. + */ +export function readDeviceInfo(): string { + const fields = [`platform=${process.platform}`] + collect(fields, 'os', () => process.getSystemVersion()) + fields.push(`app_arch=${process.arch}`) + collect(fields, 'cpu', () => cpus()[0]?.model) + collect(fields, 'memory_gib', () => (totalmem() / 1024 ** 3).toFixed(1)) + return fields.join('; ') +} + +/** + * Append one optional field, omitting the field when its source is empty or fails. + * @param fields - collected fields, appended in call order. + * @param name - field name written before `=`. + * @param read - source of the field value. + */ +function collect(fields: string[], name: string, read: () => string | undefined): void { + let value: string | undefined + try { + value = read() + } catch (_error) { + // A refused or unsupported source omits only its own field. + return + } + if (value !== undefined && value !== '') fields.push(`${name}=${value}`) +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 45175929d2..fc19021fa6 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -21,6 +21,7 @@ export const DESKTOP_IPC = { browserRelease: 'dsh-desktop:browser-release', browserOpenRequested: 'dsh-desktop:browser-open-requested', directoryPick: 'dsh-desktop:directory-pick', + deviceInfo: 'dsh-desktop:device-info', localeBootstrap: 'dsh-desktop:locale-bootstrap', localeChanged: 'dsh-desktop:locale-changed', updatesStatus: 'dsh-desktop:updates-status', @@ -73,6 +74,11 @@ export interface DshDesktopProductApi { readonly browser: DesktopBrowserBridge readonly keyboard: DesktopKeyboardApi readonly shortcuts: DesktopShortcutsApi + /** + * Local machine description for the feedback questionnaire. + * @returns `name=value` fields separated by `; `, with no hostname, user name, or serial number. + */ + deviceInfo(): Promise readonly updates: { status(): Promise open(): Promise diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index eb75980ad7..53bc447011 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -30,6 +30,7 @@ import { installDesktopDirectoryPicker } from './directory-picker.ts' import { installMicrophonePermissions } from './microphone-permissions.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' +import { readDeviceInfo } from './device-info.ts' import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' @@ -741,6 +742,10 @@ async function main(): Promise { assertProductSender(event) return presentDesktopUpdate(updates.state) }) + ipcMain.handle(DESKTOP_IPC.deviceInfo, (event) => { + assertProductSender(event) + return readDeviceInfo() + }) ipcMain.handle(DESKTOP_IPC.onboardingApiKey, async (event) => { assertProductSender(event) return (await readWelcomeState()).hasApiKey diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index ee262d97d5..f590b8bc8b 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -14,6 +14,7 @@ function createProductApi(): DshDesktopProductApi { return { protocolVersion: 1, browser: createDesktopBrowserBridge(), + deviceInfo: () => ipcRenderer.invoke(DESKTOP_IPC.deviceInfo) as Promise, keyboard: { closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise, subscribe: (listener) => { diff --git a/apps/desktop/tests/device-info.spec.ts b/apps/desktop/tests/device-info.spec.ts new file mode 100644 index 0000000000..b29b4865ba --- /dev/null +++ b/apps/desktop/tests/device-info.spec.ts @@ -0,0 +1,50 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { readDeviceInfo } from '../src/device-info.ts' + +const os = vi.hoisted(() => ({ cpus: vi.fn(), totalmem: vi.fn() })) +vi.mock('node:os', () => os) + +/** + * Replace the process face the reader sees, keeping the fields the real process owns. + * @param platform - operating system platform reported to the reader. + * @param arch - application architecture reported to the reader. + * @param osVersion - system version answer, or a thrower for an unavailable source. + */ +function stubProcess(platform: string, arch: string, osVersion: () => string): void { + vi.stubGlobal('process', { ...process, platform, arch, getSystemVersion: osVersion }) +} + +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() }) + +it.each([ + ['darwin', 'arm64', '15.6', 'Apple M4', 32, 'platform=darwin; os=15.6; app_arch=arm64; cpu=Apple M4; memory_gib=32.0'], + ['win32', 'x64', '10.0.26100', 'Intel(R) Core(TM) i7-13700H', 16, 'platform=win32; os=10.0.26100; app_arch=x64; cpu=Intel(R) Core(TM) i7-13700H; memory_gib=16.0'], +] as const)( + 'describes a %s application on %s with one decimal of memory', (platform, arch, osVersion, model, memoryGib, expected) => { + stubProcess(platform, arch, () => osVersion) + os.cpus.mockReturnValue([{ model }]) + os.totalmem.mockReturnValue(memoryGib * 1024 ** 3) + expect(readDeviceInfo()).toBe(expected) + }, +) + +it('reports fractional memory in GiB rounded to one decimal', () => { + stubProcess('darwin', 'arm64', () => '15.6') + os.cpus.mockReturnValue([{ model: 'Apple M4' }]) + os.totalmem.mockReturnValue(17_543_000_000) + expect(readDeviceInfo().split('; ').at(-1)).toBe('memory_gib=16.3') +}) + +it('omits a field whose source is absent instead of failing the whole description', () => { + stubProcess('linux', 'x64', () => '6.6.0') + os.cpus.mockReturnValue([]) + os.totalmem.mockReturnValue(8 * 1024 ** 3) + expect(readDeviceInfo()).toBe('platform=linux; os=6.6.0; app_arch=x64; memory_gib=8.0') +}) + +it('omits the fields whose sources throw and keeps the rest', () => { + stubProcess('win32', 'x64', () => { throw new Error('refused') }) + os.cpus.mockImplementation(() => { throw new Error('refused') }) + os.totalmem.mockImplementation(() => { throw new Error('refused') }) + expect(readDeviceInfo()).toBe('platform=win32; app_arch=x64') +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index abce2c0bbc..1b54c9fa46 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -665,20 +665,22 @@ describe('desktop main startup', () => { } }) - it('accepts update IPC only from the current application top frame in the owned main window', async () => { + it('accepts product IPC only from the current application top frame in the owned main window', async () => { await readyForUpdate() - const handler = harness.handlers.get(DESKTOP_IPC.updatesStatus)! const sender = harness.windows[0]!.webContents - expect(() => handler({ sender, senderFrame: sender.mainFrame })).not.toThrow() - for (const event of [ - { sender: {}, senderFrame: sender.mainFrame }, - { sender, senderFrame: { url: sender.mainFrame.url } }, - { sender, senderFrame: { url: 'http://127.0.0.1:40000/' } }, - ]) expect(() => handler(event)).toThrow('unowned renderer') - const original = sender.mainFrame.url - sender.mainFrame.url = 'http://127.0.0.1:40000/' - expect(() => handler({ sender, senderFrame: sender.mainFrame })).toThrow('unowned renderer') - sender.mainFrame.url = original + for (const channel of [DESKTOP_IPC.updatesStatus, DESKTOP_IPC.deviceInfo]) { + const handler = harness.handlers.get(channel)! + expect(() => handler({ sender, senderFrame: sender.mainFrame })).not.toThrow() + for (const event of [ + { sender: {}, senderFrame: sender.mainFrame }, + { sender, senderFrame: { url: sender.mainFrame.url } }, + { sender, senderFrame: { url: 'http://127.0.0.1:40000/' } }, + ]) expect(() => handler(event)).toThrow('unowned renderer') + const original = sender.mainFrame.url + sender.mainFrame.url = 'http://127.0.0.1:40000/' + expect(() => handler({ sender, senderFrame: sender.mainFrame })).toThrow('unowned renderer') + sender.mainFrame.url = original + } }) it.each(['darwin', 'win32', 'linux'] as const)('limits native titlebar styling to macOS on %s', async (platform) => { diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 9ead080f9b..e238f583c1 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -64,6 +64,21 @@ it.each(['dsh-app://shell/plugin-manager.html', 'dsh-app://other/index.html', 'h expect(electron.ipcRenderer.on.mock.calls.some(([channel]) => channel === DESKTOP_IPC.browserOpenRequested)).toBe(false) }) +it('reads the local machine description only from the application main frame', async () => { + const description = 'platform=darwin; os=15.6; app_arch=arm64; cpu=Apple M4; memory_gib=32.0' + vi.stubGlobal('location', new URL('dsh-app://app/index.html')) + electron.ipcRenderer.invoke.mockResolvedValue(description) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + await expect(api.deviceInfo()).resolves.toBe(description) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.deviceInfo) + vi.resetModules() + electron.contextBridge.exposeInMainWorld.mockClear() + vi.stubGlobal('process', { ...process, isMainFrame: false }) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) +}) + it('exposes asynchronous boot only to the local application document', async () => { vi.stubGlobal('location', new URL('dsh-app://app/')) await import('../src/preload-app.ts') diff --git a/apps/desktop/tests/preload.e2e.ts b/apps/desktop/tests/preload.e2e.ts index d18f475bc1..f18bb38144 100644 --- a/apps/desktop/tests/preload.e2e.ts +++ b/apps/desktop/tests/preload.e2e.ts @@ -49,9 +49,9 @@ function browserEnvironment() { } describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop preloads', () => { - it.each(['preload-app', 'preload-welcome'])('%s loads without filesystem module access', (name) => { + it.each(['preload-app', 'preload-welcome'])('%s loads without filesystem module access', async (name) => { const exposed = new Map>() - const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' })) + const invoke = vi.fn<(...args: unknown[]) => Promise>(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' })) const send = vi.fn() const browser = browserEnvironment() const electron = { @@ -64,11 +64,15 @@ describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop pr if (id !== 'electron') throw new Error(`sandbox cannot load ${id}`) return electron }, - process: { argv: ['electron', '--dsh-welcome-locale=en'] }, + process: { argv: ['electron', '--dsh-welcome-locale=en'], isMainFrame: true }, location: new URL('dsh-app://app/'), exports: {}, }) if (name === 'preload-app') { + const product = exposed.get('dshDesktop') as { deviceInfo(): Promise } + invoke.mockResolvedValueOnce('platform=darwin; memory_gib=32.0') + await expect(product.deviceInfo()).resolves.toBe('platform=darwin; memory_gib=32.0') + expect(invoke).toHaveBeenCalledWith('dsh-desktop:device-info') browser.loadTheme('dark') expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'dark') browser.changeTheme('light') diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index cb0531a5d8..db7fcf5c68 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -15,8 +15,8 @@ en: c26f81e8adf0128b zh: e11ab371f51f66bc /deepseek-ai-dsh-client-ui-settings-account/use-this-package: - en: bc11406a04989a27 - zh: 27e5fffa2b3eda6d + en: 92b504eb2bca6379 + zh: 72e03dcc09943005 /deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding: en: c1c4db71fd09ca13 zh: a40c0a01e9951945 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index a417443c7b..a7d73020c6 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -40,7 +40,7 @@ Desktop usage, top-up, and onboarding recharge actions open one isolated native Chat offers each live quota failure from a bound Session to the frame-wide `shell.quota-notice` chain from its host in `shell.overlay`, so the notice outlives the Chat panel that reported it. The account entry claims `ACCOUNT_QUOTA`: while an account credential is stored and the shared host entry exists, the Modal offers Cancel and Top up. While a page requested through the shared request channel is showing, the native view covers this document, so the entry paints neither the Modal nor the Toast. A quota failure that arrives while a Settings-opened page is already showing is not retained: it stays subject to newest-notice replacement, and the Modal appears after the viewer returns while that notice is still current. That Modal reports the failure of the original request, not the account's current balance: the top-up re-read is a separate read, and returning neither proves a successful payment nor dismisses the notice on its own. The notice's own Top up is the retained case: it calls `keepOpen()` and requests the `top-up` page through the shared request channel, keeping that notice and its page mounted through later quota failures. With the shared host entry the entry waits for the first account snapshot instead of rendering a Toast that its own timer could discard; a snapshot that reports no stored credential, or a failed account stream with no snapshot, then renders the same neutral warning Toast itself, and without that host entry the entry renders that Toast immediately, instead of relying on the chat host fallback. The entry owns the hold and its page request and releases both when it unmounts, so a later quota failure publishes again without replaying the dropped ones; dismissal or sign-out clears the hold and the page request. Close, Cancel, a sign-out, or leaving the notice's own embedded page takes the notice down without retrying the request. -The account menu's Feedback entry opens the Feishu questionnaire in the system browser. It supplies the available build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; the account UID, tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation. +The account menu's Feedback entry opens the Feishu questionnaire in the system browser. Every click samples its context then: the account UID when the profile is ready and carries an id, the build version as harness_version, the UI locale, the physical screen resolution, and device_info. Each context field also carries hide_*=1, stale prefill values for these fields are dropped from the configured form URL first, and the default form URL hides uid, device_info and harness_version. Tokens and masked contact details stay out, and a signed-out, still-loading, or failed profile leaves prefill_uid out. The Web opens synchronously with navigator.userAgent, which keeps the click's user gesture. Desktop exposes the optional dshDesktop.deviceInfo reader, whose field format the [Desktop README](../../../apps/desktop/README.md) owns; its answer replaces the user agent, a failed read opens the form with device_info empty instead of dropping the report, and device_info describes the reported environment rather than a hardware model. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form; contactSource defaults to empty until the questionnaire supports a Harness source option. Account profile and balance cards share the [settings card material and radius](../../../docs/web-styling.md#corner-radii-and-settings-cards). Usage and top-up links match standard Button geometry; authorization actions use the shared Button. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 2d357ee54c..a13aec2dd7 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -40,7 +40,7 @@ Desktop 的用量、充值与引导充值操作在 48px 返回栏下方打开同 Chat 通过 `shell.overlay` 中的宿主把已绑定 Session 的实时欠费交给全局 `shell.quota-notice` 链,提示的生命周期因此长于上报它的 Chat 面板。账号条目认领 `ACCOUNT_QUOTA`:已存储账号凭证且存在共享宿主条目时,Modal 提供取消和去充值。只要通过该共享请求通道请求的页面正在显示,原生视图就会盖住本页,因此该条目既不绘制 Modal 也不绘制 Toast。在 Account 设置页已打开页面的情况下新到达的欠费不会被保持:它仍受最新提示替换规则约束,返回后若该提示仍是最新一条就显示 Modal。该 Modal 报告的是原始请求的失败,而不是账号当前余额:返回时的重读是一次独立读取,返回本身既不证明支付成功,也不会自动撤下该提示。提示自身的去充值属于被保持的情形:它调用 `keepOpen()` 并通过共享请求通道申请 `top-up` 页面,使该提示与其页面在后续欠费期间保持挂载。存在共享宿主条目时,该条目会等待首个账号快照,而不是渲染一个可能被自身计时器丢弃的 Toast;快照报告未存储凭证,或账号流失败且无快照时,才由该条目自行渲染同一段中立警告 Toast,缺少该宿主条目时则立即渲染该 Toast,而不依赖 Chat 宿主回退。该条目持有保持与页面请求,并在自身卸载时释放二者,因此后续欠费会重新发布而不重放已丢弃的提示;关闭与退出登录也会清除保持和页面请求。关闭、取消、退出登录或离开该提示自己的内嵌页面都会撤下提示,且不会重试请求。 -账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。链接通过 prefill_* 参数填写已有的构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传账号 UID、token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。 +账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。每次点击都按当时的上下文取值:profile ready 且带 id 时的账号 UID、以 harness_version 表示的构建版本、界面语言、屏幕物理分辨率,以及 device_info。所有上下文字段同时设置 hide_*=1,配置的表单 URL 中这些字段的 stale prefill 参数先被清除,默认表单 URL 另行隐藏 uid、device_info 与 harness_version。不传 token 或脱敏联系方式;未登录、profile 仍在加载或读取失败时都不填写 prefill_uid。Web 端同步打开表单,device_info 为 navigator.userAgent,以保留点击手势。Desktop 端暴露可选的 dshDesktop.deviceInfo 读取器,其字段格式由 [Desktop README](../../../apps/desktop/README.zh.md) 负责;该读取器的结果取代 user-agent,读取失败时仍打开表单、仅 device_info 留空,而 device_info 描述的是上报的环境而非硬件型号。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷;问卷支持 Harness 来源选项前,contactSource 默认为空。 账号资料与余额卡片共用[设置卡片材质与圆角](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards)。用量与充值链接与标准 Button 尺寸一致;授权操作使用公共 Button。 diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index ba6530bef1..d59b45a976 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -58,7 +58,7 @@ export interface AccountSectionInjected { * @returns after both reads settle. */ refreshAccount: () => Promise - /** Open the external support questionnaire with the current build and browser environment. */ + /** Open the external support questionnaire with the account, build and environment sampled by this click. */ contactUs: () => void /** Open or dismiss the login dialog. */ showLogin: (visible: boolean) => void diff --git a/packages/client/ui-settings-account/src/client/contact-url.ts b/packages/client/ui-settings-account/src/client/contact-url.ts index 2f3ef229ec..a67c58a546 100644 --- a/packages/client/ui-settings-account/src/client/contact-url.ts +++ b/packages/client/ui-settings-account/src/client/contact-url.ts @@ -1,15 +1,18 @@ -/** Feishu questionnaire context follows the Platform Web ticket fields. */ +/** Feishu questionnaire context: Harness build, locale, screen and reported environment. */ +import type { AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types' import type { ContactConfig } from '../contact-config.ts' /** * Build an external questionnaire URL without authentication credentials. * @param config - questionnaire destination and supported source option. - * @param context - currently available build and browser environment. + * @param context - account, build and environment facts sampled by the caller. * @returns questionnaire URL with hidden, optionally prefilled context fields. */ export function contactUrl(config: ContactConfig, context: { + uid: AccountUserId | null | undefined version: string | undefined locale: string + deviceInfo: string width: number height: number pixelRatio: number @@ -19,8 +22,8 @@ export function contactUrl(config: ContactConfig, context: { const width = Math.round(context.width * ratio) const height = Math.round(context.height * ratio) const fields = { - source: config.contactSource, app_version: context.version, - os_version: undefined, device_brand: undefined, device_model: undefined, + uid: context.uid, source: config.contactSource, harness_version: context.version, + os_version: undefined, device_brand: undefined, device_info: context.deviceInfo, app_locale: context.locale, screen_resolution: width > 0 && height > 0 ? `${width}x${height}` : undefined, } for (const [name, value] of Object.entries(fields)) { @@ -28,7 +31,10 @@ export function contactUrl(config: ContactConfig, context: { url.searchParams.delete(`prefill_${name}`) if (value) url.searchParams.set(`prefill_${name}`, value) } - url.searchParams.delete('prefill_uid') - url.searchParams.delete('hide_uid') + // A configured form URL may still carry fields this build renamed or removed. + url.searchParams.delete('prefill_app_version') + url.searchParams.delete('hide_app_version') + url.searchParams.delete('prefill_device_model') + url.searchParams.delete('hide_device_model') return url.href } diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index e165c063b7..6d9c9f4119 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -161,12 +161,35 @@ export function apply(ctx: Context): void { ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, refreshAccount, contactUs() { - const url = contactUrl(config, { + // Sample the account, build and environment before awaiting native information, so + // a profile the read outlasts cannot replace the UID this click reported. + const profile = snapshot.details?.profile + const context = { + uid: profile?.status === 'ready' ? profile.value.id : null, version: process.env.DSH_CLIENT_VERSION, locale: ctx.locale.getSnapshot().active === 'zh' ? 'zh-CN' : 'en', width: window.screen.width, height: window.screen.height, pixelRatio: window.devicePixelRatio, - }) - window.open(url, '_blank', 'noopener,noreferrer') + } + const openForm = (deviceInfo: string): void => { + window.open(contactUrl(config, { ...context, deviceInfo }), '_blank', 'noopener,noreferrer') + } + const readDeviceInfo = (globalThis as typeof globalThis & { + dshDesktop?: { deviceInfo?: () => Promise } + }).dshDesktop?.deviceInfo + if (readDeviceInfo === undefined) { + // Without the optional Desktop reader the form opens synchronously, so the click + // still carries its user gesture. + openForm(navigator.userAgent) + return + } + void (async () => { + let deviceInfo = '' + try { deviceInfo = await readDeviceInfo() } + catch (_error) { + // Native information is optional; the questionnaire opens without it. + } + openForm(deviceInfo) + })() }, showLogin(visible) { publish({ ...snapshot, loginVisible: visible }) }, setOnboarding(active) { publish({ ...snapshot, onboarding: active }) }, diff --git a/packages/client/ui-settings-account/src/contact-config.ts b/packages/client/ui-settings-account/src/contact-config.ts index 4d8262f4f7..b1afcbf049 100644 --- a/packages/client/ui-settings-account/src/contact-config.ts +++ b/packages/client/ui-settings-account/src/contact-config.ts @@ -14,7 +14,7 @@ export interface ContactConfig { } /** Validate public questionnaire options. */ export const ContactConfigFields = { - contactFormUrl: z.string().pattern(/^https:\/\/[^/\s]+\//).default('https://trtgsjkv6r.feishu.cn/share/base/form/shrcnlCoGElW7MQznGy9r3YYXcg'), + contactFormUrl: z.string().pattern(/^https:\/\/[^/\s]+\//).default('https://trtgsjkv6r.feishu.cn/share/base/form/shrcnlCoGElW7MQznGy9r3YYXcg?hide_uid=1&hide_device_info=1&hide_harness_version=1'), contactSource: z.string().default(''), bonusAckRetryDelayMs: z.number().min(1).default(1_000), bonusAckRetryMaxDelayMs: z.number().min(1).default(60_000), diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 393d5232ec..839396b947 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -89,7 +89,7 @@ it('claims account balance notices from the frame-wide quota chain and declines }, 60_000) it('shares account actions across seats, publishes dialog ownership, and opens contextual support', async ({ start }) => { - vi.stubGlobal(CONTACT_CONFIG_GLOBAL, { contactFormUrl: 'https://example.test/form/', contactSource: 'harness' }) + vi.stubGlobal(CONTACT_CONFIG_GLOBAL, { contactFormUrl: 'https://example.test/form/?prefill_device_model=old&hide_device_model=1', contactSource: 'harness' }) const open = vi.spyOn(window, 'open').mockReturnValue(null) vi.stubGlobal('dshDesktop', {}) const c = await start() @@ -119,7 +119,7 @@ it('shares account actions across seats, publishes dialog ownership, and opens c actions.contactUs() const signedOut = new URL(String(open.mock.calls.at(-1)![0])) expect(signedOut.searchParams.has('prefill_uid')).toBe(false) - expect(signedOut.searchParams.has('hide_uid')).toBe(false) + expect(signedOut.searchParams.get('hide_uid')).toBe('1') c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) c.mock.streams.push('account/watch', stored) await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().details?.profile).toEqual(profile) }) @@ -129,13 +129,77 @@ it('shares account actions across seats, publishes dialog ownership, and opens c vi.spyOn(c.ctx.locale, 'getSnapshot').mockReturnValue({ ...c.ctx.locale.getSnapshot(), active: 'zh' }) actions.contactUs() const support = new URL(String(open.mock.calls.at(-1)![0])) - expect(support.searchParams.has('prefill_uid')).toBe(false) - expect(support.searchParams.has('hide_uid')).toBe(false) + expect(support.searchParams.get('prefill_uid')).toBe('account-user') + expect(support.searchParams.get('hide_uid')).toBe('1') + expect(support.searchParams.get('hide_harness_version')).toBe('1') + expect(support.searchParams.get('prefill_harness_version')).toBe('0.0.0-test') + expect(support.searchParams.get('hide_device_info')).toBe('1') + expect(support.searchParams.get('prefill_device_info')).toBe(navigator.userAgent) + expect(support.searchParams.has('prefill_device_model')).toBe(false) + expect(support.searchParams.has('hide_device_model')).toBe(false) expect(support.searchParams.get('prefill_app_locale')).toBe('zh-CN') + expect(support.searchParams.has('prefill_app_version')).toBe(false) + c.mock.streams.push('account/watch', view) + await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().view).toEqual(view) }) + actions.contactUs() + const signedOutAgain = new URL(String(open.mock.calls.at(-1)![0])) + expect(signedOutAgain.searchParams.has('prefill_uid')).toBe(false) + expect(signedOutAgain.searchParams.get('hide_uid')).toBe('1') await c.unload(SELF) expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) }, 60_000) +it('prefills the native device description from the Desktop bridge on every click', async ({ start }) => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + const deviceInfo = vi.fn(async () => 'platform=darwin; os=15.0; app_arch=arm64; cpu=Apple M3; memory_gib=16.0') + vi.stubGlobal('dshDesktop', { deviceInfo }) + const c = await start() + const actions = operations(c) + actions.contactUs() + await vi.waitFor(() => { expect(open).toHaveBeenCalledOnce() }) + const first = new URL(String(open.mock.calls.at(-1)![0])) + expect(first.searchParams.get('prefill_device_info')) + .toBe('platform=darwin; os=15.0; app_arch=arm64; cpu=Apple M3; memory_gib=16.0') + expect(first.searchParams.get('hide_device_info')).toBe('1') + deviceInfo.mockResolvedValue('platform=win32; os=10.0; app_arch=x64; memory_gib=32.0') + actions.contactUs() + await vi.waitFor(() => { expect(open).toHaveBeenCalledTimes(2) }) + expect(new URL(String(open.mock.calls.at(-1)![0])).searchParams.get('prefill_device_info')) + .toBe('platform=win32; os=10.0; app_arch=x64; memory_gib=32.0') + expect(deviceInfo).toHaveBeenCalledTimes(2) +}, 60_000) + +it('opens the questionnaire with an empty device field when the native read fails', async ({ start }) => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + const deviceInfo = vi.fn(async (): Promise => { throw new Error('platform ipc unavailable') }) + vi.stubGlobal('dshDesktop', { deviceInfo }) + const c = await start() + operations(c).contactUs() + await vi.waitFor(() => { expect(open).toHaveBeenCalledOnce() }) + const url = new URL(String(open.mock.calls.at(-1)![0])) + expect(url.searchParams.has('prefill_device_info')).toBe(false) + expect(url.searchParams.get('hide_device_info')).toBe('1') + expect(url.searchParams.get('prefill_app_locale')).toBe('en') + expect(deviceInfo).toHaveBeenCalledOnce() +}, 60_000) + +it('reports the account sampled by the click when a native read outlasts a sign-out', async ({ start }) => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + const device = Promise.withResolvers() + vi.stubGlobal('dshDesktop', { deviceInfo: vi.fn(() => device.promise) }) + const c = await start() + const actions = operations(c) + c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) + c.mock.streams.push('account/watch', stored) + await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().details?.profile).toEqual(profile) }) + actions.contactUs() + c.mock.streams.push('account/watch', view) + await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().view).toEqual(view) }) + device.resolve('platform=darwin; os=15.0; app_arch=arm64; memory_gib=16.0') + await vi.waitFor(() => { expect(open).toHaveBeenCalledOnce() }) + expect(new URL(String(open.mock.calls.at(-1)![0])).searchParams.get('prefill_uid')).toBe('account-user') +}, 60_000) + it('coalesces refreshes, publishes independent failures, and rejects stale responses after sign-out or unload', async ({ start }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() diff --git a/packages/client/ui-settings-account/tests/contact-url.client.spec.ts b/packages/client/ui-settings-account/tests/contact-url.client.spec.ts index bbc37fb180..7108f38b92 100644 --- a/packages/client/ui-settings-account/tests/contact-url.client.spec.ts +++ b/packages/client/ui-settings-account/tests/contact-url.client.spec.ts @@ -1,42 +1,63 @@ import { expect, it } from 'vitest' +import type { AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types' import { ContactConfig } from '../src/contact-config.ts' import { contactUrl } from '../src/client/contact-url.ts' -it('prefills available support context and hides all context fields', () => { +it('prefills the available support context and hides every context field', () => { const config = ContactConfig({ contactSource: 'app_harness' }) const url = new URL(contactUrl(config, { - version: '1.2.3', locale: 'zh-CN', width: 1512, height: 982, pixelRatio: 2, + uid: 'account-user' as AccountUserId, version: '1.2.3', locale: 'zh-CN', + deviceInfo: 'Mozilla/5.0 (Macintosh) Harness/1.2.3', width: 1512, height: 982, pixelRatio: 2, })) expect(url.origin).toBe('https://trtgsjkv6r.feishu.cn') expect(url.pathname).toBe('/share/base/form/shrcnlCoGElW7MQznGy9r3YYXcg') + expect(Object.fromEntries(new URL(ContactConfig({}).contactFormUrl).searchParams)).toEqual({ + hide_uid: '1', hide_device_info: '1', hide_harness_version: '1', + }) expect(Object.fromEntries(url.searchParams)).toEqual({ - hide_source: '1', prefill_source: 'app_harness', hide_app_version: '1', prefill_app_version: '1.2.3', - hide_os_version: '1', - hide_device_brand: '1', hide_device_model: '1', hide_app_locale: '1', prefill_app_locale: 'zh-CN', + hide_uid: '1', prefill_uid: 'account-user', + hide_source: '1', prefill_source: 'app_harness', + hide_harness_version: '1', prefill_harness_version: '1.2.3', + hide_os_version: '1', hide_device_brand: '1', + hide_device_info: '1', prefill_device_info: 'Mozilla/5.0 (Macintosh) Harness/1.2.3', + hide_app_locale: '1', prefill_app_locale: 'zh-CN', hide_screen_resolution: '1', prefill_screen_resolution: '3024x1964', }) - expect(url.searchParams.has('prefill_uid')).toBe(false) - expect(url.searchParams.has('hide_uid')).toBe(false) }) -it('opens a configured form without stale context when unavailable', () => { - const url = new URL(contactUrl(ContactConfig({ contactFormUrl: 'https://example.test/form/?prefill_uid=old&hide_uid=1&prefill_app_version=old&prefill_source=old' }), { - version: undefined, locale: 'en', width: 0, height: 0, pixelRatio: 1, +it('percent-encodes context values that carry URL delimiters', () => { + const url = new URL(contactUrl(ContactConfig({ contactSource: 'a&b=c' }), { + uid: 'user&admin=1' as AccountUserId, version: '1.0.0-rc.1+build', locale: 'en', + deviceInfo: 'platform=darwin; os=15.0; app_arch=arm64; cpu=Apple M3; memory_gib=16.0', + width: 800, height: 600, pixelRatio: 1, + })) + expect(url.href).toContain('prefill_uid=user%26admin%3D1') + expect(url.searchParams.get('prefill_uid')).toBe('user&admin=1') + expect(url.searchParams.get('prefill_source')).toBe('a&b=c') + expect(url.searchParams.get('prefill_device_info')).toBe('platform=darwin; os=15.0; app_arch=arm64; cpu=Apple M3; memory_gib=16.0') + expect(url.searchParams.get('prefill_harness_version')).toBe('1.0.0-rc.1+build') +}) + +it('opens a configured form and omits unavailable context', () => { + const url = new URL(contactUrl(ContactConfig({ contactFormUrl: 'https://example.test/form/' }), { + uid: null, version: undefined, locale: 'en', deviceInfo: '', width: 0, height: 0, pixelRatio: 1, })) expect(url.origin).toBe('https://example.test') expect(url.searchParams.has('prefill_uid')).toBe(false) - expect(url.searchParams.has('hide_uid')).toBe(false) - expect(url.searchParams.has('prefill_app_version')).toBe(false) + expect(url.searchParams.get('hide_uid')).toBe('1') + expect(url.searchParams.has('prefill_harness_version')).toBe(false) + expect(url.searchParams.has('prefill_device_info')).toBe(false) expect(url.searchParams.has('prefill_source')).toBe(false) expect(url.searchParams.has('prefill_screen_resolution')).toBe(false) expect(url.searchParams.get('hide_source')).toBe('1') - expect(url.searchParams.get('hide_app_version')).toBe('1') + expect(url.searchParams.get('hide_harness_version')).toBe('1') + expect(url.searchParams.get('hide_device_info')).toBe('1') expect(() => ContactConfig({ contactFormUrl: 'javascript:alert(1)' })).toThrow() }) it.each([NaN, Infinity])('uses CSS pixel dimensions when device pixel ratio is %s', (pixelRatio) => { const url = new URL(contactUrl(ContactConfig({}), { - version: undefined, locale: 'en', width: 800, height: 600, pixelRatio, + uid: null, version: undefined, locale: 'en', deviceInfo: '', width: 800, height: 600, pixelRatio, })) expect(url.searchParams.get('prefill_screen_resolution')).toBe('800x600') })