chore: finalize electron desktop migration
@@ -0,0 +1,9 @@
|
||||
# Build output
|
||||
dist/
|
||||
release/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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}" "$@"
|
||||
@@ -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;
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 873 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||