From 168dc9de83580a1a0e1ab737c983c1e1d6e63325 Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 17 May 2026 09:26:28 -0500 Subject: [PATCH 1/2] bug-001: fix tray menu, add autostart and crash recovery launcher --- .claude/settings.local.json | 6 +- .gitea/workflows/build-check.yml | 26 ++++ .gitea/workflows/release.yml | 24 +++- Cargo.toml | 10 ++ src/app.rs | 37 ++++- src/launcher.rs | 49 +++++++ src/main.rs | 34 +++++ wix/License.rtf | Bin 0 -> 1260 bytes wix/main.wxs | 223 +++++++++++++++++++++++++++++++ 9 files changed, 400 insertions(+), 9 deletions(-) create mode 100644 .gitea/workflows/build-check.yml create mode 100644 src/launcher.rs create mode 100644 wix/License.rtf create mode 100644 wix/main.wxs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0b6a3bc..b7e29eb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,11 @@ "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=cashflowStatementHistory,balanceSheetHistory,calendarEvents&crumb=t5.A45X0LQC')", "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=financialData,recommendationTrend&crumb=t5.A45X0LQC')", "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=incomeStatementHistoryQuarterly&crumb=t5.A45X0LQC')", - "Bash(cargo doc *)" + "Bash(cargo doc *)", + "Bash(git checkout *)", + "Bash(git push *)", + "Bash(grep -n \"tray_icon::TrayIconEvent\\\\|tray_icon::menu::MenuEvent\\\\|set_event_handler\" ~/.cargo/registry/src/*/tray-icon-0.14*/src/lib.rs 2>/dev/null | head -20)", + "Bash(git add *)" ] } } diff --git a/.gitea/workflows/build-check.yml b/.gitea/workflows/build-check.yml new file mode 100644 index 0000000..7e041d5 --- /dev/null +++ b/.gitea/workflows/build-check.yml @@ -0,0 +1,26 @@ +name: Build Check + +on: + push: + branches: + - 'bug-*' + - 'feat-*' + - 'dev' + pull_request: + branches: + - main + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Build check + shell: powershell + run: cargo build --release + + - name: Report success + shell: powershell + run: Write-Host "Build succeeded - safe to merge to main" \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e4c5b28..3fd1de7 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -16,6 +16,11 @@ jobs: shell: powershell run: cargo build --release + - name: Build installer + shell: powershell + run: | + cargo wix --nocapture + - name: Create Gitea Release id: create_release shell: powershell @@ -59,4 +64,21 @@ jobs: $uri = "https://gitea.whitlocktech.com/api/v1/repos/whitlocktech/tickr/releases/$releaseId/assets?name=tickr.exe" - & curl.exe -X POST -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" -F "attachment=@$filePath" "$uri" \ No newline at end of file + & curl.exe -X POST -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" -F "attachment=@$filePath" "$uri" + + - name: Upload installer + shell: powershell + run: | + $releaseId = "${{ steps.create_release.outputs.release_id }}" + $msiPath = (Get-ChildItem -Path "target\wix\*.msi" | Select-Object -First 1).FullName + + Write-Host "MSI Path: $msiPath" + + if (!(Test-Path $msiPath)) { + Write-Error "MSI not found" + exit 1 + } + + $uri = "https://gitea.whitlocktech.com/api/v1/repos/whitlocktech/tickr/releases/$releaseId/assets?name=tickr-setup.msi" + + & curl.exe -X POST -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" -F "attachment=@$msiPath" "$uri" \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 59ca869..2e2f095 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,11 +2,17 @@ name = "tickr" version = "0.4.0" edition = "2021" +authors = ["Whitlocktech"] +license = "MIT" [[bin]] name = "tickr" path = "src/main.rs" +[[bin]] +name = "tickr-launcher" +path = "src/launcher.rs" + [dependencies] eframe = { version = "0.27", features = ["default"] } egui = "0.27" @@ -19,6 +25,7 @@ anyhow = "1" toml = "0.8" dirs = "5" semver = "1" +winreg = "0.52" tray-icon = "0.14" image = { version = "0.25", default-features = false, features = ["ico"] } @@ -30,3 +37,6 @@ strip = true [target.'cfg(windows)'.build-dependencies] winres = "0.1" + +[package.metadata.wix] +eula = false \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index e974daa..4a6c3ab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -74,12 +74,16 @@ pub struct TickrApp { update_in_progress: bool, update_result_rx: mpsc::Receiver, - // Tray + // Tray — use our own channels fed by set_event_handler so events are + // delivered even when the window is hidden (the global receiver() only + // works reliably while the winit loop is actively processing messages). #[allow(dead_code)] tray_icon: Option, tray_open_id: Option, tray_check_id: Option, tray_quit_id: Option, + tray_ev_rx: mpsc::Receiver, + menu_ev_rx: mpsc::Receiver, window_visible: bool, hide_to_tray: bool, should_quit: bool, @@ -108,6 +112,24 @@ impl TickrApp { let (tray_icon, tray_open_id, tray_check_id, tray_quit_id) = setup_tray(icon_rgba.clone(), icon_size, watchlist.tickers.len()); + // Route tray events through our own sync channels + wake egui immediately. + // SyncSender is Send + Sync, satisfying the 'static closure requirement. + // (The global receiver() channel is NOT used — set_event_handler replaces it.) + let (tray_ev_tx, tray_ev_rx) = mpsc::sync_channel::(64); + let (menu_ev_tx, menu_ev_rx) = mpsc::sync_channel::(64); + + let ctx_tray = cc.egui_ctx.clone(); + tray_icon::TrayIconEvent::set_event_handler(Some(move |e| { + let _ = tray_ev_tx.send(e); + ctx_tray.request_repaint(); + })); + + let ctx_menu = cc.egui_ctx.clone(); + tray_icon::menu::MenuEvent::set_event_handler(Some(move |e| { + let _ = menu_ev_tx.send(e); + ctx_menu.request_repaint(); + })); + // Spawn background check task let (bg_cmd_tx, bg_cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); let (update_result_tx, update_result_rx) = mpsc::channel::(); @@ -138,6 +160,8 @@ impl TickrApp { tray_open_id, tray_check_id, tray_quit_id, + tray_ev_rx, + menu_ev_rx, window_visible: start_visible, hide_to_tray: false, should_quit: false, @@ -870,17 +894,16 @@ impl TickrApp { } fn poll_tray_events(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { - use tray_icon::TrayIconEvent; - - while let Ok(event) = TrayIconEvent::receiver().try_recv() { + // Drain icon-level events (single/double click → show window) + while let Ok(event) = self.tray_ev_rx.try_recv() { match event { - TrayIconEvent::Click { .. } => self.show_window(ctx), + tray_icon::TrayIconEvent::Click { .. } => self.show_window(ctx), _ => {} } } - use tray_icon::menu::MenuEvent; - while let Ok(event) = MenuEvent::receiver().try_recv() { + // Drain context-menu events + while let Ok(event) = self.menu_ev_rx.try_recv() { if self.tray_open_id.as_ref() == Some(&event.id) { self.show_window(ctx); } else if self.tray_check_id.as_ref() == Some(&event.id) { diff --git a/src/launcher.rs b/src/launcher.rs new file mode 100644 index 0000000..1f6f0d8 --- /dev/null +++ b/src/launcher.rs @@ -0,0 +1,49 @@ +// tickr-launcher — thin crash-recovery wrapper for tickr.exe +// +// Spawns tickr.exe from the same directory, watches it, and relaunches on +// non-zero exit codes (crashes). A clean exit (code 0) stops the loop so the +// user can quit intentionally without the launcher interfering. + +use std::process::Command; +use std::thread; +use std::time::Duration; + +fn main() { + let exe_path = std::env::current_exe() + .expect("tickr-launcher: cannot resolve own path"); + let exe_dir = exe_path + .parent() + .expect("tickr-launcher: cannot resolve exe directory"); + let tickr = exe_dir.join("tickr.exe"); + + loop { + let mut child = match Command::new(&tickr).spawn() { + Ok(c) => c, + Err(e) => { + eprintln!("tickr-launcher: failed to spawn tickr.exe: {e}"); + thread::sleep(Duration::from_secs(3)); + continue; + } + }; + + let status = match child.wait() { + Ok(s) => s, + Err(e) => { + eprintln!("tickr-launcher: wait() error: {e}"); + thread::sleep(Duration::from_secs(3)); + continue; + } + }; + + match status.code() { + Some(0) => break, // intentional clean exit — do not relaunch + code => { + eprintln!( + "tickr-launcher: tickr.exe exited with code {:?}, relaunching in 3 s…", + code + ); + thread::sleep(Duration::from_secs(3)); + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 840489a..2657566 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,41 @@ mod watchlist; pub use version::VERSION; +/// Write `HKCU\...\Run\Tickr` pointing to `tickr-launcher.exe` on first launch. +/// If the key already exists (any value) it is left untouched. +fn ensure_autostart() { + use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_WRITE}; + use winreg::RegKey; + + let exe_path = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return, + }; + let exe_dir = match exe_path.parent() { + Some(d) => d, + None => return, + }; + // The launcher watches tickr.exe; autostart should invoke the launcher. + let launcher = exe_dir.join("tickr-launcher.exe"); + let launcher_str = launcher.to_string_lossy().into_owned(); + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let run_key = match hkcu.open_subkey_with_flags( + "Software\\Microsoft\\Windows\\CurrentVersion\\Run", + KEY_READ | KEY_WRITE, + ) { + Ok(k) => k, + Err(_) => return, + }; + + // Only add if not already present + if run_key.get_value::("Tickr").is_err() { + let _ = run_key.set_value("Tickr", &launcher_str); + } +} + fn main() -> eframe::Result<()> { + ensure_autostart(); let config = config::ensure_exists(); let (rgba, size) = icon::make_rgba(); diff --git a/wix/License.rtf b/wix/License.rtf new file mode 100644 index 0000000000000000000000000000000000000000..15fd95f2d0976c6523ce6cf4cf79d5156bd508b7 GIT binary patch literal 1260 zcmZ8h&2HN`5Z-fuyu%=;wh+8_wk^8tiIM4;s4N+jTsMF&v_x6l%A`S3HG&}T-XZOD zkpc!}4QIZ;5%^SF-_2^FovK^eb<F3!fZO3PG%pbAOCr-e=2$U zp>+Gc9-A3lU)4up+uDiQCnR>a@9RP6{lAc!oNC$6xFX5)VOx(PUB&`$!D2-?mUXI|T zd~Ze%RyuE06EG5NYKE~58eWbEbx;?EFdh?dLHX#=LC~4N!L+IiPZ@0;$K9Y@pFpbu z^ltRn=|ZuQI!vLDzi}H*4nsgts5^T7UtR18ESL@QUm}XUr`{Z1JHd#$(Q2f`P;jO- z7+GZePc}YOg7VG`gE<9jjnS1Afa;Eh&`6qS_Kpft9f4#WyymbGRWT zzTYIUW!19SRDd0vFW4(knH7bQjdIl|^F zUvOlhE1FjmWW^fLZ})&+b-(1OBzeaNAQ4;7uQ!4%VM7}<{aeeNk_@qMJ^8EVNw~J+m4KJfU)NW^077$^)c^nh literal 0 HcmV?d00001 diff --git a/wix/main.wxs b/wix/main.wxs new file mode 100644 index 0000000..8190aa0 --- /dev/null +++ b/wix/main.wxs @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- 2.49.1 From 03ba79a8ce6cf2917f416bdba351c37a1a1b6332 Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 17 May 2026 09:33:28 -0500 Subject: [PATCH 2/2] fix version --- .claude/settings.local.json | 3 ++- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b7e29eb..c7ba4a9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,8 @@ "Bash(git checkout *)", "Bash(git push *)", "Bash(grep -n \"tray_icon::TrayIconEvent\\\\|tray_icon::menu::MenuEvent\\\\|set_event_handler\" ~/.cargo/registry/src/*/tray-icon-0.14*/src/lib.rs 2>/dev/null | head -20)", - "Bash(git add *)" + "Bash(git add *)", + "Bash(git commit *)" ] } } diff --git a/Cargo.toml b/Cargo.toml index 2e2f095..50a7f15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tickr" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = ["Whitlocktech"] license = "MIT" -- 2.49.1