@homie-rocks/studio 0.31.0 → 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.
- package/CHANGELOG.md +68 -1
- package/README.md +44 -6
- package/bin/homie-studio.mjs +101 -8
- package/lib/build.mjs +1 -1
- package/lib/cloudflare.mjs +80 -6
- package/lib/directory.mjs +61 -9
- package/lib/doctor.mjs +23 -10
- package/lib/mcp-tools.mjs +18 -11
- package/lib/mcp.mjs +2 -0
- package/lib/parts.mjs +27 -0
- package/lib/routes.mjs +110 -2
- package/lib/scaffold.mjs +3 -3
- package/lib/standalone-cli.mjs +106 -0
- package/lib/standalone-files.mjs +360 -0
- package/lib/standalone-icons.mjs +106 -0
- package/lib/standalone-lock.json +3404 -0
- package/lib/standalone-pins.json +18 -0
- package/lib/standalone-tools.mjs +63 -0
- package/lib/standalone.mjs +868 -0
- package/lib/template-history.json +80 -0
- package/netplay/NETPLAY.md +148 -10
- package/netplay/netplay.ts +234 -33
- package/package.json +2 -1
- package/parts/PARTS.md +8 -2
- package/port/index.ts +1 -1
- package/port/room.ts +228 -23
- package/standalone/STANDALONE.md +313 -0
- package/standalone/electron/files.cjs +50 -0
- package/standalone/electron/main.cjs +139 -0
- package/standalone/electron/pack.mjs +82 -0
- package/standalone/shell/index.html +36 -0
- package/standalone/shell/shell.css +46 -0
- package/standalone/shell/shell.js +346 -0
- package/starters/ember-vale/src/main.ts +4 -0
- package/starters/gem-rush/src/main.ts +29 -7
- package/starters/gem-rush-3d/src/main.ts +36 -7
- package/starters/hero-rush-3d/src/main.ts +36 -7
- package/worker/index.mjs +55 -8
- package/worker/pages.mjs +19 -10
- package/worker/parts.mjs +43 -2
- package/worker/standalone.mjs +34 -0
- package/worker/version.mjs +1 -1
|
@@ -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 };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* THE DESKTOP SHELL (standalone/STANDALONE.md): one window that shows the game's web folder, and nothing else. It is
|
|
3
|
+
* the same file for every game: what differs is app.json beside it. The page gets no Node, no preload and no way out
|
|
4
|
+
* of its own folder; a link opens in the person's browser.
|
|
5
|
+
*
|
|
6
|
+
* --smoke[=<ms>] a self-check for `homie-studio standalone`: load, wait, print ONE line of JSON saying where the
|
|
7
|
+
* page is, whether the game's frame loaded, what failed to load and what the console said, exit.
|
|
8
|
+
* With --shot=<file.png>, a picture of the window as it was then.
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
const { app, BrowserWindow, Menu, net, protocol, session, shell } = require('electron');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { Readable } = require('node:stream');
|
|
15
|
+
const { pathToFileURL } = require('node:url');
|
|
16
|
+
const { fileFor, rangeOf, mayNavigate } = require('./files.cjs');
|
|
17
|
+
|
|
18
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'app.json'), 'utf8'));
|
|
19
|
+
const WEB = path.join(__dirname, 'web');
|
|
20
|
+
const ORIGIN = 'app://game';
|
|
21
|
+
const smokeArg = process.argv.find((a) => /^--smoke(=\d+)?$/.test(a));
|
|
22
|
+
const SMOKE = smokeArg ? Number(smokeArg.split('=')[1]) || 6000 : 0;
|
|
23
|
+
|
|
24
|
+
// Nothing a page does may raise Electron's own error dialog over the game: said in the log, and the game goes on.
|
|
25
|
+
process.on('uncaughtException', (error) => { try { console.error(`shell: ${String(error && error.stack ? error.stack : error).slice(0, 600)}`); } catch { /* no console */ } });
|
|
26
|
+
|
|
27
|
+
// This game's own storage and its own single-instance lock: two games with the same name never share either.
|
|
28
|
+
// The self-check keeps nothing: its storage is a folder of its own in the temporary folder, removed when it ends.
|
|
29
|
+
const smokeData = SMOKE ? fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'standalone-smoke-')) : null;
|
|
30
|
+
if (smokeData) { app.setPath('userData', smokeData); app.on('quit', () => { try { fs.rmSync(smokeData, { recursive: true, force: true }); } catch { /* the system clears it */ } }); }
|
|
31
|
+
else if (/^[A-Za-z][A-Za-z0-9.]{2,120}$/.test(cfg.appId || '')) app.setPath('userData', path.join(app.getPath('appData'), cfg.appId));
|
|
32
|
+
|
|
33
|
+
// The page's own origin: a standard, secure one, so its storage, its modules and its fetches behave as on a site.
|
|
34
|
+
protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } }]);
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* STARTED BY STEAM (Steam sets SteamAppId for what it starts; a game.json with a Steam app is not enough). The
|
|
38
|
+
* overlay is a library the Steam client loads into this process, and it draws only when the GPU runs in the browser
|
|
39
|
+
* process, so that switch is passed. This has never been run under Steam, and Electron apps often show no overlay.
|
|
40
|
+
* Chromium's sandbox is NOT turned off here: a switch added at this point is too late for it, and inside Steam's
|
|
41
|
+
* Linux container the way to do that is the launch option --no-sandbox, set in Steamworks (STANDALONE.md says so).
|
|
42
|
+
*/
|
|
43
|
+
const underSteam = Boolean(process.env.SteamAppId || process.env.SteamGameId || process.env.SteamOverlayGameId);
|
|
44
|
+
if (underSteam) app.commandLine.appendSwitch('in-process-gpu');
|
|
45
|
+
// Steam's own API, only when game.json asked for it and Steam started the game: whatever happens is said in the log,
|
|
46
|
+
// and the game runs either way. This binding has never been run by the people who wrote this file.
|
|
47
|
+
if (underSteam && cfg.steam && cfg.steam.api) {
|
|
48
|
+
try {
|
|
49
|
+
const steam = require('steamworks-ffi-node');
|
|
50
|
+
const s = (steam.SteamworksSDK || steam.default || steam).getInstance();
|
|
51
|
+
// Steam's own libraries, copied beside this file from the SDK the studio's owner downloaded (never shipped by Homie).
|
|
52
|
+
s.setSdkPath(path.join(__dirname, 'steamworks_sdk'));
|
|
53
|
+
const started = s.init({ appId: cfg.steam.app });
|
|
54
|
+
if (started) { console.log('steam: the Steamworks binding started'); app.on('before-quit', () => { try { s.shutdown(); } catch { /* gone */ } }); }
|
|
55
|
+
else console.error('steam: the Steamworks binding said it did not start (init answered false); playing without it');
|
|
56
|
+
} catch (error) { console.error(`steam: the Steamworks binding did not start (${String(error && error.message).slice(0, 200)}); playing without it`); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const MEDIA = { '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.mp4': 'video/mp4', '.webm': 'video/webm' };
|
|
60
|
+
async function serve(request) {
|
|
61
|
+
const file = fileFor(WEB, request.url);
|
|
62
|
+
if (!file) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
63
|
+
// A part of a file (a player seeking in a song): answered here, since a file fetch is always the whole of it.
|
|
64
|
+
let size = 0;
|
|
65
|
+
try { size = fs.statSync(file).size; } catch { return new Response('not found', { status: 404 }); }
|
|
66
|
+
const range = rangeOf(request.headers.get('range'), size);
|
|
67
|
+
if (range === 'whole') return net.fetch(pathToFileURL(file).toString());
|
|
68
|
+
if (range === 'none') return new Response(null, { status: 416, headers: { 'content-range': `bytes */${size}` } });
|
|
69
|
+
const stream = fs.createReadStream(file, range);
|
|
70
|
+
// A player that stops asking (a seek, a closed page) ends the stream early: that is not an error of anybody's.
|
|
71
|
+
stream.on('error', () => {});
|
|
72
|
+
return new Response(Readable.toWeb(stream), { status: 206, headers: { 'content-type': MEDIA[path.extname(file).toLowerCase()] || 'application/octet-stream', 'content-length': String(range.end - range.start + 1), 'content-range': `bytes ${range.start}-${range.end}/${size}`, 'accept-ranges': 'bytes' } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** macOS wants a menu bar: the app's own (Quit), Edit (so copy and paste work in the room-code field) and Window. No Reload, no tools, no Help. */
|
|
76
|
+
function menu() {
|
|
77
|
+
if (process.platform !== 'darwin') return null;
|
|
78
|
+
return Menu.buildFromTemplate([
|
|
79
|
+
{ label: cfg.name, submenu: [{ role: 'about', label: `About ${cfg.name}` }, { type: 'separator' }, { role: 'hide', label: `Hide ${cfg.name}` }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit', label: `Quit ${cfg.name}` }] },
|
|
80
|
+
{ label: 'Edit', submenu: [{ role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }] },
|
|
81
|
+
{ label: 'Window', submenu: [{ role: 'minimize' }, { role: 'zoom' }, { role: 'togglefullscreen' }] },
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function open() {
|
|
86
|
+
const win = new BrowserWindow({
|
|
87
|
+
width: cfg.width || 1280, height: cfg.height || 720, minWidth: 480, minHeight: 320, show: !SMOKE,
|
|
88
|
+
title: cfg.name, backgroundColor: cfg.background || '#04060c', autoHideMenuBar: true,
|
|
89
|
+
...(process.platform === 'linux' && fs.existsSync(path.join(__dirname, 'icon.png')) ? { icon: path.join(__dirname, 'icon.png') } : {}),
|
|
90
|
+
// The developer tools are for the copy a studio runs from its own folder, never for the one it ships.
|
|
91
|
+
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false, devTools: !app.isPackaged },
|
|
92
|
+
});
|
|
93
|
+
const wc = win.webContents;
|
|
94
|
+
wc.on('will-navigate', (event, url) => { if (!mayNavigate(url, { main: true })) event.preventDefault(); });
|
|
95
|
+
wc.on('will-frame-navigate', (event) => { if (!mayNavigate(event.url, { main: event.isMainFrame })) event.preventDefault(); });
|
|
96
|
+
wc.setWindowOpenHandler(({ url }) => { if (/^https:\/\//i.test(url)) shell.openExternal(url); return { action: 'deny' }; });
|
|
97
|
+
wc.on('before-input-event', (event, input) => {
|
|
98
|
+
if (input.type === 'keyDown' && (input.key === 'F11' || (input.key === 'Enter' && input.alt))) { win.setFullScreen(!win.isFullScreen()); event.preventDefault(); }
|
|
99
|
+
});
|
|
100
|
+
if (SMOKE) smoke(win);
|
|
101
|
+
win.loadURL(`${ORIGIN}/index.html`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The self-check: what a person would see go wrong, as one line of JSON, then exit (1 when the game did not load). */
|
|
105
|
+
function smoke(win) {
|
|
106
|
+
const wc = win.webContents;
|
|
107
|
+
const failed = []; const errors = []; let requests = 0;
|
|
108
|
+
session.defaultSession.webRequest.onCompleted((d) => { requests += 1; if (d.statusCode >= 400) failed.push(`${d.statusCode} ${d.url}`.slice(0, 200)); });
|
|
109
|
+
session.defaultSession.webRequest.onErrorOccurred((d) => { failed.push(`${d.error} ${d.url}`.slice(0, 200)); });
|
|
110
|
+
wc.on('console-message', (event) => { if (event.level === 'error') errors.push(String(event.message).slice(0, 300)); });
|
|
111
|
+
wc.on('did-fail-load', (event, code, why, url) => failed.push(`${code} ${why} ${url}`.slice(0, 200)));
|
|
112
|
+
const probe = `(() => { const f = document.querySelector('iframe.game'); let g = null;
|
|
113
|
+
try { const w = f.contentWindow, d = f.contentDocument, n = w.__homieNet; g = { href: w.location.href, ready: d.readyState, canvas: d.querySelectorAll('canvas').length, net: w.HOMIE_NET ? { url: w.HOMIE_NET.url, room: w.HOMIE_NET.room || null, app: w.HOMIE_NET.app === true } : null, helper: n ? { role: n.role, offline: n.offline, connected: n.connected, link: n.link, roster: Array.isArray(n.roster) ? n.roster.length : null } : null }; } catch (e) { g = { error: String(e) }; }
|
|
114
|
+
return { origin: location.origin, title: document.title, shell: window.__shell ? { room: window.__shell.room, online: window.__shell.online, link: window.__shell.link, starts: window.__shell.starts } : null, game: g }; })()`;
|
|
115
|
+
setTimeout(async () => {
|
|
116
|
+
let page = null;
|
|
117
|
+
try { page = await wc.executeJavaScript(probe); } catch (error) { errors.push(`probe: ${String(error && error.message)}`); }
|
|
118
|
+
const shot = process.argv.find((x) => x.startsWith('--shot='));
|
|
119
|
+
if (shot) { try { fs.writeFileSync(shot.slice(7), (await wc.capturePage()).toPNG()); } catch (error) { errors.push(`shot: ${String(error && error.message)}`); } }
|
|
120
|
+
const loaded = Boolean(page && page.game && page.game.ready === 'complete');
|
|
121
|
+
process.stdout.write(`${JSON.stringify({ smoke: 1, ok: loaded && !failed.length && !errors.length, loaded, packaged: app.isPackaged, requests, failed, errors, ...page })}\n`);
|
|
122
|
+
app.exit(loaded ? 0 : 1);
|
|
123
|
+
}, SMOKE);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!SMOKE && !app.requestSingleInstanceLock()) app.quit();
|
|
127
|
+
else {
|
|
128
|
+
app.on('second-instance', () => { const w = BrowserWindow.getAllWindows()[0]; if (w) { if (w.isMinimized()) w.restore(); w.focus(); } });
|
|
129
|
+
app.whenReady().then(() => {
|
|
130
|
+
protocol.handle('app', serve);
|
|
131
|
+
// The page may lock the pointer, go full screen and put an invite link on the clipboard. Nothing else is asked for.
|
|
132
|
+
const allowed = new Set(['pointerLock', 'fullscreen', 'clipboard-sanitized-write']);
|
|
133
|
+
session.defaultSession.setPermissionRequestHandler((wc, permission, done) => done(allowed.has(permission)));
|
|
134
|
+
session.defaultSession.setPermissionCheckHandler((wc, permission) => allowed.has(permission));
|
|
135
|
+
Menu.setApplicationMenu(menu());
|
|
136
|
+
open();
|
|
137
|
+
});
|
|
138
|
+
app.on('window-all-closed', () => app.quit());
|
|
139
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Package the desktop app with @electron/packager (standalone/STANDALONE.md):
|
|
4
|
+
* `node pack.mjs <platform> <arch> <debug|release>`, run in the standalone project by `homie-studio standalone
|
|
5
|
+
* build`. It is the same file for every game: what it packs is electron/ (main.cjs, app.json, the web folder),
|
|
6
|
+
* unpacked beside the Electron binary (no asar: Steam patches a build file by file, and the game's files stay the
|
|
7
|
+
* files the web build made). The result is out/<os>/<debug|release>/: that folder IS the app's folder.
|
|
8
|
+
*
|
|
9
|
+
* macOS signing comes from the environment only, never from a file or an argument:
|
|
10
|
+
* HOMIE_APPLE_IDENTITY the signing identity's name ("Developer ID Application: …", or - for an ad hoc
|
|
11
|
+
* signature that only this Mac trusts); with it the app is signed with the hardened
|
|
12
|
+
* runtime. The signer's own entitlements are used, which differ for the app and each
|
|
13
|
+
* helper; a game with a Steam app gets entitlements.mac.plist instead (see the
|
|
14
|
+
* generator), because the signer replaces, never merges. So does an ad hoc
|
|
15
|
+
* signature: with no team in it, the hardened runtime would refuse the app's own
|
|
16
|
+
* frameworks unless library validation is off
|
|
17
|
+
* HOMIE_APPLE_NOTARY_PROFILE a notarytool keychain profile (xcrun notarytool store-credentials, made by the
|
|
18
|
+
* person in their own terminal); with it the signed app is sent to Apple's notary
|
|
19
|
+
* "notarized" is said only when `xcrun stapler validate` agrees afterwards; otherwise the result says what was asked
|
|
20
|
+
* for and that it was not confirmed.
|
|
21
|
+
* It prints ONE line of JSON: { ok, platform, arch, path, app, exe, signed, notarized, checks } or { ok: false, why }.
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { packager } from '@electron/packager';
|
|
28
|
+
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const [platform, arch, kind = 'debug'] = process.argv.slice(2);
|
|
31
|
+
const FOLDER = { darwin: 'mac', win32: 'windows', linux: 'linux' };
|
|
32
|
+
const say = (o) => process.stdout.write(`${JSON.stringify(o)}\n`);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (!FOLDER[platform] || !arch || !['debug', 'release'].includes(kind)) throw new Error('usage: node pack.mjs darwin|win32|linux <arch> debug|release');
|
|
36
|
+
const meta = JSON.parse(readFileSync(join(here, 'electron', 'package.json'), 'utf8'));
|
|
37
|
+
const app = JSON.parse(readFileSync(join(here, 'electron', 'app.json'), 'utf8'));
|
|
38
|
+
const electron = JSON.parse(readFileSync(join(here, 'node_modules', 'electron', 'package.json'), 'utf8')).version;
|
|
39
|
+
const identity = process.env.HOMIE_APPLE_IDENTITY || '';
|
|
40
|
+
const profile = process.env.HOMIE_APPLE_NOTARY_PROFILE || '';
|
|
41
|
+
const sign = platform === 'darwin' && Boolean(identity);
|
|
42
|
+
const adhoc = identity === '-';
|
|
43
|
+
const notarize = sign && !adhoc && Boolean(profile);
|
|
44
|
+
const icon = { darwin: join(here, 'icons', 'icon.icns'), win32: join(here, 'icons', 'icon.ico') }[platform];
|
|
45
|
+
const entitlements = join(here, 'entitlements.mac.plist');
|
|
46
|
+
const staging = join(here, 'out', `.pack-${FOLDER[platform]}-${kind}`);
|
|
47
|
+
rmSync(staging, { recursive: true, force: true });
|
|
48
|
+
const [made] = await packager({
|
|
49
|
+
// The file's name is the game's name made safe for every file system (the toolkit's fileName). macOS shows that
|
|
50
|
+
// name in the menu bar and the Dock too (the packager writes it over any other); the window's title is the game's own.
|
|
51
|
+
dir: join(here, 'electron'), out: staging, name: meta.fileName, executableName: platform === 'darwin' ? undefined : meta.name,
|
|
52
|
+
platform, arch, electronVersion: electron, overwrite: true, asar: false, prune: true, quiet: true,
|
|
53
|
+
appBundleId: app.appId, appVersion: meta.version, buildVersion: String(app.build ?? 1), appCopyright: meta.author ? `© ${meta.author}` : undefined,
|
|
54
|
+
appCategoryType: 'public.app-category.games',
|
|
55
|
+
// Where Electron's own download is kept between builds: its usual cache, or the folder Electron's installer is told.
|
|
56
|
+
...(process.env.electron_config_cache ? { download: { cacheRoot: process.env.electron_config_cache } } : {}),
|
|
57
|
+
...(icon && existsSync(icon) ? { icon } : {}),
|
|
58
|
+
...(platform === 'win32' ? { win32metadata: { CompanyName: meta.author ?? '', ProductName: meta.productName, FileDescription: meta.productName } } : {}),
|
|
59
|
+
...(sign ? { osxSign: { identity, ...(adhoc ? { identityValidation: false } : {}), optionsForFile: () => ({ hardenedRuntime: true, ...((app.steam || adhoc) && existsSync(entitlements) ? { entitlements } : {}) }) } } : {}),
|
|
60
|
+
...(notarize ? { osxNotarize: { keychainProfile: profile } } : {}),
|
|
61
|
+
});
|
|
62
|
+
if (!made) throw new Error('the packager made nothing');
|
|
63
|
+
const out = join(here, 'out', FOLDER[platform], kind);
|
|
64
|
+
rmSync(out, { recursive: true, force: true });
|
|
65
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
66
|
+
renameSync(made, out);
|
|
67
|
+
rmSync(staging, { recursive: true, force: true });
|
|
68
|
+
// What is really there, by looking: never a name worked out from the game's.
|
|
69
|
+
const names = readdirSync(out);
|
|
70
|
+
const bundle = platform === 'darwin' ? names.find((n) => n.endsWith('.app')) : null;
|
|
71
|
+
const exe = platform === 'darwin' ? (bundle ? join(out, bundle, 'Contents', 'MacOS', readdirSync(join(out, bundle, 'Contents', 'MacOS'))[0]) : null) : join(out, platform === 'win32' ? `${meta.name}.exe` : meta.name);
|
|
72
|
+
if (!exe || !existsSync(exe)) throw new Error(`the packager finished, and the app's program is not where it should be in ${out}`);
|
|
73
|
+
// What was asked of the signer and the notary is one thing; what the finished app answers is what gets said.
|
|
74
|
+
const checks = {};
|
|
75
|
+
const ask = (name, cmd, args) => { try { execFileSync(cmd, args, { stdio: 'pipe', timeout: 60_000 }); checks[name] = true; } catch (error) { checks[name] = false; checks[`${name}Says`] = String(error?.stderr ?? error?.message ?? '').trim().split('\n').slice(-2).join(' ').slice(0, 300); } };
|
|
76
|
+
if (sign && bundle) ask('signature', 'codesign', ['--verify', '--deep', '--strict', join(out, bundle)]);
|
|
77
|
+
if (notarize && bundle) ask('stapled', 'xcrun', ['stapler', 'validate', join(out, bundle)]);
|
|
78
|
+
say({ ok: true, platform, arch, kind, path: out, app: bundle ? join(out, bundle) : out, exe, electron, signed: sign && checks.signature === true, adhoc, notarizeAsked: notarize, notarized: notarize && checks.stapled === true, checks });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
say({ ok: false, why: String(error?.message ?? error).split('\n').slice(0, 6).join(' ').slice(0, 800) });
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
|
6
|
+
<title>Game</title>
|
|
7
|
+
<link rel="stylesheet" href="shell.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<iframe class="game" title="Game" allow="fullscreen; autoplay; gamepad"></iframe>
|
|
11
|
+
<div class="room at-top-right" data-room-ui>
|
|
12
|
+
<button class="pill" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room and invite"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Finding a room…</span></button>
|
|
13
|
+
<div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
|
|
14
|
+
<div class="code"><b data-room-label>Offline</b><span data-room-state></span></div>
|
|
15
|
+
<div class="acts">
|
|
16
|
+
<button type="button" class="primary" data-invite>Invite</button>
|
|
17
|
+
<button type="button" data-quick>Quick play</button>
|
|
18
|
+
</div>
|
|
19
|
+
<span class="link" data-room-link></span>
|
|
20
|
+
<form class="join" data-join-form>
|
|
21
|
+
<input type="text" data-join-code inputmode="latin" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="32" placeholder="Room code" aria-label="Room code">
|
|
22
|
+
<button type="submit">Join a room</button>
|
|
23
|
+
</form>
|
|
24
|
+
<div class="foot"><button type="button" data-private>New private room</button><button type="button" data-offline>Play offline</button></div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="toast" data-toast role="status" hidden></div>
|
|
28
|
+
<div class="notice" data-notice role="alertdialog" aria-modal="true" hidden>
|
|
29
|
+
<div class="box"><h1 data-notice-title></h1><p data-notice-text></p>
|
|
30
|
+
<div class="acts"><button type="button" class="primary" data-notice-quick>Quick play</button><button type="button" data-notice-offline>Play offline</button></div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<script src="config.js"></script>
|
|
34
|
+
<script src="shell.js"></script>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|