diff --git a/extensions/deja-dup/.gitignore b/extensions/deja-dup/.gitignore new file mode 100644 index 00000000..8ba8000b --- /dev/null +++ b/extensions/deja-dup/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +vicinae-env.d.ts diff --git a/extensions/deja-dup/CHANGELOG.md b/extensions/deja-dup/CHANGELOG.md new file mode 100644 index 00000000..6b7a2482 --- /dev/null +++ b/extensions/deja-dup/CHANGELOG.md @@ -0,0 +1,11 @@ +# Déjà Dup Backups Changelog + +### Friday, July 17, 2026 + +- Initial release. +- Browse snapshots and restore individual files or folders. +- Fast, offline file search backed by a one-time local index (with optional background indexing). +- Back Up Now and Backup Status commands. +- Backends: local folder, removable drive, network server (SMB/SFTP/WebDAV/Nextcloud), + Google Drive, OneDrive, and rclone remotes. +- Native, Snap and (best-effort) Flatpak installations, with manual overrides in preferences. diff --git a/extensions/deja-dup/LICENSE b/extensions/deja-dup/LICENSE new file mode 100644 index 00000000..e7079364 --- /dev/null +++ b/extensions/deja-dup/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 samvoelzke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/deja-dup/README.md b/extensions/deja-dup/README.md new file mode 100644 index 00000000..593e3d33 --- /dev/null +++ b/extensions/deja-dup/README.md @@ -0,0 +1,65 @@ +# Déjà Dup Backups + +Browse, search and restore files from your [Déjà Dup](https://wiki.gnome.org/Apps/DejaDup) +backups directly from [Vicinae](https://vicinae.com) — without opening the GUI. + +Déjà Dup 45+ stores its backups as a [restic](https://restic.net) repository. This extension +talks to that repository read-only, reusing the credentials Déjà Dup already saved, so there is +nothing to configure. + +## Commands + +- **Browse Backup** — list snapshots, drill into the file tree, preview a file, and restore + individual files or folders (to their original location or anywhere you choose). +- **Search Backup Files** — build a small local index of your latest backup once, then search it + instantly and offline (searching a cloud backup file-by-file is far too slow otherwise). +- **Back Up Now** — start a fresh backup, and see the last backup, destination, schedule and + included/excluded paths. + +## Supported backends + +| Backend | Status | Notes | +| --- | --- | --- | +| Local folder | ✅ | | +| Removable drive | ✅ | drive must be connected/mounted | +| Network server | ✅ | SMB, SFTP, WebDAV/Nextcloud — mount it once in Files first | +| Google Drive | ✅ | | +| OneDrive (Personal) | ✅ | | +| rclone remote | ✅ | uses your `rclone.conf` | + +Backups made with **duplicity** or **borg** (older Déjà Dup, or an explicit choice) are not +readable by this extension — only restic backups are. + +## Installation flavor (native / Flatpak / Snap) + +The extension auto-detects how Déjà Dup is installed and adapts: + +- **Native** (system package) and **Snap** (classic confinement) — full support, all backends. +- **Flatpak** — Déjà Dup's config and passwords live in the app's sandbox, unreachable from + outside. Local, network and drive backups work if you enter your backup password under + **Backup Password** in the extension settings; cloud backups (Drive/OneDrive/rclone) keep their + tokens in the sandboxed keyring and need the native or Snap Déjà Dup. + +You can override the detected flavor and set custom binary paths / the backup password under the +extension's preferences (⌘/Ctrl+,) if your setup is unusual. + +## Requirements + +- Déjà Dup with a restic backup already configured and run at least once +- `restic` and (for cloud backends) `rclone` available on `PATH` +- The GNOME keyring unlocked (open Déjà Dup once after login so the password/token are available) + +All access is **read-only** on the repository (`restic --no-lock`); the extension never prunes, +forgets or writes to your backup. Restores only ever create new files in the target directory. + +## How it works + +Configuration (backend, target folder, schedule) is read from GSettings +(`org.gnome.DejaDup`). The repository password comes from the keyring +(`secret-tool lookup owner deja-dup type passphrase`). For Google Drive, Déjà Dup's own OAuth +refresh token is read from the keyring and exchanged for a short-lived access token, which is +cached in the extension's support directory until it expires. + +## License + +MIT diff --git a/extensions/deja-dup/assets/extension_icon.png b/extensions/deja-dup/assets/extension_icon.png new file mode 100644 index 00000000..26c8f848 Binary files /dev/null and b/extensions/deja-dup/assets/extension_icon.png differ diff --git a/extensions/deja-dup/package-lock.json b/extensions/deja-dup/package-lock.json new file mode 100644 index 00000000..eee4b04f --- /dev/null +++ b/extensions/deja-dup/package-lock.json @@ -0,0 +1,597 @@ +{ + "name": "deja-dup", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deja-dup", + "license": "MIT", + "dependencies": { + "@vicinae/api": "^0.23.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.9.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@vicinae/api": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@vicinae/api/-/api-0.23.1.tgz", + "integrity": "sha512-aBqOwMrLXOztmRDPndxx6GQArGbC4RGpWVR29TRZu+vZwaeWyi82P9yc+jxMYe7rMcRVrxrm0T53ARJirFjFxA==", + "license": "ISC", + "dependencies": { + "@types/react": "19.1.17", + "chokidar": "^4.0.3", + "esbuild": "^0.25.2", + "react": "19.1.1", + "zod": "^4.0.17" + }, + "bin": { + "vici": "dist/bin.js" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@vicinae/api/node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/deja-dup/package.json b/extensions/deja-dup/package.json new file mode 100644 index 00000000..b9615219 --- /dev/null +++ b/extensions/deja-dup/package.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json", + "name": "deja-dup", + "title": "Déjà Dup Backups", + "description": "Browse, search and restore files from your Déjà Dup (restic) backups", + "categories": [ + "System" + ], + "license": "MIT", + "platforms": [ + "Linux" + ], + "author": "samvoelzke", + "contributors": [], + "icon": "extension_icon.png", + "commands": [ + { + "name": "browse-backup", + "title": "Browse Backup", + "subtitle": "Déjà Dup", + "description": "Browse snapshots and restore individual files from your backup", + "mode": "view" + }, + { + "name": "search-files", + "title": "Search Backup Files", + "subtitle": "Déjà Dup", + "description": "Search for files across your backup snapshots", + "mode": "view" + }, + { + "name": "backup-now", + "title": "Back Up Now", + "subtitle": "Déjà Dup", + "description": "Start a backup immediately", + "keywords": [ + "backup", + "take backup", + "run backup" + ], + "mode": "no-view" + }, + { + "name": "backup-status", + "title": "Backup Status", + "subtitle": "Déjà Dup", + "description": "See the last backup, destination, schedule and included paths", + "keywords": [ + "backup", + "status" + ], + "mode": "view" + }, + { + "name": "index-latest", + "title": "Refresh Search Index", + "subtitle": "Déjà Dup", + "description": "Keep the latest backup indexed for instant browsing and search", + "mode": "no-view", + "interval": "6h" + } + ], + "preferences": [ + { + "name": "dejaDupFlavor", + "title": "Déjà Dup Installation", + "description": "How Déjà Dup is installed. Auto-detects by default; override if detection is wrong or you use a custom setup.", + "type": "dropdown", + "required": false, + "default": "auto", + "data": [ + { + "title": "Auto-detect", + "value": "auto" + }, + { + "title": "Native (system package)", + "value": "native" + }, + { + "title": "Flatpak", + "value": "flatpak" + }, + { + "title": "Snap", + "value": "snap" + } + ] + }, + { + "name": "backupPassword", + "title": "Backup Password", + "description": "Leave empty to read it from the keyring automatically. Set it only if the extension cannot find it (e.g. a Flatpak install, or a custom keyring).", + "type": "password", + "required": false, + "default": "" + }, + { + "name": "autoIndex", + "title": "Automatic Indexing", + "label": "Keep the latest backup indexed in the background", + "description": "When on, a new backup is indexed automatically in the background (downloads the file listing metadata over the network — scales with file count) so the first browse/search is instant. When off, you build the index on demand.", + "type": "checkbox", + "required": false, + "default": false + }, + { + "name": "resticPath", + "title": "restic Binary", + "description": "Path to the restic executable (leave as 'restic' to use PATH). Ignored for Flatpak installs, which use their bundled restic.", + "type": "textfield", + "required": false, + "default": "restic" + }, + { + "name": "rclonePath", + "title": "rclone Binary", + "description": "Path to the rclone executable (leave as 'rclone' to use PATH)", + "type": "textfield", + "required": false, + "default": "rclone" + }, + { + "name": "cacheDir", + "title": "restic Cache Directory", + "description": "Leave empty to reuse Déjà Dup's cache at ~/.cache/deja-dup/restic", + "type": "textfield", + "required": false, + "default": "" + } + ], + "scripts": { + "build": "vici build", + "dev": "vici develop", + "lint": "vici lint" + }, + "dependencies": { + "@vicinae/api": "^0.23.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.9.2" + } +} diff --git a/extensions/deja-dup/src/backup-now.ts b/extensions/deja-dup/src/backup-now.ts new file mode 100644 index 00000000..d3697707 --- /dev/null +++ b/extensions/deja-dup/src/backup-now.ts @@ -0,0 +1,24 @@ +import { showHUD, showToast, Toast } from "@vicinae/api"; +import { execFile } from "node:child_process"; + +/** + * No-view command: selecting it and pressing Enter starts a backup immediately — + * no intermediate view to click through. Confirmation via HUD so it shows even after + * the launcher window closes. + */ +export default async function BackUpNow() { + await new Promise((resolve) => { + execFile("deja-dup", ["--backup"], async (err) => { + if (err) { + await showToast({ + style: Toast.Style.Failure, + title: "Could not start backup", + message: err.message, + }); + } else { + await showHUD("Backup started in Déjà Dup"); + } + resolve(); + }); + }); +} diff --git a/extensions/deja-dup/src/backup-status.tsx b/extensions/deja-dup/src/backup-status.tsx new file mode 100644 index 00000000..729894c9 --- /dev/null +++ b/extensions/deja-dup/src/backup-status.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { Action, ActionPanel, Color, Detail, Icon, Toast, showToast } from "@vicinae/api"; +import { execFile } from "node:child_process"; +import { BackupStatus, RepoStats, readStatus, repoStats } from "./lib/deja-dup"; +import { formatBytes, fullDate, relativeTime } from "./lib/format"; +import { launchDejaDup } from "./browse-backup"; + +export default function BackupStatusView() { + const [status, setStatus] = useState(null); + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + readStatus() + .then(setStatus) + .catch((e) => setError(e instanceof Error ? e.message : String(e))); + repoStats() + .then(setStats) + .catch(() => undefined); + }, []); + + const M = Detail.Metadata; + + const markdown = error + ? `# Backup Status\n\n${error}` + : status + ? [ + "# Déjà Dup", + "", + `Last backup **${relativeTime(status.lastBackup)}**.`, + ].join("\n") + : "# Déjà Dup\n\nLoading status…"; + + return ( + + + + + + + + + + {stats && (stats.total_size || stats.snapshots_count) ? ( + + ) : null} + + {status.includeList.length > 0 && ( + + {status.includeList.map((p) => ( + + ))} + + )} + {status.excludeList.length > 0 && ( + + {status.excludeList.map((p) => ( + + ))} + + )} + + ) : undefined + } + actions={ + + + + + } + /> + ); +} + +async function backupNow() { + const toast = await showToast({ style: Toast.Style.Animated, title: "Starting backup…" }); + execFile("deja-dup", ["--backup"], (err) => { + if (err) { + toast.style = Toast.Style.Failure; + toast.title = "Could not start backup"; + toast.message = err.message; + } else { + toast.style = Toast.Style.Success; + toast.title = "Backup started in Déjà Dup"; + } + }); +} diff --git a/extensions/deja-dup/src/browse-backup.tsx b/extensions/deja-dup/src/browse-backup.tsx new file mode 100644 index 00000000..2b99e4d2 --- /dev/null +++ b/extensions/deja-dup/src/browse-backup.tsx @@ -0,0 +1,499 @@ +import { useEffect, useRef, useState } from "react"; +import { + Action, + ActionPanel, + Color, + Detail, + Form, + Icon, + List, + Toast, + showToast, + useNavigation, + open, + environment, +} from "@vicinae/api"; +import { execFile } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join, basename } from "node:path"; +import { + DejaConfig, + IndexEntry, + Snapshot, + buildFileIndex, + dumpFile, + hasIndex, + listDir, + listDirFromLines, + listSnapshots, + loadIndex, + pruneOrphanIndexes, + readConfig, + restorePath, +} from "./lib/deja-dup"; +import { useCached } from "./lib/cache"; +import { + duration, + formatBytes, + fullDate, + recencyBucket, + relativeTime, + shortDateTime, +} from "./lib/format"; + +const BUCKET_ORDER = ["Today", "This Week", "This Month", "This Year", "Older"]; + +export default function BrowseBackup() { + const cfg = useCached("config", readConfig); + const snaps = useCached("snapshots", listSnapshots); + + useEffect(() => { + if (snaps.data) pruneOrphanIndexes(snaps.data.map((s) => s.short_id)); + }, [snaps.data]); + + const error = cfg.error || snaps.error; + if (error && !snaps.data) return ; + + const config = cfg.data; + const snapshots = snaps.data ?? []; + + const grouped = new Map(); + for (const s of snapshots) { + const bucket = recencyBucket(s.time); + (grouped.get(bucket) ?? grouped.set(bucket, []).get(bucket)!).push(s); + } + + return ( + + + {BUCKET_ORDER.filter((b) => grouped.has(b)).map((bucket) => ( + + {grouped.get(bucket)!.map((s) => ( + + ))} + + ))} + + ); +} + +function SnapshotItem({ snapshot: s, config }: { snapshot: Snapshot; config: DejaConfig | null }) { + return ( + } + actions={ + config ? ( + + } + /> + + + ) : undefined + } + /> + ); +} + +function SnapshotDetail({ snapshot: s }: { snapshot: Snapshot }) { + const M = List.Item.Detail.Metadata; + const sum = s.summary; + const dur = duration(sum?.backup_start, sum?.backup_end); + return ( + + + + {dur && } + + + {s.program_version && } + + {sum?.total_files_processed != null && ( + + )} + {sum?.total_bytes_processed != null && ( + + )} + {sum?.data_added != null && sum.data_added > 0 && ( + + )} + {(sum?.files_new != null || sum?.files_changed != null) && ( + + {sum?.files_new ? : null} + {sum?.files_changed ? : null} + {sum?.files_unmodified ? : null} + + )} + + + {s.paths.map((p) => ( + + ))} + + {s.tags && s.tags.length > 0 && ( + + {s.tags.map((t) => ( + + ))} + + )} + + + } + /> + ); +} + +interface BrowseNode { + name: string; + type: string; + path: string; + size?: number; + mtime?: string; +} + +type LoadFn = (path: string) => Promise; + +function toBrowseNodes(entries: BrowseNode[]): BrowseNode[] { + return entries.map((e) => ({ + name: e.name, + type: e.type, + path: e.path, + size: e.size, + mtime: e.mtime, + })); +} + +/** + * Decides how to browse a snapshot: from a local index (instant) if one exists, otherwise + * offers to build it once (making both browsing AND search instant) or to browse live via + * restic (correct but several seconds per folder on a cloud backend). + */ +function BrowseGate({ config, snapshot }: { config: DejaConfig; snapshot: Snapshot }) { + const rootPath = snapshot.paths.length === 1 ? snapshot.paths[0] : "/"; + const [mode, setMode] = useState<"checking" | "choose" | "building" | "index" | "live">( + "checking", + ); + const [progress, setProgress] = useState(0); + const linesRef = useRef([]); + + useEffect(() => { + (async () => { + if (await hasIndex(snapshot.short_id)) { + linesRef.current = await loadIndex(snapshot.short_id); + setMode("index"); + } else { + setMode("choose"); + } + })(); + }, []); + + async function build() { + setMode("building"); + setProgress(0); + const toast = await showToast({ style: Toast.Style.Animated, title: "Building index…" }); + try { + await buildFileIndex( + snapshot, + (c) => { + setProgress(c); + toast.message = `${c.toLocaleString()} files`; + }, + config, + ); + linesRef.current = await loadIndex(snapshot.short_id); + toast.style = Toast.Style.Success; + toast.title = "Instant browsing ready"; + setMode("index"); + } catch (e) { + toast.style = Toast.Style.Failure; + toast.title = "Index build failed"; + toast.message = e instanceof Error ? e.message : String(e); + setMode("choose"); + } + } + + if (mode === "checking" || mode === "building") { + const md = + mode === "building" + ? `# Building index…\n\nIndexed **${progress.toLocaleString()}** files.\n\nThis reads the snapshot once so browsing and search become instant. It can take up to a minute on a cloud backup.` + : "# Loading…"; + return ; + } + + if (mode === "choose") { + return ( + + + setMode("live")} /> + + + } + /> + ); + } + + const load: LoadFn = + mode === "index" + ? (p) => Promise.resolve(toBrowseNodes(listDirFromLines(linesRef.current, p))) + : (p) => listDir(snapshot.id, p, config).then(toBrowseNodes); + + return ; +} + +function FileBrowser({ + load, + mode, + config, + snapshot, + path, +}: { + load: LoadFn; + mode: string; + config: DejaConfig; + snapshot: Snapshot; + path: string; +}) { + const { data, isLoading, isFirstLoad, error } = useCached( + `browse:${mode}:${snapshot.id}:${path}`, + () => load(path), + ); + const nodes = data ?? []; + + if (error && !data) return ; + + const dirs = nodes.filter((n) => n.type === "dir"); + const files = nodes.filter((n) => n.type !== "dir"); + const title = path === "/" ? "/" : path; + + return ( + + + + {dirs.map((n) => ( + + ))} + + + {files.map((n) => ( + + ))} + + + ); +} + +function FileItem({ + node, + load, + mode, + config, + snapshot, +}: { + node: BrowseNode; + load: LoadFn; + mode: string; + config: DejaConfig; + snapshot: Snapshot; +}) { + const isDir = node.type === "dir"; + return ( + + } + /> + ); +} + +function FileActions({ + node, + load, + mode, + config, + snapshot, +}: { + node: BrowseNode; + load: LoadFn; + mode: string; + config: DejaConfig; + snapshot: Snapshot; +}) { + if (node.type === "dir") { + return ( + + + } + /> + + + ); + } + return ( + + previewFile(config, snapshot, node)} /> + + + + ); +} + +function RestoreActions({ + config, + snapshot, + node, +}: { + config: DejaConfig; + snapshot: Snapshot; + node: BrowseNode; +}) { + return ( + <> + runRestore(config, snapshot, node.path, "/")} + /> + } + /> + + ); +} + +function RestoreForm({ + config, + snapshot, + node, +}: { + config: DejaConfig; + snapshot: Snapshot; + node: BrowseNode; +}) { + const { pop } = useNavigation(); + const [target, setTarget] = useState(join(process.env.HOME || "/tmp", "Restored")); + return ( +
+ { + const dest = (values.target as string) || target; + await runRestore(config, snapshot, node.path, dest); + pop(); + }} + /> + + } + > + + + + ); +} + +async function previewFile(config: DejaConfig, snapshot: Snapshot, node: BrowseNode) { + const toast = await showToast({ style: Toast.Style.Animated, title: `Fetching ${node.name}…` }); + try { + const dest = join(environment.supportPath || tmpdir(), `preview-${basename(node.path)}`); + await dumpFile(snapshot.id, node.path, dest, config); + toast.style = Toast.Style.Success; + toast.title = "Opening preview"; + await open(dest); + } catch (e) { + toast.style = Toast.Style.Failure; + toast.title = "Preview failed"; + toast.message = e instanceof Error ? e.message : String(e); + } +} + +async function runRestore(config: DejaConfig, snapshot: Snapshot, path: string, targetDir: string) { + const toast = await showToast({ style: Toast.Style.Animated, title: `Restoring ${basename(path)}…` }); + try { + await restorePath(snapshot.id, path, targetDir, config); + toast.style = Toast.Style.Success; + toast.title = "Restored"; + toast.message = `${basename(path)} → ${targetDir}`; + } catch (e) { + toast.style = Toast.Style.Failure; + toast.title = "Restore failed"; + toast.message = e instanceof Error ? e.message : String(e); + } +} + +export function launchDejaDup() { + execFile("deja-dup", (err) => { + if (err) showToast({ style: Toast.Style.Failure, title: "Could not launch Déjà Dup" }); + }); +} + +export function ErrorView({ message }: { message: string }) { + return ( + + + + } + /> + ); +} diff --git a/extensions/deja-dup/src/index-latest.ts b/extensions/deja-dup/src/index-latest.ts new file mode 100644 index 00000000..461b0efd --- /dev/null +++ b/extensions/deja-dup/src/index-latest.ts @@ -0,0 +1,28 @@ +import { + autoIndexEnabled, + buildFileIndex, + hasIndex, + listSnapshots, + pruneOrphanIndexes, +} from "./lib/deja-dup"; + +/** + * Background command (runs on an interval). Always cleans up orphaned indexes, and — when + * "Automatic Indexing" is enabled — builds the index for the latest snapshot ahead of time, + * so the first Browse/Search after a new backup is instant instead of downloading the file + * listing on demand (metadata scales with file count — e.g. ~170 MB for ~1.3M files). + */ +export default async function IndexLatest() { + try { + const snaps = await listSnapshots(); + await pruneOrphanIndexes(snaps.map((s) => s.short_id)); + + if (!autoIndexEnabled()) return; + const latest = snaps[0]; + if (!latest || (await hasIndex(latest.short_id))) return; + + await buildFileIndex(latest, () => undefined); + } catch { + // Background run: fail silently (e.g. locked keyring, offline). Retried next interval. + } +} diff --git a/extensions/deja-dup/src/lib/cache.ts b/extensions/deja-dup/src/lib/cache.ts new file mode 100644 index 00000000..eb608dae --- /dev/null +++ b/extensions/deja-dup/src/lib/cache.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { Cache } from "@vicinae/api"; + +const store = new Cache({ namespace: "deja-dup", capacity: 25 * 1024 * 1024 }); + +export function readCache(key: string): T | null { + const raw = store.get(key); + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export function writeCache(key: string, value: T): void { + try { + store.set(key, JSON.stringify(value)); + } catch { + // cache write failures are non-fatal + } +} + +export interface CachedResult { + data: T | null; + isLoading: boolean; + error: string | null; + /** True while the first-ever (uncached) fetch is running. */ + isFirstLoad: boolean; + revalidate: () => void; +} + +/** + * Stale-while-revalidate: returns cached data instantly (from disk, synchronously), + * then refreshes in the background and updates. Makes repeat navigation feel instant + * even though each restic call over a cloud backend takes several seconds. + */ +export function useCached(key: string, fetcher: () => Promise): CachedResult { + const [data, setData] = useState(() => readCache(key)); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + fetcher() + .then((result) => { + if (cancelled) return; + setData(result); + writeCache(key, result); + setError(null); + }) + .catch((e) => { + if (cancelled) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [key, nonce]); + + return { + data, + isLoading, + error, + isFirstLoad: isLoading && data === null, + revalidate: () => setNonce((n) => n + 1), + }; +} diff --git a/extensions/deja-dup/src/lib/deja-dup.ts b/extensions/deja-dup/src/lib/deja-dup.ts new file mode 100644 index 00000000..89bc0f1e --- /dev/null +++ b/extensions/deja-dup/src/lib/deja-dup.ts @@ -0,0 +1,974 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir, hostname } from "node:os"; +import { environment, getPreferenceValues } from "@vicinae/api"; + +const execFileP = promisify(execFile); + +/** Déjà Dup's own registered Google OAuth client (from libdeja). Client-id only, no secret. */ +const GOOGLE_CLIENT_ID = + "916137916439-evn6skqan91m96fmsskk8102e3iepv22.apps.googleusercontent.com"; +const GOOGLE_TOKEN_URL = "https://www.googleapis.com/oauth2/v4/token"; + +export interface Preferences { + resticPath: string; + rclonePath: string; + cacheDir: string; + autoIndex?: boolean; + dejaDupFlavor?: string; + backupPassword?: string; +} + +export function autoIndexEnabled(): boolean { + return getPreferenceValues().autoIndex === true; +} + +export function prefs(): Preferences { + const p = getPreferenceValues(); + return { + resticPath: p.resticPath?.trim() || "restic", + rclonePath: p.rclonePath?.trim() || "rclone", + cacheDir: p.cacheDir?.trim() || join(homedir(), ".cache", "deja-dup", "restic"), + dejaDupFlavor: p.dejaDupFlavor?.trim() || "auto", + backupPassword: p.backupPassword ?? "", + }; +} + +/** Thrown when the backup exists but this extension cannot read it (e.g. duplicity). */ +export class UnsupportedError extends Error {} + +/* --------------------------------------------------------------------------- + * Installation flavor (native / flatpak / snap) + * + * Native and Snap (classic confinement) expose Déjà Dup's GSettings, keyring and a + * usable restic to the host. A Flatpak sandboxes all three, so we detect it and adapt + * (read the GSettings keyfile directly, run the bundled restic via `flatpak run`), and + * fall back to the manual "Backup Password" preference where the keyring is unreachable. + * ------------------------------------------------------------------------- */ + +export type Flavor = "native" | "flatpak" | "snap"; +const FLATPAK_APP = "org.gnome.DejaDup"; + +let flavorCache: Flavor | null = null; + +async function ok(cmd: string, args: string[]): Promise { + try { + await execFileP(cmd, args, { timeout: 5000 }); + return true; + } catch { + return false; + } +} + +export async function getFlavor(): Promise { + const override = prefs().dejaDupFlavor; + if (override === "native" || override === "flatpak" || override === "snap") return override; + if (flavorCache) return flavorCache; + + let detected: Flavor = "native"; + if (existsSync(join(homedir(), ".var", "app", FLATPAK_APP)) || (await ok("flatpak", ["info", FLATPAK_APP]))) { + detected = "flatpak"; + } else if (existsSync("/snap/deja-dup/current") || (await ok("snap", ["list", "deja-dup"]))) { + detected = "snap"; + } + flavorCache = detected; + return detected; +} + +export type Backend = string; + +export interface DejaConfig { + backend: Backend; + tool: string; + /** Backend-specific target folder / path. */ + folder: string; + /** rclone remote name (only for the "rclone" backend). */ + rcloneRemote?: string; + /** Network URI (only for the "remote" backend), e.g. smb://…, sftp://…, davs://…. */ + remoteUri?: string; + /** Removable-drive identity (only for the "drive" backend). */ + driveUuid?: string; + driveName?: string; +} + +/* --------------------------------------------------------------------------- + * GSettings reading — from host `gsettings` (native/Snap) or, for a Flatpak + * install, straight from the sandboxed keyfile the Flatpak writes instead of dconf. + * ------------------------------------------------------------------------- */ + +const FLATPAK_KEYFILE = join( + homedir(), + ".var/app", + FLATPAK_APP, + "config/glib-2.0/settings/keyfile", +); + +// dconf paths (verified: schemas map to /org/gnome/deja-dup//, lower-cased). +const SCHEMA_SUFFIX: Record = { + "org.gnome.DejaDup": "deja-dup", + "org.gnome.DejaDup.Local": "deja-dup/local", + "org.gnome.DejaDup.Google": "deja-dup/google", + "org.gnome.DejaDup.Microsoft": "deja-dup/microsoft", + "org.gnome.DejaDup.Remote": "deja-dup/remote", + "org.gnome.DejaDup.Rclone": "deja-dup/rclone", + "org.gnome.DejaDup.Drive": "deja-dup/drive", +}; + +let keyfileCache: Map> | null = null; + +async function readKeyfile(): Promise>> { + if (keyfileCache) return keyfileCache; + const groups = new Map>(); + try { + const raw = await readFile(FLATPAK_KEYFILE, "utf8"); + let cur = ""; + for (const line of raw.split("\n")) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + const gm = t.match(/^\[(.+)\]$/); + if (gm) { + cur = gm[1]; + groups.set(cur, new Map()); + continue; + } + const eq = t.indexOf("="); + if (eq > 0 && cur) groups.get(cur)!.set(t.slice(0, eq).trim(), t.slice(eq + 1).trim()); + } + } catch { + // keyfile missing — leave empty + } + keyfileCache = groups; + return groups; +} + +function keyfileValue(groups: Map>, schema: string): Map | undefined { + const suffix = SCHEMA_SUFFIX[schema]; + if (!suffix) return undefined; + return ( + groups.get(`org/gnome/${suffix}`) ?? + groups.get(suffix) ?? + [...groups.entries()].find(([g]) => g.endsWith(suffix))?.[1] + ); +} + +function unquote(v: string): string { + return v.trim().replace(/^'(.*)'$/, "$1"); +} + +function parseList(v: string): string[] { + const m = v.match(/'([^']*)'/g); + return m ? m.map((s) => s.slice(1, -1)) : []; +} + +async function readSetting(schema: string, key: string): Promise { + if ((await getFlavor()) === "flatpak") { + const raw = keyfileValue(await readKeyfile(), schema)?.get(key); + return raw != null ? unquote(raw) : ""; + } + return execFileP("gsettings", ["get", schema, key]) + .then((r) => unquote(r.stdout.trim())) + .catch(() => ""); +} + +async function readSettingList(schema: string, key: string): Promise { + if ((await getFlavor()) === "flatpak") { + const raw = keyfileValue(await readKeyfile(), schema)?.get(key); + return raw != null ? parseList(raw) : []; + } + return execFileP("gsettings", ["get", schema, key]) + .then((r) => parseList(r.stdout.trim())) + .catch(() => []); +} + +/** Read Déjà Dup's current backend + tool + target from GSettings. */ +export async function readConfig(): Promise { + const backend = await readSetting("org.gnome.DejaDup", "backend"); + if (!backend) { + throw new UnsupportedError( + "Déjà Dup does not appear to be configured (no backend found).", + ); + } + const tool = await readSetting("org.gnome.DejaDup", "tool"); + + // Déjà Dup 45+ defaults to restic. Only "duplicity"/"borg" produce repos restic cannot read. + if (tool === "duplicity" || tool === "borg") { + throw new UnsupportedError( + `This backup uses "${tool}", which this extension cannot read. Only restic backups are supported.`, + ); + } + + const cfg: DejaConfig = { backend, tool, folder: "" }; + switch (backend) { + case "google": + cfg.folder = expandFolder(await readSetting("org.gnome.DejaDup.Google", "folder")); + break; + case "microsoft": + cfg.folder = expandFolder(await readSetting("org.gnome.DejaDup.Microsoft", "folder")); + break; + case "local": + case "file": + case "auto": + cfg.folder = expandHome(await readSetting("org.gnome.DejaDup.Local", "folder")); + break; + case "rclone": + cfg.folder = expandFolder(await readSetting("org.gnome.DejaDup.Rclone", "folder")); + cfg.rcloneRemote = await readSetting("org.gnome.DejaDup.Rclone", "remote"); + break; + case "remote": + cfg.folder = expandFolder(await readSetting("org.gnome.DejaDup.Remote", "folder")); + cfg.remoteUri = await readSetting("org.gnome.DejaDup.Remote", "uri"); + break; + case "drive": + cfg.folder = expandFolder(await readSetting("org.gnome.DejaDup.Drive", "folder")); + cfg.driveUuid = await readSetting("org.gnome.DejaDup.Drive", "uuid"); + cfg.driveName = await readSetting("org.gnome.DejaDup.Drive", "name"); + break; + default: + break; + } + + return cfg; +} + +/** Déjà Dup uses $HOSTNAME as the default folder token on cloud/remote targets. */ +function expandFolder(p: string): string { + return p === "$HOSTNAME" ? hostname() : p; +} + +function expandHome(p: string): string { + if (p === "$HOME") return homedir(); + if (p.startsWith("$HOME/")) return join(homedir(), p.slice(6)); + if (p.startsWith("~/")) return join(homedir(), p.slice(2)); + if (p && !p.startsWith("/")) return join(homedir(), p); // relative → under $HOME + return p; +} + +/** Look up a secret by attribute pairs via `secret-tool`. Returns "" if not found. */ +async function secretLookup(attrs: string[]): Promise { + try { + const { stdout } = await execFileP("secret-tool", ["lookup", ...attrs], { + maxBuffer: 1024 * 1024, + }); + // secret-tool does not append a trailing newline; trim only a single one defensively. + return stdout.replace(/\n$/, ""); + } catch { + return ""; + } +} + +/** The restic repository passphrase (Déjà Dup "Backup encryption password"). */ +export async function getPassphrase(): Promise { + // A manually entered password wins — the escape hatch for Flatpak or custom keyrings. + const manual = prefs().backupPassword; + if (manual) return manual; + + const pass = await secretLookup(["owner", "deja-dup", "type", "passphrase"]); + if (pass) return pass; + + const flavor = await getFlavor(); + if (flavor === "flatpak") { + throw new UnsupportedError( + "Déjà Dup is a Flatpak, so its backup password is stored in the app's own sandboxed keyring and can't be read from the host. Enter it under “Backup Password” in this extension's settings (⌘/Ctrl+,), or use the native/Snap Déjà Dup.", + ); + } + throw new UnsupportedError( + "Could not find the backup password in the keyring. Open Déjà Dup once to unlock it, or set it under “Backup Password” in this extension's settings.", + ); +} + +interface CachedToken { + access_token: string; + /** Epoch milliseconds when the token expires. */ + expires_at: number; +} + +async function tokenCachePath(name: string): Promise { + const dir = environment.supportPath; + await mkdir(dir, { recursive: true }); + return join(dir, `${name}-token.json`); +} + +/** + * Exchange Déjà Dup's stored Google refresh token for a fresh access token, + * caching it in the extension support dir until shortly before it expires. + */ +async function getGoogleAccessToken(): Promise<{ token: string; expiry: string }> { + const cachePath = await tokenCachePath("google"); + const now = Date.now(); + + try { + const cached: CachedToken = JSON.parse(await readFile(cachePath, "utf8")); + if (cached.access_token && cached.expires_at - 60_000 > now) { + return { token: cached.access_token, expiry: new Date(cached.expires_at).toISOString() }; + } + } catch { + // no / invalid cache — fall through to refresh + } + + const refresh = + (await secretLookup([ + "xdg:schema", + "org.gnome.DejaDup.Google", + "client_id", + GOOGLE_CLIENT_ID, + ])) || (await secretLookup(["client_id", GOOGLE_CLIENT_ID])); + + if (!refresh) { + throw new UnsupportedError( + "Could not find the Google Drive token in the keyring. Open Déjà Dup once to reconnect the account, then try again.", + ); + } + + const body = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + refresh_token: refresh, + grant_type: "refresh_token", + }); + const res = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!res.ok) { + throw new Error(`Google token refresh failed (HTTP ${res.status}). Re-authorise Déjà Dup.`); + } + const data = (await res.json()) as { access_token?: string; expires_in?: number }; + if (!data.access_token) { + throw new Error("Google token refresh returned no access token."); + } + const expiresAt = now + (data.expires_in ?? 3600) * 1000; + const cached: CachedToken = { access_token: data.access_token, expires_at: expiresAt }; + await writeFile(cachePath, JSON.stringify(cached), { mode: 0o600 }); + + return { token: data.access_token, expiry: new Date(expiresAt).toISOString() }; +} + +/* --- Microsoft OneDrive (Personal) --- */ +const MS_CLIENT_ID = "5291592c-3c09-44fb-a275-5027aa238645"; +const MS_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; +const MS_GRAPH_DRIVE = "https://graph.microsoft.com/v1.0/me/drive?select=id"; + +interface CachedMsAuth { + tokenJson: string; + driveId: string; + expires_at: number; +} + +async function getMicrosoftAuth(): Promise<{ tokenJson: string; driveId: string }> { + const cachePath = await tokenCachePath("microsoft"); + const now = Date.now(); + try { + const c: CachedMsAuth = JSON.parse(await readFile(cachePath, "utf8")); + if (c.tokenJson && c.driveId && c.expires_at - 60_000 > now) { + return { tokenJson: c.tokenJson, driveId: c.driveId }; + } + } catch { + // fall through to refresh + } + + const refresh = + (await secretLookup(["xdg:schema", "org.gnome.DejaDup.Microsoft", "client_id", MS_CLIENT_ID])) || + (await secretLookup(["client_id", MS_CLIENT_ID])); + if (!refresh) { + throw new UnsupportedError( + "Could not find the OneDrive token in the keyring. Open Déjà Dup once to reconnect OneDrive, then try again.", + ); + } + + const body = new URLSearchParams({ + client_id: MS_CLIENT_ID, + refresh_token: refresh, + grant_type: "refresh_token", + scope: "offline_access Files.ReadWrite", + }); + const res = await fetch(MS_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!res.ok) throw new Error(`OneDrive token refresh failed (HTTP ${res.status}). Re-authorise Déjà Dup.`); + const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number }; + if (!data.access_token) throw new Error("OneDrive token refresh returned no access token."); + + const expiresAt = now + (data.expires_in ?? 3600) * 1000; + const tokenJson = JSON.stringify({ + access_token: data.access_token, + token_type: "Bearer", + refresh_token: data.refresh_token ?? refresh, + expiry: new Date(expiresAt).toISOString(), + }); + + const dres = await fetch(MS_GRAPH_DRIVE, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }); + if (!dres.ok) throw new Error(`Could not read the OneDrive drive id (HTTP ${dres.status}).`); + const drive = (await dres.json()) as { id?: string }; + if (!drive.id) throw new Error("Could not determine the OneDrive drive id."); + + await writeFile(cachePath, JSON.stringify({ tokenJson, driveId: drive.id, expires_at: expiresAt }), { + mode: 0o600, + }); + return { tokenJson, driveId: drive.id }; +} + +/* --- Network (gvfs) and removable-drive mount resolution --- */ + +async function resolveRemoteMount(uri: string, folder: string): Promise { + let host = ""; + try { + host = new URL(uri).hostname; + } catch { + throw new UnsupportedError(`Could not parse the network address "${uri}".`); + } + const uid = typeof process.getuid === "function" ? process.getuid() : 1000; + const gvfsDir = `/run/user/${uid}/gvfs`; + const entries = await readdir(gvfsDir).catch(() => [] as string[]); + const mount = entries.find((e) => e.includes(`host=${host}`) || e.includes(`server=${host}`)); + if (!mount) { + throw new UnsupportedError( + `The network location ${uri} isn't mounted. Open it once in your file manager (Files) so it mounts, then try again.`, + ); + } + const base = join(gvfsDir, mount); + let sub = ""; + try { + sub = decodeURIComponent(new URL(uri).pathname).replace(/^\/+/, ""); + } catch { + /* ignore */ + } + // For SMB the share is already part of the mount name; drop the leading share segment. + if (mount.startsWith("smb-share:")) sub = sub.split("/").slice(1).join("/"); + const repo = join(base, sub, folder); + if (!existsSync(repo)) { + throw new UnsupportedError( + `The backup folder wasn't found at the mounted network location (${repo}). Make sure it's mounted and the path is correct.`, + ); + } + return repo; +} + +async function resolveDriveMount(uuid: string, name: string, folder: string): Promise { + if (!uuid) { + throw new UnsupportedError("The backup drive is not identified in Déjà Dup's settings."); + } + const target = await execFileP("findmnt", ["-rn", "-S", `UUID=${uuid}`, "-o", "TARGET"]) + .then((r) => r.stdout.trim().split("\n")[0]) + .catch(() => ""); + if (!target) { + throw new UnsupportedError( + `The backup drive${name ? ` “${name}”` : ""} isn't connected/mounted. Plug it in and mount it, then try again.`, + ); + } + return join(target, folder); +} + +export interface ResolvedRepo { + repoUrl: string; + env: NodeJS.ProcessEnv; +} + +/** Build the restic repo URL and the environment needed to reach it for the given backend. */ +export async function resolveRepo(config: DejaConfig): Promise { + const flavor = await getFlavor(); + const isLocalish = + config.backend === "local" || + config.backend === "file" || + config.backend === "auto" || + config.backend === "remote" || + config.backend === "drive"; + + // A Flatpak's cloud tokens live in its sandboxed keyring, unreachable from the host. + if (flavor === "flatpak" && !isLocalish) { + throw new UnsupportedError( + "Déjà Dup is a Flatpak, and cloud backups (Google Drive, OneDrive, rclone) keep their tokens in the app's sandboxed keyring, which can't be reached from here. Use the native or Snap Déjà Dup for cloud backends. Local, network and drive backups still work.", + ); + } + + const passphrase = await getPassphrase(); + const env: NodeJS.ProcessEnv = { RESTIC_PASSWORD: passphrase, RCLONE_ASK_PASSWORD: "false" }; + + switch (config.backend) { + case "local": + case "file": + case "auto": + if (!config.folder) throw new UnsupportedError("Local backup folder is not configured."); + return { repoUrl: config.folder, env }; + + case "remote": + if (!config.remoteUri) throw new UnsupportedError("Network backup location is not configured."); + return { repoUrl: await resolveRemoteMount(config.remoteUri, config.folder), env }; + + case "drive": + return { + repoUrl: await resolveDriveMount(config.driveUuid ?? "", config.driveName ?? "", config.folder), + env, + }; + + case "google": { + const { token, expiry } = await getGoogleAccessToken(); + env.RCLONE_DRIVE_CLIENT_ID = GOOGLE_CLIENT_ID; + env.RCLONE_DRIVE_SCOPE = "drive.file"; + env.RCLONE_DRIVE_USE_TRASH = "false"; + env.RCLONE_CONFIG = ""; + env.RCLONE_DRIVE_TOKEN = JSON.stringify({ access_token: token, token_type: "Bearer", expiry }); + return { repoUrl: `rclone::drive:${config.folder}`, env }; + } + + case "microsoft": { + const { tokenJson, driveId } = await getMicrosoftAuth(); + env.RCLONE_CONFIG = ""; + env.RCLONE_ONEDRIVE_CLIENT_ID = MS_CLIENT_ID; + env.RCLONE_ONEDRIVE_TOKEN = tokenJson; + env.RCLONE_ONEDRIVE_DRIVE_ID = driveId; + env.RCLONE_ONEDRIVE_DRIVE_TYPE = "personal"; + return { repoUrl: `rclone::onedrive:${config.folder}`, env }; + } + + case "rclone": { + if (!config.rcloneRemote) throw new UnsupportedError("rclone remote is not configured."); + const pass = await secretLookup(["xdg:schema", "org.gnome.DejaDup.Rclone", "type", "password"]); + if (pass) env.RCLONE_CONFIG_PASS = pass; + // Named remote from the user's rclone.conf → single colon (not an on-the-fly connection string). + return { repoUrl: `rclone:${config.rcloneRemote}:${config.folder}`, env }; + } + + default: + throw new UnsupportedError( + `The "${config.backend}" backend isn't supported. Works today: local, network, drive, Google Drive, OneDrive, rclone.`, + ); + } +} + +/* --------------------------------------------------------------------------- + * restic invocation + * ------------------------------------------------------------------------- */ + +export interface Snapshot { + id: string; + short_id: string; + time: string; + hostname: string; + username: string; + paths: string[]; + tags?: string[]; + program_version?: string; + summary?: { + total_files_processed?: number; + total_bytes_processed?: number; + files_new?: number; + files_changed?: number; + files_unmodified?: number; + data_added?: number; + backup_start?: string; + backup_end?: string; + }; +} + +export interface ResticNode { + name: string; + type: "file" | "dir" | "symlink" | string; + path: string; + size?: number; + mode?: number; + mtime?: string; + uid?: number; + gid?: number; +} + +function humanExit(code: number | null, stderr: string): string { + switch (code) { + case 10: + return "Backup repository not found or unreachable."; + case 11: + return "Repository is locked by another operation."; + case 12: + return "Wrong backup password."; + case 130: + return "Cancelled."; + default: + return stderr.trim().split("\n").slice(-3).join("\n") || `restic exited with code ${code}.`; + } +} + +async function buildEnvAndBase(config?: DejaConfig): Promise<{ + repoUrl: string; + cmd: string; + env: NodeJS.ProcessEnv; + base: string[]; +}> { + const cfg = config ?? (await readConfig()); + const { repoUrl, env } = await resolveRepo(cfg); + const p = prefs(); + const flavor = await getFlavor(); + const resticArgs = [ + "--repo", + repoUrl, + "--no-lock", + "--cache-dir", + p.cacheDir, + "--option", + `rclone.program=${flavor === "flatpak" ? "rclone" : p.rclonePath}`, + ]; + + if (flavor === "flatpak") { + // Run the Flatpak's bundled restic inside the sandbox. flatpak filters the environment, + // so the backend env must be passed as explicit --env= flags before the app id. + const envFlags = Object.entries(env).map(([k, v]) => `--env=${k}=${v ?? ""}`); + return { + repoUrl, + cmd: "flatpak", + base: ["run", "--filesystem=host", ...envFlags, "--command=restic", FLATPAK_APP, ...resticArgs], + env: { ...process.env }, + }; + } + + // native + snap (classic confinement) → host restic works directly. + return { repoUrl, cmd: p.resticPath, base: resticArgs, env: { ...process.env, ...env } }; +} + +/** Run a restic subcommand, returning stdout. Throws a human-readable Error on failure. */ +export async function restic(args: string[], config?: DejaConfig): Promise { + const { cmd, env, base } = await buildEnvAndBase(config); + try { + const { stdout } = await execFileP(cmd, [...base, ...args], { + env, + maxBuffer: 64 * 1024 * 1024, + }); + return stdout; + } catch (err) { + const e = err as { code?: number; stderr?: string }; + // Exit code 3 = could not read some source files; output is still valid. + if (e.code === 3 && typeof (err as { stdout?: string }).stdout === "string") { + return (err as { stdout: string }).stdout; + } + throw new Error(humanExit(e.code ?? null, e.stderr ?? "")); + } +} + +export async function listSnapshots(config?: DejaConfig): Promise { + const out = await restic(["snapshots", "--json"], config); + const arr = JSON.parse(out || "[]") as Snapshot[]; + return arr.sort((a, b) => (a.time < b.time ? 1 : -1)); +} + +/** + * List one directory level inside a snapshot. `restic ls` emits NDJSON: the first + * line is the snapshot object, the rest are nodes. Without `--recursive` and with an + * explicit directory path, restic returns only that subtree's direct children (plus the + * ancestor dirs it traverses, which we filter out). Passing no path would list the ENTIRE + * snapshot recursively — for a large home dir that is hundreds of MB, so always pass one. + */ +export async function listDir( + snapshotId: string, + path: string, + config?: DejaConfig, +): Promise { + const dir = path && path !== "" ? path : "/"; + const out = await restic(["ls", snapshotId, dir, "--json"], config); + + const nodes: ResticNode[] = []; + const wanted = dir === "/" ? "" : dir.replace(/\/$/, ""); + for (const line of out.split("\n")) { + if (!line.trim()) continue; + let obj: ResticNode & { struct_type?: string }; + try { + obj = JSON.parse(line); + } catch { + continue; + } + if (!obj.path || obj.struct_type === "snapshot" || !obj.type) continue; + const parent = obj.path.slice(0, obj.path.lastIndexOf("/")) || ""; + if (parent === wanted) nodes.push(obj); + } + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return nodes; +} + +/* --------------------------------------------------------------------------- + * Local search index + * + * `restic find` over a cloud backend re-walks every snapshot's tree and takes + * many seconds per query — unusable for interactive search. Instead we walk one + * snapshot ONCE (streamed, so a million files never sit in memory), write a compact + * index to disk, and search it locally and instantly afterwards. + * ------------------------------------------------------------------------- */ + +export interface IndexEntry { + path: string; + name: string; + type: string; + size: number; +} + +export interface IndexMeta { + snapshotId: string; + shortId: string; + count: number; + builtAt: number; +} + +function indexPaths(shortId: string) { + const dir = environment.supportPath; + return { + data: join(dir, `index-${shortId}.tsv`), + meta: join(dir, `index-${shortId}.meta.json`), + }; +} + +export async function readIndexMeta(shortId: string): Promise { + try { + return JSON.parse(await readFile(indexPaths(shortId).meta, "utf8")) as IndexMeta; + } catch { + return null; + } +} + +export async function hasIndex(shortId: string): Promise { + return (await readIndexMeta(shortId)) !== null; +} + +/** + * Delete index files for snapshots that no longer exist in the repository (e.g. pruned by + * Déjà Dup's retention). A new backup keeps its own new index; only orphaned ones are removed. + */ +export async function pruneOrphanIndexes(validShortIds: string[]): Promise { + const keep = new Set(validShortIds); + try { + for (const f of await readdir(environment.supportPath)) { + const m = f.match(/^index-(.+)\.(tsv|meta\.json)$/); + if (m && !keep.has(m[1])) { + await unlink(join(environment.supportPath, f)).catch(() => undefined); + } + } + } catch { + // support dir may not exist yet — nothing to prune + } +} + +/** + * Walk a snapshot recursively and stream every file/dir into a local TSV index. + * onProgress is called periodically with the running count. + */ +export async function buildFileIndex( + snapshot: Snapshot, + onProgress: (count: number) => void, + config?: DejaConfig, +): Promise { + const cfg = config ?? (await readConfig()); + const { cmd, env, base } = await buildEnvAndBase(cfg); + const { data, meta } = indexPaths(snapshot.short_id); + await mkdir(environment.supportPath, { recursive: true }); + + const { createWriteStream } = await import("node:fs"); + const { spawn } = await import("node:child_process"); + const roots = snapshot.paths.length ? snapshot.paths : ["/"]; + + let count = 0; + const out = createWriteStream(data, { flags: "w" }); + + for (const root of roots) { + await new Promise((resolve, reject) => { + const child = spawn( + cmd, + [...base, "ls", snapshot.id, root, "--recursive", "--json"], + { env }, + ); + let buf = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + buf += chunk; + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (!line) continue; + let obj: { path?: string; name?: string; type?: string; size?: number; struct_type?: string }; + try { + obj = JSON.parse(line); + } catch { + continue; + } + if (!obj.path || obj.struct_type === "snapshot" || !obj.type) continue; + out.write(`${obj.type}\t${obj.size ?? 0}\t${obj.path}\n`); + if (++count % 5000 === 0) onProgress(count); + } + }); + child.stderr.on("data", (d) => (stderr += d.toString())); + child.on("error", reject); + child.on("close", (code) => { + // Exit 3 = some files unreadable; index is still valid. + if (code === 0 || code === 3) resolve(); + else reject(new Error(humanExit(code, stderr))); + }); + }); + } + + await new Promise((resolve) => out.end(resolve)); + const info: IndexMeta = { + snapshotId: snapshot.id, + shortId: snapshot.short_id, + count, + builtAt: Date.now(), + }; + await writeFile(meta, JSON.stringify(info)); + onProgress(count); + return info; +} + +/** Load the on-disk index into memory (array of raw TSV lines). */ +export async function loadIndex(shortId: string): Promise { + try { + const raw = await readFile(indexPaths(shortId).data, "utf8"); + return raw.length ? raw.split("\n") : []; + } catch { + return []; + } +} + +/** Direct children of `path` from a loaded index — the local, instant equivalent of listDir. */ +export function listDirFromLines(lines: string[], path: string): IndexEntry[] { + const wanted = path === "/" || path === "" ? "" : path.replace(/\/$/, ""); + const out: IndexEntry[] = []; + for (const line of lines) { + if (!line) continue; + const firstTab = line.indexOf("\t"); + const secondTab = line.indexOf("\t", firstTab + 1); + if (secondTab < 0) continue; + const p = line.slice(secondTab + 1); + const parent = p.slice(0, p.lastIndexOf("/")) || ""; + if (parent !== wanted) continue; + out.push({ + type: line.slice(0, firstTab), + size: Number(line.slice(firstTab + 1, secondTab)) || 0, + path: p, + name: p.slice(p.lastIndexOf("/") + 1), + }); + } + out.sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return out; +} + +/** Filter pre-loaded index lines by a case-insensitive substring, capped for rendering. */ +export function searchIndex(lines: string[], query: string, limit = 200): IndexEntry[] { + const q = query.toLowerCase(); + const out: IndexEntry[] = []; + for (const line of lines) { + if (!line) continue; + // Match against the path portion (after the second tab) case-insensitively. + const firstTab = line.indexOf("\t"); + const secondTab = line.indexOf("\t", firstTab + 1); + if (secondTab < 0) continue; + const path = line.slice(secondTab + 1); + if (!path.toLowerCase().includes(q)) continue; + out.push({ + type: line.slice(0, firstTab), + size: Number(line.slice(firstTab + 1, secondTab)) || 0, + path, + name: path.slice(path.lastIndexOf("/") + 1), + }); + if (out.length >= limit) break; + } + return out; +} + +/** Dump a single file's contents from a snapshot to a local path (for preview). */ +export async function dumpFile( + snapshotId: string, + path: string, + targetPath: string, + config?: DejaConfig, +): Promise { + const { cmd, env, base } = await buildEnvAndBase(config); + const { createWriteStream } = await import("node:fs"); + const { spawn } = await import("node:child_process"); + await new Promise((resolve, reject) => { + const out = createWriteStream(targetPath, { mode: 0o600 }); + const child = spawn(cmd, [...base, "dump", snapshotId, path], { env }); + let stderr = ""; + child.stdout.pipe(out); + child.stderr.on("data", (d) => (stderr += d.toString())); + child.on("error", reject); + child.on("close", (code) => { + out.close(); + if (code === 0) resolve(); + else reject(new Error(humanExit(code, stderr))); + }); + }); +} + +/** Restore a single file/dir from a snapshot into targetDir. */ +export async function restorePath( + snapshotId: string, + path: string, + targetDir: string, + config?: DejaConfig, +): Promise { + await restic( + ["restore", snapshotId, "--target", targetDir, "--include", path], + config, + ); +} + +export interface RepoStats { + total_size?: number; + total_file_count?: number; + snapshots_count?: number; +} + +export async function repoStats(config?: DejaConfig): Promise { + try { + const out = await restic(["stats", "latest", "--json"], config); + return JSON.parse(out || "{}") as RepoStats; + } catch { + return {}; + } +} + +export interface BackupStatus { + backend: string; + tool: string; + folder: string; + lastBackup: string; + lastRun: string; + periodic: boolean; + periodicPeriod: string; + includeList: string[]; + excludeList: string[]; +} + +/** Read a human-facing status summary straight from GSettings (no repo access needed). */ +export async function readStatus(): Promise { + const cfg = await readConfig(); + const [lastBackup, lastRun, periodic, period, includeList, excludeList] = await Promise.all([ + readSetting("org.gnome.DejaDup", "last-backup"), + readSetting("org.gnome.DejaDup", "last-run"), + readSetting("org.gnome.DejaDup", "periodic"), + readSetting("org.gnome.DejaDup", "periodic-period"), + readSettingList("org.gnome.DejaDup", "include-list"), + readSettingList("org.gnome.DejaDup", "exclude-list"), + ]); + const target = + cfg.remoteUri || (cfg.rcloneRemote ? `${cfg.rcloneRemote}:${cfg.folder}` : cfg.folder); + return { + backend: cfg.backend, + tool: cfg.tool === "unset" ? "restic (auto)" : cfg.tool, + folder: target, + lastBackup, + lastRun, + periodic: periodic === "true", + periodicPeriod: period, + includeList, + excludeList, + }; +} + diff --git a/extensions/deja-dup/src/lib/format.ts b/extensions/deja-dup/src/lib/format.ts new file mode 100644 index 00000000..0dcc728e --- /dev/null +++ b/extensions/deja-dup/src/lib/format.ts @@ -0,0 +1,64 @@ +export function formatBytes(n?: number): string { + if (!n || n <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); + return `${(n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +/** Compact relative time like "2h ago", "3d ago", "Just now". */ +export function relativeTime(iso: string): string { + const then = new Date(iso).getTime(); + if (isNaN(then)) return "—"; + const secs = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (secs < 60) return "Just now"; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return `${weeks}w ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(days / 365)}y ago`; +} + +/** Coarse recency bucket used to group snapshots into list sections. */ +export function recencyBucket(iso: string): string { + const then = new Date(iso).getTime(); + if (isNaN(then)) return "Older"; + const days = (Date.now() - then) / (1000 * 60 * 60 * 24); + if (days < 1) return "Today"; + if (days < 7) return "This Week"; + if (days < 31) return "This Month"; + if (days < 366) return "This Year"; + return "Older"; +} + +export function fullDate(iso: string): string { + const d = new Date(iso); + return isNaN(d.getTime()) ? iso || "—" : d.toLocaleString(); +} + +/** Human duration between two ISO timestamps, e.g. "1h 11m" or "45s". */ +export function duration(startIso?: string, endIso?: string): string | null { + if (!startIso || !endIso) return null; + const ms = new Date(endIso).getTime() - new Date(startIso).getTime(); + if (isNaN(ms) || ms < 0) return null; + const secs = Math.round(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ${secs % 60}s`; + const hours = Math.floor(mins / 60); + return `${hours}h ${mins % 60}m`; +} + +/** Compact, readable date+time for narrow list columns, e.g. "15 Jul, 09:57". */ +export function shortDateTime(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso || "—"; + const date = d.toLocaleDateString(undefined, { day: "2-digit", month: "short" }); + const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + return `${date}, ${time}`; +} diff --git a/extensions/deja-dup/src/search-files.tsx b/extensions/deja-dup/src/search-files.tsx new file mode 100644 index 00000000..03d47be8 --- /dev/null +++ b/extensions/deja-dup/src/search-files.tsx @@ -0,0 +1,248 @@ +import { useEffect, useRef, useState } from "react"; +import { Action, ActionPanel, Color, Detail, Icon, List, Toast, showToast } from "@vicinae/api"; +import { tmpdir } from "node:os"; +import { join, basename } from "node:path"; +import { + DejaConfig, + IndexEntry, + IndexMeta, + Snapshot, + buildFileIndex, + dumpFile, + listSnapshots, + loadIndex, + pruneOrphanIndexes, + readConfig, + readIndexMeta, + restorePath, + searchIndex, +} from "./lib/deja-dup"; +import { formatBytes } from "./lib/format"; +import { ErrorView, launchDejaDup } from "./browse-backup"; +import { open, environment } from "@vicinae/api"; + +type Phase = "loading" | "needs-index" | "building" | "ready" | "error"; + +export default function SearchFiles() { + const [phase, setPhase] = useState("loading"); + const [meta, setMeta] = useState(null); + const [latest, setLatest] = useState(null); + const [config, setConfig] = useState(null); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(0); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const indexRef = useRef([]); + + async function init() { + setPhase("loading"); + try { + const [cfg, snaps] = await Promise.all([readConfig(), listSnapshots()]); + setConfig(cfg); + const newest = snaps[0] ?? null; + setLatest(newest); + pruneOrphanIndexes(snaps.map((s) => s.short_id)); + const m = newest ? await readIndexMeta(newest.short_id) : null; + setMeta(m); + if (m && newest) { + indexRef.current = await loadIndex(newest.short_id); + setPhase("ready"); + } else { + setPhase("needs-index"); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setPhase("error"); + } + } + + useEffect(() => { + init(); + }, []); + + async function build() { + if (!latest) return; + setPhase("building"); + setProgress(0); + const toast = await showToast({ style: Toast.Style.Animated, title: "Building search index…" }); + try { + const info = await buildFileIndex(latest, (c) => { + setProgress(c); + toast.message = `${c.toLocaleString()} files`; + }, config ?? undefined); + indexRef.current = await loadIndex(latest.short_id); + setMeta(info); + toast.style = Toast.Style.Success; + toast.title = "Index ready"; + toast.message = `${info.count.toLocaleString()} files`; + setPhase("ready"); + } catch (e) { + toast.style = Toast.Style.Failure; + toast.title = "Index build failed"; + toast.message = e instanceof Error ? e.message : String(e); + setPhase("needs-index"); + } + } + + // Debounced local filtering — instant once the index is loaded. + useEffect(() => { + if (phase !== "ready") return; + if (query.trim().length < 2) { + setResults([]); + return; + } + const handle = setTimeout(() => { + setResults(searchIndex(indexRef.current, query.trim())); + }, 150); + return () => clearTimeout(handle); + }, [query, phase]); + + if (phase === "error") return ; + + if (phase === "needs-index" || phase === "building" || phase === "loading") { + return ( + + ); + } + + const stale = meta && latest && meta.snapshotId !== latest.id; + return ( + + + {results.map((r) => ( + + {r.type !== "dir" && config && meta && ( + previewFile(config, meta.snapshotId, r)} + /> + )} + {config && meta && ( + runRestore(config, meta.snapshotId, r.path, "/")} + /> + )} + + {stale && ( + + )} + + } + /> + ))} + + ); +} + +function BuildScreen({ + phase, + latest, + meta, + progress, + onBuild, +}: { + phase: Phase; + latest: Snapshot | null; + meta: IndexMeta | null; + progress: number; + onBuild: () => void; +}) { + const md = + phase === "building" + ? [ + "# Building search index…", + "", + `Indexed **${progress.toLocaleString()}** files so far.`, + "", + "This reads your latest backup once so that searching afterwards is instant and offline.", + "It can take up to a minute on a cloud backup — you only do this once per backup.", + ].join("\n") + : phase === "loading" + ? "# Loading…" + : [ + "# Fast file search", + "", + "Searching a cloud backup file-by-file is painfully slow, so this builds a small local", + "index of your latest backup **once**. After that, search is instant and works offline.", + "", + latest ? `Latest snapshot: **${latest.short_id}** (${latest.paths.join(", ")})` : "", + meta ? `\nA previous index exists for **${meta.shortId}** — rebuild it for the latest backup.` : "", + ] + .filter(Boolean) + .join("\n"); + + return ( + + + + + ) + } + /> + ); +} + +async function previewFile(config: DejaConfig, snapshotId: string, entry: IndexEntry) { + const toast = await showToast({ style: Toast.Style.Animated, title: `Fetching ${entry.name}…` }); + try { + const dest = join(environment.supportPath || tmpdir(), `preview-${basename(entry.path)}`); + await dumpFile(snapshotId, entry.path, dest, config); + toast.style = Toast.Style.Success; + toast.title = "Opening preview"; + await open(dest); + } catch (e) { + toast.style = Toast.Style.Failure; + toast.title = "Preview failed"; + toast.message = e instanceof Error ? e.message : String(e); + } +} + +async function runRestore(config: DejaConfig, snapshotId: string, path: string, targetDir: string) { + const toast = await showToast({ style: Toast.Style.Animated, title: `Restoring ${basename(path)}…` }); + try { + await restorePath(snapshotId, path, targetDir, config); + toast.style = Toast.Style.Success; + toast.title = "Restored"; + toast.message = `${basename(path)} → ${targetDir}`; + } catch (e) { + toast.style = Toast.Style.Failure; + toast.title = "Restore failed"; + toast.message = e instanceof Error ? e.message : String(e); + } +} diff --git a/extensions/deja-dup/tsconfig.json b/extensions/deja-dup/tsconfig.json new file mode 100644 index 00000000..66a98a8a --- /dev/null +++ b/extensions/deja-dup/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ES2023"], + "module": "ESNext", + "target": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "isolatedModules": true, + "jsx": "react-jsx", + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +}