@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/package.json +4 -2
- package/runtime/app/app.ts +15 -0
- package/runtime/app/main-page.ts +11 -2
- package/runtime/app/shell/custom-webview.android.ts +81 -46
- package/runtime/app/shell/custom-webview.ios.ts +18 -8
- package/runtime/app/shell/handlers-android.ts +25 -6
- package/runtime/app/shell/handlers-background.ts +1 -1
- package/runtime/app/shell/web-quirks.ts +63 -0
- package/runtime/webpack.config.js +20 -0
- package/runtime-desktop/autotest/index.html +77 -0
- package/runtime-desktop/src-tauri/Cargo.lock +5459 -0
- package/runtime-desktop/src-tauri/Cargo.toml +38 -0
- package/runtime-desktop/src-tauri/build.rs +3 -0
- package/runtime-desktop/src-tauri/capabilities/default.json +6 -0
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +10 -0
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +138 -0
- package/runtime-desktop/src-tauri/src/main.rs +683 -0
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +202 -0
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +139 -0
- package/runtime-desktop/src-tauri/src/popup_mac.rs +466 -0
- package/runtime-desktop/src-tauri/src/push_mac.rs +193 -0
- package/runtime-desktop/src-tauri/src/sidecar.rs +398 -0
- package/runtime-desktop/src-tauri/tauri.conf.json +13 -0
- package/scripts/stage-assets.mjs +4 -2
- package/src/cli.ts +621 -48
- package/src/config.ts +40 -1
- package/src/desktop.ts +246 -0
- package/src/handlers.ts +71 -0
- package/src/icon.ts +207 -0
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(
|
|
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
|
-
//
|
|
1429
|
-
// workflows into the framework's .github — that's the stray-workflow-each-init bug.
|
|
1430
|
-
|
|
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 (
|
|
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 =
|
|
1458
|
-
? ' ci ← fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
|
|
1459
|
-
: ' ci ←
|
|
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 =
|
|
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
|
-
//
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
2840
|
-
*
|
|
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
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
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
|
|
2863
|
-
//
|
|
2864
|
-
|
|
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' +
|