From f964f1f6629266be3bb63911114b3b78aa0fa1ec Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 31 Oct 2026 16:07:08 +0100 Subject: [PATCH] Add the first version of the desktop app The window is the website viewer, ported: open, render, search, zoom, edit, save, PDF marks and passwords. The desktop adds saves to disk, Save As, one tab for each sheet, recent documents, new empty documents, print, a menu bar, file associations for ODF and iWork, and one window for each document. The core runs as WebAssembly in a Web Worker, behind a Backend interface, so a native backend can replace it without a change to the viewer. CI runs a smoke test: the built app opens each file in test/documents and saves a copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FW6WZZR7nYJaknHYwVDGAq --- .github/workflows/build_test.yml | 13 +- AGENTS.md | 44 + CHANGELOG.md | 7 +- README.md | 5 +- index.html | 1 - package-lock.json | 12 +- package.json | 3 +- print.html | 10 + src-tauri/Cargo.lock | 533 ++++++++++ src-tauri/Cargo.toml | 6 + src-tauri/capabilities/default.json | 13 +- src-tauri/capabilities/print.json | 7 + src-tauri/src/lib.rs | 386 ++++++- src-tauri/src/menu.rs | 202 ++++ src-tauri/tauri.conf.json | 107 +- src/core/backend.ts | 78 ++ src/core/wasm-backend.ts | 152 +++ src/core/wasm-worker.ts | 88 ++ src/main.ts | 26 +- src/platform.ts | 172 +++ src/print.ts | 27 + src/styles.css | 121 +++ src/viewer/frame-bridge.js | 243 +++++ src/viewer/icons.ts | 50 + src/viewer/markup.ts | 245 +++++ src/viewer/viewer.ts | 1354 ++++++++++++++++++++++++ test/documents/encrypted-password.xlsx | Bin 0 -> 8192 bytes test/documents/sample.docx | Bin 0 -> 7282 bytes test/documents/sample.md | 26 + test/documents/sample.ods | Bin 0 -> 17811 bytes test/documents/sample.odt | Bin 0 -> 26251 bytes test/documents/sample.pdf | Bin 0 -> 51949 bytes test/documents/sample.pptx | Bin 0 -> 11599 bytes test/documents/sample.txt | 14 + test/documents/sample.xlsx | Bin 0 -> 6693 bytes test/documents/two-sheets.ods | Bin 0 -> 21453 bytes test/smoke.mjs | 51 + vite.config.ts | 39 +- 38 files changed, 4017 insertions(+), 18 deletions(-) create mode 100644 print.html create mode 100644 src-tauri/capabilities/print.json create mode 100644 src-tauri/src/menu.rs create mode 100644 src/core/backend.ts create mode 100644 src/core/wasm-backend.ts create mode 100644 src/core/wasm-worker.ts create mode 100644 src/platform.ts create mode 100644 src/print.ts create mode 100644 src/viewer/frame-bridge.js create mode 100644 src/viewer/icons.ts create mode 100644 src/viewer/markup.ts create mode 100644 src/viewer/viewer.ts create mode 100644 test/documents/encrypted-password.xlsx create mode 100644 test/documents/sample.docx create mode 100644 test/documents/sample.md create mode 100644 test/documents/sample.ods create mode 100644 test/documents/sample.odt create mode 100644 test/documents/sample.pdf create mode 100644 test/documents/sample.pptx create mode 100644 test/documents/sample.txt create mode 100644 test/documents/sample.xlsx create mode 100644 test/documents/two-sheets.ods create mode 100644 test/smoke.mjs diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 6daa2d3..1354022 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -16,8 +16,11 @@ jobs: matrix: include: - os: macos-latest + app: src-tauri/target/release/opendocument-desktop - os: windows-latest + app: src-tauri/target/release/opendocument-desktop.exe - os: ubuntu-24.04 + app: src-tauri/target/release/opendocument-desktop runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 @@ -26,7 +29,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev xvfb - uses: actions/setup-node@v5 with: @@ -43,6 +46,14 @@ jobs: - run: npm run check - run: npx tauri build + # Opens every file in test/documents in the built app, and saves a copy. + - name: Smoke test + if: runner.os != 'Linux' + run: node test/smoke.mjs ${{ matrix.app }} + - name: Smoke test + if: runner.os == 'Linux' + run: xvfb-run -a node test/smoke.mjs ${{ matrix.app }} + - uses: actions/upload-artifact@v5 with: name: bundle-${{ matrix.os }} diff --git a/AGENTS.md b/AGENTS.md index 4fdfcf7..f1b4f9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,50 @@ The frontend owns the user interface, and it runs the core. The renderer is [OpenDocument.core](https://github.com/opendocument-app/OpenDocument.core). Unsupported formats and rendering bugs belong there, not here. +## Architecture + +``` +src/core/backend.ts the Backend interface: the only way to the core +src/core/wasm-*.ts the Backend over the WebAssembly core, in a Web Worker +src/viewer/viewer.ts one window: open, render, tabs, search, zoom, edit, save, print +src/viewer/frame-bridge.js runs inside the rendered document, talks over postMessage +src/platform.ts files, dialogs, menus, recents: everything from Tauri +src-tauri/src/lib.rs file access, window routing, print windows, smoke mode +src-tauri/src/menu.rs the menu bar; a click is a `menu` event to the focused window +``` + +- **The engine is behind `Backend`.** The viewer calls nothing else of the + core. A native backend, for example the core's HTTP server as a sidecar, can + replace the WebAssembly one without a change to the viewer. +- **The viewer comes from the website.** `viewer.ts` and `frame-bridge.js` are + ports of `src/scripts/` in OpenDocument.web. A fix in one is usually a fix in + the other. The rendered document runs in a `sandbox="allow-scripts"` frame + with an opaque origin, so it cannot reach Tauri. +- **One document per window.** A file that arrives while a window holds a + document opens a new window. On macOS the system sends files with + `RunEvent::Opened`, on Windows and Linux a second launch hands its arguments + to the first through the single-instance plugin. +- **A save writes a temporary file beside the target and renames it over the + target**, so a failed save never leaves half a document. +- **Print renders the view again** with every part laid out, and a `print-*` + window shows it as a top-level page. On macOS, Tauri prints only a top-level + page, not a frame. The print windows get no file access. +- **The CSP keeps `'unsafe-inline'`**, because the srcdoc frame takes the + policy of its parent and the renderer writes inline scripts and styles. + `dangerousDisableAssetCspModification` stops Tauri from adding hashes, which + would switch `'unsafe-inline'` off. +- **No Undo item in the menu.** The page owns its edit log and takes the undo + chord itself. +- **No edition gate, no ads, no store.** How the desktop app earns money is + not decided yet. + +## Checks + +- `npm run check` type-checks the frontend. +- `node test/smoke.mjs ` opens every file in `test/documents/` in + the built app and saves a copy. CI runs it on all three systems. A file named + `*-.` opens with that password. + ## Rules - The app compiles no C++. It uses the WebAssembly build of the core from npm, diff --git a/CHANGELOG.md b/CHANGELOG.md index c871018..f56be25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,4 +8,9 @@ Before a release, a pull request changes that heading to the version. ## Unreleased -- The first version of OpenDocument Reader for Windows, macOS and Linux. +- The first version of OpenDocument Reader for Windows, macOS and Linux. It opens + OpenDocument, Word, Excel, PowerPoint, PDF, Pages, Numbers, Keynote and more, + also with a password. +- Edit and save text documents, spreadsheets and presentations, and mark up PDFs. +- Search, zoom, print, a tab for each sheet, recent documents, and new empty + documents. diff --git a/README.md b/README.md index a859d68..91906af 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ npm run tauri dev # starts the app with hot reload npm run tauri build # writes the installers to src-tauri/target/release/bundle/ ``` -`npm run check` type-checks the frontend. +`npm run check` type-checks the frontend. After a build, +`node test/smoke.mjs src-tauri/target/release/opendocument-desktop` opens each +document in `test/documents/` in the app and saves a copy. ## Layout @@ -29,6 +31,7 @@ npm run tauri build # writes the installers to src-tauri/target/release/bundle | `src/` | the frontend: TypeScript, Vite and Tailwind | | `src-tauri/` | the Rust shell: windows, menus, file access, bundling | | `scripts/sync-odr.mjs` | copies the core from `node_modules` into `public/odr/` | +| `test/` | the smoke test and its documents | [`AGENTS.md`](AGENTS.md) describes the architecture and the rules. diff --git a/index.html b/index.html index f09733a..cda9883 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,6 @@ OpenDocument Reader -
diff --git a/package-lock.json b/package-lock.json index a6617d5..2186b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@opendocument/odr-core": "^7.7.0", "@tauri-apps/api": "^2.12.4", - "@tauri-apps/plugin-dialog": "^2.8.4" + "@tauri-apps/plugin-dialog": "^2.8.4", + "@tauri-apps/plugin-opener": "^2.7.2" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -860,6 +861,15 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.2.tgz", + "integrity": "sha512-LyybP1VSzTMQiaFYcsbAse47KpivlyXdLZMQzGLEnkgQacGESnpjAfFoQ6h5sqWgAdZDH9szYjPzHMpv0Sz+TQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", diff --git a/package.json b/package.json index faadc1f..c7c720c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "dependencies": { "@opendocument/odr-core": "^7.7.0", "@tauri-apps/api": "^2.12.4", - "@tauri-apps/plugin-dialog": "^2.8.4" + "@tauri-apps/plugin-dialog": "^2.8.4", + "@tauri-apps/plugin-opener": "^2.7.2" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", diff --git a/print.html b/print.html new file mode 100644 index 0000000..095f897 --- /dev/null +++ b/print.html @@ -0,0 +1,10 @@ + + + + + Print + + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f01cd55..2660503 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,137 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atk" version = "0.18.2" @@ -142,6 +273,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "9.1.1" @@ -332,6 +476,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -708,6 +861,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -725,6 +905,36 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -847,6 +1057,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1190,6 +1413,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1470,6 +1699,25 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1698,6 +1946,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -2079,15 +2333,30 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "opendocument-desktop" version = "0.1.0" dependencies = [ + "percent-encoding", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-window-state", ] [[package]] @@ -2096,6 +2365,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2121,6 +2400,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2209,6 +2494,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2241,6 +2537,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2518,6 +2828,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2795,6 +3118,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3225,6 +3558,59 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726b6da0dd5bd1344f84f4108a622c07d01923baacb5c803a605189672c5352f" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3" +dependencies = [ + "bitflags 2.13.2", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-runtime" version = "2.12.2" @@ -3324,6 +3710,19 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3594,9 +3993,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3646,6 +4057,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.27" @@ -4322,6 +4744,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4387,3 +4879,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f31bfe8..8621d52 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,9 @@ tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tauri-plugin-opener = "2" +percent-encoding = "2" +tauri-plugin-window-state = "2" [profile.release] codegen-units = 1 @@ -26,3 +29,6 @@ lto = true opt-level = "s" panic = "abort" strip = true + +[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] +tauri-plugin-single-instance = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c757650..9f3c6f1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,14 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "What the windows of the app may call.", - "windows": ["*"], - "permissions": ["core:default", "dialog:default"] + "description": "What the document windows may call.", + "windows": ["main", "doc-*"], + "permissions": [ + "core:default", + "core:window:allow-set-title", + "core:window:allow-destroy", + "core:window:allow-close", + "dialog:default", + "opener:default" + ] } diff --git a/src-tauri/capabilities/print.json b/src-tauri/capabilities/print.json new file mode 100644 index 0000000..19f5258 --- /dev/null +++ b/src-tauri/capabilities/print.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "print", + "description": "What a print window may call. The app commands refuse these windows too.", + "windows": ["print-*"], + "permissions": ["core:event:default", "core:webview:allow-print", "core:window:allow-set-title"] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2630bf..87c3f2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,387 @@ +//! The shell around the frontend: windows, menus, file access and printing. +//! +//! The frontend runs the core and owns the document. This side reads and +//! writes the files, routes the files that the operating system opens to a +//! window, and shows the print window. + +mod menu; + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; + +use percent_encoding::percent_decode_str; +use tauri::ipc::{InvokeBody, Request, Response}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +/// The prefix of the windows that only print. They get no file access. +const PRINT_PREFIX: &str = "print-"; + +#[derive(Default)] +struct Shared { + /// The files that wait for a window, by window label. + pending: HashMap>, + /// The windows whose frontend asked for its files, so it hears events. + ready: HashSet, + /// The markup that a print window shows, by window label. + print: HashMap, +} + +#[derive(Clone, serde::Serialize)] +struct PrintJob { + title: String, + html: String, +} + +struct State { + shared: Mutex, + next_window: AtomicU32, +} + +fn refuse_print_window(window: &WebviewWindow) -> Result<(), String> { + if window.label().starts_with(PRINT_PREFIX) { + return Err("a print window cannot do this".into()); + } + Ok(()) +} + +/// The bytes of a file, as an `ArrayBuffer` on the frontend. +#[tauri::command] +fn read_file(window: WebviewWindow, path: String) -> Result { + refuse_print_window(&window)?; + fs::read(&path) + .map(Response::new) + .map_err(|e| e.to_string()) +} + +/// Writes the raw body of the request to the path in the `x-path` header. +#[tauri::command] +fn write_file(window: WebviewWindow, request: Request<'_>) -> Result<(), String> { + refuse_print_window(&window)?; + let InvokeBody::Raw(bytes) = request.body() else { + return Err("the body must be raw bytes".into()); + }; + let header = request + .headers() + .get("x-path") + .ok_or("no x-path header")? + .to_str() + .map_err(|e| e.to_string())?; + let path = percent_decode_str(header) + .decode_utf8() + .map_err(|e| e.to_string())?; + write_atomically(Path::new(path.as_ref()), bytes).map_err(|e| e.to_string()) +} + +/// Writes into a temporary file beside the target, then renames it over the +/// target. So a failed write never leaves half a document. +fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let dir = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let temp = dir.join(format!(".{name}.{}.saving", std::process::id())); + + let result = (|| { + let mut file = fs::File::create(&temp)?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + if let Ok(meta) = fs::metadata(path) { + fs::set_permissions(&temp, meta.permissions())?; + } + fs::rename(&temp, path) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +/// The files that wait for the calling window. After this call, the window +/// gets later files as `open-paths` events. +#[tauri::command] +fn take_launch(window: WebviewWindow, state: tauri::State<'_, State>) -> Vec { + let mut shared = state.shared.lock().unwrap(); + shared.ready.insert(window.label().to_string()); + shared.pending.remove(window.label()).unwrap_or_default() +} + +/// Opens one new window for each path, or one empty window for no path. +#[tauri::command] +fn open_window(app: AppHandle, window: WebviewWindow, paths: Vec) -> Result<(), String> { + refuse_print_window(&window)?; + if paths.is_empty() { + return new_document_window(&app, Vec::new()).map(|_| ()); + } + for path in paths { + new_document_window(&app, vec![path])?; + } + Ok(()) +} + +fn new_document_window(app: &AppHandle, paths: Vec) -> Result { + let state = app.state::(); + let label = format!("doc-{}", state.next_window.fetch_add(1, Ordering::Relaxed)); + if !paths.is_empty() { + state + .shared + .lock() + .unwrap() + .pending + .insert(label.clone(), paths); + } + WebviewWindowBuilder::new(app, &label, WebviewUrl::App("index.html".into())) + .title("OpenDocument Reader") + .inner_size(1100.0, 800.0) + .min_inner_size(480.0, 360.0) + .build() + .map_err(|e| e.to_string()) +} + +/// Opens a print window for the markup. That window prints itself. +#[tauri::command] +fn start_print( + app: AppHandle, + window: WebviewWindow, + title: String, + html: String, +) -> Result<(), String> { + refuse_print_window(&window)?; + let state = app.state::(); + let label = format!( + "{PRINT_PREFIX}{}", + state.next_window.fetch_add(1, Ordering::Relaxed) + ); + state.shared.lock().unwrap().print.insert( + label.clone(), + PrintJob { + title: title.clone(), + html, + }, + ); + WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("print.html".into())) + .title(format!("Print — {title}")) + .inner_size(900.0, 1000.0) + .build() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +/// The markup of the calling print window, once. +#[tauri::command] +fn take_print(window: WebviewWindow, state: tauri::State<'_, State>) -> Option { + state.shared.lock().unwrap().print.remove(window.label()) +} + +/// Sets the check mark of a check item in the menu. +#[tauri::command] +fn set_menu_check(app: AppHandle, id: String, checked: bool) -> Result<(), String> { + menu::set_check(&app, &id, checked) +} + +/// What the smoke mode does: `ODR_DESKTOP_SMOKE` names a file to open, and +/// `ODR_DESKTOP_SMOKE_SAVE` names a path to save a copy to, and +/// `ODR_DESKTOP_SMOKE_PASSWORD` is the password of an encrypted file. The frontend +/// reports, and the app exits. CI uses this to check a build. +#[derive(serde::Serialize)] +struct Smoke { + path: String, + save_to: Option, + password: Option, +} + +#[tauri::command] +fn smoke_path(window: WebviewWindow) -> Option { + if window.label() != "main" { + return None; + } + let path = std::env::var("ODR_DESKTOP_SMOKE") + .ok() + .filter(|p| !p.is_empty())?; + let save_to = std::env::var("ODR_DESKTOP_SMOKE_SAVE") + .ok() + .filter(|p| !p.is_empty()); + let password = std::env::var("ODR_DESKTOP_SMOKE_PASSWORD").ok(); + Some(Smoke { + path, + save_to, + password, + }) +} + +#[tauri::command] +fn smoke_report(app: AppHandle, ok: bool, detail: String) { + println!("smoke: {} {detail}", if ok { "ok" } else { "FAILED" }); + app.exit(if ok { 0 } else { 1 }); +} + +/// Sends files to a window that can take them. A window that is not ready +/// keeps them until its frontend asks. With no window, a new one opens. +fn route_paths(app: &AppHandle, paths: Vec) { + if paths.is_empty() { + return; + } + let windows: Vec = app + .webview_windows() + .into_values() + .filter(|w| !w.label().starts_with(PRINT_PREFIX)) + .collect(); + let state = app.state::(); + let mut shared = state.shared.lock().unwrap(); + + let ready: Vec<&WebviewWindow> = windows + .iter() + .filter(|w| shared.ready.contains(w.label())) + .collect(); + let target = ready + .iter() + .find(|w| w.is_focused().unwrap_or(false)) + .or_else(|| ready.first()) + .map(|w| w.label().to_string()); + + if let Some(label) = target { + drop(shared); + let _ = app.emit_to(&label, "open-paths", paths); + if let Some(w) = app.get_webview_window(&label) { + let _ = w.set_focus(); + } + } else if let Some(w) = windows.first() { + shared + .pending + .entry(w.label().to_string()) + .or_default() + .extend(paths); + } else { + drop(shared); + let _ = new_document_window(app, paths); + } +} + +/// Asks every window to close. A window with unsaved changes asks the user, +/// and the app exits when the last window is gone. +fn close_all_windows(app: &AppHandle) { + for window in app.webview_windows().into_values() { + let _ = window.close(); + } +} + +/// The paths among the arguments of a launch. A relative path is relative to +/// the directory that the launch came from. +fn paths_from_args(args: &[String], cwd: &Path) -> Vec { + args.iter() + .skip(1) + .filter(|a| !a.starts_with('-')) + .map(|a| { + let p = PathBuf::from(a); + if p.is_absolute() { + p + } else { + cwd.join(p) + } + }) + .map(|p| p.to_string_lossy().into_owned()) + .collect() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + #[allow(unused_mut)] + let mut builder = tauri::Builder::default(); + + // A second launch on Windows and Linux hands its files to this one. On + // macOS the system does that, with `RunEvent::Opened`. + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + let paths = paths_from_args(&args, Path::new(&cwd)); + if paths.is_empty() { + if let Some(w) = app.webview_windows().into_values().next() { + let _ = w.set_focus(); + } + } else { + route_paths(app, paths); + } + })); + } + + builder .plugin(tauri_plugin_dialog::init()) - .run(tauri::generate_context!()) - .expect("error while running the application"); + .plugin(tauri_plugin_opener::init()) + .plugin( + tauri_plugin_window_state::Builder::new() + .with_filter(|label| label == "main") + .build(), + ) + .manage(State { + shared: Mutex::default(), + next_window: AtomicU32::new(1), + }) + .setup(|app| { + let cwd = std::env::current_dir().unwrap_or_default(); + let args: Vec = std::env::args().collect(); + let paths = paths_from_args(&args, &cwd); + if !paths.is_empty() { + app.state::() + .shared + .lock() + .unwrap() + .pending + .insert("main".into(), paths); + } + menu::install(app.handle())?; + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::Destroyed = event { + let state = window.state::(); + let mut shared = state.shared.lock().unwrap(); + shared.ready.remove(window.label()); + shared.pending.remove(window.label()); + shared.print.remove(window.label()); + } + }) + .invoke_handler(tauri::generate_handler![ + read_file, + write_file, + take_launch, + open_window, + start_print, + take_print, + set_menu_check, + smoke_path, + smoke_report, + ]) + .build(tauri::generate_context!()) + .expect("error while building the application") + .run(|_app, _event| { + // A quit from outside the menu, such as the Dock on macOS, closes + // the windows first, so each window asks about unsaved changes. + // With no window left, the app exits. + if let tauri::RunEvent::ExitRequested { + code: None, api, .. + } = &_event + { + if !_app.webview_windows().is_empty() { + api.prevent_exit(); + close_all_windows(_app); + } + } + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Opened { urls } = &_event { + let paths = urls + .iter() + .filter_map(|u| u.to_file_path().ok()) + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + route_paths(_app, paths); + } + }); } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs new file mode 100644 index 0000000..9107e3e --- /dev/null +++ b/src-tauri/src/menu.rs @@ -0,0 +1,202 @@ +//! The menu bar. A click goes to the focused window as a `menu` event with +//! the id of the item, and the frontend acts on it. +//! +//! There is no Undo or Redo item. The rendered page owns its edit log and +//! takes the undo chord itself. A native Undo item would take the key first +//! and send it to the text field of the web view. + +use tauri::menu::{ + AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, + WINDOW_SUBMENU_ID, +}; +use tauri::{AppHandle, Emitter, Manager, Wry}; +use tauri_plugin_opener::OpenerExt; + +const WEBSITE: &str = "https://opendocument.app"; +const ISSUES: &str = "https://github.com/opendocument-app/OpenDocument.desktop/issues"; + +struct Checks { + darken: CheckMenuItem, +} + +pub fn install(app: &AppHandle) -> tauri::Result<()> { + let item = |id: &str, text: &str, accelerator: Option<&str>| { + MenuItem::with_id(app, id, text, true, accelerator) + }; + let separator = || PredefinedMenuItem::separator(app); + + let new = Submenu::with_items( + app, + "New", + true, + &[ + &item("new-odt", "Text Document (ODT)", None)?, + &item("new-ods", "Spreadsheet (ODS)", None)?, + &item("new-docx", "Word Document (DOCX)", None)?, + &item("new-xlsx", "Excel Spreadsheet (XLSX)", None)?, + ], + )?; + + let file = Submenu::with_items( + app, + "File", + true, + &[ + &item("new-window", "New Window", Some("CmdOrCtrl+N"))?, + &new, + &item("open", "Open…", Some("CmdOrCtrl+O"))?, + &separator()?, + &item("save", "Save", Some("CmdOrCtrl+S"))?, + &item("save-as", "Save As…", Some("CmdOrCtrl+Shift+S"))?, + &separator()?, + &item("print", "Print…", Some("CmdOrCtrl+P"))?, + &separator()?, + &item( + "close-document", + "Close Document", + Some("CmdOrCtrl+Shift+W"), + )?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?; + + let edit = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + &separator()?, + &item("find", "Find…", Some("CmdOrCtrl+F"))?, + &item("find-next", "Find Next", Some("CmdOrCtrl+G"))?, + &item("find-previous", "Find Previous", Some("CmdOrCtrl+Shift+G"))?, + &separator()?, + &item("edit-mode", "Edit Document", Some("CmdOrCtrl+E"))?, + ], + )?; + + let darken = + CheckMenuItem::with_id(app, "darken", "Darken Documents", true, true, None::<&str>)?; + let view = Submenu::with_items( + app, + "View", + true, + &[ + &item("zoom-in", "Zoom In", Some("CmdOrCtrl+="))?, + &item("zoom-out", "Zoom Out", Some("CmdOrCtrl+-"))?, + &item("zoom-fit", "Fit to Window", Some("CmdOrCtrl+0"))?, + &separator()?, + &darken, + &separator()?, + &PredefinedMenuItem::fullscreen(app, None)?, + ], + )?; + + let window = Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + ], + )?; + + let help = Submenu::with_id_and_items( + app, + HELP_SUBMENU_ID, + "Help", + true, + &[ + &item("website", "OpenDocument Reader Website", None)?, + &item("report", "Report a Problem", None)?, + ], + )?; + + // Not the predefined Quit, which ends the app at once. This one closes the + // windows one by one, so each window asks about its unsaved changes. + let quit = item("quit", "Quit OpenDocument Reader", Some("CmdOrCtrl+Q"))?; + + let menu = Menu::new(app)?; + + #[cfg(target_os = "macos")] + { + let about = AboutMetadata { + website: Some(WEBSITE.into()), + ..Default::default() + }; + let app_menu = Submenu::with_items( + app, + "OpenDocument Reader", + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about))?, + &separator()?, + &PredefinedMenuItem::services(app, None)?, + &separator()?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::show_all(app, None)?, + &separator()?, + &quit, + ], + )?; + menu.append(&app_menu)?; + } + #[cfg(not(target_os = "macos"))] + { + let _ = AboutMetadata::default(); + file.append(&separator()?)?; + file.append(&quit)?; + } + + menu.append_items(&[&file, &edit, &view, &window, &help])?; + app.set_menu(menu)?; + app.manage(Checks { darken }); + + app.on_menu_event(|app, event| { + let id = event.id().as_ref(); + match id { + "website" => { + let _ = app.opener().open_url(WEBSITE, None::<&str>); + } + "report" => { + let _ = app.opener().open_url(ISSUES, None::<&str>); + } + "quit" => crate::close_all_windows(app), + _ => { + let target = app + .webview_windows() + .into_values() + .filter(|w| !w.label().starts_with(crate::PRINT_PREFIX)) + .find(|w| w.is_focused().unwrap_or(false)); + match target { + Some(w) => { + let _ = app.emit_to(w.label(), "menu", id); + } + // Only New Window and Open make sense with no window. + None if id == "new-window" || id == "open" => { + let _ = crate::new_document_window(app, Vec::new()); + } + None => {} + } + } + } + }); + Ok(()) +} + +pub fn set_check(app: &AppHandle, id: &str, checked: bool) -> Result<(), String> { + let checks = app.state::(); + match id { + "darken" => checks + .darken + .set_checked(checked) + .map_err(|e| e.to_string()), + _ => Err(format!("no check item {id}")), + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 729816a..329e673 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,11 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost; frame-src 'self' blob:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'none'", + "dangerousDisableAssetCspModification": [ + "script-src", + "style-src" + ] } }, "bundle": { @@ -39,6 +43,107 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" + ], + "fileAssociations": [ + { + "ext": [ + "odt" + ], + "name": "OpenDocument Text", + "description": "OpenDocument Text", + "mimeType": "application/vnd.oasis.opendocument.text", + "role": "Editor" + }, + { + "ext": [ + "ods" + ], + "name": "OpenDocument Spreadsheet", + "description": "OpenDocument Spreadsheet", + "mimeType": "application/vnd.oasis.opendocument.spreadsheet", + "role": "Editor" + }, + { + "ext": [ + "odp" + ], + "name": "OpenDocument Presentation", + "description": "OpenDocument Presentation", + "mimeType": "application/vnd.oasis.opendocument.presentation", + "role": "Editor" + }, + { + "ext": [ + "odg" + ], + "name": "OpenDocument Drawing", + "description": "OpenDocument Drawing", + "mimeType": "application/vnd.oasis.opendocument.graphics", + "role": "Editor" + }, + { + "ext": [ + "fodt" + ], + "name": "Flat OpenDocument Text", + "description": "Flat OpenDocument Text", + "mimeType": "application/vnd.oasis.opendocument.text-flat-xml", + "role": "Editor" + }, + { + "ext": [ + "fods" + ], + "name": "Flat OpenDocument Spreadsheet", + "description": "Flat OpenDocument Spreadsheet", + "mimeType": "application/vnd.oasis.opendocument.spreadsheet-flat-xml", + "role": "Editor" + }, + { + "ext": [ + "fodp" + ], + "name": "Flat OpenDocument Presentation", + "description": "Flat OpenDocument Presentation", + "mimeType": "application/vnd.oasis.opendocument.presentation-flat-xml", + "role": "Editor" + }, + { + "ext": [ + "fodg" + ], + "name": "Flat OpenDocument Drawing", + "description": "Flat OpenDocument Drawing", + "mimeType": "application/vnd.oasis.opendocument.graphics-flat-xml", + "role": "Editor" + }, + { + "ext": [ + "pages" + ], + "name": "Pages Document", + "description": "Pages Document", + "mimeType": "application/vnd.apple.pages", + "role": "Viewer" + }, + { + "ext": [ + "numbers" + ], + "name": "Numbers Spreadsheet", + "description": "Numbers Spreadsheet", + "mimeType": "application/vnd.apple.numbers", + "role": "Viewer" + }, + { + "ext": [ + "key" + ], + "name": "Keynote Presentation", + "description": "Keynote Presentation", + "mimeType": "application/vnd.apple.keynote", + "role": "Viewer" + } ] } } diff --git a/src/core/backend.ts b/src/core/backend.ts new file mode 100644 index 0000000..623db61 --- /dev/null +++ b/src/core/backend.ts @@ -0,0 +1,78 @@ +/* + The one interface between the user interface and the core. The viewer calls + only this, so the engine behind it can change: today the WebAssembly build + in a worker, later perhaps the native core as a sidecar process. + + Every call is async, because no engine answers on the main thread. +*/ +import type { + EnumTables, + FileTypeInfo, + HtmlConfig, + OpenOptions, + PdfPermissions, + Recalculation, + Rendered, + View, +} from '@opendocument/odr-core'; + +export type { + EnumTables, + FileTypeInfo, + HtmlConfig, + OpenOptions, + PdfPermissions, + Recalculation, + Rendered, + View, +}; + +/** An error of the engine. `name` is the C++ exception type, such as `WrongPassword`. */ +export class EngineError extends Error { + constructor( + name: string, + message: string, + readonly code?: number, + ) { + super(message); + this.name = name; + } +} + +export interface Backend { + /** Starts the engine. The other calls wait for it. */ + load(): Promise; + /** The version of the engine. */ + version(): string; + enums(): EnumTables; + fileTypes(): FileTypeInfo[]; + /** The types that the bytes can be, the most specific last. */ + detect(bytes: Uint8Array): Promise; + /** Takes the bytes: the caller must not use them after the call. */ + open(bytes: Uint8Array, options: OpenOptions): Promise; + create(fileType: number, config: HtmlConfig): Promise; +} + +/** A document that the engine holds open. */ +export interface OpenDocument { + readonly fileType: number; + isPasswordEncrypted(): Promise; + /** Throws `WrongPassword` for a wrong password. */ + decrypt(password: string): Promise; + /** What the document is now: after a decrypt, the type inside. Has + `documentType`, such as `spreadsheet`, for a document file. */ + meta(): Promise>; + isEditable(): Promise; + isSavable(encrypted: boolean): Promise; + isAnnotatable(): Promise; + permissions(): Promise; + listViews(): Promise; + render(index: number): Promise; + /** Applies the operation log of the rendered page, a JSON string. */ + edit(operations: string): Promise; + recalculate(): Promise; + save(password?: string): Promise; + /** The pdf with the marks of the rendered page, a JSON string. */ + annotate(annotations: string): Promise; + close(): Promise; +} diff --git a/src/core/wasm-backend.ts b/src/core/wasm-backend.ts new file mode 100644 index 0000000..91f6498 --- /dev/null +++ b/src/core/wasm-backend.ts @@ -0,0 +1,152 @@ +/* + The `Backend` over the WebAssembly core in a worker. See `wasm-worker.ts`. +*/ +import { + EngineError, + type Backend, + type EnumTables, + type FileTypeInfo, + type HtmlConfig, + type OpenDocument, + type OpenOptions, +} from './backend'; + +export class WasmBackend implements Backend { + private worker: Worker | null = null; + private calls = new Map void; reject: (e: Error) => void }>(); + private nextCall = 1; + private loading: Promise | null = null; + private info: { version: string; enums: EnumTables; fileTypes: FileTypeInfo[] } | null = null; + + private start() { + const worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' }); + worker.onmessage = (event: MessageEvent) => { + const { id, ok, result, error } = event.data; + const call = this.calls.get(id); + this.calls.delete(id); + if (!call) return; + if (ok) call.resolve(result); + else call.reject(new EngineError(error.name, error.message, error.code)); + }; + worker.onerror = (event) => { + // A worker that fails to start answers nothing. Fail every open call. + const error = new EngineError('WorkerError', event.message || 'The engine stopped.'); + for (const call of this.calls.values()) call.reject(error); + this.calls.clear(); + }; + return worker; + } + + call(message: Record, transfer: Transferable[] = []): Promise { + this.worker ??= this.start(); + const id = this.nextCall++; + return new Promise((resolve, reject) => { + this.calls.set(id, { resolve, reject }); + this.worker!.postMessage({ ...message, id }, transfer); + }); + } + + load(): Promise { + this.loading ??= this.call({ op: 'load' }).then( + (info) => { + this.info = info; + }, + (e) => { + // A failed start can be tried again. + this.loading = null; + throw e; + }, + ); + return this.loading; + } + + private loaded() { + if (!this.info) throw new Error('The engine is not loaded.'); + return this.info; + } + + version() { + return this.loaded().version; + } + + enums() { + return this.loaded().enums; + } + + fileTypes() { + return this.loaded().fileTypes; + } + + detect(bytes: Uint8Array) { + // A copy, because the caller keeps its bytes. + return this.call({ op: 'detect', bytes: bytes.slice() }); + } + + async open(bytes: Uint8Array, options: OpenOptions): Promise { + const { handle, fileType } = await this.call<{ handle: number; fileType: number }>( + { op: 'open', bytes, options }, + [bytes.buffer as ArrayBuffer], + ); + return new WasmDocument(this, handle, fileType); + } + + async create(fileType: number, config: HtmlConfig): Promise { + const created = await this.call<{ handle: number; fileType: number }>({ op: 'create', fileType, config }); + return new WasmDocument(this, created.handle, created.fileType); + } +} + +class WasmDocument implements OpenDocument { + constructor( + private backend: WasmBackend, + private handle: number, + readonly fileType: number, + ) {} + + private call(method: string, ...args: unknown[]) { + return this.backend.call({ op: 'call', handle: this.handle, method, args }); + } + + isPasswordEncrypted() { + return this.call('isPasswordEncrypted'); + } + decrypt(password: string) { + return this.call('decrypt', password); + } + meta() { + return this.call>('meta'); + } + isEditable() { + return this.call('isEditable'); + } + isSavable(encrypted: boolean) { + return this.call('isSavable', encrypted); + } + isAnnotatable() { + return this.call('isAnnotatable'); + } + permissions() { + return this.call('permissions'); + } + listViews() { + return this.call('listViews'); + } + render(index: number) { + return this.call('render', index); + } + edit(operations: string) { + return this.call('edit', operations); + } + recalculate() { + return this.call('recalculate'); + } + save(password?: string) { + return password === undefined ? this.call('save') : this.call('save', password); + } + annotate(annotations: string) { + return this.call('annotate', annotations); + } + close() { + return this.backend.call({ op: 'close', handle: this.handle }); + } +} diff --git a/src/core/wasm-worker.ts b/src/core/wasm-worker.ts new file mode 100644 index 0000000..fe39d07 --- /dev/null +++ b/src/core/wasm-worker.ts @@ -0,0 +1,88 @@ +/* + Runs the WebAssembly core off the main thread. A large pdf takes seconds to + render, and the window must stay responsive meanwhile. + + The worker holds the open documents by a number. The main thread sends + `{id, op, ...}` and gets `{id, ok, result}` or `{id, ok: false, error}`. +*/ +/// + +// Imported by URL, from the copy that `scripts/sync-odr.mjs` makes. The glue +// finds `odr-core.wasm` beside itself. +const ODR_ENTRY = `/odr/${__ODR_VERSION__}/index.js`; + +/** The calls that a document takes over the worker boundary. */ +const DOCUMENT_CALLS = new Set([ + 'isPasswordEncrypted', + 'decrypt', + 'meta', + 'isEditable', + 'isSavable', + 'isAnnotatable', + 'permissions', + 'listViews', + 'render', + 'edit', + 'recalculate', + 'save', + 'annotate', +]); + +let odr: any = null; +const documents = new Map(); +let nextHandle = 1; + +function keep(document: any) { + const handle = nextHandle++; + documents.set(handle, document); + return { handle, fileType: document.fileType as number }; +} + +async function run(message: any): Promise { + switch (message.op) { + case 'load': { + if (!odr) { + const module = await import(/* @vite-ignore */ new URL(ODR_ENTRY, self.location.origin).href); + odr = await module.Odr.load(); + } + return { version: odr.version(), enums: odr.enums, fileTypes: odr.fileTypes() }; + } + case 'detect': + return odr.detect(message.bytes).fileTypes; + case 'open': + return keep(odr.open(message.bytes, message.options)); + case 'create': + return keep(odr.create(message.fileType, message.config)); + case 'close': { + documents.get(message.handle)?.close(); + documents.delete(message.handle); + return undefined; + } + case 'call': { + const document = documents.get(message.handle); + if (!document) throw Object.assign(new Error('The document is closed.'), { name: 'Closed' }); + if (!DOCUMENT_CALLS.has(message.method)) throw new Error(`No call ${message.method}.`); + const result = document[message.method](...message.args); + // A call that returns the document itself has nothing to send back. + return result === document ? undefined : result; + } + default: + throw new Error(`No operation ${message.op}.`); + } +} + +self.onmessage = async (event: MessageEvent) => { + const { id } = event.data; + try { + const result = await run(event.data); + const transfer: Transferable[] = []; + if (result instanceof Uint8Array) transfer.push(result.buffer as ArrayBuffer); + self.postMessage({ id, ok: true, result }, transfer); + } catch (e: any) { + self.postMessage({ + id, + ok: false, + error: { name: e?.name ?? 'Error', message: e?.message ?? String(e), code: e?.code }, + }); + } +}; diff --git a/src/main.ts b/src/main.ts index 8702886..4e8be97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,2 +1,24 @@ -const app = document.getElementById('app'); -if (app) app.textContent = `OpenDocument Reader, core ${__ODR_VERSION__}`; +import './styles.css'; + +import { WasmBackend } from './core/wasm-backend'; +import * as platform from './platform'; +import { mountViewer } from './viewer/viewer'; + +const root = document.getElementById('app'); +if (!root) throw new Error('no #app'); + +const viewer = mountViewer({ root, backend: new WasmBackend() }); + +const smoke = await platform.smokePath(); +if (smoke) { + const errors: string[] = []; + window.addEventListener('error', (e) => errors.push(String(e.message))); + window.addEventListener('unhandledrejection', (e) => errors.push(String(e.reason?.message ?? e.reason))); + try { + await platform.smokeReport(true, await viewer.smoke(smoke.path, smoke.save_to, smoke.password)); + } catch (e: any) { + await platform.smokeReport(false, [String(e?.message ?? e), ...errors].join(' | ')); + } +} else { + await platform.onOpenPaths((paths) => void viewer.openPaths(paths)); +} diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..a67f852 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,172 @@ +/* + Everything that the viewer needs from the operating system, over Tauri. + The viewer does not import Tauri itself. +*/ +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { getCurrentWebview } from '@tauri-apps/api/webview'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { ask, open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; +import { openUrl } from '@tauri-apps/plugin-opener'; + +export async function readFile(path: string): Promise { + const buffer = await invoke('read_file', { path }); + return new Uint8Array(buffer); +} + +export async function writeFile(path: string, bytes: Uint8Array): Promise { + // A raw body, so the bytes do not go through JSON. The path goes in a + // header, which takes only ASCII. + await invoke('write_file', bytes, { headers: { 'x-path': encodeURIComponent(path) } }); +} + +export function baseName(path: string) { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) || path; +} + +export function dirName(path: string) { + const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return cut > 0 ? path.slice(0, cut) : ''; +} + +export async function pickFile(extensions: string[]): Promise { + const picked = await openDialog({ + multiple: false, + directory: false, + filters: [ + { name: 'Documents', extensions }, + { name: 'All files', extensions: ['*'] }, + ], + }); + return typeof picked === 'string' ? picked : null; +} + +/** A path to save to. The extension stays the one of the format. */ +export async function pickSaveTarget(suggested: string, extension: string): Promise { + const picked = await saveDialog({ + defaultPath: suggested, + filters: [{ name: extension.toUpperCase(), extensions: [extension] }], + }); + if (!picked) return null; + return /\.[^./\\]+$/.test(baseName(picked)) ? picked : `${picked}.${extension}`; +} + +export function confirmDiscard(name: string): Promise { + return ask(`${name} has changes that are not saved. If you continue, the changes are lost.`, { + title: 'Unsaved changes', + kind: 'warning', + okLabel: 'Discard changes', + cancelLabel: 'Cancel', + }); +} + +export function setTitle(title: string) { + return getCurrentWindow().setTitle(title); +} + +/** Asks before the window closes. The handler returns false to keep it open. */ +export function onCloseRequested(allow: () => Promise) { + return getCurrentWindow().onCloseRequested(async (event) => { + if (!(await allow())) event.preventDefault(); + }); +} + +export function closeWindow() { + return getCurrentWindow().close(); +} + +/** The files that the operating system gave this window, now and later. */ +export async function onOpenPaths(handler: (paths: string[]) => void) { + await listen('open-paths', (event) => handler(event.payload)); + const launch = await invoke('take_launch'); + if (launch.length) handler(launch); +} + +export function openWindows(paths: string[]) { + return invoke('open_window', { paths }); +} + +export function onMenu(handler: (id: string) => void) { + return listen('menu', (event) => handler(event.payload)); +} + +export function setMenuCheck(id: string, checked: boolean) { + return invoke('set_menu_check', { id, checked }); +} + +/** Files that are dragged over the window, by path. */ +export function onDragDrop(handlers: { + enter: () => void; + leave: () => void; + drop: (paths: string[]) => void; +}) { + return getCurrentWebview().onDragDropEvent((event) => { + const p = event.payload; + if (p.type === 'enter') handlers.enter(); + else if (p.type === 'leave') handlers.leave(); + else if (p.type === 'drop') { + handlers.leave(); + if (p.paths.length) handlers.drop(p.paths); + } + }); +} + +export function startPrint(title: string, html: string) { + return invoke('start_print', { title, html }); +} + +const LINK_SCHEMES = /^(https?|mailto):/i; + +/** Opens a link of a document in the browser of the system. */ +export function openLink(href: string) { + if (LINK_SCHEMES.test(href)) void openUrl(href); +} + +export function smokePath() { + return invoke<{ path: string; save_to: string | null; password: string | null } | null>('smoke_path'); +} + +export function smokeReport(ok: boolean, detail: string) { + return invoke('smoke_report', { ok, detail }); +} + +export interface Recent { + path: string; + name: string; + openedAt: number; +} + +const RECENTS_KEY = 'recents'; +const RECENTS_MAX = 20; + +export function recents(): Recent[] { + try { + const list = JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); + return Array.isArray(list) ? list.filter((r) => typeof r?.path === 'string') : []; + } catch { + return []; + } +} + +export function rememberRecent(path: string) { + const list = recents().filter((r) => r.path !== path); + list.unshift({ path, name: baseName(path), openedAt: Date.now() }); + localStorage.setItem(RECENTS_KEY, JSON.stringify(list.slice(0, RECENTS_MAX))); +} + +export function forgetRecent(path: string) { + localStorage.setItem(RECENTS_KEY, JSON.stringify(recents().filter((r) => r.path !== path))); +} + +export function clearRecents() { + localStorage.removeItem(RECENTS_KEY); +} + +export function setting(key: string, fallback: boolean): boolean { + const value = localStorage.getItem(`setting.${key}`); + return value === null ? fallback : value === 'true'; +} + +export function setSetting(key: string, value: boolean) { + localStorage.setItem(`setting.${key}`, String(value)); +} diff --git a/src/print.ts b/src/print.ts new file mode 100644 index 0000000..d4035d2 --- /dev/null +++ b/src/print.ts @@ -0,0 +1,27 @@ +/* + The print window. It takes the markup that the document window gave the + shell, makes it this page, and opens the print dialog. On macOS, Tauri + replaces `window.print` with the native print of the web view. + + The markup has no scripts: the document window removed them. +*/ +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWindow } from '@tauri-apps/api/window'; + +interface PrintJob { + title: string; + html: string; +} + +const job = await invoke('take_print'); +if (job) { + document.open(); + document.write(job.html); + document.close(); + document.title = job.title; + await getCurrentWindow().setTitle(`Print — ${job.title}`); + // Images and fonts are inline, but they decode after the parse. + await document.fonts.ready; + await new Promise((resolve) => setTimeout(resolve, 300)); + window.print(); +} diff --git a/src/styles.css b/src/styles.css index d4b5078..45c75c1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1 +1,122 @@ @import 'tailwindcss'; + +/* + The palette of the apps, as on the website: the Material 3 roles from + OpenDocument.droid `res/values/colors.xml` and its `values-night` file. +*/ +:root { + --md-primary: #00658f; + --md-on-primary: #ffffff; + --md-primary-container: #c7e7ff; + --md-on-primary-container: #001e2f; + + --md-secondary: #4f616e; + --md-secondary-container: #d2e5f5; + --md-on-secondary-container: #0b1d29; + + --md-surface: #fcfcff; + --md-on-surface: #191c1e; + --md-surface-variant: #dde3ea; + --md-on-surface-variant: #41484d; + --md-surface-container: #eceff3; + + --md-outline: #71787e; + --md-outline-variant: #c1c7ce; + + /* The three stacked pages of the launcher icon, sampled from it. They stand + for the three document kinds throughout the page. */ + --brand-text: #4bb3e8; + --brand-sheet: #62bc77; + --brand-slide: #fca110; + + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --md-primary: #86cfff; + --md-on-primary: #00344c; + --md-primary-container: #004c6d; + --md-on-primary-container: #c7e7ff; + + --md-secondary: #b6c9d8; + --md-secondary-container: #384955; + --md-on-secondary-container: #d2e5f5; + + --md-surface: #191c1e; + --md-on-surface: #e2e2e5; + --md-surface-variant: #41484d; + --md-on-surface-variant: #c1c7ce; + --md-surface-container: #1d2022; + + --md-outline: #8b9198; + --md-outline-variant: #41484d; + } +} + +/* `inline` so the utilities emit `var(--md-*)` rather than the value frozen at + build time — that is what lets the media query above flip the whole page. */ +@theme inline { + --color-primary: var(--md-primary); + --color-on-primary: var(--md-on-primary); + --color-primary-container: var(--md-primary-container); + --color-on-primary-container: var(--md-on-primary-container); + + --color-secondary: var(--md-secondary); + --color-secondary-container: var(--md-secondary-container); + --color-on-secondary-container: var(--md-on-secondary-container); + + --color-surface: var(--md-surface); + --color-on-surface: var(--md-on-surface); + --color-surface-variant: var(--md-surface-variant); + --color-on-surface-variant: var(--md-on-surface-variant); + --color-surface-container: var(--md-surface-container); + + --color-outline: var(--md-outline); + --color-outline-variant: var(--md-outline-variant); + + --color-brand-text: var(--brand-text); + --color-brand-sheet: var(--brand-sheet); + --color-brand-slide: var(--brand-slide); + + /* No webfont. The app makes no network request. */ + --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + monospace; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +body { + background: var(--md-surface); + color: var(--md-on-surface); +} + +:where(a, button, summary, [tabindex]):focus-visible { + outline: 2px solid var(--md-primary); + outline-offset: 2px; + border-radius: 0.25rem; +} + +/* The chrome is an application, not a page: its labels are not text to select. */ +#app { + user-select: none; + -webkit-user-select: none; +} +#app input, +#app [data-viewer-recent-path] { + user-select: text; + -webkit-user-select: text; +} diff --git a/src/viewer/frame-bridge.js b/src/viewer/frame-bridge.js new file mode 100644 index 0000000..28b8a42 --- /dev/null +++ b/src/viewer/frame-bridge.js @@ -0,0 +1,243 @@ +// @ts-check +/* + Taken from the website (`src/scripts/frame-bridge.js`). Keep the two in + step: a fix in one is usually a fix in the other. + + Runs inside the rendered document, appended by the viewer after the + renderer's own scripts, and is the only thing in the frame that knows there + is a page around it. The frame is sandboxed with `allow-scripts` and without + `allow-same-origin`, so it has an opaque origin: the page cannot reach into + it, and nothing in it - a `javascript:` link in a dropped document included - + can reach the page. What crosses is `postMessage`, in both directions, and + this script is one end of it. `viewer.ts` is the other. + + Plain javascript rather than typescript because it is inlined verbatim: the + viewer imports it as a string and writes it into the markup before mounting. + Nothing here may spell a closing script tag, which would end the inline + block early. + + The page's own callbacks do not pass through here. The viewer renders with + `hostMessageHandler: 'odrHost.post'`, and the renderer's `host-bridge.js` + sends every `odr.on*` callback to that function as one JSON string. The + viewer writes `odrHost` into the head, so it exists before the renderer's + scripts run, and no callback is lost before this script runs. +*/ +(function () { + 'use strict'; + + /** @type {any} */ + var odr = /** @type {any} */ (window).odr || {}; + var editing = odr.editing || null; + var annotation = odr.annotation || null; + var root = document.documentElement; + + /* The row and column edits of a sheet, in the names `odr.editing` takes. */ + var STRUCTURE = ['insertRows', 'deleteRows', 'insertColumns', 'deleteColumns']; + + /** @param {Record} message */ + function post(message) { + message.odrViewer = true; + // `*`: the frame has no origin to name, and nothing here is a secret. + window.parent.postMessage(message, '*'); + } + + /* + A `srcdoc` document resolves urls against the page that holds it, so a + pdf's `#p2` points at the page's own url, and a click would navigate the + frame onto the homepage. This script scrolls a fragment in the frame. The + renderer removes the `href` of a relative link, and it gives an external + link `target="_blank"`, which the sandbox keeps inert because it has no + `allow-popups`. + */ + var anchors = document.querySelectorAll('a[href^="#"]'); + for (var i = 0; i < anchors.length; ++i) { + var a = /** @type {HTMLAnchorElement} */ (anchors[i]); + var href = a.getAttribute('href') || ''; + a.removeAttribute('href'); + a.style.cursor = 'pointer'; + a.addEventListener('click', onFragment(href.slice(1))); + } + + /* + The sandbox has no `allow-popups`, so an external link does nothing by + itself. The app opens it in the browser of the system. The app checks the + scheme again. + */ + document.addEventListener('click', function (event) { + var target = /** @type {Element | null} */ (event.target); + var link = target && target.closest ? target.closest('a[href]') : null; + if (!link) return; + var url = link.getAttribute('href') || ''; + if (!/^(https?|mailto):/i.test(url)) return; + event.preventDefault(); + post({ type: 'link', href: url }); + }); + + /** @param {string} id */ + function onFragment(id) { + return function (/** @type {Event} */ event) { + event.preventDefault(); + var target = document.getElementById(id); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + } + + /* + Escape leaves whatever mode the pen turned on, unless something in the + frame - a sheet's cell editor, say - took the key for itself first. + */ + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && !event.defaultPrevented) post({ type: 'escape' }); + }); + + /* + Ctrl+F and Cmd+F open the search strip of the viewer, not the find of the + browser. The page lays out only the parts near the viewport, so the find + of the browser does not reach the rest, but `odr.search` does. + */ + document.addEventListener('keydown', function (event) { + if (typeof odr.search !== 'function') return; + if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'f') { + event.preventDefault(); + post({ type: 'find' }); + } + }); + + /* A step goes to the next or the previous hit of the same text, and searches + first if the text changed. The count comes back as `searchChange`. */ + /** @param {string} text @param {number} step */ + function search(text, step) { + if (typeof odr.search !== 'function') return; + if (!text) odr.resetSearch(); + else if (step > 0) odr.searchNext(text); + else if (step < 0) odr.searchPrevious(text); + else odr.search(text); + } + + /* The zoom is the renderer's where its viewport script is present, and a + bare `body{zoom}` where a view carries none. Then this script reports the + zoom in the form `host-bridge.js` uses. */ + /** @param {number} value */ + function setZoom(value) { + if (typeof odr.setZoom === 'function') { + odr.setZoom(value); + return; + } + document.body.style.zoom = String(value); + root.style.setProperty('--odr-zoom', String(value)); + var host = /** @type {any} */ (window).odrHost; + if (host) host.post(JSON.stringify({ type: 'zoomChange', detail: { zoom: value, followsFit: false } })); + } + function resetZoom() { + if (typeof odr.resetZoom === 'function') { + odr.resetZoom(); + return; + } + setZoom(1); + } + + /* + A tool button does one of three things, and only the frame can tell which, + because only the frame can see the selection. The annotator's `press` + decides: with text selected, the tool marks that selection once, and then + no tool stays armed. Without a selection, a press arms the tool, and a + second press disarms it. An armed tool marks every selection as it is + made. The pen sends `null`, so it disarms outright. + + A new colour for a tool goes to `recolor`, which acts as the document + editor's highlight colour does: it marks a selection once, and it + recolours the tool if the tool is armed. Otherwise the page only keeps the + colour. + */ + /** @param {number[] | null} rgb */ + function setTool( + /** @type {string | null} */ tool, + rgb, + /** @type {number} */ width, + /** @type {boolean} */ recolor + ) { + if (!annotation) return; + /** @type {{color?: number[], width?: number}} */ + var style = {}; + if (rgb) style.color = rgb; + if (width) style.width = width; + if (recolor) annotation.recolor(tool, style); + else if (tool) annotation.press(tool, style); + else annotation.setTool(null); + post({ type: 'tool', armed: annotation.getTool() }); + } + + window.addEventListener('message', function (event) { + if (event.source !== window.parent) return; + var m = event.data; + if (!m || m.odrViewer !== true) return; + switch (m.type) { + case 'setZoom': + setZoom(m.value); + break; + case 'resetZoom': + resetZoom(); + break; + case 'edit': + if (editing) m.on ? editing.enable() : editing.disable(); + // A sheet computes its formula cells in the page. The module loads + // now, so that the first edit does not wait for it. + if (editing && m.on && typeof editing.loadFormulas === 'function') editing.loadFormulas(); + break; + case 'search': + search(String(m.text || ''), Number(m.step) || 0); + break; + case 'undo': + if (annotation) { + annotation.undo(); + } else if (editing) { + editing.undo(); + } + break; + case 'redo': + if (editing) editing.redo(); + break; + case 'format': + if (editing) editing.format(m.style); + break; + case 'toggle': + if (editing) editing.toggle(m.property); + break; + case 'structure': + // Only the four row and column calls. The page refuses a call that + // the open document does not have, so a text document says why. + if (editing && STRUCTURE.indexOf(m.call) >= 0) editing[m.call](m.where || undefined); + break; + case 'getOperations': + post({ type: 'operations', id: m.id, payload: editing ? editing.getOperations() : null }); + break; + case 'committed': + if (editing) editing.committed(); + break; + case 'tool': + setTool(m.tool || null, m.color, m.width, m.recolor === true); + break; + case 'getAnnotations': + post({ + type: 'annotations', + id: m.id, + payload: annotation ? annotation.getAnnotations() : null, + }); + break; + } + }); + + post({ + type: 'ready', + editable: editing ? editing.isEditable() === true : false, + sheet: !!odr.sheet, + // A csv states no style, so its cells take no format. + sheetStyles: document.body.getAttribute('data-odr-sheet-styles') !== 'false', + annotatable: !!annotation, + searchable: typeof odr.search === 'function', + // The canvas the renderer painted on the body, for the frame to wear on + // the overscroll; see the viewer. + background: getComputedStyle(document.body).backgroundColor, + zoom: typeof odr.getZoom === 'function' ? odr.getZoom() : 1, + }); +})(); diff --git a/src/viewer/icons.ts b/src/viewer/icons.ts new file mode 100644 index 0000000..3dd5db4 --- /dev/null +++ b/src/viewer/icons.ts @@ -0,0 +1,50 @@ +/* Stroke icons, from the website (`src/components/Icon.astro`). */ +export const ICONS = { + lock: 'M5 11V8a7 7 0 0 1 14 0v3M5 11h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2Z', + search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM21 21l-4.35-4.35', + pencil: 'M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z', + save: 'M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2ZM17 21v-8H7v8M7 3v5h8', + printer: 'M6 9V2h12v7M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 14h12v8H6v-8Z', + upload: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12', + close: 'M18 6 6 18M6 6l12 12', + chevron: 'm6 9 6 6 6-6', + undo: 'M3 7v6h6M21 17a9 9 0 0 0-15-6.7L3 13', + redo: 'M21 7v6h-6M3 17a9 9 0 0 1 15-6.7L21 13', + highlighter: 'm9 11-6 6v3h9l3-3M22 12l-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4l8 8Z', + underline: 'M7 3v6a5 5 0 0 0 10 0V3M4 20h16', + strike: 'M16 4H9a3 3 0 0 0-2.83 4M14 12a4 4 0 0 1 0 8H6M4 12h16', + squiggle: 'M7 3v6a5 5 0 0 0 10 0V3M3 20c1.5-1.5 3-1.5 4.5 0s3 1.5 4.5 0 3-1.5 4.5 0 3 1.5 4.5 0', + scribble: 'M3 17c2-4 4.5-9 6.5-8.5S8 16 10.5 16.5 15 10 17 10.5 15 17 21 15', + alignLeft: 'M21 6H3M15 12H3M17 18H3', + alignCenter: 'M21 6H3M17 12H7M19 18H5', + alignRight: 'M21 6H3M21 12H9M21 18H7', + alignJustify: 'M3 6h18M3 12h18M3 18h18', + fill: 'm19 11-8-8-8.6 8.6a2 2 0 0 0 0 2.8l5.2 5.2c.8.8 2 .8 2.8 0L19 11ZM5 2l5 5M2 13h15M22 20a2 2 0 1 1-4 0c0-1.6 1.7-2.4 2-4 .3 1.6 2 2.4 2 4Z', + rowAbove: 'M3 14h18v6H3zM12 3v7M8.5 6.5h7', + rowBelow: 'M3 4h18v6H3zM12 14v7M8.5 17.5h7', + rowDelete: 'M3 8h18v8H3zM10 10l4 4M14 10l-4 4', + columnLeft: 'M14 3h6v18h-6zM3 12h7M6.5 8.5v7', + columnRight: 'M4 3h6v18H4zM14 12h7M17.5 8.5v7', + columnDelete: 'M8 3h8v18H8zM10 10l4 4M14 10l-4 4', + folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z', + file: 'M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5ZM14 3v5h5', +} as const; + +export type IconName = keyof typeof ICONS; + +export function icon(name: IconName, className = 'h-4 w-4') { + return ``; +} + +/* The launcher icon as a vector, from the website (`src/components/Logo.astro`). */ +export function logo(className = 'h-9 w-9') { + return ``; +} diff --git a/src/viewer/markup.ts b/src/viewer/markup.ts new file mode 100644 index 0000000..a9a7614 --- /dev/null +++ b/src/viewer/markup.ts @@ -0,0 +1,245 @@ +/* + The markup of the window, ported from the viewer of the website + (`src/components/viewer/Chrome.astro`, `Panel.astro` and `pages/tryit.astro`). + The viewer finds every control by its `data-viewer-*` attribute. + + The desktop has no samples. The landing panel shows the recent documents + instead, and a document can have one tab for each sheet. +*/ +import { icon, logo, type IconName } from './icons'; + +const swatch = + 'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-surface'; +const chevron = + 'relative -ml-0.5 flex h-7 w-4 shrink-0 items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-surface'; +const round = + 'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-outline transition-colors hover:bg-surface disabled:opacity-40 disabled:hover:bg-transparent'; +const tool = + 'flex h-7 min-w-7 shrink-0 items-center justify-center rounded-full border border-transparent px-1.5 text-sm leading-none transition-colors hover:bg-surface aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-on-primary disabled:opacity-40'; + +/* Point sizes that documents often use. */ +const sizes = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48]; + +const aligns: { align: string; label: string; icon: IconName }[] = [ + { align: 'left', label: 'Align left', icon: 'alignLeft' }, + { align: 'center', label: 'Centre', icon: 'alignCenter' }, + { align: 'right', label: 'Align right', icon: 'alignRight' }, + { align: 'justify', label: 'Justify', icon: 'alignJustify' }, +]; + +const structure: { call: string; where: string; label: string; icon: IconName }[] = [ + { call: 'insertRows', where: 'above', label: 'Insert rows above', icon: 'rowAbove' }, + { call: 'insertRows', where: 'below', label: 'Insert rows below', icon: 'rowBelow' }, + { call: 'deleteRows', where: '', label: 'Delete rows', icon: 'rowDelete' }, + { call: 'insertColumns', where: 'left', label: 'Insert columns to the left', icon: 'columnLeft' }, + { call: 'insertColumns', where: 'right', label: 'Insert columns to the right', icon: 'columnRight' }, + { call: 'deleteColumns', where: '', label: 'Delete columns', icon: 'columnDelete' }, +]; + +/* The marks of a pdf, in the names of the annotator, with their first colour. */ +const marks: { tool: string; label: string; icon: IconName; color: string }[] = [ + { tool: 'highlight', label: 'Highlight', icon: 'highlighter', color: '#ffe633' }, + { tool: 'underline', label: 'Underline', icon: 'underline', color: '#e53935' }, + { tool: 'strikeOut', label: 'Strike out', icon: 'strike', color: '#e53935' }, + { tool: 'squiggly', label: 'Squiggly underline', icon: 'squiggle', color: '#e53935' }, + { tool: 'ink', label: 'Draw', icon: 'scribble', color: '#1e88e5' }, +]; + +/* The types that a new, empty document can have. */ +export const BLANKS: { ext: string; tone: 'text' | 'sheet' }[] = [ + { ext: 'odt', tone: 'text' }, + { ext: 'ods', tone: 'sheet' }, + { ext: 'docx', tone: 'text' }, + { ext: 'xlsx', tone: 'sheet' }, +]; + +const tones = { text: 'text-brand-text', sheet: 'text-brand-sheet' } as const; + +function bar() { + return ` +
+ +
+ + + + + + + +`; +} + +function panel() { + return ` +
+
+
${logo('h-14 w-14')}
+ + + + +

Open a document

+

Or drop it on this window. ODT, ODS, ODP, DOCX, XLSX, PPTX, PDF and more.

+ + + +
+ + +
+

Or start an empty one:

+ ${BLANKS.map( + (b) => + ``, + ).join('')} +
+ + +
+
+
`; +} + +export function windowMarkup() { + return ` +${bar()} +
+ ${panel()} + +
`; +} + +/* The viewer fills the name and the folder as text, never as markup. */ +export function recentItem() { + return ` +
  • + + +
  • `; +} + +export function tabButton() { + return ``; +} diff --git a/src/viewer/viewer.ts b/src/viewer/viewer.ts new file mode 100644 index 0000000..9963bd3 --- /dev/null +++ b/src/viewer/viewer.ts @@ -0,0 +1,1354 @@ +/* + The viewer of one window, ported from the website (`src/scripts/viewer.ts`). + + The document lives in an iframe that runs the scripts of the renderer and + `frame-bridge.js`, and nothing else of ours. The frame is sandboxed without + `allow-same-origin`, so this side never touches its DOM: every command goes + over `postMessage`, and every answer comes back the same way. The engine + holds the open document. An edit goes into it, and a save comes out of it. + + The differences from the website: + - The engine is a `Backend`, and every call to it is async. + - A save writes the file on disk. "Save As" picks a new path. + - A spreadsheet shows one tab for each sheet, as on Android. + - The landing panel lists the recent documents. + - A document that the system opens while this window holds one goes to a new + window. +*/ +import type { Backend, OpenDocument, OpenOptions, View } from '../core/backend'; +import * as platform from '../platform'; +import bridgeSource from './frame-bridge.js?raw'; +import { recentItem, tabButton, windowMarkup } from './markup'; + +/* + The most cells that one sheet writes. The markup of a sheet goes into + `srcdoc` as one string, so a big sheet costs memory and time before + anything paints. A styled cell is about 226 bytes, so this is about 45 MB. + Android caps at 100,000 rows and 500 columns, and that cap applies too. +*/ +const SHEET_CELL_BUDGET = 200_000; +const SHEET_LIMIT = { rows: 100_000, columns: 500 }; + +const ZOOM_MIN = 0.25; +const ZOOM_MAX = 4; +const ZOOM_STEP = 1.25; + +const DROP_ACTIVE = ['border-primary', 'bg-primary-container/30']; + +const IDLE_TITLE = 'Open a document'; +const IDLE_HINT = 'Or drop it on this window. ODT, ODS, ODP, DOCX, XLSX, PPTX, PDF and more.'; + +const APP_NAME = 'OpenDocument Reader'; + +/* A "path" that asks a new window for an empty document of a type, such as + `odr-new:ods`. No file path starts with it. */ +const NEW_PREFIX = 'odr-new:'; + +/* The width of a line that the Draw tool makes. */ +const INK_WIDTH = 2; + +/* + What the window says when the page refuses an edit. The page reports a + reason and an English message for a console. The host chooses the words. +*/ +const REFUSALS: Record = { + newLine: 'A line break inside a paragraph is more than an edit can hand back.', + formula: 'That cell holds a formula that is shared with other cells, so it stays as it is.', + formulaInput: 'This page cannot take a formula; a number or some text it can.', + formulaInvalid: 'That formula does not parse, so the cell keeps what it had.', + array: + 'That cell is part of an array formula, so it stays as it is, and rows and columns cannot be added or removed through it.', + rich: 'That cell holds more than plain text, so it stays as it is.', + shapes: 'That cell holds a drawing, so it stays as it is.', + readOnly: 'This document cannot be edited.', + unsupportedEdit: 'That kind of edit is not supported here.', + range: 'An edit cannot reach over a picture or a table.', + unnameableEdit: 'That edit landed where the engine cannot name it, so it was not taken.', + outOfScope: 'That edit reaches past what this page offers.', + sheetCut: 'That part of the sheet is past what the app shows, so it cannot be edited here.', +}; + +/* `#rrggbb` to the 0..1 rgb triple that the annotator takes. */ +function rgbOf(hex: string): number[] { + const n = parseInt(hex.slice(1), 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +} + +function extensionOf(name: string) { + const dot = name.lastIndexOf('.'); + return dot < 0 ? '' : name.slice(dot + 1).toLowerCase(); +} + +/* + The function that `hostMessageHandler` names. The `host-bridge.js` of the + renderer calls it with every `odr.on*` callback of the page as one JSON + string, and it posts that string to this page unchanged. +*/ +const HOST_HANDLER = 'odrHost.post'; +const HOST_SCRIPT = + ""; + +/* The host function goes first in the head, and the bridge goes after the + scripts of the renderer, at the end of the body. */ +function withBridge(html: string) { + const head = html.indexOf(''); + const at = head < 0 ? 0 : head + ''.length; + html = html.slice(0, at) + HOST_SCRIPT + html.slice(at); + const script = ``; + const end = html.lastIndexOf(''); + return end < 0 ? html + script : html.slice(0, end) + script + html.slice(end); +} + +/* The print window shows the markup as a top-level page. It needs no script: + the print render lays out everything, and the scripts are the editor. */ +function withoutScripts(html: string) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + for (const s of doc.querySelectorAll('script')) s.remove(); + return '\n' + doc.documentElement.outerHTML; +} + +/* A file to open: the bytes and the path they came from. A blank document + has no bytes, and a document that was never saved has no path. */ +interface Source { + bytes: Uint8Array | null; + name: string; + path: string | null; +} + +export interface ViewerOptions { + root: HTMLElement; + backend: Backend; +} + +export function mountViewer({ root, backend }: ViewerOptions) { + root.innerHTML = windowMarkup(); + + const find = (name: string) => { + const el = root.querySelector(`[data-viewer-${name}]`); + if (!el) throw new Error(`viewer: no [data-viewer-${name}]`); + return el; + }; + const all = (name: string) => [...root.querySelectorAll(`[data-viewer-${name}]`)]; + + const panel = find('panel'); + const zone = find('zone'); + const spinner = find('spinner'); + const idleIcon = find('idle-icon'); + const status = find('status'); + const hint = find('hint'); + const actions = find('actions'); + const passwordForm = find('password'); + const passwordInput = find('password-input'); + const passwordCancelBtn = find('password-cancel'); + const uploadIcon = find('icon-upload'); + const lockIcon = find('icon-lock'); + const pickBtn = find('pick'); + const blankBtns = all('blank'); + const recentsBox = find('recents'); + const recentsList = find('recents-list'); + const recentsClearBtn = find('recents-clear'); + const result = find('result'); + const frameHost = find('frame-host'); + const tabsBar = find('tabs'); + const filename = find('filename'); + const resetBtn = find('reset'); + const zoomBar = find('zoom'); + const zoomOutBtn = find('zoom-out'); + const zoomInBtn = find('zoom-in'); + const zoomLabel = find('zoom-level'); + const cutNote = find('cut'); + const staleNote = find('stale'); + const alertNote = find('alert'); + const penBtn = find('pen'); + const penEdit = find('pen-edit'); + const penMark = find('pen-mark'); + const saveBtn = find('save'); + const printBtn = find('print'); + const tools = find('tools'); + const toolsHint = find('tools-hint'); + const formatGroup = find('format'); + const styleBtns = all('style'); + const highlightBtn = find('highlight'); + const colorInput = find('color'); + const colorBar = find('color-bar'); + const highlightColorInput = find('highlight-color'); + const highlightBar = find('highlight-bar'); + const highlightPick = find('highlight-pick'); + const fillBtn = find('fill'); + const fillPick = find('fill-pick'); + const fillColorInput = find('fill-color'); + const fillBar = find('fill-bar'); + const alignBtns = all('align'); + const structureGroup = find('structure'); + const structureBtns = all('structure-call'); + const sizeSelect = find('size'); + const markGroup = find('mark'); + const toolBtns = all('tool'); + const toolColors = all('tool-color'); + const toolBars = all('tool-bar'); + const undoBtn = find('undo'); + const redoBtn = find('redo'); + const findOpenBtn = find('find-open'); + const findStrip = find('find'); + const findText = find('find-text'); + const findCount = find('find-count'); + const findPreviousBtn = find('find-previous'); + const findNextBtn = find('find-next'); + const findCloseBtn = find('find-close'); + const openOnly = all('open'); + + let currentFrame: HTMLIFrameElement | null = null; + let currentDoc: OpenDocument | null = null; + /** The source of the open document. A save replaces the bytes and the path. */ + let source: Source | null = null; + /** True from the start of an open until a document shows or the panel is back. */ + let busy = false; + + /** The views that the window shows, and the one on screen. */ + let views: View[] = []; + let viewIndex = 0; + + let canEdit = false; + let canFormat = false; + let canMark = false; + let plainText = false; + let sheet = false; + let editOnReady = false; + let modeOn = false; + let armedTool: string | null = null; + /** What the log of the page holds, as the page reports it. */ + let editDirty = false; + /** Edits that went into the engine but not into the file: from a sheet that + the tabs left, or before a print. */ + let appliedUnsaved = false; + let canUndo = false; + let canRedo = false; + let marks = 0; + let savedMarks = 0; + let currentZoom = 1; + let canFind = false; + + let passwordAnswer: ((password: string | null) => void) | null = null; + let encrypted = false; + let savePassword: string | null = null; + + const asks = new Map void>(); + let nextAsk = 1; + + const dirty = () => editDirty || appliedUnsaved || marks !== savedMarks; + const hasDocument = () => currentDoc !== null || busy; + + function send(message: Record) { + currentFrame?.contentWindow?.postMessage({ ...message, odrViewer: true }, '*'); + } + + /* A question with an answer: the frame replies with the same id. */ + function ask(type: string): Promise { + return new Promise((resolve, reject) => { + const id = nextAsk++; + const timer = window.setTimeout(() => { + asks.delete(id); + reject(new Error('The document did not answer.')); + }, 5000); + asks.set(id, (payload) => { + window.clearTimeout(timer); + resolve(payload); + }); + send({ type, id }); + }); + } + + function paintTitle() { + if (!source || !currentDoc) { + void platform.setTitle(APP_NAME); + return; + } + void platform.setTitle(dirty() ? `${source.name} — Edited` : source.name); + } + + function showResult(on: boolean) { + panel.hidden = on; + result.hidden = !on; + for (const el of openOnly) el.hidden = !on; + if (!on) paintRecents(); + } + + /** What the panel said, in order. The smoke mode reports it on a failure. */ + const trail: string[] = []; + + function setBusy(message: string) { + trail.push(message); + passwordForm.hidden = true; + spinner.hidden = false; + idleIcon.hidden = true; + actions.hidden = true; + hint.hidden = true; + status.textContent = message; + status.classList.remove('text-red-600', 'dark:text-red-400'); + } + + function setIdle(message: string, detail: string, isError = false) { + trail.push(`${message} (${detail})`); + passwordForm.hidden = true; + uploadIcon.hidden = false; + lockIcon.hidden = true; + spinner.hidden = true; + idleIcon.hidden = !isError; + actions.hidden = false; + hint.hidden = false; + status.textContent = message; + hint.textContent = detail; + status.classList.toggle('text-red-600', isError); + status.classList.toggle('dark:text-red-400', isError); + paintRecents(); + } + + function paintRecents() { + const list = platform.recents(); + recentsBox.hidden = list.length === 0; + recentsList.replaceChildren(); + for (const r of list) { + const holder = document.createElement('template'); + holder.innerHTML = recentItem().trim(); + const item = holder.content.firstElementChild as HTMLElement; + item.querySelector('[data-viewer-recent-name]')!.textContent = r.name; + item.querySelector('[data-viewer-recent-path]')!.textContent = platform.dirName(r.path); + item.title = r.path; + item.querySelector('[data-viewer-recent-open]')!.addEventListener('click', () => void openPath(r.path)); + item.querySelector('[data-viewer-recent-forget]')!.addEventListener('click', () => { + platform.forgetRecent(r.path); + paintRecents(); + }); + recentsList.append(item); + } + } + + /* Lets the busy message paint before the engine starts a long call. */ + const nextFrame = () => + new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; + requestAnimationFrame(() => requestAnimationFrame(finish)); + setTimeout(finish, 50); + }); + + function askPassword(name: string, wrong: boolean): Promise { + spinner.hidden = true; + idleIcon.hidden = false; + actions.hidden = true; + hint.hidden = false; + status.textContent = wrong ? 'That password is not correct' : `${name} is password-protected`; + hint.textContent = wrong + ? `Type the password of ${name} again.` + : 'Type its password. The document is decrypted on this computer.'; + status.classList.toggle('text-red-600', wrong); + status.classList.toggle('dark:text-red-400', wrong); + uploadIcon.hidden = true; + lockIcon.hidden = false; + passwordForm.hidden = false; + passwordInput.value = ''; + passwordInput.focus(); + return new Promise((resolve) => { + passwordAnswer = resolve; + }); + } + + function friendlyError(e: any, name: string): [string, string] { + switch (e?.name) { + case 'UnsupportedFileType': + case 'UnknownFileType': + return [`${name} is not a format we can open`, 'Try an ODT, DOCX, XLSX, PPTX or PDF file.']; + case 'FileEncrypted': + return [`${name} is password-protected`, 'Open it again, and type its password.']; + case 'FileNotFound': + case 'NoDocumentFile': + return [`${name} could not be read`, 'The file looks empty or truncated.']; + default: + return [`${name} could not be opened`, e?.message ? String(e.message) : 'Unknown error.']; + } + } + + function showCut(cut: View['sheetCut']) { + if (!cut) { + cutNote.hidden = true; + return; + } + const n = (value: number) => value.toLocaleString(); + const parts: string[] = []; + if (cut.renderedRows < cut.contentRows) parts.push(`the first ${n(cut.renderedRows)} of ${n(cut.contentRows)} rows`); + if (cut.renderedColumns < cut.contentColumns) + parts.push(`the first ${n(cut.renderedColumns)} of ${n(cut.contentColumns)} columns`); + if (!parts.length) { + cutNote.hidden = true; + return; + } + cutNote.textContent = `This sheet is too large to show completely. The app shows ${parts.join(' and ')}. The rest stays in the file.`; + cutNote.hidden = false; + } + + const ALERT_TONES = { + error: ['bg-red-50', 'text-red-700', 'dark:bg-red-950/40', 'dark:text-red-300'], + warn: ['bg-amber-50', 'text-amber-800', 'dark:bg-amber-950/40', 'dark:text-amber-200'], + }; + let alertTimer: number | undefined; + function showAlert(message: string | null, tone: keyof typeof ALERT_TONES = 'error') { + window.clearTimeout(alertTimer); + alertNote.textContent = message ?? ''; + alertNote.hidden = !message; + for (const t of Object.values(ALERT_TONES)) alertNote.classList.remove(...t); + if (message) alertNote.classList.add(...ALERT_TONES[tone]); + if (message && tone === 'warn') alertTimer = window.setTimeout(() => showAlert(null), 6000); + } + + let staleCount = 0; + function showStale(count: number) { + staleCount = count; + if (!count) { + staleNote.hidden = true; + return; + } + staleNote.textContent = `${count} formula ${count === 1 ? 'cell shows a result' : 'cells show results'} that the app could not compute after your edit. A save computes ${count === 1 ? 'it' : 'them'} again.`; + staleNote.hidden = false; + } + + function showUncomputed(recalc: any) { + const count = (recalc?.unevaluated?.length ?? 0) + (recalc?.circular?.length ?? 0); + if (!count) return; + staleNote.textContent = `${count} formula ${count === 1 ? 'cell' : 'cells'} could not be computed here. A spreadsheet app computes ${count === 1 ? 'it' : 'them'} when it opens the saved file.`; + staleNote.hidden = false; + } + + function paintChrome() { + const changeable = canEdit || canMark; + penBtn.hidden = !changeable; + saveBtn.hidden = !changeable; + findOpenBtn.hidden = !canFind; + penEdit.hidden = canMark; + penMark.hidden = !canMark; + const penLabel = canMark + ? modeOn + ? 'Put the marker down' + : 'Mark up this PDF' + : modeOn + ? 'Stop editing' + : 'Edit this document'; + penBtn.title = penLabel; + penBtn.setAttribute('aria-label', penLabel); + penBtn.setAttribute('aria-pressed', String(modeOn)); + penBtn.classList.toggle('bg-primary', modeOn); + penBtn.classList.toggle('text-on-primary', modeOn); + penBtn.classList.toggle('border-transparent', modeOn); + penBtn.classList.toggle('border-outline', !modeOn); + penBtn.classList.toggle('hover:bg-surface', !modeOn); + + // A document that was never saved has no file yet, so its first save is a + // "Save As". That works also without changes. + saveBtn.disabled = !(dirty() || (source !== null && source.path === null)); + + tools.hidden = !modeOn; + formatGroup.hidden = !canFormat; + highlightBtn.hidden = sheet; + highlightPick.hidden = sheet; + fillBtn.hidden = !sheet; + fillPick.hidden = !sheet; + for (const b of alignBtns) b.hidden = sheet && b.dataset.viewerAlign === 'justify'; + structureGroup.hidden = !(canEdit && sheet); + markGroup.hidden = !canMark; + undoBtn.disabled = canMark ? marks === 0 : !canUndo; + redoBtn.hidden = canMark; + redoBtn.disabled = !canRedo; + for (const b of toolBtns) b.setAttribute('aria-pressed', String(b.dataset.viewerTool === armedTool)); + toolsHint.textContent = canMark + ? 'Select text, then a tool, to mark it once. A pressed tool marks every selection; Draw draws on the page.' + : sheet + ? canFormat + ? 'Type a value or a formula such as =SUM(B2:B4) into a cell. Drag over cells, or click a header, to format them or to add and remove rows and columns.' + : 'Type into a cell; Enter keeps the value, Escape drops it. Click a header to add and remove rows and columns. A csv has no styles.' + : canFormat + ? 'Type into the document. Select some text for the buttons, or use the keyboard shortcuts for bold, italic and underline.' + : 'Type into the file. It is plain text, so it saves as UTF-8 with no styles.'; + paintTitle(); + } + + function paintSelection(style: Record) { + for (const b of styleBtns) b.setAttribute('aria-pressed', String(style[b.dataset.viewerStyle ?? ''] === true)); + highlightBtn.setAttribute('aria-pressed', String(typeof style.highlight === 'string')); + if (typeof style.color === 'string' && /^#[0-9a-f]{6}$/i.test(style.color)) colorInput.value = style.color; + if (typeof style.highlight === 'string' && /^#[0-9a-f]{6}$/i.test(style.highlight)) + highlightColorInput.value = style.highlight; + fillBtn.setAttribute('aria-pressed', String(typeof style.fill === 'string')); + if (typeof style.fill === 'string' && /^#[0-9a-f]{6}$/i.test(style.fill)) fillColorInput.value = style.fill; + for (const b of alignBtns) b.setAttribute('aria-pressed', String(style.align === b.dataset.viewerAlign)); + paintColor(); + paintHighlight(); + paintFill(); + const size = typeof style.size === 'string' ? style.size : ''; + sizeSelect.value = [...sizeSelect.options].some((o) => o.value === size) ? size : ''; + } + + function setMode(on: boolean) { + showAlert(null); + if (canMark) { + modeOn = on; + if (!on) sendTool(null); + paintChrome(); + } else if (canEdit) { + send({ type: 'edit', on }); + } + if (on) currentFrame?.focus(); + } + + function colorOf(tool: string): string { + return toolColors.find((i) => i.dataset.viewerToolColor === tool)?.value ?? '#000000'; + } + + function sendTool(tool: string | null, recolor = false) { + const color = tool ? rgbOf(colorOf(tool)) : null; + send({ type: 'tool', tool, color, width: INK_WIDTH, recolor }); + } + + /* + Puts the edits of the page into the engine. After this, the page and the + engine agree, so the log of the page starts again. The file on disk does + not have the edits yet. + */ + async function applyPageEdits() { + if (!currentDoc || canMark || !editDirty) return; + const payload = await ask('getOperations'); + if (!payload) throw new Error('The edits could not be read back.'); + if (JSON.parse(payload).ops.length) await currentDoc.edit(payload); + appliedUnsaved = true; + send({ type: 'committed' }); + editDirty = false; + } + + /* The bytes of the document with every change of the page. */ + async function currentBytes(): Promise<{ bytes: Uint8Array; recalc: any }> { + if (!currentDoc) throw new Error('No document is open.'); + if (canMark) { + const payload = await ask('getAnnotations'); + if (!payload) throw new Error('The marks could not be read back.'); + return { bytes: await currentDoc.annotate(payload), recalc: null }; + } + await applyPageEdits(); + const recalc = sheet ? await currentDoc.recalculate() : null; + const bytes = encrypted ? await currentDoc.save(savePassword ?? '') : await currentDoc.save(); + return { bytes, recalc }; + } + + /* + Writes the document to its file, or to a new file for "Save As" and for a + document that was never saved. The file keeps the format it was opened + as, so "Save As" offers only that extension. + + A sheet is recalculated before the save. If the page left formula cells + stale, and the recalculation changed any cell, the sheet renders again, + so the page shows what the file holds. + */ + async function saveDocument(as = false) { + if (!currentDoc || !source || !(canEdit || canMark)) return; + showAlert(null); + const extension = extensionOf(source.name); + let target = source.path; + if (as || !target) { + target = await platform.pickSaveTarget(source.path ?? source.name, extension); + if (!target) return; + } + saveBtn.disabled = true; + await nextFrame(); + try { + const { bytes, recalc } = await currentBytes(); + await platform.writeFile(target, bytes); + source = { bytes, name: platform.baseName(target), path: target }; + platform.rememberRecent(target); + filename.textContent = source.name; + appliedUnsaved = false; + savedMarks = marks; + if (!canMark) { + send({ type: 'committed' }); + if (staleCount && recalc && Object.values(recalc).some((cells: any) => cells.length)) await renderView(viewIndex); + showUncomputed(recalc); + } + } catch (e: any) { + const detail = e?.message || e?.name || String(e) || 'Unknown error.'; + showAlert(`${source.name} could not be saved: ${detail} Your changes are still here.`); + } finally { + paintChrome(); + } + } + + /* True if the changes may go: there are none, or the user said so. */ + async function mayDiscard() { + if (!currentDoc || !dirty()) return true; + return platform.confirmDiscard(source?.name ?? 'This document'); + } + + function teardown() { + passwordAnswer?.(null); + passwordAnswer = null; + encrypted = false; + savePassword = null; + for (const [, resolve] of asks) resolve(null); + asks.clear(); + modeOn = false; + canEdit = false; + canFormat = false; + canMark = false; + plainText = false; + sheet = false; + editOnReady = false; + editDirty = false; + appliedUnsaved = false; + canUndo = false; + canRedo = false; + marks = 0; + savedMarks = 0; + armedTool = null; + canFind = false; + views = []; + viewIndex = 0; + showFind(false); + showAlert(null); + showStale(0); + void currentDoc?.close(); + currentDoc = null; + source = null; + frameHost.replaceChildren(); + tabsBar.replaceChildren(); + tabsBar.hidden = true; + currentFrame = null; + zoomBar.hidden = true; + cutNote.hidden = true; + paintChrome(); + } + + /* A new iframe for each render, in a container that is visible already. */ + function mountFrame(html: string) { + const frame = document.createElement('iframe'); + frame.title = 'Rendered document'; + // `allow-scripts` without `allow-same-origin`: the scripts of the renderer + // run, and the document gets an opaque origin. See the website viewer. + frame.setAttribute('sandbox', 'allow-scripts'); + frame.className = 'h-full w-full border-0'; + frameHost.replaceChildren(frame); + currentFrame = frame; + frame.srcdoc = withBridge(html); + } + + /* + Spreadsheets show one tab for each sheet. Every other format shows only + its "document" view, if it has one. This is what Android does + (`CoreLoader.selectViews`). + */ + async function selectViews(doc: OpenDocument): Promise { + const all = await doc.listViews(); + const meta = await doc.meta().catch(() => ({}) as Record); + if (meta.documentType === 'spreadsheet') { + const sheets = all.filter((v) => v.name !== 'document'); + if (sheets.length) return sheets; + } + const whole = all.filter((v) => v.name === 'document'); + return whole.length ? whole : all.slice(0, 1); + } + + function paintTabs() { + tabsBar.hidden = views.length < 2; + tabsBar.replaceChildren(); + if (views.length < 2) return; + views.forEach((view, i) => { + const holder = document.createElement('template'); + holder.innerHTML = tabButton(); + const btn = holder.content.firstElementChild as HTMLButtonElement; + btn.textContent = view.name; + btn.setAttribute('aria-selected', String(i === viewIndex)); + btn.addEventListener('click', () => void switchView(i)); + tabsBar.append(btn); + }); + } + + async function renderView(i: number) { + if (!currentDoc) return; + const html = (await currentDoc.render(views[i].index)).html; + viewIndex = i; + editOnReady = modeOn; + modeOn = false; + editDirty = false; + canUndo = false; + canRedo = false; + showStale(0); + showCut(views[i].sheetCut); + paintTabs(); + paintChrome(); + mountFrame(html); + } + + /* The edits of the sheet on screen go into the engine before another + sheet renders, so no edit is lost. */ + async function switchView(i: number) { + if (i === viewIndex || !currentDoc) return; + try { + await applyPageEdits(); + await renderView(i); + } catch (e: any) { + showAlert(`That sheet could not be shown: ${e?.message ?? e}`); + } + } + + window.addEventListener('message', (event) => { + if (!currentFrame || event.source !== currentFrame.contentWindow) return; + const m = event.data; + if (!m || m.odrViewer !== true) return; + switch (m.type) { + case 'ready': + if (m.background && !/^rgba\(0, 0, 0, 0\)$|^transparent$/.test(m.background)) { + currentFrame.style.background = m.background; + } + canEdit = canEdit && m.editable === true; + sheet = m.sheet === true; + canFormat = canEdit && !plainText && !(sheet && m.sheetStyles === false); + canMark = canMark && m.annotatable === true; + canFind = m.searchable === true; + if (canFind && !findStrip.hidden && findText.value) send({ type: 'search', text: findText.value, step: 0 }); + else if (!canFind) showFind(false); + paintZoom(m.zoom); + zoomBar.hidden = false; + paintChrome(); + if (editOnReady && canEdit) setMode(true); + editOnReady = false; + onReady?.(); + break; + case 'page': + onPage(m.message); + break; + case 'tool': + armedTool = typeof m.armed === 'string' ? m.armed : null; + paintChrome(); + break; + case 'escape': + if (!findStrip.hidden) showFind(false); + else if (modeOn) setMode(false); + break; + case 'find': + if (canFind) showFind(true); + break; + case 'link': + if (typeof m.href === 'string') platform.openLink(m.href); + break; + case 'operations': + case 'annotations': { + const resolve = asks.get(m.id); + asks.delete(m.id); + resolve?.(typeof m.payload === 'string' ? m.payload : null); + break; + } + } + }); + + function onPage(message: unknown) { + if (typeof message !== 'string') return; + let type: unknown; + let detail: any; + try { + ({ type, detail } = JSON.parse(message)); + } catch { + return; + } + switch (type) { + case 'zoomChange': + paintZoom(Number(detail?.zoom) || 1); + break; + case 'editModeChange': + modeOn = detail?.editing === true; + if (detail?.reason) showAlert(REFUSALS[detail.reason] ?? detail.message, 'warn'); + paintChrome(); + break; + case 'editChange': + editDirty = detail?.dirty === true; + canUndo = detail?.canUndo === true; + canRedo = detail?.canRedo === true; + paintChrome(); + break; + case 'editRefused': + showAlert(REFUSALS[detail?.reason] ?? detail?.message ?? 'That edit was not taken.', 'warn'); + break; + case 'cellsStale': + showStale(Array.isArray(detail?.cells) ? detail.cells.length : 0); + break; + case 'selectionChange': + paintSelection(detail ?? {}); + break; + case 'searchChange': + paintFind(detail ?? {}); + break; + case 'annotationChange': + marks = Number(detail?.count) || 0; + paintChrome(); + break; + case 'error': + console.warn(`renderer error ${detail?.code}: ${detail?.message}`); + break; + } + } + + function paintZoom(zoom: number) { + currentZoom = zoom; + zoomLabel.textContent = `${Math.round(zoom * 100)}%`; + zoomOutBtn.disabled = zoom <= ZOOM_MIN; + zoomInBtn.disabled = zoom >= ZOOM_MAX; + } + + function nudgeZoom(factor: number) { + const value = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, currentZoom * factor)); + send({ type: 'setZoom', value }); + } + + let findTimer: number | undefined; + function showFind(on: boolean) { + window.clearTimeout(findTimer); + findStrip.hidden = !on; + findOpenBtn.setAttribute('aria-expanded', String(on)); + if (on) { + findText.focus(); + findText.select(); + return; + } + if (findText.value) send({ type: 'search', text: '', step: 0 }); + findText.value = ''; + paintFind({}); + } + + function paintFind(state: { count?: number; current?: number; complete?: boolean }) { + const count = Number(state.count) || 0; + const more = state.complete === false; + findPreviousBtn.disabled = count === 0; + findNextBtn.disabled = count === 0; + if (!findText.value) findCount.textContent = ''; + else if (count === 0) findCount.textContent = more ? 'Searching…' : 'No hits'; + else findCount.textContent = `${(state.current ?? 0) + 1} of ${count.toLocaleString()}${more ? '…' : ''}`; + } + + function search(step: number) { + window.clearTimeout(findTimer); + send({ type: 'search', text: findText.value, step }); + } + + /* The types that a name asks for, for a format with no signature, such as + markdown. Only types that render. */ + let askable: Map | null = null; + function askedType(name: string): number | undefined { + if (!askable) { + askable = new Map(); + for (const t of backend.fileTypes()) { + if (t.capabilities?.detectByContent || !t.capabilities?.translateHtml) continue; + for (const ext of t.extensions ?? []) askable.set(ext.toLowerCase(), t.fileType); + } + } + return askable.get(extensionOf(name)); + } + + function typeInfo(fileType: number | undefined) { + return fileType === undefined ? undefined : backend.fileTypes().find((t) => t.fileType === fileType); + } + + function blankType(name: string): number | undefined { + const ext = extensionOf(name); + return backend.fileTypes().find((t) => t.capabilities?.create && t.extensions?.includes(ext))?.fileType; + } + + /** Whether documents follow the dark mode of the system. */ + let darken = platform.setting('darken', true); + + /* + Opens a source in this window. `knownPassword` is the password from an + earlier open of the same file, for a render again. + */ + async function open(src: Source, knownPassword: string | null = null) { + teardown(); + busy = true; + setBusy('Starting the engine…'); + + try { + await backend.load(); + } catch (e: any) { + const detail = [e?.name, e?.message].filter(Boolean).join(': '); + setIdle('The engine could not start', detail || 'Unknown error.', true); + return; + } + + setBusy(`Opening ${src.name}…`); + await nextFrame(); + + const enums = backend.enums(); + const asked = src.bytes ? askedType(src.name) : undefined; + const blank = src.bytes ? undefined : blankType(src.name); + let detected: number | undefined; + if (src.bytes && asked === undefined) { + try { + detected = (await backend.detect(src.bytes)).at(-1); + } catch { + detected = undefined; + } + } + const info = typeInfo(src.bytes ? (asked ?? detected) : blank); + const editableFormat = Boolean(info?.capabilities?.edit && info?.capabilities?.save); + const markableFormat = Boolean(info?.capabilities?.annotate); + plainText = info?.category === enums.FileCategory.text; + + const scheme = darken && info?.capabilities?.colorScheme ? enums.HtmlColorScheme.system : enums.HtmlColorScheme.light; + const options: OpenOptions = { + lazyLayout: true, + editable: editableFormat, + editingScope: enums.HtmlEditingScope.document, + spreadsheetCellLimit: SHEET_CELL_BUDGET, + spreadsheetLimit: SHEET_LIMIT, + textDocumentMargin: true, + hostMessageHandler: HOST_HANDLER, + pdfAnnotationMarkOnSelection: true, + pdfEnforcePermissions: true, + colorScheme: scheme, + name: src.name, + }; + if (asked !== undefined) options.fileType = asked; + + let doc: OpenDocument; + try { + if (src.bytes) { + // The engine takes its own copy, and the window keeps these bytes for + // a print and for a render again. + doc = await backend.open(src.bytes.slice(), options); + } else if (blank !== undefined) { + doc = await backend.create(blank, options); + editOnReady = true; + } else { + throw new Error('This engine cannot make that type of document.'); + } + } catch (e) { + const [msg, detail] = friendlyError(e, src.name); + busy = false; + setIdle(msg, detail, true); + return; + } + currentDoc = doc; + source = src; + + let password: string | null = null; + if (await doc.isPasswordEncrypted()) { + encrypted = true; + let wrong = false; + for (;;) { + password = knownPassword ?? (await askPassword(src.name, wrong)); + knownPassword = null; + if (password === null || currentDoc !== doc) { + busy = false; + return; + } + setBusy(`Decrypting ${src.name}…`); + await nextFrame(); + if (currentDoc !== doc) return; + try { + await doc.decrypt(password); + break; + } catch (e: any) { + if (e?.name !== 'WrongPassword') { + const [msg, detail] = friendlyError(e, src.name); + teardown(); + busy = false; + setIdle(msg, detail, true); + return; + } + wrong = true; + } + } + } + savePassword = password; + + const answers = async (question: () => Promise) => { + try { + return (await question()) === true; + } catch { + return false; + } + }; + canEdit = editableFormat && (await answers(async () => (await doc.isEditable()) && (await doc.isSavable(encrypted)))); + canMark = + markableFormat && + (await answers(async () => (await doc.isAnnotatable()) && (await doc.permissions())?.modifyAnnotations !== false)); + + setBusy(`Rendering ${src.name}…`); + await nextFrame(); + + try { + views = await selectViews(doc); + if (!views.length) throw new Error('The document has nothing to show.'); + filename.textContent = src.name; + showResult(true); + await renderView(0); + } catch (e) { + const [msg, detail] = friendlyError(e, src.name); + teardown(); + showResult(false); + busy = false; + setIdle(msg, detail, true); + return; + } + busy = false; + if (src.path) platform.rememberRecent(src.path); + } + + async function openSafely(src: Source, knownPassword: string | null = null) { + try { + await open(src, knownPassword); + } catch (e: any) { + teardown(); + showResult(false); + busy = false; + setIdle(`${src.name} could not be opened`, e?.message ? String(e.message) : 'Something went wrong in the viewer.', true); + } + } + + /* Opens a file here if this window is empty, else in a new window. */ + async function openPath(path: string) { + if (hasDocument()) { + await platform.openWindows([path]); + return; + } + const name = platform.baseName(path); + showResult(false); + setBusy(`Reading ${name}…`); + let bytes: Uint8Array; + try { + bytes = await platform.readFile(path); + } catch (e: any) { + platform.forgetRecent(path); + setIdle(`${name} could not be read`, String(e?.message ?? e), true); + return; + } + await openSafely({ bytes, name, path }); + } + + async function openPaths(paths: string[]) { + if (!paths.length) return; + const [first, ...rest] = paths; + if (rest.length) void platform.openWindows(rest); + if (first.startsWith(NEW_PREFIX)) await newBlank(first.slice(NEW_PREFIX.length)); + else await openPath(first); + } + + async function pickAndOpen() { + const extensions = [...new Set(backend.fileTypes().flatMap((t) => (t.capabilities?.translateHtml ? t.extensions : [])))]; + const path = await platform.pickFile(extensions.length ? extensions : ['*']); + if (path) await openPath(path); + } + + async function newBlank(ext: string) { + if (hasDocument()) { + // A new window makes it, so the open document stays. + await platform.openWindows([`${NEW_PREFIX}${ext}`]); + return; + } + await openSafely({ bytes: null, name: `Untitled.${ext}`, path: null }); + } + + /* + Prints the view on screen. The engine renders it once more, with every + part laid out and with light colours, and a print window shows it as a + top-level page. Tauri prints a top-level page on all three systems, but on + macOS not a frame inside one. + */ + async function print() { + if (!currentDoc || !source) return; + showAlert(null); + let printDoc: OpenDocument | null = null; + try { + const bytes = dirty() || !source.bytes ? (await currentBytes()).bytes : source.bytes.slice(); + const enums = backend.enums(); + printDoc = await backend.open(bytes, { + lazyLayout: false, + editable: false, + textDocumentMargin: true, + spreadsheetCellLimit: SHEET_CELL_BUDGET, + spreadsheetLimit: SHEET_LIMIT, + colorScheme: enums.HtmlColorScheme.light, + name: source.name, + }); + if (encrypted && (await printDoc.isPasswordEncrypted())) await printDoc.decrypt(savePassword ?? ''); + const printViews = await selectViews(printDoc); + const view = printViews.find((v) => v.name === views[viewIndex]?.name) ?? printViews[0]; + const html = (await printDoc.render(view.index)).html; + await platform.startPrint(source.name, withoutScripts(html)); + } catch (e: any) { + showAlert(`${source.name} could not be printed: ${e?.message ?? e}`); + } finally { + void printDoc?.close(); + paintChrome(); + } + } + + /* Renders the open document again, for a new colour scheme. */ + async function reload() { + if (!source || !currentDoc) return; + if (dirty()) { + showAlert('The new setting applies to the next document. Save this one first to see it here.', 'warn'); + return; + } + const again = source; + const password = savePassword; + await openSafely(again.bytes ? { ...again, bytes: again.bytes } : again, password); + } + + async function closeDocument() { + if (!(await mayDiscard())) return; + teardown(); + showResult(false); + setIdle(IDLE_TITLE, IDLE_HINT); + } + + // The landing panel. + pickBtn.addEventListener('click', () => void pickAndOpen()); + for (const btn of blankBtns) btn.addEventListener('click', () => void newBlank(btn.dataset.viewerBlank ?? 'odt')); + recentsClearBtn.addEventListener('click', () => { + platform.clearRecents(); + paintRecents(); + }); + + passwordForm.addEventListener('submit', (e) => { + e.preventDefault(); + const answer = passwordAnswer; + passwordAnswer = null; + const value = passwordInput.value; + passwordInput.value = ''; + answer?.(value); + }); + function cancelPassword() { + teardown(); + busy = false; + setIdle(IDLE_TITLE, IDLE_HINT); + } + passwordCancelBtn.addEventListener('click', cancelPassword); + passwordInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') cancelPassword(); + }); + + resetBtn.addEventListener('click', () => void closeDocument()); + + // Files dragged on the window. Tauri gives the paths, so a save can go back + // to the same file. + function highlight(on: boolean) { + zone.classList.toggle('border-dashed', !on); + for (const c of DROP_ACTIVE) zone.classList.toggle(c, on); + } + void platform.onDragDrop({ + enter: () => highlight(true), + leave: () => highlight(false), + drop: (paths) => void openPaths(paths), + }); + + penBtn.addEventListener('click', () => setMode(!modeOn)); + saveBtn.addEventListener('click', () => void saveDocument()); + printBtn.addEventListener('click', () => void print()); + + /* A button in the strip must not take the focus from the frame, where the + selection is. */ + const keepFocus = (e: Event) => e.preventDefault(); + for (const b of [...styleBtns, highlightBtn, fillBtn, ...alignBtns, ...structureBtns, ...toolBtns, undoBtn, redoBtn]) { + b.addEventListener('mousedown', keepFocus); + } + for (const b of styleBtns) b.addEventListener('click', () => send({ type: 'toggle', property: b.dataset.viewerStyle })); + highlightBtn.addEventListener('click', () => { + const on = highlightBtn.getAttribute('aria-pressed') === 'true'; + send({ type: 'format', style: { highlight: on ? null : highlightColorInput.value } }); + }); + fillBtn.addEventListener('click', () => { + const on = fillBtn.getAttribute('aria-pressed') === 'true'; + send({ type: 'format', style: { fill: on ? null : fillColorInput.value } }); + }); + for (const b of alignBtns) { + b.addEventListener('click', () => { + const off = sheet && b.getAttribute('aria-pressed') === 'true'; + send({ type: 'format', style: { align: off ? null : b.dataset.viewerAlign } }); + }); + } + for (const b of structureBtns) { + b.addEventListener('click', () => + send({ type: 'structure', call: b.dataset.viewerStructureCall, where: b.dataset.viewerStructureWhere }), + ); + } + sizeSelect.addEventListener('change', () => { + if (sizeSelect.value) send({ type: 'format', style: { size: sizeSelect.value } }); + }); + for (const b of toolBtns) b.addEventListener('click', () => sendTool(b.dataset.viewerTool ?? 'highlight')); + + /* A colour control: a picker over a bar in its colour. See the website + viewer for why a second press closes the picker this way. */ + function colourControl(input: HTMLInputElement, bar: HTMLElement | undefined, apply: () => void) { + let isOpen = false; + let applied = input.value; + const paint = () => { + if (bar) bar.style.background = input.value; + }; + paint(); + input.addEventListener('input', paint); + input.addEventListener('change', () => { + if (input.value === applied) return; + applied = input.value; + apply(); + }); + input.addEventListener('click', (e) => { + if (!isOpen) { + isOpen = true; + applied = input.value; + return; + } + e.preventDefault(); + isOpen = false; + input.type = 'text'; + input.type = 'color'; + input.blur(); + input.dispatchEvent(new Event('change')); + }); + input.addEventListener('blur', () => { + isOpen = false; + }); + return paint; + } + + const paintColor = colourControl(colorInput, colorBar, () => send({ type: 'format', style: { color: colorInput.value } })); + const paintHighlight = colourControl(highlightColorInput, highlightBar, () => + send({ type: 'format', style: { highlight: highlightColorInput.value } }), + ); + const paintFill = colourControl(fillColorInput, fillBar, () => + send({ type: 'format', style: { fill: fillColorInput.value } }), + ); + for (const input of toolColors) { + const tool = input.dataset.viewerToolColor ?? ''; + const bar = toolBars.find((b) => b.dataset.viewerToolBar === tool); + colourControl(input, bar, () => sendTool(tool, true)); + } + undoBtn.addEventListener('click', () => send({ type: 'undo' })); + redoBtn.addEventListener('click', () => send({ type: 'redo' })); + + findOpenBtn.addEventListener('click', () => showFind(findStrip.hidden === true)); + findCloseBtn.addEventListener('click', () => showFind(false)); + findPreviousBtn.addEventListener('click', () => search(-1)); + findNextBtn.addEventListener('click', () => search(1)); + findText.addEventListener('input', () => { + window.clearTimeout(findTimer); + findTimer = window.setTimeout(() => search(0), 200); + }); + findText.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + search(e.shiftKey ? -1 : 1); + } else if (e.key === 'Escape') { + e.preventDefault(); + showFind(false); + currentFrame?.focus(); + } + }); + + zoomInBtn.addEventListener('click', () => nudgeZoom(ZOOM_STEP)); + zoomOutBtn.addEventListener('click', () => nudgeZoom(1 / ZOOM_STEP)); + zoomLabel.addEventListener('click', () => send({ type: 'resetZoom' })); + + // The menu bar. + void platform.setMenuCheck('darken', darken); + void platform.onMenu((id) => { + switch (id) { + case 'new-window': + void platform.openWindows([]); + break; + case 'new-odt': + case 'new-ods': + case 'new-docx': + case 'new-xlsx': + void newBlank(id.slice(4)); + break; + case 'open': + void pickAndOpen(); + break; + case 'save': + void saveDocument(false); + break; + case 'save-as': + void saveDocument(true); + break; + case 'print': + void print(); + break; + case 'close-document': + void closeDocument(); + break; + case 'find': + if (canFind) showFind(true); + break; + case 'find-next': + if (canFind && findText.value) search(1); + break; + case 'find-previous': + if (canFind && findText.value) search(-1); + break; + case 'edit-mode': + if (canEdit || canMark) setMode(!modeOn); + break; + case 'zoom-in': + nudgeZoom(ZOOM_STEP); + break; + case 'zoom-out': + nudgeZoom(1 / ZOOM_STEP); + break; + case 'zoom-fit': + send({ type: 'resetZoom' }); + break; + case 'darken': + // The check item toggles itself. The window keeps the setting. + darken = !darken; + platform.setSetting('darken', darken); + void platform.setMenuCheck('darken', darken); + void reload(); + break; + } + }); + + void platform.onCloseRequested(mayDiscard); + + showResult(false); + setIdle(IDLE_TITLE, IDLE_HINT); + paintTitle(); + + /* Called once each time a frame reports that it is ready. */ + let onReady: (() => void) | null = null; + + return { + openPaths, + hasDocument, + /** Opens the file, waits for the frame, and gives what the window shows. */ + async smoke(path: string, saveTo: string | null, password: string | null): Promise { + const ready = new Promise((resolve) => { + onReady = resolve; + }); + await openSafely({ bytes: await platform.readFile(path), name: platform.baseName(path), path }, password); + if (!currentDoc) throw new Error(trail.join(' > ')); + await Promise.race([ + ready, + new Promise((_, reject) => setTimeout(() => reject(new Error('the frame did not report')), 30000)), + ]); + onReady = null; + const names = views.map((v) => v.name).join('|'); + let saved = ''; + // Only a document that the window would save. A password-protected + // file cannot be saved with its password, so the window offers no save. + if (saveTo && (canEdit || canMark)) { + const { bytes } = await currentBytes(); + await platform.writeFile(saveTo, bytes); + const back = await platform.readFile(saveTo); + if (back.length !== bytes.length) throw new Error(`wrote ${bytes.length} bytes, read ${back.length}`); + saved = ` saved=${bytes.length}B`; + } + return `${source?.name}: views=[${names}] editable=${canEdit} markable=${canMark} searchable=${canFind} engine=${backend.version() || __ODR_VERSION__}${saved}`; + }, + }; +} diff --git a/test/documents/encrypted-password.xlsx b/test/documents/encrypted-password.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..88410be14a17dd14bb6288d44123d47f3d8cbfdc GIT binary patch literal 8192 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 a@q69%SM>i%=O32;@%$^@`}IHffBpjlp$u^V literal 0 HcmV?d00001 diff --git a/test/documents/sample.docx b/test/documents/sample.docx new file mode 100644 index 0000000000000000000000000000000000000000..e6fd1b359a83eb0d99954ec6f87c13fd1452546b GIT binary patch literal 7282 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 literal 0 HcmV?d00001 diff --git a/test/documents/sample.md b/test/documents/sample.md new file mode 100644 index 0000000..a5f8d0c --- /dev/null +++ b/test/documents/sample.md @@ -0,0 +1,26 @@ +# A sample Markdown file + +Opened in your browser by **OpenDocument Reader** — nothing was uploaded. + +Markdown is plain text with a convention on top, so a `.md` file carries no +signature. The demo asks the engine for it *by name*, and the engine renders +the prose: headings, emphasis, lists, links and tables. + +## What survives + +- **Bold** and *italic* text +- `inline code` and fenced blocks +- [Links](https://opendocument.app), drawn but not followed here +- Tables: + +| Application | Open format | Also opens | +|-------------|-------------|-----------------| +| Writer | ODT | DOC, DOCX, RTF | +| Calc | ODS | XLS, XLSX, CSV | +| Impress | ODP | PPT, PPTX | + +``` +No pen for this one: a rendered Markdown file is read, not written. +``` + +Now drop a Markdown file of your own onto the page. diff --git a/test/documents/sample.ods b/test/documents/sample.ods new file mode 100644 index 0000000000000000000000000000000000000000..904d8af60e6a966e8836780d859772c69fb4d3a9 GIT binary patch literal 17811 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 literal 0 HcmV?d00001 diff --git a/test/documents/sample.odt b/test/documents/sample.odt new file mode 100644 index 0000000000000000000000000000000000000000..9caf5c1ee027b75da6083f75e266beec7b0511f3 GIT binary patch literal 26251 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 k;$Ij1_eT0tj*b4!wo;IWg!=s$?E4P~3*Q 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 Gc=vym=89qf literal 0 HcmV?d00001 diff --git a/test/documents/two-sheets.ods b/test/documents/two-sheets.ods new file mode 100644 index 0000000000000000000000000000000000000000..ef16d5b9c7f158eb3fd5e7b5b35c14aa237c0cde GIT binary patch literal 21453 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 J%$kzF{sf)P;s5{u literal 0 HcmV?d00001 diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..f07fb86 --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,51 @@ +/* + Runs the built app in its smoke mode on every file in `test/documents/`. + The app opens the file, waits for the rendered page, saves a copy where the + window would offer a save, reports one line, and exits. + + node test/smoke.mjs + + A file named `*-.` is opened with that password. +*/ +import { spawn } from 'node:child_process'; +import { mkdtempSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const binary = resolve(process.argv[2] ?? ''); +const documents = resolve(import.meta.dirname, 'documents'); +const out = mkdtempSync(join(tmpdir(), 'odr-smoke-')); +const TIMEOUT_MS = 90_000; + +function run(file) { + const password = file.match(/-([^-.]+)\.[^.]+$/)?.[1]; + const env = { + ...process.env, + ODR_DESKTOP_SMOKE: join(documents, file), + ODR_DESKTOP_SMOKE_SAVE: join(out, file), + }; + if (password) env.ODR_DESKTOP_SMOKE_PASSWORD = password; + return new Promise((done) => { + const child = spawn(binary, [], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (d) => (output += d)); + child.stderr.on('data', (d) => (output += d)); + const timer = setTimeout(() => child.kill(), TIMEOUT_MS); + child.on('exit', (code) => { + clearTimeout(timer); + const line = output.split('\n').find((l) => l.startsWith('smoke: ')) ?? `no report (exit ${code})`; + done({ ok: code === 0 && line.startsWith('smoke: ok'), line, output }); + }); + }); +} + +let failed = 0; +for (const file of readdirSync(documents).sort()) { + const { ok, line, output } = await run(file); + console.log(`${ok ? 'ok ' : 'FAIL'} ${file}: ${line}`); + if (!ok) { + failed++; + console.log(output.trim().split('\n').slice(-20).join('\n')); + } +} +process.exit(failed ? 1 : 0); diff --git a/vite.config.ts b/vite.config.ts index 5710f9d..e548b5b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,25 +1,58 @@ -import { readFileSync } from 'node:fs'; +import { createReadStream, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { createRequire } from 'node:module'; import tailwindcss from '@tailwindcss/vite'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; // The worker imports the core by URL, from the directory that // `scripts/sync-odr.mjs` fills. The bundle needs the version for that URL. const manifest = createRequire(import.meta.url).resolve('@opendocument/odr-core/package.json'); const { version: odrVersion } = JSON.parse(readFileSync(manifest, 'utf8')); +/* + In dev, Vite answers a module import of a `public/` file with an error, and + it adds `?import` to dynamic imports at runtime. This middleware serves + `/odr/` before Vite sees the request, as the files are. A build has no such + problem, because Tauri serves `dist/` as it is. +*/ +function serveOdrRaw(): Plugin { + const types: Record = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.wasm': 'application/wasm', + }; + return { + name: 'odr-raw', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = (req.url ?? '').split('?')[0]; + if (!url.startsWith('/odr/')) return next(); + const base = join(process.cwd(), 'public', 'odr'); + const file = join(process.cwd(), 'public', decodeURIComponent(url)); + if (!file.startsWith(base) || !existsSync(file)) return next(); + const ext = file.slice(file.lastIndexOf('.')); + res.setHeader('Content-Type', types[ext] ?? 'application/octet-stream'); + createReadStream(file).pipe(res); + }); + }, + }; +} + export default defineConfig({ // Tauri expects a fixed port, and it shows the Rust errors itself. clearScreen: false, server: { port: 1420, strictPort: true }, envPrefix: ['VITE_', 'TAURI_ENV_'], - plugins: [tailwindcss()], + plugins: [tailwindcss(), serveOdrRaw()], define: { __ODR_VERSION__: JSON.stringify(odrVersion) }, worker: { format: 'es' }, build: { // WebKitGTK on Linux is the oldest engine that we support. target: 'es2022', sourcemap: true, + rollupOptions: { + input: { main: 'index.html', print: 'print.html' }, + }, }, });