moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

6
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
tsconfig.tsbuildinfo
# Rust build artifacts
src-tauri/target/

25
app/components.json Normal file
View 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": {}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

47
app/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View 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

View 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();

View 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);
});
});

View 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
View 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);
}

View 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();
}
});
});

View 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;
}

View 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."

View 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
}

View 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('-');
});
});

View 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,
};
}

View 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

File diff suppressed because it is too large Load Diff

29
app/src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

File diff suppressed because one or more lines are too long

View 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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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

View File

View 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");
}
}

View 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))
}

View 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(&params.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
})
}

View 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;

View 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");
}
}

View 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
View 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();
}
}
}
}
});
}

View 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();
}

View 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(), &current_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
View 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(())
}

View File

@@ -0,0 +1,10 @@
{
"build": {
"beforeBuildCommand": ""
},
"bundle": {
"macOS": {
"signingIdentity": "-"
}
}
}

View 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
}
}
}

View 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
View 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;

View 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.
});

View 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
View 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');
});
});

View 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');
});
});

View 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}`,
},
});
}

View 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
View 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
View 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/**"],
},
},
});