From 1c4ae8a011312ca0004c27469ff2e0bbe614c68c Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 8 Nov 2026 12:33:24 +0700 Subject: [PATCH 01/16] feat: global show/hide shortcut entry --- extension/hotkeys.js | 12 +++++++++++- extension/shortcut-editor.js | 2 +- tests/hotkeys.test.js | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/extension/hotkeys.js b/extension/hotkeys.js index 0ba8838..f0d6a78 100644 --- a/extension/hotkeys.js +++ b/extension/hotkeys.js @@ -57,6 +57,8 @@ const DEFAULT_HOTKEYS = [ { 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: "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/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/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({}); + } +}); From d7317b0e7659963d9576e9ba1b7810c2a65438b8 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 8 Nov 2026 12:34:13 +0700 Subject: [PATCH 02/16] feat: file-backed storage area for the desktop app --- extension/desktop.js | 81 ++++++++++++++++++++++++++++++ tests/desktop.test.js | 113 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 extension/desktop.js create mode 100644 tests/desktop.test.js diff --git a/extension/desktop.js b/extension/desktop.js new file mode 100644 index 0000000..99eb3b2 --- /dev/null +++ b/extension/desktop.js @@ -0,0 +1,81 @@ +// 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(); + 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.catch(() => {}); + 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) }, + 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. +export function historyKey(event) { + if (!event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) return null; + return { ArrowLeft: "back", ArrowRight: "forward" }[event.code] ?? null; +} diff --git a/tests/desktop.test.js b/tests/desktop.test.js new file mode 100644 index 0000000..6d9f5c6 --- /dev/null +++ b/tests/desktop.test.js @@ -0,0 +1,113 @@ +import "./dom.js"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createFileArea, historyKey, recoveryMessage, toAccelerator } 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); +}); From cf53ee2f7b1878ca6e65992981b734f33bd3b68d Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 8 Nov 2026 12:35:07 +0700 Subject: [PATCH 03/16] feat: atomic notes file with backup and damage recovery --- desktop/.gitignore | 1 + desktop/Cargo.toml | 17 ++++ desktop/src/lib.rs | 1 + desktop/src/storage.rs | 188 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 desktop/.gitignore create mode 100644 desktop/Cargo.toml create mode 100644 desktop/src/lib.rs create mode 100644 desktop/src/storage.rs diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml new file mode 100644 index 0000000..a8c8c7a --- /dev/null +++ b/desktop/Cargo.toml @@ -0,0 +1,17 @@ +[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"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs new file mode 100644 index 0000000..30f61eb --- /dev/null +++ b/desktop/src/lib.rs @@ -0,0 +1 @@ +pub mod storage; 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""); + } +} From 7e46134beb618242cb620ef4f18545b840c55c53 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 8 Nov 2026 12:39:38 +0700 Subject: [PATCH 04/16] feat: Tauri desktop shell with tray, global shortcut and updater --- desktop/.gitignore | 1 + desktop/Cargo.toml | 22 ++ desktop/build.rs | 3 + desktop/capabilities/default.json | 7 + desktop/icons/128x128.png | Bin 0 -> 2775 bytes desktop/icons/128x128@2x.png | Bin 0 -> 5680 bytes desktop/icons/32x32.png | Bin 0 -> 786 bytes desktop/icons/icon.ico | Bin 0 -> 10655 bytes desktop/icons/icon.png | Bin 0 -> 11812 bytes desktop/icons/source.png | Bin 0 -> 12055 bytes desktop/src/lib.rs | 338 ++++++++++++++++++++++++++++++ desktop/src/main.rs | 6 + desktop/tauri.conf.json | 48 +++++ package-lock.json | 218 +++++++++++++++++++ package.json | 6 +- tools/make-icons.mjs | 5 + 16 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 desktop/build.rs create mode 100644 desktop/capabilities/default.json create mode 100644 desktop/icons/128x128.png create mode 100644 desktop/icons/128x128@2x.png create mode 100644 desktop/icons/32x32.png create mode 100644 desktop/icons/icon.ico create mode 100644 desktop/icons/icon.png create mode 100644 desktop/icons/source.png create mode 100644 desktop/src/main.rs create mode 100644 desktop/tauri.conf.json diff --git a/desktop/.gitignore b/desktop/.gitignore index b83d222..79c707c 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -1 +1,2 @@ /target/ +/gen/ diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index a8c8c7a..2c71ed5 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -9,9 +9,31 @@ publish = false name = "notelet_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[build-dependencies] +tauri-build = { version = "2", features = [] } + [dependencies] +tauri = { version = "2", 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.38" +windows-core = "0.61" + [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 0000000000000000000000000000000000000000..51acf4656a1afc754e2198b8a93775b5b34bfb4e GIT binary patch literal 2775 zcmV;|3Mlo7P)FI&QZDz$K-scjL1kBwwTfkd zwq&hVWUU?`!mM1LmbLySbn)$}%h=2wJ2e6C+!X|}*6~5#??7Ngd|n-nJ&wR!ufu(` zfVbrKcjcZj`9LF;62A=)$c;g0$bu+VfcGM{pdxDs*OC8rg3?CQtl^xj>7?BB#o!RW z+PVH1M`&aJMA$b0>eu0p-$>qg?o%8qx%fa{~`Z*;J=#`BX%4S+?;GW!Zs~O;vk@vCOj?GIaz|LJCU5CAJ2EUglUpv-^ z;~QI|v$hdhFdv$nwH3l2MIu>YDpIT51E*33mRGSP*KZEZ;7|4qQ4;&q2G$;Jy)7y! z;^q0zFedO$4Ad~Xx?$A2FYn}73i_+n5CV&=c_?PW>#G2ZYnT;;t`5J4$#%@1X>RtS z^0OEE?`_Qt)|u=n~d54EIfd?1>M*#Bdnk>!Ve? z_IMq>ys)Rs-O)twj~;>dxPiNli;E}T!ThsGVDGB-Q}CkzQ?*_NziUi`U_`8l8L=aV z#Bw)tVD0AcQSFPn`47I(+NOXH1;5LjM3TT0?Cwfl;SFOBe!wxb+e5wV0krv4`)eBt zewR9l9XXqqa@|qzR}ejvYNoI((}3^I;B%WfmP|7(YdT%sN^?hjZk)h}A#WcbcvhB2k&oKiH@9)` z#1Ka55E`J_%)xqoTGCbUb6h|o#>ASKx57Yn^01l10<>o*|0IZswdvmCb@uS}0C%L@ zWJEf7B5mf#y+kFN0UvYQz*#$O;3W7^@CzXXpK0Nd1bgY;Wdmochfk#_i6N5Uf@@m0 z)$$iIBp~)K`}Wzo4UPSa>S5iV{FY3810`P*DP?6inQX_!O3N z&}lX3>S|0JDgg+5?1yqFp;+)U17n$%%(%b=kX49f;7#?Hzk_@HJmR1s*(p2cwRq{p zcB=@MrqG!ESd^bRl2cR-V43E$(y2@68N6614X}OW7ij$R<7Bu_(S3k;dnNfBHAMqB zIoAL($iptRzO(uOEhmu03Ac4Q$GsR&d}j>_(+SQ0`~}h01Ct5NeV1CA3G^5Lo|ZojSL0bS zJt{u*T0jtMEugq2Ku#%mP5}=lX*KXECs0MhKouo`iV{FY37}FWCy)_kW z>WqdEb5u1h+_ z+pe%w185#VMG2sy1W-{YP@O<^0@VpDm;lP-=gOy zojoXWbMDW;;!N&}Rh@Az!0+5ak)}Rj%g<{^zB++L9299G%5}%GhJl&~P*DP?C;?QU znLr&Bxl0sa_2jl)tuq=zdOCMdWUEibqym)yFTW8)j8OCb=LpyT)sa6L6LXB6T}IUS zcWN;6r3rLUSGZZ2uM8PS~K!QlOo0o5c2tE#=M!ZgMb334p>3r-+F; zvA3n^b(UuxpcG?yBG$y5*xRQrV^eg&WN*pX1|>lL?SGVFJxT2CG#&Uw7-c`@tdjFN zg%+_U=B-rl;1IrAYv5kF2TrVJAkW}O#F$tUb3=fs33&E6q%9M-DrKVrv_C7qj(m%=DIl*4QkLqxF zgD~S}U&Vw8fi79@Lcf665@Q3tU{3673}KW?+^CFTU^B-e|j0$GK}#{Mp zD335UV(1dq9TykZH}Rm>D!~cW-A_kiQzE7*GH>( zP0c~~sD18yf`8&2%(vV9J#_{1349`7K>EktpdFzz!E_?S3SH{X3-yQ%F(OvP?0Min zw=JAc%*+9u2zrv$5^Tnla<{L2gq@M$BNoJj*reY6cuVb%KRcDxZuz*x@@qxU^m0A_29ih{vv>G-2#3p4~C=LP9;?e{r@3MiDJ$$BxFBR7F&s{;l ds@_50@PCf^Wq4;IFIE5m002ovPDHLkV1fs!P!j+E literal 0 HcmV?d00001 diff --git a/desktop/icons/128x128@2x.png b/desktop/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fa679ddef3e02d92c719f08598a438e26477d038 GIT binary patch literal 5680 zcmb7|XEa=GyT|vKF{4J1B1DLis3Fm!*96f5*{)>hZGE+Z;)4Ys0)h6_XI z#22pHD{Kb#eQ;j)`g!=W_sNvkrYttoTd@i6>(bY>NLF?(Z6uCmR||H5wx1Oob|@uz zJ8I1gD}NCeX)Nj#)#j@^X`09un=FoZJsv48C$8dc406YJ^D^?ODqIvAUkzxm2VCi0 z!f6M~*{5VF{al_6<4Yit;8S?K@`M~;QYk@RLFC&sYNm@;n-rEQ2n%`s@>xRQYA zcm_V`zg{oRFu%c&&Az6Ug5(7z%#b)J@bH)>=yb)w zHcrc3uZj`5QX^e#(6=-z8cgUCNHmsNPwKOBcjOIFAFot@e0p{PY%UOF8@{vMySv#nnkblnaLs5Jutzv(+?rvtA5-)3`gK{$7egT>uH)*hT zxv}%p$%7tLK`3#3>?2;oN!7pa7Hh)IeZ@$r^g~zXE9Nmqu_Nb%s@`vy#oVWaW#@6~4M* zv5ewW?v3<;yh01suqrq?iT9Inc%!ZLPIi-9X~mBQ`QX3JOU3sygf^X|JQC3SQ4;5pu1tIiz0PPQ*mnEPD*|R0Xqo%VS17F`RuxT4E zs7p=23yYCgG~vDzwJ*n1T{dsQ{M=sO25**mZA299E%Tt1xzKdxhrHYbATlAQmL~SH zvzO5}W1a4qZOVe?<$@}n3`S@Q)Z`b#<5*Pr3Nu=N^^sS?)#95wb5enGr1>!mVNwmL z$Kce`shw$STjvw@cNd!I{wCNMK3ta*c~^<>*K1B%tROORA4kK0od?vPaV1Qlgi?!~;w;ZSY?lxxMWwSiW#V&ZWO< z^UEpUwe$KM%zXZ}iWy6UwNxEq<9R7PX<%LYs&^pW0E)8SuvZ7;&Rf1C%@ryX>G)+{ z$@CPy?9!gpw@6*FB21TFE6HEM-)=BjVU>ZBfWRg^XP>>pU?(aF8>IR}beVBbpJS!i zb6NTsn0bk8AmN{2-W}+M<+so0gL_z3J3+J}YjyJ2GtCV4s!9C@#*`o~1m_A>u@vq- zvQN^{Qc|x>hYoJSExL$pFtZVtQ6m8y6$Q;+C9qSmn^mdJp7yY>&-C_@{GSS}&b$KL zLW-%j!7e`}v++3oJBqyIyA_%tbtbG^ZyH+q7LHuy)O@$84b{FeAwJNDWs5a!2>sot zvGEB-rDJh>NzBI;dm3Kb(s${oa26sx6nO}eZW8|!L#njihy0<(z$%Qe=)QCwNUc$&BO z^-A(B>Ia{XSbYMSGbwk;ADK%qjrSQ$G5tE*2XMv6+9eSaNB@37%)|4dksgYq>U#-3 zf}^?VK{TfzIlhbV-hkrK(EuUzGes!9o|q67TbFGeG^3%*!FcLB{WjQJO9+uFwVoPb zC3RZL+#K}Vz{+>kP!nBYA5Mutf)_NYw6<&*fOZ${z!r1+g$s{FE7nUXxR!p|#pwZA zyISuf9`?{zpQ50Mb9No5(P%{<$H@Kl0#_N8#ad1o#!!u*ko4{P`U;ix}YjnbA@Hw!x~u@92x%@ZrlE z059+8=K5k*2jm@6PWeTk&FU^QW}jG<@tBJ<(fL&6=djwc`+XBKsQV5_ zR_cKX?uoWY-DI_al9=S?;>&3|aE4g?(u5tBvp?<-lqmmmmQnTc@CFv*NeWK`#XYKH4#N@hXCDAfTHpQOvKsq^W6ul zu=$U;rnMhAz(sOc!wYV$Px{If@gAjz>5>wV6V)3wChI31N<9n5Qj&gmU*b19J%ABE zF7ddu;q_=FAzHEgj2v%DR|h|Z37on#fBp$2<@G{8Zz)siVbitIoa7d^ZSd;=X>RG# zw1vF4)W32Fr~Bf<7sPde`CHJ`VZGgff!~W6H;$B5_^DnzS|xtIc{w7_tR#$FQ*=wF zPr8#LhYbu~8d$u)D+fEoXGcKl3YTE)2|dg})&vUZs!>9W5tFps%rpy;`mbJ50j~*Q z-BtbF;^6U1ey~6?s2I=NjI_<1>W%_T60LfH+IEXAgF_~G9O|3flUGOr1Hw7+6$xzO zXp7kgZr2{C9umKZHVa+@;0Z-4VuS;NJF|+r8c`YZARN#(-PvT^Cr%=VK@fVNuyc6& z(e7k+ElqKOE%2;s;vwwaYY=VabMS!vz5CFgi0F)6!fh7tk{TmaN6XrXRfdN5A z<&O|#BIj#HHWlZzLCNSVF+xFO3%gB`vSOn7xDEuVZ@?AM}x>$gbzE{I5I-yC4QgQEdsc*IOt;((xt zyw^+qBQyA0!NVBPqbtmQxj^2Ti|Sh_1YiIFFz{y-2Y_Y@=KZgeY%Ywq=@)^MhiUix z)LNsALRt<+1JuM+7^X`eXE!`$yv_orlx?E-`kVrTZRdjJYag4}& zzeuHFF-{~^K^YKsLel{qCl|mvC$uN@O3n!O^Z&bm|Elv}A;19hdd?$Ul!kBYO$JRt zj?g8CRoX`2Hl{o#d}Sjx1s%Tg+kc-rQwjkDNJO>c4d2T9n&edaI?`DtkM~9G(osKcTZtRee5L*y(Bc#s8QacIs4UmKQF4pbUWUqg?@+OZs;qJ6V z$eWc(p@s|kq8YMqMj*WWQ~}%P_x(Cy)Uz~ffJvY7ZNQEb`1jWV_xkwxX(I>j^R-~# zo$$5i%wQ;7H0B0(NL;14sfa}nM==BfVpF*}x&6d<{pT6sIx^%87Ce<()b5!U8Xy&2 z&}ZQemnp-5ygS2JweJM0&UDc;bO@OwJJP=dc_Vb34LBgitky^H8&O=~r9oC#8mkvb zmH+z}`qvdeNyvx*cxwqw3)j{?X{z;bMoT4iybQ==jJ{D@Sz+fX8hqqf*V*tsoxXh_nD8bODIU>?K=Qh%HX|v;EoNm| zdFZ%Eb0kve14<8SG{*#Y+b{XH!$2tSc|lYv>KNZYeC^Y;4Xv=>vw z)VmT806xRbjX$IBucj|931%3FU&k1DxIKooulUu6H`bil6nH{#FRTzW*P+kINlEuH-+Rpo=@z%)>S3)%(yBZFAoy^f;0oM{t8oR3iGVJ^Z+g4X|4YD5=20C-&Sh9D8D1o+^vcVk?p@ob&xjO!C$TXtDv>f zqVT+gH;~8uQ%cc=UFOe6^I{a}w)WfS`Xc88t)=|s^(Q!@0H7OwN^DPa?zSe33ZsRp zo2DNDf!`LW*BPISlppLz6=Z`A0TqWxawUaCT*rL8$Y*&x-LEIFSxqGO0&uMYm5t=J$l!Z z3l`b0azTH6JJfG2o~Ir=yANhdJUj%^!hCwT7I|a`39C*Z<|nkj41LL+)z0kFqG|bh zM7$VI6{UW`*|QZ}IWn!e+#7^;zSA!4zi(S(Wi$ z6Q3-cZ1uZKWH#PJ()Wgp%ZMUhqA6ZyY4&~X#o~HyFfhimDf16r>rCAEde;TkUuMk` zckPWlis|>b9k=Rht(i(V4T4wxl#fE|w}^2RT4x^-E}$>HwQzD-)aHcVT2X}z5=-V< zd8z8fl$pcb483$}5~d$ptc}Y@dRNZadeV6i4RV)t5%DB9Pt$Ao_pOh(DF8QYOUhTo zWg+|j0r*?qMgNx_9 z5?ZT(O-WWr8QWlxF|hKX=KQuJFSO#oH-r`Ia~Ql3F}Wi#KC66pjy))U^)YadOI5Zo zxZS&0Rc~5*(r@c;eWr#SNVqDdM)2@m?LQtUhHR0xkpwQACw}7DdM377gNEy=M3C=? zazxBoSo4aP1$Wr|s#R~QWqr~m-n_zstWmBG&AdMzZ#ClX8*-(3iekR8W_(5tR4Whe z=^^%_f^xgM(0kt6cX5cy^UXs2)&6J8veC0zA$}h&k#xP}C8>#KBQ5Bmo&6jtfn&fa#lkyTfnNpf!u!pi?#b*}mr8yuG!7@eG+1T2p!3v{2=I|0p(V9KT9TM=UG&} zkK8a2gVsDrqz=izI>uIRu3|~vz9|Y>U+C_gyeX)shYr$Y?Fk?8$vd~|uT`qc+b_(a zoF`o;1Fh*OulJEv%w6?x+B&wY{}B}-_vM`Z{jDzM(IRlA?QTZoal>RjG@2)RU`>~2 z{0ZiY$`#YCcWbT}ZP_DO##GZ;CCaWH6sM1PwO*68+w}4}5lX7*+#hcNEODXUzeH`l zl?yqwiX$(dnV{u!{o&X$zG@4wmD|;MvSwx{Ah)IOj<2&`%r@PZg81n8b?nvHHGW4w zbJ)LF@O(6z)0uOlE)G=9{;enfm;@vL*?Nz5qGE!}l^Sgw&V`PsILx9uwd4GJB56Bjgzsw2Klf1GIVZ03fZO3-+st_lF)y3T!mpH? z+5g)1NqA=IUcVt}womD_t+o&?Us%YG zTzOq0v)NTEX4_g6I(fjT!7mTDh^x53y-I7CFCwWd{@m^nSck*KEAb>%j<-`;5OdLY z9K|I3z=Ey31!c=9Jku*Z!Bb}#9B`k@efdP+sV$)RGp-Mh^@%BOi8uS?UoZ&uXn)MZ zS@9%OTh7}e%G=I700nW^=Bc-a?Vbx>HVt;cb(jV>+F}n@n1)N2H8@!@$uYLA2N5>+ zEo+msgPmka)WPp@9k2kLe;?C)aPVm>rM!p^7V6SFJ{@y8DP^SflvHMFaC$ QibVjl@9ArlsUbrD1M>KNL;wH) literal 0 HcmV?d00001 diff --git a/desktop/icons/32x32.png b/desktop/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..55a5014b623d8c196837d092a22b46d45807daa4 GIT binary patch literal 786 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 OZ%P77gn#|4?*9N6m+wFT literal 0 HcmV?d00001 diff --git a/desktop/icons/icon.png b/desktop/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c17c0a72375c54ef83a2c628f48c913fb09e8cd7 GIT binary patch literal 11812 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 d), +} + +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); + }); +} + +fn run_pending(app: &AppHandle, then: &str) { + let pending = { + let Ok(mut slot) = app.state::().pending.lock() else { 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)) => { + let _ = app.save_window_state(window_state_flags()); + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + match update.download_and_install(|_, _| {}, || {}).await { + Ok(()) => handle.restart(), + Err(error) => show_message(&handle, 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 { + Ok(Some(update)) => request_after_flush(&app, Pending::Update(Box::new(update))), + 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.into()); + } + }); +} + +#[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/tauri.conf.json b/desktop/tauri.conf.json new file mode 100644 index 0000000..69ab2cf --- /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": "SET_IN_RELEASE_SETUP", + "endpoints": ["https://github.com/ttncode/notelet/releases/latest/download/latest.json"], + "windows": { + "installMode": "passive" + } + } + } +} 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..a2b9c6b 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "notelet", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "scripts": { - "test": "node --test" + "test": "node --test tests/", + "tauri": "tauri" }, "devDependencies": { + "@tauri-apps/cli": "^2.12.4", "linkedom": "^0.18.13" } } 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)); From 6433e3cdb19f2535e8d226a2b441a84cb5031b22 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 8 Nov 2026 12:42:01 +0700 Subject: [PATCH 05/16] feat: run the UI in the desktop app; drop extension-only code --- extension/app.js | 101 +++++++++++++++++++++++++++---------- extension/background.js | 35 ------------- extension/dialogs.js | 27 ++++++---- extension/hotkeys.js | 2 +- extension/manifest.json | 21 -------- extension/notes.css | 2 +- extension/notes.html | 2 +- extension/popup-window.js | 15 ------ tests/dialogs.test.js | 20 ++++++++ tests/popup-window.test.js | 15 ------ tools/package.sh | 24 --------- 11 files changed, 113 insertions(+), 151 deletions(-) delete mode 100644 extension/background.js delete mode 100644 extension/manifest.json delete mode 100644 extension/popup-window.js delete mode 100644 tests/popup-window.test.js delete mode 100755 tools/package.sh diff --git a/extension/app.js b/extension/app.js index d9ab214..feb899a 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 } 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,11 @@ main().catch((error) => { }); async function main() { - document.body.classList.toggle("popup-view", IS_POPUP); + listenForFlush(); + addEventListener("keydown", onHistoryKey, { capture: true }); + 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 +81,7 @@ async function main() { state.ui = loaded.ui; state.settings = loaded.settings; setCustomKeys(state.settings.hotkeys); + registerGlobalShortcut().catch(() => showToast(SHORTCUT_TAKEN_MESSAGE)); showTheme(state.ui.theme); await removeStaleNotes(); editor = new NoteEditor({ element: byId("editor"), isMac: IS_MAC, onChange: onEditorChange }); @@ -87,8 +94,44 @@ async function main() { store.onChange(applyRemoteChanges); openInitialNote(); steps = createNavigation({ readView, showView }); + 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)]); + await tauri.core.invoke("after_flush", { then: payload.then }); + }).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 +246,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 +459,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 +616,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 +765,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 +800,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/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 f0d6a78..1da8f63 100644 --- a/extension/hotkeys.js +++ b/extension/hotkeys.js @@ -55,7 +55,7 @@ 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 }, 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

-