@livx.cc/appwrap 0.42.3 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -2,16 +2,17 @@
2
2
  /**
3
3
  * appwrap CLI v0 — scaffold a native wrapper around a built PWA.
4
4
  *
5
- * appwrap init [--config <path>] [--out native] # from the PWA project dir
5
+ * appwrap init [--config <path>] [--out native] [--ci] # from the PWA project dir; --ci also scaffolds GH Actions workflows
6
6
  * appwrap sync [--config <path>] [--out native] # re-copy PWA dist into the wrapper
7
7
  *
8
8
  * Config (TS preferred, JSON fallback) — probed in order: appwrap.config.ts → .js → appwrap.json.
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 {
@@ -1414,22 +1437,25 @@ export function isFrameworkRepo(root: string): boolean {
1414
1437
  return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
1415
1438
  }
1416
1439
 
1417
- /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/).
1440
+ /** Emit CI scaffolding (GH Actions → git repo root ONLY with --ci, fastlane → native/).
1441
+ * Writing `.github/workflows` into a consumer repo is a repo-level side effect (it can shadow or
1442
+ * clash with the project's own CI), so it is OPT-IN via `appwrap init --ci` — never implicit.
1418
1443
  * GH workflows are never overwritten (users may customize them). The fastlane lane IS appwrap-managed
1419
1444
  * (the release recipe, not for hand-editing — see AGENTS.md), so it is RE-EMITTED on `--force` to keep
1420
1445
  * the recipe current after a framework upgrade; without --force it's still first-time-only. */
