@livx.cc/appwrap 0.60.1 → 0.61.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.60.1",
3
+ "version": "0.61.1",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -54,6 +54,10 @@ export const SHELL_CONFIG = {
54
54
  * WebView — regular-native-app behavior. Same-origin SPA navigation is untouched. Off by default.
55
55
  * See `externalNavGuardJs`. */
56
56
  openNewWindowsInBrowser: false,
57
+ /** Android only. Lock the WebView's text zoom to 100% so the device's system font-size setting
58
+ * doesn't scale text inside the app. Default true (native-app behavior). False = respect the
59
+ * user's OS font-size preference. No effect on iOS (WKWebView ignores Dynamic Type). */
60
+ androidLockTextZoom: true,
57
61
  /** Remote push configured, per platform (iOS aps-environment entitlement / Android FCM). Drives the
58
62
  * `push` capability flag at runtime by platform — off unless `appwrap.json.push` enables it, so an
59
63
  * un-provisioned build honestly reports 'none' (and a personal-team iOS build keeps `pushIos:false`). */
@@ -168,6 +168,9 @@ export class CustomWebView extends WebView {
168
168
  settings.setSupportZoom(false);
169
169
  settings.setBuiltInZoomControls(false);
170
170
  settings.setDisplayZoomControls(false);
171
+ // Lock text zoom to 100% so Android system font scaling doesn't inflate WebView text.
172
+ // Controlled by appwrap config `androidLockTextZoom` (default true — native-app behavior).
173
+ if (SHELL_CONFIG.androidLockTextZoom !== false) settings.setTextZoom(100);
171
174
  // No rubber-band overscroll glow.
172
175
  wv.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER);
173
176
 
@@ -5,6 +5,7 @@ import { geoAuthAction } from './geo-auth';
5
5
  import { onRemoteMessage } from './handlers-push';
6
6
  import { uiImageToDataUrl } from './ios-image';
7
7
  import { notifIdentity, type NotifIdentity } from './notif-identity';
8
+ import { resolveSoundName } from './notif-sound';
8
9
  import { maskForLock, setIosOrientationMask } from './orientation';
9
10
 
10
11
  interface GeoResult { lat: number; lng: number; accuracy: number; }
@@ -227,10 +228,14 @@ export function registerExtendedHandlers(): void {
227
228
  });
228
229
  });
229
230
 
