6
app/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Rust build artifacts
|
||||
src-tauri/target/
|
||||
25
app/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
25
app/icons/ICONS-README.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
Waggle Installer Icons
|
||||
======================
|
||||
|
||||
This directory holds icon assets for the NSIS installer and app branding.
|
||||
The Tauri build references icons from src-tauri/icons/ for the main app icon.
|
||||
|
||||
Required assets (replace placeholders with real designs):
|
||||
|
||||
icon.ico — Main application icon (256x256, multi-resolution .ico)
|
||||
Used for: app window, taskbar, installer, desktop shortcut
|
||||
Location: src-tauri/icons/icon.ico (already exists as placeholder)
|
||||
|
||||
icon.png — PNG version (512x512 recommended)
|
||||
Used for: web display, documentation, store listings
|
||||
|
||||
header.bmp — NSIS installer header image (150x57 pixels, 24-bit BMP)
|
||||
Shown at top-right of installer wizard pages
|
||||
|
||||
sidebar.bmp — NSIS installer sidebar image (164x314 pixels, 24-bit BMP)
|
||||
Shown on welcome and finish pages
|
||||
|
||||
Design guidelines:
|
||||
- Waggle brand: honeycomb/bee/swarm motif
|
||||
- Primary colors: amber/gold (#F59E0B) on dark (#1E1B4B)
|
||||
- Clean, modern, recognizable at small sizes
|
||||
13
app/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/waggle-logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Waggle</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3449
app/package-lock.json
generated
Normal file
47
app/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "waggle-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc -b",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:build": "node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/bundle-node.mjs && node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build",
|
||||
"tauri:build:local": "node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/bundle-node.mjs && node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --debug",
|
||||
"tauri:build:win": "node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/bundle-node.mjs && node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target x86_64-pc-windows-msvc",
|
||||
"tauri:build:mac": "npm run tauri:build:mac:arm64 && npm run tauri:build:mac:x64",
|
||||
"tauri:build:mac:arm64": "node ../scripts/build-sidecar.mjs && TARGET_ARCH=arm64 node ../scripts/bundle-native-deps.mjs && TARGET_ARCH=arm64 node ../scripts/bundle-node.mjs && TARGET_ARCH=arm64 node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target aarch64-apple-darwin",
|
||||
"tauri:build:mac:x64": "node ../scripts/build-sidecar.mjs && TARGET_ARCH=x64 node ../scripts/bundle-native-deps.mjs && TARGET_ARCH=x64 node ../scripts/bundle-node.mjs && TARGET_ARCH=x64 node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target x86_64-apple-darwin",
|
||||
"tauri:dev": "npx tauri dev",
|
||||
"tauri:sign:pilot:win:setup": "powershell -ExecutionPolicy Bypass -File scripts/sign-windows-pilot.ps1 -Mode Setup",
|
||||
"tauri:sign:pilot:win:apply": "node scripts/apply-signing-config.mjs",
|
||||
"tauri:sign:pilot:win:sign": "powershell -ExecutionPolicy Bypass -File scripts/sign-windows-pilot.ps1 -Mode Sign -ArtifactPath",
|
||||
"tauri:sign:pilot:mac:adhoc": "bash scripts/sign-macos-adhoc.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
BIN
app/public/waggle-logo.jpeg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
23
app/public/waggle-logo.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120" fill="none">
|
||||
<!-- Waggle bee logo — two hexagons (body), wings, antennae -->
|
||||
|
||||
<!-- Antennae -->
|
||||
<path d="M42 28 L38 16" stroke="#E8920F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="M58 28 L62 16" stroke="#E8920F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
|
||||
<!-- Upper hexagon (head) -->
|
||||
<path d="M50 26 L65 35 L65 51 L50 60 L35 51 L35 35 Z"
|
||||
stroke="#E8920F" stroke-width="3" stroke-linejoin="round" fill="none"/>
|
||||
|
||||
<!-- Lower hexagon (abdomen) -->
|
||||
<path d="M50 60 L65 69 L65 85 L50 94 L35 85 L35 69 Z"
|
||||
stroke="#E8920F" stroke-width="3" stroke-linejoin="round" fill="none"/>
|
||||
|
||||
<!-- Left wing -->
|
||||
<path d="M35 51 L18 46 Q10 50 18 58 L35 60"
|
||||
stroke="#E8920F" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" fill="none"/>
|
||||
|
||||
<!-- Right wing -->
|
||||
<path d="M65 51 L82 46 Q90 50 82 58 L65 60"
|
||||
stroke="#E8920F" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
130
app/scripts/apply-signing-config.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* apply-signing-config.mjs — write the captured Windows code-signing thumbprint
|
||||
* into app/src-tauri/tauri.build-override.conf.json.
|
||||
*
|
||||
* Read by `npm run tauri:sign:pilot:win:apply`. Idempotent: re-running with the
|
||||
* same thumbprint produces an identical file. Updating the cert (rotation) is
|
||||
* handled by re-running the upstream cert-gen script + this CLI.
|
||||
*
|
||||
* Pure logic lives in `signing-config.ts`; this is the thin file-I/O wrapper.
|
||||
*
|
||||
* Reference: docs/code-signing-pilot-and-launch.md §1.1
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// The pure helpers below mirror app/scripts/signing-config.ts so this CLI has
|
||||
// zero TS-loader dependency at runtime. The .ts version is the canonical
|
||||
// implementation tested by signing-config.test.ts (19 cases covering parse,
|
||||
// merge, idempotency, immutability). Keep the two implementations in lockstep:
|
||||
// any change to parseThumbprintString or addWindowsSigningToOverride below
|
||||
// MUST be mirrored in signing-config.ts and vice versa.
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const APP_DIR = resolve(SCRIPT_DIR, '..');
|
||||
const REPO_ROOT = resolve(APP_DIR, '..');
|
||||
|
||||
const OVERRIDE_PATH = resolve(
|
||||
APP_DIR,
|
||||
'src-tauri',
|
||||
'tauri.build-override.conf.json',
|
||||
);
|
||||
const THUMBPRINT_PATH = resolve(APP_DIR, 'src-tauri', '.thumbprint.txt');
|
||||
|
||||
const DEFAULT_DIGEST_ALGORITHM = 'sha256';
|
||||
const DEFAULT_TIMESTAMP_URL = 'http://timestamp.digicert.com';
|
||||
const THUMBPRINT_LENGTH = 40;
|
||||
const HEX_PATTERN = /^[0-9A-F]+$/;
|
||||
|
||||
function parseThumbprintString(raw) {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
throw new Error('Thumbprint is empty — cert generation may have failed.');
|
||||
}
|
||||
const compact = raw.replace(/\s+/g, '').toUpperCase();
|
||||
if (compact.length !== THUMBPRINT_LENGTH || !HEX_PATTERN.test(compact)) {
|
||||
throw new Error(
|
||||
`Thumbprint must be 40 hex characters; got ${compact.length} chars (sample: "${compact.slice(0, 16)}...").`,
|
||||
);
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
function addWindowsSigningToOverride(config, thumbprint, options = {}) {
|
||||
const normalisedThumbprint = parseThumbprintString(thumbprint);
|
||||
const digestAlgorithm = options.digestAlgorithm ?? DEFAULT_DIGEST_ALGORITHM;
|
||||
const timestampUrl = options.timestampUrl ?? DEFAULT_TIMESTAMP_URL;
|
||||
|
||||
const existingBundle = config.bundle ?? {};
|
||||
const existingWindows = existingBundle.windows ?? {};
|
||||
|
||||
return {
|
||||
...config,
|
||||
bundle: {
|
||||
...existingBundle,
|
||||
windows: {
|
||||
...existingWindows,
|
||||
certificateThumbprint: normalisedThumbprint,
|
||||
digestAlgorithm,
|
||||
timestampUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
if (!existsSync(THUMBPRINT_PATH)) {
|
||||
console.error(
|
||||
`[apply-signing-config] thumbprint file missing: ${THUMBPRINT_PATH}`,
|
||||
);
|
||||
console.error(
|
||||
'[apply-signing-config] Run `npm run tauri:sign:pilot:win:setup` (or app/scripts/sign-windows-pilot.ps1 -Mode Setup) first.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!existsSync(OVERRIDE_PATH)) {
|
||||
console.error(
|
||||
`[apply-signing-config] override config missing: ${OVERRIDE_PATH}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rawThumbprint = readFileSync(THUMBPRINT_PATH, 'utf8');
|
||||
const overrideRaw = readFileSync(OVERRIDE_PATH, 'utf8');
|
||||
|
||||
let override;
|
||||
try {
|
||||
override = JSON.parse(overrideRaw);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[apply-signing-config] failed to parse ${OVERRIDE_PATH}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let updated;
|
||||
try {
|
||||
updated = addWindowsSigningToOverride(override, rawThumbprint);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[apply-signing-config] failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Format with 2-space indent + trailing newline (matches existing JSON files
|
||||
// in the repo). Idempotent: same input → same output bytes.
|
||||
const serialised = JSON.stringify(updated, null, 2) + '\n';
|
||||
writeFileSync(OVERRIDE_PATH, serialised, 'utf8');
|
||||
|
||||
const relativePath = OVERRIDE_PATH.replace(REPO_ROOT, '').replace(/^\\/, '');
|
||||
console.log(
|
||||
`[apply-signing-config] wrote thumbprint ${updated.bundle.windows.certificateThumbprint.slice(0, 8)}... to ${relativePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
180
app/scripts/bundle-runtimes.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getNodeDownloadUrl,
|
||||
getPythonDownloadUrl,
|
||||
getResourcePaths,
|
||||
getBundleStatus,
|
||||
parseVersion,
|
||||
isValidVersion,
|
||||
} from './bundle-utils.js';
|
||||
|
||||
// ─── getNodeDownloadUrl ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getNodeDownloadUrl', () => {
|
||||
it('returns Windows x64 URL by default', () => {
|
||||
const url = getNodeDownloadUrl('20.11.1');
|
||||
expect(url).toBe('https://nodejs.org/dist/v20.11.1/win-x64/node.exe');
|
||||
});
|
||||
|
||||
it('returns Windows arm64 URL', () => {
|
||||
const url = getNodeDownloadUrl('20.11.1', 'win32', 'arm64');
|
||||
expect(url).toBe('https://nodejs.org/dist/v20.11.1/win-arm64/node.exe');
|
||||
});
|
||||
|
||||
it('returns macOS tar.gz URL', () => {
|
||||
const url = getNodeDownloadUrl('20.11.1', 'darwin', 'x64');
|
||||
expect(url).toContain('darwin-x64.tar.gz');
|
||||
});
|
||||
|
||||
it('returns Linux tar.xz URL', () => {
|
||||
const url = getNodeDownloadUrl('20.11.1', 'linux', 'x64');
|
||||
expect(url).toContain('linux-x64.tar.xz');
|
||||
});
|
||||
|
||||
it('includes the version in the URL', () => {
|
||||
const url = getNodeDownloadUrl('18.19.0');
|
||||
expect(url).toContain('v18.19.0');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getPythonDownloadUrl ────────────────────────────────────────────────────
|
||||
|
||||
describe('getPythonDownloadUrl', () => {
|
||||
it('returns Windows amd64 embed URL by default', () => {
|
||||
const url = getPythonDownloadUrl('3.11.8');
|
||||
expect(url).toBe(
|
||||
'https://www.python.org/ftp/python/3.11.8/python-3.11.8-embed-amd64.zip',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns Windows arm64 URL', () => {
|
||||
const url = getPythonDownloadUrl('3.11.8', 'win32', 'arm64');
|
||||
expect(url).toContain('embed-arm64.zip');
|
||||
});
|
||||
|
||||
it('returns macOS pkg URL', () => {
|
||||
const url = getPythonDownloadUrl('3.11.8', 'darwin', 'x64');
|
||||
expect(url).toContain('macos11.pkg');
|
||||
});
|
||||
|
||||
it('returns Linux source URL', () => {
|
||||
const url = getPythonDownloadUrl('3.11.8', 'linux', 'x64');
|
||||
expect(url).toContain('Python-3.11.8.tar.xz');
|
||||
});
|
||||
|
||||
it('includes the version in the URL', () => {
|
||||
const url = getPythonDownloadUrl('3.12.1');
|
||||
expect(url).toContain('3.12.1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getResourcePaths ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResourcePaths', () => {
|
||||
it('returns correct Windows paths when platform is win32', () => {
|
||||
const dir = '/app/src-tauri/resources';
|
||||
const paths = getResourcePaths(dir, 'win32');
|
||||
|
||||
expect(paths.node).toBe(path.join(dir, 'node', 'node.exe'));
|
||||
expect(paths.python).toBe(path.join(dir, 'python', 'python.exe'));
|
||||
expect(paths.litellm).toBe(
|
||||
path.join(dir, 'python', 'Lib', 'site-packages', 'litellm'),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles Windows-style paths', () => {
|
||||
const dir = 'C:\\Users\\user\\app\\resources';
|
||||
const paths = getResourcePaths(dir, 'win32');
|
||||
|
||||
expect(paths.node).toContain('node.exe');
|
||||
expect(paths.python).toContain('python.exe');
|
||||
expect(paths.litellm).toContain('litellm');
|
||||
});
|
||||
|
||||
it('returns correct Unix paths when platform is darwin', () => {
|
||||
const dir = '/app/src-tauri/resources';
|
||||
const paths = getResourcePaths(dir, 'darwin');
|
||||
|
||||
expect(paths.node).toBe(path.join(dir, 'node', 'bin', 'node'));
|
||||
expect(paths.python).toBe(path.join(dir, 'python', 'bin', 'python3'));
|
||||
expect(paths.litellm).toBe(
|
||||
path.join(dir, 'python', 'lib', 'python3.11', 'site-packages', 'litellm'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct Unix paths when platform is linux', () => {
|
||||
const dir = '/app/src-tauri/resources';
|
||||
const paths = getResourcePaths(dir, 'linux');
|
||||
|
||||
expect(paths.node).toBe(path.join(dir, 'node', 'bin', 'node'));
|
||||
expect(paths.python).toBe(path.join(dir, 'python', 'bin', 'python3'));
|
||||
expect(paths.litellm).toBe(
|
||||
path.join(dir, 'python', 'lib', 'python3.11', 'site-packages', 'litellm'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getBundleStatus ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getBundleStatus', () => {
|
||||
it('reports all missing when nothing exists', () => {
|
||||
const mockExists = () => false;
|
||||
const status = getBundleStatus('/fake/dir', mockExists);
|
||||
|
||||
expect(status.nodeReady).toBe(false);
|
||||
expect(status.pythonReady).toBe(false);
|
||||
expect(status.litellmReady).toBe(false);
|
||||
});
|
||||
|
||||
it('reports all ready when all exist', () => {
|
||||
const mockExists = () => true;
|
||||
const status = getBundleStatus('/fake/dir', mockExists);
|
||||
|
||||
expect(status.nodeReady).toBe(true);
|
||||
expect(status.pythonReady).toBe(true);
|
||||
expect(status.litellmReady).toBe(true);
|
||||
});
|
||||
|
||||
it('reports partial status correctly', () => {
|
||||
const paths = getResourcePaths('/fake/dir', process.platform);
|
||||
const existingPaths = new Set([paths.node, paths.python]);
|
||||
const mockExists = (p: string) => existingPaths.has(p);
|
||||
|
||||
const status = getBundleStatus('/fake/dir', mockExists);
|
||||
|
||||
expect(status.nodeReady).toBe(true);
|
||||
expect(status.pythonReady).toBe(true);
|
||||
expect(status.litellmReady).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseVersion ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseVersion', () => {
|
||||
it('parses a standard semver string', () => {
|
||||
const v = parseVersion('20.11.1');
|
||||
expect(v).toEqual({ major: 20, minor: 11, patch: 1 });
|
||||
});
|
||||
|
||||
it('parses a version with zeros', () => {
|
||||
const v = parseVersion('3.0.0');
|
||||
expect(v).toEqual({ major: 3, minor: 0, patch: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidVersion ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('isValidVersion', () => {
|
||||
it('accepts a valid version', () => {
|
||||
expect(isValidVersion('20.11.1')).toBe(true);
|
||||
expect(isValidVersion('3.11.8')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid versions', () => {
|
||||
expect(isValidVersion('20.11')).toBe(false);
|
||||
expect(isValidVersion('abc')).toBe(false);
|
||||
expect(isValidVersion('20.11.1.2')).toBe(false);
|
||||
expect(isValidVersion('')).toBe(false);
|
||||
});
|
||||
});
|
||||
166
app/scripts/bundle-runtimes.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* bundle-runtimes — Download and prepare Node.js + Python runtimes
|
||||
* for embedding in the Tauri installer.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx app/scripts/bundle-runtimes.ts # Download all
|
||||
* npx tsx app/scripts/bundle-runtimes.ts --status # Check what's ready
|
||||
*
|
||||
* Output directory: app/src-tauri/resources/
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync, createWriteStream } from 'node:fs';
|
||||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Readable } from 'node:stream';
|
||||
import { getNodeDownloadUrl, getPythonDownloadUrl, getResourcePaths, getBundleStatus } from './bundle-utils.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = path.dirname(_filename);
|
||||
const RESOURCES_DIR = path.resolve(_dirname, '..', 'src-tauri', 'resources');
|
||||
|
||||
const NODE_VERSION = '20.11.1';
|
||||
const PYTHON_VERSION = '3.11.8';
|
||||
|
||||
// ─── Side-effect functions (download / install) ─────────────────────────────
|
||||
|
||||
async function downloadFile(url: string, destPath: string): Promise<void> {
|
||||
console.log(` Downloading: ${url}`);
|
||||
console.log(` Destination: ${destPath}`);
|
||||
|
||||
await mkdir(path.dirname(destPath), { recursive: true });
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} downloading ${url}`);
|
||||
}
|
||||
|
||||
const fileStream = createWriteStream(destPath);
|
||||
// Convert web ReadableStream to Node.js Readable
|
||||
const body = res.body;
|
||||
if (!body) throw new Error('Empty response body');
|
||||
const nodeStream = Readable.fromWeb(body as import('node:stream/web').ReadableStream);
|
||||
await pipeline(nodeStream, fileStream);
|
||||
console.log(` Done.`);
|
||||
}
|
||||
|
||||
async function downloadNodeBinary(version: string, resourcesDir: string): Promise<void> {
|
||||
const url = getNodeDownloadUrl(version);
|
||||
const dest = getResourcePaths(resourcesDir).node;
|
||||
await downloadFile(url, dest);
|
||||
}
|
||||
|
||||
async function downloadPythonEmbed(version: string, resourcesDir: string): Promise<void> {
|
||||
if (process.platform !== 'win32') {
|
||||
console.warn('Warning: Python embed bundling is currently only supported on Windows.');
|
||||
console.warn('On macOS/Linux, use system Python or a different bundling strategy.');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getPythonDownloadUrl(version);
|
||||
const zipDest = path.join(resourcesDir, 'python', `python-${version}-embed.zip`);
|
||||
await downloadFile(url, zipDest);
|
||||
|
||||
const pythonDir = path.join(resourcesDir, 'python');
|
||||
console.log(` Extracting to ${pythonDir}...`);
|
||||
execFileSync('powershell', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Expand-Archive -Force -Path "${zipDest}" -DestinationPath "${pythonDir}"`,
|
||||
]);
|
||||
|
||||
await rm(zipDest, { force: true });
|
||||
console.log(` Extracted.`);
|
||||
}
|
||||
|
||||
async function installLiteLLM(resourcesDir: string): Promise<void> {
|
||||
const pythonExe = getResourcePaths(resourcesDir).python;
|
||||
if (!existsSync(pythonExe)) {
|
||||
throw new Error('Python must be downloaded before installing LiteLLM');
|
||||
}
|
||||
|
||||
const pythonDir = path.join(resourcesDir, 'python');
|
||||
const pthFiles = readdirSync(pythonDir).filter((f) => f.endsWith('._pth'));
|
||||
for (const pth of pthFiles) {
|
||||
const pthPath = path.join(pythonDir, pth);
|
||||
const content = readFileSync(pthPath, 'utf8');
|
||||
const patched = content.replace(/^#\s*import site/m, 'import site');
|
||||
writeFileSync(pthPath, patched);
|
||||
}
|
||||
|
||||
const getPipUrl = 'https://bootstrap.pypa.io/get-pip.py';
|
||||
const getPipDest = path.join(pythonDir, 'get-pip.py');
|
||||
await downloadFile(getPipUrl, getPipDest);
|
||||
|
||||
console.log(' Installing pip...');
|
||||
const targetDir = path.join(pythonDir, 'Lib', 'site-packages');
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
execFileSync(pythonExe, [getPipDest, '--target', targetDir, '--no-warn-script-location'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
console.log(' Installing litellm...');
|
||||
execFileSync(pythonExe, ['-m', 'pip', 'install', '--target', targetDir, 'litellm', '--no-warn-script-location'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PYTHONPATH: targetDir },
|
||||
});
|
||||
|
||||
await rm(getPipDest, { force: true });
|
||||
console.log(' LiteLLM installed.');
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--status')) {
|
||||
const status = getBundleStatus(RESOURCES_DIR);
|
||||
console.log('Bundle status:');
|
||||
console.log(` Node.js : ${status.nodeReady ? 'READY' : 'NOT FOUND'}`);
|
||||
console.log(` Python : ${status.pythonReady ? 'READY' : 'NOT FOUND'}`);
|
||||
console.log(` LiteLLM : ${status.litellmReady ? 'READY' : 'NOT FOUND'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== Waggle Runtime Bundler ===\n');
|
||||
console.log(`Resources dir: ${RESOURCES_DIR}`);
|
||||
console.log(`Node.js ${NODE_VERSION} | Python ${PYTHON_VERSION}\n`);
|
||||
|
||||
await mkdir(RESOURCES_DIR, { recursive: true });
|
||||
|
||||
const status = getBundleStatus(RESOURCES_DIR);
|
||||
|
||||
if (!status.nodeReady) {
|
||||
console.log('[1/3] Downloading Node.js...');
|
||||
await downloadNodeBinary(NODE_VERSION, RESOURCES_DIR);
|
||||
} else {
|
||||
console.log('[1/3] Node.js already present, skipping.');
|
||||
}
|
||||
|
||||
if (!status.pythonReady) {
|
||||
console.log('[2/3] Downloading embedded Python...');
|
||||
await downloadPythonEmbed(PYTHON_VERSION, RESOURCES_DIR);
|
||||
} else {
|
||||
console.log('[2/3] Python already present, skipping.');
|
||||
}
|
||||
|
||||
if (!status.litellmReady) {
|
||||
console.log('[3/3] Installing LiteLLM...');
|
||||
await installLiteLLM(RESOURCES_DIR);
|
||||
} else {
|
||||
console.log('[3/3] LiteLLM already present, skipping.');
|
||||
}
|
||||
|
||||
console.log('\n=== All runtimes ready! ===');
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
123
app/scripts/bundle-utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* bundle-utils.ts — Pure utility functions for runtime bundling.
|
||||
* No side effects — safe to import in tests.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// ─── URL builders ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the download URL for a Node.js binary.
|
||||
*/
|
||||
export function getNodeDownloadUrl(
|
||||
version: string,
|
||||
platform: string = 'win32',
|
||||
arch: string = 'x64',
|
||||
): string {
|
||||
if (platform === 'win32') {
|
||||
return `https://nodejs.org/dist/v${version}/win-${arch}/node.exe`;
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return `https://nodejs.org/dist/v${version}/node-v${version}-darwin-${arch}.tar.gz`;
|
||||
}
|
||||
// linux
|
||||
return `https://nodejs.org/dist/v${version}/node-v${version}-linux-${arch}.tar.xz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the download URL for an embeddable Python zip.
|
||||
*/
|
||||
export function getPythonDownloadUrl(
|
||||
version: string,
|
||||
platform: string = 'win32',
|
||||
arch: string = 'x64',
|
||||
): string {
|
||||
if (platform === 'win32') {
|
||||
const archSuffix = arch === 'x64' ? 'amd64' : arch;
|
||||
return `https://www.python.org/ftp/python/${version}/python-${version}-embed-${archSuffix}.zip`;
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return `https://www.python.org/ftp/python/${version}/python-${version}-macos11.pkg`;
|
||||
}
|
||||
return `https://www.python.org/ftp/python/${version}/Python-${version}.tar.xz`;
|
||||
}
|
||||
|
||||
// ─── Path helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ResourcePaths {
|
||||
node: string;
|
||||
python: string;
|
||||
litellm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the expected file paths for each bundled component.
|
||||
*/
|
||||
export function getResourcePaths(resourcesDir: string, platform: string = process.platform): ResourcePaths {
|
||||
if (platform === 'win32') {
|
||||
return {
|
||||
node: path.join(resourcesDir, 'node', 'node.exe'),
|
||||
python: path.join(resourcesDir, 'python', 'python.exe'),
|
||||
litellm: path.join(resourcesDir, 'python', 'Lib', 'site-packages', 'litellm'),
|
||||
};
|
||||
}
|
||||
// darwin / linux
|
||||
return {
|
||||
node: path.join(resourcesDir, 'node', 'bin', 'node'),
|
||||
python: path.join(resourcesDir, 'python', 'bin', 'python3'),
|
||||
litellm: path.join(resourcesDir, 'python', 'lib', 'python3.11', 'site-packages', 'litellm'),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Status ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BundleStatus {
|
||||
nodeReady: boolean;
|
||||
pythonReady: boolean;
|
||||
litellmReady: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which runtimes are already present and ready.
|
||||
* Accepts an optional existsSync override for testing.
|
||||
*/
|
||||
export function getBundleStatus(
|
||||
resourcesDir: string,
|
||||
_existsSync: (p: string) => boolean = existsSync,
|
||||
): BundleStatus {
|
||||
const paths = getResourcePaths(resourcesDir);
|
||||
return {
|
||||
nodeReady: _existsSync(paths.node),
|
||||
pythonReady: _existsSync(paths.python),
|
||||
litellmReady: _existsSync(paths.litellm),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Version helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ParsedVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a version string like '20.11.1' into { major, minor, patch }.
|
||||
*/
|
||||
export function parseVersion(version: string): ParsedVersion {
|
||||
const parts = version.split('.').map(Number);
|
||||
return {
|
||||
major: parts[0] || 0,
|
||||
minor: parts[1] || 0,
|
||||
patch: parts[2] || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a version string (must be X.Y.Z with numeric parts).
|
||||
*/
|
||||
export function isValidVersion(version: string): boolean {
|
||||
return /^\d+\.\d+\.\d+$/.test(version);
|
||||
}
|
||||
263
app/scripts/installer-config.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
getDefaultInstallerConfig,
|
||||
generateNsisDefines,
|
||||
validateInstallPath,
|
||||
isSystemPath,
|
||||
getUninstallPrompt,
|
||||
getVersionFromPackage,
|
||||
} from './installer-config.js';
|
||||
|
||||
// ─── getDefaultInstallerConfig ──────────────────────────────────────────────
|
||||
|
||||
describe('getDefaultInstallerConfig', () => {
|
||||
it('returns a valid config with expected defaults', () => {
|
||||
const config = getDefaultInstallerConfig();
|
||||
expect(config.productName).toBe('Waggle');
|
||||
expect(config.version).toBe('0.1.0');
|
||||
expect(config.publisher).toBe('Waggle');
|
||||
expect(config.defaultInstallDir).toBe('C:\\Program Files\\Waggle');
|
||||
expect(config.dataDir).toBe('~/.waggle');
|
||||
expect(config.autostart).toBe(true);
|
||||
expect(config.desktopShortcut).toBe(true);
|
||||
expect(config.startMenuEntry).toBe(true);
|
||||
expect(config.launchAfterInstall).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a new object each time', () => {
|
||||
const a = getDefaultInstallerConfig();
|
||||
const b = getDefaultInstallerConfig();
|
||||
expect(a).toEqual(b);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── generateNsisDefines ───────────────────────────────────────────────────
|
||||
|
||||
describe('generateNsisDefines', () => {
|
||||
it('maps all config fields to NSIS defines', () => {
|
||||
const config = getDefaultInstallerConfig();
|
||||
const defines = generateNsisDefines(config);
|
||||
|
||||
expect(defines.PRODUCT_NAME).toBe('Waggle');
|
||||
expect(defines.PRODUCT_VERSION).toBe('0.1.0');
|
||||
expect(defines.PRODUCT_PUBLISHER).toBe('Waggle');
|
||||
expect(defines.DEFAULT_INSTALL_DIR).toBe('C:\\Program Files\\Waggle');
|
||||
expect(defines.DATA_DIR).toBe('~/.waggle');
|
||||
expect(defines.AUTOSTART).toBe('1');
|
||||
expect(defines.DESKTOP_SHORTCUT).toBe('1');
|
||||
expect(defines.START_MENU_ENTRY).toBe('1');
|
||||
expect(defines.LAUNCH_AFTER_INSTALL).toBe('1');
|
||||
});
|
||||
|
||||
it('sets boolean defines to "0" when disabled', () => {
|
||||
const config = getDefaultInstallerConfig();
|
||||
config.autostart = false;
|
||||
config.desktopShortcut = false;
|
||||
config.startMenuEntry = false;
|
||||
config.launchAfterInstall = false;
|
||||
|
||||
const defines = generateNsisDefines(config);
|
||||
|
||||
expect(defines.AUTOSTART).toBe('0');
|
||||
expect(defines.DESKTOP_SHORTCUT).toBe('0');
|
||||
expect(defines.START_MENU_ENTRY).toBe('0');
|
||||
expect(defines.LAUNCH_AFTER_INSTALL).toBe('0');
|
||||
});
|
||||
|
||||
it('handles custom product names and versions', () => {
|
||||
const config = getDefaultInstallerConfig();
|
||||
config.productName = 'Waggle Pro';
|
||||
config.version = '2.5.0';
|
||||
|
||||
const defines = generateNsisDefines(config);
|
||||
expect(defines.PRODUCT_NAME).toBe('Waggle Pro');
|
||||
expect(defines.PRODUCT_VERSION).toBe('2.5.0');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateInstallPath ────────────────────────────────────────────────────
|
||||
|
||||
describe('validateInstallPath', () => {
|
||||
it('accepts a standard Program Files path', () => {
|
||||
const result = validateInstallPath('C:\\Program Files\\Waggle');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a custom install path', () => {
|
||||
const result = validateInstallPath('D:\\Apps\\Waggle');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts forward slashes', () => {
|
||||
const result = validateInstallPath('C:/Users/test/Waggle');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts UNC paths', () => {
|
||||
const result = validateInstallPath('\\\\server\\share\\Waggle');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty path', () => {
|
||||
const result = validateInstallPath('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('empty');
|
||||
});
|
||||
|
||||
it('rejects whitespace-only path', () => {
|
||||
const result = validateInstallPath(' ');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('empty');
|
||||
});
|
||||
|
||||
it('rejects relative paths', () => {
|
||||
const result = validateInstallPath('Waggle\\bin');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('absolute');
|
||||
});
|
||||
|
||||
it('rejects paths with invalid characters', () => {
|
||||
const result = validateInstallPath('C:\\Program Files\\Waggle<test>');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('invalid characters');
|
||||
});
|
||||
|
||||
it('rejects paths that are too long', () => {
|
||||
const longPath = 'C:\\' + 'a'.repeat(250);
|
||||
const result = validateInstallPath(longPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('too long');
|
||||
});
|
||||
|
||||
it('rejects bare drive root', () => {
|
||||
const result = validateInstallPath('C:\\');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('drive root');
|
||||
});
|
||||
|
||||
it('rejects drive letter without backslash', () => {
|
||||
const result = validateInstallPath('C:');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isSystemPath ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('isSystemPath', () => {
|
||||
it('detects Program Files', () => {
|
||||
expect(isSystemPath('C:\\Program Files\\Waggle')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Program Files (x86)', () => {
|
||||
expect(isSystemPath('C:\\Program Files (x86)\\Waggle')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Windows directory', () => {
|
||||
expect(isSystemPath('C:\\Windows\\System32')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects ProgramData', () => {
|
||||
expect(isSystemPath('C:\\ProgramData\\Waggle')).toBe(true);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(isSystemPath('c:\\program files\\waggle')).toBe(true);
|
||||
expect(isSystemPath('C:\\PROGRAM FILES\\Waggle')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles forward slashes', () => {
|
||||
expect(isSystemPath('C:/Program Files/Waggle')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for user directories', () => {
|
||||
expect(isSystemPath('C:\\Users\\test\\Waggle')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for custom paths', () => {
|
||||
expect(isSystemPath('D:\\Apps\\Waggle')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getUninstallPrompt ─────────────────────────────────────────────────────
|
||||
|
||||
describe('getUninstallPrompt', () => {
|
||||
it('includes the data directory in the prompt', () => {
|
||||
const prompt = getUninstallPrompt('~/.waggle');
|
||||
expect(prompt).toContain('~/.waggle');
|
||||
});
|
||||
|
||||
it('mentions keeping data for future use', () => {
|
||||
const prompt = getUninstallPrompt('C:\\Users\\test\\.waggle');
|
||||
expect(prompt).toContain('keep it for future use');
|
||||
});
|
||||
|
||||
it('mentions deleting all data option', () => {
|
||||
const prompt = getUninstallPrompt('~/.waggle');
|
||||
expect(prompt).toContain('delete all data');
|
||||
});
|
||||
|
||||
it('mentions agents and memories', () => {
|
||||
const prompt = getUninstallPrompt('~/.waggle');
|
||||
expect(prompt).toContain('agents');
|
||||
expect(prompt).toContain('memories');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getVersionFromPackage ──────────────────────────────────────────────────
|
||||
|
||||
describe('getVersionFromPackage', () => {
|
||||
const tmpDir = path.join(tmpdir(), 'waggle-installer-test-' + Date.now());
|
||||
|
||||
// Setup / teardown
|
||||
const setup = () => mkdirSync(tmpDir, { recursive: true });
|
||||
const cleanup = () => rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
it('reads the version from a valid package.json', () => {
|
||||
setup();
|
||||
try {
|
||||
const pkgPath = path.join(tmpDir, 'package.json');
|
||||
writeFileSync(pkgPath, JSON.stringify({ name: 'test', version: '1.2.3' }));
|
||||
expect(getVersionFromPackage(pkgPath)).toBe('1.2.3');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws for package.json without version', () => {
|
||||
setup();
|
||||
try {
|
||||
const pkgPath = path.join(tmpDir, 'package.json');
|
||||
writeFileSync(pkgPath, JSON.stringify({ name: 'test' }));
|
||||
expect(() => getVersionFromPackage(pkgPath)).toThrow('No valid "version"');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws for non-existent file', () => {
|
||||
expect(() => getVersionFromPackage('/nonexistent/package.json')).toThrow();
|
||||
});
|
||||
|
||||
it('reads the actual app package.json version', () => {
|
||||
const appPkgPath = path.resolve(__dirname, '..', 'package.json');
|
||||
const version = getVersionFromPackage(appPkgPath);
|
||||
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it('throws with a descriptive error for malformed JSON', () => {
|
||||
setup();
|
||||
try {
|
||||
const pkgPath = path.join(tmpDir, 'bad.json');
|
||||
writeFileSync(pkgPath, '{ not valid json!!!');
|
||||
expect(() => getVersionFromPackage(pkgPath)).toThrow('Failed to parse');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
164
app/scripts/installer-config.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* installer-config.ts — Testable utility functions for NSIS installer configuration.
|
||||
* No side effects — safe to import in tests.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface InstallerConfig {
|
||||
productName: string;
|
||||
version: string;
|
||||
publisher: string;
|
||||
defaultInstallDir: string;
|
||||
dataDir: string; // ~/.waggle
|
||||
autostart: boolean;
|
||||
desktopShortcut: boolean;
|
||||
startMenuEntry: boolean;
|
||||
launchAfterInstall: boolean;
|
||||
}
|
||||
|
||||
// ─── Default config ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getDefaultInstallerConfig(): InstallerConfig {
|
||||
return {
|
||||
productName: 'Waggle',
|
||||
version: '0.1.0',
|
||||
publisher: 'Waggle',
|
||||
defaultInstallDir: 'C:\\Program Files\\Waggle',
|
||||
dataDir: '~/.waggle',
|
||||
autostart: true,
|
||||
desktopShortcut: true,
|
||||
startMenuEntry: true,
|
||||
launchAfterInstall: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── NSIS defines ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate NSIS !define statements from an InstallerConfig.
|
||||
* These become compile-time constants in the NSIS script.
|
||||
*/
|
||||
export function generateNsisDefines(
|
||||
config: InstallerConfig,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
PRODUCT_NAME: config.productName,
|
||||
PRODUCT_VERSION: config.version,
|
||||
PRODUCT_PUBLISHER: config.publisher,
|
||||
DEFAULT_INSTALL_DIR: config.defaultInstallDir,
|
||||
DATA_DIR: config.dataDir,
|
||||
AUTOSTART: config.autostart ? '1' : '0',
|
||||
DESKTOP_SHORTCUT: config.desktopShortcut ? '1' : '0',
|
||||
START_MENU_ENTRY: config.startMenuEntry ? '1' : '0',
|
||||
LAUNCH_AFTER_INSTALL: config.launchAfterInstall ? '1' : '0',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Path validation ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a Windows install directory path.
|
||||
* Returns { valid, error? }.
|
||||
*/
|
||||
export function validateInstallPath(
|
||||
installPath: string,
|
||||
): { valid: boolean; error?: string } {
|
||||
if (!installPath || installPath.trim().length === 0) {
|
||||
return { valid: false, error: 'Install path cannot be empty' };
|
||||
}
|
||||
|
||||
const trimmed = installPath.trim();
|
||||
|
||||
// Must be an absolute path (drive letter or UNC)
|
||||
const isAbsolute =
|
||||
/^[A-Za-z]:[/\\]/.test(trimmed) || trimmed.startsWith('\\\\');
|
||||
if (!isAbsolute) {
|
||||
return { valid: false, error: 'Install path must be an absolute path' };
|
||||
}
|
||||
|
||||
// Check for invalid characters (Windows filename restrictions)
|
||||
// Drive prefix and backslashes/forward slashes are allowed
|
||||
// For UNC paths (\\server\share\...), skip the leading \\; for drive paths, skip "C:\"
|
||||
const pathBody = trimmed.startsWith('\\\\')
|
||||
? trimmed.slice(2) // skip leading "\\" for UNC
|
||||
: trimmed.slice(3); // skip "C:\" for drive paths
|
||||
if (/[<>"|?*:]/.test(pathBody)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Install path contains invalid characters: < > " | ? * :',
|
||||
};
|
||||
}
|
||||
|
||||
// Path should not be too long (Windows MAX_PATH = 260, but allow some room)
|
||||
if (trimmed.length > 240) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Install path is too long (max 240 characters)',
|
||||
};
|
||||
}
|
||||
|
||||
// Should not be a root drive path alone
|
||||
if (/^[A-Za-z]:[/\\]?$/.test(trimmed)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot install directly to a drive root',
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// ─── System path detection ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a path is under a system-protected directory (requires admin/elevation).
|
||||
*/
|
||||
export function isSystemPath(installPath: string): boolean {
|
||||
const normalized = installPath.replace(/\//g, '\\').toLowerCase();
|
||||
const systemPrefixes = [
|
||||
'c:\\program files\\',
|
||||
'c:\\program files (x86)\\',
|
||||
'c:\\windows\\',
|
||||
'c:\\programdata\\',
|
||||
];
|
||||
return systemPrefixes.some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
// ─── Uninstall prompt ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate the uninstaller prompt text for data directory removal.
|
||||
*/
|
||||
export function getUninstallPrompt(dataDir: string): string {
|
||||
return (
|
||||
`Waggle stores your personal data (agents, memories, configuration) in:\n\n` +
|
||||
` ${dataDir}\n\n` +
|
||||
`Do you want to remove this data as well?\n\n` +
|
||||
`Choose "Yes" to delete all data, or "No" to keep it for future use.`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Version from package.json ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the version string from a package.json file.
|
||||
* Throws if the file cannot be read or has no version field.
|
||||
*/
|
||||
export function getVersionFromPackage(packageJsonPath: string): string {
|
||||
const raw = readFileSync(packageJsonPath, 'utf-8');
|
||||
let pkg: Record<string, unknown>;
|
||||
try {
|
||||
pkg = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to parse ${packageJsonPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
if (!pkg.version || typeof pkg.version !== 'string') {
|
||||
throw new Error(`No valid "version" field in ${packageJsonPath}`);
|
||||
}
|
||||
return pkg.version;
|
||||
}
|
||||
58
app/scripts/sign-macos-adhoc.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# sign-macos-adhoc.sh — re-sign + verify a Tauri-built .app bundle for the
|
||||
# Wave-1 Egzakta-internal pilot using ad-hoc signing.
|
||||
#
|
||||
# Implements docs/code-signing-pilot-and-launch.md §1.2 (macOS ad-hoc).
|
||||
#
|
||||
# Usage:
|
||||
# ./sign-macos-adhoc.sh <path-to-Waggle.app>
|
||||
#
|
||||
# Tauri's bundle config (tauri.build-override.conf.json) already passes
|
||||
# `signingIdentity: "-"` to codesign at build time, so the produced .app is
|
||||
# already ad-hoc-signed. This script:
|
||||
#
|
||||
# 1. Re-signs the bundle with --force --deep to catch any nested helpers
|
||||
# (sidecar binary, native deps) that Tauri's pass missed.
|
||||
# 2. Verifies the signature with --verify --deep --strict.
|
||||
#
|
||||
# Distribute the result wrapped in .zip (NOT .dmg — Gatekeeper enforces
|
||||
# notarization more aggressively on disk images since macOS 10.15).
|
||||
#
|
||||
# NOT for public Day-0 — that requires Apple Developer ID + notarization
|
||||
# per docs/code-signing-pilot-and-launch.md §2.2.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_PATH="${1:-}"
|
||||
|
||||
if [[ -z "$APP_PATH" ]]; then
|
||||
echo "usage: $0 <path-to-Waggle.app>" >&2
|
||||
exit 64 # EX_USAGE
|
||||
fi
|
||||
|
||||
if [[ ! -d "$APP_PATH" ]]; then
|
||||
echo "[sign-macos-adhoc] not a directory: $APP_PATH" >&2
|
||||
exit 66 # EX_NOINPUT
|
||||
fi
|
||||
|
||||
if [[ ! "$APP_PATH" =~ \.app$ ]]; then
|
||||
echo "[sign-macos-adhoc] path must end in .app: $APP_PATH" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if ! command -v codesign >/dev/null 2>&1; then
|
||||
echo "[sign-macos-adhoc] codesign not found — Xcode command-line tools required." >&2
|
||||
exit 69 # EX_UNAVAILABLE
|
||||
fi
|
||||
|
||||
echo "[sign-macos-adhoc] re-signing: $APP_PATH"
|
||||
codesign --force --deep --sign - "$APP_PATH"
|
||||
|
||||
echo "[sign-macos-adhoc] verifying signature"
|
||||
codesign --verify --deep --strict "$APP_PATH"
|
||||
|
||||
echo "[sign-macos-adhoc] OK"
|
||||
echo ""
|
||||
echo "Next:"
|
||||
echo " ditto -c -k --keepParent \"$APP_PATH\" \"${APP_PATH%.app}.zip\""
|
||||
echo " # Distribute the .zip, NOT a .dmg, for the pilot."
|
||||
203
app/scripts/sign-windows-pilot.ps1
Normal file
@@ -0,0 +1,203 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Self-sign cert generation + signtool wrapper for the Wave-1 Egzakta-internal pilot build.
|
||||
|
||||
.DESCRIPTION
|
||||
Implements docs/code-signing-pilot-and-launch.md §1.1 (Windows self-sign).
|
||||
Two modes:
|
||||
|
||||
-Mode Setup Generate self-signed cert (idempotent — reuses existing cert
|
||||
by subject if it exists), export to .pfx, write thumbprint
|
||||
to app/src-tauri/.thumbprint.txt.
|
||||
-Mode Sign Sign the artefact at -ArtifactPath using the cert produced
|
||||
by Setup. Wraps signtool.exe.
|
||||
|
||||
Password resolution order (Setup): env WAGGLE_PILOT_PFX_PASSWORD; otherwise
|
||||
Read-Host -AsSecureString prompt. Setup writes the .pfx to %USERPROFILE%
|
||||
so it never lands inside the repo working tree.
|
||||
|
||||
NOT for public Day-0 signing — that uses a real EV Authenticode cert per §2.
|
||||
|
||||
.PARAMETER Mode
|
||||
Setup or Sign. Setup is idempotent — safe to re-run.
|
||||
|
||||
.PARAMETER ArtifactPath
|
||||
Required when -Mode Sign. Path to the .msi or .exe to sign.
|
||||
|
||||
.PARAMETER Subject
|
||||
Cert subject. Default: "CN=Egzakta Internal Pilot, O=Egzakta Group, C=RS".
|
||||
Override only if rotating cert identity.
|
||||
|
||||
.PARAMETER PfxPath
|
||||
Where to write the exported .pfx. Default:
|
||||
$env:USERPROFILE\waggle-pilot-codesign.pfx
|
||||
|
||||
.PARAMETER ThumbprintFile
|
||||
Where to write the captured thumbprint for downstream consumption by
|
||||
apply-signing-config.mjs. Default: app/src-tauri/.thumbprint.txt
|
||||
(relative to repo root, resolved via this script's location).
|
||||
|
||||
.PARAMETER TimestampUrl
|
||||
RFC3161 timestamp server. Default: http://timestamp.digicert.com.
|
||||
|
||||
.EXAMPLE
|
||||
PS> $env:WAGGLE_PILOT_PFX_PASSWORD = "your-strong-pw"
|
||||
PS> .\sign-windows-pilot.ps1 -Mode Setup
|
||||
Generates cert (or reuses existing), writes thumbprint to .thumbprint.txt.
|
||||
|
||||
.EXAMPLE
|
||||
PS> .\sign-windows-pilot.ps1 -Mode Sign -ArtifactPath .\target\release\bundle\msi\Waggle_0.2.0_x64_en-US.msi
|
||||
Signs the MSI using the cert from Setup.
|
||||
|
||||
.NOTES
|
||||
Last updated: LAUNCH-06 (Phase 2 Step 4).
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('Setup', 'Sign')]
|
||||
[string]$Mode,
|
||||
|
||||
[Parameter()]
|
||||
[string]$ArtifactPath,
|
||||
|
||||
[Parameter()]
|
||||
[string]$Subject = 'CN=Egzakta Internal Pilot, O=Egzakta Group, C=RS',
|
||||
|
||||
[Parameter()]
|
||||
[string]$PfxPath = (Join-Path $env:USERPROFILE 'waggle-pilot-codesign.pfx'),
|
||||
|
||||
[Parameter()]
|
||||
[string]$ThumbprintFile,
|
||||
|
||||
[Parameter()]
|
||||
[string]$TimestampUrl = 'http://timestamp.digicert.com'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ─── Resolve repo-root paths ────────────────────────────────────────────────
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$AppDir = Split-Path -Parent $ScriptDir # ...\waggle-os\app
|
||||
$RepoRoot = Split-Path -Parent $AppDir # ...\waggle-os
|
||||
|
||||
if (-not $ThumbprintFile) {
|
||||
$ThumbprintFile = Join-Path $AppDir 'src-tauri\.thumbprint.txt'
|
||||
}
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Resolve-Password {
|
||||
if ($env:WAGGLE_PILOT_PFX_PASSWORD) {
|
||||
return ConvertTo-SecureString -String $env:WAGGLE_PILOT_PFX_PASSWORD -Force -AsPlainText
|
||||
}
|
||||
Write-Host 'WAGGLE_PILOT_PFX_PASSWORD not set in env — prompting.' -ForegroundColor Yellow
|
||||
return Read-Host -Prompt 'Enter password to protect the .pfx export' -AsSecureString
|
||||
}
|
||||
|
||||
function Find-Signtool {
|
||||
# Prefer signtool from latest installed Windows SDK; fall back to PATH.
|
||||
$candidates = @(
|
||||
'C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe',
|
||||
'C:\Program Files (x86)\Windows Kits\10\bin\10.0.22000.0\x64\signtool.exe',
|
||||
'C:\Program Files (x86)\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe'
|
||||
)
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
$fromPath = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||
if ($fromPath) { return $fromPath.Source }
|
||||
throw 'signtool.exe not found. Install Windows 10 SDK or add signtool to PATH.'
|
||||
}
|
||||
|
||||
# ─── Mode: Setup ────────────────────────────────────────────────────────────
|
||||
|
||||
if ($Mode -eq 'Setup') {
|
||||
Write-Host "[setup] subject: $Subject"
|
||||
Write-Host "[setup] pfx path: $PfxPath"
|
||||
Write-Host "[setup] thumbprint out: $ThumbprintFile"
|
||||
|
||||
# Reuse existing cert by subject if present (idempotency).
|
||||
$existing = Get-ChildItem 'Cert:\CurrentUser\My' |
|
||||
Where-Object { $_.Subject -eq $Subject -and $_.HasPrivateKey } |
|
||||
Sort-Object NotAfter -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($existing -and $existing.NotAfter -gt (Get-Date)) {
|
||||
Write-Host "[setup] reusing existing cert (NotAfter $($existing.NotAfter))" -ForegroundColor Green
|
||||
$cert = $existing
|
||||
} else {
|
||||
Write-Host '[setup] generating new self-signed code-signing cert' -ForegroundColor Cyan
|
||||
$cert = New-SelfSignedCertificate `
|
||||
-Type CodeSigningCert `
|
||||
-Subject $Subject `
|
||||
-KeyUsage DigitalSignature `
|
||||
-KeySpec Signature `
|
||||
-KeyAlgorithm RSA -KeyLength 2048 `
|
||||
-NotAfter (Get-Date).AddYears(2) `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
|
||||
}
|
||||
|
||||
# Export .pfx (always — re-export is harmless and refreshes the file).
|
||||
$pwd = Resolve-Password
|
||||
Export-PfxCertificate -Cert $cert -FilePath $PfxPath -Password $pwd | Out-Null
|
||||
Write-Host "[setup] exported .pfx -> $PfxPath" -ForegroundColor Green
|
||||
|
||||
# Write thumbprint where apply-signing-config.mjs expects it.
|
||||
$thumbprintDir = Split-Path -Parent $ThumbprintFile
|
||||
if (-not (Test-Path $thumbprintDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $thumbprintDir | Out-Null
|
||||
}
|
||||
Set-Content -Path $ThumbprintFile -Value $cert.Thumbprint -Encoding ascii -NoNewline
|
||||
Write-Host "[setup] thumbprint -> $ThumbprintFile" -ForegroundColor Green
|
||||
Write-Host ''
|
||||
Write-Host 'Next:' -ForegroundColor Cyan
|
||||
Write-Host ' 1. cd app && npm run tauri:sign:pilot:win:apply'
|
||||
Write-Host ' 2. npm run tauri:build:win'
|
||||
Write-Host ' 3. .\scripts\sign-windows-pilot.ps1 -Mode Sign -ArtifactPath <path-to-msi>'
|
||||
return
|
||||
}
|
||||
|
||||
# ─── Mode: Sign ─────────────────────────────────────────────────────────────
|
||||
|
||||
if ($Mode -eq 'Sign') {
|
||||
if (-not $ArtifactPath) {
|
||||
throw '-ArtifactPath required when -Mode Sign'
|
||||
}
|
||||
if (-not (Test-Path $ArtifactPath)) {
|
||||
throw "Artifact not found: $ArtifactPath"
|
||||
}
|
||||
if (-not (Test-Path $PfxPath)) {
|
||||
throw "PFX not found at $PfxPath. Run -Mode Setup first."
|
||||
}
|
||||
|
||||
$signtool = Find-Signtool
|
||||
$pwd = Resolve-Password
|
||||
$plainPwd = [System.Net.NetworkCredential]::new('', $pwd).Password
|
||||
|
||||
Write-Host "[sign] signtool: $signtool"
|
||||
Write-Host "[sign] artifact: $ArtifactPath"
|
||||
|
||||
& $signtool sign `
|
||||
/f $PfxPath `
|
||||
/p $plainPwd `
|
||||
/tr $TimestampUrl `
|
||||
/td sha256 /fd sha256 `
|
||||
$ArtifactPath
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "signtool failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host '[sign] verifying signature' -ForegroundColor Cyan
|
||||
& $signtool verify /pa /v $ArtifactPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "signtool verify failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host '[sign] OK' -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
177
app/scripts/signing-config.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseThumbprintString,
|
||||
addWindowsSigningToOverride,
|
||||
addMacosAdhocToOverride,
|
||||
type TauriOverrideConfig,
|
||||
} from './signing-config.js';
|
||||
|
||||
// ─── parseThumbprintString ──────────────────────────────────────────────────
|
||||
|
||||
describe('parseThumbprintString', () => {
|
||||
it('returns the uppercased thumbprint when given valid 40-hex input', () => {
|
||||
const raw = 'abcdef0123456789abcdef0123456789abcdef01';
|
||||
expect(parseThumbprintString(raw)).toBe(
|
||||
'ABCDEF0123456789ABCDEF0123456789ABCDEF01',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips surrounding whitespace and trailing newline (PowerShell output shape)', () => {
|
||||
const raw = ' ABCDEF0123456789ABCDEF0123456789ABCDEF01\r\n';
|
||||
expect(parseThumbprintString(raw)).toBe(
|
||||
'ABCDEF0123456789ABCDEF0123456789ABCDEF01',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes embedded whitespace inside the thumbprint (some clipboards introduce spaces)', () => {
|
||||
const raw = 'AB CD EF 01 23 45 67 89 AB CD EF 01 23 45 67 89 AB CD EF 01';
|
||||
expect(parseThumbprintString(raw)).toBe(
|
||||
'ABCDEF0123456789ABCDEF0123456789ABCDEF01',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the thumbprint is shorter than 40 chars', () => {
|
||||
expect(() => parseThumbprintString('ABCDEF')).toThrow(
|
||||
/must be 40 hex characters/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the thumbprint contains non-hex characters', () => {
|
||||
const raw = 'ZZCDEF0123456789ABCDEF0123456789ABCDEF01';
|
||||
expect(() => parseThumbprintString(raw)).toThrow(/must be 40 hex characters/i);
|
||||
});
|
||||
|
||||
it('throws on empty input', () => {
|
||||
expect(() => parseThumbprintString('')).toThrow(/empty/i);
|
||||
expect(() => parseThumbprintString(' ')).toThrow(/empty/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addWindowsSigningToOverride ────────────────────────────────────────────
|
||||
|
||||
describe('addWindowsSigningToOverride', () => {
|
||||
const VALID_THUMBPRINT = 'ABCDEF0123456789ABCDEF0123456789ABCDEF01';
|
||||
|
||||
it('writes certificateThumbprint + sensible defaults when no options given', () => {
|
||||
const input: TauriOverrideConfig = { build: { beforeBuildCommand: '' } };
|
||||
const out = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
|
||||
|
||||
expect(out.bundle?.windows?.certificateThumbprint).toBe(VALID_THUMBPRINT);
|
||||
expect(out.bundle?.windows?.digestAlgorithm).toBe('sha256');
|
||||
expect(out.bundle?.windows?.timestampUrl).toBe(
|
||||
'http://timestamp.digicert.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing top-level fields (build, app, etc.)', () => {
|
||||
const input: TauriOverrideConfig = {
|
||||
build: { beforeBuildCommand: 'echo hello' },
|
||||
app: { security: { csp: 'default-src self' } },
|
||||
};
|
||||
const out = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
|
||||
|
||||
expect(out.build).toEqual({ beforeBuildCommand: 'echo hello' });
|
||||
expect(out.app).toEqual({ security: { csp: 'default-src self' } });
|
||||
});
|
||||
|
||||
it('preserves existing bundle.windows fields not related to signing', () => {
|
||||
const input: TauriOverrideConfig = {
|
||||
bundle: {
|
||||
windows: { nsis: { installMode: 'currentUser' } },
|
||||
},
|
||||
};
|
||||
const out = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
|
||||
|
||||
expect(out.bundle?.windows?.nsis).toEqual({ installMode: 'currentUser' });
|
||||
expect(out.bundle?.windows?.certificateThumbprint).toBe(VALID_THUMBPRINT);
|
||||
});
|
||||
|
||||
it('overrides custom digestAlgorithm and timestampUrl when options provided', () => {
|
||||
const out = addWindowsSigningToOverride({}, VALID_THUMBPRINT, {
|
||||
digestAlgorithm: 'sha384',
|
||||
timestampUrl: 'http://timestamp.sectigo.com',
|
||||
});
|
||||
|
||||
expect(out.bundle?.windows?.digestAlgorithm).toBe('sha384');
|
||||
expect(out.bundle?.windows?.timestampUrl).toBe(
|
||||
'http://timestamp.sectigo.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mutate the input config (immutability invariant)', () => {
|
||||
const input: TauriOverrideConfig = { build: { beforeBuildCommand: '' } };
|
||||
const inputSnapshot = JSON.parse(JSON.stringify(input));
|
||||
addWindowsSigningToOverride(input, VALID_THUMBPRINT);
|
||||
expect(input).toEqual(inputSnapshot);
|
||||
});
|
||||
|
||||
it('is idempotent — applying twice with the same thumbprint yields equal output', () => {
|
||||
const input: TauriOverrideConfig = {};
|
||||
const once = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
|
||||
const twice = addWindowsSigningToOverride(once, VALID_THUMBPRINT);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it('replaces an old thumbprint when called with a new one (cert rotation)', () => {
|
||||
const input: TauriOverrideConfig = {};
|
||||
const v1 = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
|
||||
const newThumb = '1234567890ABCDEF1234567890ABCDEF12345678';
|
||||
const v2 = addWindowsSigningToOverride(v1, newThumb);
|
||||
expect(v2.bundle?.windows?.certificateThumbprint).toBe(newThumb);
|
||||
});
|
||||
|
||||
it('rejects an invalid thumbprint up front', () => {
|
||||
expect(() =>
|
||||
addWindowsSigningToOverride({}, 'too-short'),
|
||||
).toThrow(/must be 40 hex characters/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addMacosAdhocToOverride ────────────────────────────────────────────────
|
||||
|
||||
describe('addMacosAdhocToOverride', () => {
|
||||
it('sets bundle.macOS.signingIdentity to "-" (ad-hoc sign sentinel)', () => {
|
||||
const out = addMacosAdhocToOverride({});
|
||||
expect(out.bundle?.macOS?.signingIdentity).toBe('-');
|
||||
});
|
||||
|
||||
it('preserves existing top-level and bundle fields', () => {
|
||||
const input: TauriOverrideConfig = {
|
||||
build: { beforeBuildCommand: 'echo' },
|
||||
bundle: {
|
||||
windows: { certificateThumbprint: 'AB'.repeat(20) },
|
||||
},
|
||||
};
|
||||
const out = addMacosAdhocToOverride(input);
|
||||
|
||||
expect(out.build).toEqual({ beforeBuildCommand: 'echo' });
|
||||
expect(out.bundle?.windows?.certificateThumbprint).toBe('AB'.repeat(20));
|
||||
expect(out.bundle?.macOS?.signingIdentity).toBe('-');
|
||||
});
|
||||
|
||||
it('does not mutate the input config', () => {
|
||||
const input: TauriOverrideConfig = { bundle: { macOS: {} } };
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
addMacosAdhocToOverride(input);
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
const once = addMacosAdhocToOverride({});
|
||||
const twice = addMacosAdhocToOverride(once);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it('preserves additional macOS fields (entitlements, providerShortName)', () => {
|
||||
const input: TauriOverrideConfig = {
|
||||
bundle: {
|
||||
macOS: { entitlements: './ent.plist', providerShortName: 'TEAM' },
|
||||
},
|
||||
};
|
||||
const out = addMacosAdhocToOverride(input);
|
||||
|
||||
expect(out.bundle?.macOS?.entitlements).toBe('./ent.plist');
|
||||
expect(out.bundle?.macOS?.providerShortName).toBe('TEAM');
|
||||
expect(out.bundle?.macOS?.signingIdentity).toBe('-');
|
||||
});
|
||||
});
|
||||
147
app/scripts/signing-config.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* signing-config.ts — Pure utilities for managing code-signing fields in
|
||||
* Tauri's `tauri.build-override.conf.json`.
|
||||
*
|
||||
* Used by:
|
||||
* - `apply-signing-config.mjs` (LAUNCH-06 pilot wiring)
|
||||
* - `tauri:sign:pilot:win:apply` npm script
|
||||
*
|
||||
* No filesystem side effects — safe to import in tests. The thin CLI wrapper
|
||||
* does the file I/O.
|
||||
*
|
||||
* Reference: docs/code-signing-pilot-and-launch.md §1.1 (Windows self-sign)
|
||||
* docs/code-signing-pilot-and-launch.md §1.2 (macOS ad-hoc)
|
||||
*/
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TauriBundleWindows {
|
||||
certificateThumbprint?: string;
|
||||
digestAlgorithm?: string;
|
||||
timestampUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TauriBundleMacOS {
|
||||
signingIdentity?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TauriBundle {
|
||||
windows?: TauriBundleWindows;
|
||||
macOS?: TauriBundleMacOS;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TauriOverrideConfig {
|
||||
bundle?: TauriBundle;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WindowsSigningOptions {
|
||||
digestAlgorithm?: string;
|
||||
timestampUrl?: string;
|
||||
}
|
||||
|
||||
// ─── Defaults ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_DIGEST_ALGORITHM = 'sha256';
|
||||
const DEFAULT_TIMESTAMP_URL = 'http://timestamp.digicert.com';
|
||||
const MACOS_ADHOC_IDENTITY = '-';
|
||||
const THUMBPRINT_LENGTH = 40;
|
||||
const HEX_PATTERN = /^[0-9A-F]+$/;
|
||||
|
||||
// ─── parseThumbprintString ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalise a raw thumbprint string into the canonical 40-char uppercase form.
|
||||
*
|
||||
* Accepts whitespace anywhere (tab, space, newline) since PowerShell's
|
||||
* `$cert.Thumbprint` plus clipboard round-tripping can introduce arbitrary
|
||||
* spacing. Throws when the result is not exactly 40 hex characters.
|
||||
*/
|
||||
export function parseThumbprintString(raw: string): string {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
throw new Error('Thumbprint is empty — cert generation may have failed.');
|
||||
}
|
||||
|
||||
const compact = raw.replace(/\s+/g, '').toUpperCase();
|
||||
|
||||
if (compact.length !== THUMBPRINT_LENGTH || !HEX_PATTERN.test(compact)) {
|
||||
throw new Error(
|
||||
`Thumbprint must be 40 hex characters; got ${compact.length} chars (sample: "${compact.slice(0, 16)}...").`,
|
||||
);
|
||||
}
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
// ─── addWindowsSigningToOverride ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return a new override config with Windows code-signing fields applied.
|
||||
*
|
||||
* Preserves all existing top-level and bundle fields; replaces only the
|
||||
* three signing-specific keys under `bundle.windows`. Idempotent — calling
|
||||
* twice with the same thumbprint yields an equal result.
|
||||
*/
|
||||
export function addWindowsSigningToOverride<T extends TauriOverrideConfig>(
|
||||
config: Readonly<T>,
|
||||
thumbprint: string,
|
||||
options?: WindowsSigningOptions,
|
||||
): T {
|
||||
const normalisedThumbprint = parseThumbprintString(thumbprint);
|
||||
const digestAlgorithm = options?.digestAlgorithm ?? DEFAULT_DIGEST_ALGORITHM;
|
||||
const timestampUrl = options?.timestampUrl ?? DEFAULT_TIMESTAMP_URL;
|
||||
|
||||
const existingBundle: TauriBundle = config.bundle ?? {};
|
||||
const existingWindows: TauriBundleWindows = existingBundle.windows ?? {};
|
||||
|
||||
const nextWindows: TauriBundleWindows = {
|
||||
...existingWindows,
|
||||
certificateThumbprint: normalisedThumbprint,
|
||||
digestAlgorithm,
|
||||
timestampUrl,
|
||||
};
|
||||
|
||||
const nextBundle: TauriBundle = {
|
||||
...existingBundle,
|
||||
windows: nextWindows,
|
||||
};
|
||||
|
||||
return {
|
||||
...config,
|
||||
bundle: nextBundle,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── addMacosAdhocToOverride ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return a new override config with macOS ad-hoc signing applied.
|
||||
*
|
||||
* Sets `bundle.macOS.signingIdentity` to "-" (Tauri / codesign sentinel for
|
||||
* ad-hoc sign). Preserves all other fields. Used during pilot before a real
|
||||
* Apple Developer ID cert is procured.
|
||||
*/
|
||||
export function addMacosAdhocToOverride<T extends TauriOverrideConfig>(
|
||||
config: Readonly<T>,
|
||||
): T {
|
||||
const existingBundle: TauriBundle = config.bundle ?? {};
|
||||
const existingMacOS: TauriBundleMacOS = existingBundle.macOS ?? {};
|
||||
|
||||
const nextMacOS: TauriBundleMacOS = {
|
||||
...existingMacOS,
|
||||
signingIdentity: MACOS_ADHOC_IDENTITY,
|
||||
};
|
||||
|
||||
const nextBundle: TauriBundle = {
|
||||
...existingBundle,
|
||||
macOS: nextMacOS,
|
||||
};
|
||||
|
||||
return {
|
||||
...config,
|
||||
bundle: nextBundle,
|
||||
};
|
||||
}
|
||||
17
app/src-tauri/.cargo/config.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Intentionally NO `linker` override here.
|
||||
#
|
||||
# A previous version pinned the MSVC linker to an absolute, version-stamped path
|
||||
# (…/MSVC/14.44.35207/…/link.exe) so Git Bash's GNU coreutils `link` couldn't
|
||||
# shadow MSVC's linker on one local machine. That pin rotted: windows-latest CI
|
||||
# ships a different MSVC toolset, and so does any dev box without that exact
|
||||
# version, so cargo failed with:
|
||||
# error: linker `…14.44.35207…\link.exe` not found
|
||||
# note: The system cannot find the path specified. (os error 3)
|
||||
#
|
||||
# rustc/cc auto-detect the MSVC linker via vswhere on a properly installed
|
||||
# toolchain — windows-latest works out of the box, no override needed.
|
||||
#
|
||||
# If Git's `link` shadows MSVC's linker locally, fix it in the ENVIRONMENT, not
|
||||
# here: build from a "Developer Command Prompt for VS 2022" (or run
|
||||
# vcvarsall.bat) so MSVC's bin precedes Git on PATH. Do NOT re-pin an absolute,
|
||||
# version-stamped path — it breaks CI and every other machine.
|
||||
6185
app/src-tauri/Cargo.lock
generated
Normal file
29
app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "waggle"
|
||||
version = "0.2.0"
|
||||
description = "Waggle - Your personal AI agent swarm"
|
||||
authors = ["Marko Markovic"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "waggle_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
urlencoding = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
3
app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
12
app/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister"
|
||||
]
|
||||
}
|
||||
1
app/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
app/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:default","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}}
|
||||
2990
app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2990
app/src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
app/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
app/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
BIN
app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
BIN
app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 42 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
app/src-tauri/icons/icon.icns
Normal file
BIN
app/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
app/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 611 B |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
69
app/src-tauri/nsis/installer.nsi
Normal file
@@ -0,0 +1,69 @@
|
||||
; ─── Waggle NSIS Installer Template ──────────────────────────────────────────
|
||||
;
|
||||
; Custom hooks for the Tauri NSIS installer:
|
||||
; 1. Welcome message with Waggle branding
|
||||
; 2. Desktop shortcut creation
|
||||
; 3. Start Menu entry
|
||||
; 4. "Launch Waggle" on finish
|
||||
; 5. Uninstaller with optional ~/.waggle/ data removal
|
||||
;
|
||||
; Tauri injects NSIS defines: PRODUCT_NAME, PRODUCT_VERSION, MAINBINARYNAME,
|
||||
; DEFAULT_INSTALL_DIR. Autostart is handled by tauri-plugin-autostart at
|
||||
; runtime, not by the installer.
|
||||
;
|
||||
; Reference: https://tauri.app/distribute/windows-installer/#nsis
|
||||
; ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
InstallDir "${DEFAULT_INSTALL_DIR}"
|
||||
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
DetailPrint "Installing ${PRODUCT_NAME} v${PRODUCT_VERSION}..."
|
||||
DetailPrint "Your personal AI agent workspace — powered by Waggle."
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTINSTALL
|
||||
; ── Desktop shortcut ──────────────────────────────────────────────────────
|
||||
CreateShortcut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" \
|
||||
"" "$INSTDIR\${MAINBINARYNAME}.exe" 0
|
||||
DetailPrint "Desktop shortcut created."
|
||||
|
||||
; ── Start Menu entry ──────────────────────────────────────────────────────
|
||||
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" \
|
||||
"$INSTDIR\${MAINBINARYNAME}.exe" "" "$INSTDIR\${MAINBINARYNAME}.exe" 0
|
||||
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk" \
|
||||
"$INSTDIR\uninstall.exe" "" "$INSTDIR\uninstall.exe" 0
|
||||
DetailPrint "Start Menu entry created."
|
||||
|
||||
; ── Launch after install ──────────────────────────────────────────────────
|
||||
Exec '"$INSTDIR\${MAINBINARYNAME}.exe"'
|
||||
DetailPrint "Launching ${PRODUCT_NAME}..."
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTUNINSTALL
|
||||
; ── Remove desktop shortcut ─────────────────────────────────────────────
|
||||
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
|
||||
|
||||
; ── Remove Start Menu entries ───────────────────────────────────────────
|
||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk"
|
||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk"
|
||||
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
|
||||
; ── Ask about user data removal ─────────────────────────────────────────
|
||||
MessageBox MB_YESNO|MB_ICONQUESTION \
|
||||
"Waggle stores your data (agents, memories, configuration) in:$\r$\n$\r$\n\
|
||||
$PROFILE\.waggle$\r$\n$\r$\n\
|
||||
Do you want to remove this data as well?$\r$\n$\r$\n\
|
||||
Choose $\"Yes$\" to delete all data, or $\"No$\" to keep it for future use." \
|
||||
IDYES removeData IDNO skipData
|
||||
|
||||
removeData:
|
||||
RMDir /r "$PROFILE\.waggle"
|
||||
DetailPrint "User data removed: $PROFILE\.waggle"
|
||||
Goto doneData
|
||||
|
||||
skipData:
|
||||
DetailPrint "User data preserved: $PROFILE\.waggle"
|
||||
|
||||
doneData:
|
||||
!macroend
|
||||
0
app/src-tauri/resources/.gitkeep
Normal file
268
app/src-tauri/src/commands/agent.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
// CC Sesija A §2.1 Task A3 — agent loop streaming Tauri command.
|
||||
//
|
||||
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.1 Task A3
|
||||
//
|
||||
// Pattern: command returns a request_id immediately; tokio task POSTs to the
|
||||
// sidecar's `/api/chat` SSE endpoint, parses each event block, and emits
|
||||
// `agent-stream-{request_id}` to the webview per chunk. End-of-stream emits
|
||||
// `agent-stream-{request_id}-end` with either `{ ok: true }` or `{ error, ... }`.
|
||||
//
|
||||
// The webview's tauri-bindings.runAgentQuery() returns the request_id + an
|
||||
// unlisten handle so React components can subscribe per-conversation without
|
||||
// global state.
|
||||
//
|
||||
// A3.1 follow-up tracking:
|
||||
// - sidecar `/api/chat` calls `runAgentLoop`, not `runRetrievalAgentLoop`
|
||||
// as the brief requested. Faza 1's runRetrievalAgentLoop with shape
|
||||
// selection is not yet wired into the chat path. The `shape` param is
|
||||
// accepted here and passed through the body so a future sidecar patch
|
||||
// can read it without changing this command's surface. Document in
|
||||
// A3.1 follow-up.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::service::ServiceState;
|
||||
|
||||
const STREAM_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Start an agent query. Returns a request_id; chunks arrive via the
|
||||
/// `agent-stream-{request_id}` Tauri event, end via `agent-stream-{request_id}-end`.
|
||||
#[tauri::command]
|
||||
pub async fn run_agent_query(
|
||||
app: AppHandle,
|
||||
state: State<'_, ServiceState>,
|
||||
query: String,
|
||||
shape: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
persona: Option<String>,
|
||||
model: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let request_id = format!("agent-{}", Uuid::new_v4());
|
||||
let port = state.port;
|
||||
let app_clone = app.clone();
|
||||
let req_id_clone = request_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stream_chat(
|
||||
app_clone,
|
||||
port,
|
||||
req_id_clone,
|
||||
query,
|
||||
shape,
|
||||
workspace_id,
|
||||
persona,
|
||||
model,
|
||||
session,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// The end event is already emitted from inside stream_chat on error
|
||||
// paths; this stderr is a developer-facing breadcrumb only.
|
||||
eprintln!("[agent] run_agent_query stream task error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(request_id)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn stream_chat(
|
||||
app: AppHandle,
|
||||
port: u16,
|
||||
request_id: String,
|
||||
query: String,
|
||||
shape: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
persona: Option<String>,
|
||||
model: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// A3.1 (2026-04-30): re-pointed from /api/chat to /api/agent/run.
|
||||
// /api/agent/run is the dedicated shape-aware structured-retrieval
|
||||
// endpoint (runRetrievalAgentLoop) — distinct from /api/chat which is
|
||||
// for conversational multi-turn dialogue (runAgentLoop). Shape now flows
|
||||
// end-to-end: Tauri body field "shape" → sidecar promptShapeOverride
|
||||
// → runRetrievalAgentLoop pickShape().
|
||||
//
|
||||
// The TS binding's `session` arg is intentionally ignored here:
|
||||
// /api/agent/run is one-shot (no persistent session), so passing
|
||||
// session through would just be dead weight. Binding signature stays
|
||||
// stable so callers don't break.
|
||||
let url = format!("http://127.0.0.1:{}/api/agent/run", port);
|
||||
let mut body = json!({ "question": query });
|
||||
if let Some(ws) = workspace_id {
|
||||
body["workspace"] = json!(ws);
|
||||
}
|
||||
if let Some(p) = persona {
|
||||
body["persona"] = json!(p);
|
||||
}
|
||||
if let Some(m) = model {
|
||||
body["model"] = json!(m);
|
||||
}
|
||||
if let Some(sh) = shape {
|
||||
body["shape"] = json!(sh);
|
||||
}
|
||||
// `session` arg accepted by the Tauri command for binding-stability but
|
||||
// not threaded into /api/agent/run (one-shot). Suppress unused-warn.
|
||||
let _ = session;
|
||||
|
||||
let event_name = format!("agent-stream-{}", request_id);
|
||||
let end_event = format!("agent-stream-{}-end", request_id);
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(STREAM_TIMEOUT_SECS))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = app.emit(
|
||||
&end_event,
|
||||
json!({ "error": format!("client build failed: {}", e) }),
|
||||
);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let mut resp = match client.post(&url).json(&body).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = app.emit(
|
||||
&end_event,
|
||||
json!({ "error": format!("HTTP POST failed: {}", e) }),
|
||||
);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
let _ = app.emit(
|
||||
&end_event,
|
||||
json!({
|
||||
"error": format!("sidecar returned {}", status),
|
||||
"body": body_text,
|
||||
}),
|
||||
);
|
||||
return Err(format!("sidecar returned {}", status));
|
||||
}
|
||||
|
||||
// Read SSE stream chunk by chunk. SSE events are delimited by a blank
|
||||
// line (\n\n). Each block has zero or one `event:` line and one or more
|
||||
// `data:` lines. We accumulate bytes into a String buffer, then drain
|
||||
// complete blocks one at a time.
|
||||
let mut buffer = String::new();
|
||||
loop {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(bytes)) => {
|
||||
if let Ok(s) = std::str::from_utf8(&bytes) {
|
||||
buffer.push_str(s);
|
||||
}
|
||||
while let Some(idx) = buffer.find("\n\n") {
|
||||
let block: String = buffer.drain(..idx + 2).collect();
|
||||
if let Some(parsed) = parse_sse_event(&block) {
|
||||
let _ = app.emit(&event_name, parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = app.emit(
|
||||
&end_event,
|
||||
json!({ "error": format!("stream read error: {}", e) }),
|
||||
);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain any tail block that didn't end with a blank line (server may close
|
||||
// the connection without a final separator).
|
||||
let tail = buffer.trim();
|
||||
if !tail.is_empty() {
|
||||
if let Some(parsed) = parse_sse_event(tail) {
|
||||
let _ = app.emit(&event_name, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit(&end_event, json!({ "ok": true }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a single SSE event block. Returns `{ event, data }` where `data` is the
|
||||
/// parsed JSON value when possible, otherwise the raw concatenated data text.
|
||||
/// SSE allows multiple `data:` lines within a single event — they are joined
|
||||
/// with `\n` per the spec.
|
||||
fn parse_sse_event(block: &str) -> Option<Value> {
|
||||
let mut event_name = String::from("message");
|
||||
let mut data_lines: Vec<String> = Vec::new();
|
||||
|
||||
for line in block.lines() {
|
||||
if let Some(rest) = line.strip_prefix("event:") {
|
||||
event_name = rest.trim().to_string();
|
||||
} else if let Some(rest) = line.strip_prefix("data:") {
|
||||
// Per SSE spec, a single leading space after `data:` is stripped.
|
||||
let value = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
data_lines.push(value.to_string());
|
||||
}
|
||||
// `id:` and `retry:` and comment (`:`) lines are intentionally ignored.
|
||||
}
|
||||
|
||||
if data_lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let data_str = data_lines.join("\n");
|
||||
let data_value: Value =
|
||||
serde_json::from_str(&data_str).unwrap_or_else(|_| Value::String(data_str));
|
||||
|
||||
Some(json!({
|
||||
"event": event_name,
|
||||
"data": data_value,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_sse_simple_data_only() {
|
||||
let block = "data: hello\n\n";
|
||||
let parsed = parse_sse_event(block).expect("should parse");
|
||||
assert_eq!(parsed["event"], "message");
|
||||
assert_eq!(parsed["data"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_event_and_json_data() {
|
||||
let block = "event: step\ndata: {\"chunk\":\"hi\"}\n\n";
|
||||
let parsed = parse_sse_event(block).expect("should parse");
|
||||
assert_eq!(parsed["event"], "step");
|
||||
assert_eq!(parsed["data"]["chunk"], "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_multiline_data_joined_with_newline() {
|
||||
let block = "event: step\ndata: line1\ndata: line2\n\n";
|
||||
let parsed = parse_sse_event(block).expect("should parse");
|
||||
assert_eq!(parsed["data"], "line1\nline2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_empty_data_returns_none() {
|
||||
let block = "event: ping\n\n";
|
||||
assert!(parse_sse_event(block).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_strips_single_leading_space_after_colon() {
|
||||
let block = "data: foo\n\n";
|
||||
let parsed = parse_sse_event(block).expect("should parse");
|
||||
assert_eq!(parsed["data"], "foo");
|
||||
}
|
||||
}
|
||||
43
app/src-tauri/src/commands/http.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
// CC Sesija A §2.1 Task A4 — shared HTTP helpers for sidecar-proxy commands.
|
||||
//
|
||||
// Extracted from commands/memory.rs at A4 because wiki.rs becomes the second
|
||||
// consumer (rule of two). Future command modules (onboarding A10+, agent.rs
|
||||
// where applicable) should also import from here rather than reimplementing.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SIDECAR_HOST: &str = "127.0.0.1";
|
||||
|
||||
pub fn sidecar_url(port: u16, path: &str) -> String {
|
||||
format!("http://{}:{}{}", SIDECAR_HOST, port, path)
|
||||
}
|
||||
|
||||
pub async fn http_get(url: &str) -> Result<reqwest::Response, String> {
|
||||
reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("HTTP GET {} failed: {}", url, e))
|
||||
}
|
||||
|
||||
pub async fn http_post(url: &str, body: &Value) -> Result<reqwest::Response, String> {
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(url)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP POST {} failed: {}", url, e))
|
||||
}
|
||||
|
||||
pub async fn parse_json(resp: reqwest::Response) -> Result<Value, String> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<unreadable body>".to_string());
|
||||
return Err(format!("sidecar returned {}: {}", status, body_text));
|
||||
}
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| format!("sidecar returned non-JSON body: {}", e))
|
||||
}
|
||||
137
app/src-tauri/src/commands/memory.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
// CC Sesija A §2.1 Tasks A1 + A4 — memory + KG + identity Tauri commands.
|
||||
//
|
||||
// Architecture: thin HTTP proxies to local sidecar (Fastify, port from ServiceState).
|
||||
// Tauri Rust shell intentionally has no direct sqlite/sqlite-vec dependencies — all
|
||||
// substrate access goes through the sidecar so that the storage layer (FrameStore,
|
||||
// HybridSearch, KnowledgeGraph, wiki-compiler) lives in one process.
|
||||
//
|
||||
// Command surface (4 commands here; wiki commands moved to commands/wiki.rs at A4):
|
||||
// recall_memory → GET /api/memory/search
|
||||
// save_memory → POST /api/memory/frames
|
||||
// search_entities → GET /api/memory/graph
|
||||
// get_identity → GET /api/identity (route not yet in sidecar — graceful 404
|
||||
// → placeholder; A1.1 follow-up adds route)
|
||||
//
|
||||
// Shared HTTP helpers extracted to commands/http.rs at A4 (rule of two — wiki.rs is
|
||||
// the second consumer).
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::http::{http_get, http_post, parse_json, sidecar_url};
|
||||
use crate::service::ServiceState;
|
||||
|
||||
/// Recall memory frames matching `query`. Optional `scope` (all|personal|workspace),
|
||||
/// `limit` (default sidecar-side ~20), `workspace_id` (workspace mind to search).
|
||||
#[tauri::command]
|
||||
pub async fn recall_memory(
|
||||
state: State<'_, ServiceState>,
|
||||
query: String,
|
||||
scope: Option<String>,
|
||||
limit: Option<u32>,
|
||||
workspace_id: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut url = format!(
|
||||
"{}?q={}",
|
||||
sidecar_url(state.port, "/api/memory/search"),
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
if let Some(s) = scope {
|
||||
url.push_str(&format!("&scope={}", urlencoding::encode(&s)));
|
||||
}
|
||||
if let Some(l) = limit {
|
||||
url.push_str(&format!("&limit={}", l));
|
||||
}
|
||||
if let Some(ws) = workspace_id {
|
||||
url.push_str(&format!("&workspace={}", urlencoding::encode(&ws)));
|
||||
}
|
||||
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Persist a new memory frame. `content` is required. Optional `workspace_id`,
|
||||
/// `importance` (low|normal|high|critical per @waggle/core Importance enum), `source`
|
||||
/// (one of: user_stated, tool_verified, agent_inferred, import, system).
|
||||
#[tauri::command]
|
||||
pub async fn save_memory(
|
||||
state: State<'_, ServiceState>,
|
||||
content: String,
|
||||
workspace_id: Option<String>,
|
||||
importance: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut body = json!({ "content": content });
|
||||
if let Some(ws) = workspace_id {
|
||||
body["workspace"] = json!(ws);
|
||||
}
|
||||
if let Some(imp) = importance {
|
||||
body["importance"] = json!(imp);
|
||||
}
|
||||
if let Some(src) = source {
|
||||
body["source"] = json!(src);
|
||||
}
|
||||
|
||||
let url = sidecar_url(state.port, "/api/memory/frames");
|
||||
let resp = http_post(&url, &body).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Search the knowledge graph for entities/relations. Returns `{nodes, edges}` shape
|
||||
/// from sidecar's KnowledgeGraph layer. `scope` defaults to "all" if not provided.
|
||||
#[tauri::command]
|
||||
pub async fn search_entities(
|
||||
state: State<'_, ServiceState>,
|
||||
workspace_id: Option<String>,
|
||||
scope: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut url = sidecar_url(state.port, "/api/memory/graph").to_string();
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
if let Some(ws) = workspace_id {
|
||||
params.push(format!("workspace={}", urlencoding::encode(&ws)));
|
||||
}
|
||||
if let Some(s) = scope {
|
||||
params.push(format!("scope={}", urlencoding::encode(&s)));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(¶ms.join("&"));
|
||||
}
|
||||
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Get the user identity record from sidecar's IdentityLayer.
|
||||
///
|
||||
/// Backed by /api/identity sidecar route (A1.1 shipped 2026-04-30). The route
|
||||
/// always returns a 200 with either configured: true + IdentityLayer fields,
|
||||
/// or configured: false + null fields + optional _note. The 404 + transport-
|
||||
/// error fallbacks here remain as defense-in-depth — they were the original
|
||||
/// pre-A1.1 placeholders and now only fire on hard sidecar outages.
|
||||
#[tauri::command]
|
||||
pub async fn get_identity(state: State<'_, ServiceState>) -> Result<Value, String> {
|
||||
let url = sidecar_url(state.port, "/api/identity");
|
||||
match http_get(&url).await {
|
||||
Ok(resp) if resp.status().as_u16() == 404 => Ok(identity_placeholder(
|
||||
"sidecar route 404 (unexpected post-A1.1)",
|
||||
)),
|
||||
Ok(resp) => parse_json(resp).await,
|
||||
Err(_) => Ok(identity_placeholder("sidecar unreachable")),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_placeholder(note: &str) -> Value {
|
||||
json!({
|
||||
"configured": false,
|
||||
"name": null,
|
||||
"role": null,
|
||||
"department": null,
|
||||
"personality": null,
|
||||
"capabilities": null,
|
||||
"system_prompt": null,
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"_note": note
|
||||
})
|
||||
}
|
||||
8
app/src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// CC Sesija A §2.1 + §2.3: command modules grouped under commands/.
|
||||
// Each submodule exposes #[tauri::command] async fns wired in lib.rs invoke_handler.
|
||||
|
||||
pub mod agent;
|
||||
pub mod http;
|
||||
pub mod memory;
|
||||
pub mod onboarding;
|
||||
pub mod wiki;
|
||||
120
app/src-tauri/src/commands/onboarding.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
// CC Sesija A §2.3 Task A10 — first-launch detection via filesystem flag.
|
||||
//
|
||||
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.3 Task A10
|
||||
//
|
||||
// Persists a flag file at `~/.waggle/first-launch.flag` so the onboarding wizard
|
||||
// state survives across app reinstalls (browser localStorage doesn't, since
|
||||
// Tauri builds may use a fresh WebView profile per install). The web `npm run
|
||||
// dev` path continues to use localStorage via useOnboarding — these commands
|
||||
// are the durable Tauri-mode addition, not a replacement.
|
||||
//
|
||||
// Cross-platform user-home resolution uses std::env (USERPROFILE on Windows,
|
||||
// HOME on Unix) to avoid pulling in a new dirs/home crate dep.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
const FLAG_DIR: &str = ".waggle";
|
||||
const FLAG_FILE: &str = "first-launch.flag";
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("USERPROFILE")
|
||||
.or_else(|| std::env::var_os("HOME"))
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Resolve the flag location: `WAGGLE_DATA_DIR` (when set and non-empty) else
|
||||
/// `~/.waggle` — the SAME resolution order the sidecar's resolveDataDir uses
|
||||
/// (UX-Refactor P4/D11). Without this, a custom-data-dir install reads/writes
|
||||
/// the flag in `~/.waggle` while the server's completion stamp lives in the
|
||||
/// data dir — and a stale `~/.waggle` flag from a prior default install would
|
||||
/// auto-skip onboarding against a brand-new data dir (P4 review, 5 findings).
|
||||
fn flag_path() -> Result<PathBuf, String> {
|
||||
if let Some(dir) = std::env::var_os("WAGGLE_DATA_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return Ok(PathBuf::from(dir).join(FLAG_FILE));
|
||||
}
|
||||
}
|
||||
let home = home_dir().ok_or_else(|| {
|
||||
"could not resolve user home directory (USERPROFILE/HOME unset)".to_string()
|
||||
})?;
|
||||
Ok(home.join(FLAG_DIR).join(FLAG_FILE))
|
||||
}
|
||||
|
||||
/// Returns true if the user has not yet completed onboarding.
|
||||
/// Implementation: returns `!flag_file_exists`. On any IO error (e.g. home dir
|
||||
/// unresolvable in a sandboxed environment) returns `true` so the wizard runs
|
||||
/// — better to show the wizard once too often than to silently skip it.
|
||||
#[tauri::command]
|
||||
pub async fn is_first_launch() -> Result<bool, String> {
|
||||
let path = match flag_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(true),
|
||||
};
|
||||
Ok(!path.exists())
|
||||
}
|
||||
|
||||
/// Marks onboarding as complete by creating the flag file. Idempotent.
|
||||
/// Creates the parent `~/.waggle/` directory if needed.
|
||||
#[tauri::command]
|
||||
pub async fn mark_first_launch_complete() -> Result<(), String> {
|
||||
let path = flag_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("create_dir_all {} failed: {}", parent.display(), e))?;
|
||||
}
|
||||
std::fs::write(&path, b"completed\n")
|
||||
.map_err(|e| format!("write {} failed: {}", path.display(), e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the first-launch flag (deletes the file). For dev / QA flows that
|
||||
/// need to re-trigger onboarding without a full reinstall.
|
||||
#[tauri::command]
|
||||
pub async fn reset_first_launch() -> Result<(), String> {
|
||||
let path = flag_path()?;
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| format!("remove {} failed: {}", path.display(), e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flag_path_resolution_order() {
|
||||
// ONE sequential test — env vars are process-global and cargo runs
|
||||
// tests in parallel threads; split tests would race on WAGGLE_DATA_DIR.
|
||||
let tmp = std::env::temp_dir().join(format!("waggle-test-home-{}", std::process::id()));
|
||||
std::env::set_var("USERPROFILE", &tmp);
|
||||
std::env::set_var("HOME", &tmp);
|
||||
|
||||
// Default: ~/.waggle.
|
||||
std::env::remove_var("WAGGLE_DATA_DIR");
|
||||
let path = flag_path().expect("flag_path resolves with USERPROFILE/HOME set");
|
||||
assert!(
|
||||
path.ends_with(".waggle/first-launch.flag")
|
||||
|| path.ends_with(".waggle\\first-launch.flag")
|
||||
);
|
||||
|
||||
// P4/D11: a custom data dir keeps the flag NEXT TO the server's stamp.
|
||||
let data_dir =
|
||||
std::env::temp_dir().join(format!("waggle-test-datadir-{}", std::process::id()));
|
||||
std::env::set_var("WAGGLE_DATA_DIR", &data_dir);
|
||||
let custom = flag_path().expect("flag_path resolves with WAGGLE_DATA_DIR set");
|
||||
assert!(custom.starts_with(&data_dir));
|
||||
assert!(custom.ends_with("first-launch.flag"));
|
||||
|
||||
// Empty env value falls through to the home default (matches resolveDataDir).
|
||||
std::env::set_var("WAGGLE_DATA_DIR", "");
|
||||
let fallback = flag_path().expect("flag_path resolves with empty WAGGLE_DATA_DIR");
|
||||
assert!(
|
||||
fallback.ends_with(".waggle/first-launch.flag")
|
||||
|| fallback.ends_with(".waggle\\first-launch.flag")
|
||||
);
|
||||
|
||||
std::env::remove_var("WAGGLE_DATA_DIR");
|
||||
}
|
||||
}
|
||||
75
app/src-tauri/src/commands/wiki.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
// CC Sesija A §2.1 Task A4 — wiki Tauri commands.
|
||||
//
|
||||
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.1 Task A4
|
||||
//
|
||||
// Read-side wiki commands proxying to the sidecar's /api/wiki/* routes which read
|
||||
// from the per-workspace MindDB (FrameStore + KnowledgeGraph). The compile route
|
||||
// triggers a fresh wiki-compiler pass for the active workspace.
|
||||
//
|
||||
// Brief §2.1 Task A4 says "reads packages/wiki-compiler output direktno iz hive-mind
|
||||
// frame store" — direktno here means "without intermediate cache" not "direct
|
||||
// sqlite from Rust" (Tauri shell intentionally has no sqlite deps; substrate stays
|
||||
// in the sidecar process — see commands/memory.rs header for full rationale).
|
||||
//
|
||||
// Note: compile_wiki_section was previously colocated in commands/memory.rs (A1).
|
||||
// A4 moves it here for cohesion. Tauri command surface is unchanged; lib.rs
|
||||
// invoke_handler entry just rewires from `memory::compile_wiki_section` to
|
||||
// `wiki::compile_wiki_section`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::http::{http_get, http_post, parse_json, sidecar_url};
|
||||
use crate::service::ServiceState;
|
||||
|
||||
/// List all compiled wiki pages for the active workspace. Returns the page
|
||||
/// index (slugs + titles + metadata); call get_wiki_page_content for the body.
|
||||
#[tauri::command]
|
||||
pub async fn get_wiki_pages(state: State<'_, ServiceState>) -> Result<Value, String> {
|
||||
let url = sidecar_url(state.port, "/api/wiki/pages");
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Fetch a single wiki page's metadata (title, type, source frame ids, etc.)
|
||||
/// without the full markdown body. Use get_wiki_page_content for the body.
|
||||
#[tauri::command]
|
||||
pub async fn get_wiki_page(state: State<'_, ServiceState>, slug: String) -> Result<Value, String> {
|
||||
let url = sidecar_url(
|
||||
state.port,
|
||||
&format!("/api/wiki/pages/{}", urlencoding::encode(&slug)),
|
||||
);
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Fetch the full markdown content for a wiki page by slug. Returns whatever
|
||||
/// the sidecar emits (typically `{ slug, content, ... }`).
|
||||
#[tauri::command]
|
||||
pub async fn get_wiki_page_content(
|
||||
state: State<'_, ServiceState>,
|
||||
slug: String,
|
||||
) -> Result<Value, String> {
|
||||
let url = sidecar_url(
|
||||
state.port,
|
||||
&format!("/api/wiki/pages/{}/content", urlencoding::encode(&slug)),
|
||||
);
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Trigger wiki compilation for the active or specified workspace. Returns the
|
||||
/// compilation summary (page count, entities, gaps) from packages/wiki-compiler.
|
||||
#[tauri::command]
|
||||
pub async fn compile_wiki_section(
|
||||
state: State<'_, ServiceState>,
|
||||
workspace_id: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut body = json!({});
|
||||
if let Some(ws) = workspace_id {
|
||||
body["workspace"] = json!(ws);
|
||||
}
|
||||
let url = sidecar_url(state.port, "/api/wiki/compile");
|
||||
let resp = http_post(&url, &body).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
130
app/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
// Tauri 2 requires a lib.rs for the cdylib/staticlib crate types.
|
||||
// The actual app entry point is main.rs.
|
||||
|
||||
mod commands;
|
||||
mod service;
|
||||
mod tray;
|
||||
|
||||
use service::ServiceState;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_notification(
|
||||
app: tauri::AppHandle,
|
||||
title: String,
|
||||
body: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
app.notification()
|
||||
.builder()
|
||||
.title(&title)
|
||||
.body(&body)
|
||||
.show()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--minimized"]),
|
||||
))
|
||||
.manage(ServiceState::new(3333))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
service::ensure_service,
|
||||
service::stop_service,
|
||||
service::get_service_port,
|
||||
show_notification,
|
||||
commands::memory::recall_memory,
|
||||
commands::memory::save_memory,
|
||||
commands::memory::search_entities,
|
||||
commands::memory::get_identity,
|
||||
commands::wiki::get_wiki_pages,
|
||||
commands::wiki::get_wiki_page,
|
||||
commands::wiki::get_wiki_page_content,
|
||||
commands::wiki::compile_wiki_section,
|
||||
commands::agent::run_agent_query,
|
||||
commands::onboarding::is_first_launch,
|
||||
commands::onboarding::mark_first_launch_complete,
|
||||
commands::onboarding::reset_first_launch,
|
||||
])
|
||||
.setup(|app| {
|
||||
tray::setup_tray(app.handle())?;
|
||||
|
||||
// Register global hotkey: Ctrl+Shift+W to toggle window visibility
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
|
||||
let app_handle = app.handle().clone();
|
||||
app.handle().plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(move |_app, _shortcut, event| {
|
||||
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyW);
|
||||
// R7-005: a hotkey collision must not crash setup — log and continue.
|
||||
if let Err(e) = app.global_shortcut().register(shortcut) {
|
||||
eprintln!(
|
||||
"[waggle] Failed to register Ctrl+Shift+W global shortcut: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-start the sidecar service before the webview loads so the
|
||||
// React app finds it already healthy on localhost:3333.
|
||||
let service_state = app.state::<ServiceState>();
|
||||
let port = service_state.port;
|
||||
match service::spawn_service_sync(port, &service_state.process) {
|
||||
Ok(()) => eprintln!("[waggle] Sidecar spawn initiated on port {}", port),
|
||||
Err(e) => eprintln!("[waggle] Failed to auto-start sidecar: {}", e),
|
||||
}
|
||||
|
||||
// Start service watchdog
|
||||
let app_handle_watchdog = app.handle().clone();
|
||||
service::start_watchdog(app_handle_watchdog, port);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
// Window management: close minimizes to tray instead of quitting
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
// R7-002: kill the sidecar on app exit so it doesn't orphan and hold port 3333.
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Some(state) = app_handle.try_state::<ServiceState>() {
|
||||
if let Ok(mut proc) = state.process.lock() {
|
||||
if let Some(mut child) = proc.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
6
app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
waggle_lib::run();
|
||||
}
|
||||
361
app/src-tauri/src/service.rs
Normal file
@@ -0,0 +1,361 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
pub struct ServiceState {
|
||||
pub process: Mutex<Option<Child>>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl ServiceState {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
process: Mutex::new(None),
|
||||
port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Node.js binary path.
|
||||
/// Priority: WAGGLE_NODE_PATH env → bundled resources/node[.exe] → system PATH "node"
|
||||
fn resolve_node_path() -> String {
|
||||
// 1. Explicit env override (development/advanced users)
|
||||
if let Ok(custom) = std::env::var("WAGGLE_NODE_PATH") {
|
||||
return custom;
|
||||
}
|
||||
|
||||
// 2. Bundled Node.js in resources/ directory (next to exe)
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
|
||||
if let Some(ref dir) = exe_dir {
|
||||
let bundled = if cfg!(windows) {
|
||||
dir.join("resources").join("node.exe")
|
||||
} else {
|
||||
dir.join("resources").join("node")
|
||||
};
|
||||
if bundled.exists() {
|
||||
return bundled.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Development fallback: relative to src-tauri working dir
|
||||
if cfg!(debug_assertions) {
|
||||
let dev_resources = if cfg!(windows) {
|
||||
"resources/node.exe"
|
||||
} else {
|
||||
"resources/node"
|
||||
};
|
||||
if std::path::Path::new(dev_resources).exists() {
|
||||
return dev_resources.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. System PATH fallback
|
||||
"node".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ServiceScriptKind {
|
||||
Bundled,
|
||||
DevSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct ServiceScript {
|
||||
path: PathBuf,
|
||||
kind: ServiceScriptKind,
|
||||
}
|
||||
|
||||
fn find_dev_service_script(current_dir: &Path) -> Option<PathBuf> {
|
||||
for dir in current_dir.ancestors() {
|
||||
let candidate = dir
|
||||
.join("packages")
|
||||
.join("server")
|
||||
.join("src")
|
||||
.join("local")
|
||||
.join("service.ts");
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_service_script(
|
||||
exe_dir: Option<&Path>,
|
||||
current_dir: &Path,
|
||||
) -> Result<ServiceScript, String> {
|
||||
if let Some(dir) = exe_dir {
|
||||
let bundled = dir.join("resources").join("service.js");
|
||||
if bundled.exists() {
|
||||
return Ok(ServiceScript {
|
||||
path: bundled,
|
||||
kind: ServiceScriptKind::Bundled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
if let Some(script) = find_dev_service_script(current_dir) {
|
||||
return Ok(ServiceScript {
|
||||
path: script,
|
||||
kind: ServiceScriptKind::DevSource,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err("Unable to locate sidecar service.js resource".to_string())
|
||||
}
|
||||
|
||||
/// Build the Command used to spawn the sidecar process.
|
||||
/// Shared by both the sync auto-start path and the async `ensure_service` tauri command.
|
||||
fn build_service_command(port: u16) -> Result<Command, String> {
|
||||
let node_path = resolve_node_path();
|
||||
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
|
||||
let current_dir = std::env::current_dir().map_err(|e| e.to_string())?;
|
||||
let service_script = resolve_service_script(exe_dir.as_deref(), ¤t_dir)?;
|
||||
|
||||
let mut cmd = if service_script.kind == ServiceScriptKind::DevSource {
|
||||
let mut c = Command::new(&node_path);
|
||||
c.arg("--import").arg("tsx").arg(&service_script.path);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(&node_path);
|
||||
c.arg(&service_script.path);
|
||||
c
|
||||
};
|
||||
|
||||
cmd.env("WAGGLE_PORT", port.to_string());
|
||||
|
||||
if service_script.kind == ServiceScriptKind::Bundled {
|
||||
cmd.env("WAGGLE_SKIP_LITELLM", "1");
|
||||
|
||||
if let Some(ref dir) = exe_dir {
|
||||
let resources_dir = dir.join("resources");
|
||||
let native_dir = resources_dir.join("native");
|
||||
let node_modules_dir = resources_dir.join("node_modules");
|
||||
|
||||
// NODE_PATH must include the staged production deps
|
||||
// (resources/node_modules — better-sqlite3, @fastify/static,
|
||||
// drizzle-orm, @huggingface/transformers, …) so the sidecar's bare
|
||||
// require()/import() calls resolve, plus resources/native for any
|
||||
// abs-path native consumers. Node accepts multiple entries,
|
||||
// ';'-separated on Windows and ':' elsewhere.
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let node_path = format!(
|
||||
"{}{}{}",
|
||||
node_modules_dir.to_string_lossy(),
|
||||
sep,
|
||||
native_dir.to_string_lossy(),
|
||||
);
|
||||
cmd.env("NODE_PATH", node_path);
|
||||
|
||||
let vec_ext = if cfg!(windows) {
|
||||
native_dir.join("vec0.dll")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
native_dir.join("vec0.dylib")
|
||||
} else {
|
||||
native_dir.join("vec0.so")
|
||||
};
|
||||
if vec_ext.exists() {
|
||||
cmd.env("WAGGLE_SQLITE_VEC_PATH", vec_ext.to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
let ort_dir = native_dir.join("onnxruntime");
|
||||
if ort_dir.exists() {
|
||||
cmd.env(
|
||||
"ONNXRUNTIME_NODE_BINDING_PATH",
|
||||
ort_dir.to_string_lossy().as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn service_script_prefers_bundled_resource_when_present() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("waggle-service-script-{}", std::process::id()));
|
||||
let resources = root.join("resources");
|
||||
std::fs::create_dir_all(&resources).expect("creates temp resources");
|
||||
std::fs::write(resources.join("service.js"), "console.log('ok')").expect("writes service");
|
||||
|
||||
let script = resolve_service_script(Some(&root), Path::new("D:/Projects/waggle-os"))
|
||||
.expect("bundled script resolves");
|
||||
|
||||
assert_eq!(script.kind, ServiceScriptKind::Bundled);
|
||||
assert_eq!(script.path, resources.join("service.js"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_service_script_searches_current_dir_ancestors() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("waggle-dev-service-script-{}", std::process::id()));
|
||||
let script = root
|
||||
.join("packages")
|
||||
.join("server")
|
||||
.join("src")
|
||||
.join("local")
|
||||
.join("service.ts");
|
||||
std::fs::create_dir_all(script.parent().expect("script parent")).expect("creates dirs");
|
||||
std::fs::write(&script, "export {};").expect("writes service");
|
||||
|
||||
let nested = root.join("app").join("src-tauri");
|
||||
std::fs::create_dir_all(&nested).expect("creates nested cwd");
|
||||
|
||||
assert_eq!(find_dev_service_script(&nested), Some(script));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronously spawn the sidecar process if not already running. Does not wait
|
||||
/// for the health check. Safe to call from Tauri's synchronous `.setup()` callback.
|
||||
pub fn spawn_service_sync(port: u16, process: &Mutex<Option<Child>>) -> Result<(), String> {
|
||||
{
|
||||
let proc = process.lock().map_err(|e| e.to_string())?;
|
||||
if proc.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = build_service_command(port)?;
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start service: {}", e))?;
|
||||
|
||||
let mut proc = process.lock().map_err(|e| e.to_string())?;
|
||||
*proc = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ensure_service(state: State<'_, ServiceState>) -> Result<String, String> {
|
||||
let port = state.port;
|
||||
let health_url = format!("http://127.0.0.1:{}/health", port);
|
||||
|
||||
match reqwest::get(&health_url).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
return Ok("Service already running".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
spawn_service_sync(port, &state.process)?;
|
||||
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
match reqwest::get(&health_url).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
return Ok("Service started".to_string());
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Err("Service failed to start within 30 seconds".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_service(state: State<'_, ServiceState>) -> Result<String, String> {
|
||||
let mut proc = state.process.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(mut child) = proc.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok("Service stopped".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_service_port(state: State<'_, ServiceState>) -> Result<u16, String> {
|
||||
Ok(state.port)
|
||||
}
|
||||
|
||||
pub fn start_watchdog(app: AppHandle, port: u16) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let health_url = format!("http://127.0.0.1:{}/health", port);
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
let mut restart_count: u32 = 0;
|
||||
let mut restart_window_start = Instant::now();
|
||||
const MAX_RESTARTS: u32 = 5;
|
||||
const RESTART_WINDOW: Duration = Duration::from_secs(600);
|
||||
|
||||
// Wait for initial startup
|
||||
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
|
||||
match reqwest::get(&health_url).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
_ => {
|
||||
consecutive_failures += 1;
|
||||
if consecutive_failures >= 3 {
|
||||
if restart_window_start.elapsed() > RESTART_WINDOW {
|
||||
restart_count = 0;
|
||||
restart_window_start = Instant::now();
|
||||
}
|
||||
|
||||
if restart_count >= MAX_RESTARTS {
|
||||
let _ = app.emit(
|
||||
"waggle://service-status",
|
||||
serde_json::json!({ "status": "failed" }),
|
||||
);
|
||||
eprintln!("[waggle] Watchdog: max restarts exceeded, giving up");
|
||||
break;
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"waggle://service-status",
|
||||
serde_json::json!({ "status": "restarting" }),
|
||||
);
|
||||
eprintln!(
|
||||
"[waggle] Watchdog: server unresponsive, respawning (attempt {})",
|
||||
restart_count + 1
|
||||
);
|
||||
|
||||
// R7-003: self-heal — reap the dead child (so spawn_service_sync's
|
||||
// is_some() early-return clears) then respawn the sidecar in place.
|
||||
if let Some(state) = app.try_state::<ServiceState>() {
|
||||
{
|
||||
if let Ok(mut proc) = state.process.lock() {
|
||||
if let Some(mut child) = proc.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
match spawn_service_sync(port, &state.process) {
|
||||
Ok(()) => eprintln!("[waggle] Watchdog: sidecar respawned"),
|
||||
Err(e) => eprintln!("[waggle] Watchdog: respawn failed: {}", e),
|
||||
}
|
||||
}
|
||||
let _ = app.emit("waggle://service-restart-needed", ());
|
||||
|
||||
restart_count += 1;
|
||||
consecutive_failures = 0;
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
76
app/src-tauri/src/tray.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::TrayIconBuilder,
|
||||
AppHandle, Emitter, Manager,
|
||||
};
|
||||
|
||||
fn generate_tray_icon() -> (Vec<u8>, u32, u32) {
|
||||
let size: u32 = 32;
|
||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
||||
// Orange filled square with rounded-ish corners
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let idx = ((y * size + x) * 4) as usize;
|
||||
// Simple circle mask for rounded look
|
||||
let cx = (x as f32) - 15.5;
|
||||
let cy = (y as f32) - 15.5;
|
||||
let dist = (cx * cx + cy * cy).sqrt();
|
||||
if dist < 14.0 {
|
||||
// Orange: #E8922A
|
||||
rgba[idx] = 0xE8; // R
|
||||
rgba[idx + 1] = 0x92; // G
|
||||
rgba[idx + 2] = 0x2A; // B
|
||||
rgba[idx + 3] = 0xFF; // A
|
||||
}
|
||||
}
|
||||
}
|
||||
(rgba, size, size)
|
||||
}
|
||||
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show = MenuItemBuilder::with_id("show", "Open Waggle").build(app)?;
|
||||
let settings = MenuItemBuilder::with_id("settings", "Settings").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit Waggle").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.items(&[&show, &settings, &quit])
|
||||
.build()?;
|
||||
|
||||
let (rgba, w, h) = generate_tray_icon();
|
||||
let icon = Image::new_owned(rgba, w, h);
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.tooltip("Waggle Agent Service")
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show" => {
|
||||
show_main_window(app);
|
||||
}
|
||||
"settings" => {
|
||||
show_main_window(app);
|
||||
let _ = app.emit("waggle://navigate", "/settings");
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click { .. } = event {
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
eprintln!("[waggle] System tray icon created successfully");
|
||||
Ok(())
|
||||
}
|
||||
10
app/src-tauri/tauri.build-override.conf.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"bundle": {
|
||||
"macOS": {
|
||||
"signingIdentity": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Waggle",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.egzakta.waggle",
|
||||
"build": {
|
||||
"frontendDist": "../../apps/web/dist",
|
||||
"devUrl": "http://localhost:8080",
|
||||
"beforeDevCommand": "cd ../apps/web && npx vite",
|
||||
"beforeBuildCommand": "node ../scripts/build-sidecar.mjs && node ../scripts/check-sidecar-resources.mjs && cd ../apps/web && npx vite build"
|
||||
},
|
||||
"bundle": {
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico", "icons/icon.png"],
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/*", "resources/native/*", "resources/native/onnxruntime/*", "resources/node_modules/**/*"],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "nsis/installer.nsi",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installMode": "currentUser",
|
||||
"languages": ["English"],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Waggle",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://us.i.posthog.com; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:"
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"tooltip": "Waggle - AI Agent Swarm"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src-tauri/tauri.dev-override.conf.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeDevCommand": ""
|
||||
},
|
||||
"app": {
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://us.i.posthog.com; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: blob:"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
app/tailwind.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/ui/src/**/*.{ts,tsx}",
|
||||
],
|
||||
} satisfies Config;
|
||||
126
app/tests/auto-update.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Auto-update configuration validation tests.
|
||||
*
|
||||
* Verifies the Tauri updater config, release workflow, and capability
|
||||
* permissions are correctly set up for the auto-update flow.
|
||||
*
|
||||
* These tests validate static config only — no network calls.
|
||||
*
|
||||
* Release flow:
|
||||
* git tag v1.0.1 && git push --tags
|
||||
* This triggers release.yml which builds + signs + publishes to GitHub Releases.
|
||||
* Tauri updater fetches latest.json from the release assets.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const REPO_ROOT = resolve(ROOT, '..');
|
||||
|
||||
function readJson(relPath: string): unknown {
|
||||
const content = readFileSync(resolve(ROOT, relPath), 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
function readText(relPath: string): string {
|
||||
return readFileSync(resolve(REPO_ROOT, relPath), 'utf-8');
|
||||
}
|
||||
|
||||
describe('auto-update configuration', () => {
|
||||
describe('tauri.conf.json updater section', () => {
|
||||
const config = readJson('src-tauri/tauri.conf.json') as {
|
||||
plugins?: {
|
||||
updater?: {
|
||||
endpoints?: string[];
|
||||
pubkey?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// The updater plugin CONFIG was removed for v1 because release.yml published
|
||||
// latest.json with EMPTY signatures — with a pubkey present, every client
|
||||
// update would fail signature verification (a broken update channel). The
|
||||
// dependency remains staged so it can be re-enabled once updater signing is
|
||||
// provisioned (TAURI_SIGNING_PRIVATE_KEY + createUpdaterArtifacts + a
|
||||
// real-signature latest.json generator), but runtime registration is off.
|
||||
it('has the updater plugin config intentionally disabled for v1', () => {
|
||||
expect(config.plugins?.updater).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities permissions', () => {
|
||||
const caps = readJson('src-tauri/capabilities/default.json') as {
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
it('does not expose updater commands while updater config is disabled', () => {
|
||||
expect(caps.permissions).toBeDefined();
|
||||
expect(caps.permissions).not.toContain('updater:default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cargo.toml dependencies', () => {
|
||||
const cargo = readFileSync(resolve(ROOT, 'src-tauri/Cargo.toml'), 'utf-8');
|
||||
|
||||
it('includes tauri-plugin-updater dependency', () => {
|
||||
expect(cargo).toContain('tauri-plugin-updater');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rust updater plugin registration', () => {
|
||||
const libRs = readFileSync(resolve(ROOT, 'src-tauri/src/lib.rs'), 'utf-8');
|
||||
|
||||
it('does not import UpdaterExt while updater config is disabled', () => {
|
||||
expect(libRs).not.toContain('UpdaterExt');
|
||||
});
|
||||
|
||||
it('does not register the updater plugin while updater config is disabled', () => {
|
||||
expect(libRs).not.toContain('tauri_plugin_updater::Builder::new().build()');
|
||||
});
|
||||
|
||||
it('does not run startup update checks while updater config is disabled', () => {
|
||||
expect(libRs).not.toContain('.updater()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('release workflow (release.yml)', () => {
|
||||
const workflow = readText('.github/workflows/release.yml');
|
||||
|
||||
it('triggers on tag push', () => {
|
||||
expect(workflow).toContain("- 'v*'");
|
||||
});
|
||||
|
||||
it('builds for Windows', () => {
|
||||
expect(workflow).toContain('windows-latest');
|
||||
});
|
||||
|
||||
it('builds for macOS (both architectures)', () => {
|
||||
expect(workflow).toContain('aarch64-apple-darwin');
|
||||
expect(workflow).toContain('x86_64-apple-darwin');
|
||||
});
|
||||
|
||||
it('uses tauri-action for builds', () => {
|
||||
expect(workflow).toContain('tauri-apps/tauri-action');
|
||||
});
|
||||
|
||||
it('does NOT publish a broken (empty-signature) updater manifest', () => {
|
||||
// The update-manifest job was removed with the updater config (it published
|
||||
// latest.json with empty signatures). Re-add it with real updater signing.
|
||||
// A re-enable note in comments may still mention it — assert no active job.
|
||||
expect(workflow).not.toMatch(/^\s*update-manifest:/m);
|
||||
});
|
||||
|
||||
it('stages sidecar dependencies before packaging (P0-2)', () => {
|
||||
// The packaged sidecar require()s esbuild-externalized deps that must be
|
||||
// staged into resources/node_modules or it dies with MODULE_NOT_FOUND.
|
||||
expect(workflow).toContain('stage-sidecar-deps');
|
||||
});
|
||||
});
|
||||
|
||||
// Frontend update hook describe block removed — the Tauri app/src/
|
||||
// frontend was deprecated in favor of apps/web/ (see commit a883050).
|
||||
// apps/web keeps a future `waggle://update-available` notice mapper, but
|
||||
// native updater emission stays disabled until signed updater artifacts exist.
|
||||
});
|
||||
92
app/tests/cockpit-agent-intelligence.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* AgentIntelligenceCard — tests that feedback stats are rendered correctly.
|
||||
*
|
||||
* Uses a lightweight approach: tests the data-transformation logic and
|
||||
* verifies the component accepts the FeedbackStats shape without errors.
|
||||
* Since this is a Tauri desktop app (not Next.js), we test the card's
|
||||
* data contract and rendering expectations.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { FeedbackStats } from '../src/components/cockpit/types';
|
||||
|
||||
describe('AgentIntelligenceCard data contract', () => {
|
||||
it('FeedbackStats shape matches the /api/feedback/stats response', () => {
|
||||
const stats: FeedbackStats = {
|
||||
totalFeedback: 42,
|
||||
positiveRate: 0.85,
|
||||
topIssues: ['wrong_answer', 'too_verbose'],
|
||||
correctionsThisWeek: 3,
|
||||
improvementTrend: '+12%',
|
||||
};
|
||||
|
||||
expect(stats.totalFeedback).toBe(42);
|
||||
expect(stats.positiveRate).toBe(0.85);
|
||||
expect(stats.topIssues).toHaveLength(2);
|
||||
expect(stats.correctionsThisWeek).toBe(3);
|
||||
expect(stats.improvementTrend).toBe('+12%');
|
||||
});
|
||||
|
||||
it('handles empty stats (zero feedback)', () => {
|
||||
const stats: FeedbackStats = {
|
||||
totalFeedback: 0,
|
||||
positiveRate: 0,
|
||||
topIssues: [],
|
||||
correctionsThisWeek: 0,
|
||||
improvementTrend: '0%',
|
||||
};
|
||||
|
||||
expect(stats.totalFeedback).toBe(0);
|
||||
expect(stats.positiveRate).toBe(0);
|
||||
expect(stats.topIssues).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles negative improvement trend', () => {
|
||||
const stats: FeedbackStats = {
|
||||
totalFeedback: 10,
|
||||
positiveRate: 0.5,
|
||||
topIssues: ['wrong_tool'],
|
||||
correctionsThisWeek: 7,
|
||||
improvementTrend: '-5%',
|
||||
};
|
||||
|
||||
expect(stats.improvementTrend).toBe('-5%');
|
||||
// Negative trend starts with '-'
|
||||
expect(stats.improvementTrend.startsWith('-')).toBe(true);
|
||||
});
|
||||
|
||||
it('trend parsing for positive values', () => {
|
||||
const trend = '+12%';
|
||||
const match = trend.match(/^([+-]?\d+)%$/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(parseInt(match![1], 10)).toBe(12);
|
||||
});
|
||||
|
||||
it('trend parsing for negative values', () => {
|
||||
const trend = '-5%';
|
||||
const match = trend.match(/^([+-]?\d+)%$/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(parseInt(match![1], 10)).toBe(-5);
|
||||
});
|
||||
|
||||
it('trend parsing for zero', () => {
|
||||
const trend = '0%';
|
||||
const match = trend.match(/^([+-]?\d+)%$/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(parseInt(match![1], 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('reason formatting converts snake_case to Title Case', () => {
|
||||
const formatReason = (reason: string): string =>
|
||||
reason
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
expect(formatReason('wrong_answer')).toBe('Wrong Answer');
|
||||
expect(formatReason('too_verbose')).toBe('Too Verbose');
|
||||
expect(formatReason('wrong_tool')).toBe('Wrong Tool');
|
||||
expect(formatReason('too_slow')).toBe('Too Slow');
|
||||
expect(formatReason('other')).toBe('Other');
|
||||
});
|
||||
});
|
||||
314
app/tests/e2e/chat.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* E2E Tests: Chat Streaming, Tool Events, and Approval Gate
|
||||
*
|
||||
* Scenarios covered:
|
||||
* 4. Chat message sent and response received (with mock agentRunner)
|
||||
* 9. Tool execution — mock runner calls onToolUse, SSE includes tool event
|
||||
* 10. External mutation gate — eventBus-based approval flow
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { startService } from '@waggle/server/local/service';
|
||||
import type { AgentRunner } from '@waggle/server/local/routes/chat';
|
||||
import { injectWithAuth } from './test-utils.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-e2e-'));
|
||||
}
|
||||
|
||||
// Port 0 lets the OS assign a free port; inject() bypasses the network anyway
|
||||
const TEST_PORT = 0;
|
||||
|
||||
/**
|
||||
* Parse SSE text into structured events.
|
||||
* Each event is: "event: <type>\ndata: <json>\n\n"
|
||||
*/
|
||||
function parseSSE(raw: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
const blocks = raw.split('\n\n').filter(b => b.trim());
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n');
|
||||
let event = '';
|
||||
let data = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
event = line.slice(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
data = line.slice(6);
|
||||
}
|
||||
}
|
||||
if (event && data) {
|
||||
try {
|
||||
events.push({ event, data: JSON.parse(data) });
|
||||
} catch {
|
||||
events.push({ event, data });
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe('Chat E2E', () => {
|
||||
const servers: FastifyInstance[] = [];
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of servers) {
|
||||
try { await s.close(); } catch { /* ignore */ }
|
||||
}
|
||||
servers.length = 0;
|
||||
for (const d of tmpDirs) {
|
||||
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
// Scenario 4: Chat message sent and response received via SSE
|
||||
it('sends chat message and receives SSE token + done events', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Inject mock agentRunner
|
||||
const mockRunner: AgentRunner = async (config) => {
|
||||
if (config.onToken) {
|
||||
config.onToken('Hello ');
|
||||
config.onToken('world');
|
||||
}
|
||||
return {
|
||||
content: 'Hello world',
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
toolsUsed: [],
|
||||
};
|
||||
};
|
||||
server.agentRunner = mockRunner;
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/chat',
|
||||
payload: { message: 'Hi there' },
|
||||
});
|
||||
|
||||
// SSE hijacks the response — status comes from raw writeHead
|
||||
// inject() returns the raw body as payload
|
||||
const events = parseSSE(res.payload);
|
||||
|
||||
// Should have token events
|
||||
const tokenEvents = events.filter(e => e.event === 'token');
|
||||
expect(tokenEvents.length).toBe(2);
|
||||
expect((tokenEvents[0].data as { content: string }).content).toBe('Hello ');
|
||||
expect((tokenEvents[1].data as { content: string }).content).toBe('world');
|
||||
|
||||
// Should have done event
|
||||
const doneEvents = events.filter(e => e.event === 'done');
|
||||
expect(doneEvents.length).toBe(1);
|
||||
const doneData = doneEvents[0].data as { content: string; usage: unknown; toolsUsed: string[] };
|
||||
expect(doneData.content).toBe('Hello world');
|
||||
expect(doneData.toolsUsed).toEqual([]);
|
||||
});
|
||||
|
||||
// Scenario 9: Tool execution events in SSE stream
|
||||
it('includes tool events in SSE when agentRunner calls onToolUse', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
const mockRunner: AgentRunner = async (config) => {
|
||||
if (config.onToken) {
|
||||
config.onToken('Reading file...');
|
||||
}
|
||||
if (config.onToolUse) {
|
||||
config.onToolUse('read_file', { path: '/src/index.ts' });
|
||||
}
|
||||
if (config.onToken) {
|
||||
config.onToken(' Done.');
|
||||
}
|
||||
if (config.onToolUse) {
|
||||
config.onToolUse('write_file', { path: '/src/output.ts', content: 'export {}' });
|
||||
}
|
||||
return {
|
||||
content: 'Reading file... Done.',
|
||||
usage: { inputTokens: 20, outputTokens: 10 },
|
||||
toolsUsed: ['read_file', 'write_file'],
|
||||
};
|
||||
};
|
||||
server.agentRunner = mockRunner;
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/chat',
|
||||
payload: { message: 'Read the index file' },
|
||||
});
|
||||
|
||||
const events = parseSSE(res.payload);
|
||||
|
||||
// Should have tool events
|
||||
const toolEvents = events.filter(e => e.event === 'tool');
|
||||
expect(toolEvents.length).toBe(2);
|
||||
|
||||
const tool1 = toolEvents[0].data as { name: string; input: Record<string, unknown> };
|
||||
expect(tool1.name).toBe('read_file');
|
||||
expect(tool1.input.path).toBe('/src/index.ts');
|
||||
|
||||
const tool2 = toolEvents[1].data as { name: string; input: Record<string, unknown> };
|
||||
expect(tool2.name).toBe('write_file');
|
||||
|
||||
// Done event should list tools used
|
||||
const doneEvents = events.filter(e => e.event === 'done');
|
||||
expect(doneEvents.length).toBe(1);
|
||||
const doneData = doneEvents[0].data as { toolsUsed: string[] };
|
||||
expect(doneData.toolsUsed).toEqual(['read_file', 'write_file']);
|
||||
});
|
||||
|
||||
// Scenario 10: External mutation gate — eventBus approval flow
|
||||
// The approval gate is driven by the eventBus: the agent emits a
|
||||
// 'gate:request' event and waits for 'gate:response'. This test
|
||||
// verifies the eventBus-based flow without needing an HTTP endpoint.
|
||||
it('external mutation gate: eventBus blocks and resumes on approval', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Simulate the approval gate pattern used by the desktop app:
|
||||
// agentRunner emits 'gate:request' on the eventBus, waits for 'gate:response'
|
||||
const gateLog: string[] = [];
|
||||
|
||||
const mockRunner: AgentRunner = async (config) => {
|
||||
// Simulate a tool that triggers an approval gate
|
||||
if (config.onToolUse) {
|
||||
config.onToolUse('bash', { command: 'rm -rf /important' });
|
||||
}
|
||||
|
||||
// Emit gate request and wait for response
|
||||
const approved = await new Promise<boolean>((resolve) => {
|
||||
server.eventBus.once('gate:response', (response: { approved: boolean }) => {
|
||||
gateLog.push(response.approved ? 'approved' : 'denied');
|
||||
resolve(response.approved);
|
||||
});
|
||||
server.eventBus.emit('gate:request', {
|
||||
tool: 'bash',
|
||||
input: { command: 'rm -rf /important' },
|
||||
requestId: 'gate-001',
|
||||
});
|
||||
});
|
||||
|
||||
if (config.onToken) {
|
||||
config.onToken(approved ? 'Executed.' : 'Blocked.');
|
||||
}
|
||||
|
||||
return {
|
||||
content: approved ? 'Executed.' : 'Blocked.',
|
||||
usage: { inputTokens: 15, outputTokens: 3 },
|
||||
toolsUsed: approved ? ['bash'] : [],
|
||||
};
|
||||
};
|
||||
server.agentRunner = mockRunner;
|
||||
|
||||
// Listen for gate requests and auto-approve
|
||||
server.eventBus.on('gate:request', (req: { requestId: string }) => {
|
||||
gateLog.push('request-received');
|
||||
// Simulate user clicking "Approve" in the UI
|
||||
server.eventBus.emit('gate:response', { requestId: req.requestId, approved: true });
|
||||
});
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/chat',
|
||||
payload: { message: 'Delete the folder' },
|
||||
});
|
||||
|
||||
const events = parseSSE(res.payload);
|
||||
const doneEvents = events.filter(e => e.event === 'done');
|
||||
expect(doneEvents.length).toBe(1);
|
||||
|
||||
const doneData = doneEvents[0].data as { content: string; toolsUsed: string[] };
|
||||
expect(doneData.content).toBe('Executed.');
|
||||
expect(doneData.toolsUsed).toEqual(['bash']);
|
||||
|
||||
// Verify gate flow happened in correct order
|
||||
expect(gateLog.length).toBe(2);
|
||||
expect(gateLog[0]).toBe('request-received');
|
||||
expect(gateLog[1]).toBe('approved');
|
||||
});
|
||||
|
||||
// Scenario 10b: External mutation gate — denial path
|
||||
it('external mutation gate: eventBus blocks and returns denial', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
const gateLog: string[] = [];
|
||||
|
||||
const mockRunner: AgentRunner = async (config) => {
|
||||
if (config.onToolUse) {
|
||||
config.onToolUse('bash', { command: 'rm -rf /important' });
|
||||
}
|
||||
|
||||
// Emit gate request and wait for response
|
||||
const approved = await new Promise<boolean>((resolve) => {
|
||||
server.eventBus.once('gate:response', (response: { approved: boolean }) => {
|
||||
gateLog.push(response.approved ? 'approved' : 'denied');
|
||||
resolve(response.approved);
|
||||
});
|
||||
server.eventBus.emit('gate:request', {
|
||||
tool: 'bash',
|
||||
input: { command: 'rm -rf /important' },
|
||||
requestId: 'gate-002',
|
||||
});
|
||||
});
|
||||
|
||||
if (config.onToken) {
|
||||
config.onToken(approved ? 'Executed.' : 'Blocked.');
|
||||
}
|
||||
|
||||
return {
|
||||
content: approved ? 'Executed.' : 'Blocked.',
|
||||
usage: { inputTokens: 15, outputTokens: 3 },
|
||||
toolsUsed: approved ? ['bash'] : [],
|
||||
};
|
||||
};
|
||||
server.agentRunner = mockRunner;
|
||||
|
||||
// Listen for gate requests and auto-DENY
|
||||
server.eventBus.on('gate:request', (req: { requestId: string }) => {
|
||||
gateLog.push('request-received');
|
||||
// Simulate user clicking "Deny" in the UI
|
||||
server.eventBus.emit('gate:response', { requestId: req.requestId, approved: false });
|
||||
});
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/chat',
|
||||
payload: { message: 'Delete the folder' },
|
||||
});
|
||||
|
||||
const events = parseSSE(res.payload);
|
||||
const doneEvents = events.filter(e => e.event === 'done');
|
||||
expect(doneEvents.length).toBe(1);
|
||||
|
||||
const doneData = doneEvents[0].data as { content: string; toolsUsed: string[] };
|
||||
expect(doneData.content).toBe('Blocked.');
|
||||
expect(doneData.toolsUsed).toEqual([]);
|
||||
|
||||
// Verify gate flow happened in correct order with denial
|
||||
expect(gateLog.length).toBe(2);
|
||||
expect(gateLog[0]).toBe('request-received');
|
||||
expect(gateLog[1]).toBe('denied');
|
||||
});
|
||||
});
|
||||
132
app/tests/e2e/startup.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* E2E Tests: Startup, Onboarding, and Settings Persistence
|
||||
*
|
||||
* Scenarios covered:
|
||||
* 1. Service starts and responds to health check
|
||||
* 2. Onboarding wizard completes successfully (config save/load via settings API)
|
||||
* 7. Settings saved and persisted across restart
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { startService } from '@waggle/server/local/service';
|
||||
import { injectWithAuth } from './test-utils.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-e2e-'));
|
||||
}
|
||||
|
||||
// Port 0 lets the OS assign a free port; inject() bypasses the network anyway
|
||||
const TEST_PORT = 0;
|
||||
|
||||
describe('Startup & Settings E2E', () => {
|
||||
const servers: FastifyInstance[] = [];
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of servers) {
|
||||
try { await s.close(); } catch { /* ignore */ }
|
||||
}
|
||||
servers.length = 0;
|
||||
for (const d of tmpDirs) {
|
||||
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
// Scenario 1: Service starts and responds to health check
|
||||
it('health check returns 200 with status ok and mode local', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
const res = await server.inject({ method: 'GET', url: '/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.payload);
|
||||
// With skipLiteLLM, health is degraded (no verified LLM), not 'ok' — truthful health
|
||||
expect(['ok', 'degraded', 'unavailable']).toContain(body.status);
|
||||
expect(body.mode).toBe('local');
|
||||
expect(body.timestamp).toBeDefined();
|
||||
// Deep health fields present
|
||||
expect(body.llm).toBeDefined();
|
||||
expect(body.database).toBeDefined();
|
||||
});
|
||||
|
||||
// Scenario 2: Onboarding — save config via PUT, read it back via GET
|
||||
it('onboarding: saves and loads config via settings API', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Save onboarding config
|
||||
const putRes = await injectWithAuth(server, {
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: {
|
||||
defaultModel: 'anthropic/claude-sonnet-4-20250514',
|
||||
providers: {
|
||||
anthropic: { apiKey: 'sk-ant-test-key-1234567890', models: ['claude-sonnet-4-20250514'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(putRes.statusCode).toBe(200);
|
||||
|
||||
const putBody = JSON.parse(putRes.payload);
|
||||
expect(putBody.defaultModel).toBe('anthropic/claude-sonnet-4-20250514');
|
||||
|
||||
// Read config back
|
||||
const getRes = await injectWithAuth(server, { method: 'GET', url: '/api/settings' });
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
|
||||
const getBody = JSON.parse(getRes.payload);
|
||||
expect(getBody.defaultModel).toBe('anthropic/claude-sonnet-4-20250514');
|
||||
expect(getBody.providers).toBeDefined();
|
||||
expect(getBody.dataDir).toBe(dataDir);
|
||||
});
|
||||
|
||||
// Scenario 7: Settings persisted across restart
|
||||
it('settings persist across server restart', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
|
||||
// --- First server: save settings ---
|
||||
const port1 = TEST_PORT;
|
||||
const { server: server1 } = await startService({ dataDir, port: port1, skipLiteLLM: true });
|
||||
servers.push(server1);
|
||||
|
||||
const putRes = await injectWithAuth(server1, {
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: {
|
||||
defaultModel: 'openai/gpt-4o',
|
||||
providers: {
|
||||
openai: { apiKey: 'sk-test-openai-key-1234567', models: ['gpt-4o'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(putRes.statusCode).toBe(200);
|
||||
|
||||
await server1.close();
|
||||
servers.pop();
|
||||
|
||||
// --- Second server: read settings back ---
|
||||
const port2 = TEST_PORT;
|
||||
const { server: server2 } = await startService({ dataDir, port: port2, skipLiteLLM: true });
|
||||
servers.push(server2);
|
||||
|
||||
const getRes = await injectWithAuth(server2, { method: 'GET', url: '/api/settings' });
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(getRes.payload);
|
||||
expect(body.defaultModel).toBe('openai/gpt-4o');
|
||||
});
|
||||
});
|
||||
22
app/tests/e2e/test-utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared test utilities for e2e tests.
|
||||
* Provides authenticated inject helper for local server tests (SEC-011).
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, InjectOptions } from 'fastify';
|
||||
|
||||
/**
|
||||
* Shorthand: inject with auth token from the server's agentState.
|
||||
* Returns the same result as server.inject().
|
||||
*/
|
||||
export function injectWithAuth(server: FastifyInstance, opts: InjectOptions) {
|
||||
const token = server.agentState.wsSessionToken;
|
||||
const existingHeaders = (opts.headers ?? {}) as Record<string, string>;
|
||||
return server.inject({
|
||||
...opts,
|
||||
headers: {
|
||||
...existingHeaders,
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
332
app/tests/e2e/workspaces.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* E2E Tests: Workspaces, Memory Isolation, and Sessions
|
||||
*
|
||||
* Scenarios covered:
|
||||
* 3. Workspace created via API
|
||||
* 5. Workspace switching changes mind context
|
||||
* 6. Memory search returns results from correct mind
|
||||
* 8. Session management (create, list, delete)
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { startService } from '@waggle/server/local/service';
|
||||
import { FrameStore, SessionStore } from '@waggle/core';
|
||||
import { injectWithAuth } from './test-utils.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-e2e-'));
|
||||
}
|
||||
|
||||
// Port 0 lets the OS assign a free port; inject() bypasses the network anyway
|
||||
const TEST_PORT = 0;
|
||||
|
||||
describe('Workspaces & Sessions E2E', () => {
|
||||
const servers: FastifyInstance[] = [];
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of servers) {
|
||||
try { await s.close(); } catch { /* ignore */ }
|
||||
}
|
||||
servers.length = 0;
|
||||
for (const d of tmpDirs) {
|
||||
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
// Scenario 3: Create workspace via POST, verify GET returns it
|
||||
it('creates a workspace and retrieves it', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Create workspace
|
||||
const createRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Test Project', group: 'Work', icon: 'briefcase' },
|
||||
});
|
||||
expect(createRes.statusCode).toBe(201);
|
||||
|
||||
const created = JSON.parse(createRes.payload);
|
||||
expect(created.name).toBe('Test Project');
|
||||
expect(created.group).toBe('Work');
|
||||
expect(created.id).toBeDefined();
|
||||
|
||||
// List workspaces — should contain the new one
|
||||
const listRes = await injectWithAuth(server, { method: 'GET', url: '/api/workspaces' });
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
|
||||
const list = JSON.parse(listRes.payload);
|
||||
expect(list).toBeInstanceOf(Array);
|
||||
// Server now auto-creates a Default Workspace on boot via
|
||||
// WorkspaceManager.ensureDefault(), so the list contains both
|
||||
// the default and the one this test just created.
|
||||
const testWs = list.find((w: { name: string }) => w.name === 'Test Project');
|
||||
expect(testWs).toBeDefined();
|
||||
|
||||
// Get by ID
|
||||
const getRes = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${created.id}`,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
const fetched = JSON.parse(getRes.payload);
|
||||
expect(fetched.id).toBe(created.id);
|
||||
expect(fetched.icon).toBe('briefcase');
|
||||
});
|
||||
|
||||
// Scenario 5: Workspace switching changes mind context
|
||||
it('workspace switching isolates memory context', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Create two workspaces
|
||||
const res1 = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Alpha', group: 'Work', model: 'openai/gpt-4o' },
|
||||
});
|
||||
const ws1 = JSON.parse(res1.payload);
|
||||
|
||||
const res2 = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Beta', group: 'Personal', model: 'anthropic/claude-sonnet-4-20250514' },
|
||||
});
|
||||
const ws2 = JSON.parse(res2.payload);
|
||||
|
||||
// Verify they are distinct
|
||||
expect(ws1.id).not.toBe(ws2.id);
|
||||
expect(ws1.group).toBe('Work');
|
||||
expect(ws2.group).toBe('Personal');
|
||||
|
||||
// Each workspace has its own .mind file on disk
|
||||
const mind1Path = path.join(dataDir, 'workspaces', ws1.id, 'workspace.mind');
|
||||
const mind2Path = path.join(dataDir, 'workspaces', ws2.id, 'workspace.mind');
|
||||
expect(fs.existsSync(mind1Path)).toBe(true);
|
||||
expect(fs.existsSync(mind2Path)).toBe(true);
|
||||
|
||||
// Switch to ws-A, store memory, verify it's found
|
||||
server.multiMind.switchWorkspace(mind1Path);
|
||||
const wsASessions = new SessionStore(server.multiMind.workspace!);
|
||||
const wsASession = wsASessions.create();
|
||||
const wsAFrames = new FrameStore(server.multiMind.workspace!);
|
||||
wsAFrames.createIFrame(wsASession.gop_id, 'Alpha project uses Kubernetes for deployment');
|
||||
|
||||
// Search ws-A scope — should find Kubernetes
|
||||
const searchA = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Kubernetes&scope=workspace',
|
||||
});
|
||||
expect(searchA.statusCode).toBe(200);
|
||||
const resultsA = JSON.parse(searchA.payload);
|
||||
expect(resultsA.count).toBeGreaterThan(0);
|
||||
|
||||
// Switch to ws-B — search should NOT find ws-A's memory
|
||||
server.multiMind.switchWorkspace(mind2Path);
|
||||
const searchB = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Kubernetes&scope=workspace',
|
||||
});
|
||||
expect(searchB.statusCode).toBe(200);
|
||||
const resultsB = JSON.parse(searchB.payload);
|
||||
expect(resultsB.count).toBe(0);
|
||||
|
||||
// Store different memory in ws-B
|
||||
const wsBSessions = new SessionStore(server.multiMind.workspace!);
|
||||
const wsBSession = wsBSessions.create();
|
||||
const wsBFrames = new FrameStore(server.multiMind.workspace!);
|
||||
wsBFrames.createIFrame(wsBSession.gop_id, 'Beta project uses Docker Compose locally');
|
||||
|
||||
// ws-B should find Docker but not Kubernetes
|
||||
const searchB2 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Docker&scope=workspace',
|
||||
});
|
||||
expect(searchB2.statusCode).toBe(200);
|
||||
expect(JSON.parse(searchB2.payload).count).toBeGreaterThan(0);
|
||||
|
||||
// Switch back to ws-A — should find Kubernetes, not Docker
|
||||
server.multiMind.switchWorkspace(mind1Path);
|
||||
const searchA2 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Kubernetes&scope=workspace',
|
||||
});
|
||||
expect(searchA2.statusCode).toBe(200);
|
||||
expect(JSON.parse(searchA2.payload).count).toBeGreaterThan(0);
|
||||
|
||||
const searchA3 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Docker&scope=workspace',
|
||||
});
|
||||
expect(searchA3.statusCode).toBe(200);
|
||||
expect(JSON.parse(searchA3.payload).count).toBe(0);
|
||||
});
|
||||
|
||||
// Scenario 6: Memory search returns results from correct mind only
|
||||
it('memory search returns results scoped to the correct mind', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Store a memory in the personal mind — need a session first (FK constraint)
|
||||
const personalSessions = new SessionStore(server.multiMind.personal);
|
||||
const pSession = personalSessions.create();
|
||||
const personalFrames = new FrameStore(server.multiMind.personal);
|
||||
personalFrames.createIFrame(pSession.gop_id, 'Waggle architecture uses Tauri with React frontend');
|
||||
|
||||
// Create a workspace and store memory in its mind
|
||||
const createRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Research', group: 'Study' },
|
||||
});
|
||||
const ws = JSON.parse(createRes.payload);
|
||||
|
||||
// Switch multiMind to workspace so workspace search works
|
||||
const wsMindPath = path.join(dataDir, 'workspaces', ws.id, 'workspace.mind');
|
||||
server.multiMind.switchWorkspace(wsMindPath);
|
||||
|
||||
// Store memory in workspace mind — need a session first (FK constraint)
|
||||
const wsSessions = new SessionStore(server.multiMind.workspace!);
|
||||
const wSession = wsSessions.create();
|
||||
const wsFrames = new FrameStore(server.multiMind.workspace!);
|
||||
wsFrames.createIFrame(wSession.gop_id, 'GraphContext uses SHACL validation for knowledge graphs');
|
||||
|
||||
// Search personal scope — should find "Tauri" but not "SHACL"
|
||||
const personalSearch = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Tauri&scope=personal',
|
||||
});
|
||||
expect(personalSearch.statusCode).toBe(200);
|
||||
const personalResults = JSON.parse(personalSearch.payload);
|
||||
expect(personalResults.count).toBeGreaterThan(0);
|
||||
|
||||
// Search workspace scope — should find "SHACL" but not "Tauri"
|
||||
const wsSearch = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=SHACL&scope=workspace',
|
||||
});
|
||||
expect(wsSearch.statusCode).toBe(200);
|
||||
const wsResults = JSON.parse(wsSearch.payload);
|
||||
expect(wsResults.count).toBeGreaterThan(0);
|
||||
|
||||
// Cross-check: personal scope should NOT find workspace-only content
|
||||
const crossCheck = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=SHACL&scope=personal',
|
||||
});
|
||||
const crossResults = JSON.parse(crossCheck.payload);
|
||||
expect(crossResults.count).toBe(0);
|
||||
|
||||
// Search all scope — should find both
|
||||
const allSearch = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=architecture&scope=all',
|
||||
});
|
||||
const allResults = JSON.parse(allSearch.payload);
|
||||
expect(allResults.count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Scenario 8: Session CRUD — create, list, rename (switch), delete
|
||||
it('creates, lists, renames, and deletes sessions within a workspace', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Create workspace first
|
||||
const wsRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Chat Workspace', group: 'Work' },
|
||||
});
|
||||
const ws = JSON.parse(wsRes.payload);
|
||||
|
||||
// Create a session
|
||||
const createRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
payload: { title: 'My First Chat' },
|
||||
});
|
||||
expect(createRes.statusCode).toBe(201);
|
||||
const session = JSON.parse(createRes.payload);
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.title).toBe('My First Chat');
|
||||
expect(session.messageCount).toBe(0);
|
||||
|
||||
// Create a second session
|
||||
const createRes2 = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
payload: { title: 'Debug Session' },
|
||||
});
|
||||
expect(createRes2.statusCode).toBe(201);
|
||||
const session2 = JSON.parse(createRes2.payload);
|
||||
|
||||
// List sessions — should have 2
|
||||
const listRes = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
});
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
const list = JSON.parse(listRes.payload);
|
||||
expect(list.length).toBe(2);
|
||||
|
||||
// Rename (switch equivalent) — proves session is accessible and modifiable
|
||||
const patchRes = await injectWithAuth(server, {
|
||||
method: 'PATCH',
|
||||
url: `/api/sessions/${session.id}?workspace=${ws.id}`,
|
||||
payload: { title: 'Renamed Chat' },
|
||||
});
|
||||
expect(patchRes.statusCode).toBe(200);
|
||||
const patched = JSON.parse(patchRes.payload);
|
||||
expect(patched.title).toBe('Renamed Chat');
|
||||
expect(patched.id).toBe(session.id);
|
||||
|
||||
// Verify rename persisted in list
|
||||
const listRes3 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
});
|
||||
const list3 = JSON.parse(listRes3.payload);
|
||||
const renamed = list3.find((s: { id: string }) => s.id === session.id);
|
||||
expect(renamed).toBeDefined();
|
||||
expect(renamed.title).toBe('Renamed Chat');
|
||||
|
||||
// Delete first session
|
||||
const delRes = await injectWithAuth(server, {
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${session.id}?workspace=${ws.id}`,
|
||||
});
|
||||
expect(delRes.statusCode).toBe(200);
|
||||
const delBody = JSON.parse(delRes.payload);
|
||||
expect(delBody.deleted).toBe(true);
|
||||
|
||||
// List again — should have 1
|
||||
const listRes2 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
});
|
||||
const list2 = JSON.parse(listRes2.payload);
|
||||
expect(list2.length).toBe(1);
|
||||
expect(list2[0].id).toBe(session2.id);
|
||||
});
|
||||
});
|
||||
34
app/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
// app/ is now the Tauri Rust shell (app/src-tauri/); the React UI lives in
|
||||
// apps/web. The only TypeScript that remains under app/ is the build/installer/
|
||||
// signing tooling in scripts/, so the tauri-tsc gate typechecks that.
|
||||
"include": ["scripts"]
|
||||
}
|
||||
47
app/vite.config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://tauri.app/start/frontend/vite/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
|
||||
// Prevent vite from obscuring Rust errors
|
||||
clearScreen: false,
|
||||
|
||||
build: {
|
||||
// Output to dist/ for both Tauri and web mode
|
||||
outDir: "dist",
|
||||
// Generate source maps for debugging (stripped by Tauri for production)
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
// Mark Tauri packages as external — they're loaded dynamically at runtime
|
||||
// and only available in the Tauri desktop environment
|
||||
external: [
|
||||
/^@tauri-apps\/.*/,
|
||||
],
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/react-dom') || id.includes('node_modules/react/')) return 'vendor';
|
||||
if (id.includes('node_modules/marked') || id.includes('node_modules/dompurify')) return 'markdown';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
// Tell vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||