diff --git a/scripts/release.cjs b/scripts/release.cjs index 219624c..779dc7e 100644 --- a/scripts/release.cjs +++ b/scripts/release.cjs @@ -33,6 +33,18 @@ const SKIP_GH = process.argv.includes('--skip-github') const SKIP_GITEA = process.argv.includes('--skip-gitea') const SKIP_GITEA_RELEASE = process.argv.includes('--skip-gitea-release') const CHANNEL_ONLY = process.argv.includes('--channel-only') +// Rejected before anything runs (no network, no uploads): --channel-only exists +// to republish the Gitea update channel, so pairing it with --skip-gitea asks +// for two opposite things. Without this check the CHANNEL_ONLY branch returns +// before the SKIP_GITEA warning is ever printed and the channel silently +// uploads anyway. (--skip-gitea-release is fine: channel-only never creates a +// release object.) +if (CHANNEL_ONLY && SKIP_GITEA) { + console.error('*** --channel-only and --skip-gitea are contradictory: --channel-only exists to ***') + console.error('*** publish the Gitea update channel. To republish just the channel, use ***') + console.error('*** --channel-only alone; to skip Gitea entirely, drop --channel-only. ***') + process.exit(1) +} /** Strip one pair of matching quotes. A .env quotes its values for the shell that would source it, so the quotes are syntax, not part of the token. */ diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index 0fd51fe..9574125 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -204,11 +204,19 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh * Preset patterns shipped by earlier versions, keyed by rule id. A built-in rule * whose pattern is still one of these was never edited by the user, so the * load-time refresh below may swap in the current preset for it. + * + * MAINTENANCE RULE: whenever a built-in preset's pattern changes, the pattern it + * replaces MUST be appended here in the same change — an unregistered old + * pattern means every install that shipped it keeps the stale pattern forever + * (the refresh only upgrades patterns it recognizes). The entries below are the + * complete set of historically changed presets, extracted from git history. */ const LEGACY_BUILTIN_PATTERNS: Record = { perm: ['^[-dlbcps][rwxstST-]{9}'], okstate: ['\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b'], - badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b'] + badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b'], + // v0.x preset, widened in 237265e (ftp/www alternation) + url: ['https?://[^\\s]+'] } /** Built-in ids of the preset set as it stood before `warnstate`/`danger` existed. */ diff --git a/src/main/updater.ts b/src/main/updater.ts index ead33cb..355a251 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -76,12 +76,24 @@ let eventsWired = false */ let checkInFlight: Promise | undefined let downloadInFlight: Promise | undefined -/** The periodic check timer; reassigned on every settings save. */ +/** The periodic check timer; rebuilt only when the configured interval changes. */ let intervalTimer: NodeJS.Timeout | undefined +/** The interval the current `intervalTimer` was armed with, so a settings save + * that does not touch the interval leaves the countdown running. */ +let scheduledIntervalHours: number | undefined /** Once per run: the startup check is armed the first time the schedule is * applied, and a later settings save must not schedule a second one. */ let startupArmed = false +/** + * Test seam only: the arming is per-run state with no reset path in the app + * itself. The self-test replays "the first apply of a run" with different + * startup-toggle states, which a single process cannot do otherwise. + */ +export function __resetStartupArmForTests(): void { + startupArmed = false +} + /** * What has already been done for ONE release, so an app left running for days * does not deal with the same version over and over: every 4 hours the periodic @@ -517,33 +529,39 @@ export function configureAutoUpdater(): void { // The startup check and the periodic one are armed by applyUpdateSchedule // (wired in index.ts through settingsStore's injected applier) instead of - // here: a settings save must re-arm the schedule, not add a second timer. + // here: a settings save feeds the same applier, which rebuilds the timer only + // when the interval changed. } /** * Arm (or re-arm) the update schedule from the current settings. Called by * settingsStore's injected applier on startup and after every settings save, so - * it must be idempotent: the previous interval timer is dropped first, and the - * startup check is armed at most once per run — a settings save two seconds into - * the run must not schedule a second "startup" check. + * it must be idempotent: + * + * - the startup check is armed at most once per run, on the process's FIRST + * call — and the arming is consumed even when the user left 「启动时自动检 + * 查」off, so enabling it mid-session arms only the periodic timer (a + * "startup" check hours into the session would contradict the label); + * - the periodic timer is rebuilt only when the configured interval actually + * changes. Every settings save re-runs this function, and an unconditional + * rebuild would reset the countdown on keystroke-level writes (color + * pickers, text fields) — a 4h check could be postponed forever. * * Dev builds schedule nothing (there is no feed), which is also why the startup * flag is not set on the way out. */ export function applyUpdateSchedule(system: SystemSettings): void { - if (intervalTimer) { - clearInterval(intervalTimer) - intervalTimer = undefined - } if (!app.isPackaged) return - // Startup check only when the user left it enabled (设置 → 关于). Left alone by - // later calls, and unref'd like every other timer here: a pending check must - // never keep the app (or a test process) alive. - if (!startupArmed && system.autoCheckUpdate !== false) { + // Startup check only when the user left it enabled (设置 → 关于). Unref'd like + // every other timer here: a pending check must never keep the app (or a test + // process) alive. + if (!startupArmed) { startupArmed = true - const startup = setTimeout(() => void runCheck('startup'), budgets.startupDelay) - startup.unref?.() + if (system.autoCheckUpdate !== false) { + const startup = setTimeout(() => void runCheck('startup'), budgets.startupDelay) + startup.unref?.() + } } // 0 = never: the startup and the manual check are all that is left. Any other @@ -552,10 +570,17 @@ export function applyUpdateSchedule(system: SystemSettings): void { const hours = isUpdateCheckInterval(system.updateCheckIntervalHours) ? system.updateCheckIntervalHours : FALLBACK_CHECK_INTERVAL_HOURS - if (hours > 0) { - const timer = setInterval(() => void runCheck('scheduled'), hours * budgets.hour) - timer.unref?.() - intervalTimer = timer + if (hours !== scheduledIntervalHours) { + if (intervalTimer) { + clearInterval(intervalTimer) + intervalTimer = undefined + } + scheduledIntervalHours = hours + if (hours > 0) { + const timer = setInterval(() => void runCheck('scheduled'), hours * budgets.hour) + timer.unref?.() + intervalTimer = timer + } } // Auto-download switched on while an update was already found: start now, the diff --git a/tests/settings-store.mjs b/tests/settings-store.mjs index 9b874d5..a482ec7 100644 --- a/tests/settings-store.mjs +++ b/tests/settings-store.mjs @@ -493,6 +493,15 @@ console.log('[preset refresh]') okstate?.priority === 10 && okstate?.color.fg === '#123456' && okstate?.enabled === true, 'a refreshed rule keeps the priority, colour and switch the user left it at' ) + // The very first url preset (https?:// only, widened by ftp/www alternation + // later) is a registered legacy pattern too — an early install upgrades + // exactly like okstate above. This regressed silently before the url entry + // was added to LEGACY_BUILTIN_PATTERNS. + const url = upgraded.find((r) => r.id === 'url') + ok( + typeof url?.pattern === 'string' && url.pattern.includes('ftp://') && url.pattern !== V1.url, + 'the early url preset (https?:// only) is refreshed to the current one' + ) const addedSince = [ 'warnstate', 'danger', @@ -524,6 +533,14 @@ console.log('[preset refresh]') 'a hand-edited built-in rule is left alone' ) + // The legacy url pattern on a NON-builtin rule must not be refreshed either. + const nonBuiltin = v1Set().map((r) => (r.id === 'url' ? { ...r, builtin: false } : r)) + const afterNonBuiltin = (await roundTrip({ highlightRules: nonBuiltin })).highlightRules + ok( + afterNonBuiltin.find((r) => r.id === 'url')?.pattern === V1.url, + 'the legacy url pattern on a non-builtin rule is left alone' + ) + // ...and so must a rule the user deleted: it must not come back. const pruned = v1Set().filter((r) => r.id !== 'numbers') const afterPrune = (await roundTrip({ highlightRules: pruned })).highlightRules diff --git a/tests/updater-fallback.mjs b/tests/updater-fallback.mjs index d68fe28..5c1e724 100644 --- a/tests/updater-fallback.mjs +++ b/tests/updater-fallback.mjs @@ -591,9 +591,10 @@ console.log('install') * release at a time, is dropped by a manual check and is replaced wholesale * when a different version shows up — so each scenario uses its own version * number (7.x and 9.9.9 are spent by section 7), or starts from a manual check; - * - the "startup check armed" flag is per run too: the first `applyUpdateSchedule` - * that allows startup checks is the only one that can arm it, which is why - * sections 10-13 run in this order. + * - the "startup check armed" flag is consumed by the process's FIRST + * `applyUpdateSchedule` call regardless of the startup toggle; the sections + * below replay "the first apply of a run" through the __resetStartupArmForTests + * seam, so their relative order no longer encodes the arming state. * `reset()` re-points the feeds and the check/download doubles, so those do not * leak between sections. */ @@ -677,6 +678,9 @@ console.log('interval 0: the periodic check is off, the startup check still runs ctl.checkForUpdates = recordCheck fetchImpl = () => Promise.resolve(errResponse(503)) + // Replay the first apply of a run with the toggle already on (the section + // above consumed the real one with the toggle off). + updater.__resetStartupArmForTests() const from = checkLog.length updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 }) await sleep(150) @@ -686,6 +690,64 @@ console.log('interval 0: the periodic check is off, the startup check still runs ok(startupChecks() === 1, 'the run has seen its startup check exactly once') } +console.log('a settings save that keeps the interval does not restart the countdown') +{ + reset() + stub.__setPackaged(true) + updater.setUpdateTimeouts({ startupDelay: 20, hour: 5 }) + writeSystem({ autoCheckUpdate: false, updateCheckIntervalHours: 24, autoDownloadUpdate: false }) + ctl.checkForUpdates = recordCheck + fetchImpl = () => Promise.resolve(errResponse(503)) + + // 24h at 5ms/hour = a 120ms period. The first apply starts the countdown; + // 50ms in — mid-period — an unrelated settings save re-runs the applier with + // the very same interval. If that rebuilt the timer, the next tick would land + // a full period after the re-apply (~120ms); if the countdown survives, it + // lands ~70ms after it. + const from = checkLog.length + updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 24 }) + await sleep(50) + const reappliedAt = checkLog.length // === from: mid-period, nothing fired yet + const reappliedWall = Date.now() + updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 24 }) + let guard = 0 + while (checkLog.length === reappliedAt && guard++ < 200) await sleep(5) + const gap = Date.now() - reappliedWall + ok(checkLog.length > reappliedAt, 'the periodic timer kept ticking across the re-apply') + ok(gap < 100, `the countdown was not restarted (next tick ${gap}ms after the re-apply; a restart would be ~120ms)`) + + // Leave the section clean: stop the timer so its phase cannot bleed into the + // cadence section below. + updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 0 }) +} + +console.log('enabling the startup check mid-session does not fire a startup check') +{ + reset() + stub.__setPackaged(true) + updater.setUpdateTimeouts({ startupDelay: 20, hour: 5 }) + writeSystem({ autoCheckUpdate: false, updateCheckIntervalHours: 0, autoDownloadUpdate: false }) + ctl.checkForUpdates = recordCheck + fetchImpl = () => Promise.resolve(errResponse(503)) + + // Replay a run whose FIRST apply sees the toggle off: the arming is consumed + // silently. Flipping the toggle on later must arm only future behavior (the + // periodic timer), never retroactively fire a "startup" check hours into the + // session — the settings label promises a check *at startup*. + updater.__resetStartupArmForTests() + const before = checkLog.length + updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 0 }) + await sleep(60) // well past the 20ms startup delay + ok(checkLog.length === before, 'the toggle-off first apply scheduled nothing') + + updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 }) + await sleep(80) + ok( + checkLog.length === before && startupChecks() === 1, + 'enabling mid-session fires no startup check (the earlier section still owns the only one)' + ) +} + console.log('re-applying the schedule replaces the cadence (and 0 stops it)') { reset()