@livx.cc/appwrap 0.58.4 → 0.60.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 +1 -1
- package/runtime/app/main-page.ts +1 -1
- package/runtime/app/main-page.xml +1 -1
- package/runtime/app/shell/capabilities.manifest.ts +1 -1
- package/runtime/app/shell/config.ts +7 -0
- package/runtime/app/shell/custom-webview.android.ts +12 -0
- package/runtime/app/shell/custom-webview.ios.ts +8 -4
- package/runtime/app/shell/file-chooser.android.ts +187 -0
- package/runtime/app/shell/plugin-host.ts +2 -2
- package/scripts/stage-assets.mjs +2 -2
- package/src/cli.ts +141 -26
- package/src/config.ts +19 -85
- package/src/icon.ts +4 -5
- package/src/packs.ts +2 -2
- package/src/plugin/index.ts +1 -1
- package/src/plugin/types.ts +7 -7
- package/src/testing.ts +3 -3
package/package.json
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -86,7 +86,7 @@ function armNativeSurfaceRecovery(webView: CustomWebView): void {
|
|
|
86
86
|
|
|
87
87
|
export function onPageLoaded(args: EventData): void {
|
|
88
88
|
const page = args.object as Page;
|
|
89
|
-
page.bindingContext = { backgroundColor: SHELL_CONFIG.backgroundColor };
|
|
89
|
+
page.bindingContext = { backgroundColor: SHELL_CONFIG.backgroundColor, appName: SHELL_CONFIG.name };
|
|
90
90
|
bindStatusBarPage(page);
|
|
91
91
|
if (isAndroid) enableAndroidEdgeToEdge();
|
|
92
92
|
applyThemeColor(SHELL_CONFIG.themeColor); // manifest/config theme_color → native chrome at boot
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<!-- loader:'server' load-failure fallback (App Review 2.1a): shown by main-page.ts when the
|
|
10
10
|
server navigation fails (offline / server down) so the reviewer never sees a white screen. -->
|
|
11
11
|
<StackLayout id="loadFallback" row="0" visibility="collapse" backgroundColor="#ffffff" verticalAlignment="center" padding="32">
|
|
12
|
-
<Label text="
|
|
12
|
+
<Label text="{{ appName }}" fontSize="34" fontWeight="700" color="#000000" textAlignment="center" />
|
|
13
13
|
<Label text="Can't connect" fontSize="17" fontWeight="600" color="#333333" textAlignment="center" marginTop="18" />
|
|
14
14
|
<Label text="Check your internet connection — we'll keep trying." fontSize="14" color="#777777" textAlignment="center" textWrap="true" marginTop="6" />
|
|
15
15
|
<Button id="loadRetryBtn" text="Retry" fontSize="16" fontWeight="600" color="#ffffff" backgroundColor="#000000" borderRadius="22" height="44" width="160" marginTop="24" />
|
|
@@ -326,7 +326,7 @@ export const MODULES: ModuleManifest[] = [
|
|
|
326
326
|
},
|
|
327
327
|
},
|
|
328
328
|
|
|
329
|
-
// NOTE: billing, health, and widget are
|
|
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
|
|
|
@@ -74,4 +74,11 @@ export const SHELL_CONFIG = {
|
|
|
74
74
|
* inert. `envs` = declared presets; `allowPattern` = anchored regex gating "Other" (default-deny
|
|
75
75
|
* when ''). Stamped by `appwrap init`/`sync` — see `stampShellConfig`. */
|
|
76
76
|
envSwitcher: { enabled: false, envs: [] as { label: string; url: string }[], allowPattern: '' },
|
|
77
|
+
/** TCC-gated web APIs this build DECLARED (active modules + the config's `permissions{}`) — what the
|
|
78
|
+
* document-start capability guard exposes to the page. NOT the same question as "is the Info.plist
|
|
79
|
+
* usage string present": the plist also carries the webview baseline (NSCameraUsageDescription is
|
|
80
|
+
* stamped in every build so WKWebView's `<input type="file">` "Take Photo" can't TCC-kill the
|
|
81
|
+
* process), and an app that never asked for the camera must still not be handing `getUserMedia` to
|
|
82
|
+
* whatever page it renders. Stamped by `appwrap init`/`sync` — see `stampShellConfig`. */
|
|
83
|
+
webCaps: { camera: false, microphone: false, geolocation: false },
|
|
77
84
|
};
|
|
@@ -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
|
}
|
|
@@ -332,11 +332,15 @@ export class CustomWebView extends WebView {
|
|
|
332
332
|
// (reject camera/mic/geolocation in JS before WebKit's native path — see capabilityGuardJs) +
|
|
333
333
|
// native-feel suppression, all injected before the page's own scripts run. Globals first so the page
|
|
334
334
|
// can read __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__.
|
|
335
|
-
|
|
335
|
+
// Driven by the STAMPED declaration, not by sniffing Info.plist: NSCameraUsageDescription is now
|
|
336
|
+
// present in every build (the webview baseline — so WKWebView's file-input "Take Photo" can't
|
|
337
|
+
// TCC-kill us), and reading presence would therefore open getUserMedia({video}) to every page an
|
|
338
|
+
// app renders, including ones a loader:'server' shell doesn't control. SHELL_CONFIG.webCaps is
|
|
339
|
+
// what the app actually declared (modules + `permissions{}`).
|
|
336
340
|
const capabilityGuard = capabilityGuardJs({
|
|
337
|
-
camera:
|
|
338
|
-
microphone:
|
|
339
|
-
geolocation:
|
|
341
|
+
camera: SHELL_CONFIG.webCaps.camera,
|
|
342
|
+
microphone: SHELL_CONFIG.webCaps.microphone,
|
|
343
|
+
geolocation: SHELL_CONFIG.webCaps.geolocation,
|
|
340
344
|
});
|
|
341
345
|
const swGuard = serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker);
|
|
342
346
|
const extNavGuard = externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser);
|
|
@@ -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
|
|
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
|
|
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
|
package/scripts/stage-assets.mjs
CHANGED
|
@@ -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;
|
|
12
|
-
//
|
|
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
|
|
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,
|
|
@@ -87,6 +86,12 @@ const IOS_PERMISSION_KEYS: Record<string, string[]> = {
|
|
|
87
86
|
calendar: ['NSCalendarsFullAccessUsageDescription', 'NSCalendarsUsageDescription'],
|
|
88
87
|
};
|
|
89
88
|
|
|
89
|
+
/** Default copy for the always-stamped NSCameraUsageDescription (see "the webview baseline" in
|
|
90
|
+
* nativeReqs). Written for an App Review reader: it describes the ONLY way a plain appwrap app reaches
|
|
91
|
+
* the camera — choosing "Take Photo" from a file upload. An app that calls the camera directly (the
|
|
92
|
+
* `camera`/`scanner` modules, or its own `permissions.camera`) replaces this with its own copy. */
|
|
93
|
+
const WEBVIEW_BASELINE_CAMERA_USAGE = 'Take a photo when you choose “Take Photo” while attaching a file.';
|
|
94
|
+
|
|
90
95
|
/** Runtime permissions stamped into AndroidManifest.xml per declared domain.
|
|
91
96
|
* photos/faceid need none: system picker / USE_BIOMETRIC is baseline. */
|
|
92
97
|
const ANDROID_PERMISSION_KEYS: Record<string, string[]> = {
|
|
@@ -109,21 +114,19 @@ function resolveAssetRoot(rel: string): string {
|
|
|
109
114
|
const TEMPLATE_DIR = resolveAssetRoot('runtime');
|
|
110
115
|
/** Ordered runtime-template roots copied into native/ (later roots overlay earlier — file-level
|
|
111
116
|
* 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
|
|
117
|
+
* (a host) layers extra shell files on top of the built-in one without forking the template. A single root
|
|
113
118
|
* is byte-identical to the pre-overlay behavior. */
|
|
114
119
|
let TEMPLATE_ROOTS: string[] = [TEMPLATE_DIR];
|
|
115
120
|
const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
|
|
116
121
|
/** Scaffold for `appwrap create-module` — a starter module pack (see packs.ts / @livx.cc/appwrap/testing). */
|
|
117
122
|
const MODULE_PACK_TEMPLATE_DIR = resolveAssetRoot('templates/module-pack');
|
|
118
|
-
/** Desktop
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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. */
|
|
123
|
+
/** Desktop template dir. No desktop template ships here — the desktop lane is host-owned: a consumer
|
|
124
|
+
* points this at its own template via `runCli({ desktopTemplateDir })` (A5). A `let` so runCli can
|
|
125
|
+
* override it; empty by default so the mobile lanes are unaffected. */
|
|
123
126
|
let DESKTOP_TEMPLATE_DIR = '';
|
|
124
127
|
|
|
125
|
-
/** The desktop
|
|
126
|
-
* lane (
|
|
128
|
+
/** The desktop template dir set via `runCli({ desktopTemplateDir })`. Read by the host-provided desktop
|
|
129
|
+
* lane (composed through the `platforms` seam) so the seam stays authoritative in one place. */
|
|
127
130
|
export function getDesktopTemplateDir(): string {
|
|
128
131
|
return DESKTOP_TEMPLATE_DIR;
|
|
129
132
|
}
|
|
@@ -139,7 +142,7 @@ export interface CliOptions {
|
|
|
139
142
|
commands?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
|
|
140
143
|
/** Pluggable platform handlers for `dev`/`build` (keyed by the platform arg, e.g. 'desktop') —
|
|
141
144
|
* 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.
|
|
145
|
+
* 4th arg is the driving command ('dev' | 'build') so a single handler (e.g. a desktop lane)
|
|
143
146
|
* can branch dev-vs-build — `dev desktop` and `build desktop` route to the SAME handler key. */
|
|
144
147
|
platforms?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[], command: 'dev' | 'build') => Promise<void> | void>;
|
|
145
148
|
/** Module packs injected by the host, applied BEFORE the app config's own `modulePacks` (so the app
|
|
@@ -147,7 +150,7 @@ export interface CliOptions {
|
|
|
147
150
|
modulePacks?: string[];
|
|
148
151
|
/** Extra runtime-template overlay roots, appended after the built-in template (later roots win). */
|
|
149
152
|
templateRoots?: string[];
|
|
150
|
-
/** Override for the desktop
|
|
153
|
+
/** Override for the desktop template dir. */
|
|
151
154
|
desktopTemplateDir?: string;
|
|
152
155
|
}
|
|
153
156
|
|
|
@@ -235,6 +238,11 @@ interface NativeReqs {
|
|
|
235
238
|
activeOptIn: string[]; // opt-in capability names that are active (for the handshake map)
|
|
236
239
|
activeOptionalGroups: string[]; // strippable handler groups (own file) that are active
|
|
237
240
|
iosPlist: Array<{ key: string; usage: string }>;
|
|
241
|
+
/** What the app DECLARED it wants the web layer to be able to use — modules + `permissions{}`, and
|
|
242
|
+
* deliberately NOT the webview baseline. Drives the shell's document-start capability guard.
|
|
243
|
+
* Kept separate from `iosPlist` because the two answer different questions: the plist must cover
|
|
244
|
+
* everything the OS can kill us for REACHING, the guard must expose only what the app ASKED for. */
|
|
245
|
+
webCaps: { camera: boolean; microphone: boolean; geolocation: boolean };
|
|
238
246
|
iosEntitlements: Record<string, boolean | string | string[]>;
|
|
239
247
|
androidPerms: string[];
|
|
240
248
|
androidGradleDeps: string[];
|
|
@@ -280,7 +288,11 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
280
288
|
for (const p of m.ios?.permissions ?? []) {
|
|
281
289
|
if (seenKeys.has(p.key)) continue;
|
|
282
290
|
seenKeys.add(p.key);
|
|
283
|
-
|
|
291
|
+
// `permissions{}` overrides the module's default COPY. A `false` there is an opt-out of the
|
|
292
|
+
// webview baseline (below), never of a module's own key — the module ships native code that
|
|
293
|
+
// touches the class, so the string is mandatory; fall back to the default copy.
|
|
294
|
+
const override = cfg.permissions?.[p.domain as keyof typeof cfg.permissions];
|
|
295
|
+
iosPlist.push({ key: p.key, usage: typeof override === 'string' && override ? override : p.defaultUsage });
|
|
284
296
|
}
|
|
285
297
|
for (const ap of m.android?.permissions ?? []) androidPerms.add(ap);
|
|
286
298
|
for (const g of m.android?.gradleDeps ?? []) gradle.add(g);
|
|
@@ -292,14 +304,52 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
292
304
|
// resolves under the pack's own dir, so it must not be conflated with the built-in dir names.
|
|
293
305
|
if (m.nativeSrc && !packInfo(m.name)) nativeSrc.push(m.nativeSrc);
|
|
294
306
|
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// A DECLARED permission is always stamped — in BOTH modes. Before, `permissions{}` was read only in
|
|
310
|
+
// legacy mode; with `modules` present it merely overrode a module's usage copy, so declaring a domain
|
|
311
|
+
// no module owned stamped NOTHING. That silent inertness shipped a store rejection (Copy Bin,
|
|
312
|
+
// Guideline 2.1(a): TCC killed the app for a missing NSCameraUsageDescription). Module-derived keys are
|
|
313
|
+
// collected FIRST and win the dedupe, so an app whose declared domains its modules already own stamps
|
|
314
|
+
// byte-identically. `false` = an explicit opt-out (see the webview baseline below), never a usage string.
|
|
315
|
+
for (const [domain, text] of Object.entries(cfg.permissions ?? {})) {
|
|
316
|
+
if (text === false) continue;
|
|
317
|
+
for (const key of IOS_PERMISSION_KEYS[domain] ?? []) {
|
|
318
|
+
if (text && !seenKeys.has(key)) { seenKeys.add(key); iosPlist.push({ key, usage: text }); }
|
|
302
319
|
}
|
|
320
|
+
for (const p of ANDROID_PERMISSION_KEYS[domain] ?? []) androidPerms.add(p);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Snapshot the DECLARED capabilities before the baseline widens the plist — see NativeReqs.webCaps.
|
|
324
|
+
const webCaps = {
|
|
325
|
+
camera: seenKeys.has('NSCameraUsageDescription'),
|
|
326
|
+
microphone: seenKeys.has('NSMicrophoneUsageDescription'),
|
|
327
|
+
geolocation: seenKeys.has('NSLocationWhenInUseUsageDescription'),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// ── The webview baseline ───────────────────────────────────────────────────────────────────────
|
|
331
|
+
// The shell's invariant is that every TCC-gated capability a WEB PAGE can reach is either DECLARED
|
|
332
|
+
// (usage string present) or BLOCKED before WebKit enters its native path — see mediaCaptureGuardJs /
|
|
333
|
+
// geolocationGuardJs, which reject getUserMedia + geolocation in JS at document-start.
|
|
334
|
+
//
|
|
335
|
+
// `<input type="file">` is the one reachable path with no such seam: WKWebView's own picker is native
|
|
336
|
+
// and internal, it offers "Take Photo" for an image accept, and TCC hard-kills the HOST process for a
|
|
337
|
+
// camera access with no usage string. There is no JS hook to suppress that menu item and no config in
|
|
338
|
+
// which it is absent — EVERY appwrap iOS build can reach it. When the answer is unconditional the
|
|
339
|
+
// correct mechanism is a default, not a warning the developer has no way to act on: an app author
|
|
340
|
+
// reads `modules` as "features we call", and nothing about a file input reads as "camera".
|
|
341
|
+
//
|
|
342
|
+
// So the key is stamped by default, with honest copy, overridable via `permissions.camera` and
|
|
343
|
+
// opt-out-able with `permissions: { camera: false }`. An unused iOS usage string is inert (it is only
|
|
344
|
+
// ever surfaced when the class is actually requested) — it is not an App Review flag, unlike an
|
|
345
|
+
// entitlement or a background mode.
|
|
346
|
+
//
|
|
347
|
+
// iOS only: the Android chooser's capture path already handles an UNDECLARED CAMERA permission (it
|
|
348
|
+
// checks `declares()` and falls back to the plain picker — file-chooser.android.ts), so Android needs
|
|
349
|
+
// nothing and stays opt-in; adding a runtime permission there would change the Play listing.
|
|
350
|
+
if (cfg.permissions?.camera !== false && !seenKeys.has('NSCameraUsageDescription')) {
|
|
351
|
+
seenKeys.add('NSCameraUsageDescription');
|
|
352
|
+
iosPlist.push({ key: 'NSCameraUsageDescription', usage: WEBVIEW_BASELINE_CAMERA_USAGE });
|
|
303
353
|
}
|
|
304
354
|
|
|
305
355
|
// Active PACK modules (from the config's modulePacks) — their native source + register handler
|
|
@@ -319,6 +369,7 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
319
369
|
activeOptIn: optIn.filter((m) => active.has(m.name)).map((m) => m.name),
|
|
320
370
|
activeOptionalGroups: OPTIONAL_GROUPS.filter((g) => activeMods.some((m) => m.group === g)),
|
|
321
371
|
iosPlist,
|
|
372
|
+
webCaps,
|
|
322
373
|
iosEntitlements,
|
|
323
374
|
androidPerms: [...androidPerms],
|
|
324
375
|
androidGradleDeps: [...gradle],
|
|
@@ -342,7 +393,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
|
|
|
342
393
|
appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
|
|
343
394
|
backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
|
|
344
395
|
shareTarget: { file: './handlers-share-target', fn: 'registerShareTargetHandlers' },
|
|
345
|
-
// billing/health/widget live in host-provided module packs
|
|
396
|
+
// billing/health/widget live in host-provided module packs — a consumer opts in via modulePacks.
|
|
346
397
|
};
|
|
347
398
|
|
|
348
399
|
/** The bare specifier a pack handler uses to import a built-in shell API — rewritten to a relative
|
|
@@ -862,6 +913,12 @@ export async function loadConfig(cwd: string, flags: Record<string, string>): Pr
|
|
|
862
913
|
export function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
|
|
863
914
|
// Resolve the env-switcher block. Absent block OR `enabled:false` → the whole feature is inert
|
|
864
915
|
// (the shell reads `envSwitcher.enabled`). `allowPattern`/`envs` default to empty (default-deny).
|
|
916
|
+
// Which TCC-gated web APIs this build DECLARED (modules + `permissions{}`). The shell's guard used to
|
|
917
|
+
// sniff Info.plist for the usage strings, but the plist now also carries the webview baseline
|
|
918
|
+
// (an always-present NSCameraUsageDescription so the file-input picker can't kill the process) —
|
|
919
|
+
// sniffing it would silently hand getUserMedia({video}) to every app, including a loader:'server'
|
|
920
|
+
// shell showing pages the author doesn't control. Intent and plist are now stamped separately.
|
|
921
|
+
const webCaps = nativeReqs(cfg).webCaps;
|
|
865
922
|
const es = cfg.envSwitcher;
|
|
866
923
|
const envSwitcher = {
|
|
867
924
|
enabled: !!es && es.enabled !== false,
|
|
@@ -896,6 +953,7 @@ export const SHELL_CONFIG = {
|
|
|
896
953
|
pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
|
|
897
954
|
iosKeyboardExtraLift: ${JSON.stringify(cfg.iosKeyboardExtraLift ?? 82)},
|
|
898
955
|
envSwitcher: ${JSON.stringify(envSwitcher)} as { enabled: boolean; envs: { label: string; url: string }[]; allowPattern: string },
|
|
956
|
+
webCaps: ${JSON.stringify(webCaps)} as { camera: boolean; microphone: boolean; geolocation: boolean },
|
|
899
957
|
};
|
|
900
958
|
`;
|
|
901
959
|
writeFileSync(join(outDir, 'app/shell/config.ts'), content);
|
|
@@ -2040,15 +2098,20 @@ export function renderCiWorkflow(src: string, ctx: CiRenderContext): string {
|
|
|
2040
2098
|
: true;
|
|
2041
2099
|
const out: string[] = [];
|
|
2042
2100
|
let dropBlock = false;
|
|
2101
|
+
let inBlock = false; // block directives DON'T nest — fail loud rather than silently mis-render
|
|
2043
2102
|
for (const line of src.split('\n')) {
|
|
2044
2103
|
const t = line.trim();
|
|
2045
|
-
if (t.startsWith('#@if:')) {
|
|
2046
|
-
|
|
2104
|
+
if (t.startsWith('#@if:')) {
|
|
2105
|
+
if (inBlock) throw new Error('renderCiWorkflow: nested #@if: block directives are unsupported');
|
|
2106
|
+
inBlock = true; dropBlock = !holds(t.slice(5)); continue;
|
|
2107
|
+
}
|
|
2108
|
+
if (t === '#@end') { inBlock = false; dropBlock = false; continue; }
|
|
2047
2109
|
if (dropBlock) continue;
|
|
2048
2110
|
const m = line.match(/^(.*?)\s*#@if:([a-z]+)$/);
|
|
2049
2111
|
if (m) { if (holds(m[2])) out.push(m[1]); }
|
|
2050
2112
|
else out.push(line);
|
|
2051
2113
|
}
|
|
2114
|
+
if (inBlock) throw new Error('renderCiWorkflow: unterminated #@if: block (missing #@end)');
|
|
2052
2115
|
return out.join('\n')
|
|
2053
2116
|
.replaceAll('__DIR__', ctx.subdir ? `${ctx.subdir}/` : '')
|
|
2054
2117
|
.replaceAll('__APP_DIR__', ctx.subdir)
|
|
@@ -2195,7 +2258,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
2195
2258
|
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
2259
|
}
|
|
2197
2260
|
// 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
|
|
2261
|
+
// consumer template can add or replace shell files without forking the template. Single root (default) is
|
|
2199
2262
|
// exactly the pre-overlay copy.
|
|
2200
2263
|
for (const root of TEMPLATE_ROOTS) {
|
|
2201
2264
|
if (!existsSync(root)) { console.warn(`⚠ template root not found: ${root}`); continue; }
|
|
@@ -3146,7 +3209,29 @@ function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: strin
|
|
|
3146
3209
|
}
|
|
3147
3210
|
}
|
|
3148
3211
|
stats.push({ sum: runtime, newest: runtimeNewest });
|
|
3149
|
-
|
|
3212
|
+
const parts = stats.map((s) => s.sum);
|
|
3213
|
+
// The STAMPED shell config (native/app/shell/config.ts) — compiled into bundle.js, and the ONLY
|
|
3214
|
+
// input that carries values which never touch a fingerprinted file. `dev --url` / `deploy` stamp it
|
|
3215
|
+
// from the EFFECTIVE cfg (serverUrl, loader, debug), so with `--url` every other input is byte-identical
|
|
3216
|
+
// and the fingerprint matched → "inputs unchanged" → the previous .ipa (carrying the PREVIOUS
|
|
3217
|
+
// serverUrl) was reinstalled while the on-disk config said the new one. Silent stale deploy.
|
|
3218
|
+
// CONTENT, not mtime: sync/stamp rewrites this file unconditionally on every run, so its mtime is
|
|
3219
|
+
// pure noise (it would bust the cache always). Deliberately excluded from `newest` for the same
|
|
3220
|
+
// reason — an always-now mtime would make the `--resume` gate unusable, and the fingerprint is the
|
|
3221
|
+
// real gate; --resume is the evidence-relaxed first-run path by construction.
|
|
3222
|
+
parts.push(stringHash(readFileIfExists(join(resolve(cwd, flags.out ?? 'native'), 'app/shell/config.ts'))));
|
|
3223
|
+
return { parts, newest: Math.max(...stats.map((s) => s.newest)) };
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
function readFileIfExists(p: string): string {
|
|
3227
|
+
try { return readFileSync(p, 'utf8'); } catch { return ''; }
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
/** djb2 over a string — same family as the fingerprint hash; only needs to change when content does. */
|
|
3231
|
+
function stringHash(s: string): number {
|
|
3232
|
+
let h = 5381;
|
|
3233
|
+
for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
|
|
3234
|
+
return h;
|
|
3150
3235
|
}
|
|
3151
3236
|
|
|
3152
3237
|
/** Newest mtime across every build input — the evidence the `--resume` gate needs when no build cache
|
|
@@ -3660,6 +3745,11 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
3660
3745
|
// ungated. (A fingerprintMatch skip already has an identical cache; nothing to write.)
|
|
3661
3746
|
if (!canSkipBuild || adoptCache) writeBuildCache(outDir, 'ios', { fingerprint: fp, artifactPath: ipaPath, builtAt: new Date().toISOString() });
|
|
3662
3747
|
|
|
3748
|
+
// Prove the .ipa we're about to install actually carries the serverUrl we just stamped — the CLI used
|
|
3749
|
+
// to print "✓ Deployed" while shipping an artifact built from a PREVIOUS URL (a build-skip whose cache
|
|
3750
|
+
// key ignored the stamped shell config). Cheap, and it fails BEFORE the install rather than silently.
|
|
3751
|
+
assertShippedServerUrl(ipaPath, cfg);
|
|
3752
|
+
|
|
3663
3753
|
console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
|
|
3664
3754
|
let installedViaUsbmux = false;
|
|
3665
3755
|
let installed = false;
|
|
@@ -3753,6 +3843,31 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
3753
3843
|
: `✓ Deployed to ${device.name}.`);
|
|
3754
3844
|
}
|
|
3755
3845
|
|
|
3846
|
+
/** Deploy-time verification for `loader:'server'`: the .ipa's compiled bundle must contain the exact
|
|
3847
|
+
* `serverUrl` we stamped. Reads the bundle straight out of the zip (no extraction). Exits on mismatch —
|
|
3848
|
+
* shipping a shell pointed at the WRONG origin looks like a working deploy and is the hardest class of
|
|
3849
|
+
* bug to see from the outside. Unverifiable (no unzip / bundle not found) → warn, never block. */
|
|
3850
|
+
function assertShippedServerUrl(ipaPath: string, cfg: AppwrapConfig): void {
|
|
3851
|
+
if ((cfg.loader ?? 'app') !== 'server' || !cfg.serverUrl) return;
|
|
3852
|
+
let bundle = '';
|
|
3853
|
+
try {
|
|
3854
|
+
bundle = execFileSync('sh', ['-c', `unzip -p ${JSON.stringify(ipaPath)} 'Payload/*.app/app/bundle.js'`], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
3855
|
+
} catch { /* no unzip / unexpected layout → fall through to the unverifiable warning */ }
|
|
3856
|
+
if (!bundle) return void console.warn(` ⚠ could not read the bundle out of ${ipaPath.split('/').pop()} — shipped serverUrl NOT verified.`);
|
|
3857
|
+
if (bundle.includes(JSON.stringify(cfg.serverUrl))) {
|
|
3858
|
+
console.log(`✓ verified shipped serverUrl → ${cfg.serverUrl}`);
|
|
3859
|
+
return;
|
|
3860
|
+
}
|
|
3861
|
+
const shipped = [...bundle.matchAll(/serverUrl:\s*("(?:[^"\\]|\\.)*")/g)].map((m) => m[1]);
|
|
3862
|
+
console.error(
|
|
3863
|
+
`\n✖ The .ipa does NOT carry the serverUrl that was just stamped.\n` +
|
|
3864
|
+
` stamped: ${cfg.serverUrl}\n` +
|
|
3865
|
+
(shipped.length ? ` in .ipa: ${shipped.join(', ')}\n` : '') +
|
|
3866
|
+
` → a stale artifact was about to be installed. Re-run with --force to rebuild.\n`
|
|
3867
|
+
);
|
|
3868
|
+
process.exit(1);
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3756
3871
|
/** Install an .ipa over usbmux via ideviceinstaller — a separate stack from devicectl/CoreDevice, so it
|
|
3757
3872
|
* works when the CoreDevice tunnel is stuck. Returns false if ideviceinstaller is absent or the install
|
|
3758
3873
|
* fails (the caller then prints the re-plug / brew-install remedy). */
|
|
@@ -4110,7 +4225,7 @@ async function createModule(cwd: string, flags: Record<string, string>, position
|
|
|
4110
4225
|
/**
|
|
4111
4226
|
* The CLI entry point — the bare `appwrap` bin calls `runCli()` with no options (identical to the
|
|
4112
4227
|
* historical `main`). A host calls `runCli({ commands, platforms, modulePacks,
|
|
4113
|
-
* templateRoots, desktopTemplateDir })` to compose extra capability on top
|
|
4228
|
+
* templateRoots, desktopTemplateDir })` to compose extra capability on top without forking it.
|
|
4114
4229
|
*/
|
|
4115
4230
|
export async function runCli(options: CliOptions = {}): Promise<void> {
|
|
4116
4231
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
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
|
|
@@ -316,11 +245,17 @@ export interface AppwrapConfig {
|
|
|
316
245
|
* testing — products resolve without App Store Connect. Only applies when launched from
|
|
317
246
|
* Xcode (simulator or device-from-Xcode), not a standalone devicectl sideload. */
|
|
318
247
|
storekitConfig?: string;
|
|
319
|
-
/** Permission usage strings, keyed by domain.
|
|
320
|
-
*
|
|
321
|
-
*
|
|
248
|
+
/** Permission usage strings, keyed by domain. A listed domain is ALWAYS stamped (iOS: Info.plist
|
|
249
|
+
* usage string; Android: <uses-permission>) — with or without `modules`; when a module already owns
|
|
250
|
+
* the same key, the string here overrides its default copy. 'contacts' has no iOS key
|
|
251
|
+
* (CNContactPicker needs none) — it only stamps Android READ_CONTACTS.
|
|
252
|
+
*
|
|
253
|
+
* `camera` is stamped by DEFAULT in every iOS build: WKWebView's `<input type="file">` picker offers
|
|
254
|
+
* "Take Photo", and iOS hard-kills the app for a camera access with no usage string. Pass
|
|
255
|
+
* `camera: false` to opt out (only for an app with no file inputs at all), or your own string to
|
|
256
|
+
* replace the default copy. */
|
|
322
257
|
permissions?: Partial<
|
|
323
|
-
Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion' | 'tracking', string>
|
|
258
|
+
Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion' | 'tracking', string | false>
|
|
324
259
|
>;
|
|
325
260
|
/** App Tracking Transparency tracking domains (iOS, `tracking` module). When the module is active
|
|
326
261
|
* the CLI sets the privacy manifest's `NSPrivacyTracking` → true and fills `NSPrivacyTrackingDomains`
|
|
@@ -357,8 +292,8 @@ export interface AppwrapConfig {
|
|
|
357
292
|
* build finds MULTIPLE candidate profiles for an id and you pick one interactively — so you're not
|
|
358
293
|
* re-prompted. Usually unset: a single matching profile is selected automatically. */
|
|
359
294
|
signingProfiles?: Record<string, string>;
|
|
360
|
-
/** Store-lane block (listing metadata, screenshot dirs, submission answers) consumed by
|
|
361
|
-
*
|
|
295
|
+
/** Store-lane block (listing metadata, screenshot dirs, submission answers) consumed by external
|
|
296
|
+
* store tooling. Carried OPAQUE — nothing here reads it; the
|
|
362
297
|
* key is registered only so `unknownConfigKeys` doesn't warn on configs that declare it. */
|
|
363
298
|
store?: Record<string, unknown>;
|
|
364
299
|
/** Pure-native escape hatch: a directory (relative to the PWA project) whose contents are copied
|
|
@@ -367,10 +302,9 @@ export interface AppwrapConfig {
|
|
|
367
302
|
overrides?: string;
|
|
368
303
|
/** appwrap TS plugins (`@livx.cc/appwrap/plugin`). Each entry is an npm package name or a path to a
|
|
369
304
|
* plugin entrypoint (that `export default definePlugin(...)`); the object form adds a config-level
|
|
370
|
-
* `attachTo` override (which windows it attaches to).
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
* stubbed (full module-manifest reuse lands later). */
|
|
305
|
+
* `attachTo` override (which windows it attaches to). On mobile the runtime registers a plugin's
|
|
306
|
+
* `handlers` in-process; a host-provided desktop lane bundles them for its own shell. Manifest/perms
|
|
307
|
+
* merge is stubbed (full module-manifest reuse lands later). */
|
|
374
308
|
plugins?: (string | { name: string; attachTo?: 'main' | 'all' | string; options?: unknown })[];
|
|
375
309
|
/** Opt-in capability allow-list (built-in modules — see capabilities.manifest.ts). When PRESENT,
|
|
376
310
|
* 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:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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.
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
|
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
|
*/
|
package/src/plugin/types.ts
CHANGED
|
@@ -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
|
|
5
|
-
* ({@link WindowCtx}) — it never touches
|
|
6
|
-
* multiplexed Bun host
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.`;
|