@caperjs/core 0.7.2 → 0.7.4

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 (68) hide show
  1. package/build/index.mjs +1 -1
  2. package/build/internal/buildFlags.mjs +63 -18
  3. package/build/internal/schema.mjs +53 -0
  4. package/build/plugins/pwa.mjs +13 -7
  5. package/cli/doctor.mjs +53 -2
  6. package/cli/doctor.test.mjs +80 -1
  7. package/cli/native.mjs +126 -15
  8. package/cli/native.test.mjs +219 -3
  9. package/extras/llms.txt +35 -3
  10. package/lib/{CaptionsPlugin-COrofpHN.js → CaptionsPlugin-CAgQBcA1.js} +7 -7
  11. package/lib/{CaptionsPlugin-COrofpHN.js.map → CaptionsPlugin-CAgQBcA1.js.map} +1 -1
  12. package/lib/{DataAdapter-T-22-_D1.js → DataAdapter-CbyqWMo-.js} +5 -5
  13. package/lib/{DataAdapter-T-22-_D1.js.map → DataAdapter-CbyqWMo-.js.map} +1 -1
  14. package/lib/{DebugRenderer-ZN692Pbg.js → DebugRenderer-Bdkpyyt6.js} +2 -2
  15. package/lib/{DebugRenderer-ZN692Pbg.js.map → DebugRenderer-Bdkpyyt6.js.map} +1 -1
  16. package/lib/{DevToolsPlugin-CQXFoj1T.js → DevToolsPlugin-Cq5HHN9K.js} +2 -2
  17. package/lib/{DevToolsPlugin-CQXFoj1T.js.map → DevToolsPlugin-Cq5HHN9K.js.map} +1 -1
  18. package/lib/{GSAPPlugin-CNkncyXS.js → GSAPPlugin-D-ujhVYo.js} +2 -2
  19. package/lib/{GSAPPlugin-CNkncyXS.js.map → GSAPPlugin-D-ujhVYo.js.map} +1 -1
  20. package/lib/{LayoutPlugin-BuJgr9bd.js → LayoutPlugin-DqbwBm-X.js} +4 -4
  21. package/lib/{LayoutPlugin-BuJgr9bd.js.map → LayoutPlugin-DqbwBm-X.js.map} +1 -1
  22. package/lib/{ScreenDebugPlugin-kz9Mr6j1.js → ScreenDebugPlugin-CyT8UxEa.js} +3 -3
  23. package/lib/{ScreenDebugPlugin-kz9Mr6j1.js.map → ScreenDebugPlugin-CyT8UxEa.js.map} +1 -1
  24. package/lib/{SpinePlugin-DDzD-Qxd.js → SpinePlugin-DyXJyA3x.js} +2 -2
  25. package/lib/{SpinePlugin-DDzD-Qxd.js.map → SpinePlugin-DyXJyA3x.js.map} +1 -1
  26. package/lib/{StatsPlugin-tyFGzVjy.js → StatsPlugin-CEhgzWby.js} +4 -4
  27. package/lib/{StatsPlugin-tyFGzVjy.js.map → StatsPlugin-CEhgzWby.js.map} +1 -1
  28. package/lib/{VoiceOverPlugin-rTNz_9eb.js → VoiceOverPlugin-CSpNZjpJ.js} +2 -2
  29. package/lib/{VoiceOverPlugin-rTNz_9eb.js.map → VoiceOverPlugin-CSpNZjpJ.js.map} +1 -1
  30. package/lib/caper.mjs +126 -129
  31. package/lib/caper.mjs.map +1 -1
  32. package/lib/{const-Cj14a5U2.js → const-Bca54QyV.js} +235 -227
  33. package/lib/{const-Cj14a5U2.js.map → const-Bca54QyV.js.map} +1 -1
  34. package/lib/core/Application.d.ts +4 -0
  35. package/lib/core/Application.d.ts.map +1 -1
  36. package/lib/core/interfaces/IApplicationOptions.d.ts +21 -0
  37. package/lib/core/interfaces/IApplicationOptions.d.ts.map +1 -1
  38. package/lib/plugins/OrientationPlugin.d.ts +57 -0
  39. package/lib/plugins/OrientationPlugin.d.ts.map +1 -0
  40. package/lib/plugins/OrientationPlugin.test.d.ts +2 -0
  41. package/lib/plugins/OrientationPlugin.test.d.ts.map +1 -0
  42. package/lib/plugins/defaults.d.ts.map +1 -1
  43. package/lib/plugins/index.d.ts +1 -0
  44. package/lib/plugins/index.d.ts.map +1 -1
  45. package/lib/{registries-DmNGVLAi.js → registries-C1nPl8B3.js} +593 -502
  46. package/lib/registries-C1nPl8B3.js.map +1 -0
  47. package/lib/ui/UICanvas.d.ts +10 -2
  48. package/lib/ui/UICanvas.d.ts.map +1 -1
  49. package/lib/utils/index.d.ts +1 -0
  50. package/lib/utils/index.d.ts.map +1 -1
  51. package/lib/utils/orientation.d.ts +44 -0
  52. package/lib/utils/orientation.test.d.ts +2 -0
  53. package/lib/utils/orientation.test.d.ts.map +1 -0
  54. package/package.json +1 -1
  55. package/src/core/Application.ts +10 -0
  56. package/src/core/interfaces/IApplicationOptions.ts +21 -0
  57. package/src/plugins/OrientationPlugin.test.ts +252 -0
  58. package/src/plugins/OrientationPlugin.ts +178 -0
  59. package/src/plugins/defaults.ts +6 -0
  60. package/src/plugins/index.ts +1 -0
  61. package/src/ui/UICanvas.test.ts +23 -1
  62. package/src/ui/UICanvas.ts +22 -3
  63. package/src/utils/index.ts +1 -0
  64. package/src/utils/orientation.d.ts +44 -0
  65. package/src/utils/orientation.js +37 -0
  66. package/src/utils/orientation.test.ts +30 -0
  67. package/src/version.ts +1 -1
  68. package/lib/registries-DmNGVLAi.js.map +0 -1
