@swiftbrowser/native 0.18.3 → 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.
Files changed (73) hide show
  1. package/README.md +19 -0
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/bridge.d.ts +34 -7
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js.map +1 -1
  6. package/dist/encoding.d.ts +13 -0
  7. package/dist/encoding.d.ts.map +1 -1
  8. package/dist/encoding.js +24 -0
  9. package/dist/encoding.js.map +1 -1
  10. package/dist/fetch.d.ts +10 -4
  11. package/dist/fetch.d.ts.map +1 -1
  12. package/dist/fetch.js +19 -11
  13. package/dist/fetch.js.map +1 -1
  14. package/dist/hostRequests.d.ts +22 -6
  15. package/dist/hostRequests.d.ts.map +1 -1
  16. package/dist/hostRequests.js +65 -13
  17. package/dist/hostRequests.js.map +1 -1
  18. package/dist/ios.d.ts +79 -0
  19. package/dist/ios.d.ts.map +1 -0
  20. package/dist/ios.js +181 -0
  21. package/dist/ios.js.map +1 -0
  22. package/dist/runtime.d.ts.map +1 -1
  23. package/dist/runtime.js +16 -7
  24. package/dist/runtime.js.map +1 -1
  25. package/dist/subtle.d.ts +1 -1
  26. package/dist/subtle.d.ts.map +1 -1
  27. package/dist/subtle.js +9 -5
  28. package/dist/subtle.js.map +1 -1
  29. package/dist/webSocket.d.ts +12 -5
  30. package/dist/webSocket.d.ts.map +1 -1
  31. package/dist/webSocket.js +24 -10
  32. package/dist/webSocket.js.map +1 -1
  33. package/package.json +7 -3
  34. package/src/bridge.ts +30 -7
  35. package/src/encoding.ts +22 -0
  36. package/src/fetch.ts +18 -13
  37. package/src/hostRequests.ts +72 -19
  38. package/src/ios.ts +253 -0
  39. package/src/runtime.ts +23 -8
  40. package/src/subtle.ts +10 -6
  41. package/src/webSocket.ts +27 -12
  42. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  43. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  44. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
  45. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
  46. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
  47. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  48. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  49. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  50. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  51. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  52. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  53. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
  54. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
  55. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
  56. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  57. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  58. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  59. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  60. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  61. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  62. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
  63. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  64. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  65. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  66. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
  67. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  68. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  70. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
  71. package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
  72. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  73. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -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 { base64Decode, base64Encode } from './encoding';
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 = typeof result === 'string' ? (JSON.parse(result) as { value?: unknown; error?: string }) : result;
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
- bridge.hostRequest(id, kind, JSON.stringify(params));
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: base64Encode(file.bytes()) };
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([base64Decode(file.data)], file.name, { type: file.type }));
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> => (iCloud ??= files<{ url?: string } | null>({ action: 'iCloud' }).then((found) => (typeof found?.url === 'string' ? found.url : 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> => base64Decode((await files<{ data?: string }>({ action: 'read', url }))?.data ?? ''),
168
- write: (url: string, data: Uint8Array): Promise<void> => files({ action: 'write', url, data: base64Encode(data) }).then(() => undefined),
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>({ action: 'attributes', url });
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: base64Encode(blob.bytes()) }] }).catch(() => undefined);
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[]> => filesOf(await hostRequest('pickFiles', { accept: options.accept, multiple: options.multiple })),
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: base64Encode(file.bytes()) }] });
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> => (await hostRequest<{ state?: string } | null>('appleSignIn', { action: 'credentialState', user }))?.state ?? 'unknown',
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 &amp; 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 = (level: string) => (...args: unknown[]) => native.log(level, args.map(format).join(' '));
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
- const fetchGlobals = createFetch(native);
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({ wsOpen: native.wsOpen.bind(native), wsSend: native.wsSend.bind(native), wsClose: native.wsClose.bind(native) });
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({ hostRequest: native.hostRequest.bind(native), ...(native.appDirectories ? { appDirectories: native.appDirectories.bind(native) } : {}) });
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 = base64Decode(native.randomBytes(array.byteLength));
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 = base64Decode(native.randomBytes(16));
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
- function toBuffer(base64: string): ArrayBuffer {
42
- const bytes = base64Decode(base64);
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 result = native.hmac?.(key.algorithm.hash.name, base64Encode(key.bytes), base64Encode(bytesOf(data)));
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
  },