diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index f177adadec..615270aae0 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -28,9 +28,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } writeFileSync(join(root, 'package.json'), '{"type":"module"}') writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') - copyFileSync(fileURLToPath(new URL('../../../packages/boot/app-boot/lib/types/launch-flag.js', import.meta.url)), join(modules, 'dsh-app-boot', 'launch-flag.js')) writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), ` - export { resolveLaunchFlag } from './launch-flag.js'; export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({}); diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index bfd01f4b7c..4a47a92ce8 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -2,7 +2,7 @@ import { delimiter, join } from 'node:path' import { inspect } from 'node:util' -import { loadLayeredEnv, loadProfileDirectory, resolveLaunchFlag, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv, loadProfileDirectory, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot' import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -21,7 +21,6 @@ async function main(): Promise { installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) - process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = resolveLaunchFlag('DSH_PRODUCT_ANALYTICS_ENABLED', process.env.DSH_PRODUCT_ANALYTICS_ENABLED, true) ? '1' : '0' reportSkippedBundles('dsh', profile) const application = runProfile({ environment: loadLayeredEnv('dsh'), diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 9189c4bffe..5eef9404af 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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: 84dbd7387e8791c9 - zh: ec1ce14b79e953f5 + en: 2a9d7d1de31172d4 + zh: 6680e1bf85abb6f1 /deepseek-harness-desktop/closing-the-window-and-quitting: en: fb852f3bd70d8602 zh: 3d2b0a8d580d7f0a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3d1d252824..56750b7bbf 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its launch-time switch. Web usage is excluded. +Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f73621d3e9..4288b99f2e 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。 +桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 889b4371bc..bb79bbb526 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -67,9 +67,10 @@ export interface DesktopAccountBackend { * @param listener - state recipient. * @param failed - stream failure recipient. * @param expired - live credential-expiry recipient. + * @param policy - optional Desktop collection-policy recipient; disconnected streams publish false. * @returns stream disposer. */ - watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void + watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void, policy?: (enabled: boolean) => void): () => void } /** @@ -86,19 +87,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo state: () => call('getState'), start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }), - watch(listener, failed, expired) { + watch(listener, failed, expired, policy) { let closed = false let socket: WebSocket | undefined let retry: ReturnType | undefined const connect = (): void => { const streamId = randomUUID() const expiryStreamId = randomUUID() + const policyStreamId = randomUUID() void cookies().then((cookie) => { if (closed) return const url = new URL(REMOTE_STREAM_MUX_PATH, origin) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) socket.on('open', () => { + if (policy !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: policyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) }) @@ -106,6 +109,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo try { const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId === policyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') policy?.(frame.value) + else policy?.(false) + return + } if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') if (frame.type === 'item') listener(accountView(frame.value)) @@ -113,11 +121,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo } catch { socket?.close() } }) socket.on('error', () => { socket?.close() }) - socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) - }).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) + socket.on('close', () => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } }) + }).catch(() => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() - return () => { closed = true; clearTimeout(retry); socket?.close() } + return () => { closed = true; policy?.(false); clearTimeout(retry); socket?.close() } }, } } diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index c40d10e999..b367061732 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -70,7 +70,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { }, [api, m.welcomeTitle]) useEffect(() => { - if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('view') + if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {}) visiblePage.current = page if (page === 'key') input.current?.focus() else if (page === 'entry' && focusEntry.current) { @@ -88,7 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { async function saveKey(event: FormEvent) { event.preventDefault() if (busyRef.current) return - void api.analytics?.('save-key') + void api.analytics?.('api_key_save_click', {}) const value = draft.trim() if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) { @@ -202,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) { - - + +

{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}

- +
diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index f465553e57..a442d71cf4 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -272,8 +272,7 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return await c.reload(SELF) const actions = operations(c) const track = vi.fn() - const analytics = c.ctx.get('productAnalytics')! - vi.spyOn(analytics, 'track').mockImplementation(track) + c.ctx.provide('productAnalytics', { track } as never) actions.track?.('auth_page_view', {}) expect(track).toHaveBeenCalledWith('auth_page_view', {}) const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay') diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index c288ffe5d4..1f0ffab903 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -241,11 +241,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number, messageId?: MessageId) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', - 'MessageId', 'MessageImageLoader', 'OpenFileOptions', 'RenderMessageImages', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a632d89bec..08df52a9cb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1705,8 +1705,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'productAnalytics', - summary: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', - description: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', + summary: 'Authenticated event intake; disabled instances do not inspect identity or accept new events.', + description: 'Authenticated event intake; disabled instances do not inspect identity or accept new events.', methods: [ { signature: '@Remote enabled(): boolean', @@ -1714,6 +1714,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'whether this Host currently accepts Desktop analytics.', }, + { + signature: '@Remote({ mode: \'stream\' }) async *watchPolicy(signal: AbortSignal): AsyncIterable', + description: 'Stream the effective policy initially and after live configuration edits.', + parameters: [{ name: 'signal', description: 'subscriber lifetime.' }], + returns: 'current policy values until cancellation or service disposal.', + }, { signature: '@Remote async report(event: ProductEvent): Promise', description: 'Submit selected Desktop fields; missing identity is omitted and never generated.', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5d804ccb0..498f0979b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2934,9 +2934,6 @@ importers: packages/client/product-analytics: dependencies: - '@deepseek-ai/dsh-credentials': - specifier: workspace:* - version: link:../../credentials/credentials '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol @@ -2947,6 +2944,12 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-settings': + specifier: workspace:* + version: link:../../settings/settings '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis