@limrun/ui 0.14.0-rc.2 → 0.14.0-rc.3
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.js +3 -3
- package/dist/app-store-relay/react.d.ts +1 -1
- package/dist/app-store-relay/react.js +6 -2
- package/dist/{client-CBX-hLnL.mjs → client-C8ZDEjtD.mjs} +1 -6
- package/dist/components/remote-control.d.ts +0 -156
- package/dist/core/device-install/operations/operations.d.ts +1 -1
- 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.js +5 -5
- package/dist/device-install/index.js +7 -1
- package/dist/device-install/react.js +24 -21
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1125 -1405
- package/dist/play-publish/index.cjs +1 -0
- package/dist/play-publish/index.d.ts +2 -0
- package/dist/play-publish/index.js +106 -0
- package/dist/play-publish/react.cjs +1 -0
- package/dist/play-publish/react.d.ts +21 -0
- package/dist/play-publish/react.js +81 -0
- package/dist/{provisioning-DKbkBO-p.mjs → provisioning-BmsEnF6X.mjs} +8 -8
- package/package.json +13 -2
- package/src/app-store-relay/index.test.ts +6 -6
- package/src/app-store-relay/react.ts +5 -1
- package/src/components/remote-control.css +0 -32
- package/src/components/remote-control.tsx +5 -865
- 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 +7 -1
- package/src/core/play-publish/google.ts +104 -0
- package/src/core/play-publish/publish.test.ts +68 -0
- package/src/core/play-publish/publish.ts +89 -0
- package/src/device-install/demo/demo.tsx +1 -4
- package/src/device-install/react.ts +30 -27
- package/src/play-publish/README.md +65 -0
- package/src/play-publish/index.ts +12 -0
- package/src/play-publish/react.ts +168 -0
- package/vite.config.ts +2 -0
|
@@ -99,12 +99,7 @@ export async function startBrowserOwnedAppleIDLogin({
|
|
|
99
99
|
finishTwoFactor: async (code) => {
|
|
100
100
|
const response =
|
|
101
101
|
twoFactorMethod.type === 'phone' ?
|
|
102
|
-
await proxyPhoneTwoFactorCode(
|
|
103
|
-
relay,
|
|
104
|
-
twoFactorMethod.phoneNumberId,
|
|
105
|
-
code,
|
|
106
|
-
twoFactorMethod.mode,
|
|
107
|
-
)
|
|
102
|
+
await proxyPhoneTwoFactorCode(relay, twoFactorMethod.phoneNumberId, code, twoFactorMethod.mode)
|
|
108
103
|
: await proxyTwoFactorCode(relay, code);
|
|
109
104
|
if (response.status < 200 || response.status >= 300) {
|
|
110
105
|
throw new Error(
|
|
@@ -104,10 +104,7 @@ export async function openAppleRelayWebSocket(apiUrl: string, token?: string, or
|
|
|
104
104
|
return client;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
export async function proxySrpInit(
|
|
108
|
-
relay: AppleRelayWebSocketClient,
|
|
109
|
-
payload: AppleSRPInitRequest,
|
|
110
|
-
) {
|
|
107
|
+
export async function proxySrpInit(relay: AppleRelayWebSocketClient, payload: AppleSRPInitRequest) {
|
|
111
108
|
return relay.request<AppleSRPInitResponse>('srpInit', payload);
|
|
112
109
|
}
|
|
113
110
|
|
|
@@ -46,7 +46,13 @@ export async function requestUSBAccess({ log }: RequestUSBAccessOptions) {
|
|
|
46
46
|
return target;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export async function startPairingRelay({
|
|
49
|
+
export async function startPairingRelay({
|
|
50
|
+
registryApiUrl,
|
|
51
|
+
token,
|
|
52
|
+
organizationId,
|
|
53
|
+
log,
|
|
54
|
+
target,
|
|
55
|
+
}: StartPairingRelayOptions) {
|
|
50
56
|
const deviceRelayUrl = deviceRelayWebSocketUrl(registryApiUrl, token, organizationId);
|
|
51
57
|
let relay: RelayClient | undefined;
|
|
52
58
|
try {
|
|
@@ -0,0 +1,104 @@
|
|
|
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 async function requestGoogleAccessToken({
|
|
75
|
+
clientId,
|
|
76
|
+
scope = ANDROID_PUBLISHER_SCOPE,
|
|
77
|
+
}: RequestGoogleAccessTokenInput): Promise<string> {
|
|
78
|
+
await loadGoogleIdentityServices();
|
|
79
|
+
return new Promise<string>((resolve, reject) => {
|
|
80
|
+
const client = googleOAuth2()!.initTokenClient({
|
|
81
|
+
client_id: clientId,
|
|
82
|
+
scope,
|
|
83
|
+
callback: (response) => {
|
|
84
|
+
if (response.error) {
|
|
85
|
+
reject(new Error(response.error_description || response.error));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!response.access_token) {
|
|
89
|
+
reject(new Error('Google returned no access token.'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
resolve(response.access_token);
|
|
93
|
+
},
|
|
94
|
+
error_callback: (error) => {
|
|
95
|
+
const fallback =
|
|
96
|
+
error?.type === 'popup_failed_to_open' ?
|
|
97
|
+
'Google sign-in popup was blocked by the browser.'
|
|
98
|
+
: 'Google sign-in was cancelled.';
|
|
99
|
+
reject(new Error(error?.message || fallback));
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
client.requestAccessToken();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -295,10 +295,7 @@ function App() {
|
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
async function loadAppleResources(
|
|
299
|
-
teamId = developerTeamId,
|
|
300
|
-
relay = appleLogin.session?.relay,
|
|
301
|
-
) {
|
|
298
|
+
async function loadAppleResources(teamId = developerTeamId, relay = appleLogin.session?.relay) {
|
|
302
299
|
if (!apiUrl.trim() || !relay || !teamId) return;
|
|
303
300
|
const base = {
|
|
304
301
|
relay,
|
|
@@ -136,33 +136,36 @@ export function useDeviceInstallRelay({
|
|
|
136
136
|
}
|
|
137
137
|
}, [apiUrl, cleanupDeviceAccess, device, organizationId, token]);
|
|
138
138
|
|
|
139
|
-
const startInstallation = useCallback(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
139
|
+
const startInstallation = useCallback(
|
|
140
|
+
async (installSource: InstallSource) => {
|
|
141
|
+
if (!apiUrl || !device || !pairRecord) {
|
|
142
|
+
throw new Error('Select and pair a USB device before starting installation.');
|
|
143
|
+
}
|
|
144
|
+
setBusyAction('install');
|
|
145
|
+
setError(undefined);
|
|
146
|
+
try {
|
|
147
|
+
await cleanupDeviceAccess();
|
|
148
|
+
relayRef.current = await startDeviceInstall({
|
|
149
|
+
registryApiUrl: apiUrl,
|
|
150
|
+
token,
|
|
151
|
+
organizationId,
|
|
152
|
+
log: logRef.current,
|
|
153
|
+
target: device,
|
|
154
|
+
pairRecord,
|
|
155
|
+
installSource,
|
|
156
|
+
});
|
|
157
|
+
logRef.current('Device install started', 'Installation will continue through the connected iPhone.');
|
|
158
|
+
return relayRef.current;
|
|
159
|
+
} catch (caught) {
|
|
160
|
+
await closeDeviceRelayTarget(device, logRef.current);
|
|
161
|
+
setError(errorMessage(caught));
|
|
162
|
+
return undefined;
|
|
163
|
+
} finally {
|
|
164
|
+
setBusyAction(undefined);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
[apiUrl, cleanupDeviceAccess, device, organizationId, pairRecord, token],
|
|
168
|
+
);
|
|
166
169
|
|
|
167
170
|
const stopRelay = useCallback(() => {
|
|
168
171
|
void cleanupDeviceAccess();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @limrun/ui/play-publish
|
|
2
|
+
|
|
3
|
+
Headless building blocks for publishing an AAB asset from the Limrun
|
|
4
|
+
registry to Google Play, with a browser-owned Google sign-in. No UI ships
|
|
5
|
+
here; embedders render their own buttons and dialogs around the hook,
|
|
6
|
+
same as `device-install`.
|
|
7
|
+
|
|
8
|
+
The Google access token is minted in the browser via Google Identity
|
|
9
|
+
Services (token model, no client secret) and sent to the registry once
|
|
10
|
+
per publish. Limrun never stores it.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- A Google OAuth **Web application** client ID whose authorized
|
|
15
|
+
JavaScript origins include your app's origin.
|
|
16
|
+
- The signed-in Google account needs release permission for the target
|
|
17
|
+
app in Play Console, and the app listing must already exist.
|
|
18
|
+
- The asset must be an AAB signed with the app's Play upload key.
|
|
19
|
+
|
|
20
|
+
## React
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { usePlaystorePublish } from '@limrun/ui/play-publish/react';
|
|
24
|
+
|
|
25
|
+
const play = usePlaystorePublish({
|
|
26
|
+
registryApiUrl: 'https://registry.limrun.com',
|
|
27
|
+
token: limrunToken,
|
|
28
|
+
organizationId: organizationTid,
|
|
29
|
+
googleClientId: GOOGLE_OAUTH_CLIENT_ID,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// On dialog open, warm the sign-in script so the click stays popup-safe:
|
|
33
|
+
play.preloadGoogle();
|
|
34
|
+
|
|
35
|
+
// Button handlers:
|
|
36
|
+
await play.signInWithGoogle();
|
|
37
|
+
await play.publish({ assetName: 'app-release.aab', packageName: 'com.example.app' });
|
|
38
|
+
|
|
39
|
+
// Render from state: play.status, play.versionCode, play.error, play.errorCode
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`errorCode` values: `invalidRequest`, `assetNotFound`, `internal`, `busy`,
|
|
43
|
+
`listingNotFound`, `permissionDenied`, `versionCodeExists`,
|
|
44
|
+
`uploadKeyMismatch`, `unknown`. The set grows additively.
|
|
45
|
+
|
|
46
|
+
Google access tokens expire after about an hour. A `permissionDenied`
|
|
47
|
+
error long after sign-in usually means the token expired; offer "Sign in
|
|
48
|
+
with Google" again rather than pointing users at Play Console
|
|
49
|
+
permissions.
|
|
50
|
+
|
|
51
|
+
## Without React
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { requestGoogleAccessToken, publishToPlaystore } from '@limrun/ui/play-publish';
|
|
55
|
+
|
|
56
|
+
const accessToken = await requestGoogleAccessToken({ clientId: GOOGLE_OAUTH_CLIENT_ID });
|
|
57
|
+
const { versionCode } = await publishToPlaystore({
|
|
58
|
+
registryApiUrl,
|
|
59
|
+
token,
|
|
60
|
+
organizationId,
|
|
61
|
+
accessToken,
|
|
62
|
+
assetName: 'app-release.aab',
|
|
63
|
+
packageName: 'com.example.app',
|
|
64
|
+
});
|
|
65
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ANDROID_PUBLISHER_SCOPE,
|
|
3
|
+
loadGoogleIdentityServices,
|
|
4
|
+
requestGoogleAccessToken,
|
|
5
|
+
type RequestGoogleAccessTokenInput,
|
|
6
|
+
} from '../core/play-publish/google';
|
|
7
|
+
export {
|
|
8
|
+
publishToPlaystore,
|
|
9
|
+
PlaystorePublishError,
|
|
10
|
+
type PlaystorePublishInput,
|
|
11
|
+
type PlaystorePublishResult,
|
|
12
|
+
} from '../core/play-publish/publish';
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
loadGoogleIdentityServices,
|
|
4
|
+
PlaystorePublishError,
|
|
5
|
+
publishToPlaystore,
|
|
6
|
+
requestGoogleAccessToken,
|
|
7
|
+
type PlaystorePublishInput,
|
|
8
|
+
type PlaystorePublishResult,
|
|
9
|
+
} from './index';
|
|
10
|
+
|
|
11
|
+
export type UsePlaystorePublishOptions = Partial<
|
|
12
|
+
Pick<PlaystorePublishInput, 'registryApiUrl' | 'token' | 'organizationId'>
|
|
13
|
+
> & {
|
|
14
|
+
/** Google OAuth Web application client ID used for sign-in. */
|
|
15
|
+
googleClientId?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PlaystorePublishStatus = 'idle' | 'signing-in' | 'ready' | 'publishing' | 'published' | 'error';
|
|
19
|
+
|
|
20
|
+
export type PlaystorePublishRequest = Omit<
|
|
21
|
+
PlaystorePublishInput,
|
|
22
|
+
'registryApiUrl' | 'token' | 'organizationId' | 'accessToken'
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
export type UsePlaystorePublishResult = {
|
|
26
|
+
status: PlaystorePublishStatus;
|
|
27
|
+
isSignedIn: boolean;
|
|
28
|
+
versionCode?: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
/** Registry error code driving per-cause UX, e.g. versionCodeExists. */
|
|
31
|
+
errorCode?: string;
|
|
32
|
+
/** Warm up the Google sign-in script (e.g. on dialog open) so signInWithGoogle stays synchronous with the click. */
|
|
33
|
+
preloadGoogle: () => void;
|
|
34
|
+
signInWithGoogle: () => Promise<boolean>;
|
|
35
|
+
publish: (request: PlaystorePublishRequest) => Promise<PlaystorePublishResult | undefined>;
|
|
36
|
+
reset: () => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function usePlaystorePublish({
|
|
40
|
+
registryApiUrl,
|
|
41
|
+
token,
|
|
42
|
+
organizationId,
|
|
43
|
+
googleClientId,
|
|
44
|
+
}: UsePlaystorePublishOptions): UsePlaystorePublishResult {
|
|
45
|
+
const [status, setStatus] = useState<PlaystorePublishStatus>('idle');
|
|
46
|
+
const [isSignedIn, setIsSignedIn] = useState(false);
|
|
47
|
+
const [versionCode, setVersionCode] = useState<number | undefined>();
|
|
48
|
+
const [error, setError] = useState<string | undefined>();
|
|
49
|
+
const [errorCode, setErrorCode] = useState<string | undefined>();
|
|
50
|
+
// The token lives in a ref so publish() right after signInWithGoogle()
|
|
51
|
+
// in one handler sees it before React re-renders.
|
|
52
|
+
const accessTokenRef = useRef<string | undefined>(undefined);
|
|
53
|
+
// reset() bumps the generation so an abandoned in-flight call cannot
|
|
54
|
+
// overwrite the state it settles into.
|
|
55
|
+
const generationRef = useRef(0);
|
|
56
|
+
const busyRef = useRef(false);
|
|
57
|
+
|
|
58
|
+
const preloadGoogle = useCallback(() => {
|
|
59
|
+
void loadGoogleIdentityServices().catch(() => undefined);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const signInWithGoogle = useCallback(async () => {
|
|
63
|
+
if (busyRef.current) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (!googleClientId) {
|
|
67
|
+
setError('Google OAuth client ID is not configured.');
|
|
68
|
+
setStatus('error');
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
busyRef.current = true;
|
|
72
|
+
const generation = generationRef.current;
|
|
73
|
+
setStatus('signing-in');
|
|
74
|
+
setError(undefined);
|
|
75
|
+
setErrorCode(undefined);
|
|
76
|
+
try {
|
|
77
|
+
const nextToken = await requestGoogleAccessToken({ clientId: googleClientId });
|
|
78
|
+
accessTokenRef.current = nextToken;
|
|
79
|
+
if (generation === generationRef.current) {
|
|
80
|
+
setIsSignedIn(true);
|
|
81
|
+
setStatus('ready');
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
} catch (caught) {
|
|
85
|
+
if (generation === generationRef.current) {
|
|
86
|
+
setError(errorMessage(caught));
|
|
87
|
+
setStatus('error');
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
} finally {
|
|
91
|
+
busyRef.current = false;
|
|
92
|
+
}
|
|
93
|
+
}, [googleClientId]);
|
|
94
|
+
|
|
95
|
+
const publish = useCallback(
|
|
96
|
+
async (request: PlaystorePublishRequest) => {
|
|
97
|
+
if (busyRef.current) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
if (!registryApiUrl) {
|
|
101
|
+
setError('Registry URL is not configured.');
|
|
102
|
+
setStatus('error');
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const accessToken = accessTokenRef.current;
|
|
106
|
+
if (!accessToken) {
|
|
107
|
+
setError('Sign in with Google before publishing.');
|
|
108
|
+
setStatus('error');
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
busyRef.current = true;
|
|
112
|
+
const generation = generationRef.current;
|
|
113
|
+
setStatus('publishing');
|
|
114
|
+
setError(undefined);
|
|
115
|
+
setErrorCode(undefined);
|
|
116
|
+
try {
|
|
117
|
+
const result = await publishToPlaystore({
|
|
118
|
+
registryApiUrl,
|
|
119
|
+
token,
|
|
120
|
+
organizationId,
|
|
121
|
+
accessToken,
|
|
122
|
+
...request,
|
|
123
|
+
});
|
|
124
|
+
if (generation === generationRef.current) {
|
|
125
|
+
setVersionCode(result.versionCode);
|
|
126
|
+
setStatus('published');
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
} catch (caught) {
|
|
130
|
+
if (generation === generationRef.current) {
|
|
131
|
+
setError(errorMessage(caught));
|
|
132
|
+
if (caught instanceof PlaystorePublishError) {
|
|
133
|
+
setErrorCode(caught.code);
|
|
134
|
+
}
|
|
135
|
+
setStatus('error');
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
} finally {
|
|
139
|
+
busyRef.current = false;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
[registryApiUrl, token, organizationId],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const reset = useCallback(() => {
|
|
146
|
+
generationRef.current += 1;
|
|
147
|
+
setVersionCode(undefined);
|
|
148
|
+
setError(undefined);
|
|
149
|
+
setErrorCode(undefined);
|
|
150
|
+
setStatus(accessTokenRef.current ? 'ready' : 'idle');
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
status,
|
|
155
|
+
isSignedIn,
|
|
156
|
+
versionCode,
|
|
157
|
+
error,
|
|
158
|
+
errorCode,
|
|
159
|
+
preloadGoogle,
|
|
160
|
+
signInWithGoogle,
|
|
161
|
+
publish,
|
|
162
|
+
reset,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function errorMessage(error: unknown) {
|
|
167
|
+
return error instanceof Error ? error.message : String(error);
|
|
168
|
+
}
|
package/vite.config.ts
CHANGED
|
@@ -17,6 +17,8 @@ export default defineConfig({
|
|
|
17
17
|
'device-build/react': resolve(__dirname, 'src/device-build/react.ts'),
|
|
18
18
|
'device-install/index': resolve(__dirname, 'src/device-install/index.ts'),
|
|
19
19
|
'device-install/react': resolve(__dirname, 'src/device-install/react.ts'),
|
|
20
|
+
'play-publish/index': resolve(__dirname, 'src/play-publish/index.ts'),
|
|
21
|
+
'play-publish/react': resolve(__dirname, 'src/play-publish/react.ts'),
|
|
20
22
|
},
|
|
21
23
|
name: 'LimrunUI',
|
|
22
24
|
formats: ['es', 'cjs'],
|