← Commit history

0.22.0: desktop and Windows support (#60-#66)

Colby Knox ·a2dcc3185c ·19d ago ·parent 540222b
5 files changed +103−1
bin/hero-studio

  
install.sh

  
pose.mjs

  
test/platform.mjsadded+102
@@ -0,0 +1,102 @@+#!/usr/bin/env node+// Desktop / Windows portability (hero-studio#60-#66): node test/platform.mjs+//+// The studio assumed an Adom container: a token at /var/run/adom/api-key,+// HOME set, python3 with Pillow, Linux Chrome paths, a VS Code proxy URL.+// These checks pin the replacements, on this Linux box, by driving the+// platform branches with explicit inputs (and a real server with a desktop+// identity and no stamp tools).+import { spawn, execFileSync } from 'child_process';+import fs from 'fs';+import os from 'os';+import path from 'path';+import net from 'net';+import { fileURLToPath } from 'url';+import { createRequire } from 'module';++const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');+const require = createRequire(import.meta.url);+const RP = require(path.join(ROOT, 'renderer-paths.cjs'));+let pass = 0; const fails = [];+const ok = (name, cond, detail) => { if (cond) { pass++; console.log('  ok   ' + name); } else { fails.push(name); console.log('  FAIL ' + name + (detail ? ': ' + detail : '')); } };++// --- identity order (#60), each source alone, in a throwaway home ---+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-plat-'));+const home = path.join(tmp, 'home'); fs.mkdirSync(home, { recursive: true });+const cfg = path.join(tmp, 'cfg');+const baseEnv = { PATH: process.env.PATH, HOME: home, XDG_CONFIG_HOME: cfg, HERO_STUDIO_CONTAINER_KEY: path.join(tmp, 'no-key') };+const ident = (extra = {}) => JSON.parse(execFileSync(process.execPath, ['-e',+  "const p=require(" + JSON.stringify(path.join(ROOT, 'platform.cjs')) + ");const i=p.wikiIdentity();console.log(JSON.stringify({source:i.source,anonymous:i.anonymous,token:i.token}))"],+  { env: { ...baseEnv, ...extra } }).toString());+let id = ident();+ok('no identity anywhere reads as anonymous', id.anonymous && id.source === 'none', JSON.stringify(id));+fs.mkdirSync(path.join(home, '.adom'), { recursive: true });+fs.writeFileSync(path.join(home, '.adom', 'session.json'), JSON.stringify({ session_token: 'sess-tok', user: 'x' }));+id = ident();+ok('the Adom Bridge desktop session is used when nothing else is', id.token === 'sess-tok' && /session/.test(id.source), JSON.stringify(id));+fs.mkdirSync(path.join(cfg, 'adom-cli'), { recursive: true });+fs.writeFileSync(path.join(cfg, 'adom-cli', 'config.json'), JSON.stringify({ api_token: 'cli-tok' }));+id = ident();+ok('an adom-cli login beats the session', id.token === 'cli-tok', JSON.stringify(id));+fs.writeFileSync(path.join(tmp, 'key'), 'container-tok\n');+id = ident({ HERO_STUDIO_CONTAINER_KEY: path.join(tmp, 'key') });+ok('the container key beats the adom-cli login', id.token === 'container-tok', JSON.stringify(id));+fs.mkdirSync(path.join(cfg, 'adom-wiki'), { recursive: true });+fs.writeFileSync(path.join(cfg, 'adom-wiki', 'config.json'), JSON.stringify({ api_token: 'wiki-tok' }));+id = ident({ HERO_STUDIO_CONTAINER_KEY: path.join(tmp, 'key') });+ok('the adom-wiki stored token beats the container key', id.token === 'wiki-tok', JSON.stringify(id));+id = ident({ ADOM_WIKI_TOKEN: 'env-tok' });+ok('ADOM_WIKI_TOKEN beats everything', id.token === 'env-tok' && id.source === 'env:ADOM_WIKI_TOKEN', JSON.stringify(id));++// --- import specifiers (#61) ---+const { importSpecifier } = await import(path.join(ROOT, 'doctor.mjs'));+ok('an absolute path becomes a file:// URL for import()', importSpecifier('/x/puppeteer-core.js') === 'file:///x/puppeteer-core.js', importSpecifier('/x/puppeteer-core.js'));+ok('a file:// URL passes through untouched', importSpecifier('file:///C:/x/p.js') === 'file:///C:/x/p.js');++// --- Chrome candidates per OS (#61) ---+const win = RP.chromeCandidates('win32', { ProgramFiles: 'C:\\Program Files', 'ProgramFiles(x86)': 'C:\\Program Files (x86)', LOCALAPPDATA: 'C:\\Users\\u\\AppData\\Local' }, home);+ok('Windows: Program Files Chrome is a candidate', win.includes('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'), win.join(' | '));+ok('Windows: the per-user install is a candidate', win.includes('C:\\Users\\u\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe'));+const cacheDir = path.join(home, '.cache', 'puppeteer', 'chrome', 'win64-150.0.0');+fs.mkdirSync(cacheDir, { recursive: true });+const win2 = RP.chromeCandidates('win32', {}, home);+ok('Windows: the puppeteer cache is read as chrome-win64\\chrome.exe', win2.some((c) => c.endsWith(path.join('chrome-win64', 'chrome.exe'))), win2.join(' | '));+const mac = RP.chromeCandidates('darwin', {}, home);+ok('macOS: /Applications Chrome is a candidate', mac.includes('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'));+const lin = RP.chromeCandidates('linux', {}, home);+ok('Linux: the cache layout and system Chrome are candidates', lin.some((c) => c.includes('chrome-linux64')) && lin.includes('/usr/bin/google-chrome'));++// --- a real server: desktop identity, snapshot path, no stamp tools (#60 #64 #66) ---+const port = await new Promise((r) => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => r(p)); }); });+const binDir = path.join(tmp, 'bin'); fs.mkdirSync(binDir);+// PATH holds only a `node` shim: no python, no python3, no magick, no convert+fs.symlinkSync(process.execPath, path.join(binDir, 'node'));+const srvEnv = { ...baseEnv, PATH: binDir, HERO_STUDIO_PORT: String(port), ADOM_WIKI_REGISTRY: 'http://127.0.0.1:9', HERO_RENDERER: 'local',+  CHROME: RP.chromePath() || '', PUPPETEER_CORE: RP.puppeteerPath() || '' };+fs.rmSync(path.join(cfg, 'adom-wiki'), { recursive: true, force: true });+fs.rmSync(path.join(cfg, 'adom-cli'), { recursive: true, force: true });+const srv = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--port', String(port), '--ai-thread', 'platform test', '--ttl', '1'], { env: srvEnv, cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });+let log = ''; srv.stdout.on('data', (d) => { log += d; }); srv.stderr.on('data', (d) => { log += d; });+const base = 'http://127.0.0.1:' + port + '/';+let up = false;+for (let i = 0; i < 60 && !up; i++) { try { up = (await fetch(base + 'version')).ok; } catch {} if (!up) await new Promise((r) => setTimeout(r, 200)); }+ok('a server starts with a desktop-only environment', up, log.slice(-300));+try {+  const st = await (await fetch(base + 'state')).json();+  ok('state.identity names the desktop session, never the token', st.identity && /session/.test(st.identity.source) && st.identity.anonymous === false && !JSON.stringify(st).includes('sess-tok'), JSON.stringify(st.identity));+  const expectSnap = path.join(home, '.adom', 'instances', 'hero-studio', 'snapshots');+  const con = await (await fetch(base + 'console')).text();+  ok('boot logs where it looked for the snapshot, under the real home dir', con.includes(expectSnap), con.slice(0, 400));+  const post = async (p, b) => (await fetch(base + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aiThread: 'platform test', ...b }) })).json();+  await post('state', { mode: 'component' });+  await fetch(base + 'compose.png');+  const gen = await post('generate', { fromGate: true, renderer: 'local' });+  ok('with no Python/Pillow and no ImageMagick, Generate fails with STAMP_FAILED instead of shipping an unstamped PNG',+    gen.ok === false && gen.errorCode === 'STAMP_FAILED', JSON.stringify(gen).slice(0, 300));+  ok('  and the refusal says how to fix it', /pillow|Pillow/.test(gen.hint || ''), gen.hint);+} finally {+  srv.kill();+  fs.rmSync(tmp, { recursive: true, force: true });+}+console.log('\n' + pass + ' passed, ' + fails.length + ' failed');+process.exit(fails.length ? 1 : 0);
test/renderer.mjs+1−1
@@ -28,7 +28,7 @@ ok('pin: Node 18 gets puppeteer-core 24', RP.puppeteerPin('18.19.1') === '24'); ok('pin: Node 22.11 gets 24', RP.puppeteerPin('22.11.9') === '24'); ok('pin: Node 22.12 gets latest', RP.puppeteerPin('22.12.0') === 'latest'); ok('pin: Node 24 gets latest', RP.puppeteerPin('24.1.0') === 'latest');-ok('install command targets the cache dir, not the package dir', RP.puppeteerInstallCommand().includes('--prefix ' + RP.RENDERER_HOME) && !RP.puppeteerInstallCommand().includes(ROOT));+ok('install command targets the cache dir, not the package dir', RP.puppeteerInstallCommand().includes('--prefix "' + RP.RENDERER_HOME + '"') && !RP.puppeteerInstallCommand().includes(ROOT));  // an isolated box: nothing resolves, then the cache install appears const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-renderer-'));