@homie-rocks/studio 0.31.1 → 0.32.0

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.
@@ -1652,6 +1652,46 @@
1652
1652
  }
1653
1653
  }
1654
1654
  }
1655
+ },
1656
+ "0.32.0": {
1657
+ "files": {
1658
+ "CLAUDE.md": "ddf2b2a0376202f6",
1659
+ "README.md": "684b3cca8001673c",
1660
+ "HANDOFF.md": "29337e2ace95b2e7",
1661
+ "games/README.md": "5ad32fe80c61f117",
1662
+ "music/README.md": "a3dc042dadd39df8",
1663
+ "videos/README.md": "16babc3437d5545b",
1664
+ "posts/README.md": "37841aae476bd540",
1665
+ "site/README.md": "732894273a32dc3f",
1666
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1667
+ },
1668
+ "sections": {
1669
+ "AGENTS.md": {
1670
+ "(intro)": "214aa46eb3a895d5",
1671
+ "## Layout": "8baf49e4291e7e88",
1672
+ "## Commands (all through the pinned CLI in node_modules)": "29a04ed3d956610a",
1673
+ "## The site": "0cb8dd9a6f8a0096",
1674
+ "## Making games": "2da7fa74c8884834",
1675
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1676
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1677
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1678
+ "## Servers and AI seats": "98d08705087a33ec",
1679
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1680
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1681
+ "## Rules": "2e6b29afb85dcf7e",
1682
+ "## Telling Homie": "80b46fdd9e578db8",
1683
+ "## Beta": "9d02638207a26e73"
1684
+ }
1685
+ },
1686
+ "named": {
1687
+ "Homie Arcade": {
1688
+ "sections": {
1689
+ "AGENTS.md": {
1690
+ "## Commands (all through the pinned CLI in node_modules)": "5d1c068adda1116b"
1691
+ }
1692
+ }
1693
+ }
1694
+ }
1655
1695
  }
1656
1696
  }
1657
1697
  }
@@ -1662,6 +1662,16 @@ reads the other's snapshot with the wrong code.
1662
1662
  names no version is one pool, as before. A relay nobody tells which build is live (no Worker in
1663
1663
  front) uses the hello's `ver`: two builds still never share a room, and nobody is called stale.
1664
1664
 
1665
+ **A standalone copy** (a desktop or phone app, `standalone/STANDALONE.md`) is one build for good:
1666
+ it cannot load a newer one, only be updated. So it says its own revision to the Lobby
1667
+ (`/api/lobby?gv=`), which matches it with copies of that revision whatever is live now, and its
1668
+ `HOMIE_NET.app` makes the helper's lines truthful: "A new version is out. Update the app to play
1669
+ online.", and a tap reloads nothing. A copy that knocks at a room of the live revision is refused
1670
+ like any old tab, and plays on by itself. What a copy says of itself is its own word, so the Lobby
1671
+ keeps rooms for at most eight revisions besides the live one (a copy on a ninth plays offline for
1672
+ now), and a helper's `closed` word to its page names the room it is about. Nothing on the wire differs. `homie-studio deploy` says
1673
+ when a deploy makes another revision live than the copies a studio has built.
1674
+
1665
1675
  **What a revision cannot see.** Until a game's bundle has a content-hashed
1666
1676
  name, the edge may serve the previous `main.js` for up to a minute after a deploy while the page
1667
1677
  around it is already stamped with the new version. A tab loaded in that minute says it is the new
@@ -139,6 +139,12 @@ export interface NetConfig {
139
139
  params?: Record<string, string>;
140
140
  /** Revision 9: the page around the frame keeps `net.prefs` (a play page from before it does not say so). */
141
141
  prefs?: boolean;
142
+ /**
143
+ * This game runs in a standalone copy (a desktop or phone app, standalone/STANDALONE.md), whose files are the
144
+ * build it was made from: a reload cannot bring a newer one, only an update of the app can. The lines about a newer
145
+ * version say so, and a tap on one reloads nothing. Nothing on the wire changes.
146
+ */
147
+ app?: boolean;
142
148
  }
143
149
 
144
150
  /* ------------------------------------------------- the link, revisions and the page (revision 9, sections 22-24) */
