dsh-offwork 1.1.1 → 1.2.2
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/CHANGELOG.md +39 -0
- package/README.md +48 -26
- package/ROADMAP.md +70 -0
- package/client.js +5066 -1099
- package/client.source.js +1988 -541
- package/companion-app.js +1431 -0
- package/companion-image.js +59 -0
- package/companion-state.js +424 -0
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/docs/RELEASE-1.2.2.md +20 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/icon.svg +28 -1
- package/index.js +59 -6
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/media-manifest.js +29 -0
- package/media-server.js +123 -0
- package/package.json +13 -3
- package/pet-interactions.js +121 -17
- package/pet-motion.js +2 -1
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +130 -6
- package/pet-video.js +221 -72
- package/pet-view.js +71 -4
- package/settings-api.js +126 -0
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion-state.test.mjs +267 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +26 -13
- package/tests/hydration-extras.test.mjs +168 -204
- package/tests/hydration-store.test.mjs +199 -213
- package/tests/hydration.test.mjs +116 -186
- package/tests/media-server.test.mjs +179 -0
- package/tests/pet-interactions.test.mjs +19 -0
- package/tests/pet-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +171 -5
- package/tests/settings-api.test.mjs +130 -0
- package/tests/ui.test.mjs +4 -1
- package/tools/build.mjs +45 -16
- package/tools/check-package.mjs +12 -0
- package/ui-utils.js +44 -0
package/tests/pet-video.test.mjs
CHANGED
|
@@ -5,12 +5,13 @@ import { createPetVideoMotion, petVideoAlphaSupported } from '../pet-video.js';
|
|
|
5
5
|
const flush = async () => {
|
|
6
6
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
7
7
|
};
|
|
8
|
-
function fixture({ alpha = true, rejectPlay = false, idle = false } = {}) {
|
|
8
|
+
function fixture({ alpha = true, rejectPlay = false, idle = false, options = {} } = {}) {
|
|
9
9
|
const media = Object.assign(new EventTarget(), { matches: false });
|
|
10
10
|
const document = Object.assign(new EventTarget(), { hidden: false });
|
|
11
11
|
const videos = [],
|
|
12
12
|
timers = new Map(),
|
|
13
|
-
calls = []
|
|
13
|
+
calls = [],
|
|
14
|
+
states = [];
|
|
14
15
|
let nextTimer = 0;
|
|
15
16
|
document.createElement = (tag) => {
|
|
16
17
|
if (tag === 'canvas')
|
|
@@ -26,6 +27,11 @@ function fixture({ alpha = true, rejectPlay = false, idle = false } = {}) {
|
|
|
26
27
|
duration: 5,
|
|
27
28
|
removed: false,
|
|
28
29
|
canPlayType: () => 'probably',
|
|
30
|
+
requestVideoFrameCallback(callback) {
|
|
31
|
+
queueMicrotask(callback);
|
|
32
|
+
return 1;
|
|
33
|
+
},
|
|
34
|
+
cancelVideoFrameCallback() {},
|
|
29
35
|
setAttribute() {},
|
|
30
36
|
removeAttribute(name) {
|
|
31
37
|
delete this[name];
|
|
@@ -81,9 +87,10 @@ function fixture({ alpha = true, rejectPlay = false, idle = false } = {}) {
|
|
|
81
87
|
base,
|
|
82
88
|
{ greet: 'greet-data', celebrate: 'drink-data', ...(idle ? { idle: 'idle-data' } : {}) },
|
|
83
89
|
'probe',
|
|
90
|
+
{ ...options, onStateChange: (state) => states.push(state) },
|
|
84
91
|
);
|
|
85
92
|
motion.configure({ enabled: true, resting: false });
|
|
86
|
-
return { host, document, media, element, base, motion, videos, timers, calls };
|
|
93
|
+
return { host, document, media, element, base, motion, videos, timers, calls, states };
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
test('alpha support checks decoded pixels rather than trusting WebM codec support', async () => {
|
|
@@ -112,7 +119,15 @@ test('clips reveal only after playing; accepted drinking interrupts greeting and
|
|
|
112
119
|
f.motion.play('celebrate');
|
|
113
120
|
await flush();
|
|
114
121
|
const drink = f.videos.at(-1);
|
|
122
|
+
assert.equal(greet.removed, false);
|
|
123
|
+
assert.equal(greet.paused, false);
|
|
124
|
+
assert.equal(f.element.dataset.action, 'greet');
|
|
125
|
+
assert.equal(f.element.dataset.videoLoading, 'celebrate');
|
|
126
|
+
assert.equal(drink.style.opacity, '0');
|
|
115
127
|
drink.onplaying();
|
|
128
|
+
assert.equal(greet.paused, true);
|
|
129
|
+
assert.equal(greet.style.opacity, '0');
|
|
130
|
+
await fireTimer(f, 150);
|
|
116
131
|
assert.equal(greet.removed, true);
|
|
117
132
|
staleEnd();
|
|
118
133
|
assert.equal(f.element.dataset.action, 'celebrate');
|
|
@@ -121,7 +136,7 @@ test('clips reveal only after playing; accepted drinking interrupts greeting and
|
|
|
121
136
|
drink.onended();
|
|
122
137
|
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
123
138
|
assert.equal(f.element.dataset.action, undefined);
|
|
124
|
-
|
|
139
|
+
await fireTimer(f, 150);
|
|
125
140
|
assert.equal(drink.removed, true);
|
|
126
141
|
f.motion.destroy();
|
|
127
142
|
});
|
|
@@ -219,6 +234,8 @@ test('idle loops are interrupted by actions and resume once after action complet
|
|
|
219
234
|
await flush();
|
|
220
235
|
const greet = f.videos.at(-1);
|
|
221
236
|
greet.onplaying();
|
|
237
|
+
assert.equal(idle.paused, true);
|
|
238
|
+
await fireTimer(f, 150);
|
|
222
239
|
assert.equal(idle.removed, true);
|
|
223
240
|
assert.equal(greet.loop, false);
|
|
224
241
|
const oldEnd = greet.onended;
|
|
@@ -274,7 +291,7 @@ test('quiet state and a held drag suppress idle until they finish', async () =>
|
|
|
274
291
|
f.motion.play('greet');
|
|
275
292
|
await flush();
|
|
276
293
|
f.videos.at(-1).onended();
|
|
277
|
-
await
|
|
294
|
+
await flush();
|
|
278
295
|
assert.equal(f.element.dataset.action, undefined);
|
|
279
296
|
f.motion.configure({ enabled: true, resting: false });
|
|
280
297
|
await flush();
|
|
@@ -335,3 +352,152 @@ test('failed action fallback suspends idle during CSS feedback and then returns
|
|
|
335
352
|
f.motion.destroy();
|
|
336
353
|
assert.equal(f.timers.size, 0);
|
|
337
354
|
});
|
|
355
|
+
|
|
356
|
+
test('preview waits for explicit play, switches actions freely and never resumes idle automatically', async () => {
|
|
357
|
+
const f = fixture({
|
|
358
|
+
idle: true,
|
|
359
|
+
options: { autoIdle: false, fallback: false, interruptible: true },
|
|
360
|
+
});
|
|
361
|
+
await flush();
|
|
362
|
+
assert.equal(f.videos.length, 0);
|
|
363
|
+
f.motion.play('greet');
|
|
364
|
+
await flush();
|
|
365
|
+
const greet = f.videos.at(-1);
|
|
366
|
+
greet.onplaying();
|
|
367
|
+
const announcements = f.states.length;
|
|
368
|
+
greet.onplaying();
|
|
369
|
+
assert.equal(f.states.length, announcements);
|
|
370
|
+
greet.onended();
|
|
371
|
+
await flush();
|
|
372
|
+
assert.equal(f.states.at(-1).phase, 'idle');
|
|
373
|
+
assert.equal(f.videos.length, 2);
|
|
374
|
+
await fireTimer(f, 150);
|
|
375
|
+
f.motion.play('idle');
|
|
376
|
+
await flush();
|
|
377
|
+
f.videos.at(-1).onplaying();
|
|
378
|
+
f.motion.play('celebrate');
|
|
379
|
+
await flush();
|
|
380
|
+
f.videos.at(-1).onplaying();
|
|
381
|
+
assert.equal(f.motion.play('greet'), true);
|
|
382
|
+
await flush();
|
|
383
|
+
f.videos.at(-1).onplaying();
|
|
384
|
+
assert.equal(f.motion.play('idle'), true);
|
|
385
|
+
await flush();
|
|
386
|
+
f.videos.at(-1).onplaying();
|
|
387
|
+
assert.ok(f.videos.filter((v) => v.src && !v.removed).length <= 2);
|
|
388
|
+
f.motion.stop();
|
|
389
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
390
|
+
assert.equal(f.states.at(-1).phase, 'idle');
|
|
391
|
+
assert.deepEqual(f.calls, []);
|
|
392
|
+
f.motion.destroy();
|
|
393
|
+
assert.equal(f.timers.size, 0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('preview cancellation ignores late probe and video callbacks', async () => {
|
|
397
|
+
const f = fixture({ options: { autoIdle: false, fallback: false, interruptible: true } });
|
|
398
|
+
f.motion.play('greet');
|
|
399
|
+
f.motion.stop();
|
|
400
|
+
await flush();
|
|
401
|
+
assert.equal(f.videos.length, 1);
|
|
402
|
+
assert.deepEqual(
|
|
403
|
+
f.states.map((s) => s.phase),
|
|
404
|
+
['loading', 'idle'],
|
|
405
|
+
);
|
|
406
|
+
f.motion.play('greet');
|
|
407
|
+
await flush();
|
|
408
|
+
const late = f.videos.at(-1).onplaying;
|
|
409
|
+
f.motion.stop();
|
|
410
|
+
late();
|
|
411
|
+
assert.equal(f.states.at(-1).phase, 'idle');
|
|
412
|
+
const count = f.states.length;
|
|
413
|
+
f.motion.destroy();
|
|
414
|
+
late();
|
|
415
|
+
assert.equal(f.states.length, count);
|
|
416
|
+
assert.equal(f.timers.size, 0);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('preview reports media failure without pretending to play and supports explicit retry', async () => {
|
|
420
|
+
const f = fixture({
|
|
421
|
+
rejectPlay: true,
|
|
422
|
+
options: { autoIdle: false, fallback: false, interruptible: true },
|
|
423
|
+
});
|
|
424
|
+
f.motion.play('greet');
|
|
425
|
+
await flush();
|
|
426
|
+
assert.equal(f.states.at(-1).phase, 'unavailable');
|
|
427
|
+
assert.deepEqual(f.calls, []);
|
|
428
|
+
const create = f.document.createElement;
|
|
429
|
+
f.document.createElement = (tag) => {
|
|
430
|
+
const node = create(tag);
|
|
431
|
+
if (tag === 'video') node.play = () => Promise.resolve();
|
|
432
|
+
return node;
|
|
433
|
+
};
|
|
434
|
+
assert.equal(f.motion.retry('greet'), true);
|
|
435
|
+
await flush();
|
|
436
|
+
f.videos.at(-1).onplaying();
|
|
437
|
+
assert.equal(f.states.at(-1).phase, 'playing');
|
|
438
|
+
f.motion.destroy();
|
|
439
|
+
assert.equal(f.timers.size, 0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('preview distinguishes unsupported transparency from retryable resource failures', async () => {
|
|
443
|
+
const f = fixture({
|
|
444
|
+
alpha: false,
|
|
445
|
+
options: { autoIdle: false, fallback: false, interruptible: true },
|
|
446
|
+
});
|
|
447
|
+
f.motion.play('greet');
|
|
448
|
+
await flush();
|
|
449
|
+
assert.equal(f.states.at(-1).phase, 'unsupported');
|
|
450
|
+
assert.deepEqual(f.calls, []);
|
|
451
|
+
const count = f.videos.length;
|
|
452
|
+
f.motion.retry('greet');
|
|
453
|
+
await flush();
|
|
454
|
+
assert.equal(f.videos.length, count);
|
|
455
|
+
assert.equal(f.states.at(-1).phase, 'unsupported');
|
|
456
|
+
f.motion.destroy();
|
|
457
|
+
assert.equal(f.timers.size, 0);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('alpha detection waits for a readable frame rather than treating an empty canvas as unsupported', async () => {
|
|
461
|
+
const f = fixture();
|
|
462
|
+
const create = f.document.createElement;
|
|
463
|
+
let reads = 0;
|
|
464
|
+
f.document.createElement = (tag) => {
|
|
465
|
+
const node = create(tag);
|
|
466
|
+
if (tag === 'canvas')
|
|
467
|
+
node.getContext = () => ({
|
|
468
|
+
drawImage() {},
|
|
469
|
+
getImageData(x) {
|
|
470
|
+
reads++;
|
|
471
|
+
return { data: [0, 0, 0, reads <= 2 ? 0 : x === 1 ? 255 : 0] };
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
return node;
|
|
475
|
+
};
|
|
476
|
+
assert.equal(await petVideoAlphaSupported(f.host, 'probe'), true);
|
|
477
|
+
assert.ok(reads >= 4);
|
|
478
|
+
f.motion.destroy();
|
|
479
|
+
assert.equal(f.timers.size, 0);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('a probe timeout remains retryable instead of poisoning the browser capability cache', async () => {
|
|
483
|
+
const f = fixture();
|
|
484
|
+
const create = f.document.createElement;
|
|
485
|
+
f.document.createElement = (tag) => {
|
|
486
|
+
const node = create(tag);
|
|
487
|
+
if (tag === 'video') node.load = () => {};
|
|
488
|
+
if (tag === 'canvas')
|
|
489
|
+
node.getContext = () => ({
|
|
490
|
+
drawImage() {
|
|
491
|
+
throw new Error('no decoded frame');
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
return node;
|
|
495
|
+
};
|
|
496
|
+
const first = petVideoAlphaSupported(f.host, 'probe');
|
|
497
|
+
await fireTimer(f, 5000);
|
|
498
|
+
assert.equal(await first, null);
|
|
499
|
+
f.document.createElement = create;
|
|
500
|
+
assert.equal(await petVideoAlphaSupported(f.host, 'probe'), true);
|
|
501
|
+
f.motion.destroy();
|
|
502
|
+
assert.equal(f.timers.size, 0);
|
|
503
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile, readFile, stat, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { createOperations, DEFAULT_SETTINGS, installOperations } from '../index.js';
|
|
8
|
+
import { createSettingsAccess, createSettingsHandler } from '../settings-api.js';
|
|
9
|
+
async function fixture(t) {
|
|
10
|
+
const dir = await mkdtemp(join(tmpdir(), 'offwork-settings-api-'));
|
|
11
|
+
t.after(() => rm(dir, { recursive: true, force: true }));
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
test('stable migration preserves original bytes and never overwrites an existing target', async (t) => {
|
|
15
|
+
const dir = await fixture(t),
|
|
16
|
+
legacy = join(dir, 'legacy.json'),
|
|
17
|
+
target = join(dir, 'storage', 'settings.json');
|
|
18
|
+
const bytes = JSON.stringify({ ...DEFAULT_SETTINGS, monthlySalary: 12345 });
|
|
19
|
+
await writeFile(legacy, bytes);
|
|
20
|
+
const operate = createOperations(target, { legacyFile: legacy });
|
|
21
|
+
const result = await operate('get');
|
|
22
|
+
assert.equal(result.ok, true);
|
|
23
|
+
assert.equal(result.settings.monthlySalary, 12345);
|
|
24
|
+
assert.equal(await readFile(legacy, 'utf8'), bytes);
|
|
25
|
+
assert.equal((await stat(target)).mode & 0o777, 0o600);
|
|
26
|
+
await operate('set', { monthlySalary: 444 });
|
|
27
|
+
await writeFile(legacy, JSON.stringify(DEFAULT_SETTINGS));
|
|
28
|
+
assert.equal(
|
|
29
|
+
(await createOperations(target, { legacyFile: legacy })('get')).settings.monthlySalary,
|
|
30
|
+
444,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
test('corrupt legacy data fails closed without creating a defaults file', async (t) => {
|
|
34
|
+
const dir = await fixture(t),
|
|
35
|
+
legacy = join(dir, 'legacy.json'),
|
|
36
|
+
target = join(dir, 'storage', 'settings.json');
|
|
37
|
+
await writeFile(legacy, '{broken');
|
|
38
|
+
const result = await createOperations(target, { legacyFile: legacy })('get');
|
|
39
|
+
assert.equal(result.ok, false);
|
|
40
|
+
await assert.rejects(readFile(target), { code: 'ENOENT' });
|
|
41
|
+
assert.equal(await readFile(legacy, 'utf8'), '{broken');
|
|
42
|
+
});
|
|
43
|
+
test('UI settings API rejects cross-origin, missing header and non-JSON writes', async (t) => {
|
|
44
|
+
const dir = await fixture(t),
|
|
45
|
+
operate = createOperations(join(dir, 'settings.json')),
|
|
46
|
+
access = createSettingsAccess(dir);
|
|
47
|
+
const server = createServer(createSettingsHandler(operate, access, () => undefined));
|
|
48
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
49
|
+
t.after(() => new Promise((r) => server.close(r)));
|
|
50
|
+
const url = `http://127.0.0.1:${server.address().port}/offwork-clock/settings`;
|
|
51
|
+
assert.equal((await fetch(url)).status, 403);
|
|
52
|
+
assert.equal(
|
|
53
|
+
(await fetch(url, { headers: { 'x-offwork-client': '1', 'sec-fetch-site': 'cross-site' } }))
|
|
54
|
+
.status,
|
|
55
|
+
403,
|
|
56
|
+
);
|
|
57
|
+
assert.equal(
|
|
58
|
+
(await fetch(url, { headers: { 'x-offwork-client': '1', origin: 'https://evil.example' } }))
|
|
59
|
+
.status,
|
|
60
|
+
403,
|
|
61
|
+
);
|
|
62
|
+
const headers = { 'x-offwork-client': '1', 'content-type': 'application/json' };
|
|
63
|
+
assert.equal((await (await fetch(url, { headers })).json()).agentAccess, false);
|
|
64
|
+
assert.equal(
|
|
65
|
+
(await fetch(url, { method: 'POST', headers: { 'x-offwork-client': '1' }, body: '{}' })).status,
|
|
66
|
+
415,
|
|
67
|
+
);
|
|
68
|
+
const saved = await (
|
|
69
|
+
await fetch(url, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers,
|
|
72
|
+
body: JSON.stringify({ action: 'set', settings: { dailySalary: 234 } }),
|
|
73
|
+
})
|
|
74
|
+
).json();
|
|
75
|
+
assert.equal(saved.settings.dailySalary, 234);
|
|
76
|
+
await fetch(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers,
|
|
79
|
+
body: JSON.stringify({ action: 'access', allowed: true }),
|
|
80
|
+
});
|
|
81
|
+
assert.equal(await access.get(), true);
|
|
82
|
+
assert.equal(
|
|
83
|
+
(await fetch(url, { method: 'POST', headers, body: 'x'.repeat(17000) })).status,
|
|
84
|
+
413,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
test('private settings default to unauthenticated denial before reading data', async (t) => {
|
|
88
|
+
const dir = await fixture(t);
|
|
89
|
+
let reads = 0;
|
|
90
|
+
const server = createServer(
|
|
91
|
+
createSettingsHandler(async () => {
|
|
92
|
+
reads++;
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}, createSettingsAccess(dir)),
|
|
95
|
+
);
|
|
96
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
97
|
+
t.after(() => new Promise((r) => server.close(r)));
|
|
98
|
+
const response = await fetch(`http://127.0.0.1:${server.address().port}/offwork-clock/settings`, {
|
|
99
|
+
headers: { 'x-offwork-client': '1' },
|
|
100
|
+
});
|
|
101
|
+
assert.equal(response.status, 401);
|
|
102
|
+
assert.equal(reads, 0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('agent salary access defaults off, can be explicitly enabled and revoked', async (t) => {
|
|
106
|
+
const dir = await fixture(t),
|
|
107
|
+
operate = createOperations(join(dir, 'settings.json')),
|
|
108
|
+
access = createSettingsAccess(dir);
|
|
109
|
+
let tool;
|
|
110
|
+
installOperations(
|
|
111
|
+
{
|
|
112
|
+
effect: (fn) => fn(),
|
|
113
|
+
commands: { register: () => () => {} },
|
|
114
|
+
tools: {
|
|
115
|
+
register: (t) => {
|
|
116
|
+
tool = t;
|
|
117
|
+
return () => {};
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
operate,
|
|
122
|
+
access,
|
|
123
|
+
);
|
|
124
|
+
assert.equal((await tool.execute({ action: 'get' })).ok, false);
|
|
125
|
+
await access.set(true);
|
|
126
|
+
assert.equal((await tool.execute({ action: 'get' })).ok, true);
|
|
127
|
+
await access.set(false);
|
|
128
|
+
assert.equal((await tool.execute({ action: 'set', settings: { monthlySalary: 777 } })).ok, false);
|
|
129
|
+
assert.equal((await operate('get')).configured, false);
|
|
130
|
+
});
|
package/tests/ui.test.mjs
CHANGED
|
@@ -125,7 +125,10 @@ test('Client uses the exact tested engine and a single surface', async () => {
|
|
|
125
125
|
assert.match(source, /removeEventListener\('storage'/);
|
|
126
126
|
assert.match(source, /ro\?\.disconnect/);
|
|
127
127
|
assert.match(source, /generation\.current/);
|
|
128
|
-
assert.doesNotMatch(source, /document\.body|window\.open
|
|
128
|
+
assert.doesNotMatch(source, /document\.body|window\.open/);
|
|
129
|
+
assert.match(source, /offwork-clock\/settings/);
|
|
130
|
+
assert.match(source, /credentials: 'same-origin'/);
|
|
131
|
+
assert.doesNotMatch(source, /remote\.commands\.execute/);
|
|
129
132
|
assert.match(source, /money\(safeState\.earned\)/);
|
|
130
133
|
assert.match(source, /owc-glass/);
|
|
131
134
|
assert.match(source, /--owc-alpha/);
|
package/tools/build.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
|
|
3
4
|
const root = new URL('../', import.meta.url);
|
|
4
5
|
const moduleNames = [
|
|
@@ -9,10 +10,15 @@ const moduleNames = [
|
|
|
9
10
|
'pet-view.js',
|
|
10
11
|
'pet-motion.js',
|
|
11
12
|
'pet-video.js',
|
|
13
|
+
'pet-reminder.js',
|
|
12
14
|
'pet-interactions.js',
|
|
13
15
|
'pet-surfaces.js',
|
|
14
16
|
'hydration.js',
|
|
15
17
|
'hydration-store.js',
|
|
18
|
+
'countdown.js',
|
|
19
|
+
'companion-state.js',
|
|
20
|
+
'companion-image.js',
|
|
21
|
+
'companion-app.js',
|
|
16
22
|
];
|
|
17
23
|
const [entry, ...modules] = await Promise.all(
|
|
18
24
|
moduleNames.map((name) => readFile(new URL(name, root), 'utf8')),
|
|
@@ -26,7 +32,12 @@ if (entry.split(marker).length !== 2) {
|
|
|
26
32
|
// The browser and Node.js tests execute the same calculation and store code.
|
|
27
33
|
const pure = modules
|
|
28
34
|
.map((source) =>
|
|
29
|
-
source
|
|
35
|
+
source
|
|
36
|
+
.replace(
|
|
37
|
+
/^import\s+\{[^}]+\}\s+from '\.\/(?:hydration|clock|countdown|ui-utils)\.js';\n/gm,
|
|
38
|
+
'',
|
|
39
|
+
)
|
|
40
|
+
.replace(/^export /gm, ''),
|
|
30
41
|
)
|
|
31
42
|
.join('\n');
|
|
32
43
|
const banner = '// Generated by tools/build.mjs; edit the source modules, not this file.\n';
|
|
@@ -44,14 +55,20 @@ const sprites = Object.fromEntries(
|
|
|
44
55
|
);
|
|
45
56
|
const clipMarker = '/* PET_CLIPS */';
|
|
46
57
|
if (entry.split(clipMarker).length !== 2) throw new Error('Expected one clip marker');
|
|
47
|
-
|
|
58
|
+
const mediaRoute = '/offwork-clock/media';
|
|
59
|
+
const mediaAssets = {};
|
|
60
|
+
async function clip(name, maximum, inline = false) {
|
|
48
61
|
const bytes = await readFile(new URL(`assets/animations/${name}.webm`, root));
|
|
49
|
-
if (bytes.readUInt32BE(0) !== 0x1a45dfa3 || bytes.length > maximum)
|
|
62
|
+
if (bytes.length < 4 || bytes.readUInt32BE(0) !== 0x1a45dfa3 || bytes.length > maximum)
|
|
50
63
|
throw new Error(`Invalid or oversized WebM: ${name}`);
|
|
51
|
-
return 'data:video/webm;base64,' + bytes.toString('base64');
|
|
64
|
+
if (inline) return 'data:video/webm;base64,' + bytes.toString('base64');
|
|
65
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
66
|
+
const key = `${sha256}/${name}.webm`;
|
|
67
|
+
mediaAssets[key] = { file: `assets/animations/${name}.webm`, size: bytes.length, sha256 };
|
|
68
|
+
return `${mediaRoute.slice(1)}/${key}`;
|
|
52
69
|
}
|
|
53
70
|
const [probe, greet, celebrate, ornateIdle, ornateGreet, ornateDrink] = await Promise.all([
|
|
54
|
-
clip('alpha-probe', 10000),
|
|
71
|
+
clip('alpha-probe', 10000, true),
|
|
55
72
|
clip('whale-greet', 2000000),
|
|
56
73
|
clip('whale-drink', 2000000),
|
|
57
74
|
clip('ornate-idle', 2000000),
|
|
@@ -70,15 +87,27 @@ const result =
|
|
|
70
87
|
entry
|
|
71
88
|
.replace(marker, pure)
|
|
72
89
|
.replace(spriteMarker, () => `const PET_SPRITES = ${JSON.stringify(sprites)};`)
|
|
73
|
-
.replace(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
.replace(
|
|
91
|
+
clipMarker,
|
|
92
|
+
() =>
|
|
93
|
+
`const PET_CLIPS = ${JSON.stringify(clips)};\nPET_CLIPS.models = petClipUrls(PET_CLIPS.models, document.baseURI);`,
|
|
94
|
+
);
|
|
95
|
+
const manifest =
|
|
96
|
+
banner +
|
|
97
|
+
`export const MEDIA_ROUTE = ${JSON.stringify(mediaRoute)};\nexport const MEDIA_ASSETS = Object.freeze(${JSON.stringify(Object.fromEntries(Object.entries(mediaAssets).sort()), null, 2)});\n`;
|
|
98
|
+
const outputs = [
|
|
99
|
+
['client.js', result],
|
|
100
|
+
['media-manifest.js', manifest],
|
|
101
|
+
];
|
|
102
|
+
for (const [name, contents] of outputs) {
|
|
103
|
+
const output = new URL(name, root);
|
|
104
|
+
if (process.argv.includes('--check')) {
|
|
105
|
+
if ((await readFile(output, 'utf8')) !== contents)
|
|
106
|
+
throw new Error(`Generated artifact is stale: ${name}. Run npm run build.`);
|
|
107
|
+
} else await writeFile(output, contents);
|
|
84
108
|
}
|
|
109
|
+
console.log(
|
|
110
|
+
process.argv.includes('--check')
|
|
111
|
+
? 'Client and media manifest match tested source'
|
|
112
|
+
: 'Built client and content-addressed media manifest',
|
|
113
|
+
);
|
package/tools/check-package.mjs
CHANGED
|
@@ -16,6 +16,10 @@ assert.ok(pkg.engines?.dsh, 'Declare the supported Harness versions');
|
|
|
16
16
|
assert.equal(pkg.dsh?.bundle?.patch, './cordis.patch.yml');
|
|
17
17
|
assert.equal(pkg.dsh?.client?.platform, 'web');
|
|
18
18
|
assert.match(await read('LICENSE'), /^MIT License/);
|
|
19
|
+
assert.ok(
|
|
20
|
+
Buffer.byteLength(await read('client.js')) < 2000000,
|
|
21
|
+
'Keep the initial client below 2MB; character movies must remain on-demand resources',
|
|
22
|
+
);
|
|
19
23
|
|
|
20
24
|
// This check covers this project's patch; Harness composition remains the
|
|
21
25
|
// authoritative check that the bundle can be installed in a real profile.
|
|
@@ -67,15 +71,23 @@ assert.equal(packed.version, pkg.version);
|
|
|
67
71
|
for (const path of [
|
|
68
72
|
'package.json',
|
|
69
73
|
'index.js',
|
|
74
|
+
'settings-api.js',
|
|
75
|
+
'companion-state.js',
|
|
76
|
+
'companion-image.js',
|
|
77
|
+
'companion-app.js',
|
|
78
|
+
'media-server.js',
|
|
79
|
+
'media-manifest.js',
|
|
70
80
|
'client.js',
|
|
71
81
|
'companion.js',
|
|
72
82
|
'pet-view.js',
|
|
73
83
|
'pet-motion.js',
|
|
74
84
|
'pet-video.js',
|
|
85
|
+
'pet-reminder.js',
|
|
75
86
|
'pet-interactions.js',
|
|
76
87
|
'pet-surfaces.js',
|
|
77
88
|
'hydration.js',
|
|
78
89
|
'hydration-store.js',
|
|
90
|
+
'countdown.js',
|
|
79
91
|
'cordis.patch.yml',
|
|
80
92
|
'icon.svg',
|
|
81
93
|
'assets/characters/whale-dress.webp',
|
package/ui-utils.js
CHANGED
|
@@ -7,6 +7,50 @@ export function formatDuration(seconds) {
|
|
|
7
7
|
.join(':');
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export function classicClockPresentation({ hasSession, loaded, configured, failed, state, now }) {
|
|
11
|
+
if (!hasSession)
|
|
12
|
+
return { phase: 'waiting', title: 'classicNoSession', hint: 'noSession', seconds: null };
|
|
13
|
+
if (failed)
|
|
14
|
+
return { phase: 'error', title: 'classicUnavailable', hint: 'classicRetryHint', seconds: null };
|
|
15
|
+
if (!loaded)
|
|
16
|
+
return { phase: 'loading', title: 'classicLoading', hint: 'classicLoadingHint', seconds: null };
|
|
17
|
+
if (!configured) return { phase: 'setup', title: 'setup', hint: 'setupHint', seconds: null };
|
|
18
|
+
switch (state?.status) {
|
|
19
|
+
case 'before':
|
|
20
|
+
return {
|
|
21
|
+
phase: 'before',
|
|
22
|
+
title: 'before',
|
|
23
|
+
hint: 'classicBeforeHint',
|
|
24
|
+
seconds: state.startRemaining,
|
|
25
|
+
};
|
|
26
|
+
case 'working':
|
|
27
|
+
return {
|
|
28
|
+
phase: 'working',
|
|
29
|
+
title: 'working',
|
|
30
|
+
hint: 'classicWorkingHint',
|
|
31
|
+
seconds: state.remaining,
|
|
32
|
+
};
|
|
33
|
+
case 'lunch':
|
|
34
|
+
return {
|
|
35
|
+
phase: 'lunch',
|
|
36
|
+
title: 'classicLunchRemaining',
|
|
37
|
+
hint: 'classicLunchHint',
|
|
38
|
+
seconds: Math.max(0, Math.ceil((state.breakEnd - now) / 1000)),
|
|
39
|
+
};
|
|
40
|
+
case 'done':
|
|
41
|
+
return { phase: 'done', title: 'done', hint: 'classicDoneHint', seconds: null };
|
|
42
|
+
case 'rest':
|
|
43
|
+
return { phase: 'rest', title: 'rest', hint: 'classicRestHint', seconds: null };
|
|
44
|
+
default:
|
|
45
|
+
return {
|
|
46
|
+
phase: 'error',
|
|
47
|
+
title: 'classicUnavailable',
|
|
48
|
+
hint: 'classicRetryHint',
|
|
49
|
+
seconds: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
10
54
|
export function clampPosition(position, viewport, size) {
|
|
11
55
|
const width = Math.max(0, viewport.width),
|
|
12
56
|
height = Math.max(0, viewport.height);
|