fix(linux): require static AppImage runtimes (#17319)

This commit is contained in:
Neil
2026-09-01 03:40:04 -07:00
parent 9f63251c80
commit a0bacaeedf
15 changed files with 1063 additions and 12 deletions
@@ -28,7 +28,6 @@ RUN apt-get update \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
@@ -22,16 +22,15 @@ RUN apt-get update \
libxkbcommon0 \
libxrandr2 \
libxss1 \
p7zip-full \
procps \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY run-signal-case.sh /usr/local/bin/run-signal-case
COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case
ENTRYPOINT ["/usr/local/bin/run-signal-case"]
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
set -euo pipefail
appimage=${1:-/input/orca.AppImage}
startup_timeout_seconds=90
if [[ $# -gt 1 ]]; then
echo "usage: run-appimage-desktop-startup-case.sh [appimage]" >&2
exit 64
fi
if ((EUID == 0)); then
if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then
echo 'FAIL: unable to create the AppImage startup state directory' >&2
exit 1
fi
if ! chown orca:orca "$state_dir"; then
echo "FAIL: unable to hand the AppImage startup state directory to orca: $state_dir" >&2
rm -rf -- "$state_dir" || true
exit 1
fi
exec runuser --user orca --preserve-environment -- env \
ORCA_STARTUP_STATE_DIR="$state_dir" \
ORCA_STARTUP_STATE_DIR_CLEANUP=1 \
"$0" "$@"
fi
remove_state_dir_on_exit=${ORCA_STARTUP_STATE_DIR_CLEANUP:-0}
if [[ -n "${ORCA_STARTUP_STATE_DIR:-}" ]]; then
state_dir=$ORCA_STARTUP_STATE_DIR
else
if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then
echo 'FAIL: unable to create the AppImage startup state directory' >&2
exit 1
fi
remove_state_dir_on_exit=1
fi
stdout_log="$state_dir/stdout.log"
stderr_log="$state_dir/stderr.log"
launcher_pid=
launcher_start_ticks=
launcher_pgid=
launcher_status=
launcher_waited=false
tree_pids=()
declare -A tree_start_ticks=()
read_start_ticks() {
local pid=$1
[[ -r "/proc/$pid/stat" ]] || return 1
awk '{print $22}' "/proc/$pid/stat"
}
identity_alive() {
local pid=$1
local expected_ticks=$2
[[ -n "$expected_ticks" ]] || return 1
[[ -r "/proc/$pid/stat" ]] || return 1
[[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "$expected_ticks" ]] || return 1
local process_state
process_state=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true)
[[ -n "$process_state" && "$process_state" != Z* ]]
}
collect_process_tree() {
tree_pids=()
tree_start_ticks=()
[[ -n "$launcher_pid" ]] || return
[[ -n "$launcher_start_ticks" ]] || return
tree_pids+=("$launcher_pid")
tree_start_ticks["$launcher_pid"]="$launcher_start_ticks"
local -a frontier=("$launcher_pid")
while ((${#frontier[@]})); do
local parent=${frontier[0]}
frontier=("${frontier[@]:1}")
while read -r child; do
[[ -n "$child" ]] || continue
[[ -z "${tree_start_ticks[$child]+present}" ]] || continue
local child_ticks
child_ticks=$(read_start_ticks "$child" 2>/dev/null || true)
[[ -n "$child_ticks" ]] || continue
tree_pids+=("$child")
tree_start_ticks["$child"]="$child_ticks"
frontier+=("$child")
done < <(ps -eo pid=,ppid= | awk -v parent="$parent" '$2 == parent {print $1}')
done
}
process_is_xvfb() {
local pid=$1
local command_name
command_name=$(ps -o comm= -p "$pid" 2>/dev/null || true)
[[ "$command_name" == Xvfb ]] && return 0
local command_line
command_line=$(ps -o args= -p "$pid" 2>/dev/null || true)
[[ "$command_line" =~ (^|[[:space:]/])Xvfb([[:space:]]|$) ]]
}
signal_process_group() {
local signal=$1
identity_alive "$launcher_pid" "$launcher_start_ticks" || return 0
[[ "$launcher_pgid" =~ ^[0-9]+$ ]] || return 0
[[ "$launcher_pgid" != "$(ps -o pgid= -p "$$" | tr -d ' ')" ]] || return 0
kill -s "$signal" -- "-$launcher_pgid" 2>/dev/null || true
}
signal_owned_processes() {
local signal=$1
local index pid ticks
for ((index = ${#tree_pids[@]} - 1; index >= 0; index--)); do
pid=${tree_pids[index]}
ticks=${tree_start_ticks[$pid]-}
if identity_alive "$pid" "$ticks"; then
kill -s "$signal" "$pid" 2>/dev/null || true
fi
done
}
wait_for_owned_exit() {
local timeout_seconds=$1
local deadline=$((SECONDS + timeout_seconds))
local pid ticks alive
while ((SECONDS < deadline)); do
alive=0
for pid in "${tree_pids[@]}"; do
ticks=${tree_start_ticks[$pid]-}
if identity_alive "$pid" "$ticks"; then
alive=1
break
fi
done
if ((alive == 0)); then
return 0
fi
sleep 0.2
done
return 1
}
dump_logs() {
echo "--- desktop startup stdout ---" >&2
cat "$stdout_log" >&2 2>/dev/null || true
echo "--- desktop startup stderr ---" >&2
cat "$stderr_log" >&2 2>/dev/null || true
}
cleanup_state_dir() {
[[ "$remove_state_dir_on_exit" == 1 ]] || return 0
[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\.[^/]+$ ]] || return 0
[[ -d "$state_dir" && ! -L "$state_dir" && -O "$state_dir" ]] || return 0
rm -rf -- "$state_dir"
}
capture_launcher_status() {
[[ "$launcher_waited" == false ]] || return 0
[[ -n "$launcher_pid" ]] || return 1
if wait "$launcher_pid"; then
launcher_status=0
else
launcher_status=$?
fi
launcher_waited=true
}
report_launcher_exit() {
local reason=$1
local observed_status=unknown
local exit_status=1
if capture_launcher_status; then
observed_status=$launcher_status
if ((launcher_status != 0)); then
exit_status=$launcher_status
fi
fi
echo "FAIL: desktop launcher exited before ${reason} (status=${observed_status})" >&2
exit "$exit_status"
}
cleanup() {
local status=$?
trap - EXIT
signal_process_group TERM || true
signal_owned_processes TERM || true
if ! wait_for_owned_exit 10; then
signal_process_group KILL || true
signal_owned_processes KILL || true
wait_for_owned_exit 5 || status=1
fi
capture_launcher_status || true
if ((status != 0)); then
dump_logs
else
if ! cleanup_state_dir; then
status=1
dump_logs
fi
fi
exit "$status"
}
trap cleanup EXIT
mkdir -p "$state_dir/home" "$state_dir/config" "$state_dir/cache" "$state_dir/runtime"
chmod 700 "$state_dir/runtime"
export HOME="$state_dir/home"
export XDG_CONFIG_HOME="$state_dir/config"
export XDG_CACHE_HOME="$state_dir/cache"
export XDG_RUNTIME_DIR="$state_dir/runtime"
export LIBGL_ALWAYS_SOFTWARE=1
export ORCA_STARTUP_DIAGNOSTICS=1
ulimit -c 0
[[ -r "$appimage" ]] || { echo "FAIL: AppImage is not readable: $appimage" >&2; exit 1; }
[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }
setsid --wait dbus-run-session -- xvfb-run -a "$appimage" --appimage-extract-and-run --no-sandbox \
>"$stdout_log" 2>"$stderr_log" &
launcher_pid=$!
launcher_start_ticks=$(read_start_ticks "$launcher_pid" 2>/dev/null || true)
launcher_pgid=$(ps -o pgid= -p "$launcher_pid" 2>/dev/null | tr -d ' ' || true)
if [[ -z "$launcher_start_ticks" ]]; then
report_launcher_exit 'its identity could be recorded'
fi
marker_seen=false
deadline=$((SECONDS + startup_timeout_seconds))
while ((SECONDS < deadline)); do
if grep -Eq '^\[startup\] updater-setup-done t=[0-9]+$' "$stderr_log"; then
marker_seen=true
break
fi
if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then
report_launcher_exit 'the updater-setup-done marker'
fi
sleep 0.2
done
if [[ "$marker_seen" != true ]]; then
if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then
report_launcher_exit 'the updater-setup-done marker'
fi
echo "FAIL: desktop AppImage did not emit updater-setup-done within ${startup_timeout_seconds}s" >&2
exit 1
fi
if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then
echo "FAIL: desktop launcher identity changed after startup marker" >&2
exit 1
fi
collect_process_tree
xvfb_pids=()
for pid in "${tree_pids[@]}"; do
if process_is_xvfb "$pid"; then
xvfb_pids+=("$pid")
fi
done
if ((${#xvfb_pids[@]} == 0)); then
echo "FAIL: no launcher-owned Xvfb process was found after startup" >&2
exit 1
fi
for pid in "${xvfb_pids[@]}"; do
if ! identity_alive "$pid" "${tree_start_ticks[$pid]-}"; then
echo "FAIL: launcher-owned Xvfb identity changed before cleanup" >&2
exit 1
fi
done
echo "Desktop AppImage startup validation passed (launcher=${launcher_pid}, xvfb=${xvfb_pids[*]})."
+7
View File
@@ -18,6 +18,7 @@ const {
verifyPackagedNodePtyJobOwnership
} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs')
// Why: dev-channel builds must carry the *release* identity — same bundle id,
// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to
@@ -110,6 +111,7 @@ module.exports = {
appId,
productName: 'Orca',
protocols: [{ name: 'Orca', schemes: ['orca'] }],
toolsets: { appimage: '1.0.3' },
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
@@ -230,6 +232,11 @@ module.exports = {
'node_modules/zod/**',
'node_modules/yaml/**'
],
artifactBuildCompleted: ({ file, arch }) => {
if (file.endsWith('.AppImage')) {
verifyStaticAppImagePackage(file, arch)
}
},
afterPack: async (context) => {
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
+28 -5
View File
@@ -16278,16 +16278,17 @@
"providers": ["local-daemon"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["local-daemon"],
"coverageNotes": "An Ubuntu 26.04 amd64 container extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, then exercises terminal-style foreground-process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same foreground AppRun identity contract.",
"coverageNotes": "An Ubuntu 26.04 amd64 container first launches the original AppImage through dbus-run-session and xvfb-run with startup diagnostics, then extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, and exercises terminal-style foreground-process-group SIGINT plus the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same startup and foreground-AppRun identity contracts.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/14109",
"https://linear.app/stably/issue/STA-4051"
],
"invariant": "After packaged foreground headless serve publishes structured readiness, one SIGINT or SIGTERM exits successfully without an Electron fatal trap or core evidence, releases the exact listener and owned Xvfb/process tree, and leaves an unrelated process identity untouched.",
"oracle": "For each signal, start a fresh unprivileged Ubuntu 26.04 container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.",
"oracle": "First start the original, readable-and-executable AppImage once in a fresh restricted Ubuntu 26.04 container through dbus-run-session -- xvfb-run -a --appimage-extract-and-run with ORCA_STARTUP_DIAGNOSTICS=1, and require the exact updater-setup-done marker within 90 seconds while fencing the launcher and owned Xvfb by PID start ticks. For each signal, start a separate unprivileged container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot",
"shellcheck config/docker/headless-serve-shutdown/run-signal-case.sh",
"shellcheck config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh",
"node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage",
"node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64"
],
@@ -16295,7 +16296,8 @@
"src/main/startup/ensure-virtual-display.test.ts",
"config/scripts/headless-serve-shutdown-workflow.test.mjs",
"config/scripts/run-headless-serve-shutdown-docker.mjs",
"config/docker/headless-serve-shutdown/run-signal-case.sh"
"config/docker/headless-serve-shutdown/run-signal-case.sh",
"config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh"
],
"assertionRefs": [
{
@@ -16312,15 +16314,19 @@
"file": "config/scripts/headless-serve-shutdown-workflow.test.mjs",
"assertions": [
"PR CI builds an x64 AppImage before invoking the packaged shutdown oracle",
"the original AppImage desktop startup oracle is wired before extraction and signal cases",
"the bound AppImage is readable and executable before desktop launch and extraction",
"the documented systemd unit uses KillMode=mixed so graceful TERM targets Orca before its owned Xvfb"
]
},
{
"file": "config/scripts/run-headless-serve-shutdown-docker.mjs",
"assertions": [
"the original AppImage startup runs through dbus-run-session and xvfb-run with a bounded diagnostics marker",
"SIGINT and SIGTERM run in separate disposable containers",
"both signal failures are reported before the oracle exits",
"the exact AppImage SHA-256, entrypoint, and signal target are published",
"the read-only AppImage bind is checked for read and execute permissions before extraction",
"the launcher exec overlay isolates the related STA-4017 signal boundary"
]
},
@@ -16331,6 +16337,14 @@
"SIGTERM reaches the exact AppRun PID under the documented systemd KillMode=mixed policy",
"target and descendant identities are fenced by PID start ticks before signaling and residue checks"
]
},
{
"file": "config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh",
"assertions": [
"the original AppImage emits the exact updater-setup-done startup marker within 90 seconds",
"launcher and owned Xvfb identities are fenced by PID start ticks",
"cleanup sends bounded TERM then KILL signals and preserves failure logs"
]
}
],
"evidenceRuns": [
@@ -16351,11 +16365,20 @@
"result": "passed",
"durationSeconds": 48,
"summary": "The extracted candidate AppRun passed process-group SIGINT and systemd-mixed main-PID SIGTERM under Ubuntu 26.04 amd64 emulation with status zero, no fatal evidence, full listener/Xvfb/tree cleanup, and an unchanged canary identity."
},
{
"date": "2026-08-31",
"runner": "ci",
"platform": "linux",
"command": "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64",
"result": "passed",
"durationSeconds": 1336,
"summary": "Native-amd64 PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 built AppImage SHA-256 999d43bfe123e87a77fe917a5f46be1efd5c45a5205f99f02998a75136d8a793 and ran the restricted original-AppImage startup oracle before each of the three signal matrices. Each startup reached the exact updater-setup-done marker with stable launcher/Xvfb PID-start-tick identities and bounded TERM/KILL cleanup; SIGINT and SIGTERM then returned wait status 0 with no fatal evidence, listener, descendant, Xvfb, or canary residue. This is CI evidence only; no fresh local Docker oracle is claimed."
}
],
"runtimeBudget": {
"p95Seconds": 240,
"scope": "two fresh Ubuntu 26.04 containers, one per foreground signal"
"p95Seconds": 1800,
"scope": "three AppImage startup/extraction matrices, each with fresh Ubuntu 26.04 INT and TERM containers"
},
"flakeHistory": {
"status": "not-started",
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { resolve } from 'node:path'
const SUPPORTED_ARCHES = new Set(['x64', 'arm64'])
/** Select the local Linux package architecture without relying on builder defaults. */
export function resolveLinuxBuildArch({
platform = process.platform,
hostArch = process.arch,
requestedArch = process.env.ORCA_LINUX_BUILD_ARCH
} = {}) {
const arch = requestedArch ?? (platform === 'linux' ? hostArch : 'x64')
if (!SUPPORTED_ARCHES.has(arch)) {
throw new Error(
`Unsupported Linux build architecture: ${arch}. Use ORCA_LINUX_BUILD_ARCH=x64|arm64.`
)
}
return arch
}
export function buildLinuxElectronBuilderArgs(arch, extraArgs = []) {
if (!SUPPORTED_ARCHES.has(arch)) {
throw new Error(`Unsupported Linux build architecture: ${arch}`)
}
return [
'exec',
'electron-builder',
'--config',
'config/electron-builder.config.cjs',
'--linux',
'AppImage',
'deb',
'rpm',
`--${arch}`,
...extraArgs
]
}
export function runLocalLinuxBuild({
arch = resolveLinuxBuildArch(),
extraArgs = [],
environment = process.env,
execFile = execFileSync,
platform = process.platform,
cwd = resolve(import.meta.dirname, '../..')
} = {}) {
const env = { ...environment }
if (arch === 'arm64') {
env.ORCA_LINUX_ARM64_RELEASE = '1'
} else {
delete env.ORCA_LINUX_ARM64_RELEASE
}
const pnpm = platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
execFile(pnpm, buildLinuxElectronBuilderArgs(arch, extraArgs), {
cwd,
env,
stdio: 'inherit'
})
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
runLocalLinuxBuild({ extraArgs: process.argv.slice(2) })
}
+85
View File
@@ -0,0 +1,85 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
buildLinuxElectronBuilderArgs,
resolveLinuxBuildArch,
runLocalLinuxBuild
} from './build-linux-local.mjs'
describe('local Linux build target', () => {
it('is the package script used by the local Linux build', () => {
const packageJson = JSON.parse(
readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8')
)
expect(packageJson.scripts['build:linux']).toContain(
'node config/scripts/build-linux-local.mjs'
)
})
it('follows a native Linux host architecture', () => {
expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'arm64' })).toBe('arm64')
expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'x64' })).toBe('x64')
})
it('defaults cross-platform Linux builds to x64 and allows an explicit override', () => {
expect(resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64' })).toBe('x64')
expect(
resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64', requestedArch: 'arm64' })
).toBe('arm64')
})
it('rejects unsupported architectures', () => {
expect(() => resolveLinuxBuildArch({ platform: 'linux', hostArch: 'ia32' })).toThrow(
'Unsupported Linux build architecture'
)
expect(() => buildLinuxElectronBuilderArgs('ia32')).toThrow(
'Unsupported Linux build architecture'
)
})
it('passes an explicit target and matching artifact-name environment', () => {
const execFile = vi.fn()
runLocalLinuxBuild({
arch: 'arm64',
environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: undefined },
execFile,
platform: 'linux',
cwd: '/workspace'
})
expect(execFile).toHaveBeenCalledWith(
'pnpm',
buildLinuxElectronBuilderArgs('arm64'),
expect.objectContaining({
cwd: '/workspace',
env: expect.objectContaining({ ORCA_LINUX_ARM64_RELEASE: '1' }),
stdio: 'inherit'
})
)
expect(buildLinuxElectronBuilderArgs('x64')).toEqual(
expect.arrayContaining(['--linux', 'AppImage', 'deb', 'rpm', '--x64'])
)
runLocalLinuxBuild({
arch: 'x64',
environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: '1' },
execFile,
platform: 'linux',
cwd: '/workspace'
})
expect(execFile).toHaveBeenLastCalledWith(
'pnpm',
buildLinuxElectronBuilderArgs('x64'),
expect.objectContaining({
env: expect.not.objectContaining({ ORCA_LINUX_ARM64_RELEASE: expect.anything() })
})
)
})
it('uses the Windows pnpm command name when cross-host packaging', () => {
const execFile = vi.fn()
runLocalLinuxBuild({ arch: 'x64', execFile, platform: 'win32', cwd: 'C:\\workspace' })
expect(execFile.mock.calls[0]?.[0]).toBe('pnpm.cmd')
})
})
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises'
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -271,6 +272,7 @@ describe('electron-builder config', () => {
it('uses the release artifact set as local Linux targets without changing existing names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm'])
expect(electronBuilderConfig.toolsets).toEqual({ appimage: '1.0.3' })
expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}')
expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}')
expect(electronBuilderConfig.rpm).toMatchObject({
@@ -279,6 +281,23 @@ describe('electron-builder config', () => {
})
})
it('validates each AppImage before electron-builder publishes it', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-appimage-'))
try {
const appImage = join(root, 'orca-linux.AppImage')
await writeFile(appImage, 'not an ELF')
await chmod(appImage, 0o755)
expect(() =>
electronBuilderConfig.artifactBuildCompleted({ file: appImage, arch: 1 })
).toThrow(/ELF header is outside/)
expect(() =>
electronBuilderConfig.artifactBuildCompleted({ file: join(root, 'orca-ide.deb') })
).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('uses a distinct AppImage name for Linux arm64 release uploads', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = process.env.ORCA_LINUX_ARM64_RELEASE
@@ -6,6 +6,15 @@ import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8')
const signalCase = readFileSync('config/docker/headless-serve-shutdown/run-signal-case.sh', 'utf8')
const shutdownDockerRunner = readFileSync(
'config/scripts/run-headless-serve-shutdown-docker.mjs',
'utf8'
)
const shutdownDockerfile = readFileSync('config/docker/headless-serve-shutdown/Dockerfile', 'utf8')
const desktopStartupOracle = readFileSync(
'config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh',
'utf8'
)
function readSystemdUnitBlocks(doc, unitName) {
const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -100,6 +109,50 @@ describe('headless serve shutdown PR gate', () => {
expect(signalCase).toContain('outside the entrypoint process tree')
})
it('runs the original AppImage desktop startup oracle before extraction and signals', () => {
expect(shutdownDockerfile).toContain(
'COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case'
)
const startupCall = shutdownDockerRunner.indexOf(
'runDesktopStartupOracle({ image, appImage, platform })'
)
const extractionCall = shutdownDockerRunner.indexOf(
"'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract"
)
const signalLoop = shutdownDockerRunner.indexOf("for (const signal of ['INT', 'TERM'])")
expect(startupCall).toBeGreaterThan(-1)
expect(extractionCall).toBeGreaterThan(startupCall)
expect(signalLoop).toBeGreaterThan(startupCall)
expect(shutdownDockerRunner).toContain("'/usr/local/bin/run-appimage-desktop-startup-case'")
})
it('preserves startup logs when the launcher exits before its marker', () => {
expect(desktopStartupOracle).toContain('signal_process_group TERM || true')
expect(desktopStartupOracle).toContain('signal_process_group KILL || true')
expect(desktopStartupOracle).toContain('cat "$stdout_log" >&2 2>/dev/null || true')
expect(desktopStartupOracle).toContain('cat "$stderr_log" >&2 2>/dev/null || true')
expect(desktopStartupOracle).toContain(
'FAIL: desktop launcher exited before ${reason} (status=${observed_status})'
)
expect(desktopStartupOracle).toContain('ORCA_STARTUP_STATE_DIR_CLEANUP=1')
expect(desktopStartupOracle).toContain(
'[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\\.[^/]+$ ]] || return 0'
)
})
it('requires the bound AppImage to be executable before launch and extraction', () => {
expect(desktopStartupOracle).toContain(
'[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }'
)
expect(shutdownDockerRunner).toContain(
'\'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }\''
)
})
it('gives the original AppImage enough bounded extraction space', () => {
expect(shutdownDockerRunner).toContain("'/tmp:rw,nosuid,nodev,exec,size=1g'")
})
it('keeps owned Xvfb alive during the documented systemd graceful stop', () => {
const serveUnits = readSystemdUnitBlocks(headlessLinuxGuide, 'orca-serve.service')
const ownedXvfbUnits = serveUnits.filter((unit) => !/^Environment=DISPLAY=/m.test(unit))
+2
View File
@@ -182,8 +182,10 @@ const SHARED_PACKAGE_PREFIXES = [
const LINUX_PACKAGE_PREFIXES = [
...SHARED_PACKAGE_PREFIXES,
'config/docker/cli-launch-contract/',
'config/docker/headless-pairing/',
'config/docker/headless-serve-shutdown/',
'config/scripts/run-linux-cli-launch-contract',
'config/scripts/run-headless-linux-pairing-docker',
'config/scripts/static-appimage-package-contract',
'native/computer-use-linux/',
'resources/linux/',
@@ -185,8 +185,11 @@ describe('per-job path classification', () => {
for (const file of [
'config/docker/cli-launch-contract/Dockerfile',
'config/docker/cli-launch-contract/run-cli-case.sh',
'config/docker/headless-pairing/Dockerfile',
'config/docker/headless-pairing/run-appimage-case.sh',
'config/docker/headless-serve-shutdown/Dockerfile',
'config/scripts/run-linux-cli-launch-contract-docker.mjs',
'config/scripts/run-headless-linux-pairing-docker.mjs',
'config/scripts/static-appimage-package-contract.cjs'
]) {
expectClassification([file], { package: true })
@@ -54,11 +54,19 @@ try {
shutdownDockerDirectory
])
docker(['volume', 'create', artifactVolume])
runDesktopStartupOracle({ image, appImage, platform })
docker([
'run',
'--rm',
'--platform',
platform,
'--network',
'none',
'--read-only',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--entrypoint',
'bash',
'-v',
@@ -68,11 +76,17 @@ try {
image,
'-lc',
[
'7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null',
'trap \'status=$?; if [ "$status" -ne 0 ]; then cat /artifacts/appimage-help.log /artifacts/appimage-extract.log 2>/dev/null || true; fi; exit "$status"\' EXIT',
'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }',
'timeout --kill-after=5s 15s /input/orca.AppImage --appimage-help > /artifacts/appimage-help.log 2>&1',
'cd /artifacts',
'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract > /artifacts/appimage-extract.log 2>&1',
'mv squashfs-root root',
launcherExecOverlay
? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide"
: ':',
'chmod -R a+rX /artifacts/root'
'chmod -R a+rX /artifacts/root',
'rm /artifacts/appimage-help.log /artifacts/appimage-extract.log'
].join(' && ')
])
@@ -131,6 +145,38 @@ try {
docker(['image', 'rm', image], { allowFailure: true })
}
function runDesktopStartupOracle({ image, appImage, platform }) {
console.log('Running original AppImage desktop startup oracle...')
docker([
'run',
'--rm',
'--init',
'--platform',
platform,
'--network',
'none',
'--read-only',
'--tmpfs',
'/tmp:rw,nosuid,nodev,exec,size=1g',
'--shm-size',
'256m',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--user',
'orca',
'--entrypoint',
'/usr/local/bin/run-appimage-desktop-startup-case',
'-e',
'ORCA_STARTUP_DIAGNOSTICS=1',
'-v',
`${appImage}:/input/orca.AppImage:ro`,
image,
'/input/orca.AppImage'
])
}
function valueAfter(flag) {
const index = args.indexOf(flag)
return index === -1 ? null : (args[index + 1] ?? null)
@@ -0,0 +1,260 @@
const { closeSync, fstatSync, openSync, readSync } = require('node:fs')
const { basename } = require('node:path')
const EXPECTED_ARCHITECTURE_BY_FILENAME = new Map([
['orca-linux.AppImage', 'x64'],
['orca-linux-arm64.AppImage', 'arm64']
])
const APPIMAGE_MAGIC = Buffer.from([0x41, 0x49, 0x02])
const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime')
const TARGET_ARCHITECTURE_BY_ENUM = new Map([
[1, 'x64'],
[3, 'arm64']
])
const RUNTIME_ARCHITECTURE_BY_MACHINE = new Map([
[0x3e, 'x64'],
[0xb7, 'arm64']
])
const ELF_HEADER_BYTES = 64
const PROGRAM_HEADER_BYTES = 56
const DYNAMIC_ENTRY_BYTES = 16
const MAX_PROGRAM_HEADERS = 128
const MAX_LOAD_BYTES = 16 * 1024 * 1024
const MAX_DYNAMIC_BYTES = 1024 * 1024
function verifyStaticAppImagePackage(filePath, targetArch) {
const filename = basename(filePath)
const filenameArchitecture = EXPECTED_ARCHITECTURE_BY_FILENAME.get(filename)
if (!filenameArchitecture) {
invalid(
filename,
`unsupported artifact name; expected ${[...EXPECTED_ARCHITECTURE_BY_FILENAME.keys()].join(' or ')}`
)
}
const targetArchitecture = normalizeTargetArchitecture(targetArch, filename)
if (filenameArchitecture !== targetArchitecture) {
invalid(
filename,
`artifact filename targets ${filenameArchitecture}, but electron-builder target is ${targetArchitecture}`
)
}
const descriptor = openSync(filePath, 'r')
try {
const stats = fstatSync(descriptor, { bigint: true })
if (process.platform !== 'win32' && (stats.mode & 0o111n) === 0n) {
invalid(filename, 'artifact is not executable')
}
const fileSize = stats.size
const header = readRange(
descriptor,
0n,
BigInt(ELF_HEADER_BYTES),
fileSize,
filename,
'ELF header'
)
const { entry, machine } = verifyElfHeader(header, filename)
const runtimeArchitecture = RUNTIME_ARCHITECTURE_BY_MACHINE.get(machine)
if (runtimeArchitecture !== targetArchitecture) {
invalid(
filename,
`runtime architecture ${runtimeArchitecture ?? `machine 0x${machine.toString(16)}`} does not match electron-builder target ${targetArchitecture}`
)
}
const programHeaderOffset = header.readBigUInt64LE(32)
const programHeaderSize = header.readUInt16LE(54)
const programHeaderCount = header.readUInt16LE(56)
if (programHeaderSize !== PROGRAM_HEADER_BYTES) {
invalid(filename, `unexpected ELF program-header size ${programHeaderSize}`)
}
if (programHeaderCount === 0 || programHeaderCount > MAX_PROGRAM_HEADERS) {
invalid(filename, `invalid ELF program-header count ${programHeaderCount}`)
}
const tableSize = BigInt(programHeaderSize * programHeaderCount)
const table = readRange(
descriptor,
programHeaderOffset,
tableSize,
fileSize,
filename,
'ELF program-header table'
)
const segments = parseProgramHeaders(table, programHeaderSize)
verifySegments(descriptor, segments, fileSize, filename, entry)
} finally {
closeSync(descriptor)
}
}
function verifyElfHeader(header, filename) {
if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) {
invalid(filename, 'missing ELF magic')
}
if (header[4] !== 2 || header[5] !== 1 || header[6] !== 1) {
invalid(filename, 'runtime must be ELF64 little-endian version 1')
}
if (!header.subarray(8, 11).equals(APPIMAGE_MAGIC)) {
invalid(filename, 'missing type-2 AppImage marker')
}
if (header.readUInt16LE(16) !== 3) {
invalid(filename, 'runtime must be an ET_DYN static PIE')
}
const machine = header.readUInt16LE(18)
if (!RUNTIME_ARCHITECTURE_BY_MACHINE.has(machine)) {
invalid(filename, `unsupported ELF machine 0x${machine.toString(16)}`)
}
if (header.readUInt32LE(20) !== 1) {
invalid(filename, 'runtime has an unsupported ELF version')
}
if (header.readUInt16LE(52) !== ELF_HEADER_BYTES) {
invalid(filename, `unexpected ELF header size ${header.readUInt16LE(52)}`)
}
return { entry: header.readBigUInt64LE(24), machine }
}
function parseProgramHeaders(table, entrySize) {
const segments = []
for (let offset = 0; offset < table.length; offset += entrySize) {
segments.push({
type: table.readUInt32LE(offset),
flags: table.readUInt32LE(offset + 4),
offset: table.readBigUInt64LE(offset + 8),
virtualAddress: table.readBigUInt64LE(offset + 16),
fileSize: table.readBigUInt64LE(offset + 32),
memorySize: table.readBigUInt64LE(offset + 40)
})
}
return segments
}
function verifySegments(descriptor, segments, fileSize, filename, entry) {
if (segments.some((segment) => segment.type === 3)) {
invalid(filename, 'runtime contains PT_INTERP')
}
const loadSegments = segments.filter((segment) => segment.type === 1)
const totalLoadBytes = loadSegments.reduce((total, segment) => total + segment.fileSize, 0n)
if (loadSegments.length === 0 || totalLoadBytes > BigInt(MAX_LOAD_BYTES)) {
invalid(filename, `invalid or oversized PT_LOAD data (${totalLoadBytes} bytes)`)
}
if (
!loadSegments.some(
(segment) =>
segment.flags & 1 &&
entry >= segment.virtualAddress &&
entry - segment.virtualAddress < segment.memorySize
)
) {
invalid(filename, 'ELF entry point is outside an executable PT_LOAD segment')
}
let identifiesStaticRuntime = false
for (const segment of loadSegments) {
verifyFileBackedSegment(segment, fileSize, filename, 'PT_LOAD')
const data = readRange(
descriptor,
segment.offset,
segment.fileSize,
fileSize,
filename,
'PT_LOAD data'
)
identifiesStaticRuntime ||= data.includes(RUNTIME_SOURCE)
}
if (!identifiesStaticRuntime) {
invalid(filename, `runtime does not identify ${RUNTIME_SOURCE.toString()}`)
}
for (const segment of segments.filter((entry) => entry.type === 2)) {
verifyDynamicSegment(descriptor, segment, fileSize, filename)
}
}
function normalizeTargetArchitecture(targetArch, filename) {
const architecture =
typeof targetArch === 'number' ? TARGET_ARCHITECTURE_BY_ENUM.get(targetArch) : targetArch
if (architecture !== 'x64' && architecture !== 'arm64') {
invalid(filename, `unsupported electron-builder target architecture ${String(targetArch)}`)
}
return architecture
}
function verifyFileBackedSegment(segment, fileSize, filename, label) {
if (segment.memorySize < segment.fileSize) {
invalid(filename, `${label} memory size is smaller than its file size`)
}
verifyRange(segment.offset, segment.fileSize, fileSize, filename, label)
}
function verifyDynamicSegment(descriptor, segment, fileSize, filename) {
verifyFileBackedSegment(segment, fileSize, filename, 'PT_DYNAMIC')
if (
segment.fileSize === 0n ||
segment.fileSize > BigInt(MAX_DYNAMIC_BYTES) ||
segment.fileSize % BigInt(DYNAMIC_ENTRY_BYTES) !== 0n
) {
invalid(filename, `invalid PT_DYNAMIC size ${segment.fileSize}`)
}
const dynamic = readRange(
descriptor,
segment.offset,
segment.fileSize,
fileSize,
filename,
'PT_DYNAMIC data'
)
let terminated = false
for (let offset = 0; offset < dynamic.length; offset += DYNAMIC_ENTRY_BYTES) {
const tag = dynamic.readBigInt64LE(offset)
if (tag === 0n) {
terminated = true
break
}
if (tag === 1n) {
invalid(filename, 'runtime contains a DT_NEEDED dependency')
}
}
if (!terminated) {
invalid(filename, 'PT_DYNAMIC is missing DT_NULL')
}
}
function readRange(descriptor, offset, size, fileSize, filename, label) {
verifyRange(offset, size, fileSize, filename, label)
const buffer = Buffer.alloc(Number(size))
let bytesRead = 0
while (bytesRead < buffer.length) {
const count = readSync(
descriptor,
buffer,
bytesRead,
buffer.length - bytesRead,
Number(offset) + bytesRead
)
if (count === 0) {
throw new Error(`Unable to read complete ${label}`)
}
bytesRead += count
}
return buffer
}
function verifyRange(offset, size, fileSize, filename, label) {
const maxSafeOffset = BigInt(Number.MAX_SAFE_INTEGER)
if (
offset > fileSize ||
size > fileSize - offset ||
offset > maxSafeOffset ||
size > maxSafeOffset - offset
) {
invalid(filename, `${label} is outside the artifact`)
}
}
function invalid(filename, reason) {
throw new Error(`Invalid static AppImage ${filename}: ${reason}`)
}
module.exports = { verifyStaticAppImagePackage }
@@ -0,0 +1,225 @@
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { verifyStaticAppImagePackage } = require('./static-appimage-package-contract.cjs')
const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime')
const LOAD_HEADER = 64
const DYNAMIC_HEADER = 120
const DYNAMIC_OFFSET = 320
const FIXTURE_BYTES = 384
describe('static AppImage package contract', () => {
it.each([
['orca-linux.AppImage', 0x3e, 1],
['orca-linux-arm64.AppImage', 0xb7, 'arm64']
])('accepts a dependency-free type-2 %s runtime', async (filename, machine, targetArch) => {
await withFixture(filename, createRuntime({ machine }), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).not.toThrow()
})
})
it.each([
['generic filename for an arm64 runtime and target', 'orca-linux.AppImage', 0xb7, 3],
['arm64 filename for an x64 runtime and target', 'orca-linux-arm64.AppImage', 0x3e, 1],
['generic x64 runtime for an arm64 target', 'orca-linux.AppImage', 0x3e, 3],
['generic arm64 runtime for an x64 target', 'orca-linux.AppImage', 0xb7, 1],
['arm64 artifact filename for an x64 target', 'orca-linux-arm64.AppImage', 0xb7, 1],
['x64 runtime under an arm64 artifact filename', 'orca-linux-arm64.AppImage', 0x3e, 3]
])('rejects %s', async (_label, filename, machine, targetArch) => {
await withFixture(filename, createRuntime({ machine }), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/architecture|target/)
})
})
it.each([undefined, 0, 'ia32'])(
'rejects unsupported target architecture %s',
async (targetArch) => {
await withFixture('orca-linux.AppImage', createRuntime(), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/target architecture/)
})
}
)
it('accepts PT_DYNAMIC relocation metadata without dependencies', async () => {
const runtime = createRuntime()
runtime.writeBigInt64LE(7n, DYNAMIC_OFFSET)
await withFixture('orca-linux.AppImage', runtime, (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow()
})
})
it('does not scan the appended AppImage payload as outer ELF data', async () => {
const payload = Buffer.concat([RUNTIME_SOURCE, Buffer.alloc(16, 1)])
await withFixture('orca-linux.AppImage', Buffer.concat([createRuntime(), payload]), (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow()
})
const unidentifiedRuntime = createRuntime()
unidentifiedRuntime.fill(0, 192, 192 + RUNTIME_SOURCE.length)
await withFixture(
'orca-linux.AppImage',
Buffer.concat([unidentifiedRuntime, payload]),
(path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/does not identify/)
}
)
})
it('rejects artifact names outside the release contract before reading them', () => {
expect(() => verifyStaticAppImagePackage('/missing/orca-preview.AppImage')).toThrow(
'unsupported artifact name'
)
})
it.skipIf(process.platform === 'win32')(
'rejects a readable but non-executable AppImage',
async () => {
await withFixture(
'orca-linux.AppImage',
createRuntime(),
(path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/not executable/)
},
{ mode: 0o644 }
)
}
)
it.each([
[
'non-ELF64 runtimes',
(runtime) => {
runtime[4] = 1
},
/ELF64 little-endian/
],
[
'unsupported ELF versions',
(runtime) => runtime.writeUInt32LE(2, 20),
/unsupported ELF version/
],
[
'non-type-2 AppImages',
(runtime) => {
runtime[10] = 1
},
/type-2 AppImage marker/
],
['non-PIE runtimes', (runtime) => runtime.writeUInt16LE(2, 16), /ET_DYN static PIE/],
[
'unsupported architectures',
(runtime) => runtime.writeUInt16LE(3, 18),
/unsupported ELF machine/
],
['dynamic loaders', (runtime) => runtime.writeUInt32LE(3, DYNAMIC_HEADER), /PT_INTERP/],
[
'shared-library dependencies',
(runtime) => runtime.writeBigInt64LE(1n, DYNAMIC_OFFSET),
/DT_NEEDED/
],
[
'unidentified runtimes',
(runtime) => runtime.fill(0, 192, 192 + RUNTIME_SOURCE.length),
/does not identify/
],
[
'out-of-bounds load segments',
(runtime) => {
runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 32)
runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 40)
},
/outside the artifact/
],
[
'oversized load claims',
(runtime) => {
runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 32)
runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 40)
},
/oversized PT_LOAD/
],
[
'non-executable entry segments',
(runtime) => runtime.writeUInt32LE(4, LOAD_HEADER + 4),
/executable PT_LOAD/
],
[
'entry points outside load segments',
(runtime) => runtime.writeBigUInt64LE(4096n, 24),
/entry point/
]
])('rejects %s', async (_label, mutate, expected) => {
const runtime = createRuntime()
mutate(runtime)
await withFixture('orca-linux.AppImage', runtime, (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(expected)
})
})
})
function createRuntime({ machine = 0x3e } = {}) {
const runtime = Buffer.alloc(FIXTURE_BYTES)
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(runtime)
Buffer.from([0x41, 0x49, 0x02]).copy(runtime, 8)
runtime.writeUInt16LE(3, 16)
runtime.writeUInt16LE(machine, 18)
runtime.writeUInt32LE(1, 20)
runtime.writeBigUInt64LE(0n, 24)
runtime.writeBigUInt64LE(64n, 32)
runtime.writeUInt16LE(64, 52)
runtime.writeUInt16LE(56, 54)
runtime.writeUInt16LE(2, 56)
writeProgramHeader(runtime, LOAD_HEADER, {
type: 1,
flags: 5,
offset: 0,
virtualAddress: 0,
size: FIXTURE_BYTES,
memorySize: FIXTURE_BYTES,
alignment: 4096
})
writeProgramHeader(runtime, DYNAMIC_HEADER, {
type: 2,
flags: 4,
offset: DYNAMIC_OFFSET,
virtualAddress: DYNAMIC_OFFSET,
size: 32,
memorySize: 32,
alignment: 8
})
RUNTIME_SOURCE.copy(runtime, 192)
return runtime
}
function writeProgramHeader(
runtime,
headerOffset,
{ type, flags, offset, virtualAddress, size, memorySize = size, alignment }
) {
runtime.writeUInt32LE(type, headerOffset)
runtime.writeUInt32LE(flags, headerOffset + 4)
runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 8)
runtime.writeBigUInt64LE(BigInt(virtualAddress), headerOffset + 16)
runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 24)
runtime.writeBigUInt64LE(BigInt(size), headerOffset + 32)
runtime.writeBigUInt64LE(BigInt(memorySize), headerOffset + 40)
runtime.writeBigUInt64LE(BigInt(alignment), headerOffset + 48)
}
async function withFixture(filename, contents, check, { mode = 0o755 } = {}) {
const root = await mkdtemp(join(tmpdir(), 'orca-static-appimage-contract-'))
try {
const path = join(root, filename)
await writeFile(path, contents)
await chmod(path, mode)
await check(path)
} finally {
await rm(root, { recursive: true, force: true })
}
}
+1 -1
View File
@@ -92,7 +92,7 @@
"build:icons": "bash resources/icon-source/generate.sh",
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:keyboard-layout-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:keyboard-layout-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && node config/scripts/build-linux-local.mjs",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
"test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",