@caperjs/core 0.7.0 → 0.7.2

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.
Files changed (64) hide show
  1. package/build/assetpack.mjs +35 -3
  2. package/build/defaults.mjs +39 -1
  3. package/build/index.mjs +13 -2
  4. package/build/internal/outputMarker.mjs +52 -0
  5. package/cli/doctor.mjs +205 -2
  6. package/cli/doctor.test.mjs +522 -1
  7. package/cli/native.mjs +808 -0
  8. package/cli/native.test.mjs +1039 -0
  9. package/cli.mjs +4 -0
  10. package/extras/css/fullscreen.css +8 -0
  11. package/extras/llms.txt +18 -0
  12. package/extras/skills/caper/SKILL.md +1 -0
  13. package/lib/{CaptionsPlugin-Boj-gm3A.js → CaptionsPlugin-COrofpHN.js} +2 -2
  14. package/lib/{CaptionsPlugin-Boj-gm3A.js.map → CaptionsPlugin-COrofpHN.js.map} +1 -1
  15. package/lib/{DataAdapter-Cra9dJ6W.js → DataAdapter-T-22-_D1.js} +2 -2
  16. package/lib/{DataAdapter-Cra9dJ6W.js.map → DataAdapter-T-22-_D1.js.map} +1 -1
  17. package/lib/{DebugRenderer-19S58S4h.js → DebugRenderer-ZN692Pbg.js} +4 -4
  18. package/lib/{DebugRenderer-19S58S4h.js.map → DebugRenderer-ZN692Pbg.js.map} +1 -1
  19. package/lib/{DevToolsPlugin-BTSkRsdS.js → DevToolsPlugin-CQXFoj1T.js} +3 -3
  20. package/lib/{DevToolsPlugin-BTSkRsdS.js.map → DevToolsPlugin-CQXFoj1T.js.map} +1 -1
  21. package/lib/{GSAPPlugin-BcL7vjyw.js → GSAPPlugin-CNkncyXS.js} +2 -2
  22. package/lib/{GSAPPlugin-BcL7vjyw.js.map → GSAPPlugin-CNkncyXS.js.map} +1 -1
  23. package/lib/{LayoutPlugin-BDhSngz_.js → LayoutPlugin-BuJgr9bd.js} +26 -26
  24. package/lib/{LayoutPlugin-BDhSngz_.js.map → LayoutPlugin-BuJgr9bd.js.map} +1 -1
  25. package/lib/{ScreenDebugPlugin-C6pxs_3O.js → ScreenDebugPlugin-kz9Mr6j1.js} +2 -2
  26. package/lib/{ScreenDebugPlugin-C6pxs_3O.js.map → ScreenDebugPlugin-kz9Mr6j1.js.map} +1 -1
  27. package/lib/{SpinePlugin-CrQhaK6r.js → SpinePlugin-DDzD-Qxd.js} +1099 -877
  28. package/lib/SpinePlugin-DDzD-Qxd.js.map +1 -0
  29. package/lib/{StatsPlugin-Cu625dmk.js → StatsPlugin-tyFGzVjy.js} +2 -2
  30. package/lib/{StatsPlugin-Cu625dmk.js.map → StatsPlugin-tyFGzVjy.js.map} +1 -1
  31. package/lib/{VoiceOverPlugin-D4rfHFyJ.js → VoiceOverPlugin-rTNz_9eb.js} +2 -2
  32. package/lib/{VoiceOverPlugin-D4rfHFyJ.js.map → VoiceOverPlugin-rTNz_9eb.js.map} +1 -1
  33. package/lib/caper.mjs +51 -51
  34. package/lib/caper.mjs.map +1 -1
  35. package/lib/{const-BQ2iMCtc.js → const-Cj14a5U2.js} +5 -3
  36. package/lib/const-Cj14a5U2.js.map +1 -0
  37. package/lib/plugins/FullScreenPlugin.d.ts +65 -0
  38. package/lib/plugins/FullScreenPlugin.d.ts.map +1 -1
  39. package/lib/plugins/spine/SpinePlugin.d.ts.map +1 -1
  40. package/lib/plugins/spine/SpinePlugin.test.d.ts +2 -0
  41. package/lib/plugins/spine/SpinePlugin.test.d.ts.map +1 -0
  42. package/lib/{registries-BmDw_GV1.js → registries-DmNGVLAi.js} +489 -446
  43. package/lib/registries-DmNGVLAi.js.map +1 -0
  44. package/lib/ui/Button.d.ts.map +1 -1
  45. package/lib/utils/platform.d.ts +5 -0
  46. package/lib/utils/platform.d.ts.map +1 -1
  47. package/lib/utils/platform.test.d.ts +2 -0
  48. package/lib/utils/platform.test.d.ts.map +1 -0
  49. package/lib/utils/types.d.ts +1 -1
  50. package/lib/utils/types.d.ts.map +1 -1
  51. package/package.json +3 -3
  52. package/src/plugins/FullScreenPlugin.test.ts +138 -0
  53. package/src/plugins/FullScreenPlugin.ts +132 -0
  54. package/src/plugins/spine/SpinePlugin.test.ts +31 -0
  55. package/src/plugins/spine/SpinePlugin.ts +9 -2
  56. package/src/ui/Button.test.ts +22 -0
  57. package/src/ui/Button.ts +7 -0
  58. package/src/utils/platform.test.ts +20 -0
  59. package/src/utils/platform.ts +6 -0
  60. package/src/utils/types.ts +5 -1
  61. package/src/version.ts +2 -2
  62. package/lib/SpinePlugin-CrQhaK6r.js.map +0 -1
  63. package/lib/const-BQ2iMCtc.js.map +0 -1
  64. package/lib/registries-BmDw_GV1.js.map +0 -1
