@swiftbrowser/native 0.18.2 → 0.19.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/README.md +19 -0
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/bridge.d.ts +34 -7
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +10 -4
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +19 -11
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +22 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -13
- package/dist/hostRequests.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +24 -10
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/bridge.ts +30 -7
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +18 -13
- package/src/hostRequests.ts +72 -19
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +27 -12
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
package/src/hostRequests.ts
CHANGED
|
@@ -14,23 +14,29 @@
|
|
|
14
14
|
* `webAuthenticate` is the sign-in window apps open for OAuth: the system's
|
|
15
15
|
* sheet, resolving with the callback URL.
|
|
16
16
|
*
|
|
17
|
+
* `audioEngine` is `AVAudioEngine` (@swiftbrowser/react): each command an
|
|
18
|
+
* `audioEngine` request, which Host/AudioEngineBridge.swift performs as it
|
|
19
|
+
* arrives, in order. `languageModel` is Foundation Models' on-device model
|
|
20
|
+
* (`LanguageModelSession`): `languageModel` requests, which
|
|
21
|
+
* Host/LanguageModelBridge.swift answers.
|
|
22
|
+
*
|
|
17
23
|
* `files` is the app's sandbox for `fileManager` (@swiftbrowser/react): the
|
|
18
|
-
* `files` requests of Host/FilesBridge.swift, bytes crossing as base64, with
|
|
24
|
+
* `files` requests of Host/FilesBridge.swift, bytes crossing as views beside the JSON (base64 to an older host), with
|
|
19
25
|
* the standard directories from the bridge's `appDirectories()`.
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
28
|
import { objectURL, SimpleBlob, SimpleFile } from './blob';
|
|
23
29
|
import type { NativeBridge } from './bridge';
|
|
24
|
-
import {
|
|
30
|
+
import { base64Encode, bridgedBytes } from './encoding';
|
|
25
31
|
import { nativeUndoHost } from './undo';
|
|
26
32
|
|
|
27
|
-
export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'>> & Pick<NativeBridge, 'appDirectories'>;
|
|
33
|
+
export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'>> & Pick<NativeBridge, 'appDirectories' | 'hostRequestBytes'>;
|
|
28
34
|
|
|
29
|
-
/** A file as it crosses the bridge: its name, MIME type and bytes (base64). */
|
|
35
|
+
/** A file as it crosses the bridge: its name, MIME type and bytes (a view beside the JSON, or base64 to a host without them). */
|
|
30
36
|
export interface BridgedFile {
|
|
31
37
|
name: string;
|
|
32
38
|
type: string;
|
|
33
|
-
data: string;
|
|
39
|
+
data: string | Uint8Array;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
export interface WebAuthenticateOptions {
|
|
@@ -50,14 +56,15 @@ export function installHostRequests(native: HostRequestBridge): void {
|
|
|
50
56
|
fileWatchers.clear();
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
/** The bridge's `__sbHostResponse(id, result)`. */
|
|
54
|
-
export function receiveHostResponse(id: number, result: string | { value?: unknown; error?: string }): void {
|
|
59
|
+
/** The bridge's `__sbHostResponse(id, result, ...buffers)`: the buffers go where the JSON says `{ "$bytes": n }`. */
|
|
60
|
+
export function receiveHostResponse(id: number, result: string | { value?: unknown; error?: string }, ...buffers: ArrayBufferView[]): void {
|
|
55
61
|
const waiting = pending.get(id);
|
|
56
62
|
if (!waiting) return;
|
|
57
63
|
pending.delete(id);
|
|
58
64
|
let parsed: { value?: unknown; error?: string };
|
|
59
65
|
try {
|
|
60
|
-
parsed =
|
|
66
|
+
parsed =
|
|
67
|
+
typeof result === 'string' ? (JSON.parse(result, buffers.length ? placeBytes(buffers) : undefined) as { value?: unknown; error?: string }) : result;
|
|
61
68
|
} catch {
|
|
62
69
|
waiting.reject(new Error('host: malformed response'));
|
|
63
70
|
return;
|
|
@@ -80,8 +87,40 @@ export function hostRequest<T = unknown>(kind: string, params: Record<string, un
|
|
|
80
87
|
}
|
|
81
88
|
const id = nextId++;
|
|
82
89
|
pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
|
|
83
|
-
|
|
90
|
+
// Through `hostRequestBytes` whenever the host has it, views or not: the
|
|
91
|
+
// host then answers with bytes beside the JSON too.
|
|
92
|
+
const { json, views } = liftBytes(params, bridge.hostRequestBytes !== undefined);
|
|
93
|
+
if (bridge.hostRequestBytes) bridge.hostRequestBytes(id, kind, json, ...views);
|
|
94
|
+
else bridge.hostRequest(id, kind, json);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The params as JSON with their bytes lifted out: each typed array or
|
|
100
|
+
* `ArrayBuffer` becomes `{ "$bytes": n }` and the nth of `views`, for a host
|
|
101
|
+
* with `hostRequestBytes`; without one, base64 of its bytes in place, the
|
|
102
|
+
* form every host reads (docs/notes/bytes.md).
|
|
103
|
+
*/
|
|
104
|
+
export function liftBytes(params: unknown, lift: boolean): { json: string; views: ArrayBufferView[] } {
|
|
105
|
+
const views: ArrayBufferView[] = [];
|
|
106
|
+
const json = JSON.stringify(params, (_key, value: unknown) => {
|
|
107
|
+
const view = value instanceof ArrayBuffer ? new Uint8Array(value) : ArrayBuffer.isView(value) ? value : null;
|
|
108
|
+
if (!view) return value;
|
|
109
|
+
if (!lift) return base64Encode(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
110
|
+
views.push(view);
|
|
111
|
+
return { $bytes: views.length - 1 };
|
|
84
112
|
});
|
|
113
|
+
return { json, views };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A JSON reviver putting `buffers` where the text says `{ "$bytes": n }`. */
|
|
117
|
+
function placeBytes(buffers: ArrayBufferView[]): (key: string, value: unknown) => unknown {
|
|
118
|
+
return (_key, value) => {
|
|
119
|
+
if (value && typeof value === 'object' && typeof (value as { $bytes?: unknown }).$bytes === 'number') {
|
|
120
|
+
return buffers[(value as { $bytes: number }).$bytes] ?? value;
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
};
|
|
85
124
|
}
|
|
86
125
|
|
|
87
126
|
/** Forgets the bridge and the waiting requests, for tests. */
|
|
@@ -92,12 +131,12 @@ export function resetHostRequests(): void {
|
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
async function bridgedFile(file: SimpleBlob & { name?: string }): Promise<BridgedFile> {
|
|
95
|
-
return { name: file.name ?? 'file', type: file.type, data:
|
|
134
|
+
return { name: file.name ?? 'file', type: file.type, data: file.bytes() };
|
|
96
135
|
}
|
|
97
136
|
|
|
98
137
|
function filesOf(value: unknown): SimpleFile[] {
|
|
99
138
|
const files = (value as { files?: BridgedFile[] } | undefined)?.files ?? [];
|
|
100
|
-
return files.map((file) => new SimpleFile([
|
|
139
|
+
return files.map((file) => new SimpleFile([bridgedBytes(file.data)], file.name, { type: file.type }));
|
|
101
140
|
}
|
|
102
141
|
|
|
103
142
|
/**
|
|
@@ -159,18 +198,22 @@ function nativeFiles(directories: FileDirectories): Record<string, unknown> {
|
|
|
159
198
|
return {
|
|
160
199
|
directories,
|
|
161
200
|
// The container's Documents (ICloudBridge.swift), resolved once; null without the entitlement or iCloud Drive.
|
|
162
|
-
iCloud: (): Promise<string | null> =>
|
|
201
|
+
iCloud: (): Promise<string | null> =>
|
|
202
|
+
(iCloud ??= files<{ url?: string } | null>({ action: 'iCloud' }).then((found) => (typeof found?.url === 'string' ? found.url : null))),
|
|
163
203
|
watch: (listener: (urls: string[]) => void): (() => void) => {
|
|
164
204
|
fileWatchers.add(listener);
|
|
165
205
|
return () => void fileWatchers.delete(listener);
|
|
166
206
|
},
|
|
167
|
-
read: async (url: string): Promise<Uint8Array> =>
|
|
168
|
-
write: (url: string, data: Uint8Array): Promise<void> => files({ action: 'write', url, data
|
|
207
|
+
read: async (url: string): Promise<Uint8Array> => bridgedBytes((await files<{ data?: string | Uint8Array }>({ action: 'read', url }))?.data ?? ''),
|
|
208
|
+
write: (url: string, data: Uint8Array): Promise<void> => files({ action: 'write', url, data }).then(() => undefined),
|
|
169
209
|
remove: (url: string): Promise<void> => files({ action: 'remove', url }).then(() => undefined),
|
|
170
210
|
createDirectory: (url: string): Promise<void> => files({ action: 'createDirectory', url }).then(() => undefined),
|
|
171
211
|
list: async (url: string): Promise<string[]> => (await files<{ urls?: string[] }>({ action: 'list', url }))?.urls ?? [],
|
|
172
212
|
attributes: async (url: string): Promise<{ size: number; modified: number | null; created: number | null; directory: boolean; type: string } | null> => {
|
|
173
|
-
const found = await files<{ size?: number; modified?: number | null; created?: number | null; directory?: boolean; type?: string } | null>({
|
|
213
|
+
const found = await files<{ size?: number; modified?: number | null; created?: number | null; directory?: boolean; type?: string } | null>({
|
|
214
|
+
action: 'attributes',
|
|
215
|
+
url,
|
|
216
|
+
});
|
|
174
217
|
if (!found || typeof found.size !== 'number') return null;
|
|
175
218
|
return {
|
|
176
219
|
size: found.size,
|
|
@@ -207,9 +250,10 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
207
250
|
},
|
|
208
251
|
canShare: () => true,
|
|
209
252
|
download: (blob: SimpleBlob, filename: string): void => {
|
|
210
|
-
void hostRequest('share', { files: [{ name: filename, type: blob.type, data:
|
|
253
|
+
void hostRequest('share', { files: [{ name: filename, type: blob.type, data: blob.bytes() }] }).catch(() => undefined);
|
|
211
254
|
},
|
|
212
|
-
pickFiles: async (options: { accept: string; multiple: boolean }): Promise<SimpleFile[]> =>
|
|
255
|
+
pickFiles: async (options: { accept: string; multiple: boolean }): Promise<SimpleFile[]> =>
|
|
256
|
+
filesOf(await hostRequest('pickFiles', { accept: options.accept, multiple: options.multiple })),
|
|
213
257
|
// `fileExporter`: the document picker, saving the files where the viewer chooses.
|
|
214
258
|
exportFiles: async (files: SimpleFile[]): Promise<{ outcome: string; urls?: string[] }> => {
|
|
215
259
|
const result = await hostRequest<{ outcome?: string; urls?: string[] } | null>('exportFiles', { files: await Promise.all(files.map(bridgedFile)) });
|
|
@@ -220,7 +264,7 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
220
264
|
writeText: (text: string): Promise<void> => hostRequest('clipboard', { action: 'write', text }).then(() => undefined),
|
|
221
265
|
writeImage: async (png: Promise<SimpleBlob>): Promise<void> => {
|
|
222
266
|
const file = await png;
|
|
223
|
-
await hostRequest('clipboard', { action: 'write', files: [{ name: 'image.png', type: file.type || 'image/png', data:
|
|
267
|
+
await hostRequest('clipboard', { action: 'write', files: [{ name: 'image.png', type: file.type || 'image/png', data: file.bytes() }] });
|
|
224
268
|
},
|
|
225
269
|
readText: async (): Promise<string> => (await hostRequest<{ text?: string }>('clipboard', { action: 'readText' }))?.text ?? '',
|
|
226
270
|
},
|
|
@@ -228,7 +272,16 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
228
272
|
// Sign in with Apple (AppleSignInBridge.swift): the system sheet, and the credential's state.
|
|
229
273
|
appleSignIn: {
|
|
230
274
|
signIn: (options: { scopes: string[]; nonce?: string; state?: string }): Promise<unknown> => hostRequest('appleSignIn', { action: 'signIn', ...options }),
|
|
231
|
-
credentialState: async (user: string): Promise<string> =>
|
|
275
|
+
credentialState: async (user: string): Promise<string> =>
|
|
276
|
+
(await hostRequest<{ state?: string } | null>('appleSignIn', { action: 'credentialState', user }))?.state ?? 'unknown',
|
|
277
|
+
},
|
|
278
|
+
// `AVAudioEngine` (AudioEngineBridge.swift): the app's engine commands, answered in the order sent.
|
|
279
|
+
audioEngine: {
|
|
280
|
+
perform: (command: Record<string, unknown>): Promise<unknown> => hostRequest('audioEngine', command),
|
|
281
|
+
},
|
|
282
|
+
// Foundation Models (LanguageModelBridge.swift): the app's sessions and streams, a `next` answered as a stream grows.
|
|
283
|
+
languageModel: {
|
|
284
|
+
perform: (command: Record<string, unknown>): Promise<unknown> => hostRequest('languageModel', command),
|
|
232
285
|
},
|
|
233
286
|
// `AVAudioRecorder` (AudioRecorderBridge.swift): a recording ends as a file URL in Documents; `trim` writes another.
|
|
234
287
|
audioRecorder: {
|
package/src/ios.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a CI job needs to hand an app's iOS build to phones, outside Xcode:
|
|
3
|
+
* an ad hoc provisioning profile made fresh through the App Store Connect
|
|
4
|
+
* API, and the over-the-air install page around the exported IPA. The
|
|
5
|
+
* reusable workflows (.github/workflows/sb-ios-adhoc.yml in the SwiftBrowser
|
|
6
|
+
* repository) run them as `swiftbrowser-native ios-profile` and
|
|
7
|
+
* `swiftbrowser-native ios-install-page`, from the app's own
|
|
8
|
+
* node_modules, so they come at the version of the app's @swiftbrowser
|
|
9
|
+
* packages.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { sign } from 'node:crypto';
|
|
13
|
+
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { basename, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export interface AppStoreConnectKey {
|
|
18
|
+
/** The key's ID (ASC_KEY_ID). */
|
|
19
|
+
keyId: string;
|
|
20
|
+
/** The team's issuer ID (ASC_ISSUER_ID). */
|
|
21
|
+
issuer: string;
|
|
22
|
+
/** The .p8 key's text (ASC_KEY_P8). It needs the Admin or App Manager role. */
|
|
23
|
+
key: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AdhocProfileOptions extends AppStoreConnectKey {
|
|
27
|
+
bundleId: string;
|
|
28
|
+
/** The profile's name, which the archive's PROVISIONING_PROFILE_SPECIFIER names. */
|
|
29
|
+
profileName: string;
|
|
30
|
+
/** The app's name, for the bundle ID when the team has none yet. */
|
|
31
|
+
appName: string;
|
|
32
|
+
/** Where profiles are installed; the user's home by default. */
|
|
33
|
+
home?: string;
|
|
34
|
+
/** For tests. */
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
log?: (line: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AdhocProfile {
|
|
40
|
+
uuid: string;
|
|
41
|
+
/** The files written, one per folder Xcode reads profiles from. */
|
|
42
|
+
files: string[];
|
|
43
|
+
devices: string[];
|
|
44
|
+
certificates: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A JWT for the App Store Connect API, good for 20 minutes. */
|
|
48
|
+
export function appStoreConnectToken({ keyId, issuer, key }: AppStoreConnectKey, now = Date.now()): string {
|
|
49
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
50
|
+
const seconds = Math.floor(now / 1000);
|
|
51
|
+
const body = `${encode({ alg: 'ES256', kid: keyId, typ: 'JWT' })}.${encode({ iss: issuer, iat: seconds, exp: seconds + 1200, aud: 'appstoreconnect-v1' })}`;
|
|
52
|
+
const signature = sign('sha256', Buffer.from(body), { key, dsaEncoding: 'ieee-p1363' });
|
|
53
|
+
return `${body}.${signature.toString('base64url')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Resource {
|
|
57
|
+
id: string;
|
|
58
|
+
attributes: Record<string, string | undefined>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Makes the ad hoc profile `profileName` for `bundleId` again, with every
|
|
63
|
+
* enabled iOS device and every valid distribution certificate of the team,
|
|
64
|
+
* so a phone registered since the last build is in the next one: registers
|
|
65
|
+
* the bundle ID when the team has none, deletes the profile of that name if
|
|
66
|
+
* there is one, and installs the new one where Xcode looks.
|
|
67
|
+
*/
|
|
68
|
+
export async function makeAdhocProfile(options: AdhocProfileOptions): Promise<AdhocProfile> {
|
|
69
|
+
const { bundleId, profileName, appName } = options;
|
|
70
|
+
const request = options.fetch ?? fetch;
|
|
71
|
+
const log = options.log ?? ((line: string) => console.log(line));
|
|
72
|
+
const authorization = `Bearer ${appStoreConnectToken(options)}`;
|
|
73
|
+
|
|
74
|
+
const api = async (method: string, path: string, body?: unknown): Promise<{ data: Resource | Resource[]; links?: { next?: string } } | null> => {
|
|
75
|
+
const response = await request(`https://api.appstoreconnect.apple.com${path}`, {
|
|
76
|
+
method,
|
|
77
|
+
headers: { authorization, 'content-type': 'application/json' },
|
|
78
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
79
|
+
});
|
|
80
|
+
const text = await response.text();
|
|
81
|
+
if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${text}`);
|
|
82
|
+
return text ? JSON.parse(text) : null;
|
|
83
|
+
};
|
|
84
|
+
/** Every page of a list. */
|
|
85
|
+
const list = async (path: string): Promise<Resource[]> => {
|
|
86
|
+
const items: Resource[] = [];
|
|
87
|
+
let next: string | null = path;
|
|
88
|
+
while (next) {
|
|
89
|
+
const page = await api('GET', next);
|
|
90
|
+
items.push(...((page?.data as Resource[]) ?? []));
|
|
91
|
+
const link = page?.links?.next;
|
|
92
|
+
next = link ? new URL(link).pathname + new URL(link).search : null;
|
|
93
|
+
}
|
|
94
|
+
return items;
|
|
95
|
+
};
|
|
96
|
+
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
|
|
97
|
+
|
|
98
|
+
let bundle = (await list(`/v1/bundleIds?${query({ 'filter[identifier]': bundleId, limit: '200' })}`)).find(
|
|
99
|
+
(item) => item.attributes['identifier'] === bundleId,
|
|
100
|
+
);
|
|
101
|
+
if (!bundle) {
|
|
102
|
+
log(`Registering the bundle ID ${bundleId}.`);
|
|
103
|
+
bundle = (await api('POST', '/v1/bundleIds', { data: { type: 'bundleIds', attributes: { identifier: bundleId, name: appName, platform: 'IOS' } } }))!
|
|
104
|
+
.data as Resource;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const certificates = (await list(`/v1/certificates?${query({ 'filter[certificateType]': 'DISTRIBUTION,IOS_DISTRIBUTION', limit: '200' })}`)).filter(
|
|
109
|
+
(item) => !item.attributes['expirationDate'] || Date.parse(item.attributes['expirationDate']) > now,
|
|
110
|
+
);
|
|
111
|
+
if (certificates.length === 0) throw new Error('The team has no valid distribution certificate.');
|
|
112
|
+
|
|
113
|
+
const devices = await list(`/v1/devices?${query({ 'filter[platform]': 'IOS', 'filter[status]': 'ENABLED', limit: '200' })}`);
|
|
114
|
+
if (devices.length === 0) throw new Error('The team has no enabled iOS devices: an ad hoc profile needs at least one.');
|
|
115
|
+
|
|
116
|
+
for (const old of await list(`/v1/profiles?${query({ 'filter[name]': profileName, limit: '200' })}`)) {
|
|
117
|
+
if (old.attributes['name'] !== profileName) continue;
|
|
118
|
+
log(`Deleting the old profile ${old.id}.`);
|
|
119
|
+
await api('DELETE', `/v1/profiles/${old.id}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const profile = (await api('POST', '/v1/profiles', {
|
|
123
|
+
data: {
|
|
124
|
+
type: 'profiles',
|
|
125
|
+
attributes: { name: profileName, profileType: 'IOS_APP_ADHOC' },
|
|
126
|
+
relationships: {
|
|
127
|
+
bundleId: { data: { type: 'bundleIds', id: bundle.id } },
|
|
128
|
+
certificates: { data: certificates.map((item) => ({ type: 'certificates', id: item.id })) },
|
|
129
|
+
devices: { data: devices.map((item) => ({ type: 'devices', id: item.id })) },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
}))!.data as Resource;
|
|
133
|
+
|
|
134
|
+
const uuid = profile.attributes['uuid']!;
|
|
135
|
+
const content = Buffer.from(profile.attributes['profileContent']!, 'base64');
|
|
136
|
+
const home = options.home ?? homedir();
|
|
137
|
+
const files: string[] = [];
|
|
138
|
+
// Xcode 16 and later read the first; older ones the second.
|
|
139
|
+
for (const dir of [join(home, 'Library/Developer/Xcode/UserData/Provisioning Profiles'), join(home, 'Library/MobileDevice/Provisioning Profiles')]) {
|
|
140
|
+
mkdirSync(dir, { recursive: true });
|
|
141
|
+
const file = join(dir, `${uuid}.mobileprovision`);
|
|
142
|
+
writeFileSync(file, content);
|
|
143
|
+
files.push(file);
|
|
144
|
+
}
|
|
145
|
+
const names = devices.map((device) => `${device.attributes['name']} (${device.attributes['model'] ?? device.attributes['deviceClass']})`);
|
|
146
|
+
log(`Made "${profileName}" (${uuid}) for ${bundleId}: ${devices.length} device(s), ${certificates.length} certificate(s).`);
|
|
147
|
+
for (const name of names) log(`- ${name}`);
|
|
148
|
+
return { uuid, files, devices: names, certificates: certificates.length };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface InstallPageOptions {
|
|
152
|
+
/** The exported IPA. */
|
|
153
|
+
ipa: string;
|
|
154
|
+
/** Where the page will be served from (`https://ios.app.pages.dev`): the manifest names the IPA there. */
|
|
155
|
+
url: string;
|
|
156
|
+
bundleId: string;
|
|
157
|
+
/** The build's version (`1.0.42`), its CFBundleVersion. */
|
|
158
|
+
version: string;
|
|
159
|
+
/** The app's name on the page and in the install prompt; the IPA's name by default. */
|
|
160
|
+
title?: string;
|
|
161
|
+
/** The same app in the browser, linked from the page. */
|
|
162
|
+
web?: string;
|
|
163
|
+
/** The commit it was built from. */
|
|
164
|
+
commit?: string;
|
|
165
|
+
/** When it was built; now by default. */
|
|
166
|
+
built?: Date;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const escapeXml = (text: string): string => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
170
|
+
|
|
171
|
+
/** The itms-services manifest for an IPA served at `ipaUrl`. */
|
|
172
|
+
export function installManifest({ ipaUrl, bundleId, version, title }: { ipaUrl: string; bundleId: string; version: string; title: string }): string {
|
|
173
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
174
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
175
|
+
<plist version="1.0"><dict><key>items</key><array><dict>
|
|
176
|
+
<key>assets</key><array><dict>
|
|
177
|
+
<key>kind</key><string>software-package</string>
|
|
178
|
+
<key>url</key><string>${escapeXml(ipaUrl)}</string>
|
|
179
|
+
</dict></array>
|
|
180
|
+
<key>metadata</key><dict>
|
|
181
|
+
<key>bundle-identifier</key><string>${escapeXml(bundleId)}</string>
|
|
182
|
+
<key>bundle-version</key><string>${escapeXml(version)}</string>
|
|
183
|
+
<key>kind</key><string>software</string>
|
|
184
|
+
<key>title</key><string>${escapeXml(title)}</string>
|
|
185
|
+
</dict>
|
|
186
|
+
</dict></array></dict></plist>
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Writes the install page into `outDir`: the IPA, its manifest, an
|
|
192
|
+
* `index.html` whose button installs it over the air (on a phone the
|
|
193
|
+
* profile names), and the `_headers` Cloudflare Pages serves them with.
|
|
194
|
+
* Returns the files written, relative to `outDir`.
|
|
195
|
+
*/
|
|
196
|
+
export function writeInstallPage(outDir: string, options: InstallPageOptions): string[] {
|
|
197
|
+
const ipaName = basename(options.ipa);
|
|
198
|
+
const title = options.title ?? ipaName.replace(/\.ipa$/, '');
|
|
199
|
+
const base = options.url.replace(/\/+$/, '');
|
|
200
|
+
const built = (options.built ?? new Date()).toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
|
201
|
+
const html = (text: string): string => escapeXml(text);
|
|
202
|
+
mkdirSync(outDir, { recursive: true });
|
|
203
|
+
copyFileSync(options.ipa, join(outDir, ipaName));
|
|
204
|
+
writeFileSync(join(outDir, 'manifest.plist'), installManifest({ ipaUrl: `${base}/${ipaName}`, bundleId: options.bundleId, version: options.version, title }));
|
|
205
|
+
writeFileSync(
|
|
206
|
+
join(outDir, '_headers'),
|
|
207
|
+
`/manifest.plist
|
|
208
|
+
Content-Type: application/xml
|
|
209
|
+
Cache-Control: no-store
|
|
210
|
+
/${ipaName}
|
|
211
|
+
Content-Type: application/octet-stream
|
|
212
|
+
Cache-Control: no-store
|
|
213
|
+
`,
|
|
214
|
+
);
|
|
215
|
+
const install = `itms-services://?action=download-manifest&url=${base}/manifest.plist`;
|
|
216
|
+
writeFileSync(
|
|
217
|
+
join(outDir, 'index.html'),
|
|
218
|
+
`<!doctype html>
|
|
219
|
+
<html lang="en">
|
|
220
|
+
<head>
|
|
221
|
+
<meta charset="utf-8" />
|
|
222
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
223
|
+
<meta name="color-scheme" content="light dark" />
|
|
224
|
+
<title>Install ${html(title)}</title>
|
|
225
|
+
<style>
|
|
226
|
+
:root { --bg: #f2f2f7; --card: #fff; --fg: #000; --muted: #6e6e73; --tint: #007aff; }
|
|
227
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #000; --card: #1c1c1e; --fg: #fff; --muted: #98989f; --tint: #0a84ff; } }
|
|
228
|
+
* { box-sizing: border-box; }
|
|
229
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;
|
|
230
|
+
background: var(--bg); color: var(--fg); font: 17px/1.4 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
|
231
|
+
main { width: 100%; max-width: 420px; background: var(--card); border-radius: 22px; padding: 28px 24px; text-align: center; }
|
|
232
|
+
h1 { font-size: 28px; margin: 0 0 4px; }
|
|
233
|
+
p { color: var(--muted); margin: 8px 0; font-size: 15px; }
|
|
234
|
+
a.install { display: block; margin: 24px 0 16px; padding: 14px; border-radius: 14px; background: var(--tint);
|
|
235
|
+
color: #fff; font-weight: 600; text-decoration: none; }
|
|
236
|
+
a { color: var(--tint); }
|
|
237
|
+
</style>
|
|
238
|
+
</head>
|
|
239
|
+
<body>
|
|
240
|
+
<main>
|
|
241
|
+
<h1>${html(title)}</h1>
|
|
242
|
+
<p>Version ${html(options.version)} · built ${built}${options.commit ? ` from ${html(options.commit.slice(0, 7))}` : ''}</p>
|
|
243
|
+
<a class="install" href="${html(install)}">Install on this iPhone</a>
|
|
244
|
+
<p>Open this page in Safari on an iPhone whose device is registered with the team: an ad hoc build installs only on the devices its profile names. After installing, open Settings → General → VPN & Device Management if iOS asks you to trust the developer.</p>${
|
|
245
|
+
options.web ? `\n <p><a href="${html(options.web)}">Try it in the browser</a></p>` : ''
|
|
246
|
+
}
|
|
247
|
+
</main>
|
|
248
|
+
</body>
|
|
249
|
+
</html>
|
|
250
|
+
`,
|
|
251
|
+
);
|
|
252
|
+
return [ipaName, 'manifest.plist', '_headers', 'index.html'];
|
|
253
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { nativeBridge, type NativeBridge } from './bridge';
|
|
36
|
-
import { atobPolyfill, base64Decode, btoaPolyfill, SimpleTextDecoder, SimpleTextEncoder } from './encoding';
|
|
36
|
+
import { atobPolyfill, base64Decode, btoaPolyfill, installNativeUTF8, SimpleTextDecoder, SimpleTextEncoder } from './encoding';
|
|
37
37
|
import { createFetch } from './fetch';
|
|
38
38
|
import { createImageGlobals } from './image';
|
|
39
39
|
import { SimpleURL, SimpleURLSearchParams } from './url';
|
|
@@ -128,7 +128,10 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
128
128
|
return String(value);
|
|
129
129
|
}
|
|
130
130
|
};
|
|
131
|
-
const log =
|
|
131
|
+
const log =
|
|
132
|
+
(level: string) =>
|
|
133
|
+
(...args: unknown[]) =>
|
|
134
|
+
native.log(level, args.map(format).join(' '));
|
|
132
135
|
const timersByLabel = new Map<string, number>();
|
|
133
136
|
const counts = new Map<string, number>();
|
|
134
137
|
define('console', {
|
|
@@ -182,7 +185,12 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
182
185
|
define('atob', atobPolyfill);
|
|
183
186
|
define('btoa', btoaPolyfill);
|
|
184
187
|
|
|
185
|
-
|
|
188
|
+
// Typed arrays beside the JSON both ways (docs/notes/bytes.md): a host with
|
|
189
|
+
// `hostRequestBytes` carries them for fetch, sockets and files alike.
|
|
190
|
+
const carriesBytes = typeof native.hostRequestBytes === 'function';
|
|
191
|
+
// UTF-8 on the host past a few dozen characters (encoding.ts).
|
|
192
|
+
installNativeUTF8(native.decodeUTF8 && native.encodeUTF8 ? { decode: native.decodeUTF8.bind(native), encode: native.encodeUTF8.bind(native) } : null);
|
|
193
|
+
const fetchGlobals = createFetch(native, { bytes: carriesBytes });
|
|
186
194
|
define('fetch', fetchGlobals.fetch);
|
|
187
195
|
define('Headers', fetchGlobals.Headers);
|
|
188
196
|
define('Request', fetchGlobals.Request);
|
|
@@ -204,12 +212,15 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
204
212
|
urlStatics.createObjectURL = (blob: SimpleBlob) => objectURL(blob);
|
|
205
213
|
urlStatics.revokeObjectURL = () => {};
|
|
206
214
|
}
|
|
207
|
-
const random = (count: number): Uint8Array => base64Decode(native.randomBytes(count));
|
|
215
|
+
const random = (count: number): Uint8Array => (native.randomValues ? native.randomValues(count) : base64Decode(native.randomBytes(count)));
|
|
208
216
|
setBodyRandomness(random);
|
|
209
217
|
|
|
210
218
|
// WebSockets (webSocket.ts) over the host's `URLSessionWebSocketTask`.
|
|
211
219
|
if (native.wsOpen && native.wsSend && native.wsClose) {
|
|
212
|
-
NativeWebSocket.install(
|
|
220
|
+
NativeWebSocket.install(
|
|
221
|
+
{ wsOpen: native.wsOpen.bind(native), wsSend: native.wsSend.bind(native), wsClose: native.wsClose.bind(native) },
|
|
222
|
+
{ bytes: carriesBytes },
|
|
223
|
+
);
|
|
213
224
|
define('WebSocket', NativeWebSocket);
|
|
214
225
|
define('__sbWebSocketEvent', NativeWebSocket.receive);
|
|
215
226
|
}
|
|
@@ -219,7 +230,11 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
219
230
|
|
|
220
231
|
// Host requests (hostRequests.ts): the share sheet, the pickers, the pasteboard, a sign-in.
|
|
221
232
|
if (native.hostRequest && !native.workerPostMessage) {
|
|
222
|
-
installHostRequests({
|
|
233
|
+
installHostRequests({
|
|
234
|
+
hostRequest: native.hostRequest.bind(native),
|
|
235
|
+
...(native.hostRequestBytes ? { hostRequestBytes: native.hostRequestBytes.bind(native) } : {}),
|
|
236
|
+
...(native.appDirectories ? { appDirectories: native.appDirectories.bind(native) } : {}),
|
|
237
|
+
});
|
|
223
238
|
define('__sbHostResponse', receiveHostResponse);
|
|
224
239
|
define('__sbFilesChanged', receiveFilesChanged);
|
|
225
240
|
// The escape hatch's functions (nativeFunctions.ts): `callNative` rides `hostRequest`.
|
|
@@ -251,14 +266,14 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
251
266
|
|
|
252
267
|
define('crypto', {
|
|
253
268
|
getRandomValues: <T extends ArrayBufferView>(array: T): T => {
|
|
254
|
-
const bytes =
|
|
269
|
+
const bytes = random(array.byteLength);
|
|
255
270
|
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes.subarray(0, array.byteLength));
|
|
256
271
|
return array;
|
|
257
272
|
},
|
|
258
273
|
// Digests and HMAC on the host's CryptoKit (subtle.ts).
|
|
259
274
|
subtle: createSubtleCrypto(native),
|
|
260
275
|
randomUUID: (): string => {
|
|
261
|
-
const bytes =
|
|
276
|
+
const bytes = new Uint8Array(random(16));
|
|
262
277
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
263
278
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
264
279
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
package/src/subtle.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { NativeBridge } from './bridge';
|
|
10
10
|
import { base64Decode, base64Encode } from './encoding';
|
|
11
11
|
|
|
12
|
-
export type SubtleBridge = Pick<NativeBridge, 'digest' | 'hmac'>;
|
|
12
|
+
export type SubtleBridge = Pick<NativeBridge, 'digest' | 'hmac' | 'digestBytes' | 'hmacBytes'>;
|
|
13
13
|
|
|
14
14
|
type Algorithm = string | { name: string; hash?: string | { name: string } };
|
|
15
15
|
|
|
@@ -31,15 +31,16 @@ function algorithmName(algorithm: Algorithm): string {
|
|
|
31
31
|
function hashName(algorithm: Algorithm): string {
|
|
32
32
|
if (typeof algorithm === 'string') return algorithm.toUpperCase();
|
|
33
33
|
const hash = algorithm.hash;
|
|
34
|
-
return (typeof hash === 'string' ? hash : hash?.name ?? '').toUpperCase();
|
|
34
|
+
return (typeof hash === 'string' ? hash : (hash?.name ?? '')).toUpperCase();
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function bytesOf(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
38
38
|
return data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
/** A result as an `ArrayBuffer` of its own: base64 from an older host, or a typed array. */
|
|
42
|
+
function toBuffer(result: string | Uint8Array): ArrayBuffer {
|
|
43
|
+
const bytes = typeof result === 'string' ? base64Decode(result) : result;
|
|
43
44
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -55,7 +56,7 @@ export function createSubtleCrypto(native: SubtleBridge) {
|
|
|
55
56
|
async digest(algorithm: Algorithm, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer> {
|
|
56
57
|
const name = algorithmName(algorithm);
|
|
57
58
|
if (!DIGESTS.has(name)) throw notSupported(`the ${name} digest`);
|
|
58
|
-
const result = native.digest?.(name, base64Encode(bytesOf(data)));
|
|
59
|
+
const result = native.digestBytes ? native.digestBytes(name, bytesOf(data)) : native.digest?.(name, base64Encode(bytesOf(data)));
|
|
59
60
|
if (!result) throw notSupported(`the ${name} digest`);
|
|
60
61
|
return toBuffer(result);
|
|
61
62
|
},
|
|
@@ -70,7 +71,10 @@ export function createSubtleCrypto(native: SubtleBridge) {
|
|
|
70
71
|
|
|
71
72
|
async sign(algorithm: Algorithm, key: HmacKey, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer> {
|
|
72
73
|
if (algorithmName(algorithm) !== 'HMAC' || key.algorithm?.name !== 'HMAC') throw notSupported(`signing with ${algorithmName(algorithm)}`);
|
|
73
|
-
const
|
|
74
|
+
const hash = key.algorithm.hash.name;
|
|
75
|
+
const result = native.hmacBytes
|
|
76
|
+
? native.hmacBytes(hash, key.bytes, bytesOf(data))
|
|
77
|
+
: native.hmac?.(hash, base64Encode(key.bytes), base64Encode(bytesOf(data)));
|
|
74
78
|
if (!result) throw notSupported('HMAC');
|
|
75
79
|
return toBuffer(result);
|
|
76
80
|
},
|