@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
|
@@ -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)
|
|
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',
|