From 34d8a62a2bd72fb1a568aa32de95d27bd5efc5ce Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:29:34 +0700 Subject: [PATCH 1/3] feat: Windows desktop app with tray, global shortcut and self-updates --- .github/workflows/ci.yml | 48 ++++ .github/workflows/release.yml | 72 +++++ .gitignore | 2 + README.md | 14 +- desktop/.gitignore | 2 + desktop/Cargo.toml | 37 +++ desktop/build.rs | 3 + desktop/capabilities/default.json | 7 + desktop/icons/128x128.png | Bin 0 -> 3769 bytes desktop/icons/128x128@2x.png | Bin 0 -> 6458 bytes desktop/icons/32x32.png | Bin 0 -> 1245 bytes desktop/icons/icon.ico | Bin 0 -> 15366 bytes desktop/icons/icon.png | Bin 0 -> 2644 bytes desktop/src/lib.rs | 295 +++++++++++++++++++++ desktop/src/main.rs | 6 + desktop/src/timer.rs | 43 +++ desktop/tauri.conf.json | 51 ++++ eslint.config.mjs | 2 + package-lock.json | 239 ++++++++++++++++- package.json | 6 +- scripts/build-extension.mjs | 5 +- src/components/boost/PostContent.tsx | 10 +- src/components/focus/notifications.ts | 7 +- src/components/hotkeys/HotkeysProvider.tsx | 8 +- src/components/hotkeys/ShortcutsPanel.tsx | 6 + src/components/lib/extension.ts | 40 ++- src/components/shell/StartMenu.tsx | 8 +- tests-e2e/app.e2e.js | 99 +++++++ tests-e2e/webdriver.js | 72 +++++ tests/desktop-bridge.test.ts | 51 ++++ 30 files changed, 1111 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 desktop/.gitignore create mode 100644 desktop/Cargo.toml create mode 100644 desktop/build.rs create mode 100644 desktop/capabilities/default.json create mode 100644 desktop/icons/128x128.png create mode 100644 desktop/icons/128x128@2x.png create mode 100644 desktop/icons/32x32.png create mode 100644 desktop/icons/icon.ico create mode 100644 desktop/icons/icon.png create mode 100644 desktop/src/lib.rs create mode 100644 desktop/src/main.rs create mode 100644 desktop/src/timer.rs create mode 100644 desktop/tauri.conf.json create mode 100644 tests-e2e/app.e2e.js create mode 100644 tests-e2e/webdriver.js create mode 100644 tests/desktop-bridge.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91beb39..d40281f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,51 @@ jobs: name: boost-focus-timer-extension path: dist/*.zip if-no-files-found: error + + desktop: + # windows-2025 images break WebView2 WebDriver sessions (actions/runner-images#14738). + runs-on: windows-2022 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + + - run: npm ci + - run: npm test + - run: npm run build:extension + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop + + - run: cargo test --manifest-path desktop/Cargo.toml + - run: cargo clippy --manifest-path desktop/Cargo.toml --all-targets -- -D warnings + - run: npx tauri build --no-bundle + + - name: Install tauri-driver and Edge driver + shell: pwsh + run: | + cargo install tauri-driver --locked + cargo install --git https://github.com/chippers/msedgedriver-tool + & "$HOME\.cargo\bin\msedgedriver-tool.exe" + + - name: End-to-end tests + shell: pwsh + env: + BOOST_APP: ${{ github.workspace }}\desktop\target\release\boost-focus-timer.exe + run: | + $driver = Start-Process tauri-driver -ArgumentList '--native-driver', "$PWD\msedgedriver.exe" -PassThru + $deadline = (Get-Date).AddSeconds(30) + while (-not (Test-NetConnection 127.0.0.1 -Port 4444 -InformationLevel Quiet)) { + if ((Get-Date) -gt $deadline) { throw "tauri-driver did not start" } + Start-Sleep -Seconds 1 + } + try { npm run test:e2e } finally { Stop-Process -Id $driver.Id -ErrorAction SilentlyContinue } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..776904a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + windows: + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + + - run: npm ci + + - name: Refuse to release without the updater public key + shell: bash + run: | + if grep -q SET_IN_RELEASE_SETUP desktop/tauri.conf.json; then + echo "::error::desktop/tauri.conf.json still has the placeholder updater pubkey; installs from this release could never update." + exit 1 + fi + + - run: npm test + - run: npm run build:extension + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop + + - run: cargo test --manifest-path desktop/Cargo.toml + + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + tagName: ${{ github.ref_name }} + releaseName: Boost Focus Timer ${{ github.ref_name }} + releaseBody: See the README for install and update steps. + releaseDraft: false + includeUpdaterJson: true + tauriScript: npx tauri + + extension: + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: windows + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + + - run: npm ci + - run: npm run build:extension + - run: gh release upload "$GITHUB_REF_NAME" dist/*.zip --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5967111..70e8dde 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ node_modules/ out/ build/ dist/ +desktop/target/ +desktop/gen/ # Environment .env diff --git a/README.md b/README.md index 1f06296..71c98f5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Boost Focus Timer A calm, keyboard-friendly focus timer with to-dos, habits, goals and session history. -Runs as a web app or as a browser extension, and keeps every bit of data in your own browser. +Runs as a Windows desktop app, a web app or a browser extension, and keeps every bit of data on your own device. [![CI](https://github.com/ttncode/boost-focus-timer/actions/workflows/ci.yml/badge.svg)](https://github.com/ttncode/boost-focus-timer/actions/workflows/ci.yml) ![Node 24+](https://img.shields.io/badge/node-%E2%89%A524-339933?logo=node.js&logoColor=white) @@ -47,6 +47,18 @@ npm run dev Open . To serve an optimized build instead, run `npm run build && npm start`. +## Install the Windows desktop app + +1. Download `Boost.Focus.Timer__x64-setup.exe` from [Releases](https://github.com/ttncode/boost-focus-timer/releases/latest). +2. Run it. Windows may warn about an unknown publisher the first time, because the installer is not code-signed: click **More info**, then **Run anyway**. It installs for your account only, so no admin rights are needed. +3. The app opens, adds an icon to the system tray and starts with Windows from then on. + +Press Ctrl+Alt+B in any program to show or hide it. Closing the window (X) only hides it, so a running session and its notification carry on; to quit, right-click the tray icon → **Quit**. Turn off **Start with Windows** in the same menu if you don't want it at sign-in. + +The app updates itself: each time it starts it checks for a new version and installs it, unless a session is running. To check now, right-click the tray icon → **Check for updates**. + +The desktop app starts with empty data; it cannot read what the browser extension or web app stored. + ## Install as a browser extension The extension is the same app packaged for Chromium-based browsers (Chrome, Edge, Brave, Opera). diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..79c707c --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,2 @@ +/target/ +/gen/ diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml new file mode 100644 index 0000000..588391c --- /dev/null +++ b/desktop/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "boost-focus-timer" +# The app's version comes from package.json through tauri.conf.json. +version = "0.0.0" +description = "A calm focus timer with to-dos, habits, goals and session history" +edition = "2021" +publish = false + +[lib] +name = "boost_focus_timer_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "~2.12", features = ["tray-icon"] } +tauri-plugin-single-instance = "2" +tauri-plugin-global-shortcut = "2" +tauri-plugin-autostart = "2" +tauri-plugin-window-state = "2" +tauri-plugin-updater = "2" +tauri-plugin-dialog = "2" +tauri-plugin-notification = "2" +tauri-plugin-opener = "2" + +# Must match the versions Tauri's WebView2 runtime uses, since with_webview hands over its types. +[target.'cfg(windows)'.dependencies] +webview2-com = "0.39" +windows-core = "0.62" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = true diff --git a/desktop/build.rs b/desktop/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/desktop/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/capabilities/default.json b/desktop/capabilities/default.json new file mode 100644 index 0000000..ba289e9 --- /dev/null +++ b/desktop/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "What the window may call: core APIs only; plugins are driven from Rust.", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/desktop/icons/128x128.png b/desktop/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..0f6616d72cd234d6458c34d7eb186d01ee15ceea GIT binary patch literal 3769 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 literal 0 HcmV?d00001 diff --git a/desktop/icons/128x128@2x.png b/desktop/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b233e47501956fdab2d5b8dc83d74ef9b5427c21 GIT binary patch literal 6458 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 Hu0mjfkdkK0 literal 0 HcmV?d00001 diff --git a/desktop/icons/icon.ico b/desktop/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9b917d1c35a183342e2d41669d56711dc23ca133 GIT binary patch literal 15366 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 J@Bf|nzW|Rhd_DjG literal 0 HcmV?d00001 diff --git a/desktop/icons/icon.png b/desktop/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..febcac71d296040f5abdb9bd9314cc1580c5f159 GIT binary patch literal 2644 zcmcguZBSHY6n@^jpTOd-mOzl~^)qHt*%|~JNB64h*O;i~Or?yj9~F*Yls2g;dlO4Pz5}Z&jP+3^Hga&>jyQwKL=BmrEF6`dkyURzr%PRfp&)$2_InVQ) zbMAT1-47RJ&Weprj0O-pXSV4jfT6bx)Jl4BZNA_EqLb#BrY_oi`Oejv)kzDVENCcQ zo@mR|?c;RWWzoOpO`1(Ey=EVxsUNSX%q#!=Ywp>ys*O?6nX;*)eoHzn7As9NqdMN} zn3ooJqC9Ke_=AisucGOC?aTZ0%XSehtCOQ(GM}PDf*U(HnC&t(L(I2jtWwT@rsWfK zD42p391^;qCOwWCEw9r-X~eW%hf~JN<$S`gvA`4pTu#hx8OwA7^A)^9%d10xDO7RsrMrjr=la%n z+?eb1j+B3zK)HU{zdi35!F^4ph&>rH6&1VxhJ*5POXV6Wd1Hpq&x3mykWwwPyR;xn z_!_5*uTuNj3cDYiLs)#g7YBTVHOCkdeAp9DjshiFWTIEb32|mxF`LR{5pbuN9FL0U z$MLa1GbDHIaSD}0PzU9urZBb_| zm8YcXqmcT!JWbZm#nQAp(>!`$!CTb|DbbtsKv1$iKd1mrg`_!e{GA@<)9on+V7wob2iK@LdovbL&gc-uGOb^ z=lX6n*3efTgbLG-q!r=|8J77j(uf+;aS@+4>x6d9;Oy) z&&XG7@4Q#qdTV}LSt@z-`HfKaV_u#632Zoa_Jgy)3)kMP?pt!*UA_86!^rG4i#K#1 zC3}lrYQ}TKUO|`Rid8qfFmV_n1?1|nhRDd$DsGmSJtZf$B zq~qkc0(^d>_)Ww0`t-7jWQHp#o!@@$;-7zfMFj6b-?>TBN5LFJ55(br0|L^`47l1> z@sNy@L-59mZ!4s&EW`mGV?d7|2aGEAiEFTveie!w%K{%i2`C;GwNV%hJV^FQtGs@w z6zsBiL>qJjhVu^5L0AUn+&XcL|7K}jb(LI=o1-~e(QVOXY4iscIV=yP8(XeB!%w)* zYB}LItaoR`QU%^&$=W1V!vy@a(!IBZfWJQpC+YV|Y_+5lRt|$ZPpZM}wkmlw1gACt z(_z_TU-o!RldNNqPw7&jlcC@YI5pK}IL%Ii>B~TWHQNcRgu$iYSqMTrb^tQ!U>3sM h90NIH7Bqtnbg{sA_MOX2_k literal 0 HcmV?d00001 diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs new file mode 100644 index 0000000..a087171 --- /dev/null +++ b/desktop/src/lib.rs @@ -0,0 +1,295 @@ +pub mod timer; + +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Manager, State, WebviewWindow, WindowEvent}; +use tauri_plugin_autostart::{MacosLauncher, ManagerExt as _}; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; +use tauri_plugin_notification::NotificationExt; +use tauri_plugin_opener::OpenerExt; +use tauri_plugin_updater::{Update, UpdaterExt}; +use tauri_plugin_window_state::{AppHandleExt, StateFlags}; +use timer::ScheduledEnd; + +const APP_NAME: &str = "Boost Focus Timer"; +const MAIN_WINDOW: &str = "main"; +const HIDDEN_ARG: &str = "--hidden"; +const AUTOSTART_OFF_MARKER: &str = "autostart-off"; +const GLOBAL_SHORTCUT: &str = "Ctrl+Alt+B"; +const END_POLL: Duration = Duration::from_secs(1); +// WebView2 writes localStorage to disk a few seconds late, and the installer ends the app abruptly. +const STORAGE_COMMIT_WAIT: Duration = Duration::from_secs(6); + +#[derive(Default)] +struct AppState { + scheduled_end: Mutex>, +} + +#[tauri::command] +fn schedule_end(state: State, at: f64, title: String, body: String) -> Result<(), String> { + let mut slot = state.scheduled_end.lock().map_err(|error| error.to_string())?; + *slot = Some(ScheduledEnd { at_ms: at, title, body }); + Ok(()) +} + +#[tauri::command] +fn cancel_end(state: State) -> Result<(), String> { + let mut slot = state.scheduled_end.lock().map_err(|error| error.to_string())?; + *slot = None; + Ok(()) +} + +fn is_web_url(url: &str) -> bool { + url.starts_with("https://") || url.starts_with("http://") +} + +/// Only web links: the page's text must never be able to launch a local file or program. +#[tauri::command] +fn open_url(app: AppHandle, url: String) -> Result<(), String> { + if !is_web_url(&url) { + return Err("only web links can be opened".to_string()); + } + app.opener().open_url(url, None::<&str>).map_err(|error| error.to_string()) +} + +fn now_ms() -> f64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0.0, |elapsed| elapsed.as_millis() as f64) +} + +/// A hidden WebView2 throttles or unloads the page, so the end is announced from here. Polling the +/// wall clock keeps it right across PC sleep, which a single long sleep would not count. +fn start_end_timer(app: AppHandle) { + std::thread::spawn(move || loop { + std::thread::sleep(END_POLL); + let state = app.state::(); + let due = match state.scheduled_end.lock() { + Ok(mut slot) => timer::take_due(&mut slot, now_ms()), + Err(_) => None, + }; + if let Some(end) = due { + let _ = app.notification().builder().title(end.title).body(end.body).show(); + } + }); +} + +fn timer_running(app: &AppHandle) -> bool { + let state = app.state::(); + state.scheduled_end.lock().is_ok_and(|slot| slot.is_some()) +} + +fn window_state_flags() -> StateFlags { + StateFlags::all() - StateFlags::VISIBLE +} + +fn show_main(app: &AppHandle) { + let Some(window) = app.get_webview_window(MAIN_WINDOW) else { return }; + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); +} + +fn hide_main(app: &AppHandle) { + let _ = app.save_window_state(window_state_flags()); + if let Some(window) = app.get_webview_window(MAIN_WINDOW) { + let _ = window.hide(); + } +} + +fn toggle_main(app: &AppHandle) { + let Some(window) = app.get_webview_window(MAIN_WINDOW) else { return }; + let in_front = window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false); + if in_front { + hide_main(app); + } else { + show_main(app); + } +} + +fn show_message(app: &AppHandle, text: String) { + app.dialog().message(text).title(APP_NAME).show(|_| {}); +} + +/// Never restarts under a running session: the next start or a tray check picks the update up. +fn install_update(app: &AppHandle, update: Update, bytes: Vec, from_tray: bool) { + if timer_running(app) { + if from_tray { + show_message(app, "A session is running. Check again when it has finished.".to_string()); + } + return; + } + hide_main(app); + std::thread::sleep(STORAGE_COMMIT_WAIT); + match update.install(bytes) { + Ok(()) => app.restart(), + Err(error) => show_message(app, format!("Couldn't install the update: {error}")), + } +} + +/// At start a failed check stays silent and is retried next start; from the tray it says why. +async fn check_for_update(app: AppHandle, from_tray: bool) { + let result = match app.updater() { + Ok(updater) => updater.check().await, + Err(error) => Err(error), + }; + match result { + Ok(Some(update)) => match update.download(|_, _| {}, || {}).await { + Ok(bytes) => install_update(&app, update, bytes, from_tray), + Err(error) if from_tray => show_message(&app, format!("Couldn't download the update: {error}")), + Err(_) => {} + }, + Ok(None) if from_tray => show_message(&app, format!("{APP_NAME} {} is the latest version.", app.package_info().version)), + Err(error) if from_tray => show_message(&app, format!("Couldn't check for updates: {error}")), + _ => {} + } +} + +fn autostart_marker(app: &AppHandle) -> Option { + app.path().app_data_dir().ok().map(|dir| dir.join(AUTOSTART_OFF_MARKER)) +} + +/// Start with Windows is on unless the user turned it off from the tray, which leaves a marker file. +fn apply_autostart_choice(app: &AppHandle) -> bool { + let turned_off = autostart_marker(app).is_some_and(|marker| marker.exists()); + if !turned_off { + let _ = app.autolaunch().enable(); + } + !turned_off +} + +fn set_autostart(app: &AppHandle, enabled: bool) { + let Some(marker) = autostart_marker(app) else { return }; + if enabled { + let _ = std::fs::remove_file(&marker); + let _ = app.autolaunch().enable(); + } else { + if let Some(dir) = marker.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&marker, b""); + let _ = app.autolaunch().disable(); + } +} + +fn quit(app: &AppHandle) { + let _ = app.save_window_state(window_state_flags()); + app.exit(0); +} + +fn build_tray(app: &AppHandle, autostart_on: bool) -> tauri::Result<()> { + let version = MenuItem::with_id(app, "version", format!("{APP_NAME} {}", app.package_info().version), false, None::<&str>)?; + let open = MenuItem::with_id(app, "open", format!("Open {APP_NAME}"), true, None::<&str>)?; + let check = MenuItem::with_id(app, "check-updates", "Check for updates", true, None::<&str>)?; + let autostart = CheckMenuItem::with_id(app, "autostart", "Start with Windows", true, autostart_on, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let separator = PredefinedMenuItem::separator(app)?; + let menu = Menu::with_items(app, &[&version, &open, &check, &autostart, &separator, &quit_item])?; + let mut tray = TrayIconBuilder::with_id("main") + .tooltip(APP_NAME) + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(move |app, event| match event.id.as_ref() { + "open" => show_main(app), + "check-updates" => { + tauri::async_runtime::spawn(check_for_update(app.clone(), true)); + } + "autostart" => set_autostart(app, autostart.is_checked().unwrap_or(false)), + "quit" => quit(app), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { + show_main(tray.app_handle()); + } + }); + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + tray.build(app)?; + Ok(()) +} + +/// WebView2's browser keys (F5, Ctrl+P, Ctrl+F find bar, Alt+arrows, …) would otherwise act +/// before the app's own shortcuts. Editing keys such as Ctrl+C and Ctrl+Z are not affected. +#[cfg(windows)] +fn disable_browser_keys(window: &WebviewWindow) { + let _ = window.with_webview(|webview| unsafe { + use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings3; + use windows_core::Interface; + let Ok(core) = webview.controller().CoreWebView2() else { return }; + let Ok(settings) = core.Settings() else { return }; + if let Ok(settings) = settings.cast::() { + let _ = settings.SetAreBrowserAcceleratorKeysEnabled(false); + } + }); +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + // Registered first, as the plugin requires: a second launch only brings the window forward, + // because two running timers would race on the same data. + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| show_main(app))) + .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec![HIDDEN_ARG]))) + .plugin(tauri_plugin_window_state::Builder::default().with_state_flags(window_state_flags()).build()) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + toggle_main(app); + } + }) + .build(), + ) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_opener::init()) + .manage(AppState::default()) + .invoke_handler(tauri::generate_handler![schedule_end, cancel_end, open_url]) + .on_window_event(|window, event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + hide_main(window.app_handle()); + } + }) + .setup(|app| { + let handle = app.handle().clone(); + let autostart_on = apply_autostart_choice(&handle); + build_tray(&handle, autostart_on)?; + #[cfg(windows)] + if let Some(window) = app.get_webview_window(MAIN_WINDOW) { + disable_browser_keys(&window); + } + let clicked = handle.clone(); + app.notification().on_action(move |_| show_main(&clicked))?; + start_end_timer(handle.clone()); + let started_hidden = std::env::args().any(|arg| arg == HIDDEN_ARG); + if !started_hidden { + show_main(&handle); + } + if app.global_shortcut().register(GLOBAL_SHORTCUT).is_err() && !started_hidden { + show_message(&handle, format!("{GLOBAL_SHORTCUT} is used by another program. Open the app from its tray icon instead.")); + } + tauri::async_runtime::spawn(check_for_update(handle, false)); + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running Boost Focus Timer"); +} + +#[cfg(test)] +mod tests { + use super::is_web_url; + + #[test] + fn only_web_links_may_open() { + assert!(is_web_url("https://example.com")); + assert!(is_web_url("http://example.com")); + assert!(!is_web_url("file:///C:/Windows/System32/calc.exe")); + assert!(!is_web_url("C:\\Windows\\System32\\calc.exe")); + } +} diff --git a/desktop/src/main.rs b/desktop/src/main.rs new file mode 100644 index 0000000..f018cab --- /dev/null +++ b/desktop/src/main.rs @@ -0,0 +1,6 @@ +// No console window behind the app in release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + boost_focus_timer_lib::run() +} diff --git a/desktop/src/timer.rs b/desktop/src/timer.rs new file mode 100644 index 0000000..85ef716 --- /dev/null +++ b/desktop/src/timer.rs @@ -0,0 +1,43 @@ +/// The end-of-session notification waiting for its moment. +pub struct ScheduledEnd { + /// Epoch milliseconds, as JavaScript's `Date.now()` counts them. + pub at_ms: f64, + pub title: String, + pub body: String, +} + +/// Empties `slot` and returns its end once `now_ms` has reached it. +pub fn take_due(slot: &mut Option, now_ms: f64) -> Option { + if slot.as_ref()?.at_ms > now_ms { + return None; + } + slot.take() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn end_at(at_ms: f64) -> Option { + Some(ScheduledEnd { at_ms, title: "Done".into(), body: "Break".into() }) + } + + #[test] + fn nothing_scheduled_is_never_due() { + assert!(take_due(&mut None, 1000.0).is_none()); + } + + #[test] + fn a_future_end_stays_scheduled() { + let mut slot = end_at(2000.0); + assert!(take_due(&mut slot, 1999.0).is_none()); + assert!(slot.is_some()); + } + + #[test] + fn a_reached_end_fires_once() { + let mut slot = end_at(2000.0); + assert_eq!(take_due(&mut slot, 2000.0).map(|end| end.title), Some("Done".to_string())); + assert!(take_due(&mut slot, 3000.0).is_none()); + } +} diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json new file mode 100644 index 0000000..07135ed --- /dev/null +++ b/desktop/tauri.conf.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Boost Focus Timer", + "version": "../package.json", + "identifier": "com.ttncode.boostfocustimer", + "build": { + "frontendDist": "../dist/extension" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "label": "main", + "title": "Boost Focus Timer", + "url": "popup.html", + "width": 420, + "height": 640, + "minWidth": 320, + "minHeight": 400, + "visible": false, + "zoomHotkeysEnabled": false, + "useHttpsScheme": true, + "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows" + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://img.youtube.com; media-src 'self' data: blob:; font-src 'self' data:; frame-src https://www.youtube.com https://open.spotify.com; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost", + "dangerousDisableAssetCspModification": ["style-src"] + } + }, + "bundle": { + "active": true, + "targets": ["nsis"], + "createUpdaterArtifacts": true, + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico"], + "windows": { + "nsis": { + "installMode": "currentUser" + } + } + }, + "plugins": { + "updater": { + "pubkey": "SET_IN_RELEASE_SETUP", + "endpoints": ["https://github.com/ttncode/boost-focus-timer/releases/latest/download/latest.json"], + "windows": { + "installMode": "passive" + } + } + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs index b5d8c8f..4d7a9fa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "dist/**", + "desktop/target/**", + "desktop/gen/**", "next-env.d.ts", ]), ]); diff --git a/package-lock.json b/package-lock.json index 8795087..01d1aa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "ai-website-clone-template", + "name": "boost-focus-timer", "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ai-website-clone-template", + "name": "boost-focus-timer", "version": "0.5.0", - "license": "MIT", + "license": "UNLICENSED", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -19,6 +19,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@tauri-apps/cli": "^2.12.4", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", @@ -1786,6 +1787,238 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@tauri-apps/cli": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.4.tgz", + "integrity": "sha512-JI9bHfudSRJikGOrjbXEm6RjzzvmKuLc+JixB1NE8hNzQkNlCCIC3pTES3uypkR9lxHrsbxzcnSwhI2IxBQphw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.12.4", + "@tauri-apps/cli-darwin-x64": "2.12.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.4", + "@tauri-apps/cli-linux-arm64-musl": "2.12.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.4", + "@tauri-apps/cli-linux-x64-gnu": "2.12.4", + "@tauri-apps/cli-linux-x64-musl": "2.12.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.4", + "@tauri-apps/cli-win32-x64-msvc": "2.12.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.4.tgz", + "integrity": "sha512-OxqKSckIkoJ6vX2AjifIJpTasFC0Hbdoo7gEiCmAjauABO3U8m2MRsLWe+QTiBwDx1mIrvgA+rvI7tvDnbnHVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.4.tgz", + "integrity": "sha512-fz76/77wMMJOaaUN6n3CybRG0P/MTEjjqYk81mGPyhi9m6Ue5T+LWBnJP/AkPooNatBPI2vtulGqomyMJ44lCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.4.tgz", + "integrity": "sha512-K0w8Bof6Lt8QKLAYhRy0yy36ZH5iA/35VuSOvnxyjvCUZ+A3JKsUTlALviH/sVgAlisaPzzmeJSH8XOn4Q2HBA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.4.tgz", + "integrity": "sha512-j5rOnjmRUwWrm1oosf0zBYl4D+fqydgUROFCk0hvKZxvbTT8SOERes0QB7rBV/XWNy+kM6pCKzb3oSm2sRglzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.4.tgz", + "integrity": "sha512-aOue3Fy6Jb7+oWcfAVUsVHfeTybSCt2CZoU06aO4jv4d2Ks/KnaTPwMAK3CLEduGOvBLY1lbBBrIyomqLLH5Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.4.tgz", + "integrity": "sha512-Fx6GShY/RvST+t6LMlTHAwd66+1tEaAAGrnU7cysVfaiRP1BKng5Vl6rVmO6rSLOA1Sp1dRzNYM4f0OWQGb7qA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.4.tgz", + "integrity": "sha512-tNzZSa79Z1nRpHxEpQ38lsAFpKlFlYvoXzjTV+lRRn/zSf3wrdUC3wnLcq2XEadchPu4ttK0zivhYzr+XGD19Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.4.tgz", + "integrity": "sha512-jmZ1pr0xEqUhuEusWtt9Yt+GF6lCnGHUmIL+KIknpbjK/XQ2qkhDBhaxK/JMHM2A8dAacVXCxXJk2EXpwhQUfA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.4.tgz", + "integrity": "sha512-fGo8pgEHZxdSYgZ/J98m1usJ7NHKmKbq7k6uT0x1rpOq6Bg2YVzzpaAblWk710f5LHYBJIL5QIFS5IrD+4gmTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.4.tgz", + "integrity": "sha512-ezl8sQIcjYsye3TUnTH5VcWmWzccZASlQm7hlZt0dYyj1XD0qpVW47u2xYVrIKui0F8uQv/7QGZixdWiClBsbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.4.tgz", + "integrity": "sha512-DJhDeCqjLDNMWYFy7ZS8cfeAHX2RK0RbQMLiA3bMVVkXRDJ4NBYiHriGqsUhsr9iG41aTb+dJGkFPmPw1gWwVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", diff --git a/package.json b/package.json index 8c2b57f..fd6c9a2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,10 @@ "test": "node --test \"tests/**/*.test.ts\"", "icons": "node scripts/generate-icons.mjs", "build:extension": "node scripts/build-extension.mjs", - "check": "npm run lint && npm run typecheck && npm test && npm run build" + "check": "npm run lint && npm run typecheck && npm test && npm run build", + "tauri": "tauri", + "test:e2e": "node --test tests-e2e/app.e2e.js", + "build:desktop": "npm run build:extension && tauri build" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -35,6 +38,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@tauri-apps/cli": "^2.12.4", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs index ec02451..e5c8b84 100644 --- a/scripts/build-extension.mjs +++ b/scripts/build-extension.mjs @@ -29,7 +29,8 @@ function walk(dir) { function exportStaticSite() { rmSync(EXPORT_DIR, { recursive: true, force: true }); - execFileSync("npx", ["next", "build"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, BUILD_TARGET: "extension" } }); + // On Windows npx is a .cmd file, which only a shell can run. + execFileSync("npx", ["next", "build"], { cwd: ROOT, stdio: "inherit", shell: process.platform === "win32", env: { ...process.env, BUILD_TARGET: "extension" } }); rmSync(EXTENSION_DIR, { recursive: true, force: true }); mkdirSync(DIST_DIR, { recursive: true }); cpSync(EXPORT_DIR, EXTENSION_DIR, { recursive: true }); @@ -43,7 +44,7 @@ function renameReservedPaths() { } // Leftover "_"-prefixed routes (e.g. the not-found page) are unused inside the extension. for (const entry of readdirSync(EXTENSION_DIR, { recursive: true })) { - const isReserved = String(entry).split("/").some((part) => part.startsWith("_")); + const isReserved = String(entry).split(/[\\/]/).some((part) => part.startsWith("_")); if (isReserved) rmSync(join(EXTENSION_DIR, String(entry)), { recursive: true, force: true }); } } diff --git a/src/components/boost/PostContent.tsx b/src/components/boost/PostContent.tsx index 204d480..ee265eb 100644 --- a/src/components/boost/PostContent.tsx +++ b/src/components/boost/PostContent.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Play } from "../icons"; +import { openLinkOnDesktop } from "../lib/extension"; const URL_PATTERN = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/g; const YOUTUBE_PATTERN = @@ -35,7 +36,14 @@ function LinkifiedText({ text }: { text: string }) {

