From 6934bf9e5b8d98bf36a4d65e8d8fcd2377f219fa Mon Sep 17 00:00:00 2001 From: s4ge Date: Sun, 30 Aug 2026 21:54:48 -0600 Subject: [PATCH 01/12] Add KT (Korea) public DNS resolver, closes #393 --- api/data/dns-resolvers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/data/dns-resolvers.js b/api/data/dns-resolvers.js index 051125624..a606b84e2 100644 --- a/api/data/dns-resolvers.js +++ b/api/data/dns-resolvers.js @@ -52,4 +52,5 @@ export const DNS_RESOLVERS = [ { id: 'dns4eu', name: 'DNS4EU', country: 'EU', udp: '86.54.11.1' }, { id: 'cznic', name: 'CZ.NIC ODVR', country: 'CZ', udp: '193.17.47.1' }, { id: 'dnssb', name: 'DNS.SB', country: 'EU', udp: '185.222.222.222', doh: 'https://doh.dns.sb/dns-query?' }, + { id: 'kt', name: 'KT', country: 'KR', udp: '168.126.63.1' }, ]; From c2432a4dd026840b06aa151791ae86404531aa62 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:54:04 +0800 Subject: [PATCH 02/12] Feat(connectivity): add Online Education list Add twelve browser-safe education probes, localized list names, and committed 64px favicons for the curated connectivity import. Co-authored-by: OpenAI Codex --- frontend/data/connectivity-import-lists.js | 18 ++++++++++++++++++ frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + frontend/locales/pt-BR.json | 1 + frontend/locales/ru.json | 1 + frontend/locales/zh-TW.json | 1 + frontend/locales/zh.json | 1 + public/favicons/brilliant.png | Bin 0 -> 1310 bytes public/favicons/codecademy.png | Bin 0 -> 463 bytes public/favicons/coursera.png | Bin 0 -> 761 bytes public/favicons/duolingo.png | Bin 0 -> 1056 bytes public/favicons/edx.png | Bin 0 -> 758 bytes public/favicons/futurelearn.png | Bin 0 -> 1177 bytes public/favicons/khan-academy.png | Bin 0 -> 787 bytes public/favicons/leetcode.png | Bin 0 -> 651 bytes public/favicons/masterclass.png | Bin 0 -> 526 bytes public/favicons/mit-ocw.png | Bin 0 -> 532 bytes public/favicons/skillshare.png | Bin 0 -> 963 bytes public/favicons/udemy.png | Bin 0 -> 463 bytes 19 files changed, 24 insertions(+) create mode 100644 public/favicons/brilliant.png create mode 100644 public/favicons/codecademy.png create mode 100644 public/favicons/coursera.png create mode 100644 public/favicons/duolingo.png create mode 100644 public/favicons/edx.png create mode 100644 public/favicons/futurelearn.png create mode 100644 public/favicons/khan-academy.png create mode 100644 public/favicons/leetcode.png create mode 100644 public/favicons/masterclass.png create mode 100644 public/favicons/mit-ocw.png create mode 100644 public/favicons/skillshare.png create mode 100644 public/favicons/udemy.png diff --git a/frontend/data/connectivity-import-lists.js b/frontend/data/connectivity-import-lists.js index 9f1065f72..426958e69 100644 --- a/frontend/data/connectivity-import-lists.js +++ b/frontend/data/connectivity-import-lists.js @@ -233,6 +233,24 @@ export const IMPORT_LISTS = [ { id: 'clickup', name: 'ClickUp', url: 'https://clickup.com/favicon.ico' }, ], }, + { + id: 'education', + emoji: '🎓', + members: [ + { id: 'coursera', name: 'Coursera', url: 'https://www.coursera.org/favicon.ico' }, + { id: 'edx', name: 'edX', url: 'https://www.edx.org/favicon.ico' }, + { id: 'khan-academy', name: 'Khan Academy', url: 'https://www.khanacademy.org/favicon.ico' }, + { id: 'udemy', name: 'Udemy', url: 'https://www.udemy.com/staticx/udemy/images/v8/favicon-32x32.png' }, + { id: 'duolingo', name: 'Duolingo', url: 'https://www.duolingo.com/robots.txt' }, + { id: 'codecademy', name: 'Codecademy', url: 'https://www.codecademy.com/favicon.ico' }, + { id: 'brilliant', name: 'Brilliant', url: 'https://brilliant.org/favicon.ico' }, + { id: 'skillshare', name: 'Skillshare', url: 'https://www.skillshare.com/favicon.ico' }, + { id: 'futurelearn', name: 'FutureLearn', url: 'https://www.futurelearn.com/favicon.ico' }, + { id: 'masterclass', name: 'MasterClass', url: 'https://www.masterclass.com/favicon-32x32.png' }, + { id: 'leetcode', name: 'LeetCode', url: 'https://leetcode.com/favicon.ico', iconDomain: 'assets.leetcode.com' }, + { id: 'mit-ocw', name: 'MIT OpenCourseWare', url: 'https://ocw.mit.edu/favicon.ico', iconDomain: 'mit.edu' }, + ], + }, { id: 'streaming', emoji: '🎬', diff --git a/frontend/locales/en.json b/frontend/locales/en.json index e20d1ccb0..20d6635dc 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1288,6 +1288,7 @@ "ai": "AI Services", "social": "Social & Messaging", "productivity": "Work & Productivity", + "education": "Online Education", "streaming": "Streaming", "music": "Music & Audio", "gaming": "Gaming", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 52b15ce36..2a3fe0c46 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1288,6 +1288,7 @@ "ai": "Services IA", "social": "Réseaux sociaux", "productivity": "Travail et productivité", + "education": "Formation en ligne", "streaming": "Streaming", "music": "Musique et audio", "gaming": "Jeux vidéo", diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 6bfce607c..73c7078d5 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -1288,6 +1288,7 @@ "ai": "Serviços de IA", "social": "Redes sociais e mensagens", "productivity": "Trabalho e produtividade", + "education": "Educação online", "streaming": "Streaming", "music": "Música e áudio", "gaming": "Jogos", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index f6dcc67ea..2cb3ac47a 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1288,6 +1288,7 @@ "ai": "ИИ-сервисы", "social": "Соцсети", "productivity": "Работа и продуктивность", + "education": "Онлайн-образование", "streaming": "Стриминг", "music": "Музыка и аудио", "gaming": "Игровые платформы", diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index b15bcafcc..378d86dff 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -1288,6 +1288,7 @@ "ai": "AI 服務", "social": "社交通訊", "productivity": "工作與生產力", + "education": "線上教育", "streaming": "流媒體", "music": "音樂與音訊", "gaming": "遊戲平臺", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 567926124..0a0c07045 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1288,6 +1288,7 @@ "ai": "AI 服务", "social": "社交通讯", "productivity": "工作与生产力", + "education": "在线教育", "streaming": "流媒体", "music": "音乐与音频", "gaming": "游戏平台", diff --git a/public/favicons/brilliant.png b/public/favicons/brilliant.png new file mode 100644 index 0000000000000000000000000000000000000000..a2c8643f1f2a6bea6f3864d3aa0aa89479af877c GIT binary patch literal 1310 zcmV+(1>yRMP)2lNoWReCtrf-Q||`B<_2ZMGD0W@kT&@? zAsRuEEf4^tP96XlZC8?xbOY!+xkr#W`PUe!%Hr3ug-Ue+=}Z4Ib#Bs>OjnS)0^DV) z$&`5kRkkZgGl2fv!Ycf4cV3X{3Q}CaJ>ivP$P$H_vbE`AIjEe#28h7#yCLO$5Xc2$ zoIC)il;o1WoeMO!?-da9F#Rzu3h3pPNE$iBM=YD1V(xB)a{P+Fe{$5-0-DMFum+ zX7TOaheC=4A@klOjvB|Po%|uvJWWF|KlozupTB-SxY8vqtpKfRs-w=x7CfHofBp1F zOeE#_d@qg~$N22`ue^4%xJfG)S3}1+K;O=3qs~Yb9w(oPfM2@KdxEHq~ zaY0aK6#~+6D-stBXo~39-GHKok{Y{7C6NP z#LIE2p{gnj!|>GSRQoWn?=mMW0)W)+LKPt2+o3r&i|@5B1FGYV@9dw1!(&%Mun+HV zeA3&2D{|$+r+lKEUP`BT%?o&`?wm-DOUnX@a`?)| zOXpS3b_CR!kw2W?xH7RLkQl+*4Gx#}tTYu$jp>!#8qp04n=w|pJ@q#cPL59Qe4b{- z*+QR(&j_funC3+5hu)>fy^hhhMu?(7qD{JLs;5q}6y*CsCd|D5?@+_=QzzsrBD6PU z)khMI{x~!dZ2YH~krqh-eR6V?P)0AMP*NG@YUIIHNp}bW2SsZ$MZ3i`7gUZ{xFzQ* zjg+WxZMs-)F;-;A5`mcVGU|i@$@c|PyNe}+Uol=je6W#~B}{5wfSa@wYM7m1bCZ^2 zij-=A+Dy@QIK1#0JJ(ly`5}~BW1+Z$8^n|<6CvFibC;FlVNM?C>&w%WZ-=hKBoYI((JMKFh)n?Z2uOEcpJyAO=gefuI^>A(KX+*W UCz)E=g8%>k07*qoM6N<$f}citHUIzs literal 0 HcmV?d00001 diff --git a/public/favicons/codecademy.png b/public/favicons/codecademy.png new file mode 100644 index 0000000000000000000000000000000000000000..463ba390295da48a1b3270d665b77222b71a7661 GIT binary patch literal 463 zcmV;=0WkiFP)5G}7^!4^+c!kf^*>8c2QD}Cwy~CZUuy}}*U2}jpRA#NUx_^w803SU74>SN8I+H&b zqyPW`AxT6*R9J=WmQizqAPj&ZC>5a@c4*tp>9+s>Ba6_LC~Nt>&vxz1C1RegR|zk9Tr?sF^Yf#$J2dar$A}{1&hE=!AWvh zHsPHDDiMT#{ND(S5xgG1yCLXOaq4NUuJq67VzMJG>tgwQ_q-e zMet}wQuwzZf&!!083bQA^Bod6^?>08dxzVytBJ%jaRVmX6!((!qy_*0002ovPDHLk FV1g9m#fks` literal 0 HcmV?d00001 diff --git a/public/favicons/coursera.png b/public/favicons/coursera.png new file mode 100644 index 0000000000000000000000000000000000000000..df0c205ddd196cdc74b233bb12d7337993a39607 GIT binary patch literal 761 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 literal 0 HcmV?d00001 diff --git a/public/favicons/khan-academy.png b/public/favicons/khan-academy.png new file mode 100644 index 0000000000000000000000000000000000000000..b6e732d8a0cbc0eba6cf38ed28e2f8310c07713d GIT binary patch literal 787 zcmV+u1MK{XP)IkK*+10KJm{z?S>|{o(iW>-zR{*1Ub$!er63xar%g=h0-5_kYaoP^&5RK)Y}DUTbnygZUaE{K?11ul@6;^IZQ$cwxwF zrg$@)X8=C{fSkV(o|evsdxR^~SHr71A;^-tdIixpf&zIkprYwxCrsUf?G5jBc4#+C6q04^I; z#W_6%IA3DCx(N!{tZg%2V6&i9UKyD?&tR$1w6cq!F2ZW>lE%(6NY*0f2AxV`R$XSv zIbaj;RIG$VY&V#n0|51*UM@!p63MBCbAUmf$=m=2i>WyW5Q<%uFCbfCAH67~1gj?V zLLjkvsN|}YJM3%b6l{Y%-Bl?Q^3cw6LUR19BW`1dTX9K9i5__u0iY5&dqVILNE!hk zFd%PTqle*s*RKR+*a|1#wxA!j(!K!DcHM`sulp~KbZsB7O(b(cs-^nN@8$26LCbCr zz)IAafC%NY;C>IelBnqdEq8lcuW@ezXJo0GX=+p{jl(NeAIvI@OTaIJvvwSE8DW;?h`F_Y8VFV!W!#`v2)C02I1xSb_9}ax6EhV(C(g!o zIH~Ga=jEy0N(V^fjQL=!F}LpkYMO`va}S~HPiM0GLs^`abzNS+^=tpV{Q)nr8|X7a R(sBR*002ovPDHLkV1j2ee-Qux literal 0 HcmV?d00001 diff --git a/public/favicons/leetcode.png b/public/favicons/leetcode.png new file mode 100644 index 0000000000000000000000000000000000000000..fbefd00dcc2615c97946b8547b685d2c84486ef4 GIT binary patch literal 651 zcmV;60(AX}P)e8EwS? z000VfQchC<|Ns9Z|Ns6_xvZDwwg3PD{YgYYR9J=Wm+O*)APj{KDWV{k_kY?AAj(Ch z)t#OF(c@HG^n4@)=`akWX%@gg6Nq_w4N=cZwrU~9rvRnR$i^gw`iO7=Pq3!EIjGfKC9+=x|}oacBWly?zEO ziJ2BaukLa6vjF5aNX)1b0LtQc831D_WY$Hf=mr2PAj=#V45d!UT*+djQ@gk zEEr{?oR$O-+8IOl*cw6t6jPVhj#9CU;1?1&wYV`%strIhxJq{!!;#5oZ`UUL2v1=K zu=Vhj zXK!Qbf&Ns0Z{K>VRwVGtTsVbuI(W8-uXnu_4%Wmw`U3t5;PIyG@wRWcyYXEE_=T~BP4lw zbc%|F@9yK8nvlF@;$`wr)9WW^OtF3@i*zD4d8o#a}IXPvM<>(b<#EGa^P_Uu64aK0DB`(=b(Mp4z4%ybPfctH*(3r zX$N%naLafj*R%1w$Q*MgSER&mc7WYm$S4QB9s@&kqDN^~*OOOr(lZ6pVQHr{m@2<@H7S1IvC9dX8)3 QRsaA107*qoM6N<$f?Nmdx&QzG literal 0 HcmV?d00001 diff --git a/public/favicons/mit-ocw.png b/public/favicons/mit-ocw.png new file mode 100644 index 0000000000000000000000000000000000000000..43efc7523fb70539ce9c956ca01ae2489a88bc8d GIT binary patch literal 532 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!yaRkfT-^(NfJ}nm|Ns9YA|j@y zrpuQv-?V8{U0q#%e*W>}$9M1E{r2tKn3$NZuCA(&DI!3FcuIo&f*Hz1x7nSG|25(1 z3XW)%ZDI@zj5(ezjv*1PccCW2Bl?Qh1I76`E@99To2PU28VqWn>D4HRtM-DMU--5)&*jDB6i)j~b1zt~`+LWx zhV*fWwZGV>n8>^e289k_OP_?o~uV^=kB?C zoSm=c$-#C##{OD6p&Ji0KU^#qHLJ;ACHudg@tOa;4R#B~j6o6W>FVdQ&MBb@03=)6 AbpQYW literal 0 HcmV?d00001 diff --git a/public/favicons/skillshare.png b/public/favicons/skillshare.png new file mode 100644 index 0000000000000000000000000000000000000000..2ba33630d157cf69170712b3b6831179be95f0ac GIT binary patch literal 963 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!N&|dCT-^(NfK1}S|NsC0{re{% zD9Fymv3~t}Nh!(Xv!_m-+R@Qr=jo!Qt9}3e{Zpq-MLu2p;lqb{^X64mRqfcZ zW5tRU8rj~y$9rw(7kq1Y8wj+Lza+>nm|;`y*9%RP{xLp}6!6gFjb6MkkAZ>dou`Xq zNJQ(}@bE=V1_C@>OxqkZxH@jUuRk(PcqR(_q!37>7t1d^C)6Fu@Leqnm^hyUJR z9&Pw{rM}^Oabm(}-FS_hI$nhX98VsJ?~A-D#{S7^(UuRjTs7y79*KFztWm3Y%yuD! zQzo~@;Gk5aCS!+FhWEoI4f0(7*3}*q`Z_yBKFZ;!Rj2EjiaY(=clmY4{CmlfB4yh+ z^`WC%x7wu)J*R^L9zBsh1@hm1EbUtq5%|a=OTw6ClS){|FS|D<#pHq)XkTe#fK^13_Ao%fX55epm&U1qEBJwN>L z&m|Lf@gpIeb3U#1OTQ=<#O&A6sDJJ`*DXcP4;Z2ck8yDEfw0Z^IwM3?!D*j63*B^I%Avi z_po99KgZ&Cf#v@atN#_&|7%VDH#Pm=QtzZ|Tl@bUKN=im`1sZ8g?jg8Yl?qPYk0li czVPpV=GucN_+2ke;{oMEPgg&ebxsLQ01ncu7XSbN literal 0 HcmV?d00001 diff --git a/public/favicons/udemy.png b/public/favicons/udemy.png new file mode 100644 index 0000000000000000000000000000000000000000..db05db5c518fddb17dc1e7abaf8d8df84dd0dfb9 GIT binary patch literal 463 zcmV;=0WkiFP)O)6*3d6`vvR@8A5PDey!@M9i4>)~)#W?*5}O@SPd&x^MH|-ri47 zPusZp#D?|z_5SPF`>|Q_!GH9vP4VW=`_ZHK^y&TL#`>v2@w8v_%aistHa47`oC^yJ z#>U3#>gs-eex|0Tb#-=LUS4f&ZMC(v$%JA*00037Nkl3)zjM_qx zUMbSc|NnonvE!(Y9zNRrg~xGS+n%x6Oh;!D3D%o2co-4N7K8ESmN1MNiFd>JJ*8NL z@znv2A5R3UP@VvB`^=d{NqwaOh*!8<&V)211g}Twf|vE z7)-!<=L-xls0U$We>_VPfZ%IJ{S*_7mpYD Date: Wed, 2 Sep 2026 15:50:46 +0800 Subject: [PATCH 03/12] Improvements --- frontend/data/default-preferences.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/data/default-preferences.js b/frontend/data/default-preferences.js index cd20d14b9..e03e7ee1e 100644 --- a/frontend/data/default-preferences.js +++ b/frontend/data/default-preferences.js @@ -13,7 +13,7 @@ export const PREFS_STORAGE_KEY = 'userPreferences_v7'; export const DEFAULT_PREFERENCES = Object.freeze({ theme: 'auto', // auto | light | dark - connectivityMultipleTests: false, + connectivityMultipleTests: true, simpleMode: false, // Per-module startup auto-run switches. IP info has no switch — it always // runs on load. See use-refresh-orchestrator.js. From a8f75bdb46b43e2f1f2f5f99763081876680d955 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 2 Sep 2026 15:51:51 +0800 Subject: [PATCH 04/12] Improvements --- tests/default-preferences.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/default-preferences.test.js b/tests/default-preferences.test.js index d21d63842..e85298934 100644 --- a/tests/default-preferences.test.js +++ b/tests/default-preferences.test.js @@ -14,7 +14,7 @@ describe('DEFAULT_PREFERENCES', () => { it('contains the full preference shape with expected defaults', () => { assert.deepEqual(DEFAULT_PREFERENCES, { theme: 'auto', - connectivityMultipleTests: false, + connectivityMultipleTests: true, simpleMode: false, autoRunConnectivity: true, autoRunWebRTC: true, From 244a121e980ded2258dd4e89dc4d522e7a4dd668 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 10:55:10 +0800 Subject: [PATCH 05/12] Chore(sentry): skip Sentry init in dev runs A local pnpm dev with a DSN in .env reported every half-finished edit against the production projects. The frontend gate now also checks import.meta.env.DEV; the backend bootstrap skips init under SENTRY_ENVIRONMENT=development, mirroring the cron check-in rule. Co-Authored-By: Claude Fable 5.1 --- api/AGENTS.md | 4 +++- frontend/AGENTS.md | 4 +++- frontend/main.js | 18 ++++++++++-------- sentry-instrument.js | 8 ++++++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/api/AGENTS.md b/api/AGENTS.md index a4d34e105..0d016c712 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -60,7 +60,9 @@ currently `dns-resolvers.js`, the country-annotated resolver list behind Root-level `sentry-instrument.js` (loaded via `node --import` *before* express, so ESM loader hooks can auto-instrument route tracing) does the init; `backend-server.js` attaches `setupExpressErrorHandler` after the - routes. No `SENTRY_DSN_BACKEND` → `@sentry/node` never loads. Handlers + routes. No `SENTRY_DSN_BACKEND` → `@sentry/node` never loads; + `SENTRY_ENVIRONMENT=development` skips the init, so a local run reports + nothing (same rule the cron check-ins already follow). Handlers never import Sentry or capture manually: uncaught throws and 5xx traces are automatic; caught failures stay on the logger — a hook in `common/logger.js` mirrors warn+ to Sentry Logs and elevates error+ to diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 0094ce1e9..369e59f2b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -76,7 +76,9 @@ replaces the map, and Home clears it on unmount — shortcuts are home-route onl ### Error monitoring (Sentry) is env-gated and invisible to app code No `VITE_SENTRY_DSN_FRONTEND` → no Sentry code in the bundle at all -(build-time-gated dynamic import, like `firebase-init.js`). Rules: +(build-time-gated dynamic import, like `firebase-init.js`). The same gate in +`main.js` skips the load under `import.meta.env.DEV`, so a `pnpm dev` run +reports nothing even with a DSN in `.env`. Rules: - **Never import `@sentry/vue` in app code** — a static import drags the SDK into the main bundle. All config lives in `sentry-init.js`. diff --git a/frontend/main.js b/frontend/main.js index 6b84c8cf0..559b9e6e9 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -66,16 +66,18 @@ const store = useMainStore(pinia); app.use(i18n); app.use(router); -// Sentry — build-time env gate: without the DSN the chunk neither ships nor -// loads. The SDK chunk stays OFF the boot critical path: it loads after -// mount (see the mount chain's finally below) so it never competes with the -// locale pack the first render waits on. Until init, a tiny buffer catches -// uncaught errors / rejections — and any of them triggers an immediate -// load, so a boot that never reaches mount still reports. Perf data -// survives the late init (buffered observers, backdated pageload span). +// Sentry — build-time env gate: without the DSN, or in a dev server run, the +// chunk neither ships nor loads (local runs would otherwise report every +// experiment against the production project). The SDK chunk stays OFF the +// boot critical path: it loads after mount (see the mount chain's finally +// below) so it never competes with the locale pack the first render waits +// on. Until init, a tiny buffer catches uncaught errors / rejections — and +// any of them triggers an immediate load, so a boot that never reaches mount +// still reports. Perf data survives the late init (buffered observers, +// backdated pageload span). const earlyErrors = []; let loadSentry = () => {}; -if (import.meta.env.VITE_SENTRY_DSN_FRONTEND) { +if (import.meta.env.VITE_SENTRY_DSN_FRONTEND && !import.meta.env.DEV) { const onEarlyError = (event) => { earlyErrors.push(event); loadSentry(); diff --git a/sentry-instrument.js b/sentry-instrument.js index e432669d4..a47ad4ed6 100644 --- a/sentry-instrument.js +++ b/sentry-instrument.js @@ -5,14 +5,18 @@ // // Gated on SENTRY_DSN_BACKEND, same philosophy as the frontend: when the env // var is unset this module does nothing and @sentry/node is never even -// loaded. backend-server.js attaches the matching Express error handler. +// loaded. SENTRY_ENVIRONMENT=development skips init too, so a local run never +// reports. backend-server.js attaches the matching Express error handler. import dotenv from 'dotenv'; import { scrubBreadcrumb, scrubEventRequest, scrubSpan } from './common/sentry-scrub.js'; dotenv.config({ quiet: true }); -if (process.env.SENTRY_DSN_BACKEND) { +// SENTRY_ENVIRONMENT=development (a local `pnpm dev` machine) skips init: +// experiments in progress are not production signal, same rule as the cron +// check-ins in common/sentry-cron.js. +if (process.env.SENTRY_DSN_BACKEND && process.env.SENTRY_ENVIRONMENT !== 'development') { const Sentry = await import('@sentry/node'); Sentry.init({ dsn: process.env.SENTRY_DSN_BACKEND, From 8d84ec37bdcbef581cd5ad4a4affb4350148ce94 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 10:55:10 +0800 Subject: [PATCH 06/12] Fix(sentry): drop html-to-image stylesheet noise and match firebase idb errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit html-to-image console.errors on every cross-origin stylesheet it cannot read (the docs assistant widget leaves one behind) and then carries on, so the screenshot still renders; those lines are now dropped in beforeSend. The firebase-auth ignoreErrors entry was written as 'Database is closing/hidden', which never substring-matched either real message — split into the three actual wordings. Co-Authored-By: Claude Fable 5.1 --- frontend/composables/use-screenshot.js | 5 +++-- frontend/sentry-init.js | 26 +++++++++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/frontend/composables/use-screenshot.js b/frontend/composables/use-screenshot.js index e4e9816fb..74bc61bb2 100644 --- a/frontend/composables/use-screenshot.js +++ b/frontend/composables/use-screenshot.js @@ -121,8 +121,9 @@ function freezeInfoMaskForCapture(root) { return () => restorers.forEach((fn) => fn()); } -// Extension- or translator-injected cross-origin stylesheets make -// html-to-image's webfont embedding throw a SecurityError while reading +// Cross-origin stylesheets on `document.styleSheets` — mostly the docs +// assistant widget's, plus anything an extension or translator injected — +// make html-to-image's webfont embedding throw a SecurityError while reading // `cssRules`. Retry once without font embedding — the live page has already // loaded its fonts, so the rendered output is unaffected in practice. export const toPngWithFontFallback = async (toPng, element, options) => { diff --git a/frontend/sentry-init.js b/frontend/sentry-init.js index 5108b802a..6945773b5 100644 --- a/frontend/sentry-init.js +++ b/frontend/sentry-init.js @@ -12,6 +12,14 @@ import { isValidIP } from '@/utils/valid-ip.js'; const env = import.meta.env ?? {}; +// html-to-image's webfont embedding console.errors on every stylesheet it +// can't read, then continues without it — noise, not a failed capture. +const SCREENSHOT_CSS_NOISE = [ + 'Error while reading CSS rules from', + 'Error loading remote stylesheet', + 'Error inlining remote css file', +]; + // `earlyErrors` is main.js's pre-init buffer: ErrorEvent / // PromiseRejectionEvent entries from its temporary window listeners, plus // the raw Error from the mount chain's catch. Flushed right after init; @@ -89,11 +97,15 @@ const initSentry = (app, router, earlyErrors = []) => { 'auth/internal-error', 'auth/cancelled-popup-request', 'INTERNAL ASSERTION FAILED', - // firebase auth's indexedDB layer refuses writes while the page - // is hiding (its guard against sign-out on pagehide); the - // rejection escapes from the SDK's own init promise — benign, - // means "skipped a write", not user-visible. - 'Database is closing/hidden', + // firebase auth's indexedDB persistence layer refuses writes once + // the page is hiding (its guard against sign-out on pagehide), and + // the browser itself rejects transactions on a closing connection. + // Both escape from the SDK's own init promise — benign, they mean + // "skipped a write", not user-visible. Three wordings, one per + // source; Sentry matches these as substrings. + 'Database is closing', + 'Database is hidden', + 'The database connection is closing', // Stale-deploy chunk loads: a client from before the latest // deploy lazy-loads a hashed asset that no longer exists. // Self-heals on reload, not a defect. One entry per browser @@ -130,6 +142,10 @@ const initSentry = (app, router, earlyErrors = []) => { const msg = firstArg.trim(); // Filter out DNS-leak probe chain errors. if (msg.startsWith('Error fetching leak test data:')) return null; + // html-to-image logs these while walking a cross-origin + // stylesheet it can't read, then carries on — the + // screenshot still renders (see composables/use-screenshot.js). + if (SCREENSHOT_CSS_NOISE.some((prefix) => msg.startsWith(prefix))) return null; event.fingerprint = [msg.slice(0, 200)]; } } From 1c797b30a59827d802a6f8cbdafb634b0710a534 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 10:55:11 +0800 Subject: [PATCH 07/12] Fix(i18n): tolerate locale chunks without a default export A dynamic import that resolves to an empty module threw on destructure: the app still mounted with no messages at all, and the privacy page sat on its loading state forever. Skip the empty pack, settle the chain with allSettled, and always flip the privacy page's ready flag. Co-Authored-By: Claude Fable 5.1 --- frontend/components/PrivacyPolicy.vue | 20 ++++++++++++++++---- frontend/locales/i18n.js | 11 ++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/frontend/components/PrivacyPolicy.vue b/frontend/components/PrivacyPolicy.vue index 1cdf0f2da..2625fbd75 100644 --- a/frontend/components/PrivacyPolicy.vue +++ b/frontend/components/PrivacyPolicy.vue @@ -92,10 +92,15 @@ const ready = ref(false); // Merge one locale's privacy copy into i18n (memoized). Does NOT touch `ready` — // the caller decides when to reveal, so a background fallback load can't race the // active locale and paint the wrong language. +// A chunk that resolves without a default export merges nothing instead of +// throwing. const loadPrivacy = async (loc) => { if (loaded.has(loc)) return; const load = fallbackChain(loc).map((code) => privacyLoaders[code]).find(Boolean); - const { default: msgs } = await load(); + if (!load) return; + const mod = await load(); + const msgs = mod?.default; + if (!msgs) return; mergeLocaleMessage(loc, msgs); loaded.add(loc); }; @@ -104,11 +109,18 @@ const loadPrivacy = async (loc) => { // load could resolve first and paint English (t() falling back) until the next // re-render. The rest of the chain (covering any key the active locale might // miss) loads in the background and doesn't gate the reveal. +// An unreachable pack still reveals the page: the sections then resolve down +// the i18n fallback chain, which beats a spinner that never stops. watch(locale, async (loc) => { ready.value = false; - await loadPrivacy(loc); - if (loc === locale.value) ready.value = true; // ignore a stale load if locale changed mid-flight - for (const code of fallbackChain(loc).slice(1)) loadPrivacy(code); + try { + await loadPrivacy(loc); + } catch (err) { + console.warn('Privacy copy failed to load, rendering with fallback text:', err); + } finally { + if (loc === locale.value) ready.value = true; // ignore a stale load if locale changed mid-flight + } + for (const code of fallbackChain(loc).slice(1)) loadPrivacy(code).catch(() => {}); }, { immediate: true }); // Ordered section ids, gated on which collection actually happens here. The diff --git a/frontend/locales/i18n.js b/frontend/locales/i18n.js index 2109559ee..12146ad32 100644 --- a/frontend/locales/i18n.js +++ b/frontend/locales/i18n.js @@ -82,11 +82,15 @@ const i18n = createI18n({ messages: {}, }); -// Load one locale's messages into the instance (memoized). +// Load one locale's messages into the instance (memoized). A chunk that +// resolves without a default export leaves the locale unregistered rather +// than throwing — keys resolve down the chain. const loaded = new Set(); async function loadOne(locale) { if (loaded.has(locale) || !localeLoaders[locale]) return; - const { default: msgs } = await localeLoaders[locale](); + const mod = await localeLoaders[locale](); + const msgs = mod?.default; + if (!msgs) return; i18n.global.setLocaleMessage(locale, msgs); loaded.add(locale); } @@ -94,8 +98,9 @@ async function loadOne(locale) { // Load the active locale's whole fallback chain — vue-i18n can only fall back // to messages that are actually in the instance. Awaited in main.js before // mount so the first render is already translated; the loads run in parallel. +// allSettled: one unreachable pack leaves the UI partly translated, never blank. export async function loadActiveLocaleMessages() { - await Promise.all(fallbackChain(activeLocale).map((code) => loadOne(code))); + await Promise.allSettled(fallbackChain(activeLocale).map((code) => loadOne(code))); updateMeta(); } From 675a600d1f5ccaae18e215dba859794721698c73 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 10:55:11 +0800 Subject: [PATCH 08/12] Fix(user): mark remote user info fetched only on success The fetched flag was set outside try/catch, so one failed /api/getuserinfo hid the level badge and achievements for the whole session with no way to refetch. The one-shot trigger is also cleared after use so Nav and Achievements can request the fetch again. Co-Authored-By: Claude Fable 5.1 --- frontend/components/User.vue | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/components/User.vue b/frontend/components/User.vue index d50823f42..fb107cd5f 100644 --- a/frontend/components/User.vue +++ b/frontend/components/User.vue @@ -192,18 +192,17 @@ const refreshUserInfo = async () => { } }; -// Fetch user info +// Fetch user info. `remoteUserInfoFetched` flips only on success, so a failed +// round stays refetchable from Nav / Achievements on their next open. const getUserInfo = async () => { if (remoteUserInfoFetched.value || !isSignedIn.value) return; try { - const response = await authenticatedFetch(`/api/getuserinfo`); - const data = response; - store.remoteUserInfo = data; + store.remoteUserInfo = await authenticatedFetch(`/api/getuserinfo`); + store.remoteUserInfoFetched = true; initUserAchievements(); } catch (error) { console.error('Error fetching user info:', error); } - store.remoteUserInfoFetched = true; }; // Initialize user achievements @@ -267,8 +266,12 @@ watch(() => triggerUserBenefits.value, (newVal) => { if (newVal) openUserBenefits(); }); +// One-shot trigger: cleared here so the next request from Nav / Achievements +// is a fresh false → true edge rather than a no-op write. watch(() => triggerRemoteUserInfo.value, (newVal) => { - if (newVal) getUserInfo(); + if (!newVal) return; + store.triggerRemoteUserInfo = false; + getUserInfo(); }); watch(() => triggerUpdateAchievements.value, (newVal) => { From c72dd0098477ab575f8fc9b1d64c23e2e8f03925 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 10:55:11 +0800 Subject: [PATCH 09/12] Fix(configs): widen the /api/configs timeout to 10s The boot request competes with every other first-render fetch; on slow links the default 5s cut it short and left every feature switch unset for the session. Co-Authored-By: Claude Fable 5.1 --- frontend/store.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/store.js b/frontend/store.js index bb966f65c..54e5dbc80 100644 --- a/frontend/store.js +++ b/frontend/store.js @@ -221,9 +221,10 @@ export const useMainStore = defineStore('main', { ); this.setPreferences(merged); }, - // fetch configs from server + // Fetch configs from server. A longer timeout than the default: the boot + // request competes with every other first-render fetch on slow links. fetchConfigs() { - fetchWithTimeout('/api/configs') + fetchWithTimeout('/api/configs', { timeoutMs: 10000 }) .then(response => { if (!response.ok) { throw new Error('Network response was not ok'); From ffdc9213d1180dd79076611c08c716d6a0837a43 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:00:32 +0800 Subject: [PATCH 10/12] Feat(connectivity): add Finance & Payments list Add ten traditional finance and payment probes, localized list names for every full locale, and committed favicons for the curated import. Co-authored-by: OpenAI Codex --- frontend/data/connectivity-import-lists.js | 16 ++++++++++++++++ frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + frontend/locales/pt-BR.json | 1 + frontend/locales/ru.json | 1 + frontend/locales/zh-TW.json | 1 + frontend/locales/zh.json | 1 + public/favicons/klarna.png | Bin 0 -> 448 bytes public/favicons/mastercard.png | Bin 0 -> 648 bytes public/favicons/monzo.png | Bin 0 -> 731 bytes public/favicons/payoneer.png | Bin 0 -> 2061 bytes public/favicons/paypal.png | Bin 0 -> 632 bytes public/favicons/revolut.png | Bin 0 -> 471 bytes public/favicons/stripe.png | Bin 0 -> 433 bytes public/favicons/visa.png | Bin 0 -> 775 bytes public/favicons/western-union.png | Bin 0 -> 3335 bytes public/favicons/wise.png | Bin 0 -> 607 bytes 17 files changed, 22 insertions(+) create mode 100644 public/favicons/klarna.png create mode 100644 public/favicons/mastercard.png create mode 100644 public/favicons/monzo.png create mode 100644 public/favicons/payoneer.png create mode 100644 public/favicons/paypal.png create mode 100644 public/favicons/revolut.png create mode 100644 public/favicons/stripe.png create mode 100644 public/favicons/visa.png create mode 100644 public/favicons/western-union.png create mode 100644 public/favicons/wise.png diff --git a/frontend/data/connectivity-import-lists.js b/frontend/data/connectivity-import-lists.js index 426958e69..d1ac5c235 100644 --- a/frontend/data/connectivity-import-lists.js +++ b/frontend/data/connectivity-import-lists.js @@ -343,6 +343,22 @@ export const IMPORT_LISTS = [ { id: 'heroku', name: 'Heroku', url: 'https://www.heroku.com/favicon.ico' }, ], }, + { + id: 'finance', + emoji: '💳', + members: [ + { id: 'paypal', name: 'PayPal', url: 'https://www.paypal.com/favicon.ico' }, + { id: 'wise', name: 'Wise', url: 'https://wise.com/robots.txt' }, + { id: 'revolut', name: 'Revolut', url: 'https://www.revolut.com/robots.txt' }, + { id: 'stripe', name: 'Stripe', url: 'https://stripe.com/favicon.ico' }, + { id: 'visa', name: 'Visa', url: 'https://www.visa.com/robots.txt' }, + { id: 'mastercard', name: 'Mastercard', url: 'https://developer.mastercard.com/favicon.ico', iconDomain: 'www.mastercard.com', siteUrl: 'https://www.mastercard.com' }, + { id: 'western-union', name: 'Western Union', url: 'https://www.westernunion.com/robots.txt' }, + { id: 'payoneer', name: 'Payoneer', url: 'https://www.payoneer.com/robots.txt' }, + { id: 'klarna', name: 'Klarna', url: 'https://docs.klarna.com/favicon.ico', iconDomain: 'www.klarna.com', siteUrl: 'https://www.klarna.com' }, + { id: 'monzo', name: 'Monzo', url: 'https://monzo.com/robots.txt' }, + ], + }, { id: 'crypto', emoji: '💰', diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 20d6635dc..0f3437bfd 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1294,6 +1294,7 @@ "gaming": "Gaming", "developer": "Developer", "cloud": "Cloud & CDN", + "finance": "Finance & Payments", "crypto": "Crypto Exchanges", "ecommerce": "Global E-commerce", "news": "News & Wiki" diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 2a3fe0c46..7a19d6062 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1294,6 +1294,7 @@ "gaming": "Jeux vidéo", "developer": "Développeurs", "cloud": "Cloud et CDN", + "finance": "Finance et paiements", "crypto": "Plateformes crypto", "ecommerce": "E-commerce mondial", "news": "Actualités" diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 73c7078d5..94b8740e9 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -1294,6 +1294,7 @@ "gaming": "Jogos", "developer": "Desenvolvimento", "cloud": "Nuvem e CDN", + "finance": "Finanças e pagamentos", "crypto": "Corretoras cripto", "ecommerce": "E-commerce global", "news": "Notícias e wiki" diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 2cb3ac47a..7b0d74fd2 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1294,6 +1294,7 @@ "gaming": "Игровые платформы", "developer": "Для разработчиков", "cloud": "Облака и CDN", + "finance": "Финансы и платежи", "crypto": "Криптобиржи", "ecommerce": "Электронная коммерция", "news": "Новости" diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 378d86dff..0c5dadd4b 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -1294,6 +1294,7 @@ "gaming": "遊戲平臺", "developer": "開發者", "cloud": "雲與 CDN", + "finance": "金融與支付", "crypto": "加密交易所", "ecommerce": "國際電商平臺", "news": "新聞與百科" diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 0a0c07045..f7c7e4a76 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1294,6 +1294,7 @@ "gaming": "游戏平台", "developer": "开发者", "cloud": "云与 CDN", + "finance": "金融与支付", "crypto": "加密交易所", "ecommerce": "国际电商平台", "news": "新闻与百科" diff --git a/public/favicons/klarna.png b/public/favicons/klarna.png new file mode 100644 index 0000000000000000000000000000000000000000..0b05e608fed7e44f9b7e9b986927b580225c1933 GIT binary patch literal 448 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 RkQ4u2U~BDQ^$O>k_+LhR2mSy6 literal 0 HcmV?d00001 diff --git a/public/favicons/paypal.png b/public/favicons/paypal.png new file mode 100644 index 0000000000000000000000000000000000000000..cd4e35e874f80f4e695cd129e6eb1c007c567208 GIT binary patch literal 632 zcmV-;0*C#HP)*|G&xO>-qm`(Er2i{}4BrAVi;#q0c~H zttzPhe%Akhl*mwRvoKPo&)4ej^7sIAXwR36QJTbOwwn0b9hCBS7(Q35k5Q_d>59c<()c9|z0JGN8 z0vKYzoT!TdT&2efe@a9hd0)Qz3q}dKYXF7nZ zR1RS=|MpWu2doWXqx;cA;3^eENZF47T%|$?amIcyf>NbLJR>RnA&M%U8a>UjJv(}n zID{VQ0*`?A*|Y9%fl6}-FFGL9{h~^*`UQYrRO!xV09mC>@#Ag4Rf>Ghm;3C#`uGs^ zFc+9>jzP`J=yZQO~m6esFqod~L<|ij7H#av=PfuiI zWUH&I&(F^i5)xTiSwuucEG#VA+S-7CfQpKWdU|>n7Z+}BZp;uOv;Y7BHAzH4R9J=W z)!lM}Knw-o#2*#}6e$*{+V_79ZPBoM7x^FOo8_I~o@2m=XE zr}zMYO#om%RSN;;d?^44vj96C2wh^ANC(oLpDMeEfWC#te#%oA0ivQ8)Q*Z>mv~fn zJ#f$fxy0?Lp7!;<25bTVHC;Wk0*n7ah2B93sL@vl0A*ipwDZ3J>BKgV@J@UIVt*EZ zMw|zLoiYo&vk#^KXcWUh(<0}CK<)Lm4y1QE{pIlQ!fPMM2y+1+A9dnYgqslot$3VNKX;W_{S N002ovPDHLkV1f@~&e#9| literal 0 HcmV?d00001 diff --git a/public/favicons/stripe.png b/public/favicons/stripe.png new file mode 100644 index 0000000000000000000000000000000000000000..d96078f157574c7f53a7e3ffa54e9bd2e0fbac99 GIT binary patch literal 433 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!>;rs4T+P*X_!|BF^zna2$iEP) zKfzZ2{{R0EB>l|Z2UvV{R`?re_V?!X|3=JzS1$P9RrfF6Tz^+y~aARCDZ%fSVyTgy!Jk=>nzXJv*}A-Btvaa1$yZU>Do$4-b!!u9Zq$m3JOv*dk+C2aL z{P$j(u9Fftr~Uc&;Y+J>h0ugJuU~;TmfzU_^YtHhvx9QG8|D^#6k_D5dtdzaZ1sZz zK1OkUK2tC2#L1`cT}ydlvdAc#$6=;_c<^jX2j`V%q>90u=%=y@W=_vVG+(dY+^4mD z^(BG*e7sEG^G?OMGFac2zx3tF#R(1n1a>Tbt?{6Ky`X`u>w*YjcNT`*+?=+5zchAK zxjfvgmT^(af8Aq_-Iru0Jk?}Q&^A?(BpYZ(Ao;j)PCeH*fO}0|J zlfZuG9JiTMHSfOq!^{i!q%g=Rf9P-Uw@=pmmwsaUo+{Q)XBm|2s*kSvW9!Jgpulsb z>a&I*`Spt|iW``MR;o@dZ@9y;br$3AW;>P_Y>UoW++dto_2Ol~uPr_B zMO)=!wC{yQrgE3`c(U0+{$H6TzLHnYYR%k>FT2?og5O*ZJuxfsqwC2-#aj`6ha?0; bmVb<~YnF9+sP&it(;$PVtDnm{r-UW|{arT; literal 0 HcmV?d00001 diff --git a/public/favicons/western-union.png b/public/favicons/western-union.png new file mode 100644 index 0000000000000000000000000000000000000000..ab6d4e6ec1201d5ef58849ddcde6bd431a03fe9b GIT binary patch literal 3335 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 literal 0 HcmV?d00001 From 0199902495d8e3d3cd85c7a75a7db1b68d3b5b66 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 18:25:16 +0800 Subject: [PATCH 11/12] Improvements --- frontend/data/changelog.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 42c9ac23f..283433d87 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1973,7 +1973,7 @@ }, { "version": "v7.5.0", - "date": "Beta", + "date": "2026-09-03", "content": [ { "type": "add", diff --git a/package.json b/package.json index 17f805b4f..df25239a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "7.4.0", + "version": "7.5.0", "type": "module", "packageManager": "pnpm@11.22.0", "engines": { From 70f058ed4fb66c7f4e2c2684acb840fcb140d52f Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 3 Sep 2026 18:40:03 +0800 Subject: [PATCH 12/12] Fix(i18n): load optional locale datasets through one fallback-aware loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy copy and the security checklist each fetched their locale chunk on their own. The privacy page revealed before any fallback had merged when the active chunk failed (raw keys until English arrived), and the checklist had no handling at all — a failed chunk meant an endless spinner plus an unhandled rejection. utils/locale-datasets.js now walks the fallback chain at fetch time, skipping chunks that fail or ship no default export, so both pages render the next available language and only a whole-chain failure degrades further. Co-Authored-By: Claude Fable 5.1 --- frontend/components/PrivacyPolicy.vue | 58 +++++--------- .../advanced-tools/SecurityChecklist.vue | 27 ++++--- frontend/utils/locale-datasets.js | 36 +++++++++ tests/locale-datasets.test.js | 76 +++++++++++++++++++ 4 files changed, 145 insertions(+), 52 deletions(-) create mode 100644 frontend/utils/locale-datasets.js create mode 100644 tests/locale-datasets.test.js diff --git a/frontend/components/PrivacyPolicy.vue b/frontend/components/PrivacyPolicy.vue index 2625fbd75..ebb6b6d39 100644 --- a/frontend/components/PrivacyPolicy.vue +++ b/frontend/components/PrivacyPolicy.vue @@ -47,7 +47,7 @@ import { useMainStore } from '@/store'; import { isAnalyticsEnabled } from '@/utils/analytics'; import { isDocsConfigured } from '@/composables/use-docs-assistant.js'; import { useDocumentMeta } from '@/composables/use-document-meta.js'; -import { fallbackChain } from '@/utils/locale-registry.js'; +import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js'; import Footer from '@/components/Footer.vue'; import StandalonePageHeader from '@/components/StandalonePageHeader.vue'; import { Spinner } from '@/components/ui/spinner'; @@ -77,50 +77,28 @@ const isDocsAssistantEnabled = computed(() => isDocsConfigured && store.configs? // data/tools.js), which keeps the section off deployments without it. const isPersonaCheckEnabled = computed(() => store.configs?.originalSite === true); -// Privacy copy is loaded on demand per locale (mirrors the security-checklist -// dataset pattern), then merged into i18n so t() / tm() can resolve it. -// Discovered by glob, keyed by locale code; a locale with no file of its own -// resolves to the first one on its fallback chain that has one. -const privacyPacks = import.meta.glob('../locales/privacy/*.json'); -const privacyLoaders = Object.fromEntries( - Object.entries(privacyPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), -); - -const loaded = new Set(); +// Privacy copy is loaded on demand per locale (same loader as the security +// checklist dataset), then merged into i18n so t() / tm() can resolve it. +// A pack is complete or absent (tests/locale-packs.test.js), so whichever +// pack the chain resolves to is merged under the ACTIVE locale — the page +// reveals with one language, never a mix, and never raw keys unless the whole +// chain is unreachable. +const privacyLoaders = datasetLoaders(import.meta.glob('../locales/privacy/*.json')); +const privacyCache = new Map(); +const merged = new Set(); const ready = ref(false); -// Merge one locale's privacy copy into i18n (memoized). Does NOT touch `ready` — -// the caller decides when to reveal, so a background fallback load can't race the -// active locale and paint the wrong language. -// A chunk that resolves without a default export merges nothing instead of -// throwing. -const loadPrivacy = async (loc) => { - if (loaded.has(loc)) return; - const load = fallbackChain(loc).map((code) => privacyLoaders[code]).find(Boolean); - if (!load) return; - const mod = await load(); - const msgs = mod?.default; - if (!msgs) return; - mergeLocaleMessage(loc, msgs); - loaded.add(loc); -}; - -// Reveal only after the ACTIVE locale's copy is merged — otherwise a fallback -// load could resolve first and paint English (t() falling back) until the next -// re-render. The rest of the chain (covering any key the active locale might -// miss) loads in the background and doesn't gate the reveal. -// An unreachable pack still reveals the page: the sections then resolve down -// the i18n fallback chain, which beats a spinner that never stops. watch(locale, async (loc) => { ready.value = false; - try { - await loadPrivacy(loc); - } catch (err) { - console.warn('Privacy copy failed to load, rendering with fallback text:', err); - } finally { - if (loc === locale.value) ready.value = true; // ignore a stale load if locale changed mid-flight + const pack = await loadLocaleDataset(privacyLoaders, loc, privacyCache); + if (loc !== locale.value) return; // stale load — the locale changed mid-flight + if (pack) { + if (!merged.has(loc)) mergeLocaleMessage(loc, pack.data); + merged.add(loc); + } else { + console.error('Privacy copy unavailable in every locale; rendering keys'); } - for (const code of fallbackChain(loc).slice(1)) loadPrivacy(code).catch(() => {}); + ready.value = true; }, { immediate: true }); // Ordered section ids, gated on which collection actually happens here. The diff --git a/frontend/components/advanced-tools/SecurityChecklist.vue b/frontend/components/advanced-tools/SecurityChecklist.vue index f128a0165..57a55e42a 100644 --- a/frontend/components/advanced-tools/SecurityChecklist.vue +++ b/frontend/components/advanced-tools/SecurityChecklist.vue @@ -257,7 +257,7 @@ import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; import { emitAppEvent } from '@/utils/app-events.js'; -import { fallbackChain } from '@/utils/locale-registry.js'; +import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js'; import { CircleProgressBar } from 'circle-progress.vue'; import VueMarkdown from 'vue-markdown-render'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; @@ -317,13 +317,11 @@ const { t, locale } = useI18n(); // The checklist dataset is large (~30 KB gzipped per language) and only this tool // reads it, so it's loaded on demand for the active locale instead of being baked -// into the initial i18n bundle (see frontend/locales/i18n.js). -// Discovered by glob, keyed by locale code; a locale with no dataset of its -// own resolves to the first one on its fallback chain that has one. -const securityDataPacks = import.meta.glob('../../locales/security-checklist/*.json'); -const securityDataLoaders = Object.fromEntries( - Object.entries(securityDataPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), -); +// into the initial i18n bundle (see frontend/locales/i18n.js). The shared loader +// walks the fallback chain, so a failed chunk degrades to the next language +// instead of a spinner that never stops. +const securityDataLoaders = datasetLoaders(import.meta.glob('../../locales/security-checklist/*.json')); +const securityDataCache = new Map(); const securityChecklist = ref(null); @@ -331,10 +329,15 @@ const securityChecklist = ref(null); // surfaces the template's existing loading state during the swap. const loadSecurityChecklist = async () => { fullList.value = null; - const load = fallbackChain(locale.value).map((code) => securityDataLoaders[code]).find(Boolean); - const { default: data } = await load(); - securityChecklist.value = data; - fullList.value = initSecurityList(securityChecklist.value); + const loc = locale.value; + const pack = await loadLocaleDataset(securityDataLoaders, loc, securityDataCache); + if (loc !== locale.value) return; // stale load — the locale changed mid-flight + if (!pack) { + console.error('Security checklist dataset unavailable in every locale'); + return; + } + securityChecklist.value = pack.data; + fullList.value = initSecurityList(pack.data); }; const store = useMainStore(); diff --git a/frontend/utils/locale-datasets.js b/frontend/utils/locale-datasets.js new file mode 100644 index 000000000..0a0f09929 --- /dev/null +++ b/frontend/utils/locale-datasets.js @@ -0,0 +1,36 @@ +// Loader for the optional per-locale datasets (privacy copy, the security +// checklist): JSON chunks discovered with import.meta.glob and fetched on +// demand for the active locale. One place owns the failure policy so every +// consumer behaves the same: a chunk that fails to load, or resolves without +// a default export, is skipped and the next locale on the fallback chain is +// tried; only when the whole chain fails does the caller get null. +// +// const loaders = datasetLoaders(import.meta.glob('../locales/privacy/*.json')); +// const pack = await loadLocaleDataset(loaders, locale.value, cache); +// // pack → { code, data } | null + +import { fallbackChain } from './locale-registry.js'; + +// glob result ({ '../locales/privacy/zh.json': loader }) → { zh: loader }. +export const datasetLoaders = (packs) => Object.fromEntries( + Object.entries(packs).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), +); + +// `cache` (a Map, per consumer) memoizes loaded data by pack code, so a locale +// switch back to a seen language never refetches. +export const loadLocaleDataset = async (loaders, code, cache = new Map()) => { + for (const candidate of fallbackChain(code)) { + if (cache.has(candidate)) return { code: candidate, data: cache.get(candidate) }; + const load = loaders?.[candidate]; + if (typeof load !== 'function') continue; + try { + const data = (await load())?.default; + if (!data) continue; + cache.set(candidate, data); + return { code: candidate, data }; + } catch (err) { + console.warn(`Locale dataset "${candidate}" failed to load, trying the next fallback:`, err); + } + } + return null; +}; diff --git a/tests/locale-datasets.test.js b/tests/locale-datasets.test.js new file mode 100644 index 000000000..694bc510f --- /dev/null +++ b/tests/locale-datasets.test.js @@ -0,0 +1,76 @@ +// Guards frontend/utils/locale-datasets.js — the shared failure policy for +// the optional per-locale datasets: a failing or empty chunk falls through to +// the next locale on the chain, the cache short-circuits refetches, and only +// a whole-chain failure yields null. + +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { datasetLoaders, loadLocaleDataset } from '../frontend/utils/locale-datasets.js'; + +const ok = (data) => async () => ({ default: data }); +const failing = (message) => async () => { throw new Error(message); }; +const empty = () => async () => ({}); + +let warn; +beforeEach(() => { warn = mock.method(console, 'warn', () => {}); }); +afterEach(() => { warn.mock.restore(); }); + +describe('datasetLoaders', () => { + it('keys glob entries by locale code', () => { + const zh = () => {}; + const en = () => {}; + const map = datasetLoaders({ '../locales/privacy/zh.json': zh, '../locales/privacy/en.json': en }); + assert.deepEqual(map, { zh, en }); + }); +}); + +describe('loadLocaleDataset', () => { + it('returns the active locale when its chunk loads', async () => { + const r = await loadLocaleDataset({ fr: ok('FR'), en: ok('EN') }, 'fr'); + assert.deepEqual(r, { code: 'fr', data: 'FR' }); + }); + + it('falls through the chain when the active chunk throws', async () => { + const r = await loadLocaleDataset({ fr: failing('boom'), en: ok('EN') }, 'fr'); + assert.deepEqual(r, { code: 'en', data: 'EN' }); + assert.equal(warn.mock.callCount(), 1); + }); + + it('treats a chunk without a default export like a missing one', async () => { + const r = await loadLocaleDataset({ fr: empty(), en: ok('EN') }, 'fr'); + assert.deepEqual(r, { code: 'en', data: 'EN' }); + }); + + it('walks regional → base → en', async () => { + const r = await loadLocaleDataset({ 'zh-TW': failing('x'), zh: ok('ZH'), en: ok('EN') }, 'zh-TW'); + assert.deepEqual(r, { code: 'zh', data: 'ZH' }); + const noBase = await loadLocaleDataset({ en: ok('EN') }, 'zh-TW'); + assert.deepEqual(noBase, { code: 'en', data: 'EN' }); + }); + + it('returns null only when the whole chain fails', async () => { + assert.equal(await loadLocaleDataset({ fr: failing('a'), en: failing('b') }, 'fr'), null); + assert.equal(await loadLocaleDataset({}, 'fr'), null); + assert.equal(await loadLocaleDataset(undefined, 'fr'), null); + }); + + it('caches by pack code and never refetches a cached pack', async () => { + const fr = mock.fn(ok('FR')); + const cache = new Map(); + await loadLocaleDataset({ fr, en: ok('EN') }, 'fr', cache); + const again = await loadLocaleDataset({ fr, en: ok('EN') }, 'fr', cache); + assert.deepEqual(again, { code: 'fr', data: 'FR' }); + assert.equal(fr.mock.callCount(), 1); + assert.equal(cache.get('fr'), 'FR'); + }); + + it('a cached fallback serves a locale whose own chunk keeps failing', async () => { + const cache = new Map(); + const loaders = { fr: failing('down'), en: ok('EN') }; + await loadLocaleDataset(loaders, 'fr', cache); + const r = await loadLocaleDataset(loaders, 'fr', cache); + assert.deepEqual(r, { code: 'en', data: 'EN' }); + assert.equal(cache.has('fr'), false); + }); +});