@livx.cc/appwrap 0.55.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.
Files changed (42) hide show
  1. package/package.json +2 -3
  2. package/runtime/app/shell/capabilities.manifest.ts +3 -3
  3. package/runtime/tests/bridge-response-delivery.test.ts +10 -1
  4. package/runtime/tests/env-scheme.test.ts +6 -0
  5. package/scripts/stage-assets.mjs +1 -1
  6. package/src/cli.ts +40 -570
  7. package/src/config.ts +1 -1
  8. package/src/handlers.ts +1 -1
  9. package/src/packs.ts +1 -1
  10. package/runtime-desktop/autotest/index.html +0 -77
  11. package/runtime-desktop/bridge-shim/build.sh +0 -9
  12. package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
  13. package/runtime-desktop/bridge-shim/shim.ts +0 -132
  14. package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
  15. package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
  16. package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
  17. package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
  18. package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
  19. package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
  20. package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
  21. package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
  22. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
  23. package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
  24. package/runtime-desktop/src-tauri/Cargo.toml +0 -45
  25. package/runtime-desktop/src-tauri/build.rs +0 -3
  26. package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
  27. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  28. package/runtime-desktop/src-tauri/shell_config.json +0 -10
  29. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
  30. package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
  31. package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
  32. package/runtime-desktop/src-tauri/src/main.rs +0 -1228
  33. package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
  34. package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
  35. package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
  36. package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
  37. package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
  38. package/runtime-desktop/src-tauri/src/server.rs +0 -156
  39. package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
  40. package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
  41. package/src/desktop.ts +0 -300
  42. 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 { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
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, delimiter as pathDelimiter, dirname, extname, join, resolve } from 'path';
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
@@ -25,9 +25,9 @@ import type { AppwrapConfig } from './config';
25
25
  import { unknownConfigKeys } from './config';
26
26
  import { resolveModulePacks, type ResolvedModule, type SyncContext } from './packs';
27
27
  import { createHash } from 'crypto';
28
- import { buildInfoPlist, buildMacEntitlements, deriveDesktopConfig, parseMacProfile, resolveCargoPath, stampTauriConf } from './desktop';
29
- import type { DesktopShellConfig } from './desktop';
30
- import { APPLE_ICON_GRID_SCALE, makeDockRuntimeIcon } from './icon';
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';
31
31
  import {
32
32
  androidScreenOrientation,
33
33
  applyBuildNumberFlag,
@@ -98,27 +98,38 @@ const ANDROID_PERMISSION_KEYS: Record<string, string[]> = {
98
98
  };
99
99
 
100
100
  /** Resolve a bundled asset dir. A published tarball ships runtime/ + templates/ at the package root
101
- * (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. */
102
104
  function resolveAssetRoot(rel: string): string {
103
105
  const local = resolve(import.meta.dir, '..', rel);
104
- return existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
106
+ const dir = existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
107
+ try { return realpathSync(dir); } catch { return dir; }
105
108
  }
106
109
  const TEMPLATE_DIR = resolveAssetRoot('runtime');
107
110
  /** Ordered runtime-template roots copied into native/ (later roots overlay earlier — file-level
108
111
  * last-wins), defaulting to the single built-in template. runCli (A5) can extend this so a consumer
109
- * like appwrap-ee layers extra shell files on top of CE without forking the template. A single root
112
+ * (a host) layers extra shell files on top of CE without forking the template. A single root
110
113
  * is byte-identical to the pre-overlay behavior. */
111
114
  let TEMPLATE_ROOTS: string[] = [TEMPLATE_DIR];
112
115
  const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
113
116
  /** Scaffold for `appwrap create-module` — a starter module pack (see packs.ts / @livx.cc/appwrap/testing). */
114
117
  const MODULE_PACK_TEMPLATE_DIR = resolveAssetRoot('templates/module-pack');
115
- /** Desktop (Tauri chassis) template — phase-0, macOS-only. Its own generated wrapper (`native-desktop/`)
116
- * is disposable, like `native/`; the template stays canonical (edit here, regenerate propagates).
117
- * A `let` so runCli (A5) can point it at a host-provided template (e.g. appwrap-ee owns the desktop lane). */
118
- let DESKTOP_TEMPLATE_DIR = resolveAssetRoot('runtime-desktop');
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
+ }
119
130
 
120
131
  /**
121
- * Composition seam for a HOST that wraps this CLI (appwrap-ee, or any consumer). Every field is
132
+ * Composition seam for a HOST that wraps this CLI (any consumer). Every field is
122
133
  * optional and no-op by default, so the bare `appwrap` bin behaves exactly as before. A host calls
123
134
  * `runCli({...})` to extend the CLI without forking it.
124
135
  */
@@ -127,8 +138,10 @@ export interface CliOptions {
127
138
  * switch, so a host can add a command or replace a built-in one. */
128
139
  commands?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
129
140
  /** Pluggable platform handlers for `dev`/`build` (keyed by the platform arg, e.g. 'desktop') —
130
- * consulted BEFORE the built-in platform routing, so a host can add/replace a platform lane. */
131
- platforms?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
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>;
132
145
  /** Module packs injected by the host, applied BEFORE the app config's own `modulePacks` (so the app
133
146
  * can still override them, last-wins). */
134
147
  modulePacks?: string[];
@@ -175,7 +188,7 @@ function packInfo(name: string): { source: string; packDir: string } | undefined
175
188
  * built-ins untouched → byte-identical) when the config declares no packs. Async because pack manifests
176
189
  * are dynamically imported; callers await it before the sync/regenerate pipeline. */
