@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.39.9",
3
+ "version": "0.39.10",
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",
@@ -33,7 +33,7 @@
33
33
 
34
34
  <activity
35
35
  android:name="com.tns.NativeScriptActivity"
36
- android:label="@string/title_activity_kimera"
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
- const strings = join(outDir, 'App_Resources/Android/src/main/res/values/strings.xml');
745
- if (existsSync(strings)) {
746
- let src = readFileSync(strings, 'utf8');
747
- src = src.replace(/(<string name="app_name">)[^<]*(<\/string>)/, `$1${cfg.name}$2`);
748
- writeFileSync(strings, src);
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
- /** Generate iOS appiconset + Android mipmaps from the PWA's icon via sips (macOS). */
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
- // Icon rasterization uses `sips`, which is macOS-only. On Linux/CI (e.g. a GitHub Actions Android
833
- // build) it's absent — skip gracefully and keep the template icons rather than throwing and failing
834
- // the whole init/build. Regenerate real icons on macOS for store builds.
835
- try {
836
- execFileSync('which', ['sips'], { stdio: 'ignore' });
837
- } catch {
838
- console.warn('⚠ `sips` unavailable (non-macOS host) — keeping template icons; regenerate on macOS for store builds');
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 probe = (prop: string) =>
842
- parseInt(execFileSync('sips', ['-g', prop, source]).toString().match(/(\d+)\s*$/)?.[1] ?? '0', 10);
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 the
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] (android: use `appwrap run android`)');
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>]');