@livx.cc/appwrap 0.42.4 → 0.46.1

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 (32) hide show
  1. package/package.json +4 -2
  2. package/runtime/app/app.ts +15 -0
  3. package/runtime/app/main-page.ts +11 -2
  4. package/runtime/app/shell/custom-webview.ios.ts +18 -8
  5. package/runtime/app/shell/handlers-background.ts +1 -1
  6. package/runtime/app/shell/web-quirks.ts +63 -0
  7. package/runtime-desktop/autotest/index.html +77 -0
  8. package/runtime-desktop/bridge-shim/build.sh +9 -0
  9. package/runtime-desktop/bridge-shim/content-entry.ts +87 -0
  10. package/runtime-desktop/bridge-shim/shim.ts +317 -0
  11. package/runtime-desktop/src-tauri/Cargo.lock +5461 -0
  12. package/runtime-desktop/src-tauri/Cargo.toml +40 -0
  13. package/runtime-desktop/src-tauri/build.rs +3 -0
  14. package/runtime-desktop/src-tauri/capabilities/default.json +6 -0
  15. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  16. package/runtime-desktop/src-tauri/shell_config.json +10 -0
  17. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +138 -0
  18. package/runtime-desktop/src-tauri/src/bridge_mac.rs +664 -0
  19. package/runtime-desktop/src-tauri/src/main.rs +879 -0
  20. package/runtime-desktop/src-tauri/src/notifications_mac.rs +202 -0
  21. package/runtime-desktop/src-tauri/src/oauth_mac.rs +139 -0
  22. package/runtime-desktop/src-tauri/src/popup_mac.rs +519 -0
  23. package/runtime-desktop/src-tauri/src/push_mac.rs +193 -0
  24. package/runtime-desktop/src-tauri/src/server.rs +156 -0
  25. package/runtime-desktop/src-tauri/src/sidecar.rs +398 -0
  26. package/runtime-desktop/src-tauri/tauri.conf.json +13 -0
  27. package/scripts/stage-assets.mjs +4 -2
  28. package/src/cli.ts +664 -37
  29. package/src/config.ts +76 -1
  30. package/src/desktop.ts +293 -0
  31. package/src/handlers.ts +71 -0
  32. package/src/icon.ts +207 -0
package/src/cli.ts CHANGED
@@ -9,9 +9,10 @@
9
9
  * Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
10
10
  */
11
11
  import { execFileSync, spawn } from 'child_process';
12
+ import { createHash } from 'crypto';
12
13
  import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
13
14
  import { networkInterfaces, tmpdir } from 'os';
14
- import { dirname, join, resolve } from 'path';
15
+ import { delimiter as pathDelimiter, dirname, join, resolve } from 'path';
15
16
  import { pathToFileURL } from 'url';
16
17
  // PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
17
18
  // the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
@@ -21,6 +22,10 @@ import type * as CapManifest from '../../../runtime/app/shell/capabilities.manif
21
22
  // type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
22
23
  import type { AppwrapConfig } from './config';
23
24
  import { unknownConfigKeys } from './config';
