fix(main): W1 四项修复
- updater: applyUpdateSchedule 记忆 interval,未变化不重建定时器 - updater: startupArmed 进程首次调用即消费,中途拨开只影响周期定时器 - settingsStore: LEGACY_BUILTIN_PATTERNS 补 url 预设(唯一漏登记项) - release.cjs: --channel-only 与 --skip-gitea 组合 fail-fast exit 1
This commit is contained in:
1 parent
9036bea6f3
commit
69d24fbed6
5 files changed
+147
-23
No files matched your search
@@ -33,6 +33,18 @@ const SKIP_GH = process.argv.includes('--skip-github')
|
|||||||
const SKIP_GITEA = process.argv.includes('--skip-gitea')
|
const SKIP_GITEA = process.argv.includes('--skip-gitea')
|
||||||
const SKIP_GITEA_RELEASE = process.argv.includes('--skip-gitea-release')
|
const SKIP_GITEA_RELEASE = process.argv.includes('--skip-gitea-release')
|
||||||
const CHANNEL_ONLY = process.argv.includes('--channel-only')
|
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
|
/** 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. */
|
that would source it, so the quotes are syntax, not part of the token. */
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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.
|
* 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<string, string[]> = {
|
const LEGACY_BUILTIN_PATTERNS: Record<string, string[]> = {
|
||||||
perm: ['^[-dlbcps][rwxstST-]{9}'],
|
perm: ['^[-dlbcps][rwxstST-]{9}'],
|
||||||
okstate: ['\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b'],
|
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. */
|
/** Built-in ids of the preset set as it stood before `warnstate`/`danger` existed. */
|
||||||
|
|||||||
+44
-19
@@ -76,12 +76,24 @@ let eventsWired = false
|
|||||||
*/
|
*/
|
||||||
let checkInFlight: Promise<UpdateState> | undefined
|
let checkInFlight: Promise<UpdateState> | undefined
|
||||||
let downloadInFlight: Promise<void> | undefined
|
let downloadInFlight: Promise<void> | 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
|
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
|
/** 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. */
|
* applied, and a later settings save must not schedule a second one. */
|
||||||
let startupArmed = false
|
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
|
* 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
|
* 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
|
// The startup check and the periodic one are armed by applyUpdateSchedule
|
||||||
// (wired in index.ts through settingsStore's injected applier) instead of
|
// (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
|
* 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
|
* 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
|
* it must be idempotent:
|
||||||
* startup check is armed at most once per run — a settings save two seconds into
|
*
|
||||||
* the run must not schedule a second "startup" check.
|
* - 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
|
* Dev builds schedule nothing (there is no feed), which is also why the startup
|
||||||
* flag is not set on the way out.
|
* flag is not set on the way out.
|
||||||
*/
|
*/
|
||||||
export function applyUpdateSchedule(system: SystemSettings): void {
|
export function applyUpdateSchedule(system: SystemSettings): void {
|
||||||
if (intervalTimer) {
|
|
||||||
clearInterval(intervalTimer)
|
|
||||||
intervalTimer = undefined
|
|
||||||
}
|
|
||||||
if (!app.isPackaged) return
|
if (!app.isPackaged) return
|
||||||
|
|
||||||
// Startup check only when the user left it enabled (设置 → 关于). Left alone by
|
// Startup check only when the user left it enabled (设置 → 关于). Unref'd like
|
||||||
// later calls, and unref'd like every other timer here: a pending check must
|
// every other timer here: a pending check must never keep the app (or a test
|
||||||
// never keep the app (or a test process) alive.
|
// process) alive.
|
||||||
if (!startupArmed && system.autoCheckUpdate !== false) {
|
if (!startupArmed) {
|
||||||
startupArmed = true
|
startupArmed = true
|
||||||
const startup = setTimeout(() => void runCheck('startup'), budgets.startupDelay)
|
if (system.autoCheckUpdate !== false) {
|
||||||
startup.unref?.()
|
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
|
// 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)
|
const hours = isUpdateCheckInterval(system.updateCheckIntervalHours)
|
||||||
? system.updateCheckIntervalHours
|
? system.updateCheckIntervalHours
|
||||||
: FALLBACK_CHECK_INTERVAL_HOURS
|
: FALLBACK_CHECK_INTERVAL_HOURS
|
||||||
if (hours > 0) {
|
if (hours !== scheduledIntervalHours) {
|
||||||
const timer = setInterval(() => void runCheck('scheduled'), hours * budgets.hour)
|
if (intervalTimer) {
|
||||||
timer.unref?.()
|
clearInterval(intervalTimer)
|
||||||
intervalTimer = timer
|
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
|
// Auto-download switched on while an update was already found: start now, the
|
||||||
|
|||||||
@@ -493,6 +493,15 @@ console.log('[preset refresh]')
|
|||||||
okstate?.priority === 10 && okstate?.color.fg === '#123456' && okstate?.enabled === true,
|
okstate?.priority === 10 && okstate?.color.fg === '#123456' && okstate?.enabled === true,
|
||||||
'a refreshed rule keeps the priority, colour and switch the user left it at'
|
'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 = [
|
const addedSince = [
|
||||||
'warnstate',
|
'warnstate',
|
||||||
'danger',
|
'danger',
|
||||||
@@ -524,6 +533,14 @@ console.log('[preset refresh]')
|
|||||||
'a hand-edited built-in rule is left alone'
|
'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.
|
// ...and so must a rule the user deleted: it must not come back.
|
||||||
const pruned = v1Set().filter((r) => r.id !== 'numbers')
|
const pruned = v1Set().filter((r) => r.id !== 'numbers')
|
||||||
const afterPrune = (await roundTrip({ highlightRules: pruned })).highlightRules
|
const afterPrune = (await roundTrip({ highlightRules: pruned })).highlightRules
|
||||||
|
|||||||
@@ -591,9 +591,10 @@ console.log('install')
|
|||||||
* release at a time, is dropped by a manual check and is replaced wholesale
|
* 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
|
* 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;
|
* 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`
|
* - the "startup check armed" flag is consumed by the process's FIRST
|
||||||
* that allows startup checks is the only one that can arm it, which is why
|
* `applyUpdateSchedule` call regardless of the startup toggle; the sections
|
||||||
* sections 10-13 run in this order.
|
* 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
|
* `reset()` re-points the feeds and the check/download doubles, so those do not
|
||||||
* leak between sections.
|
* leak between sections.
|
||||||
*/
|
*/
|
||||||
@@ -677,6 +678,9 @@ console.log('interval 0: the periodic check is off, the startup check still runs
|
|||||||
ctl.checkForUpdates = recordCheck
|
ctl.checkForUpdates = recordCheck
|
||||||
fetchImpl = () => Promise.resolve(errResponse(503))
|
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
|
const from = checkLog.length
|
||||||
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
|
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
|
||||||
await sleep(150)
|
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')
|
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)')
|
console.log('re-applying the schedule replaces the cadence (and 0 stops it)')
|
||||||
{
|
{
|
||||||
reset()
|
reset()
|
||||||
|
|||||||
Reference in new issue
Block a user