@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 +1 -1
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +1 -1
- package/runtime/app/shell/handlers-media.ts +171 -1
- package/runtime/app/shell/mute-probe-sound.ts +6 -0
- package/src/cli.ts +95 -29
- package/templates/ci/github/workflows/appwrap-release-android.yml +1 -0
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;
|
|
@@ -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
|
|
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]
|
|
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
|