package/cli/native.mjs CHANGED
@@ -6,6 +6,8 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import process from 'node:process';
8
8
 
9
+ import { resolveOrientation } from '../src/utils/orientation.js';
10
+
9
11
  /**
10
12
  * `caper native init` — one-shot Tauri v2 scaffolding for a Caper app: adds
11
13
  * `@tauri-apps/cli`, runs `tauri init --ci` with a pinned identifier/port,
@@ -18,6 +20,16 @@ const IDENTIFIER_RE = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
18
20
  const PLACEHOLDER_IDENTIFIER = 'com.tauri.dev';
19
21
  const DEFAULT_IDENTIFIER_PREFIX = 'dev.caper.';
20
22
 
23
+ /** A window/app title from a package name: strip an npm scope, split on `-`/`_`/`.`, capitalize each word. */
24
+ export function displayTitle(packageName) {
25
+ return String(packageName ?? '')
26
+ .replace(/^@[^/]+\//, '')
27
+ .split(/[-_.\s]+/)
28
+ .filter(Boolean)
29
+ .map((word) => word[0].toUpperCase() + word.slice(1))
30
+ .join(' ');
31
+ }
32
+
21
33
  /** Strip an npm scope, lowercase, collapse non `[a-z0-9]` runs to `-`, trim `-`. */
22
34
  export function appSlug(packageName) {
23
35
  return String(packageName ?? '')
@@ -120,10 +132,37 @@ function renderCommand({ cmd, args }) {
120
132
  return [cmd, ...args].join(' ');
121
133
  }
122
134
 
135
+ /** The `caper.config.ts` orientation values, and the `android:screenOrientation` each one becomes. */
136
+ export const ANDROID_SCREEN_ORIENTATION = Object.freeze({ portrait: 'portrait', landscape: 'sensorLandscape' });
137
+
138
+ /** The default injectable `loadConfig`: the subset of `caper.config.ts` the native commands read. */
139
+ async function defaultLoadConfig(cwd) {
140
+ // Imported lazily: build/ pulls in the oxc parser and vite, which only these commands need.
141
+ const { readConfigOrientation } = await import('../build/internal/buildFlags.mjs');
142
+ return { orientation: readConfigOrientation(cwd) };
143
+ }
144
+
145
+ /**
146
+ * The orientation lock from `caper.config.ts` through `loadConfig` (either form
147
+ * of `orientation`, normalized by `resolveOrientation`), or a warning when it
148
+ * can't be read: a broken or unusual config must never stop a native command.
149
+ *
150
+ * @returns {Promise<{ orientation?: 'portrait' | 'landscape', warning?: string }>}
151
+ */
152
+ export async function readOrientation(cwd, loadConfig = defaultLoadConfig) {
153
+ try {
154
+ return { orientation: resolveOrientation((await loadConfig(cwd))?.orientation)?.lock };
155
+ } catch (err) {
156
+ return { warning: `could not read caper.config.ts orientation (${err.message}), so orientation was skipped.` };
157
+ }
158
+ }
159
+
123
160
  /** 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 }) {
161
+ export function patchTauriConfig(conf, { identifier, title, orientation }) {
125
162
  const windows = Array.isArray(conf.app?.windows) ? [...conf.app.windows] : [];
126
- windows[0] = { ...(windows[0] ?? {}), title, width: 1280, height: 720 };
163
+ // 450x800 keeps a portrait window, title bar included, on a 13" laptop screen.
164
+ const size = orientation === 'portrait' ? { width: 450, height: 800 } : { width: 1280, height: 720 };
165
+ windows[0] = { ...(windows[0] ?? {}), title, ...size };
127
166
 
128
167
  return {
129
168
  ...conf,
@@ -198,9 +237,9 @@ function writeJson(file, data, indent) {
198
237
  *
199
238
  * @param {string} cwd
200
239
  * @param {{ identifier?: string, port?: number, icon?: string }} [opts]
201
- * @param {{ run?: typeof defaultRun }} [deps]
240
+ * @param {{ run?: typeof defaultRun, loadConfig?: typeof defaultLoadConfig }} [deps]
202
241
  */
203
- export async function initNative(cwd, opts = {}, { run = defaultRun } = {}) {
242
+ export async function initNative(cwd, opts = {}, { run = defaultRun, loadConfig = defaultLoadConfig } = {}) {
204
243
  const pkgPath = path.join(cwd, 'package.json');
205
244
  if (!fs.existsSync(pkgPath)) {
206
245
  throw new Error('no package.json found in this directory.');
@@ -219,7 +258,7 @@ export async function initNative(cwd, opts = {}, { run = defaultRun } = {}) {
219
258
  // vars can instead reflect an unrelated ancestor process (e.g. a monorepo
220
259
  // task runner). Read the name straight from the package.json we already have.
221
260
  const name = pkg.name ?? 'app';
222
- const title = opts.title ?? name;
261
+ const title = opts.title ?? displayTitle(name);
223
262
  const slug = appSlug(pkg.name ?? name);
224
263
 
225
264
  const identifier = opts.identifier ?? defaultIdentifier(slug);
@@ -244,7 +283,7 @@ export async function initNative(cwd, opts = {}, { run = defaultRun } = {}) {
244
283
  'init',
245
284
  '--ci',
246
285
  '--app-name',
247
- name,
286
+ title,
248
287
  '--window-title',
249
288
  title,
250
289
  '--frontend-dist',
@@ -258,17 +297,24 @@ export async function initNative(cwd, opts = {}, { run = defaultRun } = {}) {
258
297
  ];
259
298
  run(EXEC_RUNNER[pm], initArgs, { cwd });
260
299
 
300
+ const warnings = [];
301
+ const { orientation, warning } = await readOrientation(cwd, loadConfig);
302
+ if (warning) warnings.push(warning);
303
+
261
304
  const tauriConfPath = path.join(srcTauriDir, 'tauri.conf.json');
262
305
  const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf-8'));
263
- writeJson(tauriConfPath, patchTauriConfig(tauriConf, { identifier, title }), 2);
306
+ writeJson(tauriConfPath, patchTauriConfig(tauriConf, { identifier, title, orientation }), 2);
264
307
 
265
- writeJson(pkgPath, patchPackageScripts(pkg), detectIndent(pkgRaw));
308
+ // Re-read: the package manager rewrote package.json when it added @tauri-apps/cli,
309
+ // and patching the copy read above would silently drop that devDependency.
310
+ const freshPkgRaw = fs.readFileSync(pkgPath, 'utf-8');
311
+ writeJson(pkgPath, patchPackageScripts(JSON.parse(freshPkgRaw)), detectIndent(freshPkgRaw));
266
312
 
267
313
  if (opts.icon) {
268
314
  run(EXEC_RUNNER[pm], ['tauri', 'icon', opts.icon], { cwd });
269
315
  }
270
316
 
271
- return { status: 'ok', name, title, identifier, port, pm };
317
+ return { status: 'ok', name, title, identifier, port, pm, orientation, warnings };
272
318
  }
273
319
 
274
320
  /**
@@ -528,6 +574,38 @@ export function findMainActivity(genAndroidDir) {
528
574
  return walk(path.join(genAndroidDir, 'app/src/main/java'));
529
575
  }
530
576
 
577
+ /** Where `tauri android init` writes the app manifest, relative to `src-tauri/`. */
578
+ export const ANDROID_MANIFEST = 'gen/android/app/src/main/AndroidManifest.xml';
579
+
580
+ const MAIN_ACTIVITY_TAG_RE = /<activity\b[^>]*\bandroid:name="\.MainActivity"[^>]*>/;
581
+ const SCREEN_ORIENTATION_RE = /(\bandroid:screenOrientation=")([^"]*)(")/;
582
+
583
+ /** The MainActivity's `android:screenOrientation` in an AndroidManifest.xml, or `undefined`. */
584
+ export function readManifestOrientation(xml) {
585
+ return String(xml ?? '').match(MAIN_ACTIVITY_TAG_RE)?.[0].match(SCREEN_ORIENTATION_RE)?.[2];
586
+ }
587
+
588
+ /**
589
+ * Sets `android:screenOrientation` on the `.MainActivity` element for a
590
+ * `caper.config.ts` orientation (see `ANDROID_SCREEN_ORIENTATION`), replacing a
591
+ * different value or adding the attribute under `android:name` with its
592
+ * indentation. Returns `xml` itself when nothing changes, including when
593
+ * `orientation` is unset or there is no MainActivity element.
594
+ */
595
+ export function patchManifestOrientation(xml, orientation) {
596
+ const value = ANDROID_SCREEN_ORIENTATION[orientation];
597
+ const tag = value ? xml.match(MAIN_ACTIVITY_TAG_RE)?.[0] : undefined;
598
+ if (!tag) return xml;
599
+
600
+ let patched;
601
+ if (SCREEN_ORIENTATION_RE.test(tag)) {
602
+ patched = tag.replace(SCREEN_ORIENTATION_RE, `$1${value}$3`);
603
+ } else {
604
+ patched = tag.replace(/(\s+)(android:name="\.MainActivity")/, `$1$2$1android:screenOrientation="${value}"`);
605
+ }
606
+ return patched === tag ? xml : xml.replace(tag, patched);
607
+ }
608
+
531
609
  /** Returns a new package.json: adds `native:android:dev`/`native:android:build` scripts only where absent. */
532
610
  export function patchAndroidScripts(pkg) {
533
611
  const scripts = { ...(pkg.scripts ?? {}) };
@@ -559,13 +637,15 @@ const ANDROID_MISSING_HELP = {
559
637
  * The I/O core of `caper native android`: the Android steps from
560
638
  * docs/wiki/native-tauri.md: installs the Rust targets, runs
561
639
  * `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.
640
+ * `build.rs`, hides the system bars in `MainActivity.kt`, locks the
641
+ * MainActivity to `caper.config.ts`'s `orientation` in `AndroidManifest.xml`
642
+ * (only when it is set), and adds `native:android:*` scripts. No console
643
+ * output; the CLI wrapper below owns presentation. Idempotent: a second run
644
+ * makes zero `run` calls and zero writes.
565
645
  *
566
646
  * @param {string} cwd
567
647
  * @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]
648
+ * @param {{ run?: typeof defaultRun, exec?: typeof defaultExec, env?: Record<string, string>, platform?: string, homedir?: string, exists?: (p: string) => boolean, listDir?: (p: string) => string[], loadConfig?: typeof defaultLoadConfig }} [deps]
569
649
  */
570
650
  export async function androidNative(
571
651
  cwd,
@@ -578,6 +658,7 @@ export async function androidNative(
578
658
  homedir = os.homedir(),
579
659
  exists = fs.existsSync,
580
660
  listDir = defaultListDir,
661
+ loadConfig = defaultLoadConfig,
581
662
  } = {},
582
663
  ) {
583
664
  const pkgPath = path.join(cwd, 'package.json');
@@ -637,6 +718,24 @@ export async function androidNative(
637
718
  }
638
719
  }
639
720
 
721
+ // Unset means no lock: the manifest is left alone, never rewritten to "unspecified".
722
+ let orientationPatched = false;
723
+ const { orientation, warning: orientationWarning } = await readOrientation(cwd, loadConfig);
724
+ if (orientationWarning) warnings.push(orientationWarning);
725
+ if (orientation) {
726
+ const manifestPath = path.join(srcTauriDir, ANDROID_MANIFEST);
727
+ const xml = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath, 'utf-8') : null;
728
+ const patched = xml === null ? null : patchManifestOrientation(xml, orientation);
729
+ if (patched === null || readManifestOrientation(patched) !== ANDROID_SCREEN_ORIENTATION[orientation]) {
730
+ warnings.push(
731
+ `could not find the MainActivity in src-tauri/${ANDROID_MANIFEST}, so the ${orientation} orientation was not applied. Set android:screenOrientation="${ANDROID_SCREEN_ORIENTATION[orientation]}" on it by hand.`,
732
+ );
733
+ } else if (patched !== xml) {
734
+ fs.writeFileSync(manifestPath, patched, 'utf-8');
735
+ orientationPatched = true;
736
+ }
737
+ }
738
+
640
739
  const pkgRaw = fs.readFileSync(pkgPath, 'utf-8');
641
740
  const pkg = JSON.parse(pkgRaw);
642
741
  const patchedPkg = patchAndroidScripts(pkg);
@@ -648,11 +747,14 @@ export async function androidNative(
648
747
  const buildRsPatched = buildRsPlan === 'write';
649
748
  return {
650
749
  status: 'ok',
651
- changed: addedTargets.length > 0 || ranAndroidInit || buildRsPatched || mainActivityPatched || addedScripts.length > 0,
750
+ changed:
751
+ addedTargets.length > 0 || ranAndroidInit || buildRsPatched || mainActivityPatched || orientationPatched || addedScripts.length > 0,
652
752
  addedTargets,
653
753
  ranAndroidInit,
654
754
  buildRsPatched,
655
755
  mainActivityPatched,
756
+ orientation,
757
+ orientationPatched,
656
758
  addedScripts,
657
759
  env: {
658
760
  ANDROID_HOME: resolved.env.ANDROID_HOME,
@@ -666,7 +768,7 @@ export async function androidNative(
666
768
  }
667
769
 
668
770
  function printUsage() {
669
- console.error(red('Usage: caper native init [--identifier <id>] [--port <n>] [--icon <png>]'));
771
+ console.error(red('Usage: caper native init [--title <name>] [--identifier <id>] [--port <n>] [--icon <png>]'));
670
772
  console.error(red(' caper native plugin'));
671
773
  console.error(red(' caper native android'));
672
774
  }
@@ -687,6 +789,10 @@ function parseInitArgs(args) {
687
789
  const next = args[++i];
688
790
  if (!next) throw new Error('Missing value for --icon');
689
791
  opts.icon = next;
792
+ } else if (arg === '--title') {
793
+ const next = args[++i];
794
+ if (!next) throw new Error('Missing value for --title');
795
+ opts.title = next;
690
796
  } else {
691
797
  throw new Error(`Unknown option: ${arg}`);
692
798
  }
@@ -722,6 +828,8 @@ async function runInit(args) {
722
828
  console.log(` ${yellow('identifier:')} ${result.identifier}${result.identifier.startsWith(DEFAULT_IDENTIFIER_PREFIX) ? dim(' (placeholder — change before shipping)') : ''}`);
723
829
  console.log(` ${yellow('dev port:')} ${result.port}`);
724
830
  console.log(` ${yellow('scripts:')} native:dev, native:build`);
831
+ if (result.orientation) console.log(` ${yellow('window:')} ${result.orientation === 'portrait' ? '450x800' : '1280x720'} (${result.orientation}, from caper.config.ts)`);
832
+ for (const warning of result.warnings) console.log(yellow(` ⚠ ${warning}`));
725
833
  console.log(`\n Run ${cyan('npx caper doctor')} to check your native toolchain.`);
726
834
  console.log(` The first ${cyan('native:build')} compiles Rust — expect a few minutes.`);
727
835
  }
@@ -766,6 +874,9 @@ async function runAndroid() {
766
874
  if (result.ranAndroidInit) console.log(` ${yellow('ran:')} tauri android init --ci`);
767
875
  if (result.buildRsPatched) console.log(` ${yellow('build.rs:')} 16 KB page alignment added to src-tauri/build.rs`);
768
876
  if (result.mainActivityPatched) console.log(` ${yellow('MainActivity.kt:')} system bars hidden`);
877
+ if (result.orientationPatched) {
878
+ console.log(` ${yellow('AndroidManifest.xml:')} ${result.orientation} orientation (android:screenOrientation="${ANDROID_SCREEN_ORIENTATION[result.orientation]}")`);
879
+ }
769
880
  if (result.addedScripts.length) console.log(` ${yellow('scripts:')} ${result.addedScripts.join(', ')}`);
770
881
  }
771
882
  for (const warning of result.warnings) console.log(yellow(` ⚠ ${warning}`));
@@ -4,6 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import { afterEach, describe, expect, it } from 'vitest';
8
9
 
9
10
  import {
@@ -14,6 +15,7 @@ import {
14
15
  commandsFor,
15
16
  defaultIdentifier,
16
17
  defaultPort,
18
+ displayTitle,
17
19
  findMainActivity,
18
20
  initNative,
19
21
  isValidIdentifier,
@@ -23,15 +25,23 @@ import {
23
25
  parsePort,
24
26
  patchAndroidScripts,
25
27
  patchCapabilities,
28
+ patchManifestOrientation,
26
29
  patchPackageScripts,
27
30
  patchTauriConfig,
28
31
  planBuildRs,
29
32
  planMainActivity,
33
+ readManifestOrientation,
30
34
  renderMainActivity,
31
35
  resolveAndroidEnv,
32
36
  TAURI_PLUGIN_PERMISSIONS,
33
37
  } from './native.mjs';
34
38
 
39
+ // A copy of the manifest `tauri android init` generated for kitchen-sink.
40
+ const MANIFEST_FIXTURE = fs.readFileSync(
41
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../test/fixtures/android/AndroidManifest.xml'),
42
+ 'utf-8',
43
+ );
44
+
35
45
  let tempDir = null;
36
46
 
37
47
  afterEach(() => {
@@ -46,6 +56,14 @@ function makeTempDir() {
46
56
  return tempDir;
47
57
  }
48
58
 
59
+ describe('displayTitle', () => {
60
+ it('title-cases a package name into a window title', () => {
61
+ expect(displayTitle('rhodora')).toBe('Rhodora');
62
+ expect(displayTitle('my-game')).toBe('My Game');
63
+ expect(displayTitle('@studio/space_rocks')).toBe('Space Rocks');
64
+ });
65
+ });
66
+
49
67
  describe('appSlug', () => {
50
68
  it('strips a scope, lowercases, and collapses non-alnum runs to a hyphen', () => {
51
69
  expect(appSlug('@acme/My Game')).toBe('my-game');
@@ -244,6 +262,16 @@ describe('patchTauriConfig', () => {
244
262
  expect(patched.app.windows[0].resizable).toBe(true);
245
263
  });
246
264
 
265
+ it('uses a 450x800 window for a portrait game', () => {
266
+ const patched = patchTauriConfig(conf, { identifier: 'dev.caper.my-game', title: 'My Game', orientation: 'portrait' });
267
+ expect(patched.app.windows[0]).toMatchObject({ width: 450, height: 800 });
268
+ });
269
+
270
+ it('keeps 1280x720 for a landscape game', () => {
271
+ const patched = patchTauriConfig(conf, { identifier: 'dev.caper.my-game', title: 'My Game', orientation: 'landscape' });
272
+ expect(patched.app.windows[0]).toMatchObject({ width: 1280, height: 720 });
273
+ });
274
+
247
275
  it('does not mutate the original object', () => {
248
276
  patchTauriConfig(conf, { identifier: 'dev.caper.my-game', title: 'My Game' });
249
277
  expect(conf.identifier).toBe('com.tauri.dev');
@@ -315,6 +343,56 @@ describe('initNative', () => {
315
343
  };
316
344
  }
317
345
 
346
+ it('keeps the @tauri-apps/cli devDependency the package manager just added', async () => {
347
+ const dir = makeTempDir();
348
+ scaffoldApp(dir);
349
+ const calls = [];
350
+ const tauriRun = stubbedRun(calls);
351
+ // Like a real `pnpm add -D`, write the devDependency into package.json.
352
+ const run = (cmd, args, opts) => {
353
+ if (args[0] === 'add') {
354
+ const pkgPath = path.join(opts.cwd, 'package.json');
355
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
356
+ pkg.devDependencies = { ...pkg.devDependencies, '@tauri-apps/cli': '^2.12.1' };
357
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
358
+ }
359
+ return tauriRun(cmd, args, opts);
360
+ };
361
+
362
+ await initNative(dir, {}, { run });
363
+
364
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
365
+ expect(pkg.devDependencies['@tauri-apps/cli']).toBe('^2.12.1');
366
+ expect(pkg.scripts['native:build']).toBe('tauri build');
367
+ });
368
+
369
+ it('sizes the window from caper.config orientation', async () => {
370
+ const dir = makeTempDir();
371
+ scaffoldApp(dir);
372
+
373
+ const result = await initNative(dir, {}, { run: stubbedRun([]), loadConfig: () => ({ orientation: 'portrait' }) });
374
+
375
+ expect(result.warnings).toEqual([]);
376
+ const conf = JSON.parse(fs.readFileSync(path.join(dir, 'src-tauri/tauri.conf.json'), 'utf-8'));
377
+ expect(conf.app.windows[0]).toMatchObject({ width: 450, height: 800 });
378
+ });
379
+
380
+ it('warns and keeps 1280x720 when caper.config orientation cannot be read', async () => {
381
+ const dir = makeTempDir();
382
+ scaffoldApp(dir);
383
+ const loadConfig = () => {
384
+ throw new Error('boom');
385
+ };
386
+
387
+ const result = await initNative(dir, {}, { run: stubbedRun([]), loadConfig });
388
+
389
+ expect(result.status).toBe('ok');
390
+ expect(result.warnings).toHaveLength(1);
391
+ expect(result.warnings[0]).toMatch(/could not read caper\.config\.ts orientation/);
392
+ const conf = JSON.parse(fs.readFileSync(path.join(dir, 'src-tauri/tauri.conf.json'), 'utf-8'));
393
+ expect(conf.app.windows[0]).toMatchObject({ width: 1280, height: 720 });
394
+ });
395
+
318
396
  it('does nothing and makes zero run calls when src-tauri already exists', async () => {
319
397
  const dir = makeTempDir();
320
398
  scaffoldApp(dir);
@@ -360,9 +438,9 @@ describe('initNative', () => {
360
438
  'init',
361
439
  '--ci',
362
440
  '--app-name',
363
- 'my-game',
441
+ 'My Game',
364
442
  '--window-title',
365
- 'my-game',
443
+ 'My Game',
366
444
  '--frontend-dist',
367
445
  '../dist',
368
446
  '--dev-url',
@@ -375,7 +453,7 @@ describe('initNative', () => {
375
453
 
376
454
  const conf = JSON.parse(fs.readFileSync(path.join(dir, 'src-tauri/tauri.conf.json'), 'utf-8'));
377
455
  expect(conf.identifier).toBe('dev.caper.mygame');
378
- expect(conf.app.windows[0]).toMatchObject({ title: 'my-game', width: 1280, height: 720 });
456
+ expect(conf.app.windows[0]).toMatchObject({ title: 'My Game', width: 1280, height: 720 });
379
457
  expect(conf.app.security.csp).toBeNull();
380
458
  expect(conf.bundle).toEqual({ active: true, targets: ['app'] });
381
459
 
@@ -891,6 +969,7 @@ describe('androidNative', () => {
891
969
  const dir = path.join(opts.cwd, 'src-tauri/gen/android/app/src/main/java/x/y/z');
892
970
  fs.mkdirSync(dir, { recursive: true });
893
971
  fs.writeFileSync(path.join(dir, 'MainActivity.kt'), STOCK_MAIN_ACTIVITY, 'utf-8');
972
+ fs.writeFileSync(path.join(opts.cwd, 'src-tauri/gen/android/app/src/main/AndroidManifest.xml'), MANIFEST_FIXTURE, 'utf-8');
894
973
  } else {
895
974
  throw new Error(`unstubbed run: ${cmd} ${args.join(' ')}`);
896
975
  }
@@ -989,6 +1068,103 @@ describe('androidNative', () => {
989
1068
  expect(snapshot(dir)).toEqual(before);
990
1069
  });
991
1070
 
1071
+ const manifestPath = (dir) => path.join(dir, 'src-tauri/gen/android/app/src/main/AndroidManifest.xml');
1072
+
1073
+ it('leaves AndroidManifest.xml alone when caper.config sets no orientation', async () => {
1074
+ const dir = makeTempDir();
1075
+ const deps = scaffoldAndroidApp(dir);
1076
+
1077
+ const result = await androidNative(dir, {}, { ...stubs({ installed: [] }, []), ...deps, loadConfig: () => ({}) });
1078
+
1079
+ expect(result.orientation).toBeUndefined();
1080
+ expect(result.orientationPatched).toBe(false);
1081
+ expect(result.warnings).toEqual([]);
1082
+ expect(fs.readFileSync(manifestPath(dir), 'utf-8')).toBe(MANIFEST_FIXTURE);
1083
+ });
1084
+
1085
+ it('locks MainActivity to caper.config orientation', async () => {
1086
+ const dir = makeTempDir();
1087
+ const deps = scaffoldAndroidApp(dir);
1088
+
1089
+ const result = await androidNative(dir, {}, { ...stubs({ installed: [] }, []), ...deps, loadConfig: () => ({ orientation: 'portrait' }) });
1090
+
1091
+ expect(result.orientation).toBe('portrait');
1092
+ expect(result.orientationPatched).toBe(true);
1093
+ expect(readManifestOrientation(fs.readFileSync(manifestPath(dir), 'utf-8'))).toBe('portrait');
1094
+ });
1095
+
1096
+ it('applies a changed orientation on the next run, then makes zero writes', async () => {
1097
+ const dir = makeTempDir();
1098
+ const deps = scaffoldAndroidApp(dir);
1099
+ const state = { installed: [] };
1100
+ await androidNative(dir, {}, { ...stubs(state, []), ...deps, loadConfig: () => ({ orientation: 'portrait' }) });
1101
+
1102
+ const landscape = { ...deps, loadConfig: () => ({ orientation: 'landscape' }) };
1103
+ const changed = await androidNative(dir, {}, { ...stubs(state, []), ...landscape });
1104
+ expect(changed.changed).toBe(true);
1105
+ expect(changed.orientationPatched).toBe(true);
1106
+ expect(readManifestOrientation(fs.readFileSync(manifestPath(dir), 'utf-8'))).toBe('sensorLandscape');
1107
+
1108
+ const before = snapshot(dir);
1109
+ const calls = [];
1110
+ const again = await androidNative(dir, {}, { ...stubs(state, calls), ...landscape });
1111
+ expect(calls.filter((c) => c.kind === 'run')).toHaveLength(0);
1112
+ expect(again.changed).toBe(false);
1113
+ expect(again.orientationPatched).toBe(false);
1114
+ expect(snapshot(dir)).toEqual(before);
1115
+ });
1116
+
1117
+ it('reads the lock from the object form of orientation', async () => {
1118
+ const dir = makeTempDir();
1119
+ const deps = scaffoldAndroidApp(dir);
1120
+ const loadConfig = () => ({ orientation: { lock: 'landscape', overlay: false } });
1121
+
1122
+ const result = await androidNative(dir, {}, { ...stubs({ installed: [] }, []), ...deps, loadConfig });
1123
+
1124
+ expect(result.orientation).toBe('landscape');
1125
+ expect(readManifestOrientation(fs.readFileSync(manifestPath(dir), 'utf-8'))).toBe('sensorLandscape');
1126
+ });
1127
+
1128
+ it('warns and skips orientation when caper.config orientation is invalid', async () => {
1129
+ const dir = makeTempDir();
1130
+ const deps = scaffoldAndroidApp(dir);
1131
+
1132
+ const result = await androidNative(dir, {}, { ...stubs({ installed: [] }, []), ...deps, loadConfig: () => ({ orientation: { lock: 'any' } }) });
1133
+
1134
+ expect(result.orientationPatched).toBe(false);
1135
+ expect(result.warnings[0]).toMatch(/could not read caper\.config\.ts orientation/);
1136
+ expect(fs.readFileSync(manifestPath(dir), 'utf-8')).toBe(MANIFEST_FIXTURE);
1137
+ });
1138
+
1139
+ it('warns and skips orientation when caper.config cannot be read', async () => {
1140
+ const dir = makeTempDir();
1141
+ const deps = scaffoldAndroidApp(dir);
1142
+ const loadConfig = () => {
1143
+ throw new Error('boom');
1144
+ };
1145
+
1146
+ const result = await androidNative(dir, {}, { ...stubs({ installed: [] }, []), ...deps, loadConfig });
1147
+
1148
+ expect(result.status).toBe('ok');
1149
+ expect(result.orientationPatched).toBe(false);
1150
+ expect(result.warnings).toHaveLength(1);
1151
+ expect(result.warnings[0]).toMatch(/could not read caper\.config\.ts orientation/);
1152
+ expect(fs.readFileSync(manifestPath(dir), 'utf-8')).toBe(MANIFEST_FIXTURE);
1153
+ });
1154
+
1155
+ it('warns when orientation is set but AndroidManifest.xml is missing', async () => {
1156
+ const dir = makeTempDir();
1157
+ const deps = scaffoldAndroidApp(dir);
1158
+ await androidNative(dir, {}, { ...stubs({ installed: [] }, []), ...deps, loadConfig: () => ({}) });
1159
+ fs.rmSync(manifestPath(dir));
1160
+
1161
+ const result = await androidNative(dir, {}, { ...stubs({ installed: [...ANDROID_RUST_TARGETS] }, []), ...deps, loadConfig: () => ({ orientation: 'portrait' }) });
1162
+
1163
+ expect(result.orientationPatched).toBe(false);
1164
+ expect(result.warnings).toHaveLength(1);
1165
+ expect(result.warnings[0]).toMatch(/AndroidManifest\.xml/);
1166
+ });
1167
+
992
1168
  it('writes the build.rs template when build.rs is missing', async () => {
993
1169
  const dir = makeTempDir();
994
1170
  const deps = scaffoldAndroidApp(dir);
@@ -1023,6 +1199,46 @@ describe('androidNative', () => {
1023
1199
  });
1024
1200
  });
1025
1201
 
1202
+ describe('patchManifestOrientation', () => {
1203
+ const withAttr = (value) => MANIFEST_FIXTURE.replace('android:name=".MainActivity"', `android:name=".MainActivity"\n android:screenOrientation="${value}"`);
1204
+
1205
+ it('reads no orientation from the stock manifest', () => {
1206
+ expect(readManifestOrientation(MANIFEST_FIXTURE)).toBeUndefined();
1207
+ });
1208
+
1209
+ it('adds android:screenOrientation under android:name, matching its indentation', () => {
1210
+ const patched = patchManifestOrientation(MANIFEST_FIXTURE, 'portrait');
1211
+ expect(patched).toBe(withAttr('portrait'));
1212
+ expect(readManifestOrientation(patched)).toBe('portrait');
1213
+ });
1214
+
1215
+ it('maps landscape to sensorLandscape', () => {
1216
+ expect(patchManifestOrientation(MANIFEST_FIXTURE, 'landscape')).toBe(withAttr('sensorLandscape'));
1217
+ });
1218
+
1219
+ it('returns the same string when the value already matches', () => {
1220
+ const xml = withAttr('portrait');
1221
+ expect(patchManifestOrientation(xml, 'portrait')).toBe(xml);
1222
+ });
1223
+
1224
+ it('replaces a different existing value', () => {
1225
+ expect(patchManifestOrientation(withAttr('landscape'), 'portrait')).toBe(withAttr('portrait'));
1226
+ expect(patchManifestOrientation(withAttr('portrait'), 'landscape')).toBe(withAttr('sensorLandscape'));
1227
+ });
1228
+
1229
+ it('returns the same string when orientation is unset', () => {
1230
+ const xml = withAttr('portrait');
1231
+ expect(patchManifestOrientation(xml, undefined)).toBe(xml);
1232
+ });
1233
+
1234
+ it('only touches the MainActivity element', () => {
1235
+ const xml = MANIFEST_FIXTURE.replace('<provider', '<activity android:name=".Other" />\n\n <provider');
1236
+ const patched = patchManifestOrientation(xml, 'portrait');
1237
+ expect(patched).toContain('<activity android:name=".Other" />');
1238
+ expect(patched.match(/screenOrientation/g)).toHaveLength(1);
1239
+ });
1240
+ });
1241
+
1026
1242
  describe('findMainActivity', () => {
1027
1243
  it('finds MainActivity.kt anywhere under the java source root', () => {
1028
1244
  const dir = makeTempDir();
package/extras/llms.txt CHANGED
@@ -219,9 +219,9 @@ subcommands run from the app root.
219
219
  | `caper doctor [--offline] [--json]` | One-screen health check: installed vs latest caper, npm vs linked engine (and whether the linked build is stale), generated types present/fresh, asset manifest, agent pointers, peer deps, caches. Exit 1 on any ✗. |
220
220
  | `caper agent init [--dir <skillsDir>]` | Install the shipped `caper` agent skill (default `.claude/skills/caper/SKILL.md`) and upsert a pointer block into `AGENTS.md`/`CLAUDE.md`. Re-run after upgrading caper. |
221
221
  | `caper agent probe <url> [--action n[=json]]… [--until "<js predicate>"] [--wait ms] [--screenshot p] [--headed] [--json]` | Drive a **running** app headlessly through the automation bridge (§17) in a Chromium you own: boots, sends actions, waits on state, returns context/state/log/errors (+ screenshot). Needs `playwright` in the app's devDependencies. |
222
- | `caper native init [--identifier <id>] [--port <n>] [--icon <png>]` | One-shot Tauri v2 scaffolding: adds `@tauri-apps/cli`, runs `tauri init --ci`, pins a `dev.caper.*`-style identifier + a stable non-3000 dev port, adds `native:dev`/`native:build` scripts. No-ops with "already initialized" if `src-tauri/` exists. |
222
+ | `caper native init [--title <name>] [--identifier <id>] [--port <n>] [--icon <png>]` | One-shot Tauri v2 scaffolding: adds `@tauri-apps/cli`, runs `tauri init --ci`, names the app and window after the package (title-cased: `my-game` → "My Game"; `--title` overrides), pins a `dev.caper.*`-style identifier + a stable non-3000 dev port, opens a 1280x720 window (450x800 when `caper.config.ts` sets `orientation: 'portrait'`), adds `native:dev`/`native:build` scripts. No-ops with "already initialized" if `src-tauri/` exists. |
223
223
  | `caper native plugin` | Wires an already-`native init`'d app up for `@caperjs/plugin-tauri`: adds its JS deps, runs `tauri add store`, and patches the window permissions it needs into `capabilities/default.json`. Fails with "run `caper native init` first" if `src-tauri/` is missing; idempotent otherwise. |
224
- | `caper native android` | Sets an already-`native init`'d app up for Android: finds the SDK/NDK/JDK/rustup, adds the four Android Rust targets, runs `tauri android init --ci`, adds 16 KB page alignment to `build.rs`, hides the system bars in `MainActivity.kt`, adds `native:android:dev`/`native:android:build` scripts. Idempotent; warns instead of overwriting files you changed. |
224
+ | `caper native android` | Sets an already-`native init`'d app up for Android: finds the SDK/NDK/JDK/rustup, adds the four Android Rust targets, runs `tauri android init --ci`, adds 16 KB page alignment to `build.rs`, hides the system bars in `MainActivity.kt`, locks the activity to `caper.config.ts`'s `orientation` (if set; re-run after changing it), adds `native:android:dev`/`native:android:build` scripts. Idempotent; warns instead of overwriting files you changed. |
225
225
  | `caper update` | Update Caper to latest. |
226
226
  | `caper install` | Install peer deps (rarely needed; normally automatic). |
227
227
  | `caper vo generate [inputDir] [outputDir]` | Voiceover CSV generation from locales. Defaults: `./src/locales` → `./src/assets/audio/vo/csv`. |
@@ -236,7 +236,10 @@ exists. For Android, run `caper native android` once, then
236
236
  `pnpm native:android:dev` (device or emulator) or
237
237
  `pnpm native:android:build --debug --apk --target aarch64`, with `NDK_HOME` and
238
238
  `JAVA_HOME` exported; doctor adds `native-android-*` rows (Rust targets, NDK,
239
- JDK, 16 KB alignment, hidden system bars) once `src-tauri/gen/android/` exists. Under the Tauri CLI (detected via `TAURI_ENV_PLATFORM`), the preset
239
+ JDK, 16 KB alignment, hidden system bars, and orientation matching `caper.config.ts`) once `src-tauri/gen/android/` exists.
240
+ Set `orientation: 'portrait' | 'landscape'` in `caper.config.ts` rather than editing
241
+ `AndroidManifest.xml` by hand: `caper native android` writes it there (`portrait` or
242
+ `sensorLandscape`) and doctor flags a mismatch. Under the Tauri CLI (detected via `TAURI_ENV_PLATFORM`), the preset
240
243
  configures the dev server for you — no extra `vite.config.ts` needed — and
241
244
  disables the PWA plugin, since service workers don't work on the `tauri://`
242
245
  origin. Check `isTauri` (exported from `@caperjs/core`, alongside
@@ -480,9 +483,29 @@ Every allowed key in `caper.config.ts`. Defer shape details to
480
483
  | `resolution?: number` | DPI scale factor. |
481
484
  | `splash?: SplashOptions` | Splash screen config. |
482
485
  | `logger?: LoggerMode` | Verbosity. |
486
+ | `orientation?: 'portrait' \| 'landscape' \| { lock, overlay? }` | Lock the game to one orientation on every target. Unset means no lock. The string is shorthand for `{ lock: '...' }`. Write the lock as a string literal (the build and CLI read it by parsing the file). `caper native android` writes it into `AndroidManifest.xml`, `caper native init` opens a 450x800 window for portrait, the PWA manifest's `orientation` defaults to it, and in a mobile browser (not desktop, not Tauri) a "rotate your device" overlay covers and pauses the game while the device is held the wrong way. |
487
+ | `orientation.overlay?: false \| { text?, background?, color?, fontFamily?, className?, element? }` | Restyle that overlay; `element: () => HTMLElement` replaces it, `false` turns off the overlay and its pause (`app.orientation.onMismatchChanged` / `app.orientation.mismatched` still report). |
483
488
 
484
489
  Plus all standard PixiJS `ApplicationOptions` (`antialias`, `width`, `height`, etc.).
485
490
 
491
+ Orientation overlay, customized or replaced:
492
+
493
+ ```ts
494
+ // shorthand: lock to portrait with the default overlay
495
+ export default defineConfig({ id: 'my-game', orientation: 'portrait' });
496
+
497
+ // object form: same lock, custom overlay
498
+ export default defineConfig({
499
+ id: 'my-game',
500
+ orientation: { lock: 'portrait', overlay: { text: 'Hold your phone upright to play' } },
501
+ // also: background, color, fontFamily, className (style it in your CSS),
502
+ // element: () => myOwnElement, or overlay: false to draw nothing and react yourself
503
+ });
504
+
505
+ // with overlay: false
506
+ app.orientation.onMismatchChanged.connect((mismatched) => (mismatched ? app.pause() : app.resume()));
507
+ ```
508
+
486
509
  Screen viewport debugging is available through the built-in `screenDebug` overlay.
487
510
  Open the game URL in a normal browser tab with `?caper-screen-debug=1` to enable it.
488
511
  Then relaunch the installed PWA; the same-origin setting persists without an address bar.
@@ -834,6 +857,15 @@ then push toasts to it. Also available as `app.popups.toast(...)`.
834
857
 
835
858
  A FlexContainer preset for app-size-bound HUDs. Use as the root of in-game UI.
836
859
 
860
+ It pads by the device safe-area insets (notch, camera hole, gesture bar) by
861
+ default. `useSafeArea: false` turns that off; `{ top: false }` turns off one
862
+ edge, e.g. to let corner buttons sit in a punch-hole phone's camera strip:
863
+
864
+ ```ts
865
+ import { isAndroid, isTauri } from '@caperjs/core';
866
+ const hud = this.add.uiCanvas({ useAppSize: true, useSafeArea: isTauri && isAndroid ? { top: false } : true });
867
+ ```
868
+
837
869
  ### 7.6 `Joystick` — virtual stick
838
870
 
839
871
  For touch input. Wires into the `controls.touch.joystick` bindings set via