230
- bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon, badge, silent }: { id?: number; title?: string; body?: string; delaySec?: number; deepLink?: string; sender?: string; icon?: string; badge?: number; silent?: boolean }) => {
231
+ bridge.register('notifications.schedule', async ({ id, title, body, delaySec, deepLink, sender, icon, badge, silent, sound }: { id?: number; title?: string; body?: string; delaySec?: number; deepLink?: string; sender?: string; icon?: string; badge?: number; silent?: boolean; sound?: string }) => {
231
232
  if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
232
233
  const nid = id ?? Math.floor(Math.random() * 100000);
233
234
  const ident = notifIdentity({ title, body, sender, icon });
235
+ // A custom sound is a FILE the OS reads, never a URL it fetches — resolve (download + transcode +
236
+ // cache) before building the content. Null means "unusable", and the default alert takes over
237
+ // below: the app rings with the wrong sound rather than not at all.
238
+ const soundName = !silent && sound ? await resolveSoundName(String(sound)) : null;
234
239
  return new Promise((resolve, reject) => {
235
240
  const content = UNMutableNotificationContent.new();
236
241
  content.title = ident.title;
@@ -244,7 +249,11 @@ export function registerExtendedHandlers(): void {
244
249
  // is IMPORTANCE_DEFAULT), so the platforms disagreed on the same call. Default to the system
245
250
  // alert sound and let a caller opt out with `silent: true`; set BEFORE the communication-style
246
251
  // wrap so contentByUpdatingWithProvider carries it through.
247
- if (!silent) content.sound = UNNotificationSound.defaultSound;
252
+ if (!silent) {
253
+ content.sound = soundName
254
+ ? UNNotificationSound.soundNamed(soundName)
255
+ : UNNotificationSound.defaultSound;
256
+ }
248
257
  // Group per-sender so the mini-app's notifications thread together (iOS 15+ also
249
258
  // uses this alongside the communication-style avatar below).
250
259
  if (ident.useIdentity && ident.senderName) content.threadIdentifier = ident.senderName;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Custom notification sounds on iOS — turn a remote audio URL into something the OS will actually
3
+ * ring.
4
+ *
5
+ * WHY THIS IS NOT JUST "pass the url": iOS never fetches a notification sound. `UNNotificationSound`
6
+ * resolves a FILE NAME against the app bundle and the app container's `Library/Sounds/` directory,
7
+ * and it will only play LinearPCM/MA4/µ-law/a-law wrapped in caf/aiff/wav, ≤30s. An mp3 from a sound
8
+ * library — which is what a media search hands you — satisfies none of that, so a `sound: <url>`
9
+ * option that merely forwards the string is a lie that fails silently at delivery time.
10
+ *
11
+ * So: download once, transcode with AVAudioFile (AVFoundation decodes mp3/ogg/wav on the way in and
12
+ * writes LinearPCM on the way out — no ffmpeg, no server round-trip), trim to the OS ceiling, and
13
+ * cache under a hash of the URL so the second notification is instant.
14
+ *
15
+ * Every failure path returns null, and the caller falls back to the DEFAULT alert sound — a wrong
16
+ * ring is a cosmetic miss, but silence is the bug this whole lane exists to fix.
17
+ */
18
+ import { sha256Hex } from './sha256';
19
+
20
+ // CoreAudioTypes isn't in the bundled iOS typings (AVFoundation is), so spell the one constant we
21
+ // need: kAudioFormatLinearPCM is the four-char code 'lpcm'. Declaring the literal beats referencing a
22
+ // whole framework's d.ts for a single integer.
23
+ const kAudioFormatLinearPCM = 0x6c70636d; // 'lpcm'
24
+
25
+ /** The OS ceiling for a notification sound. Longer audio is truncated, not rejected. */
26
+ const MAX_SECONDS = 30;
27
+ /** A sound is a one-shot alert, not a download: give up rather than hold the schedule call open. */
28
+ const FETCH_TIMEOUT_MS = 15_000;
29
+
30
+ /** `<app container>/Library/Sounds`, created on demand — the only writable dir UNNotificationSound reads. */
31
+ function soundsDir(): string | null {
32
+ const libs = NSSearchPathForDirectoriesInDomains(
33
+ NSSearchPathDirectory.LibraryDirectory, NSSearchPathDomainMask.UserDomainMask, true
34
+ );
35
+ if (!libs || libs.count === 0) return null;
36
+ const dir = `${libs.objectAtIndex(0)}/Sounds`;
37
+ const fm = NSFileManager.defaultManager;
38
+ if (!fm.fileExistsAtPath(dir)) {
39
+ fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(dir, true, null);
40
+ }
41
+ return fm.fileExistsAtPath(dir) ? dir : null;
42
+ }
43
+
44
+ /** Download to a temp file. Resolves null on any transport failure (offline, 404, timeout). */
45
+ function download(url: string): Promise<string | null> {
46
+ return new Promise((resolve) => {
47
+ const nsUrl = NSURL.URLWithString(url);
48
+ if (!nsUrl) return resolve(null);
49
+ const cfg = NSURLSessionConfiguration.defaultSessionConfiguration;
50
+ cfg.timeoutIntervalForRequest = FETCH_TIMEOUT_MS / 1000;
51
+ const session = NSURLSession.sessionWithConfiguration(cfg);
52
+ const task = session.downloadTaskWithURLCompletionHandler(nsUrl, (tmp, response, error) => {
53
+ const status = (response as NSHTTPURLResponse)?.statusCode ?? 0;
54
+ if (error || !tmp || (status && (status < 200 || status >= 300))) {
55
+ console.warn(`[appwrap] notification sound download failed (${status || error?.localizedDescription})`);
56
+ return resolve(null);
57
+ }
58
+ // The temp file is deleted the moment this handler returns, so copy it out synchronously.
59
+ const keep = `${NSTemporaryDirectory()}appwrap-sound-${Date.now()}`;
60
+ const fm = NSFileManager.defaultManager;
61
+ if (fm.fileExistsAtPath(keep)) fm.removeItemAtPathError(keep, null);
62
+ const ok = fm.copyItemAtPathToPathError(tmp.path, keep, null);
63
+ resolve(ok ? keep : null);
64
+ });
65
+ task.resume();
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Decode `srcPath` (any format AVFoundation reads) and write ≤30s of LinearPCM to `destPath` (.caf).
71
+ * Returns false if either end refuses the file — a format AVFoundation can't open, or a settings
72
+ * combination the writer rejects.
73
+ */
74
+ function transcodeToCaf(srcPath: string, destPath: string): boolean {
75
+ try {
76
+ const src = AVAudioFile.alloc().initForReadingError(NSURL.fileURLWithPath(srcPath), null);
77
+ if (!src) return false;
78
+ const format = src.processingFormat;
79
+ const frames = Math.min(Number(src.length), Math.floor(format.sampleRate * MAX_SECONDS));
80
+ if (frames <= 0) return false;
81
+
82
+ // Write settings mirror the source's rate/channels but force 16-bit LinearPCM — the writer
83
+ // infers the container from the .caf extension.
84
+ const settings = NSMutableDictionary.dictionaryWithDictionary(format.settings as never);
85
+ settings.setObjectForKey(kAudioFormatLinearPCM, 'AVFormatIDKey');
86
+ settings.setObjectForKey(16, 'AVLinearPCMBitDepthKey');
87
+ settings.setObjectForKey(false, 'AVLinearPCMIsFloatKey');
88
+ const fm = NSFileManager.defaultManager;
89
+ if (fm.fileExistsAtPath(destPath)) fm.removeItemAtPathError(destPath, null);
90
+ const dest = AVAudioFile.alloc().initForWritingSettingsError(
91
+ NSURL.fileURLWithPath(destPath), settings as never, null
92
+ );
93
+ if (!dest) return false;
94
+
95
+ // Stream in chunks: a 30s buffer at 48kHz stereo is ~11MB, and a notification sound is not worth
96
+ // that resident all at once.
97
+ const chunk = Math.floor(format.sampleRate); // ~1s
98
+ let written = 0;
99
+ while (written < frames) {
100
+ const want = Math.min(chunk, frames - written);
101
+ const buf = AVAudioPCMBuffer.alloc().initWithPCMFormatFrameCapacity(format, want);
102
+ if (!buf) return false;
103
+ if (!src.readIntoBufferFrameCountError(buf, want, null)) break;
104
+ if (buf.frameLength === 0) break;
105
+ if (!dest.writeFromBufferError(buf, null)) return false;
106
+ written += buf.frameLength;
107
+ }
108
+ return written > 0 && fm.fileExistsAtPath(destPath);
109
+ } catch (e) {
110
+ console.warn('[appwrap] notification sound transcode failed:', String(e));
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Resolve a `sound` option to the FILE NAME UNNotificationSound wants, or null to fall back to the
117
+ * default alert. A bare name (no scheme) is passed through untouched — that's a sound the app already
118
+ * ships in its bundle, and it must not be treated as a URL.
119
+ */
120
+ export async function resolveSoundName(sound: string): Promise<string | null> {
121
+ const value = String(sound ?? '').trim();
122
+ if (!value) return null;
123
+ if (!/^https?:\/\//i.test(value)) return value;
124
+
125
+ const dir = soundsDir();
126
+ if (!dir) return null;
127
+ const name = `appwrap-${sha256Hex(value).slice(0, 32)}.caf`;
128
+ const dest = `${dir}/${name}`;
129
+ // Cached from an earlier schedule — the common case once a timer app has fired once.
130
+ if (NSFileManager.defaultManager.fileExistsAtPath(dest)) return name;
131
+
132
+ const src = await download(value);
133
+ if (!src) return null;
134
+ const ok = transcodeToCaf(src, dest);
135
+ NSFileManager.defaultManager.removeItemAtPathError(src, null);
136
+ if (!ok) console.warn('[appwrap] notification sound unusable, falling back to the default alert');
137
+ return ok ? name : null;
138
+ }
package/src/cli.ts CHANGED
@@ -948,6 +948,7 @@ export const SHELL_CONFIG = {
948
948
  neutralizeServiceWorker: ${JSON.stringify(cfg.neutralizeServiceWorker ?? true)},
949
949
  appBoundDomains: ${JSON.stringify(cfg.appBoundDomains ?? [])} as string[],
950
950
  openNewWindowsInBrowser: ${JSON.stringify(cfg.openNewWindowsInBrowser ?? false)},
951
+ androidLockTextZoom: ${JSON.stringify(cfg.androidLockTextZoom ?? true)},
951
952
  pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
952
953
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
953
954
  pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
package/src/config.ts CHANGED
@@ -239,6 +239,11 @@ export interface AppwrapConfig {
239
239
  * same-origin SPA navigation, subframes/iframes and `tel:`/`mailto:` are left untouched. Default
240
240
  * `false` (unchanged in-WebView navigation). See `externalNavGuardJs`. */
241
241
  openNewWindowsInBrowser?: boolean;
242
+ /** Android only. Lock the WebView's text zoom to 100% so the device's system font-size setting
243
+ * doesn't scale text inside the app. Default `true` — the app controls its own sizing (native-app
244
+ * behavior). Set `false` to let the WebView respect the user's OS-level font-size preference
245
+ * (accessibility). Has no effect on iOS — WKWebView does not apply Dynamic Type to web content. */
246
+ androidLockTextZoom?: boolean;
242
247
  /** Apple Development Team ID for device builds (Xcode → Settings → Accounts). */
243
248
  teamId?: string;
244
249
  /** Path (relative to the PWA project) to a StoreKit configuration file for LOCAL IAP
@@ -383,7 +388,7 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
383
388
  export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
384
389
  'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'ci', 'debug',
385
390
  'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'modulePacks', 'name',
386
- 'envSwitcher', 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
391
+ 'androidLockTextZoom', 'envSwitcher', 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
387
392
  'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle', 'store',
388
393
  'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
389
394
  'usesNonExemptEncryption', 'vendorPaths', 'version',