mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
merge: sync with upstream v1.3.4, resolve conflicts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
cae0950301
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-monitor"
|
||||
version = "1.3.1"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"native-tls",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-monitor"
|
||||
version = "1.3.1"
|
||||
version = "1.3.4"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Claude Code Usage Monitor"
|
||||
|
||||
@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings {
|
||||
day_suffix: "d",
|
||||
hour_suffix: "h",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Claude Code Auth Error",
|
||||
token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.",
|
||||
second_suffix: "s",
|
||||
quiet_hours: "Quiet Hours",
|
||||
quiet_start: "Start",
|
||||
|
||||
@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings {
|
||||
day_suffix: "j",
|
||||
hour_suffix: "h",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Erreur d'authentification",
|
||||
token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.",
|
||||
second_suffix: "s",
|
||||
quiet_hours: "Quiet Hours",
|
||||
quiet_start: "Start",
|
||||
|
||||
@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings {
|
||||
day_suffix: "T",
|
||||
hour_suffix: "h",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Authentifizierungsfehler",
|
||||
token_expired_body: "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu.",
|
||||
second_suffix: "s",
|
||||
quiet_hours: "Quiet Hours",
|
||||
quiet_start: "Start",
|
||||
|
||||
@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings {
|
||||
day_suffix: "日",
|
||||
hour_suffix: "時間",
|
||||
minute_suffix: "分",
|
||||
token_expired_title: "認証エラー",
|
||||
token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。",
|
||||
second_suffix: "秒",
|
||||
quiet_hours: "サイレント時間",
|
||||
quiet_start: "開始",
|
||||
|
||||
@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings {
|
||||
day_suffix: "일",
|
||||
hour_suffix: "시간",
|
||||
minute_suffix: "분",
|
||||
token_expired_title: "인증 오류",
|
||||
token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
|
||||
second_suffix: "초",
|
||||
quiet_hours: "조용한 시간",
|
||||
quiet_start: "시작",
|
||||
|
||||
@ -147,6 +147,8 @@ pub struct Strings {
|
||||
pub hour_suffix: &'static str,
|
||||
pub minute_suffix: &'static str,
|
||||
pub second_suffix: &'static str,
|
||||
pub token_expired_title: &'static str,
|
||||
pub token_expired_body: &'static str,
|
||||
pub quiet_hours: &'static str,
|
||||
pub quiet_start: &'static str,
|
||||
pub quiet_end: &'static str,
|
||||
|
||||
@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings {
|
||||
day_suffix: "d",
|
||||
hour_suffix: "h",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Error de autenticación",
|
||||
token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.",
|
||||
second_suffix: "s",
|
||||
quiet_hours: "Quiet Hours",
|
||||
quiet_start: "Start",
|
||||
|
||||
@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings {
|
||||
day_suffix: "天",
|
||||
hour_suffix: "時",
|
||||
minute_suffix: "分",
|
||||
token_expired_title: "驗證錯誤",
|
||||
token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。",
|
||||
second_suffix: "秒",
|
||||
quiet_hours: "安靜時刻",
|
||||
quiet_start: "開始時間",
|
||||
|
||||
150
src/poller.rs
150
src/poller.rs
@ -17,11 +17,20 @@ const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PollError {
|
||||
AuthRequired,
|
||||
NoCredentials,
|
||||
TokenExpired,
|
||||
RequestFailed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CredentialWatchMode {
|
||||
ActiveSource,
|
||||
AllSources,
|
||||
}
|
||||
|
||||
pub type CredentialWatchSnapshot = Vec<String>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsageResponse {
|
||||
five_hour: Option<UsageBucket>,
|
||||
@ -234,23 +243,112 @@ fn build_agent() -> Result<ureq::Agent, PollError> {
|
||||
.build())
|
||||
}
|
||||
|
||||
pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot {
|
||||
let sources = match mode {
|
||||
CredentialWatchMode::ActiveSource => read_credentials()
|
||||
.map(|creds| vec![creds.source])
|
||||
.unwrap_or_else(all_known_credential_sources),
|
||||
CredentialWatchMode::AllSources => all_known_credential_sources(),
|
||||
};
|
||||
|
||||
let mut snapshot: CredentialWatchSnapshot = sources
|
||||
.into_iter()
|
||||
.filter_map(|source| credential_watch_signature(&source))
|
||||
.collect();
|
||||
snapshot.sort();
|
||||
snapshot.dedup();
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn all_known_credential_sources() -> Vec<CredentialSource> {
|
||||
let mut sources = Vec::new();
|
||||
if let Some(source) = windows_credential_source() {
|
||||
sources.push(source);
|
||||
}
|
||||
for distro in list_wsl_distros() {
|
||||
sources.push(CredentialSource::Wsl { distro });
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
fn windows_credential_source() -> Option<CredentialSource> {
|
||||
let home = dirs::home_dir()?;
|
||||
Some(CredentialSource::Windows(
|
||||
home.join(".claude").join(".credentials.json"),
|
||||
))
|
||||
}
|
||||
|
||||
fn credential_watch_signature(source: &CredentialSource) -> Option<String> {
|
||||
match source {
|
||||
CredentialSource::Windows(path) => Some(windows_credential_watch_signature(path)),
|
||||
CredentialSource::Wsl { distro } => wsl_credential_watch_signature(distro),
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_credential_watch_signature(path: &PathBuf) -> String {
|
||||
let key = format!("win:{}", path.display());
|
||||
match std::fs::metadata(path) {
|
||||
Ok(metadata) => {
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|value| value.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("{key}|present|{}|{modified}", metadata.len())
|
||||
}
|
||||
Err(_) => format!("{key}|missing"),
|
||||
}
|
||||
}
|
||||
|
||||
fn wsl_credential_watch_signature(distro: &str) -> Option<String> {
|
||||
let output = run_with_timeout(
|
||||
Command::new("wsl.exe")
|
||||
.arg("-d")
|
||||
.arg(distro)
|
||||
.arg("--")
|
||||
.arg("sh")
|
||||
.arg("-lc")
|
||||
.arg(
|
||||
"if [ -f ~/.claude/.credentials.json ]; then \
|
||||
stat -c 'present|%s|%Y' ~/.claude/.credentials.json; \
|
||||
else echo missing; fi",
|
||||
)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null()),
|
||||
Duration::from_secs(5),
|
||||
)?;
|
||||
|
||||
let state = if output.status.success() {
|
||||
decode_wsl_text(&output.stdout).trim().to_string()
|
||||
} else {
|
||||
format!("status-{}", output.status)
|
||||
};
|
||||
|
||||
Some(format!("wsl:{distro}|{state}"))
|
||||
}
|
||||
|
||||
fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
|
||||
// Try the dedicated usage endpoint first
|
||||
if let Some(data) = try_usage_endpoint(token) {
|
||||
match try_usage_endpoint(token)? {
|
||||
Some(data) => {
|
||||
// If reset timers are missing, fill them in from the Messages API
|
||||
if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() {
|
||||
if let Ok(fallback) = fetch_usage_via_messages(token) {
|
||||
let mut merged = data;
|
||||
if merged.session.resets_at.is_none() {
|
||||
merged.session.resets_at = fallback.session.resets_at;
|
||||
if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() {
|
||||
if let Ok(fallback) = fetch_usage_via_messages(token) {
|
||||
let mut merged = data;
|
||||
if merged.session.resets_at.is_none() {
|
||||
merged.session.resets_at = fallback.session.resets_at;
|
||||
}
|
||||
if merged.weekly.resets_at.is_none() {
|
||||
merged.weekly.resets_at = fallback.weekly.resets_at;
|
||||
}
|
||||
return Ok(merged);
|
||||
}
|
||||
if merged.weekly.resets_at.is_none() {
|
||||
merged.weekly.resets_at = fallback.weekly.resets_at;
|
||||
}
|
||||
return Ok(merged);
|
||||
}
|
||||
return Ok(data);
|
||||
}
|
||||
return Ok(data);
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Fall back to Messages API with rate limit headers
|
||||
@ -261,8 +359,8 @@ fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
|
||||
result
|
||||
}
|
||||
|
||||
fn try_usage_endpoint(token: &str) -> Option<UsageData> {
|
||||
let agent = build_agent().ok()?;
|
||||
fn try_usage_endpoint(token: &str) -> Result<Option<UsageData>, PollError> {
|
||||
let agent = build_agent()?;
|
||||
|
||||
let resp = match agent
|
||||
.get(USAGE_URL)
|
||||
@ -271,10 +369,19 @@ fn try_usage_endpoint(token: &str) -> Option<UsageData> {
|
||||
.call()
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
_ => return None,
|
||||
Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
|
||||
diagnose::log(format!(
|
||||
"usage endpoint returned auth error status {code}; re-login required"
|
||||
));
|
||||
return Err(PollError::AuthRequired);
|
||||
}
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
let response: UsageResponse = resp.into_json().ok()?;
|
||||
let response: UsageResponse = match resp.into_json() {
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let mut data = UsageData::default();
|
||||
|
||||
if let Some(bucket) = &response.five_hour {
|
||||
@ -287,7 +394,7 @@ fn try_usage_endpoint(token: &str) -> Option<UsageData> {
|
||||
data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref());
|
||||
}
|
||||
|
||||
Some(data)
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
fn fetch_usage_via_messages(token: &str) -> Result<UsageData, PollError> {
|
||||
@ -308,6 +415,12 @@ fn fetch_usage_via_messages(token: &str) -> Result<UsageData, PollError> {
|
||||
.send_json(&body)
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
|
||||
diagnose::log(format!(
|
||||
"messages endpoint returned auth error status {code}; re-login required"
|
||||
));
|
||||
return Err(PollError::AuthRequired);
|
||||
}
|
||||
Err(ureq::Error::Status(_code, resp)) => resp,
|
||||
Err(_) => continue,
|
||||
};
|
||||
@ -410,8 +523,9 @@ fn read_credentials() -> Option<Credentials> {
|
||||
}
|
||||
|
||||
fn read_windows_credentials() -> Option<Credentials> {
|
||||
let home = dirs::home_dir()?;
|
||||
let cred_path = home.join(".claude").join(".credentials.json");
|
||||
let CredentialSource::Windows(cred_path) = windows_credential_source()? else {
|
||||
return None;
|
||||
};
|
||||
let content = match std::fs::read_to_string(&cred_path) {
|
||||
Ok(content) => content,
|
||||
Err(error) => {
|
||||
|
||||
@ -2,8 +2,8 @@ use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ExtractIconExW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY,
|
||||
NOTIFYICONDATAW, Shell_NotifyIconW,
|
||||
ExtractIconExW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY,
|
||||
NIIF_WARNING, NOTIFYICONDATAW, Shell_NotifyIconW,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
use windows::core::PCWSTR;
|
||||
@ -249,6 +249,35 @@ fn load_embedded_app_icon() -> HICON {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a Windows balloon notification from the tray icon.
|
||||
/// Used to alert the user when re-authentication is required.
|
||||
pub fn notify_balloon(hwnd: HWND, title: &str, message: &str) {
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = TRAY_ICON_ID;
|
||||
nid.uFlags = NIF_INFO;
|
||||
nid.dwInfoFlags = NIIF_WARNING;
|
||||
copy_wide(title, &mut nid.szInfoTitle);
|
||||
copy_wide_256(message, &mut nid.szInfo);
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a string into a fixed-size wide buffer (truncates to fit).
|
||||
fn copy_wide<const N: usize>(s: &str, buf: &mut [u16; N]) {
|
||||
let wide: Vec<u16> = s.encode_utf16().collect();
|
||||
let len = wide.len().min(N - 1);
|
||||
buf[..len].copy_from_slice(&wide[..len]);
|
||||
buf[len] = 0;
|
||||
}
|
||||
|
||||
/// Copy a string into a 256-wide buffer.
|
||||
fn copy_wide_256(s: &str, buf: &mut [u16; 256]) {
|
||||
copy_wide(s, buf)
|
||||
}
|
||||
|
||||
/// Register the tray icon with the shell.
|
||||
pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
|
||||
let hicon = create_icon(percent);
|
||||
|
||||
310
src/window.rs
310
src/window.rs
@ -65,6 +65,10 @@ struct AppState {
|
||||
|
||||
poll_interval_ms: u32,
|
||||
retry_count: u32,
|
||||
force_notify_auth_error: bool,
|
||||
auth_error_paused_polling: bool,
|
||||
auth_watch_mode: poller::CredentialWatchMode,
|
||||
auth_watch_snapshot: poller::CredentialWatchSnapshot,
|
||||
last_poll_ok: bool,
|
||||
update_status: UpdateStatus,
|
||||
last_update_check_unix: Option<u64>,
|
||||
@ -1348,6 +1352,10 @@ pub fn run() {
|
||||
data: None,
|
||||
poll_interval_ms: settings.poll_interval_ms,
|
||||
retry_count: 0,
|
||||
force_notify_auth_error: false,
|
||||
auth_error_paused_polling: false,
|
||||
auth_watch_mode: poller::CredentialWatchMode::ActiveSource,
|
||||
auth_watch_snapshot: Vec::new(),
|
||||
last_poll_ok: false,
|
||||
update_status: UpdateStatus::Idle,
|
||||
last_update_check_unix: settings.last_update_check_unix,
|
||||
@ -1766,30 +1774,87 @@ fn do_poll(send_hwnd: SendHwnd) {
|
||||
SetTimer(hwnd, TIMER_POLL, interval, None);
|
||||
}
|
||||
}
|
||||
s.force_notify_auth_error = false;
|
||||
s.auth_error_paused_polling = false;
|
||||
s.auth_watch_mode = poller::CredentialWatchMode::ActiveSource;
|
||||
s.auth_watch_snapshot.clear();
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let _ = PostMessageW(hwnd, WM_APP_USAGE_UPDATED, WPARAM(0), LPARAM(0));
|
||||
}
|
||||
}
|
||||
Err(_e) => {
|
||||
// Show refresh indicator — retry will recover silently
|
||||
let mut state = lock_state();
|
||||
if let Some(s) = state.as_mut() {
|
||||
s.session_text = "...".to_string();
|
||||
s.weekly_text = "...".to_string();
|
||||
s.last_poll_ok = false;
|
||||
Err(e) => {
|
||||
let auth_watch = match e {
|
||||
poller::PollError::AuthRequired | poller::PollError::TokenExpired => Some((
|
||||
poller::CredentialWatchMode::ActiveSource,
|
||||
poller::credential_watch_snapshot(poller::CredentialWatchMode::ActiveSource),
|
||||
)),
|
||||
poller::PollError::NoCredentials => Some((
|
||||
poller::CredentialWatchMode::AllSources,
|
||||
poller::credential_watch_snapshot(poller::CredentialWatchMode::AllSources),
|
||||
)),
|
||||
poller::PollError::RequestFailed => None,
|
||||
};
|
||||
// Distinguish auth-required errors from transient errors.
|
||||
let notify_auth_error = {
|
||||
let mut state = lock_state();
|
||||
let mut should_notify = false;
|
||||
if let Some(s) = state.as_mut() {
|
||||
s.last_poll_ok = false;
|
||||
match auth_watch {
|
||||
Some((watch_mode, watch_snapshot)) => {
|
||||
// Only show the balloon on the first failure so it doesn't spam.
|
||||
if s.retry_count == 0 || s.force_notify_auth_error {
|
||||
should_notify = true;
|
||||
}
|
||||
s.force_notify_auth_error = false;
|
||||
s.auth_error_paused_polling = true;
|
||||
s.auth_watch_mode = watch_mode;
|
||||
s.auth_watch_snapshot = watch_snapshot;
|
||||
s.session_text = "⚠".to_string();
|
||||
s.weekly_text = "⚠".to_string();
|
||||
s.retry_count = s.retry_count.saturating_add(1);
|
||||
unsafe {
|
||||
let _ = KillTimer(hwnd, TIMER_POLL);
|
||||
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
|
||||
let _ = KillTimer(hwnd, TIMER_COUNTDOWN);
|
||||
SetTimer(hwnd, TIMER_POLL, s.poll_interval_ms, None);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Transient network / credential-missing errors: exponential backoff.
|
||||
s.force_notify_auth_error = false;
|
||||
s.auth_error_paused_polling = false;
|
||||
s.auth_watch_mode = poller::CredentialWatchMode::ActiveSource;
|
||||
s.auth_watch_snapshot.clear();
|
||||
s.session_text = "...".to_string();
|
||||
s.weekly_text = "...".to_string();
|
||||
s.retry_count = s.retry_count.saturating_add(1);
|
||||
let backoff = RETRY_BASE_MS
|
||||
.saturating_mul(1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX));
|
||||
let retry_ms = backoff.min(s.poll_interval_ms);
|
||||
unsafe {
|
||||
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
|
||||
SetTimer(hwnd, TIMER_POLL, retry_ms, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
should_notify
|
||||
};
|
||||
|
||||
// Exponential backoff retry: 30s, 60s, 120s, ... up to poll_interval
|
||||
s.retry_count = s.retry_count.saturating_add(1);
|
||||
let backoff = RETRY_BASE_MS
|
||||
.saturating_mul(1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX));
|
||||
let retry_ms = backoff.min(s.poll_interval_ms);
|
||||
|
||||
unsafe {
|
||||
// Kill the 5-second reset poll so it doesn't bypass backoff
|
||||
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
|
||||
SetTimer(hwnd, TIMER_POLL, retry_ms, None);
|
||||
if notify_auth_error {
|
||||
let strings = {
|
||||
let state = lock_state();
|
||||
state.as_ref().map(|s| s.language.strings())
|
||||
};
|
||||
if let Some(strings) = strings {
|
||||
tray_icon::notify_balloon(
|
||||
hwnd,
|
||||
strings.token_expired_title,
|
||||
strings.token_expired_body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1807,13 +1872,20 @@ fn schedule_countdown_timer() {
|
||||
None => return,
|
||||
};
|
||||
|
||||
let hwnd = s.hwnd.to_hwnd();
|
||||
if !s.last_poll_ok {
|
||||
unsafe {
|
||||
let _ = KillTimer(hwnd, TIMER_COUNTDOWN);
|
||||
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let data = match &s.data {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let hwnd = s.hwnd.to_hwnd();
|
||||
|
||||
// If a reset time has passed, poll every 5s to pick up fresh data
|
||||
if poller::is_past_reset(data) {
|
||||
unsafe {
|
||||
@ -1884,20 +1956,20 @@ fn update_display() {
|
||||
|
||||
fn position_at_taskbar() {
|
||||
refresh_dpi();
|
||||
|
||||
// Extract everything we need from state, then DROP the lock before making
|
||||
// any Win32 calls. MoveWindow dispatches WM_PAINT synchronously and our
|
||||
// wnd_proc also calls lock_state() — Rust's Mutex is not reentrant,
|
||||
// so holding it across Win32 calls causes a deadlock.
|
||||
// Drop the app-state lock before any Win32 call that may synchronously
|
||||
// re-enter our window procedure.
|
||||
let (hwnd, embedded, tray_offset, taskbar_hwnd) = {
|
||||
let state = lock_state();
|
||||
let s = match state.as_ref() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Don't fight the user's drag
|
||||
if s.dragging {
|
||||
return;
|
||||
}
|
||||
|
||||
let taskbar_hwnd = match s.taskbar_hwnd {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
@ -1905,6 +1977,7 @@ fn position_at_taskbar() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
(s.hwnd.to_hwnd(), s.embedded, s.tray_offset, taskbar_hwnd)
|
||||
};
|
||||
|
||||
@ -2043,10 +2116,44 @@ unsafe extern "system" fn wnd_proc(
|
||||
match timer_id {
|
||||
TIMER_POLL => {
|
||||
if !is_quiet_time() {
|
||||
let sh = SendHwnd::from_hwnd(hwnd);
|
||||
std::thread::spawn(move || {
|
||||
do_poll(sh);
|
||||
});
|
||||
let auth_watch = {
|
||||
let state = lock_state();
|
||||
state
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
(
|
||||
s.auth_error_paused_polling,
|
||||
s.auth_watch_mode,
|
||||
s.auth_watch_snapshot.clone(),
|
||||
)
|
||||
})
|
||||
};
|
||||
match auth_watch {
|
||||
Some((true, watch_mode, previous_snapshot)) => {
|
||||
let current_snapshot = poller::credential_watch_snapshot(watch_mode);
|
||||
if current_snapshot != previous_snapshot {
|
||||
let mut state = lock_state();
|
||||
if let Some(s) = state.as_mut() {
|
||||
if s.auth_error_paused_polling && s.auth_watch_mode == watch_mode
|
||||
{
|
||||
s.auth_watch_snapshot = current_snapshot;
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
let sh = SendHwnd::from_hwnd(hwnd);
|
||||
std::thread::spawn(move || {
|
||||
do_poll(sh);
|
||||
});
|
||||
}
|
||||
}
|
||||
Some((false, _, _)) => {
|
||||
let sh = SendHwnd::from_hwnd(hwnd);
|
||||
std::thread::spawn(move || {
|
||||
do_poll(sh);
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
TIMER_COUNTDOWN => {
|
||||
@ -2055,7 +2162,14 @@ unsafe extern "system" fn wnd_proc(
|
||||
schedule_countdown_timer();
|
||||
}
|
||||
TIMER_RESET_POLL => {
|
||||
if !is_quiet_time() {
|
||||
let should_poll = {
|
||||
let state = lock_state();
|
||||
state
|
||||
.as_ref()
|
||||
.map(|s| !s.auth_error_paused_polling)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if should_poll && !is_quiet_time() {
|
||||
let sh = SendHwnd::from_hwnd(hwnd);
|
||||
std::thread::spawn(move || {
|
||||
do_poll(sh);
|
||||
@ -2063,7 +2177,6 @@ unsafe extern "system" fn wnd_proc(
|
||||
}
|
||||
}
|
||||
TIMER_QUIET_BOUNDARY => {
|
||||
// 安靜時刻邊界到了,立即重繪並排程下一個邊界
|
||||
render_layered();
|
||||
schedule_quiet_boundary_timer(hwnd);
|
||||
}
|
||||
@ -2135,86 +2248,88 @@ unsafe extern "system" fn wnd_proc(
|
||||
if is_dragging {
|
||||
let mut pt = POINT::default();
|
||||
let _ = GetCursorPos(&mut pt);
|
||||
let move_target = {
|
||||
let mut state = lock_state();
|
||||
let s = match state.as_mut() {
|
||||
Some(s) => s,
|
||||
None => return LRESULT(0),
|
||||
};
|
||||
|
||||
let mut state = lock_state();
|
||||
let s = match state.as_mut() {
|
||||
Some(s) => s,
|
||||
None => return LRESULT(0),
|
||||
};
|
||||
// Moving mouse left = positive delta = larger offset (further left)
|
||||
let delta = s.drag_start_mouse_x - pt.x;
|
||||
let mut new_offset = s.drag_start_offset + delta;
|
||||
|
||||
// Moving mouse left = positive delta = larger offset (further left)
|
||||
let delta = s.drag_start_mouse_x - pt.x;
|
||||
let mut new_offset = s.drag_start_offset + delta;
|
||||
|
||||
// Clamp: offset >= 0 (can't go right of default)
|
||||
if new_offset < 0 {
|
||||
new_offset = 0;
|
||||
}
|
||||
|
||||
// Clamp: don't go past left edge of taskbar
|
||||
if let Some(taskbar_hwnd) = s.taskbar_hwnd {
|
||||
if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) {
|
||||
let mut tray_left = taskbar_rect.right;
|
||||
if let Some(tray_hwnd) =
|
||||
native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd")
|
||||
{
|
||||
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd)
|
||||
{
|
||||
tray_left = tray_rect.left;
|
||||
}
|
||||
}
|
||||
let widget_width = total_widget_width();
|
||||
let max_offset = if s.embedded {
|
||||
tray_left - taskbar_rect.left - widget_width
|
||||
} else {
|
||||
tray_left - taskbar_rect.left - widget_width
|
||||
};
|
||||
if new_offset > max_offset {
|
||||
new_offset = max_offset;
|
||||
}
|
||||
// Clamp: offset >= 0 (can't go right of default)
|
||||
if new_offset < 0 {
|
||||
new_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
s.tray_offset = new_offset;
|
||||
let taskbar_hwnd = s.taskbar_hwnd;
|
||||
let embedded = s.embedded;
|
||||
let hwnd_val = s.hwnd.to_hwnd();
|
||||
|
||||
// Move window directly
|
||||
let hwnd_val = s.hwnd.to_hwnd();
|
||||
if let Some(taskbar_hwnd) = s.taskbar_hwnd {
|
||||
if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) {
|
||||
let taskbar_height = taskbar_rect.bottom - taskbar_rect.top;
|
||||
let mut tray_left = taskbar_rect.right;
|
||||
let anchor_top = taskbar_rect.top;
|
||||
let anchor_height = taskbar_height;
|
||||
if let Some(tray_hwnd) =
|
||||
native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd")
|
||||
{
|
||||
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd)
|
||||
// Clamp: don't go past left edge of taskbar
|
||||
if let Some(taskbar_hwnd) = taskbar_hwnd {
|
||||
if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) {
|
||||
let mut tray_left = taskbar_rect.right;
|
||||
if let Some(tray_hwnd) =
|
||||
native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd")
|
||||
{
|
||||
tray_left = tray_rect.left;
|
||||
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd)
|
||||
{
|
||||
tray_left = tray_rect.left;
|
||||
}
|
||||
}
|
||||
}
|
||||
let widget_width = total_widget_width();
|
||||
let widget_height = sc(WIDGET_HEIGHT);
|
||||
let y = compute_anchor_y(anchor_top, anchor_height, widget_height);
|
||||
if s.embedded {
|
||||
let x = tray_left - taskbar_rect.left - widget_width - new_offset;
|
||||
native_interop::move_window(
|
||||
hwnd_val,
|
||||
x,
|
||||
y - taskbar_rect.top,
|
||||
widget_width,
|
||||
widget_height,
|
||||
);
|
||||
} else {
|
||||
let x = tray_left - widget_width - new_offset;
|
||||
native_interop::move_window(
|
||||
let widget_width = total_widget_width();
|
||||
let max_offset = tray_left - taskbar_rect.left - widget_width;
|
||||
if new_offset > max_offset {
|
||||
new_offset = max_offset;
|
||||
}
|
||||
|
||||
s.tray_offset = new_offset;
|
||||
|
||||
let taskbar_height = taskbar_rect.bottom - taskbar_rect.top;
|
||||
let anchor_top = taskbar_rect.top;
|
||||
let anchor_height = taskbar_height;
|
||||
let widget_height = sc(WIDGET_HEIGHT);
|
||||
let y = compute_anchor_y(anchor_top, anchor_height, widget_height);
|
||||
let x = if embedded {
|
||||
tray_left - taskbar_rect.left - widget_width - new_offset
|
||||
} else {
|
||||
tray_left - widget_width - new_offset
|
||||
};
|
||||
Some((
|
||||
hwnd_val,
|
||||
embedded,
|
||||
x,
|
||||
y,
|
||||
taskbar_rect.top,
|
||||
widget_width,
|
||||
widget_height,
|
||||
);
|
||||
))
|
||||
} else {
|
||||
s.tray_offset = new_offset;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
s.tray_offset = new_offset;
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((hwnd_val, embedded, x, y, taskbar_top, widget_width, widget_height)) =
|
||||
move_target
|
||||
{
|
||||
if embedded {
|
||||
native_interop::move_window(
|
||||
hwnd_val,
|
||||
x,
|
||||
y - taskbar_top,
|
||||
widget_width,
|
||||
widget_height,
|
||||
);
|
||||
} else {
|
||||
native_interop::move_window(hwnd_val, x, y, widget_width, widget_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2254,6 +2369,7 @@ unsafe extern "system" fn wnd_proc(
|
||||
if let Some(s) = state.as_mut() {
|
||||
s.session_text = "...".to_string();
|
||||
s.weekly_text = "...".to_string();
|
||||
s.force_notify_auth_error = true;
|
||||
}
|
||||
}
|
||||
render_layered();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user