From 08ea809c2cd8124405def4b97f3ac54b3341ef67 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 16 Jun 2026 10:48:00 +0800 Subject: [PATCH 01/24] Improvements --- frontend/components/Footer.vue | 2 +- frontend/locales/en.json | 2 +- frontend/locales/fr.json | 2 +- frontend/locales/tr.json | 2 +- frontend/locales/zh.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/components/Footer.vue b/frontend/components/Footer.vue index 665143318..247fd203d 100644 --- a/frontend/components/Footer.vue +++ b/frontend/components/Footer.vue @@ -169,7 +169,7 @@ const sheetBody = ref(null); const personalLinks = [ { href: 'https://wujiaxian.com', labelKey: 'about.personal' }, { href: 'https://kenengba.com', labelKey: 'about.blog' }, - { href: 'https://fire.beavern.com', labelKey: 'about.retiremoney' }, + { href: 'https://www.linkedin.com/in/jason5ng32/', labelKey: 'about.linkedIn' }, { href: 'https://twitter.com/jason5ng32', labelKey: 'about.twitter' }, ]; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index c28ec4ac4..7c1d07a9e 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -841,7 +841,7 @@ "contact": "If you have any questions or suggestions, please contact me via email: jason[AT]kenengba.com.", "personal": "Personal Website", "blog": "My Blog", - "retiremoney": "Future Planner", + "linkedIn": "LinkedIn", "twitter": "Twitter", "Sponsor": "Sponsor" }, diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 7210d53d2..0ef53117a 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -841,7 +841,7 @@ "contact": "Si vous avez des questions ou des suggestions, veuillez me contacter par email : jason[AT]kenengba.com.", "personal": "Site personnel", "blog": "Mon blog", - "retiremoney": "Planificateur futur", + "linkedIn": "LinkedIn", "twitter": "Twitter", "Sponsor": "Sponsor" }, diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index 2acefcfa0..011e17987 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -841,7 +841,7 @@ "contact": "Herhangi bir sorunuz veya öneriniz varsa, lütfen benimle e-posta yoluyla iletişime geçin: jason[AT]kenengba.com.", "personal": "Kişisel Web Sitesi", "blog": "Blogum", - "retiremoney": "Gelecek Planlayıcısı", + "linkedIn": "LinkedIn", "twitter": "Twitter", "Sponsor": "Sponsor" }, diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 7db1b8cef..e82e33f35 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -841,7 +841,7 @@ "contact": "如果你有任何问题或建议,请通过邮箱联系我: jason[AT]kenengba.com", "personal": "个人网站", "blog": "可能吧博客", - "retiremoney": "躺平计算器", + "linkedIn": "LinkedIn", "twitter": "Twitter", "Sponsor": "赞助" }, From 976e971371743eacd3223373b590f5c9bfc048c8 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 16 Jun 2026 10:52:50 +0800 Subject: [PATCH 02/24] Feat(nav): surface Advanced Tools sub-tools in the navigation Turn the nav's "Advanced Tools" item into a discoverable menu of its sub-tools instead of a bare scroll link: - Desktop: a NavigationMenu (shadcn-vue) opens on hover; clicking the label keeps the original behavior of scrolling to the section. - Mobile drawer: an inline Collapsible, expanded by default, so phone users see the bottom-of-page tools when they open the nav. - Picking a tool scrolls to the Advanced Tools section, then opens that tool's in-page drawer (no jump to a standalone page). The list mirrors Advanced.vue's original-site gating so unavailable tools aren't shown. Also swap the Preferences icon to Cog (nav button + panel header), and add the navigation-menu primitive via shadcn-vue. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/Nav.vue | 112 ++++++++++++++++-- .../ui/navigation-menu/NavigationMenu.vue | 45 +++++++ .../navigation-menu/NavigationMenuContent.vue | 39 ++++++ .../NavigationMenuIndicator.vue | 33 ++++++ .../ui/navigation-menu/NavigationMenuItem.vue | 24 ++++ .../ui/navigation-menu/NavigationMenuLink.vue | 31 +++++ .../ui/navigation-menu/NavigationMenuList.vue | 30 +++++ .../navigation-menu/NavigationMenuTrigger.vue | 32 +++++ .../NavigationMenuViewport.vue | 32 +++++ .../components/ui/navigation-menu/index.js | 14 +++ frontend/components/widgets/Preferences.vue | 4 +- pnpm-lock.yaml | 26 ++-- 12 files changed, 397 insertions(+), 25 deletions(-) create mode 100644 frontend/components/ui/navigation-menu/NavigationMenu.vue create mode 100644 frontend/components/ui/navigation-menu/NavigationMenuContent.vue create mode 100644 frontend/components/ui/navigation-menu/NavigationMenuIndicator.vue create mode 100644 frontend/components/ui/navigation-menu/NavigationMenuItem.vue create mode 100644 frontend/components/ui/navigation-menu/NavigationMenuLink.vue create mode 100644 frontend/components/ui/navigation-menu/NavigationMenuList.vue create mode 100644 frontend/components/ui/navigation-menu/NavigationMenuTrigger.vue create mode 100644 frontend/components/ui/navigation-menu/NavigationMenuViewport.vue create mode 100644 frontend/components/ui/navigation-menu/index.js diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index f52b9a43f..fafd0649b 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -31,10 +31,38 @@ @@ -252,12 +258,14 @@ import { } from '@/components/ui/dropdown-menu'; import { Award, ChevronDown, HeartHandshake, - LogOut, Menu,Cog + LogOut, Menu, Cog, } from '@lucide/vue'; import { Icon } from '@iconify/vue'; import brandIcon from './svgicons/Brand.vue'; import { SECTION_IDS } from '@/data/sections'; import { ADVANCED_TOOLS } from '@/data/tools.js'; +import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js'; +import { formatStarCount } from '@/utils/format-star-count.js'; import { isRunningAsPwa } from '@/utils/pwa.js'; const { t } = useI18n(); @@ -285,6 +293,21 @@ const advancedTools = computed(() => // Mobile: Advanced Tools sub-list expanded by default for discoverability. const mobileToolsOpen = ref(true); +// GitHub star count for the repo badge. Fetched from our own edge-cached +// endpoint; stays null (badge hides the count) if the request fails. +const githubStars = ref(null); +const githubStarsLabel = computed(() => formatStarCount(githubStars.value)); +const fetchGithubStars = async () => { + try { + const res = await fetchWithTimeout('/api/github-stars'); + if (!res.ok) return; + const data = await res.json(); + if (typeof data.stars === 'number') githubStars.value = data.stars; + } catch { + /* leave the badge without a count */ + } +}; + // nav link style — current section highlight use bg-accent instead of only bold const navLinkClass = (item, { block = false } = {}) => { const base = 'rounded-md px-3 py-1.5 text-sm font-medium no-underline cursor-pointer transition-colors'; @@ -418,6 +441,7 @@ onMounted(() => { lastScrollY = window.scrollY; window.addEventListener('scroll', onScroll, { passive: true }); } + fetchGithubStars(); }); onBeforeUnmount(() => { diff --git a/frontend/utils/format-star-count.js b/frontend/utils/format-star-count.js new file mode 100644 index 000000000..702a4649c --- /dev/null +++ b/frontend/utils/format-star-count.js @@ -0,0 +1,27 @@ +// Compact formatter for the GitHub star count shown in the nav badge. +// Mirrors the shields.io style we replaced: < 1000 stays exact, larger values +// collapse to a one-decimal "k" / "M" (trailing ".0" dropped). Returns '' for +// anything that isn't a usable non-negative number, so the caller can simply +// hide the count when the fetch fails or hasn't landed yet. + +const UNITS = [ + { value: 1e6, suffix: 'M' }, + { value: 1e3, suffix: 'k' }, +]; + +export function formatStarCount(value) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return ''; + if (value < 1000) return String(Math.floor(value)); + + for (const { value: unit, suffix } of UNITS) { + if (value >= unit) { + const scaled = value / unit; + // Three-or-more-digit results (e.g. 123k) read better without a decimal. + const str = scaled >= 100 + ? String(Math.round(scaled)) + : scaled.toFixed(1).replace(/\.0$/, ''); + return str + suffix; + } + } + return String(Math.floor(value)); +} diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js index aacbf2c7a..8b6390b37 100644 --- a/tests/api-handlers.test.js +++ b/tests/api-handlers.test.js @@ -19,6 +19,7 @@ import getWhoisHandler from '../api/get-whois.js'; import cfRadarHandler from '../api/cf-radar.js'; import invisibilityHandler from '../api/invisibility-test.js'; import macCheckerHandler from '../api/mac-checker.js'; +import githubStarsHandler from '../api/github-stars.js'; import updateAchievementHandler from '../api/update-user-achievement.js'; import ipcheckIngHandler from '../api/ipcheck-ing.js'; import { getSessionResult as dnsLeakGetResult } from '../api/dns-leak-test.js'; @@ -153,6 +154,17 @@ describe('get-whois handler', () => { }); }); +// -- github-stars handler ------------------------------------------------- + +describe('github-stars handler', () => { + it('rejects non-GET with 405 before hitting GitHub', async () => { + const res = createResponse(); + await githubStarsHandler(createRequest({ method: 'POST' }), res); + assert.equal(res.statusCode, 405); + assert.deepEqual(res.body, { message: 'Method Not Allowed' }); + }); +}); + // -- cf-radar handler ----------------------------------------------------- describe('cf-radar handler', () => { diff --git a/tests/format-star-count.test.js b/tests/format-star-count.test.js new file mode 100644 index 000000000..581ac5860 --- /dev/null +++ b/tests/format-star-count.test.js @@ -0,0 +1,36 @@ +// Unit tests for the GitHub star-count compact formatter. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { formatStarCount } from '../frontend/utils/format-star-count.js'; + +describe('formatStarCount', () => { + it('returns exact integers below 1000', () => { + assert.equal(formatStarCount(0), '0'); + assert.equal(formatStarCount(7), '7'); + assert.equal(formatStarCount(999), '999'); + }); + + it('collapses thousands to one-decimal k, dropping trailing .0', () => { + assert.equal(formatStarCount(1000), '1k'); + assert.equal(formatStarCount(1234), '1.2k'); + assert.equal(formatStarCount(2000), '2k'); + assert.equal(formatStarCount(12345), '12.3k'); + }); + + it('drops the decimal once the k value reaches three digits', () => { + assert.equal(formatStarCount(123456), '123k'); + }); + + it('collapses millions to M', () => { + assert.equal(formatStarCount(1234567), '1.2M'); + }); + + it('returns empty string for unusable input', () => { + assert.equal(formatStarCount(null), ''); + assert.equal(formatStarCount(undefined), ''); + assert.equal(formatStarCount(NaN), ''); + assert.equal(formatStarCount(-5), ''); + assert.equal(formatStarCount('1234'), ''); + }); +}); From 42c8a8743132d1c36c7673d9266d9a016e64dfa0 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 16 Jun 2026 12:14:55 +0800 Subject: [PATCH 04/24] Feat(preferences): per-module startup auto-run + connectivity refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the single "Auto Run" switch into per-module switches (Connectivity / WebRTC / DNS Leak); IP info always runs and has no switch. Preferences move to the v7 key and any old `autoStart` value migrates onto the three switches. - use-refresh-orchestrator runs each module per its own switch and flags the rest loaded immediately, so allHasLoaded still resolves (it gates the info-mask button, the user-info fetch, the brand shimmer). - Connectivity summary toast now fires on manual runs too, not only at startup: handelCheckStart takes a boot/manual/refresh trigger. The multiple-refresh and notification options move to Program Settings (they apply to manual runs). - Fix language switching after the version bump: i18n reads the active language from the same prefs key as the store (shared via data/default-preferences.js), so a v6 → v7 migration no longer strands the saved choice. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/ConnectivityTest.vue | 18 +++++-- frontend/components/widgets/Preferences.vue | 54 +++++++++++++------ .../composables/use-refresh-orchestrator.js | 19 +++++-- frontend/composables/use-shortcuts.js | 2 +- frontend/data/changelog.json | 42 +++++++++++++++ frontend/data/default-preferences.js | 32 ++++++++++- frontend/locales/en.json | 2 +- frontend/locales/fr.json | 2 +- frontend/locales/i18n.js | 29 ++++++---- frontend/locales/tr.json | 2 +- frontend/locales/zh.json | 10 ++-- frontend/store.js | 29 +++++----- tests/composable-refresh-orchestrator.test.js | 52 +++++++++++++----- tests/default-preferences.test.js | 35 +++++++++++- tests/store.test.js | 43 ++++++++++----- 15 files changed, 284 insertions(+), 87 deletions(-) diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index 5b4a95585..428ad22b7 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -8,7 +8,7 @@ @@ -510,12 +510,20 @@ const finalizeMultiTestAlert = () => { }; // ── Main control ─────────────────────────────────────────────────────────── -const handelCheckStart = async (fromApp = false) => { +// `trigger` selects three behaviors (arming the toast = setting alertToShow): +// 'boot' — startup auto-run: arm toast, no card reset, auto pass (records rounds) +// 'manual' — section refresh button: arm toast, reset cards, single pass +// 'refresh' — global "refresh everything": suppress toast (global alert covers it), reset cards +const handelCheckStart = async (trigger = 'boot') => { const multi = multipleTests.value; - if (fromApp) await checkAllConnectivity(false, true, true); - else await checkAllConnectivity(true, false, false); + const isAuto = trigger === 'boot'; + const showToast = trigger !== 'refresh'; + const resetCards = trigger !== 'boot'; + await checkAllConnectivity(showToast, resetCards, !isAuto); store.setLoadingStatus('Connectivity', true); - if (multi) { + // Multi-round follow-ups are a startup-only feature; manual/global refreshes + // are a single pass and flag completion immediately so the toast can fire. + if (multi && isAuto) { intervalId.value = setInterval(async () => { if (counter.value < maxCounts.value && !manualRun.value) { await checkAllConnectivity(false, false, false); diff --git a/frontend/components/widgets/Preferences.vue b/frontend/components/widgets/Preferences.vue index 9b568d556..0a6f9e897 100644 --- a/frontend/components/widgets/Preferences.vue +++ b/frontend/components/widgets/Preferences.vue @@ -92,29 +92,46 @@ {{ t('nav.preferences.ipDBTips') }} + +
+ {{ t('nav.preferences.autoRun') }} +
+ + + + + +
+ {{ t('nav.preferences.autoRunTips') }} +
+
{{ t('nav.preferences.appSettings') }}
- - - - - + +
@@ -141,6 +158,7 @@ import { LayoutGrid, Moon, Palette, + Play, Cog, Sun, } from '@lucide/vue'; @@ -215,10 +233,14 @@ const prefSimpleMode = (value) => { trackEvent('Nav', 'PrefereceClick', 'SimpleMode'); }; -const prefAutoStart = (value) => { - store.updatePreference('autoStart', value); - trackEvent('Nav', 'PrefereceClick', 'AutoStart'); - if (isSignedIn.value && !value && !store.userAchievements.EnergySaver.achieved) { +// Per-module startup auto-run toggle. EnergySaver is earned once every auto-run +// module is off. +const prefAutoRun = (key, value) => { + store.updatePreference(key, value); + trackEvent('Nav', 'PrefereceClick', key); + const prefs = userPreferences.value; + const allOff = !prefs.autoRunConnectivity && !prefs.autoRunWebRTC && !prefs.autoRunDnsLeak; + if (isSignedIn.value && allOff && !store.userAchievements.EnergySaver.achieved) { store.setTriggerUpdateAchievements('EnergySaver'); } }; @@ -256,7 +278,7 @@ SectionTitle.props = ['icon']; const SectionTip = (props, { slots }) => h('p', { class: 'mt-2 text-xs text-muted-foreground leading-relaxed' }, slots.default?.()); -// App Settings switch row: label + tip on left, Switch on right +// App Settings switch row: label (+ optional tip) on left, Switch on right. const PrefRow = (props, { emit }) => h('div', { class: 'flex items-start justify-between gap-3 p-3' }, [ h('div', { class: 'flex-1 min-w-0' }, [ @@ -264,7 +286,9 @@ const PrefRow = (props, { emit }) => for: props.id, class: 'text-sm font-medium cursor-pointer select-none', }, props.label), - h('p', { class: 'mt-0.5 text-xs text-muted-foreground leading-relaxed' }, props.tip), + props.tip + ? h('p', { class: 'mt-0.5 text-xs text-muted-foreground leading-relaxed' }, props.tip) + : null, ]), h(Switch, { id: props.id, diff --git a/frontend/composables/use-refresh-orchestrator.js b/frontend/composables/use-refresh-orchestrator.js index 6c91c32b8..c4e766f28 100644 --- a/frontend/composables/use-refresh-orchestrator.js +++ b/frontend/composables/use-refresh-orchestrator.js @@ -43,7 +43,7 @@ export function useRefreshOrchestrator({ refs, store, t, userPreferences, infoMa const { IPCheckRef, connectivityRef, webRTCRef, dnsLeaksRef } = refs; scheduleTimedTasks([ { action: () => IPCheckRef.value.checkAllIPs(), delay: 0 }, - { action: () => connectivityRef.value.handelCheckStart(true), delay: 300 }, + { action: () => connectivityRef.value.handelCheckStart('refresh'), delay: 300 }, { action: () => webRTCRef.value.checkAllWebRTC(true), delay: 200 }, { action: () => dnsLeaksRef.value.checkAllDNSLeakTest(true), delay: 100 }, { action: () => refreshingAlert(), delay: 300 }, @@ -56,14 +56,25 @@ export function useRefreshOrchestrator({ refs, store, t, userPreferences, infoMa const { IPCheckRef, connectivityRef, webRTCRef, dnsLeaksRef } = refs; const mountedStatus = Object.values(store.mountingStatus).every(Boolean); if (mountedStatus) { + const prefs = userPreferences.value; + // IP info always runs on load — it has no per-module switch by design. setTimeout(() => IPCheckRef.value.checkAllIPs(), t1); - if (userPreferences.value.autoStart) { + // Each remaining module runs only if its switch is on; when off we + // flag it loaded immediately so allHasLoaded still resolves — it gates + // the info-mask button, the user-info fetch, and the brand shimmer. + if (prefs.autoRunConnectivity) { setTimeout(() => connectivityRef.value.handelCheckStart(), t2); - setTimeout(() => webRTCRef.value.checkAllWebRTC(false), t3); - setTimeout(() => dnsLeaksRef.value.checkAllDNSLeakTest(false), t4); } else { store.setLoadingStatus('Connectivity', true); + } + if (prefs.autoRunWebRTC) { + setTimeout(() => webRTCRef.value.checkAllWebRTC(false), t3); + } else { store.setLoadingStatus('WebRTC', true); + } + if (prefs.autoRunDnsLeak) { + setTimeout(() => dnsLeaksRef.value.checkAllDNSLeakTest(false), t4); + } else { store.setLoadingStatus('DNSLeakTest', true); } } else { diff --git a/frontend/composables/use-shortcuts.js b/frontend/composables/use-shortcuts.js index 2750f9b12..40a56a7b1 100644 --- a/frontend/composables/use-shortcuts.js +++ b/frontend/composables/use-shortcuts.js @@ -113,7 +113,7 @@ function buildShortcutConfig({ refs, store, t, configs, userPreferences, isSigne keys: 'c', action: () => { scrollToElement('Connectivity', 80); - connectivityRef.value.handelCheckStart(true); + connectivityRef.value.handelCheckStart('manual'); trackEvent('ShortCut', 'ShortCut', 'Connectivity'); }, description: t('shortcutKeys.RefreshConnectivityTests'), diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 1954c3c7e..e0b693b2b 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1267,5 +1267,47 @@ } } ] + }, + { + "version": "v6.6.0", + "date": "Beta", + "content": [ + { + "type": "add", + "change": { + "en": "Network Connectivity Test can now be set to multiple refreshes and display the minimum latency value", + "zh": "网络连通性测试可以设置多次刷新,并显示最小延迟值", + "fr": "Le test de connectivité réseau peut maintenant être configuré pour plusieurs rafraîchissements et afficher la valeur de latence minimale", + "tr": "Ağ bağlantı testi birden fazla yenileme ayarlanabilir ve minimum gecikme değeri görüntülenebilir" + } + }, + { + "type": "improve", + "change": { + "en": "Navigation bar now shows all advanced tools", + "zh": "导航栏展示所有高级工具的入口", + "fr": "La barre de navigation affiche désormais tous les outils avancés", + "tr": "Navigasyon çubuğu artık tüm gelişmiş araçları gösterir" + } + }, + { + "type": "improve", + "change": { + "en": "All modules on the homepage can now be individually controlled whether to run automatically", + "zh": "首页的所有模块现在可以单独控制是否自动运行", + "fr": "Tous les modules de la page d'accueil peuvent maintenant être contrôlés individuellement pour déterminer s'ils doivent être exécutés automatiquement", + "tr": "Ana sayfanın tüm modülleri artık bireysel olarak otomatik çalıştırılıp çalıştırılmayacağı kontrol edilebilir" + } + }, + { + "type": "fix", + "change": { + "en": "Fixed some issues", + "zh": "修复一些问题", + "fr": "Correction de petits problèmes", + "tr": "Birkaç küçük sorun düzeltildi" + } + } + ] } ] \ No newline at end of file diff --git a/frontend/data/default-preferences.js b/frontend/data/default-preferences.js index ca08b9988..605fb4159 100644 --- a/frontend/data/default-preferences.js +++ b/frontend/data/default-preferences.js @@ -3,11 +3,21 @@ // When userPreferences key is missing or missing fields in localStorage, use this default value as fallback. // store.loadPreferences() will merge localStorage override values. +// Versioned localStorage key for userPreferences; bump the suffix when stored +// values shouldn't merge onto a changed default. Shared with locales/i18n.js +// (which reads the active language from storage at boot) so the key can't drift. +export const PREFS_STORAGE_KEY = 'userPreferences_v7'; +export const LEGACY_PREFS_KEYS = ['userPreferences_v6', 'userPreferences']; + export const DEFAULT_PREFERENCES = Object.freeze({ theme: 'auto', // auto | light | dark connectivityMultipleTests: false, simpleMode: false, - autoStart: true, + // Per-module startup auto-run switches. IP info has no switch — it always + // runs on load. See use-refresh-orchestrator.js. + autoRunConnectivity: true, + autoRunWebRTC: true, + autoRunDnsLeak: true, popupConnectivityNotifications: false, ipCardsToShow: 2, ipGeoSource: 0, @@ -25,3 +35,23 @@ export const DEFAULT_PREFERENCES = Object.freeze({ export function createDefaultPreferences() { return { ...DEFAULT_PREFERENCES }; } + +/** + * Map a legacy preferences object onto the current schema: expand the single + * `autoStart` switch into the three per-module switches, pass everything else + * through. Pure — store.js does the localStorage read. + * + * @param {object|null} legacy parsed legacy preferences (or null/undefined) + * @returns {object} a writable object ready to spread over the defaults + */ +export function migrateLegacyPreferences(legacy) { + if (!legacy || typeof legacy !== 'object') return {}; + const migrated = { ...legacy }; + if (typeof legacy.autoStart === 'boolean') { + migrated.autoRunConnectivity = legacy.autoStart; + migrated.autoRunWebRTC = legacy.autoStart; + migrated.autoRunDnsLeak = legacy.autoStart; + } + delete migrated.autoStart; + return migrated; +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 7c1d07a9e..367967aef 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -160,7 +160,7 @@ "ipSourcesToCheckTips": "Select the number of servers to check your IP address. The more servers you select, the more time it will take to complete the check.", "appSettings": "Program Settings", "autoRun": "Auto Run", - "autoRunTips": "If disabled, the app will only check the local IP and will not run tests automatically.", + "autoRunTips": "IP info is always checked when the page loads. Choose which other modules run automatically on startup.", "simpleMode": "Simple Mode", "simpleModeTips": "Hide the description text of each module, suitable for users who are already familiar with the application.", "connectivityMultipleTests": "Multiple Connectivity Refreshes", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 0ef53117a..d4a0e7b3f 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -160,7 +160,7 @@ "ipSourcesToCheckTips": "Sélectionnez le nombre de serveurs pour vérifier votre adresse IP. Plus vous en sélectionnez, plus cela prendra de temps pour terminer la vérification.", "appSettings": "Paramètres de l'App", "autoRun": "Exécution Automatique", - "autoRunTips": "Si désactivé, l'appli vérifiera seulement l'IP local et n'exécutera pas de tests automatiquement.", + "autoRunTips": "Les infos IP sont toujours vérifiées au chargement de la page. Choisissez quels autres modules s'exécutent automatiquement au démarrage.", "simpleMode": "Mode Simple", "simpleModeTips": "Masque le texte de description de chaque module. Effectif uniquement sur les appareils mobiles.", "connectivityMultipleTests": "Rafraîchissements Multiples de Connectivité", diff --git a/frontend/locales/i18n.js b/frontend/locales/i18n.js index 58a98d335..0c32dded7 100644 --- a/frontend/locales/i18n.js +++ b/frontend/locales/i18n.js @@ -1,4 +1,5 @@ import { createI18n } from 'vue-i18n'; +import { PREFS_STORAGE_KEY, LEGACY_PREFS_KEYS } from '../data/default-preferences.js'; // Locale messages are loaded on demand so the first-paint path carries only the // language actually in use. Bundling all four eagerly cost ~44 KB gzipped of dead @@ -19,18 +20,28 @@ const localeLoaders = { const supportedLanguages = Object.keys(localeLoaders); const FALLBACK_LOCALE = 'en'; +// Read the saved language from localStorage, trying the current prefs key then +// the legacy ones (so a freshly bumped key still finds the choice before +// loadPreferences migrates it). Keys come from default-preferences.js so they +// stay in step with what store.js writes. +function readStoredLang() { + for (const key of [PREFS_STORAGE_KEY, ...LEGACY_PREFS_KEYS]) { + const raw = localStorage.getItem(key); + if (!raw) continue; + try { + const prefs = JSON.parse(raw); + if (supportedLanguages.includes(prefs?.lang)) return prefs.lang; + } catch { /* ignore malformed entry, try the next key */ } + } + return null; +} + // 设置语言 function setLanguage() { + const storedLang = readStoredLang(); + if (storedLang) return storedLang; + let locale = 'en'; - // Keep in sync with PREFS_STORAGE_KEY in frontend/store.js — both must read - // from the same versioned key so an old value doesn't mislead the i18n - // initialization into a previously-chosen language after we bumped defaults. - let storedPreferences = localStorage.getItem('userPreferences_v6'); - storedPreferences = storedPreferences ? JSON.parse(storedPreferences) : {}; - if (supportedLanguages.includes(storedPreferences.lang)) { - locale = storedPreferences.lang; - return locale; - } const searchParams = new URLSearchParams(window.location.search); const browserLanguage = navigator.language || navigator.userLanguage; const hl = searchParams.get('hl'); diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index 011e17987..442c8e626 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -160,7 +160,7 @@ "ipSourcesToCheckTips": "IP adresinizi kontrol etmek için sunucu sayısını seçin. Ne kadar çok sunucu seçerseniz, kontrolün tamamlanması o kadar uzun sürer.", "appSettings": "Program Ayarları", "autoRun": "Otomatik Çalıştır", - "autoRunTips": "Devre dışı bırakılırsa, uygulama yalnızca yerel IP'yi kontrol eder ve testleri otomatik olarak çalıştırmaz.", + "autoRunTips": "Sayfa yüklendiğinde IP bilgileri her zaman denetlenir. Diğer modüllerin başlangıçta otomatik çalışıp çalışmayacağını seçin.", "simpleMode": "Basit Mod", "simpleModeTips": "Her modülün açıklamasını gizler. Yalnızca mobil cihazlarda etkilidir.", "connectivityMultipleTests": "Çoklu Bağlantı Yenilemeleri", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index e82e33f35..838535c9e 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -110,7 +110,7 @@ }, "ResourceHog": { "Title": "费油的车", - "Meet": "设置多次刷新可用性" + "Meet": "设置多次刷新网络连通性" }, "MakingBigNews": { "Title": "搞大新闻", @@ -160,13 +160,13 @@ "ipSourcesToCheckTips": "选择默认从多少个 IP 检测服务器获取 IP 信息。", "appSettings": "程序设置", "autoRun": "自动运行", - "autoRunTips": "关闭后,打开应用将只会进行本机 IP 检测,不会自动运行其它的测试。", + "autoRunTips": "打开页面时始终会检测 IP 信息。你可以选择其它模块是否在启动时自动运行。", "simpleMode": "简洁模式", "simpleModeTips": "隐藏每个模块的说明文字,适合已经了解本应用的用户。", - "connectivityMultipleTests": "多次刷新可用性检测", + "connectivityMultipleTests": "多次刷新网络连通性检测", "connectivityMultipleTestsTips": "开启多次刷新后,程序在启动时将运行多次检测,并显示最小延迟值。", - "popupConnectivityNotifications": "显示可用性检测气泡", - "popupConnectivityNotificationsTips": "开启后,将会在首次检测时以气泡形式提示可用性结果。", + "popupConnectivityNotifications": "显示网络连通性检测气泡", + "popupConnectivityNotificationsTips": "开启后,将会在首次检测时以气泡形式提示网络连通性结果。", "ipDB": "IP 解析数据源", "ipDBTips": "你可以选择你默认使用的 IP 地理位置数据源,如果你选定的不可用,系统会依次使用后续的数据源。", "language": "语言设置", diff --git a/frontend/store.js b/frontend/store.js index 4b4f92202..73cdf5628 100644 --- a/frontend/store.js +++ b/frontend/store.js @@ -5,22 +5,11 @@ import { auth } from './firebase-init.js'; import i18n from './locales/i18n.js'; import { createInitialAchievementsState } from './data/achievements.js'; import { createInitialIpDBs, buildDbUrl } from './data/ip-databases.js'; -import { createDefaultPreferences } from './data/default-preferences.js'; +import { createDefaultPreferences, migrateLegacyPreferences, PREFS_STORAGE_KEY, LEGACY_PREFS_KEYS } from './data/default-preferences.js'; import { createMountingStatus, createLoadingStatus, DEFAULT_SECTION } from './data/sections.js'; import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; const { t } = i18n.global; -// Versioned localStorage key for userPreferences. -// -// When a release changes a default in a way that would be disruptive if -// merged on top of older stored overrides (e.g. repurposing an option, or -// when we simply want everyone to see the freshly-tuned defaults), bump -// this suffix. The mismatch leaves old stored values "orphaned" — the -// loader below won't find anything at the new key, falls back to defaults, -// and removes the legacy key(s) so browsers don't accumulate dead data. -const PREFS_STORAGE_KEY = 'userPreferences_v6'; -const LEGACY_PREFS_KEYS = ['userPreferences']; - export const useMainStore = defineStore('main', { state: () => ({ @@ -159,11 +148,17 @@ export const useMainStore = defineStore('main', { const currentPreferences = JSON.parse(storedPreferences); preferencesToStore = { ...defaultPreferences, ...currentPreferences }; } else { - preferencesToStore = defaultPreferences; - // First load on the current schema version — purge any older keys - // so we don't leave zombie entries in the browser forever. Running - // this only in the "fresh install" branch avoids racing users who - // have already migrated on another tab. + // No prefs at the current key yet: carry over the newest legacy + // snapshot (migrating retired keys), then purge the old keys. Purging + // only here avoids racing a tab that already migrated. + const legacyRaw = LEGACY_PREFS_KEYS + .map((key) => localStorage.getItem(key)) + .find((value) => value !== null); + let legacy = null; + if (legacyRaw) { + try { legacy = JSON.parse(legacyRaw); } catch { legacy = null; } + } + preferencesToStore = { ...defaultPreferences, ...migrateLegacyPreferences(legacy) }; for (const legacyKey of LEGACY_PREFS_KEYS) { if (localStorage.getItem(legacyKey) !== null) { localStorage.removeItem(legacyKey); diff --git a/tests/composable-refresh-orchestrator.test.js b/tests/composable-refresh-orchestrator.test.js index 479aedaf2..c4b4edc80 100644 --- a/tests/composable-refresh-orchestrator.test.js +++ b/tests/composable-refresh-orchestrator.test.js @@ -6,12 +6,18 @@ import { useRefreshOrchestrator } from '../frontend/composables/use-refresh-orch const t = (k) => `<${k}>`; -function makeStoreStub({ mountedFlags = {}, shouldRefresh = false, autoStart = false } = {}) { +function makeStoreStub({ mountedFlags = {}, shouldRefresh = false, autoRun = {} } = {}) { const state = reactive({ mountingStatus: { IPInfo: false, Connectivity: false, WebRTC: false, DNSLeakTest: false, ...mountedFlags }, loadingStatus: { IPInfo: false, Connectivity: false, WebRTC: false, DNSLeakTest: false }, shouldRefreshEveryThing: shouldRefresh, - userPreferences: { autoStart }, + // Per-module auto-run switches (default all off unless overridden). + userPreferences: { + autoRunConnectivity: false, + autoRunWebRTC: false, + autoRunDnsLeak: false, + ...autoRun, + }, alertHistory: [], }); return { @@ -46,10 +52,10 @@ describe('useRefreshOrchestrator()', () => { globalThis.setTimeout = realSetTimeout; }); - it('loadingControl: all cards mounted + autoStart=true triggers all four checks', () => { + it('loadingControl: all cards mounted + every module on triggers all four checks', () => { const store = makeStoreStub({ mountedFlags: { IPInfo: true, Connectivity: true, WebRTC: true, DNSLeakTest: true }, - autoStart: true, + autoRun: { autoRunConnectivity: true, autoRunWebRTC: true, autoRunDnsLeak: true }, }); const userPreferences = computed(() => store.state.userPreferences); const infoMaskLevel = ref(0); @@ -64,22 +70,19 @@ describe('useRefreshOrchestrator()', () => { assert.deepEqual(calls.dns, [false]); }); - it('loadingControl: autoStart=false skips auto checks and flags loading complete', () => { + it('loadingControl: every module off skips auto checks and flags loading complete', () => { const store = makeStoreStub({ mountedFlags: { IPInfo: true, Connectivity: true, WebRTC: true, DNSLeakTest: true }, - autoStart: false, + autoRun: { autoRunConnectivity: false, autoRunWebRTC: false, autoRunDnsLeak: false }, }); const userPreferences = computed(() => store.state.userPreferences); const infoMaskLevel = ref(0); const { refs, calls } = makeRefs(); - useRefreshOrchestrator({ refs, store, t, userPreferences, infoMaskLevel }); - - // Need to instantiate + call loadingControl — return value carries the action const { loadingControl } = useRefreshOrchestrator({ refs, store, t, userPreferences, infoMaskLevel }); loadingControl(); - assert.equal(calls.ip, 1); + assert.equal(calls.ip, 1, 'IP info always runs'); assert.deepEqual(calls.conn, [], 'connectivity should not auto-run'); assert.deepEqual(calls.web, [], 'webrtc should not auto-run'); assert.deepEqual(calls.dns, [], 'dns leak test should not auto-run'); @@ -88,8 +91,32 @@ describe('useRefreshOrchestrator()', () => { assert.equal(store.state.loadingStatus.DNSLeakTest, true); }); + it('loadingControl: per-module — only the enabled modules run, the rest flag loaded', () => { + const store = makeStoreStub({ + mountedFlags: { IPInfo: true, Connectivity: true, WebRTC: true, DNSLeakTest: true }, + autoRun: { autoRunConnectivity: true, autoRunWebRTC: false, autoRunDnsLeak: false }, + }); + const userPreferences = computed(() => store.state.userPreferences); + const infoMaskLevel = ref(0); + const { refs, calls } = makeRefs(); + + const { loadingControl } = useRefreshOrchestrator({ refs, store, t, userPreferences, infoMaskLevel }); + loadingControl(); + + assert.equal(calls.ip, 1); + assert.deepEqual(calls.conn, [undefined], 'connectivity runs'); + assert.deepEqual(calls.web, [], 'webrtc stays off'); + assert.deepEqual(calls.dns, [], 'dns stays off'); + // Connectivity flags itself loaded when its check resolves (not here); + // the two disabled modules are flagged loaded immediately. + assert.equal(store.state.loadingStatus.WebRTC, true); + assert.equal(store.state.loadingStatus.DNSLeakTest, true); + }); + it('watch: shouldRefreshEveryThing=true triggers full refresh, resets flag + mask', async () => { - const store = makeStoreStub({ autoStart: true }); + // Manual "refresh everything" runs every module regardless of the per-module + // auto-run switches, so the prefs here are irrelevant (left at defaults). + const store = makeStoreStub(); const userPreferences = computed(() => store.state.userPreferences); const infoMaskLevel = ref(2); const { refs, calls } = makeRefs(); @@ -101,7 +128,7 @@ describe('useRefreshOrchestrator()', () => { await nextTick(); assert.equal(calls.ip, 1, 'ipcheck refreshes'); - assert.deepEqual(calls.conn, [true], 'connectivity refresh with forced flag'); + assert.deepEqual(calls.conn, ['refresh'], 'connectivity refresh via the refresh trigger'); assert.deepEqual(calls.web, [true]); assert.deepEqual(calls.dns, [true]); assert.equal(infoMaskLevel.value, 0, 'info mask reset on refresh'); @@ -128,7 +155,6 @@ describe('useRefreshOrchestrator()', () => { const store = makeStoreStub({ // initially no card mounted mountedFlags: { IPInfo: false, Connectivity: false, WebRTC: false, DNSLeakTest: false }, - autoStart: false, }); const userPreferences = computed(() => store.state.userPreferences); const infoMaskLevel = ref(0); diff --git a/tests/default-preferences.test.js b/tests/default-preferences.test.js index 0bbc66c6e..18cdab7ad 100644 --- a/tests/default-preferences.test.js +++ b/tests/default-preferences.test.js @@ -4,6 +4,7 @@ import { describe, it } from 'node:test'; import { DEFAULT_PREFERENCES, createDefaultPreferences, + migrateLegacyPreferences, } from '../frontend/data/default-preferences.js'; describe('DEFAULT_PREFERENCES', () => { @@ -16,7 +17,9 @@ describe('DEFAULT_PREFERENCES', () => { theme: 'auto', connectivityMultipleTests: false, simpleMode: false, - autoStart: true, + autoRunConnectivity: true, + autoRunWebRTC: true, + autoRunDnsLeak: true, popupConnectivityNotifications: false, ipCardsToShow: 2, ipGeoSource: 0, @@ -26,6 +29,36 @@ describe('DEFAULT_PREFERENCES', () => { }); }); +describe('migrateLegacyPreferences()', () => { + it('maps a retired autoStart=false onto all three per-module switches', () => { + const out = migrateLegacyPreferences({ lang: 'zh', autoStart: false }); + assert.equal(out.autoRunConnectivity, false); + assert.equal(out.autoRunWebRTC, false); + assert.equal(out.autoRunDnsLeak, false); + assert.equal(out.lang, 'zh', 'other keys carry over'); + assert.ok(!('autoStart' in out), 'retired key is dropped'); + }); + + it('maps autoStart=true onto all three per-module switches', () => { + const out = migrateLegacyPreferences({ autoStart: true }); + assert.equal(out.autoRunConnectivity, true); + assert.equal(out.autoRunWebRTC, true); + assert.equal(out.autoRunDnsLeak, true); + }); + + it('leaves the per-module switches unset when autoStart is absent', () => { + const out = migrateLegacyPreferences({ theme: 'dark' }); + assert.ok(!('autoRunConnectivity' in out), 'falls through to defaults later'); + assert.equal(out.theme, 'dark'); + }); + + it('returns an empty object for null / non-object input', () => { + assert.deepEqual(migrateLegacyPreferences(null), {}); + assert.deepEqual(migrateLegacyPreferences(undefined), {}); + assert.deepEqual(migrateLegacyPreferences('nope'), {}); + }); +}); + describe('createDefaultPreferences()', () => { it('returns a writable copy with the same values', () => { const p = createDefaultPreferences(); diff --git a/tests/store.test.js b/tests/store.test.js index ead717ebb..57adcf44b 100644 --- a/tests/store.test.js +++ b/tests/store.test.js @@ -146,19 +146,19 @@ describe('store — trigger* actions', () => { describe('store — preferences', () => { it('setPreferences persists to localStorage', () => { const s = useMainStore(); - s.setPreferences({ lang: 'zh', autoStart: false }); - assert.deepEqual(s.userPreferences, { lang: 'zh', autoStart: false }); - const fromStorage = JSON.parse(globalThis.localStorage.getItem('userPreferences_v6')); - assert.deepEqual(fromStorage, { lang: 'zh', autoStart: false }); + s.setPreferences({ lang: 'zh', simpleMode: false }); + assert.deepEqual(s.userPreferences, { lang: 'zh', simpleMode: false }); + const fromStorage = JSON.parse(globalThis.localStorage.getItem('userPreferences_v7')); + assert.deepEqual(fromStorage, { lang: 'zh', simpleMode: false }); }); it('updatePreference mutates a single key AND persists', () => { const s = useMainStore(); - s.setPreferences({ lang: 'en', autoStart: true }); - s.updatePreference('autoStart', false); - assert.equal(s.userPreferences.autoStart, false); - const fromStorage = JSON.parse(globalThis.localStorage.getItem('userPreferences_v6')); - assert.equal(fromStorage.autoStart, false); + s.setPreferences({ lang: 'en', autoRunConnectivity: true }); + s.updatePreference('autoRunConnectivity', false); + assert.equal(s.userPreferences.autoRunConnectivity, false); + const fromStorage = JSON.parse(globalThis.localStorage.getItem('userPreferences_v7')); + assert.equal(fromStorage.autoRunConnectivity, false); }); it('loadPreferences seeds defaults when nothing stored', () => { @@ -168,16 +168,33 @@ describe('store — preferences', () => { // just assert the result is a non-empty object and gets persisted. assert.equal(typeof s.userPreferences, 'object'); assert.ok(Object.keys(s.userPreferences).length > 0); - assert.ok(globalThis.localStorage.getItem('userPreferences_v6')); + assert.ok(globalThis.localStorage.getItem('userPreferences_v7')); }); it('loadPreferences merges stored over defaults (stored keys win)', () => { - globalThis.localStorage.setItem('userPreferences_v6', JSON.stringify({ lang: 'zh' })); + globalThis.localStorage.setItem('userPreferences_v7', JSON.stringify({ lang: 'zh' })); const s = useMainStore(); s.loadPreferences(); assert.equal(s.userPreferences.lang, 'zh', 'stored lang wins'); - // A default key still present (autoStart exists in defaults) - assert.ok('autoStart' in s.userPreferences, 'default keys fill in missing slots'); + // A default key still present (autoRunConnectivity exists in defaults) + assert.ok('autoRunConnectivity' in s.userPreferences, 'default keys fill in missing slots'); + }); + + it('loadPreferences migrates a legacy v6 autoStart onto the per-module switches', () => { + globalThis.localStorage.setItem( + 'userPreferences_v6', + JSON.stringify({ lang: 'fr', autoStart: false }), + ); + const s = useMainStore(); + s.loadPreferences(); + assert.equal(s.userPreferences.lang, 'fr', 'other legacy keys carry over'); + assert.equal(s.userPreferences.autoRunConnectivity, false); + assert.equal(s.userPreferences.autoRunWebRTC, false); + assert.equal(s.userPreferences.autoRunDnsLeak, false); + assert.ok(!('autoStart' in s.userPreferences), 'retired key dropped'); + // Legacy key purged, value re-saved under the current key. + assert.equal(globalThis.localStorage.getItem('userPreferences_v6'), null); + assert.ok(globalThis.localStorage.getItem('userPreferences_v7')); }); }); From 827bd2c733b45a6e1e79db0c54ad635e18db5bf6 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 16 Jun 2026 12:15:01 +0800 Subject: [PATCH 05/24] Fix(info-mask): leave waiting / error placeholders unmasked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blur now skips IP slots showing a non-sensitive placeholder — WebRTC and DNS-leak waiting/error states, and the IPv4/IPv6 error strings — via a shared createMaskGate(t). A masked screenshot then shows the status word instead of a pointlessly blurred label; real addresses still blur. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/DnsLeaksTest.vue | 5 ++++- frontend/components/WebRtcTest.vue | 5 ++++- frontend/components/ip-infos/IPCard.vue | 5 ++++- frontend/composables/use-info-mask.js | 18 ++++++++++++++++++ tests/composable-info-mask.test.js | 21 ++++++++++++++++++++- 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index 16bb05901..fa5dad74a 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -50,7 +50,7 @@ + :class="textClass(toneOf(leak))" :data-mask="maskAttr(leak.ip)" /> @@ -122,6 +122,7 @@ import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { useStatusTone, ipFieldTone, isFieldPending as isFieldPendingShared } from '@/composables/use-status-tone.js'; +import { createMaskGate } from '@/composables/use-info-mask.js'; import { useMaxmind } from '@/composables/use-maxmind.js'; import { EthernetPort, Play, MapPin, RotateCw, Sparkles, ArrowRight, DoorOpen } from '@lucide/vue'; import { Icon } from '@iconify/vue'; @@ -140,6 +141,8 @@ const { t } = useI18n(); const store = useMainStore(); const router = useRouter(); const { lookupMaxmind } = useMaxmind(); +// Skip the info-mask blur on waiting/error placeholders (not a real IP). +const maskAttr = createMaskGate(t); const isStarted = ref(false); const userPreferences = computed(() => store.userPreferences); const isSimpleMode = computed(() => userPreferences.value.simpleMode); diff --git a/frontend/components/WebRtcTest.vue b/frontend/components/WebRtcTest.vue index 7be3f0bfe..2da2b1754 100644 --- a/frontend/components/WebRtcTest.vue +++ b/frontend/components/WebRtcTest.vue @@ -46,7 +46,7 @@ + :class="textClass(toneOf(stun))" :data-mask="maskAttr(stun.ip)" /> @@ -125,6 +125,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; import { useStatusTone, ipFieldTone, isFieldPending as isFieldPendingShared } from '@/composables/use-status-tone.js'; +import { createMaskGate } from '@/composables/use-info-mask.js'; import { useMaxmind } from '@/composables/use-maxmind.js'; import { Play, MapPin, EthernetPort, Flower, Network, RotateCw, FileText, ChevronDown } from '@lucide/vue'; import { Icon } from '@iconify/vue'; @@ -135,6 +136,8 @@ import { INLINE_TIERS } from '@/composables/use-fit-text.js'; const { t } = useI18n(); const store = useMainStore(); const userPreferences = computed(() => store.userPreferences); +// Skip the info-mask blur on waiting/error placeholders (not a real IP). +const maskAttr = createMaskGate(t); const isSimpleMode = computed(() => userPreferences.value.simpleMode); const { dotClass, textClass } = useStatusTone(); const { lookupMaxmind } = useMaxmind(); diff --git a/frontend/components/ip-infos/IPCard.vue b/frontend/components/ip-infos/IPCard.vue index 7fb3e08ff..d123852e1 100644 --- a/frontend/components/ip-infos/IPCard.vue +++ b/frontend/components/ip-infos/IPCard.vue @@ -37,7 +37,7 @@ -
+
t(key))); + return (value) => (placeholders.has(value) ? undefined : 'ip'); +} + const syncMaskAttribute = (level) => { if (typeof document === 'undefined') return; if (level === 0) { diff --git a/tests/composable-info-mask.test.js b/tests/composable-info-mask.test.js index f02ff139f..a37bfb355 100644 --- a/tests/composable-info-mask.test.js +++ b/tests/composable-info-mask.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it, beforeEach } from 'node:test'; import { reactive, nextTick } from 'vue'; -import { useInfoMask } from '../frontend/composables/use-info-mask.js'; +import { useInfoMask, createMaskGate } from '../frontend/composables/use-info-mask.js'; // minimal i18n stub: return key with prefix for assertions const t = (key) => `<${key}>`; @@ -65,3 +65,22 @@ describe('useInfoMask()', () => { }); }); }); + +describe('createMaskGate()', () => { + const maskAttr = createMaskGate(t); + + it("masks a real IP value with the 'ip' attribute", () => { + assert.equal(maskAttr('1.2.3.4'), 'ip'); + assert.equal(maskAttr('2001:db8::1'), 'ip'); + }); + + it('leaves waiting / error placeholders unmasked (undefined attr)', () => { + for (const key of [ + 'webrtc.StatusWait', 'webrtc.StatusError', + 'dnsleaktest.StatusWait', 'dnsleaktest.StatusError', + 'ipInfos.IPv4Error', 'ipInfos.IPv6Error', + ]) { + assert.equal(maskAttr(t(key)), undefined, `${key} should not be masked`); + } + }); +}); From a03a9933b41b555543134e3ac83e9e46325f6000 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 16 Jun 2026 12:15:09 +0800 Subject: [PATCH 06/24] Style: trim historical comments, document the comment rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut changelog-style narration from comments ("previously…", "replaced in v7", "…fixes that", the shields.io backstory) so they explain the current code, not how it got here. Codify this in AGENTS.md's Comments section. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + api/github-stars.js | 13 +++++-------- frontend/components/Nav.vue | 7 +++---- frontend/utils/format-star-count.js | 9 ++++----- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e74380ce..5a7cf0298 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ This project uses **pnpm** as its package manager (pinned via the `packageManage - **Every new file opens with a header comment** stating its purpose. One or two lines is usually enough; enough that a reader opening the file cold understands what it is. - **Large templates or functions carry block comments** on each meaningful section — enough for a maintainer six months later to orient quickly. Not every line, but every region / branch / step. +- **Comments describe the code as it is now, not how it got here.** Explain the current "why" — don't narrate past states or read like a changelog (`previously…`, `replaced X in v7`, `this used to…`, `…fixes that`). Git history covers the past. A comment should stay shorter than the code it explains; if it's growing into a story, cut it. ### i18n coverage diff --git a/api/github-stars.js b/api/github-stars.js index a0c85b7b3..6ec847fc8 100644 --- a/api/github-stars.js +++ b/api/github-stars.js @@ -1,11 +1,8 @@ -// /api/github-stars — stargazer count for this project's repo. -// -// Fetched from GitHub's public REST API without a token: `stargazers_count` is -// available unauthenticated, which sidesteps shields.io's token-pool outages -// ("Unable to select the next GitHub token from pool"). The route is edge-cached -// for a day (see backend-server.js), so behind Cloudflare the origin queries -// GitHub at most once per cache window — far under the 60 req/hour -// unauthenticated limit — and every other request is served from the CF edge. +// /api/github-stars — stargazer count for this repo, fetched from GitHub's +// public REST API without a token (`stargazers_count` is available +// unauthenticated). Edge-cached for a day (see backend-server.js), so behind +// Cloudflare the origin hits GitHub at most once per cache window — well under +// the 60 req/hour unauthenticated limit. import { fetchUpstream } from '../common/fetch-with-timeout.js'; import logger from '../common/logger.js'; diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index 7f142601c..93e0aef1f 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -63,10 +63,9 @@ {{ t(`nav.${item}`) }} - + Date: Tue, 16 Jun 2026 13:08:52 +0800 Subject: [PATCH 07/24] Improvements --- frontend/components/Nav.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index 93e0aef1f..80992fd83 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -176,14 +176,15 @@
- + - -
+ +
{{ t('nav.Navigation') }}
-
@@ -89,7 +83,7 @@ const { t } = useI18n(); // Skip the info-mask blur on the IPv4/IPv6 error placeholders (not a real IP). const maskAttr = createMaskGate(t); -const placeholderSizes = [12, 8, 6, 8, 4]; +const placeholderSizes = [12, 8, 6, 8, 4, 12, 8, 6, 2, 8, 4]; const props = defineProps({ card: { type: Object, required: true }, diff --git a/frontend/utils/authenticated-fetch.js b/frontend/utils/authenticated-fetch.js index 83adcb45d..2e9f3b07c 100644 --- a/frontend/utils/authenticated-fetch.js +++ b/frontend/utils/authenticated-fetch.js @@ -1,13 +1,20 @@ +// Authenticated fetch wrapper: attaches the Firebase ID token for /api/* proxy +// calls and enforces a client-side timeout so a hung backend can't pin the +// request open indefinitely. Every caller hits an /api/* proxy whose upstream is +// capped at 8s (fetchUpstream); the 10s client default sits just above that so +// the server's own error surfaces instead of the browser aborting first. import { useMainStore } from '../store'; +import { fetchWithTimeout } from './fetch-with-timeout.js'; -export async function authenticatedFetch(url, method = 'GET', body = null) { +export async function authenticatedFetch(url, method = 'GET', body = null, timeoutMs = 10000) { const store = useMainStore(); const options = { - method: method, + method, headers: { 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : null, // If body is provided, convert it to a JSON string + timeoutMs, }; // Check if the URL is a proxy API that needs authentication @@ -19,7 +26,9 @@ export async function authenticatedFetch(url, method = 'GET', body = null) { } try { - const response = await fetch(url, options); + // fetchWithTimeout aborts at timeoutMs; the AbortError lands in the catch + // below, so a stuck request fails fast and the caller can fail over. + const response = await fetchWithTimeout(url, options); if (!response.ok) { let errorDetail = ''; @@ -37,4 +46,4 @@ export async function authenticatedFetch(url, method = 'GET', body = null) { } catch (error) { throw new Error(`Fetch failed: ${error.message}`); } -} \ No newline at end of file +} From 93ce69ea12ce41b993319e32c1841ef21184714b Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 16 Jun 2026 20:02:37 +0800 Subject: [PATCH 10/24] Improvements --- frontend/components/advanced-tools/RuleTest.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/components/advanced-tools/RuleTest.vue b/frontend/components/advanced-tools/RuleTest.vue index 7e5bb0eb5..10d1cb880 100644 --- a/frontend/components/advanced-tools/RuleTest.vue +++ b/frontend/components/advanced-tools/RuleTest.vue @@ -19,7 +19,8 @@
-

+

{{ test.url }}

@@ -31,8 +32,7 @@ - @@ -207,7 +207,7 @@ const checkAchievements = () => { }; onMounted(() => { - setTimeout(() => { checkAllRuleTest(); }, 1000); + setTimeout(() => { checkAllRuleTest(); }, 300); }); watch(IPArray, () => { From c16c03b8815677a16552c652404fa0c71df473f8 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 17 Jun 2026 14:42:35 +0800 Subject: [PATCH 11/24] Improvements --- frontend/components/Nav.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index 910cecf29..5820de84a 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -44,10 +44,13 @@ {{ t(`nav.${item}`) }} -