typeferry 0.10.0 → 0.11.0
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/dist/application/build.d.ts +2 -2
- package/dist/application/build.js +8 -2
- package/dist/application/build.js.map +1 -1
- package/dist/application/cli-arguments.d.ts +6 -1
- package/dist/application/cli-arguments.js +9 -1
- package/dist/application/cli-arguments.js.map +1 -1
- package/dist/application/config.d.ts +38 -1
- package/dist/application/config.js +25 -0
- package/dist/application/config.js.map +1 -1
- package/dist/application/native-project.d.ts +16 -0
- package/dist/application/native-project.js +82 -0
- package/dist/application/native-project.js.map +1 -0
- package/dist/application/native.d.ts +2 -0
- package/dist/application/native.js +86 -0
- package/dist/application/native.js.map +1 -0
- package/dist/application/runtime.d.ts +13 -0
- package/dist/application/runtime.js +15 -0
- package/dist/application/runtime.js.map +1 -0
- package/dist/application/vite-config.d.ts +2 -2
- package/dist/application/vite-config.js +16 -2
- package/dist/application/vite-config.js.map +1 -1
- package/dist/auth/client/index.d.ts +4 -0
- package/dist/auth/client/index.js +2 -0
- package/dist/auth/client/index.js.map +1 -1
- package/dist/auth/client/native-http.d.ts +24 -0
- package/dist/auth/client/native-http.js +19 -0
- package/dist/auth/client/native-http.js.map +1 -0
- package/dist/auth/client/native-session.d.ts +34 -0
- package/dist/auth/client/native-session.js +65 -0
- package/dist/auth/client/native-session.js.map +1 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/server/native-handoff.d.ts +69 -0
- package/dist/auth/server/native-handoff.js +65 -0
- package/dist/auth/server/native-handoff.js.map +1 -0
- package/dist/cli/run.js +6 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/client/backend-origin.d.ts +17 -0
- package/dist/client/backend-origin.js +25 -0
- package/dist/client/backend-origin.js.map +1 -0
- package/dist/client/client-http.js +8 -3
- package/dist/client/client-http.js.map +1 -1
- package/dist/client/client-socket.js +6 -1
- package/dist/client/client-socket.js.map +1 -1
- package/dist/client/client.d.ts +9 -0
- package/dist/client/client.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/ejson/clone.js +7 -1
- package/dist/ejson/clone.js.map +1 -1
- package/dist/native/index.d.ts +76 -0
- package/dist/native/index.js +5 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/share-file.d.ts +18 -0
- package/dist/native/share-file.js +41 -0
- package/dist/native/share-file.js.map +1 -0
- package/dist/native/templates/bridge-view-controller.d.ts +4 -0
- package/dist/native/templates/bridge-view-controller.js +86 -0
- package/dist/native/templates/bridge-view-controller.js.map +1 -0
- package/dist/native/templates/file-staging.d.ts +4 -0
- package/dist/native/templates/file-staging.js +77 -0
- package/dist/native/templates/file-staging.js.map +1 -0
- package/dist/native/templates/http-session.d.ts +4 -0
- package/dist/native/templates/http-session.js +117 -0
- package/dist/native/templates/http-session.js.map +1 -0
- package/dist/native/templates/index.d.ts +4 -0
- package/dist/native/templates/index.js +20 -0
- package/dist/native/templates/index.js.map +1 -0
- package/dist/native/templates/keychain.d.ts +4 -0
- package/dist/native/templates/keychain.js +48 -0
- package/dist/native/templates/keychain.js.map +1 -0
- package/dist/native/templates/media-permission-policy.d.ts +4 -0
- package/dist/native/templates/media-permission-policy.js +24 -0
- package/dist/native/templates/media-permission-policy.js.map +1 -0
- package/dist/native/templates/plugin.d.ts +4 -0
- package/dist/native/templates/plugin.js +314 -0
- package/dist/native/templates/plugin.js.map +1 -0
- package/dist/native/templates/wake-locks.d.ts +4 -0
- package/dist/native/templates/wake-locks.js +47 -0
- package/dist/native/templates/wake-locks.js.map +1 -0
- package/dist/native/wake-lock.d.ts +9 -0
- package/dist/native/wake-lock.js +17 -0
- package/dist/native/wake-lock.js.map +1 -0
- package/package.json +18 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { PluginListenerHandle } from '@capacitor/core';
|
|
2
|
+
export interface NativeHTTPRequest {
|
|
3
|
+
url: string;
|
|
4
|
+
method: string;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
body?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface NativeHTTPResponse {
|
|
9
|
+
status: number;
|
|
10
|
+
statusText: string;
|
|
11
|
+
headers: Record<string, string>;
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
export interface NativeAppState {
|
|
15
|
+
isActive: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Optional iOS bridge. Import only from native application entry points.
|
|
19
|
+
* Origins and callback schemes are pinned by generated native configuration;
|
|
20
|
+
* JavaScript cannot expand the native trust boundary.
|
|
21
|
+
*/
|
|
22
|
+
export interface TypeFerryNativePlugin {
|
|
23
|
+
request(options: NativeHTTPRequest): Promise<NativeHTTPResponse>;
|
|
24
|
+
getSession(): Promise<{
|
|
25
|
+
value: string | null;
|
|
26
|
+
}>;
|
|
27
|
+
setSession(options: {
|
|
28
|
+
value: string;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
clearSession(): Promise<void>;
|
|
31
|
+
authenticate(options: {
|
|
32
|
+
url: string;
|
|
33
|
+
callbackScheme: string;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
url: string;
|
|
36
|
+
}>;
|
|
37
|
+
acquireWakeLock(): Promise<{
|
|
38
|
+
id: string;
|
|
39
|
+
}>;
|
|
40
|
+
releaseWakeLock(options: {
|
|
41
|
+
id: string;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
getState(): Promise<NativeAppState>;
|
|
44
|
+
addListener(eventName: 'appStateChange', listener: (state: NativeAppState) => void): Promise<PluginListenerHandle>;
|
|
45
|
+
beginFile(options: {
|
|
46
|
+
fileName: string;
|
|
47
|
+
size: number;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
id: string;
|
|
50
|
+
}>;
|
|
51
|
+
appendFile(options: {
|
|
52
|
+
id: string;
|
|
53
|
+
offset: number;
|
|
54
|
+
base64: string;
|
|
55
|
+
}): Promise<void>;
|
|
56
|
+
finishFile(options: {
|
|
57
|
+
id: string;
|
|
58
|
+
}): Promise<{
|
|
59
|
+
completed: boolean;
|
|
60
|
+
}>;
|
|
61
|
+
cancelFile(options: {
|
|
62
|
+
id: string;
|
|
63
|
+
}): Promise<void>;
|
|
64
|
+
shareFile(options: {
|
|
65
|
+
url: string;
|
|
66
|
+
fileName: string;
|
|
67
|
+
headers?: Record<string, string>;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
completed: boolean;
|
|
70
|
+
}>;
|
|
71
|
+
}
|
|
72
|
+
export declare const TypeFerryNative: TypeFerryNativePlugin;
|
|
73
|
+
export { NATIVE_FILE_CHUNK_BYTES, NATIVE_FILE_MAX_BYTES, shareNativeFile } from './share-file.js';
|
|
74
|
+
export type { ShareNativeFileOptions } from './share-file.js';
|
|
75
|
+
export { acquireNativeWakeLock } from './wake-lock.js';
|
|
76
|
+
export type { NativeWakeLock } from './wake-lock.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { registerPlugin } from '@capacitor/core';
|
|
2
|
+
export const TypeFerryNative = registerPlugin('TypeFerryNative');
|
|
3
|
+
export { NATIVE_FILE_CHUNK_BYTES, NATIVE_FILE_MAX_BYTES, shareNativeFile } from './share-file.js';
|
|
4
|
+
export { acquireNativeWakeLock } from './wake-lock.js';
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/native/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AA2ChD,MAAM,CAAC,MAAM,eAAe,GAAG,cAAc,CAAwB,iBAAiB,CAAC,CAAA;AAEvF,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAG9F,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const NATIVE_FILE_MAX_BYTES: number;
|
|
2
|
+
export declare const NATIVE_FILE_CHUNK_BYTES: number;
|
|
3
|
+
export interface ShareNativeFileOptions {
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
/**
|
|
6
|
+
* Reports bytes staged on device; completion still requires the share sheet.
|
|
7
|
+
*/
|
|
8
|
+
onProgress?: (bytes: number) => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Shares a local Blob without passing its entire contents over the native bridge.
|
|
12
|
+
* Native staging validates every offset and removes partial files after failure.
|
|
13
|
+
* Cancellation applies during staging; once presented, the system share sheet
|
|
14
|
+
* owns cancellation and returns completed=false when dismissed.
|
|
15
|
+
*/
|
|
16
|
+
export declare function shareNativeFile(file: Blob, fileName: string, options?: ShareNativeFileOptions): Promise<{
|
|
17
|
+
completed: boolean;
|
|
18
|
+
}>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { TypeFerryNative } from './index.js';
|
|
2
|
+
export const NATIVE_FILE_MAX_BYTES = 128 * 1024 * 1024;
|
|
3
|
+
export const NATIVE_FILE_CHUNK_BYTES = 48 * 1024;
|
|
4
|
+
const ENCODING_BLOCK_BYTES = 8192;
|
|
5
|
+
function encode(bytes) {
|
|
6
|
+
const blocks = [];
|
|
7
|
+
for (let index = 0; index < bytes.length; index += ENCODING_BLOCK_BYTES) {
|
|
8
|
+
blocks.push(String.fromCharCode(...bytes.subarray(index, index + ENCODING_BLOCK_BYTES)));
|
|
9
|
+
}
|
|
10
|
+
return btoa(blocks.join(''));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Shares a local Blob without passing its entire contents over the native bridge.
|
|
14
|
+
* Native staging validates every offset and removes partial files after failure.
|
|
15
|
+
* Cancellation applies during staging; once presented, the system share sheet
|
|
16
|
+
* owns cancellation and returns completed=false when dismissed.
|
|
17
|
+
*/
|
|
18
|
+
export async function shareNativeFile(file, fileName, options = {}) {
|
|
19
|
+
if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > NATIVE_FILE_MAX_BYTES) {
|
|
20
|
+
throw new Error('Native file sharing supports files up to 128 MiB.');
|
|
21
|
+
}
|
|
22
|
+
options.signal?.throwIfAborted();
|
|
23
|
+
const { id } = await TypeFerryNative.beginFile({ fileName, size: file.size });
|
|
24
|
+
try {
|
|
25
|
+
for (let offset = 0; offset < file.size; offset += NATIVE_FILE_CHUNK_BYTES) {
|
|
26
|
+
options.signal?.throwIfAborted();
|
|
27
|
+
const bytes = new Uint8Array(await file.slice(offset, offset + NATIVE_FILE_CHUNK_BYTES).arrayBuffer());
|
|
28
|
+
options.signal?.throwIfAborted();
|
|
29
|
+
await TypeFerryNative.appendFile({ id, offset, base64: encode(bytes) });
|
|
30
|
+
options.onProgress?.(offset + bytes.length);
|
|
31
|
+
}
|
|
32
|
+
options.signal?.throwIfAborted();
|
|
33
|
+
return await TypeFerryNative.finishFile({ id });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
// Cleanup failure must not hide the original failure; native staging also expires.
|
|
37
|
+
await TypeFerryNative.cancelFile({ id }).catch(() => undefined);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=share-file.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"share-file.js","sourceRoot":"","sources":["../../src/native/share-file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAEzC,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAA;AACtD,MAAM,CAAC,MAAM,uBAAuB,GAAG,EAAE,GAAG,IAAI,CAAA;AAChD,MAAM,oBAAoB,GAAG,IAAI,CAAA;AAUjC,SAAS,MAAM,CAAC,KAAiB;IAC/B,MAAM,MAAM,GAAa,EAAE,CAAA;IAE3B,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,IAAI,oBAAoB,EAAE,CAAC;QACxE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,oBAAoB,CAAC,CAAC,CAAC,CAAA;IAC1F,CAAC;IAED,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;AAC9B,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAU,EAAE,QAAgB,EAAE,UAAkC,EAAE;IACtG,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,IAAI,CAAC,IAAI,GAAG,qBAAqB,EAAE,CAAC;QAC3F,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAA;IACtE,CAAC;IAED,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAA;IAEhC,MAAM,EAAE,EAAE,EAAE,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAA;IAE7E,IAAI,CAAC;QACH,KAAK,IAAI,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC,IAAI,EAAE,MAAM,IAAI,uBAAuB,EAAE,CAAC;YAC3E,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAA;YAEhC,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,uBAAuB,CAAC,CAAC,WAAW,EAAE,CAAC,CAAA;YAEtG,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAA;YAChC,MAAM,eAAe,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;YACvE,OAAO,CAAC,UAAU,EAAE,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAA;QAC7C,CAAC;QAED,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAA;QAEhC,OAAO,MAAM,eAAe,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC,CAAA;IACjD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,mFAAmF;QACnF,MAAM,eAAe,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;QAC/D,MAAM,KAAK,CAAA;IACb,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps Capacitor's existing UI delegate intact except for media permission decisions.
|
|
3
|
+
*/
|
|
4
|
+
export const BRIDGE_VIEW_CONTROLLER_SWIFT = String.raw `import AVFoundation
|
|
5
|
+
import Capacitor
|
|
6
|
+
import WebKit
|
|
7
|
+
|
|
8
|
+
@objc(TypeFerryBridgeViewController)
|
|
9
|
+
class TypeFerryBridgeViewController: CAPBridgeViewController {
|
|
10
|
+
private var mediaDelegate: TypeFerryMediaDelegate?
|
|
11
|
+
|
|
12
|
+
override func capacitorDidLoad() {
|
|
13
|
+
bridge?.registerPluginInstance(TypeFerryNativePlugin())
|
|
14
|
+
guard let webView = webView, let bridge = bridge else { return }
|
|
15
|
+
let delegate = TypeFerryMediaDelegate(original: webView.uiDelegate, localURL: bridge.config.localURL)
|
|
16
|
+
mediaDelegate = delegate
|
|
17
|
+
webView.uiDelegate = delegate
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private final class TypeFerryMediaDelegate: NSObject, WKUIDelegate {
|
|
22
|
+
private weak var original: WKUIDelegate?
|
|
23
|
+
private let localURL: URL
|
|
24
|
+
|
|
25
|
+
init(original: WKUIDelegate?, localURL: URL) {
|
|
26
|
+
self.original = original
|
|
27
|
+
self.localURL = localURL
|
|
28
|
+
super.init()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
override func responds(to selector: Selector!) -> Bool {
|
|
32
|
+
super.responds(to: selector) || (original?.responds(to: selector) ?? false)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
override func forwardingTarget(for selector: Selector!) -> Any? {
|
|
36
|
+
if original?.responds(to: selector) == true { return original }
|
|
37
|
+
return super.forwardingTarget(for: selector)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
|
41
|
+
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
|
42
|
+
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
|
|
43
|
+
guard let requestURL = frame.request.url,
|
|
44
|
+
TypeFerryMediaPolicy.isTrusted(localURL: localURL, requestURL: requestURL, isMainFrame: frame.isMainFrame),
|
|
45
|
+
origin.protocol == localURL.scheme, origin.host == localURL.host,
|
|
46
|
+
origin.port == (localURL.port ?? 0),
|
|
47
|
+
webView.url.map({ TypeFerryMediaPolicy.isTrusted(localURL: localURL, requestURL: $0, isMainFrame: true) }) == true
|
|
48
|
+
else { decisionHandler(.deny); return }
|
|
49
|
+
|
|
50
|
+
let devices: [AVMediaType]
|
|
51
|
+
switch type {
|
|
52
|
+
case .camera: devices = [.video]
|
|
53
|
+
case .microphone: devices = [.audio]
|
|
54
|
+
case .cameraAndMicrophone: devices = [.video, .audio]
|
|
55
|
+
@unknown default: decisionHandler(.deny); return
|
|
56
|
+
}
|
|
57
|
+
authorize(devices[...]) { [weak webView] decision in
|
|
58
|
+
// A permission prompt can outlive navigation; never grant to a replacement page.
|
|
59
|
+
guard let currentURL = webView?.url,
|
|
60
|
+
TypeFerryMediaPolicy.isTrusted(localURL: self.localURL, requestURL: currentURL, isMainFrame: true)
|
|
61
|
+
else { decisionHandler(.deny); return }
|
|
62
|
+
decisionHandler(decision)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private func authorize(_ devices: ArraySlice<AVMediaType>, completion: @escaping (WKPermissionDecision) -> Void) {
|
|
67
|
+
guard let device = devices.first else { completion(.grant); return }
|
|
68
|
+
let usageKey = device == .video ? "NSCameraUsageDescription" : "NSMicrophoneUsageDescription"
|
|
69
|
+
guard let explanation = Bundle.main.object(forInfoDictionaryKey: usageKey) as? String, !explanation.isEmpty
|
|
70
|
+
else { completion(.deny); return }
|
|
71
|
+
|
|
72
|
+
switch AVCaptureDevice.authorizationStatus(for: device) {
|
|
73
|
+
case .authorized: authorize(devices.dropFirst(), completion: completion)
|
|
74
|
+
case .notDetermined:
|
|
75
|
+
AVCaptureDevice.requestAccess(for: device) { [weak self] granted in
|
|
76
|
+
DispatchQueue.main.async {
|
|
77
|
+
guard granted, let self = self else { completion(.deny); return }
|
|
78
|
+
self.authorize(devices.dropFirst(), completion: completion)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
default: completion(.deny)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
//# sourceMappingURL=bridge-view-controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bridge-view-controller.js","sourceRoot":"","sources":["../../../src/native/templates/bridge-view-controller.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiFrD,CAAA"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, sequential native file staging independent of Capacitor and UIKit.
|
|
3
|
+
*/
|
|
4
|
+
export const FILE_STAGING_SWIFT = String.raw `import Foundation
|
|
5
|
+
|
|
6
|
+
final class TypeFerryFileStaging {
|
|
7
|
+
static let maximumBytes = 128 * 1024 * 1024
|
|
8
|
+
static let chunkBytes = 48 * 1024
|
|
9
|
+
private struct Pending {
|
|
10
|
+
let id: String
|
|
11
|
+
let url: URL
|
|
12
|
+
let name: String
|
|
13
|
+
let size: Int
|
|
14
|
+
let handle: FileHandle
|
|
15
|
+
var offset: Int
|
|
16
|
+
}
|
|
17
|
+
private var pending: Pending?
|
|
18
|
+
|
|
19
|
+
deinit {
|
|
20
|
+
if let id = pending?.id { cancel(id: id) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func contains(id: String) -> Bool { pending?.id == id }
|
|
24
|
+
|
|
25
|
+
func begin(fileName: String, size: Int) throws -> String {
|
|
26
|
+
guard pending == nil else { throw failure("Another file is already being prepared.") }
|
|
27
|
+
guard size >= 0, size <= Self.maximumBytes else { throw failure("Files must be at most 128 MiB.") }
|
|
28
|
+
guard !fileName.isEmpty, fileName.utf8.count <= 255,
|
|
29
|
+
fileName == URL(fileURLWithPath: fileName).lastPathComponent,
|
|
30
|
+
![".", ".."].contains(fileName), !fileName.contains("\\"), !fileName.contains("\0")
|
|
31
|
+
else { throw failure("A safe file name is required.") }
|
|
32
|
+
|
|
33
|
+
let id = UUID().uuidString
|
|
34
|
+
let url = FileManager.default.temporaryDirectory.appendingPathComponent("typeferry-share-" + id)
|
|
35
|
+
guard FileManager.default.createFile(atPath: url.path, contents: nil) else { throw failure("Unable to create a temporary file.") }
|
|
36
|
+
do {
|
|
37
|
+
let handle = try FileHandle(forWritingTo: url)
|
|
38
|
+
pending = Pending(id: id, url: url, name: fileName, size: size, handle: handle, offset: 0)
|
|
39
|
+
return id
|
|
40
|
+
} catch {
|
|
41
|
+
try? FileManager.default.removeItem(at: url)
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func append(id: String, offset: Int, base64: String) throws {
|
|
47
|
+
guard var current = pending, current.id == id else { throw failure("The pending file is unavailable.") }
|
|
48
|
+
guard offset == current.offset, base64.utf8.count <= Self.chunkBytes / 3 * 4,
|
|
49
|
+
let data = Data(base64Encoded: base64), !data.isEmpty, data.count <= Self.chunkBytes,
|
|
50
|
+
data.count <= current.size - current.offset else { throw failure("Invalid file chunk or offset.") }
|
|
51
|
+
try current.handle.write(contentsOf: data)
|
|
52
|
+
current.offset += data.count
|
|
53
|
+
pending = current
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func finish(id: String) throws -> (url: URL, fileName: String) {
|
|
57
|
+
guard let current = pending, current.id == id, current.offset == current.size else {
|
|
58
|
+
throw failure("The pending file is incomplete.")
|
|
59
|
+
}
|
|
60
|
+
try current.handle.close()
|
|
61
|
+
pending = nil
|
|
62
|
+
return (current.url, current.name)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func cancel(id: String) {
|
|
66
|
+
guard let current = pending, current.id == id else { return }
|
|
67
|
+
pending = nil
|
|
68
|
+
try? current.handle.close()
|
|
69
|
+
try? FileManager.default.removeItem(at: current.url)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private func failure(_ message: String) -> NSError {
|
|
73
|
+
NSError(domain: "TypeFerryFileStaging", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
//# sourceMappingURL=file-staging.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-staging.js","sourceRoot":"","sources":["../../../src/native/templates/file-staging.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwE3C,CAAA"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin native transport, with a private cookie jar persisted in Keychain.
|
|
3
|
+
*/
|
|
4
|
+
export const HTTP_SESSION_SWIFT = String.raw `import Foundation
|
|
5
|
+
|
|
6
|
+
final class TypeFerryHTTPSession: NSObject, URLSessionTaskDelegate {
|
|
7
|
+
let backendURL: URL
|
|
8
|
+
private let cookieAccount: String
|
|
9
|
+
private var session: URLSession!
|
|
10
|
+
private let cookieLock = NSLock()
|
|
11
|
+
private var generation = 0
|
|
12
|
+
|
|
13
|
+
init(backendURL: URL) throws {
|
|
14
|
+
self.backendURL = backendURL
|
|
15
|
+
cookieAccount = "cookies:" + backendURL.absoluteString
|
|
16
|
+
super.init()
|
|
17
|
+
|
|
18
|
+
let configuration = Self.makeConfiguration()
|
|
19
|
+
if let data = try TypeFerryKeychain.read(cookieAccount),
|
|
20
|
+
let rows = try PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] {
|
|
21
|
+
for row in rows {
|
|
22
|
+
let properties = Dictionary(uniqueKeysWithValues: row.map { (HTTPCookiePropertyKey($0.key), $0.value) })
|
|
23
|
+
if let cookie = HTTPCookie(properties: properties),
|
|
24
|
+
cookie.expiresDate.map({ $0 > Date() }) ?? true {
|
|
25
|
+
configuration.httpCookieStorage?.setCookie(cookie)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func accepts(_ url: URL) -> Bool {
|
|
33
|
+
TypeFerryMediaPolicy.isTrusted(localURL: backendURL, requestURL: url, isMainFrame: true)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func request(_ request: URLRequest, completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) {
|
|
37
|
+
guard let url = request.url, accepts(url) else { completion(.failure(Self.failure("Untrusted backend URL."))); return }
|
|
38
|
+
let (requestSession, requestGeneration) = snapshot()
|
|
39
|
+
requestSession.dataTask(with: request) { [weak self] data, response, error in
|
|
40
|
+
if let error = error { completion(.failure(error)); return }
|
|
41
|
+
guard let self = self, let response = response as? HTTPURLResponse, let data = data else {
|
|
42
|
+
completion(.failure(Self.failure("The backend returned no response."))); return
|
|
43
|
+
}
|
|
44
|
+
do {
|
|
45
|
+
try self.persistCookies(session: requestSession, generation: requestGeneration)
|
|
46
|
+
completion(.success((data, response)))
|
|
47
|
+
} catch { completion(.failure(error)) }
|
|
48
|
+
}.resume()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func download(_ request: URLRequest, completion: @escaping (Result<URL, Error>) -> Void) {
|
|
52
|
+
guard let url = request.url, accepts(url) else { completion(.failure(Self.failure("Untrusted download URL."))); return }
|
|
53
|
+
let (requestSession, requestGeneration) = snapshot()
|
|
54
|
+
requestSession.downloadTask(with: request) { [weak self] location, response, error in
|
|
55
|
+
if let error = error { completion(.failure(error)); return }
|
|
56
|
+
guard let self = self, let location = location, let response = response as? HTTPURLResponse,
|
|
57
|
+
(200..<300).contains(response.statusCode) else {
|
|
58
|
+
completion(.failure(Self.failure("The download failed."))); return
|
|
59
|
+
}
|
|
60
|
+
do {
|
|
61
|
+
try self.persistCookies(session: requestSession, generation: requestGeneration)
|
|
62
|
+
let retained = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
63
|
+
try FileManager.default.moveItem(at: location, to: retained)
|
|
64
|
+
completion(.success(retained))
|
|
65
|
+
} catch { completion(.failure(error)) }
|
|
66
|
+
}.resume()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func clearCookies() throws {
|
|
70
|
+
cookieLock.lock()
|
|
71
|
+
defer { cookieLock.unlock() }
|
|
72
|
+
let previous = session
|
|
73
|
+
generation += 1
|
|
74
|
+
// A fresh cookie jar prevents in-flight Set-Cookie from resurrecting logout.
|
|
75
|
+
session = URLSession(configuration: Self.makeConfiguration(), delegate: self, delegateQueue: nil)
|
|
76
|
+
previous?.invalidateAndCancel()
|
|
77
|
+
try TypeFerryKeychain.delete(cookieAccount)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
|
81
|
+
newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
|
82
|
+
guard let url = request.url, accepts(url) else { completionHandler(nil); return }
|
|
83
|
+
completionHandler(request)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func snapshot() -> (URLSession, Int) {
|
|
87
|
+
cookieLock.lock()
|
|
88
|
+
defer { cookieLock.unlock() }
|
|
89
|
+
return (session, generation)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private static func makeConfiguration() -> URLSessionConfiguration {
|
|
93
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
94
|
+
configuration.httpShouldSetCookies = true
|
|
95
|
+
configuration.timeoutIntervalForRequest = 60
|
|
96
|
+
configuration.timeoutIntervalForResource = 300
|
|
97
|
+
return configuration
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func persistCookies(session: URLSession, generation: Int) throws {
|
|
101
|
+
cookieLock.lock()
|
|
102
|
+
defer { cookieLock.unlock() }
|
|
103
|
+
guard generation == self.generation else { throw Self.failure("The native session was cleared.") }
|
|
104
|
+
let rows = (session.configuration.httpCookieStorage?.cookies ?? []).compactMap { cookie -> [String: Any]? in
|
|
105
|
+
guard let properties = cookie.properties else { return nil }
|
|
106
|
+
return Dictionary(uniqueKeysWithValues: properties.map { ($0.key.rawValue, $0.value) })
|
|
107
|
+
}
|
|
108
|
+
let data = try PropertyListSerialization.data(fromPropertyList: rows, format: .binary, options: 0)
|
|
109
|
+
try TypeFerryKeychain.write(cookieAccount, data: data)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
static func failure(_ message: String) -> NSError {
|
|
113
|
+
NSError(domain: "TypeFerryNative", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
`;
|
|
117
|
+
//# sourceMappingURL=http-session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-session.js","sourceRoot":"","sources":["../../../src/native/templates/http-session.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgH3C,CAAA"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BRIDGE_VIEW_CONTROLLER_SWIFT } from './bridge-view-controller.js';
|
|
2
|
+
import { FILE_STAGING_SWIFT } from './file-staging.js';
|
|
3
|
+
import { HTTP_SESSION_SWIFT } from './http-session.js';
|
|
4
|
+
import { KEYCHAIN_SWIFT } from './keychain.js';
|
|
5
|
+
import { MEDIA_PERMISSION_POLICY_SWIFT } from './media-permission-policy.js';
|
|
6
|
+
import { NATIVE_PLUGIN_SWIFT } from './plugin.js';
|
|
7
|
+
import { WAKE_LOCKS_SWIFT } from './wake-locks.js';
|
|
8
|
+
/**
|
|
9
|
+
* Framework-owned Swift source files installed explicitly by the iOS scaffold.
|
|
10
|
+
*/
|
|
11
|
+
export const IOS_NATIVE_TEMPLATES = {
|
|
12
|
+
'TypeFerryBridgeViewController.swift': BRIDGE_VIEW_CONTROLLER_SWIFT,
|
|
13
|
+
'TypeFerryMediaPolicy.swift': MEDIA_PERMISSION_POLICY_SWIFT,
|
|
14
|
+
'TypeFerryWakeLocks.swift': WAKE_LOCKS_SWIFT,
|
|
15
|
+
'TypeFerryFileStaging.swift': FILE_STAGING_SWIFT,
|
|
16
|
+
'TypeFerryKeychain.swift': KEYCHAIN_SWIFT,
|
|
17
|
+
'TypeFerryHTTPSession.swift': HTTP_SESSION_SWIFT,
|
|
18
|
+
'TypeFerryNativePlugin.swift': NATIVE_PLUGIN_SWIFT,
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/native/templates/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,4BAA4B,EAAE,MAAM,0BAA0B,CAAA;AACvE,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAC3C,OAAO,EAAE,6BAA6B,EAAE,MAAM,2BAA2B,CAAA;AACzE,OAAO,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAA;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAE/C;;GAEG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAqC;IACpE,qCAAqC,EAAE,4BAA4B;IACnE,4BAA4B,EAAE,6BAA6B;IAC3D,0BAA0B,EAAE,gBAAgB;IAC5C,4BAA4B,EAAE,kBAAkB;IAChD,yBAAyB,EAAE,cAAc;IACzC,4BAA4B,EAAE,kBAAkB;IAChD,6BAA6B,EAAE,mBAAmB;CACnD,CAAA"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native-only secrets survive application upgrades without entering web storage.
|
|
3
|
+
*/
|
|
4
|
+
export const KEYCHAIN_SWIFT = String.raw `import Foundation
|
|
5
|
+
import Security
|
|
6
|
+
|
|
7
|
+
struct TypeFerryKeychain {
|
|
8
|
+
static func read(_ account: String) throws -> Data? {
|
|
9
|
+
var query = baseQuery(account)
|
|
10
|
+
query[kSecReturnData] = true
|
|
11
|
+
query[kSecMatchLimit] = kSecMatchLimitOne
|
|
12
|
+
var result: CFTypeRef?
|
|
13
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
14
|
+
if status == errSecItemNotFound { return nil }
|
|
15
|
+
guard status == errSecSuccess else { throw failure(status) }
|
|
16
|
+
return result as? Data
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static func write(_ account: String, data: Data) throws {
|
|
20
|
+
let query = baseQuery(account)
|
|
21
|
+
let updates: [CFString: Any] = [kSecValueData: data]
|
|
22
|
+
let status = SecItemUpdate(query as CFDictionary, updates as CFDictionary)
|
|
23
|
+
if status == errSecItemNotFound {
|
|
24
|
+
var insertion = query
|
|
25
|
+
insertion[kSecValueData] = data
|
|
26
|
+
insertion[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
27
|
+
let insertStatus = SecItemAdd(insertion as CFDictionary, nil)
|
|
28
|
+
guard insertStatus == errSecSuccess else { throw failure(insertStatus) }
|
|
29
|
+
} else if status != errSecSuccess { throw failure(status) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static func delete(_ account: String) throws {
|
|
33
|
+
let status = SecItemDelete(baseQuery(account) as CFDictionary)
|
|
34
|
+
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private static func baseQuery(_ account: String) -> [CFString: Any] {
|
|
38
|
+
[kSecClass: kSecClassGenericPassword,
|
|
39
|
+
kSecAttrService: (Bundle.main.bundleIdentifier ?? "typeferry") + ".typeferry",
|
|
40
|
+
kSecAttrAccount: account]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private static func failure(_ status: OSStatus) -> NSError {
|
|
44
|
+
NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Secure session storage is unavailable."])
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
//# sourceMappingURL=keychain.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keychain.js","sourceRoot":"","sources":["../../../src/native/templates/keychain.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CvC,CAAA"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foundation-only origin policy, also executed by the native security tests.
|
|
3
|
+
*/
|
|
4
|
+
export const MEDIA_PERMISSION_POLICY_SWIFT = String.raw `import Foundation
|
|
5
|
+
|
|
6
|
+
struct TypeFerryMediaPolicy {
|
|
7
|
+
static func isTrusted(localURL: URL, requestURL: URL, isMainFrame: Bool) -> Bool {
|
|
8
|
+
guard isMainFrame, requestURL.user == nil, requestURL.password == nil else { return false }
|
|
9
|
+
return localURL.scheme?.lowercased() == requestURL.scheme?.lowercased()
|
|
10
|
+
&& localURL.host?.lowercased() == requestURL.host?.lowercased()
|
|
11
|
+
&& effectivePort(localURL) == effectivePort(requestURL)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private static func effectivePort(_ url: URL) -> Int? {
|
|
15
|
+
if let port = url.port { return port }
|
|
16
|
+
switch url.scheme?.lowercased() {
|
|
17
|
+
case "https": return 443
|
|
18
|
+
case "http": return 80
|
|
19
|
+
default: return nil
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
//# sourceMappingURL=media-permission-policy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-permission-policy.js","sourceRoot":"","sources":["../../../src/native/templates/media-permission-policy.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;CAmBtD,CAAA"}
|