@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 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)`.
@@ -163,20 +163,37 @@ export function createArchive(deployFolder, contents, options = {}) {
163
163
  }
164
164
 
165
165
  /**
166
- * Prior artifact zip plus one deployment folder for /deploy.
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
- zip.directory(toPosixDir(artifactPath), false);
179
- appendMeta(zip, publishDir, [], { log });
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' });
@@ -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, {log});
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) so
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
  }
@@ -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
- throw new Error(
71
- `Deployment "${options.deployment}" is not configured for platform "${platformKey}". `
72
- + `Available deployments: ${deployments.join(', ')}`,
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
- artifactPath = resolveBuildArtifactDir(pkg, {
87
- outputRoot: options.outputRoot,
88
- platformKey,
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
- const deploymentDirs = wrapStage === 'build'
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
- `adaptfully: build signing credentials → ${
169
- deploymentDirs.map((dir) => path.resolve(dir)).join(', ')
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/adaptfully",
3
- "version": "5.1.2",
3
+ "version": "5.3.0",
4
4
  "description": "Platform abstraction and Wrapfully deploy client for Makefully games",
5
5
  "type": "module",
6
6
  "main": "./lib/node/index.js",