@livx.cc/appwrap 0.39.9 → 0.39.11

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.11",
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;
@@ -1231,13 +1259,45 @@ async function dev(cwd: string, flags: Record<string, string>): Promise<void> {
1231
1259
  * install when node_modules is absent — if ns later reinstalls on package.json drift it uses npm
1232
1260
  * (NativeScript has no bun package-manager mode), so trustedDependencies in the shell package.json
1233
1261
  * is what keeps the bun-installed tree behaving like npm's (parcel/watcher, ns CLI hooks). */
1262
+ /** Resolve a usable Android SDK dir: an already-valid ANDROID_HOME/ANDROID_SDK_ROOT, else the first
1263
+ * common install location that actually contains an SDK. Lets `deploy/run android` work without the
1264
+ * user having exported ANDROID_HOME in their shell (the #1 "ns can't find the SDK" foot-gun). */
1265
+ function resolveAndroidSdk(): string | undefined {
1266
+ const env = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
1267
+ if (env && existsSync(env)) return env;
1268
+ const home = process.env.HOME || '';
1269
+ const candidates = [
1270
+ join(home, 'Library/Android/sdk'), // macOS (Android Studio default)
1271
+ join(home, 'Library/Android/Sdk'),
1272
+ join(home, 'Android/Sdk'), // Linux (Android Studio default)
1273
+ '/usr/local/share/android-sdk', // homebrew cask
1274
+ '/opt/android-sdk',
1275
+ ];
1276
+ // A usable SDK has platform-tools (adb) or installed platforms; cmdline-tools-only dirs don't count.
1277
+ return candidates.find((d) => existsSync(join(d, 'platform-tools')) || existsSync(join(d, 'platforms')));
1278
+ }
1279
+
1234
1280
  function runNs(outDir: string, args: string[]): void {
1281
+ const env: NodeJS.ProcessEnv = { ...process.env };
1282
+ // Android: inject a discovered SDK so `ns` finds it even when the user's shell never exported
1283
+ // ANDROID_HOME (new terminal not sourced, conda base shell, etc.) — the common deploy blocker.
1284
+ if (args.includes('android')) {
1285
+ const sdk = resolveAndroidSdk();
1286
+ if (sdk) {
1287
+ env.ANDROID_HOME = sdk;
1288
+ env.ANDROID_SDK_ROOT = sdk;
1289
+ env.PATH = [process.env.PATH, join(sdk, 'platform-tools'), join(sdk, 'emulator')].filter(Boolean).join(':');
1290
+ if (!process.env.ANDROID_HOME) console.log(` android SDK ← ${sdk} (ANDROID_HOME not set; auto-detected)`);
1291
+ } else {
1292
+ console.warn(' ⚠ No Android SDK found (set ANDROID_HOME). Install Android Studio or the command-line tools, then `sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"`.');
1293
+ }
1294
+ }
1235
1295
  if (!existsSync(join(outDir, 'node_modules'))) {
1236
1296
  console.log(`▶ bun install (cwd: ${outDir})`);
1237
- execFileSync('bun', ['install'], { cwd: outDir, stdio: 'inherit' });
1297
+ execFileSync('bun', ['install'], { cwd: outDir, stdio: 'inherit', env });
1238
1298
  }
1239
1299
  console.log(`▶ ns ${args.join(' ')} (cwd: ${outDir})`);
1240
- execFileSync('ns', args, { cwd: outDir, stdio: 'inherit' });
1300
+ execFileSync('ns', args, { cwd: outDir, stdio: 'inherit', env });
1241
1301
  }
1242
1302
 
1243
1303
  /** Read the loader currently stamped into the generated shell (app/shell/config.ts). Used to
@@ -1711,13 +1771,19 @@ function pickDevice(devices: DeviceInfo[], explicitId?: string): DeviceInfo {
1711
1771
  return devices[idx];
1712
1772
  }
1713
1773
 
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). */
1774
+ /** `appwrap deploy <ios|android> [--device <id|name>] [--no-launch]` — build for a device, auto-pick
1775
+ * the connected phone (USB or network; prompts if several), install + launch. Debug build (no
1776
+ * distribution signing) — for testing on your own device. Run the PWA build first (or via the script).
1777
+ * iOS has a bespoke Debug-IPA path (below); Android delegates to `run` (NativeScript builds + installs
1778
+ * + launches), so the `deploy <platform>` surface is symmetric across both. */
1717
1779
  async function deploy(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1718
1780
  const platform = positionals[0];
1781
+ if (platform === 'android') {
1782
+ // Aligned surface: `deploy android` == `run android` (Android's run already does build+install+launch).
1783
+ return run(cwd, flags, positionals);
1784
+ }
1719
1785
  if (platform !== 'ios') {
1720
- console.error('Usage: appwrap deploy ios [--device <id|name>] [--no-launch] (android: use `appwrap run android`)');
1786
+ console.error('Usage: appwrap deploy <ios|android> [--device <id|name>] [--no-launch]');
1721
1787
  process.exit(1);
1722
1788
  }
1723
1789
  const cfg = await loadConfig(cwd, flags);
@@ -1998,7 +2064,7 @@ async function main(): Promise<void> {
1998
2064
  console.log('Usage: appwrap <init|sync|dev|run|build|deploy|release|submit|logs> [--config <path>] [--out native]\n' +
1999
2065
  ' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
2000
2066
  ' 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' +
2067
+ ' build <ios|android> [--release] [--aab] deploy <ios|android> [--device <id|name>] [--no-launch]\n' +
2002
2068
  ' release ios [--server-url <url>] [--env <name>] [--build-number <n>] (build+sign+upload to TestFlight)\n' +
2003
2069
  ' submit ios [--build-number <n>] [--submit-for-review] (promote the binary to the App Store; metadata stays in ASC)\n' +
2004
2070
  ' logs ios [--once] [--native] dev [--url <url> | --port <p>]');
@@ -31,6 +31,7 @@ jobs:
31
31
  - uses: actions/setup-java@v4
32
32
  with: { distribution: temurin, java-version: 17 }
33
33
  - uses: android-actions/setup-android@v3
34
+ - run: sudo apt-get update -qq && sudo apt-get install -y -qq imagemagick # appwrap icon-gen needs an image tool (no sips on Linux) — else the default NS icon ships
34
35
  - run: bun install && bun run build # → dist/ (the PWA bundle the wrapper ships)
35
36
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
36
37
  - run: npm i -g nativescript@9.0.6