177
190
  async function applyModulePacks(cwd: string, cfg: AppwrapConfig): Promise<void> {
178
- // Host-injected packs first (appwrap-ee's), then the app config's — so the app can override, last-wins.
191
+ // Host-injected packs first (the host's), then the app config's — so the app can override, last-wins.
179
192
  const refs = [...(cliOptions.modulePacks ?? []), ...(cfg.modulePacks ?? [])];
180
193
  if (refs.length === 0) {
181
194
  RESOLVED_MODULES = null;
@@ -324,7 +337,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
324
337
  tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
325
338
  appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
326
339
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
327
- // billing/health/widget moved to appwrap-ee packs — their handlers stage from packages/ee/packs/*.
340
+ // billing/health/widget live in host-provided module packs (not part of CE) — a consumer opts in via modulePacks.
328
341
  };
329
342
 
330
343
  /** The bare specifier a pack handler uses to import a built-in shell API — rewritten to a relative
@@ -783,7 +796,7 @@ function resolveConfigPath(cwd: string, flags: Record<string, string>): string {
783
796
  : (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
784
797
  }
785
798
 
786
- async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
799
+ export async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
787
800
  const configPath = resolveConfigPath(cwd, flags);
788
801
  if (!existsSync(configPath)) {
789
802
  console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
@@ -1620,7 +1633,7 @@ function stampAndroidVersion(outDir: string, cfg: AppwrapConfig): void {
1620
1633
  }
1621
1634
 
1622
1635
  /** Locate the icon source: explicit cfg.icon, else the largest icon in the PWA manifest. */
1623
- function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
1636
+ export function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
1624
1637
  if (cfg.icon) {
1625
1638
  const p = resolve(cwd, cfg.icon);
1626
1639
  if (existsSync(p)) return p;
@@ -2265,12 +2278,11 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
2265
2278
  */
2266
2279
  async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2267
2280
  const platform = positionals[0];
2268
- if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals);
2269
- if (platform === 'desktop') return devDesktop(cwd, flags);
2281
+ if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals, 'dev');
2270
2282
  const sim = 'sim' in flags || positionals[1] === 'sim';
2271
2283
  const wantDebug = 'debug' in flags;