package/cli/native.mjs ADDED
@@ -0,0 +1,808 @@
1
+ import { bold, cyan, dim, green, red, yellow } from 'kleur/colors';
2
+
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import process from 'node:process';
8
+
9
+ /**
10
+ * `caper native init` — one-shot Tauri v2 scaffolding for a Caper app: adds
11
+ * `@tauri-apps/cli`, runs `tauri init --ci` with a pinned identifier/port,
12
+ * then patches the generated `tauri.conf.json` and the app's own
13
+ * `package.json` scripts. `caper` deliberately does NOT wrap `tauri dev` /
14
+ * `tauri build` — same rule as `dev`/`build` in `cli.mjs`.
15
+ */
16
+
17
+ const IDENTIFIER_RE = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
18
+ const PLACEHOLDER_IDENTIFIER = 'com.tauri.dev';
19
+ const DEFAULT_IDENTIFIER_PREFIX = 'dev.caper.';
20
+
21
+ /** Strip an npm scope, lowercase, collapse non `[a-z0-9]` runs to `-`, trim `-`. */
22
+ export function appSlug(packageName) {
23
+ return String(packageName ?? '')
24
+ .replace(/^@[^/]+\//, '')
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, '-')
27
+ .replace(/^-+|-+$/g, '');
28
+ }
29
+
30
+ /** `dev.caper.<slug>`, guaranteed to satisfy `isValidIdentifier`. */
31
+ export function defaultIdentifier(slug) {
32
+ let segment = String(slug ?? '')
33
+ .toLowerCase()
34
+ // No hyphens: Apple allows them, but the same identifier becomes the Android
35
+ // (Java) package name, where they are illegal.
36
+ .replace(/[^a-z0-9]+/g, '')
37
+ .replace(/^[^a-z]+/, ''); // the segment itself must start with a letter
38
+ if (!segment) segment = 'app';
39
+ return `${DEFAULT_IDENTIFIER_PREFIX}${segment}`;
40
+ }
41
+
42
+ /** Reverse-DNS shape Tauri requires, rejecting its own placeholder identifier. */
43
+ export function isValidIdentifier(identifier) {
44
+ return typeof identifier === 'string' && identifier !== PLACEHOLDER_IDENTIFIER && IDENTIFIER_RE.test(identifier);
45
+ }
46
+
47
+ function hashSlug(slug) {
48
+ let hash = 0;
49
+ for (const ch of String(slug ?? '')) {
50
+ hash = (hash * 31 + ch.codePointAt(0)) | 0;
51
+ }
52
+ return Math.abs(hash);
53
+ }
54
+
55
+ /** Stable integer in 3100-3999 derived from `slug`; never 3000. */
56
+ export function defaultPort(slug) {
57
+ return 3100 + (hashSlug(slug) % 900);
58
+ }
59
+
60
+ /** Validate a user-supplied `--port`: an integer between 1024 and 65535. */
61
+ export function parsePort(raw) {
62
+ const n = Number(raw);
63
+ if (!Number.isInteger(n) || n < 1024 || n > 65535) {
64
+ throw new Error(`--port must be an integer between 1024 and 65535 (got ${JSON.stringify(raw)})`);
65
+ }
66
+ return n;
67
+ }
68
+
69
+ const LOCKFILES = [
70
+ ['pnpm-lock.yaml', 'pnpm'],
71
+ ['yarn.lock', 'yarn'],
72
+ ['bun.lockb', 'bun'],
73
+ ['package-lock.json', 'npm'],
74
+ ];
75
+
76
+ /** `'pnpm' | 'yarn' | 'bun' | 'npm'` by lockfile, walking up to the workspace root. */
77
+ export function packageManagerFor(dir) {
78
+ let current = path.resolve(dir);
79
+ for (;;) {
80
+ for (const [file, pm] of LOCKFILES) {
81
+ if (fs.existsSync(path.join(current, file))) return pm;
82
+ }
83
+ const parent = path.dirname(current);
84
+ if (parent === current) return 'npm';
85
+ current = parent;
86
+ }
87
+ }
88
+
89
+ // Package manager used to exec a locally-installed bin (`vite`, `tauri`, …)
90
+ // without a run/exec subcommand of its own.
91
+ const EXEC_RUNNER = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' };
92
+
93
+ /** `{ dev, build, addDev }` argv builders for `pm`, pinning `vite`'s dev port. */
94
+ export function commandsFor(pm, port) {
95
+ const dev = { cmd: EXEC_RUNNER[pm], args: ['vite', '--port', String(port)] };
96
+
97
+ // A bare `vite build`, deliberately NOT the app's own `build` script. Those
98
+ // scripts often clean first (kitchen-sink's did: `rimraf dist .assetpack .cache`),
99
+ // and `.cache` is Vite's dev dep cache: a native build wiped it out from under a
100
+ // running dev server, which then 504'd every pre-bundled dependency. Nothing
101
+ // needs cleaning any more — Vite empties `dist`, and the assetpack output
102
+ // marker handles a dev/production switch. An app with extra build steps edits
103
+ // `build.beforeBuildCommand` in `src-tauri/tauri.conf.json`.
104
+ const build = { cmd: EXEC_RUNNER[pm], args: ['vite', 'build'] };
105
+
106
+ const addDev = (pkg) => ({
107
+ cmd: pm,
108
+ args: pm === 'npm' ? ['install', '-D', pkg] : ['add', '-D', pkg],
109
+ });
110
+
111
+ const add = (...pkgs) => ({
112
+ cmd: pm,
113
+ args: pm === 'npm' ? ['install', ...pkgs] : ['add', ...pkgs],
114
+ });
115
+
116
+ return { dev, build, addDev, add };
117
+ }
118
+
119
+ function renderCommand({ cmd, args }) {
120
+ return [cmd, ...args].join(' ');
121
+ }
122
+
123
+ /** Returns a new tauri.conf.json: sets `identifier` and window 0's title/size; leaves the rest untouched. */
124
+ export function patchTauriConfig(conf, { identifier, title }) {
125
+ const windows = Array.isArray(conf.app?.windows) ? [...conf.app.windows] : [];
126
+ windows[0] = { ...(windows[0] ?? {}), title, width: 1280, height: 720 };
127
+
128
+ return {
129
+ ...conf,
130
+ identifier,
131
+ app: {
132
+ ...conf.app,
133
+ windows,
134
+ },
135
+ };
136
+ }
137
+
138
+ /** Returns a new package.json: adds `native:dev`/`native:build` scripts only where absent. */
139
+ export function patchPackageScripts(pkg) {
140
+ const scripts = { ...(pkg.scripts ?? {}) };
141
+ if (!('native:dev' in scripts)) scripts['native:dev'] = 'tauri dev';
142
+ if (!('native:build' in scripts)) scripts['native:build'] = 'tauri build';
143
+ return { ...pkg, scripts };
144
+ }
145
+
146
+ /**
147
+ * Window permissions `@caperjs/plugin-tauri` needs in `src-tauri/capabilities/default.json`
148
+ * (native fullscreen + quit). `tauri add store` handles the store plugin's own permission.
149
+ */
150
+ export const TAURI_PLUGIN_PERMISSIONS = Object.freeze([
151
+ 'core:window:allow-set-fullscreen',
152
+ 'core:window:allow-is-fullscreen',
153
+ 'core:window:allow-close',
154
+ ]);
155
+
156
+ /** Returns a new capabilities object with each of `permissions` missing from `capabilities.permissions` appended; object-form entries and order are untouched. */
157
+ export function patchCapabilities(capabilities, permissions) {
158
+ const existing = Array.isArray(capabilities.permissions) ? [...capabilities.permissions] : [];
159
+ const existingStrings = new Set(existing.filter((p) => typeof p === 'string'));
160
+ const missing = permissions.filter((p) => !existingStrings.has(p));
161
+
162
+ return {
163
+ ...capabilities,
164
+ permissions: [...existing, ...missing],
165
+ };
166
+ }
167
+
168
+ /** The subset of `names` present in neither `pkg.dependencies` nor `pkg.devDependencies`. */
169
+ export function missingDeps(pkg, names) {
170
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
171
+ return names.filter((name) => !(name in deps));
172
+ }
173
+
174
+ /** The default injectable `run`: a synchronous, inherited-stdio child process that throws on non-zero exit. */
175
+ function defaultRun(cmd, args, opts = {}) {
176
+ const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
177
+ if (result.error) throw result.error;
178
+ if (result.status !== 0) {
179
+ throw new Error(`${cmd} ${args.join(' ')} exited with code ${result.status}`);
180
+ }
181
+ return result;
182
+ }
183
+
184
+ function detectIndent(raw) {
185
+ const match = raw.match(/\n([ \t]+)\S/);
186
+ return match ? match[1] : 2;
187
+ }
188
+
189
+ function writeJson(file, data, indent) {
190
+ const trailingNewline = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8').endsWith('\n') : true;
191
+ fs.writeFileSync(file, JSON.stringify(data, null, indent) + (trailingNewline ? '\n' : ''), 'utf-8');
192
+ }
193
+
194
+ /**
195
+ * The I/O core of `caper native init`. No console output — the CLI wrapper
196
+ * below owns presentation. Every external command goes through `run` so
197
+ * tests can stub it.
198
+ *
199
+ * @param {string} cwd
200
+ * @param {{ identifier?: string, port?: number, icon?: string }} [opts]
201
+ * @param {{ run?: typeof defaultRun }} [deps]
202
+ */
203
+ export async function initNative(cwd, opts = {}, { run = defaultRun } = {}) {
204
+ const pkgPath = path.join(cwd, 'package.json');
205
+ if (!fs.existsSync(pkgPath)) {
206
+ throw new Error('no package.json found in this directory.');
207
+ }
208
+ const pkgRaw = fs.readFileSync(pkgPath, 'utf-8');
209
+ const pkg = JSON.parse(pkgRaw);
210
+
211
+ const srcTauriDir = path.join(cwd, 'src-tauri');
212
+ if (fs.existsSync(srcTauriDir)) {
213
+ return { status: 'already-initialized' };
214
+ }
215
+
216
+ // `readAppIdentity` (build/defaults.mjs) prefers `npm_package_*` env vars,
217
+ // which correctly reflect the *app* when a preset runs as its build script
218
+ // but not here — `caper native init` isn't invoked that way, and those env
219
+ // vars can instead reflect an unrelated ancestor process (e.g. a monorepo
220
+ // task runner). Read the name straight from the package.json we already have.
221
+ const name = pkg.name ?? 'app';
222
+ const title = opts.title ?? name;
223
+ const slug = appSlug(pkg.name ?? name);
224
+
225
+ const identifier = opts.identifier ?? defaultIdentifier(slug);
226
+ if (!isValidIdentifier(identifier)) {
227
+ throw new Error(
228
+ `"${identifier}" is not a valid identifier. Expected a reverse-DNS shape like "dev.caper.my-game", not "${PLACEHOLDER_IDENTIFIER}".`,
229
+ );
230
+ }
231
+
232
+ const port = opts.port !== undefined ? parsePort(opts.port) : defaultPort(slug);
233
+ const pm = packageManagerFor(cwd);
234
+ const { dev, build, addDev } = commandsFor(pm, port);
235
+
236
+ const appDeps = { ...pkg.dependencies, ...pkg.devDependencies };
237
+ if (!appDeps['@tauri-apps/cli']) {
238
+ const { cmd, args } = addDev('@tauri-apps/cli@^2');
239
+ run(cmd, args, { cwd });
240
+ }
241
+
242
+ const initArgs = [
243
+ 'tauri',
244
+ 'init',
245
+ '--ci',
246
+ '--app-name',
247
+ name,
248
+ '--window-title',
249
+ title,
250
+ '--frontend-dist',
251
+ '../dist',
252
+ '--dev-url',
253
+ `http://localhost:${port}`,
254
+ '--before-dev-command',
255
+ renderCommand(dev),
256
+ '--before-build-command',
257
+ renderCommand(build),
258
+ ];
259
+ run(EXEC_RUNNER[pm], initArgs, { cwd });
260
+
261
+ const tauriConfPath = path.join(srcTauriDir, 'tauri.conf.json');
262
+ const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf-8'));
263
+ writeJson(tauriConfPath, patchTauriConfig(tauriConf, { identifier, title }), 2);
264
+
265
+ writeJson(pkgPath, patchPackageScripts(pkg), detectIndent(pkgRaw));
266
+
267
+ if (opts.icon) {
268
+ run(EXEC_RUNNER[pm], ['tauri', 'icon', opts.icon], { cwd });
269
+ }
270
+
271
+ return { status: 'ok', name, title, identifier, port, pm };
272
+ }
273
+
274
+ /**
275
+ * The I/O core of `caper native plugin` — wires an already-`native init`'d app
276
+ * up for `@caperjs/plugin-tauri`: adds its JS deps, runs `tauri add store`
277
+ * (Rust crate + registration + permission + `@tauri-apps/plugin-store`), and
278
+ * patches in the window permissions the plugin needs. No console output — the
279
+ * CLI wrapper below owns presentation. Idempotent: a second run makes zero
280
+ * `run` calls and zero writes.
281
+ *
282
+ * @param {string} cwd
283
+ * @param {object} [opts] unused today; kept for symmetry with `initNative`
284
+ * @param {{ run?: typeof defaultRun }} [deps]
285
+ */
286
+ export async function addNativePlugin(cwd, opts = {}, { run = defaultRun } = {}) {
287
+ const pkgPath = path.join(cwd, 'package.json');
288
+ if (!fs.existsSync(pkgPath)) {
289
+ throw new Error('no package.json found in this directory.');
290
+ }
291
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
292
+
293
+ const srcTauriDir = path.join(cwd, 'src-tauri');
294
+ const tauriConfPath = path.join(srcTauriDir, 'tauri.conf.json');
295
+ if (!fs.existsSync(srcTauriDir) || !fs.existsSync(tauriConfPath)) {
296
+ throw new Error('no src-tauri/ found — run `caper native init` first.');
297
+ }
298
+
299
+ const pm = packageManagerFor(cwd);
300
+ const { add } = commandsFor(pm, 0);
301
+
302
+ const addedDeps = missingDeps(pkg, ['@caperjs/plugin-tauri', '@tauri-apps/api']);
303
+ if (addedDeps.length) {
304
+ const specs = addedDeps.map((name) => (name === '@tauri-apps/api' ? `${name}@^2` : name));
305
+ const { cmd, args } = add(...specs);
306
+ run(cmd, args, { cwd });
307
+ }
308
+
309
+ const cargoTomlPath = path.join(srcTauriDir, 'Cargo.toml');
310
+ const cargoToml = fs.existsSync(cargoTomlPath) ? fs.readFileSync(cargoTomlPath, 'utf-8') : '';
311
+ const ranTauriAddStore = !cargoToml.includes('tauri-plugin-store');
312
+ if (ranTauriAddStore) {
313
+ run(EXEC_RUNNER[pm], ['tauri', 'add', 'store'], { cwd });
314
+ }
315
+
316
+ const capabilitiesPath = path.join(srcTauriDir, 'capabilities/default.json');
317
+ if (!fs.existsSync(capabilitiesPath)) {
318
+ throw new Error(`src-tauri/capabilities/default.json is missing.`);
319
+ }
320
+ const capabilitiesRaw = fs.readFileSync(capabilitiesPath, 'utf-8');
321
+ let capabilities;
322
+ try {
323
+ capabilities = JSON.parse(capabilitiesRaw);
324
+ } catch {
325
+ throw new Error('src-tauri/capabilities/default.json is not valid JSON.');
326
+ }
327
+ const patched = patchCapabilities(capabilities, TAURI_PLUGIN_PERMISSIONS);
328
+ const capabilitiesChanged = JSON.stringify(patched) !== JSON.stringify(capabilities);
329
+ if (capabilitiesChanged) {
330
+ writeJson(capabilitiesPath, patched, 2);
331
+ }
332
+
333
+ return {
334
+ status: 'ok',
335
+ changed: addedDeps.length > 0 || ranTauriAddStore || capabilitiesChanged,
336
+ addedDeps,
337
+ ranTauriAddStore,
338
+ capabilitiesChanged,
339
+ };
340
+ }
341
+
342
+ /** Rust targets `tauri android` builds for. */
343
+ export const ANDROID_RUST_TARGETS = Object.freeze([
344
+ 'aarch64-linux-android',
345
+ 'armv7-linux-androideabi',
346
+ 'i686-linux-android',
347
+ 'x86_64-linux-android',
348
+ ]);
349
+
350
+ const MAC_ANDROID_SDK = 'Library/Android/sdk';
351
+ const MAC_STUDIO_JBR = '/Applications/Android Studio.app/Contents/jbr/Contents/Home';
352
+ const HOMEBREW_RUSTUP_BIN = '/opt/homebrew/opt/rustup/bin';
353
+ const HOMEBREW_BIN = '/opt/homebrew/bin';
354
+
355
+ function compareDottedVersions(a, b) {
356
+ const ap = a.split('.').map(Number);
357
+ const bp = b.split('.').map(Number);
358
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
359
+ const diff = (ap[i] ?? 0) - (bp[i] ?? 0);
360
+ if (diff) return diff;
361
+ }
362
+ return 0;
363
+ }
364
+
365
+ /**
366
+ * The env every Android child process needs: `ANDROID_HOME`, `NDK_HOME`,
367
+ * `JAVA_HOME`, and a `PATH` whose rustup toolchain proxies win (Homebrew's
368
+ * plain `rustc` can't add targets; keg-only rustup isn't on PATH). Pure: all
369
+ * filesystem access goes through `exists` / `listDir`.
370
+ *
371
+ * @returns {{ env: Record<string, string>, missing: string[] }} `env` is the full child env
372
+ */
373
+ export function resolveAndroidEnv({ env = {}, platform, homedir, exists, listDir }) {
374
+ const darwin = platform === 'darwin';
375
+ const missing = [];
376
+
377
+ let androidHome = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT;
378
+ if (!androidHome && darwin && exists(path.join(homedir, MAC_ANDROID_SDK))) {
379
+ androidHome = path.join(homedir, MAC_ANDROID_SDK);
380
+ }
381
+ if (!androidHome) missing.push('ANDROID_HOME');
382
+
383
+ let ndkHome = env.NDK_HOME;
384
+ if (!ndkHome && androidHome) {
385
+ const versions = listDir(path.join(androidHome, 'ndk'))
386
+ .filter((name) => /^\d+(\.\d+)*$/.test(name))
387
+ .sort(compareDottedVersions);
388
+ if (versions.length) ndkHome = path.join(androidHome, 'ndk', versions[versions.length - 1]);
389
+ }
390
+ if (!ndkHome) missing.push('NDK_HOME');
391
+
392
+ let javaHome = env.JAVA_HOME;
393
+ if (!javaHome && darwin && exists(MAC_STUDIO_JBR)) javaHome = MAC_STUDIO_JBR;
394
+ if (!javaHome) missing.push('JAVA_HOME');
395
+
396
+ const delimiter = platform === 'win32' ? ';' : ':';
397
+ const pathDirs = (env.PATH ?? '').split(delimiter).filter(Boolean);
398
+ const rustupDir = pathDirs.find((dir) => exists(path.join(dir, 'rustup')));
399
+ const rustcDir = pathDirs.find((dir) => exists(path.join(dir, 'rustc')));
400
+ // A rustc in /opt/homebrew/bin is always Homebrew's plain `rust` formula: rustup's
401
+ // formula is keg-only, so its proxies never land there (though `rustup` itself may).
402
+ const shadowed = rustupDir && rustcDir === HOMEBREW_BIN;
403
+ let PATH = env.PATH;
404
+ if (!rustupDir || shadowed) {
405
+ const candidates = [...(darwin ? [HOMEBREW_RUSTUP_BIN] : []), path.join(homedir, '.cargo/bin')];
406
+ const candidate = candidates.find((dir) => exists(path.join(dir, 'rustup'))) ?? rustupDir;
407
+ if (candidate) PATH = [candidate, ...pathDirs].join(delimiter);
408
+ else missing.push('rustup');
409
+ }
410
+
411
+ const resolved = { ...env };
412
+ if (androidHome) resolved.ANDROID_HOME = androidHome;
413
+ if (ndkHome) resolved.NDK_HOME = ndkHome;
414
+ if (javaHome) resolved.JAVA_HOME = javaHome;
415
+ if (PATH !== undefined) resolved.PATH = PATH;
416
+ return { env: resolved, missing };
417
+ }
418
+
419
+ /** The `ANDROID_RUST_TARGETS` absent from `rustup target list --installed` output. */
420
+ export function missingRustTargets(installedOutput) {
421
+ const installed = new Set(String(installedOutput ?? '').split(/\s+/).filter(Boolean));
422
+ return ANDROID_RUST_TARGETS.filter((target) => !installed.has(target));
423
+ }
424
+
425
+ const PAGE_SIZE_MARKER = 'max-page-size=16384';
426
+ const SYSTEM_BARS_MARKER = 'WindowInsetsCompat.Type.systemBars()';
427
+
428
+ const BUILD_RS_TEMPLATE = `fn main() {
429
+ // Android 15+ devices can use 16 KB memory pages, and Google Play requires apps
430
+ // to support them. NDK r27 and older link with 4 KB segment alignment (r28+
431
+ // defaults to 16 KB), which makes Android warn "not 16 KB compatible" at launch.
432
+ // Set it here rather than through rustflags in .cargo/config.toml: the Tauri CLI
433
+ // drives cargo with its own RUSTFLAGS, which replace config rustflags outright.
434
+ if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
435
+ println!("cargo:rustc-link-arg=-Wl,-z,${PAGE_SIZE_MARKER}");
436
+ }
437
+ tauri_build::build()
438
+ }
439
+ `;
440
+
441
+ const stripWhitespace = (s) => s.replace(/\s+/g, '');
442
+
443
+ /** `'skip'` (16 KB link arg present), `'write'` (missing or Tauri's stock file), or `'warn'` (customized). */
444
+ export function planBuildRs(content) {
445
+ if (content === null || content === undefined) return 'write';
446
+ if (content.includes(PAGE_SIZE_MARKER)) return 'skip';
447
+ return stripWhitespace(content) === 'fnmain(){tauri_build::build()}' ? 'write' : 'warn';
448
+ }
449
+
450
+ const STOCK_MAIN_ACTIVITY_BODIES = new Set(
451
+ [
452
+ 'class MainActivity : TauriActivity()',
453
+ 'class MainActivity : TauriActivity() {}',
454
+ 'class MainActivity : TauriActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) } }',
455
+ 'class MainActivity : TauriActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() } }',
456
+ ].map(stripWhitespace),
457
+ );
458
+
459
+ /** `'skip'` (bars already hidden), `'write'` (Tauri's stock template), or `'warn'` (customized). */
460
+ export function planMainActivity(content) {
461
+ if (content.includes(SYSTEM_BARS_MARKER)) return 'skip';
462
+ const body = content
463
+ .split('\n')
464
+ .filter((line) => !/^\s*(package|import)\s/.test(line))
465
+ .join('\n');
466
+ return STOCK_MAIN_ACTIVITY_BODIES.has(stripWhitespace(body)) ? 'write' : 'warn';
467
+ }
468
+
469
+ /** The immersive MainActivity (system bars hidden), under `packageLine` taken from the generated file. */
470
+ export function renderMainActivity(packageLine) {
471
+ return `${packageLine}
472
+
473
+ import android.os.Bundle
474
+ import androidx.activity.enableEdgeToEdge
475
+ import androidx.core.view.WindowCompat
476
+ import androidx.core.view.WindowInsetsCompat
477
+ import androidx.core.view.WindowInsetsControllerCompat
478
+
479
+ class MainActivity : TauriActivity() {
480
+ override fun onCreate(savedInstanceState: Bundle?) {
481
+ enableEdgeToEdge()
482
+ super.onCreate(savedInstanceState)
483
+ hideSystemBars()
484
+ }
485
+
486
+ // Games own the whole screen: hide the status bar and the navigation bar (the
487
+ // home pill / back-home-recents buttons), which otherwise draw on top of the
488
+ // game. A swipe from the edge brings them back for a moment. Android restores
489
+ // the bars whenever the window regains focus (dialogs, app switching), so
490
+ // re-hide them then.
491
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
492
+ super.onWindowFocusChanged(hasFocus)
493
+ if (hasFocus) hideSystemBars()
494
+ }
495
+
496
+ private fun hideSystemBars() {
497
+ val controller = WindowCompat.getInsetsController(window, window.decorView)
498
+ controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
499
+ controller.hide(${SYSTEM_BARS_MARKER})
500
+ }
501
+ }
502
+ `;
503
+ }
504
+
505
+ /**
506
+ * The first `MainActivity.kt` under `<genAndroidDir>/app/src/main/java/`, or
507
+ * `null`. Searched rather than derived from the identifier: Tauri mangles
508
+ * dashes and other characters when it turns the identifier into a package path.
509
+ */
510
+ export function findMainActivity(genAndroidDir) {
511
+ const walk = (dir) => {
512
+ let entries;
513
+ try {
514
+ entries = fs.readdirSync(dir, { withFileTypes: true });
515
+ } catch {
516
+ return null;
517
+ }
518
+ for (const entry of entries) {
519
+ const full = path.join(dir, entry.name);
520
+ if (entry.isFile() && entry.name === 'MainActivity.kt') return full;
521
+ if (entry.isDirectory()) {
522
+ const found = walk(full);
523
+ if (found) return found;
524
+ }
525
+ }
526
+ return null;
527
+ };
528
+ return walk(path.join(genAndroidDir, 'app/src/main/java'));
529
+ }
530
+
531
+ /** Returns a new package.json: adds `native:android:dev`/`native:android:build` scripts only where absent. */
532
+ export function patchAndroidScripts(pkg) {
533
+ const scripts = { ...(pkg.scripts ?? {}) };
534
+ if (!('native:android:dev' in scripts)) scripts['native:android:dev'] = 'tauri android dev';
535
+ if (!('native:android:build' in scripts)) scripts['native:android:build'] = 'tauri android build';
536
+ return { ...pkg, scripts };
537
+ }
538
+
539
+ /** The default injectable `exec`: a synchronous child process whose stdout is returned as a string. */
540
+ function defaultExec(cmd, args, opts = {}) {
541
+ return execFileSync(cmd, args, { encoding: 'utf-8', ...opts });
542
+ }
543
+
544
+ function defaultListDir(dir) {
545
+ try {
546
+ return fs.readdirSync(dir);
547
+ } catch {
548
+ return [];
549
+ }
550
+ }
551
+
552
+ const ANDROID_MISSING_HELP = {
553
+ NDK_HOME: 'NDK_HOME: install an NDK with Android Studio (SDK Manager > SDK Tools > NDK), or export NDK_HOME="$ANDROID_HOME/ndk/<version>".',
554
+ JAVA_HOME: 'JAVA_HOME: install Android Studio (it bundles a JDK), or export JAVA_HOME to a JDK 17+.',
555
+ rustup: 'rustup: install Rust through rustup (https://rustup.rs or `brew install rustup`). Homebrew\'s plain `rust` cannot add Android targets.',
556
+ };
557
+
558
+ /**
559
+ * The I/O core of `caper native android`: the Android steps from
560
+ * docs/wiki/native-tauri.md: installs the Rust targets, runs
561
+ * `tauri android init --ci`, adds the 16 KB page-alignment link arg to
562
+ * `build.rs`, hides the system bars in `MainActivity.kt`, and adds
563
+ * `native:android:*` scripts. No console output; the CLI wrapper below owns
564
+ * presentation. Idempotent: a second run makes zero `run` calls and zero writes.
565
+ *
566
+ * @param {string} cwd
567
+ * @param {object} [opts] unused today; kept for symmetry with `initNative`
568
+ * @param {{ run?: typeof defaultRun, exec?: typeof defaultExec, env?: Record<string, string>, platform?: string, homedir?: string, exists?: (p: string) => boolean, listDir?: (p: string) => string[] }} [deps]
569
+ */
570
+ export async function androidNative(
571
+ cwd,
572
+ opts = {},
573
+ {
574
+ run = defaultRun,
575
+ exec = defaultExec,
576
+ env = process.env,
577
+ platform = process.platform,
578
+ homedir = os.homedir(),
579
+ exists = fs.existsSync,
580
+ listDir = defaultListDir,
581
+ } = {},
582
+ ) {
583
+ const pkgPath = path.join(cwd, 'package.json');
584
+ if (!fs.existsSync(pkgPath)) {
585
+ throw new Error('no package.json found in this directory.');
586
+ }
587
+ const srcTauriDir = path.join(cwd, 'src-tauri');
588
+ if (!fs.existsSync(path.join(srcTauriDir, 'tauri.conf.json'))) {
589
+ throw new Error('no src-tauri/ found — run `caper native init` first.');
590
+ }
591
+
592
+ const resolved = resolveAndroidEnv({ env, platform, homedir, exists, listDir });
593
+ const blocking = resolved.missing.filter((name) => name in ANDROID_MISSING_HELP);
594
+ if (blocking.length) {
595
+ throw new Error(`the Android toolchain is incomplete:\n${blocking.map((name) => ` - ${ANDROID_MISSING_HELP[name]}`).join('\n')}`);
596
+ }
597
+ const childOpts = { cwd, env: resolved.env };
598
+ const warnings = [];
599
+
600
+ const addedTargets = missingRustTargets(exec('rustup', ['target', 'list', '--installed'], childOpts));
601
+ if (addedTargets.length) {
602
+ run('rustup', ['target', 'add', ...addedTargets], childOpts);
603
+ }
604
+
605
+ const genAndroidDir = path.join(srcTauriDir, 'gen/android');
606
+ const ranAndroidInit = !fs.existsSync(genAndroidDir);
607
+ if (ranAndroidInit) {
608
+ const pm = packageManagerFor(cwd);
609
+ run(EXEC_RUNNER[pm], ['tauri', 'android', 'init', '--ci'], childOpts);
610
+ }
611
+
612
+ const buildRsPath = path.join(srcTauriDir, 'build.rs');
613
+ const buildRsPlan = planBuildRs(fs.existsSync(buildRsPath) ? fs.readFileSync(buildRsPath, 'utf-8') : null);
614
+ if (buildRsPlan === 'write') {
615
+ fs.writeFileSync(buildRsPath, BUILD_RS_TEMPLATE, 'utf-8');
616
+ } else if (buildRsPlan === 'warn') {
617
+ warnings.push(
618
+ `src-tauri/build.rs is customized, so it was left alone. Add the Android link arg \`-Wl,-z,${PAGE_SIZE_MARKER}\` by hand (see docs/wiki/native-tauri.md, "Android").`,
619
+ );
620
+ }
621
+
622
+ let mainActivityPatched = false;
623
+ const mainActivityPath = findMainActivity(genAndroidDir);
624
+ if (!mainActivityPath) {
625
+ warnings.push('could not find MainActivity.kt under src-tauri/gen/android/app/src/main/java/, so the system bars are not hidden.');
626
+ } else {
627
+ const content = fs.readFileSync(mainActivityPath, 'utf-8');
628
+ const plan = planMainActivity(content);
629
+ if (plan === 'write') {
630
+ const packageLine = content.split('\n').find((line) => /^\s*package\s/.test(line))?.trim() ?? '';
631
+ fs.writeFileSync(mainActivityPath, renderMainActivity(packageLine), 'utf-8');
632
+ mainActivityPatched = true;
633
+ } else if (plan === 'warn') {
634
+ warnings.push(
635
+ `${path.relative(cwd, mainActivityPath)} is customized, so it was left alone. Hide \`${SYSTEM_BARS_MARKER}\` by hand (see docs/wiki/native-tauri.md, "Android").`,
636
+ );
637
+ }
638
+ }
639
+
640
+ const pkgRaw = fs.readFileSync(pkgPath, 'utf-8');
641
+ const pkg = JSON.parse(pkgRaw);
642
+ const patchedPkg = patchAndroidScripts(pkg);
643
+ const addedScripts = Object.keys(patchedPkg.scripts).filter((name) => !(name in (pkg.scripts ?? {})));
644
+ if (addedScripts.length) {
645
+ writeJson(pkgPath, patchedPkg, detectIndent(pkgRaw));
646
+ }
647
+
648
+ const buildRsPatched = buildRsPlan === 'write';
649
+ return {
650
+ status: 'ok',
651
+ changed: addedTargets.length > 0 || ranAndroidInit || buildRsPatched || mainActivityPatched || addedScripts.length > 0,
652
+ addedTargets,
653
+ ranAndroidInit,
654
+ buildRsPatched,
655
+ mainActivityPatched,
656
+ addedScripts,
657
+ env: {
658
+ ANDROID_HOME: resolved.env.ANDROID_HOME,
659
+ NDK_HOME: resolved.env.NDK_HOME,
660
+ JAVA_HOME: resolved.env.JAVA_HOME,
661
+ // The dir prepended to PATH so rustup's toolchain wins, or undefined when PATH was already fine.
662
+ rustupBin: resolved.env.PATH !== env.PATH ? resolved.env.PATH.split(platform === 'win32' ? ';' : ':')[0] : undefined,
663
+ },
664
+ warnings,
665
+ };
666
+ }
667
+
668
+ function printUsage() {
669
+ console.error(red('Usage: caper native init [--identifier <id>] [--port <n>] [--icon <png>]'));
670
+ console.error(red(' caper native plugin'));
671
+ console.error(red(' caper native android'));
672
+ }
673
+
674
+ function parseInitArgs(args) {
675
+ const opts = {};
676
+ for (let i = 0; i < args.length; i++) {
677
+ const arg = args[i];
678
+ if (arg === '--identifier') {
679
+ const next = args[++i];
680
+ if (!next) throw new Error('Missing value for --identifier');
681
+ opts.identifier = next;
682
+ } else if (arg === '--port') {
683
+ const next = args[++i];
684
+ if (!next) throw new Error('Missing value for --port');
685
+ opts.port = next;
686
+ } else if (arg === '--icon') {
687
+ const next = args[++i];
688
+ if (!next) throw new Error('Missing value for --icon');
689
+ opts.icon = next;
690
+ } else {
691
+ throw new Error(`Unknown option: ${arg}`);
692
+ }
693
+ }
694
+ return opts;
695
+ }
696
+
697
+ async function runInit(args) {
698
+ let opts;
699
+ try {
700
+ opts = parseInitArgs(args);
701
+ } catch (err) {
702
+ console.error(red(err.message));
703
+ printUsage();
704
+ process.exit(1);
705
+ }
706
+
707
+ let result;
708
+ try {
709
+ result = await initNative(process.cwd(), opts);
710
+ } catch (err) {
711
+ console.error(red(`caper native init: ${err.message}`));
712
+ process.exit(1);
713
+ }
714
+
715
+ if (result.status === 'already-initialized') {
716
+ console.log(yellow('src-tauri already exists — nothing to do.'));
717
+ console.log(` Run ${cyan('pnpm native:dev')} / ${cyan('pnpm native:build')}, or ${cyan('npx caper doctor')} to check your native toolchain.`);
718
+ return;
719
+ }
720
+
721
+ console.log(green(bold('✓ Initialized Tauri')) + ` ${cyan('src-tauri/')}`);
722
+ console.log(` ${yellow('identifier:')} ${result.identifier}${result.identifier.startsWith(DEFAULT_IDENTIFIER_PREFIX) ? dim(' (placeholder — change before shipping)') : ''}`);
723
+ console.log(` ${yellow('dev port:')} ${result.port}`);
724
+ console.log(` ${yellow('scripts:')} native:dev, native:build`);
725
+ console.log(`\n Run ${cyan('npx caper doctor')} to check your native toolchain.`);
726
+ console.log(` The first ${cyan('native:build')} compiles Rust — expect a few minutes.`);
727
+ }
728
+
729
+ async function runPlugin() {
730
+ let result;
731
+ try {
732
+ result = await addNativePlugin(process.cwd());
733
+ } catch (err) {
734
+ console.error(red(`caper native plugin: ${err.message}`));
735
+ process.exit(1);
736
+ }
737
+
738
+ if (!result.changed) {
739
+ console.log(yellow('Already wired up for @caperjs/plugin-tauri — nothing to do.'));
740
+ } else {
741
+ console.log(green(bold('✓ Wired up for')) + ` ${cyan('@caperjs/plugin-tauri')}`);
742
+ if (result.addedDeps.length) console.log(` ${yellow('added deps:')} ${result.addedDeps.join(', ')}`);
743
+ if (result.ranTauriAddStore) console.log(` ${yellow('ran:')} tauri add store`);
744
+ if (result.capabilitiesChanged) console.log(` ${yellow('capabilities:')} window permissions added to src-tauri/capabilities/default.json`);
745
+ }
746
+
747
+ console.log(`\n Add to ${cyan('caper.config.ts')}:`);
748
+ console.log(` plugins: [..., 'tauri'], ${dim("// or ['tauri', { pauseOnBlur: true }] to pass options")}`);
749
+ console.log(` Use it as your Store adapter id ${cyan('tauri')} for durable saves.`);
750
+ }
751
+
752
+ async function runAndroid() {
753
+ let result;
754
+ try {
755
+ result = await androidNative(process.cwd());
756
+ } catch (err) {
757
+ console.error(red(`caper native android: ${err.message}`));
758
+ process.exit(1);
759
+ }
760
+
761
+ if (!result.changed) {
762
+ console.log(yellow('Already set up for Android. Nothing to do.'));
763
+ } else {
764
+ console.log(green(bold('✓ Set up for')) + ` ${cyan('Android')}`);
765
+ if (result.addedTargets.length) console.log(` ${yellow('rust targets:')} ${result.addedTargets.join(', ')}`);
766
+ if (result.ranAndroidInit) console.log(` ${yellow('ran:')} tauri android init --ci`);
767
+ if (result.buildRsPatched) console.log(` ${yellow('build.rs:')} 16 KB page alignment added to src-tauri/build.rs`);
768
+ if (result.mainActivityPatched) console.log(` ${yellow('MainActivity.kt:')} system bars hidden`);
769
+ if (result.addedScripts.length) console.log(` ${yellow('scripts:')} ${result.addedScripts.join(', ')}`);
770
+ }
771
+ for (const warning of result.warnings) console.log(yellow(` ⚠ ${warning}`));
772
+
773
+ console.log('\n Next:');
774
+ console.log(` ${cyan('pnpm native:android:dev')} ${dim('# on a device or emulator')}`);
775
+ console.log(` ${cyan('pnpm native:android:build --debug --apk --target aarch64')}`);
776
+ console.log(` Those scripts need NDK_HOME and JAVA_HOME exported in your shell:`);
777
+ console.log(` export NDK_HOME="${result.env.NDK_HOME}"`);
778
+ console.log(` export JAVA_HOME="${result.env.JAVA_HOME}"`);
779
+ if (result.env.rustupBin) {
780
+ console.log(` and rustup's Rust ahead of any other on PATH (plain Homebrew rust has no Android targets):`);
781
+ console.log(` export PATH="${result.env.rustupBin}:$PATH"`);
782
+ }
783
+ }
784
+
785
+ /**
786
+ * CLI entry for `caper native`.
787
+ *
788
+ * @param {string[]} args
789
+ */
790
+ export async function native(args) {
791
+ if (args[0] === 'init') {
792
+ await runInit(args.slice(1));
793
+ return;
794
+ }
795
+
796
+ if (args[0] === 'plugin') {
797
+ await runPlugin();
798
+ return;
799
+ }
800
+
801
+ if (args[0] === 'android') {
802
+ await runAndroid();
803
+ return;
804
+ }
805
+
806
+ printUsage();
807
+ process.exit(1);
808
+ }