@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 +1 -1
- package/runtime/app/shell/handlers.ts +39 -22
- package/runtime/app/shell/keyboard-frame.ts +67 -0
- package/runtime/app/shell/keyboard.ts +26 -23
- package/runtime/app/shell/share-outcome.ts +21 -0
- package/runtime/tests/keyboard-frame.test.ts +97 -0
- package/runtime/tests/share-outcome.test.ts +27 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 (
|
|
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
|
+
});
|