@makefully/adaptfully 5.0.1 → 5.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/lib/node/choreCrypto.js +82 -11
- package/lib/node/deploy.js +107 -66
- package/lib/node/packagers/web.js +3 -1
- package/lib/node/unzip-file.js +194 -0
- package/lib/runtime/analytics/_helpers.js +1 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 5.0.3 — 2026-10-05
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Web packager** writes `game-config.js` (and injects the script) for **web** and **pwa** as well as **uwp**, so `window.gameConfig.version` / platform are stamped on every web-family route — matching electron and capacitor. HTTP analytics can then populate batch `appVersion` without a per-game `analyticsAppVersion` override.
|
|
10
|
+
|
|
11
|
+
## 5.0.2 — 2026-10-05
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Wrapfully chore downloads stream to a temp `.zip` on disk and extract with **yauzl** (lazy file streams) instead of buffering the whole Electron/Steam artifact in RAM via `axios` `arraybuffer` + `unzip-stream` MatcherStream (Node heap OOM despite free system RAM).
|
|
16
|
+
- Encrypted result envelopes decrypt with streamed AES-GCM (`aesDecryptFile`) to a plaintext zip file before extract.
|
|
17
|
+
|
|
5
18
|
## 5.0.1 — 2026-09-17
|
|
6
19
|
|
|
7
20
|
### Changed
|
package/README.md
CHANGED
|
@@ -178,7 +178,7 @@ Each platform entry may set a **`packager`** (`web`, `electron`, or `capacitor`)
|
|
|
178
178
|
|
|
179
179
|
| Packager | Prebuild adds |
|
|
180
180
|
|----------|---------------|
|
|
181
|
-
| `web` | `game-config.js` for
|
|
181
|
+
| `web` | `game-config.js` for **web / uwp / pwa** platform prebuilds (version + platform for analytics) |
|
|
182
182
|
| `electron` | `main.js` (Electron shell); `preload.js` when **`steam-auth`** is registered |
|
|
183
183
|
| `capacitor` | `game-config.js`, Capacitor CSP/viewport; `social-login-config.js` when **`social-auth`** is registered |
|
|
184
184
|
|
package/lib/node/choreCrypto.js
CHANGED
|
@@ -2,14 +2,14 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import {PassThrough} from 'node:stream';
|
|
6
6
|
import {pipeline} from 'node:stream/promises';
|
|
7
7
|
import archiver from 'archiver';
|
|
8
|
-
import unzipper from 'unzip-stream';
|
|
9
8
|
import {
|
|
10
9
|
WRAPFULLY_DEFAULT_KID,
|
|
11
10
|
WRAPFULLY_DEFAULT_PUBLIC_KEY,
|
|
12
11
|
} from './wrapfullyDefaultPublicKey.js';
|
|
12
|
+
import {extractZipFile, zipHasEntries} from './unzip-file.js';
|
|
13
13
|
|
|
14
14
|
const
|
|
15
15
|
ENVELOPE_DATA = 'wrapfully.enc',
|
|
@@ -40,6 +40,38 @@ function aesDecrypt (key, blob) {
|
|
|
40
40
|
return Buffer.concat([decipher.update(data), decipher.final()]);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Stream-decrypt an AES-256-GCM blob (iv||tag||ciphertext) from disk to disk.
|
|
45
|
+
* @param {Buffer} key
|
|
46
|
+
* @param {string} inPath
|
|
47
|
+
* @param {string} outPath
|
|
48
|
+
*/
|
|
49
|
+
export async function aesDecryptFile (key, inPath, outPath) {
|
|
50
|
+
const fh = await fs.promises.open(inPath, 'r');
|
|
51
|
+
const header = Buffer.alloc(28);
|
|
52
|
+
let bytesRead = 0;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
({bytesRead} = await fh.read(header, 0, 28, 0));
|
|
56
|
+
} finally {
|
|
57
|
+
await fh.close();
|
|
58
|
+
}
|
|
59
|
+
if (bytesRead < 28) {
|
|
60
|
+
throw new Error('Encrypted payload too short for AES-GCM header');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const iv = header.subarray(0, 12);
|
|
64
|
+
const tag = header.subarray(12, 28);
|
|
65
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
66
|
+
|
|
67
|
+
decipher.setAuthTag(tag);
|
|
68
|
+
await pipeline(
|
|
69
|
+
fs.createReadStream(inPath, {start: 28}),
|
|
70
|
+
decipher,
|
|
71
|
+
fs.createWriteStream(outPath),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
43
75
|
function wrapKey (aesKey, publicKeyPem) {
|
|
44
76
|
return crypto.publicEncrypt(
|
|
45
77
|
{
|
|
@@ -152,32 +184,71 @@ export async function createEnvelopeFromInnerZip (innerZip, {publicKeyPem, kid,
|
|
|
152
184
|
}
|
|
153
185
|
|
|
154
186
|
/**
|
|
155
|
-
* Decrypt a completed Yap result envelope into a plaintext zip
|
|
187
|
+
* Decrypt a completed Yap result envelope zip on disk into a plaintext zip file.
|
|
188
|
+
* Avoids holding the Electron/Steam artifact in RAM.
|
|
189
|
+
* @param {string} envelopeZipPath
|
|
190
|
+
* @param {string} resultPrivPem
|
|
191
|
+
* @param {string} outZipPath
|
|
192
|
+
* @returns {Promise<'envelope'|'passthrough'>} passthrough when file is already a plain zip
|
|
156
193
|
*/
|
|
157
|
-
export async function
|
|
194
|
+
export async function decryptResultEnvelopeToFile (envelopeZipPath, resultPrivPem, outZipPath) {
|
|
158
195
|
if (!resultPrivPem) {
|
|
159
196
|
throw new Error('Missing resultPriv for encrypted Wrapfully chore download.');
|
|
160
197
|
}
|
|
161
198
|
|
|
199
|
+
const isEnvelope = await zipHasEntries(envelopeZipPath, [RESULT_DATA, RESULT_KEY]);
|
|
200
|
+
|
|
201
|
+
if (!isEnvelope) {
|
|
202
|
+
// Not an envelope — copy as-is (streamed); never extract the whole artifact.
|
|
203
|
+
await pipeline(
|
|
204
|
+
fs.createReadStream(envelopeZipPath),
|
|
205
|
+
fs.createWriteStream(outZipPath),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return 'passthrough';
|
|
209
|
+
}
|
|
210
|
+
|
|
162
211
|
const tmp = path.join(os.tmpdir(), `adaptfully-result-${Date.now().toString(16)}`);
|
|
163
212
|
|
|
164
213
|
fs.mkdirSync(tmp, {recursive: true});
|
|
165
214
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// Not an envelope — return as-is
|
|
169
|
-
return resultZipBuf;
|
|
170
|
-
}
|
|
171
|
-
const ciphertext = fs.readFileSync(path.join(tmp, RESULT_DATA));
|
|
215
|
+
// Envelope zips are small (AES blob + wrapped key).
|
|
216
|
+
await extractZipFile(envelopeZipPath, tmp);
|
|
172
217
|
const wrapped = fs.readFileSync(path.join(tmp, RESULT_KEY));
|
|
173
218
|
const aesKey = unwrapKey(wrapped, resultPrivPem);
|
|
174
219
|
|
|
175
|
-
|
|
220
|
+
await aesDecryptFile(aesKey, path.join(tmp, RESULT_DATA), outZipPath);
|
|
221
|
+
|
|
222
|
+
return 'envelope';
|
|
176
223
|
} finally {
|
|
177
224
|
fs.rmSync(tmp, {recursive: true, force: true});
|
|
178
225
|
}
|
|
179
226
|
}
|
|
180
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Decrypt a completed Yap result envelope into a plaintext zip Buffer.
|
|
230
|
+
* Prefer {@link decryptResultEnvelopeToFile} for large Electron artifacts.
|
|
231
|
+
*/
|
|
232
|
+
export async function decryptResultEnvelope (resultZipBuf, resultPrivPem) {
|
|
233
|
+
if (!resultPrivPem) {
|
|
234
|
+
throw new Error('Missing resultPriv for encrypted Wrapfully chore download.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const stamp = Date.now().toString(16);
|
|
238
|
+
const envelopePath = path.join(os.tmpdir(), `adaptfully-env-${stamp}.zip`);
|
|
239
|
+
const outPath = path.join(os.tmpdir(), `adaptfully-plain-${stamp}.zip`);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
fs.writeFileSync(envelopePath, resultZipBuf);
|
|
243
|
+
await decryptResultEnvelopeToFile(envelopePath, resultPrivPem, outPath);
|
|
244
|
+
|
|
245
|
+
return fs.readFileSync(outPath);
|
|
246
|
+
} finally {
|
|
247
|
+
fs.rmSync(envelopePath, {force: true});
|
|
248
|
+
fs.rmSync(outPath, {force: true});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
181
252
|
export function isResultEnvelopeBuffer (buf) {
|
|
182
253
|
// Weak check — PK zip; decryptResultEnvelope falls back if members missing
|
|
183
254
|
return Buffer.isBuffer(buf) && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b;
|
package/lib/node/deploy.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import {Readable} from 'node:stream';
|
|
5
5
|
import {pipeline} from 'node:stream/promises';
|
|
6
|
-
import unzipper from 'unzip-stream';
|
|
7
6
|
import {createDeployArchive, createReleaseArchive, createSourceArchive} from './archive.js';
|
|
8
7
|
import {clearStaleBuildExtract} from './artifacts.js';
|
|
9
8
|
import {listHtmlFilesRecursive} from './fs-utils.js';
|
|
10
9
|
import {printBuildReport} from './report.js';
|
|
11
10
|
import {
|
|
12
11
|
createEnvelopeFromInnerZip,
|
|
13
|
-
|
|
12
|
+
decryptResultEnvelopeToFile,
|
|
14
13
|
generateResultKeyPair,
|
|
15
14
|
resolveEncryptPublicKey,
|
|
16
15
|
} from './choreCrypto.js';
|
|
16
|
+
import {extractZipFile, fileLooksLikeZip} from './unzip-file.js';
|
|
17
17
|
|
|
18
18
|
const POLL_MS = 5000;
|
|
19
19
|
|
|
@@ -47,22 +47,28 @@ export function buildWrapfullyJobConfig (stage, family, gameId, platformKey, dep
|
|
|
47
47
|
return job;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
async function
|
|
51
|
-
|
|
50
|
+
async function sleep (ms) {
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
52
|
+
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Stream an axios response body to a file (does not buffer the whole body in RAM).
|
|
56
|
+
* @param {import('axios').AxiosResponse} response
|
|
57
|
+
* @param {string} outPath
|
|
58
|
+
*/
|
|
59
|
+
async function streamAxiosResponseToFile (response, outPath) {
|
|
60
|
+
const body = response.data;
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
if (!body || typeof body.pipe !== 'function') {
|
|
63
|
+
throw new Error('Expected a streaming HTTP response body');
|
|
64
|
+
}
|
|
59
65
|
|
|
60
|
-
|
|
61
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
await pipeline(body, fs.createWriteStream(outPath));
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
/**
|
|
65
|
-
* Submit zip to Showfully Yap, poll until complete,
|
|
70
|
+
* Submit zip to Showfully Yap, poll until complete, stream result zip to disk.
|
|
71
|
+
* @returns {Promise<string>} absolute path to the downloaded zip (caller deletes)
|
|
66
72
|
*/
|
|
67
73
|
export async function submitWrapfullyChore ({
|
|
68
74
|
server,
|
|
@@ -159,22 +165,49 @@ export async function submitWrapfullyChore ({
|
|
|
159
165
|
|
|
160
166
|
log(`adaptfully: downloading chore ${choreId}`);
|
|
161
167
|
const downloadUrl = `${base}yap/wrapfully/${encodeURIComponent(choreId)}`;
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
validateStatus: () => true,
|
|
167
|
-
});
|
|
168
|
+
const downloadPath = path.join(
|
|
169
|
+
os.tmpdir(),
|
|
170
|
+
`adaptfully-chore-${choreId}-${Date.now().toString(16)}.zip`,
|
|
171
|
+
);
|
|
168
172
|
|
|
169
|
-
|
|
173
|
+
try {
|
|
174
|
+
const response = await axios.get(downloadUrl, {
|
|
175
|
+
headers: {Authorization: `Bearer ${accessToken}`},
|
|
176
|
+
responseType: 'stream',
|
|
177
|
+
maxContentLength: Infinity,
|
|
178
|
+
maxBodyLength: Infinity,
|
|
179
|
+
validateStatus: () => true,
|
|
180
|
+
});
|
|
181
|
+
const ctype = response.headers['content-type'] || '';
|
|
182
|
+
|
|
183
|
+
if (response.status >= 400 || ctype.includes('application/json')) {
|
|
184
|
+
const chunks = [];
|
|
185
|
+
|
|
186
|
+
for await (const chunk of response.data) {
|
|
187
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
188
|
+
}
|
|
189
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
190
|
+
let message = 'Chore download failed';
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const json = JSON.parse(text);
|
|
170
194
|
|
|
171
|
-
|
|
172
|
-
|
|
195
|
+
message = json.errors?.[0] || message;
|
|
196
|
+
} catch {
|
|
197
|
+
if (text.trim()) {
|
|
198
|
+
message = text.slice(0, 500);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
throw new Error(message);
|
|
202
|
+
}
|
|
173
203
|
|
|
174
|
-
|
|
204
|
+
await streamAxiosResponseToFile(response, downloadPath);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
fs.rmSync(downloadPath, {force: true});
|
|
207
|
+
throw err;
|
|
175
208
|
}
|
|
176
209
|
|
|
177
|
-
return
|
|
210
|
+
return downloadPath;
|
|
178
211
|
}
|
|
179
212
|
|
|
180
213
|
/**
|
|
@@ -252,29 +285,17 @@ export async function send (
|
|
|
252
285
|
log(`adaptfully: sending ${htmlFiles.length} HTML file(s) from ${path.resolve(deployFolder)}`);
|
|
253
286
|
}
|
|
254
287
|
|
|
255
|
-
// Append wrapfully.json
|
|
288
|
+
// Append wrapfully.json by extracting the upload archive to disk, then rezipping.
|
|
256
289
|
const job = buildWrapfullyJobConfig(stage, family, gameId, platformKey, options.deploymentKey);
|
|
257
290
|
const innerChunks = [];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// pipe archive to buffer, unzip to tmp, add file, rezip is heavy.
|
|
262
|
-
// Simpler: createSourceArchive already finalized as stream — append via second archiver
|
|
263
|
-
// that copies entries is complex. Instead append wrapfully.json by concatenating
|
|
264
|
-
// a sidecar zip is wrong. Best: write wrapfully.json into deploy folder meta before
|
|
265
|
-
// archive... but that mutates disk.
|
|
266
|
-
//
|
|
267
|
-
// Practical approach matching Dutifully: build archive, buffer it, then use a new
|
|
268
|
-
// archiver that includes the buffer as... no that's nested.
|
|
269
|
-
//
|
|
270
|
-
// Use jszip-like: buffer the created archive by consuming stream, then use
|
|
271
|
-
// adm-zip equivalent — we have unzipper. Extract to tmp, write wrapfully.json, rezip.
|
|
272
|
-
|
|
273
|
-
const tmpRoot = path.join(outputRoot, `.adaptfully-yap-${Date.now().toString(16)}`);
|
|
291
|
+
const stamp = Date.now().toString(16);
|
|
292
|
+
const tmpRoot = path.join(outputRoot, `.adaptfully-yap-${stamp}`);
|
|
293
|
+
const uploadZipPath = path.join(outputRoot, `.adaptfully-upload-${stamp}.zip`);
|
|
274
294
|
|
|
275
295
|
fs.mkdirSync(tmpRoot, {recursive: true});
|
|
276
296
|
try {
|
|
277
|
-
await pipeline(archiveStream,
|
|
297
|
+
await pipeline(archiveStream, fs.createWriteStream(uploadZipPath));
|
|
298
|
+
await extractZipFile(uploadZipPath, tmpRoot);
|
|
278
299
|
fs.writeFileSync(path.join(tmpRoot, 'wrapfully.json'), JSON.stringify(job, null, 2));
|
|
279
300
|
|
|
280
301
|
const {default: archiver} = await import('archiver');
|
|
@@ -296,6 +317,7 @@ export async function send (
|
|
|
296
317
|
await done;
|
|
297
318
|
} finally {
|
|
298
319
|
fs.rmSync(tmpRoot, {recursive: true, force: true});
|
|
320
|
+
fs.rmSync(uploadZipPath, {force: true});
|
|
299
321
|
}
|
|
300
322
|
|
|
301
323
|
let uploadBuffer = Buffer.concat(innerChunks);
|
|
@@ -318,39 +340,58 @@ export async function send (
|
|
|
318
340
|
log(`adaptfully: encrypting chore with kid "${kid}"`);
|
|
319
341
|
}
|
|
320
342
|
|
|
321
|
-
|
|
343
|
+
const downloadPath = await submitWrapfullyChore({
|
|
322
344
|
server,
|
|
323
345
|
accessToken,
|
|
324
346
|
zipBuffer: uploadBuffer,
|
|
325
347
|
log,
|
|
326
348
|
});
|
|
349
|
+
const plainZipPath = path.join(
|
|
350
|
+
os.tmpdir(),
|
|
351
|
+
`adaptfully-plain-${path.basename(downloadPath)}`,
|
|
352
|
+
);
|
|
353
|
+
let resultZipPath = downloadPath;
|
|
327
354
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
355
|
+
try {
|
|
356
|
+
if (resultPriv) {
|
|
357
|
+
try {
|
|
358
|
+
await decryptResultEnvelopeToFile(downloadPath, resultPriv, plainZipPath);
|
|
359
|
+
resultZipPath = plainZipPath;
|
|
360
|
+
} catch (err) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`Failed to decrypt Wrapfully result envelope: ${err.message || err}`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
333
365
|
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (!Buffer.isBuffer(resultBuffer) || resultBuffer.length < 4
|
|
337
|
-
|| resultBuffer[0] !== 0x50 || resultBuffer[1] !== 0x4b) {
|
|
338
|
-
throw new Error('Chore download is not a valid zip (truncated or corrupt response)');
|
|
339
|
-
}
|
|
340
366
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
367
|
+
if (!fileLooksLikeZip(resultZipPath)) {
|
|
368
|
+
throw new Error('Chore download is not a valid zip (truncated or corrupt response)');
|
|
369
|
+
}
|
|
344
370
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
);
|
|
351
|
-
}
|
|
371
|
+
if (mode === 'extract') {
|
|
372
|
+
try {
|
|
373
|
+
await extractZipFile(resultZipPath, extractRoot);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
const size = fs.statSync(resultZipPath).size;
|
|
352
376
|
|
|
353
|
-
|
|
354
|
-
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Failed to unpack chore result (${size} bytes): ${err.message || err}`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
printBuildReport(`${stage}-${family}`, pkg, {extractRoot});
|
|
382
|
+
} else {
|
|
383
|
+
const destZip = path.join(
|
|
384
|
+
extractRoot,
|
|
385
|
+
`${pkg.name}-${pkg.version}-${stage}-${family}.zip`,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
await pipeline(
|
|
389
|
+
fs.createReadStream(resultZipPath),
|
|
390
|
+
fs.createWriteStream(destZip),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
} finally {
|
|
394
|
+
fs.rmSync(downloadPath, {force: true});
|
|
395
|
+
fs.rmSync(plainZipPath, {force: true});
|
|
355
396
|
}
|
|
356
397
|
}
|
|
@@ -8,6 +8,8 @@ export class WebPackager extends Packager {
|
|
|
8
8
|
static defaultPlatforms = ['web', 'uwp', 'pwa'];
|
|
9
9
|
|
|
10
10
|
needsGameConfig() {
|
|
11
|
-
|
|
11
|
+
// Stamp version/platform for all web-family routes (web, uwp, pwa) so
|
|
12
|
+
// analytics resolveAppVersion() matches electron/capacitor builds.
|
|
13
|
+
return true;
|
|
12
14
|
}
|
|
13
15
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {pipeline} from 'node:stream/promises';
|
|
4
|
+
import yauzl from 'yauzl';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve entry path under destDir; reject zip-slip escapes.
|
|
8
|
+
* @param {string} destDir
|
|
9
|
+
* @param {string} fileName
|
|
10
|
+
*/
|
|
11
|
+
function safeEntryPath (destDir, fileName) {
|
|
12
|
+
const root = path.resolve(destDir);
|
|
13
|
+
const target = path.resolve(root, fileName);
|
|
14
|
+
|
|
15
|
+
if (target !== root && !target.startsWith(root + path.sep)) {
|
|
16
|
+
throw new Error(`Refusing zip entry outside extract root: ${fileName}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return target;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether a zip on disk contains every listed entry name (forward-slash paths).
|
|
24
|
+
* @param {string} zipPath
|
|
25
|
+
* @param {string[]} entryNames
|
|
26
|
+
* @returns {Promise<boolean>}
|
|
27
|
+
*/
|
|
28
|
+
export function zipHasEntries (zipPath, entryNames) {
|
|
29
|
+
const needed = new Set(entryNames.map((n) => String(n).replace(/\\/g, '/')));
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
yauzl.open(zipPath, {lazyEntries: true, autoClose: true}, (openErr, zipfile) => {
|
|
33
|
+
if (openErr) {
|
|
34
|
+
reject(openErr);
|
|
35
|
+
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let settled = false;
|
|
40
|
+
const found = new Set();
|
|
41
|
+
const fail = (err) => {
|
|
42
|
+
if (!settled) {
|
|
43
|
+
settled = true;
|
|
44
|
+
reject(err);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const finish = (value) => {
|
|
48
|
+
if (!settled) {
|
|
49
|
+
settled = true;
|
|
50
|
+
resolve(value);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
zipfile.on('error', fail);
|
|
55
|
+
zipfile.on('end', () => {
|
|
56
|
+
finish([...needed].every((n) => found.has(n)));
|
|
57
|
+
});
|
|
58
|
+
zipfile.readEntry();
|
|
59
|
+
|
|
60
|
+
zipfile.on('entry', (entry) => {
|
|
61
|
+
const name = String(entry.fileName || '').replace(/\\/g, '/');
|
|
62
|
+
|
|
63
|
+
if (needed.has(name)) {
|
|
64
|
+
found.add(name);
|
|
65
|
+
if (found.size === needed.size) {
|
|
66
|
+
// Close early once all required members are present.
|
|
67
|
+
try {
|
|
68
|
+
zipfile.close();
|
|
69
|
+
} catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
finish(true);
|
|
73
|
+
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
zipfile.readEntry();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract a zip on disk into destDir without holding the archive in RAM.
|
|
85
|
+
* Uses yauzl lazy entries + per-file streams.
|
|
86
|
+
* @param {string} zipPath
|
|
87
|
+
* @param {string} destDir
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
export function extractZipFile (zipPath, destDir) {
|
|
91
|
+
fs.mkdirSync(destDir, {recursive: true});
|
|
92
|
+
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
yauzl.open(zipPath, {lazyEntries: true, autoClose: true}, (openErr, zipfile) => {
|
|
95
|
+
if (openErr) {
|
|
96
|
+
reject(openErr);
|
|
97
|
+
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let settled = false;
|
|
102
|
+
const fail = (err) => {
|
|
103
|
+
if (!settled) {
|
|
104
|
+
settled = true;
|
|
105
|
+
reject(err);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const done = () => {
|
|
109
|
+
if (!settled) {
|
|
110
|
+
settled = true;
|
|
111
|
+
resolve();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
zipfile.on('error', fail);
|
|
116
|
+
zipfile.on('end', done);
|
|
117
|
+
zipfile.readEntry();
|
|
118
|
+
|
|
119
|
+
zipfile.on('entry', (entry) => {
|
|
120
|
+
const name = String(entry.fileName || '').replace(/\\/g, '/');
|
|
121
|
+
|
|
122
|
+
if (!name || name.includes('\0')) {
|
|
123
|
+
fail(new Error(`Invalid zip entry name: ${entry.fileName}`));
|
|
124
|
+
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let destPath;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
destPath = safeEntryPath(destDir, name);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
fail(err);
|
|
134
|
+
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (/\/$/u.test(name)) {
|
|
139
|
+
try {
|
|
140
|
+
fs.mkdirSync(destPath, {recursive: true});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
fail(err);
|
|
143
|
+
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
zipfile.readEntry();
|
|
147
|
+
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
fs.mkdirSync(path.dirname(destPath), {recursive: true});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
fail(err);
|
|
155
|
+
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
zipfile.openReadStream(entry, (streamErr, readStream) => {
|
|
160
|
+
if (streamErr) {
|
|
161
|
+
fail(streamErr);
|
|
162
|
+
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const writeStream = fs.createWriteStream(destPath);
|
|
166
|
+
|
|
167
|
+
pipeline(readStream, writeStream)
|
|
168
|
+
.then(() => {
|
|
169
|
+
zipfile.readEntry();
|
|
170
|
+
})
|
|
171
|
+
.catch(fail);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Peek whether a file starts with the ZIP local-file magic (`PK`).
|
|
180
|
+
* @param {string} filePath
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
export function fileLooksLikeZip (filePath) {
|
|
184
|
+
const fd = fs.openSync(filePath, 'r');
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const buf = Buffer.alloc(4);
|
|
188
|
+
const n = fs.readSync(fd, buf, 0, 4, 0);
|
|
189
|
+
|
|
190
|
+
return n >= 2 && buf[0] === 0x50 && buf[1] === 0x4b;
|
|
191
|
+
} finally {
|
|
192
|
+
fs.closeSync(fd);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* analyticsEnabled — when false, track is a no-op (default true when endpoint set)
|
|
10
10
|
* analyticsPlatform — optional override; else window.gameConfig.platform
|
|
11
11
|
* analyticsAppVersion — optional override; else window.gameConfig.version / config.appVersion
|
|
12
|
+
* (web/uwp/pwa/electron/capacitor prebuilds all write game-config.js)
|
|
12
13
|
*/
|
|
13
14
|
(function registerAnalyticsHelpers(ns) {
|
|
14
15
|
const OPT_OUT_KEY = 'adaptfully_analytics_opt_out';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makefully/adaptfully",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.3",
|
|
4
4
|
"description": "Platform abstraction and Wrapfully deploy client for Makefully games",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/node/index.js",
|
|
@@ -58,6 +58,6 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"archiver": "^7.0.1",
|
|
60
60
|
"axios": "^1.7.9",
|
|
61
|
-
"
|
|
61
|
+
"yauzl": "^3.4.0"
|
|
62
62
|
}
|
|
63
63
|
}
|