@kiosko/core 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/devices.js CHANGED
@@ -3,16 +3,21 @@
3
3
  *
4
4
  * virtual only the editor shows it (no hardware, demos, development)
5
5
  * trcc `trcc serve` REST API (thermalright-trcc-linux), via @loksly/trcc-client
6
+ * usb straight over USB from Node, via @loksly/usb-ly (LY panels)
6
7
  * file writes `frame.jpg` atomically for an external sender (e.g. a USB
7
8
  * sender bound to a serial number, for several identical panels)
8
9
  */
9
10
  import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
10
11
  import { join } from 'node:path';
12
+ import { LyPanel, MAX_FRAME_BYTES, findPanels, loadUsb, modelFor } from '@loksly/usb-ly';
11
13
  import { connectPanel, detectPanel, pinFrame, sendFrame, serverAlive } from '@loksly/trcc-client';
12
14
  import { expandPath } from "./paths.js";
13
15
  export function encodeFor(device, jpegQuality) {
14
16
  if (device.driver === 'file')
15
17
  return device.format ?? 'jpeg';
18
+ // The LY firmware only takes JPEG.
19
+ if (device.driver === 'usb')
20
+ return 'jpeg';
16
21
  return jpegQuality > 0 ? 'jpeg' : 'png';
17
22
  }
18
23
  class VirtualDevice {
@@ -23,6 +28,25 @@ class VirtualDevice {
23
28
  }
24
29
  close() { }
25
30
  }
