@livx.cc/appwrap 0.39.9 → 0.39.10
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
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
<activity
|
|
35
35
|
android:name="com.tns.NativeScriptActivity"
|
|
36
|
-
android:label="@string/
|
|
36
|
+
android:label="@string/app_name"
|
|
37
37
|
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|locale|uiMode"
|
|
38
38
|
android:theme="@style/LaunchScreenTheme"
|
|
39
39
|
android:hardwareAccelerated="true"
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { Utils, isIOS } from '@nativescript/core';
|
|
1
|
+
import { Utils, isIOS, isAndroid } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
|
+
import { MUTE_PROBE_WAV_B64 } from './mute-probe-sound';
|
|
4
|
+
|
|
5
|
+
// AudioToolbox isn't in the bundled iOS typings (AVFoundation is), so declare the few SystemSound
|
|
6
|
+
// symbols we use. They exist at runtime — NativeScript exposes every linked framework global.
|
|
7
|
+
declare const AudioServicesCreateSystemSoundID: (url: NSURL, outId: interop.Reference<number>) => number;
|
|
8
|
+
declare const AudioServicesPlaySystemSoundWithCompletion: (id: number, completion: () => void) => void;
|
|
3
9
|
|
|
4
10
|
let routeObserverArmed = false;
|
|
5
11
|
|
|
@@ -43,6 +49,153 @@ function observeAudioRouteForSpeaker(): void {
|
|
|
43
49
|
);
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
// ── Silent / output-volume detection ────────────────────────────────────────
|
|
53
|
+
// Drives a "will sound actually be heard?" UI signal (e.g. a disabled speaker button). Apple does NOT
|
|
54
|
+
// expose the hardware mute switch, so on iOS we combine TWO signals: the media volume (AVAudioSession
|
|
55
|
+
// .outputVolume) AND the de-facto mute-switch probe — play a ~0.3s SILENT system sound and time its
|
|
56
|
+
// completion; a muted device finishes near-instantly. VOLUME is event-driven (KVO on outputVolume) so
|
|
57
|
+
// it's instant; only the switch is polled (~1s) since it has no event. On Android there's no switch:
|
|
58
|
+
// the media-stream (STREAM_MUSIC) volume is the signal, observed REACTIVELY via a ContentObserver (no
|
|
59
|
+
// polling either platform except the unavoidable iOS switch probe). Emit `media.audioState` on change.
|
|
60
|
+
const AUDIO_POLL_MS = 1000; // ~1–2s worst-case latency to reflect a switch/volume toggle; the probe sound is silent so a 1s cadence is inaudible (matches the akram/Mute default)
|
|
61
|
+
const MUTE_PROBE_THRESHOLD = 0.1; // s — a silent clip that completes faster than this means muted output
|
|
62
|
+
|
|
63
|
+
let audioTimer: ReturnType<typeof setInterval> | null = null;
|
|
64
|
+
let lastSilent: boolean | null = null;
|
|
65
|
+
let lastVolume = -1;
|
|
66
|
+
let muteSoundID = 0; // iOS SystemSoundID for the silent probe (0 = not yet created)
|
|
67
|
+
// Retain the in-flight completion closure: NativeScript may GC a JS block passed to a C API once the
|
|
68
|
+
// local goes out of scope, so without a strong ref the completion can silently never fire — which is
|
|
69
|
+
// exactly what froze the watcher after the first probe. A module ref keeps it alive until it runs.
|
|
70
|
+
let probeCompletion: (() => void) | null = null;
|
|
71
|
+
let probeStartedAt = 0;
|
|
72
|
+
let switchMuted = false; // last hardware-mute-switch reading from the probe (iOS only)
|
|
73
|
+
let volObserver: NSObject | null = null; // iOS KVO observer on AVAudioSession.outputVolume
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- NSObject.extend is a runtime augmentation, not in the strict iOS typings
|
|
75
|
+
let VolumeObserverClass: any = null; // registered once (re-extending a name crashes)
|
|
76
|
+
let volContentObserver: android.database.ContentObserver | null = null; // Android volume observer
|
|
77
|
+
|
|
78
|
+
function emitAudioState(silent: boolean, volume: number): void {
|
|
79
|
+
// Round volume so float jitter doesn't spam the bridge; emit only on a real change.
|
|
80
|
+
const v = Math.round(volume * 100) / 100;
|
|
81
|
+
if (silent === lastSilent && v === lastVolume) return;
|
|
82
|
+
lastSilent = silent;
|
|
83
|
+
lastVolume = v;
|
|
84
|
+
bridge.emit('media.audioState', { silent, volume: v });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Create (once) the iOS SystemSoundID from the embedded silent WAV, written to a temp file. */
|
|
88
|
+
function ensureMuteProbeSound(): boolean {
|
|
89
|
+
if (muteSoundID) return true;
|
|
90
|
+
try {
|
|
91
|
+
const data = NSData.alloc().initWithBase64EncodedStringOptions(MUTE_PROBE_WAV_B64, 0 as NSDataBase64DecodingOptions);
|
|
92
|
+
if (!data) return false;
|
|
93
|
+
const path = NSTemporaryDirectory() + 'appwrap-mute-probe.wav';
|
|
94
|
+
data.writeToFileAtomically(path, true);
|
|
95
|
+
const ref = new interop.Reference<number>();
|
|
96
|
+
AudioServicesCreateSystemSoundID(NSURL.fileURLWithPath(path), ref);
|
|
97
|
+
muteSoundID = ref.value || 0;
|
|
98
|
+
return !!muteSoundID;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.warn('[appwrap] mute-probe sound init failed', e);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Combine the (event-driven) volume with the (polled) switch state and emit. iOS. */
|
|
106
|
+
function emitIOS(): void {
|
|
107
|
+
const volume = AVAudioSession.sharedInstance().outputVolume;
|
|
108
|
+
emitAudioState(switchMuted || volume <= 0.0001, volume);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Probe the hardware mute switch — the ONLY way to read it (Apple exposes no event/API) — then emit. */
|
|
112
|
+
function probeSwitchIOS(): void {
|
|
113
|
+
if (!ensureMuteProbeSound()) { emitIOS(); return; }
|
|
114
|
+
if (probeCompletion && Date.now() - probeStartedAt < 1000) return; // one still pending → skip
|
|
115
|
+
probeStartedAt = Date.now();
|
|
116
|
+
const started = probeStartedAt;
|
|
117
|
+
// Retained in a module var so NativeScript can't GC the block before AudioServices calls it back.
|
|
118
|
+
probeCompletion = () => {
|
|
119
|
+
probeCompletion = null;
|
|
120
|
+
switchMuted = (Date.now() - started) / 1000 < MUTE_PROBE_THRESHOLD;
|
|
121
|
+
emitIOS();
|
|
122
|
+
};
|
|
123
|
+
AudioServicesPlaySystemSoundWithCompletion(muteSoundID, probeCompletion);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* iOS: volume is REACTIVE via KVO on AVAudioSession.outputVolume (instant — no polling). The hardware
|
|
128
|
+
* mute switch has NO OS event (Apple won't expose it), so that one axis is polled via the silent probe.
|
|
129
|
+
*/
|
|
130
|
+
function startAudioIOS(): void {
|
|
131
|
+
const session = AVAudioSession.sharedInstance();
|
|
132
|
+
try { session.setActiveError(true); } catch { /* benign — KVO still delivers */ }
|
|
133
|
+
if (!volObserver) {
|
|
134
|
+
if (!VolumeObserverClass) {
|
|
135
|
+
VolumeObserverClass = (NSObject as any).extend(
|
|
136
|
+
{
|
|
137
|
+
observeValueForKeyPathOfObjectChangeContext(keyPath: string) {
|
|
138
|
+
if (keyPath === 'outputVolume') emitIOS(); // hardware volume changed → emit immediately
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{ name: 'AppwrapAudioVolumeObserver' }
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
volObserver = VolumeObserverClass.alloc().init();
|
|
145
|
+
session.addObserverForKeyPathOptionsContext(volObserver, 'outputVolume', NSKeyValueObservingOptions.New, null);
|
|
146
|
+
}
|
|
147
|
+
probeSwitchIOS(); // initial switch read + emit
|
|
148
|
+
audioTimer = setInterval(probeSwitchIOS, AUDIO_POLL_MS);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stopAudioIOS(): void {
|
|
152
|
+
if (audioTimer) { clearInterval(audioTimer); audioTimer = null; }
|
|
153
|
+
if (volObserver) {
|
|
154
|
+
try { AVAudioSession.sharedInstance().removeObserverForKeyPath(volObserver, 'outputVolume'); } catch { /* already gone */ }
|
|
155
|
+
volObserver = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Android: read the media-stream (STREAM_MUSIC) volume — 0 = silent — and emit. */
|
|
160
|
+
function readAudioAndroid(): void {
|
|
161
|
+
try {
|
|
162
|
+
const ctx = Utils.android.getApplicationContext();
|
|
163
|
+
const am = ctx.getSystemService(android.content.Context.AUDIO_SERVICE) as android.media.AudioManager;
|
|
164
|
+
const max = am.getStreamMaxVolume(android.media.AudioManager.STREAM_MUSIC) || 1;
|
|
165
|
+
const cur = am.getStreamVolume(android.media.AudioManager.STREAM_MUSIC);
|
|
166
|
+
emitAudioState(cur === 0, cur / max);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.warn('[appwrap] android audio read failed', e);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Android: REACTIVE — a ContentObserver on system settings fires on every volume change (no polling). */
|
|
173
|
+
function startAudioAndroid(): void {
|
|
174
|
+
try {
|
|
175
|
+
const ctx = Utils.android.getApplicationContext();
|
|
176
|
+
const handler = new android.os.Handler(android.os.Looper.getMainLooper());
|
|
177
|
+
// extend lazily — the android.* globals only exist on the Android load path
|
|
178
|
+
const ObserverClass = (android.database.ContentObserver as any).extend({
|
|
179
|
+
onChange(_selfChange: boolean) { readAudioAndroid(); },
|
|
180
|
+
});
|
|
181
|
+
volContentObserver = new ObserverClass(handler);
|
|
182
|
+
ctx.getContentResolver().registerContentObserver(
|
|
183
|
+
android.provider.Settings.System.CONTENT_URI, true, volContentObserver
|
|
184
|
+
);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.warn('[appwrap] android volume observer failed', e);
|
|
187
|
+
}
|
|
188
|
+
readAudioAndroid(); // initial state
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stopAudioAndroid(): void {
|
|
192
|
+
if (volContentObserver) {
|
|
193
|
+
try { Utils.android.getApplicationContext().getContentResolver().unregisterContentObserver(volContentObserver); }
|
|
194
|
+
catch { /* already gone */ }
|
|
195
|
+
volContentObserver = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
46
199
|
/**
|
|
47
200
|
* Live-media session config. getUserMedia / playback themselves are pure WebView
|
|
48
201
|
* APIs (unlocked by the UIDelegate / onPermissionRequest grants in the custom
|
|
@@ -117,4 +270,21 @@ export function registerMediaHandlers(): void {
|
|
|
117
270
|
session.setCategoryModeOptionsError(category, avMode, options as AVAudioSessionCategoryOptions);
|
|
118
271
|
session.setActiveError(true);
|
|
119
272
|
});
|
|
273
|
+
|
|
274
|
+
// Start streaming `media.audioState` { silent, volume } events. iOS: volume is event-driven (KVO),
|
|
275
|
+
// the switch is polled (no OS event). Android: media-stream volume polled. Idempotent; emits the
|
|
276
|
+
// current state immediately so the page doesn't wait for the first change.
|
|
277
|
+
bridge.register('media.audioWatch.start', () => {
|
|
278
|
+
if (audioTimer || volObserver || volContentObserver) return;
|
|
279
|
+
lastSilent = null; lastVolume = -1; // force the first emit
|
|
280
|
+
if (isIOS) startAudioIOS();
|
|
281
|
+
else if (isAndroid) startAudioAndroid();
|
|
282
|
+
// unsupported platform → no events (page treats absence as "audible")
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
bridge.register('media.audioWatch.stop', () => {
|
|
286
|
+
if (isIOS) stopAudioIOS();
|
|
287
|
+
else if (isAndroid) stopAudioAndroid();
|
|
288
|
+
lastSilent = null; lastVolume = -1;
|
|
289
|
+
});
|
|
120
290
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// AUTO-GENERATED. A ~0.3s mono 8kHz 16-bit SILENT WAV, base64-encoded. Used by the iOS hardware
|
|
2
|
+
// mute-switch detector in handlers-media.ts: we play this as a System Sound and time its completion —
|
|
3
|
+
// a muted device finishes near-instantly (<~0.1s), an audible one takes the full clip length. Kept as
|
|
4
|
+
// a code constant (no asset-bundling step) and written to a temp file at runtime for AudioServices.
|
|
5
|
+
export const MUTE_PROBE_WAV_B64 =
|
|
6
|
+
'UklGRuQSAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YcASAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
package/src/cli.ts
CHANGED
|
@@ -741,12 +741,17 @@ function stampFcmService(outDir: string, on: boolean): void {
|
|
|
741
741
|
}
|
|
742
742
|
|
|
743
743
|
function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
744
|
+
// Write res/values/strings.xml defining app_name = cfg.name. The NS template ships NO strings.xml
|
|
745
|
+
// (the default app_name/activity title resolve to "native" from @nativescript/core), so a regex
|
|
746
|
+
// replace was a no-op and the launcher showed "native". Write the file so app_name is authoritative
|
|
747
|
+
// (the manifest's <application> AND launcher <activity> both label off @string/app_name). XML-escape.
|
|
748
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
749
|
+
const stringsDir = join(outDir, 'App_Resources/Android/src/main/res/values');
|
|
750
|
+
mkdirSync(stringsDir, { recursive: true });
|
|
751
|
+
writeFileSync(
|
|
752
|
+
join(stringsDir, 'strings.xml'),
|
|
753
|
+
`<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <string name="app_name">${esc(cfg.name)}</string>\n <string name="title_activity_kimera">${esc(cfg.name)}</string>\n</resources>\n`
|
|
754
|
+
);
|
|
750
755
|
const manifest = join(outDir, 'App_Resources/Android/src/main/AndroidManifest.xml');
|
|
751
756
|
if (existsSync(manifest)) {
|
|
752
757
|
let src = readFileSync(manifest, 'utf8');
|
|
@@ -822,29 +827,53 @@ function findMaskableSource(cwd: string, cfg: AppwrapConfig): string | null {
|
|
|
822
827
|
return findIconSource(cwd, cfg);
|
|
823
828
|
}
|
|
824
829
|
|
|
825
|
-
/**
|
|
830
|
+
/** Square-resize/probe abstraction over `sips` (macOS) or ImageMagick (`magick` v7 / `convert` v6),
|
|
831
|
+
* so icon generation works on Linux/CI too — not just macOS. Returns null if neither tool is present. */
|
|
832
|
+
function imageRasterizer(): { width: (src: string) => number; resize: (src: string, px: number, dest: string) => void } | null {
|
|
833
|
+
const has = (cmd: string) => {
|
|
834
|
+
try { execFileSync('which', [cmd], { stdio: 'ignore' }); return true; } catch { return false; }
|
|
835
|
+
};
|
|
836
|
+
if (has('sips')) {
|
|
837
|
+
return {
|
|
838
|
+
width: (src) => parseInt(execFileSync('sips', ['-g', 'pixelWidth', src]).toString().match(/(\d+)\s*$/)?.[1] ?? '0', 10),
|
|
839
|
+
resize: (src, px, dest) => execFileSync('sips', ['-z', String(px), String(px), src, '--out', dest], { stdio: 'ignore' }),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
const magick = has('magick') ? 'magick' : has('convert') ? 'convert' : null;
|
|
843
|
+
if (magick) {
|
|
844
|
+
// v7: `magick identify` / `magick <src> -resize`. v6: `identify` / `convert <src> -resize`. `!` forces exact (square) dims.
|
|
845
|
+
return {
|
|
846
|
+
width: (src) => {
|
|
847
|
+
const args = magick === 'magick' ? ['identify', '-format', '%w', src] : ['-format', '%w', src];
|
|
848
|
+
const bin = magick === 'magick' ? 'magick' : 'identify';
|
|
849
|
+
return parseInt(execFileSync(bin, args).toString().trim() || '0', 10);
|
|
850
|
+
},
|
|
851
|
+
resize: (src, px, dest) => execFileSync(magick, [src, '-resize', `${px}x${px}!`, dest], { stdio: 'ignore' }),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Generate iOS appiconset + Android mipmaps from the PWA's icon (via sips on macOS or ImageMagick on CI). */
|
|
826
858
|
function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
|
|
827
859
|
const source = findIconSource(cwd, cfg);
|
|
828
860
|
if (!source) {
|
|
829
861
|
console.warn('⚠ No app icon source found (manifest icons or config `icon`) — keeping template icons');
|
|
830
862
|
return;
|
|
831
863
|
}
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
// the
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
console.warn('⚠
|
|
864
|
+
// Pick an image rasterizer: `sips` (macOS) OR ImageMagick (`magick` v7 / `convert` v6) so icons are
|
|
865
|
+
// generated on Linux/CI too (GitHub ubuntu runners ship ImageMagick) — NOT just macOS. Previously CI
|
|
866
|
+
// skipped icon gen entirely → the default NativeScript "N" icon shipped. If NEITHER tool exists, keep
|
|
867
|
+
// the template icons rather than failing the build.
|
|
868
|
+
const ras = imageRasterizer();
|
|
869
|
+
if (!ras) {
|
|
870
|
+
console.warn('⚠ no image tool (sips / ImageMagick) — keeping template icons; install ImageMagick or build on macOS');
|
|
839
871
|
return;
|
|
840
872
|
}
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
const w = probe('pixelWidth');
|
|
844
|
-
if (w < 512) console.warn(`⚠ Icon source is ${w}px — below the 512px App Store minimum (using it anyway)`);
|
|
873
|
+
const w = ras.width(source);
|
|
874
|
+
if (w && w < 512) console.warn(`⚠ Icon source is ${w}px — below the 512px App Store minimum (using it anyway)`);
|
|
845
875
|
|
|
846
|
-
const resize = (px: number, dest: string) =>
|
|
847
|
-
execFileSync('sips', ['-z', String(px), String(px), source, '--out', dest], { stdio: 'ignore' });
|
|
876
|
+
const resize = (px: number, dest: string) => ras.resize(source, px, dest);
|
|
848
877
|
|
|
849
878
|
const iconset = join(outDir, 'App_Resources/iOS/Assets.xcassets/AppIcon.appiconset');
|
|
850
879
|
if (existsSync(iconset)) {
|
|
@@ -870,8 +899,7 @@ function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
|
|
|
870
899
|
const ADAPTIVE_DP = 108;
|
|
871
900
|
const adaptiveXml = join(res, 'mipmap-anydpi-v26/ic_launcher.xml');
|
|
872
901
|
if (fgSource && existsSync(adaptiveXml)) {
|
|
873
|
-
const resizeFrom = (src: string, px: number, dest: string) =>
|
|
874
|
-
execFileSync('sips', ['-z', String(px), String(px), src, '--out', dest], { stdio: 'ignore' });
|
|
902
|
+
const resizeFrom = (src: string, px: number, dest: string) => ras.resize(src, px, dest);
|
|
875
903
|
for (const [density, baseline] of Object.entries(ANDROID_DENSITIES)) {
|
|
876
904
|
const dir = join(res, `mipmap-${density}`);
|
|
877
905
|
if (!existsSync(dir)) continue;
|
|
@@ -1711,13 +1739,19 @@ function pickDevice(devices: DeviceInfo[], explicitId?: string): DeviceInfo {
|
|
|
1711
1739
|
return devices[idx];
|
|
1712
1740
|
}
|
|
1713
1741
|
|
|
1714
|
-
/** `appwrap deploy ios [--device <id|name>] [--no-launch]` — build for device, auto-pick
|
|
1715
|
-
* connected phone (USB or network; prompts if several), install + launch. Debug build (no
|
|
1716
|
-
* distribution signing) — for testing on your own device. Run the PWA build first (or via the script).
|
|
1742
|
+
/** `appwrap deploy <ios|android> [--device <id|name>] [--no-launch]` — build for a device, auto-pick
|
|
1743
|
+
* the connected phone (USB or network; prompts if several), install + launch. Debug build (no
|
|
1744
|
+
* distribution signing) — for testing on your own device. Run the PWA build first (or via the script).
|
|
1745
|
+
* iOS has a bespoke Debug-IPA path (below); Android delegates to `run` (NativeScript builds + installs
|
|
1746
|
+
* + launches), so the `deploy <platform>` surface is symmetric across both. */
|
|
1717
1747
|
async function deploy(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
|
|
1718
1748
|
const platform = positionals[0];
|
|
1749
|
+
if (platform === 'android') {
|
|
1750
|
+
// Aligned surface: `deploy android` == `run android` (Android's run already does build+install+launch).
|
|
1751
|
+
return run(cwd, flags, positionals);
|
|
1752
|
+
}
|
|
1719
1753
|
if (platform !== 'ios') {
|
|
1720
|
-
console.error('Usage: appwrap deploy ios [--device <id|name>] [--no-launch]
|
|
1754
|
+
console.error('Usage: appwrap deploy <ios|android> [--device <id|name>] [--no-launch]');
|
|
1721
1755
|
process.exit(1);
|
|
1722
1756
|
}
|
|
1723
1757
|
const cfg = await loadConfig(cwd, flags);
|
|
@@ -1998,7 +2032,7 @@ async function main(): Promise<void> {
|
|
|
1998
2032
|
console.log('Usage: appwrap <init|sync|dev|run|build|deploy|release|submit|logs> [--config <path>] [--out native]\n' +
|
|
1999
2033
|
' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
|
|
2000
2034
|
' run <ios|android> [--device <id|name>] (compile + boot in a simulator/emulator, live reload)\n' +
|
|
2001
|
-
' build <ios|android> [--release] [--aab] deploy ios [--device <id|name>] [--no-launch]\n' +
|
|
2035
|
+
' build <ios|android> [--release] [--aab] deploy <ios|android> [--device <id|name>] [--no-launch]\n' +
|
|
2002
2036
|
' release ios [--server-url <url>] [--env <name>] [--build-number <n>] (build+sign+upload to TestFlight)\n' +
|
|
2003
2037
|
' submit ios [--build-number <n>] [--submit-for-review] (promote the binary to the App Store; metadata stays in ASC)\n' +
|
|
2004
2038
|
' logs ios [--once] [--native] dev [--url <url> | --port <p>]');
|