@livx.cc/appwrap 0.58.3 → 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 +1 -1
- package/runtime/app/main-page.ts +54 -1
- package/runtime/app/main-page.xml +8 -0
- package/runtime/app/shell/capabilities.manifest.ts +1 -1
- package/runtime/app/shell/custom-webview.android.ts +12 -0
- 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 +81 -24
- package/src/config.ts +22 -81
- 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/templates/ci/github/workflows/appwrap-pr.yml +25 -5
- package/templates/ci/github/workflows/appwrap-release-android.yml +31 -10
- package/templates/ci/github/workflows/appwrap-release-ios.yml +9 -4
package/package.json
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Application, AndroidApplication, EventData, Page, isAndroid, isIOS, knownFolders, path } from '@nativescript/core';
|
|
1
|
+
import { Application, AndroidApplication, Button, EventData, LoadEventData, Page, StackLayout, WebView, isAndroid, isIOS, knownFolders, path } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './shell/bridge';
|
|
3
3
|
import { effectiveServerUrl } from './shell/server-url';
|
|
4
4
|
import { registerHandlers } from './shell/handlers';
|
|
@@ -126,6 +126,7 @@ export function onPageLoaded(args: EventData): void {
|
|
|
126
126
|
// if a future surface orphans a window with no dismiss callback — gate it on a "surface presented" flag.
|
|
127
127
|
void armNativeSurfaceRecovery; // referenced to keep the helper (still callable if a scoped need arises)
|
|
128
128
|
if (isAndroid) wireAndroidSafeArea(webView); // experimental edge-to-edge (no-op unless config on)
|
|
129
|
+
wireLoadFallback(page, webView); // loader:'server' failure → branded retry view (no white screen)
|
|
129
130
|
startEventForwarding();
|
|
130
131
|
loadBundle(webView);
|
|
131
132
|
// Env indicator banner: shown in the bottom safe area on relaunch when a non-default env override is
|
|
@@ -167,6 +168,58 @@ export function onPageLoaded(args: EventData): void {
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* loader:'server' load-failure fallback (App Review 2.1a). A server-loader shell has NO local UI —
|
|
173
|
+
* if the serverUrl is unreachable (offline, server down, DNS) the reviewer sees a bare white screen.
|
|
174
|
+
* NS core forwards BOTH webView:didFailNavigation: and didFailProvisionalNavigation: (iOS) and the
|
|
175
|
+
* main-frame onReceivedError (Android) into `loadFinishedEvent` with `args.error` — so one listener
|
|
176
|
+
* covers initial-load and in-app navigation failures on both platforms. On failure: show a simple
|
|
177
|
+
* branded "can't connect — retry" view + auto-retry with capped backoff; any successful load hides
|
|
178
|
+
* it and resets. iOS NSURLErrorCancelled ("cancelled", a superseded navigation) is NOT a failure.
|
|
179
|
+
* NOTE: web-process termination has no NS seam in a prod build (the didTerminate forwarder lives on
|
|
180
|
+
* the debug-only DevCertNavDelegate) — out of scope here.
|
|
181
|
+
*/
|
|
182
|
+
function wireLoadFallback(page: Page, webView: CustomWebView): void {
|
|
183
|
+
if (SHELL_CONFIG.loader !== 'server') return; // bundled loaders can't fail on network
|
|
184
|
+
const fallback = page.getViewById<StackLayout>('loadFallback');
|
|
185
|
+
const retryBtn = page.getViewById<Button>('loadRetryBtn');
|
|
186
|
+
if (!fallback || !retryBtn) return;
|
|
187
|
+
|
|
188
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
189
|
+
let backoffMs = 3000;
|
|
190
|
+
const clearTimer = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; } };
|
|
191
|
+
const retry = () => {
|
|
192
|
+
clearTimer();
|
|
193
|
+
appwrapNativeLog('[native:fallback] retrying server load');
|
|
194
|
+
// Android: loadBundle sets `src`, but re-setting the SAME value is a NS property no-op — drive
|
|
195
|
+
// the native reload directly. iOS's loadBundle path issues a fresh loadRequest, so reuse it.
|
|
196
|
+
const droid = isAndroid ? (webView.android as android.webkit.WebView | null) : null;
|
|
197
|
+
if (droid) droid.loadUrl(effectiveServerUrl());
|
|
198
|
+
else loadBundle(webView);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
webView.on(WebView.loadFinishedEvent, (args: LoadEventData) => {
|
|
202
|
+
const err = String(args.error ?? '');
|
|
203
|
+
if (!err) {
|
|
204
|
+
// successful load → hide + reset (idempotent; covers the auto-retry that finally lands)
|
|
205
|
+
clearTimer();
|
|
206
|
+
backoffMs = 3000;
|
|
207
|
+
if (fallback.visibility !== 'collapse') {
|
|
208
|
+
appwrapNativeLog('[native:fallback] load recovered — hiding fallback');
|
|
209
|
+
fallback.visibility = 'collapse';
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (/cancel/i.test(err)) return; // NSURLErrorCancelled: navigation superseded, not a failure
|
|
214
|
+
appwrapNativeLog(`[native:fallback] load failed: ${err}`);
|
|
215
|
+
fallback.visibility = 'visible';
|
|
216
|
+
if (!retryTimer) {
|
|
217
|
+
retryTimer = setTimeout(() => { backoffMs = Math.min(backoffMs * 2, 30_000); retry(); }, backoffMs);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
retryBtn.on(Button.tapEvent, retry);
|
|
221
|
+
}
|
|
222
|
+
|
|
170
223
|
function loadBundle(webView: CustomWebView): void {
|
|
171
224
|
const wwwPath = path.join(knownFolders.currentApp().path, 'www');
|
|
172
225
|
const entryPath = path.join(wwwPath, SHELL_CONFIG.entry);
|
|
@@ -6,5 +6,13 @@
|
|
|
6
6
|
backgroundColor="{{ backgroundColor }}">
|
|
7
7
|
<GridLayout rows="*">
|
|
8
8
|
<shell:CustomWebView id="webview" row="0" />
|
|
9
|
+
<!-- loader:'server' load-failure fallback (App Review 2.1a): shown by main-page.ts when the
|
|
10
|
+
server navigation fails (offline / server down) so the reviewer never sees a white screen. -->
|
|
11
|
+
<StackLayout id="loadFallback" row="0" visibility="collapse" backgroundColor="#ffffff" verticalAlignment="center" padding="32">
|
|
12
|
+
<Label text="Blank" fontSize="34" fontWeight="700" color="#000000" textAlignment="center" />
|
|
13
|
+
<Label text="Can't connect" fontSize="17" fontWeight="600" color="#333333" textAlignment="center" marginTop="18" />
|
|
14
|
+
<Label text="Check your internet connection — we'll keep trying." fontSize="14" color="#777777" textAlignment="center" textWrap="true" marginTop="6" />
|
|
15
|
+
<Button id="loadRetryBtn" text="Retry" fontSize="16" fontWeight="600" color="#ffffff" backgroundColor="#000000" borderRadius="22" height="44" width="160" marginTop="24" />
|
|
16
|
+
</StackLayout>
|
|
9
17
|
</GridLayout>
|
|
10
18
|
</Page>
|
|
@@ -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
|
|
|
@@ -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
|
|
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
|
@@ -13,7 +13,7 @@ import { createHash } from 'crypto';
|
|
|
13
13
|
import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
14
14
|
import { builtinModules } from 'module';
|
|
15
15
|
import { networkInterfaces, tmpdir } from 'os';
|
|
16
|
-
import { basename, dirname, extname, join, resolve } from 'path';
|
|
16
|
+
import { basename, dirname, extname, join, relative, resolve } from 'path';
|
|
17
17
|
import { pathToFileURL } from 'url';
|
|
18
18
|
// PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
|
|
19
19
|
// the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
|
|
@@ -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,
|
|
@@ -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
|
|
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
|
|
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. */
|
|
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
|
|
126
|
-
* lane (
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
@@ -979,7 +976,9 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
979
976
|
// and notifications fall back to a plain banner with the host app icon. native-kit's notification
|
|
980
977
|
// identity path (INSendMessageIntent → communication notification) needs this to render the sender.
|
|
981
978
|
if (cfg.iosEntitlements?.['com.apple.developer.usernotifications.communication']) {
|
|
982
|
-
|
|
979
|
+
// ONLY the intents the shell actually donates: INSendMessageIntent (communication-notification
|
|
980
|
+
// styling). Declaring INStartCallIntent in an app with no calling feature is an App Review flag.
|
|
981
|
+
extras.push(` <key>NSUserActivityTypes</key>\n <array>\n <string>INSendMessageIntent</string>\n </array>`);
|
|
983
982
|
}
|
|
984
983
|
if (extras.length) {
|
|
985
984
|
src = src.replace(
|
|
@@ -2009,6 +2008,55 @@ export function isFrameworkRepo(root: string): boolean {
|
|
|
2009
2008
|
return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
|
|
2010
2009
|
}
|
|
2011
2010
|
|
|
2011
|
+
/** Context the CI workflow templates are rendered against — derived from the repo shape + config.
|
|
2012
|
+
* - `subdir`: app dir relative to the git root ('' = app at repo root). Auto-detected from where the
|
|
2013
|
+
* appwrap config lives, so a monorepo gets path-scoped jobs + a PR paths filter for free.
|
|
2014
|
+
* - `tagPrefix`: release-trigger tag prefix. Monorepo default `<subdir>-v` (NOT `v`) so an unrelated
|
|
2015
|
+
* repo `v*` tag can never cut a store release; overridable via config `ci.tagPrefix`.
|
|
2016
|
+
* - `webBuild`: false for loader:'server' wraps (no local PWA bundle) — web build steps drop out. */
|
|
2017
|
+
export interface CiRenderContext { subdir: string; tagPrefix: string; webBuild: boolean; }
|
|
2018
|
+
|
|
2019
|
+
/** Derive the render context for `cwd` (the app dir holding the appwrap config) inside `repoRoot`. */
|
|
2020
|
+
export function ciRenderContext(cwd: string, repoRoot: string, cfg: AppwrapConfig): CiRenderContext {
|
|
2021
|
+
const rel = relative(repoRoot, cwd);
|
|
2022
|
+
const subdir = rel === '' || rel === '.' ? '' : rel;
|
|
2023
|
+
// Default tag prefix uses the LAST path segment (apps/foo → foo-v): tags are flat names, not paths.
|
|
2024
|
+
const tagPrefix = cfg.ci?.tagPrefix ?? (subdir ? `${basename(subdir)}-v` : 'v');
|
|
2025
|
+
return { subdir, tagPrefix, webBuild: cfg.loader !== 'server' };
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/** Render a CI workflow template: substitute tokens and resolve `#@if:` directives.
|
|
2029
|
+
* Directives (never survive into output): a trailing ` #@if:<cond>` keeps that line only when the
|
|
2030
|
+
* condition holds; standalone `#@if:<cond>` … `#@end` lines gate a block (non-nesting). Conds:
|
|
2031
|
+
* `monorepo` (subdir set) / `root` / `web` (local PWA build) / `server`. Tokens: `__DIR__` →
|
|
2032
|
+
* `<subdir>/` or '' (path prefix), `__APP_DIR__` → subdir (working-directory), `__TAG_PREFIX__`. */
|
|
2033
|
+
export function renderCiWorkflow(src: string, ctx: CiRenderContext): string {
|
|
2034
|
+
const holds = (c: string): boolean =>
|
|
2035
|
+
c === 'monorepo' ? !!ctx.subdir : c === 'root' ? !ctx.subdir : c === 'web' ? ctx.webBuild : c === 'server' ? !ctx.webBuild
|
|
2036
|
+
: c === 'doc' ? false // template-authoring notes — never emitted
|
|
2037
|
+
: true;
|
|
2038
|
+
const out: string[] = [];
|
|
2039
|
+
let dropBlock = false;
|
|
2040
|
+
let inBlock = false; // block directives DON'T nest — fail loud rather than silently mis-render
|
|
2041
|
+
for (const line of src.split('\n')) {
|
|
2042
|
+
const t = line.trim();
|
|
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; }
|
|
2048
|
+
if (dropBlock) continue;
|
|
2049
|
+
const m = line.match(/^(.*?)\s*#@if:([a-z]+)$/);
|
|
2050
|
+
if (m) { if (holds(m[2])) out.push(m[1]); }
|
|
2051
|
+
else out.push(line);
|
|
2052
|
+
}
|
|
2053
|
+
if (inBlock) throw new Error('renderCiWorkflow: unterminated #@if: block (missing #@end)');
|
|
2054
|
+
return out.join('\n')
|
|
2055
|
+
.replaceAll('__DIR__', ctx.subdir ? `${ctx.subdir}/` : '')
|
|
2056
|
+
.replaceAll('__APP_DIR__', ctx.subdir)
|
|
2057
|
+
.replaceAll('__TAG_PREFIX__', ctx.tagPrefix);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2012
2060
|
/** Emit CI scaffolding (GH Actions → git repo root ONLY with --ci, fastlane → native/).
|
|
2013
2061
|
* Writing `.github/workflows` into a consumer repo is a repo-level side effect (it can shadow or
|
|
2014
2062
|
* clash with the project's own CI), so it is OPT-IN via `appwrap init --ci` — never implicit.
|
|
@@ -2027,19 +2075,28 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
|
|
|
2027
2075
|
const scaffoldWorkflows = withWorkflows && !isFrameworkRepo(repoRoot);
|
|
2028
2076
|
if (withWorkflows && !scaffoldWorkflows) {
|
|
2029
2077
|
console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
|
|
2030
|
-
} else if (scaffoldWorkflows) {
|
|
2031
|
-
targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows'), false]);
|
|
2032
2078
|
}
|
|
2033
2079
|
for (const [from, to, overwrite] of targets) {
|
|
2034
2080
|
mkdirSync(to, { recursive: true });
|
|
2035
2081
|
cpSync(from, to, { recursive: true, force: overwrite, errorOnExist: false });
|
|
2036
2082
|
}
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2039
|
-
//
|
|
2083
|
+
// Workflows are RENDERED per-repo-shape (not raw-copied): monorepo path scoping + tag prefix +
|
|
2084
|
+
// loader-aware web steps (see renderCiWorkflow), and the `bunx @livx.cc/appwrap@^x.y.z` pin is
|
|
2085
|
+
// stamped to THIS CLI's version floor so a CI run can't silently resolve an older published build
|
|
2086
|
+
// that lacks the `init`/`release` commands (it fails loudly instead). Never overwritten (users may
|
|
2087
|
+
// customize); re-init on an existing file is a no-op.
|
|
2040
2088
|
if (scaffoldWorkflows) {
|
|
2041
|
-
const
|
|
2042
|
-
|
|
2089
|
+
const ctx = ciRenderContext(cwd, repoRoot, cfg);
|
|
2090
|
+
const wfSrcDir = join(CI_TEMPLATE_DIR, 'github/workflows');
|
|
2091
|
+
const wfDir = join(repoRoot, '.github/workflows');
|
|
2092
|
+
mkdirSync(wfDir, { recursive: true });
|
|
2093
|
+
for (const file of readdirSync(wfSrcDir)) {
|
|
2094
|
+
const dest = join(wfDir, file);
|
|
2095
|
+
if (existsSync(dest)) continue;
|
|
2096
|
+
const rendered = renderCiWorkflow(readFileSync(join(wfSrcDir, file), 'utf8'), ctx)
|
|
2097
|
+
.replaceAll('__APPWRAP_VERSION__', CLI_VERSION);
|
|
2098
|
+
writeFileSync(dest, rendered);
|
|
2099
|
+
}
|
|
2043
2100
|
}
|
|
2044
2101
|
// Stamp the app id + team into the emitted fastlane (signing needs them; the templates ship
|
|
2045
2102
|
// `__APP_ID__`/`__TEAM_ID__` placeholders). Idempotent: re-init finds no placeholders → no-op.
|
|
@@ -2140,7 +2197,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
2140
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.');
|
|
2141
2198
|
}
|
|
2142
2199
|
// Copy each template root in order — later roots OVERLAY earlier (file-level last-wins), so a
|
|
2143
|
-
// consumer template can add or replace shell files without forking
|
|
2200
|
+
// consumer template can add or replace shell files without forking the template. Single root (default) is
|
|
2144
2201
|
// exactly the pre-overlay copy.
|
|
2145
2202
|
for (const root of TEMPLATE_ROOTS) {
|
|
2146
2203
|
if (!existsSync(root)) { console.warn(`⚠ template root not found: ${root}`); continue; }
|
|
@@ -4055,7 +4112,7 @@ async function createModule(cwd: string, flags: Record<string, string>, position
|
|
|
4055
4112
|
/**
|
|
4056
4113
|
* The CLI entry point — the bare `appwrap` bin calls `runCli()` with no options (identical to the
|
|
4057
4114
|
* historical `main`). A host calls `runCli({ commands, platforms, modulePacks,
|
|
4058
|
-
* templateRoots, desktopTemplateDir })` to compose extra capability on top
|
|
4115
|
+
* templateRoots, desktopTemplateDir })` to compose extra capability on top without forking it.
|
|
4059
4116
|
*/
|
|
4060
4117
|
export async function runCli(options: CliOptions = {}): Promise<void> {
|
|
4061
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
|
|
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
|
|
@@ -249,6 +178,15 @@ export interface AppwrapConfig {
|
|
|
249
178
|
loader?: 'app' | 'file' | 'server';
|
|
250
179
|
/** Live URL loaded when loader === 'server'. Set via config or `appwrap dev --url <url>`. */
|
|
251
180
|
serverUrl?: string;
|
|
181
|
+
/** CI scaffolding options (`appwrap init --ci`). The emitted GH Actions workflows auto-adapt to the
|
|
182
|
+
* repo shape (job paths + PR path filter scoped to the app subdir when the config sits below the git
|
|
183
|
+
* root; web-build steps dropped for loader:'server'); this block only overrides the derived defaults. */
|
|
184
|
+
ci?: {
|
|
185
|
+
/** Release tag prefix the release workflows trigger on (`tags: ['<prefix>*']`). Default: `v` at
|
|
186
|
+
* the repo root, `<subdir>-v` in a monorepo — so a monorepo's generic `v*` tags can never cut a
|
|
187
|
+
* store release by accident. */
|
|
188
|
+
tagPrefix?: string;
|
|
189
|
+
};
|
|
252
190
|
/** Runtime env-switcher (loader:'server' apps). Lets a build re-point the WebView between declared
|
|
253
191
|
* environments (prod / lab / a preview URL) at runtime — via the native dev-menu "Switch Environment"
|
|
254
192
|
* action + a bottom env-indicator banner — surviving a cold start, with NO separate native build.
|
|
@@ -348,16 +286,19 @@ export interface AppwrapConfig {
|
|
|
348
286
|
* build finds MULTIPLE candidate profiles for an id and you pick one interactively — so you're not
|
|
349
287
|
* re-prompted. Usually unset: a single matching profile is selected automatically. */
|
|
350
288
|
signingProfiles?: Record<string, string>;
|
|
289
|
+
/** Store-lane block (listing metadata, screenshot dirs, submission answers) consumed by external
|
|
290
|
+
* store tooling. Carried OPAQUE — nothing here reads it; the
|
|
291
|
+
* key is registered only so `unknownConfigKeys` doesn't warn on configs that declare it. */
|
|
292
|
+
store?: Record<string, unknown>;
|
|
351
293
|
/** Pure-native escape hatch: a directory (relative to the PWA project) whose contents are copied
|
|
352
294
|
* OVER the generated wrapper after stamping — for legacy/custom native code the declarative config
|
|
353
295
|
* can't express. Default `'appwrap.overrides'`; applied only if it exists. */
|
|
354
296
|
overrides?: string;
|
|
355
297
|
/** appwrap TS plugins (`@livx.cc/appwrap/plugin`). Each entry is an npm package name or a path to a
|
|
356
298
|
* plugin entrypoint (that `export default definePlugin(...)`); the object form adds a config-level
|
|
357
|
-
* `attachTo` override (which windows it attaches to).
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
* 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). */
|
|
361
302
|
plugins?: (string | { name: string; attachTo?: 'main' | 'all' | string; options?: unknown })[];
|
|
362
303
|
/** Opt-in capability allow-list (built-in modules — see capabilities.manifest.ts). When PRESENT,
|
|
363
304
|
* only the listed capabilities (plus always-on core) are advertised, permissioned, and — for
|
|
@@ -434,10 +375,10 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
|
|
|
434
375
|
* with no error, then an App Store rejection). A loud warning turns that silent no-op into a signal.
|
|
435
376
|
*/
|
|
436
377
|
export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
|
437
|
-
'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
|
|
378
|
+
'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'ci', 'debug',
|
|
438
379
|
'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'modulePacks', 'name',
|
|
439
380
|
'envSwitcher', 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
|
|
440
|
-
'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle',
|
|
381
|
+
'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle', 'store',
|
|
441
382
|
'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
|
|
442
383
|
'usesNonExemptEncryption', 'vendorPaths', 'version',
|
|
443
384
|
]);
|
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.`;
|
|
@@ -1,29 +1,49 @@
|
|
|
1
|
-
# appwrap PR checks — emitted by `appwrap init`. Adjust build commands to your project.
|
|
1
|
+
# appwrap PR checks — emitted by `appwrap init --ci`. Adjust build commands to your project.
|
|
2
|
+
#@if:doc
|
|
3
|
+
# Template directives (processed at emit by renderCiWorkflow; none survive into the scaffolded file):
|
|
4
|
+
# `#@if:<cond>` trailing a line keeps it only when the condition holds; a standalone `#@if:<cond>`
|
|
5
|
+
# … `#@end` pair gates a block (non-nesting). Conds: monorepo/root (app dir vs repo root),
|
|
6
|
+
# web/server (loader), doc (never emitted — this note).
|
|
7
|
+
# Tokens: __DIR__ → `<subdir>/` or ``; __APP_DIR__ → subdir; __TAG_PREFIX__ → release tag prefix.
|
|
8
|
+
#@end
|
|
2
9
|
name: PR
|
|
3
10
|
on:
|
|
4
11
|
pull_request:
|
|
12
|
+
paths: #@if:monorepo
|
|
13
|
+
- '__DIR__**' #@if:monorepo
|
|
14
|
+
- '.github/workflows/appwrap-pr.yml' #@if:monorepo
|
|
15
|
+
workflow_dispatch: #@if:monorepo
|
|
5
16
|
|
|
6
17
|
jobs:
|
|
18
|
+
#@if:web
|
|
7
19
|
web:
|
|
8
20
|
runs-on: ubuntu-latest
|
|
9
21
|
steps:
|
|
10
22
|
- uses: actions/checkout@v5
|
|
11
23
|
- uses: oven-sh/setup-bun@v2
|
|
12
24
|
- run: bun install
|
|
25
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
13
26
|
- run: bun test || echo "no tests"
|
|
27
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
14
28
|
- run: bun run build # → dist/ (the PWA bundle the wrapper ships)
|
|
29
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
30
|
+
#@end
|
|
15
31
|
|
|
16
32
|
ios-build:
|
|
17
33
|
runs-on: macos-15
|
|
18
|
-
needs: web
|
|
34
|
+
needs: web #@if:web
|
|
19
35
|
steps:
|
|
20
36
|
- uses: actions/checkout@v5
|
|
21
37
|
- uses: oven-sh/setup-bun@v2
|
|
22
38
|
- uses: actions/setup-node@v5
|
|
23
39
|
with: { node-version: 22 }
|
|
24
|
-
- run: bun install && bun run build
|
|
40
|
+
- run: bun install && bun run build #@if:web
|
|
41
|
+
- run: bun install # server-loader wrap — no local web build (the app loads the live URL) #@if:server
|
|
42
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
25
43
|
# Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
|
|
26
44
|
- run: npm i -g nativescript@9.0.6
|
|
27
|
-
|
|
28
|
-
-
|
|
45
|
+
# Pinned to the appwrap version that emitted this workflow (see release workflow header for why).
|
|
46
|
+
- run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ init # native/ is generated (gitignored) — regenerate it fresh in CI
|
|
47
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
48
|
+
- working-directory: __DIR__native
|
|
29
49
|
run: npm install && ns build ios --emulator
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
# appwrap Android release — tag push (
|
|
1
|
+
# appwrap Android release — tag push (__TAG_PREFIX__*) → Play internal track.
|
|
2
|
+
# Template directives (`#@if:` / `#@end` / `__TOKENS__`) are processed at emit — see appwrap-pr.yml header. #@if:doc
|
|
2
3
|
# Build is the appwrap CLI (`appwrap build android --release --aab` → signed AAB);
|
|
3
4
|
# upload is fastlane supply. Secrets contract (repo settings → Actions secrets):
|
|
4
5
|
# ANDROID_KEYSTORE_BASE64 upload keystore, base64-encoded
|
|
@@ -6,12 +7,14 @@
|
|
|
6
7
|
# ANDROID_KEY_ALIAS key alias
|
|
7
8
|
# ANDROID_KEY_ALIAS_PASSWORD key (alias) password
|
|
8
9
|
# PLAY_SERVICE_ACCOUNT_JSON Play Developer API service-account JSON (the app must already
|
|
9
|
-
# exist on Play with one prior manual upload before supply works)
|
|
10
|
+
# exist on Play with one prior manual upload before supply works).
|
|
11
|
+
# OPTIONAL — when unset the job stays green and ships the AAB
|
|
12
|
+
# artifact only (graceful degradation until the SA exists).
|
|
10
13
|
name: Release Android
|
|
11
14
|
on:
|
|
12
15
|
workflow_dispatch:
|
|
13
16
|
push:
|
|
14
|
-
tags: ['
|
|
17
|
+
tags: ['__TAG_PREFIX__*']
|
|
15
18
|
|
|
16
19
|
# Monotonic store build number (Android versionCode): computed in the shell below — GitHub Actions
|
|
17
20
|
# expressions have NO arithmetic operators, so `${{ github.run_number + 100 }}` is an INVALID workflow
|
|
@@ -37,35 +40,53 @@ jobs:
|
|
|
37
40
|
path: |
|
|
38
41
|
~/.gradle/caches
|
|
39
42
|
~/.gradle/wrapper
|
|
40
|
-
key: gradle-${{ runner.os }}-${{ hashFiles('
|
|
43
|
+
key: gradle-${{ runner.os }}-${{ hashFiles('__DIR__bun.lock', '__DIR__package.json') }}
|
|
41
44
|
restore-keys: gradle-${{ runner.os }}-
|
|
42
45
|
- uses: android-actions/setup-android@v3
|
|
43
46
|
- run: sudo apt-get update -qq && sudo apt-get install -y -qq imagemagick # appwrap icon-gen needs an image tool (no sips on Linux) — else the default NS icon ships
|
|
44
|
-
- run: bun install && bun run build
|
|
47
|
+
- run: bun install && bun run build # → dist/ (the PWA bundle the wrapper ships) #@if:web
|
|
48
|
+
- run: bun install # server-loader wrap — no local web build (the app loads the live URL) #@if:server
|
|
49
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
45
50
|
# Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
|
|
46
51
|
- run: bun add -g nativescript@9.0.6
|
|
47
|
-
|
|
48
|
-
-
|
|
52
|
+
# Pinned to the appwrap version that emitted this workflow (see the iOS release workflow for why).
|
|
53
|
+
- run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ init # native/ is generated (gitignored) — regenerate fresh in CI
|
|
54
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
55
|
+
- working-directory: __DIR__native
|
|
49
56
|
run: bun install
|
|
50
57
|
- name: Decode upload keystore
|
|
51
58
|
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/upload.keystore"
|
|
52
59
|
- name: Build signed AAB
|
|
60
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
53
61
|
env:
|
|
54
62
|
GRADLE_OPTS: "-Dorg.gradle.caching=true -Dorg.gradle.parallel=true -Dorg.gradle.daemon=false"
|
|
55
63
|
APPWRAP_ANDROID_KEYSTORE: ${{ runner.temp }}/upload.keystore
|
|
56
64
|
APPWRAP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
57
65
|
APPWRAP_ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
58
66
|
APPWRAP_ANDROID_KEYSTORE_ALIAS_PASSWORD: ${{ secrets.ANDROID_KEY_ALIAS_PASSWORD }}
|
|
59
|
-
run: bunx @livx.cc/appwrap build android --release --aab
|
|
67
|
+
run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ build android --release --aab
|
|
60
68
|
- uses: actions/upload-artifact@v4 # keep the AAB even if Play upload is skipped
|
|
61
69
|
with:
|
|
62
70
|
name: app-release-aab
|
|
63
|
-
path:
|
|
71
|
+
path: __DIR__native/platforms/android/app/build/outputs/bundle/release/app-release.aab
|
|
72
|
+
# ---- Play upload, gracefully gated on the service account ----
|
|
73
|
+
# Without PLAY_SERVICE_ACCOUNT_JSON (e.g. the SA isn't provisioned yet) the job stays GREEN and
|
|
74
|
+
# ships the AAB artifact only. secrets.* can't appear in a step `if:` — indirect through env.
|
|
75
|
+
- name: Check Play service account
|
|
76
|
+
id: play-sa
|
|
77
|
+
env:
|
|
78
|
+
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
|
79
|
+
run: |
|
|
80
|
+
if [ -n "$PLAY_SERVICE_ACCOUNT_JSON" ]; then echo "present=true" >> "$GITHUB_OUTPUT";
|
|
81
|
+
else echo "present=false" >> "$GITHUB_OUTPUT"; echo "::notice::PLAY_SERVICE_ACCOUNT_JSON not set — skipping Play upload, AAB artifact only"; fi
|
|
64
82
|
- uses: ruby/setup-ruby@v1
|
|
83
|
+
if: steps.play-sa.outputs.present == 'true'
|
|
65
84
|
with: { ruby-version: '3.3', bundler-cache: false }
|
|
66
85
|
- run: gem install fastlane -v 2.236.1
|
|
86
|
+
if: steps.play-sa.outputs.present == 'true'
|
|
67
87
|
- name: Upload to Play (internal)
|
|
68
|
-
|
|
88
|
+
if: steps.play-sa.outputs.present == 'true'
|
|
89
|
+
working-directory: __DIR__native
|
|
69
90
|
env:
|
|
70
91
|
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
|
71
92
|
run: fastlane android beta
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
# appwrap iOS → TestFlight via fastlane. On-demand (workflow_dispatch) or tag push (
|
|
1
|
+
# appwrap iOS → TestFlight via fastlane. On-demand (workflow_dispatch) or tag push (__TAG_PREFIX__*).
|
|
2
|
+
# Template directives (`#@if:` / `#@end` / `__TOKENS__`) are processed at emit — see appwrap-pr.yml header. #@if:doc
|
|
2
3
|
# Secrets contract (repo settings → Actions secrets):
|
|
3
4
|
# MATCH_GIT_URL private repo holding signing certs (fastlane match) — SSH form for the deploy key
|
|
4
5
|
# MATCH_DEPLOY_KEY read-only SSH deploy key on that certs repo (so CI can clone it)
|
|
@@ -12,7 +13,7 @@ name: Release iOS
|
|
|
12
13
|
on:
|
|
13
14
|
workflow_dispatch:
|
|
14
15
|
push:
|
|
15
|
-
tags: ['
|
|
16
|
+
tags: ['__TAG_PREFIX__*']
|
|
16
17
|
|
|
17
18
|
# Monotonic store build number (CFBundleVersion): computed in the shell below — GitHub Actions
|
|
18
19
|
# expressions have NO arithmetic operators, so `${{ github.run_number + 100 }}` is an INVALID workflow
|
|
@@ -33,7 +34,9 @@ jobs:
|
|
|
33
34
|
- uses: oven-sh/setup-bun@v2
|
|
34
35
|
- uses: actions/setup-node@v5
|
|
35
36
|
with: { node-version: 22 }
|
|
36
|
-
- run: bun install && bun run build
|
|
37
|
+
- run: bun install && bun run build #@if:web
|
|
38
|
+
- run: bun install # server-loader wrap — no local web build (the app loads the live URL) #@if:server
|
|
39
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
37
40
|
# Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
|
|
38
41
|
- run: npm i -g nativescript@9.0.6
|
|
39
42
|
# bunx is PINNED to the appwrap version that emitted this workflow (stamped at `init`). Pinning is
|
|
@@ -43,7 +46,8 @@ jobs:
|
|
|
43
46
|
# published to the @livx.cc registry. PUBLISH-ORDERING: publish the appwrap CLI BEFORE the first CI
|
|
44
47
|
# run that uses a freshly-emitted workflow.
|
|
45
48
|
- run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ init # native/ is generated (gitignored) — regenerate it fresh in CI
|
|
46
|
-
|
|
49
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
50
|
+
- working-directory: __DIR__native
|
|
47
51
|
run: npm install
|
|
48
52
|
- uses: ruby/setup-ruby@v1
|
|
49
53
|
with: { ruby-version: '3.3', bundler-cache: false }
|
|
@@ -58,6 +62,7 @@ jobs:
|
|
|
58
62
|
# Single source of truth: the SAME command a developer runs locally. It re-stamps native/ and
|
|
59
63
|
# delegates the full archive/sign/upload to the emitted fastlane lane — no fastlane logic here.
|
|
60
64
|
- name: Build & upload to TestFlight
|
|
65
|
+
working-directory: __APP_DIR__ #@if:monorepo
|
|
61
66
|
env:
|
|
62
67
|
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
|
|
63
68
|
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|