@livx.cc/appwrap 0.61.6 → 0.61.8

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.6",
3
+ "version": "0.61.8",
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",
@@ -80,7 +80,14 @@ export const MANIFEST_SCHEMA_VERSION = 1;
80
80
  export const MODULES: ModuleManifest[] = [
81
81
  // ── core (always on, no permissions) ───────────────────────────────────
82
82
  { name: 'haptics', core: true, group: 'core', capabilities: { haptics: 'native' } },
83
- { name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native' } },
83
+ // `shareFiles` hands a file to the OS sheet, and one of the activities the sheet offers for a
84
+ // photo or video is "Save Image"/"Save Video" — which WRITES to the camera roll. iOS omits that
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.
87
+ {
88
+ name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native' },
89
+ ios: { permissions: [{ key: 'NSPhotoLibraryAddUsageDescription', domain: 'photosAdd', defaultUsage: 'Save a photo or video you chose to your library.' }] },
90
+ },
84
91
  { name: 'storage', core: true, group: 'core', capabilities: { storage: 'native', secureStorage: 'native' } },
85
92
  // fs: app-sandbox file I/O (documents/data/cache) + system document picker. Core — every root is
86
93
  // inside the app sandbox and the picker returns user-chosen security-scoped URIs → zero perms.
@@ -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,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
+ });