| … | … | @@ -1 +1,2 @@ |
| 1 | 1 | node_modules/ |
| 2 | lib/assets.generated.js |
| … | … | @@ -3,7 +3,8 @@ |
| 3 | 3 | // Finds units over mDNS (_cthuloid._tcp, via the host's avahi), serves one page |
| 4 | 4 | // on the LAN, and drives the units the user includes, the same way the Blender |
| 5 | 5 | // addon does. Starts disconnected on every launch: nothing is sent to a light |
| 6 | // until someone presses Connect. | |
| 6 | // until someone presses Connect. The controller itself is lib/app.js, shared | |
| 7 | // with the Android app. | |
| 7 | 8 | // |
| 8 | 9 | // ⛔ LAN-only, no login, by decision (2026-10-02). The pgh defaults bind |
| 9 | 10 | // 0.0.0.0:4050; the code default is 127.0.0.1 so a bare `bare index.js` on a |
| … | … | @@ -12,34 +13,26 @@ |
| 12 | 13 | import os from 'bare-os' |
| 13 | 14 | import path from 'bare-path' |
| 14 | 15 | import process from 'bare-process' |
| 15 | import { Store } from './lib/store.js' | |
| 16 | 16 | import { Discovery } from './lib/discovery.js' |
| 17 | import { Rig } from './lib/rig.js' | |
| 18 | import { createServer } from './lib/server.js' | |
| 17 | import { createApp } from './lib/app.js' | |
| 19 | 18 | |
| 20 | 19 | const port = Number(process.env.CTHULOID_WEB_PORT || 4050) |
| 21 | 20 | const host = process.env.CTHULOID_WEB_HOST || '127.0.0.1' |
| 22 | 21 | const configDir = process.env.CTHULOID_WEB_CONFIG || path.join(os.homedir(), '.config', 'cthuloid-web') |
| 23 | 22 | |
| 24 | const store = new Store(configDir) | |
| 25 | 23 | const discovery = new Discovery() |
| 26 | const rig = new Rig({ store, discovery }) | |
| 27 | ||
| 28 | 24 | discovery.on('unavailable', (reason) => console.error(JSON.stringify({ msg: 'discovery unavailable', reason }))) |
| 29 | 25 | discovery.on('up', (u) => console.log(JSON.stringify({ msg: 'unit up', name: u.name, addr: u.addr, proto: u.proto }))) |
| 30 | 26 | discovery.on('down', (u) => console.log(JSON.stringify({ msg: 'unit down', name: u.name }))) |
| 31 | 27 | |
| 32 | rig.start() | |
| 33 | const server = createServer(rig) | |
| 34 | server.listen(port, host, () => { | |
| 35 | console.log(JSON.stringify({ msg: 'cthuloid-web up', url: 'http://' + host + ':' + port, config: configDir })) | |
| 28 | const app = createApp({ configDir, host, port, discovery }) | |
| 29 | app.listen().then(({ port: p }) => { | |
| 30 | console.log(JSON.stringify({ msg: 'cthuloid-web up', url: 'http://' + host + ':' + p, config: configDir })) | |
| 36 | 31 | }) |
| 37 | 32 | |
| 38 | 33 | // Settings are written on a short debounce; flush them on the way out. |
| 39 | 34 | const shutdown = () => { |
| 40 | rig.stop() | |
| 41 | server.close() | |
| 42 | process.exit(0) | |
| 35 | app.close().then(() => process.exit(0)) | |
| 43 | 36 | } |
| 44 | 37 | process.on('SIGTERM', shutdown) |
| 45 | 38 | process.on('SIGINT', shutdown) |
| … | … | @@ -0,0 +1,54 @@ |
| 1 | // The controller as a library: store + rig + HTTP server, started and stopped as | |
| 2 | // one. stortron's index.js and the Android app's worklet both start it here, so | |
| 3 | // the phone runs exactly the code stortron runs. | |
| 4 | // | |
| 5 | // Deliberately imports NO discovery: the Linux one spawns avahi-browse through | |
| 6 | // bare-subprocess, which a phone has neither of. The caller passes its own -- | |
| 7 | // avahi on a desktop, Android's NsdManager over IPC in the app -- or none, and | |
| 8 | // units are added by address. | |
| 9 | ||
| 10 | import EventEmitter from 'bare-events' | |
| 11 | import { Store } from './store.js' | |
| 12 | import { Rig } from './rig.js' | |
| 13 | import { createServer } from './server.js' | |
| 14 | ||
| 15 | // No automatic discovery: present, quiet, never "broken". | |
| 16 | class NoDiscovery extends EventEmitter { | |
| 17 | constructor () { | |
| 18 | super() | |
| 19 | this.available = true | |
| 20 | this.reason = '' | |
| 21 | } | |
| 22 | ||
| 23 | start () {} | |
| 24 | stop () {} | |
| 25 | } | |
| 26 | ||
| 27 | // assets: embedded page files (scripts/gen-assets.js) for a packed bundle; null | |
| 28 | // reads static/ from disk. | |
| 29 | export function createApp ({ configDir, host = '127.0.0.1', port = 4050, discovery = null, assets = null, rigOpts = {} }) { | |
| 30 | const store = new Store(configDir) | |
| 31 | const rig = new Rig({ store, discovery: discovery || new NoDiscovery(), ...rigOpts }) | |
| 32 | const server = createServer(rig, { assets }) | |
| 33 | let started = false | |
| 34 | ||
| 35 | return { | |
| 36 | rig, | |
| 37 | server, | |
| 38 | listen () { | |
| 39 | if (!started) { rig.start(); started = true } | |
| 40 | return new Promise((resolve, reject) => { | |
| 41 | server.once('error', reject) | |
| 42 | server.listen(port, host, () => { | |
| 43 | server.off('error', reject) | |
| 44 | resolve({ host, port: server.address().port }) | |
| 45 | }) | |
| 46 | }) | |
| 47 | }, | |
| 48 | close () { | |
| 49 | if (started) rig.stop() // flushes the debounced config save | |
| 50 | started = false | |
| 51 | return new Promise((resolve) => server.close(() => resolve())) | |
| 52 | } | |
| 53 | } | |
| 54 | } |
| … | … | @@ -0,0 +1,11 @@ |
| 1 | // The page's files: URL -> [file in static/, content type]. One list, read by the | |
| 2 | // server (from disk on a desktop) and by scripts/gen-assets.js (embedded for a | |
| 3 | // packed worklet, which has no files on disk). | |
| 4 | export const ASSET_FILES = { | |
| 5 | '/': ['index.html', 'text/html; charset=utf-8'], | |
| 6 | '/app.js': ['app.js', 'text/javascript; charset=utf-8'], | |
| 7 | '/app.css': ['app.css', 'text/css; charset=utf-8'], | |
| 8 | '/color.js': ['color.js', 'text/javascript; charset=utf-8'], | |
| 9 | '/picker.js': ['picker.js', 'text/javascript; charset=utf-8'], | |
| 10 | '/swatches.js': ['swatches.js', 'text/javascript; charset=utf-8'] | |
| 11 | } |
| … | … | @@ -9,16 +9,9 @@ |
| 9 | 9 | import http from 'bare-http1' |
| 10 | 10 | import fs from 'bare-fs' |
| 11 | 11 | import path from 'bare-path' |
| 12 | import { ASSET_FILES } from './asset-files.js' | |
| 12 | 13 | |
| 13 | 14 | const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static') |
| 14 | const ASSETS = { | |
| 15 | '/': ['index.html', 'text/html; charset=utf-8'], | |
| 16 | '/app.js': ['app.js', 'text/javascript; charset=utf-8'], | |
| 17 | '/app.css': ['app.css', 'text/css; charset=utf-8'], | |
| 18 | '/color.js': ['color.js', 'text/javascript; charset=utf-8'], | |
| 19 | '/picker.js': ['picker.js', 'text/javascript; charset=utf-8'], | |
| 20 | '/swatches.js': ['swatches.js', 'text/javascript; charset=utf-8'] | |
| 21 | } | |
| 22 | 15 | const MAX_BODY = 64 * 1024 |
| 23 | 16 | const PING_MS = 15000 |
| 24 | 17 | |
| … | … | @@ -57,7 +50,9 @@ function readJSON (req) { |
| 57 | 50 | }) |
| 58 | 51 | } |
| 59 | 52 | |
| 60 | export function createServer (rig) { | |
| 53 | // assets: { url: { type, body } } embedded page files (a packed worklet has no | |
| 54 | // files on disk); without it static/ is read per request, so edits show at once. | |
| 55 | export function createServer (rig, { assets = null } = {}) { | |
| 61 | 56 | const streams = new Set() |
| 62 | 57 | |
| 63 | 58 | // One snapshot per change, to every open page. |
| … | … | @@ -147,15 +142,22 @@ export function createServer (rig) { |
| 147 | 142 | }) |
| 148 | 143 | return |
| 149 | 144 | } |
| 150 | const asset = ASSETS[url] | |
| 145 | const asset = ASSET_FILES[url] | |
| 151 | 146 | if (!asset || req.method !== 'GET') { |
| 152 | 147 | res.writeHead(404, { 'content-type': 'text/plain' }) |
| 153 | 148 | return res.end('not found') |
| 154 | 149 | } |
| 155 | 150 | let body |
| 156 | try { | |
| 157 | body = fs.readFileSync(path.join(STATIC_DIR, asset[0])) | |
| 158 | } catch (err) { | |
| 151 | if (assets) { | |
| 152 | body = assets[url] ? Buffer.from(assets[url].body, 'utf8') : null | |
| 153 | } else { | |
| 154 | try { | |
| 155 | body = fs.readFileSync(path.join(STATIC_DIR, asset[0])) | |
| 156 | } catch { | |
| 157 | body = null | |
| 158 | } | |
| 159 | } | |
| 160 | if (!body) { | |
| 159 | 161 | res.writeHead(500, { 'content-type': 'text/plain' }) |
| 160 | 162 | return res.end('asset missing: ' + asset[0]) |
| 161 | 163 | } |
| … | … | @@ -0,0 +1,9 @@ |
| 1 | // bare scripts/gen-assets-cli.js [out] -- writes lib/assets.generated.js by default. | |
| 2 | import fs from 'bare-fs' | |
| 3 | import path from 'bare-path' | |
| 4 | import { buildAssets, assetsModule } from './gen-assets.js' | |
| 5 | ||
| 6 | const out = Bare.argv[2] || path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'lib', 'assets.generated.js') | |
| 7 | const assets = buildAssets() | |
| 8 | fs.writeFileSync(out, assetsModule(assets)) | |
| 9 | console.log('wrote ' + out + ' (' + Object.keys(assets).length + ' files)') |
| … | … | @@ -0,0 +1,21 @@ |
| 1 | // Embed the page's files into a JS module, for a packed worklet (the Android app), | |
| 2 | // where there are no files on disk to read. Used by scripts/gen-assets-cli.js and | |
| 3 | // tested in test/app.test.js against the files themselves. | |
| 4 | import fs from 'bare-fs' | |
| 5 | import path from 'bare-path' | |
| 6 | import { ASSET_FILES } from '../lib/asset-files.js' | |
| 7 | ||
| 8 | const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static') | |
| 9 | ||
| 10 | export function buildAssets (dir = STATIC_DIR) { | |
| 11 | const out = {} | |
| 12 | for (const [url, [file, type]] of Object.entries(ASSET_FILES)) { | |
| 13 | out[url] = { type, body: fs.readFileSync(path.join(dir, file), 'utf8') } | |
| 14 | } | |
| 15 | return out | |
| 16 | } | |
| 17 | ||
| 18 | export function assetsModule (assets) { | |
| 19 | return '// GENERATED by scripts/gen-assets-cli.js from static/ -- do not edit.\n' + | |
| 20 | 'export default ' + JSON.stringify(assets, null, 1) + '\n' | |
| 21 | } |
| … | … | @@ -0,0 +1,70 @@ |
| 1 | // createApp: the one entry both stortron (index.js) and the phone's worklet use. | |
| 2 | import test from 'brittle' | |
| 3 | import http from 'bare-http1' | |
| 4 | import fs from 'bare-fs' | |
| 5 | import os from 'bare-os' | |
| 6 | import path from 'bare-path' | |
| 7 | import EventEmitter from 'bare-events' | |
| 8 | import { createApp } from '../lib/app.js' | |
| 9 | import { buildAssets } from '../scripts/gen-assets.js' | |
| 10 | import { ASSET_FILES } from '../lib/asset-files.js' | |
| 11 | ||
| 12 | class FakeDiscovery extends EventEmitter { | |
| 13 | constructor () { super(); this.available = true; this.reason = '' } | |
| 14 | start () {} | |
| 15 | stop () {} | |
| 16 | } | |
| 17 | ||
| 18 | function get (port, p) { | |
| 19 | return new Promise((resolve, reject) => { | |
| 20 | http.request({ host: '127.0.0.1', port, path: p }, (res) => { | |
| 21 | const chunks = [] | |
| 22 | res.on('data', (d) => chunks.push(d)) | |
| 23 | res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], body: Buffer.concat(chunks) })) | |
| 24 | }).on('error', reject).end() | |
| 25 | }) | |
| 26 | } | |
| 27 | ||
| 28 | const tmp = () => path.join(os.tmpdir(), 'cthuloid-app-test-' + Date.now() + '-' + Math.random().toString(36).slice(2)) | |
| 29 | ||
| 30 | test('createApp starts, serves state, stops', async (t) => { | |
| 31 | const app = createApp({ configDir: tmp(), port: 0, discovery: new FakeDiscovery() }) | |
| 32 | const { port } = await app.listen() | |
| 33 | const r = await get(port, '/api/state') | |
| 34 | t.is(r.status, 200) | |
| 35 | t.is(JSON.parse(r.body).connected, false) | |
| 36 | await app.close() | |
| 37 | }) | |
| 38 | ||
| 39 | test('createApp works with no discovery at all (units added by hand)', async (t) => { | |
| 40 | const app = createApp({ configDir: tmp(), port: 0 }) | |
| 41 | const { port } = await app.listen() | |
| 42 | const st = JSON.parse((await get(port, '/api/state')).body) | |
| 43 | t.is(st.discovery.available, true, 'no "discovery broken" banner') | |
| 44 | await app.close() | |
| 45 | }) | |
| 46 | ||
| 47 | test('generated assets are exactly the files in static/', (t) => { | |
| 48 | const assets = buildAssets() | |
| 49 | const dir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'static') | |
| 50 | for (const [url, [file, type]] of Object.entries(ASSET_FILES)) { | |
| 51 | t.is(assets[url].type, type, url + ' type') | |
| 52 | t.is(assets[url].body, fs.readFileSync(path.join(dir, file), 'utf8'), url + ' body') | |
| 53 | } | |
| 54 | }) | |
| 55 | ||
| 56 | test('embedded assets are served byte-identical to the disk ones', async (t) => { | |
| 57 | const disk = createApp({ configDir: tmp(), port: 0 }) | |
| 58 | const packed = createApp({ configDir: tmp(), port: 0, assets: buildAssets() }) | |
| 59 | const a = (await disk.listen()).port | |
| 60 | const b = (await packed.listen()).port | |
| 61 | for (const url of Object.keys(ASSET_FILES)) { | |
| 62 | const x = await get(a, url) | |
| 63 | const y = await get(b, url) | |
| 64 | t.is(y.status, 200, url) | |
| 65 | t.is(y.type, x.type, url + ' type') | |
| 66 | t.ok(Buffer.compare(x.body, y.body) === 0, url + ' bytes') | |
| 67 | } | |
| 68 | await disk.close() | |
| 69 | await packed.close() | |
| 70 | }) |