w3wallets 1.0.0-beta.7 → 1.0.0-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +0 -4
- package/dist/index.d.ts +0 -4
- package/dist/index.js +27 -27
- package/dist/index.mjs +27 -27
- package/dist/scripts/cache.js +2 -1
- package/package.json +2 -2
- package/src/scripts/download.js +668 -0
package/dist/index.d.mts
CHANGED
|
@@ -134,10 +134,6 @@ declare class Metamask extends Wallet {
|
|
|
134
134
|
*/
|
|
135
135
|
lock(): Promise<void>;
|
|
136
136
|
/**
|
|
137
|
-
* Unlock MetaMask with password.
|
|
138
|
-
* After unlocking, stabilizes the wallet UI by handling post-unlock
|
|
139
|
-
* screens (metametrics, onboarding completion) and dismissing queued
|
|
140
|
-
* notifications. Ends on home.html with the wallet UI ready.
|
|
141
137
|
* Unlock MetaMask with password.
|
|
142
138
|
* After unlocking, stabilizes the wallet UI by handling post-unlock
|
|
143
139
|
* screens (metametrics, onboarding completion) and dismissing queued
|
package/dist/index.d.ts
CHANGED
|
@@ -134,10 +134,6 @@ declare class Metamask extends Wallet {
|
|
|
134
134
|
*/
|
|
135
135
|
lock(): Promise<void>;
|
|
136
136
|
/**
|
|
137
|
-
* Unlock MetaMask with password.
|
|
138
|
-
* After unlocking, stabilizes the wallet UI by handling post-unlock
|
|
139
|
-
* screens (metametrics, onboarding completion) and dismissing queued
|
|
140
|
-
* notifications. Ends on home.html with the wallet UI ready.
|
|
141
137
|
* Unlock MetaMask with password.
|
|
142
138
|
* After unlocking, stabilizes the wallet UI by handling post-unlock
|
|
143
139
|
* screens (metametrics, onboarding completion) and dismissing queued
|
package/dist/index.js
CHANGED
|
@@ -98,12 +98,14 @@ var ARIA_CLOSE_TIMEOUT = 500;
|
|
|
98
98
|
var POPUP_HIDDEN_TIMEOUT = 3e3;
|
|
99
99
|
var POST_UNLOCK_TIMEOUT = 3e4;
|
|
100
100
|
var NOTIFICATION_CHECK_TIMEOUT = 5e3;
|
|
101
|
-
var CONFIRMATION_ROUTE_TIMEOUT = 15e3;
|
|
102
101
|
var POST_CLICK_TIMEOUT = 1e4;
|
|
103
102
|
var BUTTON_OR_POPUP_TIMEOUT = 3e4;
|
|
104
103
|
var LAST_RESORT_CLICK_TIMEOUT = 1e4;
|
|
105
104
|
var LOCK_SCREEN_TIMEOUT = 3e4;
|
|
106
105
|
var MENU_BUTTON_TIMEOUT = 3e4;
|
|
106
|
+
var ONBOARD_VISIBLE_TIMEOUT = 3e4;
|
|
107
|
+
var ROUTE_RETRY_TIMEOUT = 5e3;
|
|
108
|
+
var MAX_ROUTE_ATTEMPTS = 5;
|
|
107
109
|
var MNEMONIC_KEY_DELAY = 5;
|
|
108
110
|
var MNEMONIC_WORD_DELAY = 100;
|
|
109
111
|
|
|
@@ -293,7 +295,6 @@ async function findCachedExtension(context, ExtensionClass, expectedExtensionId,
|
|
|
293
295
|
const page = await context.newPage();
|
|
294
296
|
if (homeUrl) {
|
|
295
297
|
await page.goto(`chrome-extension://${expectedExtensionId}/${homeUrl}`);
|
|
296
|
-
await page.goto(`chrome-extension://${expectedExtensionId}/${homeUrl}`);
|
|
297
298
|
}
|
|
298
299
|
const extension = new ExtensionClass(page, expectedExtensionId);
|
|
299
300
|
return extension;
|
|
@@ -360,7 +361,7 @@ var Metamask = class extends Wallet {
|
|
|
360
361
|
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
361
362
|
await (0, import_test3.expect)(
|
|
362
363
|
this.page.getByRole("button", { name: "I have an existing wallet" })
|
|
363
|
-
).toBeVisible({ timeout:
|
|
364
|
+
).toBeVisible({ timeout: ONBOARD_VISIBLE_TIMEOUT });
|
|
364
365
|
}
|
|
365
366
|
/**
|
|
366
367
|
* Onboard MetaMask with a mnemonic phrase
|
|
@@ -385,7 +386,6 @@ var Metamask = class extends Wallet {
|
|
|
385
386
|
}
|
|
386
387
|
const continueBtn = this.page.getByTestId("import-srp-confirm");
|
|
387
388
|
await (0, import_test3.expect)(continueBtn).toBeEnabled({ timeout: config.expectTimeout });
|
|
388
|
-
await (0, import_test3.expect)(continueBtn).toBeEnabled({ timeout: config.expectTimeout });
|
|
389
389
|
await continueBtn.click();
|
|
390
390
|
const passwordInputs = this.page.locator('input[type="password"]');
|
|
391
391
|
await passwordInputs.nth(0).fill(pwd);
|
|
@@ -399,7 +399,6 @@ var Metamask = class extends Wallet {
|
|
|
399
399
|
});
|
|
400
400
|
await openWalletBtn.click();
|
|
401
401
|
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
402
|
-
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
403
402
|
await this.page.goto(
|
|
404
403
|
`chrome-extension://${this.extensionId}/sidepanel.html`
|
|
405
404
|
);
|
|
@@ -537,26 +536,26 @@ var Metamask = class extends Wallet {
|
|
|
537
536
|
await btnLocator.first().waitFor({ state: "visible", timeout: BUTTON_OR_POPUP_TIMEOUT });
|
|
538
537
|
await btnLocator.first().click();
|
|
539
538
|
};
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
await this.page.waitForURL(confirmRoutePattern, {
|
|
543
|
-
timeout: CONFIRMATION_ROUTE_TIMEOUT
|
|
544
|
-
});
|
|
545
|
-
} catch {
|
|
546
|
-
console.warn(
|
|
547
|
-
`[w3wallets] confirmation route not found, retrying. URL: ${this.page.url()}`
|
|
548
|
-
);
|
|
539
|
+
let routeFound = false;
|
|
540
|
+
for (let attempt = 0; attempt < MAX_ROUTE_ATTEMPTS; attempt++) {
|
|
549
541
|
await this.page.goto(sidepanelUrl);
|
|
550
542
|
try {
|
|
551
543
|
await this.page.waitForURL(confirmRoutePattern, {
|
|
552
|
-
timeout:
|
|
544
|
+
timeout: ROUTE_RETRY_TIMEOUT
|
|
553
545
|
});
|
|
546
|
+
routeFound = true;
|
|
547
|
+
break;
|
|
554
548
|
} catch {
|
|
555
|
-
|
|
556
|
-
`
|
|
549
|
+
debug(
|
|
550
|
+
`metamask.waitAndClickButton: route attempt ${attempt + 1}/${MAX_ROUTE_ATTEMPTS} failed. URL: ${this.page.url()}`
|
|
557
551
|
);
|
|
558
552
|
}
|
|
559
553
|
}
|
|
554
|
+
if (!routeFound) {
|
|
555
|
+
console.warn(
|
|
556
|
+
`[w3wallets] confirmation route not found after ${MAX_ROUTE_ATTEMPTS} attempts. URL: ${this.page.url()}`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
560
559
|
const result = await waitForButtonOrPopup(BUTTON_OR_POPUP_TIMEOUT);
|
|
561
560
|
debug(
|
|
562
561
|
`metamask.waitAndClickButton: result=${result}, URL=${this.page.url()}`
|
|
@@ -610,14 +609,10 @@ var Metamask = class extends Wallet {
|
|
|
610
609
|
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
611
610
|
const menuBtn = this.page.getByTestId("account-options-menu-button");
|
|
612
611
|
await menuBtn.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT });
|
|
613
|
-
await menuBtn.click();
|
|
612
|
+
await menuBtn.click({ force: true });
|
|
614
613
|
await this.page.locator("text=Log out").click();
|
|
615
614
|
}
|
|
616
615
|
/**
|
|
617
|
-
* Unlock MetaMask with password.
|
|
618
|
-
* After unlocking, stabilizes the wallet UI by handling post-unlock
|
|
619
|
-
* screens (metametrics, onboarding completion) and dismissing queued
|
|
620
|
-
* notifications. Ends on home.html with the wallet UI ready.
|
|
621
616
|
* Unlock MetaMask with password.
|
|
622
617
|
* After unlocking, stabilizes the wallet UI by handling post-unlock
|
|
623
618
|
* screens (metametrics, onboarding completion) and dismissing queued
|
|
@@ -627,7 +622,6 @@ var Metamask = class extends Wallet {
|
|
|
627
622
|
debug("metamask.unlock: starting");
|
|
628
623
|
const pwd = password ?? this.defaultPassword;
|
|
629
624
|
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
630
|
-
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
631
625
|
const passwordInput = this.page.getByTestId("unlock-password");
|
|
632
626
|
await passwordInput.fill(pwd);
|
|
633
627
|
await this.page.getByTestId("unlock-submit").click();
|
|
@@ -646,7 +640,7 @@ var Metamask = class extends Wallet {
|
|
|
646
640
|
debug(`metamask.switchNetwork: ${networkName} (${networkType})`);
|
|
647
641
|
await this.page.getByTestId("sort-by-networks").click();
|
|
648
642
|
if (networkType === "Custom") {
|
|
649
|
-
await this.page.getByRole("tab", { name: "Custom" }).click();
|
|
643
|
+
await this.page.getByRole("tab", { name: "Custom" }).click({ force: true });
|
|
650
644
|
}
|
|
651
645
|
await this.page.getByText(networkName).click();
|
|
652
646
|
await (0, import_test3.expect)(this.page.getByTestId("sort-by-networks")).toHaveText(
|
|
@@ -672,7 +666,7 @@ var Metamask = class extends Wallet {
|
|
|
672
666
|
await this.page.getByRole("button", { name: /save/i }).click();
|
|
673
667
|
}
|
|
674
668
|
async addCustomNetwork(settings) {
|
|
675
|
-
await this.page.getByTestId("account-options-menu-button").click();
|
|
669
|
+
await this.page.getByTestId("account-options-menu-button").click({ force: true });
|
|
676
670
|
await this.page.getByTestId("global-menu-networks").click();
|
|
677
671
|
await this.page.getByRole("button", { name: "Add a custom network" }).click();
|
|
678
672
|
await this.page.getByTestId("network-form-network-name").fill(settings.name);
|
|
@@ -683,11 +677,17 @@ var Metamask = class extends Wallet {
|
|
|
683
677
|
await this.page.getByTestId("rpc-url-input-test").fill(settings.rpc);
|
|
684
678
|
await this.page.getByRole("button", { name: "Add URL" }).click();
|
|
685
679
|
await this.page.getByRole("button", { name: "Save" }).click();
|
|
680
|
+
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
681
|
+
await this.page.waitForLoadState("domcontentloaded");
|
|
686
682
|
}
|
|
687
683
|
async enableTestNetworks() {
|
|
688
|
-
await this.page.getByTestId("account-options-menu-button").click();
|
|
684
|
+
await this.page.getByTestId("account-options-menu-button").click({ force: true });
|
|
689
685
|
await this.page.getByTestId("global-menu-networks").click();
|
|
690
|
-
|
|
686
|
+
const toggle = this.page.locator(
|
|
687
|
+
"text=Show test networks >> xpath=following-sibling::label"
|
|
688
|
+
);
|
|
689
|
+
await (0, import_test3.expect)(toggle).toBeVisible({ timeout: config.expectTimeout });
|
|
690
|
+
await toggle.click();
|
|
691
691
|
await this.page.keyboard.press("Escape");
|
|
692
692
|
}
|
|
693
693
|
async importAccount(privateKey) {
|
package/dist/index.mjs
CHANGED
|
@@ -62,12 +62,14 @@ var ARIA_CLOSE_TIMEOUT = 500;
|
|
|
62
62
|
var POPUP_HIDDEN_TIMEOUT = 3e3;
|
|
63
63
|
var POST_UNLOCK_TIMEOUT = 3e4;
|
|
64
64
|
var NOTIFICATION_CHECK_TIMEOUT = 5e3;
|
|
65
|
-
var CONFIRMATION_ROUTE_TIMEOUT = 15e3;
|
|
66
65
|
var POST_CLICK_TIMEOUT = 1e4;
|
|
67
66
|
var BUTTON_OR_POPUP_TIMEOUT = 3e4;
|
|
68
67
|
var LAST_RESORT_CLICK_TIMEOUT = 1e4;
|
|
69
68
|
var LOCK_SCREEN_TIMEOUT = 3e4;
|
|
70
69
|
var MENU_BUTTON_TIMEOUT = 3e4;
|
|
70
|
+
var ONBOARD_VISIBLE_TIMEOUT = 3e4;
|
|
71
|
+
var ROUTE_RETRY_TIMEOUT = 5e3;
|
|
72
|
+
var MAX_ROUTE_ATTEMPTS = 5;
|
|
71
73
|
var MNEMONIC_KEY_DELAY = 5;
|
|
72
74
|
var MNEMONIC_WORD_DELAY = 100;
|
|
73
75
|
|
|
@@ -257,7 +259,6 @@ async function findCachedExtension(context, ExtensionClass, expectedExtensionId,
|
|
|
257
259
|
const page = await context.newPage();
|
|
258
260
|
if (homeUrl) {
|
|
259
261
|
await page.goto(`chrome-extension://${expectedExtensionId}/${homeUrl}`);
|
|
260
|
-
await page.goto(`chrome-extension://${expectedExtensionId}/${homeUrl}`);
|
|
261
262
|
}
|
|
262
263
|
const extension = new ExtensionClass(page, expectedExtensionId);
|
|
263
264
|
return extension;
|
|
@@ -324,7 +325,7 @@ var Metamask = class extends Wallet {
|
|
|
324
325
|
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
325
326
|
await expect(
|
|
326
327
|
this.page.getByRole("button", { name: "I have an existing wallet" })
|
|
327
|
-
).toBeVisible({ timeout:
|
|
328
|
+
).toBeVisible({ timeout: ONBOARD_VISIBLE_TIMEOUT });
|
|
328
329
|
}
|
|
329
330
|
/**
|
|
330
331
|
* Onboard MetaMask with a mnemonic phrase
|
|
@@ -349,7 +350,6 @@ var Metamask = class extends Wallet {
|
|
|
349
350
|
}
|
|
350
351
|
const continueBtn = this.page.getByTestId("import-srp-confirm");
|
|
351
352
|
await expect(continueBtn).toBeEnabled({ timeout: config.expectTimeout });
|
|
352
|
-
await expect(continueBtn).toBeEnabled({ timeout: config.expectTimeout });
|
|
353
353
|
await continueBtn.click();
|
|
354
354
|
const passwordInputs = this.page.locator('input[type="password"]');
|
|
355
355
|
await passwordInputs.nth(0).fill(pwd);
|
|
@@ -363,7 +363,6 @@ var Metamask = class extends Wallet {
|
|
|
363
363
|
});
|
|
364
364
|
await openWalletBtn.click();
|
|
365
365
|
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
366
|
-
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
367
366
|
await this.page.goto(
|
|
368
367
|
`chrome-extension://${this.extensionId}/sidepanel.html`
|
|
369
368
|
);
|
|
@@ -501,26 +500,26 @@ var Metamask = class extends Wallet {
|
|
|
501
500
|
await btnLocator.first().waitFor({ state: "visible", timeout: BUTTON_OR_POPUP_TIMEOUT });
|
|
502
501
|
await btnLocator.first().click();
|
|
503
502
|
};
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
await this.page.waitForURL(confirmRoutePattern, {
|
|
507
|
-
timeout: CONFIRMATION_ROUTE_TIMEOUT
|
|
508
|
-
});
|
|
509
|
-
} catch {
|
|
510
|
-
console.warn(
|
|
511
|
-
`[w3wallets] confirmation route not found, retrying. URL: ${this.page.url()}`
|
|
512
|
-
);
|
|
503
|
+
let routeFound = false;
|
|
504
|
+
for (let attempt = 0; attempt < MAX_ROUTE_ATTEMPTS; attempt++) {
|
|
513
505
|
await this.page.goto(sidepanelUrl);
|
|
514
506
|
try {
|
|
515
507
|
await this.page.waitForURL(confirmRoutePattern, {
|
|
516
|
-
timeout:
|
|
508
|
+
timeout: ROUTE_RETRY_TIMEOUT
|
|
517
509
|
});
|
|
510
|
+
routeFound = true;
|
|
511
|
+
break;
|
|
518
512
|
} catch {
|
|
519
|
-
|
|
520
|
-
`
|
|
513
|
+
debug(
|
|
514
|
+
`metamask.waitAndClickButton: route attempt ${attempt + 1}/${MAX_ROUTE_ATTEMPTS} failed. URL: ${this.page.url()}`
|
|
521
515
|
);
|
|
522
516
|
}
|
|
523
517
|
}
|
|
518
|
+
if (!routeFound) {
|
|
519
|
+
console.warn(
|
|
520
|
+
`[w3wallets] confirmation route not found after ${MAX_ROUTE_ATTEMPTS} attempts. URL: ${this.page.url()}`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
524
523
|
const result = await waitForButtonOrPopup(BUTTON_OR_POPUP_TIMEOUT);
|
|
525
524
|
debug(
|
|
526
525
|
`metamask.waitAndClickButton: result=${result}, URL=${this.page.url()}`
|
|
@@ -574,14 +573,10 @@ var Metamask = class extends Wallet {
|
|
|
574
573
|
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
575
574
|
const menuBtn = this.page.getByTestId("account-options-menu-button");
|
|
576
575
|
await menuBtn.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT });
|
|
577
|
-
await menuBtn.click();
|
|
576
|
+
await menuBtn.click({ force: true });
|
|
578
577
|
await this.page.locator("text=Log out").click();
|
|
579
578
|
}
|
|
580
579
|
/**
|
|
581
|
-
* Unlock MetaMask with password.
|
|
582
|
-
* After unlocking, stabilizes the wallet UI by handling post-unlock
|
|
583
|
-
* screens (metametrics, onboarding completion) and dismissing queued
|
|
584
|
-
* notifications. Ends on home.html with the wallet UI ready.
|
|
585
580
|
* Unlock MetaMask with password.
|
|
586
581
|
* After unlocking, stabilizes the wallet UI by handling post-unlock
|
|
587
582
|
* screens (metametrics, onboarding completion) and dismissing queued
|
|
@@ -591,7 +586,6 @@ var Metamask = class extends Wallet {
|
|
|
591
586
|
debug("metamask.unlock: starting");
|
|
592
587
|
const pwd = password ?? this.defaultPassword;
|
|
593
588
|
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
594
|
-
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
595
589
|
const passwordInput = this.page.getByTestId("unlock-password");
|
|
596
590
|
await passwordInput.fill(pwd);
|
|
597
591
|
await this.page.getByTestId("unlock-submit").click();
|
|
@@ -610,7 +604,7 @@ var Metamask = class extends Wallet {
|
|
|
610
604
|
debug(`metamask.switchNetwork: ${networkName} (${networkType})`);
|
|
611
605
|
await this.page.getByTestId("sort-by-networks").click();
|
|
612
606
|
if (networkType === "Custom") {
|
|
613
|
-
await this.page.getByRole("tab", { name: "Custom" }).click();
|
|
607
|
+
await this.page.getByRole("tab", { name: "Custom" }).click({ force: true });
|
|
614
608
|
}
|
|
615
609
|
await this.page.getByText(networkName).click();
|
|
616
610
|
await expect(this.page.getByTestId("sort-by-networks")).toHaveText(
|
|
@@ -636,7 +630,7 @@ var Metamask = class extends Wallet {
|
|
|
636
630
|
await this.page.getByRole("button", { name: /save/i }).click();
|
|
637
631
|
}
|
|
638
632
|
async addCustomNetwork(settings) {
|
|
639
|
-
await this.page.getByTestId("account-options-menu-button").click();
|
|
633
|
+
await this.page.getByTestId("account-options-menu-button").click({ force: true });
|
|
640
634
|
await this.page.getByTestId("global-menu-networks").click();
|
|
641
635
|
await this.page.getByRole("button", { name: "Add a custom network" }).click();
|
|
642
636
|
await this.page.getByTestId("network-form-network-name").fill(settings.name);
|
|
@@ -647,11 +641,17 @@ var Metamask = class extends Wallet {
|
|
|
647
641
|
await this.page.getByTestId("rpc-url-input-test").fill(settings.rpc);
|
|
648
642
|
await this.page.getByRole("button", { name: "Add URL" }).click();
|
|
649
643
|
await this.page.getByRole("button", { name: "Save" }).click();
|
|
644
|
+
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
|
|
645
|
+
await this.page.waitForLoadState("domcontentloaded");
|
|
650
646
|
}
|
|
651
647
|
async enableTestNetworks() {
|
|
652
|
-
await this.page.getByTestId("account-options-menu-button").click();
|
|
648
|
+
await this.page.getByTestId("account-options-menu-button").click({ force: true });
|
|
653
649
|
await this.page.getByTestId("global-menu-networks").click();
|
|
654
|
-
|
|
650
|
+
const toggle = this.page.locator(
|
|
651
|
+
"text=Show test networks >> xpath=following-sibling::label"
|
|
652
|
+
);
|
|
653
|
+
await expect(toggle).toBeVisible({ timeout: config.expectTimeout });
|
|
654
|
+
await toggle.click();
|
|
655
655
|
await this.page.keyboard.press("Escape");
|
|
656
656
|
}
|
|
657
657
|
async importAccount(privateKey) {
|
package/dist/scripts/cache.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w3wallets",
|
|
3
3
|
"description": "browser wallets for playwright",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.8",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"homepage": "https://github.com/Maksandre/w3wallets",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"files": [
|
|
29
29
|
"dist"
|
|
30
30
|
],
|
|
31
|
-
"bin": "./
|
|
31
|
+
"bin": "./src/scripts/download.js",
|
|
32
32
|
"scripts": {
|
|
33
33
|
"download-wallets": "npx w3wallets pjs mm",
|
|
34
34
|
"cache-wallets": "npx w3wallets cache --force ./tests/wallets-cache/",
|
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Downloads and extracts Chrome extensions from the Chrome Web Store.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx w3wallets metamask polkadotjs # Download by alias
|
|
8
|
+
* npx w3wallets mm pjs # Short aliases
|
|
9
|
+
* npx w3wallets <extension-id> # Download by extension ID
|
|
10
|
+
* npx w3wallets --help # Show help
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const https = require("https");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const zlib = require("zlib");
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------
|
|
19
|
+
// 1. Known aliases -> extension IDs (case-insensitive lookup)
|
|
20
|
+
// ---------------------------------------------------------------------
|
|
21
|
+
const EXTENSION_REGISTRY = {
|
|
22
|
+
// MetaMask wallet
|
|
23
|
+
metamask: "nkbihfbeogaeaoehlefnkodbefgpgknn",
|
|
24
|
+
mm: "nkbihfbeogaeaoehlefnkodbefgpgknn",
|
|
25
|
+
|
|
26
|
+
// Polkadot.js wallet
|
|
27
|
+
polkadotjs: "mopnmbcafieddcagagdcbnhejhlodfdd",
|
|
28
|
+
pjs: "mopnmbcafieddcagagdcbnhejhlodfdd",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Human-readable names for display
|
|
32
|
+
const EXTENSION_NAMES = {
|
|
33
|
+
nkbihfbeogaeaoehlefnkodbefgpgknn: "MetaMask",
|
|
34
|
+
mopnmbcafieddcagagdcbnhejhlodfdd: "Polkadot.js",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Canonical aliases for listing
|
|
38
|
+
const CANONICAL_ALIASES = [
|
|
39
|
+
{ name: "metamask", short: "mm", id: "nkbihfbeogaeaoehlefnkodbefgpgknn" },
|
|
40
|
+
{ name: "polkadotjs", short: "pjs", id: "mopnmbcafieddcagagdcbnhejhlodfdd" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------
|
|
44
|
+
// ZIP format constants (per PKWARE APPNOTE.TXT specification)
|
|
45
|
+
// ---------------------------------------------------------------------
|
|
46
|
+
const ZIP_SIGNATURES = {
|
|
47
|
+
EOCD: 0x06054b50, // End of Central Directory
|
|
48
|
+
CENTRAL_DIR: 0x02014b50, // Central Directory file header
|
|
49
|
+
LOCAL_FILE: 0x04034b50, // Local file header
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ZIP_FLAGS = {
|
|
53
|
+
ENCRYPTED: 0x0001, // File is encrypted
|
|
54
|
+
DATA_DESCRIPTOR: 0x0008, // Sizes in data descriptor after file data
|
|
55
|
+
UTF8_FILENAME: 0x0800, // Filename is UTF-8 encoded
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const ZIP_METHODS = {
|
|
59
|
+
STORE: 0, // No compression
|
|
60
|
+
DEFLATE: 8, // Deflate compression
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Marker value indicating ZIP64 format is required
|
|
64
|
+
const ZIP64_MARKER = 0xffffffff;
|
|
65
|
+
|
|
66
|
+
// Maximum size of EOCD record (22 bytes + max 65535 comment)
|
|
67
|
+
const MAX_EOCD_SEARCH = 65557;
|
|
68
|
+
|
|
69
|
+
// HTTP request timeout in milliseconds
|
|
70
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------
|
|
73
|
+
// 2. CLI Argument Parser
|
|
74
|
+
// ---------------------------------------------------------------------
|
|
75
|
+
const CLI_OPTIONS = {
|
|
76
|
+
help: false,
|
|
77
|
+
list: false,
|
|
78
|
+
output: ".w3wallets",
|
|
79
|
+
force: false,
|
|
80
|
+
debug: false,
|
|
81
|
+
dryRun: false,
|
|
82
|
+
targets: [], // aliases or extension IDs
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function printHelp() {
|
|
86
|
+
console.log(`
|
|
87
|
+
w3wallets - Download Chrome extensions from the Chrome Web Store
|
|
88
|
+
|
|
89
|
+
USAGE:
|
|
90
|
+
npx w3wallets [OPTIONS] <targets...>
|
|
91
|
+
|
|
92
|
+
TARGETS:
|
|
93
|
+
Alias name Known wallet alias (e.g., metamask, polkadotjs)
|
|
94
|
+
Short alias Short form (e.g., mm, pjs)
|
|
95
|
+
Extension ID 32-character Chrome extension ID
|
|
96
|
+
URL Chrome Web Store URL
|
|
97
|
+
|
|
98
|
+
OPTIONS:
|
|
99
|
+
-h, --help Show this help message
|
|
100
|
+
-l, --list List available wallet aliases
|
|
101
|
+
-o, --output Output directory (default: .w3wallets)
|
|
102
|
+
-f, --force Force re-download even if already exists
|
|
103
|
+
-n, --dry-run Show what would be done without downloading
|
|
104
|
+
--debug Save raw .crx file for debugging
|
|
105
|
+
|
|
106
|
+
EXAMPLES:
|
|
107
|
+
npx w3wallets metamask # Download MetaMask
|
|
108
|
+
npx w3wallets mm pjs # Download using short aliases
|
|
109
|
+
npx w3wallets --list # List available aliases
|
|
110
|
+
npx w3wallets -o ./extensions metamask # Custom output directory
|
|
111
|
+
npx w3wallets --force mm # Force re-download
|
|
112
|
+
npx w3wallets nkbihfbeogaeaoehlefnkodbefgpgknn # Download by extension ID
|
|
113
|
+
npx w3wallets "https://chromewebstore.google.com/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn"
|
|
114
|
+
`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function printList() {
|
|
118
|
+
console.log("\nAvailable wallet aliases:\n");
|
|
119
|
+
console.log(" ALIAS SHORT EXTENSION ID");
|
|
120
|
+
console.log(" " + "-".repeat(50));
|
|
121
|
+
for (const { name, short, id } of CANONICAL_ALIASES) {
|
|
122
|
+
console.log(` ${name.padEnd(12)} ${short.padEnd(7)} ${id}`);
|
|
123
|
+
}
|
|
124
|
+
console.log(
|
|
125
|
+
"\nYou can also download any extension by ID or Chrome Web Store URL.\n",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse extension ID from various input formats:
|
|
131
|
+
* - Known alias (case-insensitive): "metamask", "MetaMask", "MM"
|
|
132
|
+
* - Direct extension ID: "nkbihfbeogaeaoehlefnkodbefgpgknn"
|
|
133
|
+
* - Chrome Web Store URL: "https://chromewebstore.google.com/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn"
|
|
134
|
+
*/
|
|
135
|
+
function parseExtensionTarget(input) {
|
|
136
|
+
// Check for known alias (case-insensitive)
|
|
137
|
+
const normalizedInput = input.toLowerCase();
|
|
138
|
+
if (EXTENSION_REGISTRY[normalizedInput]) {
|
|
139
|
+
const id = EXTENSION_REGISTRY[normalizedInput];
|
|
140
|
+
// Find canonical alias name for directory
|
|
141
|
+
const alias = CANONICAL_ALIASES.find((a) => a.id === id);
|
|
142
|
+
return {
|
|
143
|
+
id,
|
|
144
|
+
name: EXTENSION_NAMES[id] || normalizedInput,
|
|
145
|
+
dirName: alias ? alias.name : id,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if it's a Chrome Web Store URL
|
|
150
|
+
const urlPatterns = [
|
|
151
|
+
/chromewebstore\.google\.com\/detail\/[^/]+\/([a-z]{32})/i,
|
|
152
|
+
/chrome\.google\.com\/webstore\/detail\/[^/]+\/([a-z]{32})/i,
|
|
153
|
+
];
|
|
154
|
+
for (const pattern of urlPatterns) {
|
|
155
|
+
const match = input.match(pattern);
|
|
156
|
+
if (match) {
|
|
157
|
+
const id = match[1].toLowerCase();
|
|
158
|
+
const alias = CANONICAL_ALIASES.find((a) => a.id === id);
|
|
159
|
+
return {
|
|
160
|
+
id,
|
|
161
|
+
name: EXTENSION_NAMES[id] || id,
|
|
162
|
+
dirName: alias ? alias.name : id,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check if it's a direct extension ID (32 lowercase letters)
|
|
168
|
+
if (/^[a-z]{32}$/i.test(input)) {
|
|
169
|
+
const id = input.toLowerCase();
|
|
170
|
+
const alias = CANONICAL_ALIASES.find((a) => a.id === id);
|
|
171
|
+
return {
|
|
172
|
+
id,
|
|
173
|
+
name: EXTENSION_NAMES[id] || id,
|
|
174
|
+
dirName: alias ? alias.name : id,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseArgs(args) {
|
|
182
|
+
let i = 0;
|
|
183
|
+
while (i < args.length) {
|
|
184
|
+
const arg = args[i];
|
|
185
|
+
|
|
186
|
+
if (arg === "-h" || arg === "--help") {
|
|
187
|
+
CLI_OPTIONS.help = true;
|
|
188
|
+
} else if (arg === "-l" || arg === "--list") {
|
|
189
|
+
CLI_OPTIONS.list = true;
|
|
190
|
+
} else if (arg === "-o" || arg === "--output") {
|
|
191
|
+
i++;
|
|
192
|
+
if (i >= args.length) {
|
|
193
|
+
console.error("Error: --output requires a directory path");
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
CLI_OPTIONS.output = args[i];
|
|
197
|
+
} else if (arg === "-f" || arg === "--force") {
|
|
198
|
+
CLI_OPTIONS.force = true;
|
|
199
|
+
} else if (arg === "-n" || arg === "--dry-run") {
|
|
200
|
+
CLI_OPTIONS.dryRun = true;
|
|
201
|
+
} else if (arg === "--debug") {
|
|
202
|
+
CLI_OPTIONS.debug = true;
|
|
203
|
+
} else if (arg.startsWith("-")) {
|
|
204
|
+
console.error(`Error: Unknown option "${arg}"`);
|
|
205
|
+
console.error("Use --help for usage information");
|
|
206
|
+
process.exit(1);
|
|
207
|
+
} else {
|
|
208
|
+
// It's a target (alias, ID, or URL)
|
|
209
|
+
const parsed = parseExtensionTarget(arg);
|
|
210
|
+
if (!parsed) {
|
|
211
|
+
console.error(
|
|
212
|
+
`Error: "${arg}" is not a valid alias, extension ID, or URL`,
|
|
213
|
+
);
|
|
214
|
+
console.error(
|
|
215
|
+
"Use --list to see available aliases, or provide a 32-character extension ID",
|
|
216
|
+
);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
CLI_OPTIONS.targets.push(parsed);
|
|
220
|
+
}
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check if first arg is "cache" — delegate to compiled cache script
|
|
226
|
+
const rawArgs = process.argv.slice(2);
|
|
227
|
+
if (rawArgs[0] === "cache") {
|
|
228
|
+
const { execFileSync } = require("child_process");
|
|
229
|
+
const cacheScript = path.join(
|
|
230
|
+
__dirname,
|
|
231
|
+
"..",
|
|
232
|
+
"..",
|
|
233
|
+
"dist",
|
|
234
|
+
"scripts",
|
|
235
|
+
"cache.js",
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
if (!fs.existsSync(cacheScript)) {
|
|
239
|
+
console.error(
|
|
240
|
+
"Error: Cache script not found. Make sure w3wallets is built (run: npx tsup).",
|
|
241
|
+
);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
execFileSync(process.execPath, [cacheScript, ...rawArgs.slice(1)], {
|
|
247
|
+
stdio: "inherit",
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
process.exit(err.status || 1);
|
|
251
|
+
}
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Parse command line arguments for download mode
|
|
256
|
+
parseArgs(rawArgs);
|
|
257
|
+
|
|
258
|
+
// Handle --help
|
|
259
|
+
if (CLI_OPTIONS.help) {
|
|
260
|
+
printHelp();
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Handle --list
|
|
265
|
+
if (CLI_OPTIONS.list) {
|
|
266
|
+
printList();
|
|
267
|
+
process.exit(0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Validate we have targets
|
|
271
|
+
if (CLI_OPTIONS.targets.length === 0) {
|
|
272
|
+
console.error("Error: No extension targets specified");
|
|
273
|
+
console.error(
|
|
274
|
+
"Use --help for usage information or --list to see available aliases",
|
|
275
|
+
);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------
|
|
280
|
+
// 3. Main: download and extract each requested extension
|
|
281
|
+
// ---------------------------------------------------------------------
|
|
282
|
+
(async function main() {
|
|
283
|
+
// Handle --dry-run mode
|
|
284
|
+
if (CLI_OPTIONS.dryRun) {
|
|
285
|
+
for (const target of CLI_OPTIONS.targets) {
|
|
286
|
+
const { id, name, dirName } = target;
|
|
287
|
+
const outDir = path.join(CLI_OPTIONS.output, dirName);
|
|
288
|
+
const manifestPath = path.join(outDir, "manifest.json");
|
|
289
|
+
const exists = fs.existsSync(manifestPath);
|
|
290
|
+
|
|
291
|
+
const downloadUrl =
|
|
292
|
+
"https://clients2.google.com/service/update2/crx" +
|
|
293
|
+
"?response=redirect" +
|
|
294
|
+
"&prod=chrome" +
|
|
295
|
+
"&prodversion=9999" +
|
|
296
|
+
"&acceptformat=crx2,crx3" +
|
|
297
|
+
`&x=id%3D${id}%26uc`;
|
|
298
|
+
|
|
299
|
+
console.log(`\n=== ${name} (${id}) ===`);
|
|
300
|
+
console.log(` Output: ${outDir}`);
|
|
301
|
+
if (exists && !CLI_OPTIONS.force) {
|
|
302
|
+
console.log(
|
|
303
|
+
` Status: already exists (would skip, use --force to override)`,
|
|
304
|
+
);
|
|
305
|
+
} else if (exists && CLI_OPTIONS.force) {
|
|
306
|
+
console.log(
|
|
307
|
+
` Status: already exists (would re-download with --force)`,
|
|
308
|
+
);
|
|
309
|
+
} else {
|
|
310
|
+
console.log(` Status: not downloaded (would download)`);
|
|
311
|
+
}
|
|
312
|
+
console.log(` URL: ${downloadUrl}`);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const target of CLI_OPTIONS.targets) {
|
|
318
|
+
const { id, name, dirName } = target;
|
|
319
|
+
const outDir = path.join(CLI_OPTIONS.output, dirName);
|
|
320
|
+
|
|
321
|
+
console.log(`\n=== ${name} (${id}) ===`);
|
|
322
|
+
|
|
323
|
+
// Check if already exists (skip unless --force)
|
|
324
|
+
const manifestPath = path.join(outDir, "manifest.json");
|
|
325
|
+
if (!CLI_OPTIONS.force && fs.existsSync(manifestPath)) {
|
|
326
|
+
console.log(`Already exists: ${outDir}`);
|
|
327
|
+
console.log("Use --force to re-download");
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
// 1) Download CRX with progress
|
|
333
|
+
console.log("Downloading...");
|
|
334
|
+
const crxBuffer = await downloadCrx(id);
|
|
335
|
+
console.log(`Downloaded ${formatBytes(crxBuffer.length)}`);
|
|
336
|
+
|
|
337
|
+
// 2) Optionally save raw CRX for debugging
|
|
338
|
+
if (CLI_OPTIONS.debug) {
|
|
339
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
340
|
+
const debugPath = path.join(outDir, `debug-${dirName}.crx`);
|
|
341
|
+
fs.writeFileSync(debugPath, crxBuffer);
|
|
342
|
+
console.log(`Debug CRX saved: ${debugPath}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 3) Extract CRX
|
|
346
|
+
console.log("Extracting...");
|
|
347
|
+
extractCrxToFolder(crxBuffer, outDir);
|
|
348
|
+
console.log(`Done: ${outDir}`);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.error(`Failed: ${err.message}`);
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
console.log("\nAll extensions downloaded successfully!");
|
|
356
|
+
})();
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------
|
|
359
|
+
// Utility: format bytes for human display
|
|
360
|
+
// ---------------------------------------------------------------------
|
|
361
|
+
function formatBytes(bytes) {
|
|
362
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
363
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
364
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------
|
|
368
|
+
// downloadCrx: Build CRX URL and fetch it
|
|
369
|
+
// ---------------------------------------------------------------------
|
|
370
|
+
async function downloadCrx(extensionId) {
|
|
371
|
+
const downloadUrl =
|
|
372
|
+
"https://clients2.google.com/service/update2/crx" +
|
|
373
|
+
"?response=redirect" +
|
|
374
|
+
"&prod=chrome" +
|
|
375
|
+
"&prodversion=9999" +
|
|
376
|
+
"&acceptformat=crx2,crx3" +
|
|
377
|
+
`&x=id%3D${extensionId}%26uc`;
|
|
378
|
+
|
|
379
|
+
console.log("Requesting:", downloadUrl);
|
|
380
|
+
|
|
381
|
+
const crxBuffer = await fetchUrl(downloadUrl);
|
|
382
|
+
return crxBuffer;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------
|
|
386
|
+
// fetchUrl: minimal GET + redirect handling with timeout and progress
|
|
387
|
+
// ---------------------------------------------------------------------
|
|
388
|
+
function fetchUrl(
|
|
389
|
+
targetUrl,
|
|
390
|
+
options = {},
|
|
391
|
+
redirectCount = 0,
|
|
392
|
+
maxRedirects = 10,
|
|
393
|
+
) {
|
|
394
|
+
return new Promise((resolve, reject) => {
|
|
395
|
+
if (redirectCount > maxRedirects) {
|
|
396
|
+
return reject(new Error("Too many redirects"));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const reqOptions = { ...options, timeout: REQUEST_TIMEOUT_MS };
|
|
400
|
+
const req = https.get(targetUrl, reqOptions, (res) => {
|
|
401
|
+
const { statusCode, headers } = res;
|
|
402
|
+
|
|
403
|
+
// Follow redirects
|
|
404
|
+
if ([301, 302, 303, 307, 308].includes(statusCode) && headers.location) {
|
|
405
|
+
const newUrl = new URL(headers.location, targetUrl).href;
|
|
406
|
+
res.resume(); // discard body
|
|
407
|
+
return resolve(
|
|
408
|
+
fetchUrl(newUrl, options, redirectCount + 1, maxRedirects),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (statusCode !== 200) {
|
|
413
|
+
res.resume();
|
|
414
|
+
return reject(
|
|
415
|
+
new Error(`Request failed with status code ${statusCode}`),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const contentLength = parseInt(headers["content-length"], 10) || 0;
|
|
420
|
+
const dataChunks = [];
|
|
421
|
+
let downloadedBytes = 0;
|
|
422
|
+
let lastProgressUpdate = 0;
|
|
423
|
+
|
|
424
|
+
res.on("data", (chunk) => {
|
|
425
|
+
dataChunks.push(chunk);
|
|
426
|
+
downloadedBytes += chunk.length;
|
|
427
|
+
|
|
428
|
+
// Update progress at most every 100ms to avoid flickering
|
|
429
|
+
const now = Date.now();
|
|
430
|
+
if (contentLength > 0 && now - lastProgressUpdate > 100) {
|
|
431
|
+
lastProgressUpdate = now;
|
|
432
|
+
const percent = Math.round((downloadedBytes / contentLength) * 100);
|
|
433
|
+
const progressBar = createProgressBar(percent);
|
|
434
|
+
process.stdout.write(
|
|
435
|
+
`\r ${progressBar} ${percent}% (${formatBytes(downloadedBytes)})`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
res.on("end", () => {
|
|
441
|
+
// Clear the progress line
|
|
442
|
+
if (contentLength > 0) {
|
|
443
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
444
|
+
}
|
|
445
|
+
resolve(Buffer.concat(dataChunks));
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
req.on("timeout", () => {
|
|
450
|
+
req.destroy();
|
|
451
|
+
reject(
|
|
452
|
+
new Error(
|
|
453
|
+
`Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${targetUrl}`,
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
req.on("error", (err) => {
|
|
459
|
+
reject(new Error(`Failed to fetch ${targetUrl}: ${err.message}`));
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------
|
|
465
|
+
// createProgressBar: Generate ASCII progress bar
|
|
466
|
+
// ---------------------------------------------------------------------
|
|
467
|
+
function createProgressBar(percent, width = 20) {
|
|
468
|
+
const filled = Math.round((percent / 100) * width);
|
|
469
|
+
const empty = width - filled;
|
|
470
|
+
return "[" + "=".repeat(filled) + " ".repeat(empty) + "]";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------
|
|
474
|
+
// extractCrxToFolder
|
|
475
|
+
// 1) Checks "Cr24" magic
|
|
476
|
+
// 2) Reads version (2 or 3/4) to find the ZIP start
|
|
477
|
+
// 3) Uses parseZipCentralDirectory() to extract files properly
|
|
478
|
+
// ---------------------------------------------------------------------
|
|
479
|
+
function extractCrxToFolder(crxBuffer, outFolder) {
|
|
480
|
+
if (crxBuffer.toString("utf8", 0, 4) !== "Cr24") {
|
|
481
|
+
throw new Error("Not a valid CRX file (missing Cr24 magic).");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const version = crxBuffer.readUInt32LE(4);
|
|
485
|
+
let zipStartOffset = 0;
|
|
486
|
+
|
|
487
|
+
if (version === 2) {
|
|
488
|
+
const pkLen = crxBuffer.readUInt32LE(8);
|
|
489
|
+
const sigLen = crxBuffer.readUInt32LE(12);
|
|
490
|
+
zipStartOffset = 16 + pkLen + sigLen;
|
|
491
|
+
} else if (version === 3 || version === 4) {
|
|
492
|
+
const headerSize = crxBuffer.readUInt32LE(8);
|
|
493
|
+
zipStartOffset = 12 + headerSize;
|
|
494
|
+
} else {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`Unsupported CRX version (${version}). Only v2, v3, or v4 are supported.`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (zipStartOffset >= crxBuffer.length) {
|
|
501
|
+
throw new Error("Malformed CRX: header size exceeds file length.");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const zipBuffer = crxBuffer.slice(zipStartOffset);
|
|
505
|
+
|
|
506
|
+
// Parse that ZIP via the central directory approach
|
|
507
|
+
parseZipCentralDirectory(zipBuffer, outFolder);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------
|
|
511
|
+
// parseZipCentralDirectory(buffer, outFolder)
|
|
512
|
+
// 1) Finds End of Central Directory (EOCD) record
|
|
513
|
+
// 2) Reads central directory for file metadata
|
|
514
|
+
// 3) For each file, decompress into outFolder
|
|
515
|
+
// ---------------------------------------------------------------------
|
|
516
|
+
function parseZipCentralDirectory(zipBuffer, outFolder) {
|
|
517
|
+
// Find EOCD by scanning backwards from end of file
|
|
518
|
+
let eocdPos = -1;
|
|
519
|
+
const minPos = Math.max(0, zipBuffer.length - MAX_EOCD_SEARCH);
|
|
520
|
+
for (let i = zipBuffer.length - 4; i >= minPos; i--) {
|
|
521
|
+
if (zipBuffer.readUInt32LE(i) === ZIP_SIGNATURES.EOCD) {
|
|
522
|
+
eocdPos = i;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (eocdPos < 0) {
|
|
527
|
+
throw new Error("Could not find End of Central Directory (EOCD) in ZIP.");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const totalCD = zipBuffer.readUInt16LE(eocdPos + 10);
|
|
531
|
+
const cdSize = zipBuffer.readUInt32LE(eocdPos + 12);
|
|
532
|
+
const cdOffset = zipBuffer.readUInt32LE(eocdPos + 16);
|
|
533
|
+
|
|
534
|
+
// ZIP64 check: marker values indicate ZIP64 format is required
|
|
535
|
+
if (cdOffset === ZIP64_MARKER || cdSize === ZIP64_MARKER) {
|
|
536
|
+
throw new Error("ZIP64 format is not supported.");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (cdOffset + cdSize > zipBuffer.length) {
|
|
540
|
+
throw new Error("Central directory offset/size out of range.");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let ptr = cdOffset;
|
|
544
|
+
const files = [];
|
|
545
|
+
for (let i = 0; i < totalCD; i++) {
|
|
546
|
+
const sig = zipBuffer.readUInt32LE(ptr);
|
|
547
|
+
if (sig !== ZIP_SIGNATURES.CENTRAL_DIR) {
|
|
548
|
+
throw new Error(`Central directory signature mismatch at ${ptr}`);
|
|
549
|
+
}
|
|
550
|
+
ptr += 4;
|
|
551
|
+
|
|
552
|
+
ptr += 2; // version made by (unused)
|
|
553
|
+
const verNeed = zipBuffer.readUInt16LE(ptr);
|
|
554
|
+
ptr += 2;
|
|
555
|
+
const flags = zipBuffer.readUInt16LE(ptr);
|
|
556
|
+
ptr += 2;
|
|
557
|
+
const method = zipBuffer.readUInt16LE(ptr);
|
|
558
|
+
ptr += 2;
|
|
559
|
+
ptr += 2; // mod time (unused)
|
|
560
|
+
ptr += 2; // mod date (unused)
|
|
561
|
+
const crc32 = zipBuffer.readUInt32LE(ptr);
|
|
562
|
+
ptr += 4;
|
|
563
|
+
const compSize = zipBuffer.readUInt32LE(ptr);
|
|
564
|
+
ptr += 4;
|
|
565
|
+
const unCompSize = zipBuffer.readUInt32LE(ptr);
|
|
566
|
+
ptr += 4;
|
|
567
|
+
const fLen = zipBuffer.readUInt16LE(ptr);
|
|
568
|
+
ptr += 2;
|
|
569
|
+
const xLen = zipBuffer.readUInt16LE(ptr);
|
|
570
|
+
ptr += 2;
|
|
571
|
+
const cLen = zipBuffer.readUInt16LE(ptr);
|
|
572
|
+
ptr += 2;
|
|
573
|
+
ptr += 2; // disk number (unused)
|
|
574
|
+
ptr += 2; // internal attributes (unused)
|
|
575
|
+
ptr += 4; // external attributes (unused)
|
|
576
|
+
const localHeaderOffset = zipBuffer.readUInt32LE(ptr);
|
|
577
|
+
ptr += 4;
|
|
578
|
+
|
|
579
|
+
const filename = zipBuffer.toString("utf8", ptr, ptr + fLen);
|
|
580
|
+
ptr += fLen + xLen + cLen; // skip the extra + comment
|
|
581
|
+
|
|
582
|
+
// Validate: encrypted files not supported
|
|
583
|
+
if (flags & ZIP_FLAGS.ENCRYPTED) {
|
|
584
|
+
throw new Error(`Encrypted files are not supported: ${filename}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Validate: ZIP64 extended sizes not supported
|
|
588
|
+
if (
|
|
589
|
+
compSize === ZIP64_MARKER ||
|
|
590
|
+
unCompSize === ZIP64_MARKER ||
|
|
591
|
+
localHeaderOffset === ZIP64_MARKER
|
|
592
|
+
) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`ZIP64 extended information not supported for file: ${filename}`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
files.push({
|
|
599
|
+
filename,
|
|
600
|
+
method,
|
|
601
|
+
compSize,
|
|
602
|
+
unCompSize,
|
|
603
|
+
flags,
|
|
604
|
+
localHeaderOffset,
|
|
605
|
+
crc32,
|
|
606
|
+
verNeed,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const resolvedOutFolder = path.resolve(outFolder);
|
|
611
|
+
fs.mkdirSync(resolvedOutFolder, { recursive: true });
|
|
612
|
+
|
|
613
|
+
for (const file of files) {
|
|
614
|
+
const { filename, method, compSize, localHeaderOffset } = file;
|
|
615
|
+
|
|
616
|
+
// Security: validate path to prevent directory traversal attacks
|
|
617
|
+
const outPath = path.join(resolvedOutFolder, filename);
|
|
618
|
+
if (
|
|
619
|
+
!outPath.startsWith(resolvedOutFolder + path.sep) &&
|
|
620
|
+
outPath !== resolvedOutFolder
|
|
621
|
+
) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`Path traversal detected, refusing to extract: ${filename}`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (filename.endsWith("/")) {
|
|
628
|
+
fs.mkdirSync(outPath, { recursive: true });
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
let lhPtr = localHeaderOffset;
|
|
633
|
+
const localSig = zipBuffer.readUInt32LE(lhPtr);
|
|
634
|
+
if (localSig !== ZIP_SIGNATURES.LOCAL_FILE) {
|
|
635
|
+
throw new Error(`Local file header mismatch at ${lhPtr} for ${filename}`);
|
|
636
|
+
}
|
|
637
|
+
lhPtr += 4;
|
|
638
|
+
|
|
639
|
+
lhPtr += 2; // version needed
|
|
640
|
+
lhPtr += 2; // flags
|
|
641
|
+
lhPtr += 2; // method
|
|
642
|
+
lhPtr += 2; // mod time
|
|
643
|
+
lhPtr += 2; // mod date
|
|
644
|
+
lhPtr += 4; // crc32
|
|
645
|
+
lhPtr += 4; // comp size
|
|
646
|
+
lhPtr += 4; // uncomp size
|
|
647
|
+
const lhFNameLen = zipBuffer.readUInt16LE(lhPtr);
|
|
648
|
+
lhPtr += 2;
|
|
649
|
+
const lhXLen = zipBuffer.readUInt16LE(lhPtr);
|
|
650
|
+
lhPtr += 2;
|
|
651
|
+
|
|
652
|
+
lhPtr += lhFNameLen + lhXLen;
|
|
653
|
+
const fileData = zipBuffer.slice(lhPtr, lhPtr + compSize);
|
|
654
|
+
|
|
655
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
656
|
+
|
|
657
|
+
if (method === ZIP_METHODS.STORE) {
|
|
658
|
+
fs.writeFileSync(outPath, fileData);
|
|
659
|
+
} else if (method === ZIP_METHODS.DEFLATE) {
|
|
660
|
+
const unzipped = zlib.inflateRawSync(fileData);
|
|
661
|
+
fs.writeFileSync(outPath, unzipped);
|
|
662
|
+
} else {
|
|
663
|
+
throw new Error(
|
|
664
|
+
`Unsupported compression method (${method}) for file ${filename}`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|