25
+ import { createHash } from 'crypto';
26
+ import { buildInfoPlist, buildMacEntitlements, deriveDesktopConfig, parseMacProfile, resolveCargoPath, stampTauriConf } from './desktop';
27
+ import type { DesktopShellConfig } from './desktop';
28
+ import { APPLE_ICON_GRID_SCALE, makeDockRuntimeIcon } from './icon';
24
29
  import {
25
30
  androidScreenOrientation,
26
31
  applyBuildNumberFlag,
@@ -43,6 +48,8 @@ interface ExecError extends Error {
43
48
  stdout?: string | Buffer;
44
49
  stderr?: string | Buffer;
45
50
  status?: number | null;
51
+ signal?: string | null;
52
+ code?: string | number | null;
46
53
  }
47
54
  const asExecError = (e: unknown): ExecError => (e ?? {}) as ExecError;
48
55
  /** Combined stdout+stderr captured on an exec failure (empty string when none). */
@@ -50,6 +57,12 @@ const execErrText = (e: unknown): string => {
50
57
  const err = asExecError(e);
51
58
  return `${err.stdout ?? ''}${err.stderr ?? ''}`;
52
59
  };
60
+ /** True when an exec failed because OUR process-level `timeout:` killed it (SIGTERM / ETIMEDOUT) —
61
+ * the signature of a child that hung silently, as a wedged CoreDeviceService makes devicectl do. */
62
+ const isTimeoutKill = (e: unknown): boolean => {
63
+ const err = asExecError(e);
64
+ return err.signal === 'SIGTERM' || err.code === 'ETIMEDOUT' || /ETIMEDOUT/.test(String(err.message ?? ''));
65
+ };
53
66
 
54
67
  /**
55
68
  * Resolved monotonic build number (iOS CFBundleVersion / Android versionCode). Thin env wrapper over
@@ -90,6 +103,9 @@ function resolveAssetRoot(rel: string): string {
90
103
  }
91
104
  const TEMPLATE_DIR = resolveAssetRoot('runtime');
92
105
  const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
106
+ /** Desktop (Tauri chassis) template — phase-0, macOS-only. Its own generated wrapper (`native-desktop/`)
107
+ * is disposable, like `native/`; the template stays canonical (edit here, regenerate propagates). */
108
+ const DESKTOP_TEMPLATE_DIR = resolveAssetRoot('runtime-desktop');
93
109
 
94
110
  /** This CLI's own published version — used to pin the `bunx @livx.cc/appwrap@^x.y.z` invocations the
95
111
  * emitted workflow runs. Pinning to THIS version's floor means CI fails LOUDLY ("version not found")
@@ -1122,8 +1138,15 @@ function findMaskableSource(cwd: string, cfg: AppwrapConfig): string | null {
1122
1138
  /** Square-resize/probe abstraction over `sips` (macOS) or ImageMagick (`magick` v7 / `convert` v6),
1123
1139
  * so icon generation works on Linux/CI too — not just macOS. Returns null if neither tool is present. */
1124
1140
  function imageRasterizer(): { width: (src: string) => number; resize: (src: string, px: number, dest: string) => void } | null {
1141
+ // Probe by RUNNING the tool, not by PATH lookup. `which`/`Bun.which` both consult process.env.PATH,
1142
+ // which can be undefined under some Bun launch contexts (observed: PATH unset yet `sips` still execs
1143
+ // fine via the OS default path) → PATH-based detection returned false and silently shipped the
1144
+ // NativeScript template icon. execFileSync resolves + runs the binary the same way the actual
1145
+ // resize/probe calls below do, so detection and invocation always agree. ENOENT = absent; any other
1146
+ // failure (e.g. bad flag, nonzero exit) means the binary IS present.
1125
1147
  const has = (cmd: string) => {
1126
- try { execFileSync('which', [cmd], { stdio: 'ignore' }); return true; } catch { return false; }
1148
+ try { execFileSync(cmd, ['--version'], { stdio: 'ignore' }); return true; }
1149
+ catch (e) { return (e as { code?: string }).code !== 'ENOENT'; }
1127
1150
  };
1128
1151
  if (has('sips')) {
1129
1152
  return {
@@ -1673,10 +1696,11 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
1673
1696
  */
1674
1697
  async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1675
1698
  const platform = positionals[0];
1699
+ if (platform === 'desktop') return devDesktop(cwd, flags);
1676
1700
  const sim = 'sim' in flags || positionals[1] === 'sim';
1677
1701
  const wantDebug = 'debug' in flags;
1678
1702
  if (platform !== 'ios' && platform !== 'android') {
1679
- console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
1703
+ console.error('Usage: appwrap dev <ios|android|desktop> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
1680
1704
  process.exit(1);
1681
1705
  }
1682
1706
  const cfg = await loadConfig(cwd, flags);
@@ -1932,10 +1956,469 @@ async function watchAndSync(cwd: string, flags: Record<string, string>, outDir:
1932
1956
  * Android signing comes from env (APPWRAP_ANDROID_KEYSTORE[_PASSWORD|_ALIAS|_ALIAS_PASSWORD]) — secrets
1933
1957
  * never live in the appwrap config. iOS distribution signing/upload is the fastlane release lane's job (the
1934
1958
  * cicd templates); `--release` here just builds the Release config for the device. */
1959
+ // ─────────────────────────────── desktop (Tauri chassis) lane ────────────────────────────────
1960
+ // Phase-0, macOS-only. Mirrors the managed-CNG model of the mobile lanes but far leaner: copy the
1961
+ // `runtime-desktop/` template into the app's disposable `native-desktop/`, stamp the app's identity +
1962
+ // window shape (shell_config.json, embedded into the Rust binary) and Tauri config (tauri.conf.json),
1963
+ // stage the built web dist, then `cargo run`/`build`. The template is canonical; `native-desktop/` is
1964
+ // gitignored + regenerated each run (framework edits propagate — same contract as `native/`).
1965
+
1966
+ /** Fail clearly on non-macOS — the desktop chassis is macOS-only for phase-0 (osascript dialogs/
1967
+ * notifications, `open`, the app-data path are all macOS). */
1968
+ function ensureDesktopMacOS(): void {
1969
+ if (process.platform !== 'darwin') {
1970
+ console.error(`✖ appwrap desktop is macOS-only for now (phase-0). Detected platform: ${process.platform}.`);
1971
+ process.exit(1);
1972
+ }
1973
+ }
1974
+
1975
+ /** Skip the heavy/generated dirs when copying the desktop template (Rust build output + Tauri codegen +
1976
+ * deps): they regenerate, and `target/` alone is GBs. Matches RELATIVE to the template root. */
1977
+ function desktopCopyFilter(src: string): boolean {
1978
+ const rel = src.slice(DESKTOP_TEMPLATE_DIR.length);
1979
+ return !/(^|\/)(target|gen|node_modules)(\/|$)/.test(rel);
1980
+ }
1981
+
1982
+ /** Resolve a `desktop.server.command` to an absolute path: an absolute/relative path is resolved
1983
+ * against the app root; a bare name is looked up on `path` (build-time PATH). Falls back to the bare
1984
+ * name (the Rust shell will surface the spawn ENOENT) when it's nowhere. */
1985
+ function resolveServerCommand(command: string, cwd: string, path: string): string {
1986
+ if (command.includes('/')) return resolve(cwd, command);
1987
+ for (const dir of path.split(pathDelimiter).filter(Boolean)) {
1988
+ const abs = join(dir, command);
1989
+ if (existsSync(abs)) return abs;
1990
+ }
1991
+ return command;
1992
+ }
1993
+
1994
+ /** The bundled splash shown while a local-server app boots (before the shell navigates to the live
1995
+ * server). A dependency-free, theme-neutral holding page. */
1996
+ function desktopSplashHtml(name: string): string {
1997
+ const safe = name.replace(/</g, '&lt;').replace(/&/g, '&amp;');
1998
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${safe}</title>
1999
+ <style>html,body{height:100%;margin:0}body{display:flex;align-items:center;justify-content:center;
2000
+ flex-direction:column;gap:20px;background:#0b0b0f;color:#e5e5e5;font:14px -apple-system,system-ui,sans-serif}
2001
+ .s{width:34px;height:34px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:r .8s linear infinite}
2002
+ @keyframes r{to{transform:rotate(360deg)}}#e{color:#f87171;max-width:70%;text-align:center;line-height:1.5}</style>
2003
+ </head><body><div class="s" id="spin"></div><div id="m">Starting ${safe}…</div><div id="e"></div></body></html>`;
2004
+ }
2005
+
2006
+ /** Copy the desktop template into `native-desktop/` and stamp it from the config: the Rust-read
2007
+ * `shell_config.json` (identity + window), `tauri.conf.json` (productName/version/identifier +
2008
+ * frontendDist), and the staged web dist (`native-desktop/dist`, referenced as `../dist`). */
2009
+ function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, pushSigned = false): void {
2010
+ if (!existsSync(DESKTOP_TEMPLATE_DIR)) {
2011
+ console.error(`✖ Desktop template not found at ${DESKTOP_TEMPLATE_DIR}`);
2012
+ process.exit(1);
2013
+ }
2014
+ const srcTauri = join(outDir, 'src-tauri');
2015
+ mkdirSync(outDir, { recursive: true });
2016
+ cpSync(DESKTOP_TEMPLATE_DIR, outDir, { recursive: true, force: true, filter: desktopCopyFilter });
2017
+
2018
+ const shell = deriveDesktopConfig(cfg);
2019
+ shell.pushSigned = pushSigned;
2020
+
2021
+ // Stage the frontendDist INTO the wrapper (mirrors mobile copyPwa) so it's a stable relative path
2022
+ // independent of where the app project sits. Local-server apps (`desktop.server`) have no meaningful
2023
+ // web dist — the window loads the live server — so we stage a tiny bundled SPLASH instead (shown
2024
+ // while the server boots, then the shell navigates away from it).
2025
+ const stagedDist = join(outDir, 'dist');
2026
+ rmSync(stagedDist, { recursive: true, force: true });
2027
+ if (shell.server) {
2028
+ mkdirSync(stagedDist, { recursive: true });
2029
+ writeFileSync(join(stagedDist, 'index.html'), desktopSplashHtml(shell.name));
2030
+ } else {
2031
+ const dist = resolve(cwd, cfg.pwaDist);
2032
+ if (!existsSync(dist)) {
2033
+ console.error(`✖ Web dist not found at ${dist} — build the PWA first (or check pwaDist).`);
2034
+ process.exit(1);
2035
+ }
2036
+ cpSync(dist, stagedDist, { recursive: true, force: true });
2037
+ }
2038
+
2039
+ // Local-server mode: resolve `command` (bare name → PATH lookup) and `cwd` to ABSOLUTE, and stamp
2040
+ // the build-time PATH — a GUI-launched .app inherits the minimal launchd PATH (no ~/.bun/bin), so
2041
+ // a bare-name spawn (or a child that itself shells out to `bun`/`node`) would fail there.
2042
+ if (shell.server) {
2043
+ const s = shell.server;
2044
+ s.cwd = s.cwd ? resolve(cwd, s.cwd) : cwd;
2045
+ s.command = resolveServerCommand(s.command, cwd, process.env.PATH ?? '');
2046
+ s.path = process.env.PATH ?? '';
2047
+ if (!existsSync(s.command)) {
2048
+ console.warn(`⚠ desktop.server.command not found (${s.command}) — the server will fail to spawn at launch.`);
2049
+ }
2050
+ }
2051
+ // Resolve the handlers path to ABSOLUTE (against the app root): the shell binary runs from a
2052
+ // different cwd, and for `build desktop` the .app runs from wherever it's installed — an absolute
2053
+ // path is the only one that stays valid. The handler file is NOT copied into the wrapper/.app; the
2054
+ // app dir (with its node_modules) must remain present at runtime (documented in the config type).
2055
+ if (shell.handlers) {
2056
+ const abs = resolve(cwd, shell.handlers);
2057
+ if (!existsSync(abs)) {
2058
+ console.warn(`⚠ desktop.handlers points to a missing file (${abs}) — the sidecar will not spawn.`);
2059
+ }
2060
+ shell.handlers = abs;
2061
+ // Stamp the ABSOLUTE Bun binary the sidecar must be spawned with: the CLI itself runs under bun,
2062
+ // so process.execPath IS the exact runtime the app was built with. A GUI-launched .app inherits
2063
+ // the launchd PATH (no ~/.bun/bin), so the Rust shell can't rely on a bare `bun` lookup.
2064
+ shell.handlersRuntime = process.execPath;
2065
+ }
2066
+ writeFileSync(join(srcTauri, 'shell_config.json'), JSON.stringify(shell, null, 2) + '\n');
2067
+
2068
+ const tauriConfPath = join(srcTauri, 'tauri.conf.json');
2069
+ const template = JSON.parse(readFileSync(tauriConfPath, 'utf8')) as Record<string, unknown>;
2070
+ const stamped = stampTauriConf(template, shell, '../dist'); // relative to src-tauri/
2071
+ writeFileSync(tauriConfPath, JSON.stringify(stamped, null, 2) + '\n');
2072
+
2073
+ stampDesktopRuntimeIcon(cwd, cfg, join(srcTauri, 'icons', 'icon.png'));
2074
+ }
2075
+
2076
+ /** Stamp the Tauri RUNTIME icon (`src-tauri/icons/icon.png`, embedded via `generate_context!`) from
2077
+ * the app's real icon — else it OVERRIDES the bundle icns/Assets.car in cmd+tab/Dock with the template
2078
+ * placeholder square. Downscale to 512 via sips → round the macOS app-icon corners + ensure RGBA (our
2079
+ * dependency-free codec) → write. On any failure, keep the template placeholder (Tauri needs *some*
2080
+ * RGBA icon.png to compile) and warn — never fail the build over an icon. */
2081
+ function stampDesktopRuntimeIcon(cwd: string, cfg: AppwrapConfig, destPng: string): void {
2082
+ const source = findIconSource(cwd, cfg);
2083
+ if (!source) return; // no cfg.icon / manifest icon → keep template placeholder
2084
+ const tmpPng = join(tmpdir(), `appwrap-desktop-icon-${Date.now()}.png`);
2085
+ try {
2086
+ // Artwork at Apple's icon-grid fraction of the canvas, then pad — full-bleed runtime icons
2087
+ // render visibly larger than bundle-masked Dock neighbors (no OS-applied margin/mask).
2088
+ const art = Math.round(512 * APPLE_ICON_GRID_SCALE);
2089
+ execFileSync('sips', ['-s', 'format', 'png', '-z', String(art), String(art), source, '--out', tmpPng], { stdio: 'ignore' });
2090
+ writeFileSync(destPng, makeDockRuntimeIcon(readFileSync(tmpPng), 512));
2091
+ console.log(` desktop runtime icon ← ${source} (${art}² rounded on 512² canvas)`);
2092
+ } catch (e: unknown) {
2093
+ console.warn(`⚠ desktop runtime icon stamp failed (${e instanceof Error ? e.message : String(e)}) — keeping placeholder`);
2094
+ } finally {
2095
+ rmSync(tmpPng, { force: true });
2096
+ }
2097
+ }
2098
+
2099
+ /** Shared target dir for the desktop crate — reuse the template's warm `target/` so a stamped copy
2100
+ * builds incrementally (~seconds) instead of a ~4-min cold compile of the Tauri dep tree. */
2101
+ /** Resolve a spawnable `cargo` (bin + PATH-augmented env) for the desktop lane. rustup installs into
2102
+ * `~/.cargo/bin`, which a GUI/login shell often lacks on PATH → a bare spawn throws a raw ENOENT. We
2103
+ * append it when the binary lives there, else fail with an actionable install/source message. */
2104
+ function desktopCargo(): { bin: string; env: NodeJS.ProcessEnv } {
2105
+ const home = process.env.HOME ?? '';
2106
+ const res = resolveCargoPath({
2107
+ path: process.env.PATH ?? '',
2108
+ home,
2109
+ delimiter: pathDelimiter,
2110
+ hasCargo: (dir) => existsSync(join(dir, 'cargo')),
2111
+ });
2112
+ if (!res.found) {
2113
+ console.error(
2114
+ `✖ cargo not found on PATH${home ? ` (and no ${home}/.cargo/bin/cargo)` : ''}.\n` +
2115
+ ` Install the Rust toolchain via rustup: https://rustup.rs\n` +
2116
+ ` If it's already installed, add it to this shell: source "$HOME/.cargo/env"`
2117
+ );
2118
+ process.exit(1);
2119
+ }
2120
+ if (res.augmented) console.log(` cargo ← ${home}/.cargo/bin (appended to PATH; not in your shell's PATH)`);
2121
+ return {
2122
+ bin: res.bin,
2123
+ env: { ...process.env, PATH: res.path, CARGO_TARGET_DIR: join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target') },
2124
+ };
2125
+ }
2126
+
2127
+ /** `appwrap dev desktop` — stamp the wrapper + `cargo run` (launches the window; blocks until closed). */
2128
+ async function devDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
2129
+ ensureDesktopMacOS();
2130
+ const cfg = await loadConfig(cwd, flags);
2131
+ const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2132
+ buildWebIfBundled(cwd, cfg, flags);
2133
+ regenerateDesktop(cwd, cfg, outDir);
2134
+ const { bin, env } = desktopCargo();
2135
+ console.log(`▶ cargo run (appwrap desktop shell — ${cfg.name})`);
2136
+ execFileSync(bin, ['run'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
2137
+ }
2138
+
2139
+ /** Generate `AppIcon.icns` from the app's icon PNG via `sips` + `iconutil` (macOS-only, like the whole
2140
+ * desktop lane). Returns the icns basename, or null when no icon source / tooling — skip gracefully. */
2141
+ function generateMacIcns(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
2142
+ const source = findIconSource(cwd, cfg);
2143
+ if (!source) return null;
2144
+ const iconset = join(tmpdir(), `appwrap-icns-${Date.now()}.iconset`);
2145
+ mkdirSync(iconset, { recursive: true });
2146
+ try {
2147
+ for (const px of [16, 32, 128, 256, 512]) {
2148
+ execFileSync('sips', ['-z', String(px), String(px), source, '--out', join(iconset, `icon_${px}x${px}.png`)], { stdio: 'ignore' });
2149
+ execFileSync('sips', ['-z', String(px * 2), String(px * 2), source, '--out', join(iconset, `icon_${px}x${px}@2x.png`)], { stdio: 'ignore' });
2150
+ }
2151
+ execFileSync('iconutil', ['-c', 'icns', iconset, '-o', join(resourcesDir, 'AppIcon.icns')], { stdio: 'ignore' });
2152
+ return 'AppIcon.icns';
2153
+ } catch (e: unknown) {
2154
+ console.warn(`⚠ icns generation failed (${e instanceof Error ? e.message : String(e)}) — .app ships without an icon`);
2155
+ return null;
2156
+ } finally {
2157
+ rmSync(iconset, { recursive: true, force: true });
2158
+ }
2159
+ }
2160
+
2161
+ /** Compile a Tahoe-native `Assets.car` (+ its own `AppIcon.icns`) from the app's icon via `actool`,
2162
+ * into `resourcesDir`. macOS 26 (Tahoe) resolves a bundle's Finder/Dock icon through
2163
+ * `CFBundleIconName` + `Assets.car`, NOT the legacy top-level `.icns`. Returns the asset icon name
2164
+ * (`AppIcon`) on success, or null when there's no icon / `xcrun`/`actool` is unavailable or fails —
2165
+ * caller then keeps the iconutil `.icns` path (fine pre-26; Tahoe Finder icon may look generic). */
2166
+ function compileMacAssetsCar(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
2167
+ const source = findIconSource(cwd, cfg);
2168
+ if (!source) return null;
2169
+ const work = join(tmpdir(), `appwrap-actool-${Date.now()}`);
2170
+ const xcassets = join(work, 'Assets.xcassets');
2171
+ const iconset = join(xcassets, 'AppIcon.appiconset');
2172
+ try {
2173
+ mkdirSync(iconset, { recursive: true });
2174
+ // 10 macOS reps: 16/32/128/256/512 pt at @1x and @2x.
2175
+ const reps = [16, 32, 128, 256, 512].flatMap((pt) => [
2176
+ { pt, scale: 1, px: pt },
2177
+ { pt, scale: 2, px: pt * 2 },
2178
+ ]);
2179
+ const images = reps.map(({ pt, scale, px }) => {
2180
+ const filename = `icon_${pt}x${pt}@${scale}x.png`;
2181
+ execFileSync('sips', ['-s', 'format', 'png', '-z', String(px), String(px), source, '--out', join(iconset, filename)], { stdio: 'ignore' });
2182
+ return { idiom: 'mac', size: `${pt}x${pt}`, scale: `${scale}x`, filename };
2183
+ });
2184
+ writeFileSync(join(iconset, 'Contents.json'), JSON.stringify({ images, info: { version: 1, author: 'appwrap' } }, null, 2));
2185
+ writeFileSync(join(xcassets, 'Contents.json'), JSON.stringify({ info: { version: 1, author: 'appwrap' } }, null, 2));
2186
+
2187
+ const partialPlist = join(work, 'partial.plist');
2188
+ execFileSync('xcrun', ['actool', xcassets,
2189
+ '--compile', resourcesDir,
2190
+ '--platform', 'macosx',
2191
+ '--target-device', 'mac',
2192
+ '--minimum-deployment-target', '11.0',
2193
+ '--app-icon', 'AppIcon',
2194
+ '--output-partial-info-plist', partialPlist,
2195
+ ], { stdio: 'ignore' });
2196
+
2197
+ if (!existsSync(join(resourcesDir, 'Assets.car'))) throw new Error('actool produced no Assets.car');
2198
+ return 'AppIcon';
2199
+ } catch (e: unknown) {
2200
+ console.warn(`⚠ actool Assets.car compile skipped (${e instanceof Error ? e.message : String(e)}) — Tahoe Finder icon may look generic; legacy .icns kept`);
2201
+ return null;
2202
+ } finally {
2203
+ rmSync(work, { recursive: true, force: true });
2204
+ }
2205
+ }
2206
+
2207
+ /** Resolved macOS signing lane. `identity` is the cert SHA-1 hash (fed to codesign — unambiguous,
2208
+ * unlike a CN which can collide); `cn` is kept for logging; `kind` records the cert class. The
2209
+ * aps-environment is NOT derived from `kind` — it comes from the provisioning profile itself. */
2210
+ interface MacSigning { identity: string; cn: string; kind: 'developer-id' | 'development'; teamId: string }
2211
+
2212
+ /** A matched macOS profile + its own aps-environment entitlement. */
2213
+ interface MacProfileMatch { path: string; apsEnvironment?: string }
2214
+
2215
+ /** Resolve a real code-signing identity for the desktop lane. Team precedence mirrors stampTeamId:
2216
+ * non-placeholder cfg.teamId → $APPWRAP_TEAM_ID → none (adhoc). Identity preference within the team:
2217
+ * "Developer ID Application" (direct distribution) → "Apple Development" (local/dev). The CN of a
2218
+ * dev cert does NOT carry the team id — match via the certificate's OU field. Returns null (→ adhoc
2219
+ * fallback) when no team is set or no identity of either class belongs to it. */
2220
+ function resolveMacSigning(cfg: AppwrapConfig): MacSigning | null {
2221
+ const teamId = (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId))
2222
+ ? process.env.APPWRAP_TEAM_ID?.trim() || null
2223
+ : cfg.teamId;
2224
+ if (!teamId) return null;
2225
+ let listing = '';
2226
+ try {
2227
+ listing = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' });
2228
+ } catch { return null; }
2229
+ // find-identity -v lines: ` 1) <40-hex-SHA1> "<CN>"`. Capture BOTH — codesign is fed the hash.
2230
+ const entries = [...listing.matchAll(/\b([0-9A-F]{40})\s+"([^"]+)"/g)].map((m) => ({ hash: m[1], cn: m[2] }));
2231
+ const teamOf = (cn: string): string | null => {
2232
+ try {
2233
+ const pem = execFileSync('security', ['find-certificate', '-c', cn, '-p'], { encoding: 'utf8' });
2234
+ const subject = execFileSync('openssl', ['x509', '-noout', '-subject'], { input: pem, encoding: 'utf8' });
2235
+ return subject.match(/OU\s*=\s*([A-Z0-9]{10})/)?.[1] ?? null;
2236
+ } catch { return null; }
2237
+ };
2238
+ for (const prefix of ['Developer ID Application', 'Apple Development'] as const) {
2239
+ const match = entries.find((e) => e.cn.startsWith(prefix) && teamOf(e.cn) === teamId);
2240
+ if (match) return { identity: match.hash, cn: match.cn, kind: prefix === 'Developer ID Application' ? 'developer-id' : 'development', teamId };
2241
+ }
2242
+ return null;
2243
+ }
2244
+
2245
+ /** Find an installed, unexpired macOS provisioning profile for `<teamId>.<bundleId>`. macOS profiles
2246
+ * are `.provisionprofile` (same CMS envelope as iOS `.mobileprovision`; findProvisioningProfiles only
2247
+ * scans the latter, so this is the mac twin). Returns the file path, or null (→ sign without the
2248
+ * restricted push entitlement — a restricted entitlement with no embedded profile kills the app at
2249
+ * launch, which is strictly worse than a signed app without push). */
2250
+ function findMacProvisionProfile(teamId: string, bundleId: string, signingCertHash?: string): MacProfileMatch | null {
2251
+ const dirs = [
2252
+ join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles'),
2253
+ join(process.env.HOME ?? '', 'Library/Developer/Xcode/UserData/Provisioning Profiles'),
2254
+ ];
2255
+ const wanted = signingCertHash?.toUpperCase();
2256
+ for (const dir of dirs) {
2257
+ if (!existsSync(dir)) continue;
2258
+ let files: string[] = [];
2259
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.provisionprofile')); } catch { continue; }
2260
+ for (const f of files) {
2261
+ try {
2262
+ const raw = execFileSync('security', ['cms', '-D', '-i', join(dir, f)],
2263
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
2264
+ const info = parseMacProfile(raw);
2265
+ const { teamId: team, appId, expiration } = info;
2266
+ if (team !== teamId || !appId) continue;
2267
+ if (expiration && Date.parse(expiration) < Date.now()) continue;
2268
+ const profBundle = appId.startsWith(team + '.') ? appId.slice(team.length + 1) : appId;
2269
+ if (profBundle !== bundleId && profBundle !== '*') continue;
2270
+ // The chosen signing cert MUST be one the profile authorizes (DER SHA-1 == find-identity hash),
2271
+ // else codesign succeeds but the app launch is rejected. Mismatch → warn + keep scanning.
2272
+ if (wanted) {
2273
+ const authorized = info.certificates.some(
2274
+ (b64) => createHash('sha1').update(Buffer.from(b64, 'base64')).digest('hex').toUpperCase() === wanted,
2275
+ );
2276
+ if (!authorized) {
2277
+ console.log(` ⚠ profile ${f} does not authorize the chosen signing certificate — skipping`);
2278
+ continue;
2279
+ }
2280
+ }
2281
+ return { path: join(dir, f), apsEnvironment: info.apsEnvironment };
2282
+ } catch { /* skip unreadable profile */ }
2283
+ }
2284
+ }
2285
+ return null;
2286
+ }
2287
+
2288
+ /** Assemble a double-clickable `<Name>.app` around the built shell binary. The Info.plist's
2289
+ * CFBundleURLTypes is what registers `urlScheme` with LaunchServices — a bare binary can't receive
2290
+ * `<scheme>://…` opens (tauri-plugin-deep-link needs the bundle). Signed with a real identity +
2291
+ * entitlements + embedded profile when teamId resolves (resolveMacSigning); ad-hoc otherwise. */
2292
+ function assembleMacApp(cwd: string, cfg: AppwrapConfig, shell: DesktopShellConfig, outDir: string, binary: string,
2293
+ lane?: { signing: MacSigning | null; profile: MacProfileMatch | null }): string {
2294
+ const appDir = join(outDir, 'dist-app', `${shell.name}.app`);
2295
+ const macosDir = join(appDir, 'Contents/MacOS');
2296
+ const resourcesDir = join(appDir, 'Contents/Resources');
2297
+ rmSync(appDir, { recursive: true, force: true });
2298
+ mkdirSync(macosDir, { recursive: true });
2299
+ mkdirSync(resourcesDir, { recursive: true });
2300
+ cpSync(binary, join(macosDir, shell.name));
2301
+ const iconFile = generateMacIcns(cwd, cfg, resourcesDir) ?? undefined;
2302
+ // Also compile a Tahoe-native Assets.car (macOS 26 reads CFBundleIconName/Assets.car, not the .icns).
2303
+ // actool emits its own AppIcon.icns alongside — let it replace the iconutil one when present.
2304
+ const iconName = compileMacAssetsCar(cwd, cfg, resourcesDir) ?? undefined;
2305
+ writeFileSync(join(appDir, 'Contents/Info.plist'), buildInfoPlist(shell, shell.name, iconFile, iconName));
2306
+ const signing = lane ? lane.signing : resolveMacSigning(cfg);
2307
+ if (signing) {
2308
+ // Signed lane: real identity; when an installed macOS profile matches, embed it and claim the
2309
+ // push entitlement (restricted → only launches profile-backed). Developer ID gets the hardened
2310
+ // runtime (notarization prerequisite; harmless otherwise).
2311
+ const profile = lane ? lane.profile : findMacProvisionProfile(signing.teamId, shell.identifier, signing.identity);
2312
+ if (profile) cpSync(profile.path, join(appDir, 'Contents/embedded.provisionprofile'));
2313
+ else console.log(` ⓘ no macOS provisioning profile installed for ${signing.teamId}.${shell.identifier} — signing without entitlements (cert only)`);
2314
+ // aps-environment comes from the PROFILE's own entitlement, not the cert kind — a Developer ID cert
2315
+ // paired with a development profile must claim `development`, or the app is killed at launch.
2316
+ const aps = profile?.apsEnvironment;
2317
+ if (profile && aps !== 'development' && aps !== 'production') {
2318
+ console.log(` ⓘ profile has no aps-environment entitlement — signing without push`);
2319
+ }
2320
+ const args = ['--force', '--deep', '-s', signing.identity];
2321
+ // Entitlements ONLY when a profile backs them: application-identifier/team-identifier are
2322
+ // RESTRICTED — claiming them without an embedded profile makes macOS refuse to launch the app
2323
+ // ("can't be opened"). No profile → plain cert signature (launches fine, no push).
2324
+ if (profile) {
2325
+ const entitlements = buildMacEntitlements({
2326
+ teamId: signing.teamId,
2327
+ bundleId: shell.identifier,
2328
+ apsEnvironment: aps === 'development' || aps === 'production' ? aps : undefined,
2329
+ });
2330
+ const entFile = join(outDir, 'dist-app', `${shell.name}.entitlements`);
2331
+ writeFileSync(entFile, entitlements);
2332
+ args.push('--entitlements', entFile);
2333
+ }
2334
+ if (signing.kind === 'developer-id') args.push('--options', 'runtime', '--timestamp');
2335
+ console.log(`▶ codesign (${signing.cn} [${signing.identity}]${profile ? ', profile embedded' : ''})`);
2336
+ execFileSync('codesign', [...args, appDir], { stdio: 'inherit' });
2337
+ } else {
2338
+ // Ad-hoc sign so Gatekeeper/LaunchServices treat the bundle as intact on this machine.
2339
+ // Only flag a skip when a REAL team was requested (placeholder teamId = signing never asked for).
2340
+ const requested = process.env.APPWRAP_TEAM_ID?.trim() || (cfg.teamId && !/YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId) ? cfg.teamId : '');
2341
+ if (requested) console.log(` ⓘ signed lane skipped — no Developer ID / Apple Development identity for team ${requested}; ad-hoc signing`);
2342
+ execFileSync('codesign', ['--force', '--deep', '-s', '-', appDir], { stdio: 'inherit' });
2343
+ }
2344
+ return appDir;
2345
+ }
2346
+
2347
+ /** Opt-in notarization (env `APPWRAP_NOTARIZE=1`, off by default). Requires the Developer ID signed
2348
+ * lane — assembleMacApp already signs it with `--options runtime --timestamp` (hardened runtime +
2349
+ * secure timestamp, both notarization prerequisites; no get-task-allow is ever claimed). Credentials:
2350
+ * `APPWRAP_NOTARY_KEYCHAIN_PROFILE` (a `notarytool store-credentials` profile) or an ASC API key —
2351
+ * `APPWRAP_NOTARY_KEY_ID` + `APPWRAP_NOTARY_ISSUER` (+ optional `APPWRAP_NOTARY_KEY` p8 path,
2352
+ * default ~/.appstoreconnect/private_keys/AuthKey_<id>.p8). */
2353
+ function notarizeMacApp(appDir: string, signing: MacSigning | null): void {
2354
+ if (process.env.APPWRAP_NOTARIZE !== '1') return;
2355
+ if (!signing || signing.kind !== 'developer-id') {
2356
+ 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`);
2357
+ process.exit(1);
2358
+ }
2359
+ const kcProfile = process.env.APPWRAP_NOTARY_KEYCHAIN_PROFILE?.trim();
2360
+ const keyId = process.env.APPWRAP_NOTARY_KEY_ID?.trim();
2361
+ const issuer = process.env.APPWRAP_NOTARY_ISSUER?.trim();
2362
+ const keyPath = process.env.APPWRAP_NOTARY_KEY?.trim()
2363
+ || (keyId ? join(process.env.HOME ?? '', `.appstoreconnect/private_keys/AuthKey_${keyId}.p8`) : '');
2364
+ let auth: string[];
2365
+ if (kcProfile) auth = ['--keychain-profile', kcProfile];
2366
+ else if (keyId && issuer) {
2367
+ if (!existsSync(keyPath)) {
2368
+ console.error(`✖ notarization: ASC API key file not found at ${keyPath} (set APPWRAP_NOTARY_KEY to the .p8 path)`);
2369
+ process.exit(1);
2370
+ }
2371
+ auth = ['--key', keyPath, '--key-id', keyId, '--issuer', issuer];
2372
+ } else {
2373
+ 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)');
2374
+ process.exit(1);
2375
+ }
2376
+ const zip = `${appDir}.notarize.zip`;
2377
+ rmSync(zip, { force: true });
2378
+ execFileSync('ditto', ['-c', '-k', '--keepParent', appDir, zip], { stdio: 'inherit' });
2379
+ console.log('▶ notarytool submit --wait (Apple-side processing — can take several minutes)');
2380
+ try {
2381
+ execFileSync('xcrun', ['notarytool', 'submit', zip, ...auth, '--wait'], { stdio: 'inherit' });
2382
+ execFileSync('xcrun', ['stapler', 'staple', appDir], { stdio: 'inherit' });
2383
+ } finally {
2384
+ rmSync(zip, { force: true });
2385
+ }
2386
+ console.log(`✓ Notarized + stapled: ${appDir}`);
2387
+ }
2388
+
2389
+ /** `appwrap build desktop` — stamp the wrapper, `cargo build --release`, then assemble the `.app`. */
2390
+ async function buildDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
2391
+ ensureDesktopMacOS();
2392
+ const cfg = await loadConfig(cwd, flags);
2393
+ const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2394
+ // Local-server apps load the live server, not a bundled web build — skip it (regenerateDesktop
2395
+ // stages a boot splash for them). Otherwise build/stage the PWA as usual.
2396
+ if (!cfg.desktop?.server) buildWebIfBundled(cwd, cfg, flags);
2397
+ // Resolve the signing lane BEFORE regeneration: shell_config.json is embedded at compile time,
2398
+ // and the Rust shell's push capability must only claim 'native' when the .app really ships the
2399
+ // profile-backed aps-environment entitlement (signed lane + installed macOS profile).
2400
+ const signing = resolveMacSigning(cfg);
2401
+ const shellCfg = deriveDesktopConfig(cfg);
2402
+ const profile = signing ? findMacProvisionProfile(signing.teamId, shellCfg.identifier, signing.identity) : null;
2403
+ // Only let the Rust shell claim NATIVE push when the app will actually ship a profile-backed
2404
+ // aps-environment entitlement (profile matched AND it carries the entitlement).
2405
+ const nativePush = !!(signing && profile && (profile.apsEnvironment === 'development' || profile.apsEnvironment === 'production'));
2406
+ regenerateDesktop(cwd, cfg, outDir, nativePush);
2407
+ const { bin, env } = desktopCargo();
2408
+ console.log(`▶ cargo build --release (appwrap desktop shell — ${cfg.name})`);
2409
+ execFileSync(bin, ['build', '--release'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
2410
+ const binary = join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target/release/appwrap-desktop-shell');
2411
+ console.log(`✓ Desktop binary: ${binary}`);
2412
+ const appDir = assembleMacApp(cwd, cfg, shellCfg, outDir, binary, { signing, profile });
2413
+ console.log(`✓ App bundle: ${appDir}`);
2414
+ notarizeMacApp(appDir, signing);
2415
+ }
2416
+
1935
2417
  async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1936
2418
  const platform = positionals[0];
2419
+ if (platform === 'desktop') return buildDesktop(cwd, flags);
1937
2420
  if (platform !== 'ios' && platform !== 'android') {
1938
- console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config <path>] [--out native]');
2421
+ console.error('Usage: appwrap build <ios|android|desktop> [--release] [--aab] [--config <path>] [--out native]');
1939
2422
  process.exit(1);
1940
2423
  }
1941
2424
  const outDir = resolve(cwd, flags.out ?? 'native');
@@ -2691,9 +3174,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2691
3174
  const force = 'force' in flags || 'f' in flags;
2692
3175
  const fp = buildFingerprint(cwd, cfg, flags);
2693
3176
  const cache = readBuildCache(outDir, 'ios');
2694
- const existingIpa = existsSync(ipaDir)
2695
- ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
2696
- : undefined;
3177
+ const existingIpa = newestIpa(ipaDir);
2697
3178
  const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.artifactPath === join(ipaDir, existingIpa);
2698
3179
  // --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
2699
3180
  // but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
@@ -2712,24 +3193,45 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2712
3193
  if (sc.provision) nsArgs.push('--provision', sc.provision);
2713
3194
  } catch { /* no sidecar → automatic signing */ }
2714
3195
  console.log(`▶ ns ${nsArgs.join(' ')} (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
3196
+ const buildStart = Date.now();
2715
3197
  try {
2716
3198
  execFileSync('ns', nsArgs, { cwd: outDir, stdio: 'inherit' });
2717
3199
  } catch (e) {
2718
- // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
2719
- console.error(
2720
- '\n✖ Device build failed — if the errors above mention signing:\n' +
2721
- ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
2722
- ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
2723
- ' unique string and re-deploy.\n' +
2724
- ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
2725
- ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
2726
- ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
2727
- );
2728
- process.exit(1);
3200
+ // exportArchive is the ACCOUNT-GATED step: with special entitlements (e.g. Associated Domains)
3201
+ // automatic-signing export must (re)provision via App Store Connect, which needs a live Xcode
3202
+ // account session — one Xcode 26 keeps silently dropping. The ARCHIVE itself signs fine from
3203
+ // locally cached profiles. So when a fresh signed .app exists in the xcarchive, resign it with
3204
+ // a local Xcode-managed dev profile + pack the ipa ourselves — no account, no network.
3205
+ const archiveApp = join(ipaDir, 'native.xcarchive/Products/Applications/native.app');
3206
+ let resigned = false;
3207
+ if (existsSync(archiveApp) && statSync(archiveApp).mtimeMs >= buildStart) {
3208
+ console.error('\n⚠ Export failed but the archive is built + signed — resigning from the archive with a local dev profile (no Apple account session needed)…');
3209
+ try {
3210
+ resigned = resignArchiveIpa(archiveApp, join(ipaDir, 'native.ipa'), cfg, device.id);
3211
+ } catch (fe) {
3212
+ console.error(` resign fallback failed: ${fe instanceof Error ? fe.message : fe}`);
3213
+ }
3214
+ }
3215
+ if (!resigned) {
3216
+ // The xcodebuild dump above is cryptic; surface the signing failures we actually hit most.
3217
+ console.error(
3218
+ '\n✖ Device build failed — if the errors above mention signing:\n' +
3219
+ ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
3220
+ ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
3221
+ ' unique string and re-deploy.\n' +
3222
+ ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
3223
+ ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
3224
+ ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.\n' +
3225
+ ' • "requires a provisioning profile with the … feature" → the resign fallback above needs a\n' +
3226
+ ' LOCAL dev profile carrying that capability: build the app once from Xcode on this Mac\n' +
3227
+ ' (auto-signing mints + caches the profile for ~1 year), then re-run `appwrap deploy ios`.'
3228
+ );
3229
+ process.exit(1);
3230
+ }
2729
3231
  }
2730
3232
  }
2731
3233
 
2732
- const builtIpa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
3234
+ const builtIpa = newestIpa(ipaDir);
2733
3235
  const ipa = builtIpa;
2734
3236
  if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
2735
3237
  const ipaPath = join(ipaDir, ipa);
@@ -2744,8 +3246,12 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2744
3246
  const installTimeout = wireless ? '120' : '25';
2745
3247
  // Capture (not inherit) so we can recognize specific failures; echo it for visibility. Wrapped so a
2746
3248
  // LOCKED device waits-and-retries. --timeout lets one attempt tolerate a brief locked/unavailable window.
3249
+ // BELT + SUSPENDERS on hangs: devicectl's own `--timeout` only covers device-side waits. A wedged
3250
+ // CoreDeviceService makes devicectl hang BEFORE that — silently, with zero output — so without a
3251
+ // process-level `timeout:` the exec blocks forever and the self-heal below never fires.
3252
+ const hardTimeoutMs = (Number(installTimeout) + 35) * 1000;
2747
3253
  const runDevicectlInstall = (): string => withUnlockRetry('Install', () =>
2748
- execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', installTimeout, '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
3254
+ execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', installTimeout, '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], timeout: hardTimeoutMs })
2749
3255
  );
2750
3256
  try {
2751
3257
  process.stdout.write(runDevicectlInstall());
@@ -2764,8 +3270,9 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2764
3270
  ' (A paid Apple Developer account removes this limit.)'
2765
3271
  );
2766
3272
  process.exit(1);
2767
- } else if (/Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) {
2768
- // devicectl/CoreDevice is stuck (connection hang, NOT a lock). Usual cause: a pegged
3273
+ } else if (isTimeoutKill(e) || /Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) {
3274
+ // devicectl/CoreDevice is stuck — either it SAID so, or our process-level timeout had to kill a
3275
+ // silent zero-output hang (the wedged-CoreDeviceService signature). Usual cause: a pegged
2769
3276
  // CoreDeviceService daemon. SELF-HEAL: kill it (auto-respawns clean) and retry devicectl once —
2770
3277
  // this is the fix a human otherwise applies by hand, and it's the ONLY path that works for a
2771
3278
  // wireless-only device (usbmux is USB-only).
@@ -2812,7 +3319,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2812
3319
  // old serverUrl) despite a correct new build — masquerading as a build/cache bug. (A reinstall
2813
3320
  // over the top does NOT replace a running process; this avoids the manual uninstall dance.)
2814
3321
  withUnlockRetry('Launch', () =>
2815
- execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout', installTimeout, '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
3322
+ execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout', installTimeout, '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], timeout: hardTimeoutMs })
2816
3323
  );
2817
3324
  } catch {
2818
3325
  console.error('⚠ Launch failed (still locked after waiting). The app is installed — unlock and tap it, or re-run.');
@@ -2828,7 +3335,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2828
3335
  * fails (the caller then prints the re-plug / brew-install remedy). */
2829
3336
  function usbmuxInstall(ipaPath: string): boolean {
2830
3337
  try {
2831
- const out = execFileSync('ideviceinstaller', ['install', ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
3338
+ const out = execFileSync('ideviceinstaller', ['install', ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], timeout: 300_000 });
2832
3339
  process.stdout.write(out);
2833
3340
  return /Complete|Installed/i.test(out);
2834
3341
  } catch (e: unknown) {
@@ -2837,18 +3344,125 @@ function usbmuxInstall(ipaPath: string): boolean {
2837
3344
  }
2838
3345
  }
2839
3346
 
3347
+ /** Newest .ipa in a build dir by mtime — NOT the first directory entry: an export names its ipa after
3348
+ * the app (e.g. Blank.ipa) while the resign fallback writes native.ipa, and a stale one from a prior
3349
+ * run can coexist with (and alphabetically precede) the fresh artifact. */
3350
+ function newestIpa(ipaDir: string): string | undefined {
3351
+ if (!existsSync(ipaDir)) return undefined;
3352
+ return readdirSync(ipaDir)
3353
+ .filter((f) => f.endsWith('.ipa'))
3354
+ .map((f) => ({ f, mtime: statSync(join(ipaDir, f)).mtimeMs }))
3355
+ .sort((a, b) => b.mtime - a.mtime)[0]?.f;
3356
+ }
3357
+
3358
+ /** Resign the archive's .app with a LOCAL Xcode-managed development profile and pack it into an ipa —
3359
+ * the no-account fallback for when `xcodebuild -exportArchive` fails (its automatic signing needs a
3360
+ * live Xcode account session; manual export refuses Xcode-managed profiles, so exportArchive is a
3361
+ * dead end both ways without one). Profile requirements: matches team+bundle id, development
3362
+ * (get-task-allow), unexpired, carries every configured entitlement key, includes the target device,
3363
+ * and lists a signing cert that is actually in the login keychain. Gotcha encoded here: the ipa MUST
3364
+ * be packed with `zip` — `ditto -c -k` stores macOS xattrs as `._*` AppleDouble entries, which
3365
+ * on-device installd rejects as "a signed resource has been added" (0xe8008017).
3366
+ * Returns true on success (ipa written to `ipaOut`); false → caller prints the manual remedies. */
3367
+ function resignArchiveIpa(archiveApp: string, ipaOut: string, cfg: AppwrapConfig, coreDeviceId: string): boolean {
3368
+ if (existsSync(join(archiveApp, 'PlugIns'))) {
3369
+ console.error(' ✖ app has extensions (PlugIns/) — per-extension resigning is not supported; use the TestFlight lane.');
3370
+ return false;
3371
+ }
3372
+ const requiredEnts = Object.keys(cfg.iosEntitlements ?? {});
3373
+ const hwUdid = libimobiledeviceUdid()?.udid ?? null; // hardware UDID (≠ devicectl id); null → skip device check
3374
+ if (!hwUdid) console.error(` ⚠ can't read the device hardware UDID (idevice_id) — picking a profile without verifying it covers device ${coreDeviceId}.`);
3375
+
3376
+ // Signing certs actually present in the keychain, by SHA-1 (uppercase hex).
3377
+ const keychainCerts = new Set(
3378
+ [...execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' })
3379
+ .matchAll(/\b([0-9A-F]{40})\b/g)].map((m) => m[1])
3380
+ );
3381
+
3382
+ const profileDirs = [
3383
+ join(process.env.HOME ?? '', 'Library/Developer/Xcode/UserData/Provisioning Profiles'),
3384
+ join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles'),
3385
+ ];
3386
+ let best: { path: string; raw: string; team: string; certSha1: string; expires: number; name: string } | null = null;
3387
+ for (const dir of profileDirs) {
3388
+ if (!existsSync(dir)) continue;
3389
+ for (const f of readdirSync(dir).filter((f) => f.endsWith('.mobileprovision'))) {
3390
+ const path = join(dir, f);
3391
+ let raw: string;
3392
+ try { raw = execFileSync('security', ['cms', '-D', '-i', path], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); }
3393
+ catch { continue; }
3394
+ const appId = raw.match(/<key>application-identifier<\/key>\s*<string>([A-Z0-9]+)\.([^<]+)<\/string>/);
3395
+ if (!appId || appId[2] !== cfg.id) continue;
3396
+ if (cfg.teamId && appId[1] !== cfg.teamId) continue;
3397
+ if (!/<key>get-task-allow<\/key>\s*<true\/>/.test(raw)) continue; // development profile only
3398
+ const exp = raw.match(/<key>ExpirationDate<\/key>\s*<date>([^<]+)<\/date>/);
3399
+ const expires = exp ? Date.parse(exp[1]) : 0;
3400
+ if (expires < Date.now()) continue;
3401
+ if (!requiredEnts.every((k) => raw.includes(`<key>${k}</key>`))) continue;
3402
+ if (hwUdid && !raw.includes(`<string>${hwUdid}</string>`)) continue;
3403
+ // First profile cert that is also in the keychain (profiles list several historical certs).
3404
+ const certsBlock = raw.split('<key>DeveloperCertificates</key>')[1]?.split('</array>')[0] ?? '';
3405
+ const certSha1 = [...certsBlock.matchAll(/<data>([\s\S]*?)<\/data>/g)]
3406
+ .map((m) => createHash('sha1').update(Buffer.from(m[1].replace(/\s+/g, ''), 'base64')).digest('hex').toUpperCase())
3407
+ .find((h) => keychainCerts.has(h));
3408
+ if (!certSha1) continue;
3409
+ const name = raw.match(/<key>Name<\/key>\s*<string>([^<]+)<\/string>/)?.[1] ?? f;
3410
+ if (!best || expires > best.expires) best = { path, raw, team: appId[1], certSha1, expires, name };
3411
+ }
3412
+ }
3413
+ if (!best) {
3414
+ console.error(` ✖ no local development profile matches ${cfg.id}${requiredEnts.length ? ` with [${requiredEnts.join(', ')}]` : ''} for this device.`);
3415
+ return false;
3416
+ }
3417
+ console.error(` ↻ using profile "${best.name}" (expires ${new Date(best.expires).toISOString().slice(0, 10)}) + keychain cert ${best.certSha1.slice(0, 8)}…`);
3418
+
3419
+ // Stage Payload/, swap in the profile, sign with entitlements derived from config + profile.
3420
+ const staging = join(tmpdir(), `appwrap-resign-${process.pid}`);
3421
+ rmSync(staging, { recursive: true, force: true });
3422
+ const stagedApp = join(staging, 'Payload/native.app');
3423
+ mkdirSync(join(staging, 'Payload'), { recursive: true });
3424
+ cpSync(archiveApp, stagedApp, { recursive: true });
3425
+ cpSync(best.path, join(stagedApp, 'embedded.mobileprovision'));
3426
+ const plistValue = (v: unknown): string => Array.isArray(v)
3427
+ ? `<array>${v.map((x) => `<string>${x}</string>`).join('')}</array>`
3428
+ : typeof v === 'boolean' ? (v ? '<true/>' : '<false/>') : `<string>${String(v)}</string>`;
3429
+ const entPlist = join(staging, 'entitlements.plist');
3430
+ writeFileSync(entPlist,
3431
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
3432
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
3433
+ '<plist version="1.0">\n<dict>\n' +
3434
+ `\t<key>application-identifier</key><string>${best.team}.${cfg.id}</string>\n` +
3435
+ `\t<key>com.apple.developer.team-identifier</key><string>${best.team}</string>\n` +
3436
+ '\t<key>get-task-allow</key><true/>\n' +
3437
+ `\t<key>keychain-access-groups</key><array><string>${best.team}.*</string></array>\n` +
3438
+ Object.entries(cfg.iosEntitlements ?? {}).map(([k, v]) => `\t<key>${k}</key>${plistValue(v)}\n`).join('') +
3439
+ '</dict>\n</plist>\n');
3440
+ // Nested code first (sealed into the app signature), then the app itself with the entitlements.
3441
+ const frameworksDir = join(stagedApp, 'Frameworks');
3442
+ const nested = existsSync(frameworksDir) ? readdirSync(frameworksDir).filter((f) => /\.(framework|dylib)$/.test(f)) : [];
3443
+ for (const f of nested) execFileSync('codesign', ['-f', '-s', best.certSha1, '--timestamp=none', join(frameworksDir, f)], { stdio: 'pipe' });
3444
+ execFileSync('codesign', ['-f', '-s', best.certSha1, '--timestamp=none', '--entitlements', entPlist, stagedApp], { stdio: 'pipe' });
3445
+ execFileSync('codesign', ['--verify', '--deep', '--strict', stagedApp], { stdio: 'pipe' });
3446
+ rmSync(ipaOut, { force: true });
3447
+ execFileSync('zip', ['-qry', ipaOut, 'Payload'], { cwd: staging, stdio: 'pipe' }); // zip, NOT ditto — see docblock
3448
+ rmSync(staging, { recursive: true, force: true });
3449
+ console.error(` ✓ resigned ipa ready: ${ipaOut}`);
3450
+ return true;
3451
+ }
3452
+
2840
3453
  /** Apple's `CoreDeviceService` daemon intermittently pegs a CPU core at 100% and wedges the device
2841
3454
  * tunnel — `devicectl` then hangs until its timeout (worse on a wireless/localNetwork target). Killing
2842
- * it is the documented remedy: launchd auto-respawns it clean. Best-effort; returns true if we
2843
- * signalled a matching process (pkill exits non-zero → throws → false when nothing matched). */
3455
+ * it is the documented remedy: launchd auto-respawns it clean. `remotepairingd` (also user-owned)
3456
+ * holds the pairing side of the same tunnel and wedges WITH it — kill both; restarting only one is
3457
+ * not enough to recover a wedged stack. Returns true if either kill matched a process. */
2844
3458
  function killStuckCoreDeviceService(): boolean {
2845
- try {
2846
- execFileSync('pkill', ['-9', '-f', 'CoreDeviceService.xpc'], { stdio: 'ignore' });
2847
- sleepSync(4000); // let launchd respawn it + re-establish the tunnel before we retry
2848
- return true;
2849
- } catch {
2850
- return false;
3459
+ let matched = false;
3460
+ for (const pattern of ['CoreDeviceService.xpc', 'remotepairingd']) {
3461
+ try { execFileSync('pkill', ['-9', '-f', pattern], { stdio: 'ignore' }); matched = true; }
3462
+ catch { /* pkill exits non-zero when nothing matched */ }
2851
3463
  }
3464
+ if (matched) sleepSync(4000); // let launchd respawn them + re-establish the tunnel before we retry
3465
+ return matched;
2852
3466
  }
2853
3467
 
2854
3468
  /** Run a devicectl op; if it fails because the device is LOCKED (or transiently unavailable), prompt
@@ -2862,9 +3476,10 @@ function withUnlockRetry<T>(label: string, run: () => T, tries = 40, delayMs = 3
2862
3476
  } catch (e: unknown) {
2863
3477
  const log = execErrText(e);
2864
3478
  if (/maximum number of installed apps|MIInstallerErrorDomain error 13|ApplicationVerificationFailed/.test(log)) throw e;
2865
- // devicectl/CoreDevice tunnel STUCK (it switches to a [wired] path and hangs) is NOT a lock —
2866
- // retrying as "waiting for unlock" is pointless + misleading. Re-throw so the caller can fall back.
2867
- if (/Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) throw e;
3479
+ // devicectl/CoreDevice tunnel STUCK (it switches to a [wired] path and hangs, or hangs silently
3480
+ // until our process-level timeout kills it) is NOT a lock — retrying as "waiting for unlock" is
3481
+ // pointless + misleading. Re-throw so the caller can self-heal / fall back.
3482
+ if (isTimeoutKill(e) || /Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) throw e;
2868
3483
  if (i >= tries) throw e;
2869
3484
  if (i === 0) process.stdout.write(`\n🔒 ${label}: device unavailable — unlock your iPhone. Waiting (auto-retries every ${delayMs / 1000}s, up to ${Math.round((tries * delayMs) / 1000)}s)…\n`);
2870
3485
  else process.stdout.write(` …waiting for unlock (${i}/${tries})\n`);
@@ -2955,10 +3570,21 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
2955
3570
  console.log(`▶ watching web logs from ${cfg.id} on ${device.name} (pull every 3s) — Ctrl-C to stop.`);
2956
3571
  console.log(' [appwrap-web] = forwarded WebView console/errors. (--once = snapshot, --native = OS firehose.)');
2957
3572
  let shown = 0;
3573
+ // Self-terminate when orphaned: if our controlling session dies, macOS reparents us to launchd and
3574
+ // this loop would otherwise poll `devicectl` FOREVER — every stale session stacking load until
3575
+ // CoreDeviceService pins a core at 100%. NOTE: `process.ppid` is cached at startup and does NOT
3576
+ // update on reparent, so probe the ORIGINAL parent's liveness with signal 0 (throws once it's gone);
3577
+ // also stop on a broken stdout pipe (consumer gone).
3578
+ const parentPid = process.ppid;
3579
+ const orphaned = (): boolean => { try { process.kill(parentPid, 0); return false; } catch { return true; } };
2958
3580
  for (;;) {
3581
+ if (orphaned()) break; // original parent gone → we've been reparented → stop
2959
3582
  const all = pull();
2960
3583
  if (all.length < shown) shown = 0; // app relaunched → file reset; reprint
2961
- if (all.length > shown) { process.stdout.write(all.slice(shown)); shown = all.length; }
3584
+ if (all.length > shown) {
3585
+ try { process.stdout.write(all.slice(shown)); } catch { break; } // pipe closed → consumer gone
3586
+ shown = all.length;
3587
+ }
2962
3588
  try { execFileSync('sleep', ['3']); } catch { break; }
2963
3589
  }
2964
3590
  }
@@ -3060,10 +3686,11 @@ async function main(): Promise<void> {
3060
3686
  ' Android wireless: --wifi flips a USB device to wireless adb (unplug + keep going); thereafter the\n' +
3061
3687
  ' device is auto-discovered via mDNS — plain `dev android` finds it with NO flag (iOS parity).\n' +
3062
3688
  ' --device <ip[:port]> `adb connect`s an already-paired one.\n\n' +
3063
- ' dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
3689
+ ' dev <ios|android|desktop> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
3064
3690
  ' live-dev: ANDROID device → ns run livesync (true on-device HMR) + re-stage on save;\n' +
3065
3691
  ' iOS device → deploy + rebuild/reinstall on save. --sim = ns run/HMR on emulator;\n' +
3066
3692
  ' --url/--port = web HMR from a dev server inside the WebView; --detached = install & exit.\n' +
3693
+ ' desktop → macOS Tauri shell (cargo run) speaking appwrap protocol v1 (phase-0, macOS-only).\n' +
3067
3694
  ' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
3068
3695
  ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
3069
3696
  ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +