@livx.cc/appwrap 0.58.4 → 0.59.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.58.4",
3
+ "version": "0.59.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -326,7 +326,7 @@ export const MODULES: ModuleManifest[] = [
326
326
  },
327
327
  },
328
328
 
329
- // NOTE: billing, health, and widget are NOT part of CE — they live in host-provided module packs
329
+ // NOTE: billing, health, and widget are not built in — they live in host-provided module packs
330
330
  // (a consumer opts in via `modulePacks`).
331
331
  ];
332
332
 
@@ -4,6 +4,7 @@ import { mimeFor } from './mime';
4
4
  import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
5
5
  import { envGlobalsJs } from './env';
6
6
  import { requestPermissions } from './android-helpers';
7
+ import { showFileChooser } from './file-chooser.android';
7
8
  import { hostOf } from './env-switcher';
8
9
 
9
10
  // `android` + `java` resolve to the real types-android namespaces (no declare needed).
@@ -80,6 +81,17 @@ function getChromeClientClass(): any {
80
81
  Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
81
82
  );
82
83
  },
84
+
85
+ // <input type="file"> — NOT optional here: replacing NS's WebChromeClient removed the
86
+ // platform's default handling, so without this the input is completely inert. All state is
87
+ // per-invocation closure state inside showFileChooser (the class is shared across webviews).
88
+ onShowFileChooser(
89
+ _view: android.webkit.WebView,
90
+ filePathCallback: android.webkit.ValueCallback<androidNative.Array<android.net.Uri>>,
91
+ fileChooserParams: android.webkit.WebChromeClient.FileChooserParams
92
+ ): boolean {
93
+ return showFileChooser(filePathCallback, fileChooserParams);
94
+ },
83
95
  });
84
96
  return chromeClientClass;
85
97
  }
@@ -0,0 +1,187 @@
1
+ import { Utils } from '@nativescript/core';
2
+ import { mimeFor } from './mime';
3
+ import { requestPermissions, startActivityForResult } from './android-helpers';
4
+
5
+ /**
6
+ * `<input type="file">` support for the Android WebView.
7
+ *
8
+ * WHY THIS FILE EXISTS: we replace NativeScript's WebChromeClient wholesale
9
+ * (custom-webview.android.ts → setWebChromeClient), so the platform's own file-chooser
10
+ * handling is gone with it. Without an `onShowFileChooser` override, tapping a file input
11
+ * does NOTHING — no picker, no callback. (iOS needs none: WKWebView implements the picker itself.)
12
+ *
13
+ * THE ONE INVARIANT: `filePathCallback.onReceiveValue` MUST be invoked EXACTLY ONCE on every
14
+ * path — success, cancel, back, no-activity, thrown exception. Chromium keeps the input in a
15
+ * "chooser open" state until the callback fires, so a missed call wedges that input PERMANENTLY
16
+ * for the rest of the page's life (a second tap silently does nothing). Hence the `settled`
17
+ * latch + the catch-all reject path below; never add an early `return` that skips `deliver`.
18
+ *
19
+ * STATE: everything is per-invocation closure state over the callback/params ARGUMENTS. The
20
+ * WebChromeClient Java proxy is shared across every webview (NS caches the class — see the
21
+ * header comment in custom-webview.android.ts), so holding per-request state on the client or
22
+ * in a module-level slot would cross-wire concurrent requests and separate webviews. Don't.
23
+ */
24
+ export function showFileChooser(
25
+ filePathCallback: android.webkit.ValueCallback<androidNative.Array<android.net.Uri>>,
26
+ params: android.webkit.WebChromeClient.FileChooserParams
27
+ ): boolean {
28
+ let settled = false;
29
+ const deliver = (uris: androidNative.Array<android.net.Uri> | null): void => {
30
+ if (settled) return;
31
+ settled = true;
32
+ Utils.dispatchToMainThread(() => {
33
+ try {
34
+ filePathCallback.onReceiveValue(uris);
35
+ } catch (e) {
36
+ console.warn('[appwrap] file-chooser callback failed: ' + e);
37
+ }
38
+ });
39
+ };
40
+
41
+ try {
42
+ pick(params).then(deliver, (e) => {
43
+ console.warn('[appwrap] file-chooser failed: ' + e);
44
+ deliver(null); // cancel semantics — the input stays usable
45
+ });
46
+ } catch (e) {
47
+ console.warn('[appwrap] file-chooser threw: ' + e);
48
+ deliver(null);
49
+ }
50
+ return true; // we own the request (returning false = "no chooser", input goes inert)
51
+ }
52
+
53
+ /** accept="" tokens → intent MIME types. `.png` style extensions map through the shared MIME table. */
54
+ function acceptMimes(params: any): string[] {
55
+ const raw: string[] = Array.from(params?.getAcceptTypes?.() ?? []).map(String);
56
+ const out = new Set<string>();
57
+ for (const t of raw) {
58
+ const token = t.trim();
59
+ if (!token) continue; // the platform pads getAcceptTypes() with empty strings for accept=""
60
+ if (token.startsWith('.')) out.add(mimeFor(token.slice(1).toLowerCase()));
61
+ else if (token.includes('/')) out.add(token);
62
+ }
63
+ // application/octet-stream is mimeFor's fallback for an unknown extension — it matches nothing
64
+ // in the picker, so an unresolvable accept must widen to "any file", not narrow to nothing.
65
+ if (out.has('application/octet-stream')) return [];
66
+ return Array.from(out);
67
+ }
68
+
69
+ async function pick(params: any): Promise<androidNative.Array<android.net.Uri> | null> {
70
+ const mimes = acceptMimes(params);
71
+ const FCP = android.webkit.WebChromeClient.FileChooserParams;
72
+ const multiple = params?.getMode?.() === FCP.MODE_OPEN_MULTIPLE;
73
+
74
+ if (params?.isCaptureEnabled?.()) {
75
+ const captured = await capture(mimes);
76
+ // Fall through to the normal picker ONLY when the capture path is UNAVAILABLE (no capture
77
+ // app, CAMERA denied): `capture` is a HINT in the HTML spec, not a hard requirement.
78
+ // A settled capture ends the request — including a CANCEL (`uris: null`), which must return
79
+ // the user to the page, not ambush them with a second picker they never asked for.
80
+ if (captured.settled) return captured.uris;
81
+ }
82
+
83
+ const I = android.content.Intent;
84
+ const intent = new I(I.ACTION_GET_CONTENT);
85
+ intent.addCategory(I.CATEGORY_OPENABLE);
86
+ intent.setType(mimes.length === 1 ? mimes[0] : '*/*');
87
+ if (mimes.length > 1) {
88
+ const arr = (Array as any).create(java.lang.String, mimes.length);
89
+ mimes.forEach((m, i) => (arr[i] = new java.lang.String(m)));
90
+ intent.putExtra(I.EXTRA_MIME_TYPES, arr);
91
+ }
92
+ if (multiple) intent.putExtra(I.EXTRA_ALLOW_MULTIPLE, true);
93
+
94
+ const { resultCode, intent: data } = await startActivityForResult(intent);
95
+ if (resultCode !== android.app.Activity.RESULT_OK || !data) return null;
96
+ return toUriArray(collect(data));
97
+ }
98
+
99
+ /** Multi-select arrives as ClipData; single select as the intent's data Uri. */
100
+ function collect(data: android.content.Intent): android.net.Uri[] {
101
+ const uris: android.net.Uri[] = [];
102
+ const clip = data.getClipData?.();
103
+ if (clip) {
104
+ for (let i = 0; i < clip.getItemCount(); i++) {
105
+ const u = clip.getItemAt(i).getUri();
106
+ if (u) uris.push(u);
107
+ }
108
+ }
109
+ const single = data.getData?.();
110
+ if (!uris.length && single) uris.push(single);
111
+ return uris;
112
+ }
113
+
114
+ function toUriArray(uris: android.net.Uri[]): androidNative.Array<android.net.Uri> | null {
115
+ if (!uris.length) return null;
116
+ const arr = (Array as any).create(android.net.Uri, uris.length);
117
+ uris.forEach((u, i) => (arr[i] = u));
118
+ return arr;
119
+ }
120
+
121
+ /**
122
+ * capture="" path: MediaStore capture intents. Image capture writes to a FileProvider-backed
123
+ * cache file (EXTRA_OUTPUT) so the web layer gets a FULL-SIZE file rather than the ~100px
124
+ * thumbnail the extras-only path returns.
125
+ *
126
+ * `settled:false` means the capture path is UNAVAILABLE (no capture activity / CAMERA denied) —
127
+ * the ONLY case the caller may fall back to the normal picker. `settled:true` owns the request:
128
+ * `uris` carries the capture, or is null when the user CANCELLED (back out of the camera). The
129
+ * two were once both a bare null, which turned one cancel into a second, unrequested picker.
130
+ */
131
+ type CaptureResult =
132
+ | { settled: true; uris: androidNative.Array<android.net.Uri> | null }
133
+ | { settled: false };
134
+
135
+ async function capture(mimes: string[]): Promise<CaptureResult> {
136
+ const kind = mimes[0]?.split('/')[0] ?? 'image';
137
+ const MS = android.provider.MediaStore;
138
+ const ctx = Utils.android.getApplicationContext();
139
+ const I = android.content.Intent;
140
+
141
+ const action = kind === 'video' ? MS.ACTION_VIDEO_CAPTURE
142
+ : kind === 'audio' ? MS.Audio.Media.RECORD_SOUND_ACTION
143
+ : MS.ACTION_IMAGE_CAPTURE;
144
+ const intent = new I(action);
145
+ if (!intent.resolveActivity(ctx.getPackageManager())) return { settled: false };
146
+
147
+ // CAMERA is only REQUIRED when the app DECLARES it: Android denies a capture intent to an app
148
+ // that declares the permission without holding it, but asks nothing of an app that doesn't
149
+ // declare it at all. Requesting an undeclared permission returns denied forever → a dead
150
+ // capture path in apps that never opted into the camera module.
151
+ if (action !== MS.Audio.Media.RECORD_SOUND_ACTION && declares('android.permission.CAMERA')) {
152
+ if (!(await requestPermissions(['android.permission.CAMERA']))) return { settled: false };
153
+ }
154
+
155
+ let output: android.net.Uri | null = null;
156
+ if (action === MS.ACTION_IMAGE_CAPTURE) {
157
+ const dir = new java.io.File(ctx.getCacheDir(), 'shared'); // already exposed by file_paths.xml
158
+ dir.mkdirs();
159
+ const file = new java.io.File(dir, 'capture-' + java.lang.System.currentTimeMillis() + '.jpg');
160
+ output = androidx.core.content.FileProvider.getUriForFile(ctx, ctx.getPackageName() + '.fileprovider', file);
161
+ intent.putExtra(MS.EXTRA_OUTPUT, output);
162
+ intent.addFlags(I.FLAG_GRANT_WRITE_URI_PERMISSION | I.FLAG_GRANT_READ_URI_PERMISSION);
163
+ }
164
+
165
+ const { resultCode, intent: data } = await startActivityForResult(intent);
166
+ // Cancelled (BACK out of the camera) — settled, with nothing: the caller delivers null.
167
+ if (resultCode !== android.app.Activity.RESULT_OK) return { settled: true, uris: null };
168
+ // EXTRA_OUTPUT captures return no data Uri — the bytes landed in `output`.
169
+ const uris = data ? collect(data) : [];
170
+ if (!uris.length && output) uris.push(output);
171
+ return { settled: true, uris: toUriArray(uris) };
172
+ }
173
+
174
+ function declares(permission: string): boolean {
175
+ try {
176
+ const ctx = Utils.android.getApplicationContext();
177
+ const info = ctx.getPackageManager().getPackageInfo(
178
+ ctx.getPackageName(),
179
+ android.content.pm.PackageManager.GET_PERMISSIONS
180
+ );
181
+ return Array.from(info.requestedPermissions ?? []).map(String).includes(permission);
182
+ } catch (e) {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ declare const androidx: any; // androidx.core.content.FileProvider — not in the android-32 platform typings
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Mobile plugin host — the in-process analog of the desktop plugin host (`plugin/host.ts`).
2
+ * Mobile plugin host — the in-process analog of an out-of-process desktop plugin host.
3
3
  *
4
- * On desktop a plugin's `WindowCtx` ops marshal over a Unix socket to the Rust shell; that host does
4
+ * On desktop a plugin's `WindowCtx` ops marshal to the native shell over IPC; that host does
5
5
  * NOT apply on mobile — there is one WKWebView/WebView and the NS runtime IS the trusted host. So the
6
6
  * mobile "host" is trivial: at boot it takes each configured plugin's def and registers its `handlers`
7
7
  * directly onto the same {@link bridge} the built-in `handlers*.ts` groups use. A PWA then reaches a
@@ -8,8 +8,8 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  const repoRoot = resolve(pkgDir, '../..');
11
- // Excludes build artifacts (NativeScript: node_modules/platforms/hooks/app/www; Tauri desktop:
12
- // src-tauri/target — gigabytes — and src-tauri/gen — regenerated on build) and OS cruft.
11
+ // Excludes build artifacts (NativeScript: node_modules/platforms/hooks/app/www; desktop shells:
12
+ // native build output — gigabytes — and generated dirs) and OS cruft.
13
13
  const EXCLUDE = /(?:^|\/)(?:node_modules|platforms|hooks|app\/www|target|gen|\.DS_Store)(?:\/|$)/;
14
14
 
15
15
  for (const name of ['runtime', 'templates']) {
package/src/cli.ts CHANGED
@@ -25,8 +25,7 @@ import type { AppwrapConfig } from './config';
25
25
  import { encodeShareDirectSync, unknownConfigKeys } from './config';
26
26
  import { resolveModulePacks, type ResolvedModule, type SyncContext } from './packs';
27
27
  import { createHash } from 'crypto';
28
- // Icon helpers re-exported so the EE desktop lane (which owns the Tauri chassis) can reuse them via
29
- // `@livx.cc/appwrap/cli` — the CE/EE split (Phase D) moved the desktop lane out but keeps these shared.
28
+ // Icon helpers re-exported via `@livx.cc/appwrap/cli` so a host-provided platform lane can reuse them.
30
29
  export { APPLE_ICON_GRID_SCALE, makeDockRuntimeIcon } from './icon';
31
30
  import {
32
31
  androidScreenOrientation,
@@ -109,21 +108,19 @@ function resolveAssetRoot(rel: string): string {
109
108
  const TEMPLATE_DIR = resolveAssetRoot('runtime');
110
109
  /** Ordered runtime-template roots copied into native/ (later roots overlay earlier — file-level
111
110
  * last-wins), defaulting to the single built-in template. runCli (A5) can extend this so a consumer
112
- * (a host) layers extra shell files on top of CE without forking the template. A single root
111
+ * (a host) layers extra shell files on top of the built-in one without forking the template. A single root
113
112
  * is byte-identical to the pre-overlay behavior. */
114
113
  let TEMPLATE_ROOTS: string[] = [TEMPLATE_DIR];
115
114
  const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
116
115
  /** Scaffold for `appwrap create-module` — a starter module pack (see packs.ts / @livx.cc/appwrap/testing). */
117
116
  const MODULE_PACK_TEMPLATE_DIR = resolveAssetRoot('templates/module-pack');
118
- /** Desktop (Tauri chassis) template dir. CE ships no bundled desktop template — the desktop lane is
119
- * host-owned: a consumer points this at its own template via `runCli({ desktopTemplateDir })` (A5).
120
- * A `let` so runCli can override it; empty by default so the mobile lanes are unaffected. The desktop
121
- * lane itself lives in the EE package (Phase D split) and reads this value via `getDesktopTemplateDir()`
122
- * — CE keeps the seam (set by `runCli({ desktopTemplateDir })`) but ships no desktop code. */
117
+ /** Desktop template dir. No desktop template ships here — the desktop lane is host-owned: a consumer
118
+ * points this at its own template via `runCli({ desktopTemplateDir })` (A5). A `let` so runCli can
119
+ * override it; empty by default so the mobile lanes are unaffected. */
123
120
  let DESKTOP_TEMPLATE_DIR = '';
124
121
 
125
- /** The desktop (Tauri) template dir set via `runCli({ desktopTemplateDir })`. Read by the EE desktop
126
- * lane (hosted on top of CE through the `platforms` seam) so the seam stays authoritative in one place. */
122
+ /** The desktop template dir set via `runCli({ desktopTemplateDir })`. Read by the host-provided desktop
123
+ * lane (composed through the `platforms` seam) so the seam stays authoritative in one place. */
127
124
  export function getDesktopTemplateDir(): string {
128
125
  return DESKTOP_TEMPLATE_DIR;
129
126
  }
@@ -139,7 +136,7 @@ export interface CliOptions {
139
136
  commands?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
140
137
  /** Pluggable platform handlers for `dev`/`build` (keyed by the platform arg, e.g. 'desktop') —
141
138
  * consulted BEFORE the built-in platform routing, so a host can add/replace a platform lane. The
142
- * 4th arg is the driving command ('dev' | 'build') so a single handler (e.g. the EE desktop lane)
139
+ * 4th arg is the driving command ('dev' | 'build') so a single handler (e.g. a desktop lane)
143
140
  * can branch dev-vs-build — `dev desktop` and `build desktop` route to the SAME handler key. */
144
141
  platforms?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[], command: 'dev' | 'build') => Promise<void> | void>;
145
142
  /** Module packs injected by the host, applied BEFORE the app config's own `modulePacks` (so the app
@@ -147,7 +144,7 @@ export interface CliOptions {
147
144
  modulePacks?: string[];
148
145
  /** Extra runtime-template overlay roots, appended after the built-in template (later roots win). */
149
146
  templateRoots?: string[];
150
- /** Override for the desktop (Tauri) template dir. */
147
+ /** Override for the desktop template dir. */
151
148
  desktopTemplateDir?: string;
152
149
  }
153
150
 
@@ -342,7 +339,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
342
339
  appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
343
340
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
344
341
  shareTarget: { file: './handlers-share-target', fn: 'registerShareTargetHandlers' },
345
- // billing/health/widget live in host-provided module packs (not part of CE) — a consumer opts in via modulePacks.
342
+ // billing/health/widget live in host-provided module packs — a consumer opts in via modulePacks.
346
343
  };
347
344
 
348
345
  /** The bare specifier a pack handler uses to import a built-in shell API — rewritten to a relative
@@ -2040,15 +2037,20 @@ export function renderCiWorkflow(src: string, ctx: CiRenderContext): string {
2040
2037
  : true;
2041
2038
  const out: string[] = [];
2042
2039
  let dropBlock = false;
2040
+ let inBlock = false; // block directives DON'T nest — fail loud rather than silently mis-render
2043
2041
  for (const line of src.split('\n')) {
2044
2042
  const t = line.trim();
2045
- if (t.startsWith('#@if:')) { dropBlock = !holds(t.slice(5)); continue; }
2046
- if (t === '#@end') { dropBlock = false; continue; }
2043
+ if (t.startsWith('#@if:')) {
2044
+ if (inBlock) throw new Error('renderCiWorkflow: nested #@if: block directives are unsupported');
2045
+ inBlock = true; dropBlock = !holds(t.slice(5)); continue;
2046
+ }
2047
+ if (t === '#@end') { inBlock = false; dropBlock = false; continue; }
2047
2048
  if (dropBlock) continue;
2048
2049
  const m = line.match(/^(.*?)\s*#@if:([a-z]+)$/);
2049
2050
  if (m) { if (holds(m[2])) out.push(m[1]); }
2050
2051
  else out.push(line);
2051
2052
  }
2053
+ if (inBlock) throw new Error('renderCiWorkflow: unterminated #@if: block (missing #@end)');
2052
2054
  return out.join('\n')
2053
2055
  .replaceAll('__DIR__', ctx.subdir ? `${ctx.subdir}/` : '')
2054
2056
  .replaceAll('__APP_DIR__', ctx.subdir)
@@ -2195,7 +2197,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
2195
2197
  console.warn(' ⚠ `shareTarget` is active but `urlScheme` is unset — the iOS share extension cannot forward to the app. Set `urlScheme` in the appwrap config.');
2196
2198
  }
2197
2199
  // Copy each template root in order — later roots OVERLAY earlier (file-level last-wins), so a
2198
- // consumer template can add or replace shell files without forking CE. Single root (default) is
2200
+ // consumer template can add or replace shell files without forking the template. Single root (default) is
2199
2201
  // exactly the pre-overlay copy.
2200
2202
  for (const root of TEMPLATE_ROOTS) {
2201
2203
  if (!existsSync(root)) { console.warn(`⚠ template root not found: ${root}`); continue; }
@@ -4110,7 +4112,7 @@ async function createModule(cwd: string, flags: Record<string, string>, position
4110
4112
  /**
4111
4113
  * The CLI entry point — the bare `appwrap` bin calls `runCli()` with no options (identical to the
4112
4114
  * historical `main`). A host calls `runCli({ commands, platforms, modulePacks,
4113
- * templateRoots, desktopTemplateDir })` to compose extra capability on top of CE without forking it.
4115
+ * templateRoots, desktopTemplateDir })` to compose extra capability on top without forking it.
4114
4116
  */
4115
4117
  export async function runCli(options: CliOptions = {}): Promise<void> {
4116
4118
  cliOptions = { ...options };
package/src/config.ts CHANGED
@@ -132,81 +132,10 @@ export interface AppwrapConfig {
132
132
  * Set 0 for no extra lift (input flush at the reported height; the strip may show). */
133
133
  iosKeyboardExtraLift?: number;
134
134
  pwaDist: string;
135
- /** Desktop (Tauri chassis) shell — phase-0, macOS-only. When present, `appwrap dev|build desktop`
136
- * stamps a generated Tauri wrapper (`native-desktop/`). Identity (`id`/`name`/`version`), `urlScheme`
137
- * (internal deep-link loopback) and `pwaDist` (→ Tauri `frontendDist`) come from the top-level config;
138
- * this only carries the desktop-window specifics. Absent → the desktop lane is inert (mobile-only app). */
139
- desktop?: {
140
- /** Window title (default: `name`). */
141
- title?: string;
142
- /** Initial window inner width in px (default 430 — a phone-ish portrait frame). */
143
- width?: number;
144
- /** Initial window inner height in px (default 800). */
145
- height?: number;
146
- /** Bundle identifier for the desktop build (default: `${id}.desktop`). Reverse-DNS. */
147
- identifier?: string;
148
- /** Remember the window's size + position across launches (tauri-plugin-window-state). Default
149
- * `true`. When `false`, the plugin isn't registered so every launch opens at the configured
150
- * `width`/`height` (no saved state applied). */
151
- restoreWindowState?: boolean;
152
- /** Force Google's account chooser in `signInWithPopup` OAuth. The shared WKWebView cookie store
153
- * keeps the Google session, so Firebase auto-signs the remembered account; for apps that run
154
- * multiple instances against different accounts (e.g. Unclaw) set `true` to append
155
- * `prompt=select_account` to the popup's `accounts.google.com` OAuth URL so the user always picks.
156
- * Default `false` (remember-me behavior stays). macOS-only. */
157
- googleAccountPicker?: boolean;
158
- /** Launch the app automatically when the user logs in (tauri-plugin-autostart, macOS LaunchAgent).
159
- * Default `false`. The shell registers a login item on first launch when `true`, and explicitly
160
- * REMOVES it when `false`, so flipping this off actually clears the login item on the next launch.
161
- * NOTE: an adhoc-signed `.app` (the phase-0 desktop build) still autostarts via the LaunchAgent
162
- * plist — no Developer ID required. macOS-only. */
163
- autostart?: boolean;
164
- /** Path (relative to the app root) to a TypeScript file that exports custom native handlers via
165
- * `defineHandlers({...})` from `@livx.cc/appwrap/handlers`. When present the desktop shell spawns a
166
- * Bun sidecar (`bun <path>`) at launch and routes any method the sidecar advertises to it — so an
167
- * app ships app-custom native capabilities (called from the PWA via `kit.invoke('myapp.foo', …)`)
168
- * without touching the Rust shell. Absent → the feature is fully inert (no sidecar spawned).
169
- * The CLI stamps the ABSOLUTE resolved path into the shell config; handlers run with their CWD at
170
- * the app dir, so `build desktop` apps must keep their repo (+ node_modules) around at runtime —
171
- * embedding the handler file into the `.app` is out of scope for phase-0. macOS-only. */
172
- handlers?: string;
173
- /** Local-server mode: boot a command that serves the app locally, then load it LIVE in the window
174
- * (instead of the bundled `pwaDist`). The shell shows a bundled splash, spawns `command` (+`args`)
175
- * in a background thread, polls `http://localhost:<port><healthPath>` until it answers, then
176
- * navigates the window there; on window-close it kills the child so the stack never outlives the
177
- * shell. Use for a desktop app that IS a local dev/server stack (e.g. `para-chat`). Absent → the
178
- * desktop lane loads the bundled `pwaDist` as usual. The CLI resolves `command` (when a bare name)
179
- * and `cwd` to absolute paths at stamp time (a GUI-launched .app inherits the minimal launchd PATH,
180
- * so a bare `bun`/PATH lookup would fail). `build desktop` apps must keep the server (+ its repo)
181
- * present at runtime — bundling the server INTO the .app is a future phase. macOS-only. */
182
- server?: {
183
- /** Command to spawn (argv[0]). A bare name is PATH-resolved at stamp time; an absolute path is used as-is. */
184
- command: string;
185
- /** Extra arguments passed to the command. */
186
- args?: string[];
187
- /** Working directory to spawn in (default: the app project root). */
188
- cwd?: string;
189
- /** How the shell learns the URL to load. Provide EXACTLY ONE:
190
- * - `urlMarker` (preferred for servers that print their address, e.g. vite/para-chat): the shell
191
- * pipes the server's stdout and, on the first line CONTAINING this marker, extracts the first
192
- * `http(s)://…` token and loads it. This tracks whatever port the server auto-picked — no
193
- * hardcoded port, no mismatch. e.g. `"Frontend:"` matches `Frontend: https://localhost:5050`.
194
- * - `port` (+ optional `https`) for a silent server on a KNOWN fixed port: the shell TCP-polls
195
- * `localhost:<port>` and loads `<http|https>://localhost:<port>`. */
196
- urlMarker?: string;
197
- /** Fixed port the server listens on (used only when `urlMarker` is absent). */
198
- port?: number;
199
- /** Load over https in fixed-`port` mode (default false = http). Ignored in `urlMarker` mode (the
200
- * captured URL carries its own scheme). The server's TLS cert CA must be trusted by the system
201
- * keychain and its SANs cover `localhost`/`127.0.0.1`, or the WebView shows a cert error. */
202
- https?: boolean;
203
- /** Health path polled for readiness (default `/`). Ready = any HTTP response (even 4xx/5xx). */
204
- healthPath?: string;
205
- /** Max ms to wait for the port before showing an error in the splash (default 120000 — a first-run
206
- * production build can be slow). */
207
- readyTimeoutMs?: number;
208
- };
209
- };
135
+ /** Desktop-shell block, consumed by an external desktop host (a module pack) that registers a
136
+ * `'desktop'` platform handler via `runCli({ platforms })`. Carried OPAQUE — nothing here reads
137
+ * it; the key is registered only so `unknownConfigKeys` doesn't warn on configs that declare it. */
138
+ desktop?: Record<string, unknown>;
210
139
  /** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
211
140
  urlScheme?: string;
212
141
  /** Android App Links: https hosts whose `https://<host>/...` links open the app directly (the
@@ -357,8 +286,8 @@ export interface AppwrapConfig {
357
286
  * build finds MULTIPLE candidate profiles for an id and you pick one interactively — so you're not
358
287
  * re-prompted. Usually unset: a single matching profile is selected automatically. */
359
288
  signingProfiles?: Record<string, string>;
360
- /** Store-lane block (listing metadata, screenshot dirs, submission answers) consumed by an EE host
361
- * (`appwrap-ee store bootstrap|push|assets`). CE carries it OPAQUE — nothing in CE reads it; the
289
+ /** Store-lane block (listing metadata, screenshot dirs, submission answers) consumed by external
290
+ * store tooling. Carried OPAQUE — nothing here reads it; the
362
291
  * key is registered only so `unknownConfigKeys` doesn't warn on configs that declare it. */
363
292
  store?: Record<string, unknown>;
364
293
  /** Pure-native escape hatch: a directory (relative to the PWA project) whose contents are copied
@@ -367,10 +296,9 @@ export interface AppwrapConfig {
367
296
  overrides?: string;
368
297
  /** appwrap TS plugins (`@livx.cc/appwrap/plugin`). Each entry is an npm package name or a path to a
369
298
  * plugin entrypoint (that `export default definePlugin(...)`); the object form adds a config-level
370
- * `attachTo` override (which windows it attaches to). `appwrap dev|build desktop` resolves + bun-builds
371
- * each into a single embedded bundle and stamps it into the desktop shell (see `regeneratePlugins`),
372
- * where the multiplexed plugin host loads them. A.1 skeleton: desktop-only; manifest/perms merge is
373
- * stubbed (full module-manifest reuse lands later). */
299
+ * `attachTo` override (which windows it attaches to). On mobile the runtime registers a plugin's
300
+ * `handlers` in-process; a host-provided desktop lane bundles them for its own shell. Manifest/perms
301
+ * merge is stubbed (full module-manifest reuse lands later). */
374
302
  plugins?: (string | { name: string; attachTo?: 'main' | 'all' | string; options?: unknown })[];
375
303
  /** Opt-in capability allow-list (built-in modules — see capabilities.manifest.ts). When PRESENT,
376
304
  * only the listed capabilities (plus always-on core) are advertised, permissioned, and — for
package/src/icon.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Dependency-free PNG codec + macOS app-icon corner rounding, in pure TS on `node:zlib`.
3
3
  *
4
- * WHY: Tauri embeds `src-tauri/icons/icon.png` via `generate_context!` and sets it as the RUNTIME
5
- * app icon on macOS — which OVERRIDES the bundle icns/Assets.car in cmd+tab/Dock. The template ships
6
- * a placeholder square, so every built app shows that square while running. We stamp the app's real
7
- * icon in instead: downscale (sips, in the CLI) → decode here → round the corners with the macOS
8
- * app-icon squircle mask → ensure RGBA (Tauri hard-requires alpha) → re-encode.
4
+ * WHY: a desktop shell can embed an icon PNG and set it as the RUNTIME app icon on macOS — which
5
+ * OVERRIDES the bundle icns/Assets.car in cmd+tab/Dock. A template placeholder would then show while
6
+ * the app runs, so we stamp the app's real icon in instead: downscale (sips, in the CLI) → decode here
7
+ * → round the corners with the macOS app-icon squircle mask → ensure RGBA (alpha is required) → re-encode.
9
8
  *
10
9
  * Supports 8-bit RGB / RGBA, non-interlaced PNG (what `sips` emits). Errors clearly on anything else.
11
10
  */
package/src/packs.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Module-pack resolver — the single public extension seam of appwrap (CE/EE seam A2).
2
+ * Module-pack resolver — the single public extension seam of appwrap.
3
3
  *
4
4
  * A "pack" is a directory (local, or an npm package) that contributes capabilities to a wrapper build
5
5
  * exactly like the built-in modules do: it exports `ModuleManifest[]` (the same contribution bags the
@@ -15,7 +15,7 @@
15
15
  * own module is not an error).
16
16
  *
17
17
  * This one mechanism serves three audiences identically: a host layers its private packs, a consumer
18
- * app vendors its own copy of a module, and the community ships plugins — none of them patch CE.
18
+ * app vendors its own copy of a module, and the community ships plugins — none of them patch the core.
19
19
  *
20
20
  * PURE of CLI I/O: filesystem/npm resolution and pack import are injected ports (`resolvePack`/
21
21
  * `importPack`), so the merge logic unit-tests against in-memory fakes. The CLI wires the real ports.
@@ -14,7 +14,7 @@
14
14
  * });
15
15
  *
16
16
  * Types-first: `definePlugin` is an identity helper that validates + returns the def, which the plugin
17
- * entrypoint `export default`s. The multiplexed host (`host.ts`) `import()`s the built bundle and reads
17
+ * entrypoint `export default`s. The multiplexed host `import()`s the built bundle and reads
18
18
  * `.default`. Nothing runs at import time (unlike `defineHandlers`, which starts a loop) — the host
19
19
  * drives the lifecycle.
20
20
  */
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * appwrap plugin contract — the PORT (`@livx.cc/appwrap/plugin`).
3
3
  *
4
- * A TS plugin consumes a CURATED, VERSIONED facade over the native `webview-control` primitives
5
- * ({@link WindowCtx}) — it never touches objc2 FFI. The plugin runs OUT-OF-WEBVIEW in the trusted
6
- * multiplexed Bun host (see `host.ts`); the host speaks a bidirectional line-JSON protocol over a
7
- * Unix domain socket to the Rust shell, which owns the primitives.
4
+ * A TS plugin consumes a CURATED, VERSIONED facade over the native window primitives
5
+ * ({@link WindowCtx}) — it never touches native FFI. The plugin runs OUT-OF-WEBVIEW in a trusted
6
+ * multiplexed Bun host, which speaks a bidirectional line-JSON protocol to the native shell that
7
+ * owns the primitives.
8
8
  *
9
9
  * IoC / Hollywood: the CORE calls the plugin — on window-created it fans out to each plugin whose
10
10
  * `attachTo` matches, invoking `onWindow(ctx)`. Plugins never reach into core internals.
@@ -37,7 +37,7 @@ export interface WindowIdentity {
37
37
  export type Dispose = () => void;
38
38
 
39
39
  /**
40
- * Curated, versioned facade over the native `webview-control` primitives, scoped to ONE window.
40
+ * Curated, versioned facade over the native window primitives, scoped to ONE window.
41
41
  * NOT raw FFI — a deliberately small subset (design risk #4: avoid a god-interface). Every method
42
42
  * marshals over the host↔shell socket as an `op` envelope and awaits a `result`.
43
43
  */
@@ -79,7 +79,7 @@ export interface PluginDef {
79
79
  onClose?(win: WindowIdentity): void;
80
80
  }
81
81
 
82
- // ── Wire protocol (host ↔ shell, one JSON object per line over a Unix domain socket) ──────────────
82
+ // ── Wire protocol (host ↔ shell, one JSON object per line) ────────────────────────────────────────
83
83
  // Envelope: { pluginId?, windowId?, kind, method?, params?, id?, ... }. Route by pluginId × windowId.
84
84
  // host → shell: { kind:'ready', plugins:[{ pluginId, methods:[…] }] } (once, at boot)
85
85
  // host → shell: { kind:'op', id, pluginId, windowId, method, params } (a WindowCtx call)
@@ -101,7 +101,7 @@ export interface StampedPlugin {
101
101
  /** Bundle/build id derived from the config source (sanitized filename or package name). This is a
102
102
  * pre-bundle-load stamp — the plugin's real def `name` isn't known until the host `import()`s the
103
103
  * bundle. It is NOT the routing/diagnostic identifier: routing (matchScope, registry) and all
104
- * diagnostics key off the loaded def `name` (see host.ts). Used only for the bundle filename. */
104
+ * diagnostics key off the loaded def `name`. Used only for the bundle filename. */
105
105
  bundleId: string;
106
106
  /** Config-level attachTo OVERRIDE (string forms only). Falls back to the def's own `attachTo`. */
107
107
  attachTo?: 'main' | 'all' | string;
package/src/testing.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pack conformance validator — the public `@livx.cc/appwrap/testing` entry (CE/EE seam A8).
2
+ * Pack conformance validator — the public `@livx.cc/appwrap/testing` entry.
3
3
  *
4
4
  * A module pack (see packs.ts) is authored out-of-tree and staged into the shell at sync. Because a
5
5
  * malformed pack fails LATE (mid-generate, on a device build), this gives pack authors a FAST, offline
@@ -32,7 +32,7 @@ export interface ValidatePackResult {
32
32
  const NODE_BUILTIN_SET = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));
33
33
 
34
34
  /** The sanctioned shell-API specifier prefix a pack handler may deep-import (rewritten to relative on
35
- * staging). Any OTHER `@livx.cc/appwrap/...` deep import reaches into CE internals → rejected. */
35
+ * staging). Any OTHER `@livx.cc/appwrap/...` deep import reaches into internals → rejected. */
36
36
  const SANCTIONED_SHELL_PREFIX = '@livx.cc/appwrap/runtime/app/shell/';
37
37
 
38
38
  /** Map a handler file extension to the Bun.Transpiler loader for its dialect (mirrors cli.ts). */
@@ -51,7 +51,7 @@ function loaderFor(file: string): 'ts' | 'tsx' | 'js' | 'jsx' {
51
51
  function bareImportError(spec: string): string | null {
52
52
  // Sanctioned shell API — always fine.
53
53
  if (spec === SANCTIONED_SHELL_PREFIX.slice(0, -1) || spec.startsWith(SANCTIONED_SHELL_PREFIX)) return null;
54
- // Any OTHER appwrap deep import escapes the sanctioned seam into CE internals.
54
+ // Any OTHER appwrap deep import escapes the sanctioned seam into internals.
55
55
  if (spec === '@livx.cc/appwrap' || spec.startsWith('@livx.cc/appwrap/')) {
56
56
  return `imports appwrap internals via "${spec}" — a pack may only deep-import the sanctioned ` +
57
57
  `"${SANCTIONED_SHELL_PREFIX}*" shell APIs.`;