@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,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STANDALONE BUILDS, THE TEXT (standalone/STANDALONE.md): every file `homie-studio standalone` writes that is not a
|
|
3
|
+
* copy of something, as a function of the studio and the game. Nothing here touches the disk, starts a program or
|
|
4
|
+
* reads the environment, so a test can hold each one to what it says. lib/standalone.mjs writes them into
|
|
5
|
+
* .studio/standalone/<id>/, which is made again on every build and never edited by hand.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
/** The wrapper tools a standalone project installs, each at one exact version (lib/standalone-pins.json). */
|
|
10
|
+
export const STANDALONE_PINS = JSON.parse(readFileSync(new URL('./standalone-pins.json', import.meta.url), 'utf8'));
|
|
11
|
+
|
|
12
|
+
export const TARGETS = ['mac', 'windows', 'linux', 'ios', 'android'];
|
|
13
|
+
export const DESKTOP = ['mac', 'windows', 'linux'];
|
|
14
|
+
export const MOBILE = ['ios', 'android'];
|
|
15
|
+
export const ORIENTATIONS = ['any', 'landscape', 'portrait'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* WHAT A STANDALONE COPY DOES NOT HAVE (v1). Said by `standalone plan` and `standalone build`, by the chat tool, by
|
|
19
|
+
* the skill and by the changelog, in these words: a person who ships to a store must hear it before they do.
|
|
20
|
+
*/
|
|
21
|
+
export const MISSING = [
|
|
22
|
+
{ what: 'Player accounts and sign-in', why: 'the app has no sign-in and no name entry: a player is a guest with a two-word handle, and an account made on the studio\'s site is not known to the app' },
|
|
23
|
+
{ what: 'Cloud saves', why: 'saves and settings are kept on the device only (the saves helper\'s local mode). They do not follow a player to the site or to another device, and they are LOST when the app is uninstalled' },
|
|
24
|
+
{ what: 'The shop', why: 'nothing is sold in the app: no Stripe page, no purchases, no supporter badge. The app stores have their own rules for selling, and none of that is built' },
|
|
25
|
+
{ what: 'Room chat', why: 'chat is off in the app: it has no report or mute controls yet, and a chat without them is not shipped' },
|
|
26
|
+
{ what: 'Watching, the big screen and the owner\'s controls', why: 'no watch page, no television view, no office overlay: the app only plays' },
|
|
27
|
+
{ what: 'Servers and private doors', why: 'the app plays on the public server through an open door. A game that is private or invite-only, or a server behind an account or an invite, plays offline in the app' },
|
|
28
|
+
{ what: 'Automatic updates', why: 'the app is the build it was made from. A newer version reaches players only when you build again and ship the update through Steam or the store' },
|
|
29
|
+
{ what: 'Steam\'s overlay, achievements and friends', why: 'none of it has been run. The desktop app passes the switch the overlay needs when Steam starts it, but Electron apps often do not show Steam\'s overlay, and achievements, rich presence and friend invites are not wired' },
|
|
30
|
+
{ what: 'Uploading to a store, and a store\'s yes', why: 'builds are made on this computer and the upload steps are printed; nothing is sent to Steam, App Store Connect or Google Play for you. Each build is the kind of file a store accepts for upload: its review, its fees and its rules are the store\'s, and nothing here makes an app pass' },
|
|
31
|
+
{ what: 'A signed Windows build', why: 'the Windows build is unsigned in v1: opened outside Steam, Windows shows its unknown-publisher warning' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A name as a file's name on every system: none of < > : " / \\ | ? * or a control character, no run of dashes, no
|
|
36
|
+
* dot or space at its end, 80 characters at most. The packager, the iOS copy, Steam's launch options and every path
|
|
37
|
+
* this toolkit reports use this one, so what is said is where the file is.
|
|
38
|
+
*/
|
|
39
|
+
export function fileName(name, fallback = 'Game') {
|
|
40
|
+
const t = String(name ?? '').replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-').replace(/-{2,}/g, '-').replace(/^[-\s.]+|[-\s.]+$/g, '').slice(0, 80).replace(/[-\s.]+$/, '');
|
|
41
|
+
return t || fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SEGMENT = (s) => { const t = String(s ?? '').toLowerCase().replace(/[^a-z0-9]/g, ''); return t ? (/^[0-9]/.test(t) ? `g${t}` : t) : ''; };
|
|
45
|
+
/** A bundle id both stores allow: two to six segments of letters and digits, each starting with a letter. */
|
|
46
|
+
export const APP_ID = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*){1,5}$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The app's id when game.json names none: the studio's own domain reversed (studio.json `cloudflare.domain`), else
|
|
50
|
+
* `rocks.homie.<studio slug>`, then the game's id with its hyphens taken out (a `g` in front when it starts with a
|
|
51
|
+
* digit). Good for a build to try; never for a store, where an id can never be changed: `--release` asks for one.
|
|
52
|
+
*/
|
|
53
|
+
export function appIdOf(studio, game) {
|
|
54
|
+
let host = null;
|
|
55
|
+
try { const d = String(studio?.cloudflare?.domain ?? '').trim(); if (d) host = new URL(/^[a-z]+:\/\//i.test(d) ? d : `https://${d}`).hostname; } catch { host = null; }
|
|
56
|
+
const base = host && host.includes('.') ? host.split('.').reverse().map(SEGMENT).filter(Boolean) : ['rocks', 'homie', SEGMENT(studio?.slug ?? studio?.name) || 'studio'];
|
|
57
|
+
return [...base, SEGMENT(game?.id) || 'game'].join('.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What the wrappers are told about one game: its name and id, the app's id and version, how it is held, and Steam's
|
|
62
|
+
* numbers. `problems` are the values of game.json `standalone` that could not be used, each with what was used instead.
|
|
63
|
+
*/
|
|
64
|
+
export function standaloneMeta(studio, game, { build = null } = {}) {
|
|
65
|
+
const s = game?.standalone && typeof game.standalone === 'object' && !Array.isArray(game.standalone) ? game.standalone : {};
|
|
66
|
+
const problems = [];
|
|
67
|
+
const said = typeof s.appId === 'string' && s.appId.trim() ? s.appId.trim() : null;
|
|
68
|
+
if (said && !APP_ID.test(said)) problems.push(`"standalone.appId" ${JSON.stringify(said.slice(0, 60))} is not an app id both stores allow (letters and digits in two to six dotted parts, each starting with a letter, like com.example.gemrush)`);
|
|
69
|
+
const appId = said && APP_ID.test(said) ? said : appIdOf(studio, game);
|
|
70
|
+
let version = '1.0.0';
|
|
71
|
+
if (s.version !== undefined) { if (/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(String(s.version))) version = String(s.version); else problems.push(`"standalone.version" ${JSON.stringify(String(s.version).slice(0, 24))} is not three numbers like 1.0.0; 1.0.0 is used`); }
|
|
72
|
+
const whole = (v) => (Number.isInteger(Number(v)) && Number(v) > 0 && Number(v) < 2_000_000_000 ? Number(v) : null);
|
|
73
|
+
let buildNo = 1;
|
|
74
|
+
if (s.build !== undefined) { if (whole(s.build)) buildNo = whole(s.build); else problems.push('"standalone.build" is a whole number from 1; 1 is used'); }
|
|
75
|
+
if (build !== null && build !== undefined && build !== true) { if (whole(build)) buildNo = whole(build); else problems.push(`--build ${String(build).slice(0, 16)} is not a whole number from 1; ${buildNo} is used`); }
|
|
76
|
+
let orientation = 'any';
|
|
77
|
+
if (s.orientation !== undefined) { if (ORIENTATIONS.includes(s.orientation)) orientation = s.orientation; else problems.push(`"standalone.orientation" is one of ${ORIENTATIONS.join(', ')}; any is used`); }
|
|
78
|
+
const st = s.steam && typeof s.steam === 'object' ? s.steam : {};
|
|
79
|
+
const depots = Object.fromEntries(DESKTOP.map((os) => [os, whole(st.depots?.[os])]));
|
|
80
|
+
const ver = typeof game?.netplay?.version === 'number' && Number.isFinite(game.netplay.version) ? String(game.netplay.version) : typeof game?.netplay?.version === 'string' ? game.netplay.version.trim() : '';
|
|
81
|
+
return {
|
|
82
|
+
id: String(game?.id ?? ''), name: String(game?.name ?? game?.id ?? 'Game'), publisher: String(studio?.name ?? ''),
|
|
83
|
+
// The app's name as a file's name (fileName): "Gem: Rush / 2" is the file "Gem- Rush - 2".
|
|
84
|
+
file: fileName(game?.name ?? game?.id, String(game?.id ?? 'Game')),
|
|
85
|
+
appId, appIdFrom: said && APP_ID.test(said) ? 'game.json' : 'derived', version, build: buildNo, orientation,
|
|
86
|
+
icon: typeof s.icon === 'string' && s.icon ? s.icon : null,
|
|
87
|
+
steam: { app: whole(st.app), depots, api: st.api === true },
|
|
88
|
+
netplayVersion: /^[A-Za-z0-9._-]{1,32}$/.test(ver) ? ver : null,
|
|
89
|
+
problems,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The exact lines to add to games/<id>/game.json before a release: the id is the one thing a store never lets change. */
|
|
94
|
+
export function standaloneBlock(meta) {
|
|
95
|
+
return JSON.stringify({ standalone: { appId: meta.appId, version: meta.version, build: meta.build, orientation: meta.orientation } }, null, 2).split('\n').slice(1, -1).join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The wrapper project's package.json: only the tools the targets asked for, each pinned exactly. Never committed. */
|
|
99
|
+
export function projectPackage(meta, { targets = TARGETS } = {}) {
|
|
100
|
+
const deps = {
|
|
101
|
+
...(targets.some((t) => DESKTOP.includes(t)) ? STANDALONE_PINS.desktop : {}),
|
|
102
|
+
...(targets.some((t) => MOBILE.includes(t)) ? STANDALONE_PINS.mobile : {}),
|
|
103
|
+
};
|
|
104
|
+
return { name: `${meta.id}-standalone`, private: true, version: meta.version, description: `Standalone builds of ${meta.name}. Made by homie-studio standalone; made again on every build.`, type: 'module', devDependencies: Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** web/config.js: everything the shell knows about the game it frames. `site` '' is a copy that only plays offline. */
|
|
108
|
+
export function configScript(meta, { target, site = '', share, colours = null, movement = null, params = {} }) {
|
|
109
|
+
const cfg = { v: 1, game: meta.id, name: meta.name, site: String(site ?? '').replace(/\/+$/, ''), ver: meta.netplayVersion ?? '', ...(movement ? { movement } : {}), params, share, colours, version: meta.version, target };
|
|
110
|
+
return `window.__HOMIE_APP = ${JSON.stringify(cfg, null, 2).replace(/</g, '\\u003c')};\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* THE ONE CHANGE TO THE GAME'S OWN FILES: a script first in the head of its index.html that hands the helper the
|
|
115
|
+
* shell's word (window.HOMIE_NET), as the site's Worker does when it serves the page. A copy, so the game never holds
|
|
116
|
+
* the shell's own object. Every other file of the game is the web build's, byte for byte.
|
|
117
|
+
*/
|
|
118
|
+
export const NET_SCRIPT = '<script>try{var n=parent!==window&&parent.__HOMIE_APP_NET;if(n)window.HOMIE_NET=JSON.parse(JSON.stringify(n))}catch(e){}</script>';
|
|
119
|
+
const HEAD = /<head(\s[^>]*)?>/i;
|
|
120
|
+
export function withNetScript(html) {
|
|
121
|
+
return HEAD.test(html) ? html.replace(HEAD, (tag) => `${tag}${NET_SCRIPT}`) : NET_SCRIPT + html;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a page's own Content-Security-Policy (a meta tag) would stop that script: a script-src (or, with none, a
|
|
126
|
+
* default-src) that does not allow 'unsafe-inline'. The game then never hears the shell and plays offline, so a plan
|
|
127
|
+
* says it. Null when the page has no such policy.
|
|
128
|
+
*/
|
|
129
|
+
export function cspBlocksInline(html) {
|
|
130
|
+
for (const [tag] of String(html).matchAll(/<meta\b[^>]*>/gi)) {
|
|
131
|
+
if (!/http-equiv\s*=\s*["']?content-security-policy/i.test(tag)) continue;
|
|
132
|
+
const policy = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag);
|
|
133
|
+
const text = policy ? policy[1] ?? policy[2] ?? '' : '';
|
|
134
|
+
const of = (name) => text.split(';').map((d) => d.trim().split(/\s+/)).find((d) => d[0]?.toLowerCase() === name);
|
|
135
|
+
const src = of('script-src-elem') ?? of('script-src') ?? of('default-src');
|
|
136
|
+
if (src && (!src.includes("'unsafe-inline'") || src.some((v) => /^'(nonce|sha\d+)-/.test(v)))) return src.join(' ');
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------------ desktop: Electron */
|
|
142
|
+
|
|
143
|
+
/** electron/package.json: what Electron and the packager read. The binding is there only when Steam's API was asked for. */
|
|
144
|
+
export function electronPackage(meta, { steamApi = false } = {}) {
|
|
145
|
+
return { name: meta.id, productName: meta.name, fileName: meta.file, version: meta.version, description: meta.name, author: meta.publisher || undefined, main: 'main.cjs', private: true, ...(steamApi ? { dependencies: STANDALONE_PINS.steam } : {}) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** electron/app.json: the few facts main.cjs reads. main.cjs itself is the same file for every game. */
|
|
149
|
+
export function electronApp(meta, { colours = null, steamApi = false } = {}) {
|
|
150
|
+
return { v: 1, id: meta.id, name: meta.name, appId: meta.appId, build: meta.build, width: 1280, height: 720, background: /^#[0-9a-f]{6}$/i.test(colours?.paper ?? '') ? colours.paper : '#04060c', ...(meta.steam.app ? { steam: { app: meta.steam.app, api: Boolean(steamApi) } } : {}) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* macOS entitlements for a build that Steam starts, used INSTEAD of the signer's own defaults for every part of the
|
|
155
|
+
* app (@electron/osx-sign replaces, never merges, when it is given a file). So it carries what Electron itself needs
|
|
156
|
+
* under the hardened runtime, first of all the JIT that V8 runs on, and then the two that let the Steam client load
|
|
157
|
+
* its overlay library into the app. A game with no Steam app gets no file at all: the signer's own defaults, which
|
|
158
|
+
* differ for the app and each of its helpers, are the right ones. No App Sandbox: this is not a Mac App Store build.
|
|
159
|
+
*/
|
|
160
|
+
export function entitlementsPlist() {
|
|
161
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
162
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
163
|
+
<plist version="1.0">
|
|
164
|
+
<dict>
|
|
165
|
+
<key>com.apple.security.cs.allow-jit</key>
|
|
166
|
+
<true/>
|
|
167
|
+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
168
|
+
<true/>
|
|
169
|
+
<key>com.apple.security.cs.disable-library-validation</key>
|
|
170
|
+
<true/>
|
|
171
|
+
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
|
172
|
+
<true/>
|
|
173
|
+
</dict>
|
|
174
|
+
</plist>
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ------------------------------------------------------------------ phones: Capacitor */
|
|
179
|
+
|
|
180
|
+
/** capacitor.config.json: the web folder, bundled. Never `server.url`: the app must not load its page from a site. */
|
|
181
|
+
export function capacitorConfig(meta, { colours = null } = {}) {
|
|
182
|
+
return { appId: meta.appId, appName: meta.name, webDir: 'web', ...(/^#[0-9a-f]{6}$/i.test(colours?.paper ?? '') ? { backgroundColor: colours.paper } : {}), server: { androidScheme: 'https' } };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const IOS_ORIENT = {
|
|
186
|
+
any: ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'],
|
|
187
|
+
landscape: ['UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'],
|
|
188
|
+
portrait: ['UIInterfaceOrientationPortrait'],
|
|
189
|
+
};
|
|
190
|
+
/** Info.plist with the ways the game may be held (an iPad's list too). Text in, text out; anything else is left as it is. */
|
|
191
|
+
export function patchInfoPlist(text, orientation) {
|
|
192
|
+
const list = IOS_ORIENT[orientation] ?? IOS_ORIENT.any;
|
|
193
|
+
const pad = orientation === 'portrait' ? [...list, 'UIInterfaceOrientationPortraitUpsideDown'] : orientation === 'any' ? ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationPortraitUpsideDown', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'] : list;
|
|
194
|
+
const arr = (items, indent) => `<array>\n${items.map((i) => `${indent}\t<string>${i}</string>`).join('\n')}\n${indent}</array>`;
|
|
195
|
+
return String(text)
|
|
196
|
+
.replace(/(<key>UISupportedInterfaceOrientations<\/key>\s*)<array>[\s\S]*?<\/array>/, (m, k) => `${k}${arr(list, '\t')}`)
|
|
197
|
+
.replace(/(<key>UISupportedInterfaceOrientations~ipad<\/key>\s*)<array>[\s\S]*?<\/array>/, (m, k) => `${k}${arr(pad, '\t')}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const ANDROID_ORIENT = { any: null, landscape: 'sensorLandscape', portrait: 'sensorPortrait' };
|
|
201
|
+
/** AndroidManifest.xml with the main activity held one way, or free again. */
|
|
202
|
+
export function patchAndroidManifest(text, orientation) {
|
|
203
|
+
const want = ANDROID_ORIENT[orientation] ?? null;
|
|
204
|
+
const bare = String(text).replace(/\s+android:screenOrientation="[^"]*"/g, '');
|
|
205
|
+
return want ? bare.replace(/<activity\b/, `<activity android:screenOrientation="${want}"`) : bare;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** android/app/build.gradle with the app's version: the store's build number and the words a player sees. */
|
|
209
|
+
export function patchGradle(text, meta) {
|
|
210
|
+
return String(text).replace(/\bversionCode\s+\d+/, `versionCode ${meta.build}`).replace(/\bversionName\s+"[^"]*"/, `versionName "${meta.version}"`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** How `xcodebuild -exportArchive` makes the .ipa for App Store Connect: on disk, never uploaded from here. */
|
|
214
|
+
export function exportOptionsPlist(team) {
|
|
215
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
216
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
217
|
+
<plist version="1.0">
|
|
218
|
+
<dict>
|
|
219
|
+
<key>method</key>
|
|
220
|
+
<string>app-store-connect</string>
|
|
221
|
+
<key>destination</key>
|
|
222
|
+
<string>export</string>
|
|
223
|
+
<key>signingStyle</key>
|
|
224
|
+
<string>automatic</string>
|
|
225
|
+
<key>teamID</key>
|
|
226
|
+
<string>${String(team ?? '').replace(/[^A-Za-z0-9]/g, '')}</string>
|
|
227
|
+
</dict>
|
|
228
|
+
</plist>
|
|
229
|
+
`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ------------------------------------------------------------------ Steam */
|
|
233
|
+
|
|
234
|
+
const vdf = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
235
|
+
/**
|
|
236
|
+
* steam/app_build.vdf: one build of the app, with each depot written INSIDE it. Steam documents the app script's
|
|
237
|
+
* ContentRoot as relative to the script, and a depot's LocalPath as relative to that root; a depot script's own
|
|
238
|
+
* relative ContentRoot is not documented the same way, so none is used. Each depot is the release build's folder,
|
|
239
|
+
* out/<os>/release/. Written from Steam's documented format; it has never been run through steamcmd here.
|
|
240
|
+
*/
|
|
241
|
+
export function steamAppBuild(meta, { depots = meta.steam.depots } = {}) {
|
|
242
|
+
const rows = DESKTOP.filter((os) => depots[os]).map((os) => `\t\t${vdf(depots[os])}\n\t\t{\n\t\t\t"FileMapping"\n\t\t\t{\n\t\t\t\t"LocalPath" ${vdf(`${os}/release/*`)}\n\t\t\t\t"DepotPath" "."\n\t\t\t\t"recursive" "1"\n\t\t\t}\n\t\t}`);
|
|
243
|
+
return `"AppBuild"\n{\n\t"AppID" ${vdf(meta.steam.app)}\n\t"Desc" ${vdf(`${meta.name} ${meta.version} build ${meta.build}`)}\n\t"ContentRoot" "../out/"\n\t"BuildOutput" "output/"\n\t"Depots"\n\t{\n${rows.join('\n')}\n\t}\n}\n`;
|
|
244
|
+
}
|
|
245
|
+
/** What Steam starts on each operating system (Steamworks, Installation, General, launch options), from the names the build uses. */
|
|
246
|
+
export function steamLaunch(meta) {
|
|
247
|
+
return { windows: `${meta.id}.exe`, mac: `${meta.file}.app`, linux: meta.id };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ------------------------------------------------------------------ GitHub Actions */
|
|
251
|
+
|
|
252
|
+
/** The actions the workflow uses, each pinned to the commit its release tag names. */
|
|
253
|
+
export const CI_ACTIONS = {
|
|
254
|
+
checkout: 'actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5',
|
|
255
|
+
node: 'actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5',
|
|
256
|
+
java: 'actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6',
|
|
257
|
+
upload: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7',
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* .github/workflows/standalone.yml: the five builds on GitHub's own Windows, Linux and macOS machines, started by
|
|
262
|
+
* hand (never by a push), each kept as an artifact of the run. It is how a studio whose computer cannot make a
|
|
263
|
+
* target (Windows from a Mac, iOS from Windows) gets that build. It uploads to no store.
|
|
264
|
+
*/
|
|
265
|
+
export function workflowYaml(meta) {
|
|
266
|
+
const when = 'contains(inputs.targets, matrix.target)';
|
|
267
|
+
const build = (cond, release, android) => ` - name: Build${android ? ' (Android, with its upload key when the secrets are set)' : ''}
|
|
268
|
+
if: ${cond}
|
|
269
|
+
shell: bash
|
|
270
|
+
env:
|
|
271
|
+
GAME: \${{ inputs.game }}
|
|
272
|
+
TARGET: \${{ matrix.target }}
|
|
273
|
+
RELEASE: \${{ ${release ? "inputs.release && '--release' || ''" : "''"} }}
|
|
274
|
+
HOMIE_STANDALONE_SITE: \${{ vars.HOMIE_SITE }}
|
|
275
|
+
${android ? ` HOMIE_ANDROID_KEYSTORE_BASE64: \${{ secrets.HOMIE_ANDROID_KEYSTORE_BASE64 }}
|
|
276
|
+
HOMIE_ANDROID_KEYSTORE_PASSWORD: \${{ secrets.HOMIE_ANDROID_KEYSTORE_PASSWORD }}
|
|
277
|
+
HOMIE_ANDROID_KEY_ALIAS: \${{ secrets.HOMIE_ANDROID_KEY_ALIAS }}
|
|
278
|
+
HOMIE_ANDROID_KEY_PASSWORD: \${{ secrets.HOMIE_ANDROID_KEY_PASSWORD }}
|
|
279
|
+
` : ''} run: |
|
|
280
|
+
${android ? ` if [ -n "$HOMIE_ANDROID_KEYSTORE_BASE64" ]; then
|
|
281
|
+
printf '%s' "$HOMIE_ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/upload.keystore"
|
|
282
|
+
export HOMIE_ANDROID_KEYSTORE="$RUNNER_TEMP/upload.keystore"
|
|
283
|
+
fi
|
|
284
|
+
` : ''} npx --no-install homie-studio standalone build "$GAME" --for "$TARGET" --build "$GITHUB_RUN_NUMBER" $RELEASE
|
|
285
|
+
tar -C ".studio/standalone/$GAME/out" -czf "$GAME-$TARGET.tgz" "$TARGET"
|
|
286
|
+
`;
|
|
287
|
+
const job = (name, runner, targets, { java = false, release = true } = {}) => ` ${name}:
|
|
288
|
+
name: \${{ matrix.target }}
|
|
289
|
+
runs-on: ${runner}
|
|
290
|
+
strategy:
|
|
291
|
+
fail-fast: false
|
|
292
|
+
matrix:
|
|
293
|
+
target: [${targets.join(', ')}]
|
|
294
|
+
steps:
|
|
295
|
+
- uses: ${CI_ACTIONS.checkout}
|
|
296
|
+
if: ${when}
|
|
297
|
+
- uses: ${CI_ACTIONS.node}
|
|
298
|
+
if: ${when}
|
|
299
|
+
with:
|
|
300
|
+
node-version: 22
|
|
301
|
+
${java ? ` - uses: ${CI_ACTIONS.java}
|
|
302
|
+
if: matrix.target == 'android' && contains(inputs.targets, 'android')
|
|
303
|
+
with:
|
|
304
|
+
distribution: temurin
|
|
305
|
+
java-version: 21
|
|
306
|
+
` : ''} - name: Install (npm ci with a lockfile, npm install without one)
|
|
307
|
+
if: ${when}
|
|
308
|
+
shell: bash
|
|
309
|
+
run: if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi
|
|
310
|
+
${targets.includes('android') ? `${build(`${when} && matrix.target != 'android'`, release, false)}${build(`${when} && matrix.target == 'android'`, release, true)}` : build(when, release, false)} - uses: ${CI_ACTIONS.upload}
|
|
311
|
+
if: ${when}
|
|
312
|
+
with:
|
|
313
|
+
name: \${{ inputs.game }}-\${{ matrix.target }}-\${{ github.run_number }}
|
|
314
|
+
path: \${{ inputs.game }}-\${{ matrix.target }}.tgz
|
|
315
|
+
if-no-files-found: error
|
|
316
|
+
`;
|
|
317
|
+
return `# Standalone builds of this studio's games (homie-studio standalone ci wrote this file; write it again with the
|
|
318
|
+
# same command, do not edit it by hand). Started by hand from the Actions tab, never by a push. Each target is built
|
|
319
|
+
# on GitHub's own machine for it and kept with the run as a .tgz (a tar keeps the files' permissions): windows on
|
|
320
|
+
# Windows, linux and android on Linux, mac and ios on macOS. Nothing is uploaded to Steam or to a store.
|
|
321
|
+
#
|
|
322
|
+
# The repository variable HOMIE_SITE is the studio's live address (https://…): the app is built to find its rooms
|
|
323
|
+
# there, and a shipped copy keeps that address for good, so use the studio's own domain when it has one. Without it
|
|
324
|
+
# the copies play offline only.
|
|
325
|
+
#
|
|
326
|
+
# "release" here means: Windows and Linux release builds (unsigned, as every Windows and Linux build of this version
|
|
327
|
+
# is), and an Android bundle signed with your upload key when the four HOMIE_ANDROID_* secrets are set (the keystore
|
|
328
|
+
# as base64; without them the job says the bundle is unsigned and fails). The macOS and iOS jobs never make a
|
|
329
|
+
# release, whatever the box says: this workflow has no Apple certificate, so the macOS app is an unsigned build to
|
|
330
|
+
# try and the iOS one is a simulator build. Sign, notarize and archive those on a Mac:
|
|
331
|
+
# homie-studio standalone build <id> --for mac,ios --release. A secret is only ever named in the env: block of the
|
|
332
|
+
# one step that needs it.
|
|
333
|
+
#
|
|
334
|
+
# This file has not been run by the people who wrote the command that makes it: read the first run's log.
|
|
335
|
+
name: Standalone builds
|
|
336
|
+
|
|
337
|
+
on:
|
|
338
|
+
workflow_dispatch:
|
|
339
|
+
inputs:
|
|
340
|
+
game:
|
|
341
|
+
description: The game's id (its folder in games/)
|
|
342
|
+
required: true
|
|
343
|
+
default: ${meta.id}
|
|
344
|
+
targets:
|
|
345
|
+
description: Which to build, of windows, linux, mac, ios, android
|
|
346
|
+
required: true
|
|
347
|
+
default: windows,linux,mac,ios,android
|
|
348
|
+
release:
|
|
349
|
+
description: Release builds for Windows, Linux and Android (game.json needs its standalone.appId)
|
|
350
|
+
type: boolean
|
|
351
|
+
default: false
|
|
352
|
+
|
|
353
|
+
permissions:
|
|
354
|
+
contents: read
|
|
355
|
+
|
|
356
|
+
jobs:
|
|
357
|
+
${job('windows', 'windows-latest', ['windows'])}
|
|
358
|
+
${job('linux', 'ubuntu-latest', ['linux', 'android'], { java: true })}
|
|
359
|
+
${job('macos', 'macos-latest', ['mac', 'ios'], { release: false })}`;
|
|
360
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STANDALONE BUILDS, THE ICON (standalone/STANDALONE.md). An app needs an icon in a dozen sizes and three container
|
|
3
|
+
* formats; a studio keeps ONE picture, or none. Every file here is made at build time into .studio/standalone/<id>/
|
|
4
|
+
* and never committed.
|
|
5
|
+
*
|
|
6
|
+
* Where the picture comes from, first found:
|
|
7
|
+
* 1. game.json `standalone.icon` (a path inside the game's folder)
|
|
8
|
+
* 2. games/<id>/icon.png
|
|
9
|
+
* 3. the game's cover (game.json `cover`), cropped square from its middle
|
|
10
|
+
* 4. a tile in the game's colours with the first letter of its name
|
|
11
|
+
* The plan says which one was used. Stores want a real square picture, 1024 by 1024, with no transparency: 3 and 4
|
|
12
|
+
* are for trying a build, not for a store page.
|
|
13
|
+
*
|
|
14
|
+
* The pictures are drawn with sharp (this toolkit's own). The Windows .ico and the macOS .icns are written by
|
|
15
|
+
* @shockpkg/icon-encoder, installed in the standalone project with the other wrapper tools: it writes both
|
|
16
|
+
* containers from PNGs and depends only on a PNG reader, so no container format is written by hand here and no
|
|
17
|
+
* second copy of sharp is installed. The phone icons and splash screens are @capacitor/assets' to make, from the
|
|
18
|
+
* pictures in assets/.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
23
|
+
|
|
24
|
+
const inside = (dir, rel) => { const abs = resolve(dir, String(rel)); return abs === dir || abs.startsWith(dir + sep) ? abs : null; };
|
|
25
|
+
const PICTURE = /\.(png|jpe?g|webp|svg)$/i;
|
|
26
|
+
|
|
27
|
+
/** Which picture the icon is made from: { from, file (null for the letter tile), note }. Reads nothing but names. */
|
|
28
|
+
export function iconSource(root, game, meta) {
|
|
29
|
+
const dir = game.dir;
|
|
30
|
+
if (meta.icon) {
|
|
31
|
+
const f = inside(dir, meta.icon);
|
|
32
|
+
if (f && existsSync(f) && PICTURE.test(f)) return { from: 'standalone.icon', file: f, note: `games/${game.id}/${relative(dir, f).split(sep).join('/')} (game.json standalone.icon)` };
|
|
33
|
+
}
|
|
34
|
+
const own = join(dir, 'icon.png');
|
|
35
|
+
if (existsSync(own)) return { from: 'icon.png', file: own, note: `games/${game.id}/icon.png` };
|
|
36
|
+
if (typeof game.cover === 'string' && game.cover && PICTURE.test(game.cover)) {
|
|
37
|
+
for (const base of [join(root, 'site', 'dist', 'games', game.id), dir, join(dir, 'public')]) {
|
|
38
|
+
const f = inside(base, game.cover);
|
|
39
|
+
if (f && existsSync(f)) return { from: 'cover', file: f, note: `the game's cover (${game.cover}), cropped square. A store wants a real icon: a square 1024×1024 PNG at games/${game.id}/icon.png` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const asked = meta.icon ? `game.json standalone.icon names ${JSON.stringify(String(meta.icon).slice(0, 60))}, which is not a picture in the game's folder. ` : '';
|
|
43
|
+
return { from: 'letter', file: null, note: `${asked}a tile with the letter ${JSON.stringify(letterOf(game.name ?? game.id))} in the game's colours, because the game has no icon and no cover. A store wants a real icon: a square 1024×1024 PNG at games/${game.id}/icon.png` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const letterOf = (name) => (String(name ?? '').trim().match(/[\p{L}\p{N}]/u)?.[0] ?? 'G').toUpperCase();
|
|
47
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
48
|
+
const xml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
49
|
+
|
|
50
|
+
/** The letter tile as an SVG, 1024 square: the game's paper, its text colour, its accent as a bar. */
|
|
51
|
+
export function letterTile(name, colours = null) {
|
|
52
|
+
const paper = HEX.test(colours?.paper ?? '') ? colours.paper : '#10131c';
|
|
53
|
+
const text = HEX.test(colours?.text ?? '') ? colours.text : '#eef1f8';
|
|
54
|
+
const hot = HEX.test(colours?.hot ?? '') ? colours.hot : '#ffcf5a';
|
|
55
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" fill="${paper}"/><rect x="232" y="780" width="560" height="44" rx="22" fill="${hot}"/><text x="512" y="700" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-weight="800" font-size="620" fill="${text}">${xml(letterOf(name))}</text></svg>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write every icon file the wrappers read into the standalone project `dir`:
|
|
60
|
+
* icons/icon.png (1024), icons/icon.ico, icons/icon.icns the desktop app's
|
|
61
|
+
* assets/icon-only.png, icon-foreground.png, icon-background.png, splash.png, splash-dark.png @capacitor/assets' sources
|
|
62
|
+
* Returns { ok, wrote, ico, icns, why? }. A container that could not be written is said (`ico: false`), never passed over:
|
|
63
|
+
* the desktop build then keeps Electron's own icon and says so.
|
|
64
|
+
*/
|
|
65
|
+
export async function writeIcons(dir, source, { name, colours = null } = {}) {
|
|
66
|
+
let sharp;
|
|
67
|
+
try { sharp = (await import('sharp')).default; } catch (error) { return { ok: false, wrote: [], ico: false, icns: false, why: `sharp did not load (${String(error?.message ?? error).split('\n')[0]}): no icon was made` }; }
|
|
68
|
+
const paper = HEX.test(colours?.paper ?? '') ? colours.paper : '#10131c';
|
|
69
|
+
const wrote = [];
|
|
70
|
+
const put = (rel, buf) => { const f = join(dir, rel); mkdirSync(join(f, '..'), { recursive: true }); writeFileSync(f, buf); wrote.push(rel); };
|
|
71
|
+
let master;
|
|
72
|
+
try {
|
|
73
|
+
master = source.file
|
|
74
|
+
? await sharp(readFileSync(source.file), { density: 300 }).resize(1024, 1024, { fit: 'cover', position: 'centre' }).flatten({ background: paper }).png().toBuffer()
|
|
75
|
+
: await sharp(Buffer.from(letterTile(name, colours))).resize(1024, 1024).png().toBuffer();
|
|
76
|
+
} catch (error) { return { ok: false, wrote, ico: false, icns: false, why: `the icon's picture could not be read (${String(error?.message ?? error).split('\n')[0]})` }; }
|
|
77
|
+
put('icons/icon.png', master);
|
|
78
|
+
// Android's adaptive icon is two layers: the picture, smaller, over a plain colour (the launcher cuts the shape).
|
|
79
|
+
const small = await sharp(master).resize(680, 680).png().toBuffer();
|
|
80
|
+
const clear = { create: { width: 1024, height: 1024, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } };
|
|
81
|
+
put('assets/icon-only.png', master);
|
|
82
|
+
put('assets/icon-foreground.png', await sharp(clear).composite([{ input: small, gravity: 'centre' }]).png().toBuffer());
|
|
83
|
+
put('assets/icon-background.png', await sharp({ create: { width: 1024, height: 1024, channels: 4, background: paper } }).png().toBuffer());
|
|
84
|
+
const splash = await sharp({ create: { width: 2732, height: 2732, channels: 4, background: paper } }).composite([{ input: await sharp(master).resize(512, 512).png().toBuffer(), gravity: 'centre' }]).png().toBuffer();
|
|
85
|
+
put('assets/splash.png', splash);
|
|
86
|
+
put('assets/splash-dark.png', splash);
|
|
87
|
+
// The desktop containers, by @shockpkg/icon-encoder from the project's own node_modules (absent until the desktop
|
|
88
|
+
// tools are installed). It is handed one PNG a size, drawn here, and writes the two containers.
|
|
89
|
+
let ico = false; let icns = false; let why;
|
|
90
|
+
try {
|
|
91
|
+
const { IconIco, IconIcns } = createRequire(join(dir, 'package.json'))('@shockpkg/icon-encoder');
|
|
92
|
+
const at = new Map();
|
|
93
|
+
const png = async (size) => { if (!at.has(size)) at.set(size, await sharp(master).resize(size, size).png().toBuffer()); return at.get(size); };
|
|
94
|
+
const a = new IconIco();
|
|
95
|
+
for (const size of [256, 128, 64, 48, 32, 24, 16]) await a.addFromPng(await png(size), null, false);
|
|
96
|
+
put('icons/icon.ico', Buffer.from(a.encode()));
|
|
97
|
+
ico = true;
|
|
98
|
+
const b = new IconIcns();
|
|
99
|
+
b.toc = true;
|
|
100
|
+
// The order iconutil writes: each type with the size it holds.
|
|
101
|
+
for (const [type, size] of [['ic12', 64], ['ic07', 128], ['ic13', 256], ['ic08', 256], ['ic04', 16], ['ic14', 512], ['ic09', 512], ['ic05', 32], ['ic10', 1024], ['ic11', 32]]) await b.addFromPng(await png(size), [type], false);
|
|
102
|
+
put('icons/icon.icns', Buffer.from(b.encode()));
|
|
103
|
+
icns = true;
|
|
104
|
+
} catch (error) { why = error?.code === 'MODULE_NOT_FOUND' ? 'the icon encoder is not installed in the standalone project yet' : `the desktop icon could not be written (${String(error?.message ?? error).split('\n')[0]})`; }
|
|
105
|
+
return { ok: true, wrote, ico, icns, ...(why ? { why } : {}) };
|
|
106
|
+
}
|