@livx.cc/appwrap 0.42.2 → 0.42.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -31,6 +31,58 @@ function buildBootstrapJs(): string {
|
|
|
31
31
|
return `${envGlobalsJs()}\n${APPWRAP_GLOBALS_JS}\n${TRANSPORT_SHIM}\n${serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker)}\n${externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser)}\n${NATIVE_FEEL_JS}`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The extended WebChromeClient MUST be generated ONCE. NativeScript caches the Java proxy
|
|
36
|
+
* class it synthesizes for `extend({...})` keyed by the methods-object shape, so calling
|
|
37
|
+
* extend() again per-webview REUSES the first invocation's class — including its captured
|
|
38
|
+
* closure. A per-instance `owner` WeakRef baked into the closure therefore cross-wires every
|
|
39
|
+
* later webview's prompt() transport to the FIRST instance (device-proven in the feedox
|
|
40
|
+
* mini-app spike: webview-2's bridge request was answered as webview-1). Route by the native
|
|
41
|
+
* view the callback is given instead, resolved against a per-view registry.
|
|
42
|
+
*/
|
|
43
|
+
let chromeClientClass: any;
|
|
44
|
+
function getChromeClientClass(): any {
|
|
45
|
+
if (chromeClientClass) return chromeClientClass;
|
|
46
|
+
// NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
|
|
47
|
+
chromeClientClass = (android.webkit.WebChromeClient as any).extend({
|
|
48
|
+
onJsPrompt(
|
|
49
|
+
view: android.webkit.WebView,
|
|
50
|
+
_url: string,
|
|
51
|
+
message: string,
|
|
52
|
+
_defaultValue: string,
|
|
53
|
+
result: android.webkit.JsPromptResult
|
|
54
|
+
): boolean {
|
|
55
|
+
if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
|
|
56
|
+
result.confirm('');
|
|
57
|
+
CustomWebView.forNative(view)?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false; // genuine page prompt — default handling
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// getUserMedia: grant the WebView's per-origin capture after ensuring the
|
|
64
|
+
// app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO).
|
|
65
|
+
// No instance state — safe on the shared class.
|
|
66
|
+
onPermissionRequest(request: android.webkit.PermissionRequest): void {
|
|
67
|
+
const PR = android.webkit.PermissionRequest;
|
|
68
|
+
const resources: string[] = Array.from(request.getResources());
|
|
69
|
+
const perms = new Set<string>();
|
|
70
|
+
for (const r of resources) {
|
|
71
|
+
if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
|
|
72
|
+
if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
|
|
73
|
+
}
|
|
74
|
+
if (!perms.size) {
|
|
75
|
+
Utils.dispatchToMainThread(() => request.grant(request.getResources()));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
requestPermissions(Array.from(perms)).then((ok) =>
|
|
79
|
+
Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
return chromeClientClass;
|
|
84
|
+
}
|
|
85
|
+
|
|
34
86
|
/**
|
|
35
87
|
* Android CustomWebView — replaces NS's default clients after creation:
|
|
36
88
|
* WebChromeClient.onJsPrompt is the appwrap transport, WebViewClient
|
|
@@ -41,6 +93,18 @@ export class CustomWebView extends WebView {
|
|
|
41
93
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
42
94
|
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
43
95
|
|
|
96
|
+
// Native-view → owning CustomWebView, keyed by the Java identity hash (stable per native
|
|
97
|
+
// object, robust to NS re-wrapping the arg passed into the ChromeClient callback). WeakRef
|
|
98
|
+
// so a destroyed view can be GC'd; entry pruned on dispose.
|
|
99
|
+
private static registry = new Map<number, WeakRef<CustomWebView>>();
|
|
100
|
+
static forNative(view: android.webkit.WebView): CustomWebView | undefined {
|
|
101
|
+
const owner = CustomWebView.registry.get(view.hashCode())?.deref();
|
|
102
|
+
// identityHashCode is not unique: on a (rare) collision the map holds the LAST writer —
|
|
103
|
+
// re-check native identity so a colliding view drops rather than cross-wires.
|
|
104
|
+
if (owner && owner.android !== view) return undefined;
|
|
105
|
+
return owner;
|
|
106
|
+
}
|
|
107
|
+
|
|
44
108
|
initNativeView(): void {
|
|
45
109
|
super.initNativeView();
|
|
46
110
|
// NS core types the `.android` getter as `any`; narrow to the real WebView for this scope.
|
|
@@ -68,7 +132,15 @@ export class CustomWebView extends WebView {
|
|
|
68
132
|
|
|
69
133
|
this.installDocumentStartShim(wv);
|
|
70
134
|
wv.setWebViewClient(createAssetServingClient());
|
|
71
|
-
|
|
135
|
+
// Bind THIS native view to THIS instance before wiring the (shared, extend-once) client.
|
|
136
|
+
CustomWebView.registry.set(wv.hashCode(), new WeakRef<CustomWebView>(this));
|
|
137
|
+
wv.setWebChromeClient(new (getChromeClientClass())());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
disposeNativeView(): void {
|
|
141
|
+
const wv = this.android as android.webkit.WebView | undefined;
|
|
142
|
+
if (wv) CustomWebView.registry.delete(wv.hashCode());
|
|
143
|
+
super.disposeNativeView();
|
|
72
144
|
}
|
|
73
145
|
|
|
74
146
|
/**
|
|
@@ -115,52 +187,15 @@ export class CustomWebView extends WebView {
|
|
|
115
187
|
console.warn('AppWrap: addDocumentStartJavaScript failed', e);
|
|
116
188
|
}
|
|
117
189
|
}
|
|
118
|
-
|
|
119
|
-
private createTransportChromeClient(): android.webkit.WebChromeClient {
|
|
120
|
-
const owner = new WeakRef<CustomWebView>(this);
|
|
121
|
-
// NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
|
|
122
|
-
const ChromeClient = (android.webkit.WebChromeClient as any).extend({
|
|
123
|
-
onJsPrompt(
|
|
124
|
-
_view: android.webkit.WebView,
|
|
125
|
-
_url: string,
|
|
126
|
-
message: string,
|
|
127
|
-
_defaultValue: string,
|
|
128
|
-
result: android.webkit.JsPromptResult
|
|
129
|
-
): boolean {
|
|
130
|
-
if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
|
|
131
|
-
result.confirm('');
|
|
132
|
-
const view = owner.get ? owner.get() : (owner as { deref(): CustomWebView }).deref();
|
|
133
|
-
view?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
return false; // genuine page prompt — default handling
|
|
137
|
-
},
|
|
138
|
-
|
|
139
|
-
// getUserMedia: grant the WebView's per-origin capture after ensuring the
|
|
140
|
-
// app holds the matching OS runtime permission (CAMERA / RECORD_AUDIO).
|
|
141
|
-
onPermissionRequest(request: android.webkit.PermissionRequest): void {
|
|
142
|
-
const PR = android.webkit.PermissionRequest;
|
|
143
|
-
const resources: string[] = Array.from(request.getResources());
|
|
144
|
-
const perms = new Set<string>();
|
|
145
|
-
for (const r of resources) {
|
|
146
|
-
if (r === PR.RESOURCE_VIDEO_CAPTURE) perms.add('android.permission.CAMERA');
|
|
147
|
-
if (r === PR.RESOURCE_AUDIO_CAPTURE) perms.add('android.permission.RECORD_AUDIO');
|
|
148
|
-
}
|
|
149
|
-
if (!perms.size) {
|
|
150
|
-
Utils.dispatchToMainThread(() => request.grant(request.getResources()));
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
requestPermissions(Array.from(perms)).then((ok) =>
|
|
154
|
-
Utils.dispatchToMainThread(() => (ok ? request.grant(request.getResources()) : request.deny()))
|
|
155
|
-
);
|
|
156
|
-
},
|
|
157
|
-
});
|
|
158
|
-
return new ChromeClient();
|
|
159
|
-
}
|
|
160
190
|
}
|
|
161
191
|
|
|
162
|
-
/** https://appwrap.local/<path> → file under www/; SPA fallback to the entry for extension-less misses.
|
|
192
|
+
/** https://appwrap.local/<path> → file under www/; SPA fallback to the entry for extension-less misses.
|
|
193
|
+
* Extend-once (same NS shape-caching pitfall as the ChromeClient above): the closure here is
|
|
194
|
+
* process-constant, but generating the class per webview would silently pin the FIRST call's
|
|
195
|
+
* closure anyway — build it once and reuse. */
|
|
196
|
+
let assetClientClass: any;
|
|
163
197
|
function createAssetServingClient(): android.webkit.WebViewClient {
|
|
198
|
+
if (assetClientClass) return new assetClientClass();
|
|
164
199
|
const wwwPath = nsPath.join(knownFolders.currentApp().path, 'www');
|
|
165
200
|
|
|
166
201
|
const respond = (filePath: string, ext: string): android.webkit.WebResourceResponse => {
|
|
@@ -246,7 +281,7 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
246
281
|
};
|
|
247
282
|
|
|
248
283
|
// NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
|
|
249
|
-
|
|
284
|
+
assetClientClass = (android.webkit.WebViewClient as any).extend({
|
|
250
285
|
// Modern (API 21+) overload passes a WebResourceRequest; the legacy overload passed a string URL.
|
|
251
286
|
// We defensively handle both, so the param is the union.
|
|
252
287
|
shouldInterceptRequest(
|
|
@@ -305,5 +340,5 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
305
340
|
}
|
|
306
341
|
},
|
|
307
342
|
});
|
|
308
|
-
return new
|
|
343
|
+
return new assetClientClass();
|
|
309
344
|
}
|
|
@@ -97,18 +97,36 @@ export function registerAndroidHandlers(): void {
|
|
|
97
97
|
}
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
// NS caches the Java proxy class synthesized by .extend({...}) by the methods-object SHAPE, so
|
|
101
|
+
// re-extending per call would permanently bake in the FIRST call's resolve/reject closure — every
|
|
102
|
+
// later authenticate would settle the first (already-settled) promise and hang its own. Extend once
|
|
103
|
+
// and route through a mutable slot (only one biometric prompt can be active at a time).
|
|
104
|
+
let biometricCallbackClass: any;
|
|
105
|
+
let biometricPending: { resolve: (v: unknown) => void; reject: (e: unknown) => void } | null = null;
|
|
106
|
+
const getBiometricCallbackClass = () => {
|
|
107
|
+
if (biometricCallbackClass) return biometricCallbackClass;
|
|
108
|
+
biometricCallbackClass = (androidx.biometric.BiometricPrompt.AuthenticationCallback as any).extend({
|
|
109
|
+
onAuthenticationSucceeded(_result: any) {
|
|
110
|
+
biometricPending?.resolve({ success: true });
|
|
111
|
+
biometricPending = null;
|
|
112
|
+
},
|
|
113
|
+
onAuthenticationError(_code: number, message: any) {
|
|
114
|
+
biometricPending?.reject(err('DENIED', String(message ?? 'authentication error')));
|
|
115
|
+
biometricPending = null;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
return biometricCallbackClass;
|
|
119
|
+
};
|
|
120
|
+
|
|
100
121
|
bridge.register('biometrics.authenticate', ({ reason }: { reason?: string }) => {
|
|
101
122
|
return new Promise((resolve, reject) => {
|
|
102
123
|
Utils.dispatchToMainThread(() => {
|
|
103
124
|
try {
|
|
104
125
|
const act = activity(); // NativeScriptActivity extends AppCompatActivity → FragmentActivity
|
|
105
126
|
const executor = androidx.core.content.ContextCompat.getMainExecutor(act);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
reject(err('DENIED', String(message ?? 'authentication error')));
|
|
110
|
-
},
|
|
111
|
-
});
|
|
127
|
+
biometricPending?.reject(err('DENIED', 'superseded by a new biometric prompt'));
|
|
128
|
+
biometricPending = { resolve, reject };
|
|
129
|
+
const Callback = getBiometricCallbackClass();
|
|
112
130
|
const prompt = new androidx.biometric.BiometricPrompt(act, executor, new Callback());
|
|
113
131
|
const info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
|
|
114
132
|
.setTitle(String(reason ?? 'Authenticate'))
|
|
@@ -117,6 +135,7 @@ export function registerAndroidHandlers(): void {
|
|
|
117
135
|
.build();
|
|
118
136
|
prompt.authenticate(info);
|
|
119
137
|
} catch (e: any) {
|
|
138
|
+
if (biometricPending?.reject === reject) biometricPending = null;
|
|
120
139
|
reject(err('UNSUPPORTED', e?.message ?? String(e)));
|
|
121
140
|
}
|
|
122
141
|
});
|
|
@@ -14,6 +14,26 @@ module.exports = (env) => {
|
|
|
14
14
|
webpack.init(env);
|
|
15
15
|
|
|
16
16
|
webpack.chainWebpack((config) => {
|
|
17
|
+
// node:-scheme imports: pure-JS deps (e.g. @noble/hashes) conditionally probe Node builtins via
|
|
18
|
+
// the `node:` scheme, which webpack cannot even READ ("UnhandledSchemeError: Reading from
|
|
19
|
+
// 'node:crypto' is not handled by plugins") — the whole NS build fails. Strip the scheme so the
|
|
20
|
+
// request becomes a bare builtin name…
|
|
21
|
+
const { NormalModuleReplacementPlugin } = require('webpack');
|
|
22
|
+
config.plugin('AppwrapStripNodeScheme').use(NormalModuleReplacementPlugin, [
|
|
23
|
+
/^node:/,
|
|
24
|
+
(resource) => {
|
|
25
|
+
resource.request = resource.request.replace(/^node:/, '');
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
// …then stub every Node builtin the NS runtime doesn't have to an empty module (fallback: false),
|
|
29
|
+
// so conditional probes resolve gracefully instead of "Module not found". `module` is excluded:
|
|
30
|
+
// @nativescript/webpack maps it to its own polyfill and that must keep winning.
|
|
31
|
+
config.resolve.merge({
|
|
32
|
+
fallback: Object.fromEntries(
|
|
33
|
+
require('module').builtinModules.filter((m) => m !== 'module').map((m) => [m, false])
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
|
|
17
37
|
// Ship the PWA bundle inside the app package
|
|
18
38
|
config.plugin('CopyWww').use(CopyWebpackPlugin, [
|
|
19
39
|
{
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* appwrap CLI v0 — scaffold a native wrapper around a built PWA.
|
|
4
4
|
*
|
|
5
|
-
* appwrap init [--config <path>] [--out native] # from the PWA project dir
|
|
5
|
+
* appwrap init [--config <path>] [--out native] [--ci] # from the PWA project dir; --ci also scaffolds GH Actions workflows
|
|
6
6
|
* appwrap sync [--config <path>] [--out native] # re-copy PWA dist into the wrapper
|
|
7
7
|
*
|
|
8
8
|
* Config (TS preferred, JSON fallback) — probed in order: appwrap.config.ts → .js → appwrap.json.
|
|
@@ -592,7 +592,11 @@ function stampTeamId(outDir: string, cfg: AppwrapConfig, ctx?: { cwd: string; co
|
|
|
592
592
|
writeFileSync(xcconfig, src);
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
-
|
|
595
|
+
/** Profile kind, derived from the .mobileprovision payload: ProvisionsAllDevices → enterprise;
|
|
596
|
+
* ProvisionedDevices + get-task-allow → development; ProvisionedDevices w/o it → adhoc; else appstore. */
|
|
597
|
+
type ProvKind = 'development' | 'adhoc' | 'enterprise' | 'appstore';
|
|
598
|
+
|
|
599
|
+
interface ProvProfile { name: string; uuid: string; teamId: string; bundleId: string; exp: number; kind: ProvKind }
|
|
596
600
|
|
|
597
601
|
/** Discover provisioning profiles installed on this machine (both the legacy MobileDevice dir and
|
|
598
602
|
* the modern Xcode UserData dir). Each is decoded with `security cms` and reduced to the fields we
|
|
@@ -623,7 +627,11 @@ function findProvisioningProfiles(): ProvProfile[] {
|
|
|
623
627
|
if (!name || !uuid || !team || !appId || byUuid.has(uuid)) continue;
|
|
624
628
|
// application-identifier is `<TEAM>.<bundle.id>`; strip the team prefix to get the bundle id.
|
|
625
629
|
const bundleId = appId.startsWith(team + '.') ? appId.slice(team.length + 1) : appId;
|
|
626
|
-
|
|
630
|
+
const kind: ProvKind = /<key>ProvisionsAllDevices<\/key>\s*<true\s*\/>/.test(raw) ? 'enterprise'
|
|
631
|
+
: /<key>ProvisionedDevices<\/key>/.test(raw)
|
|
632
|
+
? (/<key>get-task-allow<\/key>\s*<true\s*\/>/.test(raw) ? 'development' : 'adhoc')
|
|
633
|
+
: 'appstore';
|
|
634
|
+
byUuid.set(uuid, { name, uuid, teamId: team, bundleId, exp: expStr ? Date.parse(expStr) : 0, kind });
|
|
627
635
|
} catch { /* skip unreadable profile */ }
|
|
628
636
|
}
|
|
629
637
|
}
|
|
@@ -635,10 +643,14 @@ function findProvisioningProfiles(): ProvProfile[] {
|
|
|
635
643
|
* pinned) → newest-expiry (headless). Returns null when nothing matches (caller warns + skips). */
|
|
636
644
|
function selectSigningProfile(
|
|
637
645
|
profiles: ProvProfile[], teamId: string, bundleId: string, cfg: AppwrapConfig,
|
|
638
|
-
ctx?: { configPath: string },
|
|
646
|
+
ctx?: { configPath: string }, kind: ProvKind | 'device' = 'device',
|
|
639
647
|
): string | null {
|
|
640
648
|
const now = Date.now();
|
|
641
|
-
|
|
649
|
+
// 'appstore' (store archive) must sign with an App Store distribution profile; 'device' (deploy)
|
|
650
|
+
// must NOT — an appstore profile can't install on a device. Filtering here is what keeps ONE
|
|
651
|
+
// signingProfiles pin map safe: a pin of the wrong kind simply doesn't match this lane.
|
|
652
|
+
const kindOk = (p: ProvProfile): boolean => (kind === 'device' ? p.kind !== 'appstore' : p.kind === kind);
|
|
653
|
+
const matches = profiles.filter((p) => p.teamId === teamId && p.bundleId === bundleId && p.exp > now && kindOk(p));
|
|
642
654
|
if (matches.length === 0) return null;
|
|
643
655
|
const pinned = cfg.signingProfiles?.[bundleId];
|
|
644
656
|
if (pinned && matches.some((p) => p.name === pinned)) return pinned;
|
|
@@ -678,14 +690,31 @@ function stampManualSigning(outDir: string, cfg: AppwrapConfig, ctx?: { configPa
|
|
|
678
690
|
console.warn(' ⚠ signing: manual but teamId is unset — skipping manual signing.');
|
|
679
691
|
return;
|
|
680
692
|
}
|
|
693
|
+
// Store archives (`appwrap build ios --release`) sign with App Store distribution profiles +
|
|
694
|
+
// "Apple Distribution"; device deploys with development/adhoc profiles + "Apple Development".
|
|
695
|
+
// A signing-DISABLED archive that's re-signed at exportArchive gets its entitlements SYNTHESIZED
|
|
696
|
+
// from the profile (dropping app.entitlements — e.g. declared-age-range); signing the archive
|
|
697
|
+
// itself is what preserves them, so the store lane rides this same manual stamp.
|
|
698
|
+
const kind: ProvKind | 'device' = process.env.APPWRAP_SIGNING_KIND === 'appstore' ? 'appstore' : 'device';
|
|
699
|
+
const identity = kind === 'appstore' ? 'Apple Distribution' : 'Apple Development';
|
|
700
|
+
// Don't pin interactive picks from the store lane — the single signingProfiles map is keyed by
|
|
701
|
+
// bundle id only, so a store pin would clobber the device pin (and vice versa).
|
|
702
|
+
const pinCtx = kind === 'device' ? ctx : undefined;
|
|
681
703
|
const profiles = findProvisioningProfiles();
|
|
682
704
|
let mainProvision: string | null = null;
|
|
683
705
|
|
|
684
706
|
// Main app target → build.xcconfig.
|
|
685
707
|
const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
|
|
686
708
|
if (existsSync(xcconfig)) {
|
|
687
|
-
const name = selectSigningProfile(profiles, teamId, cfg.id, cfg,
|
|
709
|
+
const name = selectSigningProfile(profiles, teamId, cfg.id, cfg, pinCtx, kind);
|
|
688
710
|
if (!name) {
|
|
711
|
+
// Store lane must NOT fall through to automatic signing: an unsigned archive gets its
|
|
712
|
+
// entitlements re-synthesized from the profile at export, silently dropping any
|
|
713
|
+
// config-stamped entitlement (the declared-age-range failure this lane exists to prevent).
|
|
714
|
+
if (kind === 'appstore') {
|
|
715
|
+
console.error(` ✗ no valid App Store distribution profile for ${cfg.id} (team ${teamId}). Install one (ASC → Profiles) or seed via Xcode, then retry.`);
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
689
718
|
console.warn(` ⚠ no valid profile for ${cfg.id} (team ${teamId}) — automatic signing will run. Install one or seed via Xcode.`);
|
|
690
719
|
} else {
|
|
691
720
|
mainProvision = name;
|
|
@@ -696,10 +725,10 @@ function stampManualSigning(outDir: string, cfg: AppwrapConfig, ctx?: { configPa
|
|
|
696
725
|
? s.replace(new RegExp(`^\\s*${key}\\s*=\\s*[^;\\n]*;?`, 'm'), `${key} = ${val};`)
|
|
697
726
|
: s + `\n${key} = ${val};`;
|
|
698
727
|
src = setKey(src, 'CODE_SIGN_STYLE', 'Manual');
|
|
699
|
-
src = setKey(src, 'CODE_SIGN_IDENTITY',
|
|
728
|
+
src = setKey(src, 'CODE_SIGN_IDENTITY', identity);
|
|
700
729
|
src = setKey(src, 'PROVISIONING_PROFILE_SPECIFIER', name);
|
|
701
730
|
writeFileSync(xcconfig, src.endsWith('\n') ? src : src + '\n');
|
|
702
|
-
console.log(` sign ← ${cfg.id} → "${name}" (manual)`);
|
|
731
|
+
console.log(` sign ← ${cfg.id} → "${name}" (manual, ${identity})`);
|
|
703
732
|
}
|
|
704
733
|
}
|
|
705
734
|
|
|
@@ -715,7 +744,7 @@ function stampManualSigning(outDir: string, cfg: AppwrapConfig, ctx?: { configPa
|
|
|
715
744
|
if (!ext.isDirectory()) continue;
|
|
716
745
|
if (!existsSync(join(extRoot, ext.name, 'extension.json'))) continue;
|
|
717
746
|
const extBundle = `${cfg.id}.${ext.name}`;
|
|
718
|
-
const name = selectSigningProfile(profiles, teamId, extBundle, cfg,
|
|
747
|
+
const name = selectSigningProfile(profiles, teamId, extBundle, cfg, pinCtx, kind);
|
|
719
748
|
if (!name) { console.warn(` ⚠ no valid profile for extension ${extBundle} — it may fail to sign.`); continue; }
|
|
720
749
|
extEntries.push({ bundleId: extBundle, profile: name });
|
|
721
750
|
console.log(` sign ← ${extBundle} → "${name}" (manual, via hook)`);
|
|
@@ -725,7 +754,7 @@ function stampManualSigning(outDir: string, cfg: AppwrapConfig, ctx?: { configPa
|
|
|
725
754
|
const hookFile = join(hookDir, 'appwrap-signing.js');
|
|
726
755
|
if (extEntries.length) {
|
|
727
756
|
mkdirSync(hookDir, { recursive: true });
|
|
728
|
-
writeFileSync(hookFile, SIGNING_HOOK(teamId, extEntries));
|
|
757
|
+
writeFileSync(hookFile, SIGNING_HOOK(teamId, extEntries, identity));
|
|
729
758
|
// NS's exportOptions reads extension profiles from this file (extensions/provisioning.json) —
|
|
730
759
|
// it maps each extension bundle id → profile name so `xcodebuild -exportArchive` can build the .ipa.
|
|
731
760
|
writeFileSync(join(extRoot, 'provisioning.json'),
|
|
@@ -751,11 +780,12 @@ function stampManualSigning(outDir: string, cfg: AppwrapConfig, ctx?: { configPa
|
|
|
751
780
|
* Self-contained (no deps), zero-arg (NS DI won't choke). Per extension it upserts DEVELOPMENT_TEAM,
|
|
752
781
|
* PROVISIONING_PROFILE_SPECIFIER, CODE_SIGN_STYLE=Manual, CODE_SIGN_IDENTITY in every buildSettings
|
|
753
782
|
* block whose PRODUCT_BUNDLE_IDENTIFIER matches the extension's bundle id. */
|
|
754
|
-
const SIGNING_HOOK = (team: string, entries: Array<{ bundleId: string; profile: string }
|
|
783
|
+
const SIGNING_HOOK = (team: string, entries: Array<{ bundleId: string; profile: string }>, identity = 'Apple Development') => `// Generated by \`appwrap\` — stamps manual signing onto extension targets in project.pbxproj.
|
|
755
784
|
const fs = require('fs');
|
|
756
785
|
const path = require('path');
|
|
757
786
|
const TEAM = ${JSON.stringify(team)};
|
|
758
787
|
const ENTRIES = ${JSON.stringify(entries)};
|
|
788
|
+
const IDENTITY = ${JSON.stringify(identity)};
|
|
759
789
|
module.exports = function () {
|
|
760
790
|
const iosDir = path.join(__dirname, '..', '..', 'platforms', 'ios');
|
|
761
791
|
if (!fs.existsSync(iosDir)) return;
|
|
@@ -781,7 +811,7 @@ module.exports = function () {
|
|
|
781
811
|
block = block.replace(/^\\s*PROVISIONING_PROFILE\\s*=\\s*[^;\\n]*;\\n?/m, '');
|
|
782
812
|
block = upsert(block, 'CODE_SIGN_STYLE', 'Manual');
|
|
783
813
|
block = upsert(block, 'DEVELOPMENT_TEAM', TEAM);
|
|
784
|
-
block = upsert(block, 'CODE_SIGN_IDENTITY', q(
|
|
814
|
+
block = upsert(block, 'CODE_SIGN_IDENTITY', q(IDENTITY));
|
|
785
815
|
block = upsert(block, 'PROVISIONING_PROFILE_SPECIFIER', q(profile));
|
|
786
816
|
return block;
|
|
787
817
|
});
|
|
@@ -1384,22 +1414,25 @@ export function isFrameworkRepo(root: string): boolean {
|
|
|
1384
1414
|
return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
|
|
1385
1415
|
}
|
|
1386
1416
|
|
|
1387
|
-
/** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/).
|
|
1417
|
+
/** Emit CI scaffolding (GH Actions → git repo root ONLY with --ci, fastlane → native/).
|
|
1418
|
+
* Writing `.github/workflows` into a consumer repo is a repo-level side effect (it can shadow or
|
|
1419
|
+
* clash with the project's own CI), so it is OPT-IN via `appwrap init --ci` — never implicit.
|
|
1388
1420
|
* GH workflows are never overwritten (users may customize them). The fastlane lane IS appwrap-managed
|
|
1389
1421
|
* (the release recipe, not for hand-editing — see AGENTS.md), so it is RE-EMITTED on `--force` to keep
|
|
1390
1422
|
* the recipe current after a framework upgrade; without --force it's still first-time-only. */
|
|
1391
|
-
function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false): void {
|
|
1423
|
+
function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false, withWorkflows = false): void {
|
|
1392
1424
|
if (!existsSync(CI_TEMPLATE_DIR)) return;
|
|
1393
1425
|
const repoRoot = gitRoot(cwd);
|
|
1394
1426
|
// GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
|
|
1395
1427
|
// package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
|
|
1396
1428
|
// [from, to, overwritable]
|
|
1397
1429
|
const targets: Array<[string, string, boolean]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane'), force]];
|
|
1398
|
-
//
|
|
1399
|
-
// workflows into the framework's .github — that's the stray-workflow-each-init bug.
|
|
1400
|
-
|
|
1430
|
+
// Even with --ci: if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold
|
|
1431
|
+
// consumer workflows into the framework's .github — that's the stray-workflow-each-init bug.
|
|
1432
|
+
const scaffoldWorkflows = withWorkflows && !isFrameworkRepo(repoRoot);
|
|
1433
|
+
if (withWorkflows && !scaffoldWorkflows) {
|
|
1401
1434
|
console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
|
|
1402
|
-
} else {
|
|
1435
|
+
} else if (scaffoldWorkflows) {
|
|
1403
1436
|
targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows'), false]);
|
|
1404
1437
|
}
|
|
1405
1438
|
for (const [from, to, overwrite] of targets) {
|
|
@@ -1409,7 +1442,7 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
|
|
|
1409
1442
|
// Pin the emitted workflow's `bunx @livx.cc/appwrap@^x.y.z` to THIS CLI's version floor, so a CI run
|
|
1410
1443
|
// using a freshly-emitted workflow can't silently resolve an older published build that lacks the
|
|
1411
1444
|
// `init`/`release` commands (it would 404 loudly instead). Idempotent: re-init finds no placeholder.
|
|
1412
|
-
if (
|
|
1445
|
+
if (scaffoldWorkflows) {
|
|
1413
1446
|
const wf = join(repoRoot, '.github/workflows/appwrap-release-ios.yml');
|
|
1414
1447
|
if (existsSync(wf)) writeFileSync(wf, readFileSync(wf, 'utf8').replaceAll('__APPWRAP_VERSION__', CLI_VERSION));
|
|
1415
1448
|
}
|
|
@@ -1424,9 +1457,9 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
|
|
|
1424
1457
|
.replaceAll('__TEAM_ID__', cfg.teamId ?? '');
|
|
1425
1458
|
writeFileSync(p, stamped);
|
|
1426
1459
|
}
|
|
1427
|
-
const ci =
|
|
1428
|
-
? ' ci ← fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
|
|
1429
|
-
: ' ci ←
|
|
1460
|
+
const ci = scaffoldWorkflows
|
|
1461
|
+
? ' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
|
|
1462
|
+
: ' ci ← fastlane (native/fastlane, signing stamped) — GH Actions workflows are opt-in: `appwrap init --ci`';
|
|
1430
1463
|
console.log(ci);
|
|
1431
1464
|
}
|
|
1432
1465
|
|
|
@@ -1547,7 +1580,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
|
1547
1580
|
console.log(`🎁 appwrap init → ${outDir}`);
|
|
1548
1581
|
mkdirSync(outDir, { recursive: true });
|
|
1549
1582
|
regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
|
|
1550
|
-
copyCiTemplates(cwd, outDir, cfg, 'force' in flags); // GH workflows: first-time only; fastlane lane: re-emit on --force
|
|
1583
|
+
copyCiTemplates(cwd, outDir, cfg, 'force' in flags, 'ci' in flags); // GH workflows: opt-in (--ci), first-time only; fastlane lane: re-emit on --force
|
|
1551
1584
|
writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
|
|
1552
1585
|
applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
|
|
1553
1586
|
stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
|
|
@@ -1918,13 +1951,29 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
|
|
|
1918
1951
|
console.error(`✖ ${(e as Error).message}`);
|
|
1919
1952
|
process.exit(1);
|
|
1920
1953
|
}
|
|
1954
|
+
const release = 'release' in flags;
|
|
1955
|
+
// iOS release archive = the STORE artifact. With signing:'manual', stamp App Store distribution
|
|
1956
|
+
// signing (profile kind 'appstore' + "Apple Distribution") so xcodebuild archives a SIGNED binary —
|
|
1957
|
+
// an unsigned archive re-signed at exportArchive gets its entitlements SYNTHESIZED from the
|
|
1958
|
+
// provisioning profile, silently dropping app.entitlements (e.g. declared-age-range).
|
|
1959
|
+
if (platform === 'ios' && release) process.env.APPWRAP_SIGNING_KIND = 'appstore';
|
|
1921
1960
|
// Make sure the wrapper reflects the latest config + PWA before compiling (also validates the config).
|
|
1922
1961
|
await sync(cwd, flags);
|
|
1923
1962
|
|
|
1924
|
-
const release = 'release' in flags;
|
|
1925
1963
|
const args = ['build', platform];
|
|
1926
1964
|
if (release) args.push('--release');
|
|
1927
|
-
if (platform === 'ios' && release)
|
|
1965
|
+
if (platform === 'ios' && release) {
|
|
1966
|
+
args.push('--for-device');
|
|
1967
|
+
// Manual signing: pass the resolved App Store profile so NS emits a manual exportOptions.plist
|
|
1968
|
+
// (app + extensions via extensions/provisioning.json) for `xcodebuild -exportArchive`. The export
|
|
1969
|
+
// method comes from the archive's embedded profile (Distribution → app-store), so this yields a
|
|
1970
|
+
// store .ipa whose entitlements are the archive's own (app.entitlements intact).
|
|
1971
|
+
const sidecar = join(outDir, '.appwrap-signing.json');
|
|
1972
|
+
if (existsSync(sidecar)) {
|
|
1973
|
+
const sc = JSON.parse(readFileSync(sidecar, 'utf8')) as { provision?: string };
|
|
1974
|
+
if (sc.provision) args.push('--provision', sc.provision);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1928
1977
|
if (platform === 'android' && 'aab' in flags) args.push('--aab');
|
|
1929
1978
|
|
|
1930
1979
|
if (platform === 'android' && release) {
|
|
@@ -1949,7 +1998,8 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
|
|
|
1949
1998
|
console.log(`▶ ns ${args.join(' ').replace(/(--key-store-password|--key-store-alias-password) [^ ]*/g, '$1 ****')} (cwd: ${outDir})`);
|
|
1950
1999
|
execFileSync('ns', args, { cwd: outDir, stdio: 'inherit' });
|
|
1951
2000
|
if (platform === 'ios' && release) {
|
|
1952
|
-
|
|
2001
|
+
const ipa = join(outDir, 'platforms/ios/build/Release-iphoneos');
|
|
2002
|
+
console.log(`ℹ Signed store .ipa in ${ipa} — upload via \`bunx @livx.cc/mcp-appstores apple-upload\` or the fastlane release lane (native/fastlane).`);
|
|
1953
2003
|
}
|
|
1954
2004
|
}
|
|
1955
2005
|
|