@makefully/adaptfully 5.1.1 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/docs/credentials/ios.md +4 -1
- package/docs/device-auth.md +67 -0
- package/lib/node/apple-signing.js +18 -16
- package/lib/node/artifacts.js +13 -6
- package/lib/node/deploy.js +5 -1
- package/lib/node/packagers/web.js +3 -3
- package/lib/node/registrations.js +16 -0
- package/lib/runtime/auth/device-auth-companion.js +69 -0
- package/lib/runtime/auth/device-auth.js +335 -0
- package/lib/runtime/host/tv-host.js +123 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 5.2.0 — 2026-11-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`device-auth`** — PIN/QR limited-input auth plugin; TV starts/polls, companion completes on the web deploy. See [`docs/device-auth.md`](docs/device-auth.md).
|
|
10
|
+
- **`tv-host`** — lean-back host with Back key + `onDeviceAuth` for PIN/QR chrome.
|
|
11
|
+
- **Web packager** — default platforms include `webos` / `lgtv`; builder family resolves `webos` even with `packager: "web"`.
|
|
12
|
+
|
|
13
|
+
## 5.1.2 — 2026-11-10
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Deploy preserveArtifacts** — `adaptfully deploy` no longer deletes `artifacts/` and `wrapfully-build.json` before packaging the prior build extract, so the IPA/AAB survives into the deploy chore.
|
|
18
|
+
- **Apple PKCS#12 export** — `apple-signing` (and iOS credential docs) export `.p12` with PBE-SHA1-3DES + SHA1 MAC so macOS `security import` accepts the passphrase.
|
|
19
|
+
|
|
5
20
|
## 5.1.1 — 2026-11-05
|
|
6
21
|
|
|
7
22
|
### Added
|
package/docs/credentials/ios.md
CHANGED
|
@@ -104,8 +104,11 @@ Skip this if step 1 already exported `development.p12` / `distribution.p12`.
|
|
|
104
104
|
```bash
|
|
105
105
|
# From the folder that has private.key and the downloaded .cer
|
|
106
106
|
openssl x509 -in Certificates.cer -inform DER -out cert.pem -outform PEM
|
|
107
|
-
openssl pkcs12 -export -inkey private.key -in cert.pem -out development.p12
|
|
107
|
+
openssl pkcs12 -export -inkey private.key -in cert.pem -out development.p12 \
|
|
108
|
+
-keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1
|
|
108
109
|
# or distribution.p12 — use the matching kind
|
|
110
|
+
# (AES-256 / SHA-256 PKCS#12 often fails macOS `security import` with a false
|
|
111
|
+
# "wrong password?" MAC error — stick to the classic algorithms above.)
|
|
109
112
|
```
|
|
110
113
|
|
|
111
114
|
Move the `.p12` to `assets/meta/deployments/ios/apple/`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Device auth (PIN / QR)
|
|
2
|
+
|
|
3
|
+
`device-auth` is an Adaptfully auth plugin for limited-input shells (LG webOS TV, etc.). The TV build only **starts** and **polls** a short-lived session. Companion Google (or other) sign-in happens on the game’s **existing web deploy**, not inside the TV package.
|
|
4
|
+
|
|
5
|
+
## Registration
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
"lgtv": {
|
|
9
|
+
"packager": "web",
|
|
10
|
+
"builder": "webos",
|
|
11
|
+
"registrations": {
|
|
12
|
+
"auth": "device-auth",
|
|
13
|
+
"host": "tv-host",
|
|
14
|
+
"config": "javascript/adaptfully-bridge.js"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Config keys
|
|
20
|
+
|
|
21
|
+
| Key | Purpose |
|
|
22
|
+
|-----|---------|
|
|
23
|
+
| `deviceAuthStartUrl` | `POST` mint session |
|
|
24
|
+
| `deviceAuthPollUrl` | `GET` poll (`?device_code=`) |
|
|
25
|
+
| `deviceAuthCompanionBase` | Fallback QR/base URL if start omits `verificationUrl` |
|
|
26
|
+
|
|
27
|
+
## HTTP protocol
|
|
28
|
+
|
|
29
|
+
### `POST start`
|
|
30
|
+
|
|
31
|
+
Response:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"deviceCode": "opaque-poll-token",
|
|
36
|
+
"userCode": "AB12CD",
|
|
37
|
+
"expiresIn": 600,
|
|
38
|
+
"interval": 2000,
|
|
39
|
+
"verificationUrl": "https://game.example/tv-login.html?code=AB12CD"
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### `GET poll?device_code=…`
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{ "status": "pending" }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "status": "approved", "user": { "id": "provider-subject", "email": "a@b.co" } }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Also: `expired`, `denied`.
|
|
54
|
+
|
|
55
|
+
### `POST complete`
|
|
56
|
+
|
|
57
|
+
Body: `user_code` or `device_code`, plus `plusId`, `email`, optional `user_id`.
|
|
58
|
+
|
|
59
|
+
## Companion helpers
|
|
60
|
+
|
|
61
|
+
Optional page script: `lib/runtime/auth/device-auth-companion.js`
|
|
62
|
+
(`adaptfully.auth.deviceCompanion.readCodeFromLocation()` / `.complete(...)`).
|
|
63
|
+
|
|
64
|
+
## Events
|
|
65
|
+
|
|
66
|
+
`window` CustomEvent `adaptfully:device-auth` with `detail.phase` of `pending` | `approved` | `expired` | `denied` | `error`.
|
|
67
|
+
`tv-host` forwards these via `onDeviceAuth(cb)`.
|
|
@@ -156,26 +156,28 @@ export async function exportAppleP12(options) {
|
|
|
156
156
|
const pemPath = `${options.cerPath}.pem`;
|
|
157
157
|
await run(['x509', '-in', options.cerPath, '-inform', 'DER', '-out', pemPath, '-outform', 'PEM']);
|
|
158
158
|
|
|
159
|
+
// Prefer classic PBEwithSHA1And3-KeyTripleDES + SHA1 MAC. OpenSSL's newer
|
|
160
|
+
// AES-256 / SHA-256 PKCS#12 defaults often fail macOS `security import`
|
|
161
|
+
// with "MAC verification failed (wrong password?)" even when the passphrase
|
|
162
|
+
// is correct.
|
|
163
|
+
const exportArgs = (certIn) => [
|
|
164
|
+
'pkcs12',
|
|
165
|
+
'-export',
|
|
166
|
+
'-inkey', options.keyPath,
|
|
167
|
+
'-in', certIn,
|
|
168
|
+
'-out', options.p12Path,
|
|
169
|
+
'-passout', `pass:${options.p12Password}`,
|
|
170
|
+
'-keypbe', 'PBE-SHA1-3DES',
|
|
171
|
+
'-certpbe', 'PBE-SHA1-3DES',
|
|
172
|
+
'-macalg', 'sha1',
|
|
173
|
+
];
|
|
174
|
+
|
|
159
175
|
try {
|
|
160
|
-
await run(
|
|
161
|
-
'pkcs12',
|
|
162
|
-
'-export',
|
|
163
|
-
'-inkey', options.keyPath,
|
|
164
|
-
'-in', pemPath,
|
|
165
|
-
'-out', options.p12Path,
|
|
166
|
-
'-passout', `pass:${options.p12Password}`,
|
|
167
|
-
]);
|
|
176
|
+
await run(exportArgs(pemPath));
|
|
168
177
|
} catch (derErr) {
|
|
169
178
|
// Some downloads are already PEM
|
|
170
179
|
try {
|
|
171
|
-
await run(
|
|
172
|
-
'pkcs12',
|
|
173
|
-
'-export',
|
|
174
|
-
'-inkey', options.keyPath,
|
|
175
|
-
'-in', options.cerPath,
|
|
176
|
-
'-out', options.p12Path,
|
|
177
|
-
'-passout', `pass:${options.p12Password}`,
|
|
178
|
-
]);
|
|
180
|
+
await run(exportArgs(options.cerPath));
|
|
179
181
|
} catch {
|
|
180
182
|
throw derErr;
|
|
181
183
|
}
|
package/lib/node/artifacts.js
CHANGED
|
@@ -38,15 +38,22 @@ export function resolveExtractRoot(outputRoot, platformKey) {
|
|
|
38
38
|
* Remove prior extract artifacts in `extractRoot` only (artifacts/, status,
|
|
39
39
|
* manifest). Does not wipe sibling platform folders under output/.
|
|
40
40
|
*
|
|
41
|
+
* Deploy packages from the prior build extract in place — pass
|
|
42
|
+
* `{ preserveArtifacts: true }` so the IPA/AAB and manifest are not deleted
|
|
43
|
+
* before `createDeployArchive` runs.
|
|
44
|
+
*
|
|
41
45
|
* @param {string} [extractRoot='output']
|
|
46
|
+
* @param {{ preserveArtifacts?: boolean }} [options]
|
|
42
47
|
*/
|
|
43
|
-
export function clearStaleBuildExtract(extractRoot = 'output') {
|
|
48
|
+
export function clearStaleBuildExtract(extractRoot = 'output', options = {}) {
|
|
44
49
|
const root = path.resolve(extractRoot);
|
|
45
|
-
const stalePaths =
|
|
46
|
-
path.join(root,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
const stalePaths = options.preserveArtifacts
|
|
51
|
+
? [path.join(root, STATUS_NAME)]
|
|
52
|
+
: [
|
|
53
|
+
path.join(root, 'artifacts'),
|
|
54
|
+
path.join(root, MANIFEST_NAME),
|
|
55
|
+
path.join(root, STATUS_NAME),
|
|
56
|
+
];
|
|
50
57
|
|
|
51
58
|
for (const stalePath of stalePaths) {
|
|
52
59
|
fs.rmSync(stalePath, { recursive: true, force: true });
|
package/lib/node/deploy.js
CHANGED
|
@@ -257,7 +257,11 @@ export async function send (
|
|
|
257
257
|
|
|
258
258
|
if (mode === 'extract') {
|
|
259
259
|
fs.mkdirSync(extractRoot, { recursive: true });
|
|
260
|
-
|
|
260
|
+
// Deploy zips from extractRoot itself; wiping artifacts/manifest here
|
|
261
|
+
// deleted the IPA before createDeployArchive could package it.
|
|
262
|
+
clearStaleBuildExtract(extractRoot, {
|
|
263
|
+
preserveArtifacts: stage === 'deploy',
|
|
264
|
+
});
|
|
261
265
|
}
|
|
262
266
|
|
|
263
267
|
let archiveStream;
|
|
@@ -5,11 +5,11 @@ export class WebPackager extends Packager {
|
|
|
5
5
|
static id = 'web';
|
|
6
6
|
|
|
7
7
|
/** @type {string[]} */
|
|
8
|
-
static defaultPlatforms = ['web', 'uwp', 'pwa'];
|
|
8
|
+
static defaultPlatforms = ['web', 'uwp', 'pwa', 'webos', 'lgtv'];
|
|
9
9
|
|
|
10
10
|
needsGameConfig() {
|
|
11
|
-
// Stamp version/platform for all web-family routes (web, uwp, pwa)
|
|
12
|
-
// analytics resolveAppVersion() matches electron/capacitor builds.
|
|
11
|
+
// Stamp version/platform for all web-family routes (web, uwp, pwa, webos)
|
|
12
|
+
// so analytics resolveAppVersion() matches electron/capacitor builds.
|
|
13
13
|
return true;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -30,6 +30,10 @@ export const STANDARD_PLUGINS = {
|
|
|
30
30
|
scripts: ['core.js', 'platform.js', 'auth/_helpers.js', 'auth/dev-auth.js'],
|
|
31
31
|
registration: (key) => `adaptfully.register('${key}', adaptfully.auth.Dev);`,
|
|
32
32
|
},
|
|
33
|
+
'device-auth': {
|
|
34
|
+
scripts: ['core.js', 'platform.js', 'auth/_helpers.js', 'auth/device-auth.js'],
|
|
35
|
+
registration: (key) => `adaptfully.register('${key}', adaptfully.auth.Device);`,
|
|
36
|
+
},
|
|
33
37
|
localStorage: {
|
|
34
38
|
scripts: ['core.js', 'storage/_helpers.js', 'storage/local-storage.js'],
|
|
35
39
|
registration: (key) => `adaptfully.register('${key}', adaptfully.storage.LocalStorage());`,
|
|
@@ -58,6 +62,10 @@ export const STANDARD_PLUGINS = {
|
|
|
58
62
|
scripts: ['core.js', 'host/_helpers.js', 'host/noop-host.js'],
|
|
59
63
|
registration: (key) => `adaptfully.register('${key}', adaptfully.host.Noop());`,
|
|
60
64
|
},
|
|
65
|
+
'tv-host': {
|
|
66
|
+
scripts: ['core.js', 'host/_helpers.js', 'host/tv-host.js'],
|
|
67
|
+
registration: (key) => `adaptfully.register('${key}', adaptfully.host.Tv());`,
|
|
68
|
+
},
|
|
61
69
|
};
|
|
62
70
|
|
|
63
71
|
/** Default Wrapfully builder → config.platforms key */
|
|
@@ -77,6 +85,8 @@ export const DEFAULT_BUILDER_PLATFORMS = {
|
|
|
77
85
|
'ios-sim': 'ios',
|
|
78
86
|
webapp: 'web',
|
|
79
87
|
uwp: 'uwp',
|
|
88
|
+
webos: 'lgtv',
|
|
89
|
+
'webos-dev': 'lgtv',
|
|
80
90
|
};
|
|
81
91
|
|
|
82
92
|
const ADAPTFULLY_MARKER = '<!-- adaptfully -->';
|
|
@@ -402,6 +412,9 @@ export function resolveFamilyFromTarget(target) {
|
|
|
402
412
|
if (target === 'pwa') {
|
|
403
413
|
return 'pwa';
|
|
404
414
|
}
|
|
415
|
+
if (target === 'webos' || target === 'webos-dev') {
|
|
416
|
+
return 'webos';
|
|
417
|
+
}
|
|
405
418
|
if (target === 'webapp' || target === 'web') {
|
|
406
419
|
return 'webapp';
|
|
407
420
|
}
|
|
@@ -419,6 +432,9 @@ export function resolveFamily(targets, packager) {
|
|
|
419
432
|
if (targets.length === 1 && targets[0] === 'pwa') {
|
|
420
433
|
return 'pwa';
|
|
421
434
|
}
|
|
435
|
+
if (targets.length === 1 && (targets[0] === 'webos' || targets[0] === 'webos-dev')) {
|
|
436
|
+
return 'webos';
|
|
437
|
+
}
|
|
422
438
|
|
|
423
439
|
if (packager === 'electron') {
|
|
424
440
|
return 'electron';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* global fetch, window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optional helpers for a web companion page that completes device-auth sessions.
|
|
5
|
+
* Not registered as an Adaptfully plugin — include on the companion page only.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const code = adaptfully.auth.deviceCompanion.readCodeFromLocation();
|
|
9
|
+
* await adaptfully.auth.deviceCompanion.complete({
|
|
10
|
+
* completeUrl, deviceCode or userCode, plusId, email, userId?
|
|
11
|
+
* });
|
|
12
|
+
*/
|
|
13
|
+
(function registerDeviceAuthCompanion(ns) {
|
|
14
|
+
ns.auth = ns.auth || {};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function readCodeFromLocation() {
|
|
20
|
+
try {
|
|
21
|
+
const params = new URLSearchParams(window.location.search || '');
|
|
22
|
+
return String(params.get('code') || params.get('user_code') || params.get('pin') || '').trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{
|
|
30
|
+
* completeUrl: string,
|
|
31
|
+
* deviceCode?: string,
|
|
32
|
+
* userCode?: string,
|
|
33
|
+
* plusId: string,
|
|
34
|
+
* email: string,
|
|
35
|
+
* userId?: string,
|
|
36
|
+
* }} options
|
|
37
|
+
*/
|
|
38
|
+
async function complete(options) {
|
|
39
|
+
const completeUrl = String(options?.completeUrl || '').trim();
|
|
40
|
+
if (!completeUrl) {
|
|
41
|
+
throw new Error('deviceCompanion.complete requires completeUrl');
|
|
42
|
+
}
|
|
43
|
+
const body = {
|
|
44
|
+
device_code: options.deviceCode || undefined,
|
|
45
|
+
user_code: options.userCode || undefined,
|
|
46
|
+
plusId: options.plusId,
|
|
47
|
+
email: options.email,
|
|
48
|
+
user_id: options.userId || undefined,
|
|
49
|
+
};
|
|
50
|
+
const response = await fetch(completeUrl, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
Accept: 'application/json',
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const text = await response.text().catch(() => '');
|
|
60
|
+
throw new Error(`device-login complete failed (${response.status}): ${text}`);
|
|
61
|
+
}
|
|
62
|
+
return response.json().catch(() => ({ ok: true }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ns.auth.deviceCompanion = {
|
|
66
|
+
readCodeFromLocation,
|
|
67
|
+
complete,
|
|
68
|
+
};
|
|
69
|
+
}(window.adaptfully || (window.adaptfully = {})));
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/* global fetch, window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Device / PIN / QR auth for limited-input shells (LG webOS TV, etc.).
|
|
5
|
+
*
|
|
6
|
+
* TV build starts a short-lived session and polls. Companion sign-in happens on
|
|
7
|
+
* the game's existing web deploy (not in the TV package).
|
|
8
|
+
*
|
|
9
|
+
* Config (adaptfully config / game-config):
|
|
10
|
+
* deviceAuthStartUrl POST → { deviceCode, userCode, expiresIn, interval?, verificationUrl }
|
|
11
|
+
* deviceAuthPollUrl GET → { status, user? } (append ?device_code=)
|
|
12
|
+
* deviceAuthCompanionBase optional base for QR if start omits verificationUrl
|
|
13
|
+
*
|
|
14
|
+
* Events: window 'adaptfully:device-auth' CustomEvent with detail
|
|
15
|
+
* { phase: 'pending'|'approved'|'expired'|'denied'|'error', userCode?, verificationUrl?, qrUrl?, expiresIn?, error? }
|
|
16
|
+
*
|
|
17
|
+
* adaptfully.register('auth', adaptfully.auth.Device);
|
|
18
|
+
*/
|
|
19
|
+
(function registerDeviceAuth(ns) {
|
|
20
|
+
const { configValue, getStorage } = ns.auth.helpers;
|
|
21
|
+
|
|
22
|
+
const DEFAULT_AUTO_LOGIN_KEY = 'lastLoggedIn';
|
|
23
|
+
const DEFAULT_USER_KEY = 'adaptfully_device_auth_user';
|
|
24
|
+
const DEFAULT_INTERVAL_MS = 2000;
|
|
25
|
+
|
|
26
|
+
class DeviceAuthPlugin {
|
|
27
|
+
constructor() {
|
|
28
|
+
this.name = 'device';
|
|
29
|
+
this.user = null;
|
|
30
|
+
this.authenticated = false;
|
|
31
|
+
this._pending = null;
|
|
32
|
+
this._pollTimer = null;
|
|
33
|
+
this._loginInFlight = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
supportsAutoLogin() {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
supportsLogout() {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
requiresEmail() {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#autoLoginStorageKey() {
|
|
49
|
+
return configValue('autoLoginStorageKey', DEFAULT_AUTO_LOGIN_KEY);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#userStorageKey() {
|
|
53
|
+
return configValue('deviceAuthUserKey', DEFAULT_USER_KEY);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#startUrl() {
|
|
57
|
+
return String(configValue('deviceAuthStartUrl', '') || '').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#pollUrl() {
|
|
61
|
+
return String(configValue('deviceAuthPollUrl', '') || '').trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#companionBase() {
|
|
65
|
+
return String(configValue('deviceAuthCompanionBase', '') || '').trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#emit(detail) {
|
|
69
|
+
try {
|
|
70
|
+
window.dispatchEvent(new CustomEvent('adaptfully:device-auth', { detail }));
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#persistUser(user) {
|
|
77
|
+
const storage = getStorage();
|
|
78
|
+
if (!storage || !user?.id) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
storage.set(this.#autoLoginStorageKey(), user.id);
|
|
82
|
+
try {
|
|
83
|
+
storage.set(this.#userStorageKey(), JSON.stringify({
|
|
84
|
+
id: user.id,
|
|
85
|
+
email: user.email || '',
|
|
86
|
+
}));
|
|
87
|
+
} catch {
|
|
88
|
+
// ignore
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#clearPersisted() {
|
|
93
|
+
const storage = getStorage();
|
|
94
|
+
storage?.remove(this.#autoLoginStorageKey());
|
|
95
|
+
storage?.remove(this.#userStorageKey());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#readPersistedUser() {
|
|
99
|
+
const storage = getStorage();
|
|
100
|
+
const raw = storage?.get?.(this.#userStorageKey());
|
|
101
|
+
if (!raw || typeof raw !== 'string') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(raw);
|
|
106
|
+
if (parsed?.id) {
|
|
107
|
+
return {
|
|
108
|
+
id: String(parsed.id),
|
|
109
|
+
email: String(parsed.email || ''),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#stopPoll() {
|
|
119
|
+
if (this._pollTimer != null) {
|
|
120
|
+
window.clearTimeout(this._pollTimer);
|
|
121
|
+
this._pollTimer = null;
|
|
122
|
+
}
|
|
123
|
+
this._pending = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#buildVerificationUrl(deviceCode, userCode, fromServer) {
|
|
127
|
+
if (fromServer) {
|
|
128
|
+
return String(fromServer);
|
|
129
|
+
}
|
|
130
|
+
const base = this.#companionBase();
|
|
131
|
+
if (!base) {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
const join = base.includes('?') ? '&' : '?';
|
|
135
|
+
const code = encodeURIComponent(deviceCode || userCode || '');
|
|
136
|
+
return `${base}${join}code=${code}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
whenReady(done) {
|
|
140
|
+
if (!this.#startUrl() || !this.#pollUrl()) {
|
|
141
|
+
done({
|
|
142
|
+
error: 'device-auth: deviceAuthStartUrl and deviceAuthPollUrl are required',
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
done();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
login(callback) {
|
|
150
|
+
if (this.authenticated && this.user?.id) {
|
|
151
|
+
callback({ authenticated: true, user: this.getUser() });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (this._loginInFlight) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.#stopPoll();
|
|
158
|
+
this._loginInFlight = true;
|
|
159
|
+
|
|
160
|
+
fetch(this.#startUrl(), {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
163
|
+
body: '{}',
|
|
164
|
+
})
|
|
165
|
+
.then((response) => {
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`device-login start failed (${response.status})`);
|
|
168
|
+
}
|
|
169
|
+
return response.json();
|
|
170
|
+
})
|
|
171
|
+
.then((data) => {
|
|
172
|
+
const deviceCode = data.deviceCode || data.device_code;
|
|
173
|
+
const userCode = data.userCode || data.user_code;
|
|
174
|
+
const expiresIn = Number(data.expiresIn || data.expires_in || 600);
|
|
175
|
+
const intervalMs = Math.max(
|
|
176
|
+
1000,
|
|
177
|
+
Number(data.interval || data.intervalMs || DEFAULT_INTERVAL_MS),
|
|
178
|
+
);
|
|
179
|
+
const verificationUrl = this.#buildVerificationUrl(
|
|
180
|
+
deviceCode,
|
|
181
|
+
userCode,
|
|
182
|
+
data.verificationUrl || data.verification_url,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (!deviceCode || !userCode) {
|
|
186
|
+
throw new Error('device-login start missing deviceCode/userCode');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this._pending = {
|
|
190
|
+
deviceCode,
|
|
191
|
+
userCode,
|
|
192
|
+
verificationUrl,
|
|
193
|
+
expiresAt: Date.now() + (expiresIn * 1000),
|
|
194
|
+
intervalMs,
|
|
195
|
+
callback,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
this.#emit({
|
|
199
|
+
phase: 'pending',
|
|
200
|
+
userCode,
|
|
201
|
+
verificationUrl,
|
|
202
|
+
qrUrl: verificationUrl,
|
|
203
|
+
expiresIn,
|
|
204
|
+
deviceCode,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
this.#schedulePoll();
|
|
208
|
+
})
|
|
209
|
+
.catch((err) => {
|
|
210
|
+
this._loginInFlight = false;
|
|
211
|
+
this.#emit({
|
|
212
|
+
phase: 'error',
|
|
213
|
+
error: err?.message || String(err),
|
|
214
|
+
});
|
|
215
|
+
callback({ authenticated: false, user: null });
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#schedulePoll() {
|
|
220
|
+
const pending = this._pending;
|
|
221
|
+
if (!pending) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this._pollTimer = window.setTimeout(() => this.#pollOnce(), pending.intervalMs);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#pollOnce() {
|
|
228
|
+
const pending = this._pending;
|
|
229
|
+
if (!pending) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (Date.now() > pending.expiresAt) {
|
|
234
|
+
this.#stopPoll();
|
|
235
|
+
this._loginInFlight = false;
|
|
236
|
+
this.#emit({ phase: 'expired', userCode: pending.userCode });
|
|
237
|
+
pending.callback({ authenticated: false, user: null });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const url = new URL(this.#pollUrl(), window.location.href);
|
|
242
|
+
url.searchParams.set('device_code', pending.deviceCode);
|
|
243
|
+
|
|
244
|
+
fetch(url.toString(), { headers: { Accept: 'application/json' } })
|
|
245
|
+
.then((response) => {
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
throw new Error(`device-login poll failed (${response.status})`);
|
|
248
|
+
}
|
|
249
|
+
return response.json();
|
|
250
|
+
})
|
|
251
|
+
.then((data) => {
|
|
252
|
+
const status = String(data.status || '').toLowerCase();
|
|
253
|
+
if (status === 'approved' && data.user?.id) {
|
|
254
|
+
const user = {
|
|
255
|
+
id: String(data.user.id),
|
|
256
|
+
email: String(data.user.email || ''),
|
|
257
|
+
};
|
|
258
|
+
this.user = user;
|
|
259
|
+
this.authenticated = true;
|
|
260
|
+
this.#persistUser(user);
|
|
261
|
+
this.#emit({
|
|
262
|
+
phase: 'approved',
|
|
263
|
+
userCode: pending.userCode,
|
|
264
|
+
user,
|
|
265
|
+
});
|
|
266
|
+
const cb = pending.callback;
|
|
267
|
+
this.#stopPoll();
|
|
268
|
+
this._loginInFlight = false;
|
|
269
|
+
cb({ authenticated: true, user: this.getUser() });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (status === 'expired' || status === 'denied') {
|
|
273
|
+
this.#emit({
|
|
274
|
+
phase: status,
|
|
275
|
+
userCode: pending.userCode,
|
|
276
|
+
});
|
|
277
|
+
const cb = pending.callback;
|
|
278
|
+
this.#stopPoll();
|
|
279
|
+
this._loginInFlight = false;
|
|
280
|
+
cb({ authenticated: false, user: null });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.#schedulePoll();
|
|
284
|
+
})
|
|
285
|
+
.catch((err) => {
|
|
286
|
+
console.warn('[adaptfully device-auth] poll error', err);
|
|
287
|
+
this.#schedulePoll();
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
autoLogin(callback) {
|
|
292
|
+
const stored = this.#readPersistedUser();
|
|
293
|
+
if (stored?.id) {
|
|
294
|
+
this.user = stored;
|
|
295
|
+
this.authenticated = true;
|
|
296
|
+
callback({ authenticated: true, user: this.getUser() });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
callback({ authenticated: false, user: null });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
logout(callback) {
|
|
303
|
+
this.#stopPoll();
|
|
304
|
+
this._loginInFlight = false;
|
|
305
|
+
this.authenticated = false;
|
|
306
|
+
this.user = null;
|
|
307
|
+
this.#clearPersisted();
|
|
308
|
+
callback();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
getUser() {
|
|
312
|
+
if (!this.authenticated || !this.user) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
return { id: this.user.id, email: this.user.email };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
isAuthenticated() {
|
|
319
|
+
return !!this.authenticated;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Cancel an in-progress PIN/QR login without logging out a restored session. */
|
|
323
|
+
cancelLogin() {
|
|
324
|
+
const pending = this._pending;
|
|
325
|
+
this.#stopPoll();
|
|
326
|
+
this._loginInFlight = false;
|
|
327
|
+
if (pending?.callback) {
|
|
328
|
+
this.#emit({ phase: 'denied', userCode: pending.userCode });
|
|
329
|
+
pending.callback({ authenticated: false, user: null });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
ns.auth.Device = () => new DeviceAuthPlugin();
|
|
335
|
+
}(window.adaptfully));
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/* global window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lean-back TV host (LG webOS and similar).
|
|
5
|
+
* Handles Back key → history/back listeners and exposes environment "tv".
|
|
6
|
+
* Listen for adaptfully:device-auth to render PIN/QR chrome in the game UI.
|
|
7
|
+
*
|
|
8
|
+
* adaptfully.register('host', adaptfully.host.Tv);
|
|
9
|
+
*/
|
|
10
|
+
(function registerTvHost(ns) {
|
|
11
|
+
const { helpers } = ns.host;
|
|
12
|
+
|
|
13
|
+
ns.host.Tv = function tvHostFactory() {
|
|
14
|
+
const pauseListeners = [];
|
|
15
|
+
const captionsListeners = [];
|
|
16
|
+
const volumeListeners = {
|
|
17
|
+
sound: [],
|
|
18
|
+
music: [],
|
|
19
|
+
vo: [],
|
|
20
|
+
sfx: [],
|
|
21
|
+
};
|
|
22
|
+
const backListeners = [];
|
|
23
|
+
const deviceAuthListeners = [];
|
|
24
|
+
let features = null;
|
|
25
|
+
/** @type {Array<() => void>} */
|
|
26
|
+
const cleanups = [];
|
|
27
|
+
|
|
28
|
+
function onKeyDown(event) {
|
|
29
|
+
const key = event.key || '';
|
|
30
|
+
const keyCode = event.keyCode || event.which;
|
|
31
|
+
// webOS Back / Escape / browser Back
|
|
32
|
+
const isBack = key === 'Backspace'
|
|
33
|
+
|| key === 'Escape'
|
|
34
|
+
|| key === 'XF86Back'
|
|
35
|
+
|| keyCode === 461
|
|
36
|
+
|| keyCode === 27
|
|
37
|
+
|| keyCode === 8;
|
|
38
|
+
if (!isBack) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (backListeners.length === 0) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
event.stopPropagation();
|
|
46
|
+
helpers.emit(backListeners, { key, keyCode });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onDeviceAuth(event) {
|
|
50
|
+
helpers.emit(deviceAuthListeners, event?.detail || null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
54
|
+
window.addEventListener('adaptfully:device-auth', onDeviceAuth);
|
|
55
|
+
cleanups.push(() => window.removeEventListener('keydown', onKeyDown, true));
|
|
56
|
+
cleanups.push(() => window.removeEventListener('adaptfully:device-auth', onDeviceAuth));
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
kind: 'tv',
|
|
60
|
+
|
|
61
|
+
getEnvironment() {
|
|
62
|
+
return Promise.resolve('tv');
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
onPause(cb) {
|
|
66
|
+
return helpers.subscribe(pauseListeners, cb);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
onCaptionsMuted(cb) {
|
|
70
|
+
return helpers.subscribe(captionsListeners, cb);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
onVolume(channel, cb) {
|
|
74
|
+
const list = volumeListeners[channel];
|
|
75
|
+
if (!list) {
|
|
76
|
+
return () => {};
|
|
77
|
+
}
|
|
78
|
+
return helpers.subscribe(list, cb);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/** Magix Remote / webOS Back */
|
|
82
|
+
onBack(cb) {
|
|
83
|
+
return helpers.subscribe(backListeners, cb);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** Forwarded adaptfully:device-auth detail for PIN/QR UI */
|
|
87
|
+
onDeviceAuth(cb) {
|
|
88
|
+
return helpers.subscribe(deviceAuthListeners, cb);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
setFeatures(next) {
|
|
92
|
+
features = next && typeof next === 'object' ? { ...next } : next;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
notifyLoaded() {},
|
|
96
|
+
|
|
97
|
+
notifyProgress(_progress, _detail) {},
|
|
98
|
+
|
|
99
|
+
notifyEndGame(_payload) {},
|
|
100
|
+
|
|
101
|
+
send(_event, _data) {},
|
|
102
|
+
|
|
103
|
+
dispose() {
|
|
104
|
+
while (cleanups.length) {
|
|
105
|
+
try {
|
|
106
|
+
cleanups.pop()();
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/** @internal */
|
|
114
|
+
_getState() {
|
|
115
|
+
return {
|
|
116
|
+
features,
|
|
117
|
+
backListeners: backListeners.length,
|
|
118
|
+
deviceAuthListeners: deviceAuthListeners.length,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
}(window.adaptfully));
|