Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/forge-linux.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
4 changes: 4 additions & 0 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
212 changes: 212 additions & 0 deletions apps/desktop/resources/linux/install-user.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#!/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 (( ${activation_in_progress:-0} )); then
rollback_activation || true
fi
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

write_launcher() {
[[ $mode == --install ]] || return 0
# Stage the launcher beside the other transaction files so an interrupted
# write cannot leave a truncated executable behind. Replacing it is safe:
# the managed prefix owns this stable entry point.
local quoted=${prefix//\'/\'\\\'\'}
printf '#!/bin/sh\nexec '\''%s/current/Cindy'\'' "$@"\n' "$quoted" > "$stage/launch"
chmod 755 "$stage/launch"
mv -Tf -- "$stage/launch" "$prefix/launch"
}

# 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
if [[ $current == "$release" ]]; then
write_launcher
exit 0
fi
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"
ln -s -- "$release" "$stage/current"
had_previous=0
previous_target=''
if [[ -e $prefix/previous || -L $prefix/previous ]]; then
[[ -L $prefix/previous ]] || fail 'previous is not a managed symlink.'
had_previous=1
previous_target=$(readlink -- "$prefix/previous")
fi
if [[ -n $current ]]; then
ln -s -- "$current" "$stage/previous"
ln -s -- "$current" "$stage/restore-current"
if (( had_previous )); then
ln -s -- "$previous_target" "$stage/restore-previous"
fi
fi

current_activated=0
previous_activated=0
rollback_activation() {
local rollback_failed=0
if (( previous_activated )); then
if (( had_previous )); then
if ! mv -Tf -- "$stage/restore-previous" "$prefix/previous"; then
[[ $(readlink -- "$prefix/previous" 2>/dev/null || true) == "$previous_target" ]] || rollback_failed=1
fi
else
rm -f -- "$prefix/previous" || rollback_failed=1
fi
fi
if (( current_activated )); then
if [[ -n $current ]]; then
if ! mv -Tf -- "$stage/restore-current" "$prefix/current"; then
[[ $(readlink -- "$prefix/current" 2>/dev/null || true) == "$current" ]] || rollback_failed=1
fi
else
rm -f -- "$prefix/current" || rollback_failed=1
fi
fi
return "$rollback_failed"
}
activation_in_progress=1

# Commit the pointers in a recoverable order. A failed current rename leaves
# the old previous pointer untouched; a later previous failure rolls current
# back to its staged old target before the transaction exits.
current_activated=1
if mv -Tf -- "$stage/current" "$prefix/current"; then
:
else
status=$?
if rollback_activation; then activation_in_progress=0; fi
exit "$status"
fi
if [[ -n $current ]]; then
previous_activated=1
if mv -Tf -- "$stage/previous" "$prefix/previous"; then
:
else
status=$?
if rollback_activation; then activation_in_progress=0; fi
exit "$status"
fi
fi
activation_in_progress=0
if [[ $mode == --install ]]; then
write_launcher
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
50 changes: 50 additions & 0 deletions apps/desktop/resources/linux/register-desktop.sh
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 2 additions & 1 deletion apps/desktop/scripts/package-desktop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' } };
}

Expand Down
Loading