31
+ /**
32
+ * `rename` over a file another process has open fails on Windows with
33
+ * EPERM/EBUSY/EACCES (the sender is reading it right now): it passes in a few
34
+ * milliseconds, so try again a few times before giving up.
35
+ */
36
+ export async function renameReplacing(from, to, rename = renameSync, tries = 8) {
37
+ for (let i = 1;; i++) {
38
+ try {
39
+ rename(from, to);
40
+ return;
41
+ }
42
+ catch (err) {
43
+ const code = err.code;
44
+ if (i >= tries || !(code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'))
45
+ throw err;
46
+ await new Promise((r) => setTimeout(r, 5 * i));
47
+ }
48
+ }
49
+ }
26
50
  class FileDevice {
27
51
  status;
28
52
  dir;
@@ -36,7 +60,7 @@ class FileDevice {
36
60
  const tmp = join(this.dir, '.frame.tmp');
37
61
  writeFileSync(tmp, frame);
38
62
  // Atomic: the sender never reads half a JPEG.
39
- renameSync(tmp, join(this.dir, 'frame.jpg'));
63
+ await renameReplacing(tmp, join(this.dir, 'frame.jpg'));
40
64
  this.status.ok = true;
41
65
  this.status.framesSent++;
42
66
  this.status.lastSentAt = Date.now();
@@ -97,10 +121,163 @@ class TrccDevice {
97
121
  this.stopPin?.();
98
122
  }
99
123
  }
100
- export function openDevice(cfg, log) {
124
+ /**
125
+ * A panel over USB, owned by this process. Two things the firmware asks for:
126
+ * a new frame goes out as soon as it exists, and an unchanged one is sent
127
+ * again every `keepalive` seconds, or the panel falls back to the factory logo
128
+ * after 2-3 s. A bus error drops the connection and reconnects with a growing
129
+ * pause; a panel with the configured serial number that is not there is
130
+ * waited for, never replaced by another one.
131
+ */
132
+ export class UsbDevice {
133
+ status = { driver: 'usb', ok: false, message: 'connecting…', framesSent: 0, lastSentAt: null };
134
+ cfg;
135
+ log;
136
+ rotate;
137
+ open;
138
+ retryMs;
139
+ panel = null;
140
+ latest = null;
141
+ fresh = false;
142
+ lastWrite = 0;
143
+ retryAt = 0;
144
+ failures = 0;
145
+ closed = false;
146
+ pumping = null;
147
+ timer = null;
148
+ lastMessage = '';
149
+ constructor(cfg, log, o = {}) {
150
+ this.cfg = cfg;
151
+ this.log = log;
152
+ this.rotate = o.rotate;
153
+ this.open = o.open ?? ((serial) => LyPanel.open(serial ? { serial } : {}));
154
+ this.retryMs = o.retryMs ?? 1000;
155
+ const every = Math.round(Math.min(2, Math.max(0.1, cfg.keepalive ?? 1)) * 1000);
156
+ this.timer = setInterval(() => void this.pump(), Math.min(every, 250));
157
+ this.timer.unref();
158
+ }
159
+ say(message, ok) {
160
+ this.status.ok = ok;
161
+ this.status.message = message;
162
+ if (message !== this.lastMessage) {
163
+ this.lastMessage = message;
164
+ this.log(message);
165
+ }
166
+ }
167
+ due(now) {
168
+ return now - this.lastWrite >= Math.min(2, Math.max(0.1, this.cfg.keepalive ?? 1)) * 1000;
169
+ }
170
+ /** Writes what is pending; concurrent callers share one run. */
171
+ pump() {
172
+ if (this.pumping)
173
+ return this.pumping;
174
+ const run = async () => {
175
+ while (!this.closed && this.latest && Date.now() >= this.retryAt && (this.fresh || this.due(Date.now()))) {
176
+ try {
177
+ const frame = this.latest;
178
+ this.fresh = false;
179
+ if (!this.panel)
180
+ await this.connect();
181
+ const kept = await this.panel.send(frame);
182
+ this.lastWrite = Date.now();
183
+ this.failures = 0;
184
+ this.status.framesSent++;
185
+ this.status.lastSentAt = this.lastWrite;
186
+ if (!kept)
187
+ this.say(`the panel dropped a ${Math.round(frame.length / 1024)} KB frame (limit ${Math.round(MAX_FRAME_BYTES / 1024)} KB): lower the JPEG quality`, false);
188
+ else if (!this.status.ok)
189
+ this.say(this.describe(), true);
190
+ }
191
+ catch (err) {
192
+ this.fresh = true; // a reconnected panel has nothing on it
193
+ const p = this.panel;
194
+ this.panel = null;
195
+ void p?.close().catch(() => undefined);
196
+ this.failures++;
197
+ this.retryAt = Date.now() + Math.min(30 * this.retryMs, this.retryMs * 2 ** Math.min(this.failures, 5));
198
+ const why = err instanceof Error ? err.message : String(err);
199
+ this.say(`${why}${this.cfg.serial && !why.includes(this.cfg.serial) ? ` (serial ${this.cfg.serial})` : ''}; retrying`, false);
200
+ }
201
+ }
202
+ };
203
+ this.pumping = run().finally(() => (this.pumping = null));
204
+ return this.pumping;
205
+ }
206
+ describe() {
207
+ const i = this.panel?.info;
208
+ if (!i)
209
+ return 'usb';
210
+ return `usb ${i.serial ?? i.location ?? ''} PM ${i.pm} SUB ${i.sub}${i.model ? ` ${i.model.width}×${i.model.height}` : ''}`.replace(/\s+/g, ' ');
211
+ }
212
+ async connect() {
213
+ const panel = await this.open(this.cfg.serial);
214
+ this.panel = panel;
215
+ const m = panel.info?.model;
216
+ if (m) {
217
+ this.status.detected = { width: m.width, height: m.height };
218
+ // The picture is turned in the compositor, before encoding: the firmware's mount angle must match the screen's.
219
+ if (this.rotate !== undefined && m.rotate !== this.rotate)
220
+ this.log(`the panel's firmware expects rotation ${m.rotate}° but this screen has ${this.rotate}°: the picture may come out turned`);
221
+ }
222
+ this.say(this.describe(), true);
223
+ }
224
+ async send(frame) {
225
+ this.latest = frame;
226
+ this.fresh = true;
227
+ await this.pump();
228
+ }
229
+ close() {
230
+ this.closed = true;
231
+ if (this.timer)
232
+ clearInterval(this.timer);
233
+ this.timer = null;
234
+ const p = this.panel;
235
+ this.panel = null;
236
+ void p?.close().catch(() => undefined);
237
+ }
238
+ }
239
+ /** The LY panels libusb sees (serial and where), without claiming any: for `kiosko doctor`. */
240
+ export async function listUsb() {
241
+ try {
242
+ const found = await findPanels(await loadUsb());
243
+ return { ok: true, panels: found.map((f) => ({ ...(f.serial ? { serial: f.serial } : {}), pid: f.pid, location: f.location, ...(f.error ? { error: f.error } : {}) })) };
244
+ }
245
+ catch (err) {
246
+ return { ok: false, panels: [], error: err instanceof Error ? err.message : String(err) };
247
+ }
248
+ }
249
+ /** The LY panels libusb sees right now, for the "add screen" menu. Never throws. */
250
+ export async function discoverUsb() {
251
+ try {
252
+ const found = await findPanels(await loadUsb());
253
+ const out = [];
254
+ for (const f of found) {
255
+ // The resolution needs the hello, which needs the device: only if no other process has it.
256
+ let model;
257
+ if (f.serial) {
258
+ try {
259
+ const p = await LyPanel.open({ serial: f.serial });
260
+ model = p.info?.model;
261
+ await p.close();
262
+ }
263
+ catch {
264
+ /* held by another process, or no driver: listed anyway */
265
+ }
266
+ }
267
+ out.push({ serial: f.serial ?? '', key: f.serial ?? `usb ${f.location}`, width: model?.width ?? 0, height: model?.height ?? 0, location: f.location, ...(f.error ? { error: f.error } : {}) });
268
+ }
269
+ return out;
270
+ }
271
+ catch {
272
+ return [];
273
+ }
274
+ }
275
+ export function openDevice(cfg, log, rotate) {
101
276
  switch (cfg.driver) {
102
277
  case 'trcc':
103
278
  return new TrccDevice(cfg, log);
279
+ case 'usb':
280
+ return new UsbDevice(cfg, log, rotate === undefined ? {} : { rotate });
104
281
  case 'file':
105
282
  return new FileDevice(cfg);
106
283
  default:
package/dist/env.js CHANGED
@@ -24,9 +24,25 @@ export function resolveLocale(cfg) {
24
24
  export function baseContext(cfg, plugin, log, now) {
25
25
  let made = false;
26
26
  const dir = paths.data(plugin);
27
+ const cache = paths.cache(plugin);
28
+ let cacheMade = false;
27
29
  const base = createBaseContext({ now, locale: resolveLocale(cfg), timeFormat: cfg.timeFormat, workday: cfg.workday, ...(cfg.location ? { location: cfg.location } : {}), log: (msg) => log(plugin, msg) });
28
30
  return {
29
31
  ...base,
32
+ get cacheDir() {
33
+ if (!cache)
34
+ return this.dataDir;
35
+ if (!cacheMade) {
36
+ try {
37
+ mkdirSync(cache, { recursive: true });
38
+ }
39
+ catch {
40
+ return this.dataDir;
41
+ }
42
+ cacheMade = true;
43
+ }
44
+ return cache;
45
+ },
30
46
  get dataDir() {
31
47
  if (!made) {
32
48
  try {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Makes `path` readable by its owner only: mode 0600, or, on Windows (where
3
+ * `chmod` can't do it), an ACL without inheritance that grants just the
4
+ * current user. Best effort: a file system without permissions (FAT, some
5
+ * network shares) just keeps what it has.
6
+ */
7
+ export declare function restrictToOwner(path: string, os?: NodeJS.Platform): void;
package/dist/fsperm.js ADDED
@@ -0,0 +1,23 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync } from 'node:fs';
3
+ import { userInfo } from 'node:os';
4
+ /**
5
+ * Makes `path` readable by its owner only: mode 0600, or, on Windows (where
6
+ * `chmod` can't do it), an ACL without inheritance that grants just the
7
+ * current user. Best effort: a file system without permissions (FAT, some
8
+ * network shares) just keeps what it has.
9
+ */
10
+ export function restrictToOwner(path, os = process.platform) {
11
+ try {
12
+ if (os !== 'win32') {
13
+ chmodSync(path, 0o600);
14
+ return;
15
+ }
16
+ const user = process.env.USERNAME || userInfo().username;
17
+ const domain = process.env.USERDOMAIN;
18
+ execFileSync('icacls', [path, '/inheritance:r', '/grant:r', `${domain ? `${domain}\\` : ''}${user}:F`], { stdio: 'ignore', windowsHide: true, timeout: 5000 });
19
+ }
20
+ catch {
21
+ /* best effort */
22
+ }
23
+ }
package/dist/index.d.ts CHANGED
@@ -14,3 +14,5 @@ export * from './devices.ts';
14
14
  export * from './fonts.ts';
15
15
  export * from './paths.ts';
16
16
  export * from './env.ts';
17
+ export * from './fsperm.ts';
18
+ export * from './sdk-resolver.ts';
package/dist/index.js CHANGED
@@ -14,3 +14,5 @@ export * from "./devices.js";
14
14
  export * from "./fonts.js";
15
15
  export * from "./paths.js";
16
16
  export * from "./env.js";
17
+ export * from "./fsperm.js";
18
+ export * from "./sdk-resolver.js";
package/dist/paths.d.ts CHANGED
@@ -1,11 +1,18 @@
1
+ import { expandPath } from '@kiosko/sdk';
1
2
  export declare function kioskoHome(): string;
2
3
  export declare const paths: {
3
4
  readonly home: string;
4
5
  readonly config: string;
5
6
  /** External plugins: one folder (or npm package) per plugin. */
6
7
  readonly plugins: string;
7
- /** Per-plugin caches: `data/<plugin>`. */
8
+ /**
9
+ * Per-plugin re-fetchable caches, in RAM: `$XDG_RUNTIME_DIR/kiosko-cache/<plugin>`
10
+ * (or `/dev/shm/kiosko-cache-<uid>/<plugin>`). Null where there is no tmpfs.
11
+ * On Windows there is none, but the cache belongs in the machine-local
12
+ * profile (`%LOCALAPPDATA%\\Kiosko\\cache`), not in the roaming one `data/` uses.
13
+ */
14
+ cache(plugin: string): string | null;
15
+ /** Per-plugin data that must persist: `data/<plugin>`. */
8
16
  data(plugin: string): string;
9
17
  };
10
- /** `~/x` and `$VAR`/`${VAR}` expanded. */
11
- export declare function expandPath(p: string): string;
18
+ export { expandPath };
package/dist/paths.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs';
1
2
  /**
2
3
  * Where Kiosko keeps its things, per platform:
3
4
  * Linux $XDG_CONFIG_HOME/kiosko (~/.config/kiosko)
@@ -6,6 +7,7 @@
6
7
  * `KIOSKO_HOME` overrides all of them (tests, portable installs).
7
8
  */
8
9
  import { homedir, platform } from 'node:os';
10
+ import { expandPath } from '@kiosko/sdk';
9
11
  import { join } from 'node:path';
10
12
  export function kioskoHome() {
11
13
  if (process.env.KIOSKO_HOME)
@@ -31,13 +33,21 @@ export const paths = {
31
33
  get plugins() {
32
34
  return join(kioskoHome(), 'plugins');
33
35
  },
34
- /** Per-plugin caches: `data/<plugin>`. */
36
+ /**
37
+ * Per-plugin re-fetchable caches, in RAM: `$XDG_RUNTIME_DIR/kiosko-cache/<plugin>`
38
+ * (or `/dev/shm/kiosko-cache-<uid>/<plugin>`). Null where there is no tmpfs.
39
+ * On Windows there is none, but the cache belongs in the machine-local
40
+ * profile (`%LOCALAPPDATA%\\Kiosko\\cache`), not in the roaming one `data/` uses.
41
+ */
42
+ cache(plugin) {
43
+ if (platform() === 'win32')
44
+ return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Kiosko', 'cache', plugin.replace(/[^a-z0-9._-]/gi, '_'));
45
+ const base = process.env.XDG_RUNTIME_DIR && existsSync(process.env.XDG_RUNTIME_DIR) ? join(process.env.XDG_RUNTIME_DIR, 'kiosko-cache') : existsSync('/dev/shm') ? `/dev/shm/kiosko-cache-${process.getuid?.() ?? 'u'}` : null;
46
+ return base && join(base, plugin.replace(/[^a-z0-9._-]/gi, '_'));
47
+ },
48
+ /** Per-plugin data that must persist: `data/<plugin>`. */
35
49
  data(plugin) {
36
50
  return join(kioskoHome(), 'data', plugin.replace(/[^a-z0-9._-]/gi, '_'));
37
51
  },
38
52
  };
39
- /** `~/x` and `$VAR`/`${VAR}` expanded. */
40
- export function expandPath(p) {
41
- const v = p.replace(/\$(?:\{(\w+)\}|(\w+))/g, (whole, a, b) => process.env[a ?? b ?? ''] ?? whole);
42
- return v === '~' || v.startsWith('~/') ? join(homedir(), v.slice(1)) : v;
43
- }
53
+ export { expandPath };
package/dist/runtime.d.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { EventEmitter } from 'node:events';
13
13
  import { type Theme } from '@kiosko/sdk';
14
+ import type { Canvas } from '@napi-rs/canvas';
14
15
  import { type ComposeResult } from './compositor.ts';
15
16
  import { type Config, type Page, type Screen } from './config/model.ts';
16
17
  import { FetchHost, SourceHub } from './data.ts';
@@ -34,8 +35,21 @@ export interface FrameInfo {
34
35
  image: () => Buffer;
35
36
  tiles: ComposeResult['tiles'];
36
37
  interlude: string | null;
38
+ /** Type of the widget whose animation is playing (a frame of it), if any. */
39
+ animation?: string;
37
40
  sleeping: boolean;
38
41
  }
42
+ /**
43
+ * Encodes a frame no bigger than `maxBytes`: when the JPEG at `quality` is
44
+ * over it, tries lower qualities (10 at a time, down to 20) until one fits.
45
+ * LY panels drop frames over ~500 KB and keep showing the previous one, so a
46
+ * photo-heavy page at quality 100 looked frozen. `over` says nothing fitted.
47
+ */
48
+ export declare function encodeFit(c: Canvas, kind: 'png' | 'jpeg', quality: number, maxBytes: number): {
49
+ out: Buffer;
50
+ quality: number;
51
+ over: boolean;
52
+ };
39
53
  export declare class Runtime extends EventEmitter {
40
54
  config: Config;
41
55
  readonly hub: SourceHub;
@@ -43,6 +57,10 @@ export declare class Runtime extends EventEmitter {
43
57
  private runners;
44
58
  private timer;
45
59
  readonly logs: LogEntry[];
60
+ /** How many animations play together on a screen (see animation.ts). */
61
+ maxConcurrent: number;
62
+ /** Random numbers for the animations; tests replace it with a seeded one. */
63
+ rnd: () => number;
46
64
  readonly registry: Registry;
47
65
  /**
48
66
  * Demo mode: no source or fetch runs, every widget paints its sample data.
@@ -78,6 +96,7 @@ export declare class Runtime extends EventEmitter {
78
96
  page: string | null;
79
97
  pinned: string | null;
80
98
  interlude: string | null;
99
+ animation: string | null;
81
100
  device: DeviceStatus;
82
101
  lastFrameAt: number | null;
83
102
  sleeping: boolean;