1421
- function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false): void {
1446
+ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false, withWorkflows = false): void {
1422
1447
  if (!existsSync(CI_TEMPLATE_DIR)) return;
1423
1448
  const repoRoot = gitRoot(cwd);
1424
1449
  // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
1425
1450
  // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
1426
1451
  // [from, to, overwritable]
1427
1452
  const targets: Array<[string, string, boolean]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane'), force]];
1428
- // …but if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold consumer
1429
- // workflows into the framework's .github — that's the stray-workflow-each-init bug.
1430
- if (isFrameworkRepo(repoRoot)) {
1453
+ // Even with --ci: if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold
1454
+ // consumer workflows into the framework's .github — that's the stray-workflow-each-init bug.
1455
+ const scaffoldWorkflows = withWorkflows && !isFrameworkRepo(repoRoot);
1456
+ if (withWorkflows && !scaffoldWorkflows) {
1431
1457
  console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
1432
- } else {
1458
+ } else if (scaffoldWorkflows) {
1433
1459
  targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows'), false]);
1434
1460
  }
1435
1461
  for (const [from, to, overwrite] of targets) {
@@ -1439,7 +1465,7 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
1439
1465
  // Pin the emitted workflow's `bunx @livx.cc/appwrap@^x.y.z` to THIS CLI's version floor, so a CI run
1440
1466
  // using a freshly-emitted workflow can't silently resolve an older published build that lacks the
1441
1467
  // `init`/`release` commands (it would 404 loudly instead). Idempotent: re-init finds no placeholder.
1442
- if (!isFrameworkRepo(repoRoot)) {
1468
+ if (scaffoldWorkflows) {
1443
1469
  const wf = join(repoRoot, '.github/workflows/appwrap-release-ios.yml');
1444
1470
  if (existsSync(wf)) writeFileSync(wf, readFileSync(wf, 'utf8').replaceAll('__APPWRAP_VERSION__', CLI_VERSION));
1445
1471
  }
@@ -1454,9 +1480,9 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
1454
1480
  .replaceAll('__TEAM_ID__', cfg.teamId ?? '');
1455
1481
  writeFileSync(p, stamped);
1456
1482
  }
1457
- const ci = isFrameworkRepo(repoRoot)
1458
- ? ' ci ← fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
1459
- : ' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers';
1483
+ const ci = scaffoldWorkflows
1484
+ ? ' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
1485
+ : ' ci ← fastlane (native/fastlane, signing stamped) — GH Actions workflows are opt-in: `appwrap init --ci`';
1460
1486
  console.log(ci);
1461
1487
  }
1462
1488
 
@@ -1577,7 +1603,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1577
1603
  console.log(`🎁 appwrap init → ${outDir}`);
1578
1604
  mkdirSync(outDir, { recursive: true });
1579
1605
  regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
1580
- copyCiTemplates(cwd, outDir, cfg, 'force' in flags); // GH workflows: first-time only; fastlane lane: re-emit on --force
1606
+ copyCiTemplates(cwd, outDir, cfg, 'force' in flags, 'ci' in flags); // GH workflows: opt-in (--ci), first-time only; fastlane lane: re-emit on --force
1581
1607
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1582
1608
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
1583
1609
  stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
@@ -1670,10 +1696,11 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
1670
1696
  */
1671
1697
  async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1672
1698
  const platform = positionals[0];
1699
+ if (platform === 'desktop') return devDesktop(cwd, flags);
1673
1700
  const sim = 'sim' in flags || positionals[1] === 'sim';
1674
1701
  const wantDebug = 'debug' in flags;
1675
1702
  if (platform !== 'ios' && platform !== 'android') {
1676
- 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>]');
1677
1704
  process.exit(1);
1678
1705
  }
1679
1706
  const cfg = await loadConfig(cwd, flags);
@@ -1929,10 +1956,423 @@ async function watchAndSync(cwd: string, flags: Record<string, string>, outDir:
1929
1956
  * Android signing comes from env (APPWRAP_ANDROID_KEYSTORE[_PASSWORD|_ALIAS|_ALIAS_PASSWORD]) — secrets
1930
1957
  * never live in the appwrap config. iOS distribution signing/upload is the fastlane release lane's job (the
1931
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
+ /** Copy the desktop template into `native-desktop/` and stamp it from the config: the Rust-read
1983
+ * `shell_config.json` (identity + window), `tauri.conf.json` (productName/version/identifier +
1984
+ * frontendDist), and the staged web dist (`native-desktop/dist`, referenced as `../dist`). */
1985
+ function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, pushSigned = false): void {
1986
+ if (!existsSync(DESKTOP_TEMPLATE_DIR)) {
1987
+ console.error(`✖ Desktop template not found at ${DESKTOP_TEMPLATE_DIR}`);
1988
+ process.exit(1);
1989
+ }
1990
+ const srcTauri = join(outDir, 'src-tauri');
1991
+ mkdirSync(outDir, { recursive: true });
1992
+ cpSync(DESKTOP_TEMPLATE_DIR, outDir, { recursive: true, force: true, filter: desktopCopyFilter });
1993
+
1994
+ // Stage the built web dist INTO the wrapper (mirrors mobile copyPwa) so frontendDist is a stable
1995
+ // relative path independent of where the app project sits.
1996
+ const dist = resolve(cwd, cfg.pwaDist);
1997
+ if (!existsSync(dist)) {
1998
+ console.error(`✖ Web dist not found at ${dist} — build the PWA first (or check pwaDist).`);
1999
+ process.exit(1);
2000
+ }
2001
+ const stagedDist = join(outDir, 'dist');
2002
+ rmSync(stagedDist, { recursive: true, force: true });
2003
+ cpSync(dist, stagedDist, { recursive: true, force: true });
2004
+
2005
+ const shell = deriveDesktopConfig(cfg);
2006
+ shell.pushSigned = pushSigned;
2007
+ // Resolve the handlers path to ABSOLUTE (against the app root): the shell binary runs from a
2008
+ // different cwd, and for `build desktop` the .app runs from wherever it's installed — an absolute
2009
+ // path is the only one that stays valid. The handler file is NOT copied into the wrapper/.app; the
2010
+ // app dir (with its node_modules) must remain present at runtime (documented in the config type).
2011
+ if (shell.handlers) {
2012
+ const abs = resolve(cwd, shell.handlers);
2013
+ if (!existsSync(abs)) {
2014
+ console.warn(`⚠ desktop.handlers points to a missing file (${abs}) — the sidecar will not spawn.`);
2015
+ }
2016
+ shell.handlers = abs;
2017
+ // Stamp the ABSOLUTE Bun binary the sidecar must be spawned with: the CLI itself runs under bun,
2018
+ // so process.execPath IS the exact runtime the app was built with. A GUI-launched .app inherits
2019
+ // the launchd PATH (no ~/.bun/bin), so the Rust shell can't rely on a bare `bun` lookup.
2020
+ shell.handlersRuntime = process.execPath;
2021
+ }
2022
+ writeFileSync(join(srcTauri, 'shell_config.json'), JSON.stringify(shell, null, 2) + '\n');
2023
+
2024
+ const tauriConfPath = join(srcTauri, 'tauri.conf.json');
2025
+ const template = JSON.parse(readFileSync(tauriConfPath, 'utf8')) as Record<string, unknown>;
2026
+ const stamped = stampTauriConf(template, shell, '../dist'); // relative to src-tauri/
2027
+ writeFileSync(tauriConfPath, JSON.stringify(stamped, null, 2) + '\n');
2028
+
2029
+ stampDesktopRuntimeIcon(cwd, cfg, join(srcTauri, 'icons', 'icon.png'));
2030
+ }
2031
+
2032
+ /** Stamp the Tauri RUNTIME icon (`src-tauri/icons/icon.png`, embedded via `generate_context!`) from
2033
+ * the app's real icon — else it OVERRIDES the bundle icns/Assets.car in cmd+tab/Dock with the template
2034
+ * placeholder square. Downscale to 512 via sips → round the macOS app-icon corners + ensure RGBA (our
2035
+ * dependency-free codec) → write. On any failure, keep the template placeholder (Tauri needs *some*
2036
+ * RGBA icon.png to compile) and warn — never fail the build over an icon. */
2037
+ function stampDesktopRuntimeIcon(cwd: string, cfg: AppwrapConfig, destPng: string): void {
2038
+ const source = findIconSource(cwd, cfg);
2039
+ if (!source) return; // no cfg.icon / manifest icon → keep template placeholder
2040
+ const tmpPng = join(tmpdir(), `appwrap-desktop-icon-${Date.now()}.png`);
2041
+ try {
2042
+ // Artwork at Apple's icon-grid fraction of the canvas, then pad — full-bleed runtime icons
2043
+ // render visibly larger than bundle-masked Dock neighbors (no OS-applied margin/mask).
2044
+ const art = Math.round(512 * APPLE_ICON_GRID_SCALE);
2045
+ execFileSync('sips', ['-s', 'format', 'png', '-z', String(art), String(art), source, '--out', tmpPng], { stdio: 'ignore' });
2046
+ writeFileSync(destPng, makeDockRuntimeIcon(readFileSync(tmpPng), 512));
2047
+ console.log(` desktop runtime icon ← ${source} (${art}² rounded on 512² canvas)`);
2048
+ } catch (e: unknown) {
2049
+ console.warn(`⚠ desktop runtime icon stamp failed (${e instanceof Error ? e.message : String(e)}) — keeping placeholder`);
2050
+ } finally {
2051
+ rmSync(tmpPng, { force: true });
2052
+ }
2053
+ }
2054
+
2055
+ /** Shared target dir for the desktop crate — reuse the template's warm `target/` so a stamped copy
2056
+ * builds incrementally (~seconds) instead of a ~4-min cold compile of the Tauri dep tree. */
2057
+ /** Resolve a spawnable `cargo` (bin + PATH-augmented env) for the desktop lane. rustup installs into
2058
+ * `~/.cargo/bin`, which a GUI/login shell often lacks on PATH → a bare spawn throws a raw ENOENT. We
2059
+ * append it when the binary lives there, else fail with an actionable install/source message. */
2060
+ function desktopCargo(): { bin: string; env: NodeJS.ProcessEnv } {
2061
+ const home = process.env.HOME ?? '';
2062
+ const res = resolveCargoPath({
2063
+ path: process.env.PATH ?? '',
2064
+ home,
2065
+ delimiter: pathDelimiter,
2066
+ hasCargo: (dir) => existsSync(join(dir, 'cargo')),
2067
+ });
2068
+ if (!res.found) {
2069
+ console.error(
2070
+ `✖ cargo not found on PATH${home ? ` (and no ${home}/.cargo/bin/cargo)` : ''}.\n` +
2071
+ ` Install the Rust toolchain via rustup: https://rustup.rs\n` +
2072
+ ` If it's already installed, add it to this shell: source "$HOME/.cargo/env"`
2073
+ );
2074
+ process.exit(1);
2075
+ }
2076
+ if (res.augmented) console.log(` cargo ← ${home}/.cargo/bin (appended to PATH; not in your shell's PATH)`);
2077
+ return {
2078
+ bin: res.bin,
2079
+ env: { ...process.env, PATH: res.path, CARGO_TARGET_DIR: join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target') },
2080
+ };
2081
+ }
2082
+
2083
+ /** `appwrap dev desktop` — stamp the wrapper + `cargo run` (launches the window; blocks until closed). */
2084
+ async function devDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
2085
+ ensureDesktopMacOS();
2086
+ const cfg = await loadConfig(cwd, flags);
2087
+ const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2088
+ buildWebIfBundled(cwd, cfg, flags);
2089
+ regenerateDesktop(cwd, cfg, outDir);
2090
+ const { bin, env } = desktopCargo();
2091
+ console.log(`▶ cargo run (appwrap desktop shell — ${cfg.name})`);
2092
+ execFileSync(bin, ['run'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
2093
+ }
2094
+
2095
+ /** Generate `AppIcon.icns` from the app's icon PNG via `sips` + `iconutil` (macOS-only, like the whole
2096
+ * desktop lane). Returns the icns basename, or null when no icon source / tooling — skip gracefully. */
2097
+ function generateMacIcns(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
2098
+ const source = findIconSource(cwd, cfg);
2099
+ if (!source) return null;
2100
+ const iconset = join(tmpdir(), `appwrap-icns-${Date.now()}.iconset`);
2101
+ mkdirSync(iconset, { recursive: true });
2102
+ try {
2103
+ for (const px of [16, 32, 128, 256, 512]) {
2104
+ execFileSync('sips', ['-z', String(px), String(px), source, '--out', join(iconset, `icon_${px}x${px}.png`)], { stdio: 'ignore' });
2105
+ execFileSync('sips', ['-z', String(px * 2), String(px * 2), source, '--out', join(iconset, `icon_${px}x${px}@2x.png`)], { stdio: 'ignore' });
2106
+ }
2107
+ execFileSync('iconutil', ['-c', 'icns', iconset, '-o', join(resourcesDir, 'AppIcon.icns')], { stdio: 'ignore' });
2108
+ return 'AppIcon.icns';
2109
+ } catch (e: unknown) {
2110
+ console.warn(`⚠ icns generation failed (${e instanceof Error ? e.message : String(e)}) — .app ships without an icon`);
2111
+ return null;
2112
+ } finally {
2113
+ rmSync(iconset, { recursive: true, force: true });
2114
+ }
2115
+ }
2116
+
2117
+ /** Compile a Tahoe-native `Assets.car` (+ its own `AppIcon.icns`) from the app's icon via `actool`,
2118
+ * into `resourcesDir`. macOS 26 (Tahoe) resolves a bundle's Finder/Dock icon through
2119
+ * `CFBundleIconName` + `Assets.car`, NOT the legacy top-level `.icns`. Returns the asset icon name
2120
+ * (`AppIcon`) on success, or null when there's no icon / `xcrun`/`actool` is unavailable or fails —
2121
+ * caller then keeps the iconutil `.icns` path (fine pre-26; Tahoe Finder icon may look generic). */
2122
+ function compileMacAssetsCar(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
2123
+ const source = findIconSource(cwd, cfg);
2124
+ if (!source) return null;
2125
+ const work = join(tmpdir(), `appwrap-actool-${Date.now()}`);
2126
+ const xcassets = join(work, 'Assets.xcassets');
2127
+ const iconset = join(xcassets, 'AppIcon.appiconset');
2128
+ try {
2129
+ mkdirSync(iconset, { recursive: true });
2130
+ // 10 macOS reps: 16/32/128/256/512 pt at @1x and @2x.
2131
+ const reps = [16, 32, 128, 256, 512].flatMap((pt) => [
2132
+ { pt, scale: 1, px: pt },
2133
+ { pt, scale: 2, px: pt * 2 },
2134
+ ]);
2135
+ const images = reps.map(({ pt, scale, px }) => {
2136
+ const filename = `icon_${pt}x${pt}@${scale}x.png`;
2137
+ execFileSync('sips', ['-s', 'format', 'png', '-z', String(px), String(px), source, '--out', join(iconset, filename)], { stdio: 'ignore' });
2138
+ return { idiom: 'mac', size: `${pt}x${pt}`, scale: `${scale}x`, filename };
2139
+ });
2140
+ writeFileSync(join(iconset, 'Contents.json'), JSON.stringify({ images, info: { version: 1, author: 'appwrap' } }, null, 2));
2141
+ writeFileSync(join(xcassets, 'Contents.json'), JSON.stringify({ info: { version: 1, author: 'appwrap' } }, null, 2));
2142
+
2143
+ const partialPlist = join(work, 'partial.plist');
2144
+ execFileSync('xcrun', ['actool', xcassets,
2145
+ '--compile', resourcesDir,
2146
+ '--platform', 'macosx',
2147
+ '--target-device', 'mac',
2148
+ '--minimum-deployment-target', '11.0',
2149
+ '--app-icon', 'AppIcon',
2150
+ '--output-partial-info-plist', partialPlist,
2151
+ ], { stdio: 'ignore' });
2152
+
2153
+ if (!existsSync(join(resourcesDir, 'Assets.car'))) throw new Error('actool produced no Assets.car');
2154
+ return 'AppIcon';
2155
+ } catch (e: unknown) {
2156
+ console.warn(`⚠ actool Assets.car compile skipped (${e instanceof Error ? e.message : String(e)}) — Tahoe Finder icon may look generic; legacy .icns kept`);
2157
+ return null;
2158
+ } finally {
2159
+ rmSync(work, { recursive: true, force: true });
2160
+ }
2161
+ }
2162
+
2163
+ /** Resolved macOS signing lane. `identity` is the cert SHA-1 hash (fed to codesign — unambiguous,
2164
+ * unlike a CN which can collide); `cn` is kept for logging; `kind` records the cert class. The
2165
+ * aps-environment is NOT derived from `kind` — it comes from the provisioning profile itself. */
2166
+ interface MacSigning { identity: string; cn: string; kind: 'developer-id' | 'development'; teamId: string }
2167
+
2168
+ /** A matched macOS profile + its own aps-environment entitlement. */
2169
+ interface MacProfileMatch { path: string; apsEnvironment?: string }
2170
+
2171
+ /** Resolve a real code-signing identity for the desktop lane. Team precedence mirrors stampTeamId:
2172
+ * non-placeholder cfg.teamId → $APPWRAP_TEAM_ID → none (adhoc). Identity preference within the team:
2173
+ * "Developer ID Application" (direct distribution) → "Apple Development" (local/dev). The CN of a
2174
+ * dev cert does NOT carry the team id — match via the certificate's OU field. Returns null (→ adhoc
2175
+ * fallback) when no team is set or no identity of either class belongs to it. */
2176
+ function resolveMacSigning(cfg: AppwrapConfig): MacSigning | null {
2177
+ const teamId = (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId))
2178
+ ? process.env.APPWRAP_TEAM_ID?.trim() || null
2179
+ : cfg.teamId;
2180
+ if (!teamId) return null;
2181
+ let listing = '';
2182
+ try {
2183
+ listing = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' });
2184
+ } catch { return null; }
2185
+ // find-identity -v lines: ` 1) <40-hex-SHA1> "<CN>"`. Capture BOTH — codesign is fed the hash.
2186
+ const entries = [...listing.matchAll(/\b([0-9A-F]{40})\s+"([^"]+)"/g)].map((m) => ({ hash: m[1], cn: m[2] }));
2187
+ const teamOf = (cn: string): string | null => {
2188
+ try {
2189
+ const pem = execFileSync('security', ['find-certificate', '-c', cn, '-p'], { encoding: 'utf8' });
2190
+ const subject = execFileSync('openssl', ['x509', '-noout', '-subject'], { input: pem, encoding: 'utf8' });
2191
+ return subject.match(/OU\s*=\s*([A-Z0-9]{10})/)?.[1] ?? null;
2192
+ } catch { return null; }
2193
+ };
2194
+ for (const prefix of ['Developer ID Application', 'Apple Development'] as const) {
2195
+ const match = entries.find((e) => e.cn.startsWith(prefix) && teamOf(e.cn) === teamId);
2196
+ if (match) return { identity: match.hash, cn: match.cn, kind: prefix === 'Developer ID Application' ? 'developer-id' : 'development', teamId };
2197
+ }
2198
+ return null;
2199
+ }
2200
+
2201
+ /** Find an installed, unexpired macOS provisioning profile for `<teamId>.<bundleId>`. macOS profiles
2202
+ * are `.provisionprofile` (same CMS envelope as iOS `.mobileprovision`; findProvisioningProfiles only
2203
+ * scans the latter, so this is the mac twin). Returns the file path, or null (→ sign without the
2204
+ * restricted push entitlement — a restricted entitlement with no embedded profile kills the app at
2205
+ * launch, which is strictly worse than a signed app without push). */
2206
+ function findMacProvisionProfile(teamId: string, bundleId: string, signingCertHash?: string): MacProfileMatch | null {
2207
+ const dirs = [
2208
+ join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles'),
2209
+ join(process.env.HOME ?? '', 'Library/Developer/Xcode/UserData/Provisioning Profiles'),
2210
+ ];
2211
+ const wanted = signingCertHash?.toUpperCase();
2212
+ for (const dir of dirs) {
2213
+ if (!existsSync(dir)) continue;
2214
+ let files: string[] = [];
2215
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.provisionprofile')); } catch { continue; }
2216
+ for (const f of files) {
2217
+ try {
2218
+ const raw = execFileSync('security', ['cms', '-D', '-i', join(dir, f)],
2219
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
2220
+ const info = parseMacProfile(raw);
2221
+ const { teamId: team, appId, expiration } = info;
2222
+ if (team !== teamId || !appId) continue;
2223
+ if (expiration && Date.parse(expiration) < Date.now()) continue;
2224
+ const profBundle = appId.startsWith(team + '.') ? appId.slice(team.length + 1) : appId;
2225
+ if (profBundle !== bundleId && profBundle !== '*') continue;
2226
+ // The chosen signing cert MUST be one the profile authorizes (DER SHA-1 == find-identity hash),
2227
+ // else codesign succeeds but the app launch is rejected. Mismatch → warn + keep scanning.
2228
+ if (wanted) {
2229
+ const authorized = info.certificates.some(
2230
+ (b64) => createHash('sha1').update(Buffer.from(b64, 'base64')).digest('hex').toUpperCase() === wanted,
2231
+ );
2232
+ if (!authorized) {
2233
+ console.log(` ⚠ profile ${f} does not authorize the chosen signing certificate — skipping`);
2234
+ continue;
2235
+ }
2236
+ }
2237
+ return { path: join(dir, f), apsEnvironment: info.apsEnvironment };
2238
+ } catch { /* skip unreadable profile */ }
2239
+ }
2240
+ }
2241
+ return null;
2242
+ }
2243
+
2244
+ /** Assemble a double-clickable `<Name>.app` around the built shell binary. The Info.plist's
2245
+ * CFBundleURLTypes is what registers `urlScheme` with LaunchServices — a bare binary can't receive
2246
+ * `<scheme>://…` opens (tauri-plugin-deep-link needs the bundle). Signed with a real identity +
2247
+ * entitlements + embedded profile when teamId resolves (resolveMacSigning); ad-hoc otherwise. */
2248
+ function assembleMacApp(cwd: string, cfg: AppwrapConfig, shell: DesktopShellConfig, outDir: string, binary: string,
2249
+ lane?: { signing: MacSigning | null; profile: MacProfileMatch | null }): string {
2250
+ const appDir = join(outDir, 'dist-app', `${shell.name}.app`);
2251
+ const macosDir = join(appDir, 'Contents/MacOS');
2252
+ const resourcesDir = join(appDir, 'Contents/Resources');
2253
+ rmSync(appDir, { recursive: true, force: true });
2254
+ mkdirSync(macosDir, { recursive: true });
2255
+ mkdirSync(resourcesDir, { recursive: true });
2256
+ cpSync(binary, join(macosDir, shell.name));
2257
+ const iconFile = generateMacIcns(cwd, cfg, resourcesDir) ?? undefined;
2258
+ // Also compile a Tahoe-native Assets.car (macOS 26 reads CFBundleIconName/Assets.car, not the .icns).
2259
+ // actool emits its own AppIcon.icns alongside — let it replace the iconutil one when present.
2260
+ const iconName = compileMacAssetsCar(cwd, cfg, resourcesDir) ?? undefined;
2261
+ writeFileSync(join(appDir, 'Contents/Info.plist'), buildInfoPlist(shell, shell.name, iconFile, iconName));
2262
+ const signing = lane ? lane.signing : resolveMacSigning(cfg);
2263
+ if (signing) {
2264
+ // Signed lane: real identity; when an installed macOS profile matches, embed it and claim the
2265
+ // push entitlement (restricted → only launches profile-backed). Developer ID gets the hardened
2266
+ // runtime (notarization prerequisite; harmless otherwise).
2267
+ const profile = lane ? lane.profile : findMacProvisionProfile(signing.teamId, shell.identifier, signing.identity);
2268
+ if (profile) cpSync(profile.path, join(appDir, 'Contents/embedded.provisionprofile'));
2269
+ else console.log(` ⓘ no macOS provisioning profile installed for ${signing.teamId}.${shell.identifier} — signing without entitlements (cert only)`);
2270
+ // aps-environment comes from the PROFILE's own entitlement, not the cert kind — a Developer ID cert
2271
+ // paired with a development profile must claim `development`, or the app is killed at launch.
2272
+ const aps = profile?.apsEnvironment;
2273
+ if (profile && aps !== 'development' && aps !== 'production') {
2274
+ console.log(` ⓘ profile has no aps-environment entitlement — signing without push`);
2275
+ }
2276
+ const args = ['--force', '--deep', '-s', signing.identity];
2277
+ // Entitlements ONLY when a profile backs them: application-identifier/team-identifier are
2278
+ // RESTRICTED — claiming them without an embedded profile makes macOS refuse to launch the app
2279
+ // ("can't be opened"). No profile → plain cert signature (launches fine, no push).
2280
+ if (profile) {
2281
+ const entitlements = buildMacEntitlements({
2282
+ teamId: signing.teamId,
2283
+ bundleId: shell.identifier,
2284
+ apsEnvironment: aps === 'development' || aps === 'production' ? aps : undefined,
2285
+ });
2286
+ const entFile = join(outDir, 'dist-app', `${shell.name}.entitlements`);
2287
+ writeFileSync(entFile, entitlements);
2288
+ args.push('--entitlements', entFile);
2289
+ }
2290
+ if (signing.kind === 'developer-id') args.push('--options', 'runtime', '--timestamp');
2291
+ console.log(`▶ codesign (${signing.cn} [${signing.identity}]${profile ? ', profile embedded' : ''})`);
2292
+ execFileSync('codesign', [...args, appDir], { stdio: 'inherit' });
2293
+ } else {
2294
+ // Ad-hoc sign so Gatekeeper/LaunchServices treat the bundle as intact on this machine.
2295
+ // Only flag a skip when a REAL team was requested (placeholder teamId = signing never asked for).
2296
+ const requested = process.env.APPWRAP_TEAM_ID?.trim() || (cfg.teamId && !/YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId) ? cfg.teamId : '');
2297
+ if (requested) console.log(` ⓘ signed lane skipped — no Developer ID / Apple Development identity for team ${requested}; ad-hoc signing`);
2298
+ execFileSync('codesign', ['--force', '--deep', '-s', '-', appDir], { stdio: 'inherit' });
2299
+ }
2300
+ return appDir;
2301
+ }
2302
+
2303
+ /** Opt-in notarization (env `APPWRAP_NOTARIZE=1`, off by default). Requires the Developer ID signed
2304
+ * lane — assembleMacApp already signs it with `--options runtime --timestamp` (hardened runtime +
2305
+ * secure timestamp, both notarization prerequisites; no get-task-allow is ever claimed). Credentials:
2306
+ * `APPWRAP_NOTARY_KEYCHAIN_PROFILE` (a `notarytool store-credentials` profile) or an ASC API key —
2307
+ * `APPWRAP_NOTARY_KEY_ID` + `APPWRAP_NOTARY_ISSUER` (+ optional `APPWRAP_NOTARY_KEY` p8 path,
2308
+ * default ~/.appstoreconnect/private_keys/AuthKey_<id>.p8). */
2309
+ function notarizeMacApp(appDir: string, signing: MacSigning | null): void {
2310
+ if (process.env.APPWRAP_NOTARIZE !== '1') return;
2311
+ if (!signing || signing.kind !== 'developer-id') {
2312
+ 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`);
2313
+ process.exit(1);
2314
+ }
2315
+ const kcProfile = process.env.APPWRAP_NOTARY_KEYCHAIN_PROFILE?.trim();
2316
+ const keyId = process.env.APPWRAP_NOTARY_KEY_ID?.trim();
2317
+ const issuer = process.env.APPWRAP_NOTARY_ISSUER?.trim();
2318
+ const keyPath = process.env.APPWRAP_NOTARY_KEY?.trim()
2319
+ || (keyId ? join(process.env.HOME ?? '', `.appstoreconnect/private_keys/AuthKey_${keyId}.p8`) : '');
2320
+ let auth: string[];
2321
+ if (kcProfile) auth = ['--keychain-profile', kcProfile];
2322
+ else if (keyId && issuer) {
2323
+ if (!existsSync(keyPath)) {
2324
+ console.error(`✖ notarization: ASC API key file not found at ${keyPath} (set APPWRAP_NOTARY_KEY to the .p8 path)`);
2325
+ process.exit(1);
2326
+ }
2327
+ auth = ['--key', keyPath, '--key-id', keyId, '--issuer', issuer];
2328
+ } else {
2329
+ 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)');
2330
+ process.exit(1);
2331
+ }
2332
+ const zip = `${appDir}.notarize.zip`;
2333
+ rmSync(zip, { force: true });
2334
+ execFileSync('ditto', ['-c', '-k', '--keepParent', appDir, zip], { stdio: 'inherit' });
2335
+ console.log('▶ notarytool submit --wait (Apple-side processing — can take several minutes)');
2336
+ try {
2337
+ execFileSync('xcrun', ['notarytool', 'submit', zip, ...auth, '--wait'], { stdio: 'inherit' });
2338
+ execFileSync('xcrun', ['stapler', 'staple', appDir], { stdio: 'inherit' });
2339
+ } finally {
2340
+ rmSync(zip, { force: true });
2341
+ }
2342
+ console.log(`✓ Notarized + stapled: ${appDir}`);
2343
+ }
2344
+
2345
+ /** `appwrap build desktop` — stamp the wrapper, `cargo build --release`, then assemble the `.app`. */
2346
+ async function buildDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
2347
+ ensureDesktopMacOS();
2348
+ const cfg = await loadConfig(cwd, flags);
2349
+ const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2350
+ buildWebIfBundled(cwd, cfg, flags);
2351
+ // Resolve the signing lane BEFORE regeneration: shell_config.json is embedded at compile time,
2352
+ // and the Rust shell's push capability must only claim 'native' when the .app really ships the
2353
+ // profile-backed aps-environment entitlement (signed lane + installed macOS profile).
2354
+ const signing = resolveMacSigning(cfg);
2355
+ const shellCfg = deriveDesktopConfig(cfg);
2356
+ const profile = signing ? findMacProvisionProfile(signing.teamId, shellCfg.identifier, signing.identity) : null;
2357
+ // Only let the Rust shell claim NATIVE push when the app will actually ship a profile-backed
2358
+ // aps-environment entitlement (profile matched AND it carries the entitlement).
2359
+ const nativePush = !!(signing && profile && (profile.apsEnvironment === 'development' || profile.apsEnvironment === 'production'));
2360
+ regenerateDesktop(cwd, cfg, outDir, nativePush);
2361
+ const { bin, env } = desktopCargo();
2362
+ console.log(`▶ cargo build --release (appwrap desktop shell — ${cfg.name})`);
2363
+ execFileSync(bin, ['build', '--release'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
2364
+ const binary = join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target/release/appwrap-desktop-shell');
2365
+ console.log(`✓ Desktop binary: ${binary}`);
2366
+ const appDir = assembleMacApp(cwd, cfg, shellCfg, outDir, binary, { signing, profile });
2367
+ console.log(`✓ App bundle: ${appDir}`);
2368
+ notarizeMacApp(appDir, signing);
2369
+ }
2370
+
1932
2371
  async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1933
2372
  const platform = positionals[0];
2373
+ if (platform === 'desktop') return buildDesktop(cwd, flags);
1934
2374
  if (platform !== 'ios' && platform !== 'android') {
1935
- console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config <path>] [--out native]');
2375
+ console.error('Usage: appwrap build <ios|android|desktop> [--release] [--aab] [--config <path>] [--out native]');
1936
2376
  process.exit(1);
1937
2377
  }
1938
2378
  const outDir = resolve(cwd, flags.out ?? 'native');
@@ -2688,9 +3128,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2688
3128
  const force = 'force' in flags || 'f' in flags;
2689
3129
  const fp = buildFingerprint(cwd, cfg, flags);
2690
3130
  const cache = readBuildCache(outDir, 'ios');
2691
- const existingIpa = existsSync(ipaDir)
2692
- ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
2693
- : undefined;
3131
+ const existingIpa = newestIpa(ipaDir);
2694
3132
  const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.artifactPath === join(ipaDir, existingIpa);
2695
3133
  // --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
2696
3134
  // but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
@@ -2709,24 +3147,45 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2709
3147
  if (sc.provision) nsArgs.push('--provision', sc.provision);
2710
3148
  } catch { /* no sidecar → automatic signing */ }
2711
3149
  console.log(`▶ ns ${nsArgs.join(' ')} (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
3150
+ const buildStart = Date.now();
2712
3151
  try {
2713
3152
  execFileSync('ns', nsArgs, { cwd: outDir, stdio: 'inherit' });
2714
3153
  } catch (e) {
2715
- // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
2716
- console.error(
2717
- '\n✖ Device build failed — if the errors above mention signing:\n' +
2718
- ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
2719
- ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
2720
- ' unique string and re-deploy.\n' +
2721
- ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
2722
- ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
2723
- ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
2724
- );
2725
- process.exit(1);
3154
+ // exportArchive is the ACCOUNT-GATED step: with special entitlements (e.g. Associated Domains)
3155
+ // automatic-signing export must (re)provision via App Store Connect, which needs a live Xcode
3156
+ // account session — one Xcode 26 keeps silently dropping. The ARCHIVE itself signs fine from
3157
+ // locally cached profiles. So when a fresh signed .app exists in the xcarchive, resign it with
3158
+ // a local Xcode-managed dev profile + pack the ipa ourselves — no account, no network.
3159
+ const archiveApp = join(ipaDir, 'native.xcarchive/Products/Applications/native.app');
3160
+ let resigned = false;
3161
+ if (existsSync(archiveApp) && statSync(archiveApp).mtimeMs >= buildStart) {
3162
+ 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)…');
3163
+ try {
3164
+ resigned = resignArchiveIpa(archiveApp, join(ipaDir, 'native.ipa'), cfg, device.id);
3165
+ } catch (fe) {
3166
+ console.error(` resign fallback failed: ${fe instanceof Error ? fe.message : fe}`);
3167
+ }
3168
+ }
3169
+ if (!resigned) {
3170
+ // The xcodebuild dump above is cryptic; surface the signing failures we actually hit most.
3171
+ console.error(
3172
+ '\n✖ Device build failed — if the errors above mention signing:\n' +
3173
+ ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
3174
+ ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
3175
+ ' unique string and re-deploy.\n' +
3176
+ ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
3177
+ ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
3178
+ ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.\n' +
3179
+ ' • "requires a provisioning profile with the … feature" → the resign fallback above needs a\n' +
3180
+ ' LOCAL dev profile carrying that capability: build the app once from Xcode on this Mac\n' +
3181
+ ' (auto-signing mints + caches the profile for ~1 year), then re-run `appwrap deploy ios`.'
3182
+ );
3183
+ process.exit(1);
3184
+ }
2726
3185
  }
2727
3186
  }
2728
3187
 
2729
- const builtIpa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
3188
+ const builtIpa = newestIpa(ipaDir);
2730
3189
  const ipa = builtIpa;
2731
3190
  if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
2732
3191
  const ipaPath = join(ipaDir, ipa);
@@ -2741,8 +3200,12 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2741
3200
  const installTimeout = wireless ? '120' : '25';
2742
3201
  // Capture (not inherit) so we can recognize specific failures; echo it for visibility. Wrapped so a
2743
3202
  // LOCKED device waits-and-retries. --timeout lets one attempt tolerate a brief locked/unavailable window.
3203
+ // BELT + SUSPENDERS on hangs: devicectl's own `--timeout` only covers device-side waits. A wedged
3204
+ // CoreDeviceService makes devicectl hang BEFORE that — silently, with zero output — so without a
3205
+ // process-level `timeout:` the exec blocks forever and the self-heal below never fires.
3206
+ const hardTimeoutMs = (Number(installTimeout) + 35) * 1000;
2744
3207
  const runDevicectlInstall = (): string => withUnlockRetry('Install', () =>
2745
- execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', installTimeout, '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
3208
+ execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--timeout', installTimeout, '--device', device.id, ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], timeout: hardTimeoutMs })
2746
3209
  );
2747
3210
  try {
2748
3211
  process.stdout.write(runDevicectlInstall());
@@ -2761,8 +3224,9 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2761
3224
  ' (A paid Apple Developer account removes this limit.)'
2762
3225
  );
2763
3226
  process.exit(1);
2764
- } else if (/Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) {
2765
- // devicectl/CoreDevice is stuck (connection hang, NOT a lock). Usual cause: a pegged
3227
+ } else if (isTimeoutKill(e) || /Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) {
3228
+ // devicectl/CoreDevice is stuck — either it SAID so, or our process-level timeout had to kill a
3229
+ // silent zero-output hang (the wedged-CoreDeviceService signature). Usual cause: a pegged
2766
3230
  // CoreDeviceService daemon. SELF-HEAL: kill it (auto-respawns clean) and retry devicectl once —
2767
3231
  // this is the fix a human otherwise applies by hand, and it's the ONLY path that works for a
2768
3232
  // wireless-only device (usbmux is USB-only).
@@ -2809,7 +3273,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2809
3273
  // old serverUrl) despite a correct new build — masquerading as a build/cache bug. (A reinstall
2810
3274
  // over the top does NOT replace a running process; this avoids the manual uninstall dance.)
2811
3275
  withUnlockRetry('Launch', () =>
2812
- execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout', installTimeout, '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] })
3276
+ execFileSync('xcrun', ['devicectl', 'device', 'process', 'launch', '--terminate-existing', '--timeout', installTimeout, '--device', device.id, cfg.id], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], timeout: hardTimeoutMs })
2813
3277
  );
