@limrun/ui 0.14.0-rc.2 → 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.
- package/dist/app-store-relay/index.cjs +3 -3
- package/dist/app-store-relay/index.js +5 -5
- package/dist/app-store-relay/react.cjs +1 -1
- package/dist/app-store-relay/react.d.ts +1 -1
- package/dist/app-store-relay/react.js +34 -31
- package/dist/apple-credentials/index.cjs +1 -0
- package/dist/apple-credentials/index.d.ts +59 -0
- package/dist/apple-credentials/index.js +227 -0
- package/dist/browser-storage-BnO-Ma-e.mjs +219 -0
- package/dist/browser-storage-DLxIqZk5.js +1 -0
- package/dist/client-D1drDXxE.js +1 -0
- package/dist/{client-CBX-hLnL.mjs → client-DSuNtWDv.mjs} +2 -7
- package/dist/core/device-install/operations/operations.d.ts +1 -1
- package/dist/core/device-install/storage/browser-storage.d.ts +8 -0
- package/dist/core/device-install/storage/index.d.ts +1 -0
- package/dist/core/device-install/storage/secret-store.d.ts +101 -0
- package/dist/core/device-install/types.d.ts +6 -0
- package/dist/core/errors.d.ts +1 -0
- package/dist/core/play-publish/google.d.ts +13 -0
- package/dist/core/play-publish/publish.d.ts +34 -0
- package/dist/device-build/index.cjs +1 -1
- package/dist/device-build/index.js +25 -24
- package/dist/device-build/react.cjs +1 -1
- package/dist/device-build/react.js +42 -43
- package/dist/device-install/index.cjs +1 -1
- package/dist/device-install/index.js +164 -160
- package/dist/device-install/react.cjs +1 -1
- package/dist/device-install/react.js +61 -60
- package/dist/errors-BSkqS7U-.js +1 -0
- package/dist/errors-gy8yQJos.mjs +6 -0
- package/dist/gsa-srp-Bme3JLEi.mjs +266 -0
- package/dist/gsa-srp-CCIfUz7Y.js +1 -0
- package/dist/index-ChhtuQ7e.js +22 -0
- package/dist/index-YDu8K9a4.mjs +11279 -0
- package/dist/play-publish/index.cjs +1 -0
- package/dist/play-publish/index.d.ts +2 -0
- package/dist/play-publish/index.js +109 -0
- package/dist/play-publish/react.cjs +1 -0
- package/dist/play-publish/react.d.ts +31 -0
- package/dist/play-publish/react.js +91 -0
- package/dist/{provisioning-DKbkBO-p.mjs → provisioning-B_DefBEp.mjs} +9 -9
- package/dist/{provisioning-Va0PH4Kk.js → provisioning-BlQMccX_.js} +1 -1
- package/package.json +18 -2
- package/src/app-store-relay/index.test.ts +6 -6
- package/src/app-store-relay/react.ts +6 -5
- package/src/apple-credentials/index.test.ts +158 -0
- package/src/apple-credentials/index.ts +241 -0
- package/src/core/device-install/apple/client.ts +1 -6
- package/src/core/device-install/apple/relay.ts +1 -4
- package/src/core/device-install/operations/operations.ts +8 -5
- package/src/core/device-install/storage/browser-storage.test.ts +65 -0
- package/src/core/device-install/storage/browser-storage.ts +33 -0
- package/src/core/device-install/storage/index.ts +1 -0
- package/src/core/device-install/storage/secret-store.ts +298 -0
- package/src/core/device-install/types.ts +6 -0
- package/src/core/errors.ts +3 -0
- package/src/core/play-publish/google.ts +113 -0
- package/src/core/play-publish/publish.test.ts +68 -0
- package/src/core/play-publish/publish.ts +89 -0
- package/src/device-build/react.ts +1 -4
- package/src/device-install/demo/demo.tsx +2 -8
- package/src/device-install/react.ts +31 -31
- package/src/play-publish/README.md +66 -0
- package/src/play-publish/index.ts +12 -0
- package/src/play-publish/react.ts +208 -0
- package/vite.config.ts +3 -0
- package/dist/browser-storage-Cx9eW-bm.mjs +0 -205
- package/dist/browser-storage-jJaIht4K.js +0 -1
- package/dist/client-BtC4n_qf.js +0 -1
- package/dist/index-CMeQfhYy.js +0 -22
- package/dist/index-DFdCvi2_.mjs +0 -11543
|
@@ -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,113 @@
|
|
|
1
|
+
const GSI_SRC = 'https://accounts.google.com/gsi/client';
|
|
2
|
+
|
|
3
|
+
export const ANDROID_PUBLISHER_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
|
|
4
|
+
|
|
5
|
+
type TokenResponse = { access_token?: string; error?: string; error_description?: string };
|
|
6
|
+
type TokenClient = { requestAccessToken: () => void };
|
|
7
|
+
type GoogleOAuth2 = {
|
|
8
|
+
initTokenClient: (config: {
|
|
9
|
+
client_id: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
callback: (response: TokenResponse) => void;
|
|
12
|
+
error_callback?: (error: { type?: string; message?: string }) => void;
|
|
13
|
+
}) => TokenClient;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function googleOAuth2(): GoogleOAuth2 | undefined {
|
|
17
|
+
return (globalThis as { google?: { accounts?: { oauth2?: GoogleOAuth2 } } }).google?.accounts?.oauth2;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let gsiLoad: Promise<void> | undefined;
|
|
21
|
+
|
|
22
|
+
export function loadGoogleIdentityServices(): Promise<void> {
|
|
23
|
+
if (googleOAuth2()) {
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
if (typeof document === 'undefined') {
|
|
27
|
+
return Promise.reject(new Error('Google sign-in requires a browser environment.'));
|
|
28
|
+
}
|
|
29
|
+
if (!gsiLoad) {
|
|
30
|
+
gsiLoad = new Promise<void>((resolve, reject) => {
|
|
31
|
+
const script = document.createElement('script');
|
|
32
|
+
script.src = GSI_SRC;
|
|
33
|
+
script.async = true;
|
|
34
|
+
script.addEventListener(
|
|
35
|
+
'load',
|
|
36
|
+
() => {
|
|
37
|
+
if (googleOAuth2()) {
|
|
38
|
+
resolve();
|
|
39
|
+
} else {
|
|
40
|
+
reject(new Error('Google sign-in script loaded without the OAuth client.'));
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{ once: true },
|
|
44
|
+
);
|
|
45
|
+
script.addEventListener(
|
|
46
|
+
'error',
|
|
47
|
+
() => {
|
|
48
|
+
script.remove();
|
|
49
|
+
reject(new Error('Failed to load the Google sign-in script.'));
|
|
50
|
+
},
|
|
51
|
+
{ once: true },
|
|
52
|
+
);
|
|
53
|
+
document.head.appendChild(script);
|
|
54
|
+
});
|
|
55
|
+
// A failed load (offline, blocked CDN) must not poison retries.
|
|
56
|
+
gsiLoad.catch(() => {
|
|
57
|
+
gsiLoad = undefined;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return gsiLoad;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type RequestGoogleAccessTokenInput = {
|
|
64
|
+
clientId: string;
|
|
65
|
+
scope?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Opens the Google consent popup and resolves with a short-lived OAuth
|
|
70
|
+
* access token. Call from a user gesture so popup blockers allow it, and
|
|
71
|
+
* call loadGoogleIdentityServices beforehand (e.g. when the surrounding
|
|
72
|
+
* dialog opens) to keep this call synchronous with the click.
|
|
73
|
+
*/
|
|
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({
|
|
85
|
+
clientId,
|
|
86
|
+
scope = ANDROID_PUBLISHER_SCOPE,
|
|
87
|
+
}: RequestGoogleAccessTokenInput): Promise<string> {
|
|
88
|
+
return new Promise<string>((resolve, reject) => {
|
|
89
|
+
const client = googleOAuth2()!.initTokenClient({
|
|
90
|
+
client_id: clientId,
|
|
91
|
+
scope,
|
|
92
|
+
callback: (response) => {
|
|
93
|
+
if (response.error) {
|
|
94
|
+
reject(new Error(response.error_description || response.error));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!response.access_token) {
|
|
98
|
+
reject(new Error('Google returned no access token.'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
resolve(response.access_token);
|
|
102
|
+
},
|
|
103
|
+
error_callback: (error) => {
|
|
104
|
+
const fallback =
|
|
105
|
+
error?.type === 'popup_failed_to_open' ?
|
|
106
|
+
'Google sign-in popup was blocked by the browser.'
|
|
107
|
+
: 'Google sign-in was cancelled.';
|
|
108
|
+
reject(new Error(error?.message || fallback));
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
client.requestAccessToken();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PlaystorePublishError, publishToPlaystore } from './publish';
|
|
3
|
+
|
|
4
|
+
function mockFetch(status: number, body: string) {
|
|
5
|
+
const fetchMock = vi.fn(async () => new Response(body, { status }));
|
|
6
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
7
|
+
return fetchMock;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('publishToPlaystore', () => {
|
|
15
|
+
it('posts to the publish path with auth and org headers and returns the versionCode', async () => {
|
|
16
|
+
const fetchMock = mockFetch(200, JSON.stringify({ versionCode: 42 }));
|
|
17
|
+
const result = await publishToPlaystore({
|
|
18
|
+
registryApiUrl: 'https://registry-staging.limrun.dev',
|
|
19
|
+
token: 'limrun-token',
|
|
20
|
+
organizationId: 'org_x',
|
|
21
|
+
accessToken: 'google-token',
|
|
22
|
+
packageName: 'com.example.app',
|
|
23
|
+
assetName: 'app-release.aab',
|
|
24
|
+
});
|
|
25
|
+
expect(result.versionCode).toBe(42);
|
|
26
|
+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
27
|
+
expect(url).toBe('https://registry-staging.limrun.dev/android/playstore/publish');
|
|
28
|
+
const headers = init.headers as Record<string, string>;
|
|
29
|
+
expect(headers.Authorization).toBe('Bearer limrun-token');
|
|
30
|
+
expect(headers['X-Limrun-Organization']).toBe('org_x');
|
|
31
|
+
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
32
|
+
expect(body).toEqual({
|
|
33
|
+
accessToken: 'google-token',
|
|
34
|
+
packageName: 'com.example.app',
|
|
35
|
+
assetName: 'app-release.aab',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('surfaces the registry error message and code', async () => {
|
|
40
|
+
mockFetch(
|
|
41
|
+
409,
|
|
42
|
+
JSON.stringify({ message: 'Version code 2 has already been used', code: 'versionCodeExists' }),
|
|
43
|
+
);
|
|
44
|
+
const error = await publishToPlaystore({
|
|
45
|
+
registryApiUrl: 'https://registry-staging.limrun.dev',
|
|
46
|
+
accessToken: 'google-token',
|
|
47
|
+
packageName: 'com.example.app',
|
|
48
|
+
assetName: 'app-release.aab',
|
|
49
|
+
}).catch((caught: unknown) => caught);
|
|
50
|
+
expect(error).toBeInstanceOf(PlaystorePublishError);
|
|
51
|
+
const publishError = error as PlaystorePublishError;
|
|
52
|
+
expect(publishError.code).toBe('versionCodeExists');
|
|
53
|
+
expect(publishError.status).toBe(409);
|
|
54
|
+
expect(publishError.message).toContain('already been used');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles non-JSON error bodies', async () => {
|
|
58
|
+
mockFetch(502, '<html>bad gateway</html>');
|
|
59
|
+
const error = await publishToPlaystore({
|
|
60
|
+
registryApiUrl: 'https://registry-staging.limrun.dev',
|
|
61
|
+
accessToken: 'google-token',
|
|
62
|
+
packageName: 'com.example.app',
|
|
63
|
+
assetId: 'asset_1',
|
|
64
|
+
}).catch((caught: unknown) => caught);
|
|
65
|
+
expect(error).toBeInstanceOf(PlaystorePublishError);
|
|
66
|
+
expect((error as PlaystorePublishError).message).toContain('HTTP 502');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type PlaystorePublishInput = {
|
|
2
|
+
registryApiUrl: string;
|
|
3
|
+
/** Limrun API token authenticating the registry call. */
|
|
4
|
+
token?: string;
|
|
5
|
+
/** Organization tid; required when it differs from the token's default organization. */
|
|
6
|
+
organizationId?: string;
|
|
7
|
+
/** Google OAuth access token with the androidpublisher scope. Sent per request, never stored. */
|
|
8
|
+
accessToken: string;
|
|
9
|
+
packageName: string;
|
|
10
|
+
assetId?: string;
|
|
11
|
+
assetName?: string;
|
|
12
|
+
/** Play track ID, defaults to internal on the server. */
|
|
13
|
+
track?: string;
|
|
14
|
+
/** Required by the server when track is production. */
|
|
15
|
+
releaseStatus?: 'draft' | 'completed';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PlaystorePublishResult = {
|
|
19
|
+
versionCode: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Registry publish failure. `code` carries the registry's machine-readable
|
|
24
|
+
* error code when available: invalidRequest, assetNotFound, internal, busy,
|
|
25
|
+
* listingNotFound, permissionDenied, versionCodeExists, uploadKeyMismatch
|
|
26
|
+
* or unknown; the set grows additively.
|
|
27
|
+
*/
|
|
28
|
+
export class PlaystorePublishError extends Error {
|
|
29
|
+
readonly code?: string;
|
|
30
|
+
readonly status?: number;
|
|
31
|
+
|
|
32
|
+
constructor(message: string, options: { code?: string; status?: number } = {}) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'PlaystorePublishError';
|
|
35
|
+
this.code = options.code;
|
|
36
|
+
this.status = options.status;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function publishToPlaystore(input: PlaystorePublishInput): Promise<PlaystorePublishResult> {
|
|
41
|
+
const { registryApiUrl, token, organizationId, ...body } = input;
|
|
42
|
+
let url: URL;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(registryApiUrl);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new PlaystorePublishError(`Invalid registry URL: ${registryApiUrl}`);
|
|
47
|
+
}
|
|
48
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/android/playstore/publish`;
|
|
49
|
+
url.search = '';
|
|
50
|
+
url.hash = '';
|
|
51
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
52
|
+
if (token) {
|
|
53
|
+
headers.Authorization = `Bearer ${token}`;
|
|
54
|
+
}
|
|
55
|
+
if (organizationId) {
|
|
56
|
+
headers['X-Limrun-Organization'] = organizationId;
|
|
57
|
+
}
|
|
58
|
+
let response: Response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(url.toString(), {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers,
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
});
|
|
65
|
+
} catch (caught) {
|
|
66
|
+
throw new PlaystorePublishError(
|
|
67
|
+
`Cannot reach the registry: ${caught instanceof Error ? caught.message : String(caught)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const raw = await response.text();
|
|
71
|
+
let parsed: { versionCode?: number; message?: string; code?: string } | undefined;
|
|
72
|
+
try {
|
|
73
|
+
parsed = raw ? (JSON.parse(raw) as typeof parsed) : undefined;
|
|
74
|
+
} catch {
|
|
75
|
+
parsed = undefined;
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new PlaystorePublishError(parsed?.message || `Play publish failed with HTTP ${response.status}`, {
|
|
79
|
+
code: parsed?.code,
|
|
80
|
+
status: response.status,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (typeof parsed?.versionCode !== 'number') {
|
|
84
|
+
throw new PlaystorePublishError('Play publish response is missing versionCode.', {
|
|
85
|
+
status: response.status,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return { versionCode: parsed.versionCode };
|
|
89
|
+
}
|
|
@@ -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;
|
|
@@ -295,10 +296,7 @@ function App() {
|
|
|
295
296
|
}
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
async function loadAppleResources(
|
|
299
|
-
teamId = developerTeamId,
|
|
300
|
-
relay = appleLogin.session?.relay,
|
|
301
|
-
) {
|
|
299
|
+
async function loadAppleResources(teamId = developerTeamId, relay = appleLogin.session?.relay) {
|
|
302
300
|
if (!apiUrl.trim() || !relay || !teamId) return;
|
|
303
301
|
const base = {
|
|
304
302
|
relay,
|
|
@@ -1056,10 +1054,6 @@ function useLocalStorage(key: string, initialValue: string) {
|
|
|
1056
1054
|
return [value, setValue] as const;
|
|
1057
1055
|
}
|
|
1058
1056
|
|
|
1059
|
-
function errorMessage(error: unknown) {
|
|
1060
|
-
return error instanceof Error ? error.message : String(error);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
1057
|
createRoot(document.getElementById('root')!).render(
|
|
1064
1058
|
<StrictMode>
|
|
1065
1059
|
<App />
|