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