@livx.cc/appwrap 0.61.7 → 0.61.9

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.7",
3
+ "version": "0.61.9",
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",
@@ -8,6 +8,7 @@ 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
12
 
12
13
  /** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
13
14
  export const SHELL_BUILD = 'share-target-1';
@@ -18,6 +19,40 @@ let lastWebVersion: WebVersionInfo = {};
18
19
  /** Latest version status the web reported — read by the dev-menu App Info screen. */
19
20
  export function getReportedWebVersion(): WebVersionInfo { return lastWebVersion; }
20
21
 
22
+ /**
23
+ * Present the iOS share sheet and resolve when it ENDS — not when it opens — with how it ended
24
+ * (`{ completed, activity? }`, e.g. activity `com.apple.UIKit.activity.SaveToCameraRoll` for "Save
25
+ * Video"). That is what lets a caller say "Saved to Photos"; the sheet itself gives no feedback.
26
+ */
27
+ function presentShareSheet(items: NSMutableArray<any>): Promise<ShareOutcome> {
28
+ return new Promise((resolve) => {
29
+ const controller = UIActivityViewController.alloc().initWithActivityItemsApplicationActivities(items, null);
30
+ const rootVC = Utils.ios.getRootViewController();
31
+ // iPad requires a popover anchor
32
+ if (controller.popoverPresentationController) {
33
+ controller.popoverPresentationController.sourceView = rootVC.view;
34
+ }
35
+ let done = false;
36
+ const finish = (o: ShareOutcome | null) => {
37
+ if (done || !o) return;
38
+ done = true;
39
+ resolve(o);
40
+ };
41
+ const presented = () => !!controller.presentingViewController && !controller.isBeingDismissed;
42
+ controller.completionWithItemsHandler = (activityType: string, completed: boolean) => {
43
+ // Recover the WebView on dismiss — a presented sheet can orphan a touch-stealing window / leave
44
+ // the renderer throttled (see CustomWebView.recoverAfterNativeSurface).
45
+ bridge.getWebView()?.recoverAfterNativeSurface();
46
+ const now = settleShare(activityType, !!completed, presented());
47
+ if (now) return finish(now);
48
+ // Still presented: a cancelled sub-activity (sheet stays) OR a dismissal still animating out.
49
+ // Re-check once it would have finished; a later handler call wins if it comes first.
50
+ setTimeout(() => finish(settleShare(null, false, presented())), 700);
51
+ };
52
+ rootVC.presentViewControllerAnimatedCompletion(controller, true, null);
53
+ });
54
+ }
55
+
21
56
  /** Register all protocol-v1 handlers. */
