@homie-rocks/studio 0.32.0 → 0.32.1
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 +29 -2
- package/README.md +10 -3
- package/bin/homie-studio.mjs +10 -1
- package/lib/standalone-cli.mjs +30 -2
- package/lib/standalone-tools.mjs +20 -2
- package/lib/standalone.mjs +215 -9
- package/lib/template-history.json +40 -0
- package/package.json +1 -1
- package/standalone/STANDALONE.md +61 -6
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,36 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.32.1] - 2026-10-07
|
|
21
|
+
|
|
22
|
+
**Plugin 0.33.1** · [#55](https://github.com/homie-rocks/homie/pull/55)
|
|
23
|
+
|
|
24
|
+
A standalone game can be put on your own iPhone or iPad with one command, and the guide says what has now been run on a real phone and corrects what an older site means for a copy.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **`homie-studio standalone run <game> --for ios --device`** builds the game for the one iPhone or iPad plugged in to your Mac, signs it for your Apple team, installs it over the cable, starts it, and then looks for it among the phone's running programs. It says the game is running only when it found it there. In chat it is `game_standalone` with `device: true`.
|
|
29
|
+
- **`--device` is your yes to what that changes outside your computer.** It adds the phone to your Apple team's list of development devices (a team may register a limited number a year, and a device stays on the list until the membership year renews) and lets Xcode make a development profile for the app. The app is signed for that phone only: a build to try, never one for the store.
|
|
30
|
+
- Every way it can stop is an answer with what to do: no phone connected, more than one (it lists them by model and builds nothing), Developer Mode off (on the phone: Settings, Privacy & Security, Developer Mode; the phone restarts), a phone that does not trust the Mac, a locked phone (it waits a minute for you to unlock it), no Apple account signed in to Xcode, a team with no registered phone, a build that failed. It never says or returns the phone's id, its name or your team's id.
|
|
31
|
+
- The team is `HOMIE_APPLE_TEAM`, else the one team among your keychain's development identities; it says which it used. With none, or more than one, it stops and asks for `HOMIE_APPLE_TEAM`.
|
|
32
|
+
- `setup status` and `standalone plan` have a row for a phone to try a build on: which model is connected and whether its Developer Mode is on, or "could not check".
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **What an older site means for a copy was said too broadly.** Only **Quick play** needs your live site on 0.32.0 or later: it asks the site which room to join, and an older site does not let an app read the answer. A room made in the app or joined by its code connects through an older site too. We saw that against a site on 0.31.0; it is not promised for every older version. The plan, the build, the chat tool, the skill and the guide now say this.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- The guide's "What has been run and what has not" now has what was run on 2026-10-07: an iOS build on a real iPhone, signed for a development team and installed over the cable, playing offline with its bots, and an online private room from that phone through a studio's live site. The new command built, signed and installed a game on that iPhone; the phone was locked, so its last two steps (starting the game and finding it running) have been run against stand-ins only. Still never run: anything under Steam, Developer ID signing and notarization, the iOS archive for the App Store, the GitHub workflow, the Windows and Linux builds started on any computer, a real Android phone, and Quick play online from a phone.
|
|
41
|
+
|
|
42
|
+
### Upgrade notes
|
|
43
|
+
|
|
44
|
+
- A run on a phone needs a Mac with Xcode signed in to your Apple account, and the phone unlocked, trusting the Mac, with Developer Mode on.
|
|
45
|
+
- There is no `--device` for an Android phone yet: `standalone run <game> --for android` hands over to Capacitor, which asks which phone or emulator. That has been run on an emulator only.
|
|
46
|
+
|
|
20
47
|
## [0.32.0] - 2026-10-06
|
|
21
48
|
|
|
22
|
-
**Plugin 0.33.0** · [#54](https://github.com/homie-rocks/homie/pull/54)
|
|
49
|
+
**Plugin 0.33.0** · [#54](https://github.com/homie-rocks/homie/pull/54) · [release-2026-10-06-studio-0.32.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-06-studio-0.32.0)
|
|
23
50
|
|
|
24
51
|
A game can now be an app of its own: a desktop app for macOS, Windows and Linux and a phone app for iOS and Android, the same web game in a thin shell that still plays through your studio's own site.
|
|
25
52
|
|
|
@@ -46,7 +73,7 @@ A game can now be an app of its own: a desktop app for macOS, Windows and Linux
|
|
|
46
73
|
|
|
47
74
|
### Upgrade notes
|
|
48
75
|
|
|
49
|
-
- **Upgrade and deploy before
|
|
76
|
+
- **Upgrade and deploy before Quick play can find a room in a standalone copy.** Quick play asks your live site which room to join, and only a site deployed with 0.32.0 or later lets an app read the answer. Until then Quick play is offline with the game's bots. (Corrected in 0.32.1: a room made or joined by its code connects through an older site too.)
|
|
50
77
|
- The address of your site is built into every copy and cannot be changed in a copy that has shipped. Put the studio on its own domain before you ship: a workers.dev address belongs to the Cloudflare account.
|
|
51
78
|
- Before you ship, name the game's revision in `game.json` (`"netplay": { "version": "1" }`) and raise it with every change an older copy cannot play with. Older copies then keep playing with each other, and say "Update <Name> to play online with everyone." when they meet a newer one.
|
|
52
79
|
- In an app the game's code is trusted with everything the app's page has, and on phones with Capacitor's native bridge. That is fine for your own code; do not load scripts from the network in a game you ship as an app.
|
package/README.md
CHANGED
|
@@ -823,12 +823,19 @@ every other target says "built, not started on this computer".
|
|
|
823
823
|
|
|
824
824
|
**What the standalone game does not have (v1):** player accounts and sign-in, cloud saves (saves stay on the
|
|
825
825
|
device and are lost when the app is uninstalled), the shop, room chat, watching and the big screen, servers
|
|
826
|
-
other than the public one, automatic updates, and a signed Windows build.
|
|
827
|
-
**and deployed
|
|
826
|
+
other than the public one, automatic updates, and a signed Windows build. For Quick play to find a room, a
|
|
827
|
+
studio must be on 0.32.0 or later **and deployed**; a room made or joined by its code works with an older site
|
|
828
|
+
too (seen against 0.31.0, not promised for every older version).
|
|
829
|
+
|
|
830
|
+
**On your own iPhone or iPad:** `standalone run <id> --for ios --device` builds the game for the one phone
|
|
831
|
+
plugged in, signs it for your Apple team, installs it over the cable, starts it and looks for it among the
|
|
832
|
+
phone's running programs. `--device` is your yes to what it changes: it adds the phone to your Apple team's list
|
|
833
|
+
of development devices.
|
|
828
834
|
|
|
829
835
|
**What has not been run:** the Windows and Linux builds have never been started; nothing has been started by
|
|
830
836
|
Steam (its overlay, which Electron apps often do not show, included); a Developer ID signature, notarization and
|
|
831
|
-
the iOS archive have never been made; nothing has run on a real phone
|
|
837
|
+
the iOS archive have never been made; nothing has run on a real Android phone, and Quick play has not been seen
|
|
838
|
+
online from a phone. The guide lists it all.
|
|
832
839
|
|
|
833
840
|
## How it feels: the Game Lab (`homie-studio lab`)
|
|
834
841
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -295,6 +295,13 @@
|
|
|
295
295
|
* id, and an unsigned release is said as UNSIGNED and is not a success); run:
|
|
296
296
|
* the built copy, here; steam: Steam's build file; ci: a GitHub workflow that
|
|
297
297
|
* builds all five. Nothing is ever uploaded to Steam or a store
|
|
298
|
+
* homie-studio standalone run <game> --for ios --device
|
|
299
|
+
* the game on the ONE iPhone or iPad plugged in to this Mac: built, signed for
|
|
300
|
+
* your Apple team (HOMIE_APPLE_TEAM, else the keychain's only one), installed
|
|
301
|
+
* over the cable, started, and then looked for among the phone's running
|
|
302
|
+
* programs. --device is your yes to what it changes outside this computer: it
|
|
303
|
+
* adds the phone to your Apple team's list of development devices. A phone with
|
|
304
|
+
* Developer Mode off, locked, or not trusting this Mac is said with its fix
|
|
298
305
|
*
|
|
299
306
|
* homie-studio statusline the current build in one line (what Claude Code's status line shows)
|
|
300
307
|
* homie-studio statusline --install [--project <folder>]
|
|
@@ -361,7 +368,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
|
|
|
361
368
|
|
|
362
369
|
const argv = process.argv.slice(2);
|
|
363
370
|
const flags = new Map();
|
|
364
|
-
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release'];
|
|
371
|
+
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
365
372
|
const positional = [];
|
|
366
373
|
for (let i = 0; i < argv.length; i++) {
|
|
367
374
|
const a = argv[i];
|
|
@@ -806,6 +813,8 @@ const OUTWARD_FLAGS = {
|
|
|
806
813
|
'shop connect': ['url', 'managed', 'live'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send'],
|
|
807
814
|
'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
|
|
808
815
|
'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
|
|
816
|
+
// With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
|
|
817
|
+
'standalone run': ['for', 'device', 'site'],
|
|
809
818
|
};
|
|
810
819
|
/** Every command takes these: how it answers, never what it does. */
|
|
811
820
|
const ANY_COMMAND = ['json', 'help', 'version'];
|
package/lib/standalone-cli.mjs
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* standalone build <game> [--for …] [--release] [--site <address>] [--build <n>]
|
|
10
10
|
* builds the game for the web, then every target this computer can make; one it cannot is skipped with why.
|
|
11
11
|
* standalone run <game> [--for ios|android] the built copy, started here (none: this computer's desktop app)
|
|
12
|
+
* standalone run <game> --for ios --device built for the one iPhone or iPad plugged in, signed for the person's
|
|
13
|
+
* Apple team (HOMIE_APPLE_TEAM, else the keychain's only one), installed over the cable, started, and then looked
|
|
14
|
+
* for among the phone's running programs. --device is the yes to what it changes: the phone is added to the Apple
|
|
15
|
+
* team's list of development devices.
|
|
12
16
|
* standalone steam <game> Steam's build files from game.json's numbers; uploads nothing
|
|
13
17
|
* standalone ci <game> .github/workflows/standalone.yml: every target on GitHub's machines
|
|
14
18
|
*
|
|
@@ -35,7 +39,9 @@ export async function standaloneCommand(root, sub, positional, flags, { log = ()
|
|
|
35
39
|
switch (sub ?? 'plan') {
|
|
36
40
|
case 'plan': return told(await standalonePlan(root, id, opts));
|
|
37
41
|
case 'build': return told(await standaloneBuild(root, id, opts));
|
|
38
|
-
|
|
42
|
+
// --device: onto the one iPhone or iPad that is plugged in. It is the person's yes to what that changes outside
|
|
43
|
+
// this computer (DEVICE_ADDS), and the result carries its own words like a build's.
|
|
44
|
+
case 'run': { const r = await standaloneRun(root, id, { for: flags.has('for') ? flags.get('for') : null, device: flags.has('device'), site: str('site'), log, ...on }); return r?.device ? { ...r, say: standaloneLines(r) } : r; }
|
|
39
45
|
case 'steam': return standaloneSteam(root, id);
|
|
40
46
|
case 'ci': return standaloneCi(root, id);
|
|
41
47
|
default: return { ok: false, command: 'standalone', why: `"${sub}" is not a standalone command: ${STANDALONE_VERBS.join(', ')}` };
|
|
@@ -52,8 +58,30 @@ export function missingLines(missing) {
|
|
|
52
58
|
return ['What the standalone game does not have (v1):', ...missing.map((m) => ` - ${m.what}: ${m.why}.`)];
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/** A run on a real phone: what happened, in the order a person needs it, with what it changed outside this computer. */
|
|
62
|
+
function deviceLines(r) {
|
|
63
|
+
const L = [];
|
|
64
|
+
const on = r.phone?.model ? `the ${r.phone.model}` : 'the phone';
|
|
65
|
+
if (r.ok) {
|
|
66
|
+
L.push(`${r.name} is on ${on} and running: it was built, installed over the cable, started, and then found among the phone's running programs.`,
|
|
67
|
+
` Signed for ${r.team.from === 'HOMIE_APPLE_TEAM' ? 'the team HOMIE_APPLE_TEAM names' : r.team.from}, as ${r.appId}${r.appIdFrom === 'derived' ? ' (a made-up id for trying a build)' : ''}.`,
|
|
68
|
+
` ${r.adds}`, ...r.notes.map((n) => ` ${n}`));
|
|
69
|
+
} else {
|
|
70
|
+
L.push(`NOT on ${r.phone?.model ? on : 'a phone'}: ${r.why}.`);
|
|
71
|
+
if (r.fix) L.push(` ${r.fix}`);
|
|
72
|
+
if (r.installed) L.push(` The game IS installed on ${on}; it was not seen running.`);
|
|
73
|
+
else if (r.built) L.push(' The app was built and signed; it is not on the phone.');
|
|
74
|
+
// What it may already have changed outside this computer, said even when the rest did not work.
|
|
75
|
+
if (r.team && r.built) L.push(' The build was signed: the phone is on your Apple team\'s list of development devices now, if it was not before.');
|
|
76
|
+
else if (r.team && r.stage === 'build-failed') L.push(' The build stopped before the app was made; it may have added the phone to your Apple team\'s list of development devices on the way.');
|
|
77
|
+
}
|
|
78
|
+
for (const w of r.warnings ?? []) L.push(`Warning: ${w}`);
|
|
79
|
+
return L;
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
export function standaloneLines(r) {
|
|
56
83
|
const L = [];
|
|
84
|
+
if (r.command === 'standalone run' && r.device) return deviceLines(r);
|
|
57
85
|
// A refusal with something to do about it (a release with no id yet, Steam with no numbers) still says it all.
|
|
58
86
|
if (r.ok === false && !r.targets && !r.rows) { L.push(r.why ?? 'failed'); if (r.instead) L.push('', r.instead); return L; }
|
|
59
87
|
switch (r.command) {
|
|
@@ -89,7 +117,7 @@ export function standaloneLines(r) {
|
|
|
89
117
|
break;
|
|
90
118
|
}
|
|
91
119
|
case 'standalone run':
|
|
92
|
-
L.push(`${r.game} (${r.target}) ended.`);
|
|
120
|
+
if (r.device) L.push(...deviceLines(r)); else L.push(`${r.game} (${r.target}) ended.`);
|
|
93
121
|
break;
|
|
94
122
|
case 'standalone steam':
|
|
95
123
|
L.push(`Steam's build file for app ${r.app}: ${r.dir}/${r.wrote.join(', ')} (each depot is that system's release build, out/<system>/release/).`,
|
package/lib/standalone-tools.mjs
CHANGED
|
@@ -11,20 +11,25 @@
|
|
|
11
11
|
* Nothing is ever uploaded to Steam or a store from here, and no signing key is taken in chat: a release reads the
|
|
12
12
|
* environment only. What the standalone game does not have is said to the person as it is, every time.
|
|
13
13
|
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
14
15
|
import { join } from 'node:path';
|
|
15
16
|
import { SAY_MISSING, standaloneLines } from './standalone-cli.mjs';
|
|
16
|
-
import {
|
|
17
|
+
import { compareVersions } from './changelog.mjs';
|
|
18
|
+
import { DEVICE_ADDS, STANDALONE_SINCE, standalonePlan, toolkitNote } from './standalone.mjs';
|
|
19
|
+
import { pinnedVersion } from './upgrade.mjs';
|
|
17
20
|
import { TARGETS } from './standalone-files.mjs';
|
|
18
21
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
19
22
|
|
|
20
23
|
export { SAY_MISSING };
|
|
24
|
+
/** The first toolkit with `standalone run --device`. */
|
|
25
|
+
export const DEVICE_SINCE = '0.32.1';
|
|
21
26
|
|
|
22
27
|
export function standaloneToolDefs(ctx, h) {
|
|
23
28
|
const { ok, fail, cli, stillRunning, whyOf, needsInstall, str, STUDIO_ARG } = h;
|
|
24
29
|
return [
|
|
25
30
|
{
|
|
26
31
|
name: 'game_standalone', title: 'Make a standalone game',
|
|
27
|
-
description: `Make one of the studio's games into an app of its own: a desktop app for macOS, Windows and Linux (the kind of build Steam accepts for upload) and a phone app for iOS and Android (the files the App Store and Google Play accept for upload; each store's review, fees and rules are its own, and nothing here makes an app pass). It is the same web build of the game in a thin shell (Electron on computers, Capacitor on phones); multiplayer still goes through the studio's own deployed site, and with no connection the game plays offline with its bots. Use it when the person asks for a Steam build, a desktop app, a download, an iPhone or Android app, or "a standalone version". ALWAYS call it with plan: true first and tell the person what it says: which targets this computer can build and what the others need, the address that gets built into every copy, and what a standalone copy does NOT have in this version (no player accounts, no cloud saves: saves stay on the device and are lost when the app is uninstalled, no shop, no chat). Then, when they say go, call it again without plan: it builds as a background job that takes minutes (the first build downloads the wrapper tools into the studio's .studio/ folder): it answers with a job, and studio_job { "job": … } gives the result in words when it is done; read that result to the person, including its list of what the standalone game does not have. A target whose tool is missing on this computer is skipped with its fix and the others still build; only the desktop build for this computer's own system is started once to see that the game loads, and every other build says "built, not started on this computer". It never uploads anything and never signs with a key from the chat: a release (release: true) signs from environment variables the person sets themselves, needs the app's id written in game.json first (the tool gives the exact lines), and says UNSIGNED when it could not sign.
|
|
32
|
+
description: `Make one of the studio's games into an app of its own: a desktop app for macOS, Windows and Linux (the kind of build Steam accepts for upload) and a phone app for iOS and Android (the files the App Store and Google Play accept for upload; each store's review, fees and rules are its own, and nothing here makes an app pass). It is the same web build of the game in a thin shell (Electron on computers, Capacitor on phones); multiplayer still goes through the studio's own deployed site, and with no connection the game plays offline with its bots. Use it when the person asks for a Steam build, a desktop app, a download, an iPhone or Android app, or "a standalone version". ALWAYS call it with plan: true first and tell the person what it says: which targets this computer can build and what the others need, the address that gets built into every copy, and what a standalone copy does NOT have in this version (no player accounts, no cloud saves: saves stay on the device and are lost when the app is uninstalled, no shop, no chat). Then, when they say go, call it again without plan: it builds as a background job that takes minutes (the first build downloads the wrapper tools into the studio's .studio/ folder): it answers with a job, and studio_job { "job": … } gives the result in words when it is done; read that result to the person, including its list of what the standalone game does not have. A target whose tool is missing on this computer is skipped with its fix and the others still build; only the desktop build for this computer's own system is started once to see that the game loads, and every other build says "built, not started on this computer". It never uploads anything and never signs with a key from the chat: a release (release: true) signs from environment variables the person sets themselves, needs the app's id written in game.json first (the tool gives the exact lines), and says UNSIGNED when it could not sign. In a copy, Quick play finds a room only when the studio's live site is on @homie-rocks/studio ${STANDALONE_SINCE} or later AND deployed since; a room made or joined by its code works with an older site too (seen against 0.31.0, not promised for every older version). ON THE PERSON'S OWN iPHONE OR iPAD: device: true builds the game for the one phone plugged in to this Mac, signs it for their Apple team, installs it over the cable, starts it, and then looks for it among the phone's running programs (it says "running" only when it is there). ${DEVICE_ADDS} So call it with device: true only after the person said yes to that, in those words; it needs a Mac with Xcode signed in to their Apple account, the phone unlocked and trusting the Mac, and Developer Mode on (Settings, Privacy & Security, Developer Mode; the phone restarts). It is a background job too. There is no such run for an Android phone in this version.`,
|
|
28
33
|
inputSchema: {
|
|
29
34
|
type: 'object',
|
|
30
35
|
properties: {
|
|
@@ -32,6 +37,7 @@ export function standaloneToolDefs(ctx, h) {
|
|
|
32
37
|
for: { type: 'array', items: { type: 'string', enum: [...TARGETS] }, description: 'Which to build (default: every one this computer can make)' },
|
|
33
38
|
plan: { type: 'boolean', description: 'Only say what would be built and what is missing; build nothing, install nothing. Do this first' },
|
|
34
39
|
release: { type: 'boolean', description: 'A build for a store or Steam: needs "standalone.appId" in the game\'s game.json, and signs from the environment. Only when the person asked for a release' },
|
|
40
|
+
device: { type: 'boolean', description: 'Put the game on the one iPhone or iPad plugged in to this Mac and start it. It adds the phone to the person\'s Apple team\'s list of development devices: only after they said yes to that' },
|
|
35
41
|
...STUDIO_ARG,
|
|
36
42
|
},
|
|
37
43
|
required: ['game'],
|
|
@@ -50,6 +56,18 @@ export function standaloneToolDefs(ctx, h) {
|
|
|
50
56
|
}
|
|
51
57
|
const need = needsInstall(ctx, root);
|
|
52
58
|
if (need) return need;
|
|
59
|
+
if (a.device === true) {
|
|
60
|
+
// The run on a phone arrived in 0.32.1: a studio whose own toolkit is older has no such flag (and would
|
|
61
|
+
// read the words as a different command), so this toolkit's runs.
|
|
62
|
+
let pinned = null;
|
|
63
|
+
try { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); pinned = pinnedVersion(pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio']); } catch { pinned = null; }
|
|
64
|
+
const ours = pinned && compareVersions(pinned, DEVICE_SINCE) < 0;
|
|
65
|
+
const r = await cli(ctx, root, `standalone run ${game} on a phone`, ['standalone', 'run', game, '--for', 'ios', '--device'], ours ? { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs') } : {});
|
|
66
|
+
if (!r.ended) return stillRunning(r.job, `Putting ${game} on the phone (it takes minutes the first time; its last lines say where it is)`);
|
|
67
|
+
if (!r.result?.device) return fail(`Not on the phone: ${whyOf(r.job)}`, r.result ? { kind: 'standalone-device', ...r.result } : undefined);
|
|
68
|
+
const text = (r.result.say ?? standaloneLines(r.result)).join('\n');
|
|
69
|
+
return r.result.ok ? ok(text, { kind: 'standalone-device', ...r.result }) : fail(text, { kind: 'standalone-device', ...r.result });
|
|
70
|
+
}
|
|
53
71
|
const args = ['standalone', 'build', game, ...(only ? ['--for', only] : []), ...(a.release === true ? ['--release'] : [])];
|
|
54
72
|
const r = await cli(ctx, root, `standalone build ${game}`, args, old ? { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs') } : {});
|
|
55
73
|
// The words are in the result itself (`say`), so studio_job says them too when the build outlasts this call.
|
package/lib/standalone.mjs
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* "built, not started on this computer". A release that could not be signed is said as UNSIGNED and is not `ok`.
|
|
25
25
|
*/
|
|
26
26
|
import { spawn } from 'node:child_process';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
27
|
+
import { X509Certificate } from 'node:crypto';
|
|
28
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { homedir, tmpdir } from 'node:os';
|
|
29
30
|
import { dirname, join, relative } from 'node:path';
|
|
30
31
|
import { build as buildSite, uiOf } from './build.mjs';
|
|
31
32
|
import { compareVersions } from './changelog.mjs';
|
|
@@ -222,6 +223,20 @@ export async function standaloneRows({ platform = process.platform, exec = runTo
|
|
|
222
223
|
fix: mac && team ? null : { who: 'person', open: 'https://developer.apple.com/account#MembershipDetailsCard', say: `With an Apple Developer membership: sign in to it in Xcode (Settings, Accounts), and put your team id (ten letters and digits, on that page) in HOMIE_APPLE_TEAM. ${ENV_HOW}` },
|
|
223
224
|
});
|
|
224
225
|
}
|
|
226
|
+
// A phone for trying a build on (standalone run --for ios --device): what Xcode's own device list says is
|
|
227
|
+
// connected now, by model, and whether its Developer Mode is on. Nothing is asked of the phone itself.
|
|
228
|
+
if (mac) {
|
|
229
|
+
const found = await phones({ exec, env });
|
|
230
|
+
const here = found.ok ? found.phones.filter((p) => p.here) : [];
|
|
231
|
+
const ready = here.filter((p) => p.trusted && p.developerMode === true);
|
|
232
|
+
const one = (p) => `${p.model}${!p.trusted ? ', which does not trust this Mac yet' : p.developerMode === true ? ', Developer Mode on' : p.developerMode === false ? ', Developer Mode OFF' : ', Developer Mode not known'}`;
|
|
233
|
+
rows.push({
|
|
234
|
+
id: 'standalone-phone', label: 'A phone for trying a build', need: 'optional', state: !found.ok ? 'unknown' : ready.length === 1 && here.length === 1 ? 'ok' : 'optional',
|
|
235
|
+
detail: !found.ok ? `could not check (${found.why})` : here.length ? `connected: ${here.map(one).join('; ')}${here.length > 1 ? ' (a run takes one phone: unplug the others)' : ''}` : 'no iPhone or iPad is connected now',
|
|
236
|
+
unlocks: 'the game on your own iPhone or iPad, over the cable (standalone run <game> --for ios --device): it adds the phone to your Apple team\'s device list',
|
|
237
|
+
fix: !found.ok || (ready.length === 1 && here.length === 1) ? null : !here.length ? { who: 'person', say: 'Plug the phone in with a cable, unlock it, and tap Trust if it asks about this computer.' } : here.some((p) => !p.trusted) ? { who: 'person', say: deviceTrouble('not paired').fix } : here.some((p) => p.developerMode !== true) ? { who: 'person', say: deviceTrouble('Developer Mode disabled').fix } : { who: 'person', say: 'Unplug all but the one phone to try the game on.' },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
225
240
|
// steamcmd: only to upload, which a person does.
|
|
226
241
|
{
|
|
227
242
|
const r = await exec(platform === 'win32' ? 'where' : 'which', ['steamcmd'], { timeout: 5000 });
|
|
@@ -292,7 +307,7 @@ export function toolkitNote(root) {
|
|
|
292
307
|
try { pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return null; }
|
|
293
308
|
const pinned = pinnedVersion(pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio']);
|
|
294
309
|
if (!pinned || compareVersions(pinned, STANDALONE_SINCE) >= 0) return null;
|
|
295
|
-
return `This studio pins @homie-rocks/studio ${pinned}, from before standalone copies (${STANDALONE_SINCE}): its live site
|
|
310
|
+
return `This studio pins @homie-rocks/studio ${pinned}, from before standalone copies (${STANDALONE_SINCE}): its live site does not let a standalone copy read which room to join, so in a copy Quick play is OFFLINE until the studio is upgraded to ${STANDALONE_SINCE} or later AND deployed again. A room made or joined by its code still connects (seen against 0.31.0, not promised for every older version).`;
|
|
296
311
|
}
|
|
297
312
|
|
|
298
313
|
/** Everything a plan and a build both need to know, read once. */
|
|
@@ -366,7 +381,7 @@ export async function standalonePlan(root, id, opts = {}) {
|
|
|
366
381
|
icon: { from: s.icon.from, note: s.icon.note }, steam: s.meta.steam,
|
|
367
382
|
dir: relative(root, s.dir), targets, rows: s.rows, warnings: s.warnings, missing: MISSING, pins: STANDALONE_PINS,
|
|
368
383
|
...(s.note ? { toolkitNote: s.note } : {}),
|
|
369
|
-
needs: `
|
|
384
|
+
needs: `Quick play finds a room only when the live site runs @homie-rocks/studio ${STANDALONE_SINCE} or later (upgrade the studio, then deploy). A room made or joined by its code works with an older site too (seen against 0.31.0, not promised for every older version). This plan did not ask the site which version it runs.`,
|
|
370
385
|
...(refused ? { why: refused.why, instead: refused.instead, needsField: 'appId' } : {}),
|
|
371
386
|
};
|
|
372
387
|
}
|
|
@@ -560,15 +575,22 @@ async function capProject(s, platform, { say }) {
|
|
|
560
575
|
const patch = (file, fn) => { if (!existsSync(file)) return false; const before = readFileSync(file, 'utf8'); const after = fn(before); if (after !== before) writeFileSync(file, after); return true; };
|
|
561
576
|
const freshOut = (s, target) => { const out = join(s.dir, 'out', target, s.kind); rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true }); return out; };
|
|
562
577
|
|
|
563
|
-
|
|
578
|
+
/** The iOS project with this build's web folder in it, ready for xcodebuild: { ok, notes, project } or { ok: false, why }. */
|
|
579
|
+
async function iosProject(s, { say }) {
|
|
564
580
|
const p = await capProject(s, 'ios', { say });
|
|
565
|
-
if (!p.ok) return {
|
|
581
|
+
if (!p.ok) return { ok: false, why: p.why };
|
|
566
582
|
const notes = [...p.notes];
|
|
567
583
|
if (!patch(join(s.dir, 'ios', 'App', 'App', 'Info.plist'), (t) => patchInfoPlist(t, s.meta.orientation))) notes.push('Info.plist was not where Capacitor puts it: the way the game is held was not set');
|
|
568
584
|
const sync = await tool(s, 'cap', ['sync', 'ios'], { env: p.env, timeout: 15 * 60_000 });
|
|
569
|
-
if (sync.code !== 0) return {
|
|
585
|
+
if (sync.code !== 0) return { ok: false, why: `cap sync ios failed: ${tail(sync)}` };
|
|
570
586
|
const app = join(s.dir, 'ios', 'App');
|
|
571
|
-
|
|
587
|
+
return { ok: true, notes, project: existsSync(join(app, 'App.xcworkspace')) ? ['-workspace', join(app, 'App.xcworkspace')] : ['-project', join(app, 'App.xcodeproj')] };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function buildIos(s, { say }) {
|
|
591
|
+
const p = await iosProject(s, { say });
|
|
592
|
+
if (!p.ok) return { state: 'failed', why: p.why };
|
|
593
|
+
const { notes, project } = p;
|
|
572
594
|
const derived = join(s.dir, 'ios', 'build');
|
|
573
595
|
const version = [`MARKETING_VERSION=${s.meta.version}`, `CURRENT_PROJECT_VERSION=${s.meta.build}`];
|
|
574
596
|
const env = toolEnv(s.env);
|
|
@@ -753,9 +775,18 @@ export async function standaloneBuild(root, id, opts = {}) {
|
|
|
753
775
|
/* ------------------------------------------------------------------ run, steam, ci, deploy */
|
|
754
776
|
|
|
755
777
|
/** `standalone run <game> [--for mac|windows|linux|ios|android]`: the copy that was built, started here. */
|
|
756
|
-
export async function standaloneRun(root, id,
|
|
778
|
+
export async function standaloneRun(root, id, opts = {}) {
|
|
779
|
+
const { for: said = null, platform = process.platform, env = process.env, exec = runTool, home = homedir(), device = false } = opts;
|
|
757
780
|
const g = gameOf(root, id);
|
|
758
781
|
if (g.why) return { ok: false, command: 'standalone run', why: g.why };
|
|
782
|
+
// --device: onto the one phone that is plugged in (runOnPhone). It is the person's yes to what that changes.
|
|
783
|
+
if (device) {
|
|
784
|
+
const asked = targetsOf(said ?? 'ios');
|
|
785
|
+
if (asked.why || asked.targets.length !== 1) return { ok: false, command: 'standalone run', why: asked.why ?? '--device takes one target: --for ios' };
|
|
786
|
+
if (asked.targets[0] === 'android') return { ok: false, command: 'standalone run', why: '--device is for an iPhone or iPad in this version. For an Android phone, leave --device out: `standalone run <game> --for android` hands over to Capacitor, which lists the phones and emulators it finds over adb and asks which one. That has been run on an emulator, never on a real Android phone.' };
|
|
787
|
+
if (asked.targets[0] !== 'ios') return { ok: false, command: 'standalone run', why: `--device puts the game on a phone: --for ios. A ${asked.targets[0]} build runs on a computer (leave --device out).` };
|
|
788
|
+
return runOnPhone(root, id, opts);
|
|
789
|
+
}
|
|
759
790
|
const dir = standaloneDir(root, g.game.id);
|
|
760
791
|
const host = { darwin: 'mac', win32: 'windows', linux: 'linux' }[platform] ?? 'linux';
|
|
761
792
|
const t = targetsOf(said ?? host);
|
|
@@ -865,4 +896,179 @@ export function standaloneDeployNotes(root) {
|
|
|
865
896
|
return notes;
|
|
866
897
|
}
|
|
867
898
|
|
|
899
|
+
/* ------------------------------------------------------------------ a real iPhone or iPad */
|
|
900
|
+
|
|
901
|
+
/** What `--device` changes outside this computer, said before it happens, in the result, in the tool and in the skill. */
|
|
902
|
+
export const DEVICE_ADDS = 'This adds the phone to your Apple team\'s list of development devices (a team may register a limited number a year, and a device stays on the list until the membership year renews) and lets Xcode make a development profile for this app. Apple signs the app for that phone only: it is a build to try, never one for the store.';
|
|
903
|
+
|
|
904
|
+
/** A devicectl command whose answer is a JSON file: { r, json } (json null when it wrote none). */
|
|
905
|
+
async function jsonOf(s, args, { timeout = 60_000 } = {}) {
|
|
906
|
+
const dir = mkdtempSync(join(tmpdir(), 'standalone-device-'));
|
|
907
|
+
const file = join(dir, 'out.json');
|
|
908
|
+
const r = await s.exec('xcrun', ['devicectl', ...args, '--json-output', file], { env: toolEnv(s.env), timeout });
|
|
909
|
+
let json = null;
|
|
910
|
+
try { json = JSON.parse(readFileSync(file, 'utf8')); } catch { json = null; }
|
|
911
|
+
rmSync(dir, { recursive: true, force: true });
|
|
912
|
+
return { r, json };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* The iPhones and iPads this Mac knows, as Xcode's own devicectl lists them: { ok, phones: [{ id, udid, model,
|
|
917
|
+
* trusted, here, developerMode }] } or { ok: false, why }. `here`: plugged in or on the network now. An id never
|
|
918
|
+
* leaves this module in a result or a line: a person is told a phone by its model.
|
|
919
|
+
*/
|
|
920
|
+
export async function phones({ exec = runTool, env = process.env } = {}) {
|
|
921
|
+
const { r, json } = await jsonOf({ exec, env }, ['list', 'devices']);
|
|
922
|
+
const list = json?.result?.devices;
|
|
923
|
+
if (r.code !== 0 || !Array.isArray(list)) return { ok: false, why: r.code === 127 ? 'xcrun is not here (Xcode is not installed)' : `Xcode's device list did not answer (${tail(r, 2) || `code ${r.code}`})` };
|
|
924
|
+
return {
|
|
925
|
+
ok: true,
|
|
926
|
+
phones: list.filter((d) => d?.hardwareProperties?.reality === 'physical' && ['iPhone', 'iPad'].includes(d?.hardwareProperties?.deviceType)).map((d) => ({
|
|
927
|
+
id: String(d.identifier ?? ''), udid: String(d.hardwareProperties.udid ?? ''), model: String(d.hardwareProperties.marketingName ?? d.hardwareProperties.deviceType),
|
|
928
|
+
trusted: d.connectionProperties?.pairingState === 'paired', here: ['connected', 'disconnected'].includes(d.connectionProperties?.tunnelState),
|
|
929
|
+
developerMode: d.deviceProperties?.developerModeStatus === 'enabled' ? true : d.deviceProperties?.developerModeStatus === 'disabled' ? false : null,
|
|
930
|
+
})),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const TEAM_ID = /^[A-Z0-9]{10}$/;
|
|
935
|
+
/**
|
|
936
|
+
* The Apple team a build to try is signed for: HOMIE_APPLE_TEAM, else the ONE team among the keychain's development
|
|
937
|
+
* identities. An identity's name carries a person's id, not the team's: the team is in its certificate (the OU of
|
|
938
|
+
* its subject), which is read here and nothing else of it is kept. { team, from } or { team: null, why, teams }.
|
|
939
|
+
*/
|
|
940
|
+
export async function appleTeam({ env = process.env, exec = runTool } = {}) {
|
|
941
|
+
const said = String(env.HOMIE_APPLE_TEAM ?? '').trim();
|
|
942
|
+
if (said) return TEAM_ID.test(said) ? { team: said, from: 'HOMIE_APPLE_TEAM' } : { team: null, teams: 0, why: 'HOMIE_APPLE_TEAM is set and is not a team id (ten capital letters and digits, on your Apple Developer account\'s Membership page)' };
|
|
943
|
+
const ids = await exec('security', ['find-identity', '-v', '-p', 'codesigning'], { timeout: 10_000 });
|
|
944
|
+
if (ids.code !== 0) return { team: null, teams: 0, why: 'could not check the keychain for a development identity (the security tool did not answer)' };
|
|
945
|
+
const hashes = new Set(ids.stdout.split('\n').filter((l) => /"(Apple Development|iPhone Developer): /.test(l)).map((l) => /\b([0-9A-F]{40})\b/.exec(l)?.[1]).filter(Boolean));
|
|
946
|
+
if (!hashes.size) return { team: null, teams: 0, why: 'the keychain has no Apple Development identity: sign in to your Apple account in Xcode (Settings, Accounts), which makes one' };
|
|
947
|
+
const certs = await exec('security', ['find-certificate', '-a', '-p'], { timeout: 20_000 });
|
|
948
|
+
const teams = new Set();
|
|
949
|
+
for (const pem of certs.stdout.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []) {
|
|
950
|
+
try {
|
|
951
|
+
const c = new X509Certificate(pem);
|
|
952
|
+
if (!hashes.has(c.fingerprint.replace(/:/g, ''))) continue;
|
|
953
|
+
const ou = /^OU=([A-Z0-9]{10})$/m.exec(c.subject)?.[1];
|
|
954
|
+
if (ou) teams.add(ou);
|
|
955
|
+
} catch { /* not a certificate this reads */ }
|
|
956
|
+
}
|
|
957
|
+
if (teams.size === 1) return { team: [...teams][0], from: 'the one Apple team among the keychain\'s development identities' };
|
|
958
|
+
return { team: null, teams: teams.size, why: teams.size ? `the keychain has development identities of ${teams.size} Apple teams, and which one is yours to say` : 'the team of the keychain\'s development identity could not be read' };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* What stopped xcodebuild, devicectl or the phone, in the words a person can act on: { stage, why, fix } or null
|
|
963
|
+
* when the text is none of the things met so far (the caller then says the tool's own last lines).
|
|
964
|
+
*/
|
|
965
|
+
export function deviceTrouble(text) {
|
|
966
|
+
const t = String(text ?? '');
|
|
967
|
+
if (/Developer Mode disabled|enable Developer Mode|developer mode is (?:not enabled|disabled)/i.test(t)) return { stage: 'developer-mode', why: 'Developer Mode is off on the phone, so it takes no build to try', fix: 'On the phone: Settings, Privacy & Security, Developer Mode, turn it on. The phone restarts, then asks once more to turn it on: say yes, unlock it, and run this again. (The switch appears only after the phone has been plugged in to a Mac with Xcode.)' };
|
|
968
|
+
if (/No Accounts?:|No Account for Team|Add a new account in Accounts settings|not signed in|requires? (?:a|an) (?:development team|Apple ID)/i.test(t)) return { stage: 'no-account', why: 'Xcode is not signed in to an Apple account of that team, so nothing can sign the app', fix: 'Open Xcode, Settings, Accounts, add your Apple account (a free one can put a game on your own phone for a week; a Developer membership for longer), then run this again.' };
|
|
969
|
+
if (/has no devices from which to generate a provisioning profile|No profiles for '[^']*' were found/i.test(t)) return { stage: 'no-devices', why: 'your Apple team has no registered phone to make a development profile for, and Apple did not register this one', fix: 'Unlock the phone, keep it plugged in and run this again: it registers the phone. If it says the same, open the project once in Xcode (ios/App/App.xcodeproj in the standalone folder), pick the phone and press Run: Xcode registers it and says what stands in the way.' };
|
|
970
|
+
if (/device (?:is|was) (?:passcode )?locked|is locked|Unlock .* to Continue|could not be unlocked|because the device was not, or could not be, unlocked/i.test(t)) return { stage: 'locked', why: 'the phone is locked', fix: 'Unlock the phone, keep it awake and run this again.' };
|
|
971
|
+
if (/not paired|is not trusted|Trust This Computer|pairing/i.test(t)) return { stage: 'not-trusted', why: 'the phone does not trust this Mac yet', fix: 'Unlock the phone, plug it in and tap Trust when it asks about this computer (it asks for the passcode), then run this again.' };
|
|
972
|
+
if (/Timed out waiting for all destinations|Unable to find a (?:destination|device) matching/i.test(t)) return { stage: 'not-ready', why: 'Xcode waited for the phone and it never became ready', fix: 'Unlock the phone, keep it plugged in and awake, and run this again. If Xcode has never seen this phone, open Xcode once with it plugged in (Window, Devices and Simulators) and let it finish preparing the phone.' };
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* `standalone run <game> --for ios --device`: the game built for the ONE iPhone or iPad that is plugged in, signed
|
|
978
|
+
* for the person's own Apple team, installed over the cable and started, and then LOOKED FOR among the phone's
|
|
979
|
+
* running programs: "started" is said only when it is there. Every way it can stop is an answer with what to do.
|
|
980
|
+
* Nothing of the phone or the team is in the result but the phone's model and where the team's id was found.
|
|
981
|
+
*/
|
|
982
|
+
async function runOnPhone(root, id, opts = {}) {
|
|
983
|
+
const say = opts.log ?? (() => {});
|
|
984
|
+
const command = 'standalone run';
|
|
985
|
+
const stop = (stage, why, extra = {}) => ({ ok: false, command, target: 'ios', device: true, stage, why, ...extra });
|
|
986
|
+
if ((opts.platform ?? process.platform) !== 'darwin') return stop('not-a-mac', 'a game goes onto an iPhone from a Mac with Xcode');
|
|
987
|
+
const s = await survey(root, id, { ...opts, for: 'ios', release: false });
|
|
988
|
+
if (s.why) return stop('game', s.why);
|
|
989
|
+
const g = gate('ios', s);
|
|
990
|
+
if (g) return stop('xcode', g.why, { game: s.game.id, row: g.row?.id ?? null, fix: g.row?.fix?.say ?? null });
|
|
991
|
+
const base = { game: s.game.id, name: s.meta.name, appId: s.meta.appId, appIdFrom: s.meta.appIdFrom, site: s.where.url || null, warnings: s.warnings, adds: DEVICE_ADDS };
|
|
992
|
+
// The phone: exactly one, trusted, here, with Developer Mode on.
|
|
993
|
+
const found = await phones(s);
|
|
994
|
+
if (!found.ok) return stop('could-not-check', `could not look for a phone: ${found.why}`, base);
|
|
995
|
+
const here = found.phones.filter((p) => p.here);
|
|
996
|
+
if (!here.length) {
|
|
997
|
+
const known = found.phones.length;
|
|
998
|
+
return stop('no-phone', known ? `no iPhone or iPad is connected now (this Mac knows ${known}: ${found.phones.map((p) => p.model).join(', ')})` : 'no iPhone or iPad is connected to this Mac', { ...base, fix: 'Plug the phone in with a cable, unlock it, and tap Trust if it asks about this computer. Then run this again.' });
|
|
999
|
+
}
|
|
1000
|
+
if (here.length > 1) return stop('several', `${here.length} phones are connected (${here.map((p) => p.model).join(', ')}), and this puts the game on one`, { ...base, phones: here.map((p) => ({ model: p.model })), fix: 'Unplug all but the one to try the game on (and turn Wi-Fi off on the others, or they stay connected over the network), then run this again.' });
|
|
1001
|
+
const phone = here[0];
|
|
1002
|
+
const device = { model: phone.model };
|
|
1003
|
+
if (!phone.trusted) return stop('not-trusted', `the ${phone.model} does not trust this Mac yet`, { ...base, phone: device, fix: deviceTrouble('not paired').fix });
|
|
1004
|
+
if (phone.developerMode === false) { const t = deviceTrouble('Developer Mode disabled'); return stop(t.stage, `Developer Mode is off on the ${phone.model}, so it takes no build to try`, { ...base, phone: device, fix: t.fix }); }
|
|
1005
|
+
// The team: said, or the keychain's only one. Where it came from is said; the id is not.
|
|
1006
|
+
const signer = await appleTeam(s);
|
|
1007
|
+
if (!signer.team) return stop('no-team', `no Apple team to sign for: ${signer.why}`, { ...base, phone: device, fix: `Put your team id in HOMIE_APPLE_TEAM (ten capital letters and digits, on your Apple Developer account's Membership page). ${ENV_HOW}` });
|
|
1008
|
+
say(`for the ${phone.model}, signed for ${signer.from === 'HOMIE_APPLE_TEAM' ? 'the team HOMIE_APPLE_TEAM names' : signer.from}`);
|
|
1009
|
+
say(DEVICE_ADDS);
|
|
1010
|
+
const on = { ...base, phone: device, team: { from: signer.from } };
|
|
1011
|
+
// The game, built as the site builds it, in the same project a simulator build uses.
|
|
1012
|
+
if (opts.buildSite !== false) {
|
|
1013
|
+
say(`building ${s.game.id} for the web (the same build the site serves)`);
|
|
1014
|
+
try { await (opts.buildSite ?? buildSite)(root, { only: s.game.id, log: say }); } catch (error) { return stop('build-failed', `the game did not build: ${String(error?.message ?? error)}`, on); }
|
|
1015
|
+
}
|
|
1016
|
+
const tools = await installTools(s, { say });
|
|
1017
|
+
if (!tools.ok) return stop('build-failed', tools.why, on);
|
|
1018
|
+
await writeIcons(s.dir, s.icon, { name: s.meta.name, colours: uiOf(s.game) });
|
|
1019
|
+
try { webBundle(root, s.game, s.dir, { meta: s.meta, target: 'ios', site: s.where.url }); } catch (error) { return stop('build-failed', String(error?.message ?? error).split('\n')[0], on); }
|
|
1020
|
+
const p = await iosProject(s, { say });
|
|
1021
|
+
if (!p.ok) return stop('build-failed', p.why, on);
|
|
1022
|
+
const derived = join(s.dir, 'ios', 'build-device');
|
|
1023
|
+
say('xcodebuild: a Debug build for the phone, signed by Xcode for your team (the first one registers the phone and makes its profile)');
|
|
1024
|
+
const b = await s.exec('xcodebuild', [...p.project, '-scheme', 'App', '-configuration', 'Debug', '-destination', `id=${phone.udid}`, '-derivedDataPath', derived, `MARKETING_VERSION=${s.meta.version}`, `CURRENT_PROJECT_VERSION=${s.meta.build}`, `DEVELOPMENT_TEAM=${signer.team}`, 'CODE_SIGN_STYLE=Automatic', '-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration', 'build'], { cwd: s.dir, env: toolEnv(s.env), timeout: 40 * 60_000 });
|
|
1025
|
+
const app = join(derived, 'Build', 'Products', 'Debug-iphoneos', 'App.app');
|
|
1026
|
+
// What a tool printed may name the phone or the team: only its meaning, or its last lines with both taken out, is said.
|
|
1027
|
+
const hush = (text) => String(text).split(phone.udid).join('<the phone>').split(phone.id).join('<the phone>').split(signer.team).join('<your team>');
|
|
1028
|
+
if (b.code !== 0 || !existsSync(app)) {
|
|
1029
|
+
const t = deviceTrouble(`${b.stdout}\n${b.stderr}`);
|
|
1030
|
+
return t ? stop(t.stage, t.why, { ...on, fix: t.fix }) : stop('build-failed', `xcodebuild did not finish: ${hush(tail(b, 12))}`, on);
|
|
1031
|
+
}
|
|
1032
|
+
say(`installing it on the ${phone.model}`);
|
|
1033
|
+
const i = await jsonOf(s, ['device', 'install', 'app', '--device', phone.id, app], { timeout: 10 * 60_000 });
|
|
1034
|
+
if (i.r.code !== 0) {
|
|
1035
|
+
const t = deviceTrouble(`${i.r.stdout}\n${i.r.stderr}`);
|
|
1036
|
+
return t ? stop(t.stage, t.why, { ...on, built: true, fix: t.fix }) : stop('install-failed', `the app was built and the phone did not take it: ${hush(tail(i.r, 6))}`, { ...on, built: true });
|
|
1037
|
+
}
|
|
1038
|
+
// Where the phone put it, from the phone's own list of apps: every Capacitor app's program has the same name, so
|
|
1039
|
+
// a running one is told from another game's by where it is installed.
|
|
1040
|
+
const apps = await jsonOf(s, ['device', 'info', 'apps', '--device', phone.id], { timeout: 60_000 });
|
|
1041
|
+
const listed = (apps.json?.result?.apps ?? []).find((x) => x?.bundleIdentifier === s.meta.appId);
|
|
1042
|
+
if (apps.r.code === 0 && Array.isArray(apps.json?.result?.apps) && !listed) return stop('install-failed', 'the phone said it took the app, and the app is not in the phone\'s list of apps', { ...on, built: true });
|
|
1043
|
+
const where = String(listed?.url ?? i.json?.result?.installedApplications?.[0]?.installationURL ?? '');
|
|
1044
|
+
// A locked phone starts nothing. It is asked for now, and waited for a minute, rather than failing at the last step.
|
|
1045
|
+
const wait = opts.wait ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1046
|
+
const locked = async () => (await jsonOf(s, ['device', 'info', 'lockState', '--device', phone.id], { timeout: 30_000 })).json?.result?.passcodeRequired === true;
|
|
1047
|
+
if (await locked()) {
|
|
1048
|
+
say(`The ${phone.model} is locked: unlock it now (waiting up to a minute).`);
|
|
1049
|
+
for (let t = 0; t < (opts.unlockMs ?? 60_000); t += 3000) { await wait(3000); if (!(await locked())) break; }
|
|
1050
|
+
}
|
|
1051
|
+
say('starting it');
|
|
1052
|
+
const l = await jsonOf(s, ['device', 'process', 'launch', '--device', phone.id, '--terminate-existing', s.meta.appId], { timeout: 2 * 60_000 });
|
|
1053
|
+
if (l.r.code !== 0) {
|
|
1054
|
+
const t = deviceTrouble(`${l.r.stdout}\n${l.r.stderr}`);
|
|
1055
|
+
return stop(t?.stage === 'locked' ? 'locked' : 'not-started', t?.stage === 'locked' ? 'the game is on the phone, and the phone is locked, so it was not started' : `the game is on the phone and did not start: ${hush(tail(l.r, 6))}`, { ...on, built: true, installed: true, fix: `Unlock the phone and tap ${s.meta.name} on its home screen.` });
|
|
1056
|
+
}
|
|
1057
|
+
const pid = Number(l.json?.result?.process?.processIdentifier);
|
|
1058
|
+
// Read back: the game is among the phone's running programs a few seconds later, or it is not said to be running.
|
|
1059
|
+
await wait(4000);
|
|
1060
|
+
const ps = await jsonOf(s, ['device', 'info', 'processes', '--device', phone.id], { timeout: 60_000 });
|
|
1061
|
+
const list = ps.json?.result?.runningProcesses;
|
|
1062
|
+
if (ps.r.code !== 0 || !Array.isArray(list)) return stop('not-confirmed', 'the game was installed and asked to start, and the phone\'s running programs could not be read to see it', { ...on, built: true, installed: true, fix: `Look at the phone: ${s.meta.name} should be on its screen.` });
|
|
1063
|
+
const running = list.some((x) => (Number.isFinite(pid) && Number(x?.processIdentifier) === pid) || (where && String(x?.executable ?? '').startsWith(where)));
|
|
1064
|
+
if (!running) return stop('not-running', 'the game was installed and started, and four seconds later it was not among the phone\'s running programs: it closed, or crashed, as it opened', { ...on, built: true, installed: true, fix: `Tap ${s.meta.name} on the phone; if it closes again, open Xcode, Window, Devices and Simulators, the phone, and read its crash log.` });
|
|
1065
|
+
return {
|
|
1066
|
+
ok: true, command, target: 'ios', device: true, stage: 'started', ...on, built: true, installed: true, running: true,
|
|
1067
|
+
notes: [
|
|
1068
|
+
'a development build signed for this phone only: it stops opening when its profile ends (a week with a free Apple account, a year with a Developer membership), and it is not a build for the store',
|
|
1069
|
+
s.where.url ? `Quick play finds a room only when ${s.where.url} runs @homie-rocks/studio ${STANDALONE_SINCE} or later; a room made or joined by its code works with an older site too` : 'this copy has no address built in: it plays offline with its bots',
|
|
1070
|
+
],
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
868
1074
|
export { MISSING, TARGETS };
|
|
@@ -1692,6 +1692,46 @@
|
|
|
1692
1692
|
}
|
|
1693
1693
|
}
|
|
1694
1694
|
}
|
|
1695
|
+
},
|
|
1696
|
+
"0.32.1": {
|
|
1697
|
+
"files": {
|
|
1698
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1699
|
+
"README.md": "684b3cca8001673c",
|
|
1700
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1701
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1702
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1703
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1704
|
+
"posts/README.md": "37841aae476bd540",
|
|
1705
|
+
"site/README.md": "732894273a32dc3f",
|
|
1706
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1707
|
+
},
|
|
1708
|
+
"sections": {
|
|
1709
|
+
"AGENTS.md": {
|
|
1710
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1711
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1712
|
+
"## Commands (all through the pinned CLI in node_modules)": "29a04ed3d956610a",
|
|
1713
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1714
|
+
"## Making games": "2da7fa74c8884834",
|
|
1715
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1716
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1717
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1718
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1719
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1720
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1721
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
1722
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1723
|
+
"## Beta": "9d02638207a26e73"
|
|
1724
|
+
}
|
|
1725
|
+
},
|
|
1726
|
+
"named": {
|
|
1727
|
+
"Homie Arcade": {
|
|
1728
|
+
"sections": {
|
|
1729
|
+
"AGENTS.md": {
|
|
1730
|
+
"## Commands (all through the pinned CLI in node_modules)": "5d1c068adda1116b"
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1695
1735
|
}
|
|
1696
1736
|
}
|
|
1697
1737
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.1",
|
|
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",
|
package/standalone/STANDALONE.md
CHANGED
|
@@ -24,6 +24,7 @@ npx --no-install homie-studio standalone plan <game> # what would be built,
|
|
|
24
24
|
npx --no-install homie-studio standalone build <game> # every target this computer can make
|
|
25
25
|
npx --no-install homie-studio standalone build <game> --for mac,windows
|
|
26
26
|
npx --no-install homie-studio standalone run <game> # the built desktop copy, here (--for ios | android)
|
|
27
|
+
npx --no-install homie-studio standalone run <game> --for ios --device # onto the iPhone or iPad plugged in
|
|
27
28
|
npx --no-install homie-studio standalone steam <game> # Steam's build file; uploads nothing
|
|
28
29
|
npx --no-install homie-studio standalone ci <game> # a GitHub workflow that builds all five
|
|
29
30
|
```
|
|
@@ -55,7 +56,8 @@ Say this to whoever will ship it, before they do.
|
|
|
55
56
|
|
|
56
57
|
## What has been run and what has not
|
|
57
58
|
|
|
58
|
-
This is version 1, and it says what it knows.
|
|
59
|
+
This is version 1, and it says what it knows. By the people who wrote it, on one Mac and one iPhone; each line
|
|
60
|
+
was true on 2026-10-06 unless it says another day:
|
|
59
61
|
|
|
60
62
|
**Run and seen:**
|
|
61
63
|
|
|
@@ -70,6 +72,16 @@ This is version 1, and it says what it knows. As of 2026-10-06, by the people wh
|
|
|
70
72
|
environment), and the bundle carries that key's certificate.
|
|
71
73
|
- The Windows build's program carries the game's icon and its name; the Linux build is a Linux program. Both
|
|
72
74
|
were made on the Mac.
|
|
75
|
+
- **On a real iPhone (2026-10-07).** A 3D game's iOS build was signed for a development team, installed over
|
|
76
|
+
the cable and started by hand, with the same three tools `standalone run --for ios --device` now runs. It
|
|
77
|
+
loaded and played offline with its bots.
|
|
78
|
+
- **Online from that phone (2026-10-07).** A private room made in the app, joined from another device by its
|
|
79
|
+
invite link, connected through a studio's live Worker. That site was on 0.31.0, from before standalone copies.
|
|
80
|
+
- **`standalone run --for ios --device` itself (2026-10-07).** It found the phone, read the team from the
|
|
81
|
+
keychain, built and signed the app for the phone and installed it; the phone's own list of apps had it. The
|
|
82
|
+
phone was locked, so the command waited a minute, said so, and stopped with "the game is on the phone, and
|
|
83
|
+
the phone is locked". Its last two steps, starting the game and finding it among the phone's running programs,
|
|
84
|
+
have been run against stand-ins only.
|
|
73
85
|
|
|
74
86
|
**Never run:**
|
|
75
87
|
|
|
@@ -84,18 +96,29 @@ This is version 1, and it says what it knows. As of 2026-10-06, by the people wh
|
|
|
84
96
|
checks the result (`codesign --verify`, `xcrun stapler validate`); it says "notarized" only when the second
|
|
85
97
|
agrees. That path has not been run with a real identity. Opening a notarized app on another Mac was not tried.
|
|
86
98
|
- **The iOS archive and its `.ipa`.** It needs Xcode signed in to an Apple account of your team; it was not run.
|
|
87
|
-
- **
|
|
99
|
+
- **Quick play online from a phone.** The one site a phone build has met was older than 0.32.0, where an app
|
|
100
|
+
cannot read the Lobby's answer: Quick play was offline there, as it should be.
|
|
101
|
+
- **A real Android phone.** The Android build has run in the emulator only. `standalone run --for android` hands
|
|
102
|
+
over to Capacitor, which asks which phone or emulator; `--device` is for an iPhone or iPad.
|
|
103
|
+
- **`run --device` starting a game on an unlocked phone**, and each of its answers for a phone that is not
|
|
104
|
+
ready (Developer Mode off, not trusted, no Apple account in Xcode), other than against stand-ins. The words
|
|
105
|
+
it matches for those are the ones Xcode printed on 2026-10-07.
|
|
88
106
|
- **The GitHub workflow.** It is checked as text. Read the first run's log.
|
|
89
107
|
- **A copy meeting a deployed site**, and an old copy meeting a newer site, outside the tests.
|
|
90
108
|
- **Building from Windows or Linux.**
|
|
91
109
|
|
|
92
110
|
When one of these fails for you, that is news to us: tell Homie.
|
|
93
111
|
|
|
94
|
-
## Before
|
|
112
|
+
## Before Quick play can find a room
|
|
95
113
|
|
|
96
|
-
|
|
97
|
-
your site, so it is another origin to your Worker; 0.32.0 is the first Worker that lets an
|
|
98
|
-
answer
|
|
114
|
+
For **Quick play**, your studio must be on `@homie-rocks/studio` 0.32.0 or later **and deployed**. The app's
|
|
115
|
+
page is not served by your site, so it is another origin to your Worker; 0.32.0 is the first Worker that lets an
|
|
116
|
+
app read the Lobby's answer, which is how Quick play learns which room to join. With an older site, Quick play
|
|
117
|
+
is offline with the game's bots.
|
|
118
|
+
|
|
119
|
+
**A room made or joined by its code does not need that.** The room's own socket never asked where a page came
|
|
120
|
+
from, so "New private room" and "Join a room" connect through an older site too. That has been seen against a
|
|
121
|
+
site on 0.31.0; it is not promised for every older version.
|
|
99
122
|
|
|
100
123
|
The site's address is **built into every copy** (`--site <address>`, else `HOMIE_STANDALONE_SITE`, else the
|
|
101
124
|
address your last deploy from this computer got). A copy that has shipped keeps it for good, so put the studio
|
|
@@ -107,6 +130,38 @@ connection for that in the app, exactly as on the web: bundle what the game must
|
|
|
107
130
|
`index.html` carries a Content-Security-Policy that forbids inline scripts never hears the shell (a copy adds
|
|
108
131
|
one inline script to that page) and plays offline only; the plan warns when it sees one.
|
|
109
132
|
|
|
133
|
+
## On your own iPhone or iPad
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
npx --no-install homie-studio standalone run <game> --for ios --device
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
builds the game for the **one** iPhone or iPad plugged in to this Mac, signs it for your Apple team, installs it
|
|
140
|
+
over the cable, starts it, and then looks for it among the phone's running programs. It says the game is running
|
|
141
|
+
only when it found it there.
|
|
142
|
+
|
|
143
|
+
**`--device` is your yes to what this changes outside your computer:** it adds the phone to your Apple team's
|
|
144
|
+
list of development devices (a team may register a limited number a year, and a device stays on the list until
|
|
145
|
+
the membership year renews) and lets Xcode make a development profile for the app. The app is signed for that
|
|
146
|
+
phone only. It is a build to try, never one for the store, and it stops opening when its profile ends (a week
|
|
147
|
+
with a free Apple account, a year with a Developer membership).
|
|
148
|
+
|
|
149
|
+
What it needs, and what it says when one is missing:
|
|
150
|
+
|
|
151
|
+
- **A Mac with Xcode, signed in to your Apple account** (Xcode, Settings, Accounts). Not signed in: it says so.
|
|
152
|
+
- **Your team.** `HOMIE_APPLE_TEAM`, else the one team among the keychain's development identities (it says
|
|
153
|
+
which it used, never an identity's name). None, or more than one: it stops and asks for `HOMIE_APPLE_TEAM`.
|
|
154
|
+
- **One phone, plugged in, unlocked, trusting this Mac.** None, or several: it says which models it sees and
|
|
155
|
+
builds nothing. Locked: it waits a minute for you to unlock it. Not trusted: unlock it and tap Trust.
|
|
156
|
+
- **Developer Mode on**: on the phone, Settings, Privacy & Security, Developer Mode. The phone restarts and asks
|
|
157
|
+
once more. The switch appears only after the phone has been plugged in to a Mac with Xcode.
|
|
158
|
+
|
|
159
|
+
Nothing it prints or returns names the phone, its id or your team's id: a phone is said by its model.
|
|
160
|
+
|
|
161
|
+
For an Android phone there is no `--device` yet: `standalone run <game> --for android` hands over to Capacitor,
|
|
162
|
+
with the JDK and SDK the build uses, and Capacitor asks which phone or emulator. That has been run on an
|
|
163
|
+
emulator, never on a real Android phone.
|
|
164
|
+
|
|
110
165
|
## Versions: an old copy and a new site
|
|
111
166
|
|
|
112
167
|
Name the game's revision in `game.json` (`"netplay": { "version": "1" }`, NETPLAY.md section 23) before you
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.32.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.32.1';
|