@livx.cc/appwrap 0.61.9 → 0.61.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.61.9",
3
+ "version": "0.61.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",
@@ -83,9 +83,11 @@ export const MODULES: ModuleManifest[] = [
83
83
  // `shareFiles` hands a file to the OS sheet, and one of the activities the sheet offers for a
84
84
  // photo or video is "Save Image"/"Save Video" — which WRITES to the camera roll. iOS omits that
85
85
  // activity SILENTLY when the app carries no add-only photo-library usage string, so the sheet
86
- // appears correct and simply lacks the one row the person was reaching for.
86
+ // appears correct and simply lacks the one row the person was reaching for. `saveToPhotos` writes
87
+ // straight to the library under that SAME add-only string — why it lives here and not in `photos`
88
+ // (which is opt-in and asks for READ access).
87
89
  {
88
- name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native' },
90
+ name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native', saveToPhotos: 'native' },
89
91
  ios: { permissions: [{ key: 'NSPhotoLibraryAddUsageDescription', domain: 'photosAdd', defaultUsage: 'Save a photo or video you chose to your library.' }] },
90
92
  },
91
93
  { name: 'storage', core: true, group: 'core', capabilities: { storage: 'native', secureStorage: 'native' } },
@@ -4,12 +4,15 @@ import { bridge } from './bridge';
4
4
  import { connectivityStatus } from './handlers-extended';
5
5
  import { isEnvDeepLink, handleEnvDeepLink } from './env-switcher';
6
6
 
7
+ /** A notification-tap payload as the PWA receives it (`parseApnsPayload` / the FCM intent extras). */
8
+ export type PushTapPayload = { data: Record<string, unknown>; title?: string; body?: string };
9
+
7
10
  let pendingDeepLink: string | null = null;
8
11
  // A cold-launch env-switch deep link (`<scheme>://env?url=…`). Buffered like `pendingDeepLink` until the
9
12
  // PWA handshake, so the confirm dialog + WebView reload run when the shell is actually ready (not during
10
13
  // iOS didFinishLaunching / the Android launch-intent read, when no WebView exists yet).
11
14
  let pendingEnvDeepLink: string | null = null;
12
- let pendingPushTap: { data: Record<string, string> } | null = null;
15
+ let pendingPushTap: PushTapPayload | null = null;
13
16
  let pendingShortcut: string | null = null;
14
17
  // True only once the PWA's JS has handshaked — i.e. the WebView is actually
15
18
  // running our bundle and about to subscribe. Native page-load is too early:
@@ -81,10 +84,15 @@ export function onShortcut(id: string): void {
81
84
  else pendingShortcut = id;
82
85
  }
83
86
 
84
- /** Android: a tray notification (FCM) was tapped → re-launched the activity with the data payload as
85
- * intent extras. Buffered until handshake like deep links (cold-start-from-notification). iOS routes
86
- * taps via the AppDelegate (handlers-push onRemoteMessage). */
87
- export function onPushTap(payload: { data: Record<string, string> }): void {
87
+ /** A tray notification was tapped → the app was re-launched (or foregrounded) with the payload.
88
+ * Buffered until the handshake like deep links, because a cold start from a notification fires this
89
+ * before the WebView is running our bundle — emit it raw and the route is lost, the PWA boots at `/`
90
+ * and an authed user lands on the app's home list instead of the conversation.
91
+ *
92
+ * BOTH platforms route through here: Android from the launch intent, iOS from the
93
+ * UNUserNotificationCenter delegate (handlers-push `onRemoteMessage`, tapped=true). iOS used to
94
+ * `bridge.emit` directly and so had no cold-start buffering at all. */
95
+ export function onPushTap(payload: PushTapPayload): void {
88
96
  if (pwaReady) bridge.emit('push.tap', payload);
89
97
  else pendingPushTap = payload;
90
98
  }
@@ -2,6 +2,7 @@ import { Application, Utils } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { requestPermissions, startActivityForResult, uriToDataUrl, bitmapToDataUrl } from './android-helpers';
4
4
  import { notifIdentity, notifActions } from './notif-identity';
5
+ import { photoAssetKind } from './share-outcome';
5
6
 
6
7
  // no NS types: android-32 typings omit ContactsContract/MediaStore column + ACTION_PICK_IMAGES constants this file reads
7
8
  declare const android: any, androidx: any;
@@ -585,6 +586,40 @@ export function registerAndroidHandlers(): void {
585
586
  ctx.startActivity(chooser);
586
587
  });
587
588
 
589
+ // ── save to Photos (MediaStore insert into Pictures/ or Movies/) ─────
590
+ // API 29+ scoped storage needs NO permission to add media the app creates. Older devices would need
591
+ // WRITE_EXTERNAL_STORAGE, which the template does not request — reported as unsupported, not faked.
592
+ bridge.register('share.saveToPhotos', ({ name, mimeType, base64 }: { name?: string; mimeType?: string; base64: string }) => {
593
+ const kind = photoAssetKind(mimeType, name);
594
+ if (!kind) return { saved: false, reason: 'unsupported', message: 'only photos and videos can be saved to Photos' };
595
+ if (android.os.Build.VERSION.SDK_INT < 29) return { saved: false, reason: 'unsupported', message: 'needs Android 10 or later' };
596
+ const MS = android.provider.MediaStore;
597
+ const resolver = context().getContentResolver();
598
+ const collection = kind === 'video'
599
+ ? MS.Video.Media.getContentUri(MS.VOLUME_EXTERNAL_PRIMARY)
600
+ : MS.Images.Media.getContentUri(MS.VOLUME_EXTERNAL_PRIMARY);
601
+ const values = new android.content.ContentValues();
602
+ values.put('_display_name', String(name || (kind === 'video' ? 'video.mp4' : 'photo.jpg')));
603
+ if (mimeType) values.put('mime_type', String(mimeType));
604
+ values.put('relative_path', kind === 'video' ? 'Movies' : 'Pictures');
605
+ values.put('is_pending', new java.lang.Integer(1));
606
+ let uri: any = null;
607
+ try {
608
+ uri = resolver.insert(collection, values);
609
+ if (!uri) return { saved: false, reason: 'failed', message: 'MediaStore refused the file' };
610
+ const out = resolver.openOutputStream(uri);
611
+ out.write(android.util.Base64.decode(base64 ?? '', android.util.Base64.DEFAULT));
612
+ out.close();
613
+ const done = new android.content.ContentValues();
614
+ done.put('is_pending', new java.lang.Integer(0));
615
+ resolver.update(uri, done, null, null);
616
+ return { saved: true };
617
+ } catch (e) {
618
+ if (uri) try { resolver.delete(uri, null, null); } catch { /* best-effort cleanup */ }
619
+ return { saved: false, reason: 'failed', message: String((e as Error)?.message ?? e) };
620
+ }
621
+ });
622
+
588
623
  // ── screen orientation (Activity.setRequestedOrientation) ──────────
589
624
  const SO = android.content.pm.ActivityInfo;
590
625
  const requestedOrientation = (o: string): number => {
@@ -1,6 +1,7 @@
1
1
  import { Application, Http, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { SHELL_CONFIG } from './config';
4
+ import { onPushTap } from './events';
4
5
 
5
6
  /**
6
7
  * Remote push (APNs/FCM) — DEVICE SIDE. Acquires the token + surfaces incoming
@@ -75,9 +76,16 @@ export function onApnsError(message: string): void {
75
76
  }
76
77
  }
77
78
 
78
- /** iOS AppDelegate / UN delegate → a remote notification arrived (tapped = user opened it). */
79
+ /** iOS AppDelegate / UN delegate → a remote notification arrived (tapped = user opened it).
80
+ *
81
+ * A TAP goes through `onPushTap` rather than `bridge.emit` so it is buffered until the PWA
82
+ * handshake. A tap that LAUNCHES the app fires from the UN delegate while the WebView has not yet
83
+ * loaded our bundle, so a raw emit reaches no listener and the route is dropped — the app opens on
84
+ * its home screen instead of the notification's target. Android already went through that gate. */
79
85
  export function onRemoteMessage(userInfo: any, tapped: boolean): void { // any: APNs userInfo is NSDictionary or auto-marshalled JS object (dual-path)
80
- bridge.emit(tapped ? 'push.tap' : 'push.message', parseApnsPayload(userInfo));
86
+ const payload = parseApnsPayload(userInfo);
87
+ if (tapped) onPushTap(payload);
88
+ else bridge.emit('push.message', payload);
81
89
  }
82
90
 
83
91
  /** Android FirebaseMessagingService (fcm-service.android.ts) → a message arrived while the app
@@ -8,10 +8,14 @@ import { setStatusBarStyle } from './status-bar';
8
8
  import { buildCapabilityMap } from './capabilities.manifest';
9
9
  import { ACTIVE_MODULE_NAMES, PACK_MODULES } from './active-modules.generated';
10
10
  import { consumePendingBackgroundTaskId } from './background-context';
11
- import { settleShare, type ShareOutcome } from './share-outcome';
11
+ import { settleShare, photoAssetKind, type ShareOutcome, type SaveToPhotosOutcome } from './share-outcome';
12
+ import { appwrapNativeLog } from './native-log';
13
+
14
+ /** Debug-only native diagnostic line → Documents/appwrap-web.log (`appwrap logs ios`). */
15
+ const dlog = (line: string) => { if (SHELL_CONFIG.debug) appwrapNativeLog(line); };
12
16
 
13
17
  /** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
14
- export const SHELL_BUILD = 'share-target-1';
18
+ export const SHELL_BUILD = 'save-to-photos-1';
15
19
 
16
20
  /** Version status the web side (native-kit `kit.updates`) reports via `app.reportWebVersion`. */
17
21
  export interface WebVersionInfo { current?: string; latest?: string; build?: string | number; updateAvailable?: boolean; }
@@ -36,10 +40,12 @@ function presentShareSheet(items: NSMutableArray<any>): Promise<ShareOutcome> {
36
40
  const finish = (o: ShareOutcome | null) => {
37
41
  if (done || !o) return;
38
42
  done = true;
43
+ dlog(`[native:share] resolve ${JSON.stringify(o)}`);
39
44
  resolve(o);
40
45
  };
41
- const presented = () => !!controller.presentingViewController && !controller.isBeingDismissed;
46
+ const presented = () => !!controller.presentingViewController && !controller.beingDismissed;
42
47
  controller.completionWithItemsHandler = (activityType: string, completed: boolean) => {
48
+ dlog(`[native:share] completion activity=${activityType} completed=${!!completed} presented=${presented()}`);
43
49
  // Recover the WebView on dismiss — a presented sheet can orphan a touch-stealing window / leave
44
50
  // the renderer throttled (see CustomWebView.recoverAfterNativeSurface).
45
51
  bridge.getWebView()?.recoverAfterNativeSurface();
@@ -152,6 +158,44 @@ export function registerHandlers(): void {
152
158
  }
153
159
  );
154
160
 
161
+ // SAVE TO PHOTOS — write straight into the library, no sheet. The "Save" a camera app offers is
162
+ // this, not a share sheet with a Save row among twenty others. Add-only access (the same
163
+ // NSPhotoLibraryAddUsageDescription the share module already stamps), so the person is never asked
164
+ // to expose their library just to receive one clip. Resolves { saved } — a refusal is a value, not
165
+ // a throw, so the caller can say something honest. Android overrides this in handlers-android.
166
+ bridge.register(
167
+ 'share.saveToPhotos',
168
+ ({ name, mimeType, base64 }: { name?: string; mimeType?: string; base64: string }): Promise<SaveToPhotosOutcome> | SaveToPhotosOutcome => {
169
+ const kind = photoAssetKind(mimeType, name);
170
+ if (!isIOS || !kind) return { saved: false, reason: 'unsupported', message: kind ? 'no photo library here' : 'only photos and videos can be saved to Photos' };
171
+ return new Promise((resolve) => {
172
+ PHPhotoLibrary.requestAuthorizationForAccessLevelHandler(PHAccessLevel.AddOnly, (status) => {
173
+ dlog(`[native:photos] add-only authorization status=${status}`);
174
+ // Authorized = 3, Limited = 4. Limited still permits adding.
175
+ if (status !== 3 && status !== 4) return resolve({ saved: false, reason: 'denied', message: 'Photos access is off for this app' });
176
+ const data = NSData.alloc().initWithBase64EncodedStringOptions(base64 ?? '', 0 as unknown as NSDataBase64DecodingOptions);
177
+ if (!data) return resolve({ saved: false, reason: 'failed', message: 'could not read that file' });
178
+ // A real extension matters: Photos sniffs the container from the file URL.
179
+ const ext = (String(name ?? '').match(/\.[A-Za-z0-9]+$/)?.[0]) ?? (kind === 'video' ? '.mp4' : '.jpg');
180
+ const filePath = NSTemporaryDirectory() + 'save-' + Date.now() + ext;
181
+ if (!data.writeToFileAtomically(filePath, true)) return resolve({ saved: false, reason: 'failed', message: 'could not stage that file' });
182
+ const url = NSURL.fileURLWithPath(filePath);
183
+ PHPhotoLibrary.sharedPhotoLibrary().performChangesCompletionHandler(
184
+ () => {
185
+ PHAssetCreationRequest.creationRequestForAsset().addResourceWithTypeFileURLOptions(
186
+ kind === 'video' ? PHAssetResourceType.Video : PHAssetResourceType.Photo, url, null);
187
+ },
188
+ (ok, error) => {
189
+ NSFileManager.defaultManager.removeItemAtPathError(filePath);
190
+ dlog(`[native:photos] save ${kind} ok=${ok}${error ? ' err=' + error.localizedDescription : ''}`);
191
+ resolve(ok ? { saved: true } : { saved: false, reason: 'failed', message: error?.localizedDescription || 'Photos refused that file' });
192
+ }
193
+ );
194
+ });
195
+ });
196
+ }
197
+ );
198
+
155
199
  bridge.register('storage.get', ({ key }: { key: string }) =>
156
200
  JSON.parse(ApplicationSettings.getString(`kit:${key}`, 'null'))
157
201
  );
@@ -162,9 +206,10 @@ export function registerHandlers(): void {
162
206
  ApplicationSettings.remove(`kit:${key}`)
163
207
  );
164
208
 
165
- bridge.register('toast.show', ({ message, duration }: { message: string; duration?: 'short' | 'long' }) =>
166
- showToast(String(message ?? ''), duration ?? 'short')
167
- );
209
+ bridge.register('toast.show', ({ message, duration }: { message: string; duration?: 'short' | 'long' }) => {
210
+ dlog(`[native:toast] show "${message}" presentedOver=${isIOS ? !!Utils.ios.getRootViewController()?.presentedViewController : "n/a"}`);
211
+ return showToast(String(message ?? ''), duration ?? 'short');
212
+ });
168
213
 
169
214
  // Persistent, tappable banner (e.g. the remote-update "tap to reload" prompt). Tap emits
170
215
  // `toast.action` { id } back to the web side.
@@ -19,3 +19,24 @@ export function settleShare(
19
19
  if (completed) return activityType ? { completed: true, activity: String(activityType) } : { completed: true };
20
20
  return sheetStillPresented ? null : { completed: false };
21
21
  }
22
+
23
+ /** What `share.saveToPhotos` resolves with (native-kit `SaveToPhotosResult`). `saved:false` is never a
24
+ * throw: `reason` says why — 'denied' (the person refused, or Settings forbids it: only they can change
25
+ * it), 'unsupported' (not an image/video, or no handler on this platform), 'failed' (the write itself). */
26
+ export interface SaveToPhotosOutcome {
27
+ saved: boolean;
28
+ reason?: 'denied' | 'unsupported' | 'failed';
29
+ message?: string;
30
+ }
31
+
32
+ /** Which photo-library asset a file becomes — by mime type, then by extension. `null` = neither (a
33
+ * library only holds photos and videos; a PDF must go through the share sheet instead). */
34
+ export function photoAssetKind(mimeType?: string, name?: string): 'image' | 'video' | null {
35
+ const m = String(mimeType ?? '').toLowerCase();
36
+ if (m.startsWith('image/')) return 'image';
37
+ if (m.startsWith('video/')) return 'video';
38
+ const ext = String(name ?? '').toLowerCase().split('.').pop() ?? '';
39
+ if (['jpg', 'jpeg', 'png', 'heic', 'heif', 'gif', 'webp'].includes(ext)) return 'image';
40
+ if (['mp4', 'mov', 'm4v'].includes(ext)) return 'video';
41
+ return null;
42
+ }
@@ -41,7 +41,10 @@ function showIOSToast(message: string, durationMs: number): void {
41
41
  const screenBounds = UIScreen.mainScreen.bounds;
42
42
  containerView.center = CGPointMake(screenBounds.size.width / 2, screenBounds.size.height - 120);
43
43
 
44
- rootVC.view.addSubview(containerView);
44
+ // Onto the WINDOW, not the root view: a toast that reports how a sheet ended is shown while that
45
+ // sheet may still be presented (or animating out), and the root view sits BELOW every presented
46
+ // controller — the toast would render, underneath, and be gone before the sheet was.
47
+ (rootVC.view.window ?? rootVC.view).addSubview(containerView);
45
48
  containerView.alpha = 0;
46
49
  containerView.transform = CGAffineTransformMakeScale(0.8, 0.8);
47
50
 
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test';
4
4
  // Superset shape — bun shares the runtime-module cache across files in a dir run, so every
5
5
  // @nativescript/core mock must expose the same named exports the runtime modules import.
6
6
  mock.module('@nativescript/core', () => ({
7
+ Http: { request: async () => ({ content: null }) },
7
8
  isIOS: false, isAndroid: false, WebView: class {},
8
9
  ApplicationSettings: { getString: (_k: string, d = '') => d, setString: () => {}, remove: () => {} },
9
10
  Dialogs: { confirm: async () => true, action: async () => '', alert: async () => undefined, prompt: async () => ({ result: false, text: '' }) },
@@ -10,6 +10,7 @@ import { afterEach, describe, expect, mock, test } from 'bun:test';
10
10
  // which throws-and-falls-back off-device — safe to stub minimally).
11
11
  let androidFlag = false;
12
12
  mock.module('@nativescript/core', () => ({
13
+ Http: { request: async () => ({ content: null }) },
13
14
  Application: { android: {} },
14
15
  get isAndroid() { return androidFlag; },
15
16
  isIOS: false,
@@ -25,3 +25,20 @@ describe('settleShare', () => {
25
25
  expect(settleShare('com.apple.UIKit.activity.Mail', false, true)).toBeNull();
26
26
  });
27
27
  });
28
+
29
+ import { photoAssetKind } from '../app/shell/share-outcome';
30
+
31
+ describe('photoAssetKind', () => {
32
+ test('mime type decides first', () => {
33
+ expect(photoAssetKind('video/mp4', 'x.png')).toBe('video');
34
+ expect(photoAssetKind('image/png')).toBe('image');
35
+ });
36
+ test('falls back to the extension when the mime type is generic', () => {
37
+ expect(photoAssetKind('application/octet-stream', 'clip.MOV')).toBe('video');
38
+ expect(photoAssetKind('', 'shot.jpeg')).toBe('image');
39
+ });
40
+ test('anything else is not a photo-library asset', () => {
41
+ expect(photoAssetKind('application/pdf', 'a.pdf')).toBeNull();
42
+ expect(photoAssetKind(undefined, undefined)).toBeNull();
43
+ });
44
+ });