diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..91be099 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + windows: + # windows-2025 images break WebView2 WebDriver sessions (actions/runner-images#14738). + runs-on: windows-2022 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Unit tests + run: npm test + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop + - name: Rust tests + run: cargo test --manifest-path desktop/Cargo.toml + - name: Clippy + run: cargo clippy --manifest-path desktop/Cargo.toml --all-targets -- -D warnings + - name: Resolved WebView2 crates + run: cargo tree --manifest-path desktop/Cargo.toml -i webview2-com + - name: Build app + 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: + NOTELET_APP: ${{ github.workspace }}\desktop\target\release\notelet.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..69070a2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + windows: + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + 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 + - 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: Notelet ${{ github.ref_name }} + releaseBody: See the README for install and update steps. + releaseDraft: false + includeUpdaterJson: true + tauriScript: npx tauri diff --git a/README.md b/README.md index 228f27b..80ecb38 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@

Notelet

- Notes for Chrome in the style of iPhone Notes, with sprint tracking built in.
+ Notes for Windows in the style of iPhone Notes, with sprint tracking built in.
No account, no sync, nothing sent anywhere.

Latest release License: MIT - Chrome Manifest V3 + Windows 10 and 11

@@ -34,9 +34,9 @@ - **Feels like Notes.** Title, Heading and Body styles, bulleted, dashed, numbered and check lists, pinned notes, Recently Deleted, light and dark themes. In a narrow window it switches to the iPhone layout and its slide between screens. - **Sprints in one note.** Every sprint and a Backlog live in one Sprints note: a points donut, goals per sprint and per month, drag-and-drop tasks and a rich-text note for each task. - **Keyboard first.** Every button has a shortcut, and you can change any of them. -- **Back and forward.** The mouse's side buttons, the browser's Back and Forward and Alt+← / Alt+→ step through screens. -- **Two windows, nothing lost.** Edit in the popup and a full page at once; changes merge instead of overwriting each other. -- **Private.** Notes stay in your Chrome profile. Export a backup file whenever you like. +- **Back and forward.** The mouse's side buttons and Alt+← / Alt+→ step through screens. +- **A keystroke away.** `Ctrl+Alt+N` shows or hides Notelet from any app; it waits in the system tray. +- **Private.** Notes stay in one file on your PC. Export a backup file whenever you like. ## Screenshots @@ -71,29 +71,27 @@ Notelet is an independent project and is not affiliated with or endorsed by Appl ## Install -1. Download `notelet-.zip` from [Releases](https://github.com/ttncode/notelet/releases/latest). -2. Unzip it into a folder you will keep, so `manifest.json` sits directly inside it. On Windows, `%LOCALAPPDATA%\Programs\Notelet` works well. Avoid Downloads, the Desktop and OneDrive folders, which can be cleaned up or made online-only. -3. Open `chrome://extensions` and turn on **Developer mode**. -4. Click **Load unpacked** and choose that folder. -5. Pin Notelet to the toolbar. To open it from anywhere, set a shortcut at `chrome://extensions/shortcuts` (suggested: `Alt+.`). +1. Download `Notelet__x64-setup.exe` from [Releases](https://github.com/ttncode/notelet/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. Notelet opens, adds an icon to the system tray and starts with Windows from then on. -Clicking the Notelet icon opens a small window at the top right that you can move and resize; it remembers where you left it. The ⤢ button in that window opens Notelet as a full page. - -Works the same in Edge (`edge://extensions`) and Brave (`brave://extensions`). +Press `Ctrl+Alt+N` from any app to show or hide Notelet. Closing the window (X) only hides it; to quit, right-click the tray icon → **Quit**. Turn off **Start with Windows** in the same menu if you don't want it at login. The window remembers its size and place; `Alt+Enter` or the ⤢ button maximizes and restores it. ## Update -Extensions loaded this way never update themselves. +Notelet updates itself. Each time it starts it checks for a new version, installs it and reopens, so nothing is lost: any change you are typing is saved first. To check now, right-click the tray icon → **Check for updates**. + +## Moving from the Chrome extension -1. Export a backup (**⋯** above the notes list → **Export**). -2. Replace the folder's contents with the new release. -3. In `chrome://extensions`, click the reload icon on Notelet. Don't click **Remove**: that deletes your notes. +1. In the extension, export a backup (**⋯** above the notes list → **Export**). +2. Install the desktop app. +3. On first start it offers **Import backup**; choose the file. Or use **⋯** → **Import** any time. -Your notes stay: Notelet's extension ID is fixed, so Chrome keeps the same storage even if you move the folder (load it again from the new place). +Notes, deleted notes, sprints, statuses, settings and custom shortcuts come across. The theme and the notes list width don't; set them again once. You can remove the extension afterwards. ## Back up and restore -Notes live only in this browser profile. **Removing the extension deletes all of them**, and Chrome offers no way to keep them, so export regularly. +Notes live in `%APPDATA%\com.ttncode.notelet\notelet.json`, with the previous save kept beside it as `notelet.json.bak`. If the file is ever damaged, Notelet opens the last good copy, or moves the damaged file aside (it is never deleted) and offers Import. The file stays on this PC only, so export regularly. - **Export** saves every note, sprint, setting and custom shortcut to one `.json` file. - **Import** merges a backup back in: a note with the same ID is replaced, and everything else is kept. Backups from any earlier version import, and old task-tracking notes in them join the Sprints note. @@ -131,27 +129,24 @@ Press `Ctrl+/` (`⌘/` on Mac) for the full list, grouped by topic. Every button | Change case: lower, UPPER, Title | `Shift+F3` | `⇧F3` | | Back / Forward | mouse side buttons, `Alt+←` / `Alt+→` | mouse side buttons, `⌘[` / `⌘]` | | All shortcuts | `Ctrl+/` | `⌘/` | +| Show / hide Notelet from any app | `Ctrl+Alt+N` | — | -Text styles follow Notes for Mac with Ctrl in place of Cmd; where Chrome keeps a key for itself (Ctrl+N, Ctrl+Shift+T) Notelet uses Alt instead. +Text styles follow Notes for Mac with Ctrl in place of Cmd; where Chrome kept a key for itself (Ctrl+N, Ctrl+Shift+T) Notelet uses Alt instead, the same keys as the extension had. -**To change a shortcut**, click its key in the list and press the new one. Esc, Tab or a click elsewhere cancels; Backspace removes the key. Keys Chrome or Windows keep are refused, and a key another shortcut uses asks before taking it over. ↺ resets one shortcut; **Reset All to Default** resets them all. +**To change a shortcut**, click its key in the list and press the new one. Esc, Tab or a click elsewhere cancels; Backspace removes the key. Keys Windows keeps, and those Chrome kept, are refused; the show / hide key must use Ctrl or Alt, and a key another app holds is refused with a message; and a key another shortcut uses asks before taking it over. ↺ resets one shortcut; **Reset All to Default** resets them all. ## Development ```bash npm install -npm test # unit tests for the pure modules -node tools/make-icons.mjs # regenerate icons -tools/package.sh # build dist/notelet-.zip +npm test # unit tests for the UI modules +node tools/make-icons.mjs # regenerate icons, then: npx tauri icon desktop/icons/source.png -o desktop/icons +npm run tauri dev # run the app (Windows, with Rust and the MSVC build tools) ``` -Load the `extension/` folder unpacked to try changes, then click reload in `chrome://extensions`. - -Working in WSL? Chrome's folder picker cannot open `\\wsl.localhost` paths, so copy the folder to the Windows side and load the copy: +The UI lives in `extension/` and runs unchanged inside the Tauri shell in `desktop/`. CI on Windows runs the unit tests, `cargo test`, clippy, a release build and end-to-end tests that press every shortcut in the built app. Working in WSL, where the Windows toolchain is not available, push a branch and let CI build it. -```bash -rsync -a --delete extension/ /mnt/c/Users//Notelet-dev/ -``` +Pushing a `v*` tag builds the installer, signs the update, and publishes the release with the `latest.json` the app checks. ## License 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..23a3b56 --- /dev/null +++ b/desktop/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "notelet" +version = "0.7.0" +description = "Lightweight notes with sprint tracking" +edition = "2021" +publish = false + +[lib] +name = "notelet_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" +serde = { version = "1", features = ["derive"] } +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" + +[dev-dependencies] +tempfile = "3" + +[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..88fd593 --- /dev/null +++ b/desktop/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "What the Notelet 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..51acf46 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..fa679dd 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..55a5014 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..9d0a416 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..c17c0a7 Binary files /dev/null and b/desktop/icons/icon.png differ diff --git a/desktop/icons/source.png b/desktop/icons/source.png new file mode 100644 index 0000000..95aed95 Binary files /dev/null and b/desktop/icons/source.png differ diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs new file mode 100644 index 0000000..248e9fe --- /dev/null +++ b/desktop/src/lib.rs @@ -0,0 +1,352 @@ +pub mod storage; + +use serde::Serialize; +use serde_json::{Map, Value}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::Duration; +use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow, WindowEvent}; +use tauri_plugin_autostart::{MacosLauncher, ManagerExt as _}; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; +use tauri_plugin_updater::{Update, UpdaterExt}; +use tauri_plugin_window_state::{AppHandleExt, StateFlags}; + +const MAIN_WINDOW: &str = "main"; +const HIDDEN_ARG: &str = "--hidden"; +const AUTOSTART_OFF_MARKER: &str = "autostart-off"; +// How long the page gets to write pending edits before Quit or an update goes ahead anyway. +const FLUSH_TIMEOUT: Duration = Duration::from_secs(5); + +enum Pending { + Quit, + /// Downloaded already, so only the install runs after the page has saved. + Update(Box, Vec), +} + +impl Pending { + fn name(&self) -> &'static str { + match self { + Pending::Quit => "quit", + Pending::Update(..) => "update", + } + } +} + +#[derive(Default)] +struct AppState { + storage_lock: Mutex<()>, + global_shortcut: Mutex>, + pending: Mutex>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LoadResponse { + data: Map, + recovered: Option<&'static str>, + damaged_path: Option, +} + +#[derive(Clone, Serialize)] +struct FlushRequest { + then: &'static str, +} + +fn data_dir(app: &AppHandle) -> Result { + app.path().app_data_dir().map_err(|error| error.to_string()) +} + +#[tauri::command] +fn storage_load(app: AppHandle, state: State) -> Result { + let _guard = state.storage_lock.lock().map_err(|error| error.to_string())?; + let loaded = storage::load(&data_dir(&app)?).map_err(|error| error.to_string())?; + Ok(LoadResponse { + data: loaded.data, + recovered: loaded.recovered, + damaged_path: loaded.damaged_path.map(|path| path.display().to_string()), + }) +} + +#[tauri::command] +fn storage_save(app: AppHandle, state: State, data: Map) -> Result<(), String> { + let _guard = state.storage_lock.lock().map_err(|error| error.to_string())?; + storage::save(&data_dir(&app)?, &data).map_err(|error| error.to_string()) +} + +/// Registers the new key before letting go of the old one, so a key another app holds leaves +/// the old key working. Returns `Err("taken")` in that case; `None` removes the key. +#[tauri::command] +fn set_global_shortcut(app: AppHandle, state: State, accelerator: Option) -> Result<(), String> { + let wanted = accelerator + .map(|text| text.parse::().map_err(|error| error.to_string())) + .transpose()?; + let mut current = state.global_shortcut.lock().map_err(|error| error.to_string())?; + if wanted == *current { + return Ok(()); + } + let shortcuts = app.global_shortcut(); + if let Some(shortcut) = wanted { + shortcuts.register(shortcut).map_err(|_| "taken".to_string())?; + } + if let Some(old) = current.take() { + let _ = shortcuts.unregister(old); + } + *current = wanted; + Ok(()) +} + +#[tauri::command] +fn toggle_maximize(window: WebviewWindow) -> Result<(), String> { + let maximized = window.is_maximized().map_err(|error| error.to_string())?; + let result = if maximized { window.unmaximize() } else { window.maximize() }; + result.map_err(|error| error.to_string()) +} + +#[tauri::command] +fn after_flush(app: AppHandle, then: String) { + run_pending(&app, &then); +} + +/// Native Save dialog for a backup. `Ok(false)` when the user cancels. +#[tauri::command] +async fn save_backup(app: AppHandle, file_name: String, text: String) -> Result { + let mut dialog = app.dialog().file().set_file_name(&file_name).add_filter("Notelet backup", &["json"]); + if let Ok(downloads) = app.path().download_dir() { + dialog = dialog.set_directory(downloads); + } + let Some(chosen) = dialog.blocking_save_file() else { + return Ok(false); + }; + let path = chosen.into_path().map_err(|error| error.to_string())?; + std::fs::write(path, text).map_err(|error| error.to_string())?; + Ok(true) +} + +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); + } +} + +/// Asks the page to write pending edits, then quits or updates once it answers or the timeout +/// passes, whichever comes first. +fn request_after_flush(app: &AppHandle, pending: Pending) { + let then = pending.name(); + if let Ok(mut slot) = app.state::().pending.lock() { + *slot = Some(pending); + } + let _ = app.emit("flush-request", FlushRequest { then }); + let handle = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(FLUSH_TIMEOUT); + run_pending(&handle, then); + }); +} + +/// `then` is the action the page saved for, or `"cancel"` when its save failed: the pending +/// action is dropped and the window shown, so the unsaved text stays in front of the user. +fn run_pending(app: &AppHandle, then: &str) { + let state = app.state::(); + let pending = { + let Ok(mut slot) = state.pending.lock() else { return }; + if then == "cancel" { + slot.take(); + drop(slot); + show_main(app); + return; + } + if slot.as_ref().map(Pending::name) != Some(then) { + return; + } + slot.take() + }; + match pending { + Some(Pending::Quit) => { + let _ = app.save_window_state(window_state_flags()); + app.exit(0); + } + Some(Pending::Update(update, bytes)) => { + let _ = app.save_window_state(window_state_flags()); + match update.install(bytes) { + Ok(()) => app.restart(), + Err(error) => show_message(app, format!("Couldn't install the update: {error}")), + } + } + None => {} + } +} + +fn show_message(app: &AppHandle, text: String) { + app.dialog().message(text).title("Notelet").show(|_| {}); +} + +/// 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 { + // Downloading first keeps the gap between the page's last save and the installer closing + // the app to the install alone. + Ok(Some(update)) => match update.download(|_, _| {}, || {}).await { + Ok(bytes) => request_after_flush(&app, Pending::Update(Box::new(update), bytes)), + 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!("Notelet {} 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 { + data_dir(app).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 build_tray(app: &AppHandle, autostart_on: bool) -> tauri::Result<()> { + let version = MenuItem::with_id(app, "version", format!("Notelet {}", app.package_info().version), false, None::<&str>)?; + let open = MenuItem::with_id(app, "open", "Open Notelet", 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 = 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])?; + let mut tray = TrayIconBuilder::with_id("main") + .tooltip("Notelet") + .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" => request_after_flush(app, Pending::Quit), + _ => {} + }) + .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 Notelet'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. + .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()) + .manage(AppState::default()) + .invoke_handler(tauri::generate_handler![ + storage_load, + storage_save, + set_global_shortcut, + toggle_maximize, + after_flush, + save_backup + ]) + .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); + } + if !std::env::args().any(|arg| arg == HIDDEN_ARG) { + show_main(&handle); + } + tauri::async_runtime::spawn(check_for_update(handle, false)); + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running Notelet"); +} diff --git a/desktop/src/main.rs b/desktop/src/main.rs new file mode 100644 index 0000000..d5e9297 --- /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() { + notelet_lib::run() +} diff --git a/desktop/src/storage.rs b/desktop/src/storage.rs new file mode 100644 index 0000000..9b9d03d --- /dev/null +++ b/desktop/src/storage.rs @@ -0,0 +1,188 @@ +//! The notes file. Every save goes to a temp file first and the previous file is kept as +//! `.bak`, so a crash mid-save or a damaged file never costs more than the last change. +//! A damaged file is renamed aside, never deleted or overwritten. + +use serde_json::{Map, Value}; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAIN: &str = "notelet.json"; +const BACKUP: &str = "notelet.json.bak"; +const TEMP: &str = "notelet.json.tmp"; + +pub struct Loaded { + pub data: Map, + /// `Some("backup")` when the `.bak` copy was opened, `Some("reset")` when nothing readable was left. + pub recovered: Option<&'static str>, + pub damaged_path: Option, +} + +enum Read { + Missing, + Damaged, + Good(Map), +} + +pub fn save(dir: &Path, data: &Map) -> io::Result<()> { + fs::create_dir_all(dir)?; + let temp = dir.join(TEMP); + let mut file = fs::File::create(&temp)?; + file.write_all(&serde_json::to_vec(data)?)?; + file.sync_all()?; + drop(file); + let main = dir.join(MAIN); + if main.exists() { + fs::rename(&main, dir.join(BACKUP))?; + } + fs::rename(&temp, &main) +} + +pub fn load(dir: &Path) -> io::Result { + let main = dir.join(MAIN); + let backup = dir.join(BACKUP); + let main_read = read(&main)?; + if let Read::Good(data) = main_read { + return Ok(Loaded { data, recovered: None, damaged_path: None }); + } + let backup_read = read(&backup)?; + let main_damaged = matches!(main_read, Read::Damaged); + match backup_read { + Read::Missing if !main_damaged => Ok(Loaded { data: Map::new(), recovered: None, damaged_path: None }), + Read::Good(data) => { + if main_damaged { + move_aside(&main)?; + } + Ok(Loaded { data, recovered: Some("backup"), damaged_path: None }) + } + _ => { + let moved_main = if main_damaged { Some(move_aside(&main)?) } else { None }; + let moved_backup = if matches!(backup_read, Read::Damaged) { Some(move_aside(&backup)?) } else { None }; + Ok(Loaded { data: Map::new(), recovered: Some("reset"), damaged_path: moved_main.or(moved_backup) }) + } + } +} + +fn read(path: &Path) -> io::Result { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Read::Missing), + Err(error) => return Err(error), + }; + Ok(match serde_json::from_slice::(&bytes) { + Ok(Value::Object(map)) => Read::Good(map), + _ => Read::Damaged, + }) +} + +fn move_aside(path: &Path) -> io::Result { + let millis = SystemTime::now().duration_since(UNIX_EPOCH).map(|elapsed| elapsed.as_millis()).unwrap_or(0); + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".damaged-{millis}")); + let target = path.with_file_name(name); + fs::rename(path, &target)?; + Ok(target) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn map(value: Value) -> Map { + value.as_object().unwrap().clone() + } + + fn damaged_files(dir: &Path, prefix: &str) -> Vec { + fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.file_name().unwrap().to_string_lossy().starts_with(&format!("{prefix}.damaged-"))) + .collect() + } + + #[test] + fn save_then_load_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let data = map(json!({ "note:a": { "html": "

é

" }, "schemaVersion": 5 })); + save(dir.path(), &data).unwrap(); + let loaded = load(dir.path()).unwrap(); + assert_eq!(loaded.data, data); + assert_eq!(loaded.recovered, None); + } + + #[test] + fn second_save_keeps_previous_as_bak() { + let dir = tempfile::tempdir().unwrap(); + save(dir.path(), &map(json!({ "v": 1 }))).unwrap(); + save(dir.path(), &map(json!({ "v": 2 }))).unwrap(); + let bak: Value = serde_json::from_slice(&fs::read(dir.path().join(BACKUP)).unwrap()).unwrap(); + assert_eq!(bak, json!({ "v": 1 })); + assert_eq!(load(dir.path()).unwrap().data, map(json!({ "v": 2 }))); + } + + #[test] + fn no_tmp_file_left_after_save() { + let dir = tempfile::tempdir().unwrap(); + save(dir.path(), &map(json!({ "v": 1 }))).unwrap(); + assert!(!dir.path().join(TEMP).exists()); + } + + #[test] + fn fresh_dir_loads_empty_without_recovery() { + let dir = tempfile::tempdir().unwrap(); + let loaded = load(&dir.path().join("not-created-yet")).unwrap(); + assert!(loaded.data.is_empty()); + assert_eq!(loaded.recovered, None); + assert_eq!(loaded.damaged_path, None); + } + + #[test] + fn load_uses_bak_when_main_missing() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(BACKUP), br#"{"v":1}"#).unwrap(); + let loaded = load(dir.path()).unwrap(); + assert_eq!(loaded.data, map(json!({ "v": 1 }))); + assert_eq!(loaded.recovered, Some("backup")); + } + + #[test] + fn load_uses_bak_when_main_damaged_and_moves_main_aside() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(MAIN), b"{not json").unwrap(); + fs::write(dir.path().join(BACKUP), br#"{"v":1}"#).unwrap(); + let loaded = load(dir.path()).unwrap(); + assert_eq!(loaded.data, map(json!({ "v": 1 }))); + assert_eq!(loaded.recovered, Some("backup")); + let moved = damaged_files(dir.path(), MAIN); + assert_eq!(moved.len(), 1); + assert_eq!(fs::read(&moved[0]).unwrap(), b"{not json"); + assert!(!dir.path().join(MAIN).exists()); + } + + #[test] + fn both_damaged_moves_both_aside_and_starts_empty() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(MAIN), b"[1,2]").unwrap(); + fs::write(dir.path().join(BACKUP), b"garbage").unwrap(); + let loaded = load(dir.path()).unwrap(); + assert!(loaded.data.is_empty()); + assert_eq!(loaded.recovered, Some("reset")); + let main_moved = damaged_files(dir.path(), MAIN); + let backup_moved = damaged_files(dir.path(), BACKUP); + assert_eq!(fs::read(&main_moved[0]).unwrap(), b"[1,2]"); + assert_eq!(fs::read(&backup_moved[0]).unwrap(), b"garbage"); + assert_eq!(loaded.damaged_path.as_ref(), Some(&main_moved[0])); + } + + #[test] + fn main_damaged_and_no_bak_resets() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(MAIN), b"").unwrap(); + let loaded = load(dir.path()).unwrap(); + assert!(loaded.data.is_empty()); + assert_eq!(loaded.recovered, Some("reset")); + assert_eq!(fs::read(loaded.damaged_path.unwrap()).unwrap(), b""); + } +} diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json new file mode 100644 index 0000000..33e13e6 --- /dev/null +++ b/desktop/tauri.conf.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Notelet", + "version": "0.7.0", + "identifier": "com.ttncode.notelet", + "build": { + "frontendDist": "../extension" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "label": "main", + "title": "Notelet", + "url": "notes.html", + "width": 420, + "height": 640, + "minWidth": 320, + "minHeight": 400, + "visible": false, + "zoomHotkeysEnabled": false + } + ], + "security": { + "csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'" + } + }, + "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": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDQ0NFREFENDUwODkwQ0QKUldUTmtBaEZyZTNNckRmUTl6UGpsYzJkMmdwaHFWZ2txdDFYZkQyWGFWNnFwY2ltT2t4MmdoUHYK", + "endpoints": ["https://github.com/ttncode/notelet/releases/latest/download/latest.json"], + "windows": { + "installMode": "passive" + } + } + } +} diff --git a/extension/app.js b/extension/app.js index d9ab214..92de352 100644 --- a/extension/app.js +++ b/extension/app.js @@ -1,11 +1,12 @@ import { backupFileName, createBackup, parseBackup } from "./backup.js"; -import { closeOnOutsideClick, downloadFile, pickTextFile, showToast } from "./dialogs.js"; +import { closeOnOutsideClick, pickTextFile, showToast } from "./dialogs.js"; +import { createFileArea, historyKey, recoveryMessage, toAccelerator, whenShown } from "./desktop.js"; import { mergeTrackers, newBoardNote } from "./board-merge.js"; import { boardSearchText, findSprint, findTask, mapTasks, removeSprint, updateSprint } from "./board.js"; import { createBoardView } from "./board-view.js"; import { NoteEditor } from "./editor.js"; import { formatEditedDate } from "./format.js"; -import { applyShortcutTitles, matchHotkey, setCustomKeys } from "./hotkeys.js"; +import { applyShortcutTitles, globalCombo, matchHotkey, setCustomKeys } from "./hotkeys.js"; import { setupLayout } from "./layout.js"; import { EMPTY_BODY_HTML, EMPTY_NOTE_HTML, isBlankNote, isEmptyNote, isExpired, newNote, noteText, noteTitleOf, orderedGroups, placeNote, stepNote } from "./model.js"; import { mergeNote } from "./merge.js"; @@ -24,11 +25,10 @@ const SAVE_MAX_WAIT_MS = 5000; const UI_SAVE_DELAY_MS = 300; const MENU_GAP_PX = 6; const VIEWPORT_MARGIN_PX = 8; -const IS_POPUP = new URLSearchParams(location.search).get("view") === "popup"; -const FULL_PAGE_URL = chrome.runtime.getURL("notes.html"); const IS_MAC = /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform); const NARROW = matchMedia("(max-width: 699px)"); const MERGE_CONFLICT_MESSAGE = "This note was also edited in another window. A paragraph you both changed now appears in both versions; for anything else, this window's change was kept."; +const SHORTCUT_TAKEN_MESSAGE = "That key is already used by another app, so Notelet kept the previous one."; const SAVE_FAILED_MESSAGE = "Couldn't save your last change. Your text is still here — keep this tab open and try again, or export a backup."; const THEME_ICONS = { @@ -37,7 +37,9 @@ const THEME_ICONS = { dark: '', }; const byId = (id) => document.getElementById(id); -const store = createStore(chrome.storage.local); +const tauri = window.__TAURI__; +const area = createFileArea(tauri.core.invoke); +const store = createStore(area); const notes = new Map(); // What storage last held for each note, as told by its change events: the base for merging // another window's save with edits this window has not saved yet. @@ -59,6 +61,7 @@ const navigation = { replace: () => steps?.replace(), }; let uiSaveTimer = null; +let appVersion = ""; main().catch((error) => { console.error("Notelet failed to start", error); @@ -66,8 +69,12 @@ main().catch((error) => { }); async function main() { - document.body.classList.toggle("popup-view", IS_POPUP); + listenForFlush(); + // Bubble phase, after onAppHotkey on document, so a user shortcut on Alt+← / Alt+→ wins. + addEventListener("keydown", onHistoryKey); + appVersion = await tauri.app.getVersion(); const loaded = await store.load(); + const recovery = await area.recovery(); loaded.notes.forEach((note) => { notes.set(note.id, note); synced.set(note.id, note); @@ -75,6 +82,7 @@ async function main() { state.ui = loaded.ui; state.settings = loaded.settings; setCustomKeys(state.settings.hotkeys); + registerGlobalShortcut().catch(() => whenShown(document, window, () => showToast(SHORTCUT_TAKEN_MESSAGE))); showTheme(state.ui.theme); await removeStaleNotes(); editor = new NoteEditor({ element: byId("editor"), isMac: IS_MAC, onChange: onEditorChange }); @@ -87,8 +95,47 @@ async function main() { store.onChange(applyRemoteChanges); openInitialNote(); steps = createNavigation({ readView, showView }); + whenShown(document, window, () => showStartupNotice(recovery, loaded.notes.length === 0)); } +const registerGlobalShortcut = () => tauri.core.invoke("set_global_shortcut", { accelerator: toAccelerator(globalCombo()) }); + +// shortcut: an edit typed under SAVE_DELAY_MS before Windows shuts down can be lost, since WebView2 +// is killed without a page event; fixing it needs a WM_QUERYENDSESSION hook in Rust. +function listenForFlush() { + tauri.event.listen("flush-request", async ({ payload }) => { + clearTimeout(uiSaveTimer); + await Promise.allSettled([saves.flush(), store.saveUi(state.ui)]); + const saved = area.isSaved(); + // A failed save cancels the quit or update, so the unsaved text stays on screen. + if (!saved) showToast(SAVE_FAILED_MESSAGE); + await tauri.core.invoke("after_flush", { then: saved ? payload.then : "cancel" }); + }).catch((error) => console.error("Notelet: could not listen for quit and update", error)); +} + +// WebView2's own back/forward keys are turned off with its other browser keys. +function onHistoryKey(event) { + const direction = historyKey(event); + if (!direction) return; + event.preventDefault(); + if (direction === "back") history.back(); + else history.forward(); +} + +// A damaged notes file is explained once; a first launch with no notes offers the backup from the extension. +function showStartupNotice(recovery, isEmpty) { + const message = recoveryMessage(recovery); + if (message) { + showToast(message); + return; + } + if (!isEmpty || state.ui.importOffered) return; + saveUiSoon({ importOffered: true }); + showToast("Moving from the Chrome extension? Export a backup there, then choose Import backup.", { label: "Import backup", run: () => importNotes().catch(reportImportError) }); +} + +const toggleMaximize = () => tauri.core.invoke("toggle_maximize").catch((error) => console.error("Notelet: could not resize the window", error)); + function readView() { const pane = document.body.classList.contains("show-editor") ? "note" : "list"; const board = notes.get(state.currentId)?.board ? boardView.viewState() : null; @@ -203,20 +250,6 @@ function renderList() { const deletedGroups = (visible) => (visible.length > 0 ? [{ label: null, notes: visible }] : []); -// Saves first: closing the popup window would otherwise cut off a pending write. -async function openFullPage() { - clearTimeout(uiSaveTimer); - await Promise.all([saves.flush(), store.saveUi(state.ui)]); - const [fullPage] = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentUrls: [FULL_PAGE_URL] }); - if (fullPage) { - await chrome.tabs.update(fullPage.tabId, { active: true }); - await chrome.windows.update(fullPage.windowId, { focused: true }); - } else { - await chrome.tabs.create({ url: FULL_PAGE_URL }); - } - await chrome.windows.remove((await chrome.windows.getCurrent()).id); -} - function cycleTheme() { const theme = nextTheme(state.ui.theme); showTheme(theme); @@ -430,12 +463,17 @@ function toggleDeletedView() { selectFirstVisible(); } -function exportNotes() { - flushSaves(); +async function exportNotes() { + await flushSaves(); const now = new Date(); - downloadFile(createBackup([...notes.values()], state.settings, now), backupFileName(now)); + await tauri.core.invoke("save_backup", { fileName: backupFileName(now), text: createBackup([...notes.values()], state.settings, now) }); } +const reportExportError = (error) => { + console.error("Notelet: export failed", error); + showToast(`Couldn't save the backup: ${error}`); +}; + async function importNotes() { const text = await pickTextFile(".json,application/json"); if (text === null) return; @@ -582,8 +620,8 @@ function wireButtons() { "deleted-toggle": toggleDeletedView, "deleted-menu-item": toggleDeletedView, "theme-button": cycleTheme, - "open-full-page": () => openFullPage().catch((error) => console.error("Notelet: could not open the full page", error)), - "export-button": exportNotes, + "open-full-page": toggleMaximize, + "export-button": () => exportNotes().catch(reportExportError), "import-button": () => importNotes().catch(reportImportError), "help-button": showHelp, }; @@ -731,14 +769,14 @@ const APP_HOTKEYS = { }), deletedView: () => toggleDeletedView(), theme: () => cycleTheme(), - export: () => exportNotes(), + export: () => exportNotes().catch(reportExportError), import: () => importNotes().catch(reportImportError), formatMenu: () => state.mode === "notes" && notes.has(state.currentId) && openMenu(byId("format-menu")), noteMenu: () => notes.has(state.currentId) && openMenu(byId("note-menu")), pin: () => state.mode === "notes" && notes.has(state.currentId) && togglePin(), deleteNote: () => notes.has(state.currentId) && deleteCurrent(), recover: () => state.mode === "deleted" && notes.has(state.currentId) && recoverCurrent(), - openFullPage: () => IS_POPUP && openFullPage().catch((error) => console.error("Notelet: could not open the full page", error)), + openFullPage: toggleMaximize, trackerSettings: () => Boolean(notes.get(state.currentId)?.board) && openSettings(), }; @@ -766,12 +804,23 @@ function onMenuKeydown(event) { } const showHelp = () => openShortcuts({ - dialog: byId("help"), isMac: IS_MAC, version: chrome.runtime.getManifest().version, keys: state.settings.hotkeys, onChange: saveShortcuts, + dialog: byId("help"), isMac: IS_MAC, version: appVersion, keys: state.settings.hotkeys, onChange: saveShortcuts, }); function saveShortcuts(hotkeys) { + const previous = state.settings.hotkeys; adoptSettings({ ...state.settings, hotkeys }); store.saveSettings(state.settings).catch(reportSaveError); + registerGlobalShortcut().catch(() => keepPreviousGlobalKey(previous)); +} + +// Windows refused the new global key, so the setting goes back to the key still registered. +function keepPreviousGlobalKey(previousHotkeys) { + const { showHide: _refused, ...hotkeys } = state.settings.hotkeys; + const restored = Object.hasOwn(previousHotkeys, "showHide") ? { ...hotkeys, showHide: previousHotkeys.showHide } : hotkeys; + adoptSettings({ ...state.settings, hotkeys: restored }); + store.saveSettings(state.settings).catch(reportSaveError); + showToast(SHORTCUT_TAKEN_MESSAGE); } // Changed shortcuts take effect at once, tooltips included. diff --git a/extension/background.js b/extension/background.js deleted file mode 100644 index 762622b..0000000 --- a/extension/background.js +++ /dev/null @@ -1,35 +0,0 @@ -import { defaultPopupBounds, POPUP_PATH } from "./popup-window.js"; - -const POPUP_URL = chrome.runtime.getURL(POPUP_PATH); -const BOUNDS_KEY = "popupBounds"; - -chrome.action.onClicked.addListener(() => { - openPopup().catch((error) => console.error("Notelet: could not open the popup window", error)); -}); - -chrome.windows.onBoundsChanged.addListener((browserWindow) => { - rememberBounds(browserWindow).catch((error) => console.error("Notelet: could not remember the popup size", error)); -}); - -async function openPopup() { - const existing = await popupContext(); - if (existing) { - await chrome.windows.update(existing.windowId, { focused: true }); - return; - } - const { [BOUNDS_KEY]: saved } = await chrome.storage.local.get(BOUNDS_KEY); - const bounds = saved ?? defaultPopupBounds(await chrome.windows.getLastFocused()); - await chrome.windows.create({ url: POPUP_URL, type: "popup", focused: true, ...bounds }); -} - -async function popupContext() { - const [context] = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentUrls: [POPUP_URL] }); - return context ?? null; -} - -async function rememberBounds(browserWindow) { - const popup = await popupContext(); - if (popup?.windowId !== browserWindow.id) return; - const { left, top, width, height } = browserWindow; - await chrome.storage.local.set({ [BOUNDS_KEY]: { left, top, width, height } }); -} diff --git a/extension/desktop.js b/extension/desktop.js new file mode 100644 index 0000000..47eed1a --- /dev/null +++ b/extension/desktop.js @@ -0,0 +1,103 @@ +// Glue between the UI and the Tauri desktop shell. Nothing here touches window.__TAURI__ at +// load time, so the tests can import it in Node. + +// A stand-in for chrome.storage.local backed by one JSON file that Rust writes. The whole map +// is kept in memory and rewritten on every change; writes run one after another so an older +// map never lands on disk after a newer one. +export function createFileArea(invoke) { + let loading = null; + let data = null; + let writes = Promise.resolve(); + // Each write holds the whole map, so the last write succeeding means nothing is unsaved. + let lastWriteSucceeded = true; + const listeners = []; + const load = () => { + loading ??= invoke("storage_load").then((result) => { + data = result.data; + return result; + }); + return loading; + }; + const write = async (change) => { + await load(); + const changes = change(data); + const snapshot = structuredClone(data); + const done = writes.then(() => invoke("storage_save", { data: snapshot })); + writes = done.then(() => { lastWriteSucceeded = true; }, () => { lastWriteSucceeded = false; }); + await done; + if (Object.keys(changes).length > 0) listeners.forEach((listener) => listener(changes)); + }; + return { + get: async (key) => { + await load(); + if (key === null || key === undefined) return structuredClone(data); + return Object.hasOwn(data, key) ? { [key]: structuredClone(data[key]) } : {}; + }, + set: (items) => write((map) => setItems(map, items)), + remove: (keys) => write((map) => removeKeys(map, keys)), + onChanged: { addListener: (listener) => listeners.push(listener) }, + isSaved: () => lastWriteSucceeded, + recovery: async () => { + const { recovered, damagedPath } = await load(); + return { recovered, damagedPath }; + }, + }; +} + +function setItems(map, items) { + const changes = {}; + for (const [key, value] of Object.entries(items)) { + if (JSON.stringify(map[key]) === JSON.stringify(value)) continue; + changes[key] = { oldValue: map[key], newValue: structuredClone(value) }; + map[key] = structuredClone(value); + } + return changes; +} + +function removeKeys(map, keys) { + const changes = {}; + for (const key of keys.filter((candidate) => Object.hasOwn(map, candidate))) { + changes[key] = { oldValue: map[key] }; + delete map[key]; + } + return changes; +} + +// The accelerator string the global-shortcut plugin parses, e.g. "Ctrl+Alt+KeyN". +export function toAccelerator(combo) { + if (!combo?.code) return null; + const modifiers = [combo.mod && "Ctrl", combo.alt && "Alt", combo.shift && "Shift"].filter(Boolean); + return [...modifiers, combo.code].join("+"); +} + +export function recoveryMessage({ recovered, damagedPath }) { + if (recovered === "backup") return "Notelet's notes file was damaged, so the last good copy was opened. Your most recent change may be missing."; + if (recovered === "reset") return `Notelet's notes file was damaged and was moved to ${damagedPath}. Import a backup to bring your notes back.`; + return null; +} + +// WebView2's own back/forward keys are off along with its other browser keys, so Notelet walks +// its history itself — unless one of the user's shortcuts already took the key. +export function historyKey(event) { + if (event.defaultPrevented || !event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) return null; + return { ArrowLeft: "back", ArrowRight: "forward" }[event.code] ?? null; +} + +// Runs once the window is first on screen. At login Notelet starts hidden in the tray, and a +// message shown then would time out unseen. +export function whenShown(page, window, run) { + if (page.visibilityState === "visible") { + run(); + return; + } + const done = () => { + page.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("focus", done); + run(); + }; + const onVisibility = () => { + if (page.visibilityState === "visible") done(); + }; + page.addEventListener("visibilitychange", onVisibility); + window.addEventListener("focus", done); +} diff --git a/extension/dialogs.js b/extension/dialogs.js index 8669b50..abd650c 100644 --- a/extension/dialogs.js +++ b/extension/dialogs.js @@ -1,23 +1,30 @@ const TOAST_DURATION_MS = 5000; -const URL_REVOKE_DELAY_MS = 1000; +const ACTION_TOAST_DURATION_MS = 15000; let toastTimer = null; -export function showToast(message) { +// action, when given, is { label, run }: one button that runs and closes the toast. Such a +// toast stays longer, since it asks for a decision. +export function showToast(message, action = null) { const toast = document.getElementById("toast"); toast.textContent = message; + if (action) toast.append(" ", actionButton(toast, action)); toast.hidden = false; clearTimeout(toastTimer); - toastTimer = setTimeout(() => { toast.hidden = true; }, TOAST_DURATION_MS); + toastTimer = setTimeout(() => { toast.hidden = true; }, action ? ACTION_TOAST_DURATION_MS : TOAST_DURATION_MS); } -export function downloadFile(text, fileName) { - const url = URL.createObjectURL(new Blob([text], { type: "application/json" })); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - link.click(); - setTimeout(() => URL.revokeObjectURL(url), URL_REVOKE_DELAY_MS); +function actionButton(toast, { label, run }) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "text-button toast-action"; + button.textContent = label; + button.addEventListener("click", () => { + clearTimeout(toastTimer); + toast.hidden = true; + run(); + }); + return button; } export function pickTextFile(accept) { diff --git a/extension/hotkeys.js b/extension/hotkeys.js index 0ba8838..1da8f63 100644 --- a/extension/hotkeys.js +++ b/extension/hotkeys.js @@ -55,8 +55,10 @@ const DEFAULT_HOTKEYS = [ // Alt+E opens Chrome's menu on Windows. { group: "App", action: "export", label: "Export backup", alt: true, code: "KeyX" }, { group: "App", action: "import", label: "Import backup", alt: true, code: "KeyI" }, - { group: "App", action: "openFullPage", label: "Open full page (popup)", alt: true, code: "Enter" }, + { group: "App", action: "openFullPage", label: "Maximize / restore window", alt: true, code: "Enter" }, { group: "App", action: "help", label: "Show or hide this list", mod: true, code: "Slash" }, + // Registered with Windows, so it works while another app is in front. + { group: "App", action: "showHide", label: "Show / hide Notelet (from any app)", mod: true, alt: true, code: "KeyN", global: true }, ]; // Each shortcut has a stable id: its action, with 2 added for an action's second key. @@ -115,8 +117,9 @@ const WINDOWS_ALT_KEYS = new Set(["KeyF", "KeyE", "F4", "Space", "Tab"]); // F1 to F12 type nothing, so they work without Ctrl or Alt. const FUNCTION_KEY = /^F\d+$/; -export function reservedReason(combo, isMac) { +export function reservedReason(combo, isMac, hotkey = null) { const label = hotkeyLabel(combo, isMac); + if (hotkey?.global && !combo.mod && !combo.alt) return "A key that works in every app needs Ctrl or Alt."; if (!combo.mod && !combo.alt && !FUNCTION_KEY.test(combo.code)) return `Add ${isMac ? "⌘ or ⌥" : "Ctrl or Alt"}, so the key doesn't just type.`; if (!isMac && combo.alt && combo.shift && !combo.mod) return "Alt+Shift switches the keyboard language on Windows."; if (combo.mod && !combo.alt && (combo.shift ? CHROME_MOD_SHIFT_KEYS : CHROME_MOD_KEYS).has(combo.code)) return `Chrome keeps ${label} for itself.`; @@ -152,6 +155,13 @@ const MAC_KEY_NAMES = { ...KEY_NAMES, Backspace: "⌫", Enter: "↩" }; const keyName = (code, isMac) => (isMac ? MAC_KEY_NAMES : KEY_NAMES)[code] ?? code.replace(/^(Key|Digit|Numpad)/, ""); +// The key to register with Windows for showing Notelet, or null when the user removed it. +export function globalCombo() { + const hotkey = activeHotkeys().find((candidate) => candidate.global); + if (hotkey.code === null) return null; + return { mod: Boolean(hotkey.mod), alt: Boolean(hotkey.alt), shift: Boolean(hotkey.shift), code: hotkey.code }; +} + // An action's first key that is set; "no key" when the user removed them all. export function shortcutFor(action, isMac) { const hotkey = activeHotkeys().find((candidate) => candidate.action === action && candidate.code !== null); diff --git a/extension/manifest.json b/extension/manifest.json deleted file mode 100644 index 66a00eb..0000000 --- a/extension/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "manifest_version": 3, - "name": "Notelet", - "version": "0.6.0", - "description": "Lightweight notes in the style of iPhone Notes, with task tracking for sprints.", - "minimum_chrome_version": "116", - "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkmdIzZeblGtLJO85S/t9kC61orieKLhnZMsTXP6MARLAYjJDKnH29JjFRFu/qq/ApvRF1+X/Qwll7t/4X5kFkaHt93+1wIHLcos5nPTsGLjhkVAwvlajKYHxVIc25pwF+M1JzilJdf12+hZQV6/c8XjIcIo+foHchdrqSDxTlq5whXWDCPi863HB3SDahpu7effyUvFcnganVdotqTuL5/mBABhI9pYtKUq17Sb9deAECrmNR59vsMvH/lI5+Lplek6dA77NlaRmBbzDCDwVvoPqfUqTWiDMzmYjCZ7wUiTCjNje481TSw9vvV2DFvM3mxSuO+dEg99FaP2R6TeRvQIDAQAB", - "permissions": ["storage", "unlimitedStorage"], - "background": { "service_worker": "background.js", "type": "module" }, - "action": { - "default_title": "Open Notelet", - "default_icon": { "16": "icons/icon-16.png", "32": "icons/icon-32.png" } - }, - "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" }, - "commands": { - "_execute_action": { - "suggested_key": { "default": "Alt+Period" }, - "description": "Open Notelet" - } - } -} diff --git a/extension/notes.css b/extension/notes.css index fd04be4..a169f08 100644 --- a/extension/notes.css +++ b/extension/notes.css @@ -129,7 +129,6 @@ body.viewing-deleted #new-note-list-button { visibility: hidden; } .editor-pane { grid-column: 3; display: flex; flex-direction: column; min-width: 0; min-height: 0; } .toolbar { display: flex; align-items: center; gap: 0.25rem; padding: 0.5rem 0.75rem; } -body:not(.popup-view) #open-full-page { display: none; } @media (min-width: 700px) { body:not(.sidebar-hidden) #new-note-button { display: none; } } body:not(.has-note) #note-menu-button { display: none; } body.viewing-deleted #format-button, @@ -310,6 +309,7 @@ body:not(.viewing-deleted) #recover-button { display: none; } .help tr.key-hint td { color: var(--secondary); } .toast { position: fixed; bottom: 1.5rem; left: 50%; max-width: min(32rem, calc(100% - 2rem)); padding: 0.6rem 1rem; border-radius: 0.75rem; background: var(--toast-bg); color: var(--toast-text); font-size: 0.9rem; transform: translateX(-50%); } +.toast-action { padding: 0.1rem 0.3rem; color: inherit; font-weight: 600; text-decoration: underline; } .form-list { padding: 0; list-style: none; } .form-row { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; width: 100%; min-height: 2.75rem; padding: 0.35rem 1rem; } diff --git a/extension/notes.html b/extension/notes.html index ad87a9b..b140fff 100644 --- a/extension/notes.html +++ b/extension/notes.html @@ -48,7 +48,7 @@

Notes

-