Compare commits

..
4 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 19fc8dd3ca ci: fetch the APK after restoring the Rust cache
Build & Publish Release / Android APK (push) Successful in 7m18s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m41s
A cache restored without an exact match (Cargo.lock changed with the
rustls update) cleans target/, which removed the downloaded APK, so the
v0.42.0 release job stopped at INK_NOTES_REQUIRE_APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:44 +01:00
haraldandClaude Opus 5.5 17d0f07b9a build(deps): bump rustls from 0.23.45 to 0.23.46 (Issue #51)
Build & Publish Release / Android APK (push) Successful in 8m14s
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 25m48s
Fixes RUSTSEC key material in the Debug output of client session values;
ink-notes doesn't use a custom session store, so it wasn't affected.
Flatpak cargo sources updated alongside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:23:33 +01:00
haraldandClaude Opus 5.5 200f76e32f chore(release): bump version to 0.42.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:12:58 +01:00
haraldandClaude Opus 5.5 b8748c56d4 feat(sync): built-in Nextcloud sync (Issue #49, stage 2)
Sign in to any Nextcloud server (also private ones in a subfolder, with
their own port or a self-signed certificate) through the browser with
Login Flow v2; the app gets an app password of its own, which
"Disconnect" revokes again.

A background thread syncs the storage folder with the folder ink-notes
on the server every 30 seconds and shortly after saving, three-way
against the state after the last pass: one-sided changes are copied,
manifests and library.json are merged, a page changed on both sides is
kept twice (the server's version as conflict copy, which disk_watch
turns into a page), empty pages give way, deletions go into the trash
on both sides and lose against changes. Changes to the open notebook
wait while it has unsaved changes. Folder ETags spare listing unchanged
notebooks; the pass stops instead of deleting when the storage folder
or the server folder is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:24:49 +01:00
17 changed files with 3104 additions and 16 deletions

No files matched your search

+8 -6
View File
@@ -19,12 +19,6 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4.2.2
- name: Fetch Android APK
uses: actions/download-artifact@v4.3.0
with:
name: ink-notes-android-apk
path: target
- name: Install System Packaging Tools
run: |
export DEBIAN_FRONTEND=noninteractive
@@ -71,6 +65,14 @@ jobs:
cargo clippy --all-targets -- -D warnings
cargo test
# Only after the Rust cache step: restoring a cache that doesn't match exactly (e.g.
# after Cargo.lock changed) cleans target/ and would remove the APK again
- name: Fetch Android APK
uses: actions/download-artifact@v4.3.0
with:
name: ink-notes-android-apk
path: target
- name: Build Release Artifacts (.deb, .AppImage, .exe, .apk, SHA256SUMS.txt + .minisig)
run: |
chmod +x scripts/*.sh
Generated
+5 -3
View File
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.41.1"
version = "0.42.0"
dependencies = [
"ab_glyph",
"android_logger",
@@ -1565,6 +1565,7 @@ dependencies = [
"pollster",
"quick-xml",
"resvg",
"rustls",
"semver",
"serde",
"serde_json",
@@ -1576,6 +1577,7 @@ dependencies = [
"wayland-client",
"wayland-protocols",
"webbrowser",
"webpki-roots 0.26.11",
"wgpu",
"winit",
]
@@ -2578,9 +2580,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.41.1"
version = "0.42.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -38,6 +38,10 @@ semver = "1.0"
sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5"
# Nextcloud sync (Issue #49): pinning a self-signed certificate needs our own verifier;
# same versions and features as ureq uses
rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] }
webpki-roots = "0.26"
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
+3
View File
@@ -74,6 +74,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
- **Nextcloud eingebaut** (☰-Menü, „Nextcloud“; auch unter Android und ohne Sync-Client): Adresse der eigenen Nextcloud eingeben – auch ein privater Server, in einem Unterordner (`example.org/nextcloud`) oder mit eigenem Port. Die Anmeldung läuft im Browser (auch mit Zwei-Faktor-Anmeldung); die App bekommt ein eigenes App-Passwort, das sich in Nextcloud unter Einstellungen → Sicherheit jederzeit widerrufen lässt, und sieht das echte Passwort nie. Die Notizbücher werden alle 30 Sekunden und kurz nach dem Speichern mit dem Ordner `ink-notes` in den Nextcloud-Dateien abgeglichen; offline arbeitet die App normal weiter. Wurde eine Seite auf zwei Geräten geändert, bleiben beide Fassungen erhalten (die andere als eigene Seite); gelöschte Notizbücher und Seiten landen im Papierkorb (lokal `.trash`, auf dem Server dessen Papierkorb). Einem selbst signierten Zertifikat vertraut die App, nachdem du seinen Fingerabdruck bestätigt hast; Adressen mit `http://` gehen nur mit Warnung, weil das App-Passwort dann im Netz lesbar wäre. „Trennen“ beendet den Abgleich und widerruft das App-Passwort; die Notizbücher bleiben erhalten. Bitte entweder den eingebauten Abgleich oder den Ordner eines Sync-Clients als Speicherort verwenden, nicht beides für dieselben Notizbücher.
### Export
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
@@ -195,6 +196,8 @@ src/
├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
├── nextcloud.rs # Nextcloud: Anmeldung im Browser, WebDAV, gemerkte Zertifikate
├── cloud_sync.rs # Eingebauter Abgleich mit Nextcloud (Vergleich, Zusammenführen, Hintergrund)
├── library.rs # Ordner der Notizbücher (library.json)
├── math.rs # Exaktes Ausrechnen handgeschriebener Formeln (Issue #46)
├── math_ink.rs # Erkennung handgeschriebener Formeln ($P-Punktwolken)
+3
View File
@@ -74,6 +74,7 @@ The interface is available in English and German; ink-notes follows the system l
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. Opening a notebook that is open on another device shows a warning.
- **Nextcloud built in** (☰ menu, "Nextcloud"; also on Android and without a sync client): enter the address of your Nextcloud – also a private server, in a subfolder (`example.org/nextcloud`) or with its own port. Signing in happens in the browser (also with two-factor sign-in); the app gets an app password of its own, which you can revoke in Nextcloud under Settings → Security at any time, and never sees your real password. The notebooks are synced with the folder `ink-notes` in your Nextcloud files every 30 seconds and shortly after saving; the app keeps working offline. If a page was changed on two devices, both versions are kept (the other one as a page of its own); deleted notebooks and pages go into the trash (locally `.trash`, on the server its trash bin). A self-signed certificate is trusted once you have confirmed its fingerprint; addresses with `http://` only work with a warning, since the app password would be readable on the network. "Disconnect" stops syncing and revokes the app password; the notebooks stay. Use either the built-in sync or a sync client's folder as storage location, not both for the same notebooks.
### Export
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
@@ -197,6 +198,8 @@ src/
├── history.rs # Undo/redo
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
├── disk_watch.rs # Taking over changes from other devices (sync clients)
├── nextcloud.rs # Nextcloud: sign-in in the browser, WebDAV, pinned certificates
├── cloud_sync.rs # Built-in sync with Nextcloud (comparison, merging, background thread)
├── library.rs # Notebook folders (library.json)
├── math.rs # Exact evaluation of handwritten math (Issue #46)
├── math_ink.rs # Recognition of handwritten math ($P point clouds)
@@ -84,6 +84,11 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.42.0" date="2026-10-28">
<description>
<p>Built-in Nextcloud sync: sign in through the browser and the notebooks are synced with your Nextcloud, also private servers and without a sync client.</p>
</description>
</release>
<release version="0.41.1" date="2026-10-28">
<description>
<p>Android: the toolbar no longer hides behind the status bar and the navigation bar.</p>
+5 -5
View File
@@ -3655,14 +3655,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls/rustls-0.23.45.crate",
"sha256": "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634",
"dest": "cargo/vendor/rustls-0.23.45"
"url": "https://static.crates.io/crates/rustls/rustls-0.23.46.crate",
"sha256": "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf",
"dest": "cargo/vendor/rustls-0.23.46"
},
{
"type": "inline",
"contents": "{\"package\": \"0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634\", \"files\": {}}",
"dest": "cargo/vendor/rustls-0.23.45",
"contents": "{\"package\": \"48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf\", \"files\": {}}",
"dest": "cargo/vendor/rustls-0.23.46",
"dest-filename": ".cargo-checksum.json"
},
{
+5
View File
@@ -84,6 +84,11 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.42.0" date="2026-10-28">
<description>
<p>Built-in Nextcloud sync: sign in through the browser and the notebooks are synced with your Nextcloud, also private servers and without a sync client.</p>
</description>
</release>
<release version="0.41.1" date="2026-10-28">
<description>
<p>Android: the toolbar no longer hides behind the status bar and the navigation bar.</p>
+2
View File
@@ -3,6 +3,8 @@
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Nextcloud built in**: ☰ → Storage location → Nextcloud syncs the notebooks directly with your Nextcloud – also on Android and without a sync client. Enter the server address (private servers in a subfolder, with their own port or a self-signed certificate work too), sign in in the browser; the app gets an app password of its own that "Disconnect" revokes again. Syncing runs every 30 seconds and shortly after saving and keeps working offline; a page changed on two devices is kept twice, deleted notebooks and pages go into the trash on both sides, changes arriving for the open notebook wait until it is saved (Issue #49)
- **Security update**: rustls 0.23.46 (TLS library for update checks and the Nextcloud sync) fixes a way for key material to end up in logs (Issue #51); ink-notes itself was not affected, as it doesn't keep TLS sessions
- **Android toolbar fix**: The toolbar and menus stay clear of the status bar, the navigation bar and the camera cutout, so the topmost button (📚 notebooks) can be tapped again; in landscape the page is centered between the side bars. Nothing changes on Linux and Windows (Issue #50)
- **Computing formulas (experimental)**: Turn on in ☰ → Input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as text in the ink color and as high as the handwriting. Digits, `+ − · : /`, parentheses, exponents, fraction bars, decimal commas and `x`, `y`, `a`, `b` are read; numbers are computed exactly, expressions with variables multiplied out. Recognition runs offline without machine learning – feedback with real handwriting welcome (Issue #46)
- **Android**: `ink-notes_<version>_arm64.apk` brings ink-notes to Android 8 and newer (64-bit ARM) – download it on the device and open it to install. The pen is told apart from fingers (pressure, pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks live in the app's own storage and are removed when uninstalling; updates are installed by opening the new APK. Nothing changes on Linux and Windows
+41
View File
@@ -1483,6 +1483,8 @@ pub fn run_with(
ui_state.library = Library::load(&storage_base_dir);
ui_state.storage_dir = storage_base_dir.clone();
ui_state.storage_dir_custom = ui_settings.storage_dir.is_some();
// Built-in Nextcloud sync of the storage folder (Issue #49, stage 2)
let mut cloud = crate::cloud_sync::CloudSync::new(storage_base_dir.clone());
if let Some(warning) = startup_warning {
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message(warning);
@@ -1732,6 +1734,21 @@ pub fn run_with(
);
}
// Built-in sync: changes from the server to the open notebook wait while it
// has unsaved changes; a pass soon after saving
let unsaved = new_status != AutosaveStatus::Saved
|| is_drawing
|| is_stylus_drawing
|| ui_state.text_edit.is_some();
if cloud.update(&manifest.id, unsaved) {
disk_watch.check_soon(Instant::now());
refresh_notebooks(&storage_base_dir, &mut ui_state);
window.request_redraw();
}
if prev_status != AutosaveStatus::Saved && new_status == AutosaveStatus::Saved {
cloud.sync_soon();
}
// Take over changes from other devices (Issue #49), but only while everything
// local is saved and nothing is being drawn or typed
if disk_watch.dir != storage_dir {
@@ -2461,6 +2478,7 @@ pub fn run_with(
long_press.deadline(),
ui_state.math.deadline(),
Some(disk_watch.deadline()),
cloud.deadline(),
// The updater thread does not wake the loop: look for its results now and then
matches!(
updater.status(),
@@ -2578,6 +2596,7 @@ pub fn run_with(
WindowEvent::Focused(true) => {
// Coming back from another app: look for changes right away
disk_watch.check_soon(Instant::now());
cloud.sync_soon();
window.request_redraw();
}
@@ -4180,6 +4199,7 @@ pub fn run_with(
.as_ref()
.filter(|(id, _, _)| *id == manifest.id)
.map(|(_, index, _)| index + 1);
ui_state.cloud = cloud.view();
let actions = ui::render_ui(
&egui_ctx,
&mut ui_state,
@@ -5241,6 +5261,26 @@ pub fn run_with(
}
window.request_redraw();
}
UiAction::CloudConnect { server, trusted } => {
cloud.connect(&server, trusted);
window.request_redraw();
}
UiAction::CloudCancel => {
cloud.cancel_login();
window.request_redraw();
}
UiAction::CloudSyncNow => {
let _ = autosave.flush();
cloud.sync_now();
window.request_redraw();
}
UiAction::CloudDisconnect => {
cloud.disconnect();
ui_state.set_export_message(
t!("Nextcloud getrennt – die Notizbücher bleiben auf diesem Gerät").to_string(),
);
window.request_redraw();
}
UiAction::OpenUrlInBrowser(url) => {
let _ = webbrowser::open(&url);
}
@@ -5343,6 +5383,7 @@ pub fn run_with(
ui_state.storage_dir = storage_base_dir.clone();
ui_state.storage_dir_custom =
ui_settings.storage_dir.is_some();
cloud.set_local_dir(storage_base_dir.clone());
refresh_notebooks(&storage_base_dir, &mut ui_state);
// Same notebook if it exists there, otherwise the first one
let open_id = ui_state
+1945
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -87,7 +87,7 @@ fn scan(dir: &Path) -> HashMap<String, Fingerprint> {
}
/// Page id of a regular page file (`page_<id>.json`).
fn page_file_id(name: &str) -> Option<u64> {
pub(crate) fn page_file_id(name: &str) -> Option<u64> {
name.strip_prefix("page_")?
.strip_suffix(".json")?
.parse()
+2
View File
@@ -172,6 +172,8 @@ mod tests {
("rnote.rs", include_str!("rnote.rs")),
("saber.rs", include_str!("saber.rs")),
("xopp.rs", include_str!("xopp.rs")),
("nextcloud.rs", include_str!("nextcloud.rs")),
("cloud_sync.rs", include_str!("cloud_sync.rs")),
];
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
+39
View File
@@ -13,6 +13,7 @@ pub const EN: &[(&str, &str)] = &[
("1 Objekt | [R] Drehen [Entf] Löschen", "1 object | [R] Rotate [Del] Delete"),
("Abbrechen", "Cancel"),
("Abfrage fehlgeschlagen:", "Request failed:"),
("Abgleich beenden und das App-Passwort widerrufen; die Notizbücher bleiben auf diesem Gerät und auf dem Server", "Stop syncing and revoke the app password; the notebooks stay on this device and on the server"),
("Achsenkreuz", "Axis cross"),
("Achteck (Oktagon)", "Octagon"),
("Aktuelle Version:", "Current version:"),
@@ -20,6 +21,9 @@ pub const EN: &[(&str, &str)] = &[
("Alle", "All"),
("Alles gespeichert", "All saved"),
("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"),
("Anmeldung abgelaufen – bitte erneut verbinden", "Sign-in expired – please connect again"),
("Anmeldung abgelehnt – bitte neu verbinden", "Sign-in rejected – please connect again"),
("Anmeldung im Browser; die App bekommt ein eigenes App-Passwort, das sich in Nextcloud jederzeit widerrufen lässt", "Sign in in the browser; the app gets an app password of its own, which can be revoked in Nextcloud at any time"),
("AppImage (Portabel)", "AppImage (portable)"),
("Asset-Name darf nicht leer sein.", "Asset name must not be empty."),
("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"),
@@ -44,6 +48,8 @@ pub const EN: &[(&str, &str)] = &[
("Bild mit transparentem Hintergrund, nur Inhalt", "Image with transparent background, content only"),
("Bild nicht eingefügt: {err}", "Image not inserted: {err}"),
("Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", "Paste an image, text or copied objects from the clipboard here"),
("Bitte die Adresse des Nextcloud-Servers eingeben", "Please enter the address of the Nextcloud server"),
("Bitte im Browser anmelden und den Zugriff erlauben …", "Please sign in in the browser and grant access …"),
("Blanko", "Blank"),
("Blau", "Blue"),
("Code", "Code"),
@@ -56,15 +62,34 @@ pub const EN: &[(&str, &str)] = &[
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
("Den Zielordner gibt es nicht mehr", "The target folder no longer exists"),
("Der Ordner „ink-notes“ auf dem Server fehlt. Zur Sicherheit wird nichts gelöscht – zum erneuten Hochladen die Verbindung trennen und neu verbinden.", "The folder “ink-notes” is missing on the server. To be safe, nothing is deleted – to upload again, disconnect and connect again."),
("Der Ordnername ist leer", "The folder name is empty"),
("Der Ordnername ist zu lang (höchstens {max} Zeichen)", "The folder name is too long (at most {max} characters)"),
("Der Server leitet nach {to} weiter – bitte diese Adresse verwenden", "The server redirects to {to} – please use that address"),
("Der Speicherort {dir} fehlt. Zur Sicherheit wird nichts gelöscht.", "The storage location {dir} is missing. To be safe, nothing is deleted."),
("Der übergeordnete Ordner existiert nicht mehr", "The parent folder no longer exists"),
("DICKEN-PRESETS", "WIDTH PRESETS"),
("Die Adresse muss mit https:// beginnen", "The address must start with https://"),
("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"),
("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"),
("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"),
("Die Flatpak-Version wird über die Software-Verwaltung aktualisiert.", "The Flatpak version is updated through the software manager."),
("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."),
("Inzwischen geändert", "Changed meanwhile"),
("Kein Datenordner", "No data folder"),
("NEXTCLOUD", "NEXTCLOUD"),
("Nextcloud getrennt – die Notizbücher bleiben auf diesem Gerät", "Nextcloud disconnected – the notebooks stay on this device"),
("Nicht gefunden", "Not found"),
("Server nicht erreichbar: {msg}", "Server not reachable: {msg}"),
("Server-Fehler {code}", "Server error {code}"),
("Sofort mit dem Server abgleichen", "Sync with the server right away"),
("Trennen", "Disconnect"),
("Unerwartete Antwort des Servers", "Unexpected answer from the server"),
("Ungültige Adresse: {e}", "Invalid address: {e}"),
("Unter dieser Adresse antwortet kein Nextcloud-Server", "No Nextcloud server answers at this address"),
("Vertrauen", "Trust"),
("Zertifikat nicht vertrauenswürdig (SHA-256 {fingerprint})", "Certificate not trusted (SHA-256 {fingerprint})"),
("Zugang nicht gespeichert: {err}", "Sign-in not saved: {err}"),
("die oberste Ebene", "the top level"),
("Die Zwischenablage ist leer", "The clipboard is empty"),
("Diese Version wird über die Software-Verwaltung aktualisiert (z. B. GNOME Software oder „flatpak update“).", "This version is updated through the software manager (e.g. GNOME Software or “flatpak update”)."),
@@ -124,6 +149,7 @@ pub const EN: &[(&str, &str)] = &[
("Graphit-Grau", "Graphite gray"),
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
("Grün", "Green"),
("gerade eben", "just now"),
("hart", "hard"),
("Hell", "Light"),
("Hellgrün", "Light green"),
@@ -366,6 +392,8 @@ pub const EN: &[(&str, &str)] = &[
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
("vor {hours} h", "{hours} h ago"),
("vor {minutes} min", "{minutes} min ago"),
("weich", "soft"),
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
("Weitere Funktionen – Update verfügbar", "More – update available"),
@@ -385,6 +413,7 @@ pub const EN: &[(&str, &str)] = &[
("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."),
("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt", "Back to pages only works while everything lies in the starting area"),
("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
("z. B. cloud.example.org", "e.g. cloud.example.org"),
("{count} Objekt(e) eingefügt", "{count} object(s) pasted"),
("{count} Objekt(e) gelöscht", "{count} object(s) deleted"),
("{count} Objekt(e) kopiert – auch als Bild für andere Programme", "{count} object(s) copied – also as an image for other apps"),
@@ -405,21 +434,28 @@ pub const EN: &[(&str, &str)] = &[
("„{title}“ verschieben nach:", "Move “{title}” to:"),
("↩ Rückgängig", "↩ Undo"),
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
("⏳ Verbinde …", "⏳ Connecting …"),
("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
("♾ Endlos", "♾ Endless"),
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
("⚠ Beschädigt", "⚠ Damaged"),
("⚠ Das Zertifikat von {server} ist von keiner bekannten Stelle signiert. Nur vertrauen, wenn dieser Fingerabdruck mit dem des Servers übereinstimmt:", "⚠ The certificate of {server} isn't signed by a known authority. Only trust it if this fingerprint matches the server's:"),
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
("⚠ In anderer Instanz geöffnet (Schreibschutz)", "⚠ Opened in another instance (read-only)"),
("⚠ Ohne https ist das App-Passwort im Netz lesbar – nur im eigenen Heimnetz verwenden", "⚠ Without https the app password can be read on the network – only use this in your own home network"),
("⚠ Ungespeicherte Änderungen", "⚠ Unsaved changes"),
("⚠ Unverschlüsselt (http)", "⚠ Unencrypted (http)"),
("⚠\u{fe0f} {name} (Beschädigt - kein Manifest)", "⚠\u{fe0f} {name} (damaged - no manifest)"),
("⚠\u{fe0f} {name} (Beschädigt)", "⚠\u{fe0f} {name} (damaged)"),
("⚪ Offline – neuer Versuch in Kürze ({msg})", "⚪ Offline – trying again shortly ({msg})"),
("⚫ Aktiv", "⚫ Active"),
("✔ Synchronisiert {when}", "✔ Synced {when}"),
("✔ Update bereit", "✔ Update ready"),
("➕ Neue Seite einfügen", "➕ Insert new page"),
("➕ Notizbuch", "➕ Notebook"),
("⟲ Gelöschte Seite {number} wiederherstellen", "⟲ Restore deleted page {number}"),
("⬇ Downloads", "⬇ Downloads"),
("🌐 Browser öffnen", "🌐 Open browser"),
("🌐 Release auf Gitea öffnen", "🌐 Open release on Gitea"),
("🏠 Persönlicher Ordner", "🏠 Home folder"),
("💾 Einstellungen speichern", "💾 Save settings"),
@@ -435,10 +471,13 @@ pub const EN: &[(&str, &str)] = &[
("📚 Oberste Ebene", "📚 Top level"),
("📜 Durchgehend", "📜 Continuous"),
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
("🔄 Jetzt", "🔄 Now"),
("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
("🔄 Synchronisiere …", "🔄 Syncing …"),
("🔄 Updates & Versionen", "🔄 Updates & versions"),
("🔄 Backup", "🔄 Backup"),
("🔄 Nach Updates suchen", "🔄 Check for updates"),
("🔗 Verbinden …", "🔗 Connect …"),
("🖥 Schreibtisch", "🖥 Desktop"),
("🖼 Bild aus Datei …", "🖼 Image from file …"),
("🖼 Bild einfügen … (Strg+I)", "🖼 Insert image … (Ctrl+I)"),
+2
View File
@@ -7,6 +7,7 @@ pub mod assets;
pub mod autosave;
pub mod background;
pub mod canvas;
pub mod cloud_sync;
pub mod disk_watch;
pub mod document;
pub mod export;
@@ -21,6 +22,7 @@ pub mod library;
pub mod math;
pub mod math_assist;
pub mod math_ink;
pub mod nextcloud;
pub mod osk;
pub mod page_export;
pub mod paths;
+853
View File
@@ -0,0 +1,853 @@
//! Talking to a Nextcloud server (Issue #49, stage 2): finding the server, signing in through
//! the browser (Login Flow v2, the app gets its own app password), and WebDAV access to the
//! folder `ink-notes` in the user's files. The sync itself is in `cloud_sync.rs`.
//!
//! Private servers often use a self-signed certificate. Such a certificate is trusted once the
//! user has confirmed its SHA-256 fingerprint; it is then pinned for this server.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use base64::Engine;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::t;
/// Name of the synced folder in the user's files.
pub const REMOTE_FOLDER: &str = "ink-notes";
/// Largest file the sync downloads (bigger ones are surely not ours).
const MAX_DOWNLOAD: u64 = 512 * 1024 * 1024;
/// A connected Nextcloud account, stored in `nextcloud.json` next to the settings.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
/// Server address including a subfolder, without trailing slash
pub server: String,
/// Login name for the app password
pub login_name: String,
/// User id in WebDAV paths (differs from the login name e.g. with e-mail logins)
pub user_id: String,
pub app_password: String,
/// SHA-256 fingerprint of a certificate the user trusts although it isn't signed by a
/// known authority
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pinned_cert: Option<String>,
}
impl Account {
pub fn file_path() -> Option<PathBuf> {
crate::paths::config_dir().map(|d| d.join("nextcloud.json"))
}
pub fn load_from(path: &Path) -> Option<Self> {
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
}
/// Stores the account; the file is only readable by the user.
pub fn save_to(&self, path: &Path) -> Result<(), String> {
if let Some(dir) = path.parent() {
crate::storage::create_secure_dir_all(dir).map_err(|e| e.to_string())?;
}
crate::storage::atomic_write_json(path, self).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
/// `user@server` for the interface.
pub fn label(&self) -> String {
let host = url::Url::parse(&self.server)
.ok()
.and_then(|u| {
let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
u.host_str()
.map(|h| format!("{h}{port}{}", u.path().trim_end_matches('/')))
})
.unwrap_or_else(|| self.server.clone());
format!("{}@{host}", self.login_name)
}
pub fn is_insecure(&self) -> bool {
self.server.starts_with("http://")
}
}
/// Why talking to the server failed.
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
/// The certificate isn't signed by a known authority (or doesn't match the pinned one)
Untrusted {
fingerprint: String,
},
/// Server not reachable (offline, wrong address)
Offline(String),
/// App password revoked or wrong
Unauthorized,
NotFound,
/// The file changed on the server since we last saw it
PreconditionFailed,
Other(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Untrusted { fingerprint } => write!(
f,
"{}",
t!(
"Zertifikat nicht vertrauenswürdig (SHA-256 {fingerprint})",
fingerprint = fingerprint
)
),
Error::Offline(msg) => write!(f, "{}", t!("Server nicht erreichbar: {msg}", msg = msg)),
Error::Unauthorized => write!(f, "{}", t!("Anmeldung abgelehnt – bitte neu verbinden")),
Error::NotFound => write!(f, "{}", t!("Nicht gefunden")),
Error::PreconditionFailed => write!(f, "{}", t!("Inzwischen geändert")),
Error::Other(msg) => write!(f, "{msg}"),
}
}
}
/// Server address from what the user typed: `https://` is added when missing, a pasted link
/// into the web interface is cut back to the server (a subfolder such as `/nextcloud` stays).
pub fn normalize_server(input: &str) -> Result<String, String> {
let input = input.trim();
if input.is_empty() {
return Err(t!("Bitte die Adresse des Nextcloud-Servers eingeben").to_string());
}
let with_scheme = if input.contains("://") {
input.to_string()
} else {
format!("https://{input}")
};
let mut url = url::Url::parse(&with_scheme).map_err(|e| t!("Ungültige Adresse: {e}", e = e))?;
if !matches!(url.scheme(), "https" | "http") || url.host_str().is_none() {
return Err(t!("Die Adresse muss mit https:// beginnen").to_string());
}
url.set_query(None);
url.set_fragment(None);
let _ = url.set_username("");
let _ = url.set_password(None);
let mut path = url.path().to_string();
for marker in [
"/index.php",
"/apps/",
"/login",
"/remote.php",
"/status.php",
"/s/",
] {
if let Some(pos) = path.find(marker) {
path.truncate(pos);
}
}
url.set_path(path.trim_end_matches('/'));
Ok(url.as_str().trim_end_matches('/').to_string())
}
/// SHA-256 fingerprint as colon-separated hex pairs, as browsers show it.
pub fn fingerprint(cert: &[u8]) -> String {
Sha256::digest(cert)
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(":")
}
/// Accepts what the usual authorities sign and, besides, the one pinned certificate. Remembers
/// the fingerprint of a rejected certificate so the user can be asked about it.
#[derive(Debug)]
struct Verifier {
webpki: Arc<rustls::client::WebPkiServerVerifier>,
provider: Arc<rustls::crypto::CryptoProvider>,
pinned: Option<String>,
rejected: Arc<Mutex<Option<String>>>,
}
impl ServerCertVerifier for Verifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
match self.webpki.verify_server_cert(
end_entity,
intermediates,
server_name,
ocsp_response,
now,
) {
Ok(ok) => Ok(ok),
Err(err) => {
let print = fingerprint(end_entity);
if self.pinned.as_deref() == Some(print.as_str()) {
return Ok(ServerCertVerified::assertion());
}
if let Ok(mut slot) = self.rejected.lock() {
*slot = Some(print);
}
Err(err)
}
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
/// HTTP client for one server, with the pinned certificate if any.
pub struct Client {
agent: ureq::Agent,
rejected: Arc<Mutex<Option<String>>>,
user_agent: String,
}
impl Client {
pub fn new(pinned: Option<String>) -> Self {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let rejected = Arc::new(Mutex::new(None));
let mut agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(120))
.timeout_write(Duration::from_secs(120))
.redirects(0);
let webpki = rustls::client::WebPkiServerVerifier::builder_with_provider(
Arc::new(roots),
provider.clone(),
)
.build();
let config = webpki.map_err(|e| e.to_string()).and_then(|webpki| {
let verifier = Verifier {
webpki,
provider: provider.clone(),
pinned,
rejected: rejected.clone(),
};
rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map(|b| {
b.dangerous()
.with_custom_certificate_verifier(Arc::new(verifier))
.with_no_client_auth()
})
.map_err(|e| e.to_string())
});
match config {
Ok(config) => agent = agent.tls_config(Arc::new(config)),
Err(err) => eprintln!("[Nextcloud] TLS-Einrichtung fehlgeschlagen: {err}"),
}
Self {
agent: agent.build(),
rejected,
user_agent: format!("ink-notes ({})", crate::storage::device_name()),
}
}
fn request(&self, method: &str, url: &str) -> ureq::Request {
if let Ok(mut slot) = self.rejected.lock() {
*slot = None;
}
self.agent
.request(method, url)
.set("User-Agent", &self.user_agent)
}
/// Turns a failed request into an [`Error`].
fn error(&self, err: ureq::Error) -> Error {
match err {
ureq::Error::Status(401, _) => Error::Unauthorized,
ureq::Error::Status(404, _) => Error::NotFound,
ureq::Error::Status(412, _) => Error::PreconditionFailed,
ureq::Error::Status(code, resp) => {
let location = resp.header("Location").map(str::to_string);
match location {
Some(to) if (300..400).contains(&code) => Error::Other(t!(
"Der Server leitet nach {to} weiter – bitte diese Adresse verwenden",
to = to
)),
_ => Error::Other(t!("Server-Fehler {code}", code = code)),
}
}
ureq::Error::Transport(transport) => {
if let Some(print) = self.rejected.lock().ok().and_then(|s| s.clone()) {
return Error::Untrusted { fingerprint: print };
}
Error::Offline(transport.to_string())
}
}
}
}
/// Checks that a Nextcloud server answers at `server`.
pub fn check_server(client: &Client, server: &str) -> Result<(), Error> {
let resp = client
.request("GET", &format!("{server}/status.php"))
.call()
.map_err(|e| client.error(e))?;
let status: serde_json::Value = resp.into_json().map_err(|_| not_nextcloud())?;
if status.get("installed").and_then(|v| v.as_bool()) == Some(true) {
Ok(())
} else {
Err(not_nextcloud())
}
}
fn not_nextcloud() -> Error {
Error::Other(t!("Unter dieser Adresse antwortet kein Nextcloud-Server").to_string())
}
/// A started sign-in: the user approves it in the browser, the app polls for the result.
#[derive(Debug, Clone, PartialEq)]
pub struct LoginFlow {
pub login_url: String,
poll_url: String,
token: String,
}
/// Puts a link the server built with its own idea of its address (wrong behind a proxy) onto
/// the address the user gave.
fn onto_server(link: &str, server: &str, marker: &str) -> String {
let same_origin = match (url::Url::parse(link), url::Url::parse(server)) {
(Ok(a), Ok(b)) => a.origin() == b.origin(),
_ => false,
};
match link.find(marker) {
Some(pos) if !same_origin => format!("{server}/index.php{}", &link[pos..]),
_ => link.to_string(),
}
}
/// Starts Login Flow v2; the app password gets the device name as its name.
pub fn start_login(client: &Client, server: &str) -> Result<LoginFlow, Error> {
#[derive(Deserialize)]
struct Poll {
token: String,
endpoint: String,
}
#[derive(Deserialize)]
struct Start {
poll: Poll,
login: String,
}
let resp = client
.request("POST", &format!("{server}/index.php/login/v2"))
.call()
.map_err(|e| client.error(e))?;
let start: Start = resp.into_json().map_err(|_| not_nextcloud())?;
Ok(LoginFlow {
login_url: onto_server(&start.login, server, "/login/v2"),
poll_url: onto_server(&start.poll.endpoint, server, "/login/v2"),
token: start.poll.token,
})
}
/// Login name and app password once the user has approved the sign-in.
pub fn poll_login(client: &Client, flow: &LoginFlow) -> Result<Option<(String, String)>, Error> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Done {
login_name: String,
app_password: String,
}
match client
.request("POST", &flow.poll_url)
.send_form(&[("token", flow.token.as_str())])
{
Ok(resp) => {
let done: Done = resp.into_json().map_err(|e| Error::Other(e.to_string()))?;
Ok(Some((done.login_name, done.app_password)))
}
// Not approved yet
Err(ureq::Error::Status(404, _)) => Ok(None),
Err(err) => Err(client.error(err)),
}
}
fn basic_auth(login_name: &str, password: &str) -> String {
let raw = format!("{login_name}:{password}");
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(raw)
)
}
/// User id of the signed-in user (the name in WebDAV paths).
pub fn user_id(
client: &Client,
server: &str,
login_name: &str,
password: &str,
) -> Result<String, Error> {
let resp = client
.request(
"GET",
&format!("{server}/ocs/v2.php/cloud/user?format=json"),
)
.set("OCS-APIRequest", "true")
.set("Authorization", &basic_auth(login_name, password))
.call()
.map_err(|e| client.error(e))?;
let value: serde_json::Value = resp.into_json().map_err(|e| Error::Other(e.to_string()))?;
value["ocs"]["data"]["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| Error::Other(t!("Unerwartete Antwort des Servers").to_string()))
}
/// Revokes the app password on the server (when disconnecting).
pub fn revoke(account: &Account) -> Result<(), Error> {
let client = Client::new(account.pinned_cert.clone());
client
.request(
"DELETE",
&format!("{}/ocs/v2.php/core/apppassword", account.server),
)
.set("OCS-APIRequest", "true")
.set(
"Authorization",
&basic_auth(&account.login_name, &account.app_password),
)
.call()
.map(|_| ())
.map_err(|e| client.error(e))
}
/// One entry of a folder listing on the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// Path relative to the synced folder, `/`-separated
pub path: String,
pub is_dir: bool,
pub etag: String,
}
/// How an upload must find the file on the server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Expect<'a> {
/// Must not exist yet
New,
/// Must still have this ETag
Etag(&'a str),
}
/// The folder `ink-notes` on the server, as the sync sees it.
pub trait Remote {
/// Entries directly in `dir` (`""` is the synced folder); `None` if it doesn't exist.
fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error>;
/// Content and ETag of a file.
fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error>;
/// Uploads a file and returns its new ETag.
fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error>;
/// Creates a folder (fine if it exists).
fn mkdir(&mut self, path: &str) -> Result<(), Error>;
/// Deletes a file or folder (into the server's trash bin); with an ETag only if unchanged.
fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error>;
}
/// [`Remote`] over WebDAV.
pub struct WebDav {
client: Client,
/// `…/remote.php/dav/files/<user>/ink-notes/`
root: url::Url,
auth: String,
}
impl WebDav {
pub fn new(account: &Account) -> Result<Self, Error> {
Self::with_folder(account, REMOTE_FOLDER)
}
/// Like [`Self::new`], with another folder (for tests against a real server).
pub fn with_folder(account: &Account, folder: &str) -> Result<Self, Error> {
let mut root = url::Url::parse(&format!("{}/", account.server))
.map_err(|e| Error::Other(e.to_string()))?;
root.path_segments_mut()
.map_err(|_| Error::Other(account.server.clone()))?
.pop_if_empty()
.extend(["remote.php", "dav", "files", &account.user_id, folder, ""]);
Ok(Self {
client: Client::new(account.pinned_cert.clone()),
root,
auth: basic_auth(&account.login_name, &account.app_password),
})
}
fn url(&self, path: &str, dir: bool) -> String {
let mut url = self.root.clone();
if let Ok(mut segments) = url.path_segments_mut() {
segments.pop_if_empty();
if !path.is_empty() {
segments.extend(path.split('/'));
}
if dir {
segments.push("");
}
}
url.to_string()
}
fn request(&self, method: &str, url: &str) -> ureq::Request {
self.client
.request(method, url)
.set("Authorization", &self.auth)
}
}
/// Decodes `%xx` escapes of a URL path.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
if let Ok(b) = u8::from_str_radix(hex, 16) {
out.push(b);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Entries of a PROPFIND answer: (decoded href path, is folder, ETag).
pub fn parse_multistatus(xml: &str) -> Result<Vec<(String, bool, String)>, String> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(xml);
let mut entries = Vec::new();
let mut href = String::new();
let mut etag = String::new();
let mut is_dir = false;
let mut ok = false;
let mut in_tag: Option<&'static str> = None;
loop {
match reader.read_event().map_err(|e| e.to_string())? {
Event::Start(e) => {
let name = e.local_name();
match name.as_ref() {
b"response" => {
href.clear();
etag.clear();
is_dir = false;
ok = false;
}
b"href" => in_tag = Some("href"),
b"getetag" => in_tag = Some("etag"),
b"status" => in_tag = Some("status"),
b"collection" => is_dir = true,
_ => {}
}
}
Event::Empty(e) => {
if e.local_name().as_ref() == b"collection" {
is_dir = true;
}
}
event @ (Event::Text(_) | Event::GeneralRef(_)) => {
let text = match event {
Event::Text(t) => t.decode().map_err(|e| e.to_string())?.into_owned(),
Event::GeneralRef(r) => match r.resolve_char_ref() {
Ok(Some(c)) => c.to_string(),
_ => r
.decode()
.ok()
.and_then(|name| quick_xml::escape::resolve_predefined_entity(&name))
.unwrap_or_default()
.to_string(),
},
_ => String::new(),
};
match in_tag {
Some("href") => href.push_str(&text),
Some("etag") => etag.push_str(&text),
Some("status") => ok |= text.contains(" 200 "),
_ => {}
}
}
Event::End(e) => {
let name = e.local_name();
match name.as_ref() {
b"href" | b"getetag" | b"status" => in_tag = None,
b"response" if ok => {
entries.push((percent_decode(href.trim()), is_dir, etag.trim().to_string()))
}
_ => {}
}
}
Event::Eof => break,
_ => {}
}
}
Ok(entries)
}
/// ETag of a file in an answer, as PROPFIND lists it. Nextcloud's own `OC-ETag` is left alone
/// by the web server; a plain `ETag` may carry a mark of the compression.
fn etag_of(resp: &ureq::Response) -> Option<String> {
if let Some(etag) = resp.header("OC-ETag") {
return Some(etag.to_string());
}
let etag = resp.header("ETag")?;
let plain = etag.strip_suffix('"').and_then(|unquoted| {
["-gzip", "-br", "-zstd", "-df"]
.iter()
.find_map(|mark| unquoted.strip_suffix(mark))
});
Some(plain.map_or(etag.to_string(), |tag| format!("{tag}\"")))
}
const PROPFIND_BODY: &str = r#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
impl Remote for WebDav {
fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error> {
let url = self.url(dir, true);
let resp = match self
.request("PROPFIND", &url)
.set("Depth", "1")
.set("Content-Type", "application/xml; charset=utf-8")
.send_string(PROPFIND_BODY)
{
Ok(resp) => resp,
Err(ureq::Error::Status(404, _)) => return Ok(None),
Err(err) => return Err(self.client.error(err)),
};
let body = resp
.into_string()
.map_err(|e| Error::Offline(e.to_string()))?;
let root_path = percent_decode(self.root.path());
let entries = parse_multistatus(&body).map_err(Error::Other)?;
Ok(Some(
entries
.into_iter()
.filter_map(|(href, is_dir, etag)| {
// Hrefs are paths, sometimes full URLs
let path = match href.find("/remote.php/") {
Some(pos) if href.contains("://") => href[pos..].to_string(),
_ => href,
};
let path = path.strip_prefix(&root_path)?.trim_end_matches('/');
(path != dir && !path.is_empty()).then(|| Entry {
path: path.to_string(),
is_dir,
etag,
})
})
.collect(),
))
}
fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error> {
// Uncompressed: Apache marks the ETag of a compressed answer (`"…-gzip"`)
let resp = self
.request("GET", &self.url(path, false))
.set("Accept-Encoding", "identity")
.call()
.map_err(|e| self.client.error(e))?;
let etag = etag_of(&resp).unwrap_or_default();
let mut data = Vec::new();
resp.into_reader()
.take(MAX_DOWNLOAD)
.read_to_end(&mut data)
.map_err(|e| Error::Offline(e.to_string()))?;
Ok((data, etag))
}
fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error> {
let mut req = self.request("PUT", &self.url(path, false));
req = match expect {
Expect::New => req.set("If-None-Match", "*"),
Expect::Etag(etag) => req.set("If-Match", etag),
};
let resp = req.send_bytes(data).map_err(|e| self.client.error(e))?;
match etag_of(&resp) {
Some(etag) => Ok(etag),
None => {
// Look it up when the server didn't say
let parent = path.rsplit_once('/').map_or("", |(p, _)| p);
self.list(parent)?
.unwrap_or_default()
.into_iter()
.find(|e| e.path == path)
.map(|e| e.etag)
.ok_or(Error::NotFound)
}
}
}
fn mkdir(&mut self, path: &str) -> Result<(), Error> {
match self.request("MKCOL", &self.url(path, true)).call() {
Ok(_) | Err(ureq::Error::Status(405, _)) => Ok(()),
Err(err) => Err(self.client.error(err)),
}
}
fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error> {
let mut req = self.request("DELETE", &self.url(path, false));
if let Some(etag) = etag {
req = req.set("If-Match", etag);
}
match req.call() {
Ok(_) | Err(ureq::Error::Status(404, _)) => Ok(()),
Err(err) => Err(self.client.error(err)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_server_addresses() {
assert_eq!(
normalize_server("cloud.example.org").unwrap(),
"https://cloud.example.org"
);
assert_eq!(
normalize_server(" https://example.org/nextcloud/ ").unwrap(),
"https://example.org/nextcloud"
);
assert_eq!(
normalize_server("https://example.org/nextcloud/index.php/apps/files/?dir=/").unwrap(),
"https://example.org/nextcloud"
);
assert_eq!(
normalize_server("http://192.168.1.5:8080/apps/dashboard/").unwrap(),
"http://192.168.1.5:8080"
);
assert!(normalize_server("").is_err());
assert!(normalize_server("ftp://example.org").is_err());
}
#[test]
fn moves_links_onto_the_given_server() {
let server = "https://cloud.example.org/nc";
assert_eq!(
onto_server("http://localhost/login/v2/poll", server, "/login/v2"),
"https://cloud.example.org/nc/index.php/login/v2/poll"
);
let same = "https://cloud.example.org/nc/login/v2/flow/abc";
assert_eq!(onto_server(same, server, "/login/v2"), same);
}
#[test]
fn parses_propfind_answers() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/dav/files/pia/ink-notes/</d:href><d:propstat><d:prop><d:getetag>&quot;abc&quot;</d:getetag><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/pia/ink-notes/library.json</d:href><d:propstat><d:prop><d:getetag>&quot;e1&quot;</d:getetag><d:resourcetype/></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/pia/ink-notes/Mathe%20%C3%BC/</d:href><d:propstat><d:prop><d:getetag>"e2"</d:getetag><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
let entries = parse_multistatus(xml).unwrap();
assert_eq!(
entries,
vec![
(
"/remote.php/dav/files/pia/ink-notes/".to_string(),
true,
"\"abc\"".to_string()
),
(
"/remote.php/dav/files/pia/ink-notes/library.json".to_string(),
false,
"\"e1\"".to_string()
),
(
"/remote.php/dav/files/pia/ink-notes/Mathe ü/".to_string(),
true,
"\"e2\"".to_string()
),
]
);
}
#[test]
fn builds_webdav_urls() {
let account = Account {
server: "https://example.org/nc".into(),
login_name: "pia@example.org".into(),
user_id: "pia".into(),
app_password: "x".into(),
pinned_cert: None,
};
let dav = WebDav::new(&account).unwrap();
assert_eq!(
dav.url("nb 1/page_1.json", false),
"https://example.org/nc/remote.php/dav/files/pia/ink-notes/nb%201/page_1.json"
);
assert_eq!(
dav.url("", true),
"https://example.org/nc/remote.php/dav/files/pia/ink-notes/"
);
assert_eq!(account.label(), "pia@example.org@example.org/nc");
}
#[test]
fn account_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cfg").join("nextcloud.json");
let account = Account {
server: "https://example.org".into(),
login_name: "pia".into(),
user_id: "pia".into(),
app_password: "secret".into(),
pinned_cert: Some("AA:BB".into()),
};
account.save_to(&path).unwrap();
assert_eq!(Account::load_from(&path), Some(account));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
}
+180
View File
@@ -343,6 +343,15 @@ pub enum UiAction {
ImportFile(std::path::PathBuf),
/// Store the notebooks in this folder, `None` for the default location (Issue #49)
SetStorageDir(Option<std::path::PathBuf>),
/// Sign in to a Nextcloud server through the browser; with the fingerprint of a
/// certificate the user trusts (Issue #49, stage 2)
CloudConnect {
server: String,
trusted: Option<String>,
},
CloudCancel,
CloudSyncNow,
CloudDisconnect,
/// Aktuelle Seite als Bild oder SVG exportieren
ExportPage(PageExportKind),
/// Titel einer Seite setzen (leer = entfernen)
@@ -513,6 +522,9 @@ pub struct UiState {
/// Folder holding the notebooks and whether the user picked it (Issue #49)
pub storage_dir: std::path::PathBuf,
pub storage_dir_custom: bool,
/// Nextcloud sync as the app last saw it, and the server address being typed
pub cloud: crate::cloud_sync::View,
pub cloud_server_input: String,
}
impl Default for UiState {
@@ -601,6 +613,8 @@ impl Default for UiState {
last_picker_dir: None,
storage_dir: std::path::PathBuf::new(),
storage_dir_custom: false,
cloud: crate::cloud_sync::View::default(),
cloud_server_input: String::new(),
}
}
}
@@ -3190,6 +3204,171 @@ pub fn key_allowed_in_view_mode(key: &winit::keyboard::Key, ctrl: bool) -> bool
}
}
/// "How long ago" for the last sync.
fn elapsed_label(at: std::time::SystemTime) -> String {
let secs = at.elapsed().map_or(0, |d| d.as_secs());
if secs < 60 {
t!("gerade eben").to_string()
} else if secs < 3600 {
t!("vor {minutes} min", minutes = secs / 60)
} else {
t!("vor {hours} h", hours = secs / 3600)
}
}
/// Nextcloud sync in the menu (Issue #49, stage 2).
fn cloud_section(ui: &mut egui::Ui, state: &mut UiState, actions: &mut Vec<UiAction>) {
use crate::cloud_sync::Status;
let small = |text: String, color: egui::Color32| {
egui::Label::new(egui::RichText::new(text).size(11.5).color(color)).wrap(true)
};
let grey = egui::Color32::from_gray(190);
let warn = egui::Color32::from_rgb(240, 180, 80);
let button = |text: String| egui::Button::new(text).min_size(egui::vec2(115.0, 28.0));
ui.add_space(4.0);
ui.label(
egui::RichText::new(t!("NEXTCLOUD"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
let status = state.cloud.status.clone();
if matches!(
status,
Status::Connecting | Status::WaitingForLogin { .. } | Status::Syncing
) {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(500));
}
match status {
Status::Off | Status::Failed(_) if state.cloud.account.is_none() => {
if let Status::Failed(msg) = &status {
ui.add(small(format!("⚠ {msg}"), warn));
}
let field = ui.add(
egui::TextEdit::singleline(&mut state.cloud_server_input)
.hint_text(t!("z. B. cloud.example.org"))
.margin(egui::vec2(8.0, 6.0))
.desired_width(f32::INFINITY),
);
if state.cloud_server_input.trim().starts_with("http://") {
ui.add(small(
t!("⚠ Ohne https ist das App-Passwort im Netz lesbar – nur im eigenen Heimnetz verwenden").to_string(),
warn,
));
}
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui
.add_enabled(
!state.cloud_server_input.trim().is_empty(),
button(t!("🔗 Verbinden …").to_string()),
)
.on_hover_text(t!("Anmeldung im Browser; die App bekommt ein eigenes App-Passwort, das sich in Nextcloud jederzeit widerrufen lässt"))
.clicked()
|| enter && !state.cloud_server_input.trim().is_empty()
{
actions.push(UiAction::CloudConnect {
server: state.cloud_server_input.clone(),
trusted: None,
});
}
}
Status::Off | Status::Connecting => {
ui.add(small(t!("⏳ Verbinde …").to_string(), grey));
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
actions.push(UiAction::CloudCancel);
}
}
Status::Untrusted {
server,
fingerprint,
} => {
ui.add(small(
t!("⚠ Das Zertifikat von {server} ist von keiner bekannten Stelle signiert. Nur vertrauen, wenn dieser Fingerabdruck mit dem des Servers übereinstimmt:", server = server),
warn,
));
ui.add(
egui::Label::new(
egui::RichText::new(&fingerprint)
.monospace()
.size(10.0)
.color(grey),
)
.wrap(true),
);
ui.horizontal(|ui| {
if ui.add(button(t!("Vertrauen").to_string())).clicked() {
actions.push(UiAction::CloudConnect {
server: server.clone(),
trusted: Some(fingerprint.clone()),
});
}
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
actions.push(UiAction::CloudCancel);
}
});
}
Status::WaitingForLogin { url } => {
ui.add(small(
t!("Bitte im Browser anmelden und den Zugriff erlauben …").to_string(),
grey,
));
ui.horizontal(|ui| {
if ui
.add(button(t!("🌐 Browser öffnen").to_string()))
.clicked()
{
actions.push(UiAction::OpenUrlInBrowser(url.clone()));
}
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
actions.push(UiAction::CloudCancel);
}
});
}
Status::Idle { .. } | Status::Syncing | Status::Offline(_) | Status::Failed(_) => {
if let Some(account) = &state.cloud.account {
ui.add(small(format!("👤 {account}"), grey).truncate(true))
.on_hover_text(account);
}
let (text, color) = match &status {
Status::Idle { last: Some(at) } => (
t!("✔ Synchronisiert {when}", when = elapsed_label(*at)),
egui::Color32::from_rgb(120, 200, 120),
),
Status::Offline(msg) => (
t!("⚪ Offline – neuer Versuch in Kürze ({msg})", msg = msg),
grey,
),
Status::Failed(msg) => (format!("⚠ {msg}"), warn),
_ => (t!("🔄 Synchronisiere …").to_string(), grey),
};
ui.add(small(text, color));
if state.cloud.insecure {
ui.add(small(t!("⚠ Unverschlüsselt (http)").to_string(), warn));
}
ui.horizontal(|ui| {
if ui
.add_enabled(
!matches!(status, Status::Syncing),
button(t!("🔄 Jetzt").to_string()),
)
.on_hover_text(t!("Sofort mit dem Server abgleichen"))
.clicked()
{
actions.push(UiAction::CloudSyncNow);
}
if ui
.add(button(t!("Trennen").to_string()))
.on_hover_text(t!("Abgleich beenden und das App-Passwort widerrufen; die Notizbücher bleiben auf diesem Gerät und auf dem Server"))
.clicked()
{
actions.push(UiAction::CloudDisconnect);
}
});
}
}
}
/// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`.
///
/// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden.
@@ -4535,6 +4714,7 @@ pub fn render_ui(
actions.push(UiAction::SetStorageDir(None));
}
});
cloud_section(ui, state, &mut actions);
section(ui, t!("STATUS"));
let (dot_color, status_text) = autosave_status_style(autosave_status);