feat: migrate packaging and auto updates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-29 22:36:44 +02:00
co-authored by Copilot
parent 66b2a94977
commit 15071fdc47
28 changed files with 1254 additions and 901 deletions
+2 -28
View File
@@ -96,20 +96,12 @@ jobs:
include:
- os: windows-latest
label: Windows
release_dir_name: Aryx-windows-x64
asset_path: release/Aryx-windows-x64-setup.exe
- os: macos-15-intel
label: macOS (x64)
release_dir_name: Aryx-macos-x64
asset_path: release/Aryx-macos-x64.dmg
- os: macos-15
label: macOS (arm64)
release_dir_name: Aryx-macos-arm64
asset_path: release/Aryx-macos-arm64.dmg
- os: ubuntu-latest
label: Linux
release_dir_name: Aryx-linux-x64
asset_path: release/aryx-linux-x64.deb
steps:
- name: Check out repository
@@ -131,28 +123,10 @@ jobs:
sudo apt-get update
sudo apt-get install -y libsecret-1-dev
- name: Install Inno Setup
if: runner.os == 'Windows'
shell: pwsh
run: choco install innosetup -y --no-progress
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Package current platform
run: bun run package
- name: Ad-hoc sign macOS app bundle
if: runner.os == 'macOS'
run: codesign --force --deep --sign - "release/${{ matrix.release_dir_name }}/Aryx.app"
- name: Create platform installer
run: bun run scripts/create-installer.ts
- name: Upload asset to GitHub release
shell: bash
- name: Build and publish release artifacts
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.ref_name }}
ASSET_PATH: ${{ matrix.asset_path }}
run: gh release upload "$TAG_NAME" "$ASSET_PATH" --clobber
run: bun run publish-release
+4 -2
View File
@@ -344,9 +344,11 @@ The build pipeline is organized around three layers:
- building the Electron renderer and main process assets
- publishing the sidecar for the target runtime
- assembling a platform-specific release bundle
- packaging platform artifacts with electron-builder
Release automation validates the app across Windows, macOS, and Linux, and tag-based releases publish platform bundles directly to GitHub Releases, including both macOS x64 and arm64 artifacts.
electron-builder bundles the packaged Electron app, copies the published sidecar into `resources/sidecar`, produces Windows NSIS installers, macOS DMG + ZIP artifacts, and Linux AppImages, and uploads the release assets plus update metadata to GitHub Releases. The main process consumes that metadata through `electron-updater`, which checks GitHub Releases for packaged builds and can stage a restart-based update install.
Current Windows builds are unsigned, so the packaging config disables executable resource editing/signing and skips Windows update signature verification until a code-signing certificate is available. The packaging scripts also clear `release/` before each build so local packaging runs cannot accidentally mix stale artifacts with current ones.
This packaging model matches the runtime architecture: one desktop shell plus one dedicated AI execution process.
+11 -1
View File
@@ -124,7 +124,17 @@ To package the current platform into `release/`, run:
- `bun run package`
GitHub Actions now runs validation on pushes and pull requests, and pushing a git tag creates a GitHub release with Windows, macOS (x64 and arm64), and Linux assets uploaded directly to the release.
To create the installable artifacts for the current platform, run:
- `bun run installer`
To publish packaged artifacts and update metadata to GitHub Releases, run:
- `bun run publish-release`
GitHub Actions runs validation on pushes and pull requests, and tagged releases now use `electron-builder` to publish Windows (NSIS), macOS (DMG + ZIP for updater metadata), and Linux (AppImage) artifacts directly to GitHub Releases. Packaged builds use `electron-updater` against those releases for in-app updates.
Windows builds are currently packaged without Authenticode signing, so Aryx disables `electron-updater`'s Windows signature verification until a signing certificate is configured. macOS auto-update metadata still requires a ZIP artifact alongside the DMG build.
## Current focus
-9
View File
@@ -1,9 +0,0 @@
[Desktop Entry]
Name=Aryx
Comment=Copilot-powered agent workflow orchestrator
Exec=/opt/aryx/Aryx %U
Icon=aryx
Terminal=false
Type=Application
Categories=Development;
StartupWMClass=Aryx
-86
View File
@@ -1,86 +0,0 @@
; Inno Setup script for Aryx
; Dynamic values are read from environment variables set by the build script.
#define PRODUCT_NAME "Aryx"
#define PRODUCT_PUBLISHER "David Kaya"
#define PRODUCT_VERSION GetEnv("ARYX_BUILD_VERSION")
#define SOURCE_DIR GetEnv("ARYX_BUILD_SOURCE_DIR")
#define OUTPUT_DIR GetEnv("ARYX_BUILD_OUTPUT_DIR")
#define OUTPUT_FILENAME GetEnv("ARYX_BUILD_OUTPUT_FILENAME")
#define ICON_PATH GetEnv("ARYX_BUILD_ICON_PATH")
#if PRODUCT_VERSION == ""
#error "ARYX_BUILD_VERSION environment variable must be set."
#endif
#if SOURCE_DIR == ""
#error "ARYX_BUILD_SOURCE_DIR environment variable must be set."
#endif
#if OUTPUT_DIR == ""
#error "ARYX_BUILD_OUTPUT_DIR environment variable must be set."
#endif
#if OUTPUT_FILENAME == ""
#error "ARYX_BUILD_OUTPUT_FILENAME environment variable must be set."
#endif
#if ICON_PATH == ""
#define ICON_PATH SOURCE_DIR + "\" + PRODUCT_NAME + ".exe"
#endif
[Setup]
AppId={{B8A3E7F1-4D2C-4A9B-8E6F-1C3D5A7B9E0F}
AppName={#PRODUCT_NAME}
AppVersion={#PRODUCT_VERSION}
AppPublisher={#PRODUCT_PUBLISHER}
AppSupportURL=https://github.com/davidkaya/aryx
DefaultDirName={localappdata}\Programs\{#PRODUCT_NAME}
DefaultGroupName={#PRODUCT_NAME}
PrivilegesRequired=lowest
OutputDir={#OUTPUT_DIR}
OutputBaseFilename={#OUTPUT_FILENAME}
Compression=lzma2/ultra64
SolidCompression=yes
SetupIconFile={#ICON_PATH}
UninstallDisplayIcon={app}\{#PRODUCT_NAME}.exe
WizardStyle=modern
DisableProgramGroupPage=yes
CloseApplications=force
RestartApplications=no
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
Source: "{#SOURCE_DIR}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{group}\{#PRODUCT_NAME}"; Filename: "{app}\{#PRODUCT_NAME}.exe"
Name: "{autodesktop}\{#PRODUCT_NAME}"; Filename: "{app}\{#PRODUCT_NAME}.exe"; Tasks: desktopicon
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Run]
Filename: "{app}\{#PRODUCT_NAME}.exe"; Description: "{cm:LaunchProgram,{#PRODUCT_NAME}}"; Flags: nowait postinstall skipifsilent
[UninstallDelete]
Type: filesandordirs; Name: "{app}"
[Code]
procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
begin
if CurStep = ssInstall then
begin
Exec('taskkill', '/F /IM {#PRODUCT_NAME}.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
ResultCode: Integer;
begin
if CurUninstallStep = usUninstall then
begin
Exec('taskkill', '/F /IM {#PRODUCT_NAME}.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
end;
+497 -23
View File
File diff suppressed because it is too large Load Diff
+83 -4
View File
@@ -8,8 +8,9 @@
"dev": "electron-vite dev",
"build:electron": "electron-vite build",
"build": "bun run build:electron && bun run sidecar:build",
"package": "bun run build:electron && bun run sidecar:publish && bun run scripts/package-electron.ts",
"installer": "bun run package && bun run scripts/create-installer.ts",
"package": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --dir --publish never",
"installer": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish never",
"publish-release": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish always",
"preview": "electron-vite preview",
"lsp:typescript": "typescript-language-server --stdio",
"typecheck": "tsc --noEmit -p tsconfig.json",
@@ -33,7 +34,6 @@
"packageManager": "bun@1.3.6",
"devDependencies": {
"@dagrejs/dagre": "^3.0.0",
"@electron/asar": "^4.1.1",
"@lexical/code": "0.42.0",
"@lexical/headless": "0.42.0",
"@lexical/link": "0.42.0",
@@ -52,11 +52,11 @@
"@xyflow/react": "^12.10.1",
"bun-types": "^1.3.11",
"electron": "^41.0.3",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
"highlight.js": "^11.11.1",
"lexical": "0.42.0",
"lucide-react": "^0.577.0",
"rcedit": "^5.0.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
@@ -71,8 +71,87 @@
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/outfit": "^5.2.8",
"electron-updater": "^6.8.3",
"keytar": "^7.9.0",
"motion": "^12.38.0",
"node-pty": "^1.1.0"
},
"build": {
"appId": "com.davidkaya.aryx",
"productName": "Aryx",
"directories": {
"buildResources": "assets",
"output": "release"
},
"files": [
"package.json",
"dist-electron/**/*",
"dist/**/*",
"assets/**/*"
],
"extraResources": [
{
"from": "dist-sidecar",
"to": "sidecar",
"filter": [
"**/*"
]
}
],
"asar": true,
"asarUnpack": [
"**/*.node"
],
"electronLanguages": [
"en-US"
],
"electronUpdaterCompatibility": ">=2.16",
"npmRebuild": false,
"publish": {
"provider": "github",
"owner": "davidkaya",
"repo": "aryx"
},
"win": {
"target": [
"nsis"
],
"icon": "assets/icons/windows/icon.ico",
"artifactName": "Aryx-windows-${arch}.${ext}",
"signAndEditExecutable": false,
"verifyUpdateCodeSignature": false
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"installerIcon": "assets/icons/windows/icon.ico",
"uninstallerIcon": "assets/icons/windows/icon.ico"
},
"mac": {
"target": [
"dmg",
"zip"
],
"icon": "assets/icons/macos/icon.icns",
"category": "public.app-category.developer-tools",
"identity": "-",
"artifactName": "Aryx-macos-${arch}.${ext}"
},
"linux": {
"target": [
"AppImage"
],
"icon": "assets/icons/linux/icons",
"category": "Development",
"artifactName": "Aryx-linux-${arch}.${ext}",
"desktop": {
"entry": {
"Name": "Aryx",
"Comment": "Copilot-powered agent workflow orchestrator",
"StartupWMClass": "Aryx"
}
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { rm } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const releaseDirectory = resolve(repositoryRoot, 'release');
await rm(releaseDirectory, { recursive: true, force: true });
-227
View File
@@ -1,227 +0,0 @@
import { spawn } from 'node:child_process';
import { constants } from 'node:fs';
import {
access,
cp,
mkdir,
readFile,
symlink,
writeFile,
} from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { productName, resolveReleaseTarget } from './releaseTarget';
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
cwd,
stdio: 'inherit',
});
child.on('error', rejectPromise);
child.on('exit', (code, signal) => {
if (code === 0) {
resolvePromise();
return;
}
if (signal) {
rejectPromise(new Error(`${command} exited because of signal ${signal}.`));
return;
}
rejectPromise(new Error(`${command} exited with code ${code ?? 'unknown'}.`));
});
});
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const releaseTarget = resolveReleaseTarget(process.platform, process.arch);
const releaseRootDirectory = join(repositoryRoot, 'release');
const packagedAppDirectory = join(releaseRootDirectory, releaseTarget.outputDirectoryName);
const installerOutputPath = join(releaseRootDirectory, releaseTarget.installerAssetName);
const installerAssetsDirectory = join(repositoryRoot, 'assets', 'installer');
async function readVersion(): Promise<string> {
const packageJson = JSON.parse(
await readFile(join(repositoryRoot, 'package.json'), 'utf8'),
) as { version: string };
return packageJson.version;
}
// --- Windows: Inno Setup installer ---
async function resolveInnoSetupCompilerPath(): Promise<string> {
const candidates = [
'C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe',
'C:\\Program Files\\Inno Setup 6\\ISCC.exe',
'iscc',
];
for (const candidate of candidates) {
if (candidate.includes('\\') && (await pathExists(candidate))) {
return candidate;
}
}
return 'iscc';
}
async function createWindowsInstaller(version: string): Promise<void> {
const issScript = join(installerAssetsDirectory, 'windows.iss');
const isccPath = await resolveInnoSetupCompilerPath();
const outputFilename = releaseTarget.installerAssetName.replace(/\.exe$/, '');
const iconPath = join(repositoryRoot, 'assets', 'icons', 'windows', 'icon.ico');
process.env.ARYX_BUILD_VERSION = version;
process.env.ARYX_BUILD_SOURCE_DIR = packagedAppDirectory;
process.env.ARYX_BUILD_OUTPUT_DIR = releaseRootDirectory;
process.env.ARYX_BUILD_OUTPUT_FILENAME = outputFilename;
process.env.ARYX_BUILD_ICON_PATH = iconPath;
await runCommand(isccPath, [issScript], repositoryRoot);
}
// --- macOS: DMG disk image ---
async function createMacInstaller(): Promise<void> {
const appBundleName = releaseTarget.appBundleName;
if (!appBundleName) {
throw new Error('macOS installer requires an app bundle name.');
}
const appBundlePath = join(packagedAppDirectory, appBundleName);
// Use macOS's native disk image tooling so packaging does not depend on
// create-dmg's native node-gyp install path on CI runners.
await runCommand(
'hdiutil',
[
'create',
'-volname',
productName,
'-srcfolder',
appBundlePath,
'-ov',
'-format',
'UDZO',
installerOutputPath,
],
repositoryRoot,
);
}
// --- Linux: .deb package ---
const linuxIconSizes = ['16x16', '32x32', '48x48', '64x64', '128x128', '256x256', '512x512'];
async function createLinuxInstaller(version: string): Promise<void> {
const stagingDirectory = join(releaseRootDirectory, 'deb-staging');
const debianDirectory = join(stagingDirectory, 'DEBIAN');
const optDirectory = join(stagingDirectory, 'opt', 'aryx');
const binDirectory = join(stagingDirectory, 'usr', 'bin');
const applicationsDirectory = join(stagingDirectory, 'usr', 'share', 'applications');
await mkdir(debianDirectory, { recursive: true });
await mkdir(binDirectory, { recursive: true });
await mkdir(applicationsDirectory, { recursive: true });
// Copy packaged app into /opt/aryx/
await cp(packagedAppDirectory, optDirectory, { recursive: true });
// Create symlink /usr/bin/aryx -> /opt/aryx/Aryx
await symlink('/opt/aryx/Aryx', join(binDirectory, 'aryx'));
// Copy desktop entry
await cp(
join(installerAssetsDirectory, 'linux', 'aryx.desktop'),
join(applicationsDirectory, 'aryx.desktop'),
);
// Install icons into hicolor theme
const sourceIconsDirectory = join(repositoryRoot, 'assets', 'icons', 'linux', 'icons');
for (const size of linuxIconSizes) {
const sourceIcon = join(sourceIconsDirectory, `${size}.png`);
if (!(await pathExists(sourceIcon))) {
continue;
}
const targetIconDirectory = join(
stagingDirectory, 'usr', 'share', 'icons', 'hicolor', size, 'apps',
);
await mkdir(targetIconDirectory, { recursive: true });
await cp(sourceIcon, join(targetIconDirectory, 'aryx.png'));
}
// Determine installed size (in KB)
const { stdout } = await new Promise<{ stdout: string }>((resolvePromise, rejectPromise) => {
const child = spawn('du', ['-sk', optDirectory], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (data: Buffer) => { out += data.toString(); });
child.on('error', rejectPromise);
child.on('exit', () => resolvePromise({ stdout: out }));
});
const installedSizeKb = parseInt(stdout.split('\t')[0] ?? '0', 10);
const debArch = releaseTarget.arch === 'x64' ? 'amd64' : 'arm64';
// Write DEBIAN/control
const controlContent = [
`Package: aryx`,
`Version: ${version}`,
`Section: devel`,
`Priority: optional`,
`Architecture: ${debArch}`,
`Installed-Size: ${installedSizeKb}`,
`Depends: libsecret-1-0`,
`Maintainer: David Kaya`,
`Description: ${productName} — Copilot-powered agent workflow orchestrator`,
` Electron desktop app for orchestrating Copilot-driven agent workflows`,
` across multiple projects.`,
'',
].join('\n');
await writeFile(join(debianDirectory, 'control'), controlContent);
// Build the .deb
await runCommand(
'dpkg-deb',
['--build', '--root-owner-group', stagingDirectory, installerOutputPath],
repositoryRoot,
);
}
// --- Entry point ---
if (!(await pathExists(packagedAppDirectory))) {
throw new Error(
`Packaged app not found at ${packagedAppDirectory}. Run "bun run package" first.`,
);
}
const version = await readVersion();
switch (releaseTarget.platform) {
case 'win32':
await createWindowsInstaller(version);
break;
case 'darwin':
await createMacInstaller();
break;
case 'linux':
await createLinuxInstaller(version);
break;
}
console.log(`Created installer: ${installerOutputPath}`);
-332
View File
@@ -1,332 +0,0 @@
import { constants } from 'node:fs';
import { access, chmod, cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createPackageWithOptions } from '@electron/asar';
import {
macBundleIdentifier,
productName,
resolveReleaseTarget,
type ReleaseTarget,
} from './releaseTarget';
interface PackageManifest {
readonly name: string;
readonly productName: string;
readonly version: string;
readonly description?: string;
readonly main: string;
readonly author?: string;
readonly license?: string;
}
interface RootPackageJson {
readonly name: string;
readonly version: string;
readonly description?: string;
readonly main: string;
readonly author?: string;
readonly license?: string;
readonly dependencies?: Record<string, string>;
}
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const assetDirectory = join(repositoryRoot, 'assets');
const genericIconPath = join(assetDirectory, 'icons', 'icon.png');
const windowsIconPath = join(assetDirectory, 'icons', 'windows', 'icon.ico');
const macosIconPath = join(assetDirectory, 'icons', 'macos', 'icon.icns');
const rendererBuildDirectory = join(repositoryRoot, 'dist');
const electronBuildDirectory = join(repositoryRoot, 'dist-electron');
const releaseTarget = resolveReleaseTarget(process.platform, process.arch);
const releaseRootDirectory = join(repositoryRoot, 'release');
const outputDirectory = join(releaseRootDirectory, releaseTarget.outputDirectoryName);
const electronDistributionDirectory = releaseTarget.platform === 'darwin'
? join(repositoryRoot, 'node_modules', 'electron', 'dist', 'Electron.app')
: join(repositoryRoot, 'node_modules', 'electron', 'dist');
const publishedSidecarDirectory = join(repositoryRoot, 'dist-sidecar', releaseTarget.dotnetRuntime);
async function ensurePathExists(path: string, label: string): Promise<void> {
try {
await access(path, constants.F_OK);
} catch {
throw new Error(`${label} was not found at ${path}.`);
}
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
async function readJson<T>(path: string): Promise<T> {
return JSON.parse(await readFile(path, 'utf8')) as T;
}
async function collectRuntimeDependencies(): Promise<string[]> {
const rootPackageJson = await readJson<RootPackageJson>(join(repositoryRoot, 'package.json'));
const dependencies = new Set(Object.keys(rootPackageJson.dependencies ?? {}));
const queue = [...dependencies];
while (queue.length > 0) {
const dependencyName = queue.shift();
if (!dependencyName) {
continue;
}
const dependencyPackageJsonPath = join(
repositoryRoot,
'node_modules',
...dependencyName.split('/'),
'package.json',
);
if (!(await pathExists(dependencyPackageJsonPath))) {
dependencies.delete(dependencyName);
continue;
}
const dependencyPackageJson = await readJson<{
readonly dependencies?: Record<string, string>;
readonly optionalDependencies?: Record<string, string>;
}>(dependencyPackageJsonPath);
for (const transitiveDependency of Object.keys({
...(dependencyPackageJson.dependencies ?? {}),
...(dependencyPackageJson.optionalDependencies ?? {}),
})) {
if (!dependencies.has(transitiveDependency)) {
dependencies.add(transitiveDependency);
queue.push(transitiveDependency);
}
}
}
return [...dependencies].sort();
}
async function copyRuntimeDependencies(
packagedAppDirectory: string,
dependencyNames: string[],
): Promise<void> {
const packagedNodeModulesDirectory = join(packagedAppDirectory, 'node_modules');
await mkdir(packagedNodeModulesDirectory, { recursive: true });
for (const dependencyName of dependencyNames) {
const dependencyPathParts = dependencyName.split('/');
const sourceDirectory = join(repositoryRoot, 'node_modules', ...dependencyPathParts);
const targetDirectory = join(packagedNodeModulesDirectory, ...dependencyPathParts);
await mkdir(dirname(targetDirectory), { recursive: true });
await cp(sourceDirectory, targetDirectory, { recursive: true });
}
}
async function writePackagedManifest(packagedAppDirectory: string): Promise<PackageManifest> {
const sourcePackageJson = await readJson<RootPackageJson>(join(repositoryRoot, 'package.json'));
const packagedManifest: PackageManifest = {
name: sourcePackageJson.name,
productName,
version: sourcePackageJson.version,
description: sourcePackageJson.description,
main: sourcePackageJson.main,
author: sourcePackageJson.author,
license: sourcePackageJson.license,
};
await writeFile(
join(packagedAppDirectory, 'package.json'),
`${JSON.stringify(packagedManifest, null, 2)}\n`,
);
return packagedManifest;
}
async function copyApplicationPayload(
packagedAppDirectory: string,
outputResourcesDirectory: string,
dependencyNames: string[],
): Promise<PackageManifest> {
await mkdir(packagedAppDirectory, { recursive: true });
const manifest = await writePackagedManifest(packagedAppDirectory);
await Promise.all([
cp(assetDirectory, join(packagedAppDirectory, 'assets'), { recursive: true }),
cp(rendererBuildDirectory, join(packagedAppDirectory, 'dist'), { recursive: true }),
cp(electronBuildDirectory, join(packagedAppDirectory, 'dist-electron'), { recursive: true }),
cp(publishedSidecarDirectory, join(outputResourcesDirectory, 'sidecar'), { recursive: true }),
]);
await copyRuntimeDependencies(packagedAppDirectory, dependencyNames);
const asarPath = join(outputResourcesDirectory, 'app.asar');
await createPackageWithOptions(packagedAppDirectory, asarPath, {
unpack: '**/*.node',
});
await rm(packagedAppDirectory, { recursive: true });
return manifest;
}
async function ensureExecutable(path: string, mode = 0o755): Promise<void> {
await chmod(path, mode);
}
function replacePlistValue(plistContents: string, key: string, value: string): string {
const pattern = new RegExp(`(<key>${key}</key>\\s*<string>)([^<]*)(</string>)`);
if (!pattern.test(plistContents)) {
throw new Error(`Could not find ${key} in macOS Info.plist.`);
}
return plistContents.replace(pattern, `$1${value}$3`);
}
async function applyMacMetadata(appBundleDirectory: string, version: string): Promise<void> {
const infoPlistPath = join(appBundleDirectory, 'Contents', 'Info.plist');
let infoPlistContents = await readFile(infoPlistPath, 'utf8');
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleDisplayName', productName);
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleExecutable', productName);
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleIconFile', 'icon.icns');
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleIdentifier', macBundleIdentifier);
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleName', productName);
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleShortVersionString', version);
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleVersion', version);
await writeFile(infoPlistPath, infoPlistContents);
const sourceExecutablePath = join(appBundleDirectory, 'Contents', 'MacOS', 'Electron');
const targetExecutablePath = join(appBundleDirectory, 'Contents', 'MacOS', productName);
await rename(sourceExecutablePath, targetExecutablePath);
await ensureExecutable(targetExecutablePath);
await cp(macosIconPath, join(appBundleDirectory, 'Contents', 'Resources', 'icon.icns'));
}
async function stripUnneededElectronFiles(electronOutputDirectory: string): Promise<void> {
const filesToRemove = ['LICENSES.chromium.html', 'LICENSE'];
const resourcesToRemove = ['default_app.asar'];
await Promise.all([
...filesToRemove.map((file) => rm(join(electronOutputDirectory, file), { force: true })),
...resourcesToRemove.map((file) =>
rm(join(electronOutputDirectory, 'resources', file), { force: true }),
),
]);
const localesDirectory = join(electronOutputDirectory, 'locales');
if (await pathExists(localesDirectory)) {
const localeFiles = await readdir(localesDirectory);
await Promise.all(
localeFiles
.filter((file) => file !== 'en-US.pak')
.map((file) => rm(join(localesDirectory, file))),
);
}
}
async function stripMacElectronFiles(resourcesDirectory: string): Promise<void> {
await rm(join(resourcesDirectory, 'LICENSES.chromium.html'), { force: true });
const entries = await readdir(resourcesDirectory);
const unusedLproj = entries.filter(
(entry) => entry.endsWith('.lproj') && entry !== 'en.lproj',
);
await Promise.all(
unusedLproj.map((dir) => rm(join(resourcesDirectory, dir), { recursive: true })),
);
}
async function packageWindows(dependencyNames: string[]): Promise<void> {
const packagedExecutablePath = join(outputDirectory, `${productName}.exe`);
const packagedAppDirectory = join(outputDirectory, 'resources', 'app');
const outputResourcesDirectory = join(outputDirectory, 'resources');
await cp(electronDistributionDirectory, outputDirectory, { recursive: true });
await stripUnneededElectronFiles(outputDirectory);
await rename(join(outputDirectory, 'electron.exe'), packagedExecutablePath);
await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
const { rcedit } = await import('rcedit');
await rcedit(packagedExecutablePath, { icon: windowsIconPath });
}
async function packageMac(dependencyNames: string[]): Promise<void> {
const appBundleName = releaseTarget.appBundleName;
if (!appBundleName) {
throw new Error('macOS packaging requires an app bundle name.');
}
const appBundleDirectory = join(outputDirectory, appBundleName);
const packagedAppDirectory = join(appBundleDirectory, 'Contents', 'Resources', 'app');
const outputResourcesDirectory = join(appBundleDirectory, 'Contents', 'Resources');
await cp(electronDistributionDirectory, appBundleDirectory, { recursive: true });
await stripMacElectronFiles(join(appBundleDirectory, 'Contents', 'Resources'));
const manifest = await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
await applyMacMetadata(appBundleDirectory, manifest.version);
await ensureExecutable(join(outputResourcesDirectory, 'sidecar', releaseTarget.sidecarExecutableName));
}
async function packageLinux(dependencyNames: string[]): Promise<void> {
const packagedExecutableName = releaseTarget.packagedExecutableName;
if (!packagedExecutableName) {
throw new Error('Linux packaging requires a packaged executable name.');
}
const packagedExecutablePath = join(outputDirectory, packagedExecutableName);
const packagedAppDirectory = join(outputDirectory, 'resources', 'app');
const outputResourcesDirectory = join(outputDirectory, 'resources');
const chromeSandboxPath = join(outputDirectory, 'chrome-sandbox');
await cp(electronDistributionDirectory, outputDirectory, { recursive: true });
await stripUnneededElectronFiles(outputDirectory);
await rename(join(outputDirectory, 'electron'), packagedExecutablePath);
await ensureExecutable(packagedExecutablePath);
await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
await ensureExecutable(join(outputResourcesDirectory, 'sidecar', releaseTarget.sidecarExecutableName));
if (await pathExists(chromeSandboxPath)) {
await chmod(chromeSandboxPath, 0o4755);
}
}
async function packageCurrentPlatform(target: ReleaseTarget, dependencyNames: string[]): Promise<void> {
switch (target.platform) {
case 'win32':
await packageWindows(dependencyNames);
return;
case 'darwin':
await packageMac(dependencyNames);
return;
case 'linux':
await packageLinux(dependencyNames);
return;
}
}
await Promise.all([
ensurePathExists(assetDirectory, 'Application assets'),
ensurePathExists(genericIconPath, 'Source application icon'),
ensurePathExists(electronDistributionDirectory, 'Electron runtime'),
ensurePathExists(rendererBuildDirectory, 'Renderer build output'),
ensurePathExists(electronBuildDirectory, 'Electron build output'),
ensurePathExists(publishedSidecarDirectory, 'Published sidecar output'),
]);
if (releaseTarget.platform === 'win32') {
await ensurePathExists(windowsIconPath, 'Windows application icon');
}
if (releaseTarget.platform === 'darwin') {
await ensurePathExists(macosIconPath, 'macOS application icon');
}
const runtimeDependencies = await collectRuntimeDependencies();
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(releaseRootDirectory, { recursive: true });
await packageCurrentPlatform(releaseTarget, runtimeDependencies);
console.log(`Packaged ${productName} for ${releaseTarget.platformLabel} to ${outputDirectory}`);
console.log(`Bundled ${runtimeDependencies.length} runtime dependencies and the self-contained .NET sidecar.`);
+35 -6
View File
@@ -3,8 +3,6 @@ import { rm } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveReleaseTarget } from './releaseTarget';
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
@@ -29,9 +27,40 @@ function runCommand(command: string, args: string[], cwd: string): Promise<void>
});
}
type SupportedPlatform = 'win32' | 'darwin' | 'linux';
type SupportedArch = 'x64' | 'arm64';
function resolveDotnetRuntime(platform: NodeJS.Platform, arch: NodeJS.Architecture): `${string}-${SupportedArch}` {
if (arch !== 'x64' && arch !== 'arm64') {
throw new Error(`Unsupported architecture for sidecar publish: ${arch}`);
}
switch (platform) {
case 'win32':
return `win-${arch}`;
case 'darwin':
return `osx-${arch}`;
case 'linux':
return `linux-${arch}`;
default:
throw new Error(`Unsupported platform for sidecar publish: ${platform}`);
}
}
function resolvePlatformLabel(platform: SupportedPlatform): 'windows' | 'macos' | 'linux' {
switch (platform) {
case 'win32':
return 'windows';
case 'darwin':
return 'macos';
case 'linux':
return 'linux';
}
}
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const releaseTarget = resolveReleaseTarget(process.platform, process.arch);
const dotnetRuntime = resolveDotnetRuntime(process.platform, process.arch);
const sidecarProjectPath = join(
repositoryRoot,
'sidecar',
@@ -39,7 +68,7 @@ const sidecarProjectPath = join(
'Aryx.AgentHost',
'Aryx.AgentHost.csproj',
);
const outputDirectory = join(repositoryRoot, 'dist-sidecar', releaseTarget.dotnetRuntime);
const outputDirectory = join(repositoryRoot, 'dist-sidecar');
await rm(outputDirectory, { recursive: true, force: true });
@@ -51,7 +80,7 @@ await runCommand(
'-c',
'Release',
'-r',
releaseTarget.dotnetRuntime,
dotnetRuntime,
'--self-contained',
'true',
'-p:DebugType=None',
@@ -65,4 +94,4 @@ await runCommand(
repositoryRoot,
);
console.log(`Published sidecar for ${releaseTarget.platformLabel} (${releaseTarget.dotnetRuntime}) to ${outputDirectory}`);
console.log(`Published sidecar for ${resolvePlatformLabel(process.platform as SupportedPlatform)} (${dotnetRuntime}) to ${outputDirectory}`);
-87
View File
@@ -1,87 +0,0 @@
export const productName = 'Aryx';
export const macBundleIdentifier = 'com.davidkaya.aryx';
type SupportedPlatform = 'win32' | 'darwin' | 'linux';
type SupportedArch = 'x64' | 'arm64';
export interface ReleaseTarget {
readonly platform: SupportedPlatform;
readonly arch: SupportedArch;
readonly platformLabel: 'windows' | 'macos' | 'linux';
readonly dotnetRuntime: `${string}-${SupportedArch}`;
readonly outputDirectoryName: string;
readonly archiveBaseName: string;
readonly installerAssetName: string;
readonly sidecarExecutableName: string;
readonly packagedExecutableName?: string;
readonly appBundleName?: string;
}
function resolveSupportedArch(
platform: SupportedPlatform,
arch: NodeJS.Architecture,
): SupportedArch {
if (arch === 'x64' || arch === 'arm64') {
return arch;
}
throw new Error(`Unsupported architecture for ${platform}: ${arch}`);
}
export function resolveReleaseTarget(
platform: NodeJS.Platform,
arch: NodeJS.Architecture,
): ReleaseTarget {
switch (platform) {
case 'win32': {
const supportedArch = resolveSupportedArch(platform, arch);
const archiveBaseName = `${productName}-windows-${supportedArch}`;
return {
platform,
arch: supportedArch,
platformLabel: 'windows',
dotnetRuntime: `win-${supportedArch}`,
outputDirectoryName: archiveBaseName,
archiveBaseName,
installerAssetName: `${archiveBaseName}-setup.exe`,
sidecarExecutableName: 'Aryx.AgentHost.exe',
packagedExecutableName: `${productName}.exe`,
};
}
case 'darwin': {
const supportedArch = resolveSupportedArch(platform, arch);
const archiveBaseName = `${productName}-macos-${supportedArch}`;
return {
platform,
arch: supportedArch,
platformLabel: 'macos',
dotnetRuntime: `osx-${supportedArch}`,
outputDirectoryName: archiveBaseName,
archiveBaseName,
installerAssetName: `${archiveBaseName}.dmg`,
sidecarExecutableName: 'Aryx.AgentHost',
appBundleName: `${productName}.app`,
};
}
case 'linux': {
const supportedArch = resolveSupportedArch(platform, arch);
const archiveBaseName = `${productName}-linux-${supportedArch}`;
return {
platform,
arch: supportedArch,
platformLabel: 'linux',
dotnetRuntime: `linux-${supportedArch}`,
outputDirectoryName: archiveBaseName,
archiveBaseName,
installerAssetName: `aryx-linux-${supportedArch}.deb`,
sidecarExecutableName: 'Aryx.AgentHost',
packagedExecutableName: productName,
};
}
default:
throw new Error(`Unsupported release platform: ${platform}`);
}
}
@@ -15,6 +15,7 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
private const string DefaultName = "GitHub Copilot Agent";
private const string DefaultDescription = "An AI agent powered by GitHub Copilot";
private const string HandoffToolPrefix = "handoff_to_";
private static readonly JsonSerializerOptions ToolArgumentJsonOptions = JsonSerialization.CreateWebOptions();
private readonly CopilotClient _copilotClient;
private readonly string? _id;
private readonly string _name;
@@ -473,11 +474,11 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
return null;
}
return JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonElement.GetRawText());
return JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonElement.GetRawText(), ToolArgumentJsonOptions);
}
string json = JsonSerializer.Serialize(arguments, arguments.GetType());
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
string json = JsonSerializer.Serialize(arguments, arguments.GetType(), ToolArgumentJsonOptions);
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json, ToolArgumentJsonOptions);
}
internal static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? MessageMode, string? TempDir)> ProcessMessageAttachmentsAsync(
@@ -601,6 +602,8 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
internal sealed class AryxCopilotAgentSession : AgentSession
{
private static readonly JsonSerializerOptions DefaultJsonOptions = JsonSerialization.CreateWebOptions();
public AryxCopilotAgentSession()
{
}
@@ -617,7 +620,7 @@ internal sealed class AryxCopilotAgentSession : AgentSession
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
JsonSerializerOptions options = jsonSerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
JsonSerializerOptions options = jsonSerializerOptions ?? DefaultJsonOptions;
return JsonSerializer.SerializeToElement(this, options);
}
@@ -630,7 +633,7 @@ internal sealed class AryxCopilotAgentSession : AgentSession
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
}
JsonSerializerOptions options = jsonSerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
JsonSerializerOptions options = jsonSerializerOptions ?? DefaultJsonOptions;
return serializedState.Deserialize<AryxCopilotAgentSession>(options)
?? new AryxCopilotAgentSession();
}
@@ -20,10 +20,7 @@ internal static class CopilotSessionHooks
ReportIntentToolName,
TaskCompleteToolName,
};
private static readonly JsonSerializerOptions HookJsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly JsonSerializerOptions HookJsonOptions = CreateHookJsonOptions();
public static SessionHooks Create(
RunTurnCommandDto command,
@@ -281,6 +278,13 @@ internal static class CopilotSessionHooks
private static string SerializeHookValue(object? value)
=> JsonSerializer.Serialize(value, HookJsonOptions);
private static JsonSerializerOptions CreateHookJsonOptions()
{
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
return options;
}
private static bool IsAlwaysAllowedTool(string? toolName)
{
string? normalizedToolName = Normalize(toolName);
@@ -5,12 +5,7 @@ namespace Aryx.AgentHost.Services;
internal static class HookConfigLoader
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
public static async Task<ResolvedHookSet> LoadAsync(string projectPath, CancellationToken cancellationToken)
{
@@ -204,4 +199,13 @@ internal static class HookConfigLoader
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static JsonSerializerOptions CreateJsonOptions()
{
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
options.AllowTrailingCommas = true;
options.PropertyNameCaseInsensitive = true;
options.ReadCommentHandling = JsonCommentHandling.Skip;
return options;
}
}
@@ -0,0 +1,15 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
namespace Aryx.AgentHost.Services;
internal static class JsonSerialization
{
public static JsonSerializerOptions CreateWebOptions()
{
return new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};
}
}
@@ -6,10 +6,7 @@ namespace Aryx.AgentHost.Services;
internal static class QuotaSnapshotMapper
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
};
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
public static Dictionary<string, QuotaSnapshotDto> Map(
IReadOnlyDictionary<string, AccountGetQuotaResultQuotaSnapshotsValue>? snapshots)
@@ -102,4 +99,11 @@ internal static class QuotaSnapshotMapper
return deserialized is null ? null : Map(deserialized);
}
private static JsonSerializerOptions CreateJsonOptions()
{
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
options.PropertyNameCaseInsensitive = true;
return options;
}
}
@@ -67,11 +67,9 @@ public sealed class SidecarProtocolHost
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_sessionManager = sessionManager ?? new CopilotSessionManager();
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true,
};
_jsonOptions = JsonSerialization.CreateWebOptions();
_jsonOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
_jsonOptions.PropertyNameCaseInsensitive = true;
_commandHandlers = new Dictionary<string, Func<CommandContext, Task>>(StringComparer.Ordinal)
{
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
@@ -12,6 +12,7 @@ internal static class WorkflowRequestInfoInterpreter
private const string ToolCallingActivityType = "tool-calling";
private const string CodeInterpreterToolName = "code interpreter";
private const string ImageGenerationToolName = "image generation";
private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions();
public static AgentActivityEventDto? TryCreateActivityFromRequest(
RunTurnCommandDto command,
@@ -193,8 +194,8 @@ internal static class WorkflowRequestInfoInterpreter
private static WorkflowRequestHandoffPayload? DeserializeHandoffPayload(object handoffValue)
{
string json = JsonSerializer.Serialize(handoffValue, handoffValue.GetType());
return JsonSerializer.Deserialize<WorkflowRequestHandoffPayload>(json);
string json = JsonSerializer.Serialize(handoffValue, handoffValue.GetType(), JsonOptions);
return JsonSerializer.Deserialize<WorkflowRequestHandoffPayload>(json, JsonOptions);
}
private abstract record RequestInterpretation;
@@ -0,0 +1,41 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class JsonSerializationTests
{
[Fact]
public void CreateWebOptions_UsesDefaultJsonTypeInfoResolver()
{
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
Assert.IsType<DefaultJsonTypeInfoResolver>(options.TypeInfoResolver);
}
[Fact]
public void CreateWebOptions_RoundTripsRuntimeTypedPayloads()
{
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
object payload = new TestPayload
{
Type = "describe-capabilities",
RequestId = "req-1",
};
string json = JsonSerializer.Serialize(payload, payload.GetType(), options);
TestPayload? deserialized = JsonSerializer.Deserialize<TestPayload>(json, options);
Assert.NotNull(deserialized);
Assert.Equal("describe-capabilities", deserialized.Type);
Assert.Equal("req-1", deserialized.RequestId);
}
private sealed class TestPayload
{
public string? Type { get; init; }
public string? RequestId { get; init; }
}
}
+9 -1
View File
@@ -3,6 +3,7 @@ import type { BrowserWindow as BrowserWindowType } from 'electron';
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater';
import { createMainWindow } from '@main/windows/createMainWindow';
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
@@ -12,12 +13,15 @@ const { app, BrowserWindow } = electron;
let mainWindow: BrowserWindowType | undefined;
let appService: AryxAppService | undefined;
let systemTray: SystemTray | undefined;
let autoUpdateService: AutoUpdateService | undefined;
async function bootstrap(): Promise<void> {
appService = new AryxAppService();
autoUpdateService?.dispose();
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
mainWindow = createMainWindow();
registerIpcHandlers(mainWindow, appService);
registerIpcHandlers(mainWindow, appService, autoUpdateService);
// Apply persisted theme to the title bar overlay
const workspace = await appService.loadWorkspace();
@@ -49,6 +53,8 @@ async function bootstrap(): Promise<void> {
if (!app.isPackaged) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
autoUpdateService.start();
}
app.whenReady().then(bootstrap);
@@ -73,6 +79,8 @@ app.on('activate', async () => {
});
app.on('before-quit', async () => {
autoUpdateService?.dispose();
autoUpdateService = undefined;
systemTray?.dispose();
await appService?.dispose();
});
+21 -1
View File
@@ -37,12 +37,18 @@ import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme } from '@shared/domain/tooling';
import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater';
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
import type { UpdateStatus } from '@shared/contracts/ipc';
const { ipcMain } = electron;
export function registerIpcHandlers(window: BrowserWindow, service: AryxAppService): void {
export function registerIpcHandlers(
window: BrowserWindow,
service: AryxAppService,
autoUpdateService: AutoUpdateService,
): void {
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
ipcMain.handle(ipcChannels.refreshSidecarCapabilities, () => service.refreshSidecarCapabilities());
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
@@ -96,6 +102,10 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
ipcChannels.setMinimizeToTray,
(_event, enabled: boolean) => service.setMinimizeToTray(enabled),
);
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
ipcMain.handle(ipcChannels.installUpdate, () => {
autoUpdateService.installUpdate();
});
ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) =>
service.saveMcpServer(input.server),
);
@@ -204,6 +214,16 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
);
service.on('session-event', handleNotification);
const sendUpdateStatus = (status: UpdateStatus) => {
if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.updateStatus, status);
}
};
autoUpdateService.onStatus(sendUpdateStatus);
window.webContents.on('did-finish-load', () => {
sendUpdateStatus(autoUpdateService.getStatus());
});
service.on('terminal-data', (data) => {
window.webContents.send(ipcChannels.terminalData, data);
});
+281
View File
@@ -0,0 +1,281 @@
import electronUpdater from 'electron-updater';
import type {
UpdateDownloadProgress,
UpdateStatus,
} from '@shared/contracts/ipc';
interface AutoUpdateInfoLike {
version?: string | null;
releaseDate?: string | null;
releaseNotes?: unknown;
}
interface AutoUpdateProgressLike {
bytesPerSecond: number;
percent: number;
total: number;
transferred: number;
}
type AutoUpdateListener = (...args: any[]) => void;
interface AutoUpdaterLike {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
on(event: string, listener: AutoUpdateListener): this;
removeListener(event: string, listener: AutoUpdateListener): this;
checkForUpdates(): Promise<unknown>;
quitAndInstall(): void;
}
export interface AutoUpdateScheduler {
setTimeout(callback: () => void, delayMs: number): unknown;
clearTimeout(handle: unknown): void;
setInterval(callback: () => void, delayMs: number): unknown;
clearInterval(handle: unknown): void;
}
export interface AutoUpdateServiceOptions {
isPackaged: boolean;
startupDelayMs?: number;
recheckIntervalMs?: number;
updater?: AutoUpdaterLike;
scheduler?: AutoUpdateScheduler;
}
const DEFAULT_STARTUP_DELAY_MS = 10_000;
const DEFAULT_RECHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
const defaultScheduler: AutoUpdateScheduler = {
setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
setInterval: (callback, delayMs) => globalThis.setInterval(callback, delayMs),
clearInterval: (handle) => globalThis.clearInterval(handle as ReturnType<typeof setInterval>),
};
function normalizeOptionalString(value: string | null | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeReleaseNotes(value: unknown): string | undefined {
if (typeof value === 'string') {
return normalizeOptionalString(value);
}
if (!Array.isArray(value)) {
return undefined;
}
const notes = value
.map((item) => {
if (typeof item === 'string') {
return normalizeOptionalString(item);
}
if (!item || typeof item !== 'object') {
return undefined;
}
const record = item as { note?: unknown; version?: unknown };
const version = typeof record.version === 'string' ? normalizeOptionalString(record.version) : undefined;
const note = typeof record.note === 'string' ? normalizeOptionalString(record.note) : undefined;
if (version && note) {
return `${version}\n${note}`;
}
return note ?? version;
})
.filter((entry): entry is string => Boolean(entry));
return notes.length > 0 ? notes.join('\n\n') : undefined;
}
function normalizeProgress(progress: AutoUpdateProgressLike): UpdateDownloadProgress {
return {
bytesPerSecond: progress.bytesPerSecond,
percent: progress.percent,
total: progress.total,
transferred: progress.transferred,
};
}
function createStatusFromInfo(
state: Extract<UpdateStatus['state'], 'available' | 'downloaded'>,
info: AutoUpdateInfoLike,
): UpdateStatus {
return {
state,
version: normalizeOptionalString(info.version),
releaseDate: normalizeOptionalString(info.releaseDate),
releaseNotes: normalizeReleaseNotes(info.releaseNotes),
};
}
function resolveErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return 'Unknown update error.';
}
export class AutoUpdateService {
private readonly updater: AutoUpdaterLike;
private readonly scheduler: AutoUpdateScheduler;
private readonly listeners = new Set<(status: UpdateStatus) => void>();
private status: UpdateStatus = { state: 'idle' };
private started = false;
private initialCheckHandle?: unknown;
private periodicCheckHandle?: unknown;
private pendingCheck?: Promise<UpdateStatus>;
private readonly checkingListener = () => {
this.publishStatus({ state: 'checking' });
};
private readonly availableListener = (info: AutoUpdateInfoLike) => {
this.publishStatus(createStatusFromInfo('available', info));
};
private readonly notAvailableListener = () => {
this.publishStatus({ state: 'idle' });
};
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
this.publishStatus({
...this.status,
state: 'downloading',
downloadProgress: normalizeProgress(progress),
});
};
private readonly downloadedListener = (info: AutoUpdateInfoLike) => {
this.publishStatus(createStatusFromInfo('downloaded', info));
};
private readonly errorListener = (error: unknown) => {
this.publishStatus({
...this.status,
state: 'error',
error: resolveErrorMessage(error),
});
};
constructor(private readonly options: AutoUpdateServiceOptions) {
this.updater = options.updater
?? (electronUpdater as { autoUpdater: AutoUpdaterLike }).autoUpdater;
this.scheduler = options.scheduler ?? defaultScheduler;
this.updater.autoDownload = true;
this.updater.autoInstallOnAppQuit = false;
this.updater.on('checking-for-update', this.checkingListener);
this.updater.on('update-available', this.availableListener);
this.updater.on('update-not-available', this.notAvailableListener);
this.updater.on('download-progress', this.progressListener);
this.updater.on('update-downloaded', this.downloadedListener);
this.updater.on('error', this.errorListener);
}
start(): void {
if (this.started || !this.options.isPackaged) {
return;
}
this.started = true;
this.initialCheckHandle = this.scheduler.setTimeout(() => {
void this.checkForUpdates();
}, this.options.startupDelayMs ?? DEFAULT_STARTUP_DELAY_MS);
this.periodicCheckHandle = this.scheduler.setInterval(() => {
void this.checkForUpdates();
}, this.options.recheckIntervalMs ?? DEFAULT_RECHECK_INTERVAL_MS);
}
getStatus(): UpdateStatus {
return this.cloneStatus(this.status);
}
onStatus(listener: (status: UpdateStatus) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async checkForUpdates(): Promise<UpdateStatus> {
if (!this.options.isPackaged) {
return this.getStatus();
}
if (this.pendingCheck) {
return this.pendingCheck;
}
const request = this.updater.checkForUpdates()
.catch((error) => {
this.errorListener(error);
})
.then(() => this.getStatus())
.finally(() => {
if (this.pendingCheck === request) {
this.pendingCheck = undefined;
}
});
this.pendingCheck = request;
return request;
}
installUpdate(): void {
if (this.status.state !== 'downloaded') {
return;
}
this.updater.quitAndInstall();
}
dispose(): void {
if (this.initialCheckHandle !== undefined) {
this.scheduler.clearTimeout(this.initialCheckHandle);
this.initialCheckHandle = undefined;
}
if (this.periodicCheckHandle !== undefined) {
this.scheduler.clearInterval(this.periodicCheckHandle);
this.periodicCheckHandle = undefined;
}
this.updater.removeListener('checking-for-update', this.checkingListener);
this.updater.removeListener('update-available', this.availableListener);
this.updater.removeListener('update-not-available', this.notAvailableListener);
this.updater.removeListener('download-progress', this.progressListener);
this.updater.removeListener('update-downloaded', this.downloadedListener);
this.updater.removeListener('error', this.errorListener);
this.listeners.clear();
}
private publishStatus(status: UpdateStatus): void {
this.status = this.cloneStatus(status);
for (const listener of this.listeners) {
listener(this.cloneStatus(this.status));
}
}
private cloneStatus(status: UpdateStatus): UpdateStatus {
return status.downloadProgress
? { ...status, downloadProgress: { ...status.downloadProgress } }
: { ...status };
}
}
+9
View File
@@ -28,6 +28,8 @@ const api: ElectronApi = {
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
@@ -97,6 +99,13 @@ const api: ElectronApi = {
ipcRenderer.on(ipcChannels.sessionEvent, handler);
return () => ipcRenderer.off(ipcChannels.sessionEvent, handler);
},
onUpdateStatus: (listener) => {
const handler = (_event: Electron.IpcRendererEvent, status: Parameters<typeof listener>[0]) =>
listener(status);
ipcRenderer.on(ipcChannels.updateStatus, handler);
return () => ipcRenderer.off(ipcChannels.updateStatus, handler);
},
onTrayCreateScratchpad: (listener) => {
const handler = () => listener();
+3
View File
@@ -17,6 +17,8 @@ export const ipcChannels = {
setTerminalHeight: 'settings:set-terminal-height',
setNotificationsEnabled: 'settings:set-notifications-enabled',
setMinimizeToTray: 'settings:set-minimize-to-tray',
checkForUpdates: 'app:check-for-updates',
installUpdate: 'app:install-update',
saveMcpServer: 'tooling:mcp:save',
deleteMcpServer: 'tooling:mcp:delete',
saveLspProfile: 'tooling:lsp:save',
@@ -55,6 +57,7 @@ export const ipcChannels = {
terminalExit: 'terminal:exit',
workspaceUpdated: 'workspace:updated',
sessionEvent: 'sessions:event',
updateStatus: 'app:update-status',
getQuota: 'sidecar:get-quota',
trayCreateScratchpad: 'tray:create-scratchpad',
} as const;
+21
View File
@@ -157,6 +157,24 @@ export interface SetTerminalHeightInput {
height?: number;
}
export type UpdateStatusState = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error';
export interface UpdateDownloadProgress {
bytesPerSecond: number;
percent: number;
total: number;
transferred: number;
}
export interface UpdateStatus {
state: UpdateStatusState;
version?: string;
releaseDate?: string;
releaseNotes?: string;
downloadProgress?: UpdateDownloadProgress;
error?: string;
}
export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
@@ -202,6 +220,8 @@ export interface ElectronApi {
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
setMinimizeToTray(enabled: boolean): Promise<WorkspaceState>;
checkForUpdates(): Promise<UpdateStatus>;
installUpdate(): Promise<void>;
describeTerminal(): Promise<TerminalSnapshot | undefined>;
createTerminal(): Promise<TerminalSnapshot>;
restartTerminal(): Promise<TerminalSnapshot>;
@@ -215,6 +235,7 @@ export interface ElectronApi {
onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void;
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
onUpdateStatus(listener: (status: UpdateStatus) => void): () => void;
onTrayCreateScratchpad(listener: () => void): () => void;
}
+173
View File
@@ -0,0 +1,173 @@
import { EventEmitter } from 'node:events';
import { describe, expect, test } from 'bun:test';
import { AutoUpdateService, type AutoUpdateScheduler } from '@main/services/autoUpdater';
import type { UpdateStatus } from '@shared/contracts/ipc';
class FakeUpdater extends EventEmitter {
autoDownload = false;
autoInstallOnAppQuit = true;
checkForUpdatesCalls = 0;
quitAndInstallCalls = 0;
async checkForUpdates(): Promise<void> {
this.checkForUpdatesCalls += 1;
}
quitAndInstall(): void {
this.quitAndInstallCalls += 1;
}
}
class FakeScheduler implements AutoUpdateScheduler {
readonly timeouts: Array<{ callback: () => void; delayMs: number }> = [];
readonly intervals: Array<{ callback: () => void; delayMs: number }> = [];
setTimeout(callback: () => void, delayMs: number): number {
this.timeouts.push({ callback, delayMs });
return this.timeouts.length - 1;
}
clearTimeout(handle: unknown): void {
const index = Number(handle);
if (Number.isInteger(index) && index >= 0) {
this.timeouts.splice(index, 1);
}
}
setInterval(callback: () => void, delayMs: number): number {
this.intervals.push({ callback, delayMs });
return this.intervals.length - 1;
}
clearInterval(handle: unknown): void {
const index = Number(handle);
if (Number.isInteger(index) && index >= 0) {
this.intervals.splice(index, 1);
}
}
async runTimeout(index = 0): Promise<void> {
await Promise.resolve(this.timeouts[index]?.callback());
}
async runInterval(index = 0): Promise<void> {
await Promise.resolve(this.intervals[index]?.callback());
}
}
describe('AutoUpdateService', () => {
test('does not schedule checks for unpackaged apps', async () => {
const updater = new FakeUpdater();
const scheduler = new FakeScheduler();
const service = new AutoUpdateService({ isPackaged: false, scheduler, updater });
service.start();
expect(scheduler.timeouts).toHaveLength(0);
expect(scheduler.intervals).toHaveLength(0);
expect(await service.checkForUpdates()).toEqual({ state: 'idle' });
expect(updater.checkForUpdatesCalls).toBe(0);
});
test('configures auto download and schedules startup and periodic checks', async () => {
const updater = new FakeUpdater();
const scheduler = new FakeScheduler();
const service = new AutoUpdateService({ isPackaged: true, scheduler, updater });
service.start();
expect(updater.autoDownload).toBe(true);
expect(updater.autoInstallOnAppQuit).toBe(false);
expect(scheduler.timeouts).toEqual([{ callback: expect.any(Function), delayMs: 10_000 }]);
expect(scheduler.intervals).toEqual([{ callback: expect.any(Function), delayMs: 4 * 60 * 60 * 1000 }]);
await scheduler.runTimeout();
await Promise.resolve();
await scheduler.runInterval();
await Promise.resolve();
expect(updater.checkForUpdatesCalls).toBe(2);
});
test('maps updater events into renderer-facing status snapshots', () => {
const updater = new FakeUpdater();
const service = new AutoUpdateService({ isPackaged: true, updater });
const statuses: UpdateStatus[] = [];
service.onStatus((status) => statuses.push(status));
updater.emit('checking-for-update');
updater.emit('update-available', {
version: '1.2.0',
releaseDate: '2026-03-29T00:00:00.000Z',
releaseNotes: 'Important fixes.',
});
updater.emit('download-progress', {
bytesPerSecond: 512,
percent: 25,
total: 400,
transferred: 100,
});
updater.emit('update-downloaded', {
version: '1.2.0',
releaseDate: '2026-03-29T00:00:00.000Z',
releaseNotes: 'Important fixes.',
});
expect(statuses).toEqual([
{ state: 'checking' },
{
state: 'available',
version: '1.2.0',
releaseDate: '2026-03-29T00:00:00.000Z',
releaseNotes: 'Important fixes.',
},
{
state: 'downloading',
version: '1.2.0',
releaseDate: '2026-03-29T00:00:00.000Z',
releaseNotes: 'Important fixes.',
downloadProgress: {
bytesPerSecond: 512,
percent: 25,
total: 400,
transferred: 100,
},
},
{
state: 'downloaded',
version: '1.2.0',
releaseDate: '2026-03-29T00:00:00.000Z',
releaseNotes: 'Important fixes.',
},
]);
});
test('reports updater errors and only installs once an update is downloaded', () => {
const updater = new FakeUpdater();
const service = new AutoUpdateService({ isPackaged: true, updater });
service.installUpdate();
expect(updater.quitAndInstallCalls).toBe(0);
updater.emit('error', new Error('network down'));
expect(service.getStatus()).toEqual({
state: 'error',
error: 'network down',
});
updater.emit('update-downloaded', {
version: '1.2.1',
releaseDate: '2026-03-29T00:00:00.000Z',
releaseNotes: 'Patch release.',
});
service.installUpdate();
expect(updater.quitAndInstallCalls).toBe(1);
});
});
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, test } from 'bun:test';
import {
macBundleIdentifier,
productName,
resolveReleaseTarget,
} from '../../scripts/releaseTarget';
describe('resolveReleaseTarget', () => {
test('maps Windows x64 to the expected runtime and output names', () => {
expect(resolveReleaseTarget('win32', 'x64')).toEqual({
platform: 'win32',
arch: 'x64',
platformLabel: 'windows',
dotnetRuntime: 'win-x64',
outputDirectoryName: 'Aryx-windows-x64',
archiveBaseName: 'Aryx-windows-x64',
installerAssetName: 'Aryx-windows-x64-setup.exe',
sidecarExecutableName: 'Aryx.AgentHost.exe',
packagedExecutableName: 'Aryx.exe',
});
});
test('maps macOS arm64 to the expected bundle metadata', () => {
expect(resolveReleaseTarget('darwin', 'arm64')).toEqual({
platform: 'darwin',
arch: 'arm64',
platformLabel: 'macos',
dotnetRuntime: 'osx-arm64',
outputDirectoryName: 'Aryx-macos-arm64',
archiveBaseName: 'Aryx-macos-arm64',
installerAssetName: 'Aryx-macos-arm64.dmg',
sidecarExecutableName: 'Aryx.AgentHost',
appBundleName: 'Aryx.app',
});
});
test('maps Linux x64 to the expected runtime and output names', () => {
expect(resolveReleaseTarget('linux', 'x64')).toEqual({
platform: 'linux',
arch: 'x64',
platformLabel: 'linux',
dotnetRuntime: 'linux-x64',
outputDirectoryName: 'Aryx-linux-x64',
archiveBaseName: 'Aryx-linux-x64',
installerAssetName: 'aryx-linux-x64.deb',
sidecarExecutableName: 'Aryx.AgentHost',
packagedExecutableName: 'Aryx',
});
});
test('exports the expected product metadata constants', () => {
expect(productName).toBe('Aryx');
expect(macBundleIdentifier).toBe('com.davidkaya.aryx');
});
test('rejects unsupported platforms', () => {
expect(() => resolveReleaseTarget('freebsd', 'x64')).toThrow(
'Unsupported release platform: freebsd',
);
});
test('rejects unsupported architectures', () => {
expect(() => resolveReleaseTarget('linux', 'ia32')).toThrow(
'Unsupported architecture for linux: ia32',
);
});
});