@makefully/adaptfully 5.1.2 → 5.3.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/device-auth.md +67 -0
- package/lib/node/archive.js +23 -6
- package/lib/node/deploy.js +6 -1
- package/lib/node/index.js +17 -0
- package/lib/node/packagers/web.js +3 -3
- package/lib/node/pipeline.js +72 -12
- package/lib/node/registrations.js +16 -0
- package/lib/node/store-assets-ledger.js +194 -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.3.0 — 2026-11-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Store-assets upload ledger** — `lib/node/store-assets-ledger.js` owns `meta/store-assets/upload-ledger.json` (sequenced SHA-256 set skip for Play/ASC listing sync).
|
|
10
|
+
- **Opt-in listing deploys** — `adaptfully deploy <platform> --deployment *-listing` may target keys not in the platform release fan-out; assets-only zips skip the prior AAB/IPA tree and package `credentialsFrom` sibling folders under `meta/deployments/`.
|
|
11
|
+
|
|
12
|
+
## 5.2.0 — 2026-11-11
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`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).
|
|
17
|
+
- **`tv-host`** — lean-back host with Back key + `onDeviceAuth` for PIN/QR chrome.
|
|
18
|
+
- **Web packager** — default platforms include `webos` / `lgtv`; builder family resolves `webos` even with `packager: "web"`.
|
|
19
|
+
|
|
5
20
|
## 5.1.2 — 2026-11-10
|
|
6
21
|
|
|
7
22
|
### Fixed
|
|
@@ -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)`.
|
package/lib/node/archive.js
CHANGED
|
@@ -163,20 +163,37 @@ export function createArchive(deployFolder, contents, options = {}) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
|
-
* Prior artifact zip plus
|
|
166
|
+
* Prior artifact zip plus deployment folder(s) for /deploy.
|
|
167
|
+
* Always packages non-deployment meta (including store-assets) from META_DIR.
|
|
167
168
|
*
|
|
168
169
|
* @param {string} artifactPath
|
|
169
|
-
* @param {string} publishDir
|
|
170
|
+
* @param {string} publishDir Primary deployment folder (…/meta/deployments/<key>)
|
|
170
171
|
* @param {string} [contents]
|
|
171
|
-
* @param {{ log?: (message: string) => void }} [options]
|
|
172
|
+
* @param {{ log?: (message: string) => void, deploymentDirs?: string[], includeArtifact?: boolean }} [options]
|
|
172
173
|
*/
|
|
173
174
|
export function createDeployArchive(artifactPath, publishDir, contents, options = {}) {
|
|
174
175
|
const
|
|
175
176
|
zip = archiver('zip', { zlib: { level: 0 } }),
|
|
176
|
-
log = options.log ?? console.log
|
|
177
|
+
log = options.log ?? console.log,
|
|
178
|
+
includeArtifact = options.includeArtifact !== false,
|
|
179
|
+
extraDirs = Array.isArray(options.deploymentDirs) ? options.deploymentDirs : [];
|
|
180
|
+
|
|
181
|
+
if (includeArtifact && artifactPath && fs.existsSync(artifactPath)) {
|
|
182
|
+
zip.directory(toPosixDir(artifactPath), false);
|
|
183
|
+
}
|
|
177
184
|
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
// Prefer meta/deployments/<key> packaging (not legacy meta/publish) so
|
|
186
|
+
// credentialsFrom sibling keys resolve under the same layout Wrapfully reads.
|
|
187
|
+
const dirs = [];
|
|
188
|
+
if (publishDir) {
|
|
189
|
+
dirs.push(publishDir);
|
|
190
|
+
}
|
|
191
|
+
for (const dir of extraDirs) {
|
|
192
|
+
if (dir && !dirs.includes(dir)) {
|
|
193
|
+
dirs.push(dir);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
appendMeta(zip, null, dirs, { log });
|
|
180
197
|
|
|
181
198
|
if (contents) {
|
|
182
199
|
zip.append(contents, { name: 'package.json' });
|
package/lib/node/deploy.js
CHANGED
|
@@ -227,6 +227,7 @@ export async function submitWrapfullyChore ({
|
|
|
227
227
|
* platformKey?: string,
|
|
228
228
|
* deploymentKey?: string,
|
|
229
229
|
* artifactPath?: string,
|
|
230
|
+
* includeArtifact?: boolean,
|
|
230
231
|
* accessToken?: string,
|
|
231
232
|
* encrypt?: boolean,
|
|
232
233
|
* encryptPublicKey?: string,
|
|
@@ -273,7 +274,11 @@ export async function send (
|
|
|
273
274
|
if (!options.deploymentKey) {
|
|
274
275
|
throw new Error('deploy stage requires options.deploymentKey');
|
|
275
276
|
}
|
|
276
|
-
archiveStream = createDeployArchive(options.artifactPath, options.publishDir, contents, {
|
|
277
|
+
archiveStream = createDeployArchive(options.artifactPath, options.publishDir, contents, {
|
|
278
|
+
log,
|
|
279
|
+
deploymentDirs: options.deploymentDirs,
|
|
280
|
+
includeArtifact: options.includeArtifact !== false,
|
|
281
|
+
});
|
|
277
282
|
} else if (stage === 'release') {
|
|
278
283
|
archiveStream = createReleaseArchive(deployFolder, contents, options.deploymentDirs ?? [], {log});
|
|
279
284
|
} else {
|
package/lib/node/index.js
CHANGED
|
@@ -115,3 +115,20 @@ export {
|
|
|
115
115
|
runAppleSigning,
|
|
116
116
|
runOpenssl,
|
|
117
117
|
} from './apple-signing.js';
|
|
118
|
+
export {
|
|
119
|
+
LEDGER_FILENAME,
|
|
120
|
+
LEDGER_VERSION,
|
|
121
|
+
emptyLedger,
|
|
122
|
+
ledgerFileExists,
|
|
123
|
+
ledgerMatchesSet,
|
|
124
|
+
ledgerPath,
|
|
125
|
+
listOrderedImageFiles,
|
|
126
|
+
makeSetKey,
|
|
127
|
+
readLedger,
|
|
128
|
+
recordSetSuccess,
|
|
129
|
+
sequencesEqual,
|
|
130
|
+
sha256Buffer,
|
|
131
|
+
sha256File,
|
|
132
|
+
sha256Sequence,
|
|
133
|
+
writeLedger,
|
|
134
|
+
} from './store-assets-ledger.js';
|
|
@@ -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
|
}
|
package/lib/node/pipeline.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
1
3
|
import path from 'node:path';
|
|
2
4
|
import { loadProjectConfig, resolveServerUrl, resolveAccessToken, resolveEncryptFlag } from './config.js';
|
|
3
5
|
import { send } from './deploy.js';
|
|
@@ -21,6 +23,28 @@ import { androidKeystoreFromCli } from './android-keystore.js';
|
|
|
21
23
|
import { appleSigningFromCli } from './apple-signing.js';
|
|
22
24
|
import { buildGameId } from './fs-name.js';
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} deploymentDir
|
|
28
|
+
* @returns {object | null}
|
|
29
|
+
*/
|
|
30
|
+
function readDeploymentManifest(deploymentDir) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(path.join(deploymentDir, 'manifest.json'), 'utf8');
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Listing sync deployers do not need the prior AAB/IPA artifact tree.
|
|
41
|
+
* @param {string | null | undefined} type
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function isListingDeployerType(type) {
|
|
45
|
+
return type === 'google-listing' || type === 'apple-listing';
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
/** @typedef {'prebuild' | 'build' | 'deploy' | 'release'} AdaptfullyStage */
|
|
25
49
|
|
|
26
50
|
const VALID_STAGES = new Set(['prebuild', 'build', 'deploy', 'release']);
|
|
@@ -66,11 +90,17 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
|
|
|
66
90
|
?? resolveExtractRoot(outputRoot, platformKey);
|
|
67
91
|
|
|
68
92
|
if (options.deployment) {
|
|
93
|
+
const publishDir = resolvePublishDir(options.deployment);
|
|
94
|
+
const exists = fs.existsSync(publishDir);
|
|
69
95
|
if (!deployments.includes(options.deployment)) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
96
|
+
// Deploy stage may target opt-in keys (e.g. android-listing) that are
|
|
97
|
+
// intentionally omitted from the platform's release fan-out list.
|
|
98
|
+
if (stage !== 'deploy' || !exists) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Deployment "${options.deployment}" is not configured for platform "${platformKey}". `
|
|
101
|
+
+ `Available deployments: ${deployments.join(', ')}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
74
104
|
}
|
|
75
105
|
deployments = [options.deployment];
|
|
76
106
|
} else if (stage === 'build') {
|
|
@@ -83,10 +113,19 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
|
|
|
83
113
|
|
|
84
114
|
let artifactPath = options.artifactPath;
|
|
85
115
|
if (stage === 'deploy' && !artifactPath) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
116
|
+
const primaryKey = deployments[0];
|
|
117
|
+
const primaryManifest = primaryKey
|
|
118
|
+
? readDeploymentManifest(resolvePublishDir(primaryKey))
|
|
119
|
+
: null;
|
|
120
|
+
if (isListingDeployerType(primaryManifest?.type)) {
|
|
121
|
+
// Assets-only: meta/store-assets + deployment folders; skip heavy binary tree.
|
|
122
|
+
artifactPath = fs.mkdtempSync(path.join(os.tmpdir(), 'adaptfully-listing-'));
|
|
123
|
+
} else {
|
|
124
|
+
artifactPath = resolveBuildArtifactDir(pkg, {
|
|
125
|
+
outputRoot: options.outputRoot,
|
|
126
|
+
platformKey,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
const prebuiltDir = stage === 'deploy'
|
|
@@ -153,9 +192,25 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
|
|
|
153
192
|
for (const deploymentKey of deployments) {
|
|
154
193
|
const publishDir = deploymentKey === 'zip' ? undefined : resolvePublishDir(deploymentKey);
|
|
155
194
|
const wrapStage = stage === 'deploy' ? 'deploy' : 'build';
|
|
156
|
-
|
|
195
|
+
/** @type {string[]} */
|
|
196
|
+
let deploymentDirs = wrapStage === 'build'
|
|
157
197
|
? resolveBuildCredentialDirs(platformKey, pkg)
|
|
158
198
|
: [];
|
|
199
|
+
let includeArtifact = true;
|
|
200
|
+
|
|
201
|
+
if (wrapStage === 'deploy' && publishDir) {
|
|
202
|
+
const manifest = readDeploymentManifest(publishDir);
|
|
203
|
+
if (isListingDeployerType(manifest?.type)) {
|
|
204
|
+
includeArtifact = Boolean(options.artifactPath);
|
|
205
|
+
deploymentDirs = [];
|
|
206
|
+
if (manifest?.credentialsFrom) {
|
|
207
|
+
const sibling = resolvePublishDir(String(manifest.credentialsFrom));
|
|
208
|
+
if (fs.existsSync(sibling)) {
|
|
209
|
+
deploymentDirs.push(sibling);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
159
214
|
|
|
160
215
|
log(
|
|
161
216
|
`adaptfully: ${stage} ${platformKey} → `
|
|
@@ -165,9 +220,13 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
|
|
|
165
220
|
|
|
166
221
|
if (deploymentDirs.length) {
|
|
167
222
|
log(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
223
|
+
wrapStage === 'deploy'
|
|
224
|
+
? `adaptfully: listing credentialsFrom → ${
|
|
225
|
+
deploymentDirs.map((dir) => path.resolve(dir)).join(', ')
|
|
226
|
+
}`
|
|
227
|
+
: `adaptfully: build signing credentials → ${
|
|
228
|
+
deploymentDirs.map((dir) => path.resolve(dir)).join(', ')
|
|
229
|
+
}`,
|
|
171
230
|
);
|
|
172
231
|
}
|
|
173
232
|
log(`adaptfully: extract → ${extractRoot}`);
|
|
@@ -187,6 +246,7 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
|
|
|
187
246
|
deploymentDirs,
|
|
188
247
|
deploymentKey: wrapStage === 'deploy' ? deploymentKey : undefined,
|
|
189
248
|
artifactPath,
|
|
249
|
+
includeArtifact,
|
|
190
250
|
},
|
|
191
251
|
);
|
|
192
252
|
|
|
@@ -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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload ledger for store listing image sets (Play, ASC, future platforms).
|
|
3
|
+
* Lives at assets/meta/store-assets/upload-ledger.json (zip: meta/store-assets/…).
|
|
4
|
+
*
|
|
5
|
+
* Set key: `{platform}/{locale}/{slot}` e.g. google/en-US/phoneScreenshots
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import fsp from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
export const LEDGER_FILENAME = 'upload-ledger.json';
|
|
13
|
+
export const LEDGER_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} storeAssetsRoot Absolute path to store-assets (…/meta/store-assets)
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function ledgerPath(storeAssetsRoot) {
|
|
20
|
+
return path.join(storeAssetsRoot, LEDGER_FILENAME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} platform
|
|
25
|
+
* @param {string} locale
|
|
26
|
+
* @param {string} slot
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function makeSetKey(platform, locale, slot) {
|
|
30
|
+
return `${platform}/${locale}/${slot}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string | Buffer | Uint8Array} data
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function sha256Buffer(data) {
|
|
38
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} filePath
|
|
43
|
+
* @returns {Promise<string>}
|
|
44
|
+
*/
|
|
45
|
+
export async function sha256File(filePath) {
|
|
46
|
+
const hash = crypto.createHash('sha256');
|
|
47
|
+
const handle = await fsp.open(filePath, 'r');
|
|
48
|
+
try {
|
|
49
|
+
const stream = handle.createReadStream();
|
|
50
|
+
for await (const chunk of stream) {
|
|
51
|
+
hash.update(chunk);
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
56
|
+
return hash.digest('hex');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ordered SHA-256 for each path (display / upload order).
|
|
61
|
+
* @param {string[]} filePaths Absolute paths
|
|
62
|
+
* @returns {Promise<string[]>}
|
|
63
|
+
*/
|
|
64
|
+
export async function sha256Sequence(filePaths) {
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const filePath of filePaths) {
|
|
67
|
+
out.push(await sha256File(filePath));
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string[]} a
|
|
74
|
+
* @param {string[]} b
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
export function sequencesEqual(a, b) {
|
|
78
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
82
|
+
if (a[i] !== b[i]) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @returns {{ version: number, sets: Record<string, object> }}
|
|
91
|
+
*/
|
|
92
|
+
export function emptyLedger() {
|
|
93
|
+
return { version: LEDGER_VERSION, sets: {} };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} storeAssetsRoot
|
|
98
|
+
* @returns {Promise<{ version: number, sets: Record<string, object> }>}
|
|
99
|
+
*/
|
|
100
|
+
export async function readLedger(storeAssetsRoot) {
|
|
101
|
+
const file = ledgerPath(storeAssetsRoot);
|
|
102
|
+
try {
|
|
103
|
+
const raw = await fsp.readFile(file, 'utf8');
|
|
104
|
+
const parsed = JSON.parse(raw);
|
|
105
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
106
|
+
return emptyLedger();
|
|
107
|
+
}
|
|
108
|
+
const sets = parsed.sets && typeof parsed.sets === 'object' ? parsed.sets : {};
|
|
109
|
+
return { version: Number(parsed.version) || LEDGER_VERSION, sets };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err && (err.code === 'ENOENT' || err instanceof SyntaxError)) {
|
|
112
|
+
return emptyLedger();
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} storeAssetsRoot
|
|
120
|
+
* @param {{ version?: number, sets: Record<string, object> }} ledger
|
|
121
|
+
* @returns {Promise<void>}
|
|
122
|
+
*/
|
|
123
|
+
export async function writeLedger(storeAssetsRoot, ledger) {
|
|
124
|
+
await fsp.mkdir(storeAssetsRoot, { recursive: true });
|
|
125
|
+
const payload = {
|
|
126
|
+
version: ledger.version || LEDGER_VERSION,
|
|
127
|
+
sets: ledger.sets || {},
|
|
128
|
+
};
|
|
129
|
+
await fsp.writeFile(ledgerPath(storeAssetsRoot), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {{ version: number, sets: Record<string, object> }} ledger
|
|
134
|
+
* @param {string} setKey
|
|
135
|
+
* @param {string[]} sequence
|
|
136
|
+
* @param {boolean} [force]
|
|
137
|
+
* @returns {boolean} true when local sequence matches last successful upload
|
|
138
|
+
*/
|
|
139
|
+
export function ledgerMatchesSet(ledger, setKey, sequence, force = false) {
|
|
140
|
+
if (force) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const entry = ledger?.sets?.[setKey];
|
|
144
|
+
if (!entry || !Array.isArray(entry.sha256Sequence)) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return sequencesEqual(entry.sha256Sequence, sequence);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Record a successful upload for one set. Does not touch other sets.
|
|
152
|
+
* @param {{ version: number, sets: Record<string, object> }} ledger
|
|
153
|
+
* @param {string} setKey
|
|
154
|
+
* @param {{ sha256Sequence: string[], assetIds?: string[], uploadedAt?: string }} update
|
|
155
|
+
*/
|
|
156
|
+
export function recordSetSuccess(ledger, setKey, update) {
|
|
157
|
+
if (!ledger.sets) {
|
|
158
|
+
ledger.sets = {};
|
|
159
|
+
}
|
|
160
|
+
ledger.sets[setKey] = {
|
|
161
|
+
sha256Sequence: [...update.sha256Sequence],
|
|
162
|
+
uploadedAt: update.uploadedAt || new Date().toISOString(),
|
|
163
|
+
...(Array.isArray(update.assetIds) ? { assetIds: [...update.assetIds] } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sync-friendly: list image files in a directory in alphanumeric order.
|
|
169
|
+
* @param {string} dir
|
|
170
|
+
* @returns {Promise<string[]>} Absolute paths
|
|
171
|
+
*/
|
|
172
|
+
export async function listOrderedImageFiles(dir) {
|
|
173
|
+
let names;
|
|
174
|
+
try {
|
|
175
|
+
names = await fsp.readdir(dir);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err && err.code === 'ENOENT') {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
const images = names
|
|
183
|
+
.filter((name) => /\.(png|jpe?g|webp)$/i.test(name))
|
|
184
|
+
.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
185
|
+
return images.map((name) => path.join(dir, name));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} storeAssetsRoot
|
|
190
|
+
* @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
export function ledgerFileExists(storeAssetsRoot) {
|
|
193
|
+
return fs.existsSync(ledgerPath(storeAssetsRoot));
|
|
194
|
+
}
|
|
@@ -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));
|