-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpopup.js
More file actions
6788 lines (6462 loc) · 318 KB
/
Copy pathpopup.js
File metadata and controls
6788 lines (6462 loc) · 318 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* WardenOne — Copyright (C) 2026 iri
Licensed under the GNU General Public License v3 or later. See LICENSE.
Official source: https://github.com/iri-dev/WardenOne
Upstream filter-list attribution: CREDITS.md
Redistributing a modified copy? GPLv3 section 5(a) requires you to mark it as changed,
with the date, and to keep these notices intact. */
/* WardenOne popup logic */
// Label switches before other startup work so a later error cannot leave them unnamed.
try { labelToggleControls(); } catch (_) {}
/* An action popup sizes itself from its content, so its body needs an intrinsic width.
An extension page opened in a browser tab can instead reflow with the viewport. */
try {
chrome.tabs.getCurrent((tab) => {
if (tab) document.documentElement.classList.add('wo-popup-tab');
});
} catch (_) {}
// Re-query cached ids when a dynamic section replaces its node.
const __getById = document.getElementById.bind(document);
const __elCache = new Map();
function $(id) {
const hit = __elCache.get(id);
if (hit && hit.isConnected) return hit;
const el = __getById(id);
if (el) __elCache.set(id, el); else __elCache.delete(id);
return el;
}
function syncConfigCheckboxes(key, checked) {
document.querySelectorAll(`input[data-key="${key}"]`).forEach((el) => {
el.checked = checked;
});
}
const KEYS = [
'blockForcedPopups', 'strictPopupShield', 'blockGesturelessNav', 'blockPopupTricks', 'backTrapGuard', 'clearCookiesOnLeave', 'clearServiceWorkersOnLeave', 'detectRedirectChains', 'blockMetaRefresh',
'blockGrabberResources', 'warnGrabberDomains', 'blockWebRTCLeak', 'blockSuspiciousWebRTC', 'certificateGuard', 'blockTrackers', 'adShield', 'googleSearchResultCleanup', 'blockSearchAiAnswers', 'blockSponsoredSearchResults', 'googleWebResultsOnly', 'flagSearchJunk', 'warnSearchResults', 'scriptletEngine', 'twitchAdBlock', 'twitchSteadyPlayback', 'twitchRewind', 'twitchVodRewind', 'sendPrivacySignals', 'antiFingerprintNoise', 'fingerprintProbeDetection', 'blockFingerprintScripts', 'blockFraudVendorScripts', 'blockThirdPartyCookies', 'blockAllCookies', 'blockFirstPartyTrackers', 'sessionShield', 'blockTokenExfil', 'continuousTokenScan', 'detectSkimmers', 'paymentCardGuard', 'breachCheck', 'forceHttps', 'insecureLoginGuard', 'loginAgeCheck', 'downloadReputation', 'downloadDomainAge', 'downloadSafeBrowsing', 'downloadVirusTotal', 'downloadVirusTotalHash', 'urlHaus', 'abuseIpDb', 'openPhish', 'phishTank', 'whoisXml', 'whoisXmlReputation', 'whoisXmlThreatIntel', 'clipboardGuard', 'clipboardSwapDetect', 'keystrokePressure', 'honeytokenMode', 'scamLockGuard', 'commandPasteGuard', 'pasteProtection', 'formTrapDetector', 'fakeUpdateDetector', 'permissionChainGuard', 'oauthGuard', 'scriptDriftGuard', 'riskySiteMode', 'antiClickjacking', 'intranetProtection', 'intranetNetworkRules', 'dnsRebindGuard', 'storageAccessGuard', 'blockAllStorageAccess', 'loginCompatibility', 'watchExtensionPermissions', 'startupCheck',
'mediaShield', 'fullscreenGuard', 'fakeWindowGuard', 'notificationAbuseGuard', 'blockCameraMic', 'blockScreenCapture', 'blockGeolocation', 'blockAutoplayMedia',
'gateAdultSites', 'adultHeuristics', 'safeSearch',
'warnRedirectParams', 'warnShorteners', 'monitorLoggerApi', 'detectPhishing', 'blockHighConfidencePhishing', 'behavioralScan', 'xssBehaviorGuard', 'removeOverlays', 'autoSkipDownloadAds', 'blockMalwareSites', 'blockCryptominers', 'cryptominerCpuWatch', 'autoUpdateLists',
'showToasts', 'showBadge', 'silentMode', 'elementZapper',
'memoryShield', 'memoryNeverPinned', 'memoryNeverAudio', 'memoryNeverForms', 'memoryNeverPayment',
'blockAutoplay', 'throttleBackgroundTabs', 'killPrefetch', 'lazyLoadMedia',
'deAmp', 'clientHintProtection', 'capReferrer', 'trackerCacheProtection', 'autoRejectConsent', 'removeConsentWalls', 'mailTrackingShield',
'trackerLearner', 'unshimLinks', 'stripTrackingParams', 'cleanCopyLinks', 'socialWidgetGuard', 'blockSupercookies'
];
const DEFAULTS = {
enabled: true,
blockGesturelessNav: true, blockForcedPopups: true, strictPopupShield: true, blockPopupTricks: true, backTrapGuard: true, clearCookiesOnLeave: false, clearServiceWorkersOnLeave: false, blockMetaRefresh: true,
detectRedirectChains: true, warnGrabberDomains: true, blockGrabberResources: true,
blockWebRTCLeak: true, certificateGuard: true, blockTrackers: true, adShield: true, googleSearchResultCleanup: false, blockSearchAiAnswers: false, blockSponsoredSearchResults: false, googleWebResultsOnly: false, flagSearchJunk: false, warnSearchResults: true, scriptletEngine: true, twitchAdBlock: true, twitchSteadyPlayback: true, twitchRewind: false, twitchRewindMinutes: 5, twitchVodRewind: true, sendPrivacySignals: true, antiFingerprintNoise: false, fingerprintProbeDetection: true, blockFingerprintScripts: true, blockFraudVendorScripts: false, antiFingerprint: false, blockThirdPartyCookies: true, blockAllCookies: false, blockFirstPartyTrackers: false, sessionShield: true, blockTokenExfil: true, continuousTokenScan: true, detectSkimmers: true, paymentCardGuard: true, breachCheck: false, forceHttps: false, insecureLoginGuard: true, loginAgeCheck: false, loginAgeMaxDays: 14, downloadReputation: true, downloadDomainAge: false, downloadSafeBrowsing: false, downloadSafeBrowsingKey: '', downloadVirusTotal: false, downloadVirusTotalHash: false, downloadVirusTotalKey: '', urlHaus: false, urlHausKey: '', abuseIpDb: false, abuseIpDbKey: '', openPhish: false, phishTank: false, phishTankKey: '', whoisXml: false, whoisXmlKey: '', whoisXmlReputation: false, whoisXmlThreatIntel: false, clipboardGuard: false, clipboardSwapDetect: true, keystrokePressure: false, honeytokenMode: false, scamLockGuard: true, commandPasteGuard: true, pasteProtection: true, formTrapDetector: true, fakeUpdateDetector: true, permissionChainGuard: true, oauthGuard: true, scriptDriftGuard: true, riskySiteMode: true, antiClickjacking: true, intranetProtection: true, intranetNetworkRules: true, dnsRebindGuard: true, storageAccessGuard: true, blockAllStorageAccess: false, loginCompatibility: true, watchExtensionPermissions: true, startupCheck: true, gateAdultSites: true, adultHeuristics: true, safeSearch: false,
mediaShield: true, fullscreenGuard: true, fakeWindowGuard: true, notificationAbuseGuard: true, blockCameraMic: true, blockScreenCapture: true, blockGeolocation: true, blockAutoplayMedia: true, blockSuspiciousWebRTC: false,
eyeShield: false, eyeShieldMode: 'off', eyeShieldBrightness: 100, eyeShieldBrightnessByHost: {},
eyeShieldContrast: 100, eyeShieldContrastByHost: {}, eyeShieldSaturation: 100, eyeShieldSaturationByHost: {},
eyeShieldWarmth: 0, eyeShieldWarmthByHost: {}, eyeShieldGrayscale: 0, eyeShieldGrayscaleByHost: {},
eyeShieldSites: {},
warnRedirectParams: true, warnShorteners: true, monitorLoggerApi: true,
detectPhishing: true, blockHighConfidencePhishing: false, behavioralScan: true, xssBehaviorGuard: true, removeOverlays: true, autoSkipDownloadAds: true, blockMalwareSites: true, blockCryptominers: true, cryptominerCpuWatch: false, autoUpdateLists: true,
showToasts: true, showBadge: true, showDownloadBar: true, silentMode: false, elementZapper: true,
notificationSettings: typeof wardenNotificationDefaultSettings === 'function' ? wardenNotificationDefaultSettings() : { version: 4, defaultDuration: 'reading', position: 'top-right', retentionDays: 30, groupSimilar: true, badgeEnabled: false, soundEnabled: false, soundMode: 'important', volume: 0.55, rules: {} },
memoryShield: true, memoryMode: 'balanced', memoryMinutesOverride: 0,
memoryNeverPinned: true, memoryNeverAudio: true, memoryNeverForms: true, memoryNeverPayment: true,
tabLimitGuard: false, tabLimitMax: 20, tabLimitClose: false, tabLimitMinIdleMinutes: 30, tabLimitWarn: true,
blockAutoplay: false, throttleBackgroundTabs: false, killPrefetch: false, lazyLoadMedia: false, deAmp: false, clientHintProtection: true, capReferrer: false, trackerCacheProtection: false, autoRejectConsent: true, removeConsentWalls: false, mailTrackingShield: true,
trackerLearner: true, unshimLinks: true, cleanCopyLinks: true, socialWidgetGuard: true, blockSupercookies: true,
forgetMeMode: 'off', forgetMeList: [], forgetMeHistory: false, forgetMeAllConfirmedAt: 0,
oneOpenPerGesture: true, stripTrackingParams: true, gestureWindowMs: 2400, allowlist: [],
// host -> epoch ms at which its allowlisting lapses. Lets "allow this site" be
// temporary without becoming a permanent hole someone forgets about.
allowlistUntil: {},
// host -> { featureKey: false }. Turns one protection off on one site instead of
// everywhere, or the whole engine off there.
siteOverrides: {},
};
// Include worker-only settings so exported backups round-trip through the popup importer.
const IMPORT_ONLY_DEFAULTS = {
logThirdPartyBeacons: true,
downloadHardBlockCritical: true,
downloadHashCheck: true,
deviceAccessGuard: true,
capabilityGuard: true,
memoryNeverSleepHosts: [],
};
const IMPORT_SCHEMA = Object.assign({}, IMPORT_ONLY_DEFAULTS, DEFAULTS);
/* Only page-side protections can be turned off per site. "mixed" leaves the network or worker
half running; "coordinated" also stops the worker backstop. Unlisted keys are rejected. */
const SITE_OVERRIDE_SCOPE = {
page: [
'blockGesturelessNav', 'backTrapGuard', 'blockMetaRefresh',
'blockSuspiciousWebRTC', 'twitchAdBlock', 'twitchSteadyPlayback', 'twitchRewind', 'twitchVodRewind', 'blockFirstPartyTrackers',
'sessionShield', 'blockTokenExfil', 'continuousTokenScan', 'breachCheck', 'insecureLoginGuard',
'clipboardGuard', 'clipboardSwapDetect', 'keystrokePressure', 'honeytokenMode', 'scamLockGuard',
'commandPasteGuard', 'pasteProtection', 'formTrapDetector', 'fakeUpdateDetector', 'riskySiteMode',
'antiClickjacking', 'storageAccessGuard', 'blockAllStorageAccess', 'mediaShield', 'fullscreenGuard',
'fakeWindowGuard', 'notificationAbuseGuard', 'blockCameraMic', 'blockScreenCapture', 'blockAutoplayMedia',
'gateAdultSites', 'adultHeuristics', 'warnRedirectParams', 'warnShorteners', 'monitorLoggerApi',
'detectPhishing', 'blockHighConfidencePhishing', 'behavioralScan', 'xssBehaviorGuard', 'removeOverlays',
'autoSkipDownloadAds', 'showToasts', 'showBadge', 'blockAutoplay', 'throttleBackgroundTabs', 'killPrefetch',
'lazyLoadMedia', 'deAmp', 'cleanCopyLinks', 'socialWidgetGuard', 'blockSupercookies',
],
mixed: [
'blockPopupTricks', 'clearCookiesOnLeave', 'detectRedirectChains', 'blockGrabberResources', 'warnGrabberDomains',
'blockWebRTCLeak', 'blockTrackers', 'adShield', 'googleSearchResultCleanup', 'blockSearchAiAnswers',
'blockSponsoredSearchResults', 'flagSearchJunk', 'warnSearchResults', 'scriptletEngine', 'sendPrivacySignals',
'antiFingerprintNoise', 'fingerprintProbeDetection', 'blockFingerprintScripts', 'blockThirdPartyCookies',
'blockAllCookies', 'detectSkimmers', 'paymentCardGuard', 'forceHttps', 'loginAgeCheck', 'downloadReputation',
'downloadSafeBrowsing', 'urlHaus', 'abuseIpDb', 'openPhish', 'phishTank', 'whoisXml', 'whoisXmlReputation',
'whoisXmlThreatIntel', 'permissionChainGuard', 'oauthGuard', 'scriptDriftGuard', 'intranetProtection',
'blockGeolocation', 'silentMode', 'capReferrer', 'autoRejectConsent', 'removeConsentWalls', 'mailTrackingShield',
'trackerLearner', 'unshimLinks', 'stripTrackingParams',
],
coordinated: ['blockForcedPopups', 'strictPopupShield'],
};
const SITE_OVERRIDE_KEYS = new Set([].concat(SITE_OVERRIDE_SCOPE.page, SITE_OVERRIDE_SCOPE.mixed, SITE_OVERRIDE_SCOPE.coordinated));
const SITE_OVERRIDE_MIXED = new Set(SITE_OVERRIDE_SCOPE.mixed);
// Keep costly or compatibility-sensitive switches out of "Turn everything on".
const MANUAL_ONLY_TOGGLES = new Set(['blockAllCookies', 'silentMode', 'cryptominerCpuWatch', 'trackerCacheProtection', 'blockAllStorageAccess', 'blockSuspiciousWebRTC']);
const ACTIVE_TAB_RELOAD_TOGGLES = new Set(['adShield', 'scriptletEngine', 'antiFingerprintNoise', 'fingerprintProbeDetection', 'blockFingerprintScripts', 'blockFraudVendorScripts', 'xssBehaviorGuard', 'commandPasteGuard', 'riskySiteMode', 'antiClickjacking', 'intranetProtection', 'googleSearchResultCleanup', 'blockSearchAiAnswers', 'blockSponsoredSearchResults', 'googleWebResultsOnly', 'flagSearchJunk', 'warnSearchResults', 'paymentCardGuard', 'blockGeolocation']);
const REPUTATION_PROVIDERS = [
{ key: 'urlHaus', keyField: 'urlHausKey', statusId: 'urlhaus-key-status', label: 'URLhaus', use: 'malware URL and download intelligence', emptyText: 'Paste a URLhaus Auth-Key to enable malware URL/download checks.', activeText: 'URLhaus malware URL checks are on. Known malware delivery URLs will be blocked.' },
{ key: 'abuseIpDb', keyField: 'abuseIpDbKey', statusId: 'abuseipdb-key-status', label: 'AbuseIPDB', use: 'malicious IP reports and suspicious server warnings', emptyText: 'Paste an AbuseIPDB API key to enable raw-IP server reputation.', activeText: 'AbuseIPDB raw-IP reputation is on. 75%+ abuse confidence blocks; 25-74 warns.' },
// OpenPhish is keyless: the community feed is fetched whole, so there is no field and no
// stored token. There used to be an "Optional OpenPhish token" here that nothing read (BUG-09).
{ key: 'openPhish', noKey: true, statusId: 'openphish-key-status', label: 'OpenPhish', use: 'phishing intelligence feed and fake login detection', emptyText: 'No key required for the OpenPhish Community feed. Test the feed to enable it.', activeText: 'OpenPhish Community feed is on. Known phishing URLs will be blocked from the cached feed.' },
{ key: 'phishTank', keyField: 'phishTankKey', statusId: 'phishtank-key-status', label: 'PhishTank', use: 'community phishing database checks', emptyText: 'Paste a PhishTank API key to enable phishing URL reputation.', activeText: 'PhishTank URL reputation is on. Verified current phishing URLs will be blocked.' },
{ key: 'whoisXml', keyField: 'whoisXmlKey', statusId: 'whoisxml-key-status', label: 'WhoisXML API', use: 'domain registration age and ownership clues', emptyText: 'Paste a WhoisXML API key to enable domain age and ownership clues.', activeText: 'WhoisXML API is on. Download Guard will use richer domain age, registrar, and ownership clues.' },
{ key: 'whoisXmlReputation', keyField: 'whoisXmlKey', statusId: 'whoisxml-reputation-status', label: 'WhoisXML Domain Reputation', use: 'domain reputation scoring and blocklist warnings', autoEnableOnKeyChange: false, emptyText: 'Uses the WhoisXML API key above. Test it to enable domain reputation scoring.', activeText: 'WhoisXML Domain Reputation is on. Low reputation and malware/phishing warnings raise risk.' },
{ key: 'whoisXmlThreatIntel', keyField: 'whoisXmlKey', statusId: 'whoisxml-threat-status', label: 'WhoisXML Threat Intelligence', use: 'IoC matches for malicious domains, URLs, and IPs', autoEnableOnKeyChange: false, emptyText: 'Uses the WhoisXML API key above. Test it to enable IoC threat intelligence.', activeText: 'WhoisXML Threat Intelligence is on. Malware/phishing IoC matches will be blocked.' },
];
let config = Object.assign({}, DEFAULTS);
// Diff against this snapshot so popup saves do not overwrite another surface's changes.
let savedConfigSnapshot = configClone(DEFAULTS);
let eyeShieldHost = '';
let eyeShieldSaveTimer = 0;
let eyeShieldEditGeneration = 0;
let eyeShieldStatusTimer = 0;
function configClone(value) {
try {
return JSON.parse(JSON.stringify(value === undefined ? null : value));
} catch (_) {
return (value && typeof value === 'object' && !Array.isArray(value)) ? Object.assign({}, value) : value;
}
}
function configValuesDiffer(a, b) {
if (a === b) return false;
try { return JSON.stringify(a) !== JSON.stringify(b); } catch (_) { return true; }
}
// Prefer reporting an owned key as changed over losing a popup edit.
function popupChangedKeys() {
const keys = new Set(Object.keys(config));
Object.keys(savedConfigSnapshot).forEach((k) => keys.add(k));
const changed = [];
keys.forEach((k) => { if (configValuesDiffer(config[k], savedConfigSnapshot[k])) changed.push(k); });
return changed;
}
const POPUP_SEARCH_KEY = 'wardenone_popup_search_memory';
// Reassigned by the settings-search block once it's wired; restores the last query
// on popup open so reopening jumps straight back to what you were looking at.
let restorePopupSearch = (done) => { if (typeof done === 'function') done(); };
function applyToUI() {
const enabledEl = $('enabled');
enabledEl.checked = config.enabled !== false;
updateMasterState();
KEYS.forEach((k) => {
document.querySelectorAll(`input[data-key="${k}"]`).forEach((el) => {
el.checked = config[k] !== false;
});
});
/* The engine runs fingerprint noise when the switch OR its legacy alias is on, and the
Maximum privacy bundle used to set the alias too -- so after choosing it, this switch read
off-able but turning it off changed nothing. It shows the truth here, and saving folds the
alias into it (readFromUI). */
if (config.antiFingerprint === true) {
document.querySelectorAll('input[data-key="antiFingerprintNoise"]').forEach((el) => { el.checked = true; });
}
document.querySelectorAll('[data-config-text]').forEach((el) => {
const key = el.getAttribute('data-config-text');
el.value = config[key] || '';
});
renderMutedToasts(config);
if ($('ss-pick')) $('ss-pick').disabled = config.elementZapper === false;
try { renderHiddenList(); } catch (_) {}
syncBreachVisibility();
syncProviderStatus();
reflectMasterDisable();
reflectSilentMode();
paintEyeShield();
loadJsShieldState();
paintTabLimitUI();
paintForgetMe();
paintMemoryModes();
paintTwitchRewindUI();
}
/* STORE-OMIT-TWITCH-PAINT-BEGIN */
// Reflect the Twitch rewind buffer length into its number input. Not a data-key
// control, so "Turn everything on" never changes the buffer size.
function paintTwitchRewindUI() {
const mins = $('tr-minutes');
if (mins) mins.value = Number(config.twitchRewindMinutes) > 0 ? Number(config.twitchRewindMinutes) : 5;
}
/* STORE-OMIT-TWITCH-PAINT-END */
// Reflect the saved Memory Shield mode (gentle/balanced/aggressive/emergency) into the
// mode buttons. Hoisted so applyToUI() can repaint it after the config loads -- the
// painter used to be local to initMemoryShield, so on reopen the highlight reverted to
// the default and the picked mode looked like it hadn't saved (it had).
function paintMemoryModes() {
const wrap = $('mem-modes');
if (!wrap) return;
const cur = config.memoryMode || 'balanced';
document.querySelectorAll('.mem-mode').forEach((b) => {
const on = b.getAttribute('data-mode') === cur;
b.style.background = on ? 'var(--wo-popup-mode-gradient)' : '';
b.style.color = on ? 'var(--wo-on-brand)' : '';
b.style.border = on ? 'none' : '';
});
}
// registrableDomain() / regDomain() come from domain-utils.js, loaded before this script
// in popup.html. That is the SAME implementation the background service worker uses, so
// the chosen-sites list stores the exact eTLD+1 (mail.google.com -> google.com) that
// gets wiped -- no risk of the two drifting.
function paintForgetMe() {
const toggle = $('forget-enable');
if (!toggle) return;
// The old "Chosen sites" mode was retired in favour of one global toggle.
// Normalise any leftover 'list' config to off so the UI and behaviour agree.
if (config.forgetMeMode === 'list') {
config.forgetMeMode = 'off';
config.forgetMeList = [];
save();
return;
}
// "Never let sites remember me" == wipe-on-leave for all sites (allowlist exempt).
toggle.checked = config.forgetMeMode === 'all' && Number(config.forgetMeAllConfirmedAt || 0) > 0;
const hist = $('forget-history');
if (hist) hist.checked = config.forgetMeHistory === true;
}
// Reflect saved Tab Limit config into its (non-data-key) controls. Kept out
// of the KEYS auto-bind so "Turn everything on" never enables auto-closing tabs.
function paintTabLimitUI() {
const guard = $('tl-guard');
if (!guard) return;
guard.checked = config.tabLimitGuard === true;
const max = $('tl-max');
if (max) max.value = Number(config.tabLimitMax) > 0 ? Number(config.tabLimitMax) : 20;
const idle = $('tl-idle');
if (idle) idle.value = Number(config.tabLimitMinIdleMinutes) >= 0 ? Number(config.tabLimitMinIdleMinutes) : 30;
const close = $('tl-close');
if (close) close.checked = config.tabLimitClose === true;
const warn = $('tl-warn');
if (warn) warn.checked = config.tabLimitWarn !== false;
}
// The site-breach lookup only works when its toggle is on (it contacts an
// external service, so it's opt-in). Disable the button + note when off.
/* Everything the reader has silenced, and the way back.
*
* A "never show this again" with no way to find it afterwards is a trap, so this
* is the other half of the button on the notification card. The row hides itself
* when nothing is muted rather than sitting there empty -- an always-present
* panel reading "nothing here" is furniture.
*
* Expired entries are swept on render as well as on write, so what is listed is
* what is actually in force. A card that says "silenced until 14:05" when 14:05
* was an hour ago is worse than no card. */
/* The words on the card, not the name of the code that drew it.
This panel exists to be recognised: someone silenced a notice a minute ago
and wants the same notice back. De-prefixing the internal key gets close
enough to read as correct -- and then hands them "Abuseipdb server" for
what the card called a suspicious server, or "Honeytoken read" for
suspicious script behaviour. Recognisable only if you wrote the code.
Kept in step with the engine's TOAST_INFO by the toast-mutes gate, which
fails if a type gains a title here that the card does not use, or gains a
card without a title here. The fallback stays for a type mid-rename. */
const TOAST_TITLES = {
blocked_popup: 'Popup blocked',
blocked_gestureless_nav: 'Forced redirect blocked',
blocked_meta_refresh: 'Auto-redirect blocked',
blocked_redirect_chain: 'Redirect chain stopped',
detected_grabber_domain: 'IP-logger detected',
blocked_grabber_fetch: 'IP-grabber blocked',
blocked_grabber_xhr: 'IP-grabber blocked',
blocked_grabber_beacon: 'IP-grabber blocked',
blocked_grabber_pixel: 'Tracking pixel blocked',
blocked_ip_lookup: 'IP lookup blocked',
blocked_grabber_element: 'Grabber element removed',
blocked_safe_browsing_link: 'Dangerous link blocked',
blocked_safe_browsing_form: 'Form submission blocked',
blocked_safe_browsing_paste: 'Secret paste blocked',
blocked_token_exfil: 'Sensitive request protected',
blocked_skimmer_exfil: 'Card/password theft blocked',
blocked_payment_card_submit: 'Card submission blocked',
blocked_confirm_bait: 'Fake confirm box removed',
detected_beacon: 'Data sent in the background',
warned_confirm_bait: 'Fake confirm box',
warned_back_trap: 'Back button trapped',
warned_payment_sheet: 'Payment sheet opened',
warned_idle_watch: 'Presence tracking started',
warned_app_install_prompt: 'Asked to install itself',
warned_notification_bait: 'Notification bait',
warned_notification_scam: 'Scam-shaped notification',
warned_device_request: 'Hardware access requested',
warned_device_silent: 'Hardware read without a prompt',
warned_service_worker: 'Installed a service worker',
blocked_speech_capture: 'Speech recognition blocked',
warned_speech_capture: 'Speech recognition started',
warned_file_request: 'File or folder access requested',
warned_file_silent: 'File access from an earlier visit',
warned_fake_window: 'Fake sign-in window',
warned_fullscreen_spoof: 'Fake address bar',
blocked_media_capture: 'Camera or mic blocked',
blocked_screen_capture: 'Screen capture blocked',
blocked_geolocation: 'Location blocked',
blocked_autoplay_media: 'Autoplay media blocked',
blocked_hidden_media: 'Hidden media blocked',
blocked_suspicious_webrtc: 'Suspicious WebRTC blocked',
warned_media_capture: 'Camera or mic requested',
warned_hidden_media_capture: 'Hidden media request',
warned_screen_capture: 'Screen capture requested',
warned_hidden_screen_capture: 'Unexpected screen-share request',
warned_shortener: 'Shortened link',
warned_redirect_param: 'Redirecting link',
warned_logger_api: 'Possible tracker',
warned_abuseipdb_server: 'Suspicious server',
warned_url_reputation: 'Suspicious URL reputation',
warned_phishing: 'Possible fake site',
warned_payment_card_entry: 'Check this checkout',
warned_fake_update: 'Fake update scam',
warned_keystroke_pressure: 'Heavy text-input monitoring',
warned_honeytoken_read: 'Suspicious script behaviour detected',
detected_manual_check: 'WardenOne check',
behavioral_risk: 'Suspicious site behavior',
};
function humanToastType(type) {
const known = Object.prototype.hasOwnProperty.call(TOAST_TITLES, type) && TOAST_TITLES[type];
if (known) return known;
const label = String(type || '').replace(/^(blocked|warned|detected|gated|cleaned)_/, '').replace(/_/g, ' ');
return label ? label.charAt(0).toUpperCase() + label.slice(1) : type;
}
function muteRemaining(until) {
if (until === 0) return 'Always hidden';
const left = Number(until) - Date.now();
if (left <= 0) return '';
const mins = Math.round(left / 60000);
if (mins < 60) return 'Hidden for ' + mins + ' more minute' + (mins === 1 ? '' : 's');
const hours = Math.round(mins / 60);
return 'Hidden for ' + hours + ' more hour' + (hours === 1 ? '' : 's');
}
function renderMutedToasts(cfg) {
const row = document.getElementById('muted-toasts-row');
const list = document.getElementById('muted-toasts-list');
if (!row || !list) return;
const mutes = (cfg && cfg.toastMutes) || {};
const now = Date.now();
const live = Object.keys(mutes)
.filter((type) => mutes[type] === 0 || Number(mutes[type]) > now)
.sort();
list.textContent = '';
/* Always shown, empty or not. It used to hide itself when nothing was muted,
which is tidier and meant that someone looking for the place to undo a
"never show this again" found nothing -- indistinguishable from the feature
not existing. An empty state that says so is worth the row. */
if (!live.length) {
const empty = document.createElement('div');
empty.style.cssText = 'font-size:11.5px;color:var(--wo-text-soft);padding:2px 0;';
empty.textContent = 'Nothing silenced right now.';
list.appendChild(empty);
return;
}
live.forEach((type) => {
const item = document.createElement('div');
item.style.cssText = 'display:flex;align-items:center;gap:8px;padding:5px 0;border-top:1px solid var(--wo-line);';
const text = document.createElement('div');
text.style.cssText = 'flex:1;min-width:0;';
const name = document.createElement('div');
name.style.cssText = 'font-size:12px;font-weight:600;color:var(--wo-text);';
name.textContent = humanToastType(type);
const when = document.createElement('div');
when.style.cssText = 'font-size:10.5px;color:var(--wo-text-soft);';
when.textContent = muteRemaining(mutes[type]);
text.appendChild(name);
text.appendChild(when);
item.appendChild(text);
const undo = document.createElement('button');
undo.type = 'button';
undo.className = 'btn';
undo.style.cssText = 'flex:none;font-size:11px;padding:3px 9px;';
undo.textContent = 'Show again';
undo.addEventListener('click', () => {
undo.disabled = true;
/* Through persistConfig, which is the one place allowed to write
wardenone_config -- a second writer is how two panels start racing each
other and a setting silently reverts. popupChangedKeys diffs config
against the saved snapshot, so mutating it here is enough to have this
key written and nothing else touched. */
const updated = Object.assign({}, config.toastMutes || {});
delete updated[type];
config.toastMutes = updated;
persistConfig(() => renderMutedToasts(config), () => { undo.disabled = false; });
});
item.appendChild(undo);
list.appendChild(item);
});
}
function syncBreachVisibility() {
const btn = $('ss-sitebreach');
if (btn) {
const on = config.breachCheck === true;
btn.disabled = !on;
btn.style.opacity = on ? '1' : '0.5';
btn.title = on ? '' : 'Enable "Breach & site-history checks" above to use this';
}
}
function updateMasterState() {
const on = $('enabled').checked;
const s = $('master-state');
s.textContent = on ? 'Enabled' : 'Disabled';
s.className = 'state ' + (on ? 'on' : 'off');
}
function reflectMasterDisable() {
const on = $('enabled').checked;
document.querySelectorAll('.group .tg').forEach((tg) => {
tg.classList.toggle('disabled', !on);
});
['js-global-wrap', 'js-smart-wrap', 'js-site-wrap', 'js-privacy-wrap'].forEach((id) => {
const tg = $(id);
if (tg) tg.classList.toggle('disabled', !on);
});
const privacyLimits = $('js-privacy-limits');
if (privacyLimits) privacyLimits.disabled = !on;
const scriptTrust = $('script-trust-add-current');
if (scriptTrust) scriptTrust.disabled = !on;
const eyePanel = $('eyeshield-panel');
if (eyePanel) eyePanel.classList.toggle('is-disabled', !on);
const eyeScope = $('eyeshield-scope-button');
if (eyeScope) eyeScope.disabled = !on || !eyeShieldHost;
document.querySelectorAll('.eyeshield-mode').forEach((btn) => { btn.disabled = !on; });
document.querySelectorAll('.eyeshield-range').forEach((r) => { r.disabled = !on; });
}
// Silent is painted over the toast and badge switches, never into them. The two controls are
// greyed while it is on and keep showing the preference underneath, which is what the page
// gets back the moment Silent is off; the bridge gates the page's copy (gateSilentPresentation
// in bridge.js), so nothing here needs to write them false. It used to: the switches were unchecked as well
// as greyed, the generic change handler read them back through readFromUI on the very next
// save, and turning Silent off left both stored off.
function reflectSilentMode() {
const disabled = config.silentMode === true || !$('enabled').checked;
['showToasts', 'showBadge'].forEach((key) => {
const el = document.querySelector(`input[data-key="${key}"]`);
if (el) {
const tg = el.closest('.tg');
if (tg) tg.classList.toggle('disabled', disabled);
}
});
}
// The one-time repair for a profile the old popup already damaged. Every save it made while
// Silent was on wrote both switches false, so a profile arriving here with Silent on and both
// off is carrying Silent's writes, not a choice: the switches were disabled the whole time,
// so nobody could have made one. When such a profile turns Silent off, Normal is put back
// to what Normal means -- both on -- instead of a silence nobody asked for. It runs only on
// that transition and only for that exact signature; a profile with Silent off is never
// touched, and a reader who had turned off just one of the two keeps it off.
function repairSilentModeRewrite(wasSilent) {
if (!wasSilent || config.silentMode === true) return false;
if (config.showToasts !== false || config.showBadge !== false) return false;
config.showToasts = true;
config.showBadge = true;
return true;
}
function syncJsShieldUI(res) {
const g = $('js-global');
const smart = $('js-smart');
const s = $('js-site');
const siteName = $('js-site-name');
const siteDesc = $('js-site-desc');
const globalWrap = $('js-global-wrap');
const smartWrap = $('js-smart-wrap');
const siteWrap = $('js-site-wrap');
const masterOn = !($('enabled') && $('enabled').checked === false);
if (!s) return;
if (!res || !res.ok) {
if (g) {
g.checked = false;
g.disabled = true;
}
if (smart) {
smart.checked = false;
smart.disabled = true;
}
s.checked = false;
s.disabled = true;
if (siteWrap) siteWrap.classList.add('disabled');
if (globalWrap) globalWrap.classList.add('disabled');
if (smartWrap) smartWrap.classList.add('disabled');
const scriptTrust = $('script-trust-add-current');
if (scriptTrust) scriptTrust.disabled = true;
if (siteName) siteName.textContent = 'Block scripts on this site';
if (siteDesc) siteDesc.textContent = 'Open a normal web page to control this site.';
return;
}
const mode = res.mode === 'smart' || res.mode === 'lockdown' ? res.mode : 'normal';
const globalOn = mode === 'lockdown' || res.global === 'block';
const host = String(res.host || '').trim();
if (g) {
g.checked = globalOn;
g.disabled = !masterOn;
}
if (smart) {
smart.checked = mode === 'smart';
smart.disabled = !masterOn || globalOn;
}
s.disabled = !masterOn;
if (globalWrap) globalWrap.classList.toggle('disabled', !masterOn);
if (smartWrap) smartWrap.classList.toggle('disabled', !masterOn || globalOn);
if (siteWrap) siteWrap.classList.toggle('disabled', !masterOn);
const scriptTrust = $('script-trust-add-current');
if (scriptTrust) scriptTrust.disabled = !masterOn;
const shield = $('js-shield');
if (shield) shield.setAttribute('data-mode', mode);
// One feature, two modes: with all sites blocked, the site row becomes the
// allowlist; otherwise it is a per-site block.
if (globalOn) {
s.checked = res.site !== 'block';
if (siteName) siteName.textContent = 'Allow JavaScript on this site';
if (siteDesc) siteDesc.textContent = host ? ('Allow scripts on ' + host + ' while Lockdown blocks the rest.') : 'Allow scripts on the active site while Lockdown blocks the rest.';
} else if (mode === 'smart') {
s.checked = res.site === 'block';
if (siteName) siteName.textContent = 'Block all scripts on this site';
if (siteDesc) siteDesc.textContent = host ? ('Hard-block ' + host + '. Smart level still controls third-party script hosts elsewhere.') : 'Hard-block this site. Smart level still controls third-party script hosts elsewhere.';
} else {
s.checked = res.site === 'block';
if (siteName) siteName.textContent = 'Block scripts on this site';
if (siteDesc) siteDesc.textContent = host ? ('Only ' + host + ' is blocked; other sites keep normal JavaScript.') : 'Only this site is blocked; other sites keep normal JavaScript.';
}
}
function loadJsShieldState() {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const url = (tabs[0] && tabs[0].url) || '';
chrome.runtime.sendMessage({ kind: 'get-javascript-state', url }, (res) => { void chrome.runtime.lastError;
syncJsShieldUI(res);
if (res && res.ok && Array.isArray(res.trustedHosts)) renderScriptTrustList(res.trustedHosts);
else renderScriptTrustList();
});
});
}
function setScriptShieldMode(mode) {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const url = (tabs[0] && tabs[0].url) || '';
chrome.runtime.sendMessage({ kind: 'set-script-shield-mode', mode, url }, (res) => {
const err = chrome.runtime.lastError && chrome.runtime.lastError.message;
if (err || !res || !res.ok) {
setSavedTick((res && res.error) || err || 'Script Shield level failed', true);
loadJsShieldState();
return;
}
syncJsShieldUI(res);
renderScriptTrustList(res.trustedHosts);
setSavedTick('Script Shield level saved', false);
});
});
}
function setJsShield(scope, block) {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const url = (tabs[0] && tabs[0].url) || '';
chrome.runtime.sendMessage({ kind: 'set-javascript-state', scope, block, url }, (res) => {
const err = chrome.runtime.lastError && chrome.runtime.lastError.message;
if (err || !res || !res.ok) {
loadJsShieldState();
return;
}
syncJsShieldUI(res);
if (res && res.ok && Array.isArray(res.trustedHosts)) renderScriptTrustList(res.trustedHosts);
if (res.persisted === false) setSavedTick('Private-window JavaScript change lasts only for this session', false);
});
});
}
function readFromUI() {
const previousProviderKeys = {
downloadSafeBrowsingKey: config.downloadSafeBrowsingKey || '',
downloadVirusTotalKey: config.downloadVirusTotalKey || '',
urlHausKey: config.urlHausKey || '',
abuseIpDbKey: config.abuseIpDbKey || '',
phishTankKey: config.phishTankKey || '',
whoisXmlKey: config.whoisXmlKey || '',
};
const wasSilent = config.silentMode === true;
config.enabled = $('enabled').checked;
KEYS.forEach((k) => {
const els = document.querySelectorAll(`input[data-key="${k}"]`);
if (els.length) config[k] = els[0].checked;
});
/* One switch, one meaning: the fingerprint-noise switch is the whole truth once saved. */
if (document.querySelector('input[data-key="antiFingerprintNoise"]')) config.antiFingerprint = false;
config.googleSearchResultCleanup = false;
config.showDownloadBar = true;
// Silent mode does not write showToasts/showBadge: the bridge gates what the page gets
// (gateSilentPresentation), and the switches here keep the preference for when Silent is off.
repairSilentModeRewrite(wasSilent);
document.querySelectorAll('[data-config-text]').forEach((el) => {
const key = el.getAttribute('data-config-text');
config[key] = el.value.trim();
});
normalizeProviderSettings(previousProviderKeys);
}
function normalizeProviderSettings(previousProviderKeys) {
void previousProviderKeys;
const vtKey = String(config.downloadVirusTotalKey || '').trim();
if (!vtKey) {
config.downloadVirusTotal = false;
config.downloadVirusTotalHash = false;
}
const sbKey = String(config.downloadSafeBrowsingKey || '').trim();
if (!sbKey) config.downloadSafeBrowsing = false;
REPUTATION_PROVIDERS.forEach((p) => {
// A saved key is for manual checks; only the separate switch consents to automatic lookups.
if (p.noKey || !p.keyField) return;
const nextKey = String(config[p.keyField] || '').trim();
if (!nextKey) config[p.key] = false;
});
}
function syncVirusTotalStatus(text, color) {
const vt = $('vt-key-status');
if (!vt) return;
if (text) {
vt.textContent = text;
vt.style.color = color || 'var(--ink-faint)';
return;
}
const hasKey = !!String(config.downloadVirusTotalKey || '').trim();
if (!hasKey) {
vt.textContent = 'Paste a VirusTotal API key to enable URL reputation. Hash lookup stays optional.';
vt.style.color = 'var(--ink-faint)';
return;
}
if (config.downloadVirusTotal !== true) {
vt.textContent = 'VirusTotal key is saved, but URL reputation is off.';
vt.style.color = 'var(--ink-faint)';
return;
}
vt.textContent = config.downloadVirusTotalHash
? 'VirusTotal URL reputation is on. URL-content hash lookup is also on.'
: 'VirusTotal URL reputation is on. URL-content hash lookup is optional below.';
vt.style.color = 'var(--plum)';
}
function syncSafeBrowsingStatus(text, color) {
const sb = $('sb-key-status');
if (!sb) return;
if (text) {
sb.textContent = text;
sb.style.color = color || 'var(--ink-faint)';
return;
}
const hasKey = !!String(config.downloadSafeBrowsingKey || '').trim();
if (!hasKey) {
sb.textContent = 'Paste a Google Safe Browsing API key to enable URL reputation.';
sb.style.color = 'var(--ink-faint)';
return;
}
if (config.downloadSafeBrowsing !== true) {
sb.textContent = 'Safe Browsing key is saved, but URL reputation is off.';
sb.style.color = 'var(--ink-faint)';
return;
}
sb.textContent = 'Google Safe Browsing URL reputation is on.';
sb.style.color = 'var(--plum)';
}
function syncReputationProviderStatus(provider, text, color) {
const meta = typeof provider === 'string' ? REPUTATION_PROVIDERS.find((p) => p.key === provider) : provider;
if (!meta) return;
const el = $(meta.statusId);
if (!el) return;
if (text) {
el.textContent = text;
el.style.color = color || 'var(--ink-faint)';
return;
}
if (meta.noKey || !meta.keyField) {
const on = config[meta.key] === true;
el.textContent = on ? (meta.activeText || (meta.label + ' is on.')) : (meta.emptyText || (meta.label + ' is off.'));
el.style.color = on ? 'var(--plum)' : 'var(--ink-faint)';
return;
}
const hasKey = !!String(config[meta.keyField] || '').trim();
if (!hasKey) {
el.textContent = meta.emptyText || ('Paste an API key to enable ' + meta.label + ' URL reputation.');
el.style.color = 'var(--ink-faint)';
return;
}
if (config[meta.key] !== true) {
el.textContent = meta.label + ' key is saved, but the provider is off.';
el.style.color = 'var(--ink-faint)';
return;
}
el.textContent = meta.activeText || (meta.label + ' URL reputation is on.');
el.style.color = 'var(--plum)';
}
function syncReputationProviderStatuses() {
REPUTATION_PROVIDERS.forEach((provider) => syncReputationProviderStatus(provider));
}
function syncProviderStatus() {
syncVirusTotalStatus();
syncSafeBrowsingStatus();
syncReputationProviderStatuses();
}
function publicConfig(cfg, tabUrl) {
const out = Object.assign({}, cfg || {});
delete out.downloadSafeBrowsingKey;
delete out.downloadVirusTotalKey;
delete out.forgetMeAllConfirmedAt;
REPUTATION_PROVIDERS.forEach((p) => { if (p.keyField) delete out[p.keyField]; });
let host = '';
try { host = new URL(tabUrl).hostname; } catch (_) {}
out.eyeShieldSites = WOEyeShieldProfiles.forHost(out.eyeShieldSites, host);
return out;
}
function load() {
chrome.storage.local.get('wardenone_config', (res) => {
const saved = (res && res.wardenone_config) || null;
if (saved) config = Object.assign({}, DEFAULTS, saved);
// Snapshot what storage actually holds BEFORE the migration below, so the
// migration reads as a change this popup intends to write rather than as state
// it already agreed with.
savedConfigSnapshot = configClone(config);
if (saved && saved.googleSearchResultCleanup === true) {
if (typeof saved.blockSearchAiAnswers === 'undefined') config.blockSearchAiAnswers = true;
if (typeof saved.blockSponsoredSearchResults === 'undefined') config.blockSponsoredSearchResults = true;
config.googleSearchResultCleanup = false;
}
config.showDownloadBar = true;
applyToUI();
reconcileForgetHistoryPermission();
updateAllowlistBtn();
// Painted after applyToUI so the per-site list can read each protection's
// label out of its own row rather than keeping a second copy of the wording.
wireSiteControls();
paintSiteControls();
wireMyFilters();
renderDownloadTrustList();
renderTrackerLearner();
renderTrackerProposals();
loadExtensionAlerts();
loadStartupReport();
renderProtectionHealth();
refreshSiteDashboard();
restorePopupSearch(() => restoreAdvancedProvidersState(restorePopupScrollPosition));
});
}
function save(afterSave) {
readFromUI();
applyToUI();
saveConfig('Saved', afterSave);
}
let savedTickTimer = 0;
function setSavedTick(text, isError) {
const tick = $('saved-tick');
if (!tick) return;
tick.textContent = text || '';
tick.className = isError ? '' : 'saved';
tick.style.color = isError ? 'var(--wo-danger)' : '';
// An error has something to read; a success tick is one word. Clearing both
// after the same 2.2s meant the message that mattered was the one that got
// taken away before it could be read.
if (text) {
const dwell = isError ? Math.min(12000, Math.max(6000, 2000 + 60 * String(text).length)) : 2600;
clearTimeout(savedTickTimer);
savedTickTimer = setTimeout(() => { tick.textContent = 'Changes save automatically'; tick.style.color = ''; }, dwell);
}
}
// A disabled provider can still be used for a right-click check. Keep its key
// until the reader erases the field, while the switch alone authorizes automatic checks.
const PROVIDER_KEY_FIELDS = {
downloadSafeBrowsing: 'downloadSafeBrowsingKey',
downloadVirusTotal: 'downloadVirusTotalKey',
urlHaus: 'urlHausKey',
abuseIpDb: 'abuseIpDbKey',
phishTank: 'phishTankKey',
whoisXml: 'whoisXmlKey',
};
function normalizeStoredProviderKeys(cfg) {
if (!cfg || typeof cfg !== 'object') return;
for (const provider of Object.keys(PROVIDER_KEY_FIELDS)) {
const field = PROVIDER_KEY_FIELDS[provider];
cfg[field] = String(cfg[field] || '').trim();
}
}
// ===== Settings backup =====
// Nothing syncs to a server and there is no account, so without this a reinstall
// means rebuilding 140-odd settings by hand.
//
// The config holds user-supplied third-party API keys, which are the only real
// secrets here. They are stripped by PATTERN rather than by a hand-written list,
// for the same reason bridge.js strips them that way: a list silently starts
// leaking the day an eighth provider is added. The same rule blocks them on the
// way IN, so a hand-edited file cannot inject a key either.
const SECRET_FIELD_RE = /Key$/;
const SETTINGS_FILE_MARKER = 'wardenone-settings';
const SETTINGS_FILE_MAX_BYTES = 2 * 1024 * 1024;
function settingsIoStatus(text, isError) {
const el = $('settings-io-result');
if (!el) return;
el.textContent = text;
el.style.display = 'block';
el.style.color = isError ? 'var(--wo-danger)' : 'var(--ink-faint)';
}
// A per-site override map, in the only shape the popup ever writes: host -> { featureKey: false }.
// Shared by export and import so what is written is exactly what can be read back (PI-03).
function sanitizeSiteOverrides(raw) {
const out = {};
let dropped = 0;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { map: out, dropped: 1 };
Object.keys(raw).slice(0, 500).forEach((host) => {
const entry = raw[host];
if (typeof host !== 'string' || !host || host.length > 260 || !entry || typeof entry !== 'object' || Array.isArray(entry)) { dropped++; return; }
const kept = {};
Object.keys(entry).forEach((key) => {
if (entry[key] === false && Object.prototype.hasOwnProperty.call(DEFAULTS, key) && typeof DEFAULTS[key] === 'boolean') kept[key] = false;
else dropped++;
});
if (Object.keys(kept).length) out[host] = kept;
});
return { map: out, dropped };
}
function exportableSettings(cfg) {
const out = {};
Object.keys(cfg || {}).forEach((key) => {
if (SECRET_FIELD_RE.test(key)) return;
out[key] = key === 'siteOverrides' ? sanitizeSiteOverrides(cfg[key]).map
: key === 'eyeShieldSites' ? WOEyeShieldProfiles.cleanSites(cfg[key]) : cfg[key];
});
return out;
}
function exportSettings() {
readFromUI();
const settings = exportableSettings(config);
const payload = { format: SETTINGS_FILE_MARKER, version: 1, settings };
let url = '';
try {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wardenone-settings.json';
document.body.appendChild(a);
a.click();
a.remove();
} catch (_) {
settingsIoStatus('Could not create the file.', true);
return;
}
setTimeout(() => { try { URL.revokeObjectURL(url); } catch (_) {} }, 15000);
settingsIoStatus('Exported ' + Object.keys(settings).length + ' settings. API keys were not included.', false);
}
// An imported file is untrusted input. Every value is matched against the shape
// of the shipped default for that key -- unknown keys, wrong types, and oversized
// lists are dropped rather than trusted.
function sanitizeImportedSettings(raw) {
const settings = {};
let ignored = 0;
Object.keys(raw || {}).forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(IMPORT_SCHEMA, key)) { ignored++; return; }
if (SECRET_FIELD_RE.test(key)) { ignored++; return; }
const def = IMPORT_SCHEMA[key];
const val = raw[key];
// The one host -> object key. The generic map branch below keeps only scalar values, so this
// used to come back as {} and be applied over the reader's stored overrides (PI-03). An entry
// that holds nothing usable is ignored, never applied as an empty map.
if (key === 'siteOverrides') {
if (!val || typeof val !== 'object' || Array.isArray(val)) { ignored++; return; }
const result = sanitizeSiteOverrides(val);
if (Object.keys(val).length && !Object.keys(result.map).length) { ignored++; return; }
settings[key] = result.map;
return;
}
if (key === 'eyeShieldSites') {
if (!val || typeof val !== 'object' || Array.isArray(val)) { ignored++; return; }
const sites = WOEyeShieldProfiles.cleanSites(val);
if (Object.keys(val).length && !Object.keys(sites).length) { ignored++; return; }
settings[key] = sites;
return;
}
if (key === 'notificationSettings') {
if (!val || typeof val !== 'object' || Array.isArray(val)) { ignored++; return; }
settings[key] = typeof sanitizeWardenNotificationSettings === 'function'
? sanitizeWardenNotificationSettings(val)
: JSON.parse(JSON.stringify(DEFAULTS.notificationSettings));
return;
}
if (typeof def === 'boolean') {
if (typeof val === 'boolean') settings[key] = val; else ignored++;
} else if (typeof def === 'number') {
if (typeof val === 'number' && Number.isFinite(val)) settings[key] = val; else ignored++;
} else if (typeof def === 'string') {
if (typeof val === 'string' && val.length <= 200) settings[key] = val; else ignored++;
} else if (Array.isArray(def)) {
if (!Array.isArray(val)) { ignored++; return; }
settings[key] = val.filter((h) => typeof h === 'string' && h.length <= 260).slice(0, 1000);
} else if (def && typeof def === 'object') {
if (!val || typeof val !== 'object' || Array.isArray(val)) { ignored++; return; }
const map = {};
Object.keys(val).slice(0, 500).forEach((host) => {
const v = val[host];
if (host.length <= 260 && (typeof v === 'number' || typeof v === 'string')) map[host] = v;
});
settings[key] = map;
} else {
ignored++;
}
});
return { settings, ignored };
}
function importSettingsFromFile(file) {
if (!file) return;
if (file.size > SETTINGS_FILE_MAX_BYTES) {
settingsIoStatus('That file is too large to be a settings export.', true);
return;
}
const reader = new FileReader();
reader.onerror = () => settingsIoStatus('Could not read that file.', true);
reader.onload = () => {
let parsed = null;
try { parsed = JSON.parse(String(reader.result || '')); } catch (_) {
settingsIoStatus('That file is not valid JSON.', true);
return;
}
if (!parsed || typeof parsed !== 'object' || parsed.format !== SETTINGS_FILE_MARKER
|| !parsed.settings || typeof parsed.settings !== 'object' || Array.isArray(parsed.settings)) {
settingsIoStatus('That does not look like a WardenOne settings file.', true);
return;
}
const result = sanitizeImportedSettings(parsed.settings);
const applied = Object.keys(result.settings);
if (!applied.length) {
settingsIoStatus('Nothing in that file could be applied.', true);
return;
}
applied.forEach((key) => { config[key] = result.settings[key]; });
applyToUI();
saveConfig('Settings imported', reloadActiveHttpTab);
settingsIoStatus('Applied ' + applied.length + ' settings'
+ (result.ignored ? ', ignored ' + result.ignored + ' unrecognised' : '')
+ '. Your API keys were left as they are.', false);
};
reader.readAsText(file);
}
// Read-modify-write. Re-reads storage at the write boundary and lays only the keys