Merge pull request #5393 from deepseek-harness/feat/desktop-login-shell-env
feat(desktop): 启动时读取用户登录 shell 环境
This commit is contained in:
1 parent
6bb3d8becf
commit
4f26143bf2
16 files changed
+533
-17
No files matched your search
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: 3cac5d877b18179d
|
||||
zh: 0376bce7b099e0a6
|
||||
/agent-note-use-electron-as-the-desktop-node-runtime/consequences:
|
||||
en: b407d0ce648beea5
|
||||
zh: 7ab329aa1d7a237c
|
||||
en: 3de90a3734d0f405
|
||||
zh: 4d26d953e05e0337
|
||||
/agent-note-use-electron-as-the-desktop-node-runtime/alternatives-considered:
|
||||
en: 880e255d56b45345
|
||||
zh: 5952d9685c564284
|
||||
@@ -18,7 +18,7 @@ This supersedes the separate-Node choice in the [packaging decision](2026-08-25-
|
||||
|
||||
Host and pnpm launches pass `--expose-internals`: the bundled Cordis loader uses Node's internal ESM loader, while its native builtin accessor cannot locate the required symbol in Electron 44. The explicit flag makes that loader available without modifying Cordis. The RunAsNode fuse remains enabled.
|
||||
|
||||
The Host inherits the caller PATH without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
|
||||
The Host inherits Desktop’s PATH, merged with the [login-shell environment](../feature/2026-09-28-desktop-login-shell-environment.md) on macOS and Linux, without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
|
||||
|
||||
Electron's Node patches and native ABI are release compatibility obligations. The packaged native smoke exercises pnpm shell scripts without system Node on PATH, terminal output through the Windows shell, Koffi, Sharp, and HTML conversion. The Host smoke loads an external plugin sharing Cordis and serves its route through the real Web application. Platform signing and installed-application qualification remain required; Windows results do not establish macOS compatibility. Windows token signing runs serially and retains the first failure, preventing queued tasks from repeating a rejected PIN.
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置
|
||||
|
||||
Host 和 pnpm 启动时传入 `--expose-internals`:内置 Cordis 加载器使用 Node 内部 ESM 加载器,而其原生 builtin 访问器无法在 Electron 44 中找到所需符号。显式参数使加载器可用,无需修改 Cordis。RunAsNode fuse 保持启用。
|
||||
|
||||
Host 继承调用者的 PATH,不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
|
||||
Host 继承 Desktop 的 PATH(macOS 和 Linux 上合并了[登录 shell 环境](../feature/2026-09-28-desktop-login-shell-environment.zh.md)),不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
|
||||
|
||||
Electron 的 Node 补丁和原生 ABI 属于发布兼容性责任。打包原生 smoke 在 PATH 不含系统 Node 的情况下验证 pnpm shell 脚本,并验证 Windows shell 终端输出、Koffi、Sharp 和 HTML 转换。Host smoke 加载共享 Cordis 的外部插件,通过真实 Web 应用提供其路由。各平台仍需完成签名和已安装应用验收;Windows 结果不能证明 macOS 兼容性。Windows Token 签名串行执行并保留首次失败,阻止排队任务重复提交被拒绝的 PIN。
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-28-desktop-login-shell-environment.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 .agents/notes/implemented/feature/2026-09-28-desktop-login-shell-environment.md
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once:
|
||||
en: f3d08cb5b03f7032
|
||||
zh: 2f117f42aed85bc3
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once/problem:
|
||||
en: 3a4b92c977e42f45
|
||||
zh: 575d92a41e29ae7a
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once/decision:
|
||||
en: d2af1761d96c498a
|
||||
zh: 8419dea042446494
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once/alternatives-considered:
|
||||
en: ed9ed8bb06a19d7f
|
||||
zh: 52ceb0d70f0d05a7
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once/consequences:
|
||||
en: f3cd204ab6691f1d
|
||||
zh: 00c9ae619065b4c3
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Desktop reads the login-shell environment once
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-28-desktop-login-shell-environment.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
macOS Dock, Finder, and Linux desktop launches start Desktop with only the session manager's environment. Variables that users export from `~/.zprofile` or `~/.zshrc`, including PATH entries for Homebrew, version managers, and API keys, are absent from the Host, agent shells, terminals, and profile configuration. The same Host launched from a terminal sees them.
|
||||
|
||||
## Decision
|
||||
|
||||
Before creating the first Host, [Desktop](../../../../apps/desktop/README.md) runs one delimited `env -0` dump through `<shell> -ilc`, where `<shell>` is the account record's login shell, followed by `/bin/zsh`, `/bin/bash`, and `/bin/sh`. Every Host in the application process uses the merged result. Shell values replace inherited values except probe-session variables and the `DSH_*` and `ELECTRON_*` namespaces. The read completes at the closing delimiter. A timeout or quit kills the probe's process group, and a failed read falls back to the inherited environment without blocking startup. Windows skips the read.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Read `$SHELL` instead of the account record.** A Dock launch inherits `$SHELL` from launchd, not from the user's current choice, and a terminal launch can carry a temporary shell. The account record names the shell the user selected with `chsh`.
|
||||
|
||||
**Non-interactive `-lc` only.** Most users export PATH additions from `~/.zshrc`, which only interactive shells read. Interactive mode requires the probe variables that stop oh-my-zsh update prompts and tmux autostart from blocking.
|
||||
|
||||
**Let shell values replace `DSH_*` variables.** Desktop resolves `DSH_HOME` and its profile paths before the read, so a `DSH_HOME` exported from `~/.zshrc` would give the Host a different home from the one Desktop prepared.
|
||||
|
||||
**Wait for stdout to close.** Background processes that startup files start inherit stdout and can hold it open indefinitely, which turns every read into a timeout.
|
||||
|
||||
## Consequences
|
||||
|
||||
Dock and Finder launches give the Host the environment a terminal launch provides. Changes to startup files apply only after Desktop restarts. Startup waits for the read, up to `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` per candidate. Account shells that cannot run the POSIX dump, such as csh, tcsh, and nushell, receive the first system shell's environment instead. `DSH_*` settings must come from the launcher's environment rather than startup files. Unit tests run stand-in shells for the early completion, timeout, abort, and fallback paths, and the Desktop startup test verifies that the Host waits for the read.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Desktop 一次性读取登录 shell 环境
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-28-desktop-login-shell-environment.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
从 macOS 的 Dock、Finder 或 Linux 桌面启动 Desktop 时,它只获得会话管理器提供的环境。用户在 `~/.zprofile` 或 `~/.zshrc` 中导出的变量,包括 Homebrew、版本管理器的 PATH 条目和 API key,在 Host、agent shell、终端和 profile 配置中都不存在。从终端启动同一个 Host 时这些变量都在。
|
||||
|
||||
## 决策
|
||||
|
||||
创建第一个 Host 之前,[Desktop](../../../../apps/desktop/README.zh.md) 通过 `<shell> -ilc` 运行一次带定界符的 `env -0` 输出,`<shell>` 依次为账户记录中的登录 shell、`/bin/zsh`、`/bin/bash` 和 `/bin/sh`。同一应用进程中的所有 Host 使用合并后的结果。shell 的值覆盖继承的值,但读取进程自身的会话变量以及 `DSH_*` 和 `ELECTRON_*` 命名空间除外。读取在结束定界符出现时完成。超时或退出会结束读取进程组;读取失败时回退到继承的环境,不阻止启动。Windows 跳过读取。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**读取 `$SHELL` 而不是账户记录。** 从 Dock 启动时,`$SHELL` 继承自 launchd,而不是用户当前的选择;从终端启动时它可能是临时 shell。账户记录给出用户用 `chsh` 选择的 shell。
|
||||
|
||||
**只使用非交互的 `-lc`。** 多数用户在 `~/.zshrc` 中添加 PATH,而只有交互式 shell 会读取它。交互模式需要设置读取变量,避免 oh-my-zsh 更新提示和 tmux 自动启动阻塞。
|
||||
|
||||
**允许 shell 的值覆盖 `DSH_*` 变量。** Desktop 在读取之前已解析 `DSH_HOME` 及其 profile 路径,因此 `~/.zshrc` 导出的 `DSH_HOME` 会让 Host 使用与 Desktop 已准备的不同的 home。
|
||||
|
||||
**等待 stdout 关闭。** 启动文件启动的后台进程继承 stdout,可能无限期保持它打开,使每次读取都超时。
|
||||
|
||||
## 影响
|
||||
|
||||
从 Dock 和 Finder 启动时,Host 获得与从终端启动相同的环境。修改启动文件后需要重启 Desktop 才生效。启动需要等待读取,每个候选最多 `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS`。无法运行 POSIX 输出命令的账户 shell(如 csh、tcsh 和 nushell)改为获得第一个系统 shell 的环境。`DSH_*` 设置必须来自启动方的环境,而不是启动文件。单元测试用替身 shell 覆盖提前完成、超时、中止和回退路径,Desktop 启动测试验证 Host 等待读取完成。
|
||||
@@ -20,6 +20,9 @@
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: 0d11af8639977a6d
|
||||
zh: 86b086b12d2210d3
|
||||
/deepseek-harness-desktop/installation-ownership/host-environment:
|
||||
en: 84a9cc7ca9455aa8
|
||||
zh: 4091b8aa8231b74a
|
||||
/deepseek-harness-desktop/develop:
|
||||
en: 40d2ad4014bf445b
|
||||
zh: 6a08de06df9ed042
|
||||
|
||||
@@ -118,6 +118,10 @@ Before the first fatal dialog opens, Electron writes a crash report to the platf
|
||||
|
||||
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
|
||||
|
||||
### Host environment
|
||||
|
||||
macOS and Linux GUI launches inherit only the session manager's environment, without variables exported by shell startup files. Before the first Host starts, Desktop runs the account's login shell (from the user database, not `$SHELL`) once as `<shell> -ilc` and reads its `env -0` output between delimiters, so `~/.zprofile` and `~/.zshrc` (or the shell's equivalents) take effect for the Host, agent shells, terminals, and profile configuration. Reading runs in parallel with profile preparation. The probe has no terminal input and sets `DISABLE_AUTO_UPDATE=true`, `ZSH_TMUX_AUTOSTARTED=true`, and `ZSH_TMUX_AUTOSTART=false` to keep oh-my-zsh and tmux plugins from blocking. Shell values replace inherited values except `PWD`, `OLDPWD`, `SHLVL`, `_`, those probe variables, and launcher-owned `DSH_*` and `ELECTRON_*` names; Desktop resolves paths such as `DSH_HOME` before the read, so the Host keeps the same values. The read completes at the closing delimiter, so background processes started by startup files may keep running with their output discarded. A candidate that fails to start, exits nonzero, prints no delimited output, or exceeds `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` (integer milliseconds from 1000 through 2147483647, default `10000`; its process group is killed) is logged as a warning, and Desktop tries `/bin/zsh`, `/bin/bash`, and `/bin/sh` in turn; when all fail the Host uses the inherited environment. The dump command uses POSIX syntax, so an account shell such as csh, tcsh, or nushell fails and the Host receives the environment from the first system shell's startup files instead. Quitting Desktop during the read kills the running probe's process group. The result is read once per application process, so changes to shell startup files apply after quitting and reopening Desktop. Windows GUI launches already inherit the user and system environment variables from the registry, so Windows skips this step.
|
||||
|
||||
## Develop
|
||||
|
||||
The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files.
|
||||
|
||||
@@ -120,6 +120,10 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
### Host 环境
|
||||
|
||||
macOS 和 Linux 从图形界面启动的程序只继承会话管理器提供的环境,不包含 shell 启动文件导出的变量。第一个 Host 启动之前,Desktop 以 `<shell> -ilc` 运行一次账户的登录 shell(取自用户数据库,不看 `$SHELL`),读取定界符之间的 `env -0` 输出,使 `~/.zprofile` 和 `~/.zshrc`(或该 shell 的对应文件)对 Host、agent shell、终端和 profile 配置生效。读取与 profile 准备并行进行。读取进程没有终端输入,并设置 `DISABLE_AUTO_UPDATE=true`、`ZSH_TMUX_AUTOSTARTED=true` 和 `ZSH_TMUX_AUTOSTART=false`,避免 oh-my-zsh 和 tmux 插件阻塞。shell 的值覆盖继承的值,但 `PWD`、`OLDPWD`、`SHLVL`、`_`、上述读取变量以及启动方自有的 `DSH_*` 和 `ELECTRON_*` 除外;Desktop 在读取之前已按 `DSH_HOME` 等变量解析路径,因此 Host 保持相同的值。读取在结束定界符出现时完成,因此启动文件启动的后台进程可以继续运行,其输出被丢弃。候选 shell 无法启动、以非零状态退出、没有输出定界内容或超过 `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS`(1000 到 2147483647 的整数毫秒,默认 `10000`;超时会结束其进程组)时,Desktop 记录一条警告,并依次尝试 `/bin/zsh`、`/bin/bash` 和 `/bin/sh`;全部失败时 Host 使用继承的环境。读取命令使用 POSIX 语法,因此 csh、tcsh 或 nushell 等账户 shell 会失败,Host 改为获得第一个系统 shell 的启动文件所设置的环境。读取期间退出 Desktop 会结束正在运行的读取进程组。每个应用进程只读取一次,因此修改 shell 启动文件后需要退出并重新打开 Desktop。Windows 从图形界面启动的程序已经从注册表继承用户和系统环境变量,因此 Windows 跳过这一步。
|
||||
|
||||
## 开发
|
||||
|
||||
开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Millisecond settings read from the Desktop process environment. */
|
||||
|
||||
/**
|
||||
* Read a duration that `setTimeout` accepts without clamping.
|
||||
* @param env - Desktop process environment.
|
||||
* @param name - Variable to read; the validation error names it.
|
||||
* @param fallback - Value used when the variable is unset.
|
||||
* @returns Integer milliseconds from 1000 through 2147483647.
|
||||
*/
|
||||
export function resolveDurationMs(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
|
||||
const value = Number(env[name] ?? fallback)
|
||||
if (!Number.isSafeInteger(value) || value < 1_000 || value > 2_147_483_647) {
|
||||
throw new Error(`${name} must be an integer from 1000 through 2147483647`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/** Login-shell environment for POSIX Desktop launches that inherit only the session manager's environment. */
|
||||
|
||||
import { spawn, type ChildProcessByStdio } from 'node:child_process'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { resolveDurationMs } from './duration-env.ts'
|
||||
|
||||
const DELIMITER = '_DSH_SHELL_ENV_DELIMITER_'
|
||||
const MARKER = Buffer.from(`\0${DELIMITER}\0`)
|
||||
const DUMP = `printf '\\0%s\\0' '${DELIMITER}'; command env -0 || exit; printf '\\0%s\\0' '${DELIMITER}'; exit`
|
||||
const FALLBACK_SHELLS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const
|
||||
/** Keep oh-my-zsh update prompts and tmux autostart plugins from blocking a non-terminal read. */
|
||||
const PROBE_ENVIRONMENT = { DISABLE_AUTO_UPDATE: 'true', ZSH_TMUX_AUTOSTARTED: 'true', ZSH_TMUX_AUTOSTART: 'false' } as const
|
||||
/** Variables that describe the probe shell process rather than the user's configuration. */
|
||||
const SHELL_SESSION_KEYS = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', ...Object.keys(PROBE_ENVIRONMENT)])
|
||||
/** Desktop resolves paths such as `DSH_HOME` before the read, so the Host keeps the same inherited values. */
|
||||
const LAUNCHER_OWNED_PREFIXES = ['DSH_', 'ELECTRON_'] as const
|
||||
|
||||
/** Validated login-shell read settings. */
|
||||
export interface DesktopLoginShellConfig {
|
||||
/** Deadline for each candidate shell, in milliseconds. */
|
||||
readonly timeoutMs: number
|
||||
}
|
||||
|
||||
/** One candidate shell that did not produce an environment. */
|
||||
export interface DesktopLoginShellFailure {
|
||||
readonly shell: string
|
||||
/**
|
||||
* `exit <code>`, terminating signal name, spawn error, `timeout`, `aborted`, or `unparsed`
|
||||
* for output without both delimiters.
|
||||
*/
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
/** Environment for the Host and the candidate failures that preceded it. */
|
||||
export interface DesktopLoginShellResult {
|
||||
/** `base` merged with the first successful shell's variables, or `base` itself when none succeeded. */
|
||||
readonly environment: NodeJS.ProcessEnv
|
||||
readonly failures: readonly DesktopLoginShellFailure[]
|
||||
}
|
||||
|
||||
/** Inputs of {@link readDesktopLoginShellEnvironment} that callers other than tests leave unset. */
|
||||
export interface DesktopLoginShellReadOptions {
|
||||
/** Operating system running Desktop; defaults to `process.platform`. */
|
||||
readonly platform?: NodeJS.Platform
|
||||
/** Candidates in trial order; defaults to {@link loginShellCandidates}. */
|
||||
readonly shells?: readonly string[]
|
||||
/** Aborting kills the running candidate's process group and skips the remaining candidates. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve login-shell read settings.
|
||||
* @param env - Desktop process environment.
|
||||
* @returns Validated per-candidate deadline; `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` defaults to 10000.
|
||||
*/
|
||||
export function resolveDesktopLoginShellConfig(env: NodeJS.ProcessEnv): DesktopLoginShellConfig {
|
||||
return { timeoutMs: resolveDurationMs(env, 'DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS', 10_000) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate shells in order: the account record's login shell (not `$SHELL`), then fixed system shells.
|
||||
* @returns Distinct absolute candidates; only the fixed shells when the account record is unreadable or empty.
|
||||
*/
|
||||
export function loginShellCandidates(): readonly string[] {
|
||||
let account: string | null = null
|
||||
try { account = userInfo().shell } catch (_error) { /* No account record for this uid: fixed candidates only. */ }
|
||||
return [...new Set([...account === null || account === '' ? [] : [account], ...FALLBACK_SHELLS])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the variables printed between the two delimiters of the dump command.
|
||||
* @param stdout - Probe output, including anything rc files printed around the dump.
|
||||
* @returns Variables, or undefined when both delimiters are not present.
|
||||
*/
|
||||
export function parseLoginShellOutput(stdout: string): Record<string, string> | undefined {
|
||||
const parts = stdout.split('\0')
|
||||
const first = parts.indexOf(DELIMITER)
|
||||
const last = parts.lastIndexOf(DELIMITER)
|
||||
if (first === -1 || first === last) return undefined
|
||||
const variables: Record<string, string> = {}
|
||||
for (const entry of parts.slice(first + 1, last)) {
|
||||
const separator = entry.indexOf('=')
|
||||
if (separator > 0) variables[entry.slice(0, separator)] = entry.slice(separator + 1)
|
||||
}
|
||||
return variables
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay login-shell variables on the inherited environment; shell values win except for
|
||||
* probe-session variables and launcher-owned `DSH_*` / `ELECTRON_*` names.
|
||||
* @param base - Environment Desktop inherited.
|
||||
* @param shell - Variables printed by the login shell.
|
||||
* @returns A new environment; neither argument is modified.
|
||||
*/
|
||||
export function mergeLoginShellEnvironment(base: NodeJS.ProcessEnv, shell: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
|
||||
const merged = { ...base }
|
||||
for (const [key, value] of Object.entries(shell)) {
|
||||
if (SHELL_SESSION_KEYS.has(key) || LAUNCHER_OWNED_PREFIXES.some(prefix => key.startsWith(prefix))) continue
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function readShell(
|
||||
shell: string, base: NodeJS.ProcessEnv, cwd: string, timeoutMs: number, signal: AbortSignal | undefined,
|
||||
): Promise<Record<string, string> | string> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted === true) { resolve('aborted'); return }
|
||||
let child: ChildProcessByStdio<null, Readable, null>
|
||||
try {
|
||||
// Interactive login mode runs ~/.zprofile and ~/.zshrc (or the shell's equivalents); stdin is
|
||||
// closed so prompts in rc files read EOF instead of waiting.
|
||||
child = spawn(shell, ['-ilc', DUMP], {
|
||||
cwd, env: { ...base, ...PROBE_ENVIRONMENT }, stdio: ['ignore', 'pipe', 'ignore'], detached: true,
|
||||
})
|
||||
} catch (error) {
|
||||
resolve(String(error))
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
let tail = Buffer.alloc(0)
|
||||
let markers = 0
|
||||
const output = (): Record<string, string> | string => parseLoginShellOutput(Buffer.concat(chunks).toString('utf8')) ?? 'unparsed'
|
||||
const onData = (chunk: Buffer): void => {
|
||||
chunks.push(chunk)
|
||||
const window = Buffer.concat([tail, chunk])
|
||||
for (let index = window.indexOf(MARKER); index !== -1; index = window.indexOf(MARKER, index + MARKER.length)) markers++
|
||||
tail = window.subarray(Math.max(0, window.length - MARKER.length + 1))
|
||||
// Background children started by rc files inherit stdout and can hold it open long after the
|
||||
// shell exits, so the read completes at the closing delimiter rather than at `close`.
|
||||
if (markers >= 2) finish(output())
|
||||
}
|
||||
const killGroup = (): void => {
|
||||
// An asynchronous spawn failure settles through the error listener before a timer or abort
|
||||
// listener can run, so the pid is present here.
|
||||
const group = -(child.pid as number)
|
||||
try { process.kill(group, 'SIGKILL') } catch (_error) { /* The group already exited. */ }
|
||||
}
|
||||
// Members that left the process group can keep stdout open after SIGKILL, so a stopped read
|
||||
// resolves without waiting for `close`.
|
||||
const stop = (reason: 'timeout' | 'aborted'): void => { finish(reason); killGroup() }
|
||||
const onAbort = (): void => { stop('aborted') }
|
||||
const timer = setTimeout(() => { stop('timeout') }, timeoutMs)
|
||||
function finish(result: Record<string, string> | string): void {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
// Keep draining so rc-started children that write to stdout do not fail with EPIPE.
|
||||
child.stdout.off('data', onData)
|
||||
child.stdout.resume()
|
||||
resolve(result)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
child.stdout.on('data', onData)
|
||||
child.on('error', (error) => { finish(error.message) })
|
||||
child.on('close', (code, closeSignal) => {
|
||||
if (code !== 0) { finish(closeSignal ?? `exit ${String(code)}`); return }
|
||||
finish(output())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the user's login-shell environment once; Windows GUI launches already inherit the registry
|
||||
* environment, so `win32` returns `base` unchanged. Never rejects: each failing candidate is
|
||||
* recorded and the next one tried, and `base` is returned when all fail or the read is aborted.
|
||||
* @param base - Environment Desktop inherited.
|
||||
* @param config - Validated read settings.
|
||||
* @param options - Platform, candidates, and cancellation.
|
||||
* @returns Host environment and the candidate failures that preceded it.
|
||||
*/
|
||||
export async function readDesktopLoginShellEnvironment(
|
||||
base: NodeJS.ProcessEnv, config: DesktopLoginShellConfig, options: DesktopLoginShellReadOptions = {},
|
||||
): Promise<DesktopLoginShellResult> {
|
||||
const { platform = process.platform, shells = loginShellCandidates(), signal } = options
|
||||
if (platform === 'win32') return { environment: base, failures: [] }
|
||||
const failures: DesktopLoginShellFailure[] = []
|
||||
for (const shell of shells) {
|
||||
const result = await readShell(shell, base, homedir(), config.timeoutMs, signal)
|
||||
if (typeof result !== 'string') return { environment: mergeLoginShellEnvironment(base, result), failures }
|
||||
failures.push({ shell, reason: result })
|
||||
if (result === 'aborted') break
|
||||
}
|
||||
return { environment: base, failures }
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { DesktopUpdatePreparationError } from './update-error.ts'
|
||||
import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts'
|
||||
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
|
||||
import { desktopErrorState } from './startup-error.ts'
|
||||
import { readDesktopLoginShellEnvironment, resolveDesktopLoginShellConfig } from './login-shell-environment.ts'
|
||||
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
|
||||
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
|
||||
@@ -322,6 +323,18 @@ async function main(): Promise<void> {
|
||||
: join(process.resourcesPath, 'runtime', 'primary-runtime')
|
||||
const activeProject = paths.profile
|
||||
const manager = new DesktopProjectManager(paths, resources)
|
||||
// Dock and Finder launches inherit only launchd's environment; every Host shares one login-shell read.
|
||||
const loginShellRead = new AbortController()
|
||||
// The probe runs in its own process group, which outlives Desktop unless the read is aborted.
|
||||
app.on('will-quit', () => { loginShellRead.abort() })
|
||||
const loginShell = readDesktopLoginShellEnvironment(process.env, resolveDesktopLoginShellConfig(process.env), {
|
||||
signal: loginShellRead.signal,
|
||||
}).then((result) => {
|
||||
for (const failure of result.failures) console.warn(`desktop login shell: ${failure.shell} failed (${failure.reason})`)
|
||||
return result.environment
|
||||
})
|
||||
let hostEnvironment: NodeJS.ProcessEnv = process.env
|
||||
const prepareHostEnvironment = async (): Promise<void> => { hostEnvironment = await loginShell }
|
||||
let quitting = false
|
||||
let startup: Promise<void> | undefined
|
||||
let workspaceRecovery: Promise<void> | undefined
|
||||
@@ -419,7 +432,7 @@ async function main(): Promise<void> {
|
||||
const backend = new DesktopBackendController((onFailure) => {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
hostInspectPort, { ...hostEnvironment, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
return {
|
||||
@@ -515,7 +528,7 @@ async function main(): Promise<void> {
|
||||
updateStoppedHost = false
|
||||
if (restoreHost) {
|
||||
// Only confirmed process exit permits replacement before another installation confirmation.
|
||||
const hostReady = backend.start(async () => {})
|
||||
const hostReady = backend.start(prepareHostEnvironment)
|
||||
startup = hostReady
|
||||
const recovery = hostReady.then(async () => {
|
||||
if (quitting) return
|
||||
@@ -545,7 +558,7 @@ async function main(): Promise<void> {
|
||||
startup ??= (async () => {
|
||||
await navigateMain(applicationUrl)
|
||||
await backend.start(async () => {
|
||||
await manager.applyRelease()
|
||||
await Promise.all([manager.applyRelease(), prepareHostEnvironment()])
|
||||
})
|
||||
if (backend.host !== undefined) await openInitialWindow()
|
||||
if (backend.host !== undefined) updateJournal?.action('workspace-ready')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Ordinary feed polling; policy queries and user-authorized transfers keep their own lifetimes. */
|
||||
|
||||
import { resolveDurationMs } from './duration-env.ts'
|
||||
import type { DesktopUpdateCoordinator } from './update-coordinator.ts'
|
||||
import type { DesktopUpdateState } from './ipc.ts'
|
||||
|
||||
@@ -16,15 +17,8 @@ export interface DesktopUpdateScheduleConfig {
|
||||
* @returns Validated durations and fractional jitter.
|
||||
*/
|
||||
export function resolveDesktopUpdateScheduleConfig(env: NodeJS.ProcessEnv): DesktopUpdateScheduleConfig {
|
||||
function duration(name: string, fallback: number): number {
|
||||
const value = Number(env[name] ?? fallback)
|
||||
if (!Number.isSafeInteger(value) || value < 1_000 || value > 2_147_483_647) {
|
||||
throw new Error(`${name} must be an integer from 1000 through 2147483647`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const intervalMs = duration('DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', 600_000)
|
||||
const maxBackoffMs = duration('DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', Math.max(intervalMs, 3_600_000))
|
||||
const intervalMs = resolveDurationMs(env, 'DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', 600_000)
|
||||
const maxBackoffMs = resolveDurationMs(env, 'DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', Math.max(intervalMs, 3_600_000))
|
||||
const jitter = Number(env.DSH_DESKTOP_UPDATE_CHECK_JITTER ?? 0.2)
|
||||
if (!Number.isFinite(jitter) || jitter < 0 || jitter > 1 || maxBackoffMs < intervalMs) {
|
||||
throw new Error('desktop update: check jitter must be in [0, 1] and max backoff must cover the check interval')
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
loginShellCandidates, mergeLoginShellEnvironment, parseLoginShellOutput, readDesktopLoginShellEnvironment,
|
||||
resolveDesktopLoginShellConfig,
|
||||
} from '../src/login-shell-environment.ts'
|
||||
|
||||
const account = vi.hoisted(() => ({ userInfo: vi.fn() }))
|
||||
vi.mock('node:os', async importOriginal => ({ ...await importOriginal<typeof import('node:os')>(), userInfo: account.userInfo }))
|
||||
|
||||
const DELIMITER = '_DSH_SHELL_ENV_DELIMITER_'
|
||||
const config = { timeoutMs: 5_000 }
|
||||
let directory: string
|
||||
|
||||
beforeEach(() => { directory = mkdtempSync(join(tmpdir(), 'dsh-login-shell-')) })
|
||||
afterEach(() => { rmSync(directory, { recursive: true, force: true }) })
|
||||
|
||||
/** Whether `pid` has exited; Linux can keep a killed process as a zombie until its new parent reaps it. */
|
||||
function exited(pid: number): boolean {
|
||||
try {
|
||||
if (process.platform === 'linux') return /^State:\s+[ZXx]\b/m.test(readFileSync(`/proc/${pid}/status`, 'utf8'))
|
||||
process.kill(pid, 0)
|
||||
return false
|
||||
} catch (error) {
|
||||
if (['ENOENT', 'ESRCH'].includes((error as NodeJS.ErrnoException).code ?? '')) return true
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** A stand-in login shell: `body` plays the rc files, then the dump command runs under /bin/sh. */
|
||||
function shell(name: string, body: string): string {
|
||||
const path = join(directory, name)
|
||||
writeFileSync(path, `#!/bin/sh\n[ "$1" = -ilc ] || exit 64\n${body}\nexec /bin/sh -c "$2"\n`)
|
||||
chmodSync(path, 0o755)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('resolveDesktopLoginShellConfig', () => {
|
||||
it('defaults to ten seconds and accepts an override', () => {
|
||||
expect(resolveDesktopLoginShellConfig({})).toEqual({ timeoutMs: 10_000 })
|
||||
expect(resolveDesktopLoginShellConfig({ DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS: '2500' })).toEqual({ timeoutMs: 2_500 })
|
||||
})
|
||||
|
||||
it.each(['999', '1.5', 'soon', '2147483648'])('rejects %s', (value) => {
|
||||
expect(() => resolveDesktopLoginShellConfig({ DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS: value }))
|
||||
.toThrow('DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS must be an integer from 1000 through 2147483647')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginShellCandidates', () => {
|
||||
it.each([
|
||||
['/opt/homebrew/bin/fish', ['/opt/homebrew/bin/fish', '/bin/zsh', '/bin/bash', '/bin/sh']],
|
||||
['/bin/bash', ['/bin/bash', '/bin/zsh', '/bin/sh']],
|
||||
['', ['/bin/zsh', '/bin/bash', '/bin/sh']],
|
||||
[null, ['/bin/zsh', '/bin/bash', '/bin/sh']],
|
||||
])('tries account shell %j before distinct system shells', (shell, candidates) => {
|
||||
account.userInfo.mockReturnValueOnce({ shell })
|
||||
expect(loginShellCandidates()).toEqual(candidates)
|
||||
})
|
||||
|
||||
it('uses only system shells when the account record is unreadable', () => {
|
||||
account.userInfo.mockImplementationOnce(() => { throw new Error('ENOENT: no such user') })
|
||||
expect(loginShellCandidates()).toEqual(['/bin/zsh', '/bin/bash', '/bin/sh'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLoginShellOutput', () => {
|
||||
it('reads only the variables between the delimiters', () => {
|
||||
const output = `motd\0${DELIMITER}\0A=1\0MULTI=x\ny=z\0=ignored\0NOEQUALS\0\0${DELIMITER}\0trailing`
|
||||
expect(parseLoginShellOutput(output)).toEqual({ A: '1', MULTI: 'x\ny=z' })
|
||||
})
|
||||
|
||||
it('rejects output without both delimiters', () => {
|
||||
expect(parseLoginShellOutput('A=1\0')).toBeUndefined()
|
||||
expect(parseLoginShellOutput(`\0${DELIMITER}\0A=1\0`)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeLoginShellEnvironment', () => {
|
||||
it('lets shell values win except probe-session and launcher-owned names', () => {
|
||||
const base = { PATH: '/usr/bin', HOME: '/home/user', DSH_DESKTOP_DSH_DIR: '/launcher', PWD: '/' }
|
||||
const merged = mergeLoginShellEnvironment(base, {
|
||||
PATH: '/login/bin:/usr/bin', XDG_CACHE_HOME: '/cache', PWD: '/home/user', OLDPWD: '/', SHLVL: '2', _: '/usr/bin/env',
|
||||
DISABLE_AUTO_UPDATE: 'true', ZSH_TMUX_AUTOSTARTED: 'true', ZSH_TMUX_AUTOSTART: 'false',
|
||||
DSH_DESKTOP_DSH_DIR: '/rc', DSH_HOME: '/rc-home', ELECTRON_RUN_AS_NODE: '1',
|
||||
})
|
||||
expect(merged).toEqual({ PATH: '/login/bin:/usr/bin', HOME: '/home/user', DSH_DESKTOP_DSH_DIR: '/launcher', PWD: '/', XDG_CACHE_HOME: '/cache' })
|
||||
expect(base.PATH).toBe('/usr/bin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDesktopLoginShellEnvironment', () => {
|
||||
it('returns the inherited environment on Windows without running a shell', async () => {
|
||||
const base = { Path: 'C:\\Windows' }
|
||||
await expect(readDesktopLoginShellEnvironment(base, config, { platform: 'win32', shells: [shell('unused', 'exit 1')] }))
|
||||
.resolves.toEqual({ environment: base, failures: [] })
|
||||
})
|
||||
|
||||
it('records a candidate whose path cannot be passed to spawn', async () => {
|
||||
await expect(readDesktopLoginShellEnvironment({}, config, { platform: 'linux', shells: ['/bin/sh\0'] })).resolves.toEqual({
|
||||
environment: {}, failures: [{ shell: '/bin/sh\0', reason: expect.stringContaining('null bytes') as string }],
|
||||
})
|
||||
})
|
||||
|
||||
it('skips every candidate when already aborted', async () => {
|
||||
const base = { PATH: '/usr/bin' }
|
||||
const signal = AbortSignal.abort()
|
||||
await expect(readDesktopLoginShellEnvironment(base, config, { platform: 'darwin', shells: ['/a', '/b'], signal })).resolves
|
||||
.toEqual({ environment: base, failures: [{ shell: '/a', reason: 'aborted' }] })
|
||||
})
|
||||
})
|
||||
|
||||
// Stand-in shells are POSIX scripts; Windows returns before spawning.
|
||||
describe.skipIf(process.platform === 'win32')('readDesktopLoginShellEnvironment on POSIX', () => {
|
||||
it('merges variables exported by rc files, ignoring their other output', async () => {
|
||||
const login = shell('login', [
|
||||
'echo "rc banner"',
|
||||
'[ "$DISABLE_AUTO_UPDATE" = true ] && [ "$ZSH_TMUX_AUTOSTART" = false ] || exit 65',
|
||||
'read -r answer; export ANSWER="${answer:-eof}"',
|
||||
'export PATH="/login/bin:$PATH" MULTI=\'a\nb\' STARTED_IN="$PWD"',
|
||||
].join('\n'))
|
||||
const result = await readDesktopLoginShellEnvironment({ PATH: '/usr/bin:/bin', KEPT: '1' }, config, { platform: 'darwin', shells: [login] })
|
||||
expect(result.failures).toEqual([])
|
||||
expect(result.environment).toMatchObject({ PATH: '/login/bin:/usr/bin:/bin', KEPT: '1', MULTI: 'a\nb', ANSWER: 'eof' })
|
||||
expect(result.environment.STARTED_IN).toBe(homedir())
|
||||
expect(result.environment).not.toHaveProperty('DISABLE_AUTO_UPDATE')
|
||||
})
|
||||
|
||||
it('records each failing candidate and uses the next one', async () => {
|
||||
const candidates = [
|
||||
join(directory, 'missing'),
|
||||
shell('exits', 'exit 3'),
|
||||
shell('signalled', 'kill -TERM $$'),
|
||||
shell('silent', 'exit 0'),
|
||||
shell('working', 'export FROM=working'),
|
||||
]
|
||||
const result = await readDesktopLoginShellEnvironment({ PATH: '/usr/bin:/bin' }, config, { platform: 'linux', shells: candidates })
|
||||
expect(result.environment.FROM).toBe('working')
|
||||
expect(result.failures).toEqual([
|
||||
{ shell: candidates[0], reason: expect.stringContaining('ENOENT') as string },
|
||||
{ shell: candidates[1], reason: 'exit 3' },
|
||||
{ shell: candidates[2], reason: 'SIGTERM' },
|
||||
{ shell: candidates[3], reason: 'unparsed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('completes at the closing delimiter while an rc-started child keeps stdout open', async () => {
|
||||
const pidFile = join(directory, 'child.pid')
|
||||
const detaching = shell('detaching', `sleep 30 & echo $! > '${pidFile}'\nexport FROM=detaching`)
|
||||
const result = await readDesktopLoginShellEnvironment({ PATH: '/usr/bin:/bin' }, config, { platform: 'darwin', shells: [detaching] })
|
||||
const child = Number(readFileSync(pidFile, 'utf8'))
|
||||
onTestFinished(() => { process.kill(child, 'SIGKILL') })
|
||||
expect(result.failures).toEqual([])
|
||||
expect(result.environment.FROM).toBe('detaching')
|
||||
expect(exited(child)).toBe(false)
|
||||
})
|
||||
|
||||
it('ends a timed-out shell with its background children and keeps the inherited environment', async () => {
|
||||
const pidFile = join(directory, 'child.pid')
|
||||
const hanging = shell('hanging', `sleep 30 & echo $! > '${pidFile}'; wait`)
|
||||
const base = { PATH: '/usr/bin:/bin' }
|
||||
const result = await readDesktopLoginShellEnvironment(base, { timeoutMs: 2_000 }, { platform: 'darwin', shells: [hanging] })
|
||||
expect(result).toEqual({ environment: base, failures: [{ shell: hanging, reason: 'timeout' }] })
|
||||
const child = Number(readFileSync(pidFile, 'utf8'))
|
||||
await vi.waitFor(() => { expect(exited(child)).toBe(true) })
|
||||
})
|
||||
|
||||
it('ends the running shell group on abort and skips the remaining candidates', async () => {
|
||||
const pidFile = join(directory, 'child.pid')
|
||||
const hanging = shell('hanging', `sleep 30 & echo $! > '${pidFile}'; wait`)
|
||||
const base = { PATH: '/usr/bin:/bin' }
|
||||
const controller = new AbortController()
|
||||
const read = readDesktopLoginShellEnvironment(base, config, {
|
||||
platform: 'linux', shells: [hanging, shell('unused', 'export FROM=unused')], signal: controller.signal,
|
||||
})
|
||||
const child = await vi.waitFor(() => {
|
||||
const pid = Number(readFileSync(pidFile, 'utf8'))
|
||||
expect(pid).toBeGreaterThan(0)
|
||||
return pid
|
||||
})
|
||||
controller.abort()
|
||||
await expect(read).resolves.toEqual({ environment: base, failures: [{ shell: hanging, reason: 'aborted' }] })
|
||||
await vi.waitFor(() => { expect(exited(child)).toBe(true) })
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,10 @@ const harness = await vi.hoisted(async () => {
|
||||
const platformDispose = vi.fn(() => platformDisposeDeferred?.promise ?? Promise.resolve())
|
||||
let platformCloseDeferred: ReturnType<typeof deferred> | undefined
|
||||
const platformCloseAndWait = vi.fn(() => platformCloseDeferred?.promise ?? Promise.resolve())
|
||||
const readLoginShell = async (base: NodeJS.ProcessEnv) => ({
|
||||
environment: { ...base, DSH_TEST_LOGIN_SHELL: 'login' }, failures: [{ shell: '/account/shell', reason: 'timeout' }],
|
||||
})
|
||||
const loginShell = vi.fn((base: NodeJS.ProcessEnv, _config: unknown, _options: { signal?: AbortSignal }) => readLoginShell(base))
|
||||
const updateCheck = vi.fn(async (_manual?: boolean): Promise<DesktopUpdateState> => updateState)
|
||||
const updateDownload = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
|
||||
const updateInstall = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
|
||||
@@ -189,7 +193,7 @@ const harness = await vi.hoisted(async () => {
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
trays, FakeTray, backgroundNotice, shellDialog,
|
||||
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
|
||||
menu, popup, socketHeaders: vi.fn(), loginShell, readLoginShell, updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
platformCloseAndWait,
|
||||
|
||||
@@ -247,6 +251,7 @@ const harness = await vi.hoisted(async () => {
|
||||
publishUpdate = undefined
|
||||
updateState = { phase: 'idle' }
|
||||
updateCheck.mockReset().mockImplementation(async () => updateState)
|
||||
loginShell.mockReset().mockImplementation(base => readLoginShell(base))
|
||||
updateDownload.mockReset().mockImplementation(async () => updateState)
|
||||
updateInstall.mockReset().mockImplementation(async () => updateState)
|
||||
nativeTheme.themeSource = 'system'; nativeTheme.shouldUseDarkColors = false
|
||||
@@ -307,6 +312,10 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
return encoding === undefined ? original.readFile(path) : original.readFile(path, encoding)
|
||||
}) }
|
||||
})
|
||||
vi.mock('../src/login-shell-environment.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../src/login-shell-environment.ts')>(),
|
||||
readDesktopLoginShellEnvironment: harness.loginShell,
|
||||
}))
|
||||
vi.mock('../src/runtime-tree.ts', () => ({ readDesktopRuntime: () => ({ release: { version: '1.0.0' } }) }))
|
||||
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
|
||||
vi.mock('../src/project-manager.ts', () => ({
|
||||
@@ -399,6 +408,7 @@ beforeEach(() => {
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
|
||||
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
|
||||
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', 'test-primary-runtime')
|
||||
@@ -2042,6 +2052,27 @@ describe('desktop main startup', () => {
|
||||
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
||||
})
|
||||
|
||||
it('creates the Host only after the login-shell read and ends the read on quit', async () => {
|
||||
let finishRead!: () => void
|
||||
const read = new Promise<void>((resolve) => { finishRead = resolve })
|
||||
harness.loginShell.mockImplementation(async (base) => { await read; return harness.readLoginShell(base) })
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
const boot = invoke(DESKTOP_IPC.boot)
|
||||
harness.prepared.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(harness.hosts).toHaveLength(0)
|
||||
finishRead()
|
||||
await harness.hostStarted.promise
|
||||
expect(harness.hosts[0]!.environment?.DSH_TEST_LOGIN_SHELL).toBe('login')
|
||||
harness.hosts[0]!.ready.resolve()
|
||||
await Promise.all([boot, harness.navigated.promise])
|
||||
const { signal } = harness.loginShell.mock.calls[0]![2]
|
||||
expect(signal?.aborted).toBe(false)
|
||||
harness.app.emit('will-quit')
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('prepares recovery offscreen and starts one Host before choosing the first visible window', async () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
@@ -2068,6 +2099,8 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
expect(harness.hosts[0]!.environment).not.toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3')
|
||||
expect(harness.hosts[0]!.environment?.DSH_TEST_LOGIN_SHELL).toBe('login')
|
||||
expect(console.warn).toHaveBeenCalledWith('desktop login shell: /account/shell failed (timeout)')
|
||||
expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} })
|
||||
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
|
||||
@@ -102,6 +102,10 @@ vi.mock('electron', () => ({
|
||||
vi.mock('../src/tray.ts', () => ({ DesktopTray: class { relabel() {} dispose() {} } }))
|
||||
|
||||
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: '/profile' }) }))
|
||||
vi.mock('../src/login-shell-environment.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../src/login-shell-environment.ts')>(),
|
||||
readDesktopLoginShellEnvironment: async (base: NodeJS.ProcessEnv) => ({ environment: base, failures: [] }),
|
||||
}))
|
||||
vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class {
|
||||
applyRelease = vi.fn(async () => {})
|
||||
canRecoverProfile = vi.fn(() => true)
|
||||
|
||||
Reference in new issue
Block a user