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..61586e2 --- /dev/null +++ b/desktop/Cargo.toml @@ -0,0 +1,38 @@ +[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" +serde_json = "1" + +# 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 0000000..0f6616d Binary files /dev/null and b/desktop/icons/128x128.png differ diff --git a/desktop/icons/128x128@2x.png b/desktop/icons/128x128@2x.png new file mode 100644 index 0000000..b233e47 Binary files /dev/null and b/desktop/icons/128x128@2x.png differ diff --git a/desktop/icons/32x32.png b/desktop/icons/32x32.png new file mode 100644 index 0000000..18b1f37 Binary files /dev/null and b/desktop/icons/32x32.png differ diff --git a/desktop/icons/icon.ico b/desktop/icons/icon.ico new file mode 100644 index 0000000..9b917d1 Binary files /dev/null and b/desktop/icons/icon.ico differ diff --git a/desktop/icons/icon.png b/desktop/icons/icon.png new file mode 100644 index 0000000..febcac7 Binary files /dev/null and b/desktop/icons/icon.png differ 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..b6fb399 --- /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": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYyNTlCNkVBMzVGRDlBQTQKUldTa212MDE2clpaOHFWN09tdjFqV0hsTExWQVBtY3EzdXA4T2xJODQ3amhVS3VONXd3SWFZUEwK", + "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 ? (