mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-28 01:52:18 +00:00
Settle the Playwright-grade reliability contract in agent-desktop-core before the Windows/Linux adapters are built, so they inherit it instead of redesigning it. Every command now observes, waits, verifies, and reports honestly instead of firing blindly. Highlights: capability-supertrait split of PlatformAdapter with not_supported() defaults; canonical role/state vocabulary with live `is --property visible`; display enumeration (`list-displays`) and honest `--screen` with scale factor; truthful Automation permission; `native_id` identity spine; window-id-first resolution; serializable `LocatorQuery` with live `find`; default-on auto-wait before every ref action; three-way `hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name precedence; typed `ActionStep` delivery tier; `ProcessState` and `APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals (`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with independent validation and a green live e2e gate (109/0), plus a head-vs-main performance comparison harness. BREAKING CHANGE: default-on auto-wait changes the timing of every previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0` restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the `APP_UNRESPONSIVE` code and process state in error details). FFI ABI major is `3` (append-only struct evolution; `wait --event` is intentionally not exposed over FFI). The legacy string clipboard API is removed in favor of typed content. `key-down`/`key-up` fail closed until daemon-owned held input exists. `close-app` verifies termination and the osascript fallback path is removed. `--text` matching is subtree containment: `find --text X --first` returns the outermost matching container.
323 lines
9.6 KiB
JavaScript
323 lines
9.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
renameSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} = require('fs');
|
|
const { dirname, isAbsolute, join } = require('path');
|
|
const { platform, arch } = require('os');
|
|
const { execFileSync } = require('child_process');
|
|
const { createHash } = require('crypto');
|
|
|
|
const projectRoot = join(__dirname, '..');
|
|
const binDir = join(projectRoot, 'bin');
|
|
const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
|
|
const version = packageJson.version;
|
|
|
|
const GITHUB_REPO = 'lahfir/agent-desktop';
|
|
const MACOS_HELPER_NAME = 'agent-desktop-macos-helper';
|
|
const MACOS_HELPER_PATH_ENV = 'AGENT_DESKTOP_MACOS_HELPER_PATH';
|
|
|
|
const TARGET_MAP = {
|
|
'darwin-arm64': 'aarch64-apple-darwin',
|
|
'darwin-x64': 'x86_64-apple-darwin',
|
|
'linux-x64': 'x86_64-unknown-linux-gnu',
|
|
'linux-arm64': 'aarch64-unknown-linux-gnu',
|
|
'win32-x64': 'x86_64-pc-windows-msvc',
|
|
};
|
|
|
|
const BINARY_NAME_MAP = {
|
|
'darwin-arm64': 'agent-desktop-darwin-arm64',
|
|
'darwin-x64': 'agent-desktop-darwin-x64',
|
|
'linux-x64': 'agent-desktop-linux-x64',
|
|
'linux-arm64': 'agent-desktop-linux-arm64',
|
|
'win32-x64': 'agent-desktop-win32-x64.exe',
|
|
};
|
|
|
|
const SUPPORTED_PLATFORMS = ['darwin'];
|
|
|
|
function log(msg) {
|
|
process.stderr.write(`agent-desktop: ${msg}\n`);
|
|
}
|
|
|
|
function trashRecoverably(path, trashCommand = 'trash') {
|
|
try {
|
|
execFileSync(trashCommand, [path], { stdio: 'pipe', timeout: 30000 });
|
|
} catch (err) {
|
|
if (!existsSync(path)) return;
|
|
const reason = err.code === 'ENOENT'
|
|
? `trash command is unavailable: ${trashCommand}`
|
|
: `trash exited with status ${err.status ?? 'unknown'}`;
|
|
log(`Could not move cleanup artifact to Trash; retained at ${path}: ${reason}`);
|
|
}
|
|
}
|
|
|
|
function getPlatformKey() {
|
|
return `${platform()}-${arch()}`;
|
|
}
|
|
|
|
function download(url, dest) {
|
|
const tmpDest = dest + '.tmp';
|
|
try {
|
|
execFileSync('curl', ['-fsSL', '--retry', '3', '--retry-delay', '2', '-o', tmpDest, url], {
|
|
stdio: 'pipe',
|
|
timeout: 60000,
|
|
});
|
|
renameSync(tmpDest, dest);
|
|
} catch (err) {
|
|
try { unlinkSync(tmpDest); } catch {}
|
|
throw new Error(`Failed to download ${url}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function verifyChecksum(filePath, expectedHash) {
|
|
return sha256(filePath) === expectedHash.toLowerCase();
|
|
}
|
|
|
|
function sha256(filePath) {
|
|
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
|
}
|
|
|
|
function checksumFor(contents, fileName) {
|
|
for (const line of contents.split('\n')) {
|
|
const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
|
|
if (match && match[2] === fileName) {
|
|
return match[1].toLowerCase();
|
|
}
|
|
}
|
|
throw new Error(`Checksum entry missing for ${fileName}`);
|
|
}
|
|
|
|
function installExecutable(source, destination) {
|
|
const temporary = `${destination}.install-${process.pid}`;
|
|
try {
|
|
writeFileSync(temporary, readFileSync(source), { mode: 0o755 });
|
|
chmodSync(temporary, 0o755);
|
|
if (sha256(source) !== sha256(temporary)) {
|
|
throw new Error(`Executable copy verification failed for ${destination}`);
|
|
}
|
|
renameSync(temporary, destination);
|
|
} finally {
|
|
try { unlinkSync(temporary); } catch {}
|
|
}
|
|
}
|
|
|
|
function customHelperPath(customBinaryPath) {
|
|
const override = process.env[MACOS_HELPER_PATH_ENV];
|
|
if (override) {
|
|
if (!isAbsolute(override)) {
|
|
throw new Error(`${MACOS_HELPER_PATH_ENV} must be an absolute path`);
|
|
}
|
|
return override;
|
|
}
|
|
return join(dirname(customBinaryPath), MACOS_HELPER_NAME);
|
|
}
|
|
|
|
function validateArchive(tarballPath) {
|
|
const listing = execFileSync('tar', ['-tzf', tarballPath], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 30000,
|
|
})
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.sort();
|
|
const expected = ['agent-desktop', MACOS_HELPER_NAME].sort();
|
|
if (JSON.stringify(listing) !== JSON.stringify(expected)) {
|
|
throw new Error(`Release archive has unexpected entries: ${listing.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
function installArchive(tarballPath, binaryPath, helperPath, trashCommand = 'trash') {
|
|
validateArchive(tarballPath);
|
|
const staging = mkdtempSync(join(binDir, '.extract-'));
|
|
try {
|
|
execFileSync('tar', ['-xzf', tarballPath, '-C', staging], {
|
|
stdio: 'pipe',
|
|
timeout: 30000,
|
|
});
|
|
const entries = readdirSync(staging).sort();
|
|
const expected = ['agent-desktop', MACOS_HELPER_NAME].sort();
|
|
if (JSON.stringify(entries) !== JSON.stringify(expected)) {
|
|
throw new Error(`Extracted archive has unexpected entries: ${entries.join(', ')}`);
|
|
}
|
|
const extractedBinary = join(staging, 'agent-desktop');
|
|
const extractedHelper = join(staging, MACOS_HELPER_NAME);
|
|
if (!lstatSync(extractedBinary).isFile() || !lstatSync(extractedHelper).isFile()) {
|
|
throw new Error('Release archive executables must be regular files');
|
|
}
|
|
installExecutable(extractedHelper, helperPath);
|
|
installExecutable(extractedBinary, binaryPath);
|
|
} finally {
|
|
trashRecoverably(staging, trashCommand);
|
|
}
|
|
}
|
|
|
|
function fixGlobalInstallBin() {
|
|
if (platform() === 'win32') return;
|
|
|
|
let npmBinDir;
|
|
try {
|
|
const prefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
npmBinDir = join(prefix, 'bin');
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const symlinkPath = join(npmBinDir, 'agent-desktop');
|
|
const platformKey = getPlatformKey();
|
|
const binaryName = BINARY_NAME_MAP[platformKey];
|
|
if (!binaryName) return;
|
|
|
|
const binaryPath = join(binDir, binaryName);
|
|
|
|
try {
|
|
const stat = lstatSync(symlinkPath);
|
|
if (!stat.isSymbolicLink()) return;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
unlinkSync(symlinkPath);
|
|
symlinkSync(binaryPath, symlinkPath);
|
|
log('Optimized: symlink points to native binary (zero overhead)');
|
|
} catch (err) {
|
|
log(`Could not optimize symlink: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function promptSkillInstall() {
|
|
const platformSkill = {
|
|
darwin: 'agent-desktop-macos',
|
|
win32: 'agent-desktop-windows',
|
|
linux: 'agent-desktop-linux',
|
|
}[platform()];
|
|
|
|
log('');
|
|
log('Claude Code skills available for agent-desktop!');
|
|
log('Install with:');
|
|
log(' claude mcp add-skill lahfir/agent-desktop');
|
|
if (platformSkill) {
|
|
log(` claude mcp add-skill lahfir/${platformSkill}`);
|
|
}
|
|
log('');
|
|
}
|
|
|
|
function main() {
|
|
if (process.env.AGENT_DESKTOP_SKIP_DOWNLOAD === '1') {
|
|
log('Skipping binary download (AGENT_DESKTOP_SKIP_DOWNLOAD=1)');
|
|
return;
|
|
}
|
|
|
|
const platformKey = getPlatformKey();
|
|
const target = TARGET_MAP[platformKey];
|
|
const binaryName = BINARY_NAME_MAP[platformKey];
|
|
|
|
if (!SUPPORTED_PLATFORMS.includes(platform()) || !target || !binaryName) {
|
|
log('agent-desktop currently supports macOS only.');
|
|
log('Windows and Linux support is coming in Phase 2.');
|
|
log(`See: https://github.com/${GITHUB_REPO}`);
|
|
return;
|
|
}
|
|
|
|
const binaryPath = join(binDir, binaryName);
|
|
const helperPath = join(binDir, MACOS_HELPER_NAME);
|
|
|
|
if (!existsSync(binDir)) {
|
|
mkdirSync(binDir, { recursive: true });
|
|
}
|
|
|
|
if (process.env.AGENT_DESKTOP_BINARY_PATH) {
|
|
const customPath = process.env.AGENT_DESKTOP_BINARY_PATH;
|
|
try {
|
|
if (!existsSync(customPath) || !lstatSync(customPath).isFile()) {
|
|
throw new Error(`binary is not a regular file: ${customPath}`);
|
|
}
|
|
const sourceHelper = customHelperPath(customPath);
|
|
if (!existsSync(sourceHelper) || !lstatSync(sourceHelper).isFile()) {
|
|
throw new Error(`macOS helper not found at ${sourceHelper}`);
|
|
}
|
|
installExecutable(sourceHelper, helperPath);
|
|
installExecutable(customPath, binaryPath);
|
|
log(`Using binary from AGENT_DESKTOP_BINARY_PATH: ${customPath}`);
|
|
fixGlobalInstallBin();
|
|
promptSkillInstall();
|
|
} catch (err) {
|
|
log(`Failed to install from AGENT_DESKTOP_BINARY_PATH: ${err.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (existsSync(binaryPath) && existsSync(helperPath)) {
|
|
chmodSync(binaryPath, 0o755);
|
|
chmodSync(helperPath, 0o755);
|
|
log(`Native executables ready: ${binaryName}, ${MACOS_HELPER_NAME}`);
|
|
fixGlobalInstallBin();
|
|
promptSkillInstall();
|
|
return;
|
|
}
|
|
|
|
const tarball = `agent-desktop-v${version}-${target}.tar.gz`;
|
|
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}`;
|
|
const tarballUrl = `${baseUrl}/${tarball}`;
|
|
const checksumsUrl = `${baseUrl}/checksums.txt`;
|
|
const tarballPath = join(binDir, tarball);
|
|
const checksumsPath = join(binDir, 'checksums.txt');
|
|
|
|
log(`Downloading native binary for ${platformKey}...`);
|
|
|
|
try {
|
|
download(tarballUrl, tarballPath);
|
|
download(checksumsUrl, checksumsPath);
|
|
const checksums = readFileSync(checksumsPath, 'utf8');
|
|
const expectedHash = checksumFor(checksums, tarball);
|
|
if (!verifyChecksum(tarballPath, expectedHash)) {
|
|
throw new Error('Checksum verification failed');
|
|
}
|
|
unlinkSync(checksumsPath);
|
|
log('Checksum verified');
|
|
|
|
installArchive(tarballPath, binaryPath, helperPath);
|
|
unlinkSync(tarballPath);
|
|
log(`Installed native executables: ${binaryName}, ${MACOS_HELPER_NAME}`);
|
|
} catch (err) {
|
|
log(`Could not download native binary: ${err.message}`);
|
|
log('');
|
|
log('Download manually from:');
|
|
log(` ${tarballUrl}`);
|
|
log(`Then place agent-desktop at: ${binaryPath}`);
|
|
log(`And ${MACOS_HELPER_NAME} at: ${helperPath}`);
|
|
|
|
try { if (existsSync(tarballPath)) unlinkSync(tarballPath); } catch {}
|
|
try { if (existsSync(checksumsPath)) unlinkSync(checksumsPath); } catch {}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
fixGlobalInstallBin();
|
|
|
|
promptSkillInstall();
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = {
|
|
checksumFor,
|
|
customHelperPath,
|
|
installArchive,
|
|
trashRecoverably,
|
|
validateArchive,
|
|
};
|