From bf89981d63b7d67bdff8a4c1801531b949aa6f96 Mon Sep 17 00:00:00 2001 From: Dash <125997726+dashhuang@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:24:58 +1200 Subject: [PATCH 1/2] feat(linux): support managed user installs and updates on Arch/Omarchy Add verified user-owned installation transactions, stable desktop and login callbacks, and keyring selector preservation. Document migration and release acceptance; cover native updater failures and Linux test fixtures. Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + README.md | 3 + apps/desktop/forge-linux.ts | 15 ++ apps/desktop/forge.config.ts | 4 + apps/desktop/resources/linux/install-user.sh | 141 +++++++++++++++ .../resources/linux/register-desktop.sh | 50 ++++++ apps/desktop/scripts/package-desktop.mjs | 3 +- .../main/__tests__/linuxInstallation.test.ts | 169 ++++++++++++++++++ .../main/__tests__/linuxPasswordStore.test.ts | 22 +++ .../main/__tests__/updateScriptLinux.test.ts | 14 ++ .../src/main/__tests__/updateService.test.ts | 39 ++++ apps/desktop/src/main/index.ts | 15 ++ apps/desktop/src/main/linuxInstallation.ts | 75 ++++++++ apps/desktop/src/main/linuxPasswordStore.ts | 18 ++ apps/desktop/src/main/updateScriptLinux.ts | 28 ++- apps/desktop/src/main/updateService.ts | 37 +++- apps/desktop/src/main/vendor.d.ts | 5 + .../updateBannerRelaunchEntry.test.tsx | 25 +++ .../components/sidebar/UpdateBanner.tsx | 56 ++++-- .../src/renderer/i18n/locales/en/common.json | 19 +- .../src/renderer/i18n/locales/ja/common.json | 19 +- .../src/renderer/i18n/locales/ko/common.json | 19 +- .../renderer/i18n/locales/zh-CN/common.json | 19 +- .../renderer/i18n/locales/zh-TW/common.json | 19 +- docs/linux.md | 156 ++++++++++++++++ .../pi/__tests__/pi-agent.integration.test.ts | 5 +- ...rpc-resource-discovery.integration.test.ts | 10 +- 27 files changed, 933 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/forge-linux.ts create mode 100755 apps/desktop/resources/linux/install-user.sh create mode 100755 apps/desktop/resources/linux/register-desktop.sh create mode 100644 apps/desktop/src/main/__tests__/linuxInstallation.test.ts create mode 100644 apps/desktop/src/main/__tests__/linuxPasswordStore.test.ts create mode 100644 apps/desktop/src/main/linuxInstallation.ts create mode 100644 apps/desktop/src/main/linuxPasswordStore.ts create mode 100644 docs/linux.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a0401aced2..eec9e6df953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,9 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: pnpm install:ripgrep + - name: Install Linux user-installer test tools + run: sudo apt-get update && sudo apt-get install --yes libarchive-tools binutils desktop-file-utils xdg-utils + - name: Run client and package unit test shard # 通过 pnpm 启动 runner,保留当前 pnpm 入口与 workspace 解析语义。 run: pnpm exec node scripts/test-workspaces.mjs --tier unit diff --git a/README.md b/README.md index f7bf135bcf3..8af0739d87f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ API keys, or use local models. See [cindy.app](https://cindy.app) for service details, [pricing](https://cindy.app/#pricing), and [downloads](https://cindy.app/download/). +Linux users: see the [Ubuntu, Arch Linux and Omarchy installation guide](docs/linux.md) +for installation, updates, keyring setup and migration from a manual install. + ## Yours to shape Open source means more than visible — it means changeable: diff --git a/apps/desktop/forge-linux.ts b/apps/desktop/forge-linux.ts new file mode 100644 index 00000000000..9e04e00d1b0 --- /dev/null +++ b/apps/desktop/forge-linux.ts @@ -0,0 +1,15 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Release identity outside ASAR for the unprivileged Linux installer. */ +export function stageLinuxBuildInfo( + buildPath: string, platform: string, arch: string, version: string, region: string, +): void { + if (platform !== 'linux') return; + if (!['x64', 'arm64'].includes(arch) || !['global', 'cn', 'dev'].includes(region) + || !/^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)?$/.test(version)) { + throw new Error('Invalid Linux build identity'); + } + fs.writeFileSync(path.join(buildPath, 'resources', 'linux-build-info'), + `cindy-linux-v1\n${version}\n${arch}\n${region}\n${region === 'dev' ? 'CindyDev' : 'Cindy'}\n`); +} diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 0056e46dea5..6bc37707637 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -11,6 +11,7 @@ import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-nati import { FusesPlugin } from '@electron-forge/plugin-fuses'; import { VitePlugin } from '@electron-forge/plugin-vite'; import type { ForgeArch, ForgeConfig, ForgePlatform } from '@electron-forge/shared-types'; +import { stageLinuxBuildInfo } from './forge-linux'; import { BRAND_IDENTITY, allDeepLinkSchemes, @@ -795,6 +796,7 @@ function extraResourcesForTarget(targetPlatform: string): string[] { if (targetPlatform === 'darwin') { base.push('resources/cli'); } + if (targetPlatform === 'linux') base.push('resources/linux'); return base; } @@ -1560,6 +1562,8 @@ const config: ForgeConfig = { // 都是已签名版本。详见 signPackagedExes() 注释。 postPackage: async (_forgeConfig, opts) => { for (const buildPath of opts.outputPaths) { + stageLinuxBuildInfo(buildPath, opts.platform, opts.arch, + process.env.APP_VERSION || DESKTOP_PACKAGE_VERSION, CINDY_REGION); const noticeName = stagePackagedThirdPartyNotices(buildPath, opts.platform); console.log(`[forge:postPackage] staged ${noticeName} + restricted component disclosure`); signPackagedExes(buildPath); diff --git a/apps/desktop/resources/linux/install-user.sh b/apps/desktop/resources/linux/install-user.sh new file mode 100755 index 00000000000..8dc35a00b01 --- /dev/null +++ b/apps/desktop/resources/linux/install-user.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# User-owned Cindy installation. Also embedded in the in-app updater: no Debian +# maintainer scripts, privilege escalation, or ASAR rewriting. +set -euo pipefail +umask 077 + +fail() { printf 'Cindy: %s\n' "$*" >&2; exit 1; } +[[ $(uname -s) == Linux ]] || fail 'This installer requires Linux.' +[[ $EUID -ne 0 ]] || fail 'Run as your desktop user, not root or sudo.' +for tool in bsdtar sha256sum stat dd mktemp realpath flock find readlink mv ln; do + command -v "$tool" >/dev/null || fail "Missing dependency: $tool (see docs/linux.md)." +done +mode=${1:-} +case "$mode" in + --install) + [[ $# -ge 3 && $# -le 4 ]] || fail 'Usage: install-user.sh --install PACKAGE.deb SHA256 [PREFIX]' + archive=$2 digest=${3,,} prefix=${4:-"$HOME/.local/opt/cindy"} + size=$(stat -c %s -- "$archive") + expected_version='' expected_region='' + ;; + --apply) + [[ $# -eq 8 ]] || fail 'Invalid update transaction arguments.' + archive=$2 digest=${3,,} size=$4 prefix=$5 expected_version=$6 expected_region=$7 expected_current=$8 + ;; + *) fail 'Usage: install-user.sh --install PACKAGE.deb SHA256 [PREFIX]' ;; +esac +[[ $digest =~ ^[a-f0-9]{64}$ ]] || fail 'A SHA-256 from the trusted release is required.' +[[ $size =~ ^[1-9][0-9]{0,10}$ ]] || fail 'Invalid package size.' +[[ -f $archive && ! -L $archive ]] || fail 'Package must be a regular file, not a symlink.' +archive=$(realpath -e -- "$archive") +[[ $prefix == /* && $prefix != *$'\n'* && $prefix != *$'\r'* ]] || fail 'PREFIX must be an absolute path without line breaks.' +prefix=$(realpath -m -- "$prefix") +user_home=$(realpath -e -- "$HOME") +[[ $prefix == "$user_home/"* && $prefix != "$user_home" ]] || fail 'PREFIX must be inside your home directory.' +marker="$prefix/.cindy-user-install" +if [[ -e $prefix ]]; then + [[ -d $prefix && -f $marker && ! -L $marker ]] || fail 'Existing PREFIX is not a managed Cindy install; choose an empty new path.' +else + [[ $mode == --install ]] || fail 'Managed installation disappeared.' + mkdir -p -- "$prefix" + printf 'cindy-user-install-v1:pending\n' > "$marker" +fi +[[ -O $prefix && -O $marker ]] || fail 'Installation is not owned by this user.' +exec 9> "$prefix/.install.lock" +flock -n 9 || fail 'Another installation is in progress.' +mkdir -p -- "$prefix/releases" +[[ ! -L $prefix/releases && -O $prefix/releases ]] || fail 'Invalid releases directory.' +stage=$(mktemp -d "$prefix/releases/.stage.XXXXXXXX") +new_release='' +cleanup() { + # Only remove this transaction's unactivated directory. In particular a + # signal just after activation must never delete the now-current release. + if [[ -n $new_release && $(readlink -- "$prefix/current" 2>/dev/null || true) != "$new_release" ]]; then + rm -rf -- "$prefix/$new_release" + fi + if [[ -n ${stage:-} && -d $stage ]]; then rm -rf -- "$stage"; fi +} +trap cleanup EXIT + +# Copy once, bounded and O_NOFOLLOW. Hash and extract the same private snapshot. +cap=$((size / 1048576 + 2)) +dd if="$archive" of="$stage/package.deb" iflag=nofollow,nonblock bs=1048576 count="$cap" status=none +[[ $(stat -c %s -- "$stage/package.deb") == "$size" ]] || fail 'Package size mismatch.' +actual=$(sha256sum -- "$stage/package.deb") +[[ ${actual:0:64} == "$digest" ]] || fail 'Package SHA-256 mismatch.' +bsdtar -tf "$stage/package.deb" > "$stage/members" +data_member='' +while IFS= read -r member; do + case "$member" in + data.tar|data.tar.gz|data.tar.xz|data.tar.zst) + [[ -z $data_member ]] || fail 'Duplicate package payload.' + data_member=$member ;; + esac +done < "$stage/members" +[[ -n $data_member ]] || fail 'Missing package payload.' +bsdtar -xOf "$stage/package.deb" "$data_member" > "$stage/data.tar" +mkdir "$stage/payload" +# libarchive's secure defaults reject traversal and symlink escapes. Never use +# -P / --absolute-paths or preserve archive ownership / setuid permissions. +bsdtar -xf "$stage/data.tar" -C "$stage/payload" --no-same-owner --no-same-permissions ./usr/lib/cindy +payload="$stage/payload/usr/lib/cindy" +[[ -d $payload && ! -L $payload ]] || fail 'Missing Cindy payload.' +info="$payload/resources/linux-build-info" +[[ -f $info && ! -L $info ]] || fail 'This package predates user-install support; use a newer release.' +mapfile -t fields < "$info" +[[ ${#fields[@]} -eq 5 && ${fields[0]} == cindy-linux-v1 ]] || fail 'Invalid build identity.' +version=${fields[1]} arch=${fields[2]} region=${fields[3]} executable=${fields[4]} +[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)?$ ]] || fail 'Invalid build version.' +[[ $region == global || $region == cn ]] || fail 'Only release builds can be installed.' +[[ $executable == Cindy ]] || fail 'Unexpected executable identity.' +case "$(uname -m):$arch" in x86_64:x64|aarch64:arm64) ;; *) fail 'Package architecture does not match this machine.' ;; esac +[[ -z $expected_version || $version == "$expected_version" ]] || fail 'Downloaded version does not match the update manifest.' +[[ -z $expected_region || $region == "$expected_region" ]] || fail 'Downloaded build belongs to a different region.' +identity=$(< "$marker") +[[ $identity == cindy-user-install-v1:pending || $identity == "cindy-user-install-v1:$region" ]] || fail 'Do not mix release regions in one installation.' +[[ -x $payload/$executable && ! -L $payload/$executable && -f $payload/resources/app.asar ]] || fail 'Incomplete application.' +while IFS= read -r -d '' entry; do + if [[ -L $entry ]]; then + target=$(realpath -m -- "$entry") + [[ $target == "$payload/"* ]] || fail 'Package symlink escapes the application.' + elif [[ ! -f $entry && ! -d $entry ]]; then + fail 'Package contains a special file.' + fi +done < <(find "$payload" -print0) + +current='' +if [[ -e $prefix/current || -L $prefix/current ]]; then + [[ -L $prefix/current ]] || fail 'current is not a managed symlink.' + current=$(readlink -- "$prefix/current") + [[ $current =~ ^releases/[A-Za-z0-9.+-]+$ && -d $prefix/$current ]] || fail 'Invalid current release.' +fi +if [[ $mode == --apply ]]; then + [[ $identity == "cindy-user-install-v1:$region" && -n $current ]] || fail 'Update requires an installed release.' + [[ $current == "$expected_current" ]] || fail 'Installation changed while the update was pending.' +fi +release="releases/$version-$digest" +if [[ -e $prefix/$release ]]; then + [[ $current == "$release" ]] && exit 0 + fail 'Release directory already exists; inspect it before retrying.' +fi +# Never install setuid/setgid helpers from a system package into user storage. +find "$payload" -type f -exec chmod u-s,g-s -- {} + +new_release=$release +mv -- "$payload" "$prefix/$release" +printf 'cindy-user-install-v1:%s\n' "$region" > "$stage/marker" +mv -T -- "$stage/marker" "$marker" +if [[ -n $current ]]; then + ln -s -- "$current" "$stage/previous" + mv -Tf -- "$stage/previous" "$prefix/previous" +fi +ln -s -- "$release" "$stage/current" +# Single rename is the activation point. Old versions are never overwritten. +mv -Tf -- "$stage/current" "$prefix/current" +if [[ $mode == --install ]]; then + # Keep launchers inside the prefix; do not overwrite other installations. + quoted=${prefix//\'/\'\\\'\'} + printf '#!/bin/sh\nexec '\''%s/current/Cindy'\'' "$@"\n' "$quoted" > "$prefix/launch" + chmod 755 "$prefix/launch" + printf 'Installed Cindy %s. Start with: %s/launch\n' "$version" "$prefix" + printf 'To add a menu entry and login links, run: bash %q %q\n' "$prefix/current/resources/linux/register-desktop.sh" "$prefix" +fi diff --git a/apps/desktop/resources/linux/register-desktop.sh b/apps/desktop/resources/linux/register-desktop.sh new file mode 100755 index 00000000000..1f3123ae5fd --- /dev/null +++ b/apps/desktop/resources/linux/register-desktop.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Explicit opt-in desktop integration, separate from the update transaction. +set -euo pipefail +umask 077 +fail() { printf 'Cindy: %s\n' "$*" >&2; exit 1; } +[[ $(uname -s) == Linux && $EUID -ne 0 && $# -eq 1 ]] || fail 'Run as your desktop user: register-desktop.sh PREFIX' +for tool in realpath sha256sum desktop-file-validate update-desktop-database xdg-mime flock; do + command -v "$tool" >/dev/null || fail "Missing dependency: $tool" +done +prefix=$(realpath -e -- "$1") +user_home=$(realpath -e -- "$HOME") +[[ $prefix == "$user_home/"* && $prefix != *[$'\n\r\t=%']* ]] || fail 'PREFIX must be inside HOME with no control characters, = or %.' +marker="$prefix/.cindy-user-install" +[[ -d $prefix && -O $prefix && -f $marker && ! -L $marker && -O $marker && -x $prefix/launch ]] || fail 'Not a managed installation.' +case "$(< "$marker")" in + cindy-user-install-v1:global|cindy-user-install-v1:cn) ;; + *) fail 'Not a release installation.' ;; +esac +exec 9> "$prefix/.install.lock" +flock -n 9 || fail 'Another installation is in progress.' +data_dir=$(realpath -m -- "${XDG_DATA_HOME:-$HOME/.local/share}") +[[ $data_dir == "$user_home/"* && $data_dir != *[$'\n\r\t']* ]] || fail 'XDG_DATA_HOME must be inside HOME.' +apps_dir="$data_dir/applications" +mkdir -p -- "$apps_dir" +id=$(printf '%s' "$prefix" | sha256sum) +app_id="com.xd.cindy.user.h${id:0:16}" +id="$app_id.desktop" +dest="$apps_dir/$id" +[[ ! -e $dest && ! -L $dest || -f $dest && ! -L $dest && -O $dest ]] || fail 'Desktop entry is not user-owned.' +temp=$(mktemp --suffix=.desktop "$apps_dir/.cindy-desktop.XXXXXXXX") +trap 'rm -f -- "$temp"' EXIT +# Desktop Entry escaping has two layers, unlike shell quoting. Keep %U +# outside the quoted executable. Reject literal % in PREFIX above. +exec_path=$prefix/launch +exec_path=${exec_path//\\/\\\\\\\\} +exec_path=${exec_path//\"/\\\\\"} +exec_path=${exec_path//\$/\\\\$} +exec_path=${exec_path//\`/\\\\\`} +icon=$prefix/current/resources/icon.png +icon=${icon//\\/\\\\} +printf '%s\n' '[Desktop Entry]' 'Type=Application' 'Name=Cindy (User)' \ + "Exec=\"$exec_path\" %U" "Icon=$icon" 'Terminal=false' \ + 'Categories=Development;' "StartupWMClass=$app_id" \ + 'MimeType=x-scheme-handler/cindy;x-scheme-handler/xdt-maker;' > "$temp" +desktop-file-validate "$temp" +mv -T -- "$temp" "$dest" +update-desktop-database "$apps_dir" +xdg-mime default "$id" x-scheme-handler/cindy x-scheme-handler/xdt-maker +printf 'Menu entry and login links registered: %s\n' "$dest" +printf 'CLI: use %s/launch (add a cindy symlink to your PATH if desired).\n' "$prefix" diff --git a/apps/desktop/scripts/package-desktop.mjs b/apps/desktop/scripts/package-desktop.mjs index c71d1114082..794e2a96617 100644 --- a/apps/desktop/scripts/package-desktop.mjs +++ b/apps/desktop/scripts/package-desktop.mjs @@ -504,7 +504,8 @@ async function finishLinux({ artifactDir, baseName, arch }) { // 包一致:归集时写死 amd64 会让 arm64 产物顶着 amd64 的名字发出去。 const installerPath = path.join(artifactDir, `${baseName}-${debianArch(arch)}.deb`); fs.copyFileSync(debPath, installerPath); - // Linux 没有 hotfix zip;应用内更新下载这份 installer .deb,再用 pkexec 覆盖安装。 + // One verified payload: Debian uses pkexec; managed user installs on Arch / + // Omarchy extract it without elevation and atomically switch releases. return { files: [fileEntry('installer', installerPath)], signing: { mode: 'none' } }; } diff --git a/apps/desktop/src/main/__tests__/linuxInstallation.test.ts b/apps/desktop/src/main/__tests__/linuxInstallation.test.ts new file mode 100644 index 00000000000..74341dcbdc6 --- /dev/null +++ b/apps/desktop/src/main/__tests__/linuxInstallation.test.ts @@ -0,0 +1,169 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { findLinuxUserInstallation, isDebianManagedInstallation, missingLinuxUserInstallTools, linuxUserDesktopName } from '../linuxInstallation'; +import { stageLinuxBuildInfo } from '../../../forge-linux'; +import { buildLinuxUpdateScript } from '../updateScriptLinux'; +import { allDeepLinkSchemes } from '@cindy/maker-shared/brand-identity'; + +describe('Linux install routing', () => { + it('requires exact Debian package ownership, not the existence of dpkg', () => { + expect(isDebianManagedInstallation('/usr/lib/cindy/Cindy', () => 'cindy: /usr/lib/cindy/Cindy\n')).toBe(true); + expect(isDebianManagedInstallation('/usr/lib/cindy/Cindy', () => 'cindy:amd64: /usr/lib/cindy/Cindy\n')).toBe(true); + expect(isDebianManagedInstallation('/home/test/Cindy', () => 'cindy: /usr/lib/cindy/Cindy\n')).toBe(false); + expect(isDebianManagedInstallation('/usr/lib/cindy/Cindy', () => 'unrelated: /usr/lib/cindy/Cindy\n')).toBe(false); + expect(isDebianManagedInstallation('/usr/lib/cindy/Cindy', () => { throw new Error('no dpkg'); })).toBe(false); + }); + it('reports missing portable dependencies', () => { + expect(missingLinuxUserInstallTools(() => true)).toEqual([]); + expect(missingLinuxUserInstallTools((name) => name !== 'bsdtar')).toEqual(['bsdtar']); + }); +}); + +// One shared, isolated filesystem fixture. No real app, credentials, network, +// package-manager database or desktop settings are accessed. +describe.skipIf(process.platform !== 'linux')('user installer transaction smoke (Linux/libarchive)', () => { + let root: string; + let prefix: string; + const installer = path.resolve(__dirname, '../../../resources/linux/install-user.sh'); + const packages = new Map(); + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-install-test-')); + prefix = path.join(root, 'home', "Cindy's space $literal"); + fs.mkdirSync(path.join(root, 'home')); + for (const version of ['1.0.0', '1.0.1', '1.0.2', '1.0.3', '1.0.4', '1.0.5']) { + const pkg = path.join(root, version); + const appDir = path.join(pkg, 'usr', 'lib', 'cindy'); + fs.mkdirSync(path.join(appDir, 'resources'), { recursive: true }); + fs.writeFileSync(path.join(appDir, 'Cindy'), '#!/bin/sh\nprintf "%s\\n" "fixture"\n', { mode: 0o755 }); + if (version === '1.0.4' || version === '1.0.5') { + fs.writeFileSync(path.join(appDir, 'Cindy'), [ + '#!/bin/sh', 'printf "%s\\n" "$$" "$@" > "$CINDY_TEST_LAUNCH_LOG"', 'exec sleep 10', '', + ].join('\n'), { mode: 0o755 }); + } + fs.writeFileSync(path.join(appDir, 'resources', 'app.asar'), 'fake archive'); + stageLinuxBuildInfo(appDir, 'linux', process.arch, version, 'global'); + if (version === '1.0.3') fs.symlinkSync('/etc/passwd', path.join(appDir, 'escape')); + execFileSync('bsdtar', ['-czf', 'data.tar.gz', './usr'], { cwd: pkg }); + fs.writeFileSync(path.join(pkg, 'debian-binary'), '2.0\n'); + const file = path.join(pkg, 'package.deb'); + execFileSync('ar', ['rc', file, 'debian-binary', 'data.tar.gz'], { cwd: pkg }); + const bytes = fs.readFileSync(file); + packages.set(version, { file, digest: createHash('sha256').update(bytes).digest('hex'), size: bytes.length }); + } + }); + afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + function run(version: string, apply = false, overrides: { digest?: string; version?: string; region?: string; env?: NodeJS.ProcessEnv } = {}) { + const pkg = packages.get(version)!; + const args = apply + ? ['--apply', pkg.file, overrides.digest ?? pkg.digest, String(pkg.size), prefix, + overrides.version ?? version, overrides.region ?? 'global', fs.readlinkSync(path.join(prefix, 'current'))] + : ['--install', pkg.file, overrides.digest ?? pkg.digest, prefix]; + return spawnSync('bash', [installer, ...args], { + env: { ...process.env, HOME: path.join(root, 'home'), ...overrides.env }, encoding: 'utf8', timeout: 15_000, + }); + } + it('installs, rejects corrupt/wrong builds, applies two updates, and retains previous releases', () => { + const first = run('1.0.0'); + expect(first.stderr).toBe(''); + expect(first.status).toBe(0); + const before = fs.readlinkSync(path.join(prefix, 'current')); + expect(execFileSync(path.join(prefix, 'launch'), { encoding: 'utf8' })).toBe('fixture\n'); + const find = () => findLinuxUserInstallation(path.join(prefix, 'current', 'Cindy'), path.join(root, 'home'), process.getuid!()); + expect(find()).toEqual({ prefix, current: before, region: 'global' }); + expect(run('1.0.1', true, { digest: '0'.repeat(64) }).status).not.toBe(0); + expect(run('1.0.1', true, { version: '9.9.9' }).status).not.toBe(0); + expect(run('1.0.1', true, { region: 'cn' }).status).not.toBe(0); + expect(run('1.0.3', true).status).not.toBe(0); + expect(fs.readlinkSync(path.join(prefix, 'current'))).toBe(before); + const faultBin = path.join(root, 'fault-bin'); + fs.mkdirSync(faultBin); + fs.writeFileSync(path.join(faultBin, 'mv'), [ + '#!/bin/bash', '[[ "${@: -1}" == "$CINDY_TEST_FAIL_DEST" ]] && exit 73', + 'exec /usr/bin/mv "$@"', '', + ].join('\n'), { mode: 0o755 }); + expect(run('1.0.1', true, { env: { + PATH: faultBin + path.delimiter + process.env.PATH, CINDY_TEST_FAIL_DEST: path.join(prefix, 'current'), + } }).status).toBe(73); + expect(fs.readlinkSync(path.join(prefix, 'current'))).toBe(before); + expect(fs.existsSync(path.join(prefix, 'releases', '1.0.1-' + packages.get('1.0.1')!.digest))).toBe(false); + // Retrying after a failure immediately before the atomic rename works. + expect(run('1.0.1', true).status).toBe(0); + expect(fs.readlinkSync(path.join(prefix, 'previous'))).toBe(before); + const second = fs.readlinkSync(path.join(prefix, 'current')); + expect(run('1.0.2', true).status).toBe(0); + expect(fs.readlinkSync(path.join(prefix, 'previous'))).toBe(second); + expect(fs.existsSync(path.join(prefix, before, 'Cindy'))).toBe(true); + expect(fs.readdirSync(path.join(prefix, 'releases')).some((name) => name.startsWith('.stage.'))).toBe(false); + expect(find()?.current).toContain('1.0.2'); + expect(findLinuxUserInstallation(path.join(prefix, before, 'Cindy'), path.join(root, 'home'), process.getuid!())).toBeNull(); + expect(findLinuxUserInstallation(path.join(prefix, 'current', 'Cindy'), prefix, process.getuid!())).toBeNull(); + }); + it('registers a valid stable desktop entry with no real desktop changes', () => { + const bin = path.join(root, 'fake-bin'); + fs.mkdirSync(bin); + const mimeLog = path.join(root, 'mime-args'); + fs.writeFileSync(path.join(bin, 'xdg-mime'), '#!/bin/sh\nprintf "%s\\n" "$@" > "$CINDY_TEST_MIME_LOG"\n', { mode: 0o755 }); + const data = path.join(root, 'home', 'data'); + const result = spawnSync('bash', [ + path.resolve(__dirname, '../../../resources/linux/register-desktop.sh'), prefix, + ], { + env: { ...process.env, HOME: path.join(root, 'home'), XDG_DATA_HOME: data, + XDG_CONFIG_HOME: path.join(root, 'home', 'config'), PATH: bin + path.delimiter + process.env.PATH, + CINDY_TEST_MIME_LOG: mimeLog }, + encoding: 'utf8', timeout: 15_000, + }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + const entries = fs.readdirSync(path.join(data, 'applications')).filter((name) => name.endsWith('.desktop')); + expect(entries).toHaveLength(1); + expect(entries[0]).toBe(linuxUserDesktopName(prefix)); + const desktop = fs.readFileSync(path.join(data, 'applications', entries[0]), 'utf8'); + expect(desktop).toContain('/launch" %U'); + expect(desktop).not.toContain('/releases/'); + expect(desktop).toContain('StartupWMClass=' + entries[0].replace(/\.desktop$/, '')); + expect(desktop).toContain('space \\\\$literal'); + expect(fs.readFileSync(mimeLog, 'utf8').trim().split('\n')).toEqual([ + 'default', entries[0], ...allDeepLinkSchemes().map((scheme) => 'x-scheme-handler/' + scheme), + ]); + }); + it('runs the detached updater and relaunches with the same backend on success and failure', () => { + const launchLog = path.join(root, 'launch-args'); + const logPath = path.join(root, 'update.log'); + const lockFilePath = path.join(root, 'update.lock'); + for (const [version, valid] of [['1.0.4', true], ['1.0.5', false]] as const) { + const pkg = packages.get(version)!; + const before = fs.readlinkSync(path.join(prefix, 'current')); + const script = buildLinuxUpdateScript({ + pid: 2147483647, // Outside Linux's pid_max: no real process may be killed. + debPath: pkg.file, sha256: valid ? pkg.digest : 'f'.repeat(64), sizeBytes: pkg.size, + exePath: path.join(prefix, before, 'Cindy'), lockFilePath, logPath, + userInstallation: { prefix, current: before, region: 'global', version }, + relaunchArgs: ['--password-store=gnome-libsecret'], + timings: { lockHeartbeatSeconds: 1, verifyTimeoutSeconds: 3, verifyRetryAtSeconds: 2 }, + }); + fs.rmSync(launchLog, { force: true }); + const result = spawnSync('setsid', ['bash', '-c', script], { + env: { ...process.env, HOME: path.join(root, 'home'), CINDY_TEST_LAUNCH_LOG: launchLog }, + encoding: 'utf8', timeout: 15_000, + }); + // The failed-install path launches asynchronously just before exiting. + const wait = spawnSync('bash', ['-c', 'for i in {1..50}; do [[ -s "$1" ]] && exit 0; sleep 0.02; done; exit 1', 'wait', launchLog], + { timeout: 3000 }); + expect(wait.status).toBe(0); + const [pid, ...args] = fs.readFileSync(launchLog, 'utf8').trim().split('\n'); + try { + expect(result.stderr).toBe(''); + expect(result.status).toBe(valid ? 0 : 1); + expect(args).toEqual(['--password-store=gnome-libsecret']); + expect(fs.readlinkSync(path.join(prefix, 'current'))).toBe(valid ? 'releases/' + version + '-' + pkg.digest : before); + expect(fs.existsSync(lockFilePath)).toBe(false); + } finally { + process.kill(Number(pid), 'SIGTERM'); // Only the fake app this test launched. + } + } + }, 30_000); +}); diff --git a/apps/desktop/src/main/__tests__/linuxPasswordStore.test.ts b/apps/desktop/src/main/__tests__/linuxPasswordStore.test.ts new file mode 100644 index 00000000000..29deaa3d334 --- /dev/null +++ b/apps/desktop/src/main/__tests__/linuxPasswordStore.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { linuxPasswordStoreFallback, linuxPasswordStoreRelaunchArgs } from '../linuxPasswordStore'; + +describe('Linux secure storage startup policy', () => { + it.each(['Hyprland', 'sway', 'niri', 'Hyprland:wlroots'])('uses Secret Service on %s', (desktop) => { + expect(linuxPasswordStoreFallback('linux', desktop, false)).toBe('gnome-libsecret'); + }); + it.each(['KDE', 'GNOME', 'XFCE', 'KDE:Hyprland', '', undefined])('leaves %s to Chromium', (desktop) => { + expect(linuxPasswordStoreFallback('linux', desktop, false)).toBeNull(); + }); + it('preserves explicit overrides and other platforms', () => { + expect(linuxPasswordStoreFallback('linux', 'Hyprland', true)).toBeNull(); + expect(linuxPasswordStoreFallback('darwin', 'Hyprland', false)).toBeNull(); + expect(linuxPasswordStoreFallback('win32', 'Hyprland', false)).toBeNull(); + }); + it('preserves the backend on relaunch without forwarding arbitrary arguments', () => { + expect(linuxPasswordStoreRelaunchArgs('gnome-libsecret')).toEqual(['--password-store=gnome-libsecret']); + expect(linuxPasswordStoreRelaunchArgs('kwallet6')).toEqual(['--password-store=kwallet6']); + expect(linuxPasswordStoreRelaunchArgs('')).toEqual([]); + expect(linuxPasswordStoreRelaunchArgs('gnome-libsecret --no-sandbox')).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/__tests__/updateScriptLinux.test.ts b/apps/desktop/src/main/__tests__/updateScriptLinux.test.ts index 67cb54ab819..f355d8a7ec7 100644 --- a/apps/desktop/src/main/__tests__/updateScriptLinux.test.ts +++ b/apps/desktop/src/main/__tests__/updateScriptLinux.test.ts @@ -38,6 +38,20 @@ describe('shellSingleQuote', () => { describe('buildLinuxUpdateScript structure', () => { const script = buildLinuxUpdateScript(makeParams()); + it('uses the embedded transaction and stable launcher for a managed user install', () => { + const portable = buildLinuxUpdateScript(makeParams({ + userInstallation: { prefix: '/home/user/Cindy', current: 'releases/1.0.0-aaa', region: 'global', version: '1.0.1' }, + relaunchArgs: ['--password-store=gnome-libsecret'], + })); + expect(portable).not.toContain('PKEXEC='); + expect(portable).not.toContain('apt-get install'); + expect(portable).toContain('cindy-user-install --apply'); + expect(portable).toContain("nohup '/home/user/Cindy/current/Cindy' '--password-store=gnome-libsecret'"); + expect(portable).toContain('flock -n 9'); + expect(portable).toContain('INSTALL_EXIT=$?'); + if (process.platform !== 'win32') execFileSync('bash', ['-n'], { input: portable }); + }); + it('installs the staged .deb through one pkexec bash shell', () => { expect(script).toContain('PKEXEC=/usr/bin/pkexec'); expect(script).toContain('ELEVATED=\'set -eu'); diff --git a/apps/desktop/src/main/__tests__/updateService.test.ts b/apps/desktop/src/main/__tests__/updateService.test.ts index a1ac71a9658..16ffbfc6a69 100644 --- a/apps/desktop/src/main/__tests__/updateService.test.ts +++ b/apps/desktop/src/main/__tests__/updateService.test.ts @@ -38,6 +38,12 @@ const spawnProcess = vi.fn(() => ({ unref: vi.fn(), on: vi.fn(), })); +const findLinuxUserInstallation = vi.fn(() => null); +const isDebianManagedInstallation = vi.fn(() => false); +const missingLinuxUserInstallTools = vi.fn(() => [] as string[]); +vi.mock('../linuxInstallation', () => ({ + findLinuxUserInstallation, isDebianManagedInstallation, missingLinuxUserInstallTools, +})); const checkWindowsUpdaterPrerequisites = vi.fn< () => { satisfied: boolean; missingFiles: string[] } >(() => ({ @@ -240,6 +246,12 @@ beforeEach(() => { readAutoUpdateSettings.mockReset(); readAutoUpdateSettings.mockReturnValue({ autoRelaunchOnIdle: true }); spawnProcess.mockClear(); + findLinuxUserInstallation.mockReset(); + findLinuxUserInstallation.mockReturnValue(null); + isDebianManagedInstallation.mockReset(); + isDebianManagedInstallation.mockReturnValue(false); + missingLinuxUserInstallTools.mockReset(); + missingLinuxUserInstallTools.mockReturnValue([]); checkWindowsUpdaterPrerequisites.mockReset(); checkWindowsUpdaterPrerequisites.mockReturnValue({ satisfied: true, @@ -327,6 +339,33 @@ function linuxInstallerManifest(version = '0.0.65') { } describe('checkForUpdate Linux installer flow', () => { + it('does not quit or increment attempts for an unmanaged Linux installation', async () => { + download.mockImplementation(async ({ targetPath }: { targetPath: string }) => { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, 'deb'); + return { path: targetPath, size: 123 }; + }); + const service = await freshUpdateService('linux', 'x64'); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + service.initUpdateService(); + try { + await expect(service.checkForUpdate(linuxInstallerManifest())).resolves.toBe('ready'); + ipcListeners.get('update-relaunch')?.({}, 'dark'); + await vi.waitFor(() => expect(ipcHandlers.get('update-get-status')?.()).toMatchObject({ + status: 'ready', errorCode: 'linux_installation_unsupported', + })); + const info = JSON.parse(fs.readFileSync(path.join(TEST_USER_DATA, 'updates', 'patch-info.json'), 'utf8')); + expect(info.applyAttempts).toBeUndefined(); + expect(fs.existsSync(path.join(TEST_USER_DATA, 'updates', info.fileName))).toBe(true); + expect(spawnProcess).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(service.isUpdateRelaunchImminent()).toBe(false); + } finally { + service.stopUpdateService(); + exitSpy.mockRestore(); + } + }); + it('downloads the Linux installer .deb instead of a hotfix zip', async () => { readAutoUpdateSettings.mockReturnValue({ autoRelaunchOnIdle: false }); download.mockImplementation(async ({ targetPath }: { targetPath: string }) => { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c4a0c0b3120..f4b89e49c09 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,6 +3,7 @@ import fixPath from 'fix-path'; import { app } from 'electron'; import { execFileSync } from 'node:child_process'; import path from 'node:path'; +import os from 'node:os'; import { setDefaultAutoSelectFamilyAttemptTimeout } from 'node:net'; import { exit, stderr } from 'node:process'; import { BRAND_IDENTITY } from '@cindy/maker-shared/brand-identity'; @@ -12,6 +13,20 @@ import { resolveRegionUserDataDirName } from './regionUserData.js'; import { createLogger, initLogger } from './logger.js'; import { beginDesktopDevInstance, type DesktopDevMode } from './devStartupStatus.js'; import { ensureSystemBinPathForMachineId } from './deviceId.js'; +import { linuxPasswordStoreFallback } from './linuxPasswordStore.js'; +import { findLinuxUserInstallation, linuxUserDesktopName } from './linuxInstallation.js'; + +// Before bootstrap/auth modules and app.ready. Updates use this same entry, +// so Hyprland does not depend on a launcher to select its existing keyring. +const passwordStore = linuxPasswordStoreFallback( + process.platform, process.env.XDG_CURRENT_DESKTOP, + app.commandLine.hasSwitch('password-store'), +); +if (passwordStore) app.commandLine.appendSwitch('password-store', passwordStore); +if (process.platform === 'linux' && app.isPackaged) { + const installation = findLinuxUserInstallation(app.getPath('exe'), os.homedir(), process.getuid?.() ?? -1); + if (installation) app.setDesktopName(linuxUserDesktopName(installation.prefix)); +} // 正式目录保持历史兼容:global 构建继续使用 CindyGlobal,cn 版继续使用 // productName 默认的 Cindy;dev 也按构建区域选择对应 profile。必须在 diff --git a/apps/desktop/src/main/linuxInstallation.ts b/apps/desktop/src/main/linuxInstallation.ts new file mode 100644 index 00000000000..af359bdb836 --- /dev/null +++ b/apps/desktop/src/main/linuxInstallation.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; + +/** Same identity as register-desktop.sh, stable across releases and valid for + * portals requiring reverse-DNS application IDs. This does not rename the app + * or its keyring identity. + */ +export function linuxUserDesktopName(prefix: string): string { + return `com.xd.cindy.user.h${createHash('sha256').update(prefix).digest('hex').slice(0, 16)}.desktop`; +} + +/** Only our marked, user-owned release layout is eligible for unprivileged + * self-update. A writable arbitrary directory is not an installation contract. + */ +export interface LinuxUserInstallation { + prefix: string; + current: string; + region: 'global' | 'cn'; +} + +export function findLinuxUserInstallation( + exePath: string, home: string, uid: number, +): LinuxUserInstallation | null { + try { + const exe = fs.realpathSync(exePath); + const release = path.dirname(exe); + const prefix = path.dirname(path.dirname(release)); + const relative = path.relative(fs.realpathSync(home), prefix); + if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) return null; + if (path.basename(exe) !== 'Cindy' || path.basename(path.dirname(release)) !== 'releases') return null; + const markerPath = path.join(prefix, '.cindy-user-install'); + const marker = fs.lstatSync(markerPath); + if (!marker.isFile() || marker.uid !== uid || fs.statSync(prefix).uid !== uid) return null; + const identity = fs.readFileSync(markerPath, 'utf8').trim(); + const region = identity === 'cindy-user-install-v1:global' ? 'global' + : identity === 'cindy-user-install-v1:cn' ? 'cn' : null; + if (!region) return null; + const current = fs.readlinkSync(path.join(prefix, 'current')); + if (!/^releases\/[A-Za-z0-9.+-]+$/.test(current)) return null; + if (fs.realpathSync(path.join(prefix, current)) !== release) return null; + fs.accessSync(prefix, fs.constants.W_OK); + return { prefix, current, region }; + } catch { return null; } +} + +/** Query ownership, not just tool existence: dpkg installed on Arch must not + * cause us to install a second Cindy while relaunching a pacman-owned binary. + */ +export function isDebianManagedInstallation( + exePath: string, + query: (exe: string) => string = (exe) => execFileSync('/usr/bin/dpkg-query', ['-S', exe], { + encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], + }), +): boolean { + try { + return query(exePath).split('\n').some((line) => /^cindy(?::[a-z0-9]+)?: /.test(line) + && line.slice(line.indexOf(': ') + 2) === exePath); + } catch { return false; } +} + +export function missingLinuxUserInstallTools( + probe: (name: string) => boolean = (name) => { + try { + execFileSync('/bin/bash', ['-c', 'command -v -- "$1" >/dev/null', 'cindy-probe', name], { + timeout: 2000, stdio: 'ignore', + }); + return true; + } catch { return false; } + }, +): string[] { + return ['bsdtar', 'sha256sum', 'stat', 'dd', 'mktemp', 'realpath', 'flock', 'find', 'readlink', 'mv', 'ln', 'setsid', 'pgrep'] + .filter((name) => !probe(name)); +} diff --git a/apps/desktop/src/main/linuxPasswordStore.ts b/apps/desktop/src/main/linuxPasswordStore.ts new file mode 100644 index 00000000000..865c55d17a4 --- /dev/null +++ b/apps/desktop/src/main/linuxPasswordStore.ts @@ -0,0 +1,18 @@ +/** Narrow fallback for standalone Wayland compositors Chromium does not detect. + * KDE/GNOME and explicit overrides retain their existing key identity. Never + * fall back to basic/plaintext when a keyring is locked. + */ +export function linuxPasswordStoreFallback( + platform: string, desktop: string | undefined, hasExplicitStore: boolean, +): 'gnome-libsecret' | null { + if (platform !== 'linux' || hasExplicitStore) return null; + const names = (desktop ?? '').toLowerCase().split(/[:;]/); + if (names.some((name) => /^(kde|gnome|x-cinnamon|xfce|unity)$/.test(name))) return null; + return names.some((name) => /^(hyprland|sway|niri)$/.test(name)) ? 'gnome-libsecret' : null; +} + +/** Preserve only the storage selector, not URLs or transient task arguments. */ +export function linuxPasswordStoreRelaunchArgs(value: string): string[] { + return ['gnome-libsecret', 'kwallet', 'kwallet5', 'kwallet6', 'basic'].includes(value) + ? [`--password-store=${value}`] : []; +} diff --git a/apps/desktop/src/main/updateScriptLinux.ts b/apps/desktop/src/main/updateScriptLinux.ts index 52e64f41150..9f8dfa31748 100644 --- a/apps/desktop/src/main/updateScriptLinux.ts +++ b/apps/desktop/src/main/updateScriptLinux.ts @@ -1,9 +1,12 @@ +import userInstallerSource from '../../resources/linux/install-user.sh?raw'; +import type { LinuxUserInstallation } from './linuxInstallation'; + /** * updateScriptLinux — pure builder for the Linux .deb update-apply bash script. * * Linux has no cindy-updater binary. After the Electron process exits, this - * script asks polkit (pkexec) to install the staged .deb over the existing - * package, then relaunches the same executable path. + * script either applies a managed user install without elevation, or asks + * polkit to replace a Debian-owned package, then relaunches the stable entry. * * Extracted so the generated script can be regression-tested without Electron. */ @@ -44,6 +47,9 @@ export interface LinuxUpdateScriptParams { lockFilePath: string; /** cindy-update.log path. */ logPath: string; + /** Only a validated, marked user install may bypass the package manager. */ + userInstallation?: LinuxUserInstallation & { version: string }; + relaunchArgs?: string[]; timings?: Partial; } @@ -86,6 +92,9 @@ export function buildLinuxUpdateScript(params: LinuxUpdateScriptParams): string const qExe = shellSingleQuote(exePath); const qLock = shellSingleQuote(lockFilePath); const qSha = shellSingleQuote(sha256); + const userInstallation = params.userInstallation; + const launchPath = userInstallation ? `${userInstallation.prefix}/current/Cindy` : exePath; + const launch = [launchPath, ...(params.relaunchArgs ?? [])].map(shellSingleQuote).join(' '); return [ '#!/bin/bash', @@ -131,7 +140,7 @@ export function buildLinuxUpdateScript(params: LinuxUpdateScriptParams): string ` wait "$LOCK_HEARTBEAT_PID" 2>/dev/null`, ' rm -f "$INSTALL_PID_FILE"', ` rm -f ${qLock}`, - ` setsid nohup ${qExe} >/dev/null 2>&1 &`, + ` setsid nohup ${launch} >/dev/null 2>&1 &`, ' LAUNCHED_PID=$!', '}', '(', @@ -174,7 +183,7 @@ export function buildLinuxUpdateScript(params: LinuxUpdateScriptParams): string 'trap cleanup EXIT', '', `echo "[$(date)] Update script started, waiting for PID ${pid}" >> ${qLog}`, - `echo "[$(date)] deb=${qDeb} exe=${qExe}" >> ${qLog}`, + `printf '[%s] deb=%s exe=%s\\n' "$(date)" ${qDeb} ${qExe} >> ${qLog}`, '', 'WAITED=0', `while kill -0 ${pid} 2>/dev/null; do`, @@ -192,6 +201,12 @@ export function buildLinuxUpdateScript(params: LinuxUpdateScriptParams): string `echo "[$(date)] Process ${pid} exited, waiting for filesystem to settle" >> ${qLog}`, 'sleep 2', '', + ...(userInstallation ? [ + // Source is embedded in the packaged application, never loaded + // from a mutable external helper or a pinned system Node installation. + `INSTALLER=${shellSingleQuote(userInstallerSource)}`, + `bash -c "$INSTALLER" cindy-user-install --apply ${qDeb} ${qSha} ${sizeBytes} ${shellSingleQuote(userInstallation.prefix)} ${shellSingleQuote(userInstallation.version)} ${shellSingleQuote(userInstallation.region)} ${shellSingleQuote(userInstallation.current)} >> ${qLog} 2>&1 &`, + ] : [ 'PKEXEC=/usr/bin/pkexec', 'if [ ! -x "$PKEXEC" ]; then', ' PKEXEC=$(command -v pkexec 2>/dev/null || true)', @@ -247,6 +262,7 @@ export function buildLinuxUpdateScript(params: LinuxUpdateScriptParams): string '', `echo "[$(date)] invoking elevated installer via pkexec" >> ${qLog}`, `"$PKEXEC" /bin/bash -c "$ELEVATED" bash ${qSha} ${qDeb} ${sizeBytes} >> ${qLog} 2>&1 &`, + ]), 'INSTALL_PID=$!', `echo "$INSTALL_PID" > "$INSTALL_PID_FILE"`, 'wait "$INSTALL_PID"', @@ -260,7 +276,7 @@ export function buildLinuxUpdateScript(params: LinuxUpdateScriptParams): string ' exit 1', 'fi', '', - `echo "[$(date)] Starting app: ${qExe}" >> ${qLog}`, + `printf '[%s] Starting app: %s\\n' "$(date)" ${shellSingleQuote(launchPath)} >> ${qLog}`, // 先杀心跳、放锁,再 setsid 拉起:新进程不在本进程组里,不会 // 被 scan_group_others 误判成安装链,也不会卡在自己的锁上。 'relaunch_app', @@ -279,7 +295,7 @@ export function buildLinuxUpdateScript(params: LinuxUpdateScriptParams): string ' fi', ` if [ "$i" -eq ${t.verifyRetryAtSeconds} ]; then`, ` echo "[$(date)] still not up after ${t.verifyRetryAtSeconds}s — retrying relaunch" >> ${qLog}`, - ` setsid nohup ${qExe} >/dev/null 2>&1 &`, + ` setsid nohup ${launch} >/dev/null 2>&1 &`, ' LAUNCHED_PID=$!', ' fi', ' sleep 1', diff --git a/apps/desktop/src/main/updateService.ts b/apps/desktop/src/main/updateService.ts index 91332185256..9dd0daa5682 100644 --- a/apps/desktop/src/main/updateService.ts +++ b/apps/desktop/src/main/updateService.ts @@ -65,6 +65,9 @@ import { throwIpcError } from './utils/ipcValidate'; import { noteExpectedExit } from './startup-diagnostics'; import { buildMacOSUpdateScript } from './updateScriptMacOS'; import { buildLinuxUpdateScript, normalizeLinuxDebSha256 } from './updateScriptLinux'; +import { findLinuxUserInstallation, isDebianManagedInstallation, missingLinuxUserInstallTools, type LinuxUserInstallation } from './linuxInstallation'; +import { linuxPasswordStoreRelaunchArgs } from './linuxPasswordStore'; +import { CURRENT_CINDY_REGION } from '../shared/brandRegion'; import { disposeAndroidAdb } from './mcp-integrations/android'; import { abortIOSSimulatorOperationsForExit } from './mcp-integrations/ios-simulator-exit'; import { getGhostNodeRuntimeBroker } from './cindy-brain/index'; @@ -1716,7 +1719,7 @@ function readStagedLinuxDebSha256(debPath: string): string | null { return linuxStagedDebSha256; } -function executeUpdateLinux(debPath: string): void { +function executeUpdateLinux(debPath: string, installation: LinuxUserInstallation | null): void { const exePath = app.getPath('exe'); const lockFilePath = getUpdateLockPath(); const logDir = path.join(app.getPath('userData'), 'logs'); @@ -1758,8 +1761,19 @@ function executeUpdateLinux(debPath: string): void { let script: string; try { + // Do not change installation strategy after the preflight (there is an + // await while reclaiming runners). A changed layout must fail closed. + const now = findLinuxUserInstallation(exePath, os.homedir(), process.getuid?.() ?? -1); + if (installation + ? !now || now.prefix !== installation.prefix || now.current !== installation.current + || now.region !== installation.region || !readyVersion + : now !== null || !isDebianManagedInstallation(exePath)) { + throw new Error('Linux installation changed after preflight'); + } script = buildLinuxUpdateScript({ pid, debPath, sha256, sizeBytes, exePath, lockFilePath, logPath, + userInstallation: installation ? { ...installation, version: readyVersion! } : undefined, + relaunchArgs: linuxPasswordStoreRelaunchArgs(app.commandLine?.getSwitchValue('password-store') ?? ''), }); } catch (err) { log.error('failed to build Linux update script:', err); @@ -1910,6 +1924,25 @@ async function executeRelaunchUnguarded(theme: 'light' | 'dark'): Promise // keeps both Cindy and the already-downloaded patch intact. if (!ensureWindowsUpdaterPrerequisites()) return; + // Do not stop active work or quit into a Debian-only installer on Arch. + // This also protects pacman/AUR-owned and manually unpacked applications. + let linuxInstallation: LinuxUserInstallation | null = null; + if (process.platform === 'linux') { + const exePath = app.getPath('exe'); + const installation = findLinuxUserInstallation(exePath, os.homedir(), process.getuid?.() ?? -1); + linuxInstallation = installation; + const supported = installation + ? installation.region === CURRENT_CINDY_REGION && missingLinuxUserInstallTools().length === 0 + : isDebianManagedInstallation(exePath); + if (!supported) { + log.error('Linux installation cannot self-update; use the installation guide or its package manager'); + isRelaunching = false; + autoRelaunchInProgress = false; + setStatus('ready', { version: readyVersion ?? undefined, errorCode: 'linux_installation_unsupported' }); + return; + } + } + // Gate *before* the updater is spawned, not inside forceQuit: once the // updater script is running it polls our pid and SIGKILLs us after 120s // (`updateScriptMacOS.ts`), so a late decision not to exit does not keep this @@ -1945,7 +1978,7 @@ async function executeRelaunchUnguarded(theme: 'light' | 'dark'): Promise break; case 'linux': incrementApplyAttempts(); - executeUpdateLinux(readyFilePath); + executeUpdateLinux(readyFilePath, linuxInstallation); break; default: log.error(`Unsupported platform: ${process.platform}`); diff --git a/apps/desktop/src/main/vendor.d.ts b/apps/desktop/src/main/vendor.d.ts index 59a9063f263..4eeffd531db 100644 --- a/apps/desktop/src/main/vendor.d.ts +++ b/apps/desktop/src/main/vendor.d.ts @@ -14,6 +14,11 @@ declare module '*.md?raw' { export default content; } +declare module '*.sh?raw' { + const content: string; + export default content; +} + declare module '*.cjs?raw' { const content: string; export default content; diff --git a/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx b/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx index 3db7c650d3d..c5265edb494 100644 --- a/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx +++ b/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx @@ -105,6 +105,31 @@ beforeEach(() => { afterEach(cleanup); describe('UpdateBanner relaunch entry', () => { + it('checks active work again when retrying a Linux update after repairing dependencies', async () => { + updateStatus.current.errorCode = 'linux_installation_unsupported'; + anyActivityBlockingRelaunch.mockResolvedValue(true); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'update.linuxInstallation.retry' })); + await waitFor(() => expect(anyActivityBlockingRelaunch).toHaveBeenCalledTimes(1)); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + }); + + it.each(['light', 'dark'])('offers the Linux guide without restarting in %s mode', async (theme) => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + updateStatus.current.errorCode = 'linux_installation_unsupported'; + render(); + await screen.findByText('update.linuxInstallation.title'); + fireEvent.click(screen.getByRole('button', { name: 'update.linuxInstallation.later' })); + await waitFor(() => expect(screen.queryByText('update.linuxInstallation.title')).toBeNull()); + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + await screen.findByText('update.linuxInstallation.title'); + fireEvent.click(screen.getByRole('button', { name: 'update.linuxInstallation.guide' })); + expect(openExternal).toHaveBeenCalledWith('https://github.com/makecindy/cindy/blob/main/docs/linux.md'); + expect(anyActivityBlockingRelaunch).not.toHaveBeenCalled(); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + document.documentElement.classList.remove('dark'); + }); + it('prompts for the VC++ Runtime, keeps the banner, and rechecks on demand', async () => { updateStatus.current = { status: 'ready', diff --git a/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx b/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx index 097e8328e5a..36b0577747e 100644 --- a/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx +++ b/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx @@ -108,6 +108,7 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP const restoreFocusRef = useRef(false); const [showTranslocatedDialog, setShowTranslocatedDialog] = useState(false); const [showWindowsRuntimeDialog, setShowWindowsRuntimeDialog] = useState(false); + const [showLinuxInstallationDialog, setShowLinuxInstallationDialog] = useState(false); const { t } = useTranslation(); const [showSpawnFailedDialog, setShowSpawnFailedDialog] = useState(false); @@ -120,8 +121,14 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP const isSpawnFailed = status === 'error' && errorCode === 'updater_spawn_failed'; const isWindowsRuntimeMissing = status === 'ready' && errorCode === 'windows_vc_runtime_missing'; + const isLinuxInstallationUnsupported = + status === 'ready' && errorCode === 'linux_installation_unsupported'; const isPreparing = status === 'superseding'; + useEffect(() => { + if (isLinuxInstallationUnsupported) setShowLinuxInstallationDialog(true); + }, [isLinuxInstallationUnsupported]); + useEffect(() => { if (isWindowsRuntimeMissing) setShowWindowsRuntimeDialog(true); }, [isWindowsRuntimeMissing]); @@ -137,11 +144,11 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // 一旦不再是 ready(如被 superseding 顶掉 / 出错),复位确认态,避免残留一个 // 指向旧补丁的「仍要重启」;同时作废在飞的探针 —— 它的结论建立在「当前补丁可装」之上。 useEffect(() => { - if (status !== 'ready' || isWindowsRuntimeMissing) { + if (status !== 'ready' || isWindowsRuntimeMissing || isLinuxInstallationUnsupported) { relaunchEpochRef.current += 1; setConfirming(false); } - }, [status, isWindowsRuntimeMissing]); + }, [status, isWindowsRuntimeMissing, isLinuxInstallationUnsupported]); // 卸载时同样作废在飞的探针。卸载后 setConfirming 只是一次无效更新,但 handleRelaunch // 会真的把 app 重启掉 —— 这条 cleanup 不是防 React 警告,是防意外重启。 @@ -236,10 +243,18 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // 卸载、已就绪补丁可能被 superseding 顶掉。少了它们,「点了稍后却重启」「装回旧补丁」 // 「confirming 残留到下次唤回」三种都会真实发生。 const handleRelaunchClick = async (): Promise => { + if (isLinuxInstallationUnsupported) { + setShowLinuxInstallationDialog(true); + return; + } if (isWindowsRuntimeMissing) { setShowWindowsRuntimeDialog(true); return; } + await probeBeforeRelaunch(); + }; + + const probeBeforeRelaunch = async (): Promise => { if (relaunchProbeRef.current) return; relaunchProbeRef.current = true; const epoch = relaunchEpochRef.current; @@ -281,7 +296,7 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP void window.electronAPI.openExternal(WINDOWS_VC_RUNTIME_DOWNLOAD_URL); }; - const handleWindowsRuntimeRetry = () => { + const handlePrerequisiteRetry = () => { const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; window.electronAPI.relaunchToUpdate(theme); }; @@ -301,8 +316,29 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // nothing because the patch has already been cleared). const isErrorOnly = isTranslocated || isSpawnFailed; - const withWindowsRuntimeDialog = (content: ReactNode) => ( + const withPrerequisiteDialogs = (content: ReactNode) => ( <> + {isLinuxInstallationUnsupported && ( + { + setShowLinuxInstallationDialog(false); + void window.electronAPI.openExternal('https://github.com/makecindy/cindy/blob/main/docs/linux.md'); + }} + onCancel={() => setShowLinuxInstallationDialog(false)} + onTertiary={() => { + setShowLinuxInstallationDialog(false); + void probeBeforeRelaunch(); + }} + /> + )} {isWindowsRuntimeMissing && ( setShowWindowsRuntimeDialog(false)} /> )} @@ -363,9 +399,9 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // The prerequisite dialog must not be suppressed by the normal busy/dismiss // rules. After the user chooses "later", the usual banner visibility rules // resume and clicking the update entry opens this dialog again. - if (!isCollapsed && hideExpandedBanner) return withWindowsRuntimeDialog(null); + if (!isCollapsed && hideExpandedBanner) return withPrerequisiteDialogs(null); if (isCollapsed && dismissed && reason === 'user' && (status === 'ready' || isPreparing)) { - return withWindowsRuntimeDialog(null); + return withPrerequisiteDialogs(null); } // ── Collapsed state: icon only ── @@ -373,7 +409,7 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // 确认态(仅在有任务在跑时出现):上方 ✓(仍要重启,占据原 Flame 图标位置,鼠标零位移), // 下方 ✕(取消)。收起态没有文案位置,「会打断进行中的任务」只能落在 ✓ 的 tooltip 上。 if (confirming && !isPreparing) { - return withWindowsRuntimeDialog( + return withPrerequisiteDialogs(