mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 13:08:36 +02:00
feat: migrate packaging and auto updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
+83
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
@@ -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}`);
|
||||
@@ -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.`);
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user