Merge branch 'master' into fix/task-manager-detail-close-race
This commit is contained in:
commit
0bcd0822bc
52 files changed
+463
-93
No files matched your search
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
/deepseek-harness-desktop:
|
||||
en: b8e4f357cf330721
|
||||
zh: cdaaf35a061f41b1
|
||||
en: eccac345d51428fa
|
||||
zh: 4a448b3d63cb8117
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: c323e02e3f0305fe
|
||||
zh: 3c730db632136443
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@ Desktop Host Platform API requests and update-policy requests identify the insta
|
||||
|
||||
A server-expired account credential returns to Welcome when no official API key is configured; an available API key keeps the workspace open. Explicit sign-out follows the same rule. Both Welcome and the workspace display the localized expiry notice.
|
||||
|
||||
Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description.
|
||||
Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization, a packaged usage description, and `com.apple.security.device.audio-input` in the main application and Helper signatures.
|
||||
|
||||
Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform
|
||||
|
||||
账号凭据被服务端判定失效后,未配置官方 API key 时返回 Welcome;有可用 API key 时保持工作区打开。主动退出登录遵循相同规则。Welcome 和工作区均显示本地化的登录失效提示。
|
||||
|
||||
桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。
|
||||
桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权、随包用途说明,以及主应用与 Helper 签名中的 `com.apple.security.device.audio-input` 权限。
|
||||
|
||||
按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。
|
||||
|
||||
@@ -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 过滤而必需。
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface DesktopElectronBuilderConfig {
|
||||
]
|
||||
readonly mac: {
|
||||
readonly extendInfo: { readonly NSMicrophoneUsageDescription: string }
|
||||
readonly entitlements: string
|
||||
readonly entitlementsInherit: string
|
||||
readonly identity: string | undefined
|
||||
readonly forceCodeSigning: boolean
|
||||
readonly notarize: boolean
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"electron-builder": "^26.15.3",
|
||||
"extract-zip": "^2.0.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"koffi": "^3.1.0",
|
||||
"koffi": "3.1.1",
|
||||
"pnpm": "11.7.0",
|
||||
"sharp": "^0.35.3",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -156,6 +156,8 @@ export function createElectronBuilderConfig(
|
||||
forceCodeSigning: true,
|
||||
hardenedRuntime: true,
|
||||
extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' },
|
||||
entitlements: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)),
|
||||
entitlementsInherit: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)),
|
||||
// ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
|
||||
signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
|
||||
notarize: true,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { NotarizeOptions } from '@electron/notarize'
|
||||
import {
|
||||
resolveDesktopAppId,
|
||||
@@ -44,6 +45,12 @@ describe('desktop macOS release signature', () => {
|
||||
expect(config.protocols).toEqual([{ name: 'DeepSeek Harness', schemes: ['dsh'] }])
|
||||
expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
|
||||
expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone')
|
||||
expect(config.mac.entitlementsInherit).toBe(config.mac.entitlements)
|
||||
const entitlements = readFileSync(config.mac.entitlements, 'utf8')
|
||||
for (const key of ['com.apple.security.cs.allow-jit', 'com.apple.security.cs.allow-unsigned-executable-memory',
|
||||
'com.apple.security.cs.disable-library-validation', 'com.apple.security.device.audio-input']) {
|
||||
expect(entitlements).toContain(`<key>${key}</key>\n <true/>`)
|
||||
}
|
||||
expect(config.extraResources).toHaveLength(2)
|
||||
expect(config.extraResources[0]?.to).toBe('runtime')
|
||||
expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime')
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
|
||||
/apps-web-browser-e2e:
|
||||
en: e1c3082481d0cace
|
||||
zh: 28cc4c5e63c0219c
|
||||
en: 3e8ecd3509fa944f
|
||||
zh: 3fd3ff9fcd1e1e90
|
||||
/apps-web-browser-e2e/completion-observations:
|
||||
en: 1a93c8d1a0e07ad8
|
||||
zh: e7c252f54f19c137
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
These tests boot the real web composition in-process and drive it with real browsers over real HTTP. Chromium runs the full lane; the [model and reasoning picker scenario](declared-reasoning.e2e.ts) also runs in WebKit to cover native mouse focus behavior. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||
These tests boot the real web composition in-process and drive it with real browsers over real HTTP. Chromium runs the full lane; the [model and reasoning picker scenario](declared-reasoning.e2e.ts) also runs in WebKit to cover native mouse focus behavior. [Session replay recovery](session-replay-reload.e2e.ts) also runs in WebKit, sharing the `fresh-round-trip` recording and expected output without rewriting them. It covers active-stream reload, completed-turn reload, and reopening a Session. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||
|
||||
After installing workspace dependencies, install the browsers and their system dependencies from the repository root:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这些测试在进程内启动真实的 web 组合,并用真实浏览器通过真实 HTTP 驱动它。Chromium 运行整个 lane;[模型与推理强度选择场景](declared-reasoning.e2e.ts) 还在 WebKit 中运行,以覆盖原生鼠标焦点行为。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。
|
||||
这些测试在进程内启动真实的 web 组合,并用真实浏览器通过真实 HTTP 驱动它。Chromium 运行整个 lane;[模型与推理强度选择场景](declared-reasoning.e2e.ts) 还在 WebKit 中运行,以覆盖原生鼠标焦点行为。[会话回放恢复](session-replay-reload.e2e.ts)还在 WebKit 中运行,共用 `fresh-round-trip` 的录制和预期输出,不改写这些文件。该用例覆盖输出中刷新、完成后刷新和重新打开 Session。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。
|
||||
|
||||
安装工作区依赖后,在仓库根目录安装浏览器及其系统依赖:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
- menu:
|
||||
- menuitem "设置"
|
||||
- menuitem "联系我们"
|
||||
- menuitem "意见反馈"
|
||||
- menuitem "登录"
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Recorded-session recovery through Chromium and WebKit's native JSON validation. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, webkit } from 'playwright'
|
||||
import { describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url))
|
||||
|
||||
describe.skipIf(webSnapshotMode() === 'record')('web session replay', () => {
|
||||
it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])(
|
||||
'$name restores an active stream, a completed turn, and a reopened session', async ({ engine }) => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: 'read-only' })
|
||||
onTestFinished(() => scaffold.close())
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const reached = Promise.withResolvers<string>()
|
||||
let paused = false
|
||||
// Hold the recorded stream until the reloaded page has received its active baseline.
|
||||
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (!paused && chunk.type === 'text-delta' && chunk.text.trim()) {
|
||||
paused = true
|
||||
reached.resolve(chunk.text.trim())
|
||||
await release.promise
|
||||
}
|
||||
}
|
||||
}, { prepend: true })
|
||||
onTestFinished(() => { release.resolve(undefined); dispose() })
|
||||
const browser = await engine.launch()
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await newEnglishPage(browser)
|
||||
onTestFailed(() => saveFailureShot(page, `session-replay-reload-${engine.name()}`))
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const prompts = fixtureUserPrompts(await readFile(await selectedSessionFixture(FIXTURE), 'utf8'))
|
||||
expect(prompts).toHaveLength(1)
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await input.fill(prompts[0]!)
|
||||
await input.press('Enter')
|
||||
const partial = await reached.promise
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
try {
|
||||
const streaming = page.locator('[data-streaming="true"]').getByText(partial, { exact: true })
|
||||
await streaming.waitFor({ timeout: 15_000 })
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload()
|
||||
await streaming.waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
await settled
|
||||
}
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload()
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await compareOrRefreshGolden(UI_EXPECTED,
|
||||
await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), 'replay')
|
||||
|
||||
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
|
||||
await page.getByText('Into the Unknown', { exact: true }).waitFor()
|
||||
await page.getByRole('treeitem').filter({ has: page.getByText('Use the bash tool to', { exact: true }) }).click()
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await compareOrRefreshGolden(UI_EXPECTED,
|
||||
await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), 'replay')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -103,6 +103,7 @@
|
||||
"tests/session-unarchive.e2e.ts",
|
||||
"tests/session-archive-active.e2e.ts",
|
||||
"tests/replay-round-trip.e2e.ts",
|
||||
"tests/session-replay-reload.e2e.ts",
|
||||
"tests/hmr-live.e2e.ts",
|
||||
"tests/preview-boot.e2e.ts",
|
||||
"tests/seeded-history.e2e.ts",
|
||||
|
||||
@@ -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 @@
|
||||
设置联系我们登录
|
||||
设置意见反馈登录
|
||||
@@ -1,5 +1,5 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
|
||||
function objectWithForgedIntrinsicPrototype(revoked = false): Record<string, unknown> {
|
||||
@@ -64,6 +64,35 @@ describe('snapshotJsonValue', () => {
|
||||
expect(Object.getPrototypeOf(arraySnapshot)).toBe(Array.prototype)
|
||||
})
|
||||
|
||||
it('accepts intrinsic containers with WebKit native constructor formatting across realms', () => {
|
||||
const foreign = runInNewContext('({ Object, Array, value: { nested: [1] } })') as {
|
||||
Object: ObjectConstructor
|
||||
Array: ArrayConstructor
|
||||
value: { nested: number[] }
|
||||
}
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, 'toString')!
|
||||
const originalToString = originalDescriptor.value as (this: unknown) => string
|
||||
const toString = vi.spyOn(Function.prototype, 'toString').mockImplementation(function (this: unknown) {
|
||||
if (this === Object || this === foreign.Object) return 'function Object() {\n [native code]\n}'
|
||||
if (this === Array || this === foreign.Array) return 'function Array() {\n [native code]\n}'
|
||||
return originalToString.call(this)
|
||||
})
|
||||
try {
|
||||
for (const value of [{ nested: [1] }, [1], foreign.value, foreign.value.nested]) {
|
||||
expect(isJsonValue(value)).toBe(true)
|
||||
const snapshot = snapshotJsonValue(value)
|
||||
expect(snapshot).toEqual(value)
|
||||
expect(snapshot).not.toBe(value)
|
||||
}
|
||||
const forged = objectWithForgedIntrinsicPrototype()
|
||||
expect(isJsonValue(forged)).toBe(false)
|
||||
expect(snapshotJsonValue(forged)).toBeUndefined()
|
||||
} finally {
|
||||
toString.mockRestore()
|
||||
}
|
||||
expect(Object.getOwnPropertyDescriptor(Function.prototype, 'toString')).toEqual(originalDescriptor)
|
||||
})
|
||||
|
||||
it('reads each object value and array slot once while materializing', () => {
|
||||
class Exotic {
|
||||
readonly accepted = false
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"koffi": "^3.1.0",
|
||||
"koffi": "3.1.1",
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:*",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:*",
|
||||
"koffi": "^3.1.0"
|
||||
"koffi": "3.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-lazy-require": "workspace:*",
|
||||
"@deepseek-ai/dsh-win32-process": "workspace:*",
|
||||
"koffi": "^3.1.0"
|
||||
"koffi": "3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:*",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@deepseek-ai/dsh-session-format-v3-to-v4": "workspace:*",
|
||||
"@deepseek-ai/node-addon-system": "workspace:~",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"koffi": "^3.1.0"
|
||||
"koffi": "3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-lazy-require": "workspace:*",
|
||||
"@deepseek-ai/dsh-win32-process": "workspace:*",
|
||||
"koffi": "^3.1.0",
|
||||
"koffi": "3.1.1",
|
||||
"node-pty": "1.2.0-beta.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-lazy-require": "workspace:*",
|
||||
"koffi": "^3.1.0"
|
||||
"koffi": "3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: f21b7bd8040a72eb
|
||||
zh: 19548d3e537c0b5b
|
||||
/deepseek-ai-dsh-util-values/understand-the-implementation:
|
||||
en: 997cf0e584054ace
|
||||
zh: 5e71d64eb1e32ddd
|
||||
en: 36866610a9a7f898
|
||||
zh: aa2f0e4c3cc12335
|
||||
/deepseek-ai-dsh-util-values/understand-the-implementation/source-map:
|
||||
en: a3e80436a6006043
|
||||
zh: 1de25c1bf436e494
|
||||
|
||||
@@ -55,7 +55,7 @@ Use `assertNever(value, context?)` in the default branch of a closed discriminat
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The JSON validator uses an explicit work stack and tracks only the active ancestor chain, so deeply nested values do not consume the JavaScript call stack and repeated non-cyclic references remain valid. Snapshot writes use own data properties, including for names such as `__proto__`. Value operations derive their result only from their arguments; `WeakMapWithValues` stores only instance-owned associations.
|
||||
The JSON validator uses an explicit work stack and tracks only the active ancestor chain, so deeply nested values do not consume the JavaScript call stack and repeated non-cyclic references remain valid. Intrinsic arrays and plain objects are recognized across JavaScript realms using native constructor representations from the running engine. Snapshot writes use own data properties, including for names such as `__proto__`. Value operations derive their result only from their arguments; `WeakMapWithValues` stores only instance-owned associations.
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ const snapshot = snapshotJsonValue(input) as JsonValue
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
JSON 校验器使用显式工作栈,并只跟踪当前祖先链,因此深层嵌套值不会消耗 JavaScript 调用栈,重复但无循环的引用仍然有效。快照写入使用自有数据属性,包括 `__proto__` 等名称。值操作的结果只取决于传入参数;`WeakMapWithValues` 仅保存实例自有的关联。
|
||||
JSON 校验器使用显式工作栈,并只跟踪当前祖先链,因此深层嵌套值不会消耗 JavaScript 调用栈,重复但无循环的引用仍然有效。内建数组与普通对象通过当前引擎的原生构造器文本表示进行跨 JavaScript realm 识别。快照写入使用自有数据属性,包括 `__proto__` 等名称。值操作的结果只取决于传入参数;`WeakMapWithValues` 仅保存实例自有的关联。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function assertNever(value: never, context?: string): never {
|
||||
throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`)
|
||||
}
|
||||
|
||||
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
|
||||
/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */
|
||||
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const constructor: unknown = descriptor?.value
|
||||
@@ -22,7 +22,7 @@ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): b
|
||||
try {
|
||||
return constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
|
||||
&& Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === 'Array' ? Array : Object)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Generated by scripts/gen-workflow-guest.ts. Do not edit directly. */
|
||||
|
||||
/** Self-contained ESM guest transported into the mounted PTC runtime. */
|
||||
export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");Line truncated
|
||||
export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");Line truncated
|
||||
Generated
+7
-7
@@ -667,7 +667,7 @@ importers:
|
||||
specifier: ^4.2.0
|
||||
version: 4.3.1
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
pnpm:
|
||||
specifier: 11.7.0
|
||||
@@ -8389,7 +8389,7 @@ importers:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -8991,7 +8991,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../util/native-command
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -10279,7 +10279,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../subprocess/win32-process
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -10889,7 +10889,7 @@ importers:
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -12746,7 +12746,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../win32-process
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
node-pty:
|
||||
specifier: 1.2.0-beta.15
|
||||
@@ -12771,7 +12771,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../util/lazy-require
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
|
||||
@@ -40,6 +40,9 @@ allowBuilds:
|
||||
protobufjs: false
|
||||
node-addon-require-builtin: false
|
||||
# JSONL durability calls MoveFileExW with write-through publication on Windows.
|
||||
# Consumers pin 3.1.1: Koffi 3.3.2's source build fails under GCC 13 when
|
||||
# optional prebuilts are omitted. Upgrade all consumers together after the
|
||||
# replacement passes that build and the release packed-install check.
|
||||
koffi: true
|
||||
# The Python runtime deploy includes the reviewed workspace postinstall that
|
||||
# restores the executable bit on node-pty's macOS spawn helper.
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"apps/web/tests/session-unarchive.e2e.ts",
|
||||
"apps/web/tests/session-archive-active.e2e.ts",
|
||||
"apps/web/tests/replay-round-trip.e2e.ts",
|
||||
"apps/web/tests/session-replay-reload.e2e.ts",
|
||||
"apps/web/tests/hmr-live.e2e.ts",
|
||||
"apps/web/tests/preview-boot.e2e.ts",
|
||||
"apps/web/tests/seeded-history.e2e.ts",
|
||||
|
||||
Reference in new issue
Block a user