@@ -1282,6 +1288,8 @@ const HEARTBEAT_MAX_MS = 4000;
1282
1288
  const RECONNECT_MAX_MS = 20_000;
1283
1289
  /** The link overlay: a reconnect shorter than this is never drawn. */
1284
1290
  const LINK_OVERLAY_MS = 700;
1291
+ /** What a standalone copy says when a newer build of the game is live: the way to it is the app's own update. */
1292
+ export const APP_STALE_LINE = 'A new version is out. Update the app to play online.';
1285
1293
  /** `net.prefs` (section 24): what the play page keeps for one game on one browser. worker/pages.mjs has the same numbers. */
1286
1294
  export const PREFS_LIMITS = Object.freeze({ bytes: 16_384, keys: 32, key: 64 });
1287
1295
  const PREFS_WAIT_MS = 2000;
@@ -1678,6 +1686,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1678
1686
  let link: LinkState = offline ? 'offline' : 'connecting';
1679
1687
  /** The line over the game while the link is down: made on first need, in this document, so a game's CSS reaches it. */
1680
1688
  const overlayOn = opts.linkOverlay !== false && !offline;
1689
+ /** A standalone copy (HOMIE_NET.app): a newer build is reached by updating the app, never by a reload. */
1690
+ const inApp = cfg?.app === true;
1681
1691
  let overlay: HTMLElement | null = null;
1682
1692
  let overlayTimer: ReturnType<typeof setTimeout> | null = null;
1683
1693
  let overlayHide: ReturnType<typeof setTimeout> | null = null;
@@ -1695,7 +1705,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1695
1705
  el.setAttribute('role', 'status');
1696
1706
  el.setAttribute('aria-live', 'polite');
1697
1707
  el.hidden = true;
1698
- el.addEventListener('click', () => { if (el.getAttribute('data-homie-link') === 'stale') { try { (globalThis as { location?: Location }).location?.reload(); } catch { /* not a page */ } } });
1708
+ el.addEventListener('click', () => { if (!inApp && el.getAttribute('data-homie-link') === 'stale') { try { (globalThis as { location?: Location }).location?.reload(); } catch { /* not a page */ } } });
1699
1709
  doc.body.appendChild(el);
1700
1710
  overlay = el;
1701
1711
  } catch { overlay = null; }
@@ -1729,7 +1739,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1729
1739
  if (closedWhy === 'room-full' || closedWhy === 'too-many') return offline ? 'This room is full · playing on your own' : 'This room is full.';
1730
1740
  if (closedWhy === 'replaced') return 'This game is open in another tab.';
1731
1741
  if (closedWhy === 'kicked' || closedWhy === 'room-closed') return 'This room is closed.';
1732
- if (closedWhy === 'version') return 'This game needs a reload to play online.';
1742
+ if (closedWhy === 'version') return inApp ? APP_STALE_LINE : 'This game needs a reload to play online.';
1733
1743
  return null;
1734
1744
  }
1735
1745
  function paintLink(): void {
@@ -1740,7 +1750,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1740
1750
  // A blip shorter than a blink is never drawn.
1741
1751
  overlayTimer = setTimeout(() => { overlayTimer = null; if (link === 'reconnecting') showOverlay('reconnecting', 'Reconnecting…'); }, LINK_OVERLAY_MS);
1742
1752
  } else if (link === 'alone') showOverlay('alone', aloneText());
1743
- else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', 'This game was updated. Tap to reload.');
1753
+ else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', inApp ? APP_STALE_LINE : 'This game was updated. Tap to reload.');
1744
1754
  else if (closedSays) showOverlay('closed', closedSays);
1745
1755
  // In the room: the game's own sentence about where this player stands, else that the room is full.
1746
1756
  else if (link === 'online' && gameLine) showOverlay('seat', gameLine);
@@ -1763,7 +1773,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1763
1773
  staleVer = next;
1764
1774
  if (!first && !final) return;
1765
1775
  post?.({ what: 'stale', ver: next || null, mine: version, final });
1766
- if (!final) showOverlay('stale', 'A new version is ready. Tap to reload.', 10_000);
1776
+ if (!final) showOverlay('stale', inApp ? APP_STALE_LINE : 'A new version is ready. Tap to reload.', 10_000);
1767
1777
  emit('stale', { ver: next || null, mine: version, final });
