Merge pull request #5310 from deepseek-harness/feat/feedback-device-info

feat(web): 反馈问卷预填账号、版本与桌面设备信息
This commit is contained in:
imccyu authored and GitHub committed 2026-09-28 15:07:50 +08:00
commit 4e25074651
27 files changed
+295 -63

No files matched your search

+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 `<webview>`. 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.
+1 -1
View File
@@ -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 参数。
只有主应用窗口启用 `<webview>`。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 过滤而必需。
+34
View File
@@ -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}`)
}
+6
View File
@@ -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<string>
readonly updates: {
status(): Promise<DesktopUpdatePresentation>
open(): Promise<void>
+5
View File
@@ -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<void> {
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
+1
View File
@@ -14,6 +14,7 @@ function createProductApi(): DshDesktopProductApi {
return {
protocolVersion: 1,
browser: createDesktopBrowserBridge(),
deviceInfo: () => ipcRenderer.invoke(DESKTOP_IPC.deviceInfo) as Promise<string>,
keyboard: {
closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
subscribe: (listener) => {
+50
View File
@@ -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')
})
+14 -12
View File
@@ -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) => {
+15
View File
@@ -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')
+7 -3
View File
@@ -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<string, Record<string, unknown>>()
const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' }))
const invoke = vi.fn<(...args: unknown[]) => Promise<unknown>>(() => 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<string> }
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')
@@ -1,4 +1,4 @@
- menu:
- menuitem "设置"
- menuitem "联系我们"
- menuitem "意见反馈"
- menuitem "登录"
+3 -1
View File
@@ -28,6 +28,8 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
// The shared menu golden uses the Linux shortcut profile on every test host.
await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'Linux x86_64' }) })
if (desktop) await page.addInitScript(() => { Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } }) })
tripwire = watchConsole(page)
})
@@ -64,7 +66,7 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti
await accountMenu.click()
const menu = page.getByRole('menu')
await menu.waitFor()
expect(await menu.getByRole('menuitem').allTextContents()).toEqual(['设置', '联系我们', '登录'])
expect(await menu.getByRole('menuitem').allTextContents()).toEqual(['设置', '意见反馈', '登录'])
const menuBox = (await menu.boundingBox())!
expect(Math.abs(menuBox.width - 124)).toBeLessThan(1)
expect(Math.abs(menuBox.height - 128)).toBeLessThan(1)
@@ -15,8 +15,8 @@
en: c26f81e8adf0128b
zh: e11ab371f51f66bc
/deepseek-ai-dsh-client-ui-settings-account/use-this-package:
en: bc11406a04989a27
zh: 27e5fffa2b3eda6d
en: db54432d24abc9d9
zh: 55add79efabfae08
/deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding:
en: c1c4db71fd09ca13
zh: a40c0a01e9951945
@@ -40,11 +40,11 @@ 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, including uid, device_info and harness_version regardless of the configured URL. Tokens and masked contact details stay out, and a signed-out, still-loading, or failed profile leaves prefill_uid out. When the Desktop bridge has no deviceInfo reader, the form opens with navigator.userAgent. 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.
The sidebar account menu uses the shared Menu surface and backdrop blur. The signed-in menu keeps the shared row typography; the signed-out menu carries its own wider rows and its Contact us copy for the Feedback entry. Its Settings row shows the effective key combination supplied by the shell. Closing Settings returns focus to the sidebar account launcher.
The sidebar account menu uses the shared Menu surface and backdrop blur. The signed-in menu keeps the shared row typography; the signed-out menu carries its own wider rows. Both menus label the questionnaire entry Feedback. Its Settings row shows the effective key combination supplied by the shell. Closing Settings returns focus to the sidebar account launcher.
The account card’s More account information link has no underline and opens the root of the Host-provided Platform usage URL in the system browser, following `platformOrigin`.
@@ -40,11 +40,11 @@ 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 参数先被清除,uid、device_info 与 harness_version 的隐藏参数不依赖配置 URL 的取值。不传 token 或脱敏联系方式;未登录、profile 仍在加载或读取失败时都不填写 prefill_uid。Desktop 桥接没有 deviceInfo 读取器时,打开的表单以 navigator.userAgent 作为 device_info。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。
侧边栏账号菜单使用公共 Menu 的表面和背景模糊。已登录菜单沿用公共菜单项排版;未登录菜单使用自己的较宽菜单项,并将“意见反馈”入口的文案改为“联系我们”。设置菜单项显示外壳传入的有效组合键。关闭设置后,焦点回到侧栏账号入口。
侧边栏账号菜单使用公共 Menu 的表面和背景模糊。已登录菜单沿用公共菜单项排版;未登录菜单使用自己的较宽菜单项。两种菜单的问卷入口均为“意见反馈”。设置菜单项显示外壳传入的有效组合键。关闭设置后,焦点回到侧栏账号入口。
账号卡片的“更多账号信息”链接不带下划线,通过系统浏览器打开 Host 提供的 Platform 用量 URL 的根路径,遵循 `platformOrigin` 配置。
@@ -71,7 +71,7 @@ export function AccountMenu({
items={[
{ id: 'settings', label: t('settings'), icon: <IconSettingsOutlineMedium size={16} />,
...(settingsShortcut === undefined ? {} : { shortcut: settingsShortcut }) },
{ id: 'contact', label: signedIn ? t('contactUs') : t('contactUsSignedOut'), icon: <IconPaperPlaneOutlineMedium size={16} /> },
{ id: 'contact', label: t('contactUs'), icon: <IconPaperPlaneOutlineMedium size={16} /> },
...(signedIn ? [{ id: 'signout', label: t('signOut'), icon: <LogoutIcon />, disabled: busy }]
: [{ id: 'signin', label: t('signIn'), icon: <IconUserOutlineMedium size={16} /> }]),
]}
@@ -58,7 +58,7 @@ export interface AccountSectionInjected {
* @returns after both reads settle.
*/
refreshAccount: () => Promise<void>
/** 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
@@ -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')
// Exclude unsupported questionnaire fields from configured URLs.
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
}
@@ -161,12 +161,34 @@ 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<string> }
}).dshDesktop?.deviceInfo
if (readDeviceInfo === undefined) {
// A Desktop bridge without the optional reader reports the renderer user agent.
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 }) },
@@ -14,7 +14,7 @@ export const en = {
failureTitle: 'Could not sign in',
platformFailed: 'Could not complete the operation. Try again.', platformRetry: 'Retry',
loading: 'Loading…', backToHarness: 'Back to DeepSeek Harness',
settings: 'Settings', contactUs: 'Feedback', contactUsSignedOut: 'Contact us', menu: 'Account menu',
settings: 'Settings', contactUs: 'Feedback', menu: 'Account menu',
nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in',
signIn: 'Sign in', signOut: 'Sign out',
signOutUnknownDescription: 'Could not check running tasks. Signing out may interrupt tasks using this account. Sign out now?',
@@ -50,7 +50,7 @@ export const zh: Record<AccountKey, string> = {
failureTitle: '登录失败',
platformFailed: '操作未完成,请重试', platformRetry: '重试',
loading: '加载中…', backToHarness: '返回 DeepSeek Harness',
settings: '设置', contactUs: '意见反馈', contactUsSignedOut: '联系我们', menu: '账号菜单',
settings: '设置', contactUs: '意见反馈', menu: '账号菜单',
nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录',
signIn: '登录', signOut: '退出登录',
signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?',
@@ -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),
@@ -187,12 +187,12 @@ it.each([en, zh])('offers settings, contact and sign-in from the signed-out acco
expect(trigger.textContent).toBe(copy.more)
expect(trigger.querySelector('svg')).not.toBeNull()
fireEvent.click(trigger)
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([copy.settings, copy.contactUsSignedOut, copy.signIn])
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([copy.settings, copy.contactUs, copy.signIn])
await expect(`${screen.getByRole('menu').textContent}\n`).toMatchFileSnapshot(`./expected/menu-signed-out-${copy === en ? 'en' : 'zh'}.txt`)
fireEvent.click(screen.getByRole('menuitem', { name: copy.settings }))
expect(openSettings).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: copy.menu }))
fireEvent.click(screen.getByRole('menuitem', { name: copy.contactUsSignedOut }))
fireEvent.click(screen.getByRole('menuitem', { name: copy.contactUs }))
expect(operations.contactUs).toHaveBeenCalledOnce()
})
@@ -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<string> => { 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<string>()
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()
@@ -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')
})
@@ -1 +1 @@
SettingsContact usSign in
SettingsFeedbackSign in
@@ -1 +1 @@
设置联系我们登录
设置意见反馈登录