22
57
  export function registerHandlers(): void {
23
58
  bridge.register('app.handshake', () => {
@@ -84,20 +119,10 @@ export function registerHandlers(): void {
84
119
  if (isIOS) {
85
120
  const items = NSMutableArray.new();
86
121
  if (content) items.addObject(content);
87
- const controller = UIActivityViewController.alloc().initWithActivityItemsApplicationActivities(
88
- items,
89
- null
90
- );
91
- const rootVC = Utils.ios.getRootViewController();
92
- // iPad requires a popover anchor
93
- if (controller.popoverPresentationController) {
94
- controller.popoverPresentationController.sourceView = rootVC.view;
95
- }
96
- // Recover the WebView on dismiss — a presented sheet can orphan a touch-stealing window / leave
97
- // the renderer throttled (see CustomWebView.recoverAfterNativeSurface).
98
- controller.completionWithItemsHandler = () => { bridge.getWebView()?.recoverAfterNativeSurface(); };
99
- rootVC.presentViewControllerAnimatedCompletion(controller, true, null);
122
+ return presentShareSheet(items);
100
123
  } else if (isAndroid) {
124
+ // The chooser cannot report what (or whether) anything was picked, so there is no outcome to
125
+ // return: resolves undefined, which callers treat as "unknown" (no success feedback).
101
126
  const intent = new android.content.Intent(android.content.Intent.ACTION_SEND);
102
127
  intent.setType('text/plain');
103
128
  if (title) intent.putExtra(android.content.Intent.EXTRA_SUBJECT, title);
@@ -123,15 +148,7 @@ export function registerHandlers(): void {
123
148
  data.writeToFileAtomically(filePath, true);
124
149
  items.addObject(NSURL.fileURLWithPath(filePath));
125
150
  });
126
- const controller = UIActivityViewController.alloc().initWithActivityItemsApplicationActivities(
127
- items,
128
- null
129
- );
130
- const rootVC = Utils.ios.getRootViewController();
131
- if (controller.popoverPresentationController) {
132
- controller.popoverPresentationController.sourceView = rootVC.view; // iPad anchor
133
- }
134
- rootVC.presentViewControllerAnimatedCompletion(controller, true, null);
151
+ return presentShareSheet(items);
135
152
  }
136
153
  );
137
154
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pure iOS keyboard-frame decision (no NativeScript/UIKit globals — unit-testable).
3
+ *
4
+ * Decides how many points of the screen a keyboard notification's end frame covers, or why the
5
+ * event must NOT shrink the webview. A hidden keyboard must always end at full height, so any
6
+ * doubtful event resolves to "skip" — the hide pair (willHide/didHide) owns the restore.
7
+ */
8
+ export type KeyboardEventTag = 'willShow' | 'didShow' | 'willChangeFrame';
9
+
10
+ export interface KeyboardFrameInput {
11
+ tag: KeyboardEventTag;
12
+ /** willShow/didShow seen since the last willHide/didHide — the keyboard is (becoming) visible. */
13
+ inShowCycle: boolean;
14
+ /** UIKeyboardFrameEndUserInfoKey, in screen coordinates. Absent → nothing to apply. */
15
+ end?: { y: number };
16
+ /** Window height, or the SCREEN height when the webview is momentarily out of a window. */
17
+ containerHeight: number;
18
+ }
19
+
20
+ export interface KeyboardFrameDecision {
21
+ height: number;
22
+ skip?: 'no-frame' | 'off-screen' | 'out-of-cycle' | 'bogus';
23
+ }
24
+
25
+ export function resolveKeyboardHeight(input: KeyboardFrameInput): KeyboardFrameDecision {
26
+ const { tag, inShowCycle, end, containerHeight } = input;
27
+ if (!end || !(containerHeight > 0)) return { height: 0, skip: 'no-frame' };
28
+ // Overlap with the container, NOT the frame's height: iOS delivers keyboard-sized but off-screen
29
+ // end frames during dismissal — the frame height would shrink a keyboard-less screen.
30
+ const height = Math.max(0, Math.round(containerHeight - end.y));
31
+ if (height <= 0) return { height: 0, skip: 'off-screen' };
32
+ // willChangeFrame also fires OUTSIDE a show cycle (after didHide: iframe/dialog teardown, audio
33
+ // session / speech-recognition takeover) with an on-screen end frame. Applying it re-shrinks a
34
+ // keyboard-less screen and nothing restores it (field bug: half-screen empty band across screens).
35
+ // A keyboard that really appears always posts willShow or didShow (warm re-focus = didShow-only),
36
+ // which re-opens the cycle and lands the shrink.
37
+ if (tag === 'willChangeFrame' && !inShowCycle) return { height: 0, skip: 'out-of-cycle' };
38
+ // Bogus full-screen frames (origin.y≈0, seen during SMS-OTP autofill): a real software keyboard is
39
+ // never >85% of the screen; the real frame follows and self-heals.
40
+ if (height > containerHeight * 0.85) return { height, skip: 'bogus' };
41
+ return { height };
42
+ }
43
+
44
+ /**
45
+ * Stateful show-cycle gate around resolveKeyboardHeight: willShow/didShow open the cycle (a keyboard
46
+ * really appearing posts one of these), the hide pair closes it. Owns the flag so keyboard.ts can't
47
+ * forget to open or close it.
48
+ */
49
+ export function createKeyboardShowCycle() {
50
+ let open = false;
51
+ return {
52
+ resolve(input: Omit<KeyboardFrameInput, 'inShowCycle'>): KeyboardFrameDecision {
53
+ if (input.tag === 'willChangeFrame') return resolveKeyboardHeight({ ...input, inShowCycle: open });
54
+ const decision = resolveKeyboardHeight({ ...input, inShowCycle: true });
55
+ // Only a show event with an on-screen keyboard opens the cycle: a skipped (off-screen/no-frame)
56
+ // didShow must not arm a following stray willChangeFrame. 'bogus' is a real keyboard mid-autofill
57
+ // whose true frame follows as willChangeFrame, so it opens.
58
+ if (!decision.skip || decision.skip === 'bogus') open = true;
59
+ // Known, unproven ordering: a late on-screen didShow AFTER didHide still opens + shrinks with no
60
+ // restoring hide to follow. Accepted until the device root cause is confirmed.
61
+ return decision;
62
+ },
63
+ close(): void {
64
+ open = false;
65
+ },
66
+ };
67
+ }
@@ -1,6 +1,7 @@
1
1
  import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { SHELL_CONFIG } from './config';
4
+ import { createKeyboardShowCycle, type KeyboardEventTag } from './keyboard-frame';
4
5
  import { appwrapNativeLog } from './native-log';
5
6
 
6
7
  declare const android: any;
@@ -19,6 +20,7 @@ declare const NSObject: any;
19
20
  declare const UIScrollViewDelegate: any;
20
21
  declare function CGPointMake(x: number, y: number): any;
21
22
  declare const UIColor: any;
23
+ declare const UIScreen: any;
22
24
 
23
25
  let observersArmed = false;
24
26
 
@@ -76,6 +78,9 @@ function armIosKeyboardObservers(): void {
76
78
  // height → apply the configured extra lift to close the gap. Reset when the keyboard fully hides.
77
79
  let sawWillShow = false;
78
80
  let activeExtraLift = 0;
81
+ // willShow/didShow seen since the last willHide/didHide. Only a live show cycle may shrink via
82
+ // willChangeFrame — out-of-cycle frames re-shrank a keyboard-less screen (see keyboard-frame.ts).
83
+ const showCycle = createKeyboardShowCycle();
79
84
 
80
85
  const getWk = (): WKWebView | undefined => bridge.getWebView()?.ios as WKWebView | undefined;
81
86
 
@@ -110,7 +115,10 @@ function armIosKeyboardObservers(): void {
110
115
  const wv = bridge.getWebView();
111
116
  const wk = getWk();
112
117
  const win = wk?.window;
113
- if (!wv || !wk || !win) {
118
+ // The RESTORE (margin 0) needs no window — only a shrink needs the safe-area inset. Waiting for a
119
+ // window on restore let a webview detached through a screen transition exhaust the retries and
120
+ // stay shrunk on the next screen.
121
+ if (!wv || !wk || (paddingBottom > 0 && !win)) {
114
122
  if (attempt < 20) pendingUpdate = setTimeout(() => Utils.dispatchToMainThread(() => updateFrame(attempt + 1)), 100);
115
123
  else if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] frame update gave up (webview detached), pad=${paddingBottom}`);
116
124
  return;
@@ -120,7 +128,7 @@ function armIosKeyboardObservers(): void {
120
128
  // which is what produced gaps, content under the status bar, and residual scroll state.
121
129
  // Subtract the bottom safe-area inset: NS layout already excludes it, and the keyboard COVERS
122
130
  // it — margining the full keyboard height double-counts those 34pt as a gap above the keyboard.
123
- const safeBottom = win.safeAreaInsets?.bottom ?? 0;
131
+ const safeBottom = win?.safeAreaInsets?.bottom ?? 0;
124
132
  // On a WARM re-focus iOS 26 reports a keyboard TALLER than it draws (it reserves the ▲▼✓ accessory
125
133
  // row but doesn't draw it), leaving bare black native space between the shrunk webview and the
126
134
  // real keys. `activeExtraLift` (see onShow) is the configured extra lift on those warm shows and 0
@@ -147,33 +155,26 @@ function armIosKeyboardObservers(): void {
147
155
  // Re-focus does NOT re-fire willShow — capture-verified: a second tap arrives ONLY as
148
156
  // didShow/willChangeFrame. All three feed the same idempotent handler; whichever iOS sends, the
149
157
  // shrink lands. Height-0 frame events do nothing (hide is owned by the willHide/didHide pair).
150
- const onShow = (tag: string, settleMs: number | null) => (note: any): void => {
158
+ const onShow = (tag: KeyboardEventTag, settleMs: number | null) => (note: any): void => {
151
159
  detachWebKitKeyboardHandling();
152
160
  if (tag === 'willShow') sawWillShow = true; // cold acquire → the accessory bar will be drawn
153
161
  // Warm re-focus (didShow/willChangeFrame with no willShow this cycle) → bar dropped → lift extra.
154
162
  activeExtraLift = sawWillShow ? 0 : (SHELL_CONFIG.iosKeyboardExtraLift ?? 82);
155
163
  const value = note?.userInfo?.objectForKey?.(UIKeyboardFrameEndUserInfoKey);
156
- // Overlap with the window, NOT frame.size.height: iOS delivers keyboard-sized but off-screen
157
- // end-frames during dismissal — size.height would shrink a keyboard-less screen.
158
- let height = 0;
159
- let winH = 0;
160
- if (value) {
161
- const end = value.CGRectValue;
162
- const win = getWk()?.window;
163
- winH = win ? win.bounds.size.height : 0;
164
- height = winH ? Math.max(0, Math.round(winH - end.origin.y)) : Math.round(end.size.height);
165
- }
166
- if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] ${tag} height=${height}`);
164
+ const rect = value?.CGRectValue;
165
+ // Keyboard frames are in screen coordinates: a webview momentarily out of its window (screen
166
+ // transition) measures against the screen — never the raw frame height, which is keyboard-sized
167
+ // even for the off-screen end frames iOS posts during dismissal.
168
+ const win = getWk()?.window;
169
+ const containerHeight = win ? win.bounds.size.height : UIScreen.mainScreen.bounds.size.height;
170
+ const { height, skip } = showCycle.resolve({
171
+ tag,
172
+ end: rect ? { y: rect.origin.y } : undefined,
173
+ containerHeight,
174
+ });
175
+ if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] ${tag} height=${height}${skip ? ` skip=${skip}` : ''} (container=${Math.round(containerHeight)})`);
167
176
  resetScrollView();
168
- if (height <= 0) return;
169
- // Guard against bogus full-screen keyboard frames. iOS occasionally delivers a transitional frame
170
- // with origin.y≈0 (seen during SMS-OTP autofill) → height ≈ the whole window → the resize would
171
- // shrink the webview to a sliver (huge black gap). A real software keyboard is never >85% of the
172
- // screen; ignore the event and wait for the real frame (which follows and self-heals).
173
- if (winH && height > winH * 0.85) {
174
- if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:keyboard] ignore bogus height=${height} (win=${Math.round(winH)})`);
175
- return;
176
- }
177
+ if (skip) return;
177
178
  // Paint the webview/window backdrop the page color so the resize shows no white flash. (The
178
179
  // iOS-26 phantom keyboard-height gap is closed geometrically by the extra lift in updateFrame.)
179
180
  syncBackdropColor();
@@ -187,11 +188,13 @@ function armIosKeyboardObservers(): void {
187
188
  center.addObserverForNameObjectQueueUsingBlock(UIKeyboardDidShowNotification, null, null, onShow('didShow', 50));
188
189
  center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillHideNotification, null, null, () => {
189
190
  if (SHELL_CONFIG.debug) appwrapNativeLog('[native:keyboard] willHide → restore');
191
+ showCycle.close(); // dismissal's willChangeFrame must not re-shrink
190
192
  setKeyboardHeight(0, 10);
191
193
  resetScrollView();
192
194
  bridge.emit('keyboard.hide');
193
195
  });
194
196
  center.addObserverForNameObjectQueueUsingBlock(UIKeyboardDidHideNotification, null, null, () => {
197
+ showCycle.close();
195
198
  setKeyboardHeight(0, 10); // enforcement pass — a hidden keyboard must always end at full height
196
199
  resetScrollView();
197
200
  sawWillShow = false; // keyboard fully gone → next show re-decides cold (bar) vs warm (no bar)
@@ -0,0 +1,21 @@
1
+ /** How a share sheet ended — what `share.share` / `share.files` resolve with (native-kit `ShareResult`). */
2
+ export interface ShareOutcome {
3
+ completed: boolean;
4
+ activity?: string;
5
+ }
6
+
7
+ /**
8
+ * Decide whether ONE `UIActivityViewController.completionWithItemsHandler` call ends the share.
9
+ * iOS 13+ calls the handler with (activityType, completed=false) when a sub-activity (Mail, Messages)
10
+ * is cancelled while the sheet STAYS open for another pick — resolving there would report "cancelled"
11
+ * and lose the real outcome. So: a completed activity ends it; otherwise only a sheet that is no longer
12
+ * presented does. `null` = not over yet, wait for the next call (or the dismissal re-check).
13
+ */
14
+ export function settleShare(
15
+ activityType: string | null | undefined,
16
+ completed: boolean,
17
+ sheetStillPresented: boolean
18
+ ): ShareOutcome | null {
19
+ if (completed) return activityType ? { completed: true, activity: String(activityType) } : { completed: true };
20
+ return sheetStillPresented ? null : { completed: false };
21
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { createKeyboardShowCycle, resolveKeyboardHeight } from '../app/shell/keyboard-frame';
5
+
6
+ /**
7
+ * iOS keyboard avoidance must never leave the webview shrunk once the keyboard is gone.
8
+ * Field bug pinned: Blank's "Fan Reach" call dialog (input in a showModal <dialog> in a sandboxed
9
+ * iframe, then TTS + STT) left a half-screen empty band that persisted onto the home grid.
10
+ */
11
+ const H = 844; // iPhone 13 Pro window height (pt)
12
+ const KB = { y: H - 336 }; // on-screen keyboard end frame
13
+
14
+ describe('resolveKeyboardHeight', () => {
15
+ test('cold show applies the on-screen overlap', () => {
16
+ expect(resolveKeyboardHeight({ tag: 'willShow', inShowCycle: true, end: KB, containerHeight: H })).toEqual({ height: 336 });
17
+ });
18
+
19
+ test('warm re-focus (didShow-only) still applies', () => {
20
+ expect(resolveKeyboardHeight({ tag: 'didShow', inShowCycle: true, end: KB, containerHeight: H }).height).toBe(336);
21
+ expect(resolveKeyboardHeight({ tag: 'didShow', inShowCycle: true, end: KB, containerHeight: H }).skip).toBeUndefined();
22
+ });
23
+
24
+ test('willChangeFrame inside a show cycle (keyboard resize) applies', () => {
25
+ const end = { y: H - 380 };
26
+ expect(resolveKeyboardHeight({ tag: 'willChangeFrame', inShowCycle: true, end, containerHeight: H })).toEqual({ height: 380 });
27
+ });
28
+
29
+ test('willChangeFrame AFTER didHide with an on-screen frame does not re-shrink', () => {
30
+ expect(resolveKeyboardHeight({ tag: 'willChangeFrame', inShowCycle: false, end: KB, containerHeight: H })).toEqual({ height: 0, skip: 'out-of-cycle' });
31
+ });
32
+
33
+ test('keyboard-sized off-screen dismissal frame is zero overlap', () => {
34
+ expect(resolveKeyboardHeight({ tag: 'willChangeFrame', inShowCycle: true, end: { y: H }, containerHeight: H }).skip).toBe('off-screen');
35
+ });
36
+
37
+ test('no container height never falls back to the raw frame height', () => {
38
+ expect(resolveKeyboardHeight({ tag: 'didShow', inShowCycle: true, end: { y: H }, containerHeight: 0 })).toEqual({ height: 0, skip: 'no-frame' });
39
+ });
40
+
41
+ test('bogus full-screen frame is skipped', () => {
42
+ expect(resolveKeyboardHeight({ tag: 'willShow', inShowCycle: true, end: { y: 0 }, containerHeight: H }).skip).toBe('bogus');
43
+ });
44
+ });
45
+
46
+ describe('createKeyboardShowCycle', () => {
47
+ const frame = (tag: 'willShow' | 'didShow' | 'willChangeFrame') => ({ tag, end: KB, containerHeight: H });
48
+
49
+ test('a fresh cycle ignores a stray willChangeFrame', () => {
50
+ expect(createKeyboardShowCycle().resolve(frame('willChangeFrame')).skip).toBe('out-of-cycle');
51
+ });
52
+
53
+ test('willShow opens the cycle so a following willChangeFrame resize applies', () => {
54
+ const cycle = createKeyboardShowCycle();
55
+ expect(cycle.resolve(frame('willShow'))).toEqual({ height: 336 });
56
+ expect(cycle.resolve(frame('willChangeFrame'))).toEqual({ height: 336 });
57
+ });
58
+
59
+ test('a skipped (off-screen / no-frame) didShow does not open the cycle for a stray willChangeFrame', () => {
60
+ const cycle = createKeyboardShowCycle();
61
+ expect(cycle.resolve({ tag: 'didShow', end: { y: H }, containerHeight: H }).skip).toBe('off-screen');
62
+ expect(cycle.resolve(frame('willChangeFrame')).skip).toBe('out-of-cycle');
63
+ expect(cycle.resolve({ tag: 'didShow', containerHeight: H }).skip).toBe('no-frame');
64
+ expect(cycle.resolve(frame('willChangeFrame')).skip).toBe('out-of-cycle');
65
+ });
66
+
67
+ test('a bogus (autofill full-screen) willShow still opens the cycle so the real frame lands', () => {
68
+ const cycle = createKeyboardShowCycle();
69
+ expect(cycle.resolve({ tag: 'willShow', end: { y: 0 }, containerHeight: H }).skip).toBe('bogus');
70
+ expect(cycle.resolve(frame('willChangeFrame'))).toEqual({ height: 336 });
71
+ });
72
+
73
+ test('warm re-focus (didShow-only) re-opens the cycle after a hide', () => {
74
+ const cycle = createKeyboardShowCycle();
75
+ cycle.resolve(frame('willShow'));
76
+ cycle.close();
77
+ expect(cycle.resolve(frame('willChangeFrame')).skip).toBe('out-of-cycle');
78
+ expect(cycle.resolve(frame('didShow'))).toEqual({ height: 336 });
79
+ expect(cycle.resolve(frame('willChangeFrame'))).toEqual({ height: 336 });
80
+ });
81
+ });
82
+
83
+ // Source-level wiring (keyboard.ts imports NativeScript and can't load under bun).
84
+ const src = readFileSync(join(import.meta.dir, '..', 'app', 'shell', 'keyboard.ts'), 'utf8');
85
+ describe('keyboard.ts wiring', () => {
86
+ test('onShow routes through the show-cycle gate', () => {
87
+ expect(src).toMatch(/showCycle\.resolve\(\{/);
88
+ expect(src).not.toMatch(/resolveKeyboardHeight\(/);
89
+ });
90
+ test('both hide notifications close the show cycle', () => {
91
+ const hides = src.split('UIKeyboardWillHideNotification, null, null')[1] ?? '';
92
+ expect((hides.match(/showCycle\.close\(\)/g) ?? []).length).toBe(2);
93
+ });
94
+ test('restore to full height does not wait for the webview to be in a window', () => {
95
+ expect(src).toMatch(/if \(!wv \|\| !wk \|\| \(paddingBottom > 0 && !win\)\)/);
96
+ });
97
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * iOS share sheet outcome — `share.share` / `share.files` resolve when the sheet ENDS with
3
+ * `{ completed, activity? }`. Contract: a completed activity ends it (with its type), a dismissed sheet
4
+ * ends it as not completed, and a cancelled sub-activity while the sheet stays open does NOT end it.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { settleShare } from '../app/shell/share-outcome';
8
+
9
+ describe('settleShare', () => {
10
+ test('"Save Video" completes with its activity type', () => {
11
+ expect(settleShare('com.apple.UIKit.activity.SaveToCameraRoll', true, false))
12
+ .toEqual({ completed: true, activity: 'com.apple.UIKit.activity.SaveToCameraRoll' });
13
+ });
14
+ test('completed while the sheet is still animating out still completes', () => {
15
+ expect(settleShare('com.apple.UIKit.activity.CopyToPasteboard', true, true))
16
+ .toEqual({ completed: true, activity: 'com.apple.UIKit.activity.CopyToPasteboard' });
17
+ });
18
+ test('completed with no activity type carries no activity key', () => {
19
+ expect(settleShare(null, true, false)).toEqual({ completed: true });
20
+ });
21
+ test('sheet dismissed without a pick → not completed', () => {
22
+ expect(settleShare(null, false, false)).toEqual({ completed: false });
23
+ });
24
+ test('sub-activity cancelled while the sheet stays open → not over yet', () => {
25
+ expect(settleShare('com.apple.UIKit.activity.Mail', false, true)).toBeNull();
26
+ });
27
+ });