chore: finalize electron desktop migration

This commit is contained in:
Mohamed Boudra
2026-03-21 01:50:00 +07:00
parent 77fb74c188
commit 111576e2ca
169 changed files with 6463 additions and 13191 deletions
+9
View File
@@ -0,0 +1,9 @@
# Build output
dist/
release/
# Dependencies
node_modules/
# TypeScript
*.tsbuildinfo
+3
View File
@@ -0,0 +1,3 @@
# desktop
Electron desktop app for Paseo.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+44
View File
@@ -0,0 +1,44 @@
appId: sh.paseo.desktop
productName: Paseo
executableName: Paseo
directories:
output: release
files:
- dist/**/*
extraResources:
- from: ../app/dist
to: app-dist
publish:
provider: github
owner: anthropics
repo: paseo
mac:
category: public.app-category.developer-tools
icon: assets/icon.icns
extraFiles:
- from: scripts/paseo
to: MacOS/paseo
target:
- dmg
- zip
linux:
category: Development
icon: assets
extraFiles:
- from: scripts/paseo
to: paseo
target:
- AppImage
- tar.gz
win:
icon: assets/icon.ico
extraFiles:
- from: scripts/paseo.cmd
to: paseo.cmd
target:
- nsis
- zip
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
-233
View File
@@ -1,233 +0,0 @@
{
"name": "desktop",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "desktop",
"version": "0.1.3",
"license": "ISC",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.9.6",
"@tauri-apps/cli-darwin-x64": "2.9.6",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
"@tauri-apps/cli-linux-arm64-musl": "2.9.6",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-musl": "2.9.6",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
"@tauri-apps/cli-win32-x64-msvc": "2.9.6"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
"integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
"integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
"integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
"integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
"integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
"integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
"integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
"integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
"integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
"integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+18 -9
View File
@@ -2,17 +2,26 @@
"name": "@getpaseo/desktop",
"version": "0.1.30",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
"scripts": {
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime && npm run validate:managed-runtime",
"dev": "tauri dev",
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs",
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
"tauri": "tauri"
"build": "npm --prefix ../.. run build:daemon && npm run build:main && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.30",
"@getpaseo/server": "0.1.30",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.0.3",
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"wait-on": "8.0.5"
}
}
@@ -1,590 +0,0 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const cacheRoot = path.join(desktopRoot, ".cache", "managed-runtime");
const packageVersion = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
).version;
const workspaces = [
{ name: "@getpaseo/relay", root: path.join(repoRoot, "packages", "relay") },
{ name: "@getpaseo/server", root: path.join(repoRoot, "packages", "server") },
{ name: "@getpaseo/cli", root: path.join(repoRoot, "packages", "cli") },
];
const ripgrepPlatformDirMap = {
darwin: {
arm64: "arm64-darwin",
x64: "x64-darwin",
},
linux: {
arm64: "arm64-linux",
x64: "x64-linux",
},
win32: {
arm64: "arm64-win32",
x64: "x64-win32",
},
};
async function rmSafe(target) {
await fs.rm(target, { recursive: true, force: true });
}
async function ensureDir(target) {
await fs.mkdir(target, { recursive: true });
}
async function copyDir(source, target) {
await ensureDir(path.dirname(target));
await fs.cp(source, target, { recursive: true, dereference: true, force: true });
}
async function copyFile(source, target, mode) {
await ensureDir(path.dirname(target));
await fs.copyFile(source, target);
if (mode != null) {
await fs.chmod(target, mode);
}
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function removeIfExists(target) {
if (await pathExists(target)) {
await rmSafe(target);
}
}
async function readToolVersions() {
const raw = await fs.readFile(path.join(repoRoot, ".tool-versions"), "utf8");
const entries = new Map();
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const [tool, ...rest] = trimmed.split(/\s+/);
if (!tool || rest.length === 0) {
continue;
}
entries.set(tool, rest.join(" "));
}
return entries;
}
function resolveNodeVersion(toolVersions) {
const raw = toolVersions.get("nodejs");
if (!raw) {
throw new Error("Missing nodejs entry in .tool-versions.");
}
const version = raw.trim().replace(/^v/, "");
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Unsupported nodejs version in .tool-versions: ${raw}`);
}
return version;
}
function resolveNodeArtifact(version) {
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const archMap = {
arm64: "arm64",
x64: "x64",
};
const platform = platformMap[process.platform];
const arch = archMap[process.arch];
if (!platform || !arch) {
throw new Error(`Managed runtime is not implemented for ${process.platform}-${process.arch}.`);
}
const baseName = `node-v${version}-${platform}-${arch}`;
const extension = process.platform === "win32" ? "zip" : process.platform === "linux" ? "tar.xz" : "tar.gz";
return {
version,
baseName,
archiveName: `${baseName}.${extension}`,
extension,
downloadUrl: `https://nodejs.org/dist/v${version}/${baseName}.${extension}`,
checksumsUrl: `https://nodejs.org/dist/v${version}/SHASUMS256.txt`,
};
}
async function downloadFile(url, target) {
await ensureDir(path.dirname(target));
runCommand("curl", [
"--fail",
"--location",
"--retry",
"3",
"--silent",
"--show-error",
"--output",
target,
url,
]);
}
async function sha256File(target) {
const hash = createHash("sha256");
hash.update(await fs.readFile(target));
return hash.digest("hex");
}
async function computeRuntimeContentHash(input) {
const hash = createHash("sha256");
hash.update(`packageVersion:${packageVersion}\n`);
hash.update(`nodeVersion:${input.nodeVersion}\n`);
hash.update(`platform:${process.platform}\n`);
hash.update(`arch:${process.arch}\n`);
for (const tarball of input.tarballs) {
hash.update(`workspace:${tarball.name}\n`);
hash.update(`filename:${path.basename(tarball.path)}\n`);
hash.update(`sha256:${await sha256File(tarball.path)}\n`);
}
return hash.digest("hex").slice(0, 12);
}
async function ensureNodeArchive(nodeArtifact) {
const versionCacheRoot = path.join(cacheRoot, `node-v${nodeArtifact.version}`);
const archivePath = path.join(versionCacheRoot, nodeArtifact.archiveName);
const checksumsPath = path.join(versionCacheRoot, "SHASUMS256.txt");
if (!(await pathExists(archivePath))) {
await downloadFile(nodeArtifact.downloadUrl, archivePath);
}
if (!(await pathExists(checksumsPath))) {
await downloadFile(nodeArtifact.checksumsUrl, checksumsPath);
}
const checksums = await fs.readFile(checksumsPath, "utf8");
const expectedLine = checksums
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.endsWith(` ${nodeArtifact.archiveName}`));
if (!expectedLine) {
throw new Error(`Missing checksum for ${nodeArtifact.archiveName} in SHASUMS256.txt`);
}
const expectedSha = expectedLine.split(/\s+/)[0];
const actualSha = await sha256File(archivePath);
if (actualSha !== expectedSha) {
throw new Error(
`Checksum mismatch for ${nodeArtifact.archiveName}: expected ${expectedSha}, got ${actualSha}`
);
}
return archivePath;
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "pipe",
encoding: "utf8",
...options,
});
if (result.status !== 0 || result.error) {
throw new Error(
[
`Command failed: ${command} ${args.join(" ")}`,
result.error ? String(result.error) : null,
result.signal ? `signal: ${result.signal}` : null,
result.status != null ? `exit code: ${result.status}` : null,
result.stdout?.trim(),
result.stderr?.trim(),
]
.filter(Boolean)
.join("\n")
);
}
return result;
}
function withManagedRuntimeNodeOptions(env = process.env) {
const existing = env.NODE_OPTIONS?.trim() ?? "";
if (existing.includes("--max-old-space-size")) {
return env;
}
const nodeOptions = existing ? `${existing} --max-old-space-size=8192` : "--max-old-space-size=8192";
return {
...env,
NODE_OPTIONS: nodeOptions,
};
}
async function extractNodeDistribution(archivePath, nodeArtifact) {
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
if (nodeArtifact.extension === "zip") {
runCommand("powershell", [
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractionRoot.replace(/'/g, "''")}' -Force`,
]);
} else {
const tarFlag = nodeArtifact.extension === "tar.xz" ? "-xJf" : "-xzf";
runCommand("tar", [tarFlag, archivePath, "-C", extractionRoot]);
}
return {
extractionRoot,
extractedRoot: path.join(extractionRoot, nodeArtifact.baseName),
};
}
async function ensureWorkspaceBuilds() {
const requiredPaths = [
path.join(repoRoot, "packages", "relay", "dist"),
path.join(repoRoot, "packages", "server", "dist"),
path.join(repoRoot, "packages", "cli", "dist"),
];
for (const requiredPath of requiredPaths) {
if (!(await pathExists(requiredPath))) {
throw new Error(
`Managed runtime build is missing required path: ${requiredPath}. Run the daemon build first.`
);
}
}
}
async function packWorkspace(packageRoot, tarballRoot) {
await ensureDir(tarballRoot);
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("Missing npm_execpath while building managed runtime.");
}
const result = runCommand(process.execPath, [
npmExecPath,
"pack",
"--json",
"--pack-destination",
tarballRoot,
], {
cwd: packageRoot,
env: withManagedRuntimeNodeOptions(process.env),
});
const [{ filename }] = JSON.parse(result.stdout.trim());
if (!filename) {
throw new Error(`npm pack did not produce a filename for ${packageRoot}`);
}
return path.join(tarballRoot, filename);
}
async function installPackedWorkspaces(runtimeRoot, bundledNodeRoot, tarballs) {
const nodeExecutable = process.platform === "win32"
? path.join(bundledNodeRoot, "node.exe")
: path.join(bundledNodeRoot, "bin", "node");
const npmCli = process.platform === "win32"
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
const install = spawnSync(nodeExecutable, [
npmCli,
"install",
"--include=optional",
"--omit=dev",
"--no-package-lock",
"--no-save",
...tarballs,
], {
cwd: runtimeRoot,
stdio: "inherit",
env: {
...process.env,
npm_config_audit: "false",
npm_config_fund: "false",
},
});
if (install.status !== 0) {
throw new Error(
`Managed runtime dependency install failed with exit code ${install.status ?? 1}.`
);
}
}
function resolveSherpaNativePackageName() {
const sherpaPlatformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const sherpaPlatform = sherpaPlatformMap[process.platform];
if (!sherpaPlatform) {
throw new Error(`Managed runtime sherpa package is not implemented for ${process.platform}.`);
}
return `sherpa-onnx-${sherpaPlatform}-${process.arch}`;
}
async function ensureSherpaNativePackage(runtimeRoot, bundledNodeRoot) {
const packageName = resolveSherpaNativePackageName();
const packageDir = path.join(runtimeRoot, "node_modules", packageName);
if (await pathExists(packageDir)) {
return;
}
const sherpaNodePackageJson = JSON.parse(
await fs.readFile(
path.join(runtimeRoot, "node_modules", "sherpa-onnx-node", "package.json"),
"utf8"
)
);
const packageVersion = sherpaNodePackageJson.version;
if (!packageVersion) {
throw new Error("Missing version in sherpa-onnx-node package.json.");
}
const nodeExecutable = process.platform === "win32"
? path.join(bundledNodeRoot, "node.exe")
: path.join(bundledNodeRoot, "bin", "node");
const npmCli = process.platform === "win32"
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
const install = spawnSync(nodeExecutable, [
npmCli,
"install",
"--include=optional",
"--omit=dev",
"--no-package-lock",
"--no-save",
`${packageName}@${packageVersion}`,
], {
cwd: runtimeRoot,
stdio: "inherit",
env: {
...process.env,
npm_config_audit: "false",
npm_config_fund: "false",
},
});
if (install.status !== 0) {
throw new Error(
`Managed runtime sherpa package install failed with exit code ${install.status ?? 1}.`
);
}
}
async function writeRuntimePackageJson(runtimeRoot) {
await fs.writeFile(
path.join(runtimeRoot, "package.json"),
JSON.stringify(
{
name: "paseo-managed-runtime",
private: true,
version: packageVersion,
},
null,
2
) + "\n",
"utf8"
);
}
async function pruneChildrenExcept(parent, keepNames) {
if (!(await pathExists(parent))) {
return;
}
const entries = await fs.readdir(parent);
await Promise.all(
entries
.filter((entry) => !keepNames.has(entry))
.map((entry) => rmSafe(path.join(parent, entry)))
);
}
async function pruneNodeDistribution(runtimeRoot) {
const nodeRoot = path.join(runtimeRoot, "node");
await Promise.all([
removeIfExists(path.join(nodeRoot, "include")),
removeIfExists(path.join(nodeRoot, "share")),
removeIfExists(path.join(nodeRoot, "CHANGELOG.md")),
]);
const nodeGlobalModulesRoot = path.join(nodeRoot, "lib", "node_modules");
if (await pathExists(nodeGlobalModulesRoot)) {
const keepNames = new Set(["npm"]);
await pruneChildrenExcept(nodeGlobalModulesRoot, keepNames);
}
}
async function pruneOnnxRuntime(runtimeRoot) {
const onnxRoot = path.join(runtimeRoot, "node_modules", "onnxruntime-node", "bin", "napi-v6");
if (!(await pathExists(onnxRoot))) {
return;
}
if (process.platform === "darwin") {
await removeIfExists(path.join(onnxRoot, "linux"));
await removeIfExists(path.join(onnxRoot, "win32"));
await pruneChildrenExcept(path.join(onnxRoot, "darwin"), new Set([process.arch]));
return;
}
if (process.platform === "linux") {
await removeIfExists(path.join(onnxRoot, "darwin"));
await removeIfExists(path.join(onnxRoot, "win32"));
await pruneChildrenExcept(path.join(onnxRoot, "linux"), new Set([process.arch]));
const archDir = path.join(onnxRoot, "linux", process.arch);
if (await pathExists(archDir)) {
const entries = await fs.readdir(archDir);
await Promise.all(
entries
.filter((name) => name.includes("cuda") || name.includes("tensorrt"))
.map((name) => fs.rm(path.join(archDir, name), { force: true }))
);
}
return;
}
if (process.platform === "win32") {
await removeIfExists(path.join(onnxRoot, "darwin"));
await removeIfExists(path.join(onnxRoot, "linux"));
await pruneChildrenExcept(path.join(onnxRoot, "win32"), new Set([process.arch]));
return;
}
}
async function pruneNodePty(runtimeRoot) {
const prebuildsRoot = path.join(runtimeRoot, "node_modules", "node-pty", "prebuilds");
const keepName = `${process.platform}-${process.arch}`;
await pruneChildrenExcept(prebuildsRoot, new Set([keepName]));
if (process.platform !== "win32") {
await removeIfExists(path.join(runtimeRoot, "node_modules", "node-pty", "third_party"));
}
}
async function pruneClaudeAgentSdk(runtimeRoot) {
const vendorRoot = path.join(
runtimeRoot,
"node_modules",
"@anthropic-ai",
"claude-agent-sdk",
"vendor"
);
const ripgrepRoot = path.join(vendorRoot, "ripgrep");
const keepName = ripgrepPlatformDirMap[process.platform]?.[process.arch];
if (keepName) {
await pruneChildrenExcept(ripgrepRoot, new Set(["COPYING", keepName]));
}
const treeSitterBashRoot = path.join(vendorRoot, "tree-sitter-bash");
if (keepName) {
await pruneChildrenExcept(treeSitterBashRoot, new Set([keepName]));
}
}
async function pruneManagedRuntime(runtimeRoot) {
await Promise.all([
pruneNodeDistribution(runtimeRoot),
pruneOnnxRuntime(runtimeRoot),
pruneNodePty(runtimeRoot),
pruneClaudeAgentSdk(runtimeRoot),
]);
}
async function main() {
await ensureWorkspaceBuilds();
const toolVersions = await readToolVersions();
const nodeVersion = resolveNodeVersion(toolVersions);
const nodeArtifact = resolveNodeArtifact(nodeVersion);
await rmSafe(resourcesRoot);
await ensureDir(resourcesRoot);
const archivePath = await ensureNodeArchive(nodeArtifact);
const { extractionRoot, extractedRoot } = await extractNodeDistribution(archivePath, nodeArtifact);
const tarballRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-pack-"));
try {
const tarballs = [];
for (const workspace of workspaces) {
tarballs.push({
name: workspace.name,
path: await packWorkspace(workspace.root, tarballRoot),
});
}
const runtimeContentHash = await computeRuntimeContentHash({
nodeVersion,
tarballs,
});
const runtimeId =
`${packageVersion}-node-${nodeVersion}-${process.platform}-${process.arch}-${runtimeContentHash}`;
const runtimeRoot = path.join(resourcesRoot, runtimeId);
await ensureDir(runtimeRoot);
await copyDir(extractedRoot, path.join(runtimeRoot, "node"));
await writeRuntimePackageJson(runtimeRoot);
await installPackedWorkspaces(
runtimeRoot,
path.join(runtimeRoot, "node"),
tarballs.map((entry) => entry.path)
);
await ensureSherpaNativePackage(runtimeRoot, path.join(runtimeRoot, "node"));
await pruneManagedRuntime(runtimeRoot);
const nodeRelativePath = process.platform === "win32"
? path.join("node", "node.exe")
: path.join("node", "bin", "node");
const manifest = {
runtimeId,
runtimeVersion: packageVersion,
platform: process.platform,
arch: process.arch,
createdAt: new Date().toISOString(),
nodeRelativePath,
cliEntrypointRelativePath: path.join(
"node_modules",
"@getpaseo",
"cli",
"dist",
"index.js"
),
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
serverRunnerRelativePath: path.join(
"node_modules",
"@getpaseo",
"server",
"dist",
"scripts",
"daemon-runner.js"
),
};
await fs.writeFile(
path.join(runtimeRoot, "runtime-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
"utf8"
);
await fs.writeFile(
path.join(resourcesRoot, "current-runtime.json"),
JSON.stringify(
{
runtimeId,
runtimeVersion: packageVersion,
relativeRoot: runtimeId,
},
null,
2
) + "\n",
"utf8"
);
if (process.platform !== "win32") {
await fs.chmod(path.join(runtimeRoot, nodeRelativePath), 0o755);
}
} finally {
await rmSafe(extractionRoot);
await rmSafe(tarballRoot);
}
}
await main();
@@ -1,807 +0,0 @@
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import WebSocket from "ws";
import { createClientChannel } from "@getpaseo/relay/e2ee";
const execFileAsync = promisify(execFile);
const COMMAND_TIMEOUT_MS = 120_000;
const repoRoot = fileURLToPath(new URL("../../..", import.meta.url));
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const relayRoot = path.join(repoRoot, "packages", "relay");
const desktopPackageJson = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
);
const currentRuntimeVersion = desktopPackageJson.version;
const currentRuntimePointer = JSON.parse(
await fs.readFile(
path.join(desktopRoot, "src-tauri", "resources", "managed-runtime", "current-runtime.json"),
"utf8"
)
);
const currentRuntimeId = currentRuntimePointer.runtimeId;
function resolvePackagedBinary() {
const targetRoot = process.env.PASEO_MANAGED_SMOKE_RUST_TARGET
? path.join(
desktopRoot,
"src-tauri",
"target",
process.env.PASEO_MANAGED_SMOKE_RUST_TARGET,
"release"
)
: path.join(desktopRoot, "src-tauri", "target", "release");
if (process.platform === "darwin") {
return path.join(
targetRoot,
"bundle",
"macos",
"Paseo.app",
"Contents",
"MacOS",
"Paseo"
);
}
if (process.platform === "linux") {
return path.join(
targetRoot,
"bundle",
"appimage",
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
);
}
if (process.platform === "win32") {
return path.join(
targetRoot,
"Paseo.exe"
);
}
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function snapshotTree(root) {
if (!(await pathExists(root))) {
return [];
}
const entries = [];
async function walk(current) {
const stat = await fs.stat(current);
const relative = path.relative(root, current) || ".";
entries.push({
relative,
kind: stat.isDirectory() ? "dir" : "file",
size: stat.size,
mtimeMs: stat.mtimeMs,
});
if (!stat.isDirectory()) {
return;
}
const children = await fs.readdir(current);
children.sort();
for (const child of children) {
await walk(path.join(current, child));
}
}
await walk(root);
entries.sort((left, right) => left.relative.localeCompare(right.relative));
return entries;
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function waitForChildExit(child, timeoutMs) {
if (!child || child.exitCode !== null || child.killed) {
return;
}
await new Promise((resolve) => {
const timeout = setTimeout(resolve, timeoutMs);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
async function execFileWithTimeout(command, args, options, label) {
try {
return await execFileAsync(command, args, {
timeout: COMMAND_TIMEOUT_MS,
...options,
});
} catch (error) {
if (error?.killed && error?.signal === "SIGTERM") {
const renderedArgs = args.map((arg) => JSON.stringify(arg)).join(" ");
throw new Error(
`Timed out after ${COMMAND_TIMEOUT_MS}ms running ${label}: ${JSON.stringify(command)} ${renderedArgs}`
);
}
throw error;
}
}
async function runBinary(binaryPath, args, env) {
const { stdout, stderr } = await execFileWithTimeout(binaryPath, args, {
env,
cwd: repoRoot,
maxBuffer: 10 * 1024 * 1024,
}, "packaged desktop binary");
const trimmed = stdout.trim();
return {
stdout,
stderr,
json: trimmed ? JSON.parse(trimmed) : null,
};
}
async function terminateChildProcess(child, label) {
if (!child || child.exitCode !== null || child.killed) {
return;
}
if (process.platform === "win32" && typeof child.pid === "number") {
try {
await execFileWithTimeout(
"taskkill",
["/pid", String(child.pid), "/T", "/F"],
{ maxBuffer: 1024 * 1024 },
`${label} taskkill`
);
} catch {}
await waitForChildExit(child, 5_000);
return;
}
try {
child.kill("SIGTERM");
} catch {}
await waitForChildExit(child, 5_000);
if (child.exitCode === null && !child.killed) {
try {
child.kill("SIGKILL");
} catch {}
await waitForChildExit(child, 5_000);
}
}
async function runWorkspaceCli(args, env) {
const { stdout, stderr } = await execFileWithTimeout(
process.execPath,
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
},
maxBuffer: 10 * 1024 * 1024,
},
"workspace CLI"
);
let json = null;
if (stdout.trim()) {
try {
json = JSON.parse(stdout.trim());
} catch {
json = null;
}
}
return { stdout, stderr, json };
}
async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
const { stdout, stderr } = await execFileWithTimeout(
path.join(runtimeRoot, manifest.nodeRelativePath),
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
PASEO_HOME: managedHome,
},
maxBuffer: 10 * 1024 * 1024,
},
"bundled runtime CLI"
);
return { stdout, stderr };
}
async function resolvePackagedRuntimeStatus(binaryPath) {
const binaryDir = path.dirname(binaryPath);
const resourceDirs = [
path.join(binaryDir, "resources"),
binaryDir,
path.join(binaryDir, "..", "Resources"),
path.join(binaryDir, "..", "resources"),
];
let bundledRoot = null;
for (const resourceDir of resourceDirs) {
for (const candidate of [
path.join(resourceDir, "resources", "managed-runtime"),
path.join(resourceDir, "managed-runtime"),
]) {
if (await pathExists(candidate)) {
bundledRoot = candidate;
break;
}
}
if (bundledRoot) {
break;
}
}
if (!bundledRoot) {
throw new Error("Managed runtime resources are not bundled with this desktop build.");
}
const pointer = JSON.parse(
await fs.readFile(path.join(bundledRoot, "current-runtime.json"), "utf8")
);
const runtimeRoot = path.join(bundledRoot, pointer.relativeRoot);
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
return {
runtimeId: manifest.runtimeId,
runtimeVersion: manifest.runtimeVersion,
runtimeRoot,
};
}
async function readDaemonStatus(home, env) {
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
return result.json ?? {};
}
async function pidListeningOnPort(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`], {
maxBuffer: 1024 * 1024,
});
const pid = Number.parseInt(stdout.trim().split("\n")[0] ?? "", 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
async function getAvailablePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate an ephemeral test port.")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
function buildRelayWebSocketUrl({ endpoint, serverId, role }) {
const url = new URL(`ws://${endpoint}/ws`);
url.searchParams.set("serverId", serverId);
url.searchParams.set("role", role);
url.searchParams.set("v", "2");
return url.toString();
}
function parseOfferUrlFromCommandOutput(stdout) {
const match = stdout.match(/https?:\/\/\S+#offer=\S+/);
if (!match) {
throw new Error(`Failed to find pairing URL in output: ${stdout}`);
}
return match[0];
}
function decodeOfferFromFragmentUrl(url) {
const marker = "#offer=";
const index = url.indexOf(marker);
if (index === -1) {
throw new Error(`Pairing URL is missing ${marker}: ${url}`);
}
const encoded = url.slice(index + marker.length);
const raw = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(raw);
if (offer?.v !== 2 || typeof offer?.serverId !== "string" || typeof offer?.daemonPublicKeyB64 !== "string") {
throw new Error(`Unexpected relay offer payload: ${raw}`);
}
return offer;
}
async function waitForRelayWebSocketReady(endpoint, timeoutMs) {
await waitFor(async () => {
const probeServerId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const url = buildRelayWebSocketUrl({
endpoint,
serverId: probeServerId,
role: "server",
});
const opened = await new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
ws.terminate();
resolve(false);
}, 5_000);
ws.once("open", () => {
clearTimeout(timer);
ws.close(1000, "probe");
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
assert.equal(opened, true);
}, timeoutMs, "relay websocket endpoint to accept connections");
}
async function connectViaRelay(endpoint, offer) {
const stableClientId = `cid-managed-smoke-${Date.now().toString(36)}`;
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint,
serverId: offer.serverId,
role: "client",
})
);
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for managed relay pong."));
}, 20_000);
const transport = {
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
ws.on("message", (data) => {
transport.onmessage?.(typeof data === "string" ? data : data.toString());
});
ws.on("close", (code, reason) => {
transport.onclose?.(code, reason.toString());
});
ws.on("error", (error) => {
transport.onerror?.(error);
});
ws.on("open", async () => {
try {
let pingSent = false;
let channelRef = null;
const channel = await createClientChannel(transport, offer.daemonPublicKeyB64, {
onmessage: (data) => {
try {
const payload = typeof data === "string" ? JSON.parse(data) : data;
if (payload?.type === "welcome") {
if (!pingSent && channelRef) {
pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" }));
}
return;
}
if (payload?.type === "pong") {
clearTimeout(timeout);
resolve(payload);
ws.close();
}
} catch (error) {
clearTimeout(timeout);
reject(error);
}
},
onerror: (error) => {
clearTimeout(timeout);
reject(error);
},
});
channelRef = channel;
await channel.send(
JSON.stringify({
type: "hello",
clientId: stableClientId,
clientType: "cli",
protocolVersion: 1,
})
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
});
}
function assertNoForbiddenPathsOrPorts(value, forbidden) {
const text =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
for (const candidate of forbidden) {
assert.ok(
!text.includes(candidate),
`Unexpected forbidden managed smoke reference: ${candidate}`
);
}
}
async function ensurePackagedArtifact(binaryPath) {
if (process.env.PASEO_MANAGED_SMOKE_SKIP_BUILD === "1") {
return;
}
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("npm_execpath is required to build the packaged desktop artifact during smoke tests.");
}
try {
await execFileWithTimeout(process.execPath, [npmExecPath, "run", "build"], {
cwd: desktopRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
}, "desktop smoke artifact build");
} catch (error) {
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
const signingBlocked =
combined.includes("TAURI_SIGNING_PRIVATE_KEY") &&
(await pathExists(binaryPath));
if (!signingBlocked) {
throw error;
}
console.warn(
"[managed-smoke] continuing after Tauri updater signing failure because the packaged app artifact was already produced"
);
}
}
async function waitFor(assertion, timeoutMs, label) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
return await assertion();
} catch {
await sleep(250);
}
}
throw new Error(`Timed out waiting for ${label}`);
}
function logStep(label) {
currentStepLabel = label;
currentStepStartedAt = Date.now();
console.log(`\n[managed-smoke] ${label}`);
}
const packagedBinary = resolvePackagedBinary();
await ensurePackagedArtifact(packagedBinary);
if (!(await pathExists(packagedBinary))) {
throw new Error(`Packaged desktop artifact not found: ${packagedBinary}`);
}
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-desktop-smoke-"));
const testRoot = path.join(tmpRoot, "managed-test-root");
const fakeHome = path.join(tmpRoot, "fake-home");
const fakePaseoHome = path.join(fakeHome, ".paseo");
const cliScratchHome = path.join(tmpRoot, "cli-scratch-home");
const externalHome = path.join(tmpRoot, "external-daemon-home");
const externalPort = await getAvailablePort();
const externalEndpoint = `127.0.0.1:${externalPort}`;
const relayPort = await getAvailablePort();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const managedRuntimeDir = path.join(testRoot, "runtime");
await fs.mkdir(testRoot, { recursive: true });
await fs.mkdir(fakePaseoHome, { recursive: true });
await fs.mkdir(cliScratchHome, { recursive: true });
await fs.mkdir(externalHome, { recursive: true });
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
const smokeStartedAt = Date.now();
const smokeDeadlineMs = 15 * 60 * 1000;
let currentStepLabel = "initializing";
let currentStepStartedAt = smokeStartedAt;
const heartbeat = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - smokeStartedAt) / 1000);
const currentStepSeconds = Math.floor((Date.now() - currentStepStartedAt) / 1000);
console.log(
`[managed-smoke] heartbeat elapsed=${elapsedSeconds}s currentStep=${JSON.stringify(currentStepLabel)} stepElapsed=${currentStepSeconds}s`
);
if (Date.now() - smokeStartedAt > smokeDeadlineMs) {
console.error(
`[managed-smoke] FAIL exceeded ${smokeDeadlineMs / 1000}s overall deadline during ${JSON.stringify(currentStepLabel)}`
);
process.exit(1);
}
}, 30_000);
const managedEnv = {
...process.env,
HOME: fakeHome,
PASEO_HOME: cliScratchHome,
PASEO_DESKTOP_TEST_ROOT: testRoot,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_RELAY_ENABLED: "true",
PASEO_RELAY_ENDPOINT: relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
PASEO_APP_BASE_URL: "https://app.paseo.test",
PASEO_PRIMARY_LAN_IP: "192.168.1.12",
CI: "true",
};
let externalPid = null;
let startedTemporaryExternalDaemon = false;
let relayProcess = null;
let runtimeStatus = null;
let managedStart = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("npm_execpath is required to launch wrangler during managed desktop smoke tests.");
}
try {
logStep(`Starting isolated local relay on ${relayEndpoint}`);
relayProcess = spawn(process.execPath, [
npmExecPath,
"exec",
"--",
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
], {
cwd: relayRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
relayProcess.stdout?.on("data", (chunk) => {
process.stdout.write(`[managed-smoke relay] ${chunk}`);
});
relayProcess.stderr?.on("data", (chunk) => {
process.stderr.write(`[managed-smoke relay] ${chunk}`);
});
await waitFor(async () => {
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
}, 60_000, "relay HTTP endpoint to start");
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
startedTemporaryExternalDaemon = true;
await runWorkspaceCli(
["start", "--home", externalHome, "--listen", externalEndpoint, "--no-relay"],
managedEnv
);
const externalStatus = await waitFor(
async () => {
const status = await readDaemonStatus(externalHome, managedEnv);
assert.equal(status.status, "running");
assert.ok(typeof status.pid === "number" && status.pid > 0, "expected external daemon pid");
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
return status;
},
15_000,
"external daemon to start"
);
externalPid = externalStatus.pid;
assert.ok(externalPid, "external daemon pid should be present");
logStep("Resolving bundled runtime from packaged desktop artifact");
runtimeStatus = await resolvePackagedRuntimeStatus(packagedBinary);
assert.equal(runtimeStatus.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.runtimeVersion, currentRuntimeVersion);
assert.match(
runtimeStatus.runtimeRoot,
new RegExp(escapeForRegExp(currentRuntimeId)),
"runtime status should point into the bundled runtime selected by current-runtime.json"
);
assert.equal(
runtimeStatus.runtimeRoot.startsWith(testRoot),
false,
"runtime status should not resolve into managed app data"
);
assertNoForbiddenPathsOrPorts(runtimeStatus, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
const managedBootstrap = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
cliScratchHome,
["start", "--json"],
managedEnv
);
managedStart = (managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null) ?? await waitFor(
async () => {
const status = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
cliScratchHome,
["daemon", "status", "--json"],
managedEnv
);
const json = JSON.parse(status.stdout.trim());
assert.equal(json.status, "running");
assert.ok(json.pid, "managed daemon pid should exist");
assert.ok(json.serverId, "managed daemon should expose a server id");
return json;
},
10_000,
"managed daemon bootstrap status"
);
assert.equal(managedStart.status, "running");
assert.ok(managedStart.pid, "managed daemon pid should exist");
assert.ok(managedStart.serverId, "managed daemon should expose a server id");
assert.ok(managedStart.home, "managed daemon should expose its home directory");
assert.ok(managedStart.listen, "managed daemon should expose its listen target");
assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
const managedPid = managedStart.pid;
logStep("Verifying the managed daemon stays alive after the packaged command exits");
await sleep(1_500);
const persistedManagedStatus = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
)
).stdout.trim()
);
assert.equal(persistedManagedStatus.pid, managedPid);
assert.equal(persistedManagedStatus.status, "running");
assert.equal(persistedManagedStatus.serverId, managedStart.serverId);
assertNoForbiddenPathsOrPorts(persistedManagedStatus, forbiddenManagedReferences);
logStep("Verifying bundled CLI access without installing a shim");
const cliVersion = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const bundledCliStatus = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
);
const bundledCliDaemonStatus = JSON.parse(bundledCliStatus.stdout.trim());
assert.equal(bundledCliDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(bundledCliDaemonStatus, forbiddenManagedReferences);
logStep("Verifying relay connectivity still works after the desktop command has exited");
const relayPairing = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "pair"],
managedEnv
);
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
assert.deepEqual(relayPong, { type: "pong" });
logStep("Re-running managed start without spawning duplicate daemons");
const managedRestartless = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["start", "--json"],
managedEnv
)
).stdout.trim()
);
assert.equal(managedRestartless.pid, managedPid);
logStep("Verifying managed and external daemons coexist");
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
const externalPidAfter = externalStatusAfter.pid ?? null;
assert.equal(externalStatusAfter.status, "running");
assert.equal(externalPidAfter, externalPid);
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
const managedStatus = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
)
).stdout.trim()
);
assert.ok(managedStatus.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus, forbiddenManagedReferences);
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore);
await fs.writeFile(
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
JSON.stringify(
{
runtimeStatus,
managedBootstrap: managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null,
managedStart,
persistedManagedStatus,
managedRestartless,
bundledCliDaemonStatus,
relayEndpoint,
relayOfferUrl,
relayPong,
externalEndpoint,
externalPid,
externalPidAfter,
managedStatus,
},
null,
2
) + "\n",
"utf8"
);
console.log(`\n[managed-smoke] PASS (${testRoot})`);
} finally {
clearInterval(heartbeat);
try {
if (runtimeStatus?.runtimeRoot) {
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart?.home ?? cliScratchHome,
["daemon", "stop", "--json"],
managedEnv
);
}
} catch {}
try {
if (startedTemporaryExternalDaemon) {
await runWorkspaceCli(["daemon", "stop", "--home", externalHome, "--json"]);
}
} catch {}
try {
await terminateChildProcess(relayProcess, "local relay");
} catch {}
}
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
APP_EXECUTABLE="${SCRIPT_DIR}/Paseo"
if [ -d "${SCRIPT_DIR}/../Resources" ]; then
RESOURCES_DIR="${SCRIPT_DIR}/../Resources"
elif [ -d "${SCRIPT_DIR}/resources" ]; then
RESOURCES_DIR="${SCRIPT_DIR}/resources"
else
echo "Unable to locate Paseo resources directory next to ${SCRIPT_DIR}" >&2
exit 1
fi
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
if [ ! -x "${APP_EXECUTABLE}" ]; then
echo "Bundled Paseo executable not found at ${APP_EXECUTABLE}" >&2
exit 1
fi
if [ ! -f "${CLI_ENTRY}" ]; then
echo "Bundled Paseo CLI entrypoint not found at ${CLI_ENTRY}" >&2
exit 1
fi
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"
+19
View File
@@ -0,0 +1,19 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
set "APP_EXECUTABLE=%SCRIPT_DIR%Paseo.exe"
set "CLI_ENTRY=%SCRIPT_DIR%resources\app.asar\node_modules\@getpaseo\cli\dist\index.js"
if not exist "%APP_EXECUTABLE%" (
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
exit /b 1
)
if not exist "%CLI_ENTRY%" (
echo Bundled Paseo CLI entrypoint not found at %CLI_ENTRY% 1>&2
exit /b 1
)
set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
@@ -1,144 +0,0 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const signingIdentity = process.env.APPLE_SIGNING_IDENTITY;
if (process.platform !== "darwin") {
throw new Error("sign-managed-runtime-macos.mjs can only run on macOS.");
}
if (!signingIdentity) {
throw new Error("APPLE_SIGNING_IDENTITY is required to sign the managed runtime.");
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function walkFiles(root) {
const files = [];
async function walk(current) {
const stat = await fs.stat(current);
if (stat.isDirectory()) {
const children = await fs.readdir(current);
children.sort();
for (const child of children) {
await walk(path.join(current, child));
}
return;
}
files.push(current);
}
await walk(root);
return files;
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "pipe",
encoding: "utf8",
...options,
});
if (result.status !== 0) {
throw new Error(
[
`Command failed: ${command} ${args.join(" ")}`,
result.stdout?.trim(),
result.stderr?.trim(),
]
.filter(Boolean)
.join("\n")
);
}
return result.stdout.trim();
}
function getFileKind(target) {
return run("file", ["-b", target]);
}
function isMachO(fileKind) {
return fileKind.includes("Mach-O");
}
function needsHardenedRuntime(fileKind) {
return fileKind.includes("executable");
}
function extractEntitlements(file) {
const result = spawnSync("codesign", ["-d", "--entitlements", "-", "--xml", file], {
stdio: "pipe",
encoding: "utf8",
});
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
return result.stdout;
}
async function main() {
const pointer = JSON.parse(
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
);
const runtimeRoot = path.join(resourcesRoot, pointer.relativeRoot);
if (!(await pathExists(runtimeRoot))) {
throw new Error(`Managed runtime root does not exist: ${runtimeRoot}`);
}
const files = await walkFiles(runtimeRoot);
const signTargets = [];
for (const file of files) {
const fileKind = getFileKind(file);
if (!isMachO(fileKind)) {
continue;
}
signTargets.push({
file,
needsRuntime: needsHardenedRuntime(fileKind),
});
}
signTargets.sort((left, right) => left.file.localeCompare(right.file));
for (const target of signTargets) {
let entitlementsFile = null;
if (target.needsRuntime) {
const entitlements = extractEntitlements(target.file);
if (entitlements) {
entitlementsFile = `${target.file}.entitlements.plist`;
await fs.writeFile(entitlementsFile, entitlements, "utf8");
}
}
const args = [
"--force",
"--sign",
signingIdentity,
"--timestamp",
];
if (target.needsRuntime) {
args.push("--options", "runtime");
if (entitlementsFile) {
args.push("--entitlements", entitlementsFile);
}
}
args.push(target.file);
console.log(`[managed-runtime-sign] ${path.relative(repoRoot, target.file)}`);
run("codesign", args);
if (entitlementsFile) {
await fs.unlink(entitlementsFile);
}
}
}
await main();
@@ -1,82 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const desktopRoot = fileURLToPath(new URL("..", import.meta.url));
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const desktopPackageJson = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
);
const expectedVersion = desktopPackageJson.version;
const currentRuntime = JSON.parse(
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
);
assert.equal(currentRuntime.runtimeVersion, expectedVersion, "current-runtime.json version mismatch");
assert.ok(currentRuntime.runtimeId, "current-runtime.json missing runtimeId");
assert.ok(currentRuntime.relativeRoot, "current-runtime.json missing relativeRoot");
const runtimeRoot = path.join(resourcesRoot, currentRuntime.relativeRoot);
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
assert.equal(manifest.runtimeId, currentRuntime.runtimeId, "manifest runtimeId mismatch");
assert.equal(manifest.runtimeVersion, expectedVersion, "manifest version mismatch");
assert.ok(manifest.nodeRelativePath, "manifest missing nodeRelativePath");
assert.ok(manifest.cliEntrypointRelativePath, "manifest missing cliEntrypointRelativePath");
assert.ok(manifest.serverRunnerRelativePath, "manifest missing serverRunnerRelativePath");
const nodeBinary = path.join(runtimeRoot, manifest.nodeRelativePath);
await fs.access(nodeBinary).catch(() => {
throw new Error(`Bundled Node binary not found: ${nodeBinary}`);
});
const cliEntry = path.join(runtimeRoot, manifest.cliEntrypointRelativePath);
await fs.access(cliEntry).catch(() => {
throw new Error(`CLI entrypoint not found: ${cliEntry}`);
});
const serverRunner = path.join(runtimeRoot, manifest.serverRunnerRelativePath);
await fs.access(serverRunner).catch(() => {
throw new Error(`Server runner not found: ${serverRunner}`);
});
const runtimePackageJson = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "package.json"), "utf8")
);
assert.equal(runtimePackageJson.version, expectedVersion, "runtime package.json version mismatch");
for (const pkg of ["@getpaseo/relay", "@getpaseo/server", "@getpaseo/cli"]) {
const pkgDir = path.join(runtimeRoot, "node_modules", ...pkg.split("/"));
await fs.access(pkgDir).catch(() => {
throw new Error(`Missing bundled dependency: ${pkg} (expected at ${pkgDir})`);
});
}
const sherpaPlatformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const sherpaPlatform = sherpaPlatformMap[manifest.platform];
assert.ok(
sherpaPlatform,
`Unsupported sherpa platform mapping for managed runtime validation: ${manifest.platform}`
);
const sherpaNativePackage = `sherpa-onnx-${sherpaPlatform}-${manifest.arch}`;
const sherpaNativePackageDir = path.join(runtimeRoot, "node_modules", sherpaNativePackage);
await fs.access(sherpaNativePackageDir).catch(() => {
throw new Error(
`Missing bundled native speech dependency: ${sherpaNativePackage} (expected at ${sherpaNativePackageDir})`
);
});
console.log(`[validate-managed-runtime] PASS`);
console.log(` runtimeId: ${manifest.runtimeId}`);
console.log(` version: ${expectedVersion}`);
console.log(` platform: ${manifest.platform}`);
console.log(` arch: ${manifest.arch}`);
console.log(` sherpaNativePackage: ${sherpaNativePackage}`);
@@ -0,0 +1,332 @@
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { chromium } from "playwright";
const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
const REQUIRED_DESKTOP_KEYS = [
"invoke",
"events",
"window",
"dialog",
"notification",
"opener",
];
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
async function captureScreenshot(page, fileName) {
const filePath = path.join(OUTPUT_DIR, fileName);
await page.screenshot({ path: filePath, fullPage: true });
return filePath;
}
async function findAppPage(browser) {
for (let attempt = 0; attempt < 30; attempt += 1) {
for (const context of browser.contexts()) {
for (const page of context.pages()) {
if (page.url().includes(APP_URL_FRAGMENT) && !page.url().startsWith("devtools://")) {
return page;
}
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Unable to find Electron app page for ${APP_URL_FRAGMENT}`);
}
async function main() {
await ensureDir(OUTPUT_DIR);
const browser = await chromium.connectOverCDP(CDP_URL);
const page = await findAppPage(browser);
const consoleMessages = [];
const results = [];
page.on("console", (message) => {
consoleMessages.push({
type: message.type(),
text: message.text(),
});
});
page.on("pageerror", (error) => {
consoleMessages.push({
type: "pageerror",
text: String(error),
});
});
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(1000);
if (!page.url().endsWith("/welcome")) {
await page.goto(`http://${APP_URL_FRAGMENT}/welcome`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(1000);
}
const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png");
const desktopDetection = await page.evaluate(() => {
const bridge = window.paseoDesktop;
const keys = bridge && typeof bridge === "object" ? Object.keys(bridge) : [];
const keyTypes = bridge && typeof bridge === "object"
? Object.fromEntries(
Object.entries(bridge).map(([key, value]) => [key, typeof value])
)
: {};
return {
exists: Boolean(bridge && typeof bridge === "object"),
keys,
keyTypes,
platform: bridge?.platform ?? null,
};
});
const hasExpectedDesktopShape =
desktopDetection.exists &&
REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key));
results.push({
check: "desktop-detection",
pass: hasExpectedDesktopShape,
details: desktopDetection,
screenshot: welcomeScreenshot,
});
const desktopStatus = await page.evaluate(() => window.paseoDesktop.invoke("desktop_daemon_status"));
assert(
typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0,
"desktop_daemon_status did not return a serverId"
);
const serverId = desktopStatus.serverId.trim();
await page.evaluate((nextServerId) => {
window.location.href = `/h/${nextServerId}/settings`;
}, serverId);
await page.waitForURL(new RegExp(`/h/${escapeRegExp(serverId)}/settings$`), {
timeout: 30_000,
});
await page.getByText("Daemon management", { exact: true }).waitFor({
timeout: 30_000,
});
const settingsScreenshot = await captureScreenshot(page, "02-settings-page.png");
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first();
const menuToggle = page.locator('[data-testid="menu-button"]').first();
if (
(await sidebarSettingsButton.isVisible().catch(() => false)) &&
(await menuToggle.isVisible().catch(() => false))
) {
await menuToggle.click();
await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(500);
}
const dragRegionCheck = await page.evaluate(() => {
const nodes = Array.from(document.querySelectorAll("*"));
const annotationId = "electron-verify-drag-style";
const existingAnnotation = document.getElementById(annotationId);
existingAnnotation?.remove();
const annotationStyle = document.createElement("style");
annotationStyle.id = annotationId;
annotationStyle.textContent = `
[data-electron-verify-drag="true"] {
outline: 3px solid #ff4d4f !important;
outline-offset: -3px !important;
}
`;
document.head.appendChild(annotationStyle);
function isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
}
function summarizeText(element) {
return (element.textContent ?? "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
}
const regions = [];
let candidate = null;
for (const element of nodes) {
if (!(element instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(element);
const appRegion =
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
if (appRegion !== "drag" || !isVisible(element)) {
continue;
}
const rect = element.getBoundingClientRect();
const text = summarizeText(element);
const info = {
tagName: element.tagName.toLowerCase(),
text,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
paddingTop: Number.parseFloat(style.paddingTop || "0"),
};
regions.push(info);
const looksLikeHeader =
rect.top < 180 &&
rect.height >= 40 &&
(text.includes("Settings") || text.includes("Sessions"));
if (!candidate && looksLikeHeader) {
candidate = { ...info };
element.setAttribute("data-electron-verify-drag", "true");
}
}
if (!candidate && regions.length > 0) {
candidate = regions
.slice()
.sort((left, right) => left.top - right.top)[0];
for (const element of nodes) {
if (!(element instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(element);
const appRegion =
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
if (appRegion !== "drag" || !isVisible(element)) {
continue;
}
const rect = element.getBoundingClientRect();
if (
Math.abs(rect.top - candidate.top) < 1 &&
Math.abs(rect.left - candidate.left) < 1 &&
Math.abs(rect.width - candidate.width) < 1 &&
Math.abs(rect.height - candidate.height) < 1
) {
element.setAttribute("data-electron-verify-drag", "true");
break;
}
}
}
return {
count: regions.length,
candidate,
regions: regions.slice(0, 10),
};
});
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
const dragRegionPassed =
dragRegionCheck.count > 0 &&
Boolean(dragRegionCheck.candidate) &&
dragRegionCheck.candidate.top < 180;
results.push({
check: "drag-regions",
pass: dragRegionPassed,
details: dragRegionCheck,
screenshot: dragScreenshot,
});
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
const isMac = process.platform === "darwin";
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
const trafficLightPaddingPassed = !isMac
? true
: typeof observedPaddingLeft === "number" &&
observedPaddingLeft >= 78 &&
observedPaddingLeft <= 110;
results.push({
check: "traffic-light-padding",
pass: trafficLightPaddingPassed,
details: {
platform: process.platform,
observedPaddingLeft,
expectedApproximatePaddingLeft: 78,
candidate: dragRegionCheck.candidate,
},
screenshot: trafficLightScreenshot,
});
const daemonManagementVisible = await Promise.all([
page.getByText("Built-in daemon", { exact: true }).isVisible(),
page.getByText("Daemon management", { exact: true }).isVisible(),
page.getByRole("button", { name: "Restart daemon" }).first().isVisible(),
]).then((values) => values.every(Boolean));
const daemonManagementScreenshot = await captureScreenshot(
page,
"05-settings-daemon-management.png"
);
results.push({
check: "settings-daemon-management",
pass: daemonManagementVisible,
details: {
route: page.url(),
serverId,
desktopStatus,
},
screenshot: daemonManagementScreenshot,
});
const desktopDetectionScreenshot = await captureScreenshot(
page,
"06-desktop-detection.png"
);
results[0].screenshot = desktopDetectionScreenshot;
const report = {
cdpUrl: CDP_URL,
outputDir: OUTPUT_DIR,
pageUrl: page.url(),
desktopStatus,
results,
consoleMessages,
};
const reportPath = path.join(OUTPUT_DIR, "report.json");
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
const failedChecks = results.filter((result) => !result.pass);
console.log(JSON.stringify(report, null, 2));
await browser.close();
if (failedChecks.length > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
-4
View File
@@ -1,4 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
[package]
name = "paseo"
version = "0.1.30"
description = "Paseo Desktop"
authors = ["moboudra"]
license = "MIT"
repository = "https://github.com/getpaseo/paseo"
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "paseo_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "Paseo"
path = "src/main.rs"
[build-dependencies]
cc = "1"
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
base64 = "0.22"
dirs = "6"
dunce = "1"
futures-util = "0.3"
http = "1"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-websocket = "2"
tokio = { version = "1", features = ["net", "sync"] }
tokio-tungstenite = "0.24"
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ApplePressAndHoldEnabled</key>
<false/>
<key>CFBundleIdentifier</key>
<string>dev.paseo.desktop</string>
<key>CFBundleName</key>
<string>Paseo</string>
<key>CFBundleShortVersionString</key>
<string>0.1.30</string>
<key>CFBundleVersion</key>
<string>0.1.30</string>
<key>NSMicrophoneUsageDescription</key>
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
</dict>
</plist>
-17
View File
@@ -1,17 +0,0 @@
fn main() {
#[cfg(target_os = "macos")]
{
println!("cargo:rerun-if-changed=macos/paseo_notifications.h");
println!("cargo:rerun-if-changed=macos/paseo_notifications.m");
cc::Build::new()
.file("macos/paseo_notifications.m")
.flag("-fobjc-arc")
.compile("paseo_notifications");
println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=UserNotifications");
}
tauri_build::build()
}
@@ -1,25 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"remote": {
"urls": [
"http://localhost:8081/*",
"http://127.0.0.1:8081/*"
]
},
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-badge-count",
"core:window:allow-toggle-maximize",
"dialog:default",
"notification:default",
"opener:default",
"updater:default",
"websocket:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

@@ -1,11 +0,0 @@
!macro NSIS_HOOK_POSTINSTALL
CreateDirectory "$LOCALAPPDATA\Microsoft\WinGet\Links"
FileOpen $0 "$LOCALAPPDATA\Microsoft\WinGet\Links\paseo.cmd" w
FileWrite $0 "@echo off$\r$\n"
FileWrite $0 '"$INSTDIR\Paseo.exe" %*$\r$\n'
FileClose $0
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
Delete "$LOCALAPPDATA\Microsoft\WinGet\Links\paseo.cmd"
!macroend
@@ -1,12 +0,0 @@
#import <stdbool.h>
typedef void (*PaseoNotificationClickCallback)(const char* payload_json);
bool paseo_notifications_initialize(PaseoNotificationClickCallback callback, char** error_out);
bool paseo_notifications_send(
const char* title,
const char* body,
const char* payload_json,
char** error_out
);
void paseo_notifications_free_string(char* value);
@@ -1,176 +0,0 @@
#import "paseo_notifications.h"
#import <dispatch/dispatch.h>
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
@interface PaseoNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
@property(nonatomic, assign) PaseoNotificationClickCallback clickCallback;
@end
@implementation PaseoNotificationDelegate
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
didReceiveNotificationResponse:(UNNotificationResponse*)response
withCompletionHandler:(void (^)(void))completionHandler {
if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier] &&
self.clickCallback != NULL) {
id payload = response.notification.request.content.userInfo[@"paseoPayloadJson"];
if ([payload isKindOfClass:[NSString class]]) {
self.clickCallback([(NSString*)payload UTF8String]);
} else {
self.clickCallback(NULL);
}
}
[center removeDeliveredNotificationsWithIdentifiers:@[
response.notification.request.identifier,
]];
completionHandler();
}
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
willPresentNotification:(UNNotification*)notification
withCompletionHandler:
(void (^)(UNNotificationPresentationOptions options))completionHandler {
if (@available(macOS 11.0, *)) {
completionHandler(
UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList
);
return;
}
completionHandler((UNNotificationPresentationOptions)0);
}
@end
static PaseoNotificationDelegate* paseoNotificationDelegate = nil;
static BOOL paseoNotificationsAvailable = NO;
static void paseo_run_on_main_sync(dispatch_block_t block) {
if ([NSThread isMainThread]) {
block();
return;
}
dispatch_sync(dispatch_get_main_queue(), block);
}
static char* paseo_strdup(NSString* value) {
if (value == nil) {
return NULL;
}
const char* utf8 = [value UTF8String];
if (utf8 == NULL) {
return NULL;
}
size_t length = strlen(utf8);
char* buffer = malloc(length + 1);
if (buffer == NULL) {
return NULL;
}
memcpy(buffer, utf8, length + 1);
return buffer;
}
static BOOL paseo_is_bundled_app_host(void) {
NSURL* bundleURL = [[NSBundle mainBundle] bundleURL];
NSString* pathExtension = [[bundleURL pathExtension] lowercaseString];
return [pathExtension isEqualToString:@"app"];
}
bool paseo_notifications_initialize(PaseoNotificationClickCallback callback, char** error_out) {
@autoreleasepool {
paseo_run_on_main_sync(^{
if (paseoNotificationDelegate == nil) {
paseoNotificationDelegate = [[PaseoNotificationDelegate alloc] init];
paseoNotificationsAvailable = paseo_is_bundled_app_host();
if (paseoNotificationsAvailable) {
[[UNUserNotificationCenter currentNotificationCenter]
setDelegate:paseoNotificationDelegate];
}
}
paseoNotificationDelegate.clickCallback = callback;
});
if (error_out != NULL) {
*error_out = NULL;
}
return true;
}
}
bool paseo_notifications_send(
const char* title,
const char* body,
const char* payload_json,
char** error_out
) {
@autoreleasepool {
if (paseoNotificationDelegate == nil) {
if (error_out != NULL) {
*error_out = paseo_strdup(@"Notification bridge is not initialized.");
}
return false;
}
if (!paseoNotificationsAvailable) {
if (error_out != NULL) {
*error_out = NULL;
}
return true;
}
NSString* notificationTitle =
[NSString stringWithUTF8String:(title != NULL ? title : "")];
NSString* notificationBody =
(body != NULL && body[0] != '\0') ? [NSString stringWithUTF8String:body] : nil;
NSString* payloadString =
(payload_json != NULL && payload_json[0] != '\0')
? [NSString stringWithUTF8String:payload_json]
: nil;
NSString* identifier = [[NSUUID UUID] UUIDString];
dispatch_async(dispatch_get_main_queue(), ^{
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.title = notificationTitle;
if (notificationBody != nil) {
content.body = notificationBody;
}
if (payloadString != nil) {
content.userInfo = @{
@"paseoPayloadJson" : payloadString,
};
}
UNNotificationRequest* request =
[UNNotificationRequest requestWithIdentifier:identifier
content:content
trigger:nil];
[[UNUserNotificationCenter currentNotificationCenter]
addNotificationRequest:request
withCompletionHandler:^(NSError* _Nullable error) {
if (error != nil) {
NSLog(@"[PaseoNotifications] Failed to schedule notification: %@",
error.localizedDescription);
}
}];
});
if (error_out != NULL) {
*error_out = NULL;
}
return true;
}
}
void paseo_notifications_free_string(char* value) {
if (value != NULL) {
free(value);
}
}
@@ -1 +0,0 @@
@@ -1,280 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{AppHandle, Wry};
pub const DESKTOP_NOTIFICATION_CLICK_EVENT: &str = "desktop-notification-click";
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopNotificationInput {
pub title: String,
pub body: Option<String>,
pub data: Option<Value>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DesktopNotificationClickPayload {
data: Option<Value>,
}
#[cfg(target_os = "macos")]
mod platform {
use super::{
AppHandle, DesktopNotificationClickPayload, DesktopNotificationInput,
DESKTOP_NOTIFICATION_CLICK_EVENT, Value, Wry,
};
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::sync::{Mutex, OnceLock};
use tauri::Emitter;
#[link(name = "paseo_notifications", kind = "static")]
unsafe extern "C" {
fn paseo_notifications_initialize(
callback: extern "C" fn(*const c_char),
error_out: *mut *mut c_char,
) -> bool;
fn paseo_notifications_send(
title: *const c_char,
body: *const c_char,
payload_json: *const c_char,
error_out: *mut *mut c_char,
) -> bool;
fn paseo_notifications_free_string(value: *mut c_char);
}
static MACOS_NOTIFICATION_APP: OnceLock<Mutex<Option<AppHandle<Wry>>>> = OnceLock::new();
static MACOS_NOTIFICATION_INIT: OnceLock<Result<(), String>> = OnceLock::new();
fn app_slot() -> &'static Mutex<Option<AppHandle<Wry>>> {
MACOS_NOTIFICATION_APP.get_or_init(|| Mutex::new(None))
}
fn read_owned_error_message(error_ptr: *mut c_char) -> String {
if error_ptr.is_null() {
return "Unknown macOS notification error".to_string();
}
let message = unsafe { CStr::from_ptr(error_ptr) }
.to_string_lossy()
.into_owned();
unsafe {
paseo_notifications_free_string(error_ptr);
}
message
}
extern "C" fn handle_notification_click(payload_json: *const c_char) {
let data = if payload_json.is_null() {
None
} else {
let raw = unsafe { CStr::from_ptr(payload_json) }.to_string_lossy();
serde_json::from_str::<Value>(&raw).ok()
};
let payload = DesktopNotificationClickPayload { data };
let Ok(guard) = app_slot().lock() else {
return;
};
let Some(app) = guard.clone() else {
return;
};
if let Err(error) = app.emit(DESKTOP_NOTIFICATION_CLICK_EVENT, payload) {
log::warn!("failed to emit desktop notification click event: {error}");
}
}
fn ensure_notification_bridge() -> Result<(), String> {
MACOS_NOTIFICATION_INIT
.get_or_init(|| {
let mut error_ptr: *mut c_char = std::ptr::null_mut();
let ok = unsafe {
paseo_notifications_initialize(handle_notification_click, &mut error_ptr)
};
if ok {
Ok(())
} else {
Err(read_owned_error_message(error_ptr))
}
})
.clone()
}
pub fn send_notification(
app: AppHandle<Wry>,
input: DesktopNotificationInput,
) -> Result<(), String> {
{
let mut guard = app_slot()
.lock()
.map_err(|_| "Failed to lock macOS notification app handle".to_string())?;
*guard = Some(app);
}
ensure_notification_bridge()?;
let title = CString::new(input.title)
.map_err(|_| "Desktop notification title contains interior NUL byte".to_string())?;
let body = CString::new(input.body.unwrap_or_default())
.map_err(|_| "Desktop notification body contains interior NUL byte".to_string())?;
let payload_json = CString::new(
input
.data
.map(|value| value.to_string())
.unwrap_or_default(),
)
.map_err(|_| "Desktop notification payload contains interior NUL byte".to_string())?;
let mut error_ptr: *mut c_char = std::ptr::null_mut();
let ok = unsafe {
paseo_notifications_send(
title.as_ptr(),
body.as_ptr(),
payload_json.as_ptr(),
&mut error_ptr,
)
};
if ok {
Ok(())
} else {
Err(read_owned_error_message(error_ptr))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::{Duration, Instant};
extern "C" fn noop_notification_click(_payload_json: *const c_char) {}
static CLICK_TEST_SENDER: std::sync::OnceLock<mpsc::Sender<Option<String>>> =
std::sync::OnceLock::new();
extern "C" fn capture_notification_click(payload_json: *const c_char) {
let payload = if payload_json.is_null() {
None
} else {
Some(
unsafe { CStr::from_ptr(payload_json) }
.to_string_lossy()
.into_owned(),
)
};
if let Some(sender) = CLICK_TEST_SENDER.get() {
let _ = sender.send(payload);
}
}
#[test]
#[ignore = "requires a running Cocoa app main queue; verify through the desktop app"]
fn native_notification_send_returns_without_waiting_for_click() {
let mut error_ptr: *mut c_char = std::ptr::null_mut();
let initialized = unsafe {
paseo_notifications_initialize(noop_notification_click, &mut error_ptr)
};
assert!(
initialized,
"bridge initialization failed: {}",
read_owned_error_message(error_ptr)
);
let title = CString::new("Paseo notification smoke test").expect("valid title");
let started_at = Instant::now();
let sent = unsafe {
paseo_notifications_send(
title.as_ptr(),
std::ptr::null(),
std::ptr::null(),
&mut error_ptr,
)
};
assert!(
sent,
"native send failed: {}",
read_owned_error_message(error_ptr)
);
assert!(
started_at.elapsed() < Duration::from_secs(2),
"native send unexpectedly blocked for {:?}",
started_at.elapsed()
);
}
#[test]
#[ignore = "manual smoke test; click the macOS notification within 60 seconds"]
fn native_notification_click_callback_roundtrip() {
let (tx, rx) = mpsc::channel();
let _ = CLICK_TEST_SENDER.set(tx);
let mut error_ptr: *mut c_char = std::ptr::null_mut();
let initialized = unsafe {
paseo_notifications_initialize(capture_notification_click, &mut error_ptr)
};
assert!(
initialized,
"bridge initialization failed: {}",
read_owned_error_message(error_ptr)
);
let title = CString::new("Paseo manual click test").expect("valid title");
let body = CString::new("Click this notification to verify the callback path.")
.expect("valid body");
let payload_json =
CString::new(r#"{"kind":"manual-smoke","source":"codex"}"#).expect("valid payload");
let sent = unsafe {
paseo_notifications_send(
title.as_ptr(),
body.as_ptr(),
payload_json.as_ptr(),
&mut error_ptr,
)
};
assert!(
sent,
"native send failed: {}",
read_owned_error_message(error_ptr)
);
eprintln!("Notification sent. Click it within 60 seconds.");
let payload = rx
.recv_timeout(Duration::from_secs(60))
.expect("timed out waiting for notification click");
assert_eq!(payload.as_deref(), Some(r#"{"kind":"manual-smoke","source":"codex"}"#));
}
}
}
#[cfg(not(target_os = "macos"))]
mod platform {
use super::{AppHandle, DesktopNotificationInput, Wry};
use tauri_plugin_notification::NotificationExt;
pub fn send_notification(
app: AppHandle<Wry>,
input: DesktopNotificationInput,
) -> Result<(), String> {
let mut notification = app.notification().builder().title(&input.title);
if let Some(body) = &input.body {
notification = notification.body(body);
}
notification
.show()
.map_err(|error| format!("Failed to send desktop notification: {error}"))?;
Ok(())
}
}
#[tauri::command]
pub async fn send_desktop_notification(
app: AppHandle<Wry>,
input: DesktopNotificationInput,
) -> Result<(), String> {
platform::send_notification(app, input)
}
-717
View File
@@ -1,717 +0,0 @@
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(target_os = "macos")]
use tauri::menu::AboutMetadata;
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
use tauri::{AppHandle, Manager, WebviewWindow};
use tauri_plugin_updater::UpdaterExt;
mod desktop_notifications;
mod runtime_manager;
use desktop_notifications::send_desktop_notification;
use runtime_manager::{
cli_symlink_instructions, close_local_daemon_transport, managed_daemon_logs,
managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
open_local_daemon_transport, restart_managed_daemon, run_managed_cli_from_current_process,
send_local_daemon_transport_message, start_managed_daemon, stop_managed_daemon,
update_managed_daemon_tcp_settings, LocalTransportState,
};
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100);
fn get_zoom_factor() -> f64 {
ZOOM_LEVEL.load(Ordering::Relaxed) as f64 / 100.0
}
fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
let clamped = factor.clamp(0.5, 3.0);
ZOOM_LEVEL.store((clamped * 100.0) as u64, Ordering::Relaxed);
let _ = webview.set_zoom(clamped);
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DaemonUpdateCommandResult {
exit_code: i32,
stdout: String,
stderr: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AppUpdateCheckResult {
has_update: bool,
current_version: String,
latest_version: Option<String>,
body: Option<String>,
date: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AppUpdateInstallResult {
installed: bool,
version: Option<String>,
message: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalDaemonVersionResult {
version: Option<String>,
error: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AttachmentFileResult {
path: String,
byte_size: u64,
}
fn is_ignored_gui_launch_arg(arg: &str) -> bool {
arg.starts_with("-psn_")
}
fn parse_cli_passthrough_args_from_argv(args: &[String]) -> Option<Vec<String>> {
let effective = args
.iter()
.skip(1)
.filter(|arg| !is_ignored_gui_launch_arg(arg))
.cloned()
.collect::<Vec<_>>();
(!effective.is_empty()).then_some(effective)
}
pub fn try_run_pre_tauri_mode() -> Option<i32> {
let args = std::env::args().collect::<Vec<_>>();
let Some(cli_args) = parse_cli_passthrough_args_from_argv(&args) else {
return None;
};
Some(
run_managed_cli_from_current_process(cli_args).unwrap_or_else(|error| {
eprintln!("{error}");
1
}),
)
}
fn shell_command(script: &str) -> Command {
#[cfg(unix)]
{
let shell = std::env::var("SHELL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "/bin/zsh".to_string());
let mut cmd = Command::new(shell);
cmd.arg("-lc").arg(script);
cmd
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut cmd = Command::new("cmd.exe");
cmd.arg("/C").arg(script);
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd
}
}
fn execute_local_daemon_version() -> LocalDaemonVersionResult {
#[cfg(unix)]
let script = r#"if command -v paseo >/dev/null 2>&1; then
paseo --version
else
echo "paseo command not found in PATH" >&2
exit 127
fi"#;
#[cfg(windows)]
let script = r#"where paseo >nul 2>&1 && paseo --version || (echo paseo command not found in PATH >&2 & exit /b 127)"#;
match shell_command(script).output() {
Ok(output) => {
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
if version.is_empty() {
LocalDaemonVersionResult {
version: None,
error: Some("paseo --version returned empty output".to_string()),
}
} else {
LocalDaemonVersionResult {
version: Some(version),
error: None,
}
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
LocalDaemonVersionResult {
version: None,
error: Some(if stderr.is_empty() {
format!(
"paseo --version exited with code {}",
output.status.code().unwrap_or(1)
)
} else {
stderr
}),
}
}
}
Err(error) => LocalDaemonVersionResult {
version: None,
error: Some(format!("Failed to run version check: {error}")),
},
}
}
fn execute_local_daemon_update() -> DaemonUpdateCommandResult {
#[cfg(unix)]
let script = r#"if command -v paseo >/dev/null 2>&1; then
paseo daemon update
else
echo "paseo command not found in PATH. Ensure Paseo CLI is installed for this user." >&2
exit 127
fi"#;
#[cfg(windows)]
let script = r#"where paseo >nul 2>&1 && paseo daemon update || (echo paseo command not found in PATH. Ensure Paseo CLI is installed for this user. >&2 & exit /b 127)"#;
match shell_command(script).output() {
Ok(output) => DaemonUpdateCommandResult {
exit_code: output.status.code().unwrap_or(1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
},
Err(error) => DaemonUpdateCommandResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("Failed to run daemon update command: {error}"),
},
}
}
#[tauri::command]
async fn get_local_daemon_version() -> LocalDaemonVersionResult {
tauri::async_runtime::spawn_blocking(execute_local_daemon_version)
.await
.unwrap_or_else(|error| LocalDaemonVersionResult {
version: None,
error: Some(format!("Version check task failed: {error}")),
})
}
#[tauri::command]
async fn run_local_daemon_update() -> DaemonUpdateCommandResult {
tauri::async_runtime::spawn_blocking(execute_local_daemon_update)
.await
.unwrap_or_else(|error| DaemonUpdateCommandResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("Daemon update task failed: {error}"),
})
}
#[tauri::command]
async fn check_app_update(app: AppHandle) -> Result<AppUpdateCheckResult, String> {
let current_version = app.package_info().version.to_string();
let updater = app
.updater()
.map_err(|error| format!("Failed to initialize updater: {error}"))?;
let update = updater
.check()
.await
.map_err(|error| format!("Failed to check for updates: {error}"))?;
if let Some(update) = update {
return Ok(AppUpdateCheckResult {
has_update: true,
current_version,
latest_version: Some(update.version.to_string()),
body: update.body,
date: update.date.map(|date| date.to_string()),
});
}
Ok(AppUpdateCheckResult {
has_update: false,
current_version,
latest_version: None,
body: None,
date: None,
})
}
#[tauri::command]
async fn install_app_update(app: AppHandle) -> Result<AppUpdateInstallResult, String> {
let updater = app
.updater()
.map_err(|error| format!("Failed to initialize updater: {error}"))?;
let update = updater
.check()
.await
.map_err(|error| format!("Failed to check for updates: {error}"))?;
let Some(update) = update else {
return Ok(AppUpdateInstallResult {
installed: false,
version: None,
message: "No update is currently available.".to_string(),
});
};
let version = update.version.to_string();
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|error| format!("Failed to download and install update: {error}"))?;
Ok(AppUpdateInstallResult {
installed: true,
version: Some(version),
message: "Update installed. Restart Paseo to finish applying it.".to_string(),
})
}
fn resolve_attachment_dir(app: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|error| format!("Failed to resolve app data directory: {error}"))?;
let attachment_dir = app_data_dir.join("paseo-desktop-attachments");
fs::create_dir_all(&attachment_dir)
.map_err(|error| format!("Failed to create attachment directory: {error}"))?;
Ok(attachment_dir)
}
fn normalize_extension(extension: Option<String>) -> String {
let raw = extension
.unwrap_or_default()
.trim()
.trim_matches('.')
.to_string();
if raw.is_empty() {
String::new()
} else {
format!(".{raw}")
}
}
fn validate_attachment_id(attachment_id: &str) -> Result<(), String> {
if attachment_id.is_empty() {
return Err("Attachment ID cannot be empty.".to_string());
}
if !attachment_id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
return Err("Attachment ID contains invalid characters.".to_string());
}
Ok(())
}
fn clear_existing_attachment_files(
attachment_dir: &Path,
attachment_id: &str,
) -> Result<(), String> {
let id_prefix = format!("{attachment_id}.");
let entries = fs::read_dir(attachment_dir)
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
for entry in entries {
let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
if file_name == attachment_id || file_name.starts_with(&id_prefix) {
fs::remove_file(&path)
.map_err(|error| format!("Failed to remove prior attachment file: {error}"))?;
}
}
Ok(())
}
fn build_attachment_path(attachment_dir: &Path, attachment_id: &str, extension: &str) -> PathBuf {
attachment_dir.join(format!("{attachment_id}{extension}"))
}
fn canonicalize_managed_attachment_path(
attachment_dir: &Path,
path: &str,
) -> Result<PathBuf, String> {
let candidate = PathBuf::from(path);
if !candidate.exists() {
return Err(format!("Attachment file not found at path: {path}"));
}
let canonical_candidate = fs::canonicalize(&candidate)
.map_err(|error| format!("Failed to resolve attachment path: {error}"))?;
let canonical_dir = fs::canonicalize(attachment_dir)
.map_err(|error| format!("Failed to resolve attachment directory: {error}"))?;
if !canonical_candidate.starts_with(&canonical_dir) {
return Err("Attachment path is outside managed attachment directory.".to_string());
}
Ok(canonical_candidate)
}
#[tauri::command]
async fn write_attachment_base64(
app: AppHandle,
attachment_id: String,
base64: String,
extension: Option<String>,
) -> Result<AttachmentFileResult, String> {
tauri::async_runtime::spawn_blocking(move || {
validate_attachment_id(&attachment_id)?;
let attachment_dir = resolve_attachment_dir(&app)?;
clear_existing_attachment_files(&attachment_dir, &attachment_id)?;
let normalized_extension = normalize_extension(extension);
let attachment_path =
build_attachment_path(&attachment_dir, &attachment_id, &normalized_extension);
let decoded_bytes = BASE64_STANDARD
.decode(base64.as_bytes())
.map_err(|error| format!("Failed to decode attachment base64: {error}"))?;
fs::write(&attachment_path, &decoded_bytes)
.map_err(|error| format!("Failed to write attachment file: {error}"))?;
Ok(AttachmentFileResult {
path: attachment_path.to_string_lossy().into_owned(),
byte_size: decoded_bytes.len() as u64,
})
})
.await
.map_err(|error| format!("Attachment write task failed: {error}"))?
}
#[tauri::command]
async fn copy_attachment_file(
app: AppHandle,
attachment_id: String,
source_path: String,
extension: Option<String>,
) -> Result<AttachmentFileResult, String> {
tauri::async_runtime::spawn_blocking(move || {
validate_attachment_id(&attachment_id)?;
let source = PathBuf::from(source_path);
if !source.exists() {
return Err("Source attachment file does not exist.".to_string());
}
let source_extension = source
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_string());
let normalized_extension = normalize_extension(extension.or(source_extension));
let attachment_dir = resolve_attachment_dir(&app)?;
clear_existing_attachment_files(&attachment_dir, &attachment_id)?;
let destination_path =
build_attachment_path(&attachment_dir, &attachment_id, &normalized_extension);
let copied_bytes = fs::copy(&source, &destination_path)
.map_err(|error| format!("Failed to copy attachment file: {error}"))?;
Ok(AttachmentFileResult {
path: destination_path.to_string_lossy().into_owned(),
byte_size: copied_bytes,
})
})
.await
.map_err(|error| format!("Attachment copy task failed: {error}"))?
}
#[tauri::command]
async fn read_file_base64(app: AppHandle, path: String) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let attachment_dir = resolve_attachment_dir(&app)?;
let attachment_path = canonicalize_managed_attachment_path(&attachment_dir, &path)?;
let bytes = fs::read(&attachment_path)
.map_err(|error| format!("Failed to read attachment file: {error}"))?;
Ok(BASE64_STANDARD.encode(bytes))
})
.await
.map_err(|error| format!("Attachment read task failed: {error}"))?
}
#[tauri::command]
async fn delete_attachment_file(app: AppHandle, path: String) -> Result<bool, String> {
tauri::async_runtime::spawn_blocking(move || {
let attachment_dir = resolve_attachment_dir(&app)?;
let attachment_path = match canonicalize_managed_attachment_path(&attachment_dir, &path) {
Ok(path) => path,
Err(_) => return Ok(false),
};
fs::remove_file(&attachment_path)
.map_err(|error| format!("Failed to delete attachment file: {error}"))?;
Ok(true)
})
.await
.map_err(|error| format!("Attachment delete task failed: {error}"))?
}
#[tauri::command]
async fn garbage_collect_attachment_files(
app: AppHandle,
referenced_ids: Vec<String>,
) -> Result<u64, String> {
tauri::async_runtime::spawn_blocking(move || {
let attachment_dir = resolve_attachment_dir(&app)?;
let referenced = referenced_ids.into_iter().collect::<HashSet<String>>();
let mut deleted_count = 0_u64;
let entries = fs::read_dir(&attachment_dir)
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
for entry in entries {
let entry =
entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
let id = file_name.split('.').next().unwrap_or_default();
if id.is_empty() || referenced.contains(id) {
continue;
}
fs::remove_file(&path)
.map_err(|error| format!("Failed to delete stale attachment file: {error}"))?;
deleted_count += 1;
}
Ok(deleted_count)
})
.await
.map_err(|error| format!("Attachment GC task failed: {error}"))?
}
#[tauri::command]
fn webview_log(level: u8, message: String) {
match level {
0 => log::debug!("[webview] {}", message),
1 => log::info!("[webview] {}", message),
2 => log::warn!("[webview] {}", message),
_ => log::error!("[webview] {}", message),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(LocalTransportState::default())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_websocket::init())
.invoke_handler(tauri::generate_handler![
managed_runtime_status,
managed_daemon_status,
cli_symlink_instructions,
start_managed_daemon,
stop_managed_daemon,
restart_managed_daemon,
managed_daemon_logs,
managed_daemon_pairing,
update_managed_daemon_tcp_settings,
open_local_daemon_transport,
send_local_daemon_transport_message,
close_local_daemon_transport,
get_local_daemon_version,
run_local_daemon_update,
check_app_update,
install_app_update,
write_attachment_base64,
copy_attachment_file,
read_file_base64,
delete_attachment_file,
garbage_collect_attachment_files,
send_desktop_notification,
webview_log
])
.setup(|app| {
let setup_start = std::time::Instant::now();
log::info!(
"[app] Paseo Desktop v{} setup starting",
app.package_info().version
);
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.level_for("tao", log::LevelFilter::Warn)
.level_for("wry", log::LevelFilter::Warn)
.max_file_size(5_000_000)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.clear_targets()
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir {
file_name: Some("app.log".into()),
},
))
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Stdout,
))
.build(),
)?;
// Start from Tauri's default menu so macOS standard shortcuts (Cmd+A/C/V/etc)
// keep working. Then inject our zoom controls into a View menu.
//
// On macOS in particular, a custom menu that omits Edit items can break
// responder-chain shortcuts across the whole app.
let menu = Menu::default(app.handle())?;
#[cfg(target_os = "macos")]
{
let app_menu = menu.items()?.into_iter().find_map(|item| match item {
MenuItemKind::Submenu(submenu) => Some(submenu),
_ => None,
});
if let Some(submenu) = app_menu {
// Tauri's default about item sets only `version`, which macOS renders as
// "Version <plist short> (<version>)". Set only `short_version` instead.
let about_metadata = AboutMetadata {
name: Some(app.package_info().name.clone()),
short_version: Some(app.package_info().version.to_string()),
copyright: app.config().bundle.copyright.clone(),
..Default::default()
};
let about =
PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
if submenu.remove_at(0)?.is_some() {
submenu.insert(&about, 0)?;
}
}
}
let zoom_in = MenuItemBuilder::with_id("zoom_in", "Zoom In")
.accelerator("CmdOrCtrl+=")
.build(app)?;
let zoom_out = MenuItemBuilder::with_id("zoom_out", "Zoom Out")
.accelerator("CmdOrCtrl+-")
.build(app)?;
let zoom_reset = MenuItemBuilder::with_id("zoom_reset", "Actual Size")
.accelerator("CmdOrCtrl+0")
.build(app)?;
let separator = PredefinedMenuItem::separator(app.handle())?;
// On macOS, Tauri's default menu already has a "View" submenu (with Fullscreen).
// Insert our zoom items at the top so we don't duplicate the submenu.
#[cfg(target_os = "macos")]
{
let mut view_submenu: Option<Submenu<_>> = None;
for item in menu.items()? {
if let MenuItemKind::Submenu(submenu) = item {
if submenu.text()? == "View" {
view_submenu = Some(submenu);
break;
}
}
}
if let Some(view) = view_submenu {
// Zoom controls first, then keep existing items (e.g. Fullscreen).
view.insert_items(&[&zoom_in, &zoom_out, &zoom_reset, &separator], 0)?;
} else {
// Fallback: if the default menu ever changes, create a View menu.
let view_menu = Submenu::with_items(
app,
"View",
true,
&[&zoom_in, &zoom_out, &zoom_reset, &separator],
)?;
menu.append(&view_menu)?;
}
}
// Non-macOS: default menu doesn't include a View menu, so add it.
#[cfg(not(target_os = "macos"))]
{
let view_menu =
Submenu::with_items(app, "View", true, &[&zoom_in, &zoom_out, &zoom_reset])?;
menu.append(&view_menu)?;
}
app.set_menu(menu)?;
log::info!("[app] setup complete ({}ms)", setup_start.elapsed().as_millis());
let window = app.get_webview_window("main").unwrap();
let window_clone = window.clone();
app.on_menu_event(move |_app, event| {
let id = event.id().as_ref();
if id == "zoom_in" {
let current = get_zoom_factor();
set_zoom_factor(&window_clone, current + 0.1);
} else if id == "zoom_out" {
let current = get_zoom_factor();
set_zoom_factor(&window_clone, current - 0.1);
} else if id == "zoom_reset" {
set_zoom_factor(&window_clone, 1.0);
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::parse_cli_passthrough_args_from_argv;
#[test]
fn routes_meaningful_args_to_cli_mode() {
let args = vec![
"/Applications/Paseo.app/Contents/MacOS/Paseo".to_string(),
"--version".to_string(),
];
assert_eq!(
parse_cli_passthrough_args_from_argv(&args),
Some(vec!["--version".to_string()])
);
}
#[test]
fn ignores_plain_gui_launch() {
let args = vec!["/Applications/Paseo.app/Contents/MacOS/Paseo".to_string()];
assert_eq!(parse_cli_passthrough_args_from_argv(&args), None);
}
#[test]
fn ignores_macos_process_serial_number_argument() {
let args = vec![
"/Applications/Paseo.app/Contents/MacOS/Paseo".to_string(),
"-psn_0_12345".to_string(),
];
assert_eq!(parse_cli_passthrough_args_from_argv(&args), None);
}
}
-15
View File
@@ -1,15 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// `tauri dev` runs the raw executable (`target/debug/Paseo`) on macOS (not the .app bundle),
// so we must embed an Info.plist containing usage descriptions for WebKit media
// permission prompts in dev.
#[cfg(all(target_os = "macos", debug_assertions))]
tauri::embed_plist::embed_info_plist!("../Info.plist");
fn main() {
if let Some(exit_code) = paseo_lib::try_run_pre_tauri_mode() {
std::process::exit(exit_code);
}
paseo_lib::run();
}
File diff suppressed because it is too large Load Diff
@@ -1,66 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Paseo",
"version": "0.1.30",
"identifier": "dev.paseo.desktop",
"build": {
"frontendDist": "../../app/dist",
"devUrl": "http://localhost:8081"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Paseo",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": {
"x": 16,
"y": 22
}
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"resources": [
"resources/**/*"
],
"targets": "all",
"windows": {
"nsis": {
"installerHooks": "./installer-hooks.nsh"
}
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": "Entitlements.plist",
"infoPlist": "Info.plist"
}
},
"plugins": {
"updater": {
"active": true,
"endpoints": [
"https://github.com/getpaseo/paseo/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFGNzE3OTlBNEVCM0U4ODgKUldTSTZMTk9tbmx4cnh6OStYMGxFelpnRVJwTklXMlpyM3FQTXBVbFc5NUpFNmZMbUdjRjkyN3gK"
}
}
}
@@ -0,0 +1,569 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { app, ipcMain } from "electron";
import {
loadConfig,
resolvePaseoHome,
getOrCreateServerId,
} from "@getpaseo/server";
import {
copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile,
garbageCollectManagedAttachmentFiles,
readManagedFileBase64,
writeAttachmentBase64,
} from "../features/attachments.js";
import {
checkForAppUpdate,
downloadAndInstallUpdate,
} from "../features/auto-updater.js";
import {
openLocalTransportSession,
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import {
createElectronNodeEnv,
resolveDaemonRunnerEntrypoint,
} from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
const PID_POLL_INTERVAL_MS = 100;
const STARTUP_POLL_INTERVAL_MS = 200;
const STARTUP_POLL_MAX_ATTEMPTS = 150;
const STOP_TIMEOUT_MS = 15_000;
const KILL_TIMEOUT_MS = 3_000;
const DETACHED_STARTUP_GRACE_MS = 1200;
const DEFAULT_ELECTRON_DEV_SERVER_URL = "http://localhost:8081";
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
type DesktopDaemonStatus = {
serverId: string;
status: DesktopDaemonState;
listen: string;
hostname: string | null;
pid: number | null;
home: string;
error: string | null;
};
type DesktopDaemonLogs = {
logPath: string;
contents: string;
};
type DesktopPairingOffer = {
relayEnabled: boolean;
url: string | null;
qr: string | null;
};
type CliSymlinkInstructions = {
title: string;
detail: string;
commands: string;
};
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function getPaseoHome(): string {
return resolvePaseoHome(process.env);
}
function pidFilePath(): string {
return path.join(getPaseoHome(), DAEMON_PID_FILENAME);
}
function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err && err.code === "EPERM") {
return true;
}
return false;
}
}
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
try {
process.kill(pid, signal);
return true;
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err) {
if (err.code === "ESRCH") return false;
if (err.code === "EPERM") return true;
}
throw err;
}
}
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
if (process.platform === "win32") return signalProcessSafely(pid, signal);
try {
process.kill(-pid, signal);
return true;
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err) {
if (err.code === "ESRCH") return signalProcessSafely(pid, signal);
if (err.code === "EPERM") return true;
}
throw err;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) return true;
await sleep(PID_POLL_INTERVAL_MS);
}
return !isProcessRunning(pid);
}
function tailFile(filePath: string, lines = 50): string {
try {
const content = readFileSync(filePath, "utf-8");
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
} catch {
return "";
}
}
function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
const origins = new Set(
(process.env.PASEO_CORS_ORIGINS ?? "")
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0)
);
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
try {
const parsed = new URL(devServerUrl);
origins.add(parsed.origin);
if (parsed.hostname === "localhost") {
origins.add(`${parsed.protocol}//127.0.0.1${parsed.port ? `:${parsed.port}` : ""}`);
} else if (parsed.hostname === "127.0.0.1") {
origins.add(`${parsed.protocol}//localhost${parsed.port ? `:${parsed.port}` : ""}`);
}
} catch {
// Ignore malformed dev server URLs and preserve any explicit env configuration.
}
return origins.size > 0 ? Array.from(origins).join(",") : undefined;
}
function toTrimmedString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function resolveTcpHostFromListen(listen: string): string | null {
const normalized = listen.trim();
if (!normalized) {
return null;
}
if (
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\")
) {
return null;
}
if (/^\d+$/.test(normalized)) {
return `127.0.0.1:${normalized}`;
}
if (normalized.includes(":")) {
return normalized;
}
return null;
}
function buildDaemonHttpBaseUrl(listen: string): string | null {
const endpoint = resolveTcpHostFromListen(listen);
if (!endpoint) {
return null;
}
return new URL(`http://${endpoint}`).toString().replace(/\/$/, "");
}
function resolveDesktopAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
}
try {
const packageJsonPath = path.join(__dirname, "..", "..", "package.json");
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
version?: unknown;
};
if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
return pkg.version.trim();
}
} catch {
// Fall back to Electron's default version if the package metadata is unavailable.
}
return app.getVersion();
}
// ---------------------------------------------------------------------------
// Daemon lifecycle
// ---------------------------------------------------------------------------
function resolveStatus(): DesktopDaemonStatus {
const home = getPaseoHome();
const config = loadConfig(home, { env: process.env });
const pidPath = pidFilePath();
let pid: number | null = null;
let hostname: string | null = null;
let listen: string = config.listen;
try {
if (existsSync(pidPath)) {
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
const pidValue = parsed.pid;
if (typeof pidValue === "number" && Number.isInteger(pidValue) && pidValue > 0) {
pid = pidValue;
hostname = typeof parsed.hostname === "string" ? parsed.hostname : null;
const pidListen =
typeof parsed.listen === "string"
? parsed.listen
: typeof parsed.sockPath === "string"
? (parsed.sockPath as string)
: null;
if (pidListen) listen = pidListen;
}
}
} catch {
// PID file missing or malformed — treat as stopped.
}
const running = pid !== null && isProcessRunning(pid);
let serverId = "";
try {
serverId = getOrCreateServerId(home);
} catch {
// Ignore — server-id may not exist yet.
}
return {
serverId,
status: running ? "running" : "stopped",
listen,
hostname: running ? hostname : null,
pid: running ? pid : null,
home,
error: null,
};
}
async function startDaemon(): Promise<DesktopDaemonStatus> {
const current = resolveStatus();
if (current.status === "running") return current;
const home = getPaseoHome();
const daemonRunner = resolveDaemonRunnerEntrypoint();
const corsOrigins = buildDesktopDaemonCorsOriginsEnv();
const child: ChildProcess = spawn(
process.execPath,
[...daemonRunner.execArgv, daemonRunner.entryPath],
{
detached: true,
env: createElectronNodeEnv({
...process.env,
PASEO_HOME: home,
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
}),
stdio: ["ignore", "ignore", "ignore"],
}
);
child.unref();
// Wait for process to survive the grace period
const exitedEarly = await new Promise<boolean>((resolve) => {
let settled = false;
const finish = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
const timer = setTimeout(() => finish(false), DETACHED_STARTUP_GRACE_MS);
child.once("error", () => {
clearTimeout(timer);
finish(true);
});
child.once("exit", () => {
clearTimeout(timer);
finish(true);
});
});
if (exitedEarly) {
const logs = tailFile(logFilePath(), 15);
throw new Error(
`Daemon failed to start.${logs ? `\n\nRecent logs:\n${logs}` : ""}`
);
}
// Poll for PID file with server ID
for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) {
const status = resolveStatus();
if (status.status === "running" && status.serverId) return status;
await sleep(STARTUP_POLL_INTERVAL_MS);
}
return resolveStatus();
}
async function stopDaemon(): Promise<DesktopDaemonStatus> {
const status = resolveStatus();
if (status.status !== "running" || !status.pid) return status;
const pid = status.pid;
signalProcessSafely(pid, "SIGTERM");
let stopped = await waitForPidExit(pid, STOP_TIMEOUT_MS);
if (!stopped) {
signalProcessGroupSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS);
}
if (!stopped) {
throw new Error(`Timed out waiting for daemon PID ${pid} to stop`);
}
return resolveStatus();
}
async function restartDaemon(): Promise<DesktopDaemonStatus> {
await stopDaemon();
return startDaemon();
}
function getDaemonLogs(): DesktopDaemonLogs {
const logPath = logFilePath();
return {
logPath,
contents: tailFile(logPath, 100),
};
}
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
const status = resolveStatus();
if (status.status !== "running") {
return {
relayEnabled: false,
url: null,
qr: null,
};
}
try {
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) {
throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`);
}
const response = await fetch(`${baseUrl}/pairing`);
if (!response.ok) {
throw new Error(`Daemon pairing request failed with ${response.status}`);
}
const payload = (await response.json()) as unknown;
if (!isRecord(payload)) {
throw new Error("Daemon pairing response was not an object.");
}
return {
relayEnabled: payload.relayEnabled === true,
url: toTrimmedString(payload.url),
qr: toTrimmedString(payload.qr),
};
} catch {
return {
relayEnabled: false,
url: null,
qr: null,
};
}
}
async function getLocalDaemonVersion(): Promise<{
version: string | null;
error: string | null;
}> {
const status = resolveStatus();
if (status.status !== "running") {
return {
version: null,
error: "Daemon is not running.",
};
}
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) {
return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` };
}
try {
const response = await fetch(`${baseUrl}/api/status`);
if (!response.ok) {
return { version: null, error: `Daemon status request failed with ${response.status}` };
}
const payload = (await response.json()) as Record<string, unknown>;
const version = typeof payload.version === "string" ? payload.version.trim() : null;
return {
version: version && version.length > 0 ? version : null,
error: version ? null : "Running daemon did not report a version.",
};
} catch (error) {
return {
version: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function resolveCurrentUpdateVersion(): Promise<string> {
const daemonVersion = await getLocalDaemonVersion();
if (daemonVersion.version) {
return daemonVersion.version;
}
return resolveDesktopAppVersion();
}
function getCliSymlinkInstructions(): CliSymlinkInstructions {
const electronExePath = app.getPath("exe");
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
const cliPath = path.join(appBundle, "Contents", "MacOS", cliShimFilename);
return {
title: "Add paseo to your shell",
detail: "Create a symlink to the bundled Paseo CLI shim.",
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
};
}
if (process.platform === "win32") {
return {
title: "Add paseo to your PATH",
detail: "Add the Paseo installation directory to your system PATH so paseo.cmd is available.",
commands: `setx PATH "%PATH%;${path.dirname(electronExePath)}"`,
};
}
// Linux
return {
title: "Add paseo to your shell",
detail: "Create a symlink to the bundled Paseo CLI shim.",
commands: `sudo ln -sf "${path.join(path.dirname(electronExePath), cliShimFilename)}" /usr/local/bin/paseo`,
};
}
// ---------------------------------------------------------------------------
// IPC registration
// ---------------------------------------------------------------------------
export function createDaemonCommandHandlers(): Record<string, DesktopCommandHandler> {
return {
desktop_daemon_status: () => resolveStatus(),
start_desktop_daemon: () => startDaemon(),
stop_desktop_daemon: () => stopDaemon(),
restart_desktop_daemon: () => restartDaemon(),
desktop_daemon_logs: () => getDaemonLogs(),
desktop_daemon_pairing: () => getDaemonPairing(),
cli_symlink_instructions: () => getCliSymlinkInstructions(),
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
read_file_base64: (args) => readManagedFileBase64(args ?? {}),
delete_attachment_file: (args) => deleteManagedAttachmentFile(args ?? {}),
garbage_collect_attachment_files: (args) =>
garbageCollectManagedAttachmentFiles(args ?? {}),
open_local_daemon_transport: async (args) => {
const target = args as { transportType: "socket" | "pipe"; transportPath: string };
return await openLocalTransportSession(target);
},
send_local_daemon_transport_message: async (args) => {
await sendLocalTransportMessage(args as { sessionId: string; text?: string; binaryBase64?: string });
},
close_local_daemon_transport: (args) => {
const sessionId = typeof args === "object" && args !== null && "sessionId" in args
? (args as { sessionId: string }).sessionId
: "";
if (sessionId) closeLocalTransportSession(sessionId);
},
check_app_update: async () => {
const currentVersion = await resolveCurrentUpdateVersion();
return checkForAppUpdate(currentVersion);
},
install_app_update: async () => {
const currentVersion = await resolveCurrentUpdateVersion();
return downloadAndInstallUpdate(currentVersion);
},
get_local_daemon_version: () => getLocalDaemonVersion(),
webview_log: (args) => {
const level = typeof args?.level === "number" ? args.level : 1;
const message = typeof args?.message === "string" ? args.message : "";
const method = level === 0 ? "debug" : level === 2 ? "warn" : level >= 3 ? "error" : "info";
console[method]("[webview]", message);
},
};
}
export function registerDaemonManager(): void {
const handlers = createDaemonCommandHandlers();
ipcMain.handle(
"paseo:invoke",
async (_event, command: string, args?: Record<string, unknown>) => {
const handler = handlers[command];
if (!handler) {
throw new Error(`Unknown desktop command: ${command}`);
}
return await handler(args);
}
);
}
@@ -0,0 +1,212 @@
import { BrowserWindow } from "electron";
import WebSocket from "ws";
type TransportTarget = {
transportType: "socket" | "pipe";
transportPath: string;
};
type TransportEventPayload = {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
};
type Session = {
id: string;
ws: WebSocket;
state: "opening" | "open" | "closing" | "closed";
};
const WS_ENDPOINT_PATH = "/ws";
let nextSessionId = 0;
const sessions = new Map<string, Session>();
function emitTransportEvent(payload: TransportEventPayload): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send("paseo:event:local-daemon-transport-event", payload);
}
}
/**
* Build a WebSocket URL that connects through a Unix domain socket or Windows
* named pipe. The `ws` library supports these via the `ws+unix://` scheme:
*
* ws+unix:///path/to/socket:/ws
* ws+unix://./pipe/paseo:/ws (Windows named pipe)
*
* The part before `:` is the IPC path, the part after is the HTTP request
* path used during the WebSocket upgrade handshake.
*/
function buildLocalWebSocketUrl(target: TransportTarget): string {
const ipcPath = target.transportPath;
return `ws+unix://${ipcPath}:${WS_ENDPOINT_PATH}`;
}
function describeTransportTarget(target: TransportTarget): string {
return target.transportType === "pipe" ? "local daemon pipe" : "local daemon socket";
}
function decodeTransportMessage(input: {
text?: string;
binaryBase64?: string;
}): string | Buffer {
if (typeof input.text === "string") {
return input.text;
}
if (typeof input.binaryBase64 === "string") {
return Buffer.from(input.binaryBase64, "base64");
}
throw new Error("Local transport send requires text or binary payload.");
}
export function openLocalTransportSession(target: TransportTarget): Promise<string> {
const sessionId = `local-session-${++nextSessionId}`;
const url = buildLocalWebSocketUrl(target);
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const session: Session = {
id: sessionId,
ws,
state: "opening",
};
sessions.set(sessionId, session);
let openSettled = false;
const finalizeOpenFailure = (message: string): void => {
if (openSettled) {
return;
}
openSettled = true;
session.state = "closed";
sessions.delete(sessionId);
reject(new Error(message));
};
ws.once("open", () => {
openSettled = true;
session.state = "open";
resolve(sessionId);
emitTransportEvent({ sessionId, kind: "open" });
});
ws.on("message", (data: WebSocket.RawData, isBinary: boolean) => {
if (isBinary || data instanceof Buffer) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
emitTransportEvent({
sessionId,
kind: "message",
binaryBase64: buf.toString("base64"),
});
return;
}
emitTransportEvent({
sessionId,
kind: "message",
text: data.toString(),
});
});
ws.on("close", (code: number, reason?: Buffer | string) => {
const shouldEmitClose = session.state === "open" || session.state === "closing";
session.state = "closed";
sessions.delete(sessionId);
if (!openSettled) {
finalizeOpenFailure(
`${describeTransportTarget(target)} closed before the session became ready.`
);
return;
}
if (shouldEmitClose) {
emitTransportEvent({
sessionId,
kind: "close",
code,
reason: reason ? String(reason) : "",
});
}
});
ws.on("error", (err: Error) => {
if (!openSettled) {
finalizeOpenFailure(
`Failed to connect to ${describeTransportTarget(target)}: ${err.message}`
);
return;
}
emitTransportEvent({
sessionId,
kind: "error",
error: err.message,
});
});
});
}
export async function sendLocalTransportMessage(input: {
sessionId: string;
text?: string;
binaryBase64?: string;
}): Promise<void> {
const session = sessions.get(input.sessionId);
if (!session) {
throw new Error(`Local transport session not found: ${input.sessionId}`);
}
if (session.state !== "open" || session.ws.readyState !== WebSocket.OPEN) {
throw new Error(
session.state === "opening"
? "Local transport session is not open yet."
: "Local transport session is closed."
);
}
const payload = decodeTransportMessage(input);
await new Promise<void>((resolve, reject) => {
session.ws.send(payload, (error) => {
if (error) {
reject(new Error(`Local transport write failed: ${error.message}`));
return;
}
resolve();
});
});
}
export function closeLocalTransportSession(sessionId: string): void {
const session = sessions.get(sessionId);
if (!session) return;
try {
if (session.ws.readyState === WebSocket.CONNECTING) {
session.state = "closed";
session.ws.terminate();
} else {
session.state = "closing";
session.ws.close();
}
} catch {
// ignore close errors
}
sessions.delete(sessionId);
}
export function closeAllTransportSessions(): void {
for (const [id] of sessions) {
closeLocalTransportSession(id);
}
}
@@ -0,0 +1,196 @@
import { existsSync, readFileSync } from "node:fs";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
const CLI_PACKAGE_NAME = "@getpaseo/cli";
const SERVER_PACKAGE_NAME = "@getpaseo/server";
const CLI_BIN_ENTRY = `${CLI_PACKAGE_NAME}/bin/paseo`;
const IGNORED_GUI_LAUNCH_ARG_PREFIX = "-psn_";
type PackageInfo = {
root: string;
};
export type NodeEntrypointSpec = {
entryPath: string;
execArgv: string[];
};
const esmRequire = createRequire(__filename);
function findPackageRootFromResolvedPath(input: {
resolvedPath: string;
packageName: string;
}): PackageInfo {
let currentDir = path.dirname(input.resolvedPath);
while (true) {
const packageJsonPath = path.join(currentDir, "package.json");
if (existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
name?: string;
};
if (pkg.name === input.packageName) {
return {
root: currentDir,
};
}
} catch {
// Ignore malformed package metadata while walking up.
}
}
const parent = path.dirname(currentDir);
if (parent === currentDir) {
break;
}
currentDir = parent;
}
throw new Error(`Unable to resolve ${input.packageName} package root`);
}
function resolveServerPackageInfo(): PackageInfo {
const serverExportPath = esmRequire.resolve(SERVER_PACKAGE_NAME);
return findPackageRootFromResolvedPath({
resolvedPath: serverExportPath,
packageName: SERVER_PACKAGE_NAME,
});
}
function resolveCliPackageInfo(): PackageInfo {
const cliBinPath = esmRequire.resolve(CLI_BIN_ENTRY);
return findPackageRootFromResolvedPath({
resolvedPath: cliBinPath,
packageName: CLI_PACKAGE_NAME,
});
}
function resolvePackagedAsarPath(): string {
return path.join(process.resourcesPath, "app.asar");
}
function assertPathExists(input: {
label: string;
filePath: string;
}): string {
if (!existsSync(input.filePath)) {
throw new Error(`${input.label} is missing at ${input.filePath}`);
}
return input.filePath;
}
export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null {
const startIndex = process.defaultApp ? 2 : 1;
const effective = argv
.slice(startIndex)
.filter((arg) => !arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX));
return effective.length > 0 ? effective : null;
}
export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
if (app.isPackaged) {
return {
entryPath: assertPathExists({
label: "Bundled daemon runner",
filePath: path.join(
resolvePackagedAsarPath(),
"node_modules",
"@getpaseo",
"server",
"dist",
"scripts",
"daemon-runner.js"
),
}),
execArgv: [],
};
}
const serverPackage = resolveServerPackageInfo();
const distRunner = path.join(serverPackage.root, "dist", "scripts", "daemon-runner.js");
if (existsSync(distRunner)) {
return {
entryPath: distRunner,
execArgv: [],
};
}
return {
entryPath: assertPathExists({
label: "Daemon runner source",
filePath: path.join(serverPackage.root, "scripts", "daemon-runner.ts"),
}),
execArgv: ["--import", "tsx"],
};
}
export function resolveCliEntrypoint(): NodeEntrypointSpec {
if (app.isPackaged) {
return {
entryPath: assertPathExists({
label: "Bundled CLI entrypoint",
filePath: path.join(
resolvePackagedAsarPath(),
"node_modules",
"@getpaseo",
"cli",
"dist",
"index.js"
),
}),
execArgv: [],
};
}
const cliPackage = resolveCliPackageInfo();
const distEntry = path.join(cliPackage.root, "dist", "index.js");
if (existsSync(distEntry)) {
return {
entryPath: distEntry,
execArgv: [],
};
}
return {
entryPath: assertPathExists({
label: "CLI source entrypoint",
filePath: path.join(cliPackage.root, "src", "index.ts"),
}),
execArgv: ["--import", "tsx"],
};
}
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...baseEnv,
ELECTRON_RUN_AS_NODE: "1",
};
}
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
const cli = resolveCliEntrypoint();
return spawnSync(process.execPath, [...cli.execArgv, cli.entryPath, ...args], {
env: createElectronNodeEnv(process.env),
stdio: "inherit",
});
}
export function runCliPassthroughCommand(args: string[]): number {
const result = spawnCliProcess(args);
if (result.error) {
throw result.error;
}
if (typeof result.status === "number") {
return result.status;
}
return result.signal ? 1 : 0;
}
@@ -0,0 +1,162 @@
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { resolvePaseoHome } from "@getpaseo/server";
const ATTACHMENTS_DIRNAME = "desktop-attachments";
const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
const EXTENSION_PATTERN = /^\.[A-Za-z0-9]{1,16}$/;
type AttachmentFileResult = {
path: string;
byteSize: number;
};
function attachmentsDirPath(): string {
return path.join(resolvePaseoHome(process.env), ATTACHMENTS_DIRNAME);
}
async function ensureAttachmentsDir(): Promise<string> {
const dirPath = attachmentsDirPath();
await mkdir(dirPath, { recursive: true });
return dirPath;
}
function normalizeAttachmentId(value: unknown): string {
if (typeof value !== "string") {
throw new Error("Attachment id is required.");
}
const normalized = value.trim();
if (!ATTACHMENT_ID_PATTERN.test(normalized)) {
throw new Error(`Invalid attachment id: ${value}`);
}
return normalized;
}
function normalizeExtension(value: unknown): string {
if (value == null || value === "") {
return ".bin";
}
if (typeof value !== "string") {
throw new Error("Attachment extension must be a string.");
}
const normalized = value.trim().toLowerCase();
if (!EXTENSION_PATTERN.test(normalized)) {
throw new Error(`Invalid attachment extension: ${value}`);
}
return normalized;
}
async function buildManagedAttachmentPath(input: {
attachmentId: unknown;
extension: unknown;
}): Promise<string> {
const dirPath = await ensureAttachmentsDir();
const attachmentId = normalizeAttachmentId(input.attachmentId);
const extension = normalizeExtension(input.extension);
return path.join(dirPath, `${attachmentId}${extension}`);
}
function resolveManagedAttachmentPath(inputPath: unknown): string {
if (typeof inputPath !== "string" || inputPath.trim().length === 0) {
throw new Error("Attachment path is required.");
}
const resolvedDir = `${path.resolve(attachmentsDirPath())}${path.sep}`;
const resolvedPath = path.resolve(inputPath.trim());
if (!resolvedPath.startsWith(resolvedDir)) {
throw new Error("Attachment path must stay within desktop-managed storage.");
}
return resolvedPath;
}
export async function writeAttachmentBase64(input: {
attachmentId?: unknown;
base64?: unknown;
extension?: unknown;
}): Promise<AttachmentFileResult> {
const base64 = typeof input.base64 === "string" ? input.base64.trim() : "";
if (base64.length === 0) {
throw new Error("Attachment base64 payload is required.");
}
const targetPath = await buildManagedAttachmentPath({
attachmentId: input.attachmentId,
extension: input.extension,
});
await writeFile(targetPath, Buffer.from(base64, "base64"));
const fileInfo = await stat(targetPath);
return {
path: targetPath,
byteSize: fileInfo.size,
};
}
export async function copyAttachmentFileToManagedStorage(input: {
attachmentId?: unknown;
sourcePath?: unknown;
extension?: unknown;
}): Promise<AttachmentFileResult> {
if (typeof input.sourcePath !== "string" || input.sourcePath.trim().length === 0) {
throw new Error("Attachment source path is required.");
}
const sourcePath = path.resolve(input.sourcePath.trim());
const targetPath = await buildManagedAttachmentPath({
attachmentId: input.attachmentId,
extension: input.extension,
});
if (sourcePath !== targetPath) {
await copyFile(sourcePath, targetPath);
}
const fileInfo = await stat(targetPath);
return {
path: targetPath,
byteSize: fileInfo.size,
};
}
export async function readManagedFileBase64(input: { path?: unknown }): Promise<string> {
const filePath = resolveManagedAttachmentPath(input.path);
const bytes = await readFile(filePath);
return bytes.toString("base64");
}
export async function deleteManagedAttachmentFile(input: {
path?: unknown;
}): Promise<boolean> {
const filePath = resolveManagedAttachmentPath(input.path);
await rm(filePath, { force: true });
return true;
}
export async function garbageCollectManagedAttachmentFiles(input: {
referencedIds?: unknown;
}): Promise<number> {
const dirPath = await ensureAttachmentsDir();
const referencedIds = Array.isArray(input.referencedIds)
? new Set(
input.referencedIds
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => ATTACHMENT_ID_PATTERN.test(value))
)
: new Set<string>();
const entries = await readdir(dirPath, { withFileTypes: true });
let deletedCount = 0;
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const attachmentId = path.parse(entry.name).name;
if (referencedIds.has(attachmentId)) {
continue;
}
await rm(path.join(dirPath, entry.name), { force: true });
deletedCount += 1;
}
return deletedCount;
}
@@ -0,0 +1,155 @@
import { app } from "electron";
import { autoUpdater, type UpdateInfo } from "electron-updater";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type AppUpdateCheckResult = {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
body: string | null;
date: string | null;
};
export type AppUpdateInstallResult = {
installed: boolean;
version: string | null;
message: string;
};
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let cachedUpdateInfo: UpdateInfo | null = null;
let downloading = false;
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
function configureAutoUpdater(): void {
// Don't auto-download — the user triggers install explicitly.
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Suppress built-in dialogs; the renderer handles UI.
autoUpdater.autoRunAppAfterInstall = true;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
if (!app.isPackaged) {
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
body: null,
date: null,
};
}
configureAutoUpdater();
try {
const result = await autoUpdater.checkForUpdates();
if (!result || !result.updateInfo) {
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
body: null,
date: null,
};
}
const info = result.updateInfo;
const latestVersion = info.version;
const hasUpdate = latestVersion !== currentVersion;
if (hasUpdate) {
cachedUpdateInfo = info;
}
return {
hasUpdate,
currentVersion,
latestVersion,
body: typeof info.releaseNotes === "string" ? info.releaseNotes : null,
date: typeof info.releaseDate === "string" ? info.releaseDate : null,
};
} catch (error) {
console.error("[auto-updater] Failed to check for updates:", error);
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
body: null,
date: null,
};
}
}
export async function downloadAndInstallUpdate(currentVersion: string): Promise<AppUpdateInstallResult> {
if (!app.isPackaged) {
return {
installed: false,
version: currentVersion,
message: "Auto-update is not available in development mode.",
};
}
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update already in progress.",
};
}
if (!cachedUpdateInfo) {
return {
installed: false,
version: currentVersion,
message: "No update available. Check for updates first.",
};
}
configureAutoUpdater();
downloading = true;
try {
await autoUpdater.downloadUpdate();
// quitAndInstall restarts the app with the new version.
// Use a short delay to allow the renderer to receive the response.
setTimeout(() => {
try {
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
return {
installed: true,
version: cachedUpdateInfo.version,
message: "Update downloaded. The app will restart shortly.",
};
} catch (error) {
downloading = false;
const message = error instanceof Error ? error.message : String(error);
console.error("[auto-updater] Failed to download/install update:", message);
return {
installed: false,
version: currentVersion,
message: `Update failed: ${message}`,
};
}
}
+49
View File
@@ -0,0 +1,49 @@
import { dialog, ipcMain, BrowserWindow } from "electron";
type AskOptions = {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
};
type OpenOptions = {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: Array<{ name: string; extensions: string[] }>;
};
export function registerDialogHandlers(): void {
ipcMain.handle("paseo:dialog:ask", async (event, message: string, options?: AskOptions) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showMessageBox(win ?? BrowserWindow.getFocusedWindow()!, {
type: options?.kind === "warning" ? "warning" : options?.kind === "error" ? "error" : "question",
title: options?.title ?? "Confirm",
message,
buttons: [options?.cancelLabel ?? "Cancel", options?.okLabel ?? "OK"],
defaultId: 1,
cancelId: 0,
});
return result.response === 1;
});
ipcMain.handle("paseo:dialog:open", async (event, options?: OpenOptions) => {
const win = BrowserWindow.fromWebContents(event.sender);
const properties: Electron.OpenDialogOptions["properties"] = [];
if (options?.directory) properties.push("openDirectory");
if (options?.multiple) properties.push("multiSelections");
if (!options?.directory) properties.push("openFile");
const result = await dialog.showOpenDialog(win ?? BrowserWindow.getFocusedWindow()!, {
title: options?.title,
defaultPath: options?.defaultPath,
properties,
filters: options?.filters,
});
if (result.canceled) return null;
return options?.multiple ? result.filePaths : (result.filePaths[0] ?? null);
});
}
+92
View File
@@ -0,0 +1,92 @@
import { app, Menu, BrowserWindow } from "electron";
function withBrowserWindow(
callback: (win: BrowserWindow) => void
): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void {
return (_item, baseWin) => {
const win = baseWin instanceof BrowserWindow ? baseWin : BrowserWindow.getFocusedWindow();
if (win) callback(win);
};
}
export function setupApplicationMenu(): void {
const isMac = process.platform === "darwin";
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac
? [
{
label: app.name,
submenu: [
{ role: "about" as const },
{ type: "separator" as const },
{ role: "services" as const },
{ type: "separator" as const },
{ role: "hide" as const },
{ role: "hideOthers" as const },
{ role: "unhide" as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
},
]
: []),
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{
label: "Zoom In",
accelerator: "CmdOrCtrl+=",
click: withBrowserWindow((win) => {
win.webContents.setZoomLevel(win.webContents.getZoomLevel() + 0.5);
}),
},
{
label: "Zoom Out",
accelerator: "CmdOrCtrl+-",
click: withBrowserWindow((win) => {
win.webContents.setZoomLevel(win.webContents.getZoomLevel() - 0.5);
}),
},
{
label: "Actual Size",
accelerator: "CmdOrCtrl+0",
click: withBrowserWindow((win) => {
win.webContents.setZoomLevel(0);
}),
},
{ type: "separator" },
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
...(isMac
? [{ type: "separator" as const }, { role: "front" as const }]
: [{ role: "close" as const }]),
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
@@ -0,0 +1,123 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { app, BrowserWindow, Notification, ipcMain, nativeImage } from "electron";
type NotificationInput = {
title?: unknown;
body?: unknown;
data?: unknown;
};
type NotificationClickPayload = {
data?: Record<string, unknown>;
};
const activeNotifications = new Set<Notification>();
function toTrimmedString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function getNotificationIcon(): Electron.NativeImage | null {
const candidates = [
path.resolve(__dirname, "../assets/icon.png"),
path.resolve(__dirname, "../assets/64x64.png"),
path.resolve(__dirname, "../assets/128x128.png"),
];
for (const iconPath of candidates) {
if (!existsSync(iconPath)) {
continue;
}
const icon = nativeImage.createFromPath(iconPath);
if (!icon.isEmpty()) {
return icon;
}
}
return null;
}
function focusSenderWindow(sender: Electron.WebContents): BrowserWindow | null {
const win = BrowserWindow.fromWebContents(sender) ?? BrowserWindow.getAllWindows()[0] ?? null;
if (!win || win.isDestroyed()) {
return null;
}
win.show();
if (win.isMinimized()) {
win.restore();
}
win.focus();
return win;
}
/**
* macOS requires a notification to have been shown at least once before
* the app appears in System Preferences > Notifications. We fire a
* silent no-op notification during startup to ensure registration.
*/
export function ensureNotificationCenterRegistration(): void {
if (process.platform !== "darwin" || !Notification.isSupported()) {
return;
}
const probe = new Notification({ title: app.name, silent: true });
probe.on("show", () => probe.close());
setTimeout(() => probe.close(), 2_000);
probe.show();
}
export function registerNotificationHandlers(): void {
ipcMain.handle("paseo:notification:isSupported", () => {
return Notification.isSupported();
});
ipcMain.handle("paseo:notification:send", async (event, rawInput?: NotificationInput) => {
if (!Notification.isSupported()) {
return false;
}
const title = toTrimmedString(rawInput?.title);
if (!title) {
return false;
}
const body = toTrimmedString(rawInput?.body) ?? undefined;
const data = toRecord(rawInput?.data);
const icon = getNotificationIcon();
const notification = new Notification({
title,
...(body ? { body } : {}),
...(icon ? { icon } : {}),
silent: true,
});
activeNotifications.add(notification);
notification.on("click", () => {
const win = focusSenderWindow(event.sender);
if (win && data && Object.keys(data).length > 0) {
const payload: NotificationClickPayload = { data };
win.webContents.send("paseo:event:notification-click", payload);
}
activeNotifications.delete(notification);
});
notification.on("close", () => {
activeNotifications.delete(notification);
});
notification.show();
return true;
});
}
+7
View File
@@ -0,0 +1,7 @@
import { shell, ipcMain } from "electron";
export function registerOpenerHandlers(): void {
ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
await shell.openExternal(url);
});
}
+180
View File
@@ -0,0 +1,180 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { app, BrowserWindow, nativeImage } from "electron";
import { registerDaemonManager } from "./daemon/daemon-manager.js";
import { parseCliPassthroughArgsFromArgv, runCliPassthroughCommand } from "./daemon/runtime-paths.js";
import { closeAllTransportSessions } from "./daemon/local-transport.js";
import { registerWindowManager, setupWindowResizeEvents, setupDragDropPrevention } from "./window/window-manager.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import { registerNotificationHandlers, ensureNotificationCenterRegistration } from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
app.setName("Paseo");
// ---------------------------------------------------------------------------
// Window creation
// ---------------------------------------------------------------------------
function getPreloadPath(): string {
return path.join(__dirname, "preload.js");
}
function getProductionEntryPath(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "app-dist", "index.html");
}
return path.resolve(__dirname, "../../app/dist/index.html");
}
function getWindowIconPath(): string | null {
const candidates = app.isPackaged
? process.platform === "win32"
? [path.join(process.resourcesPath, "icon.ico"), path.join(process.resourcesPath, "icon.png")]
: [path.join(process.resourcesPath, "icon.png")]
: process.platform === "darwin"
? [path.resolve(__dirname, "../assets/icon.png")]
: process.platform === "win32"
? [path.resolve(__dirname, "../assets/icon.ico"), path.resolve(__dirname, "../assets/icon.png")]
: [path.resolve(__dirname, "../assets/icon.png")];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function applyAppIcon(): void {
if (process.platform !== "darwin") {
return;
}
const iconPath = path.resolve(__dirname, "../assets/icon.png");
if (!existsSync(iconPath)) {
return;
}
const icon = nativeImage.createFromPath(iconPath);
if (icon.isEmpty()) {
return;
}
app.dock?.setIcon(icon);
}
async function createMainWindow(): Promise<void> {
const isMac = process.platform === "darwin";
const iconPath = getWindowIconPath();
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
...(iconPath ? { icon: iconPath } : {}),
titleBarStyle: isMac ? "hidden" : "default",
trafficLightPosition: isMac ? { x: 16, y: 20 } : undefined,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
},
});
setupWindowResizeEvents(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});
if (!app.isPackaged) {
await mainWindow.loadURL(DEV_SERVER_URL);
mainWindow.webContents.openDevTools({ mode: "detach" });
return;
}
await mainWindow.loadFile(getProductionEntryPath());
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
function setupSingleInstanceLock(): boolean {
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
return false;
}
app.on("second-instance", () => {
const win = BrowserWindow.getAllWindows()[0];
if (win) {
win.show();
if (win.isMinimized()) win.restore();
win.focus();
}
});
return true;
}
async function runCliPassthroughIfRequested(): Promise<boolean> {
const cliArgs = parseCliPassthroughArgsFromArgv(process.argv);
if (!cliArgs) {
return false;
}
try {
const exitCode = runCliPassthroughCommand(cliArgs);
process.exit(exitCode);
} catch (error) {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
}
return true;
}
async function bootstrap(): Promise<void> {
if (await runCliPassthroughIfRequested()) {
return;
}
if (!setupSingleInstanceLock()) {
return;
}
await app.whenReady();
applyAppIcon();
setupApplicationMenu();
ensureNotificationCenterRegistration();
registerDaemonManager();
registerWindowManager();
registerDialogHandlers();
registerNotificationHandlers();
registerOpenerHandlers();
await createMainWindow();
app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await createMainWindow();
}
});
}
void bootstrap().catch((error) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
app.on("before-quit", () => {
closeAllTransportSessions();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
+51
View File
@@ -0,0 +1,51 @@
import { contextBridge, ipcRenderer } from "electron";
type EventHandler = (payload: unknown) => void;
contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform,
invoke: (command: string, args?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:invoke", command, args),
events: {
on: (event: string, handler: EventHandler): Promise<() => void> => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
handler(payload);
};
ipcRenderer.on(`paseo:event:${event}`, listener);
return Promise.resolve(() => {
ipcRenderer.removeListener(`paseo:event:${event}`, listener);
});
},
},
window: {
getCurrentWindow: () => ({
startDragging: () => ipcRenderer.invoke("paseo:window:startDragging"),
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
onResized: (handler: EventHandler): (() => void) => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
handler(payload);
};
ipcRenderer.on("paseo:window:resized", listener);
return () => {
ipcRenderer.removeListener("paseo:window:resized", listener);
};
},
setBadgeCount: (count?: number) => ipcRenderer.invoke("paseo:window:setBadgeCount", count),
}),
},
dialog: {
ask: (message: string, options?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:dialog:ask", message, options),
open: (options?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:dialog:open", options),
},
notification: {
isSupported: () => ipcRenderer.invoke("paseo:notification:isSupported"),
sendNotification: (payload: { title: string; body?: string; data?: Record<string, unknown> }) =>
ipcRenderer.invoke("paseo:notification:send", payload),
},
opener: {
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
},
});
@@ -0,0 +1,61 @@
import { app, BrowserWindow, ipcMain } from "electron";
export function registerWindowManager(): void {
ipcMain.handle("paseo:window:startDragging", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
// Desktop dragging is handled via CSS `-webkit-app-region: drag`.
// This handler exists only to satisfy the renderer bridge contract.
if (win) {
// No-op.
}
});
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
});
ipcMain.handle("paseo:window:isFullscreen", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
return win?.isFullScreen() ?? false;
});
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: number) => {
if (process.platform === "darwin" || process.platform === "linux") {
app.setBadgeCount(count ?? 0);
}
});
}
export function setupWindowResizeEvents(win: BrowserWindow): void {
win.on("resize", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on("enter-full-screen", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on("leave-full-screen", () => {
win.webContents.send("paseo:window:resized", {});
});
}
/**
* Prevent Electron from navigating to files dragged onto the window.
* The renderer handles drag-drop via standard HTML5 APIs instead.
*/
export function setupDragDropPrevention(win: BrowserWindow): void {
win.webContents.on("will-navigate", (event, url) => {
// Allow normal navigation (e.g. dev server hot-reload) but block file:// URLs
// that result from dropping files onto the window.
if (url.startsWith("file://")) {
event.preventDefault();
}
});
}
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}