@livx.cc/appwrap 0.24.0 → 0.25.1
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/shell/capabilities.manifest.ts +56 -10
- package/runtime/app/shell/config.ts +4 -0
- package/runtime/app/shell/events.ts +11 -0
- package/runtime/app/shell/handlers-extended.ts +4 -1
- package/runtime/app/shell/handlers-fs.ts +11 -6
- package/runtime/app/shell/handlers-oauth.ts +90 -1
- package/runtime/app/shell/handlers-parity.ts +3 -13
- package/runtime/app/shell/handlers-push.ts +41 -3
- package/runtime/app/shell/handlers-reviews.ts +73 -0
- package/runtime/app/shell/handlers-scanner.ts +222 -0
- package/runtime/app/shell/handlers-speech.ts +336 -0
- package/runtime/package.json +1 -1
- package/src/cli.ts +29 -3
- package/src/config.ts +4 -0
package/package.json
CHANGED
|
@@ -135,23 +135,69 @@ export const MODULES: ModuleManifest[] = [
|
|
|
135
135
|
] },
|
|
136
136
|
android: { permissions: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'] },
|
|
137
137
|
},
|
|
138
|
-
{
|
|
139
|
-
name: 'reviews', group: 'system',
|
|
140
|
-
capabilities: { reviews: { ios: true, android: false } },
|
|
141
|
-
},
|
|
142
138
|
{
|
|
143
139
|
name: 'billing', group: 'billing',
|
|
144
140
|
capabilities: { billing: { ios: true, android: false } },
|
|
145
141
|
},
|
|
146
142
|
|
|
147
|
-
// ──
|
|
148
|
-
//
|
|
149
|
-
//
|
|
143
|
+
// ── reviews — in-app store review prompt — opt-in, STRIPPABLE (own handler + group) ──
|
|
144
|
+
// Moved out of the always-bundled parity/system handlers: the Android Play In-App Review path
|
|
145
|
+
// references `com.google.android.play:review` classes — a gradle dep that must NOT land in builds
|
|
146
|
+
// without `reviews` (missing-class crash / dead weight). iOS: SKStoreReviewController (no dep).
|
|
147
|
+
// HONEST LIMIT (Android): the dialog only surfaces for a Play-Store-track install; on a bare
|
|
148
|
+
// emulator / sideload the API resolves WITHOUT showing UI (documented; same class as billing).
|
|
149
|
+
{
|
|
150
|
+
name: 'reviews', group: 'reviews',
|
|
151
|
+
capabilities: { reviews: { ios: true, android: true } },
|
|
152
|
+
android: { gradleDeps: ['com.google.android.play:review:2.0.2'] },
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// ── oauth — system-browser OAuth (iOS ASWebAuthenticationSession / Android Chrome Custom Tabs) ──
|
|
156
|
+
// Opt-in, strippable (own handler file). iOS: ASWebAuthenticationSession (auto-closes on redirect).
|
|
157
|
+
// Android: a Chrome Custom Tab — it does NOT auto-close on the provider redirect, so the callback
|
|
158
|
+
// (`callbackScheme://…`) returns via the app's EXISTING urlScheme deep-link path; the handler hooks
|
|
159
|
+
// that delivery, matches the scheme, and resolves. androidx.browser is already a baseline dep
|
|
160
|
+
// (browser.open); declared here too so the module stays self-contained (CLI dedups). No permission.
|
|
150
161
|
// Lets Google et al. complete sign-in they reject inside an embedded WebView (disallowed_useragent).
|
|
151
|
-
// Android (Custom Tabs + intent callback) is a future addition.
|
|
152
162
|
{
|
|
153
163
|
name: 'oauth', group: 'oauth',
|
|
154
|
-
capabilities: { oauth: { ios: true, android:
|
|
164
|
+
capabilities: { oauth: { ios: true, android: true } },
|
|
165
|
+
android: { gradleDeps: ['androidx.browser:browser:1.8.0'] },
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// ── scanner — camera barcode/QR decode — opt-in (camera permission + decoder weight) ──
|
|
169
|
+
// Reuses the SAME camera permission as media/camera (the CLI dedups across active modules), so a
|
|
170
|
+
// scanner-only app still gets NSCameraUsageDescription / CAMERA without a second permission.
|
|
171
|
+
// iOS: AVCaptureMetadataOutput (no extra dep). Android: ZXing-android-embedded — it ships its own
|
|
172
|
+
// capture Activity, so the handler just launches it via startActivityForResult and reads the
|
|
173
|
+
// result (fewest moving parts vs ML Kit, which needs a hand-built Camera2/CameraX preview).
|
|
174
|
+
{
|
|
175
|
+
name: 'scanner', group: 'scanner',
|
|
176
|
+
capabilities: { scanner: 'native' },
|
|
177
|
+
ios: { permissions: [{ key: 'NSCameraUsageDescription', domain: 'camera', defaultUsage: 'Scan barcodes and QR codes with the camera.' }] },
|
|
178
|
+
android: {
|
|
179
|
+
permissions: ['android.permission.CAMERA'],
|
|
180
|
+
gradleDeps: ['com.journeyapps:zxing-android-embedded:4.3.0'],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// ── speech (TTS + STT) — opt-in module; ONE coherent kit.speech, TWO honest capabilities ──
|
|
185
|
+
// ONE module (not split TTS-core / STT-opt-in): TTS-only apps are rare and a split fractures
|
|
186
|
+
// `kit.speech` across tiers. The module declares the STT perms (mic + speech-recognition); the
|
|
187
|
+
// CLI only stamps them when `speech` is active, and TTS (`speak`/`voices`) rides along perm-free.
|
|
188
|
+
// It advertises TWO handshake caps so the kit gates each concern honestly:
|
|
189
|
+
// `speech` → TTS (synthesis), always native on a shell.
|
|
190
|
+
// `speechRecognition` → STT (transcription), always native on a shell.
|
|
191
|
+
// iOS: AVSpeechSynthesizer (TTS, no dep) + SFSpeechRecognizer/AVAudioEngine (STT). Android:
|
|
192
|
+
// TextToSpeech + SpeechRecognizer — both plain Java → NO kotlin flag, NO gradle dep.
|
|
193
|
+
{
|
|
194
|
+
name: 'speech', group: 'speech',
|
|
195
|
+
capabilities: { speech: 'native', speechRecognition: 'native' },
|
|
196
|
+
ios: { permissions: [
|
|
197
|
+
{ key: 'NSSpeechRecognitionUsageDescription', domain: 'speechRecognition', defaultUsage: 'Transcribe your speech to text.' },
|
|
198
|
+
{ key: 'NSMicrophoneUsageDescription', domain: 'microphone', defaultUsage: 'Listen to your voice for speech-to-text.' },
|
|
199
|
+
] },
|
|
200
|
+
android: { permissions: ['android.permission.RECORD_AUDIO'] },
|
|
155
201
|
},
|
|
156
202
|
|
|
157
203
|
// ── health (steps) — opt-in heavy module; FG live + BG via OS step store ──
|
|
@@ -204,7 +250,7 @@ export const MODULES: ModuleManifest[] = [
|
|
|
204
250
|
|
|
205
251
|
/** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
|
|
206
252
|
* groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
|
|
207
|
-
export const OPTIONAL_GROUPS = ['health', 'oauth'] as const;
|
|
253
|
+
export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech'] as const;
|
|
208
254
|
|
|
209
255
|
/** Resolve the active capability map for the handshake from a set of active capability names. */
|
|
210
256
|
export function buildCapabilityMap(
|
|
@@ -50,4 +50,8 @@ export const SHELL_CONFIG = {
|
|
|
50
50
|
* un-provisioned build honestly reports 'none' (and a personal-team iOS build keeps `pushIos:false`). */
|
|
51
51
|
pushIos: false,
|
|
52
52
|
pushAndroid: false,
|
|
53
|
+
/** Optional backend URL that the shell POSTs the device token to NATIVELY as soon as it's acquired —
|
|
54
|
+
* `{ token, platform: 'ios'|'android' }`. Native HTTP avoids the WKWebView `app://` cross-origin/CORS
|
|
55
|
+
* wall, so a token reaches your server without any WebView fetch. Empty = the app handles sending. */
|
|
56
|
+
pushRegistrationUrl: '',
|
|
53
57
|
};
|
|
@@ -10,8 +10,19 @@ let pendingPushTap: { data: Record<string, string> } | null = null;
|
|
|
10
10
|
// (the cold-start-from-notification bug). So we gate delivery on the handshake.
|
|
11
11
|
let pwaReady = false;
|
|
12
12
|
|
|
13
|
+
/** A pending native-side consumer of an inbound deep link (e.g. the Android OAuth flow waiting for
|
|
14
|
+
* the provider's `callbackScheme://…` redirect). Returns true if it CONSUMED the URL — then it's an
|
|
15
|
+
* internal callback, not an app deep link, and must NOT reach the PWA. Set by handlers-oauth. */
|
|
16
|
+
let deepLinkInterceptor: ((url: string) => boolean) | null = null;
|
|
17
|
+
export function setDeepLinkInterceptor(fn: ((url: string) => boolean) | null): void {
|
|
18
|
+
deepLinkInterceptor = fn;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
/** Called by the iOS delegate (cold start or while running) and Android intents. */
|
|
14
22
|
export function onDeepLink(url: string): void {
|
|
23
|
+
// Give a native consumer (OAuth callback) first refusal — a matched OAuth redirect is internal
|
|
24
|
+
// plumbing, not an app deep link, so it's swallowed here and never forwarded to the PWA.
|
|
25
|
+
if (deepLinkInterceptor?.(url)) return;
|
|
15
26
|
if (pwaReady) bridge.emit('deeplink.open', { url });
|
|
16
27
|
else pendingDeepLink = url; // buffer until the PWA handshakes
|
|
17
28
|
}
|
|
@@ -127,7 +127,10 @@ export function registerExtendedHandlers(): void {
|
|
|
127
127
|
Utils.dispatchToMainThread(() => {
|
|
128
128
|
UNUserNotificationCenter.currentNotificationCenter().setBadgeCountWithCompletionHandler?.(
|
|
129
129
|
count,
|
|
130
|
-
()
|
|
130
|
+
// iOS silently ignores the badge unless notification authorization (incl. .badge) was granted —
|
|
131
|
+
// the completion's error is the ONLY signal, so DON'T swallow it (else the badge just never
|
|
132
|
+
// appears with no explanation). Resolve regardless (the JS call itself didn't fail).
|
|
133
|
+
(error: any) => { if (error) console.warn('[appwrap] setBadge ignored — notifications not authorized?', String(error)); }
|
|
131
134
|
);
|
|
132
135
|
resolve();
|
|
133
136
|
});
|
|
@@ -57,13 +57,17 @@ function registerIosFs(): void {
|
|
|
57
57
|
if (!fm().fileExistsAtPath(full)) throw err('NATIVE_ERROR', `No such file: ${path}`);
|
|
58
58
|
const data = NSData.dataWithContentsOfFile(full);
|
|
59
59
|
if (!data) throw err('NATIVE_ERROR', `Read failed: ${path}`);
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// String(...) FORCES a JS string. Without it the NSString proxy goes over the bridge and
|
|
61
|
+
// JSON-serializes to `{}` — `as unknown as string` is a compile-time no-op, not a conversion.
|
|
62
|
+
if (encoding === 'base64') return String(data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions));
|
|
63
|
+
return String(NSString.alloc().initWithDataEncoding(data, 4 /* NSUTF8StringEncoding */));
|
|
62
64
|
});
|
|
63
65
|
|
|
64
|
-
bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8'
|
|
66
|
+
bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
|
|
65
67
|
const full = iosResolve(path, dir);
|
|
66
|
-
|
|
68
|
+
// Always create the parent dir — writing to a sub-path (e.g. 'demo/note.txt') otherwise fails
|
|
69
|
+
// ("Write failed"), which is a footgun. fs.append already does this; match it.
|
|
70
|
+
ensureParentIos(full);
|
|
67
71
|
const blob = encoding === 'base64'
|
|
68
72
|
? NSData.alloc().initWithBase64EncodedStringOptions(data, 0 as unknown as NSDataBase64DecodingOptions)
|
|
69
73
|
: (NSString.stringWithString(String(data ?? '')) as any).dataUsingEncoding(4);
|
|
@@ -245,9 +249,10 @@ function registerAndroidFs(): void {
|
|
|
245
249
|
return new java.lang.String(bytes, 'UTF-8').toString();
|
|
246
250
|
});
|
|
247
251
|
|
|
248
|
-
bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8'
|
|
252
|
+
bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
|
|
249
253
|
const file = androidFile(path, dir);
|
|
250
|
-
|
|
254
|
+
// Always create the parent dir (match fs.append + iOS) — writing to a sub-path otherwise fails.
|
|
255
|
+
file.getParentFile()?.mkdirs();
|
|
251
256
|
writeAndroid(file, data, encoding, false);
|
|
252
257
|
return { uri: android.net.Uri.fromFile(file).toString() };
|
|
253
258
|
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { Utils, isIOS } from '@nativescript/core';
|
|
1
|
+
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
|
+
import { setDeepLinkInterceptor } from './events';
|
|
3
4
|
|
|
4
5
|
// AuthenticationServices globals (NS auto-links the framework via metadata when referenced).
|
|
5
6
|
declare const ASWebAuthenticationSession: any;
|
|
6
7
|
declare const ASWebAuthenticationPresentationContextProviding: any;
|
|
8
|
+
declare const android: any, androidx: any;
|
|
7
9
|
|
|
8
10
|
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
9
11
|
|
|
@@ -19,6 +21,7 @@ let activeProvider: any = null;
|
|
|
19
21
|
* the auth URL + does token exchange; this only opens the browser and hands back the callback.
|
|
20
22
|
*/
|
|
21
23
|
export function registerOAuthHandlers(): void {
|
|
24
|
+
if (isAndroid) { registerAndroid(); return; }
|
|
22
25
|
if (!isIOS) return;
|
|
23
26
|
|
|
24
27
|
bridge.register(
|
|
@@ -67,3 +70,89 @@ export function registerOAuthHandlers(): void {
|
|
|
67
70
|
})
|
|
68
71
|
);
|
|
69
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Android: launch the auth URL in a Chrome Custom Tab. Unlike iOS ASWebAuthenticationSession, a
|
|
76
|
+
* Custom Tab does NOT auto-close on the provider redirect — the redirect to `callbackScheme://…`
|
|
77
|
+
* re-launches our (singleTask) activity as a VIEW intent, which the shell's existing deep-link path
|
|
78
|
+
* (events.wireAndroidDeepLinks → onDeepLink) already surfaces. We register a deep-link interceptor
|
|
79
|
+
* that matches the pending callbackScheme, swallows that URL (it's OAuth plumbing, not an app deep
|
|
80
|
+
* link), and resolves the pending authorize() with the same { url } shape the iOS path returns.
|
|
81
|
+
*
|
|
82
|
+
* Cancel contract (matches iOS user-dismiss → CANCELLED): Custom Tabs gives no dismissal callback, so
|
|
83
|
+
* we infer cancellation on app.resume — if the app comes back to the foreground after the tab opened
|
|
84
|
+
* and NO matching redirect arrived within a short grace window, the user backed out → reject CANCELLED.
|
|
85
|
+
*
|
|
86
|
+
* DEVICE-UNVERIFIED (any-typed FFI, no device run this session): the CustomTabsIntent launch + the
|
|
87
|
+
* redirect→deep-link round-trip + the resume-based cancel inference. Compile-clean only.
|
|
88
|
+
*/
|
|
89
|
+
function registerAndroid(): void {
|
|
90
|
+
// Single in-flight authorize at a time (mirrors the iOS single-session model).
|
|
91
|
+
let pending:
|
|
92
|
+
| { scheme: string; resolve: (v: any) => void; reject: (e: any) => void; settled: boolean; resumeOff: () => void }
|
|
93
|
+
| null = null;
|
|
94
|
+
|
|
95
|
+
const finish = (fn: () => void) => {
|
|
96
|
+
if (!pending || pending.settled) return;
|
|
97
|
+
pending.settled = true;
|
|
98
|
+
pending.resumeOff();
|
|
99
|
+
const p = pending;
|
|
100
|
+
pending = null;
|
|
101
|
+
fn.call(p);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// First refusal on every inbound deep link: consume the one that matches the pending callbackScheme.
|
|
105
|
+
setDeepLinkInterceptor((url: string): boolean => {
|
|
106
|
+
if (!pending || pending.settled) return false;
|
|
107
|
+
const scheme = pending.scheme;
|
|
108
|
+
if (!scheme || !String(url ?? '').toLowerCase().startsWith(scheme.toLowerCase() + ':')) return false;
|
|
109
|
+
finish(function (this: any) { this.resolve({ url: String(url) }); });
|
|
110
|
+
return true; // swallow — internal OAuth callback, not an app deep link
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
bridge.register(
|
|
114
|
+
'oauth.authorize',
|
|
115
|
+
({ url, callbackScheme, ephemeral }: { url: string; callbackScheme: string; ephemeral?: boolean }) =>
|
|
116
|
+
new Promise((resolve, reject) => {
|
|
117
|
+
const target = String(url ?? '');
|
|
118
|
+
const scheme = String(callbackScheme ?? '');
|
|
119
|
+
if (!target) { reject(err('NATIVE_ERROR', 'oauth.authorize: empty url')); return; }
|
|
120
|
+
if (!scheme) { reject(err('NATIVE_ERROR', 'oauth.authorize: empty callbackScheme')); return; }
|
|
121
|
+
if (pending && !pending.settled) { reject(err('NATIVE_ERROR', 'oauth.authorize: a flow is already in progress')); return; }
|
|
122
|
+
|
|
123
|
+
Utils.dispatchToMainThread(() => {
|
|
124
|
+
try {
|
|
125
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
126
|
+
if (!activity) { reject(err('NATIVE_ERROR', 'oauth.authorize: no foreground activity')); return; }
|
|
127
|
+
|
|
128
|
+
// Cancel inference: the tab launch will background us; the FIRST resume afterwards means
|
|
129
|
+
// the user is back in our app. If no redirect consumed the flow by then (+grace), it's a
|
|
130
|
+
// user-cancel. A small delay lets a redirect that arrives in the same resume win the race.
|
|
131
|
+
let launched = false;
|
|
132
|
+
const onResume = () => {
|
|
133
|
+
if (!launched || !pending || pending.settled) return;
|
|
134
|
+
setTimeout(() => finish(function (this: any) { this.reject(err('CANCELLED', 'oauth cancelled')); }), 400);
|
|
135
|
+
};
|
|
136
|
+
Application.on(Application.resumeEvent, onResume);
|
|
137
|
+
const resumeOff = () => Application.off(Application.resumeEvent, onResume);
|
|
138
|
+
pending = { scheme, resolve, reject, settled: false, resumeOff };
|
|
139
|
+
|
|
140
|
+
const builder = new androidx.browser.customtabs.CustomTabsIntent.Builder();
|
|
141
|
+
// `ephemeral` (fresh login) → don't share the persisted browser session. Custom Tabs has
|
|
142
|
+
// no per-launch incognito API; the closest honest knob is disabling the recents/share
|
|
143
|
+
// surface so the auth page isn't trivially resumable. Cookie isolation isn't guaranteed.
|
|
144
|
+
if (ephemeral) builder.setShareState(androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF);
|
|
145
|
+
const tabs = builder.build();
|
|
146
|
+
// No extra intent flags: the `callbackScheme://…` redirect re-launches our singleTask
|
|
147
|
+
// activity to the front (consumed by the deep-link interceptor), leaving the tab behind it.
|
|
148
|
+
tabs.launchUrl(activity, android.net.Uri.parse(target));
|
|
149
|
+
launched = true;
|
|
150
|
+
} catch (e: any) {
|
|
151
|
+
const fail = err('NATIVE_ERROR', e?.message ?? 'oauth failed to start');
|
|
152
|
+
if (pending && !pending.settled) finish(function (this: any) { this.reject(fail); });
|
|
153
|
+
else reject(fail); // failed before `pending` was wired
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -6,7 +6,7 @@ const err = (code: string, message: string) => Object.assign(new Error(message),
|
|
|
6
6
|
const iosOnly = () => err('UNSUPPORTED', 'iOS only for now');
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Extended-parity handlers: dialogs,
|
|
9
|
+
* Extended-parity handlers: dialogs, storage.clear, geo watch, theme color,
|
|
10
10
|
* motion, contacts, calendar, camera. iOS-first like handlers-extended.
|
|
11
11
|
*/
|
|
12
12
|
export function registerParityHandlers(): void {
|
|
@@ -31,18 +31,8 @@ export function registerParityHandlers(): void {
|
|
|
31
31
|
return idx >= 0 ? idx : null;
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
if (!isIOS) throw iosOnly();
|
|
37
|
-
Utils.dispatchToMainThread(() => {
|
|
38
|
-
const scene = UIApplication.sharedApplication.keyWindow?.windowScene;
|
|
39
|
-
if (scene && SKStoreReviewController.requestReviewInWindowScene) {
|
|
40
|
-
SKStoreReviewController.requestReviewInWindowScene(scene);
|
|
41
|
-
} else {
|
|
42
|
-
SKStoreReviewController.requestReview();
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
});
|
|
34
|
+
// reviews → moved to the strippable `reviews` module (handlers-reviews.ts): its Android Play
|
|
35
|
+
// In-App Review path carries a gradle dep that must not bundle into builds without `reviews`.
|
|
46
36
|
|
|
47
37
|
// ── storage.clear ──────────────────────────────────────────────────
|
|
48
38
|
bridge.register('storage.clear', () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
1
|
+
import { Application, Http, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { SHELL_CONFIG } from './config';
|
|
4
4
|
|
|
@@ -21,9 +21,32 @@ let cachedToken: string | null = null;
|
|
|
21
21
|
|
|
22
22
|
interface PushToken { platform: 'apns' | 'fcm'; token: string; }
|
|
23
23
|
|
|
24
|
+
/** POST the device token to the app's configured backend NATIVELY (no WKWebView fetch → no app://
|
|
25
|
+
* cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured. */
|
|
26
|
+
function registerTokenWithBackend(platform: 'ios' | 'android', token: string): void {
|
|
27
|
+
const url = SHELL_CONFIG.pushRegistrationUrl;
|
|
28
|
+
if (!url) return;
|
|
29
|
+
// NativeScript Http (NOT the global fetch — unreliable in the NS runtime). Native HTTP, no CORS.
|
|
30
|
+
// Guarded: never let a backend-register hiccup break token resolution (this runs before resolve()).
|
|
31
|
+
try {
|
|
32
|
+
Http.request({
|
|
33
|
+
url, method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
content: JSON.stringify({ token, platform }),
|
|
35
|
+
})
|
|
36
|
+
.then((res) => console.log('[push] token registered with backend → ' + res.statusCode))
|
|
37
|
+
.catch((e: any) => console.log('[push] backend register failed: ' + (e?.message ?? e)));
|
|
38
|
+
} catch (e: any) {
|
|
39
|
+
console.log('[push] backend register threw: ' + (e?.message ?? e));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
/** iOS AppDelegate → APNs device token arrived. Resolve any pending register() + cache. */
|
|
25
44
|
export function onApnsToken(hexToken: string): void {
|
|
26
45
|
cachedToken = hexToken;
|
|
46
|
+
// Log the FULL token so it's readable headlessly (`appwrap logs ios`) to send a test push —
|
|
47
|
+
// mirrors the Android FCM boot-log. The token alone isn't a secret (you still need the APNs key).
|
|
48
|
+
console.log('[push] APNs token: ' + hexToken);
|
|
49
|
+
registerTokenWithBackend('ios', hexToken);
|
|
27
50
|
if (pendingRegister) {
|
|
28
51
|
pendingRegister.resolve({ platform: 'apns', token: hexToken });
|
|
29
52
|
pendingRegister = null;
|
|
@@ -143,6 +166,18 @@ export function registerPushHandlers(): void {
|
|
|
143
166
|
if (isAndroid) return androidDeleteToken();
|
|
144
167
|
});
|
|
145
168
|
|
|
169
|
+
// Dev/demo: ask the backend to push THIS device on demand. Goes native (NS Http) → no app:// CORS
|
|
170
|
+
// wall. Requires push.registrationUrl + a registered token; the backend honors `test:true`.
|
|
171
|
+
bridge.register('push.sendTest', () => {
|
|
172
|
+
const url = SHELL_CONFIG.pushRegistrationUrl;
|
|
173
|
+
if (!url) return Promise.reject(Object.assign(new Error('push.registrationUrl not configured'), { code: 'UNSUPPORTED' }));
|
|
174
|
+
if (!cachedToken) return Promise.reject(Object.assign(new Error('no device token yet — register first'), { code: 'NOT_READY' }));
|
|
175
|
+
return Http.request({
|
|
176
|
+
url, method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
content: JSON.stringify({ token: cachedToken, platform: isIOS ? 'ios' : 'android', test: true }),
|
|
178
|
+
}).then((res) => ({ status: res.statusCode }));
|
|
179
|
+
});
|
|
180
|
+
|
|
146
181
|
// Debug: auto-acquire + log the FCM token at boot so it's readable HEADLESSLY (adb logcat / appwrap
|
|
147
182
|
// logs) without a UI tap — the dev loop needs the token to send a test push. Android only (iOS push
|
|
148
183
|
// is off on personal teams); no-op when push isn't configured for this platform.
|
|
@@ -207,8 +242,11 @@ function androidGetToken(): Promise<PushToken> {
|
|
|
207
242
|
const task = fcm().getToken();
|
|
208
243
|
task.addOnCompleteListener(new OnCompleteListener({
|
|
209
244
|
onComplete(t: any) {
|
|
210
|
-
if (t.isSuccessful())
|
|
211
|
-
|
|
245
|
+
if (t.isSuccessful()) {
|
|
246
|
+
const tok = String(t.getResult());
|
|
247
|
+
registerTokenWithBackend('android', tok); // parity with iOS onApnsToken
|
|
248
|
+
resolve({ platform: 'fcm', token: tok });
|
|
249
|
+
} else reject(Object.assign(new Error('FCM token fetch failed'), { code: 'NATIVE_ERROR' }));
|
|
212
250
|
},
|
|
213
251
|
}));
|
|
214
252
|
} catch (e: any) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
|
|
4
|
+
// SKStoreReviewController is declared globally in ios-frameworks.d.ts. `com` is the Android namespace.
|
|
5
|
+
declare const com: any;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* In-app store review prompt — opt-in, strippable (its own handler file + group). Lives apart from
|
|
9
|
+
* the always-bundled parity/system handlers because the Android path references the Play In-App
|
|
10
|
+
* Review classes (`com.google.android.play:review`), a gradle dep that must NOT land in builds that
|
|
11
|
+
* don't enable `reviews` (missing-class crash / dead weight otherwise).
|
|
12
|
+
*
|
|
13
|
+
* iOS: SKStoreReviewController — the OS rate-limits display (may show nothing). Fire-and-forget.
|
|
14
|
+
* Android: Play In-App Review (ReviewManager). HONEST LIMIT — the dialog only actually surfaces for a
|
|
15
|
+
* Play-Store-track install; on a bare emulator / sideload the API resolves WITHOUT showing UI (same
|
|
16
|
+
* class as android-billing). We call the API and resolve gracefully; we never fabricate "shown".
|
|
17
|
+
*
|
|
18
|
+
* DEVICE-UNVERIFIED (any-typed FFI, no device run this session): the Android ReviewManager
|
|
19
|
+
* requestReviewFlow → launchReviewFlow task/listener chain. Compile-clean only.
|
|
20
|
+
*/
|
|
21
|
+
export function registerReviewsHandlers(): void {
|
|
22
|
+
if (isIOS) registerIos();
|
|
23
|
+
else if (isAndroid) registerAndroid();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function registerIos(): void {
|
|
27
|
+
bridge.register('reviews.requestReview', () => {
|
|
28
|
+
Utils.dispatchToMainThread(() => {
|
|
29
|
+
const scene = UIApplication.sharedApplication.keyWindow?.windowScene;
|
|
30
|
+
if (scene && SKStoreReviewController.requestReviewInWindowScene) {
|
|
31
|
+
SKStoreReviewController.requestReviewInWindowScene(scene);
|
|
32
|
+
} else {
|
|
33
|
+
SKStoreReviewController.requestReview();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function registerAndroid(): void {
|
|
40
|
+
bridge.register('reviews.requestReview', () =>
|
|
41
|
+
new Promise<void>((resolve) => {
|
|
42
|
+
Utils.dispatchToMainThread(() => {
|
|
43
|
+
try {
|
|
44
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
45
|
+
if (!activity) { resolve(); return; }
|
|
46
|
+
const manager = com.google.android.play.core.review.ReviewManagerFactory.create(activity);
|
|
47
|
+
const request = manager.requestReviewFlow();
|
|
48
|
+
// requestReviewFlow() → Task<ReviewInfo>; on success launchReviewFlow() → Task<Void>.
|
|
49
|
+
// Both tasks COMPLETE even when the OS shows nothing (no Play track / rate-limited), so we
|
|
50
|
+
// resolve on completion regardless — never block, never fabricate a "shown" signal.
|
|
51
|
+
request.addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener({
|
|
52
|
+
onComplete(infoTask: any) {
|
|
53
|
+
if (!infoTask.isSuccessful()) { resolve(); return; }
|
|
54
|
+
try {
|
|
55
|
+
const flow = manager.launchReviewFlow(activity, infoTask.getResult());
|
|
56
|
+
flow.addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener({
|
|
57
|
+
onComplete() { resolve(); },
|
|
58
|
+
}));
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.warn('AppWrap: reviews.launchReviewFlow failed', e);
|
|
61
|
+
resolve();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Play services / review lib absent (e.g. non-GMS device) → honest no-op, don't reject.
|
|
67
|
+
console.warn('AppWrap: reviews.requestReview unavailable', e);
|
|
68
|
+
resolve();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
import { requestPermissions, startActivityForResult } from './android-helpers';
|
|
4
|
+
|
|
5
|
+
// AVFoundation / UIKit globals (NS auto-links the frameworks via metadata when referenced).
|
|
6
|
+
declare const AVCaptureSession: any;
|
|
7
|
+
declare const AVCaptureDevice: any;
|
|
8
|
+
declare const AVCaptureDeviceInput: any;
|
|
9
|
+
declare const AVCaptureMetadataOutput: any;
|
|
10
|
+
declare const AVCaptureVideoPreviewLayer: any;
|
|
11
|
+
declare const AVMetadataObjectTypeQRCode: any;
|
|
12
|
+
declare const AVMetadataObjectTypeEAN13Code: any;
|
|
13
|
+
declare const AVMetadataObjectTypeCode128Code: any;
|
|
14
|
+
declare const AVLayerVideoGravityResizeAspectFill: any;
|
|
15
|
+
declare const AVCaptureDevicePositionFront: any;
|
|
16
|
+
declare const AVCaptureDevicePositionBack: any;
|
|
17
|
+
declare const AVCaptureDeviceDiscoverySession: any;
|
|
18
|
+
declare const AVMediaTypeVideo: any;
|
|
19
|
+
declare const AVCaptureDeviceTypeBuiltInWideAngleCamera: any;
|
|
20
|
+
declare const AVCaptureMetadataOutputObjectsDelegate: any;
|
|
21
|
+
// `dispatch_get_main_queue()` is a C macro, NOT a bridged symbol — NativeScript can't see it
|
|
22
|
+
// ("is not defined" at runtime). `dispatch_queue_create` IS a real bridged libdispatch function;
|
|
23
|
+
// a dedicated serial queue is the correct delegate queue for AVCaptureMetadataOutput anyway.
|
|
24
|
+
declare function dispatch_queue_create(label: string, attr: any): any;
|
|
25
|
+
declare const android: any, com: any;
|
|
26
|
+
|
|
27
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Camera barcode / QR decoding. iOS: AVCaptureMetadataOutput in a full-screen capture VC presented
|
|
31
|
+
* over the WebView; first machine-readable code → stop session, dismiss, resolve. Android: ZXing's
|
|
32
|
+
* own capture Activity (zxing-android-embedded) launched via startActivityForResult.
|
|
33
|
+
*
|
|
34
|
+
* Contract: `scanner.scan` resolves `{ value, format, bounds? }` on first decode, or
|
|
35
|
+
* `{ cancelled: true }` if the user dismisses (or `scanner.cancel` fires). It never rejects on a
|
|
36
|
+
* plain cancel — only on a hard failure (no camera, permission denied at the OS level).
|
|
37
|
+
*/
|
|
38
|
+
export function registerScannerHandlers(): void {
|
|
39
|
+
if (isIOS) registerIos();
|
|
40
|
+
else if (isAndroid) registerAndroid();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Map our small enum → the platform metadata-object types. Empty/`all` = the full supported set. */
|
|
44
|
+
function iosFormatTypes(formats: any): any[] {
|
|
45
|
+
const all = [AVMetadataObjectTypeQRCode, AVMetadataObjectTypeEAN13Code, AVMetadataObjectTypeCode128Code];
|
|
46
|
+
const want = Array.isArray(formats) ? formats : formats ? [formats] : [];
|
|
47
|
+
const sel = want.filter((f: string) => f && f !== 'all');
|
|
48
|
+
if (!sel.length) return all;
|
|
49
|
+
const map: Record<string, any> = {
|
|
50
|
+
qr: AVMetadataObjectTypeQRCode,
|
|
51
|
+
ean13: AVMetadataObjectTypeEAN13Code,
|
|
52
|
+
code128: AVMetadataObjectTypeCode128Code,
|
|
53
|
+
};
|
|
54
|
+
return sel.map((f: string) => map[f]).filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function iosFormatName(type: any): string {
|
|
58
|
+
if (type === AVMetadataObjectTypeQRCode) return 'qr';
|
|
59
|
+
if (type === AVMetadataObjectTypeEAN13Code) return 'ean13';
|
|
60
|
+
if (type === AVMetadataObjectTypeCode128Code) return 'code128';
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function registerIos(): void {
|
|
65
|
+
// Strong refs while the scanner is up — ARC would otherwise free the session/VC/delegate the moment
|
|
66
|
+
// the JS locals fall out of scope, killing capture mid-stream (same hazard as handlers-oauth).
|
|
67
|
+
let activeSession: any = null;
|
|
68
|
+
let activeVC: any = null;
|
|
69
|
+
let activeDelegate: any = null;
|
|
70
|
+
let pending: { resolve: (v: any) => void } | null = null;
|
|
71
|
+
|
|
72
|
+
const dismiss = () => {
|
|
73
|
+
Utils.dispatchToMainThread(() => {
|
|
74
|
+
try { activeSession?.stopRunning(); } catch { /* already stopped */ }
|
|
75
|
+
activeVC?.dismissViewControllerAnimatedCompletion(true, null);
|
|
76
|
+
activeSession = null;
|
|
77
|
+
activeVC = null;
|
|
78
|
+
activeDelegate = null;
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const settle = (value: any) => {
|
|
83
|
+
const p = pending;
|
|
84
|
+
pending = null;
|
|
85
|
+
dismiss();
|
|
86
|
+
// The metadata delegate now fires on a background serial queue; the bridge reply drives
|
|
87
|
+
// WKWebView.evaluateJavaScript, which must run on the main thread — so hop back to resolve.
|
|
88
|
+
Utils.dispatchToMainThread(() => p?.resolve(value));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
bridge.register('scanner.cancel', () => { if (pending) settle({ cancelled: true }); });
|
|
92
|
+
|
|
93
|
+
bridge.register('scanner.scan', (params: any) =>
|
|
94
|
+
new Promise((resolve, reject) => {
|
|
95
|
+
if (pending) { reject(err('NATIVE_ERROR', 'scanner.scan: a scan is already in progress')); return; }
|
|
96
|
+
pending = { resolve };
|
|
97
|
+
|
|
98
|
+
Utils.dispatchToMainThread(() => {
|
|
99
|
+
try {
|
|
100
|
+
const wantFront = params?.camera === 'front';
|
|
101
|
+
const position = wantFront ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack;
|
|
102
|
+
const discovery = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypesMediaTypePosition(
|
|
103
|
+
[AVCaptureDeviceTypeBuiltInWideAngleCamera], AVMediaTypeVideo, position
|
|
104
|
+
);
|
|
105
|
+
const device = discovery?.devices?.firstObject ?? AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo);
|
|
106
|
+
if (!device) { pending = null; reject(err('UNSUPPORTED', 'No camera available')); return; }
|
|
107
|
+
|
|
108
|
+
const input = AVCaptureDeviceInput.deviceInputWithDeviceError(device, null);
|
|
109
|
+
if (!input) { pending = null; reject(err('NATIVE_ERROR', 'Cannot open camera input')); return; }
|
|
110
|
+
|
|
111
|
+
const session = AVCaptureSession.new();
|
|
112
|
+
if (!session.canAddInput(input)) { pending = null; reject(err('NATIVE_ERROR', 'Cannot add camera input')); return; }
|
|
113
|
+
session.addInput(input);
|
|
114
|
+
|
|
115
|
+
const output = AVCaptureMetadataOutput.new();
|
|
116
|
+
if (!session.canAddOutput(output)) { pending = null; reject(err('NATIVE_ERROR', 'Cannot add metadata output')); return; }
|
|
117
|
+
session.addOutput(output);
|
|
118
|
+
|
|
119
|
+
// Delegate fires on a dedicated serial queue (see dispatch_queue_create below); settle()
|
|
120
|
+
// hops back to the main thread for the WKWebView bridge reply.
|
|
121
|
+
const DelegateClass = (NSObject as any).extend(
|
|
122
|
+
{
|
|
123
|
+
captureOutputDidOutputMetadataObjectsFromConnection(_out: any, objects: any, _conn: any) {
|
|
124
|
+
if (!pending) return;
|
|
125
|
+
const obj = objects?.firstObject;
|
|
126
|
+
const value = obj?.stringValue;
|
|
127
|
+
if (value == null) return;
|
|
128
|
+
settle({ value: String(value), format: iosFormatName(obj.type) });
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
// The protocol MUST be declared: NativeScript uses it to map the camelCase JS method to the
|
|
132
|
+
// colonated selector `captureOutput:didOutputMetadataObjects:fromConnection:`. With an empty
|
|
133
|
+
// protocols list the method registers under the wrong selector and AVFoundation never calls
|
|
134
|
+
// it — the scanner opens but never decodes.
|
|
135
|
+
{ protocols: [AVCaptureMetadataOutputObjectsDelegate] }
|
|
136
|
+
);
|
|
137
|
+
activeDelegate = DelegateClass.new();
|
|
138
|
+
output.setMetadataObjectsDelegateQueue(activeDelegate, dispatch_queue_create('cc.livx.scanner.metadata', null));
|
|
139
|
+
// metadataObjectTypes must be set AFTER addOutput (the available set is session-derived).
|
|
140
|
+
output.metadataObjectTypes = iosFormatTypes(params?.formats) as any;
|
|
141
|
+
|
|
142
|
+
const vc = UIViewController.new();
|
|
143
|
+
const preview = AVCaptureVideoPreviewLayer.layerWithSession(session);
|
|
144
|
+
preview.videoGravity = AVLayerVideoGravityResizeAspectFill;
|
|
145
|
+
preview.frame = vc.view.bounds;
|
|
146
|
+
vc.view.layer.addSublayer(preview);
|
|
147
|
+
|
|
148
|
+
// Cancel button so the user can dismiss without a successful read.
|
|
149
|
+
const btn = UIButton.buttonWithType(0 /* UIButtonTypeCustom */);
|
|
150
|
+
btn.setTitleForState('Cancel', 0 /* UIControlStateNormal */);
|
|
151
|
+
btn.frame = CGRectMake(16, 56, 100, 40);
|
|
152
|
+
const Target = (NSObject as any).extend(
|
|
153
|
+
{ tap() { if (pending) settle({ cancelled: true }); } },
|
|
154
|
+
{ exposedMethods: { tap: { returns: interop.types.void } } }
|
|
155
|
+
);
|
|
156
|
+
activeVC = vc;
|
|
157
|
+
(vc as any)._cancelTarget = Target.new(); // retain via the (also-retained) VC
|
|
158
|
+
btn.addTargetActionForControlEvents((vc as any)._cancelTarget, 'tap', 1 << 6 /* TouchUpInside */);
|
|
159
|
+
vc.view.addSubview(btn);
|
|
160
|
+
|
|
161
|
+
activeSession = session;
|
|
162
|
+
session.startRunning();
|
|
163
|
+
|
|
164
|
+
const root = Utils.ios.getRootViewController();
|
|
165
|
+
vc.modalPresentationStyle = 0; // fullScreen
|
|
166
|
+
root.presentViewControllerAnimatedCompletion(vc, true, null);
|
|
167
|
+
} catch (e: any) {
|
|
168
|
+
pending = null;
|
|
169
|
+
reject(err('NATIVE_ERROR', e?.message ?? 'scanner failed to start'));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function registerAndroid(): void {
|
|
177
|
+
// ZXing-android-embedded ships its own capture Activity (CaptureActivity). We build the launch
|
|
178
|
+
// Intent via IntentIntegrator, run it through startActivityForResult, and parse the result —
|
|
179
|
+
// no hand-built Camera2 preview. IntentResult carries the decoded text + format name.
|
|
180
|
+
const formatName = (zxingName: string): string => {
|
|
181
|
+
const n = String(zxingName ?? '').toUpperCase();
|
|
182
|
+
if (n === 'QR_CODE') return 'qr';
|
|
183
|
+
if (n === 'EAN_13') return 'ean13';
|
|
184
|
+
if (n === 'CODE_128') return 'code128';
|
|
185
|
+
return 'unknown';
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
bridge.register('scanner.cancel', () => {
|
|
189
|
+
// The ZXing capture Activity owns its own back/up dismissal; there's no in-process handle to it
|
|
190
|
+
// once launched. Cancel is honored by the user pressing back, which returns a null-contents result.
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
bridge.register('scanner.scan', async (params: any) => {
|
|
194
|
+
const granted = await requestPermissions(['android.permission.CAMERA']);
|
|
195
|
+
if (!granted) throw err('DENIED', 'Camera permission denied');
|
|
196
|
+
|
|
197
|
+
const activity = Utils.android.getCurrentActivity?.() ?? undefined;
|
|
198
|
+
const integrator = activity
|
|
199
|
+
? new com.google.zxing.integration.android.IntentIntegrator(activity)
|
|
200
|
+
: new com.google.zxing.integration.android.IntentIntegrator(Utils.android.getApplicationContext());
|
|
201
|
+
|
|
202
|
+
// Restrict to our format set (or all). IntentIntegrator names match ZXing BarcodeFormat names.
|
|
203
|
+
const want = Array.isArray(params?.formats) ? params.formats : params?.formats ? [params.formats] : [];
|
|
204
|
+
const sel = want.filter((f: string) => f && f !== 'all');
|
|
205
|
+
if (sel.length) {
|
|
206
|
+
const map: Record<string, string> = { qr: 'QR_CODE', ean13: 'EAN_13', code128: 'CODE_128' };
|
|
207
|
+
const list = new java.util.ArrayList();
|
|
208
|
+
sel.forEach((f: string) => { const z = map[f]; if (z) list.add(z); });
|
|
209
|
+
if (list.size()) integrator.setDesiredBarcodeFormats(list);
|
|
210
|
+
}
|
|
211
|
+
integrator.setBeepEnabled(false);
|
|
212
|
+
integrator.setOrientationLocked(false);
|
|
213
|
+
|
|
214
|
+
const intent = integrator.createScanIntent();
|
|
215
|
+
const { resultCode, intent: data } = await startActivityForResult(intent);
|
|
216
|
+
|
|
217
|
+
const result = com.google.zxing.integration.android.IntentIntegrator.parseActivityResult(resultCode, data);
|
|
218
|
+
const contents = result?.getContents?.();
|
|
219
|
+
if (contents == null) return { cancelled: true }; // user backed out
|
|
220
|
+
return { value: String(contents), format: formatName(result.getFormatName?.()) };
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
import { requestPermissions } from './android-helpers';
|
|
4
|
+
|
|
5
|
+
// AVFoundation / Speech globals (NS auto-links the frameworks via metadata when referenced).
|
|
6
|
+
declare const AVSpeechSynthesizer: any;
|
|
7
|
+
declare const AVSpeechUtterance: any;
|
|
8
|
+
declare const AVSpeechSynthesisVoice: any;
|
|
9
|
+
declare const AVAudioEngine: any;
|
|
10
|
+
declare const AVAudioSession: any;
|
|
11
|
+
declare const AVAudioSessionCategoryPlayAndRecord: any;
|
|
12
|
+
declare const AVAudioSessionModeMeasurement: any;
|
|
13
|
+
declare const SFSpeechRecognizer: any;
|
|
14
|
+
declare const SFSpeechAudioBufferRecognitionRequest: any;
|
|
15
|
+
declare const SFSpeechRecognizerAuthorizationStatusAuthorized: any;
|
|
16
|
+
declare const NSLocale: any;
|
|
17
|
+
declare const android: any;
|
|
18
|
+
declare const java: any;
|
|
19
|
+
|
|
20
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Voice I/O. TTS: iOS AVSpeechSynthesizer / Android TextToSpeech — speak resolves on finish.
|
|
24
|
+
* STT: iOS SFSpeechRecognizer + AVAudioEngine mic tap / Android SpeechRecognizer — listen resolves
|
|
25
|
+
* the FINAL transcript; partials emit `speech.partial` while listening; stopListening resolves with
|
|
26
|
+
* the best-so-far transcript.
|
|
27
|
+
*
|
|
28
|
+
* DEVICE-UNVERIFIED (any-typed FFI, no device run this session): the iOS AVAudioEngine tap +
|
|
29
|
+
* SFSpeechRecognizer authorization/lifecycle, and the Android SpeechRecognizer onResults/
|
|
30
|
+
* onPartialResults threading + TextToSpeech init race. Compile-clean only.
|
|
31
|
+
*/
|
|
32
|
+
export function registerSpeechHandlers(): void {
|
|
33
|
+
if (isIOS) registerIos();
|
|
34
|
+
else if (isAndroid) registerAndroid();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function registerIos(): void {
|
|
38
|
+
// Strong refs while a session is live — ARC would otherwise free the synthesizer/engine/delegate
|
|
39
|
+
// the moment the JS locals fall out of scope (same hazard as handlers-scanner/oauth).
|
|
40
|
+
let synth: any = null;
|
|
41
|
+
let speakDelegate: any = null;
|
|
42
|
+
let speakPending: { resolve: () => void } | null = null;
|
|
43
|
+
|
|
44
|
+
let engine: any = null;
|
|
45
|
+
let recognizer: any = null;
|
|
46
|
+
let request: any = null;
|
|
47
|
+
let task: any = null;
|
|
48
|
+
let listenPending: { resolve: (v: string) => void; reject: (e: any) => void } | null = null;
|
|
49
|
+
let best = '';
|
|
50
|
+
|
|
51
|
+
const teardownListen = () => {
|
|
52
|
+
try { task?.cancel?.(); } catch { /* noop */ }
|
|
53
|
+
try { request?.endAudio?.(); } catch { /* noop */ }
|
|
54
|
+
try {
|
|
55
|
+
engine?.inputNode?.removeTapOnBus?.(0);
|
|
56
|
+
engine?.stop?.();
|
|
57
|
+
} catch { /* noop */ }
|
|
58
|
+
task = null; request = null; engine = null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const settleListen = () => {
|
|
62
|
+
const p = listenPending;
|
|
63
|
+
listenPending = null;
|
|
64
|
+
teardownListen();
|
|
65
|
+
p?.resolve(best);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
bridge.register('speech.speak', (params: any) =>
|
|
69
|
+
new Promise<void>((resolve, reject) => {
|
|
70
|
+
Utils.dispatchToMainThread(() => {
|
|
71
|
+
try {
|
|
72
|
+
if (!synth) {
|
|
73
|
+
synth = AVSpeechSynthesizer.new();
|
|
74
|
+
const DelegateClass = (NSObject as any).extend(
|
|
75
|
+
{
|
|
76
|
+
speechSynthesizerDidFinishSpeechUtterance(_s: any, _u: any) {
|
|
77
|
+
const p = speakPending; speakPending = null; p?.resolve();
|
|
78
|
+
},
|
|
79
|
+
speechSynthesizerDidCancelSpeechUtterance(_s: any, _u: any) {
|
|
80
|
+
const p = speakPending; speakPending = null; p?.resolve();
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{ protocols: [] }
|
|
84
|
+
);
|
|
85
|
+
speakDelegate = DelegateClass.new();
|
|
86
|
+
synth.delegate = speakDelegate;
|
|
87
|
+
}
|
|
88
|
+
// A new speak resolves any prior pending (its utterance is being superseded).
|
|
89
|
+
speakPending?.resolve();
|
|
90
|
+
const u = AVSpeechUtterance.speechUtteranceWithString(String(params?.text ?? ''));
|
|
91
|
+
if (params?.voice) {
|
|
92
|
+
const v = AVSpeechSynthesisVoice.voiceWithIdentifier(String(params.voice));
|
|
93
|
+
if (v) u.voice = v;
|
|
94
|
+
} else if (params?.lang) {
|
|
95
|
+
const v = AVSpeechSynthesisVoice.voiceWithLanguage(String(params.lang));
|
|
96
|
+
if (v) u.voice = v;
|
|
97
|
+
}
|
|
98
|
+
if (params?.rate != null) u.rate = Number(params.rate) * AVSpeechUtterance.alloc().rate; // relative scale
|
|
99
|
+
if (params?.pitch != null) u.pitchMultiplier = Number(params.pitch);
|
|
100
|
+
speakPending = { resolve };
|
|
101
|
+
synth.speakUtterance(u);
|
|
102
|
+
} catch (e: any) {
|
|
103
|
+
reject(err('NATIVE_ERROR', e?.message ?? 'speak failed'));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
bridge.register('speech.stop', () => {
|
|
110
|
+
Utils.dispatchToMainThread(() => {
|
|
111
|
+
try { synth?.stopSpeakingAtBoundary?.(0 /* immediate */); } catch { /* noop */ }
|
|
112
|
+
const p = speakPending; speakPending = null; p?.resolve();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
bridge.register('speech.voices', () => {
|
|
117
|
+
const voices = AVSpeechSynthesisVoice.speechVoices();
|
|
118
|
+
const out: Array<{ id: string; name: string; lang: string }> = [];
|
|
119
|
+
const n = voices?.count ?? 0;
|
|
120
|
+
for (let i = 0; i < n; i++) {
|
|
121
|
+
const v = voices.objectAtIndex(i);
|
|
122
|
+
out.push({ id: String(v.identifier), name: String(v.name), lang: String(v.language) });
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
bridge.register('speech.stopListening', () => {
|
|
128
|
+
// End audio → the recognizer flushes a final result, then we settle with best-so-far.
|
|
129
|
+
try { engine?.inputNode?.removeTapOnBus?.(0); engine?.stop?.(); request?.endAudio?.(); } catch { /* noop */ }
|
|
130
|
+
if (listenPending) settleListen();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
bridge.register('speech.listen', (params: any) =>
|
|
134
|
+
new Promise<string>((resolve, reject) => {
|
|
135
|
+
if (listenPending) { reject(err('NATIVE_ERROR', 'speech.listen: already listening')); return; }
|
|
136
|
+
best = '';
|
|
137
|
+
listenPending = { resolve, reject };
|
|
138
|
+
|
|
139
|
+
SFSpeechRecognizer.requestAuthorization((status: any) => {
|
|
140
|
+
if (status !== SFSpeechRecognizerAuthorizationStatusAuthorized) {
|
|
141
|
+
const p = listenPending; listenPending = null;
|
|
142
|
+
p?.reject(err('DENIED', 'Speech recognition not authorized'));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
Utils.dispatchToMainThread(() => {
|
|
146
|
+
try {
|
|
147
|
+
// Pick a locale SFSpeechRecognizer has a model for: try the requested lang, the device
|
|
148
|
+
// language code, then en-US (always supported) as a last resort. Do NOT gate on
|
|
149
|
+
// `isAvailable` — it's unreliable SYNCHRONOUSLY right after init (becomes true async via
|
|
150
|
+
// the availabilityDidChange delegate), so checking it here wrongly rejects a good recognizer
|
|
151
|
+
// (proven on-device: en-US reported unavailable). Accept any non-nil recognizer; a genuine
|
|
152
|
+
// failure surfaces in the recognitionTask error callback instead.
|
|
153
|
+
const tryRec = (id: string) => {
|
|
154
|
+
try { return SFSpeechRecognizer.alloc().initWithLocale(NSLocale.localeWithLocaleIdentifier(id)) || null; }
|
|
155
|
+
catch { return null; }
|
|
156
|
+
};
|
|
157
|
+
let langCode = 'en';
|
|
158
|
+
try { langCode = String(NSLocale.currentLocale.languageCode || 'en'); } catch { /* default */ }
|
|
159
|
+
const tried = params?.lang ? [String(params.lang)] : [langCode, 'en-US'];
|
|
160
|
+
recognizer = null;
|
|
161
|
+
for (const id of tried) { recognizer = tryRec(id); if (recognizer) break; }
|
|
162
|
+
if (!recognizer) {
|
|
163
|
+
const p = listenPending; listenPending = null;
|
|
164
|
+
p?.reject(err('UNSUPPORTED', `Speech recognizer unavailable (tried: ${tried.join(', ')})`));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const session = AVAudioSession.sharedInstance();
|
|
169
|
+
session.setCategoryModeOptionsError(AVAudioSessionCategoryPlayAndRecord, AVAudioSessionModeMeasurement, 1 /* duckOthers */, null);
|
|
170
|
+
session.setActiveWithOptionsError(true, 0, null);
|
|
171
|
+
|
|
172
|
+
engine = AVAudioEngine.new();
|
|
173
|
+
request = SFSpeechAudioBufferRecognitionRequest.new();
|
|
174
|
+
request.shouldReportPartialResults = !!params?.partial;
|
|
175
|
+
|
|
176
|
+
task = recognizer.recognitionTaskWithRequestResultHandler(request, (result: any, error: any) => {
|
|
177
|
+
if (result) {
|
|
178
|
+
best = String(result.bestTranscription?.formattedString ?? best);
|
|
179
|
+
if (params?.partial && !result.isFinal) bridge.emit('speech.partial', { transcript: best });
|
|
180
|
+
if (result.isFinal && listenPending) settleListen();
|
|
181
|
+
}
|
|
182
|
+
if (error && listenPending) {
|
|
183
|
+
const p = listenPending; listenPending = null;
|
|
184
|
+
teardownListen();
|
|
185
|
+
p?.resolve(best); // resolve with whatever we have — stopListening contract
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const input = engine.inputNode;
|
|
190
|
+
const format = input.outputFormatForBus(0);
|
|
191
|
+
input.installTapOnBusBufferSizeFormatBlock(0, 1024, format, (buffer: any, _when: any) => {
|
|
192
|
+
request?.appendAudioPCMBuffer(buffer);
|
|
193
|
+
});
|
|
194
|
+
engine.prepare();
|
|
195
|
+
engine.startAndReturnError(null);
|
|
196
|
+
} catch (e: any) {
|
|
197
|
+
const p = listenPending; listenPending = null;
|
|
198
|
+
teardownListen();
|
|
199
|
+
p?.reject(err('NATIVE_ERROR', e?.message ?? 'listen failed'));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
})
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function registerAndroid(): void {
|
|
208
|
+
// TextToSpeech + SpeechRecognizer are plain Java → direct interop, no kotlin flag, no gradle dep.
|
|
209
|
+
let tts: any = null;
|
|
210
|
+
let ttsReady = false;
|
|
211
|
+
const ctx = () => Utils.android.getApplicationContext();
|
|
212
|
+
|
|
213
|
+
const ensureTts = (): Promise<any> =>
|
|
214
|
+
new Promise((resolve) => {
|
|
215
|
+
if (tts && ttsReady) { resolve(tts); return; }
|
|
216
|
+
tts = new android.speech.tts.TextToSpeech(
|
|
217
|
+
ctx(),
|
|
218
|
+
new android.speech.tts.TextToSpeech.OnInitListener({
|
|
219
|
+
onInit(status: number) {
|
|
220
|
+
ttsReady = status === android.speech.tts.TextToSpeech.SUCCESS;
|
|
221
|
+
resolve(ttsReady ? tts : null);
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
bridge.register('speech.speak', async (params: any) => {
|
|
228
|
+
const engine = await ensureTts();
|
|
229
|
+
if (!engine) throw err('NATIVE_ERROR', 'TextToSpeech init failed');
|
|
230
|
+
const text = String(params?.text ?? '');
|
|
231
|
+
if (params?.lang) {
|
|
232
|
+
try { engine.setLanguage(new java.util.Locale.Builder().setLanguageTag(String(params.lang)).build()); } catch { /* keep default */ }
|
|
233
|
+
}
|
|
234
|
+
if (params?.rate != null) engine.setSpeechRate(Number(params.rate));
|
|
235
|
+
if (params?.pitch != null) engine.setPitch(Number(params.pitch));
|
|
236
|
+
const id = 'u' + Date.now();
|
|
237
|
+
return new Promise<void>((resolve) => {
|
|
238
|
+
engine.setOnUtteranceProgressListener(
|
|
239
|
+
new android.speech.tts.UtteranceProgressListener({
|
|
240
|
+
onStart() {},
|
|
241
|
+
onDone() { resolve(); },
|
|
242
|
+
onError() { resolve(); }, // resolve regardless — speak() promises completion, not success
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, id);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
bridge.register('speech.stop', () => { try { tts?.stop?.(); } catch { /* noop */ } });
|
|
250
|
+
|
|
251
|
+
bridge.register('speech.voices', () => {
|
|
252
|
+
const out: Array<{ id: string; name: string; lang: string }> = [];
|
|
253
|
+
try {
|
|
254
|
+
const voices = tts?.getVoices?.();
|
|
255
|
+
if (voices) {
|
|
256
|
+
const it = voices.iterator();
|
|
257
|
+
while (it.hasNext()) {
|
|
258
|
+
const v = it.next();
|
|
259
|
+
out.push({ id: String(v.getName()), name: String(v.getName()), lang: String(v.getLocale().toLanguageTag()) });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch { /* TTS not yet init — return empty */ }
|
|
263
|
+
return out;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// STT — SpeechRecognizer must be created + driven on the main (UI) thread.
|
|
267
|
+
let recognizer: any = null;
|
|
268
|
+
let listenPending: { resolve: (v: string) => void; reject: (e: any) => void } | null = null;
|
|
269
|
+
let best = '';
|
|
270
|
+
|
|
271
|
+
const settle = () => {
|
|
272
|
+
const p = listenPending; listenPending = null;
|
|
273
|
+
try { recognizer?.stopListening?.(); recognizer?.destroy?.(); } catch { /* noop */ }
|
|
274
|
+
recognizer = null;
|
|
275
|
+
p?.resolve(best);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
bridge.register('speech.stopListening', () => {
|
|
279
|
+
Utils.dispatchToMainThread(() => { if (listenPending) settle(); });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
bridge.register('speech.listen', async (params: any) => {
|
|
283
|
+
const granted = await requestPermissions(['android.permission.RECORD_AUDIO']);
|
|
284
|
+
if (!granted) throw err('DENIED', 'Microphone permission denied');
|
|
285
|
+
if (listenPending) throw err('NATIVE_ERROR', 'speech.listen: already listening');
|
|
286
|
+
|
|
287
|
+
return new Promise<string>((resolve, reject) => {
|
|
288
|
+
best = '';
|
|
289
|
+
listenPending = { resolve, reject };
|
|
290
|
+
Utils.dispatchToMainThread(() => {
|
|
291
|
+
try {
|
|
292
|
+
if (!android.speech.SpeechRecognizer.isRecognitionAvailable(ctx())) {
|
|
293
|
+
const p = listenPending; listenPending = null;
|
|
294
|
+
p?.reject(err('UNSUPPORTED', 'No speech recognition service'));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(ctx());
|
|
298
|
+
const pickText = (b: any): string => {
|
|
299
|
+
const arr = b?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION);
|
|
300
|
+
return arr && arr.size() ? String(arr.get(0)) : '';
|
|
301
|
+
};
|
|
302
|
+
recognizer.setRecognitionListener(
|
|
303
|
+
new android.speech.RecognitionListener({
|
|
304
|
+
onReadyForSpeech() {}, onBeginningOfSpeech() {}, onRmsChanged() {},
|
|
305
|
+
onBufferReceived() {}, onEndOfSpeech() {}, onEvent() {},
|
|
306
|
+
onPartialResults(b: any) {
|
|
307
|
+
if (!params?.partial) return;
|
|
308
|
+
const t = pickText(b);
|
|
309
|
+
if (t) { best = t; bridge.emit('speech.partial', { transcript: t }); }
|
|
310
|
+
},
|
|
311
|
+
onResults(b: any) {
|
|
312
|
+
const t = pickText(b);
|
|
313
|
+
if (t) best = t;
|
|
314
|
+
if (listenPending) settle();
|
|
315
|
+
},
|
|
316
|
+
onError(_code: number) {
|
|
317
|
+
if (listenPending) settle(); // resolve with best-so-far (e.g. timeout/no-match)
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
const intent = new android.content.Intent(android.speech.RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
|
|
322
|
+
intent.putExtra(
|
|
323
|
+
android.speech.RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
|
324
|
+
android.speech.RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
|
325
|
+
);
|
|
326
|
+
if (params?.lang) intent.putExtra(android.speech.RecognizerIntent.EXTRA_LANGUAGE, String(params.lang));
|
|
327
|
+
if (params?.partial) intent.putExtra(android.speech.RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
|
|
328
|
+
recognizer.startListening(intent);
|
|
329
|
+
} catch (e: any) {
|
|
330
|
+
const p = listenPending; listenPending = null;
|
|
331
|
+
p?.reject(err('NATIVE_ERROR', e?.message ?? 'listen failed'));
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
package/runtime/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -172,6 +172,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
|
|
|
172
172
|
const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
|
|
173
173
|
health: { file: './handlers-health', fn: 'registerHealthHandlers' },
|
|
174
174
|
oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
|
|
175
|
+
reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
|
|
176
|
+
scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
|
|
177
|
+
speech: { file: './handlers-speech', fn: 'registerSpeechHandlers' },
|
|
175
178
|
};
|
|
176
179
|
|
|
177
180
|
/** Generate the two composition artifacts in the wrapper: the active capability list (drives the
|
|
@@ -366,6 +369,7 @@ export const SHELL_CONFIG = {
|
|
|
366
369
|
openNewWindowsInBrowser: ${JSON.stringify(cfg.openNewWindowsInBrowser ?? false)},
|
|
367
370
|
pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
|
|
368
371
|
pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
|
|
372
|
+
pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
|
|
369
373
|
};
|
|
370
374
|
`;
|
|
371
375
|
writeFileSync(join(outDir, 'app/shell/config.ts'), content);
|
|
@@ -405,7 +409,7 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
405
409
|
}
|
|
406
410
|
if (cfg.urlScheme) {
|
|
407
411
|
extras.push(
|
|
408
|
-
` <key>CFBundleURLTypes</key>\n <array>\n <dict>\n <key>CFBundleURLName</key>\n <string>${cfg.id}</string>\n <key>CFBundleURLSchemes</key>\n <array>\n <string>${cfg.urlScheme}</string>\n </array>\n </dict>\n </array>`
|
|
412
|
+
` <key>CFBundleURLTypes</key>\n <array>\n <dict>\n <key>CFBundleTypeRole</key>\n <string>Editor</string>\n <key>CFBundleURLName</key>\n <string>${cfg.id}</string>\n <key>CFBundleURLSchemes</key>\n <array>\n <string>${cfg.urlScheme}</string>\n </array>\n </dict>\n </array>`
|
|
409
413
|
);
|
|
410
414
|
}
|
|
411
415
|
if (extras.length) {
|
|
@@ -434,7 +438,15 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
434
438
|
|
|
435
439
|
function stampTeamId(outDir: string, cfg: AppwrapConfig): void {
|
|
436
440
|
const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
|
|
437
|
-
|
|
441
|
+
// A placeholder/empty teamId silently produces an unsignable build (DEVELOPMENT_TEAM = YOUR_APPLE_TEAM_ID
|
|
442
|
+
// → xcodebuild "No Account for Team"). Warn loudly rather than let a long build fail cryptically.
|
|
443
|
+
if (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId)) {
|
|
444
|
+
console.warn(`⚠ teamId is unset/placeholder ("${cfg.teamId ?? ''}") — device builds won't sign.\n` +
|
|
445
|
+
' Set it to your Apple Team ID in appwrap.config (Xcode → Settings → Accounts shows it; ' +
|
|
446
|
+
'Individual = paid, Personal Team = free).');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (!existsSync(xcconfig)) return;
|
|
438
450
|
let src = readFileSync(xcconfig, 'utf8');
|
|
439
451
|
src = /DEVELOPMENT_TEAM\s*=/.test(src)
|
|
440
452
|
? src.replace(/DEVELOPMENT_TEAM\s*=\s*[^;\n]*;?/, `DEVELOPMENT_TEAM = ${cfg.teamId};`)
|
|
@@ -1147,7 +1159,21 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
1147
1159
|
// Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
|
|
1148
1160
|
stampShellConfig(outDir, { ...cfg, debug: true });
|
|
1149
1161
|
console.log('▶ ns build ios --for-device (debug: keep-awake + inspector on)');
|
|
1150
|
-
|
|
1162
|
+
try {
|
|
1163
|
+
execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
|
|
1164
|
+
} catch (e) {
|
|
1165
|
+
// The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
|
|
1166
|
+
console.error(
|
|
1167
|
+
'\n✖ Device build failed — if the errors above mention signing:\n' +
|
|
1168
|
+
` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
|
|
1169
|
+
' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
|
|
1170
|
+
' unique string and re-deploy.\n' +
|
|
1171
|
+
' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
|
|
1172
|
+
' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
|
|
1173
|
+
' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
|
|
1174
|
+
);
|
|
1175
|
+
process.exit(1);
|
|
1176
|
+
}
|
|
1151
1177
|
|
|
1152
1178
|
const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
|
|
1153
1179
|
const ipa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
|
package/src/config.ts
CHANGED
|
@@ -139,6 +139,10 @@ export interface AppwrapConfig {
|
|
|
139
139
|
apsEnvironment?: 'development' | 'production';
|
|
140
140
|
/** Path (relative to the PWA project) to the Firebase `google-services.json` for Android FCM. */
|
|
141
141
|
googleServicesJson?: string;
|
|
142
|
+
/** Optional backend URL the shell POSTs the device token to NATIVELY on acquisition (`{token,
|
|
143
|
+
* platform}`). Native HTTP sidesteps the WKWebView app:// cross-origin wall — the token reaches
|
|
144
|
+
* your server with no WebView fetch. Your backend stores it + sends pushes (provider-agnostic). */
|
|
145
|
+
registrationUrl?: string;
|
|
142
146
|
};
|
|
143
147
|
}
|
|
144
148
|
|