From f07a5ec77d3851fac3945f3825f1aaa778482a43 Mon Sep 17 00:00:00 2001 From: "Aesthetic.Computer" Date: Fri, 17 Apr 2026 00:37:14 -0700 Subject: [PATCH] ac-electron v0.1.38: always-on-top toggle, productName, CDP harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always-on-top now OFF by default (was always on). Added a "Always on Top" checkbox to the main tray menu that toggles + persists to preferences.json + applies to all open AC windows. - productName renamed to "Aesthetic.Computer" (dot instead of space) so Finder/Dock/menu-bar labels read as a single token. - New testing/cdp-latency.mjs: a standalone probe that talks to the running app over CDP (localhost:9222 — launch with --remote-debugging-port=9222), navigates to notepat, dispatches key events, reads window.__notepat_perfStats.latency, and prints stats. Enables driving/testing the installed app from outside without the old Codespaces/VS Code artery panel. - `npm run test:latency` script entry added. Auto-updater validated end-to-end: installed v0.1.37 correctly detected + began downloading v0.1.38 from the self-hosted feed on relaunch. Co-Authored-By: Claude Opus 4.7 (1M context) --- ac-electron/main.js | 30 ++- ac-electron/package-lock.json | 4 +- ac-electron/package.json | 7 +- ac-electron/testing/cdp-latency.mjs | 281 ++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 ac-electron/testing/cdp-latency.mjs diff --git a/ac-electron/main.js b/ac-electron/main.js index 6d7c34c47b..67bad19d38 100644 --- a/ac-electron/main.js +++ b/ac-electron/main.js @@ -76,7 +76,12 @@ let preferences = { showTrayTitle: true, trayTitleText: 'AC', // Short text next to tray icon launchAtLogin: false, - defaultMode: 'ac-pane' + defaultMode: 'ac-pane', + // Window float behavior. When true, new AC Pane / Notepat windows + // open with alwaysOnTop so they float above other apps (the old + // default). When false (new default), they behave like normal windows + // and can be ordered underneath. Toggled from the tray menu. + alwaysOnTop: false, }; function loadPreferences() { @@ -840,7 +845,24 @@ function rebuildTrayMenu() { label: 'Open Notepat 🎹', click: () => openNotepatWindow() }); - + + // Always-on-top toggle. Persisted to preferences.json and applied to + // every current AC window on change; new windows inherit via their + // BrowserWindow opts. + menuItems.push({ + label: 'Always on Top', + type: 'checkbox', + checked: !!preferences.alwaysOnTop, + click: (item) => { + preferences.alwaysOnTop = !!item.checked; + savePreferences(); + for (const w of BrowserWindow.getAllWindows()) { + try { w.setAlwaysOnTop(preferences.alwaysOnTop); } catch (_) {} + } + rebuildTrayMenu(); + } + }); + // Quick DevTools access (especially for Windows) menuItems.push({ label: 'Open DevTools', @@ -1211,7 +1233,7 @@ function openNotepatWindow() { frame: false, transparent: !isPaperWM, hasShadow: isPaperWM, - alwaysOnTop: !isPaperWM, + alwaysOnTop: !isPaperWM && preferences.alwaysOnTop, backgroundColor: isPaperWM ? '#000000' : '#00000000', webPreferences: { nodeIntegration: true, @@ -1328,7 +1350,7 @@ async function openAcPaneWindowInternal(options = {}) { frame: false, transparent: !isPaperWM, hasShadow: isPaperWM, - alwaysOnTop: !isPaperWM, + alwaysOnTop: !isPaperWM && preferences.alwaysOnTop, backgroundColor: isPaperWM ? '#000000' : '#00000000', webPreferences: { nodeIntegration: true, diff --git a/ac-electron/package-lock.json b/ac-electron/package-lock.json index 3798f590bb..24780df371 100644 --- a/ac-electron/package-lock.json +++ b/ac-electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "aesthetic-computer", - "version": "0.1.37", + "version": "0.1.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aesthetic-computer", - "version": "0.1.37", + "version": "0.1.38", "license": "MIT", "dependencies": { "@electron/rebuild": "^4.0.2", diff --git a/ac-electron/package.json b/ac-electron/package.json index 4d14a81f42..d2df5d14d9 100644 --- a/ac-electron/package.json +++ b/ac-electron/package.json @@ -1,6 +1,6 @@ { "name": "aesthetic-computer", - "version": "0.1.37", + "version": "0.1.38", "description": "Aesthetic Computer", "homepage": "https://aesthetic.computer", "author": "Aesthetic Computer ", @@ -30,11 +30,12 @@ "build:host:mac": "bash scripts/build-publish-host-mac.sh --skip-publish", "release:host:mac": "bash scripts/build-publish-host-mac.sh", "publish:mac": "electron-builder --mac --universal --publish always", - "release": "electron-builder --mac --universal --publish always" + "release": "electron-builder --mac --universal --publish always", + "test:latency": "node testing/cdp-latency.mjs" }, "build": { "appId": "computer.aesthetic.app", - "productName": "Aesthetic Computer", + "productName": "Aesthetic.Computer", "copyright": "Copyright © 2025 Aesthetic Computer", "directories": { "output": "dist", diff --git a/ac-electron/testing/cdp-latency.mjs b/ac-electron/testing/cdp-latency.mjs new file mode 100644 index 0000000000..5d62b8d3a7 --- /dev/null +++ b/ac-electron/testing/cdp-latency.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// CDP latency probe for the running Electron app. +// +// Connects to the Electron CDP endpoint (localhost:9222, enabled by the +// --remote-debugging-port=9222 flag the app already sets in package.json +// scripts), finds the notepat webview, dispatches keydown events, reads +// `window.__notepat_perfStats.latency`, and reports stats. +// +// Prerequisite: the app must be running. Install/launch via: +// open "/Applications/Aesthetic Computer.app" +// Or for the dev source: +// cd ac-electron && npm start +// +// Usage: +// node testing/cdp-latency.mjs [--iterations=20] [--cooldown=200] +// [--key=a] [--port=9222] +// [--open-notepat] open the notepat window first +// +// Notes: +// - Uses native fetch + WebSocket (Node 22+). No external deps. +// - Measures JS-side latency: keydown → notepat's `sound.synth` call. +// - Audio buffer/worklet latency is NOT included (typically +10-30ms). + +const DEFAULTS = { + iterations: 20, + cooldownMs: 200, + key: "a", + port: 9222, + openNotepat: false, +}; + +function parseArgs(argv) { + const out = { ...DEFAULTS }; + for (const raw of argv.slice(2)) { + const [k, v] = raw.replace(/^--/, "").split("="); + if (k === "iterations") out.iterations = Number(v); + else if (k === "cooldown") out.cooldownMs = Number(v); + else if (k === "key") out.key = v; + else if (k === "port") out.port = Number(v); + else if (k === "open-notepat") out.openNotepat = true; + else throw new Error(`unknown arg: ${raw}`); + } + return out; +} + +async function listTargets(port) { + const r = await fetch(`http://127.0.0.1:${port}/json`); + if (!r.ok) throw new Error(`CDP /json returned ${r.status}. Is the app running with --remote-debugging-port=${port}?`); + return r.json(); +} + +// Minimal CDP client over a single target's WebSocket. Keeps the wire +// protocol in one place (id → promise map + event listeners) so each +// target we talk to is just a session object. +class CdpSession { + constructor(ws) { + this.ws = ws; + this.nextId = 1; + this.pending = new Map(); + this.listeners = new Map(); + ws.addEventListener("message", (m) => this.onMessage(m)); + } + + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { + ws.addEventListener("open", res, { once: true }); + ws.addEventListener("error", rej, { once: true }); + }); + return new CdpSession(ws); + } + + onMessage(m) { + const msg = JSON.parse(m.data); + if (msg.id && this.pending.has(msg.id)) { + const { resolve, reject } = this.pending.get(msg.id); + this.pending.delete(msg.id); + if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`)); + else resolve(msg.result); + } else if (msg.method) { + const fns = this.listeners.get(msg.method); + if (fns) for (const fn of fns) fn(msg.params); + } + } + + send(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify({ id, method, params })); + }); + } + + on(event, fn) { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()); + this.listeners.get(event).add(fn); + } + + close() { this.ws.close(); } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function percentile(sorted, p) { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[idx]; +} + +function stats(samples) { + const s = [...samples].sort((a, b) => a - b); + const n = s.length; + const mean = s.reduce((a, b) => a + b, 0) / n; + const variance = s.reduce((acc, v) => acc + (v - mean) ** 2, 0) / n; + return { + n, + mean, + median: n ? s[Math.floor(n / 2)] : 0, + min: s[0] ?? 0, + max: s[n - 1] ?? 0, + p95: percentile(s, 95), + p99: percentile(s, 99), + stdDev: Math.sqrt(variance), + }; +} + +// Key name → VK code + DOM-style key. `Input.dispatchKeyEvent` wants a +// `windowsVirtualKeyCode` for modifier-free letter keys. For lowercase +// a–z, VK = uppercase ASCII code. +function keyToInput(k) { + const ch = k.toLowerCase(); + const vk = ch.toUpperCase().charCodeAt(0); + return { key: ch, code: `Key${ch.toUpperCase()}`, windowsVirtualKeyCode: vk }; +} + +async function findNotepatTarget(port) { + const targets = await listTargets(port); + // Prefer a target whose URL is the notepat piece itself; fall back to + // any aesthetic.computer page (whatever the AC Pane/Notepat window + // happens to be showing). + // AC content runs inside a element, so the CDP target type + // is "webview" (not "page"). The outer BrowserWindow is "page" and + // loads a local file:// harness — skip those. + const isAcContent = (t) => /aesthetic\.computer/.test(t.url || "") && (t.type === "webview" || t.type === "page"); + const byPiece = targets.find((t) => isAcContent(t) && /\/notepat(\W|$)/.test(t.url || "")); + if (byPiece) return byPiece; + return targets.find(isAcContent); +} + +async function openNotepatViaMain(port) { + // Find the main-process BrowserWindow target (notepat-view.html or + // flip-view.html) so we can invoke openNotepatWindow() indirectly via + // a helper exposed at runtime. For now we just log a hint; opening + // the notepat window is one click on the piano tray. + console.log("⚠️ --open-notepat: click the piano tray icon to open Notepat."); + console.log(" Waiting up to 20s for a target matching /notepat to appear..."); + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const t = await findNotepatTarget(port); + if (t && /\/notepat/.test(t.url || "")) return t; + await sleep(500); + } + return null; +} + +async function main() { + const opts = parseArgs(process.argv); + console.log(`CDP latency probe — iterations=${opts.iterations} cooldown=${opts.cooldownMs}ms key=${opts.key}`); + + let target; + if (opts.openNotepat) target = await openNotepatViaMain(opts.port) || await findNotepatTarget(opts.port); + else target = await findNotepatTarget(opts.port); + + if (!target) { + console.error("❌ No aesthetic.computer page target found on CDP :" + opts.port); + console.error(" Make sure the app is running and a Notepat/AC window is open."); + process.exit(1); + } + console.log(`→ Attaching to: ${target.url}`); + + const cdp = await CdpSession.connect(target.webSocketDebuggerUrl); + await cdp.send("Runtime.enable"); + await cdp.send("Input.enable").catch(() => {}); // Input domain doesn't exist in all CDP versions + await cdp.send("Page.enable").catch(() => {}); + + // If we grabbed some other AC page (e.g. /prompt), navigate it to the + // notepat piece via the in-app router so boot() runs and perfStats + // becomes available. `jump` is the AC convention for client-side piece + // navigation and works from any piece. + if (!/\/notepat/.test(target.url || "")) { + console.log("→ Navigating this webview to /notepat via jump('notepat')..."); + await cdp.send("Runtime.evaluate", { + expression: `(window.jump || (s) => (location.href = '/' + s))('notepat')`, + }); + } + + // Wait up to 8s for `window.__notepat_perfStats` to appear. + const bootDeadline = Date.now() + 8000; + let perfReady = false; + while (Date.now() < bootDeadline) { + const r = await cdp.send("Runtime.evaluate", { + expression: `(typeof window.__notepat_perfStats === 'object')`, + returnByValue: true, + }); + if (r.result.value) { perfReady = true; break; } + await sleep(150); + } + if (!perfReady) { + console.error("❌ `window.__notepat_perfStats` not found after boot wait."); + process.exit(1); + } + // Notepat needs a user gesture to unlock the AudioContext. Without one, + // sound.synth calls queue silently and latency reads won't be meaningful. + // Dispatch a synthetic mouseDown/mouseUp on the center of the viewport + // to satisfy the gesture requirement before the test loop starts. + await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: 100, y: 100, button: "left", clickCount: 1 }).catch(() => {}); + await sleep(30); + await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: 100, y: 100, button: "left", clickCount: 1 }).catch(() => {}); + await sleep(300); + + const input = keyToInput(opts.key); + const samples = []; + + for (let i = 0; i < opts.iterations; i++) { + // Reset the stat so a stale value doesn't bleed through. + await cdp.send("Runtime.evaluate", { + expression: `window.__notepat_perfStats && (window.__notepat_perfStats.latency = null, window.__notepat_perfStats.lastKeyTime = null)`, + }); + + await cdp.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: input.key, + code: input.code, + windowsVirtualKeyCode: input.windowsVirtualKeyCode, + }); + + // Wait up to 80ms for the sound trigger to update perfStats. + let latency = null; + for (let tries = 0; tries < 16; tries++) { + await sleep(5); + const r = await cdp.send("Runtime.evaluate", { + expression: `window.__notepat_perfStats?.latency ?? null`, + returnByValue: true, + }); + if (typeof r.result.value === "number") { latency = r.result.value; break; } + } + + await cdp.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: input.key, + code: input.code, + windowsVirtualKeyCode: input.windowsVirtualKeyCode, + }); + + if (typeof latency === "number") { + samples.push(latency); + process.stdout.write(`${latency.toFixed(1)}ms `); + } else { + process.stdout.write("MISS "); + } + await sleep(opts.cooldownMs); + } + console.log(); + + const s = stats(samples); + console.log(`\nresults (n=${s.n}/${opts.iterations}):`); + console.log(` mean: ${s.mean.toFixed(1)}ms`); + console.log(` median: ${s.median.toFixed(1)}ms`); + console.log(` min: ${s.min.toFixed(1)}ms`); + console.log(` max: ${s.max.toFixed(1)}ms`); + console.log(` stdDev: ${s.stdDev.toFixed(1)}ms`); + console.log(` p95: ${s.p95.toFixed(1)}ms`); + console.log(` p99: ${s.p99.toFixed(1)}ms`); + + cdp.close(); +} + +main().catch((e) => { + console.error("fatal:", e.message); + process.exit(1); +}); -- 2.51.2