1768
1778
  }
1769
1779
 
@@ -2306,7 +2316,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2306
2316
  if (FINAL_ERRORS.has(code)) {
2307
2317
  closed = true;
2308
2318
  closedWhy = code;
2309
- post?.({ what: 'closed', why: code, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
2319
+ post?.({ what: 'closed', why: code, room: cfg?.room ?? null, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
2310
2320
  // Kept out of a room for running an older build (section 23): the page reloads the game; the game may too.
2311
2321
  if (code === 'stale') noteStale(m['ver'], true);
2312
2322
  setLink('closed', code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -78,6 +78,7 @@
78
78
  "site/",
79
79
  "chat/",
80
80
  "parts/",
81
+ "standalone/",
81
82
  "README.md",
82
83
  "CHANGELOG.md",
83
84
  "LICENSE",
@@ -0,0 +1,313 @@
1
+ # Standalone copies of a game
2
+
3
+ `homie-studio standalone` turns a game of your studio into an app of its own:
4
+
5
+ | Target | Made with | What comes out | Where it can go |
6
+ | --- | --- | --- | --- |
7
+ | `mac` | Electron | `out/mac/<debug or release>/<Name>.app` | Steam |
8
+ | `windows` | Electron | `out/windows/<debug or release>/<id>.exe` and its files | Steam |
9
+ | `linux` | Electron | `out/linux/<debug or release>/<id>` and its files | Steam |
10
+ | `ios` | Capacitor, Xcode | a simulator `.app`, or an `.ipa` with `--release` | the App Store |
11
+ | `android` | Capacitor, Gradle | a debug `.apk`, or an `.aab` with `--release` | Google Play |
12
+
13
+ Each is **the kind of file that store accepts for upload**. Whether a store takes your game is the store's
14
+ decision: its review, its fees and its rules are its own, and nothing here makes an app pass (see "Before you
15
+ spend money", below).
16
+
17
+ It is the **same web build** of the game, the one `homie-studio build` makes and your site serves, in the
18
+ thinnest shell each platform has. Nothing about one game lives in a shell. Multiplayer still goes through your
19
+ studio's own deployed site: the app asks its Lobby for a room and opens the room's socket, exactly as the play
20
+ page does. With no connection, or when no room answers, the game plays offline with its bots.
21
+
22
+ ```sh
23
+ npx --no-install homie-studio standalone plan <game> # what would be built, what is missing; changes nothing
24
+ npx --no-install homie-studio standalone build <game> # every target this computer can make
25
+ npx --no-install homie-studio standalone build <game> --for mac,windows
26
+ npx --no-install homie-studio standalone run <game> # the built desktop copy, here (--for ios | android)
27
+ npx --no-install homie-studio standalone steam <game> # Steam's build file; uploads nothing
28
+ npx --no-install homie-studio standalone ci <game> # a GitHub workflow that builds all five
29
+ ```
30
+
31
+ In chat it is one tool, `game_standalone` (with `plan: true` first).
32
+
33
+ ## What the standalone game does not have (v1)
34
+
35
+ Say this to whoever will ship it, before they do.
36
+
37
+ - **Player accounts and sign-in.** The app has no sign-in and no name entry. A player is a guest with a two-word
38
+ handle; an account made on the site is not known to the app.
39
+ - **Cloud saves.** Saves and settings are kept on the device only (the saves helper's local mode). They do not
40
+ follow a player to the site or to another device, and they are **lost when the app is uninstalled**.
41
+ - **The shop.** Nothing is sold in the app. The app stores have their own rules for selling, and none of that is
42
+ built.
43
+ - **Room chat.** Off in the app: it has no report or mute controls yet, and a chat without them is not shipped.
44
+ - **Watching, the big screen, the owner's controls.** The app only plays.
45
+ - **Servers and private doors.** The app plays on the public server through an open door. A game that is private
46
+ or invite-only, or a server behind an account or an invite, plays offline in the app.
47
+ - **Automatic updates.** The app is the build it was made from. A newer version reaches players only when you
48
+ build again and ship the update through Steam or the store.
49
+ - **Steam's overlay, achievements and friends.** None of it has been run. The desktop app passes the switch the
50
+ overlay needs when Steam starts it, but Electron apps often do not show Steam's overlay, and achievements,
51
+ rich presence and friend invites are not wired.
52
+ - **Uploading, and a store's yes.** Builds are made on your computer and the upload steps are printed. Nothing is
53
+ sent to Steam, App Store Connect or Google Play for you, and nothing here makes an app pass a review.
54
+ - **A signed Windows build.** Unsigned in v1: opened outside Steam, Windows shows its unknown-publisher warning.
55
+
56
+ ## What has been run and what has not
57
+
58
+ This is version 1, and it says what it knows. As of 2026-10-06, by the people who wrote it, on one Mac:
59
+
60
+ **Run and seen:**
61
+
62
+ - The macOS build (Apple silicon, and the universal one a release makes) started and the game loaded with its
63
+ bots, from its own `app://game` origin, with no file failing to load. Every desktop build for the computer it
64
+ is made on is started once like this, and its result says "started here and loaded the game: yes" or "NO".
65
+ - That build, pointed at a studio's local dev server, got a room from the Lobby and opened the room's socket.
66
+ - A macOS build signed ad hoc with the hardened runtime and the Steam entitlements started and loaded the game.
67
+ - The iOS simulator build ran in the iOS Simulator; the Android debug build ran in the Android emulator. Both
68
+ offline, with their bots.
69
+ - An Android release bundle was signed by Gradle with a test key (the key's passwords given through the
70
+ environment), and the bundle carries that key's certificate.
71
+ - The Windows build's program carries the game's icon and its name; the Linux build is a Linux program. Both
72
+ were made on the Mac.
73
+
74
+ **Never run:**
75
+
76
+ - **The Windows and Linux builds have never been started**, on any computer. On Ubuntu 24.04 and later a program
77
+ in a plain folder like `out/linux/` may refuse to start outside Steam until its `chrome-sandbox` file is owned
78
+ by root with mode 4755, or it is started with `--no-sandbox`; an installer normally does the first, and there
79
+ is none.
80
+ - **Nothing has been started by Steam.** Not the overlay, not the Linux build inside Steam's runtime, not the
81
+ Steam API binding. `steam/app_build.vdf` is written from Steam's documented format and has never been run
82
+ through `steamcmd`.
83
+ - **A macOS app signed with a Developer ID, and notarization.** The code asks the signer and the notary and then
84
+ checks the result (`codesign --verify`, `xcrun stapler validate`); it says "notarized" only when the second
85
+ agrees. That path has not been run with a real identity. Opening a notarized app on another Mac was not tried.
86
+ - **The iOS archive and its `.ipa`.** It needs Xcode signed in to an Apple account of your team; it was not run.
87
+ - **Anything on a real phone**, and a phone build that plays online.
88
+ - **The GitHub workflow.** It is checked as text. Read the first run's log.
89
+ - **A copy meeting a deployed site**, and an old copy meeting a newer site, outside the tests.
90
+ - **Building from Windows or Linux.**
91
+
92
+ When one of these fails for you, that is news to us: tell Homie.
93
+
94
+ ## Before a copy can find rooms
95
+
96
+ Your studio must be on `@homie-rocks/studio` 0.32.0 or later **and deployed**. The app's page is not served by
97
+ your site, so it is another origin to your Worker; 0.32.0 is the first Worker that lets an app read the Lobby's
98
+ answer. A copy pointed at an older site plays offline.
99
+
100
+ The site's address is **built into every copy** (`--site <address>`, else `HOMIE_STANDALONE_SITE`, else the
101
+ address your last deploy from this computer got). A copy that has shipped keeps it for good, so put the studio
102
+ on its own domain (`studio.json` `cloudflare.domain`) before you ship: a workers.dev address belongs to the
103
+ Cloudflare account and changes if the Worker is renamed.
104
+
105
+ A game that loads something from another site at run time (a web font, a picture, an API) still needs a
106
+ connection for that in the app, exactly as on the web: bundle what the game must have offline. And a game whose
107
+ `index.html` carries a Content-Security-Policy that forbids inline scripts never hears the shell (a copy adds
108
+ one inline script to that page) and plays offline only; the plan warns when it sees one.
109
+
110
+ ## Versions: an old copy and a new site
111
+
112
+ Name the game's revision in `game.json` (`"netplay": { "version": "1" }`, NETPLAY.md section 23) before you
113
+ ship, and raise it with every change an older copy cannot play with. Then:
114
+
115
+ - a copy asks the Lobby for a room of **its own** revision, so copies of one revision keep playing with each
116
+ other, whatever the site runs now. The Lobby keeps rooms for the live revision and at most eight others at a
117
+ time; a copy on a ninth is told to play offline for now;
118
+ - a copy that knocks at a room on a newer revision (a friend's invite link) is refused, plays on by itself, and
119
+ says so: "Update <Name> to play online with everyone." It never reloads: only an update of the app brings a
120
+ newer build. From then on it shows that line in place of Invite, because its invite would send a friend's
121
+ browser to the newer version, which the copy cannot join;
122
+ - `homie-studio deploy` (and `deploy --plan`) says when a deploy makes a different revision live than the copies
123
+ you built, and what that means for them.
124
+
125
+ ## game.json
126
+
127
+ Everything is optional until a release:
128
+
129
+ ```json
130
+ "standalone": {
131
+ "appId": "com.example.gemrush",
132
+ "version": "1.0.0",
133
+ "build": 1,
134
+ "orientation": "landscape",
135
+ "icon": "icon.png",
136
+ "steam": { "app": 0, "depots": { "windows": 0, "mac": 0, "linux": 0 } }
137
+ }
138
+ ```
139
+
140
+ - `appId`: the app's id in both stores (letters and digits in dotted parts, each starting with a letter). With
141
+ none, a build to try uses a made-up one: your domain reversed, else `rocks.homie.<studio>`, then the game's id.
142
+ **`--release` refuses until you write one**: a store never lets it change.
143
+ - `version`, `build`: what a player sees and the store's build number (`--build <n>` overrides the number).
144
+ - `orientation`: `landscape`, `portrait` or `any` (phones).
145
+ - `icon`: a picture in the game's folder. Without it: `games/<id>/icon.png`, else the cover cropped square, else
146
+ a tile with the game's first letter. Stores want a real square 1024 by 1024 picture.
147
+ - `steam`: your App ID and one Depot ID for each operating system, for `standalone steam`.
148
+
149
+ The app's name is the game's name; its publisher is the studio's. A name with a character a file's name cannot
150
+ have (`: / \ " < > | ? *`) is still the app's name on phones and in the window's title; the desktop app's file,
151
+ and what macOS shows in its menu bar and Dock, has a `-` in its place. Every path the command reports is where
152
+ the file is.
153
+
154
+ ## Where everything is
155
+
156
+ All of it is in `.studio/standalone/<id>/`, which is git-ignored. Never edit it; never commit it.
157
+
158
+ ```
159
+ web/ the shell's page (index.html, shell.js, shell.css), config.js, and game/ (the built game)
160
+ electron/ main.cjs, files.cjs and app.json, with a copy of web/
161
+ ios/ android/ the native projects Capacitor makes
162
+ icons/ assets/ the icon in every size
163
+ steam/ app_build.vdf
164
+ out/<target>/debug/ out/<target>/release/ what was built; a debug build never replaces a release
165
+ built.json for each target, what its debug and its release copy were built from
166
+ node_modules/ package-lock.json the wrapper tools
167
+ ```
168
+
169
+ **Made again by every build:** `web/`, `electron/`, the icons, the config files, and the `out/` folder of what
170
+ is being built. **Kept between builds:** `node_modules/` (installed on the first build, a few hundred MB, and
171
+ again only when a pin changed or an install did not finish), `ios/` and `android/` (made by Capacitor once, and
172
+ again when the app's id or its name changed; their versions, orientation and icons are set on every build), and
173
+ the other kind's `out/` folder.
174
+
175
+ The wrapper tools are pinned twice: each tool at one exact version (`lib/standalone-pins.json`), and the whole
176
+ tree under them at exact versions with checksums (`lib/standalone-lock.json`, copied in as the project's
177
+ lockfile). A studio that never makes a standalone copy downloads none of them. When a file of theirs is
178
+ missing, the build installs them again, once, and says so.
179
+
180
+ `web/game/` is your site's build byte for byte, but for one script added to its `index.html`, which hands the
181
+ netplay helper the shell's word as your Worker does on the site.
182
+
183
+ ## What each target needs
184
+
185
+ `standalone plan` checks this computer and says, for each target, whether it can be built here. A target whose
186
+ tool is missing is **skipped** with its fix; the others still build. Nothing is installed for you. A row says
187
+ "ok" only for what was looked at: a name in the environment is said as "set, checked later", not as working.
188
+
189
+ - **mac**: a Mac.
190
+ - **windows**, **linux**: any computer (a Windows build from a Mac needs no Wine).
191
+ - **ios**: a Mac with Xcode and its iOS platform.
192
+ - **android**: the Android SDK with the Android 36 platform (Android Studio installs both) and JDK 21 (22 to 24
193
+ also work; Android Studio's own JDK is used when the one on your PATH does not fit, for every step).
194
+
195
+ A computer that cannot make a target can still get it: `standalone ci <game>` writes
196
+ `.github/workflows/standalone.yml`, which builds each target on GitHub's own Windows, Linux and macOS machines
197
+ when you start it by hand. Set the repository variable `HOMIE_SITE` to your studio's address first. Its macOS
198
+ and iOS jobs never make a release (it has no Apple certificate): the macOS app it makes is unsigned and the iOS
199
+ one is a simulator build.
200
+
201
+ ## What a build's result means
202
+
203
+ - **built**: the tool finished and the file is at the path given. The desktop build for this computer's own
204
+ system was also started once: "started here and loaded the game: yes" or "NO". Every other target says
205
+ "built, not started on this computer". Built is not the same as works.
206
+ - **skipped**: a tool is missing; the fix is printed.
207
+ - **failed**: in the tool's own words.
208
+ - **UNSIGNED**: a release that was built and could not be signed (or signed and not notarized). No store takes
209
+ it, and the command does not report success.
210
+
211
+ The command succeeds only when something was built, nothing failed, no release came out unsigned, and every
212
+ target you named with `--for` was built.
213
+
214
+ ## A release, and signing
215
+
216
+ `--release` makes the file a store or Steam accepts for upload. Signing reads **environment variables only**:
217
+ never a file in the studio, never the chat. Only the one tool that signs is given them; npm, Capacitor and
218
+ every script they run are not.
219
+
220
+ | Variable | For |
221
+ | --- | --- |
222
+ | `HOMIE_APPLE_IDENTITY` | macOS: the name of your "Developer ID Application" identity. The app is signed with the hardened runtime |
223
+ | `HOMIE_APPLE_NOTARY_PROFILE` | macOS: a notarytool keychain profile. Make it yourself, in your own terminal: `xcrun notarytool store-credentials "homie-notary"` |
224
+ | `HOMIE_APPLE_TEAM` | iOS: your team id. Xcode, signed in to an Apple account of that team, signs the archive and exports an `.ipa` |
225
+ | `HOMIE_ANDROID_KEYSTORE`, `HOMIE_ANDROID_KEYSTORE_PASSWORD`, `HOMIE_ANDROID_KEY_ALIAS`, `HOMIE_ANDROID_KEY_PASSWORD` | Android: your upload key. The passwords reach Gradle through its environment, never a command line |
226
+
227
+ Set them where your AI's commands run, never in the chat: in a terminal, `export NAME=…` and start your AI from
228
+ that terminal; for a desktop app on a Mac, `launchctl setenv NAME …` in Terminal and then open the app again; in
229
+ the GitHub workflow they are repository secrets.
230
+
231
+ A macOS release without the identity is built and marked UNSIGNED. Signed without the profile it is marked NOT
232
+ NOTARIZED. With both, it is called notarized only when `xcrun stapler validate` confirms the ticket on the
233
+ finished app. An Android release without all four values is built and marked UNSIGNED. A macOS game with a
234
+ Steam app is signed with entitlements that let the Steam client load its overlay library; any other is signed
235
+ with the signer's own defaults.
236
+
237
+ ## Before you spend money
238
+
239
+ What a store asks of you changes; these were true when this was written (2026-10-06), so check each store's own
240
+ page before you pay.
241
+
242
+ - **Apple**: the Apple Developer Program is a yearly membership (US$99 a year), needed to sign for iOS and to
243
+ notarize for macOS. Apple reviews every app, and its guidelines let it reject one it judges to be a web site
244
+ in a wrapper. A game whose files are all in the app and that plays offline is a better case than a page that
245
+ loads from the web, and nothing guarantees approval.
246
+ - **Google Play**: a one-time registration fee (US$25). A personal account created since late 2023 must run a
247
+ closed test with a number of testers for two weeks before it may publish to everyone.
248
+ - **Steam**: Steam Direct's fee is paid for each app (US$100, returned after the app earns a set amount), and
249
+ Steam makes a new partner and a new app wait (about thirty days after paying, and a store page live for about
250
+ two weeks) before release. Steam asks macOS builds to be signed and notarized.
251
+
252
+ ## Steam
253
+
254
+ `standalone steam <game>` writes `steam/app_build.vdf` from your numbers, with each depot inside it pointing at
255
+ that system's release build (`out/<os>/release/`), and prints the one `steamcmd` command to run yourself. With
256
+ no numbers yet it says what to create in Steamworks, and the launch options to set there: `<id>.exe`,
257
+ `<Name>.app`, `<id>`. **For the Linux one, put `--no-sandbox` in its arguments**: inside Steam's Linux runtime
258
+ the browser engine's own sandbox cannot start, and the app does not turn it off by itself.
259
+
260
+ When Steam starts the game (Steam sets `SteamAppId` for what it starts), the app passes the one switch Steam's
261
+ overlay needs to draw (`in-process-gpu`). That is all it does for the overlay, it has not been run under Steam,
262
+ and many Electron apps show no overlay at all. `standalone run` puts `steam_appid.txt` beside the app it starts
263
+ for development, never in `out/`.
264
+
265
+ Steam's own API is off unless you ask: `"steam": { "api": true }` and `HOMIE_STEAMWORKS_SDK=<the SDK folder you
266
+ downloaded from Steamworks>`. The binding (`steamworks-ffi-node`) is then installed into the desktop app and
267
+ Steam's libraries are copied from your SDK. It has never been run. The app's log says whether it started
268
+ ("the Steamworks binding started", or that it did not and why), and the game runs either way. No achievements
269
+ are wired in v1.
270
+
271
+ ## The shells, briefly
272
+
273
+ - **Desktop** (`standalone/electron/main.cjs`): one window on `app://game/`, served from the app's own `web/`
274
+ folder and nothing else (`files.cjs` decides which file an address names; a test runs it with hostile
275
+ addresses). No Node in the page, no preload, sandboxed; a link opens in the person's browser; the page may
276
+ only lock the pointer, go full screen and copy an invite link. Its storage and its single-instance lock are
277
+ kept under the app's id, so two games never share them. A shipped copy has no developer tools and, on macOS, a
278
+ menu of its own (the app, Edit, Window). F11 or Alt+Enter toggles full screen. `--smoke` is its self-check: it
279
+ loads, waits, and prints one line of JSON.
280
+ - **Phones**: Capacitor's stock shell with the web folder bundled (`capacitor://localhost` on iOS,
281
+ `https://localhost` on Android). Never `server.url`: the app does not load its page from a site.
282
+ - **The page** (`standalone/shell/`): the room button and its sheet, as on the play page. Invite shares the
283
+ room's address on your site, so a friend in a browser joins the same room; Join a room takes a code; New
284
+ private room makes one; Quick play asks the Lobby. When the Lobby has not answered in a second and a half the
285
+ game starts offline, and a room that arrives later is offered, never switched to. A seat is remembered across
286
+ restarts of the app.
287
+
288
+ ## What the game's own code can reach
289
+
290
+ On the web, your Worker serves a game into a sandboxed frame that cannot touch the page around it. In an app the
291
+ game's frame is the shell's own origin (that is what lets its saves work), so **the game's code is trusted with
292
+ everything the app's page has**:
293
+
294
+ - its own storage (saves, settings, the room key), and the shell's page around it;
295
+ - on phones, **Capacitor's native bridge**. The shell uses no plugin, but the bridge is in every frame of the
296
+ app, and through it code can ask the native side for things a web page cannot: requests that skip the
297
+ browser's cross-origin rules, the native cookie store, and whatever plugins a future version adds.
298
+
299
+ This is your own game's code, so it is yours to trust. It matters when a game runs **somebody else's** code: an
300
+ ad or analytics script, a script loaded from another site, a part you did not read. In the app that code has
301
+ the same reach. Do not load scripts from the network in a game you ship as an app.
302
+
303
+ ## What the Worker allows an app
304
+
305
+ One thing: `POST /<game>/api/lobby` from `app://game`, `capacitor://localhost` or `https://localhost` gets an
306
+ answer the app's page may read, whatever the answer is (`worker/standalone.mjs`). No credentials cross, the
307
+ Lobby is never opened to every origin, an app may only ask for the public server, and every POST that must come
308
+ from your own site's pages (players, saves, the shop, chat reports, the office) still refuses an app. That is
309
+ why an app has no account, no cloud saves and no shop.
310
+
311
+ An app says which revision it is, and anybody can claim to be an app. So the Lobby keeps rooms for at most
312
+ eight revisions besides the live one, never gives a server more rooms than it may have whatever revision asks,
313
+ and keeps at most 512 rooms for a game in all (rooms nobody is seated in make way first).
@@ -0,0 +1,50 @@
1
+ /*
2
+ * Which file a request to the desktop shell names, and which bytes of it (standalone/STANDALONE.md). Nothing here
3
+ * needs Electron, so a test runs it with hostile addresses: main.cjs serves exactly what these two say.
4
+ */
5
+ 'use strict';
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+
9
+ /** The file an `app://game/…` address names inside `web`, or null: another host, a path that climbs out, or nothing there. */
10
+ function fileFor(web, address) {
11
+ let url;
12
+ try { url = new URL(address); } catch { return null; }
13
+ if (url.protocol !== 'app:' || url.host !== 'game') return null;
14
+ let rel;
15
+ try { rel = decodeURIComponent(url.pathname); } catch { return null; }
16
+ if (rel.includes('\0') || rel.includes('\\')) return null;
17
+ const abs = path.resolve(web, `.${path.posix.normalize(`/${rel}`)}`);
18
+ if (abs !== web && !abs.startsWith(web + path.sep)) return null;
19
+ for (const f of [abs, path.join(abs, 'index.html')]) {
20
+ try {
21
+ // A link inside web/ that points out of it is not a file of the game.
22
+ const real = fs.realpathSync(f);
23
+ if (fs.statSync(real).isFile() && (real === fs.realpathSync(web) || real.startsWith(fs.realpathSync(web) + path.sep))) return f;
24
+ } catch { /* next */ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * The part of a file of `size` bytes a Range header asks for: { start, end } (both inside the file), 'whole' when
31
+ * there is no usable header (the whole file is the answer), or 'none' when the range lies outside the file (416).
32
+ */
33
+ function rangeOf(header, size) {
34
+ const m = /^bytes=(\d{0,15})-(\d{0,15})$/.exec(String(header || '').trim());
35
+ if (!m || (m[1] === '' && m[2] === '')) return 'whole';
36
+ if (!(size > 0)) return 'none';
37
+ if (m[1] === '') { const n = Number(m[2]); return n > 0 ? { start: Math.max(0, size - n), end: size - 1 } : 'none'; }
38
+ const start = Number(m[1]);
39
+ const end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
40
+ return start > end || start >= size ? 'none' : { start, end };
41
+ }
42
+
43
+ /** Where a frame of the page may go: the app's own files; and for a frame inside the game, a blob or a srcdoc of its own. */
44
+ function mayNavigate(address, { main = true } = {}) {
45
+ const url = String(address);
46
+ if (url.startsWith('app://game/')) return true;
47
+ return !main && (url === 'about:srcdoc' || url === 'about:blank' || url.startsWith('blob:app://game/'));
48
+ }
49
+
50
+ module.exports = { fileFor, rangeOf, mayNavigate };