{text.split(URL_PATTERN).map((part, index) => index % 2 === 1 ? ( - + openLinkOnDesktop(event, part)} + className="text-blue-400 hover:underline" + > {part} ) : ( diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts index f505ce2..db4bb57 100644 --- a/src/components/focus/notifications.ts +++ b/src/components/focus/notifications.ts @@ -2,8 +2,9 @@ * The browser permission is the only switch: the app asks the browser directly on first open. * Inside the extension the manifest grants it and the background worker shows the * end-of-session notifications (see lib/extension.ts), so pages there never notify themselves. + * The desktop app works the same way, with its shell in place of the worker. */ -import { isExtensionPage } from "../lib/extension"; +import { hasBackgroundWorker } from "../lib/extension"; const ICON_PATH = "/icons/icon-128.png"; @@ -12,7 +13,7 @@ const hasWebNotifications = (): boolean => typeof window !== "undefined" && "Not export type NotificationStatus = "unsupported" | NotificationPermission; export function notificationStatus(): NotificationStatus { - if (isExtensionPage()) return "granted"; + if (hasBackgroundWorker()) return "granted"; return hasWebNotifications() ? Notification.permission : "unsupported"; } @@ -79,5 +80,5 @@ function show(title: string, body: string): void { /** Shows a notification in the web app when the browser allows it; the extension's background worker handles its own. */ export function notify(title: string, body: string): void { - if (!isExtensionPage() && notificationStatus() === "granted") show(title, body); + if (!hasBackgroundWorker() && notificationStatus() === "granted") show(title, body); } diff --git a/src/components/hotkeys/HotkeysProvider.tsx b/src/components/hotkeys/HotkeysProvider.tsx index 36bb876..cb7ca01 100644 --- a/src/components/hotkeys/HotkeysProvider.tsx +++ b/src/components/hotkeys/HotkeysProvider.tsx @@ -11,6 +11,7 @@ import { type Bindings, } from "./bindings"; import { ShortcutsPanel } from "./ShortcutsPanel"; +import { isDesktopApp } from "../lib/extension"; type Handler = () => void; @@ -101,7 +102,12 @@ export function HotkeysProvider({ children }: { children: ReactNode }) { return ( {children} - {isPanelOpen ? setIsPanelOpen(false)} /> : null} + {isPanelOpen ? ( + action.id !== "app.fullPage") : HOTKEY_ACTIONS} + onClose={() => setIsPanelOpen(false)} + /> + ) : null} ); } diff --git a/src/components/hotkeys/ShortcutsPanel.tsx b/src/components/hotkeys/ShortcutsPanel.tsx index 8ccc32a..b7a1be2 100644 --- a/src/components/hotkeys/ShortcutsPanel.tsx +++ b/src/components/hotkeys/ShortcutsPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { comboFromEvent, type HotkeyAction } from "./bindings"; import { useHotkeys } from "./HotkeysProvider"; +import { isDesktopApp } from "../lib/extension"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? ""; @@ -131,6 +132,11 @@ export function ShortcutsPanel({ actions, onClose }: { actions: readonly HotkeyA To open the timer from anywhere, set a shortcut at chrome://extensions/shortcuts.

) : null} + {isDesktopApp() ? ( +

+ Press Ctrl+Alt+B in any program to show or hide the timer. +

+ ) : null} {APP_VERSION ?

Boost Focus Timer {APP_VERSION}

: null} diff --git a/src/components/lib/extension.ts b/src/components/lib/extension.ts index e73befd..70f6eff 100644 --- a/src/components/lib/extension.ts +++ b/src/components/lib/extension.ts @@ -1,7 +1,8 @@ /* - * Browser-extension glue. In the extension the app runs in a popup window the user may close - * at any time, so a running timer is recorded here and the background worker raises the - * end-of-session notification. Every function is a no-op in the web app. + * Browser-extension and desktop-app glue. In both, the app runs in a window the user may close + * at any time, so a running timer is recorded here and something outside the page (the background + * worker, or the desktop shell) raises the end-of-session notification. Every function is a no-op + * in the web app. */ const RUNNING_TIMER_KEY = "bft:runningTimer"; @@ -10,8 +11,13 @@ interface ExtensionRuntime { runtime: { id?: string; sendMessage(message: BackgroundMessage): Promise }; } +interface DesktopShell { + core: { invoke(command: string, args: Record): Promise }; +} + export type BackgroundMessage = | { type: "open-app" } + | { type: "open-url"; url: string } | { type: "schedule-end"; at: number; title: string; body: string } | { type: "cancel-end" }; @@ -20,7 +26,14 @@ function runtime(): ExtensionRuntime["runtime"] | null { return candidate?.runtime?.id ? candidate.runtime : null; } -export const isExtensionPage = (): boolean => runtime() !== null; +function desktopShell(): DesktopShell | null { + return (globalThis as { __TAURI__?: DesktopShell }).__TAURI__ ?? null; +} + +export const isDesktopApp = (): boolean => desktopShell() !== null; + +/** True when something outside the page shows end-of-session notifications. */ +export const hasBackgroundWorker = (): boolean => runtime() !== null || isDesktopApp(); /** The build marks popup.html with `data-view="popup"`; the full-page tab has no marker. */ export const isPopupView = (): boolean => @@ -28,6 +41,12 @@ export const isPopupView = (): boolean => async function send(message: BackgroundMessage): Promise { try { + const desktop = desktopShell(); + if (desktop) { + const { type, ...args } = message; + await desktop.core.invoke(type.replace("-", "_"), args); + return; + } await runtime()?.sendMessage(message); } catch (error) { console.error(`Background message "${message.type}" failed:`, error); @@ -40,6 +59,13 @@ export async function openFullPage(): Promise { if (isPopupView()) window.close(); } +/** The desktop window cannot follow `target="_blank"`, so the default browser opens the link. */ +export function openLinkOnDesktop(event: { preventDefault(): void }, url: string): void { + if (!isDesktopApp()) return; + event.preventDefault(); + void send({ type: "open-url", url }); +} + export interface RunningTimer { readonly kind: "focus" | "break"; readonly sessionId: string; @@ -54,7 +80,7 @@ export interface RunningTimer { * worker notify at `endAt` even when no app page is open. */ export function trackRunningTimer(timer: RunningTimer): void { - if (!isExtensionPage()) return; + if (!hasBackgroundWorker()) return; localStorage.setItem(RUNNING_TIMER_KEY, JSON.stringify(timer)); const notification = timer.kind === "focus" @@ -64,13 +90,13 @@ export function trackRunningTimer(timer: RunningTimer): void { } export function clearRunningTimer(): void { - if (!isExtensionPage() || localStorage.getItem(RUNNING_TIMER_KEY) === null) return; + if (!hasBackgroundWorker() || localStorage.getItem(RUNNING_TIMER_KEY) === null) return; localStorage.removeItem(RUNNING_TIMER_KEY); void send({ type: "cancel-end" }); } export function readRunningTimer(): RunningTimer | null { - if (!isExtensionPage()) return null; + if (!hasBackgroundWorker()) return null; try { const stored = localStorage.getItem(RUNNING_TIMER_KEY); return stored ? (JSON.parse(stored) as RunningTimer) : null; diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index 59ea7d9..4978507 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -6,7 +6,7 @@ import { Bell, ChevronDown, CircleUser, Expand, Keyboard, Menu, Settings, Tag, V import { hotkeyHint } from "../focus/GoalForm"; import { useHotkey, useHotkeyLabel, useHotkeys } from "../hotkeys/HotkeysProvider"; import type { Tab } from "../types"; -import { isPopupView, openFullPage } from "../lib/extension"; +import { isDesktopApp, isPopupView, openFullPage } from "../lib/extension"; import { SoundsPanel } from "./SoundsPanel"; import { MOBILE_NAV_OFFSET, MOBILE_QUERY, useMediaQuery } from "./use-media-query"; import { useDialog } from "../dialogs/DialogProvider"; @@ -74,7 +74,9 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { useHotkey("nav.menu", () => setIsOpen((open) => !open)); const menuCombo = useHotkeyLabel("nav.menu"); const fullPageCombo = useHotkeyLabel("app.fullPage"); - useHotkey("app.fullPage", () => void openFullPage(), isPopup); + // The desktop app has one compact window and no full page to open. + const canOpenFullPage = isPopup && !isDesktopApp(); + useHotkey("app.fullPage", () => void openFullPage(), canOpenFullPage); useHotkey("app.sounds", () => { setIsSoundsOpen(true); setIsOpen(false); @@ -129,7 +131,7 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { toggleRef.current?.focus(); }} > - {isPopup ? ( + {canOpenFullPage ? (