mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 05:46:45 +08:00
feat: harden official sync scheduling and integrity
This commit is contained in:
@@ -7,12 +7,16 @@ use serde::Serialize;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const EXIT_ERROR: i32 = 1;
|
||||
const EXIT_LOCKED: i32 = 75;
|
||||
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
|
||||
const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60;
|
||||
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
||||
const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
|
||||
const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60];
|
||||
const DAILY_FORCED_REFRESH_LABEL: &str = "UTC+8 03:00, 16:00, 18:00";
|
||||
const STARTUP_BANNER: &str = r#"
|
||||
============================================================
|
||||
____ _ _ _ _ _____ _ _ _ _
|
||||
@@ -120,25 +124,64 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
let service = OfficialUpdateService::new();
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let mut next_forced_refresh_at = next_forced_refresh_at_or_after(SystemTime::now());
|
||||
let mut pending_scheduled_force = false;
|
||||
logger.log_text(
|
||||
"watch",
|
||||
format!(
|
||||
"daily forced refresh schedule: {DAILY_FORCED_REFRESH_LABEL}; next forced refresh in {}",
|
||||
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
|
||||
),
|
||||
);
|
||||
loop {
|
||||
let now = SystemTime::now();
|
||||
if now >= next_forced_refresh_at {
|
||||
pending_scheduled_force = true;
|
||||
logger.log_text(
|
||||
"watch",
|
||||
format!("scheduled forced refresh triggered at {DAILY_FORCED_REFRESH_LABEL}"),
|
||||
);
|
||||
next_forced_refresh_at = next_forced_refresh_after(now);
|
||||
}
|
||||
|
||||
let mut iteration_config = options.config.clone();
|
||||
if pending_scheduled_force {
|
||||
iteration_config.force = true;
|
||||
}
|
||||
|
||||
let mut sleep_for = options.interval;
|
||||
logger.log_text("watch", "starting watch iteration");
|
||||
match service.run_with_progress(&options.config, |event| logger.log(event)) {
|
||||
logger.log_text(
|
||||
"watch",
|
||||
if pending_scheduled_force {
|
||||
"starting watch iteration with scheduled force refresh"
|
||||
} else {
|
||||
"starting watch iteration"
|
||||
},
|
||||
);
|
||||
match service.run_with_progress(&iteration_config, |event| logger.log(event)) {
|
||||
Ok(report) => {
|
||||
if pending_scheduled_force {
|
||||
pending_scheduled_force = false;
|
||||
}
|
||||
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
}
|
||||
sleep_for =
|
||||
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
|
||||
logger.log_text(
|
||||
"watch",
|
||||
format!(
|
||||
"iteration finished with status={}; next check in {}",
|
||||
"iteration finished with status={}; next check in {}; next scheduled force refresh in {}",
|
||||
report.update_status.as_str(),
|
||||
format_duration(sleep_for)
|
||||
format_duration(sleep_for),
|
||||
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
sleep_for = options.error_retry_interval;
|
||||
sleep_for =
|
||||
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
|
||||
logger.log_text(
|
||||
"watch",
|
||||
format!(
|
||||
@@ -161,6 +204,62 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn next_forced_refresh_at_or_after(now: SystemTime) -> SystemTime {
|
||||
forced_refresh_time(now, ForcedRefreshBoundary::AtOrAfter)
|
||||
}
|
||||
|
||||
fn next_forced_refresh_after(now: SystemTime) -> SystemTime {
|
||||
forced_refresh_time(now, ForcedRefreshBoundary::After)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ForcedRefreshBoundary {
|
||||
AtOrAfter,
|
||||
After,
|
||||
}
|
||||
|
||||
fn forced_refresh_time(now: SystemTime, boundary: ForcedRefreshBoundary) -> SystemTime {
|
||||
let local_seconds = beijing_local_seconds_since_epoch(now);
|
||||
let local_day = local_seconds / SECONDS_PER_DAY;
|
||||
let local_second_of_day = local_seconds % SECONDS_PER_DAY;
|
||||
|
||||
for scheduled_second in DAILY_FORCED_REFRESH_LOCAL_SECONDS {
|
||||
let matches_boundary = match boundary {
|
||||
ForcedRefreshBoundary::AtOrAfter => scheduled_second >= local_second_of_day,
|
||||
ForcedRefreshBoundary::After => scheduled_second > local_second_of_day,
|
||||
};
|
||||
if matches_boundary {
|
||||
return system_time_from_beijing_local_seconds(
|
||||
local_day
|
||||
.saturating_mul(SECONDS_PER_DAY)
|
||||
.saturating_add(scheduled_second),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
system_time_from_beijing_local_seconds(
|
||||
local_day
|
||||
.saturating_add(1)
|
||||
.saturating_mul(SECONDS_PER_DAY)
|
||||
.saturating_add(DAILY_FORCED_REFRESH_LOCAL_SECONDS[0]),
|
||||
)
|
||||
}
|
||||
|
||||
fn beijing_local_seconds_since_epoch(time: SystemTime) -> u64 {
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.saturating_add(BEIJING_UTC_OFFSET_SECONDS)
|
||||
}
|
||||
|
||||
fn system_time_from_beijing_local_seconds(local_seconds: u64) -> SystemTime {
|
||||
UNIX_EPOCH + Duration::from_secs(local_seconds.saturating_sub(BEIJING_UTC_OFFSET_SECONDS))
|
||||
}
|
||||
|
||||
fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration {
|
||||
deadline.duration_since(now).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProgressLogger {
|
||||
enabled: bool,
|
||||
@@ -365,6 +464,9 @@ fn print_usage(binary: &str) {
|
||||
[--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \
|
||||
[--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date] [--progress|--no-progress] [--banner|--no-banner]"
|
||||
);
|
||||
eprintln!(
|
||||
"watch mode also runs scheduled force refreshes every day at {DAILY_FORCED_REFRESH_LABEL}"
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_duration(value: &str) -> anyhow::Result<Duration> {
|
||||
@@ -616,6 +718,35 @@ mod tests {
|
||||
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduled_forced_refresh_uses_beijing_local_times() {
|
||||
let day = 20_000;
|
||||
|
||||
assert_eq!(
|
||||
next_forced_refresh_at_or_after(beijing_time(day, 2, 30, 0)),
|
||||
beijing_time(day, 3, 0, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
next_forced_refresh_at_or_after(beijing_time(day, 3, 0, 0)),
|
||||
beijing_time(day, 3, 0, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
next_forced_refresh_after(beijing_time(day, 3, 0, 0)),
|
||||
beijing_time(day, 16, 0, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
next_forced_refresh_after(beijing_time(day, 18, 0, 0)),
|
||||
beijing_time(day + 1, 3, 0, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
duration_until(
|
||||
next_forced_refresh_at_or_after(beijing_time(day, 17, 45, 0)),
|
||||
beijing_time(day, 17, 45, 0)
|
||||
),
|
||||
Duration::from_secs(15 * 60)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_progress_durations() {
|
||||
assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007");
|
||||
@@ -642,4 +773,13 @@ mod tests {
|
||||
);
|
||||
assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded");
|
||||
}
|
||||
|
||||
fn beijing_time(day: u64, hour: u64, minute: u64, second: u64) -> SystemTime {
|
||||
system_time_from_beijing_local_seconds(
|
||||
day.saturating_mul(SECONDS_PER_DAY)
|
||||
.saturating_add(hour.saturating_mul(60 * 60))
|
||||
.saturating_add(minute.saturating_mul(60))
|
||||
.saturating_add(second),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user