@livx.cc/appwrap 0.43.0 → 0.46.2

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
@@ -1064,6 +1064,17 @@ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
1064
1064
  if (existsSync(manifest)) {
1065
1065
  let src = readFileSync(manifest, 'utf8');
1066
1066
  if (cfg.urlScheme) src = src.replace(/android:scheme="[^"]*"/, `android:scheme="${cfg.urlScheme}"`);
1067
+ // Extra OAuth redirect schemes (e.g. Google's `com.googleusercontent.apps.<client>`) as additional
1068
+ // BROWSABLE <data> on the deep-link filter, so the Custom Tab redirect re-enters the app instead of
1069
+ // web-searching an unhandled scheme. Idempotent marker → re-sync-safe. See config.oauthRedirectSchemes.
1070
+ {
1071
+ const extra = (cfg.oauthRedirectSchemes ?? []).filter(Boolean);
1072
+ const body = extra.map((s) => `\n\t\t\t<data android:scheme="${esc(s)}" />`).join('') + (extra.length ? '\n\t\t\t' : '');
1073
+ src = src.replace(
1074
+ /<!-- appwrap:oauth-redirect-schemes -->[\s\S]*?<!-- \/appwrap:oauth-redirect-schemes -->/,
1075
+ `<!-- appwrap:oauth-redirect-schemes -->${body}<!-- /appwrap:oauth-redirect-schemes -->`
1076
+ );
1077
+ }
1067
1078
  // Supported orientation (config > manifest) on the main <activity>. Skipped when unset → keep
1068
1079
  // the template default (free); 'any' removes the attribute, so re-sync stays idempotent.
1069
1080
  if (cfg.orientation) src = stampAndroidOrientation(src, androidScreenOrientation(cfg.orientation));
@@ -1979,6 +1990,30 @@ function desktopCopyFilter(src: string): boolean {
1979
1990
  return !/(^|\/)(target|gen|node_modules)(\/|$)/.test(rel);
1980
1991
  }
1981
1992
 
1993
+ /** Resolve a `desktop.server.command` to an absolute path: an absolute/relative path is resolved
1994
+ * against the app root; a bare name is looked up on `path` (build-time PATH). Falls back to the bare
1995
+ * name (the Rust shell will surface the spawn ENOENT) when it's nowhere. */
1996
+ function resolveServerCommand(command: string, cwd: string, path: string): string {
1997
+ if (command.includes('/')) return resolve(cwd, command);
1998
+ for (const dir of path.split(pathDelimiter).filter(Boolean)) {
1999
+ const abs = join(dir, command);
2000
+ if (existsSync(abs)) return abs;
2001
+ }
2002
+ return command;
2003
+ }
2004
+
2005
+ /** The bundled splash shown while a local-server app boots (before the shell navigates to the live
2006
+ * server). A dependency-free, theme-neutral holding page. */
2007
+ function desktopSplashHtml(name: string): string {
2008
+ const safe = name.replace(/</g, '&lt;').replace(/&/g, '&amp;');
2009
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${safe}</title>
2010
+ <style>html,body{height:100%;margin:0}body{display:flex;align-items:center;justify-content:center;
2011
+ flex-direction:column;gap:20px;background:#0b0b0f;color:#e5e5e5;font:14px -apple-system,system-ui,sans-serif}
2012
+ .s{width:34px;height:34px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:r .8s linear infinite}
2013
+ @keyframes r{to{transform:rotate(360deg)}}#e{color:#f87171;max-width:70%;text-align:center;line-height:1.5}</style>
2014
+ </head><body><div class="s" id="spin"></div><div id="m">Starting ${safe}…</div><div id="e"></div></body></html>`;
2015
+ }
2016
+
1982
2017
  /** Copy the desktop template into `native-desktop/` and stamp it from the config: the Rust-read
1983
2018
  * `shell_config.json` (identity + window), `tauri.conf.json` (productName/version/identifier +
1984
2019
  * frontendDist), and the staged web dist (`native-desktop/dist`, referenced as `../dist`). */
@@ -1990,20 +2025,45 @@ function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, push
1990
2025
  const srcTauri = join(outDir, 'src-tauri');
1991
2026
  mkdirSync(outDir, { recursive: true });
1992
2027
  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 });
2028
+ // `native-desktop/` is generated + disposable (regenerated from the template on every build) — mark
2029
+ // the whole tree git-invisible so a consuming repo never commits generated native code (incl. the
2030
+ // copied `crates/` + `src-tauri/`). A self-ignoring `*` .gitignore works regardless of the app's root
2031
+ // .gitignore; mirrors the mobile lane's disposable-`native/` convention. Written AFTER cpSync so it wins.
2032
+ writeFileSync(join(outDir, '.gitignore'), '*\n');
2004
2033
 
2005
2034
  const shell = deriveDesktopConfig(cfg);
2006
2035
  shell.pushSigned = pushSigned;
2036
+
2037
+ // Stage the frontendDist INTO the wrapper (mirrors mobile copyPwa) so it's a stable relative path
2038
+ // independent of where the app project sits. Local-server apps (`desktop.server`) have no meaningful
2039
+ // web dist — the window loads the live server — so we stage a tiny bundled SPLASH instead (shown
2040
+ // while the server boots, then the shell navigates away from it).
2041
+ const stagedDist = join(outDir, 'dist');
2042
+ rmSync(stagedDist, { recursive: true, force: true });
2043
+ if (shell.server) {
2044
+ mkdirSync(stagedDist, { recursive: true });
2045
+ writeFileSync(join(stagedDist, 'index.html'), desktopSplashHtml(shell.name));
2046
+ } else {
2047
+ const dist = resolve(cwd, cfg.pwaDist);
2048
+ if (!existsSync(dist)) {
2049
+ console.error(`✖ Web dist not found at ${dist} — build the PWA first (or check pwaDist).`);
2050
+ process.exit(1);
2051
+ }
2052
+ cpSync(dist, stagedDist, { recursive: true, force: true });
2053
+ }
2054
+
2055
+ // Local-server mode: resolve `command` (bare name → PATH lookup) and `cwd` to ABSOLUTE, and stamp
2056
+ // the build-time PATH — a GUI-launched .app inherits the minimal launchd PATH (no ~/.bun/bin), so
2057
+ // a bare-name spawn (or a child that itself shells out to `bun`/`node`) would fail there.
2058
+ if (shell.server) {
2059
+ const s = shell.server;
2060
+ s.cwd = s.cwd ? resolve(cwd, s.cwd) : cwd;
2061
+ s.command = resolveServerCommand(s.command, cwd, process.env.PATH ?? '');
2062
+ s.path = process.env.PATH ?? '';
2063
+ if (!existsSync(s.command)) {
2064
+ console.warn(`⚠ desktop.server.command not found (${s.command}) — the server will fail to spawn at launch.`);
2065
+ }
2066
+ }
2007
2067
  // Resolve the handlers path to ABSOLUTE (against the app root): the shell binary runs from a
2008
2068
  // different cwd, and for `build desktop` the .app runs from wherever it's installed — an absolute
2009
2069
  // path is the only one that stays valid. The handler file is NOT copied into the wrapper/.app; the
@@ -2347,7 +2407,9 @@ async function buildDesktop(cwd: string, flags: Record<string, string>): Promise
2347
2407
  ensureDesktopMacOS();
2348
2408
  const cfg = await loadConfig(cwd, flags);
2349
2409
  const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2350
- buildWebIfBundled(cwd, cfg, flags);
2410
+ // Local-server apps load the live server, not a bundled web build — skip it (regenerateDesktop
2411
+ // stages a boot splash for them). Otherwise build/stage the PWA as usual.
2412
+ if (!cfg.desktop?.server) buildWebIfBundled(cwd, cfg, flags);
2351
2413
  // Resolve the signing lane BEFORE regeneration: shell_config.json is embedded at compile time,
2352
2414
  // and the Rust shell's push capability must only claim 'native' when the .app really ships the
2353
2415
  // profile-backed aps-environment entitlement (signed lane + installed macOS profile).
@@ -3524,10 +3586,21 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
3524
3586
  console.log(`▶ watching web logs from ${cfg.id} on ${device.name} (pull every 3s) — Ctrl-C to stop.`);
3525
3587
  console.log(' [appwrap-web] = forwarded WebView console/errors. (--once = snapshot, --native = OS firehose.)');
3526
3588
  let shown = 0;
3589
+ // Self-terminate when orphaned: if our controlling session dies, macOS reparents us to launchd and
3590
+ // this loop would otherwise poll `devicectl` FOREVER — every stale session stacking load until
3591
+ // CoreDeviceService pins a core at 100%. NOTE: `process.ppid` is cached at startup and does NOT
3592
+ // update on reparent, so probe the ORIGINAL parent's liveness with signal 0 (throws once it's gone);
3593
+ // also stop on a broken stdout pipe (consumer gone).
3594
+ const parentPid = process.ppid;
3595
+ const orphaned = (): boolean => { try { process.kill(parentPid, 0); return false; } catch { return true; } };
3527
3596
  for (;;) {
3597
+ if (orphaned()) break; // original parent gone → we've been reparented → stop
3528
3598
  const all = pull();
3529
3599
  if (all.length < shown) shown = 0; // app relaunched → file reset; reprint
3530
- if (all.length > shown) { process.stdout.write(all.slice(shown)); shown = all.length; }
3600
+ if (all.length > shown) {
3601
+ try { process.stdout.write(all.slice(shown)); } catch { break; } // pipe closed → consumer gone
3602
+ shown = all.length;
3603
+ }
3531
3604
  try { execFileSync('sleep', ['3']); } catch { break; }
3532
3605
  }
3533
3606
  }
package/src/config.ts CHANGED
@@ -89,6 +89,42 @@ export interface AppwrapConfig {
89
89
  * the app dir, so `build desktop` apps must keep their repo (+ node_modules) around at runtime —
90
90
  * embedding the handler file into the `.app` is out of scope for phase-0. macOS-only. */
91
91
  handlers?: string;
92
+ /** Local-server mode: boot a command that serves the app locally, then load it LIVE in the window
93
+ * (instead of the bundled `pwaDist`). The shell shows a bundled splash, spawns `command` (+`args`)
94
+ * in a background thread, polls `http://localhost:<port><healthPath>` until it answers, then
95
+ * navigates the window there; on window-close it kills the child so the stack never outlives the
96
+ * shell. Use for a desktop app that IS a local dev/server stack (e.g. `para-chat`). Absent → the
97
+ * desktop lane loads the bundled `pwaDist` as usual. The CLI resolves `command` (when a bare name)
98
+ * and `cwd` to absolute paths at stamp time (a GUI-launched .app inherits the minimal launchd PATH,
99
+ * so a bare `bun`/PATH lookup would fail). `build desktop` apps must keep the server (+ its repo)
100
+ * present at runtime — bundling the server INTO the .app is a future phase. macOS-only. */
101
+ server?: {
102
+ /** Command to spawn (argv[0]). A bare name is PATH-resolved at stamp time; an absolute path is used as-is. */
103
+ command: string;
104
+ /** Extra arguments passed to the command. */
105
+ args?: string[];
106
+ /** Working directory to spawn in (default: the app project root). */
107
+ cwd?: string;
108
+ /** How the shell learns the URL to load. Provide EXACTLY ONE:
109
+ * - `urlMarker` (preferred for servers that print their address, e.g. vite/para-chat): the shell
110
+ * pipes the server's stdout and, on the first line CONTAINING this marker, extracts the first
111
+ * `http(s)://…` token and loads it. This tracks whatever port the server auto-picked — no
112
+ * hardcoded port, no mismatch. e.g. `"Frontend:"` matches `Frontend: https://localhost:5050`.
113
+ * - `port` (+ optional `https`) for a silent server on a KNOWN fixed port: the shell TCP-polls
114
+ * `localhost:<port>` and loads `<http|https>://localhost:<port>`. */
115
+ urlMarker?: string;
116
+ /** Fixed port the server listens on (used only when `urlMarker` is absent). */
117
+ port?: number;
118
+ /** Load over https in fixed-`port` mode (default false = http). Ignored in `urlMarker` mode (the
119
+ * captured URL carries its own scheme). The server's TLS cert CA must be trusted by the system
120
+ * keychain and its SANs cover `localhost`/`127.0.0.1`, or the WebView shows a cert error. */
121
+ https?: boolean;
122
+ /** Health path polled for readiness (default `/`). Ready = any HTTP response (even 4xx/5xx). */
123
+ healthPath?: string;
124
+ /** Max ms to wait for the port before showing an error in the splash (default 120000 — a first-run
125
+ * production build can be slow). */
126
+ readyTimeoutMs?: number;
127
+ };
92
128
  };
93
129
  /** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
94
130
  urlScheme?: string;
@@ -105,6 +141,15 @@ export interface AppwrapConfig {
105
141
  * `<package android:name="..."/>`. For scheme probes prefer `queryUrlSchemes` (cross-platform).
106
142
  * No-op when absent. */
107
143
  queryPackages?: string[];
144
+ /** Extra custom URL schemes the system browser (Android Chrome Custom Tabs) may redirect back to
145
+ * during `kit.oauth` flows — e.g. Google's reversed iOS-client id scheme
146
+ * `com.googleusercontent.apps.<client>`. On Android the Custom Tab does NOT auto-close; the provider
147
+ * redirect re-enters the app only via a manifest deep-link intent-filter, so each OAuth redirect
148
+ * scheme MUST be registered as a BROWSABLE VIEW `<data>` on the main activity — otherwise the
149
+ * redirect has no handler and the browser web-searches it (lands on google.com). These stamp as
150
+ * additional `<data android:scheme="..."/>` siblings of `urlScheme`'s deep-link filter. iOS needs no
151
+ * equivalent — ASWebAuthenticationSession intercepts the callbackScheme directly. No-op when absent. */
152
+ oauthRedirectSchemes?: string[];
108
153
  /** App icon source (≥512px square png). Defaults to the largest icon in the PWA manifest. */
109
154
  icon?: string;
110
155
  /** Optional centered logo for the iOS launch splash — a TRANSPARENT-background png (a wordmark or
@@ -281,7 +326,7 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
281
326
  export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
282
327
  'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
283
328
  'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'name',
284
- 'iosEntitlements', 'neutralizeServiceWorker', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
329
+ 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
285
330
  'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'signing', 'signingProfiles', 'statusBarStyle',
286
331
  'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
287
332
  'usesNonExemptEncryption', 'vendorPaths', 'version',
package/src/desktop.ts CHANGED
@@ -44,6 +44,31 @@ export interface DesktopShellConfig {
44
44
  * ~/.bun/bin, so a bare `bun` spawn fails. The Rust shell falls back to `bun` when this is
45
45
  * missing/nonexistent. */
46
46
  handlersRuntime: string;
47
+ /** True when `modules` includes 'media' → the .app carries an NSMicrophoneUsageDescription so
48
+ * `getUserMedia` (voice mode) can pass macOS TCC. The WebKit-layer grant (popup_mac) is
49
+ * unconditional; this only adds the OS usage string an app that actually uses the mic needs. */
50
+ microphone: boolean;
51
+ /** Local-server mode. When present the Rust shell boots `command` (+`args`) in a background thread,
52
+ * polls `http://localhost:<port><healthPath>` until it answers, then navigates the window there
53
+ * (instead of the bundled dist); it kills the child on window-close. Absent → bundled-PWA load.
54
+ * `command`/`cwd` are resolved to ABSOLUTE by the CLI (see `regenerateDesktop`) because a
55
+ * GUI-launched .app has the minimal launchd PATH (no ~/.bun/bin). Empty when the app isn't a
56
+ * local-server app. */
57
+ server?: {
58
+ command: string;
59
+ args: string[];
60
+ cwd: string;
61
+ /** Stdout marker → capture the URL the server prints (dynamic port). '' when using fixed `port`. */
62
+ urlMarker: string;
63
+ /** Fixed port (used only when `urlMarker` is ''). 0 when unused. */
64
+ port: number;
65
+ https: boolean;
66
+ healthPath: string;
67
+ readyTimeoutMs: number;
68
+ /** PATH the child is spawned with — the CLI stamps its own (build-time) PATH so a GUI launch can
69
+ * still resolve neighbouring tools (bun, node) the launchd PATH omits. */
70
+ path: string;
71
+ };
47
72
  }
48
73
 
49
74
  /** Resolve the desktop shell config from an appwrap config: identity + `urlScheme` come from the
@@ -65,6 +90,21 @@ export function deriveDesktopConfig(cfg: AppwrapConfig): DesktopShellConfig {
65
90
  handlers: d.handlers ?? '',
66
91
  pushSigned: false, // stamped by the CLI when a signing identity + macOS profile resolve — see buildDesktop
67
92
  handlersRuntime: '', // stamped by the CLI (process.execPath) when handlers is set — see regenerateDesktop
93
+ microphone: (cfg.modules ?? []).includes('media'),
94
+ // `command`/`cwd`/`path` are resolved to absolute by the CLI (regenerateDesktop); carried as authored here.
95
+ server: d.server
96
+ ? {
97
+ command: d.server.command,
98
+ args: d.server.args ?? [],
99
+ cwd: d.server.cwd ?? '',
100
+ urlMarker: d.server.urlMarker ?? '',
101
+ port: d.server.port ?? 0,
102
+ https: d.server.https ?? false,
103
+ healthPath: d.server.healthPath ?? '/',
104
+ readyTimeoutMs: d.server.readyTimeoutMs ?? 120_000,
105
+ path: '',
106
+ }
107
+ : undefined,
68
108
  };
69
109
  }
70
110
 
@@ -134,6 +174,13 @@ export function buildInfoPlist(
134
174
  <key>CFBundleIconName</key>
135
175
  <string>${xmlEscape(iconName)}</string>`
136
176
  : '';
177
+ // NSMicrophoneUsageDescription: required for getUserMedia (voice mode) to pass macOS TCC — without
178
+ // it the OS silently denies mic access (no prompt). Added only when the app declares the `media` module.
179
+ const mic = shell.microphone
180
+ ? `
181
+ <key>NSMicrophoneUsageDescription</key>
182
+ <string>${xmlEscape(shell.name)} uses the microphone for voice input.</string>`
183
+ : '';
137
184
  return `<?xml version="1.0" encoding="UTF-8"?>
138
185
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
139
186
  <plist version="1.0">
@@ -153,7 +200,7 @@ export function buildInfoPlist(
153
200
  <key>CFBundlePackageType</key>
154
201
  <string>APPL</string>
155
202
  <key>NSHighResolutionCapable</key>
156
- <true/>${icon}${iconAsset}${urlTypes}
203
+ <true/>${icon}${iconAsset}${mic}${urlTypes}
157
204
  </dict>
158
205
  </plist>
159
206
  `;