2272
2284
  if (platform !== 'ios' && platform !== 'android') {
2273
- console.error('Usage: appwrap dev <ios|android|desktop> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
2285
+ console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
2274
2286
  process.exit(1);
2275
2287
  }
2276
2288
  const cfg = await loadConfig(cwd, flags);
@@ -2643,553 +2655,12 @@ async function watchAndSync(cwd: string, flags: Record<string, string>, outDir:
2643
2655
  * Android signing comes from env (APPWRAP_ANDROID_KEYSTORE[_PASSWORD|_ALIAS|_ALIAS_PASSWORD]) — secrets
2644
2656
  * never live in the appwrap config. iOS distribution signing/upload is the fastlane release lane's job (the
2645
2657
  * cicd templates); `--release` here just builds the Release config for the device. */
2646
- // ─────────────────────────────── desktop (Tauri chassis) lane ────────────────────────────────
2647
- // Phase-0, macOS-only. Mirrors the managed-CNG model of the mobile lanes but far leaner: copy the
2648
- // `runtime-desktop/` template into the app's disposable `native-desktop/`, stamp the app's identity +
2649
- // window shape (shell_config.json, embedded into the Rust binary) and Tauri config (tauri.conf.json),
2650
- // stage the built web dist, then `cargo run`/`build`. The template is canonical; `native-desktop/` is
2651
- // gitignored + regenerated each run (framework edits propagate — same contract as `native/`).
2652
-
2653
- /** Fail clearly on non-macOS — the desktop chassis is macOS-only for phase-0 (osascript dialogs/
2654
- * notifications, `open`, the app-data path are all macOS). */
2655
- function ensureDesktopMacOS(): void {
2656
- if (process.platform !== 'darwin') {
2657
- console.error(`✖ appwrap desktop is macOS-only for now (phase-0). Detected platform: ${process.platform}.`);
2658
- process.exit(1);
2659
- }
2660
- }
2661
-
2662
- /** Skip the heavy/generated dirs when copying the desktop template (Rust build output + Tauri codegen +
2663
- * deps): they regenerate, and `target/` alone is GBs. Matches RELATIVE to the template root. */
2664
- function desktopCopyFilter(src: string): boolean {
2665
- const rel = src.slice(DESKTOP_TEMPLATE_DIR.length);
2666
- return !/(^|\/)(target|gen|node_modules)(\/|$)/.test(rel);
2667
- }
2668
-
2669
- /** Resolve a `desktop.server.command` to an absolute path: an absolute/relative path is resolved
2670
- * against the app root; a bare name is looked up on `path` (build-time PATH). Falls back to the bare
2671
- * name (the Rust shell will surface the spawn ENOENT) when it's nowhere. */
2672
- function resolveServerCommand(command: string, cwd: string, path: string): string {
2673
- if (command.includes('/')) return resolve(cwd, command);
2674
- for (const dir of path.split(pathDelimiter).filter(Boolean)) {
2675
- const abs = join(dir, command);
2676
- if (existsSync(abs)) return abs;
2677
- }
2678
- return command;
2679
- }
2680
-
2681
- /** The bundled splash shown while a local-server app boots (before the shell navigates to the live
2682
- * server). A dependency-free, theme-neutral holding page. */
2683
- function desktopSplashHtml(name: string): string {
2684
- const safe = name.replace(/</g, '&lt;').replace(/&/g, '&amp;');
2685
- return `<!doctype html><html><head><meta charset="utf-8"><title>${safe}</title>
2686
- <style>html,body{height:100%;margin:0}body{display:flex;align-items:center;justify-content:center;
2687
- flex-direction:column;gap:20px;background:#0b0b0f;color:#e5e5e5;font:14px -apple-system,system-ui,sans-serif}
2688
- .s{width:34px;height:34px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:r .8s linear infinite}
2689
- @keyframes r{to{transform:rotate(360deg)}}#e{color:#f87171;max-width:70%;text-align:center;line-height:1.5}</style>
2690
- </head><body><div class="s" id="spin"></div><div id="m">Starting ${safe}…</div><div id="e"></div></body></html>`;
2691
- }
2692
-
2693
- /** Copy the desktop template into `native-desktop/` and stamp it from the config: the Rust-read
2694
- * `shell_config.json` (identity + window), `tauri.conf.json` (productName/version/identifier +
2695
- * frontendDist), and the staged web dist (`native-desktop/dist`, referenced as `../dist`). */
2696
- /**
2697
- * Make `cfg.plugins` LIVE (A.1 loader): resolve each entry (path OR npm package), bun-build its
2698
- * entrypoint to a single embedded file under `native-desktop/plugins/`, bun-build the multiplexed
2699
- * plugin-host once, and stamp `{ pluginHost, plugins:[{bundleId, attachTo, bundlePath}] }` into the
2700
- * shell config. The Rust shell (plugin_host.rs) spawns the host with the stamped plugins at boot.
2701
- *
2702
- * A.1 scope: manifest/perms/native-deps merge is STUBBED (noted) — full `capabilities.manifest.ts`
2703
- * reuse (perms/entitlements union) lands later. `bundleId` here is the file/package basename (a
2704
- * pre-load build id for the bundle filename only); the authoritative routing/diagnostic identifier is
2705
- * the plugin def's `name`, which the host reads from the bundle after `import()`.
2706
- */
2707
- export function regeneratePlugins(cwd: string, cfg: AppwrapConfig, outDir: string, shell: DesktopShellConfig): void {
2708
- const entries = cfg.plugins ?? [];
2709
- if (entries.length === 0) return;
2710
- const outPlugins = join(outDir, 'plugins');
2711
- mkdirSync(outPlugins, { recursive: true });
2712
- const bun = process.execPath; // the CLI runs under bun → the exact runtime to build/spawn with
2713
-
2714
- const bunBuild = (entrypoint: string, outfile: string): boolean => {
2715
- try {
2716
- execFileSync(bun, ['build', entrypoint, '--target=bun', '--outfile', outfile], { stdio: 'pipe' });
2717
- return true;
2718
- } catch (e: unknown) {
2719
- console.warn(`⚠ plugin bun-build failed (${entrypoint}): ${e instanceof Error ? e.message : String(e)}`);
2720
- return false;
2721
- }
2722
- };
2723
-
2724
- // Build the host bundle once (from THIS package's src/plugin/host.ts).
2725
- const hostSrc = resolve(import.meta.dir, 'plugin', 'host.ts');
2726
- const hostOut = join(outPlugins, 'host.js');
2727
- if (!bunBuild(hostSrc, hostOut)) {
2728
- console.warn('⚠ plugin host failed to build — plugins disabled for this build.');
2729
- return;
2730
- }
2731
-
2732
- const stamped: DesktopShellConfig['plugins'] = [];
2733
- for (const raw of entries) {
2734
- const name = typeof raw === 'string' ? raw : raw.name;
2735
- const attachTo = typeof raw === 'string' ? undefined : raw.attachTo;
2736
- // Resolve: an existing path (relative to the app root) wins; else treat as an npm package name.
2737
- let entrypoint = resolve(cwd, name);
2738
- if (!existsSync(entrypoint)) {
2739
- try {
2740
- entrypoint = (Bun as unknown as { resolveSync(id: string, parent: string): string }).resolveSync(name, cwd);
2741
- } catch {
2742
- console.warn(`⚠ plugin "${name}" not found (no such path, not resolvable as an npm package) — skipping.`);
2743
- continue;
2744
- }
2745
- }
2746
- const bundleId = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2747
- const bundlePath = join(outPlugins, `${bundleId}.js`);
2748
- if (!bunBuild(entrypoint, bundlePath)) continue;
2749
- stamped.push({ bundleId, attachTo, bundlePath });
2750
- console.log(` plugin ← ${name} (attachTo: ${attachTo ?? 'def-declared'})`);
2751
- }
2752
-
2753
- if (stamped.length > 0) {
2754
- shell.pluginHost = hostOut;
2755
- shell.plugins = stamped;
2756
- // TODO(A.x): read each plugin's `manifest` and merge perms/entitlements/native-deps via
2757
- // generateModuleArtifacts + capabilities.manifest.ts (plugins are manifest-compatible with modules).
2758
- }
2759
- }
2760
-
2761
- function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, pushSigned = false): void {
2762
- if (!existsSync(DESKTOP_TEMPLATE_DIR)) {
2763
- console.error(`✖ Desktop template not found at ${DESKTOP_TEMPLATE_DIR}`);
2764
- process.exit(1);
2765
- }
2766
- const srcTauri = join(outDir, 'src-tauri');
2767
- mkdirSync(outDir, { recursive: true });
2768
- cpSync(DESKTOP_TEMPLATE_DIR, outDir, { recursive: true, force: true, filter: desktopCopyFilter });
2769
- // `native-desktop/` is generated + disposable (regenerated from the template on every build) — mark
2770
- // the whole tree git-invisible so a consuming repo never commits generated native code (incl. the
2771
- // copied `crates/` + `src-tauri/`). A self-ignoring `*` .gitignore works regardless of the app's root
2772
- // .gitignore; mirrors the mobile lane's disposable-`native/` convention. Written AFTER cpSync so it wins.
2773
- writeFileSync(join(outDir, '.gitignore'), '*\n');
2774
-
2775
- const shell = deriveDesktopConfig(cfg);
2776
- shell.pushSigned = pushSigned;
2777
-
2778
- // Stage the frontendDist INTO the wrapper (mirrors mobile copyPwa) so it's a stable relative path
2779
- // independent of where the app project sits. Local-server apps (`desktop.server`) have no meaningful
2780
- // web dist — the window loads the live server — so we stage a tiny bundled SPLASH instead (shown
2781
- // while the server boots, then the shell navigates away from it).
2782
- const stagedDist = join(outDir, 'dist');
2783
- rmSync(stagedDist, { recursive: true, force: true });
2784
- if (shell.server) {
2785
- mkdirSync(stagedDist, { recursive: true });
2786
- writeFileSync(join(stagedDist, 'index.html'), desktopSplashHtml(shell.name));
2787
- } else {
2788
- const dist = resolve(cwd, cfg.pwaDist);
2789
- if (!existsSync(dist)) {
2790
- console.error(`✖ Web dist not found at ${dist} — build the PWA first (or check pwaDist).`);
2791
- process.exit(1);
2792
- }
2793
- cpSync(dist, stagedDist, { recursive: true, force: true });
2794
- }
2795
-
2796
- // Stage the app-owned browser sub-window chrome (‹ › ⟳ + address bar + profile▾) INTO the dist so
2797
- // `WebviewUrl::App("appwrap-browser-chrome.html")` resolves it (browser_tab::browser_subwindow_open).
2798
- const chromeSrc = join(DESKTOP_TEMPLATE_DIR, 'chrome', 'appwrap-browser-chrome.html');
2799
- if (existsSync(chromeSrc)) {
2800
- copyFileSync(chromeSrc, join(stagedDist, 'appwrap-browser-chrome.html'));
2801
- } else {
2802
- console.warn(`⚠ browser chrome not found at ${chromeSrc} — sub-window chrome will 404.`);
2803
- }
2804
-
2805
- // Local-server mode: resolve `command` (bare name → PATH lookup) and `cwd` to ABSOLUTE, and stamp
2806
- // the build-time PATH — a GUI-launched .app inherits the minimal launchd PATH (no ~/.bun/bin), so
2807
- // a bare-name spawn (or a child that itself shells out to `bun`/`node`) would fail there.
2808
- if (shell.server) {
2809
- const s = shell.server;
2810
- s.cwd = s.cwd ? resolve(cwd, s.cwd) : cwd;
2811
- s.command = resolveServerCommand(s.command, cwd, process.env.PATH ?? '');
2812
- s.path = process.env.PATH ?? '';
2813
- if (!existsSync(s.command)) {
2814
- console.warn(`⚠ desktop.server.command not found (${s.command}) — the server will fail to spawn at launch.`);
2815
- }
2816
- }
2817
- // Resolve the handlers path to ABSOLUTE (against the app root): the shell binary runs from a
2818
- // different cwd, and for `build desktop` the .app runs from wherever it's installed — an absolute
2819
- // path is the only one that stays valid. The handler file is NOT copied into the wrapper/.app; the
2820
- // app dir (with its node_modules) must remain present at runtime (documented in the config type).
2821
- if (shell.handlers) {
2822
- const abs = resolve(cwd, shell.handlers);
2823
- if (!existsSync(abs)) {
2824
- console.warn(`⚠ desktop.handlers points to a missing file (${abs}) — the sidecar will not spawn.`);
2825
- }
2826
- shell.handlers = abs;
2827
- // Stamp the ABSOLUTE Bun binary the sidecar must be spawned with: the CLI itself runs under bun,
2828
- // so process.execPath IS the exact runtime the app was built with. A GUI-launched .app inherits
2829
- // the launchd PATH (no ~/.bun/bin), so the Rust shell can't rely on a bare `bun` lookup.
2830
- shell.handlersRuntime = process.execPath;
2831
- }
2832
- // Resolve + bun-build the configured plugins (make `cfg.plugins` LIVE). Runs BEFORE writing
2833
- // shell_config (and before applyOverrides, which happens after regenerateDesktop returns) so the
2834
- // stamped plugin bundles + host are embedded and the .app is self-contained.
2835
- regeneratePlugins(cwd, cfg, outDir, shell);
2836
- writeFileSync(join(srcTauri, 'shell_config.json'), JSON.stringify(shell, null, 2) + '\n');
2837
-
2838
- const tauriConfPath = join(srcTauri, 'tauri.conf.json');
2839
- const template = JSON.parse(readFileSync(tauriConfPath, 'utf8')) as Record<string, unknown>;
2840
- const stamped = stampTauriConf(template, shell, '../dist'); // relative to src-tauri/
2841
- writeFileSync(tauriConfPath, JSON.stringify(stamped, null, 2) + '\n');
2842
-
2843
- stampDesktopRuntimeIcon(cwd, cfg, join(srcTauri, 'icons', 'icon.png'));
2844
- }
2845
-
2846
- /** Stamp the Tauri RUNTIME icon (`src-tauri/icons/icon.png`, embedded via `generate_context!`) from
2847
- * the app's real icon — else it OVERRIDES the bundle icns/Assets.car in cmd+tab/Dock with the template
2848
- * placeholder square. Downscale to 512 via sips → round the macOS app-icon corners + ensure RGBA (our
2849
- * dependency-free codec) → write. On any failure, keep the template placeholder (Tauri needs *some*
2850
- * RGBA icon.png to compile) and warn — never fail the build over an icon. */
2851
- function stampDesktopRuntimeIcon(cwd: string, cfg: AppwrapConfig, destPng: string): void {
2852
- const source = findIconSource(cwd, cfg);
2853
- if (!source) return; // no cfg.icon / manifest icon → keep template placeholder
2854
- const tmpPng = join(tmpdir(), `appwrap-desktop-icon-${Date.now()}.png`);
2855
- try {
2856
- // Artwork at Apple's icon-grid fraction of the canvas, then pad — full-bleed runtime icons
2857
- // render visibly larger than bundle-masked Dock neighbors (no OS-applied margin/mask).
2858
- const art = Math.round(512 * APPLE_ICON_GRID_SCALE);
2859
- execFileSync('sips', ['-s', 'format', 'png', '-z', String(art), String(art), source, '--out', tmpPng], { stdio: 'ignore' });
2860
- writeFileSync(destPng, makeDockRuntimeIcon(readFileSync(tmpPng), 512));
2861
- console.log(` desktop runtime icon ← ${source} (${art}² rounded on 512² canvas)`);
2862
- } catch (e: unknown) {
2863
- console.warn(`⚠ desktop runtime icon stamp failed (${e instanceof Error ? e.message : String(e)}) — keeping placeholder`);
2864
- } finally {
2865
- rmSync(tmpPng, { force: true });
2866
- }
2867
- }
2868
-
2869
- /** Shared target dir for the desktop crate — reuse the template's warm `target/` so a stamped copy
2870
- * builds incrementally (~seconds) instead of a ~4-min cold compile of the Tauri dep tree. */
2871
- /** Resolve a spawnable `cargo` (bin + PATH-augmented env) for the desktop lane. rustup installs into
2872
- * `~/.cargo/bin`, which a GUI/login shell often lacks on PATH → a bare spawn throws a raw ENOENT. We
2873
- * append it when the binary lives there, else fail with an actionable install/source message. */
2874
- function desktopCargo(): { bin: string; env: NodeJS.ProcessEnv } {
2875
- const home = process.env.HOME ?? '';
2876
- const res = resolveCargoPath({
2877
- path: process.env.PATH ?? '',
2878
- home,
2879
- delimiter: pathDelimiter,
2880
- hasCargo: (dir) => existsSync(join(dir, 'cargo')),
2881
- });
2882
- if (!res.found) {
2883
- console.error(
2884
- `✖ cargo not found on PATH${home ? ` (and no ${home}/.cargo/bin/cargo)` : ''}.\n` +
2885
- ` Install the Rust toolchain via rustup: https://rustup.rs\n` +
2886
- ` If it's already installed, add it to this shell: source "$HOME/.cargo/env"`
2887
- );
2888
- process.exit(1);
2889
- }
2890
- if (res.augmented) console.log(` cargo ← ${home}/.cargo/bin (appended to PATH; not in your shell's PATH)`);
2891
- return {
2892
- bin: res.bin,
2893
- env: { ...process.env, PATH: res.path, CARGO_TARGET_DIR: join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target') },
2894
- };
2895
- }
2896
-
2897
- /** `appwrap dev desktop` — stamp the wrapper + `cargo run` (launches the window; blocks until closed). */
2898
- async function devDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
2899
- ensureDesktopMacOS();
2900
- const cfg = await loadConfig(cwd, flags);
2901
- const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2902
- buildWebIfBundled(cwd, cfg, flags);
2903
- regenerateDesktop(cwd, cfg, outDir);
2904
- const { bin, env } = desktopCargo();
2905
- console.log(`▶ cargo run (appwrap desktop shell — ${cfg.name})`);
2906
- execFileSync(bin, ['run'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
2907
- }
2908
-
2909
- /** Generate `AppIcon.icns` from the app's icon PNG via `sips` + `iconutil` (macOS-only, like the whole
2910
- * desktop lane). Returns the icns basename, or null when no icon source / tooling — skip gracefully. */
2911
- function generateMacIcns(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
2912
- const source = findIconSource(cwd, cfg);
2913
- if (!source) return null;
2914
- const iconset = join(tmpdir(), `appwrap-icns-${Date.now()}.iconset`);
2915
- mkdirSync(iconset, { recursive: true });
2916
- try {
2917
- for (const px of [16, 32, 128, 256, 512]) {
2918
- execFileSync('sips', ['-z', String(px), String(px), source, '--out', join(iconset, `icon_${px}x${px}.png`)], { stdio: 'ignore' });
2919
- execFileSync('sips', ['-z', String(px * 2), String(px * 2), source, '--out', join(iconset, `icon_${px}x${px}@2x.png`)], { stdio: 'ignore' });
2920
- }
2921
- execFileSync('iconutil', ['-c', 'icns', iconset, '-o', join(resourcesDir, 'AppIcon.icns')], { stdio: 'ignore' });
2922
- return 'AppIcon.icns';
2923
- } catch (e: unknown) {
2924
- console.warn(`⚠ icns generation failed (${e instanceof Error ? e.message : String(e)}) — .app ships without an icon`);
2925
- return null;
2926
- } finally {
2927
- rmSync(iconset, { recursive: true, force: true });
2928
- }
2929
- }
2930
-
2931
- /** Compile a Tahoe-native `Assets.car` (+ its own `AppIcon.icns`) from the app's icon via `actool`,
2932
- * into `resourcesDir`. macOS 26 (Tahoe) resolves a bundle's Finder/Dock icon through
2933
- * `CFBundleIconName` + `Assets.car`, NOT the legacy top-level `.icns`. Returns the asset icon name
2934
- * (`AppIcon`) on success, or null when there's no icon / `xcrun`/`actool` is unavailable or fails —
2935
- * caller then keeps the iconutil `.icns` path (fine pre-26; Tahoe Finder icon may look generic). */
2936
- function compileMacAssetsCar(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
2937
- const source = findIconSource(cwd, cfg);
2938
- if (!source) return null;
2939
- const work = join(tmpdir(), `appwrap-actool-${Date.now()}`);
2940
- const xcassets = join(work, 'Assets.xcassets');
2941
- const iconset = join(xcassets, 'AppIcon.appiconset');
2942
- try {
2943
- mkdirSync(iconset, { recursive: true });
2944
- // 10 macOS reps: 16/32/128/256/512 pt at @1x and @2x.
2945
- const reps = [16, 32, 128, 256, 512].flatMap((pt) => [
2946
- { pt, scale: 1, px: pt },
2947
- { pt, scale: 2, px: pt * 2 },
2948
- ]);
2949
- const images = reps.map(({ pt, scale, px }) => {
2950
- const filename = `icon_${pt}x${pt}@${scale}x.png`;
2951
- execFileSync('sips', ['-s', 'format', 'png', '-z', String(px), String(px), source, '--out', join(iconset, filename)], { stdio: 'ignore' });
2952
- return { idiom: 'mac', size: `${pt}x${pt}`, scale: `${scale}x`, filename };
2953
- });
2954
- writeFileSync(join(iconset, 'Contents.json'), JSON.stringify({ images, info: { version: 1, author: 'appwrap' } }, null, 2));
2955
- writeFileSync(join(xcassets, 'Contents.json'), JSON.stringify({ info: { version: 1, author: 'appwrap' } }, null, 2));
2956
-
2957
- const partialPlist = join(work, 'partial.plist');
2958
- execFileSync('xcrun', ['actool', xcassets,
2959
- '--compile', resourcesDir,
2960
- '--platform', 'macosx',
2961
- '--target-device', 'mac',
2962
- '--minimum-deployment-target', '11.0',
2963
- '--app-icon', 'AppIcon',
2964
- '--output-partial-info-plist', partialPlist,
2965
- ], { stdio: 'ignore' });
2966
-
2967
- if (!existsSync(join(resourcesDir, 'Assets.car'))) throw new Error('actool produced no Assets.car');
2968
- return 'AppIcon';
2969
- } catch (e: unknown) {
2970
- console.warn(`⚠ actool Assets.car compile skipped (${e instanceof Error ? e.message : String(e)}) — Tahoe Finder icon may look generic; legacy .icns kept`);
2971
- return null;
2972
- } finally {
2973
- rmSync(work, { recursive: true, force: true });
2974
- }
2975
- }
2976
-
2977
- /** Resolved macOS signing lane. `identity` is the cert SHA-1 hash (fed to codesign — unambiguous,
2978
- * unlike a CN which can collide); `cn` is kept for logging; `kind` records the cert class. The
2979
- * aps-environment is NOT derived from `kind` — it comes from the provisioning profile itself. */
2980
- interface MacSigning { identity: string; cn: string; kind: 'developer-id' | 'development'; teamId: string }
2981
-
2982
- /** A matched macOS profile + its own aps-environment entitlement. */
2983
- interface MacProfileMatch { path: string; apsEnvironment?: string }
2984
-
2985
- /** Resolve a real code-signing identity for the desktop lane. Team precedence mirrors stampTeamId:
2986
- * non-placeholder cfg.teamId → $APPWRAP_TEAM_ID → none (adhoc). Identity preference within the team:
2987
- * "Developer ID Application" (direct distribution) → "Apple Development" (local/dev). The CN of a
2988
- * dev cert does NOT carry the team id — match via the certificate's OU field. Returns null (→ adhoc
2989
- * fallback) when no team is set or no identity of either class belongs to it. */
2990
- function resolveMacSigning(cfg: AppwrapConfig): MacSigning | null {
2991
- const teamId = (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId))
2992
- ? process.env.APPWRAP_TEAM_ID?.trim() || null
2993
- : cfg.teamId;
2994
- if (!teamId) return null;
2995
- let listing = '';
2996
- try {
2997
- listing = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' });
2998
- } catch { return null; }
2999
- // find-identity -v lines: ` 1) <40-hex-SHA1> "<CN>"`. Capture BOTH — codesign is fed the hash.
3000
- const entries = [...listing.matchAll(/\b([0-9A-F]{40})\s+"([^"]+)"/g)].map((m) => ({ hash: m[1], cn: m[2] }));
3001
- const teamOf = (cn: string): string | null => {
3002
- try {
3003
- const pem = execFileSync('security', ['find-certificate', '-c', cn, '-p'], { encoding: 'utf8' });
3004
- const subject = execFileSync('openssl', ['x509', '-noout', '-subject'], { input: pem, encoding: 'utf8' });
3005
- return subject.match(/OU\s*=\s*([A-Z0-9]{10})/)?.[1] ?? null;
3006
- } catch { return null; }
3007
- };
3008
- for (const prefix of ['Developer ID Application', 'Apple Development'] as const) {
3009
- const match = entries.find((e) => e.cn.startsWith(prefix) && teamOf(e.cn) === teamId);
3010
- if (match) return { identity: match.hash, cn: match.cn, kind: prefix === 'Developer ID Application' ? 'developer-id' : 'development', teamId };
3011
- }
3012
- return null;
3013
- }
3014
-
3015
- /** Find an installed, unexpired macOS provisioning profile for `<teamId>.<bundleId>`. macOS profiles
3016
- * are `.provisionprofile` (same CMS envelope as iOS `.mobileprovision`; findProvisioningProfiles only
3017
- * scans the latter, so this is the mac twin). Returns the file path, or null (→ sign without the
3018
- * restricted push entitlement — a restricted entitlement with no embedded profile kills the app at
3019
- * launch, which is strictly worse than a signed app without push). */
3020
- function findMacProvisionProfile(teamId: string, bundleId: string, signingCertHash?: string): MacProfileMatch | null {
3021
- const dirs = [
3022
- join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles'),
3023
- join(process.env.HOME ?? '', 'Library/Developer/Xcode/UserData/Provisioning Profiles'),
3024
- ];
3025
- const wanted = signingCertHash?.toUpperCase();
3026
- for (const dir of dirs) {
3027
- if (!existsSync(dir)) continue;
3028
- let files: string[] = [];
3029
- try { files = readdirSync(dir).filter((f) => f.endsWith('.provisionprofile')); } catch { continue; }
3030
- for (const f of files) {
3031
- try {
3032
- const raw = execFileSync('security', ['cms', '-D', '-i', join(dir, f)],
3033
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
3034
- const info = parseMacProfile(raw);
3035
- const { teamId: team, appId, expiration } = info;
3036
- if (team !== teamId || !appId) continue;
3037
- if (expiration && Date.parse(expiration) < Date.now()) continue;
3038
- const profBundle = appId.startsWith(team + '.') ? appId.slice(team.length + 1) : appId;
3039
- if (profBundle !== bundleId && profBundle !== '*') continue;
3040
- // The chosen signing cert MUST be one the profile authorizes (DER SHA-1 == find-identity hash),
3041
- // else codesign succeeds but the app launch is rejected. Mismatch → warn + keep scanning.
3042
- if (wanted) {
3043
- const authorized = info.certificates.some(
3044
- (b64) => createHash('sha1').update(Buffer.from(b64, 'base64')).digest('hex').toUpperCase() === wanted,
3045
- );
3046
- if (!authorized) {
3047
- console.log(` ⚠ profile ${f} does not authorize the chosen signing certificate — skipping`);
3048
- continue;
3049
- }
3050
- }
3051
- return { path: join(dir, f), apsEnvironment: info.apsEnvironment };
3052
- } catch { /* skip unreadable profile */ }
3053
- }
3054
- }
3055
- return null;
3056
- }
3057
-
3058
- /** Assemble a double-clickable `<Name>.app` around the built shell binary. The Info.plist's
3059
- * CFBundleURLTypes is what registers `urlScheme` with LaunchServices — a bare binary can't receive
3060
- * `<scheme>://…` opens (tauri-plugin-deep-link needs the bundle). Signed with a real identity +
3061
- * entitlements + embedded profile when teamId resolves (resolveMacSigning); ad-hoc otherwise. */
3062
- function assembleMacApp(cwd: string, cfg: AppwrapConfig, shell: DesktopShellConfig, outDir: string, binary: string,
3063
- lane?: { signing: MacSigning | null; profile: MacProfileMatch | null }): string {
3064
- const appDir = join(outDir, 'dist-app', `${shell.name}.app`);
3065
- const macosDir = join(appDir, 'Contents/MacOS');
3066
- const resourcesDir = join(appDir, 'Contents/Resources');
3067
- rmSync(appDir, { recursive: true, force: true });
3068
- mkdirSync(macosDir, { recursive: true });
3069
- mkdirSync(resourcesDir, { recursive: true });
3070
- cpSync(binary, join(macosDir, shell.name));
3071
- const iconFile = generateMacIcns(cwd, cfg, resourcesDir) ?? undefined;
3072
- // Also compile a Tahoe-native Assets.car (macOS 26 reads CFBundleIconName/Assets.car, not the .icns).
3073
- // actool emits its own AppIcon.icns alongside — let it replace the iconutil one when present.
3074
- const iconName = compileMacAssetsCar(cwd, cfg, resourcesDir) ?? undefined;
3075
- writeFileSync(join(appDir, 'Contents/Info.plist'), buildInfoPlist(shell, shell.name, iconFile, iconName));
3076
- const signing = lane ? lane.signing : resolveMacSigning(cfg);
3077
- if (signing) {
3078
- // Signed lane: real identity; when an installed macOS profile matches, embed it and claim the
3079
- // push entitlement (restricted → only launches profile-backed). Developer ID gets the hardened
3080
- // runtime (notarization prerequisite; harmless otherwise).
3081
- const profile = lane ? lane.profile : findMacProvisionProfile(signing.teamId, shell.identifier, signing.identity);
3082
- if (profile) cpSync(profile.path, join(appDir, 'Contents/embedded.provisionprofile'));
3083
- else console.log(` ⓘ no macOS provisioning profile installed for ${signing.teamId}.${shell.identifier} — signing without entitlements (cert only)`);
3084
- // aps-environment comes from the PROFILE's own entitlement, not the cert kind — a Developer ID cert
3085
- // paired with a development profile must claim `development`, or the app is killed at launch.
3086
- const aps = profile?.apsEnvironment;
3087
- if (profile && aps !== 'development' && aps !== 'production') {
3088
- console.log(` ⓘ profile has no aps-environment entitlement — signing without push`);
3089
- }
3090
- const args = ['--force', '--deep', '-s', signing.identity];
3091
- // Entitlements ONLY when a profile backs them: application-identifier/team-identifier are
3092
- // RESTRICTED — claiming them without an embedded profile makes macOS refuse to launch the app
3093
- // ("can't be opened"). No profile → plain cert signature (launches fine, no push).
3094
- if (profile) {
3095
- const entitlements = buildMacEntitlements({
3096
- teamId: signing.teamId,
3097
- bundleId: shell.identifier,
3098
- apsEnvironment: aps === 'development' || aps === 'production' ? aps : undefined,
3099
- });
3100
- const entFile = join(outDir, 'dist-app', `${shell.name}.entitlements`);
3101
- writeFileSync(entFile, entitlements);
3102
- args.push('--entitlements', entFile);
3103
- }
3104
- if (signing.kind === 'developer-id') args.push('--options', 'runtime', '--timestamp');
3105
- console.log(`▶ codesign (${signing.cn} [${signing.identity}]${profile ? ', profile embedded' : ''})`);
3106
- execFileSync('codesign', [...args, appDir], { stdio: 'inherit' });
3107
- } else {
3108
- // Ad-hoc sign so Gatekeeper/LaunchServices treat the bundle as intact on this machine.
3109
- // Only flag a skip when a REAL team was requested (placeholder teamId = signing never asked for).
3110
- const requested = process.env.APPWRAP_TEAM_ID?.trim() || (cfg.teamId && !/YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId) ? cfg.teamId : '');
3111
- if (requested) console.log(` ⓘ signed lane skipped — no Developer ID / Apple Development identity for team ${requested}; ad-hoc signing`);
3112
- execFileSync('codesign', ['--force', '--deep', '-s', '-', appDir], { stdio: 'inherit' });
3113
- }
3114
- return appDir;
3115
- }
3116
-
3117
- /** Opt-in notarization (env `APPWRAP_NOTARIZE=1`, off by default). Requires the Developer ID signed
3118
- * lane — assembleMacApp already signs it with `--options runtime --timestamp` (hardened runtime +
3119
- * secure timestamp, both notarization prerequisites; no get-task-allow is ever claimed). Credentials:
3120
- * `APPWRAP_NOTARY_KEYCHAIN_PROFILE` (a `notarytool store-credentials` profile) or an ASC API key —
3121
- * `APPWRAP_NOTARY_KEY_ID` + `APPWRAP_NOTARY_ISSUER` (+ optional `APPWRAP_NOTARY_KEY` p8 path,
3122
- * default ~/.appstoreconnect/private_keys/AuthKey_<id>.p8). */
3123
- function notarizeMacApp(appDir: string, signing: MacSigning | null): void {
3124
- if (process.env.APPWRAP_NOTARIZE !== '1') return;
3125
- if (!signing || signing.kind !== 'developer-id') {
3126
- 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`);
3127
- process.exit(1);
3128
- }
3129
- const kcProfile = process.env.APPWRAP_NOTARY_KEYCHAIN_PROFILE?.trim();
3130
- const keyId = process.env.APPWRAP_NOTARY_KEY_ID?.trim();
3131
- const issuer = process.env.APPWRAP_NOTARY_ISSUER?.trim();
3132
- const keyPath = process.env.APPWRAP_NOTARY_KEY?.trim()
3133
- || (keyId ? join(process.env.HOME ?? '', `.appstoreconnect/private_keys/AuthKey_${keyId}.p8`) : '');
3134
- let auth: string[];
3135
- if (kcProfile) auth = ['--keychain-profile', kcProfile];
3136
- else if (keyId && issuer) {
3137
- if (!existsSync(keyPath)) {
3138
- console.error(`✖ notarization: ASC API key file not found at ${keyPath} (set APPWRAP_NOTARY_KEY to the .p8 path)`);
3139
- process.exit(1);
3140
- }
3141
- auth = ['--key', keyPath, '--key-id', keyId, '--issuer', issuer];
3142
- } else {
3143
- 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)');
3144
- process.exit(1);
3145
- }
3146
- const zip = `${appDir}.notarize.zip`;
3147
- rmSync(zip, { force: true });
3148
- execFileSync('ditto', ['-c', '-k', '--keepParent', appDir, zip], { stdio: 'inherit' });
3149
- console.log('▶ notarytool submit --wait (Apple-side processing — can take several minutes)');
3150
- try {
3151
- execFileSync('xcrun', ['notarytool', 'submit', zip, ...auth, '--wait'], { stdio: 'inherit' });
3152
- execFileSync('xcrun', ['stapler', 'staple', appDir], { stdio: 'inherit' });
3153
- } finally {
3154
- rmSync(zip, { force: true });
3155
- }
3156
- console.log(`✓ Notarized + stapled: ${appDir}`);
3157
- }
3158
-
3159
- /** `appwrap build desktop` — stamp the wrapper, `cargo build --release`, then assemble the `.app`. */
3160
- async function buildDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
3161
- ensureDesktopMacOS();
3162
- const cfg = await loadConfig(cwd, flags);
3163
- const outDir = resolve(cwd, flags.out ?? 'native-desktop');
3164
- // Local-server apps load the live server, not a bundled web build — skip it (regenerateDesktop
3165
- // stages a boot splash for them). Otherwise build/stage the PWA as usual.
3166
- if (!cfg.desktop?.server) buildWebIfBundled(cwd, cfg, flags);
3167
- // Resolve the signing lane BEFORE regeneration: shell_config.json is embedded at compile time,
3168
- // and the Rust shell's push capability must only claim 'native' when the .app really ships the
3169
- // profile-backed aps-environment entitlement (signed lane + installed macOS profile).
3170
- const signing = resolveMacSigning(cfg);
3171
- const shellCfg = deriveDesktopConfig(cfg);
3172
- const profile = signing ? findMacProvisionProfile(signing.teamId, shellCfg.identifier, signing.identity) : null;
3173
- // Only let the Rust shell claim NATIVE push when the app will actually ship a profile-backed
3174
- // aps-environment entitlement (profile matched AND it carries the entitlement).
3175
- const nativePush = !!(signing && profile && (profile.apsEnvironment === 'development' || profile.apsEnvironment === 'production'));
3176
- regenerateDesktop(cwd, cfg, outDir, nativePush);
3177
- const { bin, env } = desktopCargo();
3178
- console.log(`▶ cargo build --release (appwrap desktop shell — ${cfg.name})`);
3179
- execFileSync(bin, ['build', '--release'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
3180
- const binary = join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target/release/appwrap-desktop-shell');
3181
- console.log(`✓ Desktop binary: ${binary}`);
3182
- const appDir = assembleMacApp(cwd, cfg, shellCfg, outDir, binary, { signing, profile });
3183
- console.log(`✓ App bundle: ${appDir}`);
3184
- notarizeMacApp(appDir, signing);
3185
- }
3186
2658
 
3187
2659
  async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
3188
2660
  const platform = positionals[0];
3189
- if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals);
3190
- if (platform === 'desktop') return buildDesktop(cwd, flags);
2661
+ if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals, 'build');
3191
2662
  if (platform !== 'ios' && platform !== 'android') {
3192
- console.error('Usage: appwrap build <ios|android|desktop> [--release] [--aab] [--config <path>] [--out native]');
2663
+ console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config <path>] [--out native]');
3193
2664
  process.exit(1);
3194
2665
  }
3195
2666
  const outDir = resolve(cwd, flags.out ?? 'native');
@@ -3584,7 +3055,7 @@ function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: strin
3584
3055
  const overridesDir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
3585
3056
  const stats = [mtimeStats(distDir), mtimeStats(overridesDir), mtimeStats(resolveConfigPath(cwd, flags))];
3586
3057
  // The appwrap runtime template(s) — bundled into bundle.js, so a first-class build input. Every
3587
- // overlay root counts, so editing a host-provided (e.g. appwrap-ee) template file busts the cache.
3058
+ // overlay root counts, so editing a host-provided template file busts the cache.
3588
3059
  let runtime = 0, runtimeNewest = 0;
3589
3060
  for (const root of TEMPLATE_ROOTS) {
3590
3061
  if (!existsSync(root)) continue;
@@ -3810,7 +3281,7 @@ function detectWebBuildCmd(cwd: string, cfg: AppwrapConfig): string[] | null {
3810
3281
  * app loads the live serverUrl, so its dist is unused (building it would be wasteful + misleading).
3811
3282
  * Prints exactly what it's doing either way. `--no-web-build` skips it (ship the current dist as-is,
3812
3283
  * e.g. to hit the native build-skip fast-path when only the wrapper changed). */
3813
- function buildWebIfBundled(cwd: string, cfg: AppwrapConfig, flags: Record<string, string>): void {
3284
+ export function buildWebIfBundled(cwd: string, cfg: AppwrapConfig, flags: Record<string, string>): void {
3814
3285
  if ((cfg.loader ?? 'app') === 'server') {
3815
3286
  console.log('ℹ loader:server — NOT building the web (the app loads serverUrl live; the bundle is unused).');
3816
3287
  return;
@@ -4558,7 +4029,7 @@ async function createModule(cwd: string, flags: Record<string, string>, position
4558
4029
 
4559
4030
  /**
4560
4031
  * The CLI entry point — the bare `appwrap` bin calls `runCli()` with no options (identical to the
4561
- * historical `main`). A host (appwrap-ee) calls `runCli({ commands, platforms, modulePacks,
4032
+ * historical `main`). A host calls `runCli({ commands, platforms, modulePacks,
4562
4033
  * templateRoots, desktopTemplateDir })` to compose extra capability on top of CE without forking it.
4563
4034
  */
4564
4035
  export async function runCli(options: CliOptions = {}): Promise<void> {
@@ -4620,11 +4091,10 @@ export async function runCli(options: CliOptions = {}): Promise<void> {
4620
4091
  ' Android wireless: --wifi flips a USB device to wireless adb (unplug + keep going); thereafter the\n' +
4621
4092
  ' device is auto-discovered via mDNS — plain `dev android` finds it with NO flag (iOS parity).\n' +
4622
4093
  ' --device <ip[:port]> `adb connect`s an already-paired one.\n\n' +
4623
- ' dev <ios|android|desktop> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
4094
+ ' dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
4624
4095
  ' live-dev: ANDROID device → ns run livesync (true on-device HMR) + re-stage on save;\n' +
4625
4096
  ' iOS device → deploy + rebuild/reinstall on save. --sim = ns run/HMR on emulator;\n' +
4626
4097
  ' --url/--port = web HMR from a dev server inside the WebView; --detached = install & exit.\n' +
4627
- ' desktop → macOS Tauri shell (cargo run) speaking appwrap protocol v1 (phase-0, macOS-only).\n' +
4628
4098
  ' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
4629
4099
  ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
4630
4100
  ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +