fix(desktop): require local opt-in for test authentication popups

This commit is contained in:
winewill committed 2026-10-08 14:38:27 +08:00
1 parent d83cdfafd6
commit e8226b2a84
11 files changed
+344 -10

No files matched your search

+7 -4
View File
@@ -66,11 +66,14 @@
en: be1379e4b4703e14
zh: 244ccd2874bb155a
/deepseek-harness-desktop/updates:
en: 10d58aa5bbffbd67
zh: d9c2476e744a6537
en: 41d0f50eb7173342
zh: ce940ef3cbcc6fa5
/deepseek-harness-desktop/updates/local-desktop-settings:
en: 2d7bdba035bb468f
zh: 8689f725254be306
/deepseek-harness-desktop/updates/mandatory-update-policy:
en: 1527dceec540649e
zh: 543782c36f91f893
en: 3933ae070b0b76be
zh: b1b46615208b4e65
/deepseek-harness-desktop/updates/local-updater-qualification:
en: bc50fd8a4d96c227
zh: fb0a6ce80c31cda6
+21 -1
View File
@@ -445,6 +445,24 @@ If task teardown fails after confirmed Host exit, installation is refused and th
Confirmed Host exit without successful task teardown displays localized recovery guidance in both ordinary and mandatory update dialogs. A typed preparation cause selects that guidance in each locale; changing translated wording cannot reclassify the failure. “View technical details” is collapsed by default and exposes only exit status, signal, shutdown acknowledgement, and deadline facts, not plugin stderr. Expanding it neither retries nor authorizes installation.
<a id="local-desktop-settings"></a>
### Local desktop settings
The Electron main process reads `app.getPath('userData')/desktop/settings.json` once at startup, independently of Host and Cordis configuration. The default packaged paths are `%APPDATA%\@deepseek-ai\dsh-desktop\desktop\settings.json` on Windows and `~/Library/Application Support/@deepseek-ai/dsh-desktop/desktop/settings.json` on macOS. Development uses the `userData` path printed by its launcher. `DSH_HOME` does not relocate this file.
On first launch, including after an upgrade, Desktop creates this document if it is absent:
```json
{
"updates": {
"allowTestAuthPopupWindow": false
}
}
```
Existing documents are never overwritten; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-ev-signing).
<a id="mandatory-update-policy"></a>
### Mandatory update policy
@@ -466,7 +484,9 @@ Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=tes
Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, DSH_CLIENT_VERSION, bundled dsh version, current locale and UTC offset, an empty bundle ID, and fixed Nightly. It uses no business login credentials or installation ID.
With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually.
With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login only when the local `updates.allowTestAuthPopupWindow` setting is `true`. It defaults to `false`, suppressing the entire authentication dialog flow during startup, manual update checks, mandatory-update refreshes, and deferred or repeated failures. Policy requests still require gateway authentication; an existing valid cookie can authorize them, and an authentication failure never clears a known mandatory block. Ordinary updates and product account authentication remain independent. Production keeps its anonymous policy requests regardless of this setting.
When permitted, login is offered during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually.
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
+21 -1
View File
@@ -447,6 +447,24 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
已确认 Host 退出但任务未成功收尾时,常规与强更弹窗均展示本地化恢复提示。两种语言都根据类型化的准备失败原因选择提示,翻译文案变化不会改变失败分类。“查看技术详情”默认折叠,仅展示退出状态、信号、关闭确认和截止时间事实,不展示插件 stderr。展开详情既不重试,也不授权安装。
<a id="local-desktop-settings"></a>
### 本地桌面设置
Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/settings.json`,独立于 Host 和 Cordis 配置。打包应用默认路径为 Windows 的 `%APPDATA%\@deepseek-ai\dsh-desktop\desktop\settings.json` 和 macOS 的 `~/Library/Application Support/@deepseek-ai/dsh-desktop/desktop/settings.json`。开发模式使用启动器打印的 `userData` 路径。`DSH_HOME` 不改变此文件的位置。
首次启动(包括升级后首次启动)时,Desktop 会在文件缺失时创建以下内容:
```json
{
"updates": {
"allowTestAuthPopupWindow": false
}
}
```
已有文件不会被覆盖;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-ev-signing)删除它。
<a id="mandatory-update-policy"></a>
### 强制更新策略
@@ -468,7 +486,9 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、DSH_CLIENT_VERSION、内置 dsh 版本、当前语言与 UTC 偏移、空 bundle ID 和固定 Nightly。不使用业务登录凭据或安装 ID。
启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应会在用户主动检查和打包应用首次启动检查时提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。
启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应仅在本地 `updates.allowTestAuthPopupWindow` 为 `true` 时提供登录入口。该字段默认为 `false`,禁止启动、手动检查更新、强更刷新以及延迟或重复失败触发的整个鉴权弹窗流程。策略请求仍需网关鉴权;已有有效 Cookie 可用于请求,鉴权失败不会解除已知强更阻塞。常规更新和产品账号鉴权保持独立。正式环境始终使用原有匿名策略请求,不受此设置影响。
允许弹窗时,用户主动检查和打包应用首次启动检查会提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
+5 -1
View File
@@ -51,6 +51,7 @@ import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopP
import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
import { readDesktopSettings } from './settings.ts'
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
import { readDesktopRuntime } from './runtime-tree.ts'
import { DesktopBrowserGuests } from './browser-guests.ts'
@@ -313,6 +314,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
}
async function main(): Promise<void> {
const desktopSettings = readDesktopSettings(app.getPath('userData'))
void pruneCrashReports(app.getPath('logs'))
const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
@@ -875,6 +877,7 @@ async function main(): Promise<void> {
void authenticatePolicy().catch((error: unknown) => { console.error(error) })
}
const queuePolicyAuthentication = (): void => {
if (!desktopSettings.updates.allowTestAuthPopupWindow) return
if (authenticationOperation !== undefined) {
policyAuth?.focus(); updateDialog.focus()
return
@@ -883,7 +886,8 @@ async function main(): Promise<void> {
flushQueuedPolicyAuthentication()
}
const runPolicyAuthentication = async () => {
if (policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined
if (!desktopSettings.updates.allowTestAuthPopupWindow
|| policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined
const parent = mandatoryUI?.confirmationWindow ?? currentDialogWindow()
if (parent === undefined) return undefined
const consent = await updateDialog.show(parent, { type: 'info', title: locale.messages.policyLoginTitle,
+53
View File
@@ -0,0 +1,53 @@
/** Local Electron shell settings, loaded once per process independently of the Host. */
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
/** Resolved shell preferences; absent fields use the defaults without rewriting existing files. */
export interface DesktopSettings {
readonly updates: {
readonly allowTestAuthPopupWindow: boolean
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Read shell settings, creating the default document exclusively when missing.
* @param userData - Electron's userData directory, independent of DSH_HOME.
* @returns Validated settings, or defaults when reading, creating or validating fails; changes require a restart.
*/
export function readDesktopSettings(userData: string): DesktopSettings {
const directory = join(userData, 'desktop')
const path = join(directory, 'settings.json')
const defaults: DesktopSettings = { updates: { allowTestAuthPopupWindow: false } }
try {
let raw: string
try { raw = readFileSync(path, 'utf8') } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
mkdirSync(directory, { recursive: true, mode: 0o700 })
try { writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) } catch (error) {
// An existing document belongs to the user, including one created during initialization.
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
raw = readFileSync(path, 'utf8')
}
const value: unknown = JSON.parse(raw)
if (!record(value) || (value.updates !== undefined && !record(value.updates))) {
throw new Error('expected a JSON object with an optional updates object')
}
const allow = value.updates?.allowTestAuthPopupWindow
if (allow !== undefined && typeof allow !== 'boolean') {
throw new Error('updates.allowTestAuthPopupWindow must be true or false')
}
return { updates: { allowTestAuthPopupWindow: allow ?? false } }
} catch (error) {
try {
console.warn(`desktop settings: ${path}: using defaults because settings could not be loaded`, error)
} catch (_logError) {
// TODO: Persist settings diagnostics when console output is unavailable, without blocking startup.
}
return defaults
}
}
@@ -1,4 +1,8 @@
{
"disabled": [
"Checking for updates…",
"You’re up to date!"
],
"returned": [
"Checking for updates…",
"You’re up to date!",
+98 -3
View File
@@ -3,7 +3,7 @@ import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import type { IpcMainInvokeEvent } from 'electron'
import { join } from 'node:path'
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron'
import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts'
@@ -371,6 +371,12 @@ vi.mock('../src/welcome-backend.ts', () => ({
}),
}))
function configureTestAuthPopup(allow: boolean): void {
const directory = join(harness.app.getPath('userData'), 'desktop')
mkdirSync(directory, { recursive: true })
writeFileSync(join(directory, 'settings.json'), JSON.stringify({ updates: { allowTestAuthPopupWindow: allow } }))
}
function invoke(channel: string, origin = channel === DESKTOP_IPC.boot ? 'app' : 'shell', ...args: unknown[]): unknown {
const handler = harness.handlers.get(channel)
if (handler === undefined) throw new Error(`missing handler ${channel}`)
@@ -437,6 +443,19 @@ afterEach(async () => {
})
describe('desktop main startup', () => {
it('starts the Host with authentication popups disabled when local settings are invalid', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
const raw = '{"updates":{"allowTestAuthPopupWindow":"true"}}'
writeFileSync(path, raw)
await readyForUpdate()
expect(harness.hosts).toHaveLength(1)
expect(testAuth.login).not.toHaveBeenCalled()
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error))
expect(harness.dialog.showMessageBox).not.toHaveBeenCalledWith(expect.objectContaining({ title: en.startupFailed }))
expect(readFileSync(path, 'utf8')).toBe(raw)
})
it('routes shell update documents and assets through the registered main protocol handler', async () => {
const root = join(import.meta.dirname, '..')
vi.spyOn(harness.app, 'getAppPath').mockReturnValue(root)
@@ -531,7 +550,77 @@ describe('desktop main startup', () => {
expect(console.error).toHaveBeenLastCalledWith(expect.objectContaining({ message: 'overlay unavailable' }))
})
it.each(['missing-file', 'missing-field', 'disabled'] as const)('suppresses every test auth dialog for %s while continuing update checks', async (configuration) => {
if (configuration === 'disabled') configureTestAuthPopup(false)
if (configuration === 'missing-field') {
configureTestAuthPopup(true)
writeFileSync(join(harness.app.getPath('userData'), 'desktop', 'settings.json'), '{}')
}
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
vi.stubGlobal('fetch', request)
await readyForUpdate()
await vi.advanceTimersByTimeAsync(0)
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
expect(request).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ credentials: 'include', redirect: 'error' }))
// Editing the file cannot change the consent policy of an already running process.
configureTestAuthPopup(true)
harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
await invoke(DESKTOP_IPC.updatesOpen, 'app')
harness.powerMonitor.emit('resume')
await vi.advanceTimersByTimeAsync(10_000)
expect(request.mock.calls.length).toBeGreaterThan(2)
expect(testAuth.login).not.toHaveBeenCalled()
expect(testAuth.focus).not.toHaveBeenCalled()
const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
const expected = JSON.parse(readFileSync(new URL('./expected/policy-login-en.json', import.meta.url), 'utf8')) as Record<string, string[]>
expect(messages).toEqual(expected.disabled)
expect(harness.updateDownload).not.toHaveBeenCalled()
})
it.each([false, true])('retains anonymous production checks with popup permission %s', async (allow) => {
configureTestAuthPopup(allow)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'anonymous',
allowedPageOrigins: ['https://downloads.example.com'] }
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
vi.stubGlobal('fetch', request)
await readyForUpdate()
await invoke(DESKTOP_IPC.updatesOpen, 'app')
expect(request).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ credentials: 'omit' }))
expect(testAuth.login).not.toHaveBeenCalled()
expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message))
.not.toContain(en.policyLoginRequired)
})
it('keeps a received mandatory decision when test cookies expire with popups disabled', async () => {
configureTestAuthPopup(false)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
.mockResolvedValueOnce(Response.json({ code: 40005, data: { show_content: { title: 'Update', detail: 'Required' },
desktop_app_link: 'https://downloads.example.com/' } }))
vi.stubGlobal('fetch', request)
const host = await readyForUpdate()
await harness.policyBlocked.promise
await vi.advanceTimersByTimeAsync(1000)
const modal = harness.windows[0]!
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh')
expect(harness.handlers.get(MANDATORY_IPC.status)!(owned)).toMatchObject({
policy: { blocking: true, error: 'authentication-required' },
})
expect(testAuth.login).not.toHaveBeenCalled()
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
expect(modal.isDestroyed()).toBe(false)
expect(host.stop).not.toHaveBeenCalled()
})
it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => {
configureTestAuthPopup(true)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () =>
@@ -575,6 +664,7 @@ describe('desktop main startup', () => {
})
it.each(['returned', 'cancelled', 'failed'] as const)('requires explicit test login and handles %s without downloading', async (outcome) => {
configureTestAuthPopup(true)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
const request = vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
@@ -602,6 +692,7 @@ describe('desktop main startup', () => {
})
it('does not open Feishu when the user declines test login', async () => {
configureTestAuthPopup(true)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
@@ -625,6 +716,7 @@ describe('desktop main startup', () => {
})
it('retains the embedded block and running Host after expired test login is cancelled', async () => {
configureTestAuthPopup(true)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
@@ -1557,7 +1649,8 @@ describe('desktop main startup', () => {
}
})
it('queues policy authentication until the ordinary result dialog closes', async () => {
it.each([false, true])('handles a deferred authentication failure with popup permission %s', async (allow) => {
configureTestAuthPopup(allow)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
const request = vi.fn<typeof fetch>()
@@ -1583,7 +1676,9 @@ describe('desktop main startup', () => {
.not.toContain(en.policyLoginRequired)
ordinaryResult.resolve({ response: 0 })
await operation
await policyShown.promise
if (allow) await policyShown.promise
else expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message))
.not.toContain(en.policyLoginRequired)
expect(testAuth.login).not.toHaveBeenCalled()
})
+83
View File
@@ -0,0 +1,83 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import * as fs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
import { readDesktopSettings } from '../src/settings.ts'
vi.mock('node:fs', async importOriginal => ({ ...await importOriginal<typeof import('node:fs')>() }))
beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) })
afterEach(() => { vi.restoreAllMocks() })
function fixture() {
const userData = mkdtempSync(join(tmpdir(), 'dsh-desktop-settings-'))
onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) })
const directory = join(userData, 'desktop')
mkdirSync(directory)
return { userData, path: join(directory, 'settings.json') }
}
it('creates a discoverable default file on first launch without touching other preferences', () => {
const { userData, path } = fixture()
rmSync(join(userData, 'desktop'), { recursive: true })
writeFileSync(join(userData, 'keybindings.json'), 'existing shortcuts\n')
expect(readDesktopSettings(userData)).toEqual({ updates: { allowTestAuthPopupWindow: false } })
expect(readFileSync(path, 'utf8')).toBe('{\n "updates": {\n "allowTestAuthPopupWindow": false\n }\n}\n')
expect(readFileSync(join(userData, 'keybindings.json'), 'utf8')).toBe('existing shortcuts\n')
})
it.each(['{}', '{"updates": {}}', '{"other": 1, "updates": {"other": true}}'])('defaults an older document without rewriting it: %s', (raw) => {
const { userData, path } = fixture()
writeFileSync(path, raw)
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
expect(readFileSync(path, 'utf8')).toBe(raw)
})
it('loads explicit opt-in and opt-out on the next read without changing an earlier process snapshot', () => {
const { userData, path } = fixture()
for (const allow of [true, false]) {
const raw = JSON.stringify({ updates: { allowTestAuthPopupWindow: allow }, custom: 'preserve' })
writeFileSync(path, raw)
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(allow)
expect(readFileSync(path, 'utf8')).toBe(raw)
}
const previous = readDesktopSettings(userData)
writeFileSync(path, '{"updates":{"allowTestAuthPopupWindow":true}}')
expect(previous.updates.allowTestAuthPopupWindow).toBe(false)
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(true)
})
it.each(['', '{', 'null', '[]', '{"updates":null}', '{"updates":[]}',
'{"updates":{"allowTestAuthPopupWindow":"false"}}', '{"updates":{"allowTestAuthPopupWindow":1}}',
'{"updates":{"allowTestAuthPopupWindow":null}}'])('reports invalid settings with their path and preserves the original: %s', (raw) => {
const { userData, path } = fixture()
writeFileSync(path, raw)
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error))
expect(readFileSync(path, 'utf8')).toBe(raw)
})
it('reports filesystem errors instead of replacing unreadable settings', () => {
const { userData, path } = fixture()
mkdirSync(path)
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error))
expect(fs.statSync(path).isDirectory()).toBe(true)
})
it.each(['mkdirSync', 'writeFileSync'] as const)('continues with disabled popups when %s fails', (operation) => {
const { userData, path } = fixture()
const failure = Object.assign(new Error('settings creation denied'), { code: 'EACCES' })
vi.spyOn(fs, operation).mockImplementation(() => { throw failure })
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), failure)
})
it('continues with defaults even when warning output fails', () => {
const { userData, path } = fixture()
writeFileSync(path, '{')
vi.mocked(console.warn).mockImplementation(() => { throw new Error('output unavailable') })
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
expect(readFileSync(path, 'utf8')).toBe('{')
})
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.md
/:
en: 97a3763818ba118c
zh: ba856458827cb637
/enable-test-update-policy-authentication-dialogs-locally:
en: 33515a78d8b30d7a
zh: 6f821057b75e319e
/enable-test-update-policy-authentication-dialogs-locally/change:
en: 1e5aa6b6329f212f
zh: 5659403bff659603
/enable-test-update-policy-authentication-dialogs-locally/migration:
en: e6b3bc48daaa13b1
zh: df50a981844d3de6
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "Desktop test builds require local opt-in before showing any Feishu update-policy authentication dialogs."
---
# Enable test update-policy authentication dialogs locally
English | [中文](guide.zh.md)
## Change
Test builds previously offered Feishu login after an unauthenticated startup or manual update-policy check. All test authentication dialogs now default to disabled, including manual checks and mandatory-update refreshes. Gateway authentication and known mandatory blocks remain enforced; production policy requests and product account login are unchanged.
## Migration
1. Launch the updated application once to create `app.getPath('userData')/desktop/settings.json` if missing. Existing files are retained, and a missing field defaults to `false`. See [local desktop settings](../../../../apps/desktop/README.md#local-desktop-settings) for platform paths and error recovery.
2. To permit Feishu login, set `updates.allowTestAuthPopupWindow` to `true` in that file, then fully quit and restart Desktop. An unauthenticated test-policy response can now offer login. Leave the default unchanged if no login dialogs are wanted.
3. To disable dialogs again, set the field to `false` and restart. Startup and manual checks must not show Feishu authentication dialogs. Updates still query policy, and authentication failures do not clear a known mandatory block.
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "桌面测试包需要在本地显式开启后,才能显示飞书更新策略鉴权弹窗。"
---
# 在本地开启测试更新策略鉴权弹窗
[English](guide.md) | 中文
## 变更
测试包此前会在启动或手动检查更新策略未通过鉴权后提供飞书登录入口。现在所有测试鉴权弹窗默认关闭,包括手动检查和强更刷新。网关鉴权和已知强更阻塞继续生效;正式环境策略请求和产品账号登录保持不变。
## 迁移
1. 启动更新后的应用一次,在 `app.getPath('userData')/desktop/settings.json` 缺失时生成该文件。已有文件会保留,字段缺失时默认为 `false`。平台路径和错误恢复方法见[本地桌面设置](../../../../apps/desktop/README.zh.md#local-desktop-settings)。
2. 如需允许飞书登录,在文件中将 `updates.allowTestAuthPopupWindow` 设为 `true`,然后完全退出并重启 Desktop。此时测试策略的未鉴权响应可以提供登录入口。不需要登录弹窗时保持默认值即可。
3. 如需再次关闭弹窗,将字段设为 `false` 并重启。启动和手动检查均不得显示飞书鉴权弹窗。更新仍会查询策略,鉴权失败不会解除已知强更阻塞。