@limrun/ui 0.14.0-rc.3 → 0.14.0-rc.4

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 (67) hide show
  1. package/dist/app-store-relay/index.cjs +3 -3
  2. package/dist/app-store-relay/index.js +5 -5
  3. package/dist/app-store-relay/react.cjs +1 -1
  4. package/dist/app-store-relay/react.js +32 -33
  5. package/dist/apple-credentials/index.cjs +1 -0
  6. package/dist/apple-credentials/index.d.ts +59 -0
  7. package/dist/apple-credentials/index.js +227 -0
  8. package/dist/browser-storage-BnO-Ma-e.mjs +219 -0
  9. package/dist/browser-storage-DLxIqZk5.js +1 -0
  10. package/dist/client-D1drDXxE.js +1 -0
  11. package/dist/{client-C8ZDEjtD.mjs → client-DSuNtWDv.mjs} +1 -1
  12. package/dist/components/remote-control.d.ts +156 -0
  13. package/dist/core/device-install/storage/browser-storage.d.ts +8 -0
  14. package/dist/core/device-install/storage/index.d.ts +1 -0
  15. package/dist/core/device-install/storage/secret-store.d.ts +101 -0
  16. package/dist/core/device-install/types.d.ts +6 -0
  17. package/dist/core/errors.d.ts +1 -0
  18. package/dist/core/play-publish/google.d.ts +1 -1
  19. package/dist/device-build/index.cjs +1 -1
  20. package/dist/device-build/index.js +21 -20
  21. package/dist/device-build/react.cjs +1 -1
  22. package/dist/device-build/react.js +42 -43
  23. package/dist/device-install/index.cjs +1 -1
  24. package/dist/device-install/index.js +159 -161
  25. package/dist/device-install/react.cjs +1 -1
  26. package/dist/device-install/react.js +47 -49
  27. package/dist/errors-BSkqS7U-.js +1 -0
  28. package/dist/errors-gy8yQJos.mjs +6 -0
  29. package/dist/gsa-srp-Bme3JLEi.mjs +266 -0
  30. package/dist/gsa-srp-CCIfUz7Y.js +1 -0
  31. package/dist/index-ChhtuQ7e.js +22 -0
  32. package/dist/index-YDu8K9a4.mjs +11279 -0
  33. package/dist/index.cjs +1 -1
  34. package/dist/index.css +1 -1
  35. package/dist/index.js +1405 -1125
  36. package/dist/play-publish/index.cjs +1 -1
  37. package/dist/play-publish/index.js +27 -24
  38. package/dist/play-publish/react.cjs +1 -1
  39. package/dist/play-publish/react.d.ts +12 -2
  40. package/dist/play-publish/react.js +70 -60
  41. package/dist/{provisioning-BmsEnF6X.mjs → provisioning-B_DefBEp.mjs} +1 -1
  42. package/dist/{provisioning-Va0PH4Kk.js → provisioning-BlQMccX_.js} +1 -1
  43. package/package.json +6 -1
  44. package/src/app-store-relay/react.ts +1 -4
  45. package/src/apple-credentials/index.test.ts +158 -0
  46. package/src/apple-credentials/index.ts +241 -0
  47. package/src/components/remote-control.css +32 -0
  48. package/src/components/remote-control.tsx +865 -5
  49. package/src/core/device-install/operations/operations.ts +1 -4
  50. package/src/core/device-install/storage/browser-storage.test.ts +65 -0
  51. package/src/core/device-install/storage/browser-storage.ts +33 -0
  52. package/src/core/device-install/storage/index.ts +1 -0
  53. package/src/core/device-install/storage/secret-store.ts +298 -0
  54. package/src/core/device-install/types.ts +6 -0
  55. package/src/core/errors.ts +3 -0
  56. package/src/core/play-publish/google.ts +11 -2
  57. package/src/device-build/react.ts +1 -4
  58. package/src/device-install/demo/demo.tsx +1 -4
  59. package/src/device-install/react.ts +1 -4
  60. package/src/play-publish/README.md +5 -4
  61. package/src/play-publish/react.ts +72 -32
  62. package/vite.config.ts +1 -0
  63. package/dist/browser-storage-Cx9eW-bm.mjs +0 -205
  64. package/dist/browser-storage-jJaIht4K.js +0 -1
  65. package/dist/client-BtC4n_qf.js +0 -1
  66. package/dist/index-CMeQfhYy.js +0 -22
  67. package/dist/index-DFdCvi2_.mjs +0 -11543
@@ -4,6 +4,7 @@ import type { DeviceHello, DeviceInstallLog, PairRecordPayload } from '../types'
4
4
  import { RelayClient } from './relay-client';
5
5
  import { closeUsbmuxSession, createUsbmuxSession, type UsbmuxSession } from './usbmux';
6
6
  import { claimUsbmux, findUsbmuxCandidates, requestAppleDevice, type UsbmuxCandidate } from './webusb';
7
+ import { errorMessage } from '../../errors';
7
8
 
8
9
  export type DeviceRelayTarget = {
9
10
  device: USBDevice;
@@ -210,10 +211,6 @@ export function deviceRelayWebSocketUrl(registryApiUrl: string, token?: string,
210
211
  return url.toString();
211
212
  }
212
213
 
213
- function errorMessage(error: unknown) {
214
- return error instanceof Error ? error.message : String(error);
215
- }
216
-
217
214
  async function resetUSBDevice(target: DeviceRelayTarget) {
218
215
  if (target.claimedInterfaceNumber !== undefined) {
219
216
  try {
@@ -0,0 +1,65 @@
1
+ import forge from 'node-forge';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { normalizeCertificateSerial, parseProvisioningProfileBase64 } from './browser-storage';
4
+
5
+ function selfSignedCertificateBase64(serialNumber: string) {
6
+ const keys = forge.pki.rsa.generateKeyPair(1024);
7
+ const cert = forge.pki.createCertificate();
8
+ cert.publicKey = keys.publicKey;
9
+ cert.serialNumber = serialNumber;
10
+ cert.validity.notBefore = new Date();
11
+ cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1000);
12
+ const attrs = [{ name: 'commonName', value: 'Apple Development Test' }];
13
+ cert.setSubject(attrs);
14
+ cert.setIssuer(attrs);
15
+ cert.sign(keys.privateKey);
16
+ return forge.util.encode64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
17
+ }
18
+
19
+ function profileBase64(certificatesBase64: string[]) {
20
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
21
+ <plist version="1.0">
22
+ <dict>
23
+ <key>Name</key><string>Limrun Dev</string>
24
+ <key>UUID</key><string>11111111-2222-3333-4444-555555555555</string>
25
+ <key>TeamIdentifier</key><array><string>TEAM123</string></array>
26
+ <key>ProvisionedDevices</key><array><string>00008030-000A</string></array>
27
+ <key>DeveloperCertificates</key>
28
+ <array>${certificatesBase64.map((c) => `<data>${c}</data>`).join('')}</array>
29
+ <key>Entitlements</key>
30
+ <dict>
31
+ <key>application-identifier</key><string>TEAM123.com.example.app</string>
32
+ <key>com.apple.developer.team-identifier</key><string>TEAM123</string>
33
+ </dict>
34
+ </dict>
35
+ </plist>`;
36
+ return btoa(xml);
37
+ }
38
+
39
+ describe('parseProvisioningProfileBase64', () => {
40
+ test('extracts embedded certificate serial numbers in normalized form', () => {
41
+ const parsed = parseProvisioningProfileBase64(
42
+ profileBase64([selfSignedCertificateBase64('00ab12cd'), selfSignedCertificateBase64('7f')]),
43
+ );
44
+ expect(parsed.certificateSerialNumbers).toEqual(['AB12CD', '7F']);
45
+ expect(parsed.bundleID).toBe('com.example.app');
46
+ expect(parsed.teamID).toBe('TEAM123');
47
+ expect(parsed.provisionedDevices).toEqual(['00008030-000A']);
48
+ });
49
+
50
+ test('skips unparseable certificates instead of failing the profile', () => {
51
+ const parsed = parseProvisioningProfileBase64(
52
+ profileBase64([btoa('not a certificate'), selfSignedCertificateBase64('1a')]),
53
+ );
54
+ expect(parsed.certificateSerialNumbers).toEqual(['1A']);
55
+ });
56
+ });
57
+
58
+ describe('normalizeCertificateSerial', () => {
59
+ test('uppercases and strips leading zeros', () => {
60
+ expect(normalizeCertificateSerial('00ab12')).toBe('AB12');
61
+ expect(normalizeCertificateSerial('AB12')).toBe('AB12');
62
+ expect(normalizeCertificateSerial('')).toBeUndefined();
63
+ expect(normalizeCertificateSerial(undefined)).toBeUndefined();
64
+ });
65
+ });
@@ -1,3 +1,4 @@
1
+ import forge from 'node-forge';
1
2
  import type {
2
3
  DeviceInstallSigningMode,
3
4
  ProvisioningProfileInfo,
@@ -198,11 +199,43 @@ export function parseProvisioningProfileBytes(bytes: Uint8Array) {
198
199
  applicationIdentifier,
199
200
  bundleID,
200
201
  provisionedDevices: stringArrayValue(value.ProvisionedDevices),
202
+ certificateSerialNumbers: certificateSerialNumbers(stringArrayValue(value.DeveloperCertificates)),
201
203
  getTaskAllow: booleanValue(entitlements['get-task-allow']),
202
204
  expirationDate: stringValue(value.ExpirationDate),
203
205
  } satisfies ProvisioningProfileInfo;
204
206
  }
205
207
 
208
+ /**
209
+ * Normalizes a certificate serial number for comparison: uppercase hex
210
+ * without leading zeros, the format Apple's portal reports in serialNum.
211
+ */
212
+ export function normalizeCertificateSerial(serial?: string) {
213
+ const normalized = (serial ?? '').replace(/^0+/, '').toUpperCase();
214
+ return normalized || undefined;
215
+ }
216
+
217
+ /**
218
+ * Reads the serial numbers of the DER certificates embedded in a profile's
219
+ * DeveloperCertificates array. Unparseable entries are skipped: a profile
220
+ * with an exotic certificate should degrade to weaker filtering, not fail
221
+ * the whole parse.
222
+ */
223
+ function certificateSerialNumbers(developerCertificatesBase64: string[]) {
224
+ const serials: string[] = [];
225
+ for (const entry of developerCertificatesBase64) {
226
+ try {
227
+ // Plist <data> payloads wrap base64 across lines; atob rejects whitespace.
228
+ const der = atob(entry.replace(/\s+/g, ''));
229
+ const certificate = forge.pki.certificateFromAsn1(forge.asn1.fromDer(forge.util.createBuffer(der)));
230
+ const serial = normalizeCertificateSerial(certificate.serialNumber);
231
+ if (serial && !serials.includes(serial)) serials.push(serial);
232
+ } catch {
233
+ // Skip certificates forge cannot parse.
234
+ }
235
+ }
236
+ return serials;
237
+ }
238
+
206
239
  async function getSigningAssetsByID(id?: string) {
207
240
  if (!id) return undefined;
208
241
  const db = await openDB(SIGNING_DB_NAME, SIGNING_DB_VERSION, SIGNING_STORE_NAME, 'id');
@@ -1 +1,2 @@
1
1
  export * from './browser-storage';
2
+ export * from './secret-store';
@@ -0,0 +1,298 @@
1
+ /**
2
+ * A pluggable store for Apple signing secrets.
3
+ *
4
+ * Credential material always lands in the browser first (the Apple relay
5
+ * only proxies); the browser then writes it into a store implementing this
6
+ * interface. Limrun's org secret store is the default implementation, an
7
+ * IndexedDB-backed one keeps everything local, and customers can bring
8
+ * their own store by implementing the same interface.
9
+ */
10
+
11
+ export const APPLE_CERTIFICATE_SECRET_TYPE = 'appleCertificate';
12
+ export const APPLE_PROVISIONING_PROFILE_SECRET_TYPE = 'appleProvisioningProfile';
13
+
14
+ export type SigningSecretType =
15
+ | typeof APPLE_CERTIFICATE_SECRET_TYPE
16
+ | typeof APPLE_PROVISIONING_PROFILE_SECRET_TYPE;
17
+
18
+ export type SigningSecretMetadata = {
19
+ type: string;
20
+ name: string;
21
+ createdAt?: string;
22
+ };
23
+
24
+ /** Type-specific flat key-value payload of a signing secret. */
25
+ export type SigningSecretData = Record<string, string>;
26
+
27
+ export type SigningSecret = SigningSecretMetadata & {
28
+ data: SigningSecretData;
29
+ };
30
+
31
+ export interface SigningSecretStore {
32
+ /**
33
+ * Stores a secret. When a secret with the same type and name already
34
+ * exists its data is overwritten. Returns the stored secret; callers
35
+ * should use the returned data.
36
+ */
37
+ put(type: SigningSecretType, name: string, data: SigningSecretData): Promise<SigningSecret>;
38
+ /** Returns the secret including its data, or undefined when absent. */
39
+ get(type: SigningSecretType, name: string): Promise<SigningSecret | undefined>;
40
+ /** Lists metadata of all stored signing secrets, never their data. */
41
+ list(): Promise<SigningSecretMetadata[]>;
42
+ /** Deletes a secret; resolves even when the secret does not exist. */
43
+ delete(type: SigningSecretType, name: string): Promise<void>;
44
+ }
45
+
46
+ /**
47
+ * Apple's certificate types as defined by the App Store Connect API
48
+ * (https://developer.apple.com/documentation/appstoreconnectapi/certificatetype).
49
+ */
50
+ export type AppleCertificateType =
51
+ | 'DEVELOPMENT'
52
+ | 'DISTRIBUTION'
53
+ | 'IOS_DEVELOPMENT'
54
+ | 'IOS_DISTRIBUTION'
55
+ | 'MAC_APP_DEVELOPMENT'
56
+ | 'MAC_APP_DISTRIBUTION'
57
+ | 'MAC_INSTALLER_DISTRIBUTION'
58
+ | 'DEVELOPER_ID_APPLICATION'
59
+ | 'DEVELOPER_ID_INSTALLER'
60
+ | 'DEVELOPER_ID_KEXT'
61
+ | 'PASS_TYPE_ID'
62
+ | 'PASS_TYPE_ID_WITH_NFC';
63
+
64
+ /** Data payload of an appleCertificate secret. */
65
+ export type AppleCertificateSecretData = {
66
+ certificateP12Base64: string;
67
+ /** Which of Apple's certificate types the p12 holds. */
68
+ certificateType: AppleCertificateType;
69
+ certificatePassword?: string;
70
+ teamID?: string;
71
+ /** Apple's portal certificate id, never a Limrun DB id. */
72
+ certificateID?: string;
73
+ /** The certificate's serial number: uppercase hex, no leading zeros. */
74
+ serialNumber?: string;
75
+ expirationDate?: string;
76
+ };
77
+
78
+ /**
79
+ * Data payload of an appleProvisioningProfile secret. The
80
+ * certificateSerialNumbers, bundleIDs and deviceIDs fields duplicate what
81
+ * the signed profile binds (as comma-separated lists) so profiles can be
82
+ * filtered by certificate, bundle ID or device without parsing every
83
+ * entry. Certificates are referenced by serial number, the identifier
84
+ * Apple embeds in the profile itself.
85
+ */
86
+ export type AppleProvisioningProfileSecretData = {
87
+ provisioningProfileBase64: string;
88
+ certificateSerialNumbers?: string;
89
+ bundleIDs?: string;
90
+ deviceIDs?: string;
91
+ teamID?: string;
92
+ profileName?: string;
93
+ uuid?: string;
94
+ expirationDate?: string;
95
+ };
96
+
97
+ function compactData(data: Record<string, string | undefined>): SigningSecretData {
98
+ const result: SigningSecretData = {};
99
+ for (const [key, value] of Object.entries(data)) {
100
+ if (value) result[key] = value;
101
+ }
102
+ return result;
103
+ }
104
+
105
+ /** Stores an Apple certificate bundle, conventionally named `${teamID}/${certificateType}`. */
106
+ export async function putAppleCertificateSecret(
107
+ store: SigningSecretStore,
108
+ name: string,
109
+ data: AppleCertificateSecretData,
110
+ ) {
111
+ return store.put(APPLE_CERTIFICATE_SECRET_TYPE, name, compactData(data));
112
+ }
113
+
114
+ /**
115
+ * Stores a provisioning profile, conventionally named `${teamID}/${uuid}`
116
+ * so a team can hold many profiles for the same bundle ID and certificate
117
+ * set; consumers select by the reference fields, not by name.
118
+ */
119
+ export async function putAppleProvisioningProfileSecret(
120
+ store: SigningSecretStore,
121
+ name: string,
122
+ data: AppleProvisioningProfileSecretData,
123
+ ) {
124
+ return store.put(APPLE_PROVISIONING_PROFILE_SECRET_TYPE, name, compactData(data));
125
+ }
126
+
127
+ export type LimrunSecretStoreOptions = {
128
+ /** Base URL of the Limrun backend API, e.g. https://api.limrun.com */
129
+ apiUrl: string;
130
+ /** Bearer token: a user or organization token. */
131
+ token: string;
132
+ /** Organization TID owning the secrets. */
133
+ organizationId: string;
134
+ /** Custom fetch, mainly for tests. */
135
+ fetch?: typeof fetch;
136
+ };
137
+
138
+ type BackendSecretResult = {
139
+ id: string;
140
+ type: string;
141
+ name: string;
142
+ organizationId: string;
143
+ data: SigningSecretData;
144
+ createdAt: string;
145
+ };
146
+
147
+ /**
148
+ * A SigningSecretStore backed by Limrun's organization secret store
149
+ * (`/v1/organizations/{org}/secrets`). Secrets are stored server-side and
150
+ * shared across the organization.
151
+ */
152
+ export function createLimrunSecretStore(options: LimrunSecretStoreOptions): SigningSecretStore {
153
+ const doFetch = options.fetch ?? fetch.bind(globalThis);
154
+ const base = options.apiUrl.replace(/\/+$/, '');
155
+ const secretUrl = (type: string, name: string) =>
156
+ `${base}/v1/organizations/${encodeURIComponent(options.organizationId)}/secrets/${encodeURIComponent(
157
+ type,
158
+ )}/${encodeURIComponent(name)}`;
159
+ const headers = {
160
+ Authorization: `Bearer ${options.token}`,
161
+ 'Content-Type': 'application/json',
162
+ };
163
+
164
+ async function fail(response: Response, action: string): Promise<never> {
165
+ let message = `${response.status}`;
166
+ try {
167
+ const body = (await response.json()) as { message?: string };
168
+ if (body.message) message = body.message;
169
+ } catch {
170
+ // Non-JSON error body; the status code is the best we have.
171
+ }
172
+ throw new Error(`Failed to ${action} secret: ${message}`);
173
+ }
174
+
175
+ return {
176
+ async put(type, name, data) {
177
+ const response = await doFetch(secretUrl(type, name), {
178
+ method: 'PUT',
179
+ headers,
180
+ body: JSON.stringify({ data, replace: true }),
181
+ });
182
+ if (!response.ok) await fail(response, 'store');
183
+ const result = (await response.json()) as BackendSecretResult;
184
+ return { type: result.type, name: result.name, createdAt: result.createdAt, data: result.data };
185
+ },
186
+ async get(type, name) {
187
+ const response = await doFetch(secretUrl(type, name), { method: 'GET', headers });
188
+ if (response.status === 404) return undefined;
189
+ if (!response.ok) await fail(response, 'fetch');
190
+ const result = (await response.json()) as BackendSecretResult;
191
+ return { type: result.type, name: result.name, createdAt: result.createdAt, data: result.data };
192
+ },
193
+ async list() {
194
+ const response = await doFetch(
195
+ `${base}/v1/organizations/${encodeURIComponent(options.organizationId)}/secrets`,
196
+ { method: 'GET', headers },
197
+ );
198
+ if (!response.ok) await fail(response, 'list');
199
+ const result = (await response.json()) as Omit<BackendSecretResult, 'data'>[];
200
+ return result
201
+ .filter(
202
+ (s) =>
203
+ s.type === APPLE_CERTIFICATE_SECRET_TYPE || s.type === APPLE_PROVISIONING_PROFILE_SECRET_TYPE,
204
+ )
205
+ .map((s) => ({ type: s.type, name: s.name, createdAt: s.createdAt }));
206
+ },
207
+ async delete(type, name) {
208
+ const response = await doFetch(secretUrl(type, name), { method: 'DELETE', headers });
209
+ if (response.status === 404) return;
210
+ if (!response.ok) await fail(response, 'delete');
211
+ },
212
+ };
213
+ }
214
+
215
+ const SECRETS_DB_NAME = 'limrun-signing-secrets';
216
+ const SECRETS_DB_VERSION = 1;
217
+ const SECRETS_STORE_NAME = 'secrets';
218
+
219
+ type BrowserStoredSecret = {
220
+ id: string;
221
+ type: string;
222
+ name: string;
223
+ data: SigningSecretData;
224
+ createdAt: string;
225
+ };
226
+
227
+ /**
228
+ * A SigningSecretStore keeping everything in the browser's IndexedDB.
229
+ * Nothing leaves the machine; secrets are per-browser-profile.
230
+ */
231
+ export function createBrowserSecretStore(): SigningSecretStore {
232
+ const idOf = (type: string, name: string) => `${type}:${name}`;
233
+
234
+ function open() {
235
+ return new Promise<IDBDatabase>((resolve, reject) => {
236
+ const request = indexedDB.open(SECRETS_DB_NAME, SECRETS_DB_VERSION);
237
+ request.onupgradeneeded = () => {
238
+ const db = request.result;
239
+ if (!db.objectStoreNames.contains(SECRETS_STORE_NAME)) {
240
+ db.createObjectStore(SECRETS_STORE_NAME, { keyPath: 'id' });
241
+ }
242
+ };
243
+ request.onsuccess = () => resolve(request.result);
244
+ request.onerror = () => reject(request.error ?? new Error('Open IndexedDB failed'));
245
+ });
246
+ }
247
+
248
+ function toPromise<T = unknown>(request: IDBRequest<T>) {
249
+ return new Promise<T>((resolve, reject) => {
250
+ request.onsuccess = () => resolve(request.result);
251
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
252
+ });
253
+ }
254
+
255
+ return {
256
+ async put(type, name, data) {
257
+ const db = await open();
258
+ const existing = await toPromise<BrowserStoredSecret | undefined>(
259
+ db.transaction(SECRETS_STORE_NAME, 'readonly').objectStore(SECRETS_STORE_NAME).get(idOf(type, name)),
260
+ );
261
+ const stored: BrowserStoredSecret = {
262
+ id: idOf(type, name),
263
+ type,
264
+ name,
265
+ data,
266
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
267
+ };
268
+ await toPromise(
269
+ db.transaction(SECRETS_STORE_NAME, 'readwrite').objectStore(SECRETS_STORE_NAME).put(stored),
270
+ );
271
+ return { type, name, createdAt: stored.createdAt, data };
272
+ },
273
+ async get(type, name) {
274
+ const db = await open();
275
+ const stored = await toPromise<BrowserStoredSecret | undefined>(
276
+ db.transaction(SECRETS_STORE_NAME, 'readonly').objectStore(SECRETS_STORE_NAME).get(idOf(type, name)),
277
+ );
278
+ if (!stored) return undefined;
279
+ return { type: stored.type, name: stored.name, createdAt: stored.createdAt, data: stored.data };
280
+ },
281
+ async list() {
282
+ const db = await open();
283
+ const all = await toPromise<BrowserStoredSecret[]>(
284
+ db.transaction(SECRETS_STORE_NAME, 'readonly').objectStore(SECRETS_STORE_NAME).getAll(),
285
+ );
286
+ return all.map((s) => ({ type: s.type, name: s.name, createdAt: s.createdAt }));
287
+ },
288
+ async delete(type, name) {
289
+ const db = await open();
290
+ await toPromise(
291
+ db
292
+ .transaction(SECRETS_STORE_NAME, 'readwrite')
293
+ .objectStore(SECRETS_STORE_NAME)
294
+ .delete(idOf(type, name)),
295
+ );
296
+ },
297
+ };
298
+ }
@@ -40,6 +40,12 @@ export type ProvisioningProfileInfo = {
40
40
  applicationIdentifier?: string;
41
41
  bundleID?: string;
42
42
  provisionedDevices: string[];
43
+ /**
44
+ * Serial numbers (uppercase hex, no leading zeros) of the developer
45
+ * certificates embedded in the profile; the profile is only usable for
46
+ * signing with one of these certificates.
47
+ */
48
+ certificateSerialNumbers: string[];
43
49
  getTaskAllow?: boolean;
44
50
  expirationDate?: string;
45
51
  };
@@ -0,0 +1,3 @@
1
+ export function errorMessage(error: unknown): string {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
@@ -71,11 +71,20 @@ export type RequestGoogleAccessTokenInput = {
71
71
  * call loadGoogleIdentityServices beforehand (e.g. when the surrounding
72
72
  * dialog opens) to keep this call synchronous with the click.
73
73
  */
74
- export async function requestGoogleAccessToken({
74
+ export function requestGoogleAccessToken(input: RequestGoogleAccessTokenInput): Promise<string> {
75
+ // When GSI is already loaded the popup must open inside the caller's
76
+ // click turn: even an await of a resolved promise yields a microtask,
77
+ // which the strictest popup blockers treat as leaving the gesture.
78
+ if (googleOAuth2()) {
79
+ return openTokenPopup(input);
80
+ }
81
+ return loadGoogleIdentityServices().then(() => openTokenPopup(input));
82
+ }
83
+
84
+ function openTokenPopup({
75
85
  clientId,
76
86
  scope = ANDROID_PUBLISHER_SCOPE,
77
87
  }: RequestGoogleAccessTokenInput): Promise<string> {
78
- await loadGoogleIdentityServices();
79
88
  return new Promise<string>((resolve, reject) => {
80
89
  const client = googleOAuth2()!.initTokenClient({
81
90
  client_id: clientId,
@@ -8,6 +8,7 @@ import {
8
8
  type IOSOTAInstall,
9
9
  type StoredSigningAssets,
10
10
  } from './index';
11
+ import { errorMessage } from '../core/errors';
11
12
 
12
13
  export type UseDeviceBuildOptions = {
13
14
  apiUrl?: string;
@@ -127,7 +128,3 @@ export function useDeviceBuild({
127
128
  reset,
128
129
  };
129
130
  }
130
-
131
- function errorMessage(error: unknown) {
132
- return error instanceof Error ? error.message : String(error);
133
- }
@@ -29,6 +29,7 @@ import {
29
29
  import { useDeviceBuild } from '../../device-build/react';
30
30
  import { useDeviceInstallRelay } from '../react';
31
31
  import './demo.css';
32
+ import { errorMessage } from '../../core/errors';
32
33
 
33
34
  type ActivityLine = {
34
35
  id: number;
@@ -1053,10 +1054,6 @@ function useLocalStorage(key: string, initialValue: string) {
1053
1054
  return [value, setValue] as const;
1054
1055
  }
1055
1056
 
1056
- function errorMessage(error: unknown) {
1057
- return error instanceof Error ? error.message : String(error);
1058
- }
1059
-
1060
1057
  createRoot(document.getElementById('root')!).render(
1061
1058
  <StrictMode>
1062
1059
  <App />
@@ -12,6 +12,7 @@ import {
12
12
  type RelayClient,
13
13
  type StoredPairRecord,
14
14
  } from './index';
15
+ import { errorMessage } from '../core/errors';
15
16
 
16
17
  export type DeviceInstallRelayBusyAction = 'usb' | 'pair' | 'install';
17
18
 
@@ -192,7 +193,3 @@ export function useDeviceInstallRelay({
192
193
  function noopLog() {
193
194
  // Intentionally empty. Consumers can pass a logger for progress messages.
194
195
  }
195
-
196
- function errorMessage(error: unknown) {
197
- return error instanceof Error ? error.message : String(error);
198
- }
@@ -29,7 +29,8 @@ const play = usePlaystorePublish({
29
29
  googleClientId: GOOGLE_OAUTH_CLIENT_ID,
30
30
  });
31
31
 
32
- // On dialog open, warm the sign-in script so the click stays popup-safe:
32
+ // On dialog open, warm the sign-in script so the click stays popup-safe.
33
+ // Optionally await the returned promise (true = ready) to gate the button:
33
34
  play.preloadGoogle();
34
35
 
35
36
  // Button handlers:
@@ -39,9 +40,9 @@ await play.publish({ assetName: 'app-release.aab', packageName: 'com.example.app
39
40
  // Render from state: play.status, play.versionCode, play.error, play.errorCode
40
41
  ```
41
42
 
42
- `errorCode` values: `invalidRequest`, `assetNotFound`, `internal`, `busy`,
43
- `listingNotFound`, `permissionDenied`, `versionCodeExists`,
44
- `uploadKeyMismatch`, `unknown`. The set grows additively.
43
+ `errorCode` carries the registry's machine-readable error code; the
44
+ canonical value list lives on `PlaystorePublishError`'s doc comment and
45
+ grows additively.
45
46
 
46
47
  Google access tokens expire after about an hour. A `permissionDenied`
47
48
  error long after sign-in usually means the token expired; offer "Sign in