@livx.cc/appwrap 0.58.0 → 0.58.2
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/capabilities.manifest.ts +36 -16
- package/runtime/app/shell/custom-webview.ios.ts +53 -0
- package/runtime/app/shell/handlers-share-target.ts +67 -10
- package/runtime/app/shell/share-mailbox.ts +40 -0
- package/runtime/app/shell/web-quirks.ts +25 -2
- package/runtime/modules-native/shareTarget/App_Resources/Android/src/main/java/cc/appwrap/share/AppwrapShareActivity.kt +307 -0
- package/runtime/modules-native/shareTarget/App_Resources/iOS/extensions/AppwrapShare/ShareViewController.swift +256 -42
- package/runtime/tests/ios-safe-area.test.ts +41 -0
- package/runtime/tests/share-mailbox.test.ts +78 -0
- package/src/cli.ts +22 -13
- package/src/config.ts +63 -1
package/package.json
CHANGED
|
@@ -282,27 +282,47 @@ export const MODULES: ModuleManifest[] = [
|
|
|
282
282
|
// proven cold-start buffering (handshake `deepLink`) + warm `deeplink.open` event with zero new
|
|
283
283
|
// bridge surface. iOS: a generated share-extension TARGET (`AppwrapShare`, from this module's
|
|
284
284
|
// nativeSrc via NS's App_Resources/iOS/extensions seam — same machinery as the widget pack) that
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
285
|
+
// PERSISTS the payload as the same `<urlScheme>://share?…` URL into an App-Group UserDefaults
|
|
286
|
+
// MAILBOX (modern iOS blocks share extensions from launching their host via openURL:, so there is
|
|
287
|
+
// no live hand-off — the extension shows a brief "Added to <App>" confirmation instead); the host
|
|
288
|
+
// drains the mailbox on cold launch + every resume (read-once) into the same deep-link path. Images
|
|
289
|
+
// cross via the App Group container (entitlement below, stamped on both targets) and the host shell
|
|
290
|
+
// relocates them into the app cache so the JS contract is platform-identical. `urlScheme` REQUIRED. Consume via `kit.shareTarget.onReceive` (or
|
|
291
|
+
// raw `kit.lifecycle.onDeepLink`). OPTIONAL direct sync: config `shareTarget.directSync` lets the
|
|
292
|
+
// iOS extension complete the share ITSELF (one templated HTTP call, "Syncing… → Synced" drawer
|
|
293
|
+
// status) using the app-published context KV (`kit.shareTarget.setContext`) — mailbox stays the
|
|
294
|
+
// fallback on any failure. See ShareDirectSyncConfig in appwrap-cli config.ts.
|
|
289
295
|
{
|
|
290
296
|
name: 'shareTarget', group: 'shareTarget', nativeSrc: 'shareTarget',
|
|
291
297
|
capabilities: { shareTarget: 'native' },
|
|
292
298
|
ios: { entitlements: { 'com.apple.security.application-groups': ['__APP_GROUP__'] } },
|
|
299
|
+
// Android UX parity with the iOS drawer: the SEND filters sit on a LIGHTWEIGHT translucent
|
|
300
|
+
// activity (module Kotlin source, cc.appwrap.share.AppwrapShareActivity) — NOT the main
|
|
301
|
+
// activity — so a share never boots the full app/WebView. It performs the same config-driven
|
|
302
|
+
// direct sync (toast status) and only falls back to launching the app (deep-link contract
|
|
303
|
+
// unchanged) when sync isn't possible. taskAffinity="" keeps it out of the app's task;
|
|
304
|
+
// noHistory + excludeFromRecents make it invisible to navigation.
|
|
293
305
|
android: {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
`\t\t\
|
|
298
|
-
`\t\t\
|
|
299
|
-
`\t\t\
|
|
300
|
-
`\t\t
|
|
301
|
-
`\t\t
|
|
302
|
-
`\t\t\t<
|
|
303
|
-
`\t\t\t<
|
|
304
|
-
`\t\t\t<
|
|
305
|
-
`\t\t
|
|
306
|
+
kotlin: true,
|
|
307
|
+
manifestApplication:
|
|
308
|
+
`<activity android:name="cc.appwrap.share.AppwrapShareActivity"\n` +
|
|
309
|
+
`\t\t\tandroid:exported="true"\n` +
|
|
310
|
+
`\t\t\tandroid:excludeFromRecents="true"\n` +
|
|
311
|
+
`\t\t\tandroid:noHistory="true"\n` +
|
|
312
|
+
`\t\t\tandroid:taskAffinity=""\n` +
|
|
313
|
+
`\t\t\tandroid:theme="@android:style/Theme.Translucent.NoTitleBar">\n` +
|
|
314
|
+
`\t\t\t<intent-filter>\n` +
|
|
315
|
+
`\t\t\t\t<action android:name="android.intent.action.SEND" />\n` +
|
|
316
|
+
`\t\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
|
|
317
|
+
`\t\t\t\t<data android:mimeType="text/plain" />\n` +
|
|
318
|
+
`\t\t\t\t<data android:mimeType="image/*" />\n` +
|
|
319
|
+
`\t\t\t</intent-filter>\n` +
|
|
320
|
+
`\t\t\t<intent-filter>\n` +
|
|
321
|
+
`\t\t\t\t<action android:name="android.intent.action.SEND_MULTIPLE" />\n` +
|
|
322
|
+
`\t\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
|
|
323
|
+
`\t\t\t\t<data android:mimeType="image/*" />\n` +
|
|
324
|
+
`\t\t\t</intent-filter>\n` +
|
|
325
|
+
`\t\t</activity>`,
|
|
306
326
|
},
|
|
307
327
|
},
|
|
308
328
|
|
|
@@ -161,6 +161,23 @@ class DevCertNavDelegate extends NSObject implements WKNavigationDelegate {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
export class CustomWebView extends WebView {
|
|
164
|
+
constructor() {
|
|
165
|
+
super();
|
|
166
|
+
// The WKWebView must OWN the safe areas for CSS env(safe-area-inset-*) to be non-zero: WebKit
|
|
167
|
+
// (WKWebViewIOS.mm _computedUnobscuredSafeAreaInset) feeds env() from the WKWebView's OWN UIKit
|
|
168
|
+
// safeAreaInsets when contentInsetAdjustmentBehavior is .never — which requires the view's frame
|
|
169
|
+
// to genuinely extend under the status bar / home indicator. NS core's WebView defaults
|
|
170
|
+
// `iosOverflowSafeArea` to FALSE (only layout containers default true), which routes every layout
|
|
171
|
+
// pass through IOSHelper.shrinkToSafeArea: whenever the pass runs with the view attached to a
|
|
172
|
+
// window, the frame is boxed inside the safe area → safeAreaInsets become 0 → env() = 0, and the
|
|
173
|
+
// wrapped page's own safe-area padding collapses (content behind the notch). Whether the shrink
|
|
174
|
+
// actually lands is TIMING-dependent (NS's _cachedFrame skips re-adjustment when the frame value
|
|
175
|
+
// is unchanged), so devices differ. `true` flips NS to its expandBeyondSafeArea path —
|
|
176
|
+
// deterministic full-bleed frame, real safeAreaInsets, env() populated. Page-level padding then
|
|
177
|
+
// works with zero per-app configuration. (iOS-only NS property; inert on Android.)
|
|
178
|
+
this.iosOverflowSafeArea = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
164
181
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
165
182
|
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
166
183
|
private _scriptHandler!: AppwrapScriptHandler; // retained — WKUserContentController holds it weakly
|
|
@@ -363,8 +380,44 @@ export class CustomWebView extends WebView {
|
|
|
363
380
|
return webView;
|
|
364
381
|
}
|
|
365
382
|
|
|
383
|
+
/** Debug diagnostic: snapshot the native safe-area geometry (frame, safeAreaInsets, scroll-view
|
|
384
|
+
* inset behavior, superview chain) into the page as `window.__appwrapSafeAreaDiag` + the file log.
|
|
385
|
+
* Support surface for "env(safe-area-inset-*) is 0" reports — separates the UIKit side (view
|
|
386
|
+
* geometry, this snapshot) from the WebKit side (viewport-fit meta, see NATIVE_FEEL_JS): the field
|
|
387
|
+
* bug this was built for showed PERFECT native insets while a late page viewport meta without
|
|
388
|
+
* viewport-fit reverted cover and zeroed env(). Debug builds only. */
|
|
389
|
+
private snapshotSafeAreaDiag(tag: string): void {
|
|
390
|
+
const wk = this.nativeViewProtected as WKWebView;
|
|
391
|
+
if (!wk) return;
|
|
392
|
+
const r = (x: CGRect) => `${Math.round(x.origin.x)},${Math.round(x.origin.y)},${Math.round(x.size.width)}x${Math.round(x.size.height)}`;
|
|
393
|
+
const ins = (i: UIEdgeInsets) => `t${i.top} l${i.left} b${i.bottom} r${i.right}`;
|
|
394
|
+
const chain: string[] = [];
|
|
395
|
+
let v: UIView | null = wk;
|
|
396
|
+
while (v) { chain.push(`${NSStringFromClass(v.class())}[${r(v.frame)}]sa(${ins(v.safeAreaInsets)})`); v = v.superview; }
|
|
397
|
+
const win = wk.window;
|
|
398
|
+
const diag = {
|
|
399
|
+
tag,
|
|
400
|
+
frame: r(wk.frame), bounds: r(wk.bounds),
|
|
401
|
+
safeAreaInsets: ins(wk.safeAreaInsets),
|
|
402
|
+
behavior: wk.scrollView.contentInsetAdjustmentBehavior,
|
|
403
|
+
adjusted: ins(wk.scrollView.adjustedContentInset),
|
|
404
|
+
contentInset: ins(wk.scrollView.contentInset),
|
|
405
|
+
inWindow: !!win,
|
|
406
|
+
windowSA: win ? ins(win.safeAreaInsets) : 'nil',
|
|
407
|
+
windowFrame: win ? r(win.frame) : 'nil',
|
|
408
|
+
chain: chain.join(' < '),
|
|
409
|
+
};
|
|
410
|
+
const js = `window.__appwrapSafeAreaDiag=${JSON.stringify(JSON.stringify(diag))};`;
|
|
411
|
+
wk.evaluateJavaScriptCompletionHandler(js, () => { /* fire-and-forget */ });
|
|
412
|
+
appendWebLog(`[native:sa-diag] ${JSON.stringify(diag)}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
366
415
|
initNativeView(): void {
|
|
367
416
|
super.initNativeView();
|
|
417
|
+
// Debug builds: delayed safe-area snapshots after the view settles in-window (see method doc).
|
|
418
|
+
if (SHELL_CONFIG.debug) {
|
|
419
|
+
for (const ms of [1500, 4000, 8000]) setTimeout(() => this.snapshotSafeAreaDiag(`t+${ms}`), ms);
|
|
420
|
+
}
|
|
368
421
|
// NativeScript's WebView assigns its own wkWebView.UIDelegate during init,
|
|
369
422
|
// which clobbers the media-capture grant set in createNativeView — so WebKit
|
|
370
423
|
// falls back to prompting on every getUserMedia call. Reassert ours last so
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import type { AndroidActivityNewIntentEventData } from '@nativescript/core';
|
|
3
3
|
import { SHELL_CONFIG } from './config';
|
|
4
|
+
import { bridge } from './bridge';
|
|
4
5
|
import { onDeepLink, setDeepLinkTransformer } from './events';
|
|
6
|
+
import { MAILBOX_KEY, drainShareMailbox, type ShareMailboxStore } from './share-mailbox';
|
|
7
|
+
|
|
8
|
+
/** App Group UserDefaults key holding the app-published SHARE CONTEXT (JSON string KV). Written by
|
|
9
|
+
* `shareTarget.setContext`, read by the AppwrapShare extension to resolve direct-sync `{key}`
|
|
10
|
+
* template placeholders. Must match ShareViewController.swift. */
|
|
11
|
+
const CONTEXT_KEY = 'appwrap-share-context';
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
|
-
* shareTarget (INBOUND share sheet) — Android. The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
14
|
+
* shareTarget (INBOUND share sheet) — Android. The SEND(_MULTIPLE) intent-filters live on the
|
|
15
|
+
* LIGHTWEIGHT `cc.appwrap.share.AppwrapShareActivity` (module Kotlin source, translucent — the iOS
|
|
16
|
+
* drawer's parity): it direct-syncs when configured, and on any failure launches the app with the
|
|
17
|
+
* `<urlScheme>://share?…` deep link below (a VIEW intent → the normal deep-link path). The SEND
|
|
18
|
+
* handler here remains for builds whose manifest still carries the filters on the main activity
|
|
19
|
+
* (pre-parity installs): it reads the intent (cold launch + warm onNewIntent) and re-delivers
|
|
20
|
+
* through the EXISTING deep-link path as a synthetic URL — inheriting the proven cold-start
|
|
21
|
+
* buffering (handshake `deepLink`) and warm `deeplink.open` event with no new bridge surface.
|
|
12
22
|
*
|
|
13
23
|
* URL contract (parsed by kit.shareTarget, also directly consumable via kit.lifecycle.onDeepLink):
|
|
14
24
|
* <urlScheme>://share?text=<enc>&title=<enc>&file=<enc>&file=<enc>…
|
|
@@ -17,17 +27,47 @@ import { onDeepLink, setDeepLinkTransformer } from './events';
|
|
|
17
27
|
* - `file` — repeated; each is a path RELATIVE TO THE APP CACHE DIR (`appwrap-share/…`) where the
|
|
18
28
|
* shared stream (image) was copied — read it via `kit.fs.read(p, { dir: 'cache', encoding: 'base64' })`.
|
|
19
29
|
*
|
|
20
|
-
* iOS: the generated `AppwrapShare` extension (this module's nativeSrc)
|
|
21
|
-
* SAME URL
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
30
|
+
* iOS: the generated `AppwrapShare` extension (this module's nativeSrc) PERSISTS the payload as the
|
|
31
|
+
* SAME URL into an App-Group "mailbox" (UserDefaults key `appwrap-share-mailbox`, a string array) —
|
|
32
|
+
* modern iOS silently blocks a share extension from launching its host via `openURL:`, so there is
|
|
33
|
+
* no live hand-off; the host drains the mailbox on cold launch and on every foreground/resume
|
|
34
|
+
* (read-once) and feeds each URL through the normal deep-link path (cold: buffered into the
|
|
35
|
+
* handshake `deepLink`; warm: `deeplink.open` event). Text rides in the URL directly; images land in
|
|
36
|
+
* the App Group container as `gfile=<name>` params, which the transformer below relocates into the
|
|
37
|
+
* app cache and rewrites to `file=` (cache-relative) so the contract the web app sees is
|
|
38
|
+
* platform-identical.
|
|
25
39
|
*/
|
|
26
40
|
export function registerShareTargetHandlers(): void {
|
|
41
|
+
// Share-context KV (kit.shareTarget.setContext) — generic, app-opaque. iOS persists it in the App
|
|
42
|
+
// Group (read by the AppwrapShare extension); Android persists it in SharedPreferences
|
|
43
|
+
// `appwrap-share`/`context` (read by cc.appwrap.share.AppwrapShareActivity's direct sync).
|
|
44
|
+
bridge.register('shareTarget.setContext', ({ context }: { context?: Record<string, string | number> | null }) => {
|
|
45
|
+
const json = context && typeof context === 'object' ? JSON.stringify(context) : null;
|
|
46
|
+
if (isIOS) {
|
|
47
|
+
const d = NSUserDefaults.alloc().initWithSuiteName(`group.${SHELL_CONFIG.appId}`);
|
|
48
|
+
if (!d) return;
|
|
49
|
+
if (json) d.setObjectForKey(json, CONTEXT_KEY);
|
|
50
|
+
else d.removeObjectForKey(CONTEXT_KEY);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!isAndroid) return;
|
|
54
|
+
const prefs = (Utils.android.getApplicationContext() as android.content.Context)
|
|
55
|
+
.getSharedPreferences('appwrap-share', 0 /* MODE_PRIVATE */);
|
|
56
|
+
const ed = prefs.edit();
|
|
57
|
+
if (json) ed.putString('context', json);
|
|
58
|
+
else ed.remove('context');
|
|
59
|
+
ed.apply();
|
|
60
|
+
});
|
|
27
61
|
if (isIOS) {
|
|
28
62
|
// Rewrite share links carrying App-Group files (gfile=) → cache files (file=). Text-only share
|
|
29
63
|
// links (no gfile) pass through untouched.
|
|
30
64
|
setDeepLinkTransformer((url) => (url.includes('://share?') && url.includes('gfile=') ? iosRelocateGroupFiles(url) : url));
|
|
65
|
+
const drain = () => {
|
|
66
|
+
try { drainShareMailbox(iosMailboxStore(), onDeepLink); }
|
|
67
|
+
catch (e) { console.warn('AppWrap: share mailbox drain failed', e); }
|
|
68
|
+
};
|
|
69
|
+
drain(); // cold launch — a share made while the app was killed is waiting in the mailbox
|
|
70
|
+
Application.on(Application.resumeEvent, drain); // warm — user shared, then switched back to the app
|
|
31
71
|
return;
|
|
32
72
|
}
|
|
33
73
|
if (!isAndroid) return;
|
|
@@ -38,6 +78,23 @@ export function registerShareTargetHandlers(): void {
|
|
|
38
78
|
deliverShareIntent(Application.android.startActivity?.getIntent?.());
|
|
39
79
|
}
|
|
40
80
|
|
|
81
|
+
// ── iOS App-Group mailbox (written by the AppwrapShare extension, drained by the host) ──
|
|
82
|
+
// Drain semantics (read-once, crash-safe) are PURE and live in share-mailbox.ts (bun-tested).
|
|
83
|
+
|
|
84
|
+
/** The real iOS store: `group.<appId>` suite UserDefaults, string-array under MAILBOX_KEY. */
|
|
85
|
+
function iosMailboxStore(): ShareMailboxStore {
|
|
86
|
+
const defaults = NSUserDefaults.alloc().initWithSuiteName(`group.${SHELL_CONFIG.appId}`);
|
|
87
|
+
return {
|
|
88
|
+
read: () => {
|
|
89
|
+
const arr = defaults?.arrayForKey(MAILBOX_KEY);
|
|
90
|
+
const out: string[] = [];
|
|
91
|
+
for (let i = 0; arr && i < arr.count; i++) out.push(String(arr.objectAtIndex(i)));
|
|
92
|
+
return out;
|
|
93
|
+
},
|
|
94
|
+
clear: () => { defaults?.removeObjectForKey(MAILBOX_KEY); },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
41
98
|
/** iOS: move each `gfile=<name>` from `<AppGroup>/appwrap-share/` into `<Caches>/appwrap-share/` and
|
|
42
99
|
* rewrite the param to `file=appwrap-share/<name>` (the fs `cache` root), matching Android's shape.
|
|
43
100
|
* Any file that can't be moved (missing group container, purged file) is dropped from the link;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE iOS shareTarget mailbox-drain logic (no NativeScript imports; bun-tested — mirrors
|
|
3
|
+
* geo-auth.ts/notif-identity.ts: the semantics live here, handlers-share-target.ts only does the
|
|
4
|
+
* NSUserDefaults plumbing).
|
|
5
|
+
*
|
|
6
|
+
* WHY a mailbox: modern iOS silently blocks a share extension's responder-chain `openURL:` — a share
|
|
7
|
+
* extension may NOT launch its host app. So the generated `AppwrapShare` extension durably appends
|
|
8
|
+
* the share URL (`<scheme>://share?…`) to an App-Group UserDefaults string array under MAILBOX_KEY,
|
|
9
|
+
* and the HOST drains it on cold launch and on every foreground/resume. Read-once: the drain clears
|
|
10
|
+
* the mailbox BEFORE delivering, so overlapping drains can never double-deliver; crash-safety: the
|
|
11
|
+
* extension's write persists until a drain actually runs, and one runs on every foreground.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** App-Group UserDefaults key the extension appends to (see ShareViewController.swift). */
|
|
15
|
+
export const MAILBOX_KEY = 'appwrap-share-mailbox';
|
|
16
|
+
|
|
17
|
+
/** Minimal seam over the App-Group UserDefaults mailbox — injectable for tests. */
|
|
18
|
+
export interface ShareMailboxStore {
|
|
19
|
+
read(): string[];
|
|
20
|
+
clear(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Drain the share mailbox: read all pending share URLs, CLEAR first (exactly-once), then deliver
|
|
25
|
+
* each through `deliver` (the normal deep-link ingress: cold launches buffer into the handshake
|
|
26
|
+
* `deepLink`, warm apps get `deeplink.open`). Non-share/garbage entries are dropped. Returns the
|
|
27
|
+
* number of URLs delivered.
|
|
28
|
+
*/
|
|
29
|
+
export function drainShareMailbox(store: ShareMailboxStore, deliver: (url: string) => void): number {
|
|
30
|
+
const urls = store.read();
|
|
31
|
+
if (!urls.length) return 0;
|
|
32
|
+
store.clear(); // read-once: clear BEFORE delivering so re-entry during delivery can't duplicate
|
|
33
|
+
let delivered = 0;
|
|
34
|
+
for (const u of urls) {
|
|
35
|
+
if (typeof u !== 'string' || !u.includes('://share?')) continue;
|
|
36
|
+
delivered++;
|
|
37
|
+
deliver(u);
|
|
38
|
+
}
|
|
39
|
+
return delivered;
|
|
40
|
+
}
|
|
@@ -296,14 +296,21 @@ export const NATIVE_FEEL_JS = `(function(){
|
|
|
296
296
|
window.__appwrapNativeFeel = true;
|
|
297
297
|
function apply(){
|
|
298
298
|
var head = document.head || document.documentElement;
|
|
299
|
-
|
|
299
|
+
// Normalize the LAST viewport meta — WebKit honors the last one parsed, so patching the first
|
|
300
|
+
// (or an injected one) is silently overridden when the page's own meta appears later in <head>.
|
|
301
|
+
// Device-verified failure: injected meta had viewport-fit=cover, the site's own plain
|
|
302
|
+
// "width=device-width, initial-scale=1.0" meta parsed after it → viewport-fit reverted to auto
|
|
303
|
+
// → env(safe-area-inset-*) = 0 despite correct native safeAreaInsets (t47/b34).
|
|
304
|
+
var metas = document.querySelectorAll('meta[name=viewport]');
|
|
305
|
+
var vp = metas.length ? metas[metas.length - 1] : null;
|
|
300
306
|
if (!vp){ vp = document.createElement('meta'); vp.setAttribute('name','viewport'); head.appendChild(vp); }
|
|
301
307
|
var c = vp.getAttribute('content') || 'width=device-width, initial-scale=1';
|
|
302
308
|
if (!/user-scalable\\s*=\\s*no/.test(c)) c += ', maximum-scale=1, user-scalable=no';
|
|
303
309
|
// viewport-fit=cover makes env(safe-area-inset-*) resolve to real notch /
|
|
304
310
|
// home-indicator insets — the WebView owns the safe areas (contentInset .never).
|
|
311
|
+
// A page's own explicit viewport-fit is respected (only appended when absent).
|
|
305
312
|
if (!/viewport-fit/.test(c)) c += ', viewport-fit=cover';
|
|
306
|
-
vp.setAttribute('content', c);
|
|
313
|
+
if (vp.getAttribute('content') !== c) vp.setAttribute('content', c);
|
|
307
314
|
if (!document.getElementById('__appwrap_feel')){
|
|
308
315
|
var s = document.createElement('style'); s.id = '__appwrap_feel';
|
|
309
316
|
s.textContent = ${JSON.stringify(NATIVE_FEEL_CSS)};
|
|
@@ -322,6 +329,22 @@ export const NATIVE_FEEL_JS = `(function(){
|
|
|
322
329
|
}
|
|
323
330
|
apply();
|
|
324
331
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply);
|
|
332
|
+
// SPA head managers (React Helmet, Next, vue-meta) add/rewrite the viewport meta AFTER load —
|
|
333
|
+
// re-normalize whenever one changes. apply() is idempotent (only writes on an actual content
|
|
334
|
+
// change), so the observer can't loop on its own writes.
|
|
335
|
+
try {
|
|
336
|
+
new MutationObserver(function(muts){
|
|
337
|
+
for (var i = 0; i < muts.length; i++){
|
|
338
|
+
var m = muts[i];
|
|
339
|
+
var t = m.target;
|
|
340
|
+
if (t && t.nodeName === 'META' && t.getAttribute('name') === 'viewport') return apply();
|
|
341
|
+
for (var j = 0; j < (m.addedNodes ? m.addedNodes.length : 0); j++){
|
|
342
|
+
var n = m.addedNodes[j];
|
|
343
|
+
if (n.nodeName === 'META' && n.getAttribute && n.getAttribute('name') === 'viewport') return apply();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['content', 'name'] });
|
|
347
|
+
} catch(e) { /* observer is a hardening layer; core apply() already ran */ }
|
|
325
348
|
// Pinch-zoom on iOS fires gesture* even with user-scalable=no in some builds.
|
|
326
349
|
document.addEventListener('gesturestart', function(e){ e.preventDefault(); }, { passive: false });
|
|
327
350
|
})();`;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
package cc.appwrap.share
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.graphics.Bitmap
|
|
6
|
+
import android.graphics.BitmapFactory
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import android.os.Bundle
|
|
9
|
+
import android.util.Base64
|
|
10
|
+
import android.widget.Toast
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
import java.io.ByteArrayOutputStream
|
|
13
|
+
import java.io.File
|
|
14
|
+
import java.net.HttpURLConnection
|
|
15
|
+
import java.net.URL
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* appwrap shareTarget — Android lightweight share handler (generated per-app by the CLI, active only
|
|
19
|
+
* when the `shareTarget` module is on). Declared translucent + no-history in the manifest (see the
|
|
20
|
+
* module's `manifestApplication` XML), so tapping the app in the share sheet does NOT launch the full
|
|
21
|
+
* app/WebView — this activity handles the SEND intent invisibly, exactly like the iOS share
|
|
22
|
+
* extension handles the drawer:
|
|
23
|
+
*
|
|
24
|
+
* 1. DIRECT SYNC (config `shareTarget.directSync`, stamped as `__SHARE_SYNC_B64__` — base64 JSON,
|
|
25
|
+
* empty = off): one HTTP call to the app's backend with a "Syncing… → ✓ <successMessage>" toast.
|
|
26
|
+
* `{key}` placeholders resolve from the SHARE CONTEXT the web app published via
|
|
27
|
+
* `kit.shareTarget.setContext(...)` (SharedPreferences `appwrap-share` / key `context` — the
|
|
28
|
+
* Android sibling of iOS's App-Group `appwrap-share-context`). `merge:"append"` GETs first,
|
|
29
|
+
* appends text (newline-joined) and preserves the existing image unless a new one is shared.
|
|
30
|
+
* Oversized images are downscaled (longest edge → maxImageEdge, JPEG jpegQuality) first.
|
|
31
|
+
*
|
|
32
|
+
* 2. FALLBACK (any failure: offline, non-2xx, no/unresolved context, >1 image, still-oversized
|
|
33
|
+
* image): launch the main activity with the SAME deep link Android shares always used —
|
|
34
|
+
* `<urlScheme>://share?text=…&file=<cache-relative>…` — so the share is never lost and the JS
|
|
35
|
+
* contract is unchanged (see runtime/app/shell/handlers-share-target.ts).
|
|
36
|
+
*
|
|
37
|
+
* Build-time tokens (stamped by `appwrap init`/`sync`): __URL_SCHEME__, __SHARE_SYNC_B64__.
|
|
38
|
+
*/
|
|
39
|
+
class AppwrapShareActivity : Activity() {
|
|
40
|
+
private val syncB64 = "__SHARE_SYNC_B64__"
|
|
41
|
+
private val urlScheme = "__URL_SCHEME__"
|
|
42
|
+
|
|
43
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
44
|
+
super.onCreate(savedInstanceState)
|
|
45
|
+
try {
|
|
46
|
+
handleShare()
|
|
47
|
+
} catch (e: Exception) {
|
|
48
|
+
android.util.Log.w("AppwrapShare", "share handling failed", e)
|
|
49
|
+
finish()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private fun handleShare() {
|
|
54
|
+
val action = intent?.action
|
|
55
|
+
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) { finish(); return }
|
|
56
|
+
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
|
|
57
|
+
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT) ?: ""
|
|
58
|
+
val uris = streamUris(action)
|
|
59
|
+
if (text.isEmpty() && uris.isEmpty()) { finish(); return }
|
|
60
|
+
|
|
61
|
+
val plan = directSyncPlan(text, uris)
|
|
62
|
+
if (plan == null) {
|
|
63
|
+
android.util.Log.i("AppwrapShare", "direct sync unavailable (no config/context/resolvable url or multi-image) — falling back to app launch")
|
|
64
|
+
fallbackToApp(text, title, uris); return
|
|
65
|
+
}
|
|
66
|
+
android.util.Log.i("AppwrapShare", "direct sync → ${plan.method} ${plan.url} (append=${plan.append}, image=${plan.imageDataUrl != null})")
|
|
67
|
+
Toast.makeText(applicationContext, "Syncing…", Toast.LENGTH_SHORT).show()
|
|
68
|
+
Thread {
|
|
69
|
+
val ok = runDirectSync(plan)
|
|
70
|
+
runOnUiThread {
|
|
71
|
+
if (ok) {
|
|
72
|
+
Toast.makeText(applicationContext, "✓ ${plan.successText}", Toast.LENGTH_SHORT).show()
|
|
73
|
+
finish()
|
|
74
|
+
} else {
|
|
75
|
+
fallbackToApp(text, title, uris) // never lose a share
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}.start()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun streamUris(action: String): List<Uri> = try {
|
|
82
|
+
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
|
83
|
+
@Suppress("DEPRECATION")
|
|
84
|
+
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.filterNotNull() ?: emptyList()
|
|
85
|
+
} else {
|
|
86
|
+
@Suppress("DEPRECATION")
|
|
87
|
+
(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { listOf(it) } ?: emptyList()
|
|
88
|
+
}
|
|
89
|
+
} catch (e: Exception) {
|
|
90
|
+
android.util.Log.w("AppwrapShare", "stream extras read failed", e)
|
|
91
|
+
emptyList()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── direct sync ─────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
private class SyncPlan(
|
|
97
|
+
val url: String, val method: String, val textField: String, val imageField: String,
|
|
98
|
+
val append: Boolean, val text: String, val imageDataUrl: String?, val successText: String,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
/** Decide whether THIS share can direct-sync: config stamped, context published, url template
|
|
102
|
+
* fully resolved, ≤1 image, image under the size cap (after downscale). null → fallback. */
|
|
103
|
+
private fun directSyncPlan(text: String, uris: List<Uri>): SyncPlan? {
|
|
104
|
+
if (syncB64.isEmpty()) return null
|
|
105
|
+
if (uris.size > 1) return null // multi-image keeps full fidelity via the app
|
|
106
|
+
val cfg = try { JSONObject(String(Base64.decode(syncB64, Base64.DEFAULT), Charsets.UTF_8)) }
|
|
107
|
+
catch (e: Exception) { return null }
|
|
108
|
+
val ctx = shareContext() ?: return null
|
|
109
|
+
val url = resolveTemplate(cfg.optString("urlTemplate"), ctx) ?: return null
|
|
110
|
+
if (!url.startsWith("http")) return null
|
|
111
|
+
|
|
112
|
+
var imageDataUrl: String? = null
|
|
113
|
+
if (uris.isNotEmpty()) {
|
|
114
|
+
imageDataUrl = encodeImage(
|
|
115
|
+
uris[0],
|
|
116
|
+
cfg.optInt("maxImageBytes", 4_000_000),
|
|
117
|
+
cfg.optInt("maxImageEdge", 2000),
|
|
118
|
+
cfg.optDouble("jpegQuality", 0.85),
|
|
119
|
+
) ?: return null // undecodable / still oversized → fallback keeps the ORIGINAL bytes
|
|
120
|
+
}
|
|
121
|
+
val fields = cfg.optJSONObject("fields")
|
|
122
|
+
return SyncPlan(
|
|
123
|
+
url = url,
|
|
124
|
+
method = cfg.optString("method", "PUT").ifEmpty { "PUT" },
|
|
125
|
+
textField = fields?.optString("text").takeUnless { it.isNullOrEmpty() } ?: "content",
|
|
126
|
+
imageField = fields?.optString("image").takeUnless { it.isNullOrEmpty() } ?: "image",
|
|
127
|
+
append = cfg.optString("merge") == "append",
|
|
128
|
+
text = text,
|
|
129
|
+
imageDataUrl = imageDataUrl,
|
|
130
|
+
successText = resolveTemplate(cfg.optString("successMessage").ifEmpty { "Synced" }, ctx) ?: "Synced",
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The app-published share context (SharedPreferences `appwrap-share`/`context`, JSON KV —
|
|
135
|
+
* written by the shell's `shareTarget.setContext` handler). Values stringified for templates. */
|
|
136
|
+
private fun shareContext(): Map<String, String>? = try {
|
|
137
|
+
val raw = getSharedPreferences("appwrap-share", MODE_PRIVATE).getString("context", null) ?: return null
|
|
138
|
+
val obj = JSONObject(raw)
|
|
139
|
+
val out = mutableMapOf<String, String>()
|
|
140
|
+
for (k in obj.keys()) out[k] = obj.get(k).toString()
|
|
141
|
+
out
|
|
142
|
+
} catch (e: Exception) { null }
|
|
143
|
+
|
|
144
|
+
/** Replace `{key}` placeholders; null when any stays unresolved — a partially-resolved URL must
|
|
145
|
+
* never be called. */
|
|
146
|
+
private fun resolveTemplate(template: String, ctx: Map<String, String>): String? {
|
|
147
|
+
if (template.isEmpty()) return null
|
|
148
|
+
var s = template
|
|
149
|
+
for ((k, v) in ctx) s = s.replace("{$k}", v)
|
|
150
|
+
return if (Regex("\\{[^}]+\\}").containsMatchIn(s)) null else s
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Shared image → base64 data URL under `maxBytes`; oversized inputs are downscaled (longest
|
|
154
|
+
* edge → maxEdge px, JPEG at quality) via inSampleSize decode (no full-size bitmap → no OOM).
|
|
155
|
+
* null on decode failure or still-over-cap. */
|
|
156
|
+
private fun encodeImage(uri: Uri, maxBytes: Int, maxEdge: Int, quality: Double): String? {
|
|
157
|
+
try {
|
|
158
|
+
val raw = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return null
|
|
159
|
+
val asIs = "data:${sniffMime(raw)};base64,${Base64.encodeToString(raw, Base64.NO_WRAP)}"
|
|
160
|
+
if (asIs.length <= maxBytes) return asIs
|
|
161
|
+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
162
|
+
BitmapFactory.decodeByteArray(raw, 0, raw.size, bounds)
|
|
163
|
+
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
|
164
|
+
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
|
165
|
+
var sample = 1
|
|
166
|
+
while (longest / (sample * 2) >= maxEdge) sample *= 2 // power-of-2 pre-scale keeps memory bounded
|
|
167
|
+
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
|
168
|
+
var bmp = BitmapFactory.decodeByteArray(raw, 0, raw.size, opts) ?: return null
|
|
169
|
+
val edge = maxOf(bmp.width, bmp.height)
|
|
170
|
+
if (edge > maxEdge) { // exact final scale (inSampleSize only gets within 2x)
|
|
171
|
+
val scale = maxEdge.toFloat() / edge
|
|
172
|
+
val scaled = Bitmap.createScaledBitmap(
|
|
173
|
+
bmp, maxOf(1, (bmp.width * scale).toInt()), maxOf(1, (bmp.height * scale).toInt()), true
|
|
174
|
+
)
|
|
175
|
+
if (scaled !== bmp) bmp.recycle()
|
|
176
|
+
bmp = scaled
|
|
177
|
+
}
|
|
178
|
+
val out = ByteArrayOutputStream()
|
|
179
|
+
bmp.compress(Bitmap.CompressFormat.JPEG, (quality * 100).toInt().coerceIn(1, 100), out)
|
|
180
|
+
bmp.recycle()
|
|
181
|
+
val small = "data:image/jpeg;base64,${Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)}"
|
|
182
|
+
return if (small.length <= maxBytes) small else null
|
|
183
|
+
} catch (e: Exception) {
|
|
184
|
+
android.util.Log.w("AppwrapShare", "image encode failed", e)
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private fun sniffMime(d: ByteArray): String = when {
|
|
190
|
+
d.size > 3 && d[0] == 0x89.toByte() && d[1] == 0x50.toByte() -> "image/png"
|
|
191
|
+
d.size > 1 && d[0] == 0xFF.toByte() && d[1] == 0xD8.toByte() -> "image/jpeg"
|
|
192
|
+
d.size > 2 && d[0] == 0x47.toByte() && d[1] == 0x49.toByte() -> "image/gif"
|
|
193
|
+
d.size > 11 && d[8] == 0x57.toByte() && d[9] == 0x45.toByte() -> "image/webp"
|
|
194
|
+
else -> "image/jpeg"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Execute the sync on a background thread: (append) GET current → merge → write; else write
|
|
198
|
+
* as-is. Mirrors ShareViewController.runDirectSync. Returns success. */
|
|
199
|
+
private fun runDirectSync(plan: SyncPlan): Boolean = try {
|
|
200
|
+
val existing: JSONObject = if (plan.append) {
|
|
201
|
+
val (code, body) = http("GET", plan.url, null)
|
|
202
|
+
android.util.Log.i("AppwrapShare", "direct sync merge GET → HTTP $code")
|
|
203
|
+
when {
|
|
204
|
+
code == 404 -> JSONObject() // nothing there yet — first write
|
|
205
|
+
code in 200..299 -> try { JSONObject(body) } catch (e: Exception) { JSONObject() }
|
|
206
|
+
else -> return false // offline / server error → fallback
|
|
207
|
+
}
|
|
208
|
+
} else JSONObject()
|
|
209
|
+
|
|
210
|
+
val oldText = existing.optString(plan.textField)
|
|
211
|
+
val merged = if (plan.append && oldText.isNotEmpty() && plan.text.isNotEmpty()) "$oldText\n${plan.text}"
|
|
212
|
+
else plan.text.ifEmpty { oldText }
|
|
213
|
+
val body = JSONObject().put(plan.textField, merged)
|
|
214
|
+
// New image replaces; append mode preserves the existing one (the endpoint is a full PUT).
|
|
215
|
+
when {
|
|
216
|
+
plan.imageDataUrl != null -> body.put(plan.imageField, plan.imageDataUrl)
|
|
217
|
+
plan.append && existing.has(plan.imageField) -> body.put(plan.imageField, existing.get(plan.imageField))
|
|
218
|
+
else -> body.put(plan.imageField, JSONObject.NULL)
|
|
219
|
+
}
|
|
220
|
+
val (code, _) = http(plan.method, plan.url, body.toString())
|
|
221
|
+
android.util.Log.i("AppwrapShare", "direct sync write → HTTP $code")
|
|
222
|
+
code in 200..299
|
|
223
|
+
} catch (e: Exception) {
|
|
224
|
+
android.util.Log.w("AppwrapShare", "direct sync failed", e)
|
|
225
|
+
false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private fun http(method: String, url: String, jsonBody: String?): Pair<Int, String> {
|
|
229
|
+
val conn = URL(url).openConnection() as HttpURLConnection
|
|
230
|
+
try {
|
|
231
|
+
conn.requestMethod = method
|
|
232
|
+
conn.connectTimeout = 15000
|
|
233
|
+
conn.readTimeout = 15000
|
|
234
|
+
if (jsonBody != null) {
|
|
235
|
+
conn.doOutput = true
|
|
236
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
237
|
+
conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) }
|
|
238
|
+
}
|
|
239
|
+
val code = conn.responseCode
|
|
240
|
+
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
|
241
|
+
val body = stream?.use { it.readBytes().toString(Charsets.UTF_8) } ?: ""
|
|
242
|
+
return Pair(code, body)
|
|
243
|
+
} finally {
|
|
244
|
+
conn.disconnect()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── fallback: hand the share to the full app via the existing deep-link path ──
|
|
249
|
+
|
|
250
|
+
/** Launch the main activity with `<scheme>://share?…` (the pre-direct-sync contract): text/title
|
|
251
|
+
* in the URL, streams copied into `<cacheDir>/appwrap-share/` as cache-relative `file=` params —
|
|
252
|
+
* the same shape handlers-share-target.ts produced when the SEND filter sat on the main activity. */
|
|
253
|
+
private fun fallbackToApp(text: String, title: String, uris: List<Uri>) {
|
|
254
|
+
try {
|
|
255
|
+
val params = mutableListOf<String>()
|
|
256
|
+
if (text.isNotEmpty()) params.add("text=${Uri.encode(text)}")
|
|
257
|
+
if (title.isNotEmpty()) params.add("title=${Uri.encode(title)}")
|
|
258
|
+
for (rel in copyStreamsToCache(uris)) params.add("file=${Uri.encode(rel)}")
|
|
259
|
+
if (params.isNotEmpty() && urlScheme.isNotEmpty()) {
|
|
260
|
+
val open = Intent(Intent.ACTION_VIEW, Uri.parse("$urlScheme://share?${params.joinToString("&")}"))
|
|
261
|
+
open.setPackage(packageName)
|
|
262
|
+
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
|
263
|
+
startActivity(open)
|
|
264
|
+
}
|
|
265
|
+
} catch (e: Exception) {
|
|
266
|
+
android.util.Log.w("AppwrapShare", "fallback launch failed", e)
|
|
267
|
+
}
|
|
268
|
+
finish()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Copy each content:// stream into `<cacheDir>/appwrap-share/`; return cache-relative paths. */
|
|
272
|
+
private fun copyStreamsToCache(uris: List<Uri>): List<String> {
|
|
273
|
+
val out = mutableListOf<String>()
|
|
274
|
+
if (uris.isEmpty()) return out
|
|
275
|
+
val dir = File(cacheDir, "appwrap-share").apply { mkdirs() }
|
|
276
|
+
uris.forEachIndexed { i, uri ->
|
|
277
|
+
try {
|
|
278
|
+
val name = fileNameFor(uri, i)
|
|
279
|
+
contentResolver.openInputStream(uri)?.use { input ->
|
|
280
|
+
File(dir, name).outputStream().use { input.copyTo(it) }
|
|
281
|
+
out.add("appwrap-share/$name")
|
|
282
|
+
}
|
|
283
|
+
} catch (e: Exception) {
|
|
284
|
+
android.util.Log.w("AppwrapShare", "stream copy failed", e)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Display name when the provider offers one, else mime-derived; sanitized + timestamp-prefixed
|
|
291
|
+
* so repeated shares don't collide (mirrors handlers-share-target.ts fileNameFor). */
|
|
292
|
+
private fun fileNameFor(uri: Uri, i: Int): String {
|
|
293
|
+
var name = ""
|
|
294
|
+
try {
|
|
295
|
+
contentResolver.query(uri, null, null, null, null)?.use { c ->
|
|
296
|
+
val idx = c.getColumnIndex("_display_name")
|
|
297
|
+
if (idx >= 0 && c.moveToFirst()) name = c.getString(idx) ?: ""
|
|
298
|
+
}
|
|
299
|
+
} catch (e: Exception) { /* fall through to mime-derived name */ }
|
|
300
|
+
if (name.isEmpty()) {
|
|
301
|
+
val mime = contentResolver.getType(uri) ?: ""
|
|
302
|
+
val ext = if (mime.startsWith("image/")) mime.substring(6).replace(Regex("[^a-zA-Z0-9]"), "").ifEmpty { "img" } else "bin"
|
|
303
|
+
name = "shared-$i.$ext"
|
|
304
|
+
}
|
|
305
|
+
return "${System.currentTimeMillis()}-$i-${name.replace(Regex("[^\\w.-]"), "_")}"
|
|
306
|
+
}
|
|
307
|
+
}
|