2814
3278
  } catch {
2815
3279
  console.error('⚠ Launch failed (still locked after waiting). The app is installed — unlock and tap it, or re-run.');
@@ -2825,7 +3289,7 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
2825
3289
  * fails (the caller then prints the re-plug / brew-install remedy). */
2826
3290
  function usbmuxInstall(ipaPath: string): boolean {
2827
3291
  try {
2828
- const out = execFileSync('ideviceinstaller', ['install', ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
3292
+ const out = execFileSync('ideviceinstaller', ['install', ipaPath], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], timeout: 300_000 });
2829
3293
  process.stdout.write(out);
2830
3294
  return /Complete|Installed/i.test(out);
2831
3295
  } catch (e: unknown) {
@@ -2834,18 +3298,125 @@ function usbmuxInstall(ipaPath: string): boolean {
2834
3298
  }
2835
3299
  }
2836
3300
 
3301
+ /** Newest .ipa in a build dir by mtime — NOT the first directory entry: an export names its ipa after
3302
+ * the app (e.g. Blank.ipa) while the resign fallback writes native.ipa, and a stale one from a prior
3303
+ * run can coexist with (and alphabetically precede) the fresh artifact. */
3304
+ function newestIpa(ipaDir: string): string | undefined {
3305
+ if (!existsSync(ipaDir)) return undefined;
3306
+ return readdirSync(ipaDir)
3307
+ .filter((f) => f.endsWith('.ipa'))
3308
+ .map((f) => ({ f, mtime: statSync(join(ipaDir, f)).mtimeMs }))
3309
+ .sort((a, b) => b.mtime - a.mtime)[0]?.f;
3310
+ }
3311
+
3312
+ /** Resign the archive's .app with a LOCAL Xcode-managed development profile and pack it into an ipa —
3313
+ * the no-account fallback for when `xcodebuild -exportArchive` fails (its automatic signing needs a
3314
+ * live Xcode account session; manual export refuses Xcode-managed profiles, so exportArchive is a
3315
+ * dead end both ways without one). Profile requirements: matches team+bundle id, development
3316
+ * (get-task-allow), unexpired, carries every configured entitlement key, includes the target device,
3317
+ * and lists a signing cert that is actually in the login keychain. Gotcha encoded here: the ipa MUST
3318
+ * be packed with `zip` — `ditto -c -k` stores macOS xattrs as `._*` AppleDouble entries, which
3319
+ * on-device installd rejects as "a signed resource has been added" (0xe8008017).
3320
+ * Returns true on success (ipa written to `ipaOut`); false → caller prints the manual remedies. */
3321
+ function resignArchiveIpa(archiveApp: string, ipaOut: string, cfg: AppwrapConfig, coreDeviceId: string): boolean {
3322
+ if (existsSync(join(archiveApp, 'PlugIns'))) {
3323
+ console.error(' ✖ app has extensions (PlugIns/) — per-extension resigning is not supported; use the TestFlight lane.');
3324
+ return false;
3325
+ }
3326
+ const requiredEnts = Object.keys(cfg.iosEntitlements ?? {});
3327
+ const hwUdid = libimobiledeviceUdid()?.udid ?? null; // hardware UDID (≠ devicectl id); null → skip device check
3328
+ if (!hwUdid) console.error(` ⚠ can't read the device hardware UDID (idevice_id) — picking a profile without verifying it covers device ${coreDeviceId}.`);
3329
+
3330
+ // Signing certs actually present in the keychain, by SHA-1 (uppercase hex).
3331
+ const keychainCerts = new Set(
3332
+ [...execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' })
3333
+ .matchAll(/\b([0-9A-F]{40})\b/g)].map((m) => m[1])
3334
+ );
3335
+
3336
+ const profileDirs = [
3337
+ join(process.env.HOME ?? '', 'Library/Developer/Xcode/UserData/Provisioning Profiles'),
3338
+ join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles'),
3339
+ ];
3340
+ let best: { path: string; raw: string; team: string; certSha1: string; expires: number; name: string } | null = null;
3341
+ for (const dir of profileDirs) {
3342
+ if (!existsSync(dir)) continue;
3343
+ for (const f of readdirSync(dir).filter((f) => f.endsWith('.mobileprovision'))) {
3344
+ const path = join(dir, f);
3345
+ let raw: string;
3346
+ try { raw = execFileSync('security', ['cms', '-D', '-i', path], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); }
3347
+ catch { continue; }
3348
+ const appId = raw.match(/<key>application-identifier<\/key>\s*<string>([A-Z0-9]+)\.([^<]+)<\/string>/);
3349
+ if (!appId || appId[2] !== cfg.id) continue;
3350
+ if (cfg.teamId && appId[1] !== cfg.teamId) continue;
3351
+ if (!/<key>get-task-allow<\/key>\s*<true\/>/.test(raw)) continue; // development profile only
3352
+ const exp = raw.match(/<key>ExpirationDate<\/key>\s*<date>([^<]+)<\/date>/);
3353
+ const expires = exp ? Date.parse(exp[1]) : 0;
3354
+ if (expires < Date.now()) continue;
3355
+ if (!requiredEnts.every((k) => raw.includes(`<key>${k}</key>`))) continue;
3356
+ if (hwUdid && !raw.includes(`<string>${hwUdid}</string>`)) continue;
3357
+ // First profile cert that is also in the keychain (profiles list several historical certs).
3358
+ const certsBlock = raw.split('<key>DeveloperCertificates</key>')[1]?.split('</array>')[0] ?? '';
3359
+ const certSha1 = [...certsBlock.matchAll(/<data>([\s\S]*?)<\/data>/g)]
3360
+ .map((m) => createHash('sha1').update(Buffer.from(m[1].replace(/\s+/g, ''), 'base64')).digest('hex').toUpperCase())
3361
+ .find((h) => keychainCerts.has(h));
3362
+ if (!certSha1) continue;
3363
+ const name = raw.match(/<key>Name<\/key>\s*<string>([^<]+)<\/string>/)?.[1] ?? f;
3364
+ if (!best || expires > best.expires) best = { path, raw, team: appId[1], certSha1, expires, name };
3365
+ }
3366
+ }
3367
+ if (!best) {
3368
+ console.error(` ✖ no local development profile matches ${cfg.id}${requiredEnts.length ? ` with [${requiredEnts.join(', ')}]` : ''} for this device.`);
3369
+ return false;
3370
+ }
3371
+ console.error(` ↻ using profile "${best.name}" (expires ${new Date(best.expires).toISOString().slice(0, 10)}) + keychain cert ${best.certSha1.slice(0, 8)}…`);
3372
+
3373
+ // Stage Payload/, swap in the profile, sign with entitlements derived from config + profile.
3374
+ const staging = join(tmpdir(), `appwrap-resign-${process.pid}`);
3375
+ rmSync(staging, { recursive: true, force: true });
3376
+ const stagedApp = join(staging, 'Payload/native.app');
3377
+ mkdirSync(join(staging, 'Payload'), { recursive: true });
3378
+ cpSync(archiveApp, stagedApp, { recursive: true });
3379
+ cpSync(best.path, join(stagedApp, 'embedded.mobileprovision'));
3380
+ const plistValue = (v: unknown): string => Array.isArray(v)
3381
+ ? `<array>${v.map((x) => `<string>${x}</string>`).join('')}</array>`
3382
+ : typeof v === 'boolean' ? (v ? '<true/>' : '<false/>') : `<string>${String(v)}</string>`;
3383
+ const entPlist = join(staging, 'entitlements.plist');
3384
+ writeFileSync(entPlist,
3385
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
3386
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
3387
+ '<plist version="1.0">\n<dict>\n' +
3388
+ `\t<key>application-identifier</key><string>${best.team}.${cfg.id}</string>\n` +
3389
+ `\t<key>com.apple.developer.team-identifier</key><string>${best.team}</string>\n` +
3390
+ '\t<key>get-task-allow</key><true/>\n' +
3391
+ `\t<key>keychain-access-groups</key><array><string>${best.team}.*</string></array>\n` +
3392
+ Object.entries(cfg.iosEntitlements ?? {}).map(([k, v]) => `\t<key>${k}</key>${plistValue(v)}\n`).join('') +
3393
+ '</dict>\n</plist>\n');
3394
+ // Nested code first (sealed into the app signature), then the app itself with the entitlements.
3395
+ const frameworksDir = join(stagedApp, 'Frameworks');
3396
+ const nested = existsSync(frameworksDir) ? readdirSync(frameworksDir).filter((f) => /\.(framework|dylib)$/.test(f)) : [];
3397
+ for (const f of nested) execFileSync('codesign', ['-f', '-s', best.certSha1, '--timestamp=none', join(frameworksDir, f)], { stdio: 'pipe' });
3398
+ execFileSync('codesign', ['-f', '-s', best.certSha1, '--timestamp=none', '--entitlements', entPlist, stagedApp], { stdio: 'pipe' });
3399
+ execFileSync('codesign', ['--verify', '--deep', '--strict', stagedApp], { stdio: 'pipe' });
3400
+ rmSync(ipaOut, { force: true });
3401
+ execFileSync('zip', ['-qry', ipaOut, 'Payload'], { cwd: staging, stdio: 'pipe' }); // zip, NOT ditto — see docblock
3402
+ rmSync(staging, { recursive: true, force: true });
3403
+ console.error(` ✓ resigned ipa ready: ${ipaOut}`);
3404
+ return true;
3405
+ }
3406
+
2837
3407
  /** Apple's `CoreDeviceService` daemon intermittently pegs a CPU core at 100% and wedges the device
2838
3408
  * tunnel — `devicectl` then hangs until its timeout (worse on a wireless/localNetwork target). Killing
2839
- * it is the documented remedy: launchd auto-respawns it clean. Best-effort; returns true if we
2840
- * signalled a matching process (pkill exits non-zero → throws → false when nothing matched). */
3409
+ * it is the documented remedy: launchd auto-respawns it clean. `remotepairingd` (also user-owned)
3410
+ * holds the pairing side of the same tunnel and wedges WITH it — kill both; restarting only one is
3411
+ * not enough to recover a wedged stack. Returns true if either kill matched a process. */
2841
3412
  function killStuckCoreDeviceService(): boolean {
2842
- try {
2843
- execFileSync('pkill', ['-9', '-f', 'CoreDeviceService.xpc'], { stdio: 'ignore' });
2844
- sleepSync(4000); // let launchd respawn it + re-establish the tunnel before we retry
2845
- return true;
2846
- } catch {
2847
- return false;
3413
+ let matched = false;
3414
+ for (const pattern of ['CoreDeviceService.xpc', 'remotepairingd']) {
3415
+ try { execFileSync('pkill', ['-9', '-f', pattern], { stdio: 'ignore' }); matched = true; }
3416
+ catch { /* pkill exits non-zero when nothing matched */ }
2848
3417
  }
3418
+ if (matched) sleepSync(4000); // let launchd respawn them + re-establish the tunnel before we retry
3419
+ return matched;
2849
3420
  }
2850
3421
 
2851
3422
  /** Run a devicectl op; if it fails because the device is LOCKED (or transiently unavailable), prompt
@@ -2859,9 +3430,10 @@ function withUnlockRetry<T>(label: string, run: () => T, tries = 40, delayMs = 3
2859
3430
  } catch (e: unknown) {
2860
3431
  const log = execErrText(e);
2861
3432
  if (/maximum number of installed apps|MIInstallerErrorDomain error 13|ApplicationVerificationFailed/.test(log)) throw e;
2862
- // devicectl/CoreDevice tunnel STUCK (it switches to a [wired] path and hangs) is NOT a lock —
2863
- // retrying as "waiting for unlock" is pointless + misleading. Re-throw so the caller can fall back.
2864
- if (/Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) throw e;
3433
+ // devicectl/CoreDevice tunnel STUCK (it switches to a [wired] path and hangs, or hangs silently
3434
+ // until our process-level timeout kills it) is NOT a lock — retrying as "waiting for unlock" is
3435
+ // pointless + misleading. Re-throw so the caller can self-heal / fall back.
3436
+ if (isTimeoutKill(e) || /Command timeout|got stuck|could not be reached|Unable to connect to device/.test(log)) throw e;
2865
3437
  if (i >= tries) throw e;
2866
3438
  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`);
2867
3439
  else process.stdout.write(` …waiting for unlock (${i}/${tries})\n`);
@@ -3057,10 +3629,11 @@ async function main(): Promise<void> {
3057
3629
  ' Android wireless: --wifi flips a USB device to wireless adb (unplug + keep going); thereafter the\n' +
3058
3630
  ' device is auto-discovered via mDNS — plain `dev android` finds it with NO flag (iOS parity).\n' +
3059
3631
  ' --device <ip[:port]> `adb connect`s an already-paired one.\n\n' +
3060
- ' dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
3632
+ ' dev <ios|android|desktop> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
3061
3633
  ' live-dev: ANDROID device → ns run livesync (true on-device HMR) + re-stage on save;\n' +
3062
3634
  ' iOS device → deploy + rebuild/reinstall on save. --sim = ns run/HMR on emulator;\n' +
3063
3635
  ' --url/--port = web HMR from a dev server inside the WebView; --detached = install & exit.\n' +
3636
+ ' desktop → macOS Tauri shell (cargo run) speaking appwrap protocol v1 (phase-0, macOS-only).\n' +
3064
3637
  ' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
3065
3638
  ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
3066
3639
  ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +