@caperjs/core 0.7.3 → 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 (64) 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 +105 -11
  8. package/cli/native.test.mjs +184 -0
  9. package/extras/llms.txt +26 -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 +159 -162
  31. package/lib/caper.mjs.map +1 -1
  32. package/lib/{const-BClauBNo.js → const-Bca54QyV.js} +251 -251
  33. package/lib/{const-BClauBNo.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/utils/index.d.ts +1 -0
  48. package/lib/utils/index.d.ts.map +1 -1
  49. package/lib/utils/orientation.d.ts +44 -0
  50. package/lib/utils/orientation.test.d.ts +2 -0
  51. package/lib/utils/orientation.test.d.ts.map +1 -0
  52. package/package.json +1 -1
  53. package/src/core/Application.ts +10 -0
  54. package/src/core/interfaces/IApplicationOptions.ts +21 -0
  55. package/src/plugins/OrientationPlugin.test.ts +252 -0
  56. package/src/plugins/OrientationPlugin.ts +178 -0
  57. package/src/plugins/defaults.ts +6 -0
  58. package/src/plugins/index.ts +1 -0
  59. package/src/utils/index.ts +1 -0
  60. package/src/utils/orientation.d.ts +44 -0
  61. package/src/utils/orientation.js +37 -0
  62. package/src/utils/orientation.test.ts +30 -0
  63. package/src/version.ts +1 -1
  64. package/lib/registries-DmNGVLAi.js.map +0 -1
package/build/index.mjs CHANGED
@@ -92,7 +92,7 @@ function caperPluginList({ assets = {}, pwa } = {}) {
92
92
  // place that knows a project overrode it.
93
93
  caperConfigPlugin(true, manifestUrl),
94
94
  caperDevHelperPlugin(),
95
- ...(effectivePwa ? caperPwaPlugins(effectivePwa) : []),
95
+ ...(effectivePwa ? caperPwaPlugins(effectivePwa, { orientation: buildFlags.orientation }) : []),
96
96
  ];
97
97
  }
98
98
 
@@ -1,14 +1,15 @@
1
1
  /**
2
- * Boolean build flags read out of `caper.config.ts` before anything can execute
3
- * it. See the comment on `readCaperBuildFlags` for why this is an AST parse.
2
+ * Build flags read out of `caper.config.ts` before anything can execute it. See
3
+ * the comment on `readCaperBuildFlags` for why this is an AST parse.
4
4
  */
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { AST_NODE_TYPES, findConfigObject, parse } from './ast.mjs';
8
+ import { resolveOrientation } from '../../src/utils/orientation.js';
8
9
  import { cwd } from './util.mjs';
9
10
 
10
11
  export function readCaperBuildFlags() {
11
- const flags = { useWasm: false };
12
+ const flags = { useWasm: false, orientation: undefined };
12
13
  const configPath = path.resolve(cwd, 'caper.config.ts');
13
14
  if (!fs.existsSync(configPath)) return flags;
14
15
 
@@ -22,6 +23,14 @@ export function readCaperBuildFlags() {
22
23
 
23
24
  for (const prop of configObject.properties) {
24
25
  if (prop.type !== AST_NODE_TYPES.Property || prop.key?.type !== AST_NODE_TYPES.Identifier) continue;
26
+ if (prop.key.name === 'orientation') {
27
+ try {
28
+ flags.orientation = readOrientationLock(prop.value);
29
+ } catch {
30
+ // silent here, like every other build flag; the schema reports it
31
+ }
32
+ continue;
33
+ }
25
34
  if (!(prop.key.name in flags)) continue;
26
35
  if (prop.value?.type === AST_NODE_TYPES.Literal && typeof prop.value.value === 'boolean') {
27
36
  flags[prop.key.name] = prop.value.value;
@@ -30,21 +39,57 @@ export function readCaperBuildFlags() {
30
39
  return flags;
31
40
  }
32
41
 
42
+ /** A property's value node when it is a string literal, else `undefined`. */
43
+ function stringLiteral(node) {
44
+ return node?.type === AST_NODE_TYPES.Literal && typeof node.value === 'string' ? node.value : undefined;
45
+ }
46
+
33
47
  /**
34
- * Cross-reference validation over discovered scenes/plugins/popups/entities
35
- * + the parsed `caper.config.ts` AST + the assetpack manifest. Emits
36
- * warnings via the project logger; in dev mode also forwards warnings to
37
- * the browser error overlay as info so they're impossible to miss.
38
- *
39
- * Checks:
40
- * 1. Scene `assets.preload.bundles` / `assets.background.bundles` entries
41
- * exist in the assetpack manifest.
42
- * 2. Plugin IDs in `caper.config.ts` match a discovered plugin
43
- * (npm `@caperjs/plugin-*` OR local `src/plugins/*`).
44
- * 3. `defaultScene` in `caper.config.ts` matches a discovered scene ID.
45
- * 4. No duplicate scene / plugin / popup / entity IDs across discovery.
48
+ * The lock out of an `orientation` value node: the string literal itself, or
49
+ * the `lock` string literal of an object literal (`overlay` is never read: it
50
+ * may hold a function). Normalized and validated by `resolveOrientation`.
51
+ */
52
+ function readOrientationLock(node) {
53
+ if (node?.type === AST_NODE_TYPES.ObjectExpression) {
54
+ const lockProp = node.properties.find(
55
+ (p) => p.type === AST_NODE_TYPES.Property && p.key?.type === AST_NODE_TYPES.Identifier && p.key.name === 'lock',
56
+ );
57
+ if (!lockProp) throw new Error("orientation needs a lock: { lock: 'portrait' | 'landscape' }");
58
+ const lock = stringLiteral(lockProp.value);
59
+ if (lock === undefined) throw new Error("orientation.lock must be written as a string literal, 'portrait' or 'landscape'");
60
+ return resolveOrientation({ lock }).lock;
61
+ }
62
+ const value = stringLiteral(node);
63
+ if (value === undefined) {
64
+ throw new Error("orientation must be written as a string literal ('portrait' or 'landscape') or { lock: '...' }");
65
+ }
66
+ return resolveOrientation(value).lock;
67
+ }
68
+
69
+ /**
70
+ * The orientation lock from `<root>/caper.config.ts`, for the native CLI
71
+ * commands and `caper doctor`: `orientation: 'portrait'` or
72
+ * `orientation: { lock: 'portrait', ... }`. Same AST parse as
73
+ * `readCaperBuildFlags`, so nothing is executed and no DOM stub is needed, but
74
+ * strict where that one is silent: it throws when the file does not parse,
75
+ * when no `defineConfig({...})` object literal is found, or when the lock is
76
+ * not `'portrait'` / `'landscape'` written as a string literal. Callers turn
77
+ * the throw into a warning.
46
78
  *
47
- * All issues are warnings, not errors — they shouldn't fail the build
48
- * (typos are a dev-time problem; the runtime will still start, just with
49
- * broken references the user needs to see).
79
+ * @param {string} root
80
+ * @returns {'portrait' | 'landscape' | undefined} undefined when there is no config file or no `orientation` key
50
81
  */
82
+ export function readConfigOrientation(root) {
83
+ const configPath = path.resolve(root, 'caper.config.ts');
84
+ if (!fs.existsSync(configPath)) return undefined;
85
+
86
+ const configObject = findConfigObject(parse(fs.readFileSync(configPath, 'utf-8')));
87
+ if (configObject?.type !== AST_NODE_TYPES.ObjectExpression) {
88
+ throw new Error('no defineConfig({ ... }) object literal found');
89
+ }
90
+
91
+ const prop = configObject.properties.find(
92
+ (p) => p.type === AST_NODE_TYPES.Property && p.key?.type === AST_NODE_TYPES.Identifier && p.key.name === 'orientation',
93
+ );
94
+ return prop ? readOrientationLock(prop.value) : undefined;
95
+ }
@@ -17,6 +17,55 @@ export const pluginConfigSchema = z.union([
17
17
  ]),
18
18
  ]);
19
19
 
20
+ const ORIENTATION_LOCK_MESSAGE = "orientation must be 'portrait', 'landscape', or { lock: 'portrait' | 'landscape', overlay? }";
21
+
22
+ const orientationLockSchema = z.enum(['portrait', 'landscape'], { error: ORIENTATION_LOCK_MESSAGE });
23
+
24
+ const orientationObjectSchema = z.strictObject(
25
+ {
26
+ lock: z.enum(['portrait', 'landscape'], {
27
+ error: (issue) =>
28
+ issue.input === undefined
29
+ ? "orientation.lock is required: 'portrait' or 'landscape'"
30
+ : "orientation.lock must be 'portrait' or 'landscape'",
31
+ }),
32
+ overlay: z
33
+ .union(
34
+ [
35
+ z.literal(false),
36
+ z.strictObject({
37
+ text: z.string().optional(),
38
+ background: z.string().optional(),
39
+ color: z.string().optional(),
40
+ fontFamily: z.string().optional(),
41
+ className: z.string().optional(),
42
+ element: z.function().optional(),
43
+ }),
44
+ ],
45
+ {
46
+ error:
47
+ 'orientation.overlay must be false or an object of text, background, color, fontFamily, className (strings) and element (a function)',
48
+ },
49
+ )
50
+ .optional(),
51
+ },
52
+ { error: (issue) => (issue.code === 'unrecognized_keys' ? `orientation only takes lock and overlay (got ${issue.keys.join(', ')})` : undefined) },
53
+ );
54
+
55
+ /**
56
+ * `orientation` is a string shorthand or `{ lock, overlay? }`. Validated by
57
+ * picking the branch from the value's type rather than with a `z.union`, whose
58
+ * single "Invalid input" would hide the object branch's precise messages.
59
+ */
60
+ const orientationSchema = z.any().superRefine((value, ctx) => {
61
+ const schema = value !== null && typeof value === 'object' ? orientationObjectSchema : orientationLockSchema;
62
+ const result = schema.safeParse(value);
63
+ if (result.success) return;
64
+ for (const issue of result.error.issues) {
65
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
66
+ }
67
+ });
68
+
20
69
  export const caperConfigSchema = z
21
70
  .object({
22
71
  id: z.string().min(1).optional(),
@@ -50,6 +99,10 @@ export const caperConfigSchema = z
50
99
  useHash: z.boolean().optional(),
51
100
  // Build-time only — read by readCaperBuildFlags(), no runtime effect.
52
101
  useWasm: z.boolean().optional(),
102
+ // `'portrait' | 'landscape' | { lock, overlay? }`. Read by the native CLI
103
+ // (`caper native init|android`), the PWA manifest default, and at runtime by
104
+ // the `orientation` plugin.
105
+ orientation: orientationSchema.optional(),
53
106
  showStats: z.boolean().optional(),
54
107
  showSceneDebugMenu: z.boolean().optional(),
55
108
  resizeToContainer: z.boolean().optional(),
@@ -129,8 +129,12 @@ function defaultWorkbox() {
129
129
  };
130
130
  }
131
131
 
132
- /** Caper's vite-plugin-pwa defaults. */
133
- export function defaultPwaOptions() {
132
+ /**
133
+ * Caper's vite-plugin-pwa defaults.
134
+ *
135
+ * @param {{ orientation?: 'portrait' | 'landscape' }} [caperConfig] `orientation` from caper.config.ts, if set
136
+ */
137
+ export function defaultPwaOptions({ orientation } = {}) {
134
138
  const app = readAppIdentity();
135
139
 
136
140
  return {
@@ -162,7 +166,7 @@ export function defaultPwaOptions() {
162
166
  background_color: '#000000',
163
167
  display: 'fullscreen',
164
168
  display_override: ['fullscreen', 'standalone', 'minimal-ui'],
165
- orientation: 'any',
169
+ orientation: orientation ?? 'any',
166
170
  categories: ['games', 'entertainment'],
167
171
  },
168
172
  workbox: defaultWorkbox(),
@@ -204,10 +208,11 @@ function caperPwaDefaultsPlugin(options, projectSetIcons) {
204
208
  * defaults, producing the options vite-plugin-pwa is given.
205
209
  *
206
210
  * @param {object} pwa Project options, merged over `defaultPwaOptions()`.
211
+ * @param {{ orientation?: 'portrait' | 'landscape' }} [caperConfig] build flags read out of caper.config.ts
207
212
  */
208
- export function resolvePwaOptions(pwa) {
213
+ export function resolvePwaOptions(pwa, caperConfig = {}) {
209
214
  const { autoRegister: _autoRegister, update = 'prompt', ...projectOptions } = pwa;
210
- const options = deepMerge(defaultPwaOptions(), projectOptions);
215
+ const options = deepMerge(defaultPwaOptions(caperConfig), projectOptions);
211
216
 
212
217
  // `update` is caper's plain-language spelling of registerType. 'prompt' and
213
218
  // 'manual' both wait for the user — they differ only in who draws the UI, which
@@ -222,10 +227,11 @@ export function resolvePwaOptions(pwa) {
222
227
 
223
228
  /**
224
229
  * @param {object} pwa Project options, merged over `defaultPwaOptions()`.
230
+ * @param {{ orientation?: 'portrait' | 'landscape' }} [caperConfig] build flags read out of caper.config.ts
225
231
  * @returns {import('vite').PluginOption[]}
226
232
  */
227
- export function caperPwaPlugins(pwa) {
228
- const { options, projectSetIcons } = resolvePwaOptions(pwa);
233
+ export function caperPwaPlugins(pwa, caperConfig = {}) {
234
+ const { options, projectSetIcons } = resolvePwaOptions(pwa, caperConfig);
229
235
 
230
236
  return [caperPwaDefaultsPlugin(options, projectSetIcons), VitePWA(options)];
231
237
  }
package/cli/doctor.mjs CHANGED
@@ -7,7 +7,17 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
10
- import { ANDROID_RUST_TARGETS, findMainActivity, missingRustTargets, resolveAndroidEnv, TAURI_PLUGIN_PERMISSIONS } from './native.mjs';
10
+ import {
11
+ ANDROID_MANIFEST,
12
+ ANDROID_RUST_TARGETS,
13
+ ANDROID_SCREEN_ORIENTATION,
14
+ findMainActivity,
15
+ missingRustTargets,
16
+ readManifestOrientation,
17
+ readOrientation,
18
+ resolveAndroidEnv,
19
+ TAURI_PLUGIN_PERMISSIONS,
20
+ } from './native.mjs';
11
21
 
12
22
  /**
13
23
  * `caper doctor` — one-shot health report for a Caper app.
@@ -203,7 +213,14 @@ const resolveEffectiveCompilerOptions = (configFile, ancestors = new Set(), dept
203
213
 
204
214
  export async function runChecks(
205
215
  cwd,
206
- { online = true, run = defaultDoctorRun, platform = process.platform, env = process.env, homedir = os.homedir() } = {},
216
+ {
217
+ online = true,
218
+ run = defaultDoctorRun,
219
+ platform = process.platform,
220
+ env = process.env,
221
+ homedir = os.homedir(),
222
+ loadConfig = undefined,
223
+ } = {},
207
224
  ) {
208
225
  const checks = [];
209
226
  const installedVersion = readInstalledVersion();
@@ -497,6 +514,40 @@ export async function runChecks(
497
514
  } else {
498
515
  push(checks, 'native-android-bars', 'warn', 'MainActivity does not hide the system bars', 'caper native android');
499
516
  }
517
+
518
+ const { orientation, warning: orientationWarning } = await readOrientation(cwd, loadConfig);
519
+ let manifest = '';
520
+ try {
521
+ manifest = fs.readFileSync(path.join(srcTauriDir, ANDROID_MANIFEST), 'utf-8');
522
+ } catch {
523
+ // no lock found; compared below
524
+ }
525
+ const locked = readManifestOrientation(manifest);
526
+ const expected = ANDROID_SCREEN_ORIENTATION[orientation];
527
+ if (orientationWarning) {
528
+ push(checks, 'native-android-orientation', 'warn', 'could not read caper.config.ts orientation', "write it as orientation: 'portrait' or 'landscape'");
529
+ } else if (!expected && !locked) {
530
+ push(checks, 'native-android-orientation', 'ok', 'no orientation lock');
531
+ } else if (expected === locked) {
532
+ push(checks, 'native-android-orientation', 'ok', `orientation ${orientation} (AndroidManifest ${locked})`);
533
+ } else if (!expected) {
534
+ // `caper native android` leaves the manifest alone when orientation is unset, so it is not the fix here.
535
+ push(
536
+ checks,
537
+ 'native-android-orientation',
538
+ 'warn',
539
+ `AndroidManifest locks orientation to ${locked}, caper.config.ts sets none`,
540
+ 'set orientation in caper.config.ts, or remove android:screenOrientation from AndroidManifest.xml',
541
+ );
542
+ } else {
543
+ push(
544
+ checks,
545
+ 'native-android-orientation',
546
+ 'warn',
547
+ `caper.config.ts orientation ${orientation}, AndroidManifest ${locked ? `locks ${locked}` : 'has no lock'}`,
548
+ 'caper native android',
549
+ );
550
+ }
500
551
  }
501
552
  }
502
553
 
@@ -712,8 +712,9 @@ describe('runChecks native-android rows', () => {
712
712
 
713
713
  const allTargets = 'aarch64-linux-android\narmv7-linux-androideabi\ni686-linux-android\nx86_64-linux-android\n';
714
714
 
715
- function check(cwd, env, responses = { 'rustup target list --installed': allTargets }) {
715
+ function check(cwd, env, responses = { 'rustup target list --installed': allTargets }, loadConfig = () => ({})) {
716
716
  return runChecks(cwd, {
717
+ loadConfig,
717
718
  online: false,
718
719
  platform: 'linux',
719
720
  env,
@@ -814,4 +815,82 @@ describe('runChecks native-android rows', () => {
814
815
  expect(pageSize.hint).toMatch(/caper native android/);
815
816
  expect(find(checks, 'native-android-bars').status).toBe('warn');
816
817
  });
818
+
819
+ describe('native-android-orientation', () => {
820
+ const MANIFEST = '<manifest>\n <application>\n <activity\n android:name=".MainActivity"\n android:exported="true">\n </activity>\n </application>\n</manifest>\n';
821
+ const manifestPath = (cwd) => path.join(cwd, 'src-tauri/gen/android/app/src/main/AndroidManifest.xml');
822
+ const writeManifest = (cwd, value) =>
823
+ fs.writeFileSync(
824
+ manifestPath(cwd),
825
+ value ? MANIFEST.replace('android:name=".MainActivity"', `android:name=".MainActivity"\n android:screenOrientation="${value}"`) : MANIFEST,
826
+ 'utf-8',
827
+ );
828
+
829
+ async function orientationRow(cwd, loadConfig) {
830
+ const { bin, ndk, java } = fs.existsSync(path.join(cwd, 'bin')) ? { bin: path.join(cwd, 'bin'), ndk: path.join(cwd, 'ndk'), java: path.join(cwd, 'java') } : scaffoldAndroid(cwd);
831
+ return find(await check(cwd, { PATH: bin, NDK_HOME: ndk, JAVA_HOME: java }, undefined, loadConfig), 'native-android-orientation');
832
+ }
833
+
834
+ it('is absent when src-tauri/gen/android does not exist', async () => {
835
+ const cwd = makeTempDir();
836
+ writeTauriConf(cwd);
837
+ writeTauriCli(cwd, '2.11.4');
838
+ const checks = await check(cwd, {}, undefined, () => ({ orientation: 'portrait' }));
839
+ expect(find(checks, 'native-android-orientation')).toBeUndefined();
840
+ });
841
+
842
+ it('is ok with no orientation lock when neither side sets one', async () => {
843
+ const cwd = makeTempDir();
844
+ scaffoldAndroid(cwd);
845
+ writeManifest(cwd);
846
+ const row = await orientationRow(cwd, () => ({}));
847
+ expect(row.status).toBe('ok');
848
+ expect(row.label).toMatch(/no orientation lock/);
849
+ });
850
+
851
+ it('is ok when the manifest matches caper.config', async () => {
852
+ const cwd = makeTempDir();
853
+ scaffoldAndroid(cwd);
854
+ writeManifest(cwd, 'sensorLandscape');
855
+ expect((await orientationRow(cwd, () => ({ orientation: 'landscape' }))).status).toBe('ok');
856
+ });
857
+
858
+ it('warns with the caper native android hint on a mismatch', async () => {
859
+ const cwd = makeTempDir();
860
+ scaffoldAndroid(cwd);
861
+ writeManifest(cwd, 'portrait');
862
+ const row = await orientationRow(cwd, () => ({ orientation: 'landscape' }));
863
+ expect(row.status).toBe('warn');
864
+ expect(row.hint).toBe('caper native android');
865
+ });
866
+
867
+ it('warns when caper.config sets an orientation the manifest lacks', async () => {
868
+ const cwd = makeTempDir();
869
+ scaffoldAndroid(cwd);
870
+ writeManifest(cwd);
871
+ const row = await orientationRow(cwd, () => ({ orientation: 'portrait' }));
872
+ expect(row.status).toBe('warn');
873
+ expect(row.hint).toBe('caper native android');
874
+ });
875
+
876
+ it('warns when the manifest has a lock but caper.config sets none', async () => {
877
+ const cwd = makeTempDir();
878
+ scaffoldAndroid(cwd);
879
+ writeManifest(cwd, 'portrait');
880
+ const row = await orientationRow(cwd, () => ({}));
881
+ expect(row.status).toBe('warn');
882
+ expect(row.hint).toMatch(/caper\.config\.ts/);
883
+ });
884
+
885
+ it('warns when caper.config orientation cannot be read', async () => {
886
+ const cwd = makeTempDir();
887
+ scaffoldAndroid(cwd);
888
+ writeManifest(cwd);
889
+ const row = await orientationRow(cwd, () => {
890
+ throw new Error('boom');
891
+ });
892
+ expect(row.status).toBe('warn');
893
+ expect(row.label).toMatch(/could not read caper\.config\.ts orientation/);
894
+ });
895
+ });
817
896
  });
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,
@@ -130,10 +132,37 @@ function renderCommand({ cmd, args }) {
130
132
  return [cmd, ...args].join(' ');
131
133
  }
132
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
+
133
160
  /** Returns a new tauri.conf.json: sets `identifier` and window 0's title/size; leaves the rest untouched. */
134
- export function patchTauriConfig(conf, { identifier, title }) {
161
+ export function patchTauriConfig(conf, { identifier, title, orientation }) {
135
162
  const windows = Array.isArray(conf.app?.windows) ? [...conf.app.windows] : [];
136
- 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 };
137
166
 
138
167
  return {
139
168
  ...conf,
@@ -208,9 +237,9 @@ function writeJson(file, data, indent) {
208
237
  *
209
238
  * @param {string} cwd
210
239
  * @param {{ identifier?: string, port?: number, icon?: string }} [opts]
211
- * @param {{ run?: typeof defaultRun }} [deps]
240
+ * @param {{ run?: typeof defaultRun, loadConfig?: typeof defaultLoadConfig }} [deps]
212
241
  */
213
- export async function initNative(cwd, opts = {}, { run = defaultRun } = {}) {
242
+ export async function initNative(cwd, opts = {}, { run = defaultRun, loadConfig = defaultLoadConfig } = {}) {
214
243
  const pkgPath = path.join(cwd, 'package.json');
215
244
  if (!fs.existsSync(pkgPath)) {
216
245
  throw new Error('no package.json found in this directory.');
@@ -268,9 +297,13 @@ export async function initNative(cwd, opts = {}, { run = defaultRun } = {}) {
268
297
  ];
269
298
  run(EXEC_RUNNER[pm], initArgs, { cwd });
270
299
 
300
+ const warnings = [];
301
+ const { orientation, warning } = await readOrientation(cwd, loadConfig);
302
+ if (warning) warnings.push(warning);
303
+
271
304
  const tauriConfPath = path.join(srcTauriDir, 'tauri.conf.json');
272
305
  const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf-8'));
273
- writeJson(tauriConfPath, patchTauriConfig(tauriConf, { identifier, title }), 2);
306
+ writeJson(tauriConfPath, patchTauriConfig(tauriConf, { identifier, title, orientation }), 2);
274
307
 
275
308
  // Re-read: the package manager rewrote package.json when it added @tauri-apps/cli,
276
309
  // and patching the copy read above would silently drop that devDependency.
@@ -281,7 +314,7 @@ export async function initNative(cwd, opts = {}, { run = defaultRun } = {}) {
281
314
  run(EXEC_RUNNER[pm], ['tauri', 'icon', opts.icon], { cwd });
282
315
  }
283
316
 
284
- return { status: 'ok', name, title, identifier, port, pm };
317
+ return { status: 'ok', name, title, identifier, port, pm, orientation, warnings };
285
318
  }
286
319
 
287
320
  /**
@@ -541,6 +574,38 @@ export function findMainActivity(genAndroidDir) {
541
574
  return walk(path.join(genAndroidDir, 'app/src/main/java'));
542
575
  }
543
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
+
544
609
  /** Returns a new package.json: adds `native:android:dev`/`native:android:build` scripts only where absent. */
545
610
  export function patchAndroidScripts(pkg) {
546
611
  const scripts = { ...(pkg.scripts ?? {}) };
@@ -572,13 +637,15 @@ const ANDROID_MISSING_HELP = {
572
637
  * The I/O core of `caper native android`: the Android steps from
573
638
  * docs/wiki/native-tauri.md: installs the Rust targets, runs
574
639
  * `tauri android init --ci`, adds the 16 KB page-alignment link arg to
575
- * `build.rs`, hides the system bars in `MainActivity.kt`, and adds
576
- * `native:android:*` scripts. No console output; the CLI wrapper below owns
577
- * 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.
578
645
  *
579
646
  * @param {string} cwd
580
647
  * @param {object} [opts] unused today; kept for symmetry with `initNative`
581
- * @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]
582
649
  */
583
650
  export async function androidNative(
584
651
  cwd,
@@ -591,6 +658,7 @@ export async function androidNative(
591
658
  homedir = os.homedir(),
592
659
  exists = fs.existsSync,
593
660
  listDir = defaultListDir,
661
+ loadConfig = defaultLoadConfig,
594
662
  } = {},
595
663
  ) {
596
664
  const pkgPath = path.join(cwd, 'package.json');
@@ -650,6 +718,24 @@ export async function androidNative(
650
718
  }
651
719
  }
652
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
+
653
739
  const pkgRaw = fs.readFileSync(pkgPath, 'utf-8');
654
740
  const pkg = JSON.parse(pkgRaw);
655
741
  const patchedPkg = patchAndroidScripts(pkg);
@@ -661,11 +747,14 @@ export async function androidNative(
661
747
  const buildRsPatched = buildRsPlan === 'write';
662
748
  return {
663
749
  status: 'ok',
664
- changed: addedTargets.length > 0 || ranAndroidInit || buildRsPatched || mainActivityPatched || addedScripts.length > 0,
750
+ changed:
751
+ addedTargets.length > 0 || ranAndroidInit || buildRsPatched || mainActivityPatched || orientationPatched || addedScripts.length > 0,
665
752
  addedTargets,
666
753
  ranAndroidInit,
667
754
  buildRsPatched,
668
755
  mainActivityPatched,
756
+ orientation,
757
+ orientationPatched,
669
758
  addedScripts,
670
759
  env: {
671
760
  ANDROID_HOME: resolved.env.ANDROID_HOME,
@@ -739,6 +828,8 @@ async function runInit(args) {
739
828
  console.log(` ${yellow('identifier:')} ${result.identifier}${result.identifier.startsWith(DEFAULT_IDENTIFIER_PREFIX) ? dim(' (placeholder — change before shipping)') : ''}`);
740
829
  console.log(` ${yellow('dev port:')} ${result.port}`);
741
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}`));
742
833
  console.log(`\n Run ${cyan('npx caper doctor')} to check your native toolchain.`);
743
834
  console.log(` The first ${cyan('native:build')} compiles Rust — expect a few minutes.`);
744
835
  }
@@ -783,6 +874,9 @@ async function runAndroid() {
783
874
  if (result.ranAndroidInit) console.log(` ${yellow('ran:')} tauri android init --ci`);
784
875
  if (result.buildRsPatched) console.log(` ${yellow('build.rs:')} 16 KB page alignment added to src-tauri/build.rs`);
785
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
+ }
786
880
  if (result.addedScripts.length) console.log(` ${yellow('scripts:')} ${result.addedScripts.join(', ')}`);
787
881
  }
788
882
  for (const warning of result.warnings) console.log(yellow(` ⚠ ${warning}`));