@gotcos/glasses-server 6.99.20 → 6.99.25
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 +91 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +2 -1
- package/server/lib/web-control-static.ts +226 -0
- package/server/lib/web-memories-bridge.ts +9 -8
- package/server/lib/web-pet-choice.ts +225 -0
- package/server/lib/web-pet.ts +226 -0
- package/server/lib/work-lens-rounds.ts +29 -4
- package/server/routes/web-control.ts +110 -7
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/web-control/app.css +24 -5
- package/server/web-control/app.js +646 -108
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +27 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +4 -0
- package/server/web-control/panes/cos-chat.js +26 -4
- package/server/web-control/panes/meetings.css +15 -2
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.js +59 -21
- package/server/web-control/panes/settings.js +102 -4
- package/server/web-control/panes/today-actions.css +109 -0
- package/server/web-control/panes/today-actions.js +972 -0
- package/server/web-control/panes/work.css +54 -0
- package/server/web-control/panes/work.js +1037 -18
- package/server/web-control/shell.js +6 -2
- package/server/web-control/theme.js +0 -2
- package/server/web-control/memories/memories-app.js +0 -1561
- package/server/web-control/memories/memories-theme.css +0 -73
- package/server/web-control/memories/memories.html +0 -72
- package/server/web-control/memories/memory-stewardship.js +0 -63
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
+
## 6.99.25 - COS Control Web: act from Today, Work chat, Memories rebuilt, change the pet (private)
|
|
2
|
+
|
|
3
|
+
2026-11-07. Private build on 6.99.24 (Codex root's mobile layout fix, bb5ff2c, kept unchanged). Server only. Works with COS Control 0.5.376 as installed; two Mac follow-ups are listed at the end.
|
|
4
|
+
|
|
5
|
+
Miles, 11/06 23:06 to 23:14: rebuild Memories for the Duo, message the orchestrator from Work like the Mac, build the change pet picker with an on/off switch using the 300 pets COS Control has, and make Today a place to act ("on click it opened up an overlay on the today panel that allowed me to see and interact with and make a decision"), with COS aware of what Today shows. Built by three agents and this session, then checked by five review agents (User, Skeptic, Completionist, Ghost Hunter, Parity) and fixed. Plan: `operations/personal/wk45_2026/PLAN_control_web_mobile_hub_2026-11-06.md`.
|
|
6
|
+
|
|
7
|
+
**Today is a place to act** (`panes/today-actions.js`, loaded on first tap)
|
|
8
|
+
- Tap a Needs you item, a running session or a meeting's suggestions and it opens over Today: a bottom sheet when folded, a side sheet when unfolded. Close with X, a tap outside, Escape or Back. A reload with it open reopens it.
|
|
9
|
+
- Needs you shows what the session waits on, with the same question cards and answer route as the Sessions reader, its last reply and a reply box; a waiting session never shows as working or offers Stop. If sessions could not be read, Today says so instead of "No agents are running". Running opens a peek with the live feed, a note box and Stop (tap, then tap again). Meeting suggestions offer Create task, Link, Dismiss, Open in Work and Open card through `POST /api/work-intake/:id/resolve`, one decision at a time. With no Undo route on this server, Dismiss takes a second tap.
|
|
10
|
+
- Taps inside a sheet are ignored until it has finished opening (at least 500 ms, from the sheet's and backdrop's own motion), so a double tap cannot act on something unread. Every tap-then-tap-again confirm (Dismiss, Stop, Send anyway, and Allow, Deny and Send on question cards wherever they show) ignores the second tap for 400 ms after arming. A reply typed in the overlay is kept per session for seven days and resent under the same id, so a retry never posts twice.
|
|
11
|
+
- Sending uses the Sessions reader's own `continueSession` and `parkTurn` (exported as `COSWeb.sessionKit`); the send gates, turn watch and verdict box are copies.
|
|
12
|
+
- **Needs you reads more of what the Mac's does:** sessions waiting on you, Work handoffs (merged with their session), held voices to name (one row, `/api/voice/held-groups`) and memories to review. The Mac also lists dictation fixes, older items to sort and meetings with unnamed voices; the web does not yet. Once Today has drawn, its count, headline and rail come from the same rows, and "Nothing is waiting on you" shows only after every source answered; a failed source says so. In quiet hours (evenings, nights, weekends) everything but waiting sessions folds under "can wait until tomorrow"; the Mac's switch to turn that off is not on the web.
|
|
13
|
+
- "Up next" is now "Next up", with Prep me and Open meeting. ⌘K can file a task to the Work inbox (`POST /api/tasks/capture`).
|
|
14
|
+
- **COS knows what Today shows.** An Ask from Today carries a summary of Today (at most 4,000 characters, the overflow marker included; titles and text, never ids) in the existing query-jobs `reference` field, quoted to the model as untrusted data. A session with no title is "A session", never its folder or id. If building it fails, the Ask goes without it.
|
|
15
|
+
|
|
16
|
+
**Work chat on the Work card**
|
|
17
|
+
- Type a request, tap Plan, review each assignment, then Send all or Send or Skip each one, with "Show what is sent" for COS Control's exact prompt. Plan uses the Mac's own requests (`/api/work/orchestrator/input`, `POST /api/work/chat` with `orchestrator: true`), Opus at high effort by default.
|
|
18
|
+
- Send saves the plan as a lens round with the new `source: "web"` (`lib/work-lens-rounds.ts`, additive; a lens round is unchanged), so COS Control on the Mac sends it through its own Send, journal and fences and attaches the card's and its meetings' files. The web shows "Waiting for COS Control on your Mac to send this" until it does, never Sent before. Capability: `GET /api/work/chat/web-plans` (`GET /api/work/chat` is unchanged).
|
|
19
|
+
- Also on the card: Agent workspace with Open session, View progress, Link a meeting, Mark complete, and Not done yet moving a sent card back to Draft. Drafts survive fold, reload and a second tab. A plan never sent can be removed; one whose Send answer was lost cannot, since COS Control may already be sending it.
|
|
20
|
+
- Not the same as the Mac: the card shows only plans made in this browser (the Mac's Work chat history lives on the Mac), and Unlink, Link session, Review changes, outputs, files, Mark reviewed and Clear handoff stay on the Mac (their data is local to COS Control).
|
|
21
|
+
|
|
22
|
+
**Memories is part of the app** (`panes/memories.js`)
|
|
23
|
+
- Replaces the Mac page in a frame that left white bars on the Duo. Fills the screen, keeps your place across fold, unfold and reload (the tab is in the address, `#/memories/t/<tab>`), and covers Recent learning, Applied this week, All memories with search, To review, Knowledge (Ask the graph, sources, setup, index, duplicates and merge preview), the reader (Open sources, Explore in graph, Copy context), the activity log and Memory settings, through the same `/api/web-bridge/memories` ops.
|
|
24
|
+
- New: **Cues to rate** and **Rated cues**, like the Mac's cue review (Useful, Late, Repeat, Wrong, Obvious, a note, Skip; change or clear a rating), through `GET /api/live-cues/history` and `POST /api/live-cues/feedback`.
|
|
25
|
+
- Behaviour changes: To review lists the items that need you first, matching its count (before: the tab said 7 and listed 76 other proposals); recall rows name the memory; plain labels and errors; removing a Knowledge folder takes a second tap; Recent learning keeps one order; on phones the large header is hidden. The old hosted page (about 1 MB) and its `/control/memories/*` 'unsafe-inline' CSP exception are removed: every path is `script-src 'self'`.
|
|
26
|
+
|
|
27
|
+
**Change pet** (Settings > Pet, or "Change pet" under the pet on Today)
|
|
28
|
+
- Show or hide the pet on Today, and pick Same as my Mac, a character COS Control ships (the four Jedi, animated, and Cloud Puff) or one of the ~300 OpenPets gallery pets COS Control lists, shown as its gallery picture as on the Mac. The choice is per browser; the Mac's own pet still changes in COS Control.
|
|
29
|
+
- Gallery pictures follow the Mac helper's rule exactly (https, host openpets.dev, path ending /thumb.webp, WebP only, 512 KB enforced while reading, 6 s); a thumbnail COS Control already fetched is used without a second download. **This adds an outbound request from the server to openpets.dev** whenever a gallery picture it has not seen scrolls into view in Settings.
|
|
30
|
+
- The pet's running count uses the Running card's own test, so the two never disagree.
|
|
31
|
+
|
|
32
|
+
**Loading**
|
|
33
|
+
- A pane script that stalls can no longer hang a tap: Today taps wait 4 s before their old destination, the Ask summary 1.5 s, a section 15 s before Try again (and clears itself if the script arrives later).
|
|
34
|
+
|
|
35
|
+
**Default-on changes for every web user**
|
|
36
|
+
- Today rows open overlays instead of jumping to the tab (Open session still goes to the reader). Needs you lists more kinds of items, and the Today headline and rail count them. "Next up" replaces "Up next".
|
|
37
|
+
- An Ask from Today sends a summary of Today to the model.
|
|
38
|
+
- Work cards show Work chat. Plan makes a planner model call on the Mac; nothing is sent until Send.
|
|
39
|
+
- Memories is the new pane, with the behaviour changes above and the two cue views.
|
|
40
|
+
- The pet picker and "Change pet"; the server fetches gallery pictures from openpets.dev.
|
|
41
|
+
- Unknown tools in session feeds read "Used <tool>" in the overlay and in the Sessions reader.
|
|
42
|
+
- Question cards (Allow, Deny, Send) ignore a confirming tap for 400 ms after arming, everywhere they show.
|
|
43
|
+
- Server: lens rounds accept `source: "web"` with `web-<uuid>` ids; new `GET /api/work/chat/web-plans`; new `/api/web-pet/catalog`, `/still`, `/preview`, and `?pet=` on `/api/web-pet` and its sheets.
|
|
44
|
+
|
|
45
|
+
**Needs COS Control follow-ups (relayed to the Mac lane, not in this build)**
|
|
46
|
+
- COS Control 0.5.376 labels a web plan "From glasses" and posts "A plan from your glasses is waiting" when it arrives (on Send only). It should read `source` and say "From the web".
|
|
47
|
+
- Setting the Mac's own pet from the web needs COS Control to accept a pet request.
|
|
48
|
+
|
|
49
|
+
## 6.99.24 - Mobile prep and recording controls fit small screens (private)
|
|
50
|
+
|
|
51
|
+
2026-11-06. Private build on 6.99.23, compatible with COS Control 0.5.376/build 430. Server-served web layout only.
|
|
52
|
+
|
|
53
|
+
- Recording actions fit a 320 px viewport. Phone recording and prep controls have 44 px touch targets.
|
|
54
|
+
- The mobile prep suggestion reserves enough room for its text and Add to this meeting button.
|
|
55
|
+
- Prep context and files continue through the existing explicit Add to this meeting action. No new automatic calendar binding.
|
|
56
|
+
- Default-on changes: these responsive layout adjustments for web users. No API, recording, transcription, cue, profile, or permission changes.
|
|
57
|
+
|
|
58
|
+
## 6.99.23 - Sessions and Work open at once; the pet moves right when unfolded (private)
|
|
59
|
+
|
|
60
|
+
2026-11-06. Private build on 6.99.22. Server only; no Mac app change.
|
|
61
|
+
|
|
62
|
+
Miles, 11/06 22:4x: "It's still fairly slow when loading sessions and other tabs", and move the pet to the right on the unfolded screen.
|
|
63
|
+
|
|
64
|
+
- **One session list for every pane.** Timed in the browser on 6.99.22: `/api/agent-sessions` took 5.1 s on Sessions and 2.1 s on Work (it walks every session folder and runs a 2 to 3 s lock probe on the Mac); everything else on those tabs answered in under 0.25 s. Today, Sessions, Work and the section stories each fetched it separately. COS Control Web now keeps one copy: a pane opened within 15 s of the last fetch uses it, a fetch already on the wire is shared, and Sessions paints the last copy at once with "Refreshing…" while a fresh one loads. Measured after: Sessions showed its 68 rows 0.6 s after opening with no new request; Work made no session request (slowest call 0.2 s). The server route is unchanged.
|
|
65
|
+
- **The pet on the unfolded Duo** sits at the top of the right-hand column, above Needs you. Folded, it stays above the story.
|
|
66
|
+
- Default-on changes: Sessions and Work can show a session list up to 15 s old while a fresh one loads; the pet's position when unfolded. No API change.
|
|
67
|
+
|
|
68
|
+
## 6.99.22 - The pet comes to COS Control Web (private)
|
|
69
|
+
|
|
70
|
+
2026-11-06. Private build on 6.99.21. Server only; no Mac app change.
|
|
71
|
+
|
|
72
|
+
Miles, 11/06: bring the pet to COS Control Web, at the top of Home.
|
|
73
|
+
|
|
74
|
+
- **The pet on Today.** The same pet COS Control shows, read from its own `session-pet-states.json` in `~/Library/Application Support/COS Control`, so a pet change on the Mac shows on the phone. It takes the pose the Mac would pick (COS Control's `PetSpritePose.resolve`): waiting on you with nothing running is waiting; one, two, three, or four or more sessions in play are working, duel, trio and swarm; an open quiet session is patrol; none is idle. Under it, plain text: "3 running", "1 waiting on you", or "Nothing running". Tapping it opens Sessions. Reduced motion shows a still frame. The loop runs only while Today is on screen.
|
|
75
|
+
- **Phone-sized strips.** The Mac's strips are 256 px tall and up to about 56,000 px wide. `server/lib/web-pet.ts` resizes each pose to 160 px with macOS `sips` and cuts it into chunks no wider than 8,192 px, each holding whole frames, cached under the data folder and keyed by the source file. Only the pose on screen downloads; each is cached for a year. Windu's heaviest pose (swarm) is 4.8 MB across 7 chunks, the lightest (error) 74 KB. Routes: `GET /api/web-pet` and `GET /api/web-pet/sheet/:pose/:chunk`, behind the usual sign-in.
|
|
76
|
+
- `sips` treats a crop offset of 0 as "centre", so the first chunk is cut from the mirrored strip and flipped back. A test runs real `sips` on a synthetic strip and checks every frame's colour in its chunk.
|
|
77
|
+
- **Sign-in wording.** COS Control Web's "Sign in another device" is now "Use COS on another phone or browser", says it is for the iPhone Camera, and says glasses pair from COS Control on the Mac (Miles 11/06: the two QR codes read like the same thing).
|
|
78
|
+
- Default-on changes: the pet on Today and the renamed sign-in row. No change for the glasses app or any other client.
|
|
79
|
+
|
|
80
|
+
## 6.99.21 - COS Control Web opens faster on the phone (private)
|
|
81
|
+
|
|
82
|
+
2026-11-06. Private build on 6.99.20. Server only; no Mac app change.
|
|
83
|
+
|
|
84
|
+
Miles, 11/06: speed up COS Control Web and make it sturdier on the iPhone, first step of the mobile hub plan (`operations/personal/wk45_2026/PLAN_control_web_mobile_hub_2026-11-06.md`).
|
|
85
|
+
|
|
86
|
+
- **Files are compressed and cached.** `/control` used to serve every file uncompressed with `Cache-Control: max-age=0`, so each open re-checked all 26 boot files with the Mac over Tailscale. `server/lib/web-control-static.ts` now gives each script, stylesheet, font and icon a content hash, rewrites the references in `index.html` and the CSS to `?v=<hash>`, and serves a matching versioned file as cacheable for a year. `index.html`, other HTML pages and any unversioned or stale URL are `no-cache` with an ETag, so a new release shows on the next open. Text files go out brotli or gzip compressed.
|
|
87
|
+
- **Measured on the Mac against 6.99.20** (loopback, browser-style cache): first open 1,969 KB to 708 KB on the wire; a repeat open 26 requests to 1. Not yet timed on the phone over Tailscale.
|
|
88
|
+
- `express.static` stays behind the new layer, so every URL that worked before still works. The tree is re-checked at most once a second, so a worktree test copy picks up edits without a restart.
|
|
89
|
+
- **Heavy sections load when first opened.** Messages, Threads, Speakers, Meetings, Work, Dictation and Settings (about 600 KB of scripts) no longer load at startup. Each loads the first time you open it, and once the page is showing the rest load in the background, so later taps are instant. A section that fails to load says why and offers Try again. Together with compression, the first open went from 26 requests and 1,969 KB to 19 requests and 561 KB on the Mac.
|
|
90
|
+
- Default-on changes: response headers and compression on `/control/*`, and sections loading on first open. No API, data or server behaviour change.
|
|
91
|
+
|
|
1
92
|
## 6.99.20 - Prep opens from COS Control on the Mac (private)
|
|
2
93
|
|
|
3
94
|
2026-11-06. Private fix on 6.99.19, matched to COS Control 0.5.376 already installed.
|
|
@@ -182,3 +182,23 @@ Rules the server enforces:
|
|
|
182
182
|
Checks: `server/routes/work-lens-rounds.test.ts` (record, idempotent repeat, send with and without confirm, Auto-send,
|
|
183
183
|
423, `plan_stale`, expiry, stop, loopback-only, maintenance, the existing GET and POST unchanged) and
|
|
184
184
|
`server/scripts/work-lens-rounds-mutation-gate.mjs` (not packaged).
|
|
185
|
+
|
|
186
|
+
## Web plans (COS Control Web's Work chat)
|
|
187
|
+
|
|
188
|
+
COS Control Web messages the orchestrator about a card the way the lens does. It plans with the Mac's routes and body
|
|
189
|
+
(`GET /api/work/chat`, `POST /api/work/orchestrator/input` with `{workID, meetingWorkIDs}`, then `POST /api/work/chat`
|
|
190
|
+
with `orchestrator: true`, the card's context in the helper's `work-context` format, and the planner and effort the
|
|
191
|
+
server advertises), then records the plan with `POST /api/work/chat/lens-rounds` and asks COS Control to send it with
|
|
192
|
+
`POST .../:roundId/send` (`confirm: "two-step"`: Send, then "Send to <session>", after the plan was shown). Nothing new
|
|
193
|
+
can send: COS Control performs every send through its own Send path, with its journal, fences and receipts.
|
|
194
|
+
|
|
195
|
+
| Change | Detail |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| `GET /api/work/chat/web-plans` (new) | `{protocolVersion: 1, webPlans: 1, sources: ["lens", "web"]}`. The web shows its composer only when this answers 1; an older server answers 404. `GET /api/work/chat` is unchanged. |
|
|
198
|
+
| `POST /api/work/chat/lens-rounds` | `source` is `lens` (a `lens-<uuid>` id, as before) or `web` (a `web-<uuid>` id, validated by the same pattern). An id that does not start with its source is 400 `invalid_lens_round`, field `clientRoundId`. A web id reused for a different plan (question, answer, planner, effort or any assignment) is 409 `round_id_conflict`; the same plan again is 200 with the stored round. A lens repeat keeps the lens rule (200, the stored round, whatever the repeat carries). |
|
|
199
|
+
| A web round | stored with `source: "web"`; the round view and the inbox carry `source: "web"`. A lens round has no `source` key, so its stored shape, view and inbox entry are byte for byte as before. |
|
|
200
|
+
|
|
201
|
+
COS Control 0.5.3xx reads every inbox round as a lens round, so it labels a web round "From glasses" until it reads
|
|
202
|
+
`source`. Card files and meeting files stay on the Mac: COS Control attaches them when it sends (a round with no
|
|
203
|
+
`contextItemIDs` sends every linked item). The web cannot list or add them. Checks: `server/routes/work-web-rounds.test.ts`
|
|
204
|
+
and `server/web-control/work-composer-web.test.ts`; `server/routes/work-lens-rounds.test.ts` is unchanged and passes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.99.
|
|
3
|
+
"version": "6.99.25",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -219,6 +219,7 @@ import { isPublicApiRequest, requireApiToken } from './lib/api-auth.js'
|
|
|
219
219
|
import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
|
|
220
220
|
import { createPairingRouter } from './routes/pairing.js'
|
|
221
221
|
import { createWebControlRouter, isAllowedCorsOrigin, webControlCsp, webControlEnabled } from './routes/web-control.js'
|
|
222
|
+
import { createWebControlStatic } from './lib/web-control-static.js'
|
|
222
223
|
import { isWebControlSocketAllowed, isWebSessionAuthorized, setWebSessionEpochFile } from './lib/web-session-auth.js'
|
|
223
224
|
import { getTailnetSelfName, installTailnetSelfName } from './lib/tailnet-self-name.js'
|
|
224
225
|
import { isManagedRuntime, managedServerVersion } from './lib/managed-runtime.js'
|
|
@@ -1524,7 +1525,7 @@ if (webControlEnabled()) {
|
|
|
1524
1525
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
1525
1526
|
res.setHeader('Referrer-Policy', 'no-referrer')
|
|
1526
1527
|
next()
|
|
1527
|
-
}, express.static(webControlDir))
|
|
1528
|
+
}, createWebControlStatic(webControlDir), express.static(webControlDir))
|
|
1528
1529
|
app.use('/api', createWebControlRouter({
|
|
1529
1530
|
apiToken: () => API_TOKEN,
|
|
1530
1531
|
serverVersion: () => managedServerVersion() ?? 'unknown',
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast static serving for COS Control Web (`/control/*`).
|
|
3
|
+
*
|
|
4
|
+
* Before this, `/control` went straight to `express.static`: no compression and
|
|
5
|
+
* `Cache-Control: public, max-age=0`, so every open on the phone re-asked the Mac about
|
|
6
|
+
* all 22 boot files over Tailscale and downloaded about 1.2 MB of uncompressed JS and CSS
|
|
7
|
+
* plus 770 KB of fonts whenever anything changed.
|
|
8
|
+
*
|
|
9
|
+
* What this does:
|
|
10
|
+
* - Every asset gets a content hash. `index.html` and the CSS files have their
|
|
11
|
+
* `/control/...` references rewritten to `/control/...?v=<hash>`.
|
|
12
|
+
* - A request whose `?v=` matches the current hash is cached for a year as `immutable`.
|
|
13
|
+
* Anything else (index.html, a stale or missing `?v=`) is `no-cache`, so it revalidates
|
|
14
|
+
* with its ETag and a new release is picked up on the next open.
|
|
15
|
+
* - Text assets are served brotli or gzip compressed when the browser accepts it. The
|
|
16
|
+
* compressed bytes are built on first request and kept in memory.
|
|
17
|
+
* - Files on disk are re-checked at most once per `recheckMs`, so a test copy served from
|
|
18
|
+
* a worktree picks up edits without a restart.
|
|
19
|
+
*
|
|
20
|
+
* Anything not in the table (a path this walker skips) falls through to `next()`, and the
|
|
21
|
+
* caller keeps `express.static` behind it, so no URL that worked before stops working.
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'crypto'
|
|
24
|
+
import fs from 'fs'
|
|
25
|
+
import path from 'path'
|
|
26
|
+
import zlib from 'zlib'
|
|
27
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express'
|
|
28
|
+
|
|
29
|
+
/** A versioned URL's lifetime. Safe because the hash changes whenever the bytes do. */
|
|
30
|
+
export const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
|
|
31
|
+
/** Unversioned and HTML responses: always revalidate, so a new release shows on the next open. */
|
|
32
|
+
export const REVALIDATE_CACHE = 'no-cache'
|
|
33
|
+
|
|
34
|
+
const TYPES: Record<string, string> = {
|
|
35
|
+
'.html': 'text/html; charset=utf-8',
|
|
36
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
37
|
+
'.css': 'text/css; charset=utf-8',
|
|
38
|
+
'.svg': 'image/svg+xml',
|
|
39
|
+
'.json': 'application/json; charset=utf-8',
|
|
40
|
+
'.ttf': 'font/ttf',
|
|
41
|
+
'.woff2': 'font/woff2',
|
|
42
|
+
'.png': 'image/png',
|
|
43
|
+
'.webp': 'image/webp',
|
|
44
|
+
'.jpg': 'image/jpeg',
|
|
45
|
+
}
|
|
46
|
+
/** Worth compressing. WOFF2 and images are already compressed. */
|
|
47
|
+
const COMPRESSIBLE = new Set(['.html', '.js', '.css', '.svg', '.json', '.ttf'])
|
|
48
|
+
/** Files whose `/control/...` references are rewritten to versioned URLs. */
|
|
49
|
+
const REWRITTEN = new Set(['.html', '.css'])
|
|
50
|
+
/** Only assets of these types get a versioned URL; HTML pages are always revalidated. */
|
|
51
|
+
const VERSIONABLE = new Set(['.js', '.css', '.svg', '.json', '.ttf', '.woff2', '.png', '.webp', '.jpg'])
|
|
52
|
+
|
|
53
|
+
export interface WebAsset {
|
|
54
|
+
/** Path under the mount, with a leading slash: `/panes/work.js`. */
|
|
55
|
+
urlPath: string
|
|
56
|
+
ext: string
|
|
57
|
+
type: string
|
|
58
|
+
body: Buffer
|
|
59
|
+
/** First 16 hex characters of the SHA-256 of `body` (after any rewrite). */
|
|
60
|
+
hash: string
|
|
61
|
+
gzip?: Promise<Buffer>
|
|
62
|
+
br?: Promise<Buffer>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type WebAssetTable = Map<string, WebAsset>
|
|
66
|
+
|
|
67
|
+
/** Dotfiles are never served. Sources (`.ts`) and maps stay out because TYPES does not list them. */
|
|
68
|
+
function skip(name: string): boolean {
|
|
69
|
+
return name.startsWith('.')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function walk(dir: string, base = ''): Array<{ urlPath: string; file: string; stat: fs.Stats }> {
|
|
73
|
+
const out: Array<{ urlPath: string; file: string; stat: fs.Stats }> = []
|
|
74
|
+
let names: string[]
|
|
75
|
+
try { names = fs.readdirSync(dir) } catch { return out }
|
|
76
|
+
for (const name of names.sort()) {
|
|
77
|
+
if (skip(name)) continue
|
|
78
|
+
const file = path.join(dir, name)
|
|
79
|
+
let stat: fs.Stats
|
|
80
|
+
try { stat = fs.statSync(file) } catch { continue }
|
|
81
|
+
const urlPath = `${base}/${name}`
|
|
82
|
+
if (stat.isDirectory()) out.push(...walk(file, urlPath))
|
|
83
|
+
else if (stat.isFile() && TYPES[path.extname(name).toLowerCase()]) out.push({ urlPath, file, stat })
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A cheap fingerprint of the tree: every path, size and mtime. */
|
|
89
|
+
export function treeSignature(dir: string): string {
|
|
90
|
+
return walk(dir).map((f) => `${f.urlPath}:${f.stat.size}:${f.stat.mtimeMs}`).join('|')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sha(body: Buffer): string {
|
|
94
|
+
return crypto.createHash('sha256').update(body).digest('hex').slice(0, 16)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Rewrite `/control/<path>` references (quoted, or inside `url(...)`) to carry `?v=<hash>`.
|
|
99
|
+
* Only references to a known, versionable asset are touched; a reference that already has a
|
|
100
|
+
* query string, or names a file that does not exist, is left exactly as written.
|
|
101
|
+
*/
|
|
102
|
+
export function rewriteReferences(text: string, mount: string, hashes: Map<string, string>): string {
|
|
103
|
+
const prefix = mount.replace(/\/$/, '')
|
|
104
|
+
const re = new RegExp(`(["'(])${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/[A-Za-z0-9_./-]+)(["')])`, 'g')
|
|
105
|
+
return text.replace(re, (whole, open: string, rel: string, close: string) => {
|
|
106
|
+
const hash = hashes.get(rel)
|
|
107
|
+
if (!hash || !VERSIONABLE.has(path.extname(rel).toLowerCase())) return whole
|
|
108
|
+
return `${open}${prefix}${rel}?v=${hash}${close}`
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build the table. Leaves (JS, fonts, images) are hashed first, then CSS is rewritten against
|
|
114
|
+
* them and hashed, then HTML is rewritten against everything. So a font change gives its CSS a
|
|
115
|
+
* new hash, and that gives index.html new references.
|
|
116
|
+
*/
|
|
117
|
+
export function buildWebAssetTable(dir: string, mount = '/control'): WebAssetTable {
|
|
118
|
+
const files = walk(dir)
|
|
119
|
+
const hashes = new Map<string, string>()
|
|
120
|
+
const table: WebAssetTable = new Map()
|
|
121
|
+
const read = (file: string): Buffer | null => { try { return fs.readFileSync(file) } catch { return null } }
|
|
122
|
+
const add = (urlPath: string, ext: string, body: Buffer): void => {
|
|
123
|
+
const hash = sha(body)
|
|
124
|
+
hashes.set(urlPath, hash)
|
|
125
|
+
table.set(urlPath, { urlPath, ext, type: TYPES[ext], body, hash })
|
|
126
|
+
}
|
|
127
|
+
const pass = (pick: (ext: string) => boolean): void => {
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
const ext = path.extname(f.urlPath).toLowerCase()
|
|
130
|
+
if (!pick(ext)) continue
|
|
131
|
+
const raw = read(f.file)
|
|
132
|
+
if (!raw) continue
|
|
133
|
+
const body = REWRITTEN.has(ext) ? Buffer.from(rewriteReferences(raw.toString('utf8'), mount, hashes), 'utf8') : raw
|
|
134
|
+
add(f.urlPath, ext, body)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
pass((ext) => !REWRITTEN.has(ext))
|
|
138
|
+
pass((ext) => ext === '.css')
|
|
139
|
+
pass((ext) => ext === '.html')
|
|
140
|
+
return table
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Which encoding to send: brotli, then gzip, then none. `q=0` refuses an encoding. */
|
|
144
|
+
export function pickEncoding(acceptEncoding: string | undefined): 'br' | 'gzip' | null {
|
|
145
|
+
const accepted = new Map<string, number>()
|
|
146
|
+
for (const part of String(acceptEncoding || '').toLowerCase().split(',')) {
|
|
147
|
+
const [name, ...params] = part.trim().split(';')
|
|
148
|
+
if (!name) continue
|
|
149
|
+
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='))
|
|
150
|
+
accepted.set(name.trim(), q ? Number(q.slice(2)) || 0 : 1)
|
|
151
|
+
}
|
|
152
|
+
const ok = (name: string): boolean => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0
|
|
153
|
+
if (ok('br')) return 'br'
|
|
154
|
+
if (ok('gzip')) return 'gzip'
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function compressed(asset: WebAsset, encoding: 'br' | 'gzip'): Promise<Buffer> {
|
|
159
|
+
if (encoding === 'br') {
|
|
160
|
+
asset.br ??= new Promise((resolve, reject) => zlib.brotliCompress(asset.body, {
|
|
161
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 9, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: asset.body.length },
|
|
162
|
+
}, (err, out) => (err ? reject(err) : resolve(out))))
|
|
163
|
+
return asset.br
|
|
164
|
+
}
|
|
165
|
+
asset.gzip ??= new Promise((resolve, reject) => zlib.gzip(asset.body, { level: 9 }, (err, out) => (err ? reject(err) : resolve(out))))
|
|
166
|
+
return asset.gzip
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface WebControlStaticOptions {
|
|
170
|
+
mount?: string
|
|
171
|
+
/** How often the tree is re-checked for edits. 0 checks on every request. */
|
|
172
|
+
recheckMs?: number
|
|
173
|
+
now?: () => number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createWebControlStatic(dir: string, opts: WebControlStaticOptions = {}): RequestHandler & { table: () => WebAssetTable } {
|
|
177
|
+
const mount = opts.mount ?? '/control'
|
|
178
|
+
const recheckMs = opts.recheckMs ?? 1000
|
|
179
|
+
const now = opts.now ?? Date.now
|
|
180
|
+
let signature = treeSignature(dir)
|
|
181
|
+
let table = buildWebAssetTable(dir, mount)
|
|
182
|
+
let checkedAt = now()
|
|
183
|
+
|
|
184
|
+
const current = (): WebAssetTable => {
|
|
185
|
+
if (now() - checkedAt >= recheckMs) {
|
|
186
|
+
checkedAt = now()
|
|
187
|
+
const next = treeSignature(dir)
|
|
188
|
+
if (next !== signature) { signature = next; table = buildWebAssetTable(dir, mount) }
|
|
189
|
+
}
|
|
190
|
+
return table
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const handler = (req: Request, res: Response, next: NextFunction): void => {
|
|
194
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') { next(); return }
|
|
195
|
+
let urlPath = req.path || '/'
|
|
196
|
+
if (urlPath.endsWith('/')) urlPath += 'index.html'
|
|
197
|
+
const asset = current().get(urlPath)
|
|
198
|
+
if (!asset) { next(); return }
|
|
199
|
+
|
|
200
|
+
const versioned = VERSIONABLE.has(asset.ext) && req.query.v === asset.hash
|
|
201
|
+
const encoding = COMPRESSIBLE.has(asset.ext) ? pickEncoding(req.headers['accept-encoding'] as string | undefined) : null
|
|
202
|
+
const etag = `"${asset.hash}${encoding ? `-${encoding}` : ''}"`
|
|
203
|
+
res.setHeader('Content-Type', asset.type)
|
|
204
|
+
res.setHeader('Cache-Control', versioned ? IMMUTABLE_CACHE : REVALIDATE_CACHE)
|
|
205
|
+
res.setHeader('ETag', etag)
|
|
206
|
+
if (COMPRESSIBLE.has(asset.ext)) res.setHeader('Vary', 'Accept-Encoding')
|
|
207
|
+
|
|
208
|
+
const inm = String(req.headers['if-none-match'] || '')
|
|
209
|
+
if (inm && inm.split(',').map((t) => t.trim().replace(/^W\//, '')).includes(etag)) { res.status(304).end(); return }
|
|
210
|
+
|
|
211
|
+
const send = (body: Buffer): void => {
|
|
212
|
+
if (encoding) res.setHeader('Content-Encoding', encoding)
|
|
213
|
+
res.setHeader('Content-Length', String(body.length))
|
|
214
|
+
if (req.method === 'HEAD') res.end(); else res.end(body)
|
|
215
|
+
}
|
|
216
|
+
if (!encoding) { send(asset.body); return }
|
|
217
|
+
compressed(asset, encoding).then(send, () => {
|
|
218
|
+
// Compression failed: send the plain bytes rather than nothing.
|
|
219
|
+
res.removeHeader('Content-Encoding')
|
|
220
|
+
res.setHeader('ETag', `"${asset.hash}"`)
|
|
221
|
+
res.setHeader('Content-Length', String(asset.body.length))
|
|
222
|
+
if (req.method === 'HEAD') res.end(); else res.end(asset.body)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
return Object.assign(handler, { table: current })
|
|
226
|
+
}
|
|
@@ -306,14 +306,15 @@ export async function dispatchMemoriesWebOp(
|
|
|
306
306
|
return r.status === 200 ? reply(true, 'Duplicates ready', r.body) : reply(false, r.body.message || r.body.error || 'Failed')
|
|
307
307
|
}
|
|
308
308
|
case 'cues.summary': {
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
const r = await loopbackFetch('/api/live-cues/history?days=7&limit=
|
|
313
|
-
const counts = (r.status === 200 ? r.body.counts ??
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
309
|
+
// Control's CueVerdicts.summary: the cue history read is the capability check. A 200 in the v1 contract means the
|
|
310
|
+
// server keeps the cue log and verdicts, and COS Control Web rates them in Memories (Cues to rate, Rated cues), so
|
|
311
|
+
// the answer is available with the week's counts; anything else is unavailable and the entries hide.
|
|
312
|
+
const r = await loopbackFetch('/api/live-cues/history?days=7&unrated=1&limit=1', { timeoutMs: 10_000 })
|
|
313
|
+
const counts = (r.status === 200 && r.body.v === 1 ? r.body.counts ?? null : null) as Record<string, any> | null
|
|
314
|
+
if (!counts || typeof counts.unrated !== 'number' || typeof counts.rated !== 'number' || typeof counts.total !== 'number') {
|
|
315
|
+
return reply(true, 'Cues', { available: false })
|
|
316
|
+
}
|
|
317
|
+
return reply(true, 'Cues', { available: true, unrated: counts.unrated, rated: counts.rated, total: counts.total })
|
|
317
318
|
}
|
|
318
319
|
case 'copy':
|
|
319
320
|
return reply(true, 'Copied', {})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pet COS Control Web shows (Miles 11/06 23:06: "build the change pet picker ... Just use the
|
|
3
|
+
* existing 300 pack that we have on the COS control").
|
|
4
|
+
*
|
|
5
|
+
* A choice is one of:
|
|
6
|
+
* mac the pet COS Control shows (its own session-pet-states.json, or its single sprite)
|
|
7
|
+
* shipped:<id> a character COS Control ships in its app bundle (BundledCharacters/<id>, animated),
|
|
8
|
+
* or shipped:cloud-puff, the StarterPet still
|
|
9
|
+
* gallery:<id> one of the OpenPets gallery pets COS Control lists (openpets-catalog.json, about 300).
|
|
10
|
+
* As on the Mac (installOpenPetsThumb), a gallery pick is the gallery picture as a still.
|
|
11
|
+
*
|
|
12
|
+
* The choice is kept per browser. Changing the Mac's own pet stays in COS Control on the Mac: the app does
|
|
13
|
+
* not watch its pet folder, so writing it from here would leave the Mac showing the old pet.
|
|
14
|
+
* Everything here reads COS Control's files and never writes them.
|
|
15
|
+
*/
|
|
16
|
+
import crypto from 'crypto'
|
|
17
|
+
import fs from 'fs'
|
|
18
|
+
import path from 'path'
|
|
19
|
+
import { controlSupportDir, readPetSources } from './web-pet.js'
|
|
20
|
+
|
|
21
|
+
export type PetChoice =
|
|
22
|
+
| { kind: 'mac' }
|
|
23
|
+
| { kind: 'shipped'; id: string }
|
|
24
|
+
| { kind: 'gallery'; id: string }
|
|
25
|
+
|
|
26
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,80}$/
|
|
27
|
+
|
|
28
|
+
export function parsePetChoice(raw: unknown): PetChoice | null {
|
|
29
|
+
if (raw === undefined) return { kind: 'mac' }
|
|
30
|
+
// A repeated ?pet= arrives as an array: refuse it rather than guess.
|
|
31
|
+
if (typeof raw !== 'string') return null
|
|
32
|
+
const s = raw
|
|
33
|
+
if (s === '' || s === 'mac') return { kind: 'mac' }
|
|
34
|
+
const m = s.match(/^(shipped|gallery):(.+)$/)
|
|
35
|
+
if (!m || !ID.test(m[2])) return null
|
|
36
|
+
return { kind: m[1] as 'shipped' | 'gallery', id: m[2] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function petChoiceKey(c: PetChoice): string {
|
|
40
|
+
return c.kind === 'mac' ? 'mac' : `${c.kind}:${c.id}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** COS Control's app resources. Most installs live in /Applications; ~/Applications is checked too. */
|
|
44
|
+
export function controlResourcesDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
45
|
+
if (env.COS_CONTROL_RESOURCES_DIR) return env.COS_CONTROL_RESOURCES_DIR
|
|
46
|
+
for (const base of ['/Applications', path.join(env.HOME || '', 'Applications')]) {
|
|
47
|
+
const dir = path.join(base, 'COS Control.app', 'Contents', 'Resources')
|
|
48
|
+
try { if (fs.statSync(dir).isDirectory()) return dir } catch { /* next */ }
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Names and summaries word for word from COS Control (Models.swift bundledCharacters). Cloud Puff has no
|
|
54
|
+
* summary there (defaultCharacterName only), so its line is ours. Keep in step when Control adds a character. */
|
|
55
|
+
const SHIPPED_NAMES: Record<string, { name: string; summary: string }> = {
|
|
56
|
+
'jedi-miles-windu': { name: 'Jedi Miles Windu', summary: 'Ten animated states. Purple saber and escalating droid fights.' },
|
|
57
|
+
'jedi-nia-solari': { name: 'Jedi Nia Solari', summary: 'Eight-frame idle and four combat loops. Purple saber, braided silhouette, and Force counters.' },
|
|
58
|
+
'jedi-elara-vale': { name: 'Jedi Elara Vale', summary: 'Eight-frame idle and four combat loops. Green saber, auburn braid, and agile redirects.' },
|
|
59
|
+
'jedi-rowan-vale': { name: 'Jedi Rowan Vale', summary: 'Eight-frame idle and four combat loops. Blue saber, two-handed guard, and grounded strikes.' },
|
|
60
|
+
'cloud-puff': { name: 'Cloud Puff', summary: 'A simple, calm figure. The default for new installs.' },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isFile(p: string): boolean {
|
|
64
|
+
try { return fs.statSync(p).isFile() } catch { return false }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ShippedPet { id: string; name: string; summary: string; animated: boolean }
|
|
68
|
+
|
|
69
|
+
/** Characters the installed COS Control carries. Only folders with a valid state map count. */
|
|
70
|
+
export function shippedPets(resources: string | null = controlResourcesDir()): ShippedPet[] {
|
|
71
|
+
if (!resources) return []
|
|
72
|
+
const out: ShippedPet[] = []
|
|
73
|
+
let names: string[] = []
|
|
74
|
+
try { names = fs.readdirSync(path.join(resources, 'BundledCharacters')).filter((n) => ID.test(n)).sort() } catch { /* none */ }
|
|
75
|
+
for (const id of names) {
|
|
76
|
+
if (Object.keys(readPetSources(path.join(resources, 'BundledCharacters', id))).length === 0) continue
|
|
77
|
+
const label = SHIPPED_NAMES[id] ?? { name: id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), summary: '' }
|
|
78
|
+
out.push({ id, name: label.name, summary: label.summary, animated: true })
|
|
79
|
+
}
|
|
80
|
+
if (isFile(path.join(resources, 'StarterPet', 'cloud-puff.webp'))) out.push({ id: 'cloud-puff', ...SHIPPED_NAMES['cloud-puff'], animated: false })
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GalleryPet { id: string; name: string; description: string; category: string; preview: string }
|
|
85
|
+
|
|
86
|
+
/** The OpenPets gallery COS Control fetched (openpets-catalog.json). Only https previews on openpets.dev. */
|
|
87
|
+
export function galleryPets(dir: string = controlSupportDir()): GalleryPet[] {
|
|
88
|
+
const file = path.join(dir, 'openpets-catalog.json')
|
|
89
|
+
let raw: { catalog?: { pets?: unknown[] } }
|
|
90
|
+
try {
|
|
91
|
+
if (!isFile(file)) return []
|
|
92
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
93
|
+
} catch { return [] }
|
|
94
|
+
const pets = Array.isArray(raw?.catalog?.pets) ? raw.catalog!.pets! : []
|
|
95
|
+
const out: GalleryPet[] = []
|
|
96
|
+
const seen = new Set<string>()
|
|
97
|
+
for (const p of pets as Array<Record<string, unknown>>) {
|
|
98
|
+
const id = typeof p?.id === 'string' ? p.id : ''
|
|
99
|
+
const preview = typeof p?.preview === 'string' ? p.preview : ''
|
|
100
|
+
if (!ID.test(id) || seen.has(id) || !isOpenPetsUrl(preview)) continue
|
|
101
|
+
seen.add(id)
|
|
102
|
+
out.push({
|
|
103
|
+
id,
|
|
104
|
+
name: typeof p.displayName === 'string' && p.displayName ? p.displayName : id,
|
|
105
|
+
description: typeof p.description === 'string' ? p.description : '',
|
|
106
|
+
category: [p.category, p.subcategory].filter((x) => typeof x === 'string' && x).join(' '),
|
|
107
|
+
preview,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The Mac helper's rule, exactly (HelperSources/main.swift isAllowedThumbURL): https, host openpets.dev, a path
|
|
114
|
+
* ending in /thumb.webp, no user or password. The catalog is untrusted; nothing else is ever fetched. */
|
|
115
|
+
export function isOpenPetsUrl(url: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
const u = new URL(url)
|
|
118
|
+
return u.protocol === 'https:' && u.hostname === 'openpets.dev' && u.port === '' && !u.username && !u.password && u.pathname.endsWith('/thumb.webp')
|
|
119
|
+
} catch { return false }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type ResolvedPet =
|
|
123
|
+
| { kind: 'strips'; dir: string; cacheName: string }
|
|
124
|
+
| { kind: 'still'; file: string; type: string }
|
|
125
|
+
| { kind: 'gallery'; id: string; preview: string }
|
|
126
|
+
|
|
127
|
+
const STILL_TYPES: Record<string, string> = { '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg' }
|
|
128
|
+
|
|
129
|
+
/** Where the chosen pet's art lives, or null when that pet is not available on this Mac. */
|
|
130
|
+
export function resolvePet(choice: PetChoice, opts: { supportDir?: string; resources?: string | null } = {}): ResolvedPet | null {
|
|
131
|
+
const support = opts.supportDir ?? controlSupportDir()
|
|
132
|
+
const resources = opts.resources === undefined ? controlResourcesDir() : opts.resources
|
|
133
|
+
if (choice.kind === 'mac') {
|
|
134
|
+
if (Object.keys(readPetSources(support)).length > 0) return { kind: 'strips', dir: support, cacheName: '' }
|
|
135
|
+
// A gallery pick or a single custom image on the Mac: COS Control's plain sprite (PetSpriteStore.fileStem).
|
|
136
|
+
for (const ext of Object.keys(STILL_TYPES)) {
|
|
137
|
+
const file = path.join(support, `session-pet-sprite${ext}`)
|
|
138
|
+
if (isFile(file)) return { kind: 'still', file, type: STILL_TYPES[ext] }
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
if (choice.kind === 'shipped') {
|
|
143
|
+
if (!resources) return null
|
|
144
|
+
if (choice.id === 'cloud-puff') {
|
|
145
|
+
const file = path.join(resources, 'StarterPet', 'cloud-puff.webp')
|
|
146
|
+
return isFile(file) ? { kind: 'still', file, type: 'image/webp' } : null
|
|
147
|
+
}
|
|
148
|
+
const dir = path.join(resources, 'BundledCharacters', choice.id)
|
|
149
|
+
return Object.keys(readPetSources(dir)).length > 0 ? { kind: 'strips', dir, cacheName: `shipped-${choice.id}` } : null
|
|
150
|
+
}
|
|
151
|
+
const pet = galleryPets(support).find((p) => p.id === choice.id)
|
|
152
|
+
return pet ? { kind: 'gallery', id: pet.id, preview: pet.preview } : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The Mac helper's limits (openPetsThumbMaxBytes, timeout 6): 512 KB, enforced while reading, and 6 seconds. */
|
|
156
|
+
export const GALLERY_MAX_BYTES = 512 * 1024
|
|
157
|
+
export const GALLERY_TIMEOUT_MS = 6_000
|
|
158
|
+
/** COS Control keeps the thumbnails it already fetched here (main.swift openPetsThumbsDir, <id>.webp). */
|
|
159
|
+
export const MAC_THUMBS_DIR = 'openpets-thumbs'
|
|
160
|
+
|
|
161
|
+
function isWebP(b: Buffer): boolean {
|
|
162
|
+
return b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read at most max bytes from a response body, cancelling the stream past the limit. */
|
|
166
|
+
async function readCapped(res: Response, max: number): Promise<Buffer> {
|
|
167
|
+
const reader = res.body?.getReader()
|
|
168
|
+
if (!reader) return Buffer.from(await res.arrayBuffer())
|
|
169
|
+
const parts: Buffer[] = []
|
|
170
|
+
let total = 0
|
|
171
|
+
for (;;) {
|
|
172
|
+
const { done, value } = await reader.read()
|
|
173
|
+
if (done) break
|
|
174
|
+
total += value.byteLength
|
|
175
|
+
if (total > max) { await reader.cancel().catch(() => {}); throw new Error('gallery_too_large') }
|
|
176
|
+
parts.push(Buffer.from(value))
|
|
177
|
+
}
|
|
178
|
+
return Buffer.concat(parts)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type PetFetcher = (url: string, init: { signal: AbortSignal; redirect: 'error' }) => Promise<Response>
|
|
182
|
+
const galleryInflight = new Map<string, Promise<{ file: string; type: string }>>()
|
|
183
|
+
/** A picture that just failed is not asked for again for this long, so a broken gallery is not hammered. */
|
|
184
|
+
export const GALLERY_RETRY_MS = 10 * 60_000
|
|
185
|
+
const galleryFailedAt = new Map<string, number>()
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The gallery picture for one pet, downloaded once from openpets.dev (as COS Control does) and kept under
|
|
189
|
+
* the data folder. Refuses anything the Mac helper would (not WebP, over 512 KB, redirected, slower than 6 s).
|
|
190
|
+
*/
|
|
191
|
+
export function galleryThumb(pet: { id: string; preview: string }, cacheDir: string, fetcher: PetFetcher = fetch as unknown as PetFetcher, macSupportDir: string | null = null): Promise<{ file: string; type: string }> {
|
|
192
|
+
if (!ID.test(pet.id) || !isOpenPetsUrl(pet.preview)) return Promise.reject(new Error('gallery_pet_invalid'))
|
|
193
|
+
// The thumbnail COS Control already fetched, when it is a real WebP: no second download (read only).
|
|
194
|
+
if (macSupportDir) {
|
|
195
|
+
const mac = path.join(macSupportDir, MAC_THUMBS_DIR, `${pet.id}.webp`)
|
|
196
|
+
try {
|
|
197
|
+
if (fs.statSync(mac).isFile() && fs.statSync(mac).size <= GALLERY_MAX_BYTES && isWebP(fs.readFileSync(mac))) return Promise.resolve({ file: mac, type: 'image/webp' })
|
|
198
|
+
} catch { /* not there: fetch below */ }
|
|
199
|
+
}
|
|
200
|
+
const tag = crypto.createHash('sha256').update(pet.preview).digest('hex').slice(0, 12)
|
|
201
|
+
const base = path.join(cacheDir, `${pet.id}-${tag}`)
|
|
202
|
+
if (isFile(base + '.webp')) return Promise.resolve({ file: base + '.webp', type: 'image/webp' })
|
|
203
|
+
const failed = galleryFailedAt.get(base)
|
|
204
|
+
if (failed && Date.now() - failed < GALLERY_RETRY_MS) return Promise.reject(new Error('gallery_recently_failed'))
|
|
205
|
+
let job = galleryInflight.get(base)
|
|
206
|
+
if (!job) {
|
|
207
|
+
job = (async () => {
|
|
208
|
+
const res = await fetcher(pet.preview, { signal: AbortSignal.timeout(GALLERY_TIMEOUT_MS), redirect: 'error' })
|
|
209
|
+
if (res.status !== 200) throw new Error(`gallery_http_${res.status}`)
|
|
210
|
+
const declared = Number(res.headers.get('content-length') || 0)
|
|
211
|
+
if (declared > GALLERY_MAX_BYTES) throw new Error('gallery_too_large')
|
|
212
|
+
const body = await readCapped(res, GALLERY_MAX_BYTES)
|
|
213
|
+
// WebP only, as the Mac (isOpenPetsWebP).
|
|
214
|
+
if (!isWebP(body)) throw new Error('gallery_not_image')
|
|
215
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
216
|
+
const tmp = `${base}.${process.pid}.${Date.now()}.tmp`
|
|
217
|
+
fs.writeFileSync(tmp, body)
|
|
218
|
+
fs.renameSync(tmp, base + '.webp')
|
|
219
|
+
return { file: base + '.webp', type: 'image/webp' }
|
|
220
|
+
})().then((ok) => { galleryFailedAt.delete(base); return ok }, (err) => { galleryFailedAt.set(base, Date.now()); throw err })
|
|
221
|
+
.finally(() => galleryInflight.delete(base))
|
|
222
|
+
galleryInflight.set(base, job)
|
|
223
|
+
}
|
|
224
|
+
return job
|
|
225
|
+
}
|