@livx.cc/appwrap 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/runtime/app/main-page.ts +0 -2
- package/runtime/app/shell/capabilities.manifest.ts +27 -92
- package/runtime/app/shell/config.ts +4 -0
- package/runtime/app/shell/env.ts +8 -1
- package/runtime/app/shell/handlers.ts +2 -2
- package/runtime/tests/bridge-response-delivery.test.ts +10 -1
- package/runtime/tests/env-scheme.test.ts +50 -0
- package/scripts/stage-assets.mjs +1 -1
- package/src/cli.ts +424 -621
- package/src/config.ts +8 -1
- package/src/handlers.ts +1 -1
- package/src/packs.ts +174 -0
- package/src/testing.ts +173 -0
- package/templates/module-pack/README.md +44 -0
- package/templates/module-pack/handler.ts +9 -0
- package/templates/module-pack/manifest.ts +27 -0
- package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
- package/runtime/app/shell/billing-offer.ts +0 -54
- package/runtime/app/shell/handlers-billing.ts +0 -662
- package/runtime/app/shell/handlers-health.ts +0 -178
- package/runtime/app/shell/handlers-widget.ts +0 -97
- package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
- package/runtime/tests/billing-offer.test.ts +0 -61
- package/runtime-desktop/autotest/index.html +0 -77
- package/runtime-desktop/bridge-shim/build.sh +0 -9
- package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
- package/runtime-desktop/bridge-shim/shim.ts +0 -132
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
- package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
- package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
- package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
- package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
- package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
- package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
- package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
- package/runtime-desktop/src-tauri/Cargo.toml +0 -45
- package/runtime-desktop/src-tauri/build.rs +0 -3
- package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +0 -10
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
- package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
- package/runtime-desktop/src-tauri/src/main.rs +0 -1228
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
- package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
- package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
- package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
- package/runtime-desktop/src-tauri/src/server.rs +0 -156
- package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
- package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
- package/src/desktop.ts +0 -300
- package/src/plugin/host.ts +0 -242
package/src/cli.ts
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { execFileSync, spawn, spawnSync } from 'child_process';
|
|
12
12
|
import { createHash } from 'crypto';
|
|
13
|
-
import {
|
|
13
|
+
import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
14
14
|
import { builtinModules } from 'module';
|
|
15
15
|
import { networkInterfaces, tmpdir } from 'os';
|
|
16
|
-
import { basename,
|
|
16
|
+
import { basename, dirname, extname, join, resolve } from 'path';
|
|
17
17
|
import { pathToFileURL } from 'url';
|
|
18
18
|
// PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
|
|
19
19
|
// the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
|
|
@@ -23,10 +23,11 @@ import type * as CapManifest from '../../../runtime/app/shell/capabilities.manif
|
|
|
23
23
|
// type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
|
|
24
24
|
import type { AppwrapConfig } from './config';
|
|
25
25
|
import { unknownConfigKeys } from './config';
|
|
26
|
+
import { resolveModulePacks, type ResolvedModule, type SyncContext } from './packs';
|
|
26
27
|
import { createHash } from 'crypto';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// Icon helpers re-exported so the EE desktop lane (which owns the Tauri chassis) can reuse them via
|
|
29
|
+
// `@livx.cc/appwrap/cli` — the CE/EE split (Phase D) moved the desktop lane out but keeps these shared.
|
|
30
|
+
export { APPLE_ICON_GRID_SCALE, makeDockRuntimeIcon } from './icon';
|
|
30
31
|
import {
|
|
31
32
|
androidScreenOrientation,
|
|
32
33
|
applyBuildNumberFlag,
|
|
@@ -97,16 +98,61 @@ const ANDROID_PERMISSION_KEYS: Record<string, string[]> = {
|
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
/** Resolve a bundled asset dir. A published tarball ships runtime/ + templates/ at the package root
|
|
100
|
-
* (one level above src/); the monorepo resolves them at the repo root (three levels up).
|
|
101
|
+
* (one level above src/); the monorepo resolves them at the repo root (three levels up). Returns the
|
|
102
|
+
* REAL path — a dev checkout may stage the package-root dir as a symlink to the repo-root source (what
|
|
103
|
+
* prepack does with a copy), and Bun's cpSync refuses a symlink as a copy-source root. */
|
|
101
104
|
function resolveAssetRoot(rel: string): string {
|
|
102
105
|
const local = resolve(import.meta.dir, '..', rel);
|
|
103
|
-
|
|
106
|
+
const dir = existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
|
|
107
|
+
try { return realpathSync(dir); } catch { return dir; }
|
|
104
108
|
}
|
|
105
109
|
const TEMPLATE_DIR = resolveAssetRoot('runtime');
|
|
110
|
+
/** Ordered runtime-template roots copied into native/ (later roots overlay earlier — file-level
|
|
111
|
+
* last-wins), defaulting to the single built-in template. runCli (A5) can extend this so a consumer
|
|
112
|
+
* (a host) layers extra shell files on top of CE without forking the template. A single root
|
|
113
|
+
* is byte-identical to the pre-overlay behavior. */
|
|
114
|
+
let TEMPLATE_ROOTS: string[] = [TEMPLATE_DIR];
|
|
106
115
|
const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
/** Scaffold for `appwrap create-module` — a starter module pack (see packs.ts / @livx.cc/appwrap/testing). */
|
|
117
|
+
const MODULE_PACK_TEMPLATE_DIR = resolveAssetRoot('templates/module-pack');
|
|
118
|
+
/** Desktop (Tauri chassis) template dir. CE ships no bundled desktop template — the desktop lane is
|
|
119
|
+
* host-owned: a consumer points this at its own template via `runCli({ desktopTemplateDir })` (A5).
|
|
120
|
+
* A `let` so runCli can override it; empty by default so the mobile lanes are unaffected. The desktop
|
|
121
|
+
* lane itself lives in the EE package (Phase D split) and reads this value via `getDesktopTemplateDir()`
|
|
122
|
+
* — CE keeps the seam (set by `runCli({ desktopTemplateDir })`) but ships no desktop code. */
|
|
123
|
+
let DESKTOP_TEMPLATE_DIR = '';
|
|
124
|
+
|
|
125
|
+
/** The desktop (Tauri) template dir set via `runCli({ desktopTemplateDir })`. Read by the EE desktop
|
|
126
|
+
* lane (hosted on top of CE through the `platforms` seam) so the seam stays authoritative in one place. */
|
|
127
|
+
export function getDesktopTemplateDir(): string {
|
|
128
|
+
return DESKTOP_TEMPLATE_DIR;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Composition seam for a HOST that wraps this CLI (any consumer). Every field is
|
|
133
|
+
* optional and no-op by default, so the bare `appwrap` bin behaves exactly as before. A host calls
|
|
134
|
+
* `runCli({...})` to extend the CLI without forking it.
|
|
135
|
+
*/
|
|
136
|
+
export interface CliOptions {
|
|
137
|
+
/** Extra or overriding top-level commands, keyed by command name — consulted BEFORE the built-in
|
|
138
|
+
* switch, so a host can add a command or replace a built-in one. */
|
|
139
|
+
commands?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
|
|
140
|
+
/** Pluggable platform handlers for `dev`/`build` (keyed by the platform arg, e.g. 'desktop') —
|
|
141
|
+
* consulted BEFORE the built-in platform routing, so a host can add/replace a platform lane. The
|
|
142
|
+
* 4th arg is the driving command ('dev' | 'build') so a single handler (e.g. the EE desktop lane)
|
|
143
|
+
* can branch dev-vs-build — `dev desktop` and `build desktop` route to the SAME handler key. */
|
|
144
|
+
platforms?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[], command: 'dev' | 'build') => Promise<void> | void>;
|
|
145
|
+
/** Module packs injected by the host, applied BEFORE the app config's own `modulePacks` (so the app
|
|
146
|
+
* can still override them, last-wins). */
|
|
147
|
+
modulePacks?: string[];
|
|
148
|
+
/** Extra runtime-template overlay roots, appended after the built-in template (later roots win). */
|
|
149
|
+
templateRoots?: string[];
|
|
150
|
+
/** Override for the desktop (Tauri) template dir. */
|
|
151
|
+
desktopTemplateDir?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The host composition for THIS run, set once by runCli. Empty by default → bare-bin behavior. */
|
|
155
|
+
let cliOptions: CliOptions = {};
|
|
110
156
|
|
|
111
157
|
/** This CLI's own published version — used to pin the `bunx @livx.cc/appwrap@^x.y.z` invocations the
|
|
112
158
|
* emitted workflow runs. Pinning to THIS version's floor means CI fails LOUDLY ("version not found")
|
|
@@ -116,10 +162,67 @@ const CLI_VERSION: string = (await import(resolve(import.meta.dir, '..', 'packag
|
|
|
116
162
|
|
|
117
163
|
// Load the capability manifest VALUES from the resolved runtime (pure data — safe outside NativeScript).
|
|
118
164
|
// Top-level await resolves before any command dispatches at the bottom of this file.
|
|
119
|
-
const { MODULES, OPTIONAL_GROUPS } = (await import(
|
|
165
|
+
const { MODULES, OPTIONAL_GROUPS, LEGACY_BUNDLED_GROUPS, MANIFEST_SCHEMA_VERSION } = (await import(
|
|
120
166
|
resolve(TEMPLATE_DIR, 'app/shell/capabilities.manifest')
|
|
121
167
|
)) as typeof CapManifest;
|
|
122
168
|
|
|
169
|
+
/** The composed module set for THIS command run: built-ins + the config's `modulePacks`, resolved once
|
|
170
|
+
* at command entry by `applyModulePacks`. Stays `null` when NO packs are configured — every code path
|
|
171
|
+
* then reads the built-in `MODULES` directly, so a pack-less build is byte-identical to pre-packs. */
|
|
172
|
+
let RESOLVED_MODULES: ResolvedModule[] | null = null;
|
|
173
|
+
|
|
174
|
+
/** The active module set the CLI derives from — merged (built-ins + packs) when packs are configured,
|
|
175
|
+
* else the built-ins verbatim. */
|
|
176
|
+
function moduleList(): CapManifest.ModuleManifest[] {
|
|
177
|
+
return RESOLVED_MODULES ? RESOLVED_MODULES.map((r) => r.manifest) : MODULES;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Pack provenance for a module name (source label + pack dir) — used by the generator to locate a
|
|
181
|
+
* pack module's handler file + native source. Undefined for a built-in. */
|
|
182
|
+
function packInfo(name: string): { source: string; packDir: string } | undefined {
|
|
183
|
+
const r = RESOLVED_MODULES?.find((m) => m.manifest.name === name);
|
|
184
|
+
return r && r.packDir ? { source: r.source, packDir: r.packDir } : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Resolve the config's `modulePacks` (if any) into RESOLVED_MODULES for this run. No-op (leaves the
|
|
188
|
+
* built-ins untouched → byte-identical) when the config declares no packs. Async because pack manifests
|
|
189
|
+
* are dynamically imported; callers await it before the sync/regenerate pipeline. */
|
|
190
|
+
async function applyModulePacks(cwd: string, cfg: AppwrapConfig): Promise<void> {
|
|
191
|
+
// Host-injected packs first (the host's), then the app config's — so the app can override, last-wins.
|
|
192
|
+
const refs = [...(cliOptions.modulePacks ?? []), ...(cfg.modulePacks ?? [])];
|
|
193
|
+
if (refs.length === 0) {
|
|
194
|
+
RESOLVED_MODULES = null;
|
|
195
|
+
} else {
|
|
196
|
+
const ctx: SyncContext = {
|
|
197
|
+
platform: 'both',
|
|
198
|
+
env: process.env.APPWRAP_ENV || 'default',
|
|
199
|
+
ci: !!process.env.CI,
|
|
200
|
+
};
|
|
201
|
+
const map = await resolveModulePacks({
|
|
202
|
+
builtins: MODULES,
|
|
203
|
+
builtinSchemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
204
|
+
packRefs: refs,
|
|
205
|
+
ctx,
|
|
206
|
+
cwd,
|
|
207
|
+
});
|
|
208
|
+
RESOLVED_MODULES = [...map.values()];
|
|
209
|
+
}
|
|
210
|
+
warnUnknownModules(cfg);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Warn (never fail) for any name in `modules` that resolves to neither a built-in nor a pack module —
|
|
214
|
+
* with the pack model it's easy to list a capability (e.g. 'billing') and forget to add its pack to
|
|
215
|
+
* `modulePacks`, which would otherwise silently no-op (no handler, no capability). */
|
|
216
|
+
function warnUnknownModules(cfg: AppwrapConfig): void {
|
|
217
|
+
if (!cfg.modules) return;
|
|
218
|
+
const known = new Set(moduleList().map((m) => m.name));
|
|
219
|
+
for (const name of cfg.modules) {
|
|
220
|
+
if (!known.has(name)) {
|
|
221
|
+
console.warn(`⚠ module '${name}' is listed in \`modules\` but isn't a built-in or provided by any \`modulePacks\` — did you forget to add its pack? (ignored)`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
123
226
|
/** Native requirements composed for a build: the union (deduped) of the active modules' self-contained
|
|
124
227
|
* manifest declarations. Two modes:
|
|
125
228
|
* - `modules` ABSENT (legacy): every capability active; permissions come ONLY from `permissions{}`
|
|
@@ -137,20 +240,25 @@ interface NativeReqs {
|
|
|
137
240
|
androidGradleDeps: string[];
|
|
138
241
|
androidKotlin: boolean; // any active module ships Kotlin native source
|
|
139
242
|
androidManifestApp: string[]; // raw XML injected inside AndroidManifest <application>
|
|
140
|
-
nativeSrc: string[]; // active modules' nativeSrc dir names (under runtime/modules-native/)
|
|
141
|
-
|
|
142
|
-
|
|
243
|
+
nativeSrc: string[]; // active BUILT-IN modules' nativeSrc dir names (under runtime/modules-native/)
|
|
244
|
+
packNativeSrc: Array<{ name: string; srcDir: string }>; // active PACK modules' native source (absolute src dirs)
|
|
245
|
+
packHandlers: Array<{ name: string; source: string; packDir: string; file: string; fn: string }>; // active pack modules with a register handler (for the generated barrel + staging)
|
|
246
|
+
packModules: Array<Pick<CapManifest.ModuleManifest, 'name' | 'core' | 'capabilities' | 'group'>>; // active pack modules' handshake-relevant subset (their capabilities aren't in the runtime's static MODULES)
|
|
143
247
|
}
|
|
144
248
|
|
|
145
249
|
function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
146
|
-
const
|
|
250
|
+
const modules = moduleList();
|
|
251
|
+
const optIn = modules.filter((m) => !m.core);
|
|
147
252
|
// Legacy default (no `modules` key) = every opt-in capability EXCEPT strictly-opt-in own-file
|
|
148
253
|
// modules (OPTIONAL_GROUPS, e.g. health): those carry deps/perms legacy won't stamp, so they must
|
|
149
254
|
// be explicitly requested. Explicit mode = exactly what `modules` lists.
|
|
150
255
|
const active = cfg.modules
|
|
151
256
|
? new Set(cfg.modules)
|
|
152
|
-
: new Set(optIn.filter((m) =>
|
|
153
|
-
|
|
257
|
+
: new Set(optIn.filter((m) =>
|
|
258
|
+
!OPTIONAL_GROUPS.includes(m.group as (typeof OPTIONAL_GROUPS)[number]) ||
|
|
259
|
+
LEGACY_BUNDLED_GROUPS.includes(m.group as (typeof LEGACY_BUNDLED_GROUPS)[number])
|
|
260
|
+
).map((m) => m.name));
|
|
261
|
+
const activeMods = modules.filter((m) => m.core || active.has(m.name));
|
|
154
262
|
|
|
155
263
|
const iosPlist: Array<{ key: string; usage: string }> = [];
|
|
156
264
|
const seenKeys = new Set<string>();
|
|
@@ -158,6 +266,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
158
266
|
const gradle = new Set<string>();
|
|
159
267
|
const iosEntitlements: Record<string, boolean | string | string[]> = {};
|
|
160
268
|
const nativeSrc: string[] = [];
|
|
269
|
+
const packNativeSrc: NativeReqs['packNativeSrc'] = [];
|
|
270
|
+
const packHandlers: NativeReqs['packHandlers'] = [];
|
|
271
|
+
const packModules: NativeReqs['packModules'] = [];
|
|
161
272
|
const androidManifestApp: string[] = [];
|
|
162
273
|
let androidKotlin = false;
|
|
163
274
|
|
|
@@ -174,7 +285,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
174
285
|
Object.assign(iosEntitlements, m.ios?.entitlements ?? {});
|
|
175
286
|
if (m.android?.kotlin) androidKotlin = true;
|
|
176
287
|
if (m.android?.manifestApplication) androidManifestApp.push(m.android.manifestApplication);
|
|
177
|
-
|
|
288
|
+
// built-in native source resolves under runtime/modules-native/; pack native source (below)
|
|
289
|
+
// resolves under the pack's own dir, so it must not be conflated with the built-in dir names.
|
|
290
|
+
if (m.nativeSrc && !packInfo(m.name)) nativeSrc.push(m.nativeSrc);
|
|
178
291
|
}
|
|
179
292
|
} else {
|
|
180
293
|
// legacy: only what `permissions{}` declares (via the key maps) — no behavior change
|
|
@@ -186,6 +299,18 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
186
299
|
}
|
|
187
300
|
}
|
|
188
301
|
|
|
302
|
+
// Active PACK modules (from the config's modulePacks) — their native source + register handler
|
|
303
|
+
// resolve against the pack's own dir. No-op when no packs are configured (packInfo → undefined).
|
|
304
|
+
for (const m of activeMods) {
|
|
305
|
+
const info = packInfo(m.name);
|
|
306
|
+
if (!info) continue;
|
|
307
|
+
if (m.nativeSrc) packNativeSrc.push({ name: m.name, srcDir: join(info.packDir, 'native-src', m.nativeSrc) });
|
|
308
|
+
if (m.handler) packHandlers.push({ name: m.name, source: info.source, packDir: info.packDir, file: m.handler.file, fn: m.handler.fn });
|
|
309
|
+
// The runtime's static MODULES doesn't include pack modules, so their capabilities must be carried
|
|
310
|
+
// into the generated handshake map explicitly (see generateModuleArtifacts + buildCapabilityMap).
|
|
311
|
+
packModules.push({ name: m.name, core: m.core, capabilities: m.capabilities, group: m.group });
|
|
312
|
+
}
|
|
313
|
+
|
|
189
314
|
return {
|
|
190
315
|
explicit: !!cfg.modules,
|
|
191
316
|
activeOptIn: optIn.filter((m) => active.has(m.name)).map((m) => m.name),
|
|
@@ -197,15 +322,14 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
197
322
|
androidKotlin,
|
|
198
323
|
androidManifestApp,
|
|
199
324
|
nativeSrc,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
325
|
+
packNativeSrc,
|
|
326
|
+
packHandlers,
|
|
327
|
+
packModules,
|
|
203
328
|
};
|
|
204
329
|
}
|
|
205
330
|
|
|
206
331
|
/** Map a strippable optional group → its handler file + register fn (for the generated barrel). */
|
|
207
332
|
const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
|
|
208
|
-
health: { file: './handlers-health', fn: 'registerHealthHandlers' },
|
|
209
333
|
oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
|
|
210
334
|
reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
|
|
211
335
|
scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
|
|
@@ -213,22 +337,112 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
|
|
|
213
337
|
tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
|
|
214
338
|
appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
|
|
215
339
|
backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
|
|
216
|
-
widget
|
|
340
|
+
// billing/health/widget live in host-provided module packs (not part of CE) — a consumer opts in via modulePacks.
|
|
217
341
|
};
|
|
218
342
|
|
|
343
|
+
/** The bare specifier a pack handler uses to import a built-in shell API — rewritten to a relative
|
|
344
|
+
* `./<mod>` when the file is staged next to the shell sources. A pack authored out-of-repo imports
|
|
345
|
+
* e.g. `@livx.cc/appwrap/runtime/app/shell/bridge` (resolvable, since the npm package ships runtime/),
|
|
346
|
+
* so it typechecks standalone; staging drops it beside the real shell files, where `./bridge` resolves. */
|
|
347
|
+
const SHELL_API_SPECIFIER = /(['"])@livx\.cc\/appwrap\/runtime\/app\/shell\//g;
|
|
348
|
+
|
|
349
|
+
/** Rewrite a pack handler's shell-API imports from the package specifier to relative, so the file
|
|
350
|
+
* resolves once staged beside the real shell sources. Pure (exported for tests). */
|
|
351
|
+
export function rewritePackShellImports(src: string): string {
|
|
352
|
+
return src.replace(SHELL_API_SPECIFIER, '$1./');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Resolve a pack-local relative import specifier to an absolute source file (trying the usual TS/JS
|
|
356
|
+
* extensions + an index file), or null if it doesn't resolve to a real file. */
|
|
357
|
+
function resolvePackImport(fromDir: string, spec: string): string | null {
|
|
358
|
+
const base = resolve(fromDir, spec);
|
|
359
|
+
const cands = [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.jsx`, join(base, 'index.ts'), join(base, 'index.js')];
|
|
360
|
+
return cands.find((c) => existsSync(c) && statSync(c).isFile()) ?? null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Replace an exact (quoted) import specifier in source with another — anchored so `./x` never
|
|
364
|
+
* partial-matches `./x-y`. Pure (exported for tests). */
|
|
365
|
+
export function replaceImportSpecifier(src: string, oldSpec: string, newSpec: string): string {
|
|
366
|
+
const esc = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
367
|
+
return src.replace(new RegExp(`(['"])${esc}\\1`, 'g'), `$1${newSpec}$1`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Stage a pack source file into the shell, namespaced `pack-<label>-<name>.ts` (never colliding with a
|
|
371
|
+
* built-in file — keeps built-in staged filenames stable, preserving the byte-compare gate). Rewrites
|
|
372
|
+
* (a) shell-API imports → relative `./<x>`, and (b) pack-LOCAL relative imports → their namespaced
|
|
373
|
+
* staged sibling, staging each RECURSIVELY — so a handler's own helper modules (e.g. billing's
|
|
374
|
+
* billing-offer.ts) are staged too. `staged` memoizes by abs path (dedup + cycle guard). `entryName`
|
|
375
|
+
* overrides the staged basename for the handler entry (so the barrel imports it by module identity). */
|
|
376
|
+
function stagePackFile(shell: string, label: string, packDir: string, absFile: string, staged: Map<string, string>, entryName?: string): string {
|
|
377
|
+
const cached = staged.get(absFile);
|
|
378
|
+
if (cached) return cached;
|
|
379
|
+
if (!existsSync(absFile)) {
|
|
380
|
+
console.error(`✖ module pack "${label}": imported file not found: ${absFile}`);
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
const name = entryName ?? basename(absFile).replace(/\.(tsx?|jsx?)$/, '');
|
|
384
|
+
const destName = `pack-${label}-${name}.ts`;
|
|
385
|
+
const spec = `./${destName.replace(/\.ts$/, '')}`;
|
|
386
|
+
staged.set(absFile, spec); // set BEFORE recursing so an import cycle terminates
|
|
387
|
+
|
|
388
|
+
let src = rewritePackShellImports(readFileSync(absFile, 'utf8'));
|
|
389
|
+
const fromDir = dirname(absFile);
|
|
390
|
+
for (const imp of new Bun.Transpiler({ loader: loaderForEntry(absFile) }).scanImports(src)) {
|
|
391
|
+
if (!imp.path.startsWith('.')) continue; // only pack-LOCAL relative imports need staging
|
|
392
|
+
const depAbs = resolvePackImport(fromDir, imp.path);
|
|
393
|
+
if (!depAbs || !depAbs.startsWith(packDir + '/')) continue; // must stay inside the pack dir
|
|
394
|
+
const depSpec = stagePackFile(shell, label, packDir, depAbs, staged);
|
|
395
|
+
src = replaceImportSpecifier(src, imp.path, depSpec);
|
|
396
|
+
}
|
|
397
|
+
writeFileSync(join(shell, destName), src);
|
|
398
|
+
return spec;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Stage a pack module's handler (+ its pack-local imports) into the shell. Returns the import
|
|
402
|
+
* specifier the generated barrel imports the handler by. */
|
|
403
|
+
function stagePackHandler(shell: string, h: NativeReqs['packHandlers'][number]): string {
|
|
404
|
+
const srcFile = resolve(h.packDir, h.file);
|
|
405
|
+
if (!existsSync(srcFile)) {
|
|
406
|
+
console.error(`✖ module pack "${h.source}": handler file not found: ${srcFile}`);
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
const label = h.source.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
410
|
+
const spec = stagePackFile(shell, label, h.packDir, srcFile, new Map(), h.name);
|
|
411
|
+
console.log(` pack ← ${h.source} (${h.name} handler)`);
|
|
412
|
+
return spec;
|
|
413
|
+
}
|
|
414
|
+
|
|
219
415
|
/** Generate the two composition artifacts in the wrapper: the active capability list (drives the
|
|
220
416
|
* handshake map) and the optional-handler barrel (imports only active strippable groups). */
|
|
221
417
|
function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
|
|
222
418
|
const shell = join(outDir, 'app/shell');
|
|
419
|
+
// Sweep stale pack-staged handlers first so a removed/renamed pack never leaves an orphan behind
|
|
420
|
+
// (self-pruning, like regenerateMobilePlugins wiping its dir) — the active ones are re-staged below.
|
|
421
|
+
for (const f of existsSync(shell) ? readdirSync(shell) : []) {
|
|
422
|
+
if (f.startsWith('pack-') && f.endsWith('.ts')) rmSync(join(shell, f), { force: true });
|
|
423
|
+
}
|
|
223
424
|
writeFileSync(
|
|
224
425
|
join(shell, 'active-modules.generated.ts'),
|
|
225
|
-
`/** Generated by \`appwrap\` from the appwrap config \`modules\`. Do not edit. */\n` +
|
|
226
|
-
`
|
|
426
|
+
`/** Generated by \`appwrap\` from the appwrap config \`modules\` + \`modulePacks\`. Do not edit. */\n` +
|
|
427
|
+
`import type { ModuleManifest } from './capabilities.manifest';\n` +
|
|
428
|
+
`export const ACTIVE_MODULE_NAMES: string[] = ${JSON.stringify(req.activeOptIn)};\n` +
|
|
429
|
+
// Pack modules aren't in the runtime's static MODULES, so their handshake capabilities travel here
|
|
430
|
+
// and buildCapabilityMap merges them (empty for a pack-less build).
|
|
431
|
+
`export const PACK_MODULES: Array<Pick<ModuleManifest, 'name' | 'core' | 'capabilities' | 'group'>> = ${JSON.stringify(req.packModules)};\n`
|
|
227
432
|
);
|
|
228
433
|
|
|
229
434
|
const groups = req.activeOptionalGroups.filter((g) => OPTIONAL_GROUP_HANDLERS[g]);
|
|
230
|
-
const
|
|
231
|
-
const
|
|
435
|
+
const importLines = groups.map((g) => `import { ${OPTIONAL_GROUP_HANDLERS[g].fn} } from '${OPTIONAL_GROUP_HANDLERS[g].file}';`);
|
|
436
|
+
const callLines = groups.map((g) => ` ${OPTIONAL_GROUP_HANDLERS[g].fn}();`);
|
|
437
|
+
// Active PACK modules with a register handler — staged into the shell + APPENDED after the built-in
|
|
438
|
+
// groups (empty when no packs → byte-identical to the pre-packs barrel).
|
|
439
|
+
for (const h of req.packHandlers) {
|
|
440
|
+
const spec = stagePackHandler(shell, h);
|
|
441
|
+
importLines.push(`import { ${h.fn} } from '${spec}';`);
|
|
442
|
+
callLines.push(` ${h.fn}();`);
|
|
443
|
+
}
|
|
444
|
+
const imports = importLines.join('\n');
|
|
445
|
+
const calls = callLines.join('\n');
|
|
232
446
|
writeFileSync(
|
|
233
447
|
join(shell, 'optional-handlers.generated.ts'),
|
|
234
448
|
`/** Generated by \`appwrap\` — only the active strippable modules are imported. Do not edit. */\n` +
|
|
@@ -394,9 +608,13 @@ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs):
|
|
|
394
608
|
const file = join(iosDir, 'app.entitlements');
|
|
395
609
|
const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements, ...cfg.iosEntitlements };
|
|
396
610
|
if (!!cfg.push?.enabled && cfg.push?.ios !== false) ent['aps-environment'] = cfg.push.apsEnvironment ?? 'development';
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
611
|
+
// Resolve build tokens in entitlement values (e.g. a module's `__APP_GROUP__` → group.<appId>) so a
|
|
612
|
+
// module declaring an app-derived entitlement (widget's App Group) needs no hardcoded stamper case.
|
|
613
|
+
const tokens = buildTokens(cfg);
|
|
614
|
+
for (const [k, v] of Object.entries(ent)) {
|
|
615
|
+
if (typeof v === 'string') ent[k] = substituteBuildTokens(v, tokens);
|
|
616
|
+
else if (Array.isArray(v)) ent[k] = v.map((x) => substituteBuildTokens(x, tokens));
|
|
617
|
+
}
|
|
400
618
|
const keys = Object.keys(ent);
|
|
401
619
|
if (keys.length === 0) { rmSync(file, { force: true }); return; }
|
|
402
620
|
const val = (v: boolean | string | string[]): string =>
|
|
@@ -426,29 +644,52 @@ function stampPrivacyManifest(outDir: string, cfg: AppwrapConfig, req: NativeReq
|
|
|
426
644
|
if (active) console.log(` priv ← NSPrivacyTracking=true${cfg.trackingDomains?.length ? ` (${cfg.trackingDomains.length} domain${cfg.trackingDomains.length > 1 ? 's' : ''})` : ''}`);
|
|
427
645
|
}
|
|
428
646
|
|
|
429
|
-
/** Copy active modules' native source (runtime/modules-native/<name>/
|
|
430
|
-
*
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
647
|
+
/** Copy active modules' native source (runtime/modules-native/<name>/ for built-ins, <packDir>/
|
|
648
|
+
* native-src/<name>/ for packs) into native/ — only when the module is active, so module native code
|
|
649
|
+
* stays stripped from builds that don't use it. Returns the set of dest paths (relative to outDir) it
|
|
650
|
+
* copied, so regenerateCore can prune a now-INACTIVE module's native files on re-sync (they mirror the
|
|
651
|
+
* App_Resources layout and cpSync only overwrites, never deletes). */
|
|
652
|
+
function copyModuleNativeSrc(outDir: string, req: NativeReqs): Set<string> {
|
|
653
|
+
const copied = new Set<string>();
|
|
654
|
+
const copyFrom = (src: string, label: string) => {
|
|
655
|
+
if (!existsSync(src)) { console.warn(`⚠ module nativeSrc not found: ${src}`); return; }
|
|
435
656
|
cpSync(src, outDir, { recursive: true, force: true });
|
|
436
|
-
|
|
437
|
-
}
|
|
657
|
+
for (const rel of collectRelFiles(src)) copied.add(rel);
|
|
658
|
+
console.log(` natv ← ${label} native source`);
|
|
659
|
+
};
|
|
660
|
+
for (const name of req.nativeSrc) copyFrom(join(MODULES_NATIVE_DIR, name), `module '${name}'`);
|
|
661
|
+
for (const { name, srcDir } of req.packNativeSrc) copyFrom(srcDir, `pack module '${name}'`);
|
|
662
|
+
return copied;
|
|
438
663
|
}
|
|
439
664
|
|
|
440
|
-
/**
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
function
|
|
445
|
-
|
|
665
|
+
/** Build-time tokens a module's native source / entitlements may reference — resolved from the app
|
|
666
|
+
* config so a module ships app-agnostic and the CLI stamps the app-specific value in. Not module-
|
|
667
|
+
* specific: any module (built-in or pack) can use these tokens. Currently `__APP_GROUP__` → the App
|
|
668
|
+
* Group id shared by the app + an extension (e.g. widget); extend here as new app-derived needs arise. */
|
|
669
|
+
function buildTokens(cfg: AppwrapConfig): Record<string, string> {
|
|
670
|
+
return { __APP_GROUP__: `group.${cfg.id}` };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Substitute any {@link buildTokens} in a stamped string (entitlement value or native-source file). */
|
|
674
|
+
function substituteBuildTokens(s: string, tokens: Record<string, string>): string {
|
|
675
|
+
let out = s;
|
|
676
|
+
for (const [tok, val] of Object.entries(tokens)) out = out.replaceAll(tok, val);
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Substitute build-time tokens in copied module native source (after copyModuleNativeSrc) — applied
|
|
681
|
+
* generically to every iOS extension file, so a module's app-agnostic source (e.g. the widget
|
|
682
|
+
* extension's Swift/entitlements/plist) gets the app-specific value stamped in. Idempotent (source is
|
|
683
|
+
* re-copied verbatim each sync, then re-substituted). No-op when no extensions were copied. */
|
|
684
|
+
function substituteModuleTokens(outDir: string, cfg: AppwrapConfig): void {
|
|
446
685
|
const extRoot = join(outDir, 'App_Resources/iOS/extensions');
|
|
447
686
|
if (!existsSync(extRoot)) return;
|
|
687
|
+
const tokens = buildTokens(cfg);
|
|
688
|
+
let touched = false;
|
|
448
689
|
const subst = (file: string) => {
|
|
449
690
|
const s = readFileSync(file, 'utf8');
|
|
450
|
-
|
|
451
|
-
|
|
691
|
+
const next = substituteBuildTokens(s, tokens);
|
|
692
|
+
if (next !== s) { writeFileSync(file, next); touched = true; }
|
|
452
693
|
};
|
|
453
694
|
const walk = (dir: string) => {
|
|
454
695
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -458,7 +699,7 @@ function substituteModuleTokens(outDir: string, req: NativeReqs): void {
|
|
|
458
699
|
}
|
|
459
700
|
};
|
|
460
701
|
walk(extRoot);
|
|
461
|
-
console.log(` tokn ← __APP_GROUP__ = ${
|
|
702
|
+
if (touched) console.log(` tokn ← __APP_GROUP__ = ${tokens.__APP_GROUP__} (extension source)`);
|
|
462
703
|
}
|
|
463
704
|
|
|
464
705
|
/** Hosts to register as Android App Links (autoVerify https intent-filters). Explicit
|
|
@@ -555,7 +796,7 @@ function resolveConfigPath(cwd: string, flags: Record<string, string>): string {
|
|
|
555
796
|
: (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
|
|
556
797
|
}
|
|
557
798
|
|
|
558
|
-
async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
|
|
799
|
+
export async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
|
|
559
800
|
const configPath = resolveConfigPath(cwd, flags);
|
|
560
801
|
if (!existsSync(configPath)) {
|
|
561
802
|
console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
|
|
@@ -584,7 +825,7 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
|
|
|
584
825
|
}
|
|
585
826
|
|
|
586
827
|
// loader:'server' bakes serverUrl into the shell and loads it via NSURL/WKWebView. A scheme-less
|
|
587
|
-
// value (e.g. "
|
|
828
|
+
// value (e.g. "app.example.com") produces an unusable URL — the app silently fails to load (or
|
|
588
829
|
// shows a stale page) with no error. Normalize to https:// when no scheme is present, and fail loud
|
|
589
830
|
// on a genuinely malformed URL rather than shipping a broken build.
|
|
590
831
|
if (cfg.serverUrl) {
|
|
@@ -603,7 +844,7 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
|
|
|
603
844
|
return cfg;
|
|
604
845
|
}
|
|
605
846
|
|
|
606
|
-
function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
847
|
+
export function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
607
848
|
// Resolve the env-switcher block. Absent block OR `enabled:false` → the whole feature is inert
|
|
608
849
|
// (the shell reads `envSwitcher.enabled`). `allowPattern`/`envs` default to empty (default-deny).
|
|
609
850
|
const es = cfg.envSwitcher;
|
|
@@ -628,6 +869,7 @@ export const SHELL_CONFIG = {
|
|
|
628
869
|
loader: ${JSON.stringify(cfg.loader ?? 'app')} as 'app' | 'file' | 'server',
|
|
629
870
|
serverUrl: ${JSON.stringify(cfg.serverUrl ?? '')},
|
|
630
871
|
backendOrigin: ${JSON.stringify(cfg.backendOrigin ?? '')},
|
|
872
|
+
urlScheme: ${JSON.stringify(cfg.urlScheme ?? '')},
|
|
631
873
|
debug: ${JSON.stringify(cfg.debug ?? false)},
|
|
632
874
|
debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
|
|
633
875
|
devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
|
|
@@ -1391,7 +1633,7 @@ function stampAndroidVersion(outDir: string, cfg: AppwrapConfig): void {
|
|
|
1391
1633
|
}
|
|
1392
1634
|
|
|
1393
1635
|
/** Locate the icon source: explicit cfg.icon, else the largest icon in the PWA manifest. */
|
|
1394
|
-
function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
|
|
1636
|
+
export function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
|
|
1395
1637
|
if (cfg.icon) {
|
|
1396
1638
|
const p = resolve(cwd, cfg.icon);
|
|
1397
1639
|
if (existsSync(p)) return p;
|
|
@@ -1626,17 +1868,23 @@ export function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig):
|
|
|
1626
1868
|
const dir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
|
|
1627
1869
|
// Files the template still provides must survive an override removal (regenerateCore ran first and
|
|
1628
1870
|
// re-copied them) → protect them from the override prune.
|
|
1629
|
-
const templateFiles =
|
|
1871
|
+
const templateFiles = allTemplateFiles();
|
|
1630
1872
|
// ACTIVE modules' native source needs the SAME protection: copyModuleNativeSrc re-copied it into
|
|
1631
1873
|
// these exact relative paths, but it lives under runtime/modules-native/<name>/, which
|
|
1632
1874
|
// templateCopyFilter deliberately excludes — so it is absent from templateFiles above. Without this,
|
|
1633
1875
|
// dropping a consumer override that happened to shadow a module file prunes the MODULE's own copy
|
|
1634
1876
|
// too (the prune only sees "was in the overrides manifest, isn't in overrides now"), silently
|
|
1635
1877
|
// deleting e.g. res/xml/appwrap_widget_info.xml and failing the build at AAPT.
|
|
1636
|
-
|
|
1878
|
+
const reqs = nativeReqs(cfg);
|
|
1879
|
+
for (const name of reqs.nativeSrc) {
|
|
1637
1880
|
const modDir = join(MODULES_NATIVE_DIR, name);
|
|
1638
1881
|
if (existsSync(modDir)) for (const rel of collectRelFiles(modDir)) templateFiles.add(rel);
|
|
1639
1882
|
}
|
|
1883
|
+
// Same protection for active PACK modules' native source (lives under the pack dir, absent from the
|
|
1884
|
+
// template file set) — so an override removal never prunes a pack module's own copied files.
|
|
1885
|
+
for (const { srcDir } of reqs.packNativeSrc) {
|
|
1886
|
+
if (existsSync(srcDir)) for (const rel of collectRelFiles(srcDir)) templateFiles.add(rel);
|
|
1887
|
+
}
|
|
1640
1888
|
if (!existsSync(dir)) {
|
|
1641
1889
|
// No overrides now: prune anything a PRIOR overrides run left behind (renamed/removed override files).
|
|
1642
1890
|
pruneStale(outDir, OVERRIDES_MANIFEST, new Set(), templateFiles);
|
|
@@ -1795,12 +2043,27 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
|
|
|
1795
2043
|
// so without this a relocated source lingers in native/ and gets re-bundled.
|
|
1796
2044
|
const TEMPLATE_MANIFEST = '.appwrap-template-manifest.json';
|
|
1797
2045
|
const OVERRIDES_MANIFEST = '.appwrap-overrides-manifest.json';
|
|
2046
|
+
/** Tracks module-owned native source copied into native/ (built-in + pack), so a module DEACTIVATED
|
|
2047
|
+
* since the last sync has its App_Resources files pruned rather than lingering (cpSync never deletes). */
|
|
2048
|
+
const MODULE_NATIVE_MANIFEST = '.appwrap-module-native-manifest.json';
|
|
1798
2049
|
|
|
1799
2050
|
/** Files under TEMPLATE_DIR to skip when copying/walking it (deps, build output, PWA staging, per-module
|
|
1800
2051
|
* native — those are handled selectively elsewhere). Shared by the cpSync filter and the prune walk so
|
|
1801
2052
|
* the copied set and the pruned set are defined identically. */
|
|
1802
|
-
const
|
|
1803
|
-
!/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(
|
|
2053
|
+
const templateCopyFilterFor = (root: string) => (src: string): boolean =>
|
|
2054
|
+
!/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(root.length));
|
|
2055
|
+
/** Single-root filter for the built-in template (the common path). */
|
|
2056
|
+
const templateCopyFilter = templateCopyFilterFor(TEMPLATE_DIR);
|
|
2057
|
+
/** Every file the active template roots contribute, relative to each root (deduped across roots) —
|
|
2058
|
+
* the union prune set + override-protection set for the overlay chain. */
|
|
2059
|
+
function allTemplateFiles(): Set<string> {
|
|
2060
|
+
const out = new Set<string>();
|
|
2061
|
+
for (const root of TEMPLATE_ROOTS) {
|
|
2062
|
+
if (!existsSync(root)) continue;
|
|
2063
|
+
for (const rel of collectRelFiles(root, templateCopyFilterFor(root))) out.add(rel);
|
|
2064
|
+
}
|
|
2065
|
+
return out;
|
|
2066
|
+
}
|
|
1804
2067
|
|
|
1805
2068
|
/** File (not dir) paths under `root`, relative to it, skipping entries `accept` rejects. */
|
|
1806
2069
|
function collectRelFiles(root: string, accept: (abs: string) => boolean = () => true): Set<string> {
|
|
@@ -1851,16 +2114,23 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
1851
2114
|
if (opts.firstRun && !req.explicit) {
|
|
1852
2115
|
console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
|
|
1853
2116
|
}
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2117
|
+
// Copy each template root in order — later roots OVERLAY earlier (file-level last-wins), so a
|
|
2118
|
+
// consumer template can add or replace shell files without forking CE. Single root (default) is
|
|
2119
|
+
// exactly the pre-overlay copy.
|
|
2120
|
+
for (const root of TEMPLATE_ROOTS) {
|
|
2121
|
+
if (!existsSync(root)) { console.warn(`⚠ template root not found: ${root}`); continue; }
|
|
2122
|
+
cpSync(root, outDir, {
|
|
2123
|
+
recursive: true,
|
|
2124
|
+
force: true, // explicit: Bun's cpSync does not overwrite existing files by default
|
|
2125
|
+
// modules-native/ is copied selectively per active module (copyModuleNativeSrc), not wholesale.
|
|
2126
|
+
// Match RELATIVE to the root — when installed from npm, a root itself sits under node_modules/,
|
|
2127
|
+
// so testing the absolute path would wrongly exclude the entire template.
|
|
2128
|
+
filter: templateCopyFilterFor(root),
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
// Delete template files removed/renamed since the last regenerate (cpSync only overwrites, never
|
|
2132
|
+
// deletes) — pruned against the UNION of all roots' files so an overlay-provided file isn't culled.
|
|
2133
|
+
pruneStale(outDir, TEMPLATE_MANIFEST, allTemplateFiles());
|
|
1864
2134
|
stampShellConfig(outDir, cfg);
|
|
1865
2135
|
stampNativeScriptConfig(outDir, cfg);
|
|
1866
2136
|
stampIOSDisplayName(outDir, cfg, req);
|
|
@@ -1872,8 +2142,12 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
1872
2142
|
stampKotlin(outDir, req.androidKotlin);
|
|
1873
2143
|
generateModuleArtifacts(outDir, req);
|
|
1874
2144
|
regenerateMobilePlugins(cwd, cfg, outDir); // config-gated TS plugins → bridge handlers (in-process)
|
|
1875
|
-
copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
|
|
1876
|
-
|
|
2145
|
+
const moduleNativeFiles = copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
|
|
2146
|
+
// Prune a now-inactive module's native files (in the prior manifest, not re-copied this sync). Protect
|
|
2147
|
+
// base-template files so a module that overrode a template App_Resources file, once removed, reverts to
|
|
2148
|
+
// the template copy instead of being deleted outright.
|
|
2149
|
+
pruneStale(outDir, MODULE_NATIVE_MANIFEST, moduleNativeFiles, allTemplateFiles());
|
|
2150
|
+
substituteModuleTokens(outDir, cfg); // stamp __APP_GROUP__ etc. into the copied extension source
|
|
1877
2151
|
stampLaunchScreen(outDir, cfg);
|
|
1878
2152
|
stampStoreKit(cwd, outDir, cfg);
|
|
1879
2153
|
stampPush(cwd, outDir, cfg);
|
|
@@ -1908,6 +2182,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
|
1908
2182
|
|
|
1909
2183
|
console.log(`🎁 appwrap init → ${outDir}`);
|
|
1910
2184
|
mkdirSync(outDir, { recursive: true });
|
|
2185
|
+
await applyModulePacks(cwd, cfg); // resolve config `modulePacks` (no-op when none) before deriving
|
|
1911
2186
|
regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
|
|
1912
2187
|
copyCiTemplates(cwd, outDir, cfg, 'force' in flags, 'ci' in flags); // GH workflows: opt-in (--ci), first-time only; fastlane lane: re-emit on --force
|
|
1913
2188
|
writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
|
|
@@ -1928,6 +2203,7 @@ async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: Ap
|
|
|
1928
2203
|
console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
|
|
1929
2204
|
process.exit(1);
|
|
1930
2205
|
}
|
|
2206
|
+
await applyModulePacks(cwd, cfg); // resolve config `modulePacks` (no-op when none) before deriving
|
|
1931
2207
|
regenerateCore(cwd, outDir, cfg, { flags });
|
|
1932
2208
|
applyOverrides(cwd, outDir, cfg); // overrides win last
|
|
1933
2209
|
stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
|
|
@@ -2002,11 +2278,11 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
|
|
|
2002
2278
|
*/
|
|
2003
2279
|
async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
|
|
2004
2280
|
const platform = positionals[0];
|
|
2005
|
-
if (platform
|
|
2281
|
+
if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals, 'dev');
|
|
2006
2282
|
const sim = 'sim' in flags || positionals[1] === 'sim';
|
|
2007
2283
|
const wantDebug = 'debug' in flags;
|
|
2008
2284
|
if (platform !== 'ios' && platform !== 'android') {
|
|
2009
|
-
console.error('Usage: appwrap dev <ios|android
|
|
2285
|
+
console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
|
|
2010
2286
|
process.exit(1);
|
|
2011
2287
|
}
|
|
2012
2288
|
const cfg = await loadConfig(cwd, flags);
|
|
@@ -2040,6 +2316,7 @@ async function dev(cwd: string, flags: Record<string, string>, positionals: stri
|
|
|
2040
2316
|
: stamped?.loader === 'server'
|
|
2041
2317
|
? { ...cfg, loader: 'server', serverUrl: stamped.serverUrl, debug: stamped.debug }
|
|
2042
2318
|
: cfg;
|
|
2319
|
+
await applyModulePacks(cwd, simCfg); // resolve config `modulePacks` (no-op when none) before deriving
|
|
2043
2320
|
regenerateCore(cwd, outDir, simCfg, { flags });
|
|
2044
2321
|
applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
|
|
2045
2322
|
stampIOSExtensionVersions(outDir, simCfg); // AFTER overrides: keep extension version matching the app's (Xcode warns on mismatch)
|
|
@@ -2378,552 +2655,12 @@ async function watchAndSync(cwd: string, flags: Record<string, string>, outDir:
|
|
|
2378
2655
|
* Android signing comes from env (APPWRAP_ANDROID_KEYSTORE[_PASSWORD|_ALIAS|_ALIAS_PASSWORD]) — secrets
|
|
2379
2656
|
* never live in the appwrap config. iOS distribution signing/upload is the fastlane release lane's job (the
|
|
2380
2657
|
* cicd templates); `--release` here just builds the Release config for the device. */
|
|
2381
|
-
// ─────────────────────────────── desktop (Tauri chassis) lane ────────────────────────────────
|
|
2382
|
-
// Phase-0, macOS-only. Mirrors the managed-CNG model of the mobile lanes but far leaner: copy the
|
|
2383
|
-
// `runtime-desktop/` template into the app's disposable `native-desktop/`, stamp the app's identity +
|
|
2384
|
-
// window shape (shell_config.json, embedded into the Rust binary) and Tauri config (tauri.conf.json),
|
|
2385
|
-
// stage the built web dist, then `cargo run`/`build`. The template is canonical; `native-desktop/` is
|
|
2386
|
-
// gitignored + regenerated each run (framework edits propagate — same contract as `native/`).
|
|
2387
|
-
|
|
2388
|
-
/** Fail clearly on non-macOS — the desktop chassis is macOS-only for phase-0 (osascript dialogs/
|
|
2389
|
-
* notifications, `open`, the app-data path are all macOS). */
|
|
2390
|
-
function ensureDesktopMacOS(): void {
|
|
2391
|
-
if (process.platform !== 'darwin') {
|
|
2392
|
-
console.error(`✖ appwrap desktop is macOS-only for now (phase-0). Detected platform: ${process.platform}.`);
|
|
2393
|
-
process.exit(1);
|
|
2394
|
-
}
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
/** Skip the heavy/generated dirs when copying the desktop template (Rust build output + Tauri codegen +
|
|
2398
|
-
* deps): they regenerate, and `target/` alone is GBs. Matches RELATIVE to the template root. */
|
|
2399
|
-
function desktopCopyFilter(src: string): boolean {
|
|
2400
|
-
const rel = src.slice(DESKTOP_TEMPLATE_DIR.length);
|
|
2401
|
-
return !/(^|\/)(target|gen|node_modules)(\/|$)/.test(rel);
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
/** Resolve a `desktop.server.command` to an absolute path: an absolute/relative path is resolved
|
|
2405
|
-
* against the app root; a bare name is looked up on `path` (build-time PATH). Falls back to the bare
|
|
2406
|
-
* name (the Rust shell will surface the spawn ENOENT) when it's nowhere. */
|
|
2407
|
-
function resolveServerCommand(command: string, cwd: string, path: string): string {
|
|
2408
|
-
if (command.includes('/')) return resolve(cwd, command);
|
|
2409
|
-
for (const dir of path.split(pathDelimiter).filter(Boolean)) {
|
|
2410
|
-
const abs = join(dir, command);
|
|
2411
|
-
if (existsSync(abs)) return abs;
|
|
2412
|
-
}
|
|
2413
|
-
return command;
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
|
-
/** The bundled splash shown while a local-server app boots (before the shell navigates to the live
|
|
2417
|
-
* server). A dependency-free, theme-neutral holding page. */
|
|
2418
|
-
function desktopSplashHtml(name: string): string {
|
|
2419
|
-
const safe = name.replace(/</g, '<').replace(/&/g, '&');
|
|
2420
|
-
return `<!doctype html><html><head><meta charset="utf-8"><title>${safe}</title>
|
|
2421
|
-
<style>html,body{height:100%;margin:0}body{display:flex;align-items:center;justify-content:center;
|
|
2422
|
-
flex-direction:column;gap:20px;background:#0b0b0f;color:#e5e5e5;font:14px -apple-system,system-ui,sans-serif}
|
|
2423
|
-
.s{width:34px;height:34px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:r .8s linear infinite}
|
|
2424
|
-
@keyframes r{to{transform:rotate(360deg)}}#e{color:#f87171;max-width:70%;text-align:center;line-height:1.5}</style>
|
|
2425
|
-
</head><body><div class="s" id="spin"></div><div id="m">Starting ${safe}…</div><div id="e"></div></body></html>`;
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
/** Copy the desktop template into `native-desktop/` and stamp it from the config: the Rust-read
|
|
2429
|
-
* `shell_config.json` (identity + window), `tauri.conf.json` (productName/version/identifier +
|
|
2430
|
-
* frontendDist), and the staged web dist (`native-desktop/dist`, referenced as `../dist`). */
|
|
2431
|
-
/**
|
|
2432
|
-
* Make `cfg.plugins` LIVE (A.1 loader): resolve each entry (path OR npm package), bun-build its
|
|
2433
|
-
* entrypoint to a single embedded file under `native-desktop/plugins/`, bun-build the multiplexed
|
|
2434
|
-
* plugin-host once, and stamp `{ pluginHost, plugins:[{bundleId, attachTo, bundlePath}] }` into the
|
|
2435
|
-
* shell config. The Rust shell (plugin_host.rs) spawns the host with the stamped plugins at boot.
|
|
2436
|
-
*
|
|
2437
|
-
* A.1 scope: manifest/perms/native-deps merge is STUBBED (noted) — full `capabilities.manifest.ts`
|
|
2438
|
-
* reuse (perms/entitlements union) lands later. `bundleId` here is the file/package basename (a
|
|
2439
|
-
* pre-load build id for the bundle filename only); the authoritative routing/diagnostic identifier is
|
|
2440
|
-
* the plugin def's `name`, which the host reads from the bundle after `import()`.
|
|
2441
|
-
*/
|
|
2442
|
-
export function regeneratePlugins(cwd: string, cfg: AppwrapConfig, outDir: string, shell: DesktopShellConfig): void {
|
|
2443
|
-
const entries = cfg.plugins ?? [];
|
|
2444
|
-
if (entries.length === 0) return;
|
|
2445
|
-
const outPlugins = join(outDir, 'plugins');
|
|
2446
|
-
mkdirSync(outPlugins, { recursive: true });
|
|
2447
|
-
const bun = process.execPath; // the CLI runs under bun → the exact runtime to build/spawn with
|
|
2448
|
-
|
|
2449
|
-
const bunBuild = (entrypoint: string, outfile: string): boolean => {
|
|
2450
|
-
try {
|
|
2451
|
-
execFileSync(bun, ['build', entrypoint, '--target=bun', '--outfile', outfile], { stdio: 'pipe' });
|
|
2452
|
-
return true;
|
|
2453
|
-
} catch (e: unknown) {
|
|
2454
|
-
console.warn(`⚠ plugin bun-build failed (${entrypoint}): ${e instanceof Error ? e.message : String(e)}`);
|
|
2455
|
-
return false;
|
|
2456
|
-
}
|
|
2457
|
-
};
|
|
2458
|
-
|
|
2459
|
-
// Build the host bundle once (from THIS package's src/plugin/host.ts).
|
|
2460
|
-
const hostSrc = resolve(import.meta.dir, 'plugin', 'host.ts');
|
|
2461
|
-
const hostOut = join(outPlugins, 'host.js');
|
|
2462
|
-
if (!bunBuild(hostSrc, hostOut)) {
|
|
2463
|
-
console.warn('⚠ plugin host failed to build — plugins disabled for this build.');
|
|
2464
|
-
return;
|
|
2465
|
-
}
|
|
2466
|
-
|
|
2467
|
-
const stamped: DesktopShellConfig['plugins'] = [];
|
|
2468
|
-
for (const raw of entries) {
|
|
2469
|
-
const name = typeof raw === 'string' ? raw : raw.name;
|
|
2470
|
-
const attachTo = typeof raw === 'string' ? undefined : raw.attachTo;
|
|
2471
|
-
// Resolve: an existing path (relative to the app root) wins; else treat as an npm package name.
|
|
2472
|
-
let entrypoint = resolve(cwd, name);
|
|
2473
|
-
if (!existsSync(entrypoint)) {
|
|
2474
|
-
try {
|
|
2475
|
-
entrypoint = (Bun as unknown as { resolveSync(id: string, parent: string): string }).resolveSync(name, cwd);
|
|
2476
|
-
} catch {
|
|
2477
|
-
console.warn(`⚠ plugin "${name}" not found (no such path, not resolvable as an npm package) — skipping.`);
|
|
2478
|
-
continue;
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2481
|
-
const bundleId = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
2482
|
-
const bundlePath = join(outPlugins, `${bundleId}.js`);
|
|
2483
|
-
if (!bunBuild(entrypoint, bundlePath)) continue;
|
|
2484
|
-
stamped.push({ bundleId, attachTo, bundlePath });
|
|
2485
|
-
console.log(` plugin ← ${name} (attachTo: ${attachTo ?? 'def-declared'})`);
|
|
2486
|
-
}
|
|
2487
|
-
|
|
2488
|
-
if (stamped.length > 0) {
|
|
2489
|
-
shell.pluginHost = hostOut;
|
|
2490
|
-
shell.plugins = stamped;
|
|
2491
|
-
// TODO(A.x): read each plugin's `manifest` and merge perms/entitlements/native-deps via
|
|
2492
|
-
// generateModuleArtifacts + capabilities.manifest.ts (plugins are manifest-compatible with modules).
|
|
2493
|
-
}
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, pushSigned = false): void {
|
|
2497
|
-
if (!existsSync(DESKTOP_TEMPLATE_DIR)) {
|
|
2498
|
-
console.error(`✖ Desktop template not found at ${DESKTOP_TEMPLATE_DIR}`);
|
|
2499
|
-
process.exit(1);
|
|
2500
|
-
}
|
|
2501
|
-
const srcTauri = join(outDir, 'src-tauri');
|
|
2502
|
-
mkdirSync(outDir, { recursive: true });
|
|
2503
|
-
cpSync(DESKTOP_TEMPLATE_DIR, outDir, { recursive: true, force: true, filter: desktopCopyFilter });
|
|
2504
|
-
// `native-desktop/` is generated + disposable (regenerated from the template on every build) — mark
|
|
2505
|
-
// the whole tree git-invisible so a consuming repo never commits generated native code (incl. the
|
|
2506
|
-
// copied `crates/` + `src-tauri/`). A self-ignoring `*` .gitignore works regardless of the app's root
|
|
2507
|
-
// .gitignore; mirrors the mobile lane's disposable-`native/` convention. Written AFTER cpSync so it wins.
|
|
2508
|
-
writeFileSync(join(outDir, '.gitignore'), '*\n');
|
|
2509
|
-
|
|
2510
|
-
const shell = deriveDesktopConfig(cfg);
|
|
2511
|
-
shell.pushSigned = pushSigned;
|
|
2512
|
-
|
|
2513
|
-
// Stage the frontendDist INTO the wrapper (mirrors mobile copyPwa) so it's a stable relative path
|
|
2514
|
-
// independent of where the app project sits. Local-server apps (`desktop.server`) have no meaningful
|
|
2515
|
-
// web dist — the window loads the live server — so we stage a tiny bundled SPLASH instead (shown
|
|
2516
|
-
// while the server boots, then the shell navigates away from it).
|
|
2517
|
-
const stagedDist = join(outDir, 'dist');
|
|
2518
|
-
rmSync(stagedDist, { recursive: true, force: true });
|
|
2519
|
-
if (shell.server) {
|
|
2520
|
-
mkdirSync(stagedDist, { recursive: true });
|
|
2521
|
-
writeFileSync(join(stagedDist, 'index.html'), desktopSplashHtml(shell.name));
|
|
2522
|
-
} else {
|
|
2523
|
-
const dist = resolve(cwd, cfg.pwaDist);
|
|
2524
|
-
if (!existsSync(dist)) {
|
|
2525
|
-
console.error(`✖ Web dist not found at ${dist} — build the PWA first (or check pwaDist).`);
|
|
2526
|
-
process.exit(1);
|
|
2527
|
-
}
|
|
2528
|
-
cpSync(dist, stagedDist, { recursive: true, force: true });
|
|
2529
|
-
}
|
|
2530
|
-
|
|
2531
|
-
// Stage the app-owned browser sub-window chrome (‹ › ⟳ + address bar + profile▾) INTO the dist so
|
|
2532
|
-
// `WebviewUrl::App("appwrap-browser-chrome.html")` resolves it (browser_tab::browser_subwindow_open).
|
|
2533
|
-
const chromeSrc = join(DESKTOP_TEMPLATE_DIR, 'chrome', 'appwrap-browser-chrome.html');
|
|
2534
|
-
if (existsSync(chromeSrc)) {
|
|
2535
|
-
copyFileSync(chromeSrc, join(stagedDist, 'appwrap-browser-chrome.html'));
|
|
2536
|
-
} else {
|
|
2537
|
-
console.warn(`⚠ browser chrome not found at ${chromeSrc} — sub-window chrome will 404.`);
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
// Local-server mode: resolve `command` (bare name → PATH lookup) and `cwd` to ABSOLUTE, and stamp
|
|
2541
|
-
// the build-time PATH — a GUI-launched .app inherits the minimal launchd PATH (no ~/.bun/bin), so
|
|
2542
|
-
// a bare-name spawn (or a child that itself shells out to `bun`/`node`) would fail there.
|
|
2543
|
-
if (shell.server) {
|
|
2544
|
-
const s = shell.server;
|
|
2545
|
-
s.cwd = s.cwd ? resolve(cwd, s.cwd) : cwd;
|
|
2546
|
-
s.command = resolveServerCommand(s.command, cwd, process.env.PATH ?? '');
|
|
2547
|
-
s.path = process.env.PATH ?? '';
|
|
2548
|
-
if (!existsSync(s.command)) {
|
|
2549
|
-
console.warn(`⚠ desktop.server.command not found (${s.command}) — the server will fail to spawn at launch.`);
|
|
2550
|
-
}
|
|
2551
|
-
}
|
|
2552
|
-
// Resolve the handlers path to ABSOLUTE (against the app root): the shell binary runs from a
|
|
2553
|
-
// different cwd, and for `build desktop` the .app runs from wherever it's installed — an absolute
|
|
2554
|
-
// path is the only one that stays valid. The handler file is NOT copied into the wrapper/.app; the
|
|
2555
|
-
// app dir (with its node_modules) must remain present at runtime (documented in the config type).
|
|
2556
|
-
if (shell.handlers) {
|
|
2557
|
-
const abs = resolve(cwd, shell.handlers);
|
|
2558
|
-
if (!existsSync(abs)) {
|
|
2559
|
-
console.warn(`⚠ desktop.handlers points to a missing file (${abs}) — the sidecar will not spawn.`);
|
|
2560
|
-
}
|
|
2561
|
-
shell.handlers = abs;
|
|
2562
|
-
// Stamp the ABSOLUTE Bun binary the sidecar must be spawned with: the CLI itself runs under bun,
|
|
2563
|
-
// so process.execPath IS the exact runtime the app was built with. A GUI-launched .app inherits
|
|
2564
|
-
// the launchd PATH (no ~/.bun/bin), so the Rust shell can't rely on a bare `bun` lookup.
|
|
2565
|
-
shell.handlersRuntime = process.execPath;
|
|
2566
|
-
}
|
|
2567
|
-
// Resolve + bun-build the configured plugins (make `cfg.plugins` LIVE). Runs BEFORE writing
|
|
2568
|
-
// shell_config (and before applyOverrides, which happens after regenerateDesktop returns) so the
|
|
2569
|
-
// stamped plugin bundles + host are embedded and the .app is self-contained.
|
|
2570
|
-
regeneratePlugins(cwd, cfg, outDir, shell);
|
|
2571
|
-
writeFileSync(join(srcTauri, 'shell_config.json'), JSON.stringify(shell, null, 2) + '\n');
|
|
2572
|
-
|
|
2573
|
-
const tauriConfPath = join(srcTauri, 'tauri.conf.json');
|
|
2574
|
-
const template = JSON.parse(readFileSync(tauriConfPath, 'utf8')) as Record<string, unknown>;
|
|
2575
|
-
const stamped = stampTauriConf(template, shell, '../dist'); // relative to src-tauri/
|
|
2576
|
-
writeFileSync(tauriConfPath, JSON.stringify(stamped, null, 2) + '\n');
|
|
2577
|
-
|
|
2578
|
-
stampDesktopRuntimeIcon(cwd, cfg, join(srcTauri, 'icons', 'icon.png'));
|
|
2579
|
-
}
|
|
2580
|
-
|
|
2581
|
-
/** Stamp the Tauri RUNTIME icon (`src-tauri/icons/icon.png`, embedded via `generate_context!`) from
|
|
2582
|
-
* the app's real icon — else it OVERRIDES the bundle icns/Assets.car in cmd+tab/Dock with the template
|
|
2583
|
-
* placeholder square. Downscale to 512 via sips → round the macOS app-icon corners + ensure RGBA (our
|
|
2584
|
-
* dependency-free codec) → write. On any failure, keep the template placeholder (Tauri needs *some*
|
|
2585
|
-
* RGBA icon.png to compile) and warn — never fail the build over an icon. */
|
|
2586
|
-
function stampDesktopRuntimeIcon(cwd: string, cfg: AppwrapConfig, destPng: string): void {
|
|
2587
|
-
const source = findIconSource(cwd, cfg);
|
|
2588
|
-
if (!source) return; // no cfg.icon / manifest icon → keep template placeholder
|
|
2589
|
-
const tmpPng = join(tmpdir(), `appwrap-desktop-icon-${Date.now()}.png`);
|
|
2590
|
-
try {
|
|
2591
|
-
// Artwork at Apple's icon-grid fraction of the canvas, then pad — full-bleed runtime icons
|
|
2592
|
-
// render visibly larger than bundle-masked Dock neighbors (no OS-applied margin/mask).
|
|
2593
|
-
const art = Math.round(512 * APPLE_ICON_GRID_SCALE);
|
|
2594
|
-
execFileSync('sips', ['-s', 'format', 'png', '-z', String(art), String(art), source, '--out', tmpPng], { stdio: 'ignore' });
|
|
2595
|
-
writeFileSync(destPng, makeDockRuntimeIcon(readFileSync(tmpPng), 512));
|
|
2596
|
-
console.log(` desktop runtime icon ← ${source} (${art}² rounded on 512² canvas)`);
|
|
2597
|
-
} catch (e: unknown) {
|
|
2598
|
-
console.warn(`⚠ desktop runtime icon stamp failed (${e instanceof Error ? e.message : String(e)}) — keeping placeholder`);
|
|
2599
|
-
} finally {
|
|
2600
|
-
rmSync(tmpPng, { force: true });
|
|
2601
|
-
}
|
|
2602
|
-
}
|
|
2603
|
-
|
|
2604
|
-
/** Shared target dir for the desktop crate — reuse the template's warm `target/` so a stamped copy
|
|
2605
|
-
* builds incrementally (~seconds) instead of a ~4-min cold compile of the Tauri dep tree. */
|
|
2606
|
-
/** Resolve a spawnable `cargo` (bin + PATH-augmented env) for the desktop lane. rustup installs into
|
|
2607
|
-
* `~/.cargo/bin`, which a GUI/login shell often lacks on PATH → a bare spawn throws a raw ENOENT. We
|
|
2608
|
-
* append it when the binary lives there, else fail with an actionable install/source message. */
|
|
2609
|
-
function desktopCargo(): { bin: string; env: NodeJS.ProcessEnv } {
|
|
2610
|
-
const home = process.env.HOME ?? '';
|
|
2611
|
-
const res = resolveCargoPath({
|
|
2612
|
-
path: process.env.PATH ?? '',
|
|
2613
|
-
home,
|
|
2614
|
-
delimiter: pathDelimiter,
|
|
2615
|
-
hasCargo: (dir) => existsSync(join(dir, 'cargo')),
|
|
2616
|
-
});
|
|
2617
|
-
if (!res.found) {
|
|
2618
|
-
console.error(
|
|
2619
|
-
`✖ cargo not found on PATH${home ? ` (and no ${home}/.cargo/bin/cargo)` : ''}.\n` +
|
|
2620
|
-
` Install the Rust toolchain via rustup: https://rustup.rs\n` +
|
|
2621
|
-
` If it's already installed, add it to this shell: source "$HOME/.cargo/env"`
|
|
2622
|
-
);
|
|
2623
|
-
process.exit(1);
|
|
2624
|
-
}
|
|
2625
|
-
if (res.augmented) console.log(` cargo ← ${home}/.cargo/bin (appended to PATH; not in your shell's PATH)`);
|
|
2626
|
-
return {
|
|
2627
|
-
bin: res.bin,
|
|
2628
|
-
env: { ...process.env, PATH: res.path, CARGO_TARGET_DIR: join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target') },
|
|
2629
|
-
};
|
|
2630
|
-
}
|
|
2631
|
-
|
|
2632
|
-
/** `appwrap dev desktop` — stamp the wrapper + `cargo run` (launches the window; blocks until closed). */
|
|
2633
|
-
async function devDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
2634
|
-
ensureDesktopMacOS();
|
|
2635
|
-
const cfg = await loadConfig(cwd, flags);
|
|
2636
|
-
const outDir = resolve(cwd, flags.out ?? 'native-desktop');
|
|
2637
|
-
buildWebIfBundled(cwd, cfg, flags);
|
|
2638
|
-
regenerateDesktop(cwd, cfg, outDir);
|
|
2639
|
-
const { bin, env } = desktopCargo();
|
|
2640
|
-
console.log(`▶ cargo run (appwrap desktop shell — ${cfg.name})`);
|
|
2641
|
-
execFileSync(bin, ['run'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
|
|
2642
|
-
}
|
|
2643
|
-
|
|
2644
|
-
/** Generate `AppIcon.icns` from the app's icon PNG via `sips` + `iconutil` (macOS-only, like the whole
|
|
2645
|
-
* desktop lane). Returns the icns basename, or null when no icon source / tooling — skip gracefully. */
|
|
2646
|
-
function generateMacIcns(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
|
|
2647
|
-
const source = findIconSource(cwd, cfg);
|
|
2648
|
-
if (!source) return null;
|
|
2649
|
-
const iconset = join(tmpdir(), `appwrap-icns-${Date.now()}.iconset`);
|
|
2650
|
-
mkdirSync(iconset, { recursive: true });
|
|
2651
|
-
try {
|
|
2652
|
-
for (const px of [16, 32, 128, 256, 512]) {
|
|
2653
|
-
execFileSync('sips', ['-z', String(px), String(px), source, '--out', join(iconset, `icon_${px}x${px}.png`)], { stdio: 'ignore' });
|
|
2654
|
-
execFileSync('sips', ['-z', String(px * 2), String(px * 2), source, '--out', join(iconset, `icon_${px}x${px}@2x.png`)], { stdio: 'ignore' });
|
|
2655
|
-
}
|
|
2656
|
-
execFileSync('iconutil', ['-c', 'icns', iconset, '-o', join(resourcesDir, 'AppIcon.icns')], { stdio: 'ignore' });
|
|
2657
|
-
return 'AppIcon.icns';
|
|
2658
|
-
} catch (e: unknown) {
|
|
2659
|
-
console.warn(`⚠ icns generation failed (${e instanceof Error ? e.message : String(e)}) — .app ships without an icon`);
|
|
2660
|
-
return null;
|
|
2661
|
-
} finally {
|
|
2662
|
-
rmSync(iconset, { recursive: true, force: true });
|
|
2663
|
-
}
|
|
2664
|
-
}
|
|
2665
|
-
|
|
2666
|
-
/** Compile a Tahoe-native `Assets.car` (+ its own `AppIcon.icns`) from the app's icon via `actool`,
|
|
2667
|
-
* into `resourcesDir`. macOS 26 (Tahoe) resolves a bundle's Finder/Dock icon through
|
|
2668
|
-
* `CFBundleIconName` + `Assets.car`, NOT the legacy top-level `.icns`. Returns the asset icon name
|
|
2669
|
-
* (`AppIcon`) on success, or null when there's no icon / `xcrun`/`actool` is unavailable or fails —
|
|
2670
|
-
* caller then keeps the iconutil `.icns` path (fine pre-26; Tahoe Finder icon may look generic). */
|
|
2671
|
-
function compileMacAssetsCar(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
|
|
2672
|
-
const source = findIconSource(cwd, cfg);
|
|
2673
|
-
if (!source) return null;
|
|
2674
|
-
const work = join(tmpdir(), `appwrap-actool-${Date.now()}`);
|
|
2675
|
-
const xcassets = join(work, 'Assets.xcassets');
|
|
2676
|
-
const iconset = join(xcassets, 'AppIcon.appiconset');
|
|
2677
|
-
try {
|
|
2678
|
-
mkdirSync(iconset, { recursive: true });
|
|
2679
|
-
// 10 macOS reps: 16/32/128/256/512 pt at @1x and @2x.
|
|
2680
|
-
const reps = [16, 32, 128, 256, 512].flatMap((pt) => [
|
|
2681
|
-
{ pt, scale: 1, px: pt },
|
|
2682
|
-
{ pt, scale: 2, px: pt * 2 },
|
|
2683
|
-
]);
|
|
2684
|
-
const images = reps.map(({ pt, scale, px }) => {
|
|
2685
|
-
const filename = `icon_${pt}x${pt}@${scale}x.png`;
|
|
2686
|
-
execFileSync('sips', ['-s', 'format', 'png', '-z', String(px), String(px), source, '--out', join(iconset, filename)], { stdio: 'ignore' });
|
|
2687
|
-
return { idiom: 'mac', size: `${pt}x${pt}`, scale: `${scale}x`, filename };
|
|
2688
|
-
});
|
|
2689
|
-
writeFileSync(join(iconset, 'Contents.json'), JSON.stringify({ images, info: { version: 1, author: 'appwrap' } }, null, 2));
|
|
2690
|
-
writeFileSync(join(xcassets, 'Contents.json'), JSON.stringify({ info: { version: 1, author: 'appwrap' } }, null, 2));
|
|
2691
|
-
|
|
2692
|
-
const partialPlist = join(work, 'partial.plist');
|
|
2693
|
-
execFileSync('xcrun', ['actool', xcassets,
|
|
2694
|
-
'--compile', resourcesDir,
|
|
2695
|
-
'--platform', 'macosx',
|
|
2696
|
-
'--target-device', 'mac',
|
|
2697
|
-
'--minimum-deployment-target', '11.0',
|
|
2698
|
-
'--app-icon', 'AppIcon',
|
|
2699
|
-
'--output-partial-info-plist', partialPlist,
|
|
2700
|
-
], { stdio: 'ignore' });
|
|
2701
|
-
|
|
2702
|
-
if (!existsSync(join(resourcesDir, 'Assets.car'))) throw new Error('actool produced no Assets.car');
|
|
2703
|
-
return 'AppIcon';
|
|
2704
|
-
} catch (e: unknown) {
|
|
2705
|
-
console.warn(`⚠ actool Assets.car compile skipped (${e instanceof Error ? e.message : String(e)}) — Tahoe Finder icon may look generic; legacy .icns kept`);
|
|
2706
|
-
return null;
|
|
2707
|
-
} finally {
|
|
2708
|
-
rmSync(work, { recursive: true, force: true });
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
/** Resolved macOS signing lane. `identity` is the cert SHA-1 hash (fed to codesign — unambiguous,
|
|
2713
|
-
* unlike a CN which can collide); `cn` is kept for logging; `kind` records the cert class. The
|
|
2714
|
-
* aps-environment is NOT derived from `kind` — it comes from the provisioning profile itself. */
|
|
2715
|
-
interface MacSigning { identity: string; cn: string; kind: 'developer-id' | 'development'; teamId: string }
|
|
2716
|
-
|
|
2717
|
-
/** A matched macOS profile + its own aps-environment entitlement. */
|
|
2718
|
-
interface MacProfileMatch { path: string; apsEnvironment?: string }
|
|
2719
|
-
|
|
2720
|
-
/** Resolve a real code-signing identity for the desktop lane. Team precedence mirrors stampTeamId:
|
|
2721
|
-
* non-placeholder cfg.teamId → $APPWRAP_TEAM_ID → none (adhoc). Identity preference within the team:
|
|
2722
|
-
* "Developer ID Application" (direct distribution) → "Apple Development" (local/dev). The CN of a
|
|
2723
|
-
* dev cert does NOT carry the team id — match via the certificate's OU field. Returns null (→ adhoc
|
|
2724
|
-
* fallback) when no team is set or no identity of either class belongs to it. */
|
|
2725
|
-
function resolveMacSigning(cfg: AppwrapConfig): MacSigning | null {
|
|
2726
|
-
const teamId = (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId))
|
|
2727
|
-
? process.env.APPWRAP_TEAM_ID?.trim() || null
|
|
2728
|
-
: cfg.teamId;
|
|
2729
|
-
if (!teamId) return null;
|
|
2730
|
-
let listing = '';
|
|
2731
|
-
try {
|
|
2732
|
-
listing = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' });
|
|
2733
|
-
} catch { return null; }
|
|
2734
|
-
// find-identity -v lines: ` 1) <40-hex-SHA1> "<CN>"`. Capture BOTH — codesign is fed the hash.
|
|
2735
|
-
const entries = [...listing.matchAll(/\b([0-9A-F]{40})\s+"([^"]+)"/g)].map((m) => ({ hash: m[1], cn: m[2] }));
|
|
2736
|
-
const teamOf = (cn: string): string | null => {
|
|
2737
|
-
try {
|
|
2738
|
-
const pem = execFileSync('security', ['find-certificate', '-c', cn, '-p'], { encoding: 'utf8' });
|
|
2739
|
-
const subject = execFileSync('openssl', ['x509', '-noout', '-subject'], { input: pem, encoding: 'utf8' });
|
|
2740
|
-
return subject.match(/OU\s*=\s*([A-Z0-9]{10})/)?.[1] ?? null;
|
|
2741
|
-
} catch { return null; }
|
|
2742
|
-
};
|
|
2743
|
-
for (const prefix of ['Developer ID Application', 'Apple Development'] as const) {
|
|
2744
|
-
const match = entries.find((e) => e.cn.startsWith(prefix) && teamOf(e.cn) === teamId);
|
|
2745
|
-
if (match) return { identity: match.hash, cn: match.cn, kind: prefix === 'Developer ID Application' ? 'developer-id' : 'development', teamId };
|
|
2746
|
-
}
|
|
2747
|
-
return null;
|
|
2748
|
-
}
|
|
2749
|
-
|
|
2750
|
-
/** Find an installed, unexpired macOS provisioning profile for `<teamId>.<bundleId>`. macOS profiles
|
|
2751
|
-
* are `.provisionprofile` (same CMS envelope as iOS `.mobileprovision`; findProvisioningProfiles only
|
|
2752
|
-
* scans the latter, so this is the mac twin). Returns the file path, or null (→ sign without the
|
|
2753
|
-
* restricted push entitlement — a restricted entitlement with no embedded profile kills the app at
|
|
2754
|
-
* launch, which is strictly worse than a signed app without push). */
|
|
2755
|
-
function findMacProvisionProfile(teamId: string, bundleId: string, signingCertHash?: string): MacProfileMatch | null {
|
|
2756
|
-
const dirs = [
|
|
2757
|
-
join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles'),
|
|
2758
|
-
join(process.env.HOME ?? '', 'Library/Developer/Xcode/UserData/Provisioning Profiles'),
|
|
2759
|
-
];
|
|
2760
|
-
const wanted = signingCertHash?.toUpperCase();
|
|
2761
|
-
for (const dir of dirs) {
|
|
2762
|
-
if (!existsSync(dir)) continue;
|
|
2763
|
-
let files: string[] = [];
|
|
2764
|
-
try { files = readdirSync(dir).filter((f) => f.endsWith('.provisionprofile')); } catch { continue; }
|
|
2765
|
-
for (const f of files) {
|
|
2766
|
-
try {
|
|
2767
|
-
const raw = execFileSync('security', ['cms', '-D', '-i', join(dir, f)],
|
|
2768
|
-
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
2769
|
-
const info = parseMacProfile(raw);
|
|
2770
|
-
const { teamId: team, appId, expiration } = info;
|
|
2771
|
-
if (team !== teamId || !appId) continue;
|
|
2772
|
-
if (expiration && Date.parse(expiration) < Date.now()) continue;
|
|
2773
|
-
const profBundle = appId.startsWith(team + '.') ? appId.slice(team.length + 1) : appId;
|
|
2774
|
-
if (profBundle !== bundleId && profBundle !== '*') continue;
|
|
2775
|
-
// The chosen signing cert MUST be one the profile authorizes (DER SHA-1 == find-identity hash),
|
|
2776
|
-
// else codesign succeeds but the app launch is rejected. Mismatch → warn + keep scanning.
|
|
2777
|
-
if (wanted) {
|
|
2778
|
-
const authorized = info.certificates.some(
|
|
2779
|
-
(b64) => createHash('sha1').update(Buffer.from(b64, 'base64')).digest('hex').toUpperCase() === wanted,
|
|
2780
|
-
);
|
|
2781
|
-
if (!authorized) {
|
|
2782
|
-
console.log(` ⚠ profile ${f} does not authorize the chosen signing certificate — skipping`);
|
|
2783
|
-
continue;
|
|
2784
|
-
}
|
|
2785
|
-
}
|
|
2786
|
-
return { path: join(dir, f), apsEnvironment: info.apsEnvironment };
|
|
2787
|
-
} catch { /* skip unreadable profile */ }
|
|
2788
|
-
}
|
|
2789
|
-
}
|
|
2790
|
-
return null;
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
/** Assemble a double-clickable `<Name>.app` around the built shell binary. The Info.plist's
|
|
2794
|
-
* CFBundleURLTypes is what registers `urlScheme` with LaunchServices — a bare binary can't receive
|
|
2795
|
-
* `<scheme>://…` opens (tauri-plugin-deep-link needs the bundle). Signed with a real identity +
|
|
2796
|
-
* entitlements + embedded profile when teamId resolves (resolveMacSigning); ad-hoc otherwise. */
|
|
2797
|
-
function assembleMacApp(cwd: string, cfg: AppwrapConfig, shell: DesktopShellConfig, outDir: string, binary: string,
|
|
2798
|
-
lane?: { signing: MacSigning | null; profile: MacProfileMatch | null }): string {
|
|
2799
|
-
const appDir = join(outDir, 'dist-app', `${shell.name}.app`);
|
|
2800
|
-
const macosDir = join(appDir, 'Contents/MacOS');
|
|
2801
|
-
const resourcesDir = join(appDir, 'Contents/Resources');
|
|
2802
|
-
rmSync(appDir, { recursive: true, force: true });
|
|
2803
|
-
mkdirSync(macosDir, { recursive: true });
|
|
2804
|
-
mkdirSync(resourcesDir, { recursive: true });
|
|
2805
|
-
cpSync(binary, join(macosDir, shell.name));
|
|
2806
|
-
const iconFile = generateMacIcns(cwd, cfg, resourcesDir) ?? undefined;
|
|
2807
|
-
// Also compile a Tahoe-native Assets.car (macOS 26 reads CFBundleIconName/Assets.car, not the .icns).
|
|
2808
|
-
// actool emits its own AppIcon.icns alongside — let it replace the iconutil one when present.
|
|
2809
|
-
const iconName = compileMacAssetsCar(cwd, cfg, resourcesDir) ?? undefined;
|
|
2810
|
-
writeFileSync(join(appDir, 'Contents/Info.plist'), buildInfoPlist(shell, shell.name, iconFile, iconName));
|
|
2811
|
-
const signing = lane ? lane.signing : resolveMacSigning(cfg);
|
|
2812
|
-
if (signing) {
|
|
2813
|
-
// Signed lane: real identity; when an installed macOS profile matches, embed it and claim the
|
|
2814
|
-
// push entitlement (restricted → only launches profile-backed). Developer ID gets the hardened
|
|
2815
|
-
// runtime (notarization prerequisite; harmless otherwise).
|
|
2816
|
-
const profile = lane ? lane.profile : findMacProvisionProfile(signing.teamId, shell.identifier, signing.identity);
|
|
2817
|
-
if (profile) cpSync(profile.path, join(appDir, 'Contents/embedded.provisionprofile'));
|
|
2818
|
-
else console.log(` ⓘ no macOS provisioning profile installed for ${signing.teamId}.${shell.identifier} — signing without entitlements (cert only)`);
|
|
2819
|
-
// aps-environment comes from the PROFILE's own entitlement, not the cert kind — a Developer ID cert
|
|
2820
|
-
// paired with a development profile must claim `development`, or the app is killed at launch.
|
|
2821
|
-
const aps = profile?.apsEnvironment;
|
|
2822
|
-
if (profile && aps !== 'development' && aps !== 'production') {
|
|
2823
|
-
console.log(` ⓘ profile has no aps-environment entitlement — signing without push`);
|
|
2824
|
-
}
|
|
2825
|
-
const args = ['--force', '--deep', '-s', signing.identity];
|
|
2826
|
-
// Entitlements ONLY when a profile backs them: application-identifier/team-identifier are
|
|
2827
|
-
// RESTRICTED — claiming them without an embedded profile makes macOS refuse to launch the app
|
|
2828
|
-
// ("can't be opened"). No profile → plain cert signature (launches fine, no push).
|
|
2829
|
-
if (profile) {
|
|
2830
|
-
const entitlements = buildMacEntitlements({
|
|
2831
|
-
teamId: signing.teamId,
|
|
2832
|
-
bundleId: shell.identifier,
|
|
2833
|
-
apsEnvironment: aps === 'development' || aps === 'production' ? aps : undefined,
|
|
2834
|
-
});
|
|
2835
|
-
const entFile = join(outDir, 'dist-app', `${shell.name}.entitlements`);
|
|
2836
|
-
writeFileSync(entFile, entitlements);
|
|
2837
|
-
args.push('--entitlements', entFile);
|
|
2838
|
-
}
|
|
2839
|
-
if (signing.kind === 'developer-id') args.push('--options', 'runtime', '--timestamp');
|
|
2840
|
-
console.log(`▶ codesign (${signing.cn} [${signing.identity}]${profile ? ', profile embedded' : ''})`);
|
|
2841
|
-
execFileSync('codesign', [...args, appDir], { stdio: 'inherit' });
|
|
2842
|
-
} else {
|
|
2843
|
-
// Ad-hoc sign so Gatekeeper/LaunchServices treat the bundle as intact on this machine.
|
|
2844
|
-
// Only flag a skip when a REAL team was requested (placeholder teamId = signing never asked for).
|
|
2845
|
-
const requested = process.env.APPWRAP_TEAM_ID?.trim() || (cfg.teamId && !/YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId) ? cfg.teamId : '');
|
|
2846
|
-
if (requested) console.log(` ⓘ signed lane skipped — no Developer ID / Apple Development identity for team ${requested}; ad-hoc signing`);
|
|
2847
|
-
execFileSync('codesign', ['--force', '--deep', '-s', '-', appDir], { stdio: 'inherit' });
|
|
2848
|
-
}
|
|
2849
|
-
return appDir;
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
/** Opt-in notarization (env `APPWRAP_NOTARIZE=1`, off by default). Requires the Developer ID signed
|
|
2853
|
-
* lane — assembleMacApp already signs it with `--options runtime --timestamp` (hardened runtime +
|
|
2854
|
-
* secure timestamp, both notarization prerequisites; no get-task-allow is ever claimed). Credentials:
|
|
2855
|
-
* `APPWRAP_NOTARY_KEYCHAIN_PROFILE` (a `notarytool store-credentials` profile) or an ASC API key —
|
|
2856
|
-
* `APPWRAP_NOTARY_KEY_ID` + `APPWRAP_NOTARY_ISSUER` (+ optional `APPWRAP_NOTARY_KEY` p8 path,
|
|
2857
|
-
* default ~/.appstoreconnect/private_keys/AuthKey_<id>.p8). */
|
|
2858
|
-
function notarizeMacApp(appDir: string, signing: MacSigning | null): void {
|
|
2859
|
-
if (process.env.APPWRAP_NOTARIZE !== '1') return;
|
|
2860
|
-
if (!signing || signing.kind !== 'developer-id') {
|
|
2861
|
-
console.error(`✖ APPWRAP_NOTARIZE=1 needs a Developer ID signed .app (current lane: ${signing ? signing.kind : 'ad-hoc'}) — install a "Developer ID Application" identity for the team and rebuild`);
|
|
2862
|
-
process.exit(1);
|
|
2863
|
-
}
|
|
2864
|
-
const kcProfile = process.env.APPWRAP_NOTARY_KEYCHAIN_PROFILE?.trim();
|
|
2865
|
-
const keyId = process.env.APPWRAP_NOTARY_KEY_ID?.trim();
|
|
2866
|
-
const issuer = process.env.APPWRAP_NOTARY_ISSUER?.trim();
|
|
2867
|
-
const keyPath = process.env.APPWRAP_NOTARY_KEY?.trim()
|
|
2868
|
-
|| (keyId ? join(process.env.HOME ?? '', `.appstoreconnect/private_keys/AuthKey_${keyId}.p8`) : '');
|
|
2869
|
-
let auth: string[];
|
|
2870
|
-
if (kcProfile) auth = ['--keychain-profile', kcProfile];
|
|
2871
|
-
else if (keyId && issuer) {
|
|
2872
|
-
if (!existsSync(keyPath)) {
|
|
2873
|
-
console.error(`✖ notarization: ASC API key file not found at ${keyPath} (set APPWRAP_NOTARY_KEY to the .p8 path)`);
|
|
2874
|
-
process.exit(1);
|
|
2875
|
-
}
|
|
2876
|
-
auth = ['--key', keyPath, '--key-id', keyId, '--issuer', issuer];
|
|
2877
|
-
} else {
|
|
2878
|
-
console.error('✖ APPWRAP_NOTARIZE=1 but no credentials — set APPWRAP_NOTARY_KEY_ID + APPWRAP_NOTARY_ISSUER (ASC API key) or APPWRAP_NOTARY_KEYCHAIN_PROFILE (notarytool store-credentials)');
|
|
2879
|
-
process.exit(1);
|
|
2880
|
-
}
|
|
2881
|
-
const zip = `${appDir}.notarize.zip`;
|
|
2882
|
-
rmSync(zip, { force: true });
|
|
2883
|
-
execFileSync('ditto', ['-c', '-k', '--keepParent', appDir, zip], { stdio: 'inherit' });
|
|
2884
|
-
console.log('▶ notarytool submit --wait (Apple-side processing — can take several minutes)');
|
|
2885
|
-
try {
|
|
2886
|
-
execFileSync('xcrun', ['notarytool', 'submit', zip, ...auth, '--wait'], { stdio: 'inherit' });
|
|
2887
|
-
execFileSync('xcrun', ['stapler', 'staple', appDir], { stdio: 'inherit' });
|
|
2888
|
-
} finally {
|
|
2889
|
-
rmSync(zip, { force: true });
|
|
2890
|
-
}
|
|
2891
|
-
console.log(`✓ Notarized + stapled: ${appDir}`);
|
|
2892
|
-
}
|
|
2893
|
-
|
|
2894
|
-
/** `appwrap build desktop` — stamp the wrapper, `cargo build --release`, then assemble the `.app`. */
|
|
2895
|
-
async function buildDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
2896
|
-
ensureDesktopMacOS();
|
|
2897
|
-
const cfg = await loadConfig(cwd, flags);
|
|
2898
|
-
const outDir = resolve(cwd, flags.out ?? 'native-desktop');
|
|
2899
|
-
// Local-server apps load the live server, not a bundled web build — skip it (regenerateDesktop
|
|
2900
|
-
// stages a boot splash for them). Otherwise build/stage the PWA as usual.
|
|
2901
|
-
if (!cfg.desktop?.server) buildWebIfBundled(cwd, cfg, flags);
|
|
2902
|
-
// Resolve the signing lane BEFORE regeneration: shell_config.json is embedded at compile time,
|
|
2903
|
-
// and the Rust shell's push capability must only claim 'native' when the .app really ships the
|
|
2904
|
-
// profile-backed aps-environment entitlement (signed lane + installed macOS profile).
|
|
2905
|
-
const signing = resolveMacSigning(cfg);
|
|
2906
|
-
const shellCfg = deriveDesktopConfig(cfg);
|
|
2907
|
-
const profile = signing ? findMacProvisionProfile(signing.teamId, shellCfg.identifier, signing.identity) : null;
|
|
2908
|
-
// Only let the Rust shell claim NATIVE push when the app will actually ship a profile-backed
|
|
2909
|
-
// aps-environment entitlement (profile matched AND it carries the entitlement).
|
|
2910
|
-
const nativePush = !!(signing && profile && (profile.apsEnvironment === 'development' || profile.apsEnvironment === 'production'));
|
|
2911
|
-
regenerateDesktop(cwd, cfg, outDir, nativePush);
|
|
2912
|
-
const { bin, env } = desktopCargo();
|
|
2913
|
-
console.log(`▶ cargo build --release (appwrap desktop shell — ${cfg.name})`);
|
|
2914
|
-
execFileSync(bin, ['build', '--release'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
|
|
2915
|
-
const binary = join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target/release/appwrap-desktop-shell');
|
|
2916
|
-
console.log(`✓ Desktop binary: ${binary}`);
|
|
2917
|
-
const appDir = assembleMacApp(cwd, cfg, shellCfg, outDir, binary, { signing, profile });
|
|
2918
|
-
console.log(`✓ App bundle: ${appDir}`);
|
|
2919
|
-
notarizeMacApp(appDir, signing);
|
|
2920
|
-
}
|
|
2921
2658
|
|
|
2922
2659
|
async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
|
|
2923
2660
|
const platform = positionals[0];
|
|
2924
|
-
if (platform
|
|
2661
|
+
if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals, 'build');
|
|
2925
2662
|
if (platform !== 'ios' && platform !== 'android') {
|
|
2926
|
-
console.error('Usage: appwrap build <ios|android
|
|
2663
|
+
console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config <path>] [--out native]');
|
|
2927
2664
|
process.exit(1);
|
|
2928
2665
|
}
|
|
2929
2666
|
const outDir = resolve(cwd, flags.out ?? 'native');
|
|
@@ -3277,8 +3014,8 @@ function pinTeamIdToConfig(configPath: string, teamId: string): void {
|
|
|
3277
3014
|
* `app/www` (PWA staging) is build OUTPUT and `node_modules`/`platforms`/`hooks` are generated, so all four
|
|
3278
3015
|
* would make the hash unstable across runs. Matched RELATIVE to TEMPLATE_DIR: when installed from npm
|
|
3279
3016
|
* TEMPLATE_DIR itself lives under node_modules/, and testing the absolute path would exclude everything. */
|
|
3280
|
-
const
|
|
3281
|
-
!/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(
|
|
3017
|
+
const templateFingerprintFilterFor = (root: string) => (src: string): boolean =>
|
|
3018
|
+
!/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(root.length));
|
|
3282
3019
|
|
|
3283
3020
|
/** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config + the appwrap
|
|
3284
3021
|
* `runtime/` template tree + this CLI's version.
|
|
@@ -3317,12 +3054,16 @@ function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: strin
|
|
|
3317
3054
|
const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
|
|
3318
3055
|
const overridesDir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
|
|
3319
3056
|
const stats = [mtimeStats(distDir), mtimeStats(overridesDir), mtimeStats(resolveConfigPath(cwd, flags))];
|
|
3320
|
-
// The appwrap runtime template — bundled into bundle.js, so
|
|
3057
|
+
// The appwrap runtime template(s) — bundled into bundle.js, so a first-class build input. Every
|
|
3058
|
+
// overlay root counts, so editing a host-provided template file busts the cache.
|
|
3321
3059
|
let runtime = 0, runtimeNewest = 0;
|
|
3322
|
-
for (const
|
|
3323
|
-
|
|
3060
|
+
for (const root of TEMPLATE_ROOTS) {
|
|
3061
|
+
if (!existsSync(root)) continue;
|
|
3062
|
+
for (const rel of collectRelFiles(root, templateFingerprintFilterFor(root))) {
|
|
3063
|
+
const s = mtimeStats(join(root, rel));
|
|
3324
3064
|
runtime += s.sum;
|
|
3325
3065
|
if (s.newest > runtimeNewest) runtimeNewest = s.newest;
|
|
3066
|
+
}
|
|
3326
3067
|
}
|
|
3327
3068
|
stats.push({ sum: runtime, newest: runtimeNewest });
|
|
3328
3069
|
return { parts: stats.map((s) => s.sum), newest: Math.max(...stats.map((s) => s.newest)) };
|
|
@@ -3540,7 +3281,7 @@ function detectWebBuildCmd(cwd: string, cfg: AppwrapConfig): string[] | null {
|
|
|
3540
3281
|
* app loads the live serverUrl, so its dist is unused (building it would be wasteful + misleading).
|
|
3541
3282
|
* Prints exactly what it's doing either way. `--no-web-build` skips it (ship the current dist as-is,
|
|
3542
3283
|
* e.g. to hit the native build-skip fast-path when only the wrapper changed). */
|
|
3543
|
-
function buildWebIfBundled(cwd: string, cfg: AppwrapConfig, flags: Record<string, string>): void {
|
|
3284
|
+
export function buildWebIfBundled(cwd: string, cfg: AppwrapConfig, flags: Record<string, string>): void {
|
|
3544
3285
|
if ((cfg.loader ?? 'app') === 'server') {
|
|
3545
3286
|
console.log('ℹ loader:server — NOT building the web (the app loads serverUrl live; the bundle is unused).');
|
|
3546
3287
|
return;
|
|
@@ -4240,10 +3981,69 @@ async function publish(cwd: string, flags: Record<string, string>, positionals:
|
|
|
4240
3981
|
console.log(`✓ Uploaded to Play ${track} track.`);
|
|
4241
3982
|
}
|
|
4242
3983
|
|
|
4243
|
-
|
|
3984
|
+
/** Scaffold a new module pack from the built-in template — the community entry point for authoring an
|
|
3985
|
+
* extension. `appwrap create-module <name> [--dir <parent>]` writes <parent>/<name>/ with a manifest +
|
|
3986
|
+
* handler + native-src stub, substituting the module name (and its PascalCase form) into the tokens,
|
|
3987
|
+
* then validates the result so a fresh scaffold is guaranteed pack-conformant. */
|
|
3988
|
+
async function createModule(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
|
|
3989
|
+
const name = positionals[0];
|
|
3990
|
+
if (!name || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
|
|
3991
|
+
console.error('Usage: appwrap create-module <name> [--dir <parent>]\n <name> must be a camelCase capability id (letters/digits, leading letter), e.g. `confetti`.');
|
|
3992
|
+
process.exit(1);
|
|
3993
|
+
}
|
|
3994
|
+
if (!existsSync(MODULE_PACK_TEMPLATE_DIR)) {
|
|
3995
|
+
console.error(`✖ module-pack template not found at ${MODULE_PACK_TEMPLATE_DIR}`);
|
|
3996
|
+
process.exit(1);
|
|
3997
|
+
}
|
|
3998
|
+
const pascal = name.replace(/(^|[-_])(\w)/g, (_m, _s, c: string) => c.toUpperCase());
|
|
3999
|
+
const dest = resolve(cwd, flags.dir ?? '.', name);
|
|
4000
|
+
if (existsSync(dest)) {
|
|
4001
|
+
console.error(`✖ ${dest} already exists — choose a different name or --dir.`);
|
|
4002
|
+
process.exit(1);
|
|
4003
|
+
}
|
|
4004
|
+
// Copy the scaffold, then substitute the name tokens in every text file + rename the token'd native-src dir.
|
|
4005
|
+
cpSync(MODULE_PACK_TEMPLATE_DIR, dest, { recursive: true });
|
|
4006
|
+
const rename = (from: string, to: string) => { if (existsSync(from)) renameSync(from, to); };
|
|
4007
|
+
rename(join(dest, 'native-src/__MODULE_NAME__'), join(dest, 'native-src', name));
|
|
4008
|
+
const walk = (dir: string) => {
|
|
4009
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
4010
|
+
const p = join(dir, e.name);
|
|
4011
|
+
if (e.isDirectory()) walk(p);
|
|
4012
|
+
else if (/\.(ts|md|json|swift|kt|xml)$/.test(e.name)) {
|
|
4013
|
+
writeFileSync(p, readFileSync(p, 'utf8').replaceAll('__MODULE_NAME__', name).replaceAll('__MODULE_PASCAL__', pascal));
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
};
|
|
4017
|
+
walk(dest);
|
|
4018
|
+
const { validatePack } = await import('./testing');
|
|
4019
|
+
const res = await validatePack(dest);
|
|
4020
|
+
if (!res.ok) {
|
|
4021
|
+
console.error(`✖ scaffolded pack failed validation (this is a bug in the template):\n ${res.errors.join('\n ')}`);
|
|
4022
|
+
process.exit(1);
|
|
4023
|
+
}
|
|
4024
|
+
console.log(`✓ Created module pack '${name}' → ${dest}`);
|
|
4025
|
+
console.log(` Reference it from your appwrap.config.ts:`);
|
|
4026
|
+
console.log(` modulePacks: ['${flags.dir ? join(flags.dir, name) : `./${name}`}'],`);
|
|
4027
|
+
console.log(` modules: ['${name}', ...],`);
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
/**
|
|
4031
|
+
* The CLI entry point — the bare `appwrap` bin calls `runCli()` with no options (identical to the
|
|
4032
|
+
* historical `main`). A host calls `runCli({ commands, platforms, modulePacks,
|
|
4033
|
+
* templateRoots, desktopTemplateDir })` to compose extra capability on top of CE without forking it.
|
|
4034
|
+
*/
|
|
4035
|
+
export async function runCli(options: CliOptions = {}): Promise<void> {
|
|
4036
|
+
cliOptions = { ...options };
|
|
4037
|
+
if (options.desktopTemplateDir) DESKTOP_TEMPLATE_DIR = options.desktopTemplateDir;
|
|
4038
|
+
// Host template overlays land AFTER the built-in runtime template → they win (file-level last-wins).
|
|
4039
|
+
if (options.templateRoots?.length) TEMPLATE_ROOTS = [...TEMPLATE_ROOTS, ...options.templateRoots];
|
|
4040
|
+
|
|
4244
4041
|
const { command, flags, positionals } = parseArgs(process.argv.slice(2));
|
|
4245
4042
|
const cwd = process.cwd();
|
|
4246
4043
|
|
|
4044
|
+
// Host-provided commands are consulted first — they can add a new command or override a built-in.
|
|
4045
|
+
if (command && cliOptions.commands?.[command]) { await cliOptions.commands[command](cwd, flags, positionals); return; }
|
|
4046
|
+
|
|
4247
4047
|
switch (command) {
|
|
4248
4048
|
case 'init':
|
|
4249
4049
|
await init(cwd, flags);
|
|
@@ -4251,6 +4051,9 @@ async function main(): Promise<void> {
|
|
|
4251
4051
|
case 'sync':
|
|
4252
4052
|
await sync(cwd, flags);
|
|
4253
4053
|
break;
|
|
4054
|
+
case 'create-module':
|
|
4055
|
+
await createModule(cwd, flags, positionals);
|
|
4056
|
+
break;
|
|
4254
4057
|
case 'clean':
|
|
4255
4058
|
await clean(cwd, flags);
|
|
4256
4059
|
break;
|
|
@@ -4288,21 +4091,21 @@ async function main(): Promise<void> {
|
|
|
4288
4091
|
' Android wireless: --wifi flips a USB device to wireless adb (unplug + keep going); thereafter the\n' +
|
|
4289
4092
|
' device is auto-discovered via mDNS — plain `dev android` finds it with NO flag (iOS parity).\n' +
|
|
4290
4093
|
' --device <ip[:port]> `adb connect`s an already-paired one.\n\n' +
|
|
4291
|
-
' dev <ios|android
|
|
4094
|
+
' dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
|
|
4292
4095
|
' live-dev: ANDROID device → ns run livesync (true on-device HMR) + re-stage on save;\n' +
|
|
4293
4096
|
' iOS device → deploy + rebuild/reinstall on save. --sim = ns run/HMR on emulator;\n' +
|
|
4294
4097
|
' --url/--port = web HMR from a dev server inside the WebView; --detached = install & exit.\n' +
|
|
4295
|
-
' desktop → macOS Tauri shell (cargo run) speaking appwrap protocol v1 (phase-0, macOS-only).\n' +
|
|
4296
4098
|
' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
|
|
4297
4099
|
' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
|
|
4298
4100
|
' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
|
|
4299
4101
|
' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
|
|
4300
4102
|
' clean (ns clean: wipe generated platforms/hooks/node_modules, restore deps — fixes stale/corrupt Podfile after a repo move)\n' +
|
|
4103
|
+
' create-module <name> [--dir <parent>] (scaffold a new module pack — a swappable/extensible capability; see modulePacks)\n' +
|
|
4301
4104
|
' aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
|
|
4302
4105
|
process.exit(command ? 1 : 0);
|
|
4303
4106
|
}
|
|
4304
4107
|
}
|
|
4305
4108
|
|
|
4306
4109
|
if (import.meta.main) {
|
|
4307
|
-
|
|
4110
|
+
runCli();
|
|
4308
4111
|
}
|