castle-web-cli 0.4.169 → 0.4.171
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/agent-prompts.d.ts +1 -0
- package/dist/agent-prompts.js +3 -0
- package/dist/agent.js +70 -24
- package/dist/atomicFile.d.ts +29 -0
- package/dist/atomicFile.js +120 -0
- package/dist/castle-host/host.js +108 -0
- package/dist/get-deck.js +2 -11
- package/dist/headlessCover.d.ts +4 -0
- package/dist/headlessCover.js +7 -5
- package/dist/ide.d.ts +10 -0
- package/dist/ide.js +19 -11
- package/dist/index.js +10 -6
- package/dist/init.d.ts +1 -1
- package/dist/init.js +11 -1
- package/dist/remix.js +2 -14
- package/dist/save-deck.d.ts +3 -0
- package/dist/save-deck.js +40 -73
- package/dist/saveCover.d.ts +18 -0
- package/dist/saveCover.js +61 -0
- package/dist/serve.js +18 -5
- package/dist/serveSecurity.d.ts +11 -1
- package/dist/serveSecurity.js +38 -4
- package/dist/shell/assets/index-6odVZQSZ.css +1 -0
- package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
- package/dist/shell/index.html +3 -3
- package/dist/versionStore.js +13 -4
- package/kits/base/CLAUDE.md +9 -0
- package/kits/base/castle.json +16 -11
- package/kits/base/sdk/README.md +83 -2
- package/kits/base/sdk/commands.d.ts +37 -1
- package/kits/base/sdk/commands.js +9 -0
- package/kits/base/sdk/lifecycle.d.ts +20 -0
- package/kits/base/sdk/lifecycle.js +7 -1
- package/kits/base/sdk/resumeState.d.ts +2 -0
- package/kits/base/sdk/resumeState.js +84 -0
- package/kits/base/sdk/runtime.js +65 -0
- package/kits/base/sdk/transport.d.ts +3 -0
- package/kits/base/sdk/transport.js +39 -5
- package/kits/base/sdk/unloadHandshake.d.ts +6 -0
- package/kits/base/sdk/unloadHandshake.js +47 -0
- package/kits/base/sdk/user.d.ts +4 -0
- package/kits/base/sdk/user.js +36 -1
- package/kits/multiplayer-2d/CLAUDE.md +20 -9
- package/kits/multiplayer-2d/castle.json +4 -4
- package/kits/multiplayer-2d/code/server/players.js +27 -6
- package/kits/multiplayer-2d/code/server/world.js +24 -1
- package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
- package/kits/multiplayer-2d/package-lock.json +2 -4
- package/kits/multiplayer-2d/package.json +1 -0
- package/kits/multiplayer-3d/CLAUDE.md +28 -9
- package/kits/multiplayer-3d/castle.json +5 -5
- package/kits/multiplayer-3d/code/server/players.js +54 -5
- package/kits/multiplayer-3d/code/server/world.js +45 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
- package/kits/multiplayer-3d/package-lock.json +2 -4
- package/kits/multiplayer-3d/package.json +1 -0
- package/kits/physics-2d/CLAUDE.md +75 -43
- package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
- package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
- package/kits/physics-2d/castle.json +6 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
- package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
- package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
- package/kits/physics-2d/editors/overlayCanvas.js +77 -0
- package/kits/physics-2d/editors/pathOverlay.js +5 -13
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
- package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
- package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
- package/kits/physics-2d/editors/styleEditor.module.css +23 -4
- package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
- package/kits/physics-2d/engine/SceneUI.jsx +6 -12
- package/kits/physics-2d/engine/icons.js +56 -0
- package/kits/physics-2d/engine/panelSorting.jsx +7 -14
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
- package/kits/physics-2d/engine/physics/controls.js +76 -12
- package/kits/physics-2d/engine/scene.js +47 -9
- package/kits/physics-2d/engine/ui.jsx +13 -125
- package/kits/physics-2d/engine/ui.module.css +53 -28
- package/kits/physics-2d/package-lock.json +0 -29
- package/kits/physics-2d/package.json +3 -1
- package/kits/physics-2d/pnpm-lock.yaml +1 -17
- package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
- package/kits/physics-3d/castle.json +5 -5
- package/kits/physics-3d/package-lock.json +0 -29
- package/kits/physics-3d/package.json +0 -1
- package/kits/physics-3d/pnpm-lock.yaml +0 -17
- package/kits/real-time/CLAUDE.md +23 -4
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +9 -4
- package/kits/real-time/code/client/joinOverlay.js +46 -0
- package/kits/real-time/code/client/messages.js +4 -0
- package/kits/real-time/code/server/gameHooks.js +4 -0
- package/kits/real-time/code/server/persist.js +105 -0
- package/kits/real-time/code/server/session.js +289 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +76 -20
- package/kits/turn-based/castle.json +3 -3
- package/kits/turn-based/code/server/index.js +16 -5
- package/kits/turn-based/package-lock.json +0 -27
- package/kits/turn-based/package.json +2 -2
- package/kits/turn-based/room.js +162 -13
- package/kits/turn-based/testing.js +276 -92
- package/package.json +5 -5
- package/dist/shell/assets/index-CVHj503j.css +0 -1
- package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
package/dist/shell/index.html
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>Castle
|
|
6
|
+
<title>Untitled deck - Castle</title>
|
|
7
7
|
<!-- The same favicon castle.xyz uses (copied into `public/`, so vite emits
|
|
8
8
|
them next to the bundle and the published package ships them). Vite
|
|
9
9
|
rewrites these onto the build `base`, i.e. `/__castle/ide/...`. -->
|
|
10
10
|
<link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
|
|
11
11
|
<link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
|
|
12
12
|
<link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
|
|
13
|
-
<script type="module" crossorigin src="/__castle/ide/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/__castle/ide/assets/index-Ws0WrCbi.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-6odVZQSZ.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/dist/versionStore.js
CHANGED
|
@@ -25,6 +25,7 @@ import * as crypto from 'crypto';
|
|
|
25
25
|
import * as fs from 'fs';
|
|
26
26
|
import * as path from 'path';
|
|
27
27
|
import * as zlib from 'zlib';
|
|
28
|
+
import { atomicWriteFileSync } from './atomicFile.js';
|
|
28
29
|
import { toPosixPath } from './localPaths.js';
|
|
29
30
|
// Not tracked: dependencies, build output, our own store, git, and imported
|
|
30
31
|
// decks (whose files belong to the deck that published them).
|
|
@@ -52,7 +53,7 @@ export function readHead(projectDir) {
|
|
|
52
53
|
}
|
|
53
54
|
export function writeHead(projectDir, id) {
|
|
54
55
|
fs.mkdirSync(versionsRoot(projectDir), { recursive: true });
|
|
55
|
-
|
|
56
|
+
atomicWriteFileSync(headPath(projectDir), id + '\n');
|
|
56
57
|
}
|
|
57
58
|
// A version's own file. Its extension is part of the format, not an internal
|
|
58
59
|
// detail: a deck can route `.version` to an editor of its own.
|
|
@@ -77,8 +78,7 @@ export function readVersion(projectDir, id) {
|
|
|
77
78
|
// and a future gc can delete.
|
|
78
79
|
const READ_ONLY = 0o444;
|
|
79
80
|
function writeOnce(file, content) {
|
|
80
|
-
|
|
81
|
-
fs.chmodSync(file, READ_ONLY);
|
|
81
|
+
atomicWriteFileSync(file, content, { mode: READ_ONLY, exclusive: true });
|
|
82
82
|
}
|
|
83
83
|
export function writeVersion(projectDir, version) {
|
|
84
84
|
fs.mkdirSync(versionsDir(projectDir), { recursive: true });
|
|
@@ -94,7 +94,16 @@ export function putBlob(projectDir, content) {
|
|
|
94
94
|
const blob = path.join(dataDir(projectDir), hash);
|
|
95
95
|
if (!fs.existsSync(blob)) {
|
|
96
96
|
fs.mkdirSync(dataDir(projectDir), { recursive: true });
|
|
97
|
-
|
|
97
|
+
try {
|
|
98
|
+
writeOnce(blob, zlib.gzipSync(content));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
// Two saves of identical content may race. The losing no-replace link is
|
|
102
|
+
// fine only if the winner installed valid bytes for this content hash.
|
|
103
|
+
if (error.code !== 'EEXIST')
|
|
104
|
+
throw error;
|
|
105
|
+
getBlob(projectDir, hash);
|
|
106
|
+
}
|
|
98
107
|
}
|
|
99
108
|
return hash;
|
|
100
109
|
}
|
package/kits/base/CLAUDE.md
CHANGED
|
@@ -40,6 +40,15 @@ The three calls almost every deck makes:
|
|
|
40
40
|
the deck immediately instead of waiting out a timeout. A framework kit already
|
|
41
41
|
fires this; a deck rendering for itself has to.
|
|
42
42
|
|
|
43
|
+
Decks with transient, serializable state should also pair
|
|
44
|
+
`Lifecycle.provideState(() => state)` with an `await Lifecycle.restoreState()`
|
|
45
|
+
before their first paint, so a deck that is unloaded mid-play and mounted again
|
|
46
|
+
comes back where it was instead of restarting. The host may call the provider
|
|
47
|
+
at any moment, repeatedly, while the deck runs, so it has to be synchronous and
|
|
48
|
+
side-effect-free — a read of your state, nothing more. That is pause, not save:
|
|
49
|
+
the snapshot is held in memory only, and `Store` is still where anything
|
|
50
|
+
durable goes.
|
|
51
|
+
|
|
43
52
|
## Files
|
|
44
53
|
|
|
45
54
|
```text
|
package/kits/base/castle.json
CHANGED
|
@@ -3,64 +3,69 @@
|
|
|
3
3
|
"hiddenPaths": [],
|
|
4
4
|
"visiblePaths": [],
|
|
5
5
|
"fileTypes": [
|
|
6
|
+
{
|
|
7
|
+
"ext": ".md",
|
|
8
|
+
"label": "Markdown",
|
|
9
|
+
"icon": "file-md"
|
|
10
|
+
},
|
|
6
11
|
{
|
|
7
12
|
"ext": ".png",
|
|
8
13
|
"label": "Image",
|
|
9
|
-
"icon": "image",
|
|
14
|
+
"icon": "file-image",
|
|
10
15
|
"editor": "editors/ImageViewer.js"
|
|
11
16
|
},
|
|
12
17
|
{
|
|
13
18
|
"ext": ".jpg",
|
|
14
19
|
"label": "Image",
|
|
15
|
-
"icon": "image",
|
|
20
|
+
"icon": "file-image",
|
|
16
21
|
"editor": "editors/ImageViewer.js"
|
|
17
22
|
},
|
|
18
23
|
{
|
|
19
24
|
"ext": ".jpeg",
|
|
20
25
|
"label": "Image",
|
|
21
|
-
"icon": "image",
|
|
26
|
+
"icon": "file-image",
|
|
22
27
|
"editor": "editors/ImageViewer.js"
|
|
23
28
|
},
|
|
24
29
|
{
|
|
25
30
|
"ext": ".gif",
|
|
26
31
|
"label": "Image",
|
|
27
|
-
"icon": "image",
|
|
32
|
+
"icon": "file-image",
|
|
28
33
|
"editor": "editors/ImageViewer.js"
|
|
29
34
|
},
|
|
30
35
|
{
|
|
31
36
|
"ext": ".webp",
|
|
32
37
|
"label": "Image",
|
|
33
|
-
"icon": "image",
|
|
38
|
+
"icon": "file-image",
|
|
34
39
|
"editor": "editors/ImageViewer.js"
|
|
35
40
|
},
|
|
36
41
|
{
|
|
37
42
|
"ext": ".svg",
|
|
38
43
|
"label": "Image",
|
|
39
|
-
"icon": "image",
|
|
44
|
+
"icon": "file-image",
|
|
40
45
|
"editor": "editors/ImageViewer.js"
|
|
41
46
|
},
|
|
42
47
|
{
|
|
43
48
|
"ext": ".mp3",
|
|
44
49
|
"label": "Audio",
|
|
45
|
-
"icon": "
|
|
50
|
+
"icon": "file-audio",
|
|
46
51
|
"editor": "editors/MediaPlayer.js"
|
|
47
52
|
},
|
|
48
53
|
{
|
|
49
54
|
"ext": ".wav",
|
|
50
55
|
"label": "Audio",
|
|
51
|
-
"icon": "
|
|
56
|
+
"icon": "file-audio",
|
|
52
57
|
"editor": "editors/MediaPlayer.js"
|
|
53
58
|
},
|
|
54
59
|
{
|
|
55
60
|
"ext": ".m4a",
|
|
56
61
|
"label": "Audio",
|
|
57
|
-
"icon": "
|
|
62
|
+
"icon": "file-audio",
|
|
58
63
|
"editor": "editors/MediaPlayer.js"
|
|
59
64
|
},
|
|
60
65
|
{
|
|
61
66
|
"ext": ".mp4",
|
|
62
67
|
"label": "Video",
|
|
63
|
-
"icon": "
|
|
68
|
+
"icon": "file-video",
|
|
64
69
|
"editor": "editors/MediaPlayer.js"
|
|
65
70
|
}
|
|
66
71
|
]
|
|
@@ -69,7 +74,7 @@
|
|
|
69
74
|
"title": "base",
|
|
70
75
|
"deckId": "yRcmH4_aYllE",
|
|
71
76
|
"cardId": "zkFdingmIm3m",
|
|
72
|
-
"publishedVersion": "2026-09-
|
|
77
|
+
"publishedVersion": "2026-09-10T20:10:25.416Z",
|
|
73
78
|
"provides": [
|
|
74
79
|
"castle-web-sdk"
|
|
75
80
|
]
|
package/kits/base/sdk/README.md
CHANGED
|
@@ -416,7 +416,10 @@ const dailyPuzzle = (date.daysSinceCastleEpoch % 30) + 1;
|
|
|
416
416
|
Returns the signed-in player. Throws `CastleError`
|
|
417
417
|
(`LOGIN_REQUIRED`) when nobody is signed in.
|
|
418
418
|
|
|
419
|
-
|
|
419
|
+
A `CastleUser` has `userId`, `username`, `isAnonymous`, `isActive`, `photoUrl`
|
|
420
|
+
(their 256px avatar), `frameUrl` (the 96px frame drawn around it) and `color`
|
|
421
|
+
(the hex their username is shown in); the last three are `null` when unset. The
|
|
422
|
+
urls are `data:` urls, so they go straight into an `<img>` or a texture.
|
|
420
423
|
Anonymous accounts are real logins with generated `anonymous-user-...` names;
|
|
421
424
|
check `isAnonymous` before accepting writes into anything other players see,
|
|
422
425
|
such as a shared gallery.
|
|
@@ -426,6 +429,19 @@ const me = await User.getCurrent();
|
|
|
426
429
|
greet(me.username);
|
|
427
430
|
```
|
|
428
431
|
|
|
432
|
+
### `User.get(userId): Promise<CastleUser | null>`
|
|
433
|
+
|
|
434
|
+
Another player as anyone may see them, or `null` when no such user exists.
|
|
435
|
+
Results are cached per id for the life of the page. The ids come from the
|
|
436
|
+
session's player list or from `Store.user(id)` rows.
|
|
437
|
+
|
|
438
|
+
```js
|
|
439
|
+
const them = await User.get(player.userId);
|
|
440
|
+
if (them?.photoUrl) {
|
|
441
|
+
avatar.src = them.photoUrl;
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
429
445
|
## Pass
|
|
430
446
|
|
|
431
447
|
A pass is something a creator sells to players for Castle bricks (the
|
|
@@ -547,7 +563,8 @@ be coalesced, so it's safe to call on frequent events.
|
|
|
547
563
|
|
|
548
564
|
`Lifecycle` tells the host when the deck has painted its first frame, so
|
|
549
565
|
the Castle feed can reveal it right away instead of waiting out a fixed
|
|
550
|
-
delay
|
|
566
|
+
delay — and carries state across an unload, so a deck the player leaves
|
|
567
|
+
mid-play and comes back to picks up where it was.
|
|
551
568
|
|
|
552
569
|
### `Lifecycle.ready()`
|
|
553
570
|
|
|
@@ -567,6 +584,70 @@ createRoot(root).render(<App />);
|
|
|
567
584
|
requestAnimationFrame(() => requestAnimationFrame(() => Lifecycle.ready()));
|
|
568
585
|
```
|
|
569
586
|
|
|
587
|
+
### Resuming across an unload
|
|
588
|
+
|
|
589
|
+
The host can unload a deck's page mid-play and mount it again later, and
|
|
590
|
+
by default the deck starts over when it does. Resume state closes that:
|
|
591
|
+
the host asks the deck for a snapshot of its current state, keeps it in
|
|
592
|
+
memory, and offers it back the next time the deck mounts.
|
|
593
|
+
|
|
594
|
+
It is **pause, not save**. The host holds the snapshot briefly and in
|
|
595
|
+
memory only: it does not outlive the app, and the player restarting the
|
|
596
|
+
deck clears it. Anything that should outlive the app goes in
|
|
597
|
+
[`Store`](#store) or [`Storage`](#storage).
|
|
598
|
+
|
|
599
|
+
Reach for resume state when:
|
|
600
|
+
|
|
601
|
+
- the state must NOT persist by design — a daily-challenge attempt in
|
|
602
|
+
progress, a roguelike run, an anti-farm timer;
|
|
603
|
+
- the state is too big or too churny for `Store`;
|
|
604
|
+
- the state only needs to survive the player leaving and coming back.
|
|
605
|
+
|
|
606
|
+
Don't substitute `localStorage`: it outlives the app and the host cannot
|
|
607
|
+
clear it, so the deck could never be restarted cleanly.
|
|
608
|
+
|
|
609
|
+
Not every host keeps resume state. Where one doesn't, `restoreState()`
|
|
610
|
+
resolves `null` and the provider is never called, so a deck written for
|
|
611
|
+
it works unchanged.
|
|
612
|
+
|
|
613
|
+
### `Lifecycle.provideState(provide): () => void`
|
|
614
|
+
|
|
615
|
+
Register a function returning the deck's current state, for the host to
|
|
616
|
+
hand back if it unloads and remounts this deck. Returns a function that
|
|
617
|
+
unregisters it; registering again replaces the provider.
|
|
618
|
+
|
|
619
|
+
The host calls it whenever it might need a snapshot: at arbitrary
|
|
620
|
+
moments while the deck plays on, and often. A call does not mean the deck
|
|
621
|
+
is about to unload, so treat it as a read of your state and nothing more
|
|
622
|
+
— it must be **synchronous, cheap and side-effect-free**. Return
|
|
623
|
+
`undefined` to decline; the deck then starts fresh the next time it
|
|
624
|
+
mounts.
|
|
625
|
+
|
|
626
|
+
Whatever you return is `JSON.stringify`'d, so keep it small — tens of
|
|
627
|
+
kilobytes, not megabytes. A value over 1 MB is dropped with a warning.
|
|
628
|
+
|
|
629
|
+
```js
|
|
630
|
+
import { Lifecycle } from "castle-web-sdk";
|
|
631
|
+
|
|
632
|
+
Lifecycle.provideState(() => ({ level, score, elapsedMs: clock.elapsed() }));
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### `Lifecycle.restoreState<T>(): Promise<T | null>`
|
|
636
|
+
|
|
637
|
+
The state this deck last provided, or `null` — nothing kept, the player
|
|
638
|
+
restarted the deck, or the host doesn't keep resume state. It never
|
|
639
|
+
throws for any of those, so there is nothing to catch. Reading doesn't
|
|
640
|
+
consume it.
|
|
641
|
+
|
|
642
|
+
Call it before your first paint and before `ready()`, so a deck that is
|
|
643
|
+
resuming doesn't flash its fresh state first:
|
|
644
|
+
|
|
645
|
+
```js
|
|
646
|
+
const saved = await Lifecycle.restoreState();
|
|
647
|
+
startGame(saved ?? newGame());
|
|
648
|
+
Lifecycle.ready();
|
|
649
|
+
```
|
|
650
|
+
|
|
570
651
|
## Setup
|
|
571
652
|
|
|
572
653
|
Startup, editor-mode check, and a file-write call for editor UI.
|
|
@@ -61,6 +61,12 @@ export type HapticsStatus = "triggered" | "unavailable";
|
|
|
61
61
|
export interface HapticsResult {
|
|
62
62
|
status: HapticsStatus;
|
|
63
63
|
}
|
|
64
|
+
export type LifecycleRestoreStateStatus = "restored" | "none" | "unavailable";
|
|
65
|
+
export interface LifecycleRestoreStateResult {
|
|
66
|
+
status: LifecycleRestoreStateStatus;
|
|
67
|
+
/** The deck-serialized JSON string it saved. Present iff status is "restored". */
|
|
68
|
+
state?: string;
|
|
69
|
+
}
|
|
64
70
|
export type MultiplayerMode = "named" | "party" | "public";
|
|
65
71
|
export interface MultiplayerGetSessionParams {
|
|
66
72
|
mode: MultiplayerMode;
|
|
@@ -114,6 +120,9 @@ export interface CommandParams {
|
|
|
114
120
|
score?: number | null;
|
|
115
121
|
};
|
|
116
122
|
"user.getCurrent": Record<string, never>;
|
|
123
|
+
"user.get": {
|
|
124
|
+
userId: string;
|
|
125
|
+
};
|
|
117
126
|
"time.getServerTime": Record<string, never>;
|
|
118
127
|
"pass.has": {
|
|
119
128
|
passId: string;
|
|
@@ -178,6 +187,7 @@ export interface CommandParams {
|
|
|
178
187
|
board: string;
|
|
179
188
|
subject?: string | null;
|
|
180
189
|
};
|
|
190
|
+
"lifecycle.restoreState": Record<string, never>;
|
|
181
191
|
}
|
|
182
192
|
export interface CommandResult {
|
|
183
193
|
"deckStorage.load": {
|
|
@@ -207,6 +217,9 @@ export interface CommandResult {
|
|
|
207
217
|
isAnonymous?: boolean;
|
|
208
218
|
} | null;
|
|
209
219
|
};
|
|
220
|
+
"user.get": {
|
|
221
|
+
user: UserPayload | null;
|
|
222
|
+
};
|
|
210
223
|
"time.getServerTime": {
|
|
211
224
|
timestamp: number;
|
|
212
225
|
timezoneOffset: number;
|
|
@@ -245,6 +258,15 @@ export interface CommandResult {
|
|
|
245
258
|
"cauldronStorage.boardGet": {
|
|
246
259
|
entry: StoreBoardEntry | null;
|
|
247
260
|
};
|
|
261
|
+
"lifecycle.restoreState": LifecycleRestoreStateResult;
|
|
262
|
+
}
|
|
263
|
+
export interface UserPayload {
|
|
264
|
+
userId: string;
|
|
265
|
+
username: string;
|
|
266
|
+
isAnonymous: boolean;
|
|
267
|
+
photoUrl: string | null;
|
|
268
|
+
frameUrl: string | null;
|
|
269
|
+
color: string | null;
|
|
248
270
|
}
|
|
249
271
|
export type CommandName = keyof CommandParams;
|
|
250
272
|
export interface SerializedCommandError {
|
|
@@ -267,8 +289,22 @@ export interface CommandResponseEnvelope {
|
|
|
267
289
|
error?: SerializedCommandError;
|
|
268
290
|
}
|
|
269
291
|
export declare function isResponseEnvelope(value: unknown): value is CommandResponseEnvelope;
|
|
270
|
-
export type LifecycleEvent = "ready";
|
|
292
|
+
export type LifecycleEvent = "ready" | "unloadInterest";
|
|
271
293
|
export interface LifecycleEnvelope {
|
|
272
294
|
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
|
273
295
|
lifecycle: LifecycleEvent;
|
|
274
296
|
}
|
|
297
|
+
export interface UnloadRequestEnvelope {
|
|
298
|
+
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
|
299
|
+
unload: {
|
|
300
|
+
unloadId: string;
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
export declare function unloadRequestId(value: unknown): string | null;
|
|
304
|
+
export interface UnloadDoneEnvelope {
|
|
305
|
+
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
|
306
|
+
unloadDone: {
|
|
307
|
+
unloadId: string;
|
|
308
|
+
state?: string;
|
|
309
|
+
};
|
|
310
|
+
}
|
|
@@ -14,3 +14,12 @@ export function isResponseEnvelope(value) {
|
|
|
14
14
|
typeof record.requestId === "string" &&
|
|
15
15
|
typeof record.ok === "boolean");
|
|
16
16
|
}
|
|
17
|
+
export function unloadRequestId(value) {
|
|
18
|
+
if (typeof value !== "object" || value === null)
|
|
19
|
+
return null;
|
|
20
|
+
const record = value;
|
|
21
|
+
if (record.castleSdk !== CASTLE_SDK_PROTOCOL)
|
|
22
|
+
return null;
|
|
23
|
+
const unload = record.unload;
|
|
24
|
+
return typeof unload?.unloadId === "string" ? unload.unloadId : null;
|
|
25
|
+
}
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
export interface CastleLifecycleApi {
|
|
2
2
|
ready(): void;
|
|
3
|
+
/**
|
|
4
|
+
* Register a function returning the deck's current state, for the host to hand
|
|
5
|
+
* back if it unloads and remounts this deck. Returns a function that
|
|
6
|
+
* unregisters it; registering again replaces the provider.
|
|
7
|
+
*
|
|
8
|
+
* The host may call it at any moment, repeatedly, while the deck plays on; a
|
|
9
|
+
* call does not mean the deck is about to unload. It must be synchronous,
|
|
10
|
+
* cheap and side-effect-free. Return `undefined` to decline; the deck then
|
|
11
|
+
* starts fresh the next time it mounts.
|
|
12
|
+
*/
|
|
13
|
+
provideState(provide: () => unknown): () => void;
|
|
14
|
+
/**
|
|
15
|
+
* The state this deck last provided, or `null` — nothing kept, the player
|
|
16
|
+
* restarted the deck, or the host doesn't keep resume state. Never throws for
|
|
17
|
+
* any of those.
|
|
18
|
+
*
|
|
19
|
+
* Call it before your first paint (and before `ready()`), so a deck that is
|
|
20
|
+
* resuming doesn't flash its fresh state first.
|
|
21
|
+
*/
|
|
22
|
+
restoreState<T = unknown>(): Promise<T | null>;
|
|
3
23
|
}
|
|
4
24
|
export declare const Lifecycle: CastleLifecycleApi;
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// Deck-side lifecycle signals. The feed reveals the deck when it reports `ready`
|
|
2
|
-
// (its first presentable frame) instead of waiting out a fixed timer
|
|
2
|
+
// (its first presentable frame) instead of waiting out a fixed timer, and asks
|
|
3
|
+
// it for state to resume from when it may be about to unload it.
|
|
4
|
+
import { provideState, restoreState } from "./resumeState";
|
|
3
5
|
import { hostNotify } from "./transport";
|
|
4
6
|
let readySent = false;
|
|
7
|
+
const FIRST_FRAME_READY_MARKER = "[castle-lifecycle] first-frame-ready";
|
|
5
8
|
function ready() {
|
|
6
9
|
if (readySent)
|
|
7
10
|
return;
|
|
8
11
|
readySent = true;
|
|
12
|
+
console.log(FIRST_FRAME_READY_MARKER);
|
|
9
13
|
hostNotify("ready");
|
|
10
14
|
}
|
|
11
15
|
export const Lifecycle = {
|
|
12
16
|
ready,
|
|
17
|
+
provideState,
|
|
18
|
+
restoreState,
|
|
13
19
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Resume state: the deck's answer to "you may be about to be unloaded, hand me
|
|
2
|
+
// something and I'll give it back when you mount again".
|
|
3
|
+
//
|
|
4
|
+
// This is PAUSE, not save. The host holds it in memory for a short while and
|
|
5
|
+
// loses it when the app dies; anything that should survive closing the app
|
|
6
|
+
// belongs in Storage / Store.
|
|
7
|
+
import { getCommandChannel, hostRequest } from "./transport";
|
|
8
|
+
import { notifyUnloadInterest, onHostUnload } from "./unloadHandshake";
|
|
9
|
+
// Measured in characters, not encoded bytes: this runs on every drag start, and
|
|
10
|
+
// encoding a megabyte to count it would cost more than the guard saves. Under-
|
|
11
|
+
// counts multi-byte text, which the host's own byte cap catches.
|
|
12
|
+
const WARN_STATE_CHARS = 256 * 1024;
|
|
13
|
+
const MAX_STATE_CHARS = 1024 * 1024;
|
|
14
|
+
let provider = null;
|
|
15
|
+
let subscribed = false;
|
|
16
|
+
export function provideState(provide) {
|
|
17
|
+
provider = provide;
|
|
18
|
+
if (!subscribed) {
|
|
19
|
+
subscribed = true;
|
|
20
|
+
onHostUnload(resumeStateSubscriber);
|
|
21
|
+
}
|
|
22
|
+
notifyUnloadInterest();
|
|
23
|
+
return () => {
|
|
24
|
+
if (provider === provide)
|
|
25
|
+
provider = null;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export async function restoreState() {
|
|
29
|
+
// The dev server is not a host that holds state, and the command would ride
|
|
30
|
+
// the local websocket to a CLI that doesn't know it — a 15 s hang at boot.
|
|
31
|
+
if (getCommandChannel() === "local")
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
const result = await hostRequest("lifecycle.restoreState", {});
|
|
35
|
+
if (result.status !== "restored" || typeof result.state !== "string") {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return JSON.parse(result.state);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Every failure reads the same to a deck: there is nothing to resume from.
|
|
42
|
+
// An old host answers UNKNOWN_COMMAND, an absent one CASTLE_HOST_UNAVAILABLE.
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function resumeStateSubscriber(ctx) {
|
|
47
|
+
const provide = provider;
|
|
48
|
+
if (!provide)
|
|
49
|
+
return;
|
|
50
|
+
let value;
|
|
51
|
+
try {
|
|
52
|
+
value = provide();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.warn("Castle Lifecycle.provideState threw; nothing kept.", error);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (value === undefined || value === null)
|
|
59
|
+
return;
|
|
60
|
+
if (typeof value.then === "function") {
|
|
61
|
+
console.warn("Castle Lifecycle.provideState returned a Promise. It must be synchronous. Nothing kept.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let json;
|
|
65
|
+
try {
|
|
66
|
+
json = JSON.stringify(value);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
console.warn("Castle Lifecycle.provideState value is not JSON-serializable.", error);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (json === undefined)
|
|
73
|
+
return;
|
|
74
|
+
if (json.length > MAX_STATE_CHARS) {
|
|
75
|
+
console.warn(`Castle Lifecycle.provideState value is ${json.length} chars, over the` +
|
|
76
|
+
` ${MAX_STATE_CHARS} limit. Nothing kept.`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (json.length > WARN_STATE_CHARS) {
|
|
80
|
+
console.warn(`Castle Lifecycle.provideState value is ${json.length} chars. It is` +
|
|
81
|
+
` serialized every time the host asks; keep it small.`);
|
|
82
|
+
}
|
|
83
|
+
ctx.state = json;
|
|
84
|
+
}
|
package/kits/base/sdk/runtime.js
CHANGED
|
@@ -28,6 +28,7 @@ export function setup() {
|
|
|
28
28
|
initLocalWake();
|
|
29
29
|
initHostCapture();
|
|
30
30
|
initPlaySelection();
|
|
31
|
+
initEditSelection();
|
|
31
32
|
initPlayCard();
|
|
32
33
|
logPanelLoad();
|
|
33
34
|
}
|
|
@@ -164,6 +165,70 @@ function initPlaySelection() {
|
|
|
164
165
|
`;
|
|
165
166
|
document.head.appendChild(style);
|
|
166
167
|
}
|
|
168
|
+
// Where a touch may start a text selection inside an EDITOR document. Everything
|
|
169
|
+
// else is chrome you press, drag or draw on, and a selection started there is
|
|
170
|
+
// always an accident.
|
|
171
|
+
const EDIT_SELECTABLE = 'input,textarea,[contenteditable]:not([contenteditable="false"]),.cm-editor,.xterm,[data-castle-allow-select]';
|
|
172
|
+
// `*` sets every element directly, so an allowed root's descendants need their
|
|
173
|
+
// own rule -- they inherit nothing through it.
|
|
174
|
+
const EDIT_SELECTABLE_SUBTREE = EDIT_SELECTABLE.split(",")
|
|
175
|
+
.flatMap((selector) => [selector, `${selector} *`])
|
|
176
|
+
.join(",");
|
|
177
|
+
// The same lock the Shell installs over its own chrome, for the deck document.
|
|
178
|
+
//
|
|
179
|
+
// The Shell's copy (cli/src/shell/touchSelectionLock.ts) walks into same-origin
|
|
180
|
+
// frames, but a deck's editor frame is NOT one: serve hands edit and play their
|
|
181
|
+
// own origins, so the Shell reaches the frame element and nothing inside it.
|
|
182
|
+
// Without this, a kit editor -- the sprite canvas above all, where every stroke
|
|
183
|
+
// is a press-drag over non-text chrome -- collects blue selection rectangles as
|
|
184
|
+
// you draw, and iOS raises the loupe and callout on top.
|
|
185
|
+
//
|
|
186
|
+
// Edit mode only, and only for a coarse pointer: with a mouse a stray selection
|
|
187
|
+
// is one click to dismiss and selecting editor text is often the point, while a
|
|
188
|
+
// touch has no equivalent escape.
|
|
189
|
+
function initEditSelection() {
|
|
190
|
+
if (!isEdit())
|
|
191
|
+
return;
|
|
192
|
+
if (typeof window.matchMedia !== "function")
|
|
193
|
+
return;
|
|
194
|
+
if (!window.matchMedia("(pointer: coarse)").matches)
|
|
195
|
+
return;
|
|
196
|
+
const style = document.createElement("style");
|
|
197
|
+
// The stylesheet is what WebKit's long-press gesture consults; the listeners
|
|
198
|
+
// are the backstop for a press-drag that a kit's own CSS out-specifies.
|
|
199
|
+
style.textContent =
|
|
200
|
+
`*{-webkit-user-select:none!important;user-select:none!important;` +
|
|
201
|
+
`-webkit-touch-callout:none!important;-webkit-tap-highlight-color:transparent!important;}` +
|
|
202
|
+
`${EDIT_SELECTABLE_SUBTREE}{-webkit-user-select:text!important;user-select:text!important;` +
|
|
203
|
+
`-webkit-touch-callout:default!important;}`;
|
|
204
|
+
document.head.appendChild(style);
|
|
205
|
+
const allowsSelection = (node) => {
|
|
206
|
+
const target = node;
|
|
207
|
+
const element = target && target.nodeType === 1
|
|
208
|
+
? target
|
|
209
|
+
: (target?.parentElement ?? null);
|
|
210
|
+
return !!element?.closest(EDIT_SELECTABLE);
|
|
211
|
+
};
|
|
212
|
+
// Capture phase, so a handler that stops propagation cannot leak the gesture
|
|
213
|
+
// past us. preventDefault only suppresses the platform's own behaviour -- the
|
|
214
|
+
// kit's own listeners still run.
|
|
215
|
+
const block = (event) => {
|
|
216
|
+
if (!allowsSelection(event.target))
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
};
|
|
219
|
+
document.addEventListener("selectstart", block, true);
|
|
220
|
+
document.addEventListener("contextmenu", block, true);
|
|
221
|
+
document.addEventListener("selectionchange", () => {
|
|
222
|
+
const selection = document.getSelection();
|
|
223
|
+
// Anchor, not focus: a drag that starts in a text field and runs past its
|
|
224
|
+
// edge is still that field's selection.
|
|
225
|
+
if (!selection || selection.isCollapsed)
|
|
226
|
+
return;
|
|
227
|
+
if (allowsSelection(selection.anchorNode))
|
|
228
|
+
return;
|
|
229
|
+
selection.removeAllRanges();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
167
232
|
// Constrains whatever the deck renders into #root to a 5:7 card in play mode.
|
|
168
233
|
// Hosts own max size and padding; the SDK only preserves the card aspect ratio.
|
|
169
234
|
function initPlayCard() {
|
|
@@ -14,4 +14,7 @@ declare global {
|
|
|
14
14
|
export declare function hostRequest<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResult[C]>;
|
|
15
15
|
export declare function getCommandChannel(): PostChannel | "local";
|
|
16
16
|
export declare function hostNotify(event: LifecycleEvent): void;
|
|
17
|
+
export declare function postUnloadDone(unloadId: string, state?: string): void;
|
|
18
|
+
export declare function setUnloadHandler(handler: (unloadId: string) => void): void;
|
|
19
|
+
export declare function installHostListener(): void;
|
|
17
20
|
export {};
|