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.
-
+
@@ -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 @@
-
+
diff --git a/extension/popup-window.js b/extension/popup-window.js
deleted file mode 100644
index 412666d..0000000
--- a/extension/popup-window.js
+++ /dev/null
@@ -1,15 +0,0 @@
-export const POPUP_PATH = "notes.html?view=popup";
-
-const POPUP_WIDTH = 420;
-const POPUP_HEIGHT = 640;
-const EDGE_MARGIN_PX = 16;
-const TOOLBAR_OFFSET_PX = 72;
-
-export function defaultPopupBounds(browserWindow) {
- return {
- left: Math.max(0, browserWindow.left + browserWindow.width - POPUP_WIDTH - EDGE_MARGIN_PX),
- top: Math.max(0, browserWindow.top + TOOLBAR_OFFSET_PX),
- width: POPUP_WIDTH,
- height: POPUP_HEIGHT,
- };
-}
diff --git a/extension/shortcut-editor.js b/extension/shortcut-editor.js
index 726b675..627928a 100644
--- a/extension/shortcut-editor.js
+++ b/extension/shortcut-editor.js
@@ -75,7 +75,7 @@ function recordKey(event, { id, keys, isMac }) {
if (event.key === "Escape") return {};
if (event.key === "Backspace" || event.key === "Delete") return { keys: { ...keys, [id]: null } };
const combo = comboOf(event, isMac);
- const reason = reservedReason(combo, isMac);
+ const reason = reservedReason(combo, isMac, defaultHotkey(id));
if (reason) return { message: { id, text: reason } };
const other = hotkeysWith(keys).find((hotkey) => hotkey.id !== id && sameCombo(hotkey, combo));
if (other && !isEditable(other)) return { message: { id, text: `${hotkeyLabel(combo, isMac)} is ${other.label}, which Chrome handles.` } };
diff --git a/package-lock.json b/package-lock.json
index 34720c0..ab39380 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,227 @@
"name": "notelet",
"version": "0.6.0",
"devDependencies": {
+ "@tauri-apps/cli": "^2.12.4",
"linkedom": "^0.18.13"
}
},
+ "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,
+ "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,
+ "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,
+ "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,
+ "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,
+ "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/boolbase": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
diff --git a/package.json b/package.json
index a55a445..ac12442 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,15 @@
{
"name": "notelet",
- "version": "0.6.0",
+ "version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
- "test": "node --test"
+ "test": "node --test tests/",
+ "tauri": "tauri",
+ "test:e2e": "node --test tests-e2e/app.e2e.js"
},
"devDependencies": {
+ "@tauri-apps/cli": "^2.12.4",
"linkedom": "^0.18.13"
}
}
diff --git a/tests-e2e/app.e2e.js b/tests-e2e/app.e2e.js
new file mode 100644
index 0000000..baac6cf
--- /dev/null
+++ b/tests-e2e/app.e2e.js
@@ -0,0 +1,121 @@
+// Runs against the built app through tauri-driver (see .github/workflows/ci.yml); not part of npm test.
+import { after, before, test } from "node:test";
+import assert from "node:assert/strict";
+import { HOTKEYS } from "../extension/hotkeys.js";
+import { newSession } from "./webdriver.js";
+
+const APPLICATION = process.env.NOTELET_APP ?? "desktop/target/release/notelet.exe";
+// Longer than the editor's save delay, so the note is on disk.
+const SAVE_SETTLE_MS = 1500;
+const STEP_SETTLE_MS = 400;
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+const comboName = ({ mod, alt, shift, code }) => [code, Boolean(mod), Boolean(alt), Boolean(shift)].join();
+const noteCount = "return document.querySelectorAll('#note-list .note-row').length";
+
+let app;
+
+before(async () => {
+ app = await newSession(APPLICATION);
+ await app.waitFor("return document.readyState === 'complete' && Boolean(window.__TAURI__)");
+ await sleep(STEP_SETTLE_MS);
+});
+
+after(async () => {
+ await app?.end();
+});
+
+async function newNoteInEditor(text) {
+ await app.press({ alt: true, code: "KeyN" });
+ await app.waitFor("return document.activeElement?.closest('#editor') !== null");
+ await app.type(text);
+}
+
+test("every default in-app shortcut reaches the page", async () => {
+ await newNoteInEditor("shortcut check");
+ await app.execute(`
+ window.__seen = [];
+ window.__record = (event) => {
+ if (/^(Control|Alt|Shift|Meta)/.test(event.code)) return;
+ window.__seen.push([event.code, event.ctrlKey, event.altKey, event.shiftKey].join());
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ };
+ addEventListener("keydown", window.__record, { capture: true });
+ `);
+ const combos = HOTKEYS.filter((hotkey) => !hotkey.global);
+ for (const combo of combos) await app.press(combo);
+ const seen = new Set(await app.execute("removeEventListener('keydown', window.__record, { capture: true }); return window.__seen"));
+ const missing = combos.filter((combo) => !seen.has(comboName(combo))).map((combo) => `${combo.id} (${comboName(combo)})`);
+ assert.deepEqual(missing, []);
+});
+
+test("Alt+N adds a note and Alt+1 / Alt+3 move between the list and the note", async () => {
+ const before = await app.execute(noteCount);
+ await newNoteInEditor("second");
+ assert.equal(await app.execute(noteCount), before + 1);
+ await app.press({ alt: true, code: "Digit1" });
+ await app.waitFor("return document.activeElement?.closest('#note-list') !== null");
+ await app.press({ alt: true, code: "Digit3" });
+ await app.waitFor("return document.activeElement?.closest('#editor') !== null");
+});
+
+test("Ctrl+/ opens and closes the shortcut list; Ctrl+F goes to search", async () => {
+ await app.press({ mod: true, code: "Slash" });
+ await app.waitFor("return document.getElementById('help').open");
+ await app.press({ mod: true, code: "Slash" });
+ await app.waitFor("return !document.getElementById('help').open");
+ await app.press({ mod: true, code: "KeyF" });
+ await app.waitFor("return document.activeElement?.id === 'search'");
+ await app.press({ code: "Escape" });
+});
+
+test("Ctrl+Z undoes typing", async () => {
+ await newNoteInEditor("undo me");
+ const typed = await app.execute("return document.getElementById('editor').innerText");
+ await app.press({ mod: true, code: "KeyZ" });
+ await sleep(STEP_SETTLE_MS);
+ assert.notEqual(await app.execute("return document.getElementById('editor').innerText"), typed);
+});
+
+test("Alt+Left goes back exactly one step", async () => {
+ await app.press({ alt: true, code: "Digit1" });
+ await sleep(STEP_SETTLE_MS);
+ await app.press({ alt: true, code: "Digit3" });
+ await sleep(STEP_SETTLE_MS);
+ const depth = await app.execute("return history.state.depth");
+ await app.press({ alt: true, code: "ArrowLeft" });
+ await sleep(STEP_SETTLE_MS);
+ assert.equal(await app.execute("return history.state.depth"), depth - 1);
+});
+
+test("Alt+Enter maximizes and restores the window", async () => {
+ const width = await app.execute("return window.outerWidth");
+ await app.press({ alt: true, code: "Enter" });
+ await app.waitFor(`return window.outerWidth > ${width}`);
+ await app.press({ alt: true, code: "Enter" });
+ await app.waitFor(`return window.outerWidth === ${width}`);
+});
+
+test("browser keys do nothing: reload, zoom and print", async () => {
+ await app.execute("window.__sentinel = 1; window.__printed = false; addEventListener('beforeprint', () => { window.__printed = true; })");
+ const ratio = await app.execute("return devicePixelRatio");
+ await app.press({ code: "F5" });
+ await app.press({ mod: true, code: "KeyR" });
+ await app.press({ mod: true, code: "Equal" });
+ await app.press({ mod: true, code: "Minus" });
+ await app.press({ mod: true, code: "KeyP" });
+ await sleep(SAVE_SETTLE_MS);
+ assert.equal(await app.execute("return window.__sentinel"), 1);
+ assert.equal(await app.execute("return devicePixelRatio"), ratio);
+ assert.equal(await app.execute("return window.__printed"), false);
+});
+
+test("a note survives a restart", async () => {
+ const marker = `persist-${Date.now()}`;
+ await newNoteInEditor(marker);
+ await sleep(SAVE_SETTLE_MS);
+ await app.end();
+ app = await newSession(APPLICATION);
+ await app.waitFor(`return document.getElementById('note-list')?.innerText.includes(${JSON.stringify(marker)})`, 10000);
+});
diff --git a/tests-e2e/webdriver.js b/tests-e2e/webdriver.js
new file mode 100644
index 0000000..cf0e94a
--- /dev/null
+++ b/tests-e2e/webdriver.js
@@ -0,0 +1,71 @@
+// A small W3C WebDriver client for tauri-driver, so the end-to-end tests need no npm packages.
+
+const DRIVER_URL = process.env.TAURI_DRIVER_URL ?? "http://127.0.0.1:4444";
+const POLL_MS = 100;
+
+const MODIFIER_KEYS = { mod: "", alt: "", shift: "" };
+const SPECIAL_KEYS = {
+ Enter: "\uE006", Backspace: "", Delete: "", Tab: "", Escape: "", Space: " ",
+ ArrowUp: "", ArrowDown: "", ArrowLeft: "", ArrowRight: "",
+ Slash: "/", Comma: ",", Period: ".", BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=",
+ Semicolon: ";", Quote: "'", Backslash: "\\", Backquote: "`",
+};
+
+async function request(method, path, body) {
+ const response = await fetch(`${DRIVER_URL}${path}`, {
+ method,
+ headers: { "content-type": "application/json" },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(`WebDriver ${method} ${path}: ${payload.value?.message ?? response.status}`);
+ return payload.value;
+}
+
+// The WebDriver key value for a KeyboardEvent.code such as "KeyN", "Digit1" or "F5".
+export function keyValue(code) {
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase();
+ if (/^Digit\d$/.test(code)) return code.slice(5);
+ const functionKey = /^F(\d{1,2})$/.exec(code);
+ if (functionKey) return String.fromCharCode(0xe031 + Number(functionKey[1]) - 1);
+ if (code in SPECIAL_KEYS) return SPECIAL_KEYS[code];
+ throw new Error(`no WebDriver key for ${code}`);
+}
+
+export async function newSession(application) {
+ const value = await request("POST", "/session", {
+ capabilities: { alwaysMatch: { browserName: "wry", "tauri:options": { application } } },
+ });
+ const id = value.sessionId;
+ const session = (method, path, body) => request(method, `/session/${id}${path}`, body);
+ const execute = (script, args = []) => session("POST", "/execute/sync", { script, args });
+ const perform = async (actions) => {
+ await session("POST", "/actions", { actions: [{ type: "key", id: "keyboard", actions }] });
+ await session("DELETE", "/actions");
+ };
+ return {
+ execute,
+ // Presses one combination: { mod, alt, shift, code }, Ctrl standing for mod.
+ press: (combo) => {
+ const modifiers = ["mod", "alt", "shift"].filter((name) => combo[name]).map((name) => MODIFIER_KEYS[name]);
+ const key = keyValue(combo.code);
+ return perform([
+ ...modifiers.map((value) => ({ type: "keyDown", value })),
+ { type: "keyDown", value: key },
+ { type: "keyUp", value: key },
+ ...modifiers.reverse().map((value) => ({ type: "keyUp", value })),
+ ]);
+ },
+ type: (text) => perform([...text].flatMap((value) => [{ type: "keyDown", value }, { type: "keyUp", value }])),
+ waitFor: async (script, timeoutMs = 5000) => {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const result = await execute(script);
+ if (result) return result;
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
+ }
+ throw new Error(`timed out waiting for: ${script}`);
+ },
+ end: () => session("DELETE", ""),
+ };
+}
diff --git a/tests/desktop.test.js b/tests/desktop.test.js
new file mode 100644
index 0000000..d67c9fd
--- /dev/null
+++ b/tests/desktop.test.js
@@ -0,0 +1,169 @@
+import "./dom.js";
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createFileArea, historyKey, recoveryMessage, toAccelerator, whenShown } from "../extension/desktop.js";
+import { DEFAULT_UI, SCHEMA_VERSION, createStore } from "../extension/store.js";
+import { defaultSettings } from "../extension/sprint.js";
+
+function fakeBackend(initial = {}, { recovered = null, damagedPath = null } = {}) {
+ const calls = [];
+ const saves = [];
+ let failNext = false;
+ const invoke = async (command, args) => {
+ calls.push(command);
+ if (command === "storage_load") return { data: structuredClone(initial), recovered, damagedPath };
+ if (command === "storage_save") {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ if (failNext) {
+ failNext = false;
+ throw new Error("disk full");
+ }
+ saves.push(structuredClone(args.data));
+ return null;
+ }
+ throw new Error(`unexpected command ${command}`);
+ };
+ return { invoke, calls, saves, failOnce: () => { failNext = true; } };
+}
+
+test("the file is loaded once and get returns all keys or one key", async () => {
+ const backend = fakeBackend({ ui: { theme: "dark" }, schemaVersion: 5 });
+ const area = createFileArea(backend.invoke);
+ assert.deepEqual(await area.get(null), { ui: { theme: "dark" }, schemaVersion: 5 });
+ assert.deepEqual(await area.get("ui"), { ui: { theme: "dark" } });
+ assert.deepEqual(await area.get("missing"), {});
+ assert.equal(backend.calls.filter((command) => command === "storage_load").length, 1);
+});
+
+test("writes run one after another and each saves the whole map", async () => {
+ const backend = fakeBackend();
+ const area = createFileArea(backend.invoke);
+ const changes = [];
+ area.onChanged.addListener((change) => changes.push(change));
+ await Promise.all([area.set({ a: 1 }), area.set({ b: 2 })]);
+ assert.deepEqual(backend.saves, [{ a: 1 }, { a: 1, b: 2 }]);
+ assert.deepEqual(changes, [{ a: { oldValue: undefined, newValue: 1 } }, { b: { oldValue: undefined, newValue: 2 } }]);
+});
+
+test("removing a key saves the map without it and reports no newValue", async () => {
+ const backend = fakeBackend({ a: 1, b: 2 });
+ const area = createFileArea(backend.invoke);
+ const changes = [];
+ area.onChanged.addListener((change) => changes.push(change));
+ await area.remove(["a"]);
+ assert.deepEqual(backend.saves, [{ b: 2 }]);
+ assert.deepEqual(changes, [{ a: { oldValue: 1 } }]);
+ assert.equal("newValue" in changes[0].a, false);
+});
+
+test("a value equal to what is stored fires no change for that key", async () => {
+ const backend = fakeBackend({ a: { x: 1 } });
+ const area = createFileArea(backend.invoke);
+ const changes = [];
+ area.onChanged.addListener((change) => changes.push(change));
+ await area.set({ a: { x: 1 }, b: 2 });
+ assert.deepEqual(changes, [{ b: { oldValue: undefined, newValue: 2 } }]);
+});
+
+test("a failed write rejects that call and the next write still saves", async () => {
+ const backend = fakeBackend();
+ const area = createFileArea(backend.invoke);
+ backend.failOnce();
+ await assert.rejects(area.set({ a: 1 }), /disk full/);
+ await area.set({ b: 2 });
+ assert.deepEqual(backend.saves.at(-1), { a: 1, b: 2 });
+});
+
+test("recovery reports what the load found", async () => {
+ const area = createFileArea(fakeBackend({}, { recovered: "backup" }).invoke);
+ assert.deepEqual(await area.recovery(), { recovered: "backup", damagedPath: null });
+});
+
+test("the store works on the file area: a fresh install gets defaults and the schema version", async () => {
+ const backend = fakeBackend();
+ const loaded = await createStore(createFileArea(backend.invoke)).load();
+ assert.deepEqual(loaded, { notes: [], ui: DEFAULT_UI, settings: defaultSettings() });
+ assert.equal(backend.saves.at(-1).schemaVersion, SCHEMA_VERSION);
+});
+
+test("toAccelerator names modifiers in Ctrl, Alt, Shift order", () => {
+ assert.equal(toAccelerator({ mod: true, alt: true, shift: false, code: "KeyN" }), "Ctrl+Alt+KeyN");
+ assert.equal(toAccelerator({ mod: true, alt: true, shift: true, code: "Digit1" }), "Ctrl+Alt+Shift+Digit1");
+ assert.equal(toAccelerator({ mod: false, alt: true, shift: false, code: "F2" }), "Alt+F2");
+ assert.equal(toAccelerator(null), null);
+ assert.equal(toAccelerator({ mod: true, alt: false, shift: false, code: null }), null);
+});
+
+test("recoveryMessage explains a restored copy or a moved file", () => {
+ assert.equal(recoveryMessage({ recovered: "backup", damagedPath: null }),
+ "Notelet's notes file was damaged, so the last good copy was opened. Your most recent change may be missing.");
+ assert.equal(recoveryMessage({ recovered: "reset", damagedPath: "C:\\x\\notelet.json.damaged-1" }),
+ "Notelet's notes file was damaged and was moved to C:\\x\\notelet.json.damaged-1. Import a backup to bring your notes back.");
+ assert.equal(recoveryMessage({ recovered: null, damagedPath: null }), null);
+});
+
+test("historyKey maps Alt+Left and Alt+Right only", () => {
+ const event = (code, modifiers = {}) => ({ code, altKey: false, ctrlKey: false, shiftKey: false, metaKey: false, ...modifiers });
+ assert.equal(historyKey(event("ArrowLeft", { altKey: true })), "back");
+ assert.equal(historyKey(event("ArrowRight", { altKey: true })), "forward");
+ assert.equal(historyKey(event("ArrowLeft", { altKey: true, ctrlKey: true })), null);
+ assert.equal(historyKey(event("ArrowLeft", { altKey: true, shiftKey: true })), null);
+ assert.equal(historyKey(event("ArrowLeft")), null);
+ assert.equal(historyKey(event("ArrowUp", { altKey: true })), null);
+});
+
+test("historyKey leaves a key alone when a shortcut already handled it", () => {
+ const event = { code: "ArrowLeft", altKey: true, ctrlKey: false, shiftKey: false, metaKey: false, defaultPrevented: true };
+ assert.equal(historyKey(event), null);
+});
+
+function fakePage(visibilityState) {
+ const listeners = {};
+ const target = {
+ visibilityState,
+ addEventListener: (type, listener) => { (listeners[type] ??= []).push(listener); },
+ removeEventListener: (type, listener) => { listeners[type] = (listeners[type] ?? []).filter((other) => other !== listener); },
+ };
+ const fire = (type) => [...(listeners[type] ?? [])].forEach((listener) => listener());
+ return { target, fire, count: (type) => (listeners[type] ?? []).length };
+}
+
+test("whenShown runs at once on a visible page", () => {
+ const page = fakePage("visible");
+ let runs = 0;
+ whenShown(page.target, page.target, () => { runs += 1; });
+ assert.equal(runs, 1);
+});
+
+test("whenShown waits for a hidden page to be shown or focused, and runs once", () => {
+ const page = fakePage("hidden");
+ let runs = 0;
+ whenShown(page.target, page.target, () => { runs += 1; });
+ assert.equal(runs, 0);
+ page.fire("visibilitychange");
+ assert.equal(runs, 0);
+ page.target.visibilityState = "visible";
+ page.fire("visibilitychange");
+ page.fire("focus");
+ assert.equal(runs, 1);
+ assert.equal(page.count("visibilitychange") + page.count("focus"), 0);
+});
+
+test("whenShown also runs on focus alone", () => {
+ const page = fakePage("hidden");
+ let runs = 0;
+ whenShown(page.target, page.target, () => { runs += 1; });
+ page.fire("focus");
+ assert.equal(runs, 1);
+});
+
+test("isSaved is false after a failed write and true again after the next one succeeds", async () => {
+ const backend = fakeBackend();
+ const area = createFileArea(backend.invoke);
+ assert.equal(area.isSaved(), true);
+ backend.failOnce();
+ await area.set({ a: 1 }).catch(() => {});
+ assert.equal(area.isSaved(), false);
+ await area.set({ b: 2 });
+ assert.equal(area.isSaved(), true);
+});
diff --git a/tests/dialogs.test.js b/tests/dialogs.test.js
index bd44b8a..9d43665 100644
--- a/tests/dialogs.test.js
+++ b/tests/dialogs.test.js
@@ -16,3 +16,23 @@ test("a click beyond any edge is outside", () => {
assert.equal(isPointOutside(RECT, { x: 250, y: 49 }), true);
assert.equal(isPointOutside(RECT, { x: 250, y: 301 }), true);
});
+
+test("a toast can carry one action button that runs and closes the toast", async () => {
+ const { parseHTML } = await import("linkedom");
+ const { document } = parseHTML('
');
+ globalThis.document = document;
+ const { showToast } = await import("../extension/dialogs.js");
+ let ran = 0;
+ showToast("Moving from the extension?", { label: "Import backup", run: () => { ran += 1; } });
+ const toast = document.getElementById("toast");
+ const button = toast.querySelector("button");
+ assert.equal(toast.hidden, false);
+ assert.match(toast.textContent, /Moving from the extension\?/);
+ assert.equal(button.textContent, "Import backup");
+ button.click();
+ assert.equal(ran, 1);
+ assert.equal(toast.hidden, true);
+ showToast("Saved.");
+ assert.equal(toast.querySelector("button"), null);
+ assert.equal(toast.textContent, "Saved.");
+});
diff --git a/tests/hotkeys.test.js b/tests/hotkeys.test.js
index 426cac7..ebe381f 100644
--- a/tests/hotkeys.test.js
+++ b/tests/hotkeys.test.js
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { HOTKEYS, fillShortcuts, hotkeyLabel, matchHotkey, normalizeCustomKeys, reservedReason, setCustomKeys } from "../extension/hotkeys.js";
+import { HOTKEYS, defaultHotkey, fillShortcuts, globalCombo, hotkeyLabel, matchHotkey, normalizeCustomKeys, reservedReason, setCustomKeys } from "../extension/hotkeys.js";
const key = (code, modifiers = {}) => ({
code, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, isComposing: false, ...modifiers,
@@ -132,3 +132,36 @@ test("stored shortcut changes keep only well-formed keys for shortcuts that can
assert.deepEqual(normalizeCustomKeys("nope"), {});
assert.deepEqual(new Set(HOTKEYS.map((hotkey) => hotkey.id)).size, HOTKEYS.length);
});
+
+const plainCombo = (code, modifiers = {}) => ({ mod: false, alt: false, shift: false, code, ...modifiers });
+
+test("the global shortcut defaults to Ctrl+Alt+N and is marked global", () => {
+ const hotkey = HOTKEYS.find((candidate) => candidate.id === "showHide");
+ assert.equal(hotkey.mod, true);
+ assert.equal(hotkey.alt, true);
+ assert.equal(hotkey.code, "KeyN");
+ assert.equal(hotkey.global, true);
+ assert.equal(matchHotkey(key("KeyN", { ctrlKey: true, altKey: true }), false), "showHide");
+});
+
+test("a global key needs Ctrl or Alt, even a function key", () => {
+ assert.equal(reservedReason(plainCombo("F1"), false, defaultHotkey("showHide")), "A key that works in every app needs Ctrl or Alt.");
+ assert.equal(reservedReason(plainCombo("F1"), false, defaultHotkey("help")), null);
+ assert.equal(reservedReason(plainCombo("KeyK", { mod: true, alt: true }), false, defaultHotkey("showHide")), null);
+});
+
+test("in-app key rules are unchanged", () => {
+ assert.equal(reservedReason(plainCombo("KeyN", { mod: true }), false), "Chrome keeps Ctrl+N for itself.");
+});
+
+test("globalCombo follows custom keys", () => {
+ try {
+ assert.deepEqual(globalCombo(), { mod: true, alt: true, shift: false, code: "KeyN" });
+ setCustomKeys({ showHide: null });
+ assert.equal(globalCombo(), null);
+ setCustomKeys({ showHide: plainCombo("KeyK", { alt: true }) });
+ assert.deepEqual(globalCombo(), plainCombo("KeyK", { alt: true }));
+ } finally {
+ setCustomKeys({});
+ }
+});
diff --git a/tests/popup-window.test.js b/tests/popup-window.test.js
deleted file mode 100644
index c0240f3..0000000
--- a/tests/popup-window.test.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { test } from "node:test";
-import assert from "node:assert/strict";
-import { defaultPopupBounds, POPUP_PATH } from "../extension/popup-window.js";
-
-test("the first popup opens at the top right of the current browser window", () => {
- assert.deepEqual(defaultPopupBounds({ left: 100, top: 50, width: 1600, height: 900 }), { left: 1264, top: 122, width: 420, height: 640 });
-});
-
-test("a popup never starts off the left or top edge of the screen", () => {
- assert.deepEqual(defaultPopupBounds({ left: -8, top: -8, width: 300, height: 400 }), { left: 0, top: 64, width: 420, height: 640 });
-});
-
-test("the popup is the notes page in popup view", () => {
- assert.equal(POPUP_PATH, "notes.html?view=popup");
-});
diff --git a/tests/webdriver-keys.test.js b/tests/webdriver-keys.test.js
new file mode 100644
index 0000000..9df4d0a
--- /dev/null
+++ b/tests/webdriver-keys.test.js
@@ -0,0 +1,21 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { HOTKEYS } from "../extension/hotkeys.js";
+import { keyValue } from "../tests-e2e/webdriver.js";
+
+test("WebDriver key values for letters, digits, function keys and punctuation", () => {
+ assert.equal(keyValue("KeyN"), "n");
+ assert.equal(keyValue("Digit1"), "1");
+ assert.equal(keyValue("F1"), "");
+ assert.equal(keyValue("F5"), "");
+ assert.equal(keyValue("F12"), "");
+ assert.equal(keyValue("Slash"), "/");
+ // \uE007 is the numpad Enter (code NumpadEnter); the main Enter key is \uE006.
+ assert.equal(keyValue("Enter"), "\uE006");
+ assert.equal(keyValue("ArrowLeft"), "");
+ assert.throws(() => keyValue("NumpadAdd"), /no WebDriver key/);
+});
+
+test("every default shortcut has a WebDriver key, so the end-to-end test can press it", () => {
+ for (const hotkey of HOTKEYS) assert.doesNotThrow(() => keyValue(hotkey.code), hotkey.id);
+});
diff --git a/tools/make-icons.mjs b/tools/make-icons.mjs
index a38aedc..b58008f 100644
--- a/tools/make-icons.mjs
+++ b/tools/make-icons.mjs
@@ -13,6 +13,9 @@ const LINES = [
];
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const OUT_DIR = new URL("../extension/icons/", import.meta.url);
+// Source for `npx tauri icon`, which builds the desktop app's .ico and PNGs from it.
+const DESKTOP_SOURCE = new URL("../desktop/icons/source.png", import.meta.url);
+const DESKTOP_SOURCE_SIZE = 1024;
function insideRoundedSquare(u, v) {
const dx = Math.max(CORNER_RADIUS - u, u - (1 - CORNER_RADIUS), 0);
@@ -67,3 +70,5 @@ function encodePng(size) {
mkdirSync(OUT_DIR, { recursive: true });
for (const size of SIZES) writeFileSync(new URL(`icon-${size}.png`, OUT_DIR), encodePng(size));
+mkdirSync(new URL(".", DESKTOP_SOURCE), { recursive: true });
+writeFileSync(DESKTOP_SOURCE, encodePng(DESKTOP_SOURCE_SIZE));
diff --git a/tools/package.sh b/tools/package.sh
deleted file mode 100755
index 4d5875e..0000000
--- a/tools/package.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env bash
-# Build the zip attached to a GitHub release; users unzip it and load it unpacked.
-#
-# Usage: tools/package.sh
-set -euo pipefail
-
-die() {
- printf 'error: %s\n' "$*" >&2
- exit 1
-}
-
-ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
-readonly ROOT_DIR
-
-command -v zip >/dev/null || die "zip is required"
-command -v node >/dev/null || die "node is required"
-
-version="$(node -p "require('${ROOT_DIR}/extension/manifest.json').version")"
-archive="${ROOT_DIR}/dist/notelet-${version}.zip"
-
-mkdir -p -- "${ROOT_DIR}/dist"
-rm -f -- "$archive"
-(cd -- "${ROOT_DIR}/extension" && zip -qrX "$archive" .) || die "could not build $archive"
-printf '%s\n' "$archive"