mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35418eea3f | ||
|
|
e48c4bb4b2 | ||
|
|
7ad9231858 | ||
|
|
e396c6b4d6 | ||
|
|
b37ab78451 | ||
|
|
6e3d2c335e | ||
|
|
14b6df7bce | ||
|
|
4d76462e62 | ||
|
|
a5e03ad8da |
No files matched your search
Generated
+10
-8
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.3.0"
|
version = "1.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.4",
|
"clap 4.6.4",
|
||||||
@@ -3533,8 +3533,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.3.0"
|
version = "1.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
"keyring",
|
"keyring",
|
||||||
@@ -3553,9 +3554,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.3.0"
|
version = "1.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
"notify",
|
"notify",
|
||||||
@@ -3569,7 +3571,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.3.0"
|
version = "1.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3830,9 +3832,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.3.2"
|
version = "0.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06f6b2b3aaaf90cd3eab823e3c13a4ff784693f183f7fde97f865378c4d3e0bd"
|
checksum = "3abae5e90e6889b120208159eb2c60b332612e5b708fdf3c14287307ce01715d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3857,9 +3859,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.3.2"
|
version = "0.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1037a17f8951cf3ade40a8d2a8230720caf6136383eecc7d61e89636e229793e"
|
checksum = "3fa729310c1a66e12bb41b9f5f036c86b9857e76536556052203ffecdb0b44c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+5
-3
@@ -3,15 +3,15 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.3.0"
|
version = "1.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.3.2" }
|
proton-sdk = { version = "0.5.1" }
|
||||||
proton-drive-rs = { version = "0.3.2" }
|
proton-drive-rs = { version = "0.5.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -40,5 +40,7 @@ rusqlite = { version = "0.32", features = ["bundled"] }
|
|||||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
|
async-trait = "0.1"
|
||||||
|
futures = "0.3"
|
||||||
fuser = "0.17"
|
fuser = "0.17"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
@@ -14,6 +14,8 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
|
|||||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
|
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
|
||||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||||
|
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||||
|
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||||
@@ -25,6 +27,80 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
|
|||||||
|
|
||||||
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
||||||
|
|
||||||
|
### Using your own launcher (fuzzel, rofi, wofi, …)
|
||||||
|
|
||||||
|
`pdfs-prompt --dmenu` runs the same search through an external dmenu-style
|
||||||
|
launcher instead of the built-in window, so the HUD matches the rest of a
|
||||||
|
tiling-WM desktop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs-prompt --dmenu # fuzzel/rofi/wofi/tofi/bemenu/dmenu, whichever is installed
|
||||||
|
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
|
||||||
|
pdfs-prompt --dmenu --query invoice # skip straight to a search
|
||||||
|
```
|
||||||
|
|
||||||
|
A launcher filters a fixed list — it cannot ask for a new one per keystroke — so
|
||||||
|
searching takes two steps, and the prompt says which step you are on:
|
||||||
|
|
||||||
|
- `Search Drive ›` lists your pinned files. Type anything that isn't one of them
|
||||||
|
and press Enter to search for it.
|
||||||
|
- `Drive: invoice ›` lists the results. Enter opens one; typing something else
|
||||||
|
and pressing Enter searches again. Escape closes.
|
||||||
|
|
||||||
|
`--query` skips the first step. fuzzel and rofi also get file-type icons.
|
||||||
|
|
||||||
|
To make an existing keybinding use it without re-binding, and to pin the
|
||||||
|
launcher command, set them in `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": {
|
||||||
|
"mode": "dmenu",
|
||||||
|
"menu": ["fuzzel", "--dmenu", "--width", "60"],
|
||||||
|
"menu_limit": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`--gtk` overrides `"mode": "dmenu"` for one invocation. A `{prompt}` token
|
||||||
|
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
||||||
|
own prompt flag is appended.
|
||||||
|
|
||||||
|
## Choosing How Files Open
|
||||||
|
|
||||||
|
By default every result is handed to `xdg-open`. An `open_with` block in
|
||||||
|
`config.json` overrides that per file type — for instance opening text in
|
||||||
|
Neovim inside Alacritty, while everything else still goes to the desktop:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"open_with": {
|
||||||
|
"terminal": ["alacritty", "-e"],
|
||||||
|
"rules": [
|
||||||
|
{ "match": ["@text"], "command": ["nvim"], "terminal": true },
|
||||||
|
{ "match": ["*.png", "*.jpg"], "command": ["imv"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `match` takes file-name globs (`*.md`, `notes-*.txt`) or the classes `@dir`,
|
||||||
|
`@text`, `@document`, `@image`, `@media`, `@any`. First matching rule wins.
|
||||||
|
- `command` is argv, not a shell line. A `{}` token is replaced by the path;
|
||||||
|
without one the path is appended. `$VAR` tokens expand from the environment,
|
||||||
|
so `["$EDITOR"]` follows your editor.
|
||||||
|
- `"terminal": true` wraps the command in `terminal`. When that is unset, the
|
||||||
|
terminal comes from `$PDFS_TERMINAL`, then `$TERMINAL`, then the first known
|
||||||
|
emulator on `PATH`; a bare name like `TERMINAL=alacritty` gains the right
|
||||||
|
"run this" flag automatically.
|
||||||
|
- `"default": [...]` replaces `xdg-open` for everything unmatched.
|
||||||
|
|
||||||
|
The rules apply to the prompt (both front ends) and to the GTK browser, so a
|
||||||
|
file opens the same way wherever it was picked. Patterns are matched against the
|
||||||
|
Drive name — a downloaded file is stored in the cache under its content hash,
|
||||||
|
which says nothing about its type, so matching that would send everything to
|
||||||
|
`xdg-open`.
|
||||||
|
|
||||||
## Selective Sync (`.pdfsignore`)
|
## Selective Sync (`.pdfsignore`)
|
||||||
|
|
||||||
Two-way synced folders skip paths matched by ignore rules, so syncing a project
|
Two-way synced folders skip paths matched by ignore rules, so syncing a project
|
||||||
|
|||||||
+216
-5
@@ -144,6 +144,17 @@ enum Command {
|
|||||||
/// Skip this many photos from the start of the timeline.
|
/// Skip this many photos from the start of the timeline.
|
||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
offset: usize,
|
offset: usize,
|
||||||
|
/// List only favourited photos.
|
||||||
|
#[arg(long)]
|
||||||
|
favorites: bool,
|
||||||
|
},
|
||||||
|
/// Add or remove a photo's favourite mark.
|
||||||
|
Favorite {
|
||||||
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||||
|
uid: String,
|
||||||
|
/// Remove the mark instead of adding it.
|
||||||
|
#[arg(long)]
|
||||||
|
remove: bool,
|
||||||
},
|
},
|
||||||
/// List the photo albums (including ones shared with this account).
|
/// List the photo albums (including ones shared with this account).
|
||||||
Albums,
|
Albums,
|
||||||
@@ -286,6 +297,11 @@ enum Command {
|
|||||||
/// Entry kind: member, proton or external.
|
/// Entry kind: member, proton or external.
|
||||||
kind: ShareKindArg,
|
kind: ShareKindArg,
|
||||||
},
|
},
|
||||||
|
/// Inspect and restore a file's earlier versions.
|
||||||
|
Versions {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: VersionCmd,
|
||||||
|
},
|
||||||
/// Manage a node's public link.
|
/// Manage a node's public link.
|
||||||
PublicLink {
|
PublicLink {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -380,6 +396,43 @@ impl ShareKindArg {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `pdfs versions …` — a file's revision history.
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum VersionCmd {
|
||||||
|
/// List a file's versions, newest first.
|
||||||
|
List {
|
||||||
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
/// Make an earlier version the file's current content.
|
||||||
|
///
|
||||||
|
/// The server applies this in the background, so the new content may take a
|
||||||
|
/// moment to show up in the mount.
|
||||||
|
Restore {
|
||||||
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Version id, from `pdfs versions list`.
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
/// Write an earlier version to a local file, leaving the file untouched.
|
||||||
|
Save {
|
||||||
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Version id, from `pdfs versions list`.
|
||||||
|
id: String,
|
||||||
|
/// Where to write it. Must not already exist.
|
||||||
|
dest: PathBuf,
|
||||||
|
},
|
||||||
|
/// Permanently delete an earlier version. The content is unrecoverable, and
|
||||||
|
/// the version that is currently active cannot be deleted.
|
||||||
|
Rm {
|
||||||
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Version id, from `pdfs versions list`.
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum DeviceCmd {
|
enum DeviceCmd {
|
||||||
/// List registered devices.
|
/// List registered devices.
|
||||||
@@ -529,7 +582,12 @@ fn main() -> Result<()> {
|
|||||||
Command::Unpin { path } => cmd_unpin(path),
|
Command::Unpin { path } => cmd_unpin(path),
|
||||||
Command::Pins => cmd_pins(),
|
Command::Pins => cmd_pins(),
|
||||||
Command::Ls { path } => cmd_ls(path),
|
Command::Ls { path } => cmd_ls(path),
|
||||||
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
Command::Photos {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
favorites,
|
||||||
|
} => cmd_photos(limit, offset, favorites),
|
||||||
|
Command::Favorite { uid, remove } => cmd_favorite(uid, remove),
|
||||||
Command::Albums => cmd_albums(),
|
Command::Albums => cmd_albums(),
|
||||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||||
@@ -561,6 +619,7 @@ fn main() -> Result<()> {
|
|||||||
role,
|
role,
|
||||||
} => cmd_share_role(path, id, kind, role),
|
} => cmd_share_role(path, id, kind, role),
|
||||||
Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind),
|
Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind),
|
||||||
|
Command::Versions { action } => cmd_versions(action),
|
||||||
Command::PublicLink { action } => cmd_public_link(action),
|
Command::PublicLink { action } => cmd_public_link(action),
|
||||||
Command::Shared => cmd_shared(),
|
Command::Shared => cmd_shared(),
|
||||||
Command::SharedWithMe { uid } => cmd_shared_with_me(uid),
|
Command::SharedWithMe { uid } => cmd_shared_with_me(uid),
|
||||||
@@ -788,6 +847,71 @@ fn cmd_share(
|
|||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_versions(action: VersionCmd) -> Result<()> {
|
||||||
|
match action {
|
||||||
|
VersionCmd::List { path } => {
|
||||||
|
let response = control_request(CtlRequest::ListRevisions {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
})?;
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match response {
|
||||||
|
CtlResponse::Revisions { items } if items.is_empty() => {
|
||||||
|
println!("No versions.");
|
||||||
|
}
|
||||||
|
CtlResponse::Revisions { items } => {
|
||||||
|
for r in items {
|
||||||
|
// The claimed plaintext size is what the user recognises;
|
||||||
|
// the storage size is ciphertext and always larger.
|
||||||
|
let size = r
|
||||||
|
.claimed_size
|
||||||
|
.filter(|size| *size >= 0)
|
||||||
|
.unwrap_or(r.size_on_storage)
|
||||||
|
.max(0) as u64;
|
||||||
|
let mark = if r.is_active { "*" } else { " " };
|
||||||
|
let by = r
|
||||||
|
.signed_by
|
||||||
|
.map(|email| format!(" {email}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!(
|
||||||
|
"{mark} {:<20} {:>10} {}{by}",
|
||||||
|
r.id,
|
||||||
|
human_bytes(size),
|
||||||
|
format_epoch(r.created),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("(* = current)");
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
VersionCmd::Restore { path, id } => {
|
||||||
|
ok_or_bail(control_request(CtlRequest::RestoreRevision {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
revision_id: id,
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
VersionCmd::Save { path, id, dest } => {
|
||||||
|
// The daemon writes the file, so it needs a path that means the same
|
||||||
|
// thing in its process as in this one.
|
||||||
|
let dest = std::path::absolute(&dest)
|
||||||
|
.with_context(|| format!("resolve {}", dest.display()))?;
|
||||||
|
ok_or_bail(control_request(CtlRequest::SaveRevisionAs {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
revision_id: id,
|
||||||
|
dest: dest.to_string_lossy().into_owned(),
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
VersionCmd::Rm { path, id } => ok_or_bail(control_request(CtlRequest::DeleteRevision {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
revision_id: id,
|
||||||
|
})?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_members(path: PathBuf) -> Result<()> {
|
fn cmd_members(path: PathBuf) -> Result<()> {
|
||||||
match control_request(CtlRequest::ListShare {
|
match control_request(CtlRequest::ListShare {
|
||||||
path: path_arg(&path)?,
|
path: path_arg(&path)?,
|
||||||
@@ -1212,6 +1336,11 @@ fn cmd_status() -> Result<()> {
|
|||||||
online,
|
online,
|
||||||
pending_uploads,
|
pending_uploads,
|
||||||
pending_changes,
|
pending_changes,
|
||||||
|
parked_uploads,
|
||||||
|
failing_ops,
|
||||||
|
failing_error,
|
||||||
|
staged_bytes,
|
||||||
|
staged_oldest_secs,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let state = if online { "" } else { ", offline" };
|
let state = if online { "" } else { ", offline" };
|
||||||
@@ -1220,6 +1349,25 @@ fn cmd_status() -> Result<()> {
|
|||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
println!("Mounted at {mountpoint} ({pinned} pinned{state}{queued})");
|
println!("Mounted at {mountpoint} ({pinned} pinned{state}{queued})");
|
||||||
|
// Everything below is a queue that is not making progress on its
|
||||||
|
// own. Printed only when it exists, because in the ordinary case
|
||||||
|
// there is nothing to say.
|
||||||
|
if staged_bytes > 0 {
|
||||||
|
println!(
|
||||||
|
"Staged {} not yet uploaded (oldest {})",
|
||||||
|
human_bytes(staged_bytes),
|
||||||
|
human_age(staged_oldest_secs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if parked_uploads > 0 {
|
||||||
|
println!("Parked {parked_uploads} upload(s) waiting for a final filename");
|
||||||
|
}
|
||||||
|
if failing_ops > 0 {
|
||||||
|
println!(
|
||||||
|
"Stuck {failing_ops} queued operation(s) keep failing: {}",
|
||||||
|
failing_error.as_deref().unwrap_or("no error recorded"),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||||
Err(_) => println!("Mount: not running."),
|
Err(_) => println!("Mount: not running."),
|
||||||
@@ -1487,12 +1635,13 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
|
||||||
match control_request(CtlRequest::PhotosTimeline {
|
match control_request(CtlRequest::PhotosTimeline {
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
kind: None,
|
kind: None,
|
||||||
range: None,
|
range: None,
|
||||||
|
favorites,
|
||||||
})? {
|
})? {
|
||||||
CtlResponse::Photos {
|
CtlResponse::Photos {
|
||||||
available: false, ..
|
available: false, ..
|
||||||
@@ -1511,6 +1660,13 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
|||||||
///
|
///
|
||||||
/// A timeline or album reply only carries thumbnails already in the cache, so
|
/// A timeline or album reply only carries thumbnails already in the cache, so
|
||||||
/// the rest of the page's are pulled in one batch rather than left blank.
|
/// the rest of the page's are pulled in one batch rather than left blank.
|
||||||
|
fn cmd_favorite(uid: String, remove: bool) -> Result<()> {
|
||||||
|
ok_or_bail(control_request(CtlRequest::SetPhotoFavorite {
|
||||||
|
uid,
|
||||||
|
favorite: !remove,
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
|
||||||
fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
|
fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
|
||||||
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
|
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
|
||||||
let thumbs: HashMap<String, Option<String>> =
|
let thumbs: HashMap<String, Option<String>> =
|
||||||
@@ -1524,7 +1680,8 @@ fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
|
|||||||
.and_then(|p| p.as_deref())
|
.and_then(|p| p.as_deref())
|
||||||
.or(p.thumb_path.as_deref())
|
.or(p.thumb_path.as_deref())
|
||||||
.unwrap_or("(no thumbnail)");
|
.unwrap_or("(no thumbnail)");
|
||||||
println!("{} {} {thumb}", p.capture_time, p.uid);
|
let fav = if p.favorite { "*" } else { " " };
|
||||||
|
println!("{fav} {} {} {thumb}", p.capture_time, p.uid);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1753,6 +1910,18 @@ fn path_arg(path: &Path) -> Result<String> {
|
|||||||
.ok_or_else(|| anyhow!("path is not valid UTF-8"))
|
.ok_or_else(|| anyhow!("path is not valid UTF-8"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format an age in seconds for a report line, coarsely — the point of the
|
||||||
|
/// number is whether it is minutes or weeks.
|
||||||
|
fn human_age(secs: u64) -> String {
|
||||||
|
const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)];
|
||||||
|
for (unit, scale) in UNITS {
|
||||||
|
if secs >= scale {
|
||||||
|
return format!("{}{unit}", secs / scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"just now".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Format a byte count for a report line.
|
/// Format a byte count for a report line.
|
||||||
fn human_bytes(bytes: u64) -> String {
|
fn human_bytes(bytes: u64) -> String {
|
||||||
const UNITS: [(&str, f64); 4] = [
|
const UNITS: [(&str, f64); 4] = [
|
||||||
@@ -1792,6 +1961,32 @@ fn cmd_cache(action: CacheCmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask for a deep integrity check and wait for it to finish.
|
||||||
|
///
|
||||||
|
/// The daemon answers the request immediately and runs the check on a thread —
|
||||||
|
/// it holds the single database connection for as long as it takes, which would
|
||||||
|
/// otherwise stall the mount — so a caller that needs the verdict polls for it.
|
||||||
|
/// `pdfs doctor` is that caller: it is a diagnostic the user is watching, so
|
||||||
|
/// waiting is right, and the bound is generous enough for a large database
|
||||||
|
/// without hanging forever if the daemon never finishes.
|
||||||
|
fn await_integrity_check() -> Result<CtlResponse> {
|
||||||
|
const POLL: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
const GIVE_UP_AFTER: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
let response = control_request(CtlRequest::CacheInspect { deep: true })?;
|
||||||
|
match &response {
|
||||||
|
CtlResponse::CacheReport {
|
||||||
|
integrity_running: true,
|
||||||
|
integrity_checked: false,
|
||||||
|
..
|
||||||
|
} if started.elapsed() < GIVE_UP_AFTER => std::thread::sleep(POLL),
|
||||||
|
_ => return Ok(response),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
||||||
let response = control_request(CtlRequest::CacheInspect { deep })?;
|
let response = control_request(CtlRequest::CacheInspect { deep })?;
|
||||||
if emit_json(&response)? {
|
if emit_json(&response)? {
|
||||||
@@ -1807,6 +2002,7 @@ fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
|||||||
cache_budget,
|
cache_budget,
|
||||||
integrity_problems,
|
integrity_problems,
|
||||||
integrity_checked,
|
integrity_checked,
|
||||||
|
integrity_running,
|
||||||
} => {
|
} => {
|
||||||
println!(
|
println!(
|
||||||
"Database schema v{schema_version}, {}",
|
"Database schema v{schema_version}, {}",
|
||||||
@@ -1839,6 +2035,11 @@ fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
|||||||
println!(" {problem}");
|
println!(" {problem}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if integrity_running {
|
||||||
|
// The daemon runs the check on a thread rather than holding its
|
||||||
|
// only database connection for the length of it, so the answer
|
||||||
|
// belongs to a later request.
|
||||||
|
println!("\nIntegrity checking in the background — run this again for the result");
|
||||||
} else {
|
} else {
|
||||||
println!("\nIntegrity not checked (pass --deep)");
|
println!("\nIntegrity not checked (pass --deep)");
|
||||||
}
|
}
|
||||||
@@ -2076,14 +2277,24 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
format!("{pending} waiting to upload")
|
format!("{pending} waiting to upload")
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
match control_request(CtlRequest::CacheInspect { deep: true }) {
|
match await_integrity_check() {
|
||||||
Ok(CtlResponse::CacheReport {
|
Ok(CtlResponse::CacheReport {
|
||||||
integrity_problems, ..
|
integrity_problems,
|
||||||
|
integrity_checked: true,
|
||||||
|
..
|
||||||
}) if integrity_problems.is_empty() => report.finding(
|
}) if integrity_problems.is_empty() => report.finding(
|
||||||
DiagnoseLevel::Ok,
|
DiagnoseLevel::Ok,
|
||||||
" database integrity",
|
" database integrity",
|
||||||
"SQLite integrity_check passed",
|
"SQLite integrity_check passed",
|
||||||
),
|
),
|
||||||
|
Ok(CtlResponse::CacheReport {
|
||||||
|
integrity_checked: false,
|
||||||
|
..
|
||||||
|
}) => report.finding(
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" database integrity",
|
||||||
|
"still checking — run `pdfs cache inspect --deep` for the result",
|
||||||
|
),
|
||||||
Ok(CtlResponse::CacheReport {
|
Ok(CtlResponse::CacheReport {
|
||||||
integrity_problems, ..
|
integrity_problems, ..
|
||||||
}) => report.finding(
|
}) => report.finding(
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ sha2.workspace = true
|
|||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
keyring.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
@@ -9,8 +9,10 @@
|
|||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||||
|
use proton_sdk::cache::EncryptedCacheRepository;
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
|
use proton_sdk::telemetry::TracingTelemetry;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
||||||
@@ -220,6 +222,21 @@ pub fn load() -> Result<StoredSession> {
|
|||||||
|
|
||||||
/// Forget the persisted session (best-effort; absent entry is not an error).
|
/// Forget the persisted session (best-effort; absent entry is not an error).
|
||||||
pub fn logout() -> Result<()> {
|
pub fn logout() -> Result<()> {
|
||||||
|
// The persisted entity cache describes the account being logged out of. It
|
||||||
|
// is encrypted under that account's mailbox password, but leaving it behind
|
||||||
|
// would also mean the next account inherits a store it can only ever read as
|
||||||
|
// misses. Best-effort: failing to remove a cache must not block the logout.
|
||||||
|
if let Ok(dirs) = AppDirs::new() {
|
||||||
|
let path = dirs.state_dir().join("sdk_cache.db");
|
||||||
|
if let Some(cache) = crate::sdkcache::SdkCache::opened()
|
||||||
|
&& let Err(e) = cache.clear_now()
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "clearing the SDK entity cache on logout failed");
|
||||||
|
}
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
match keyring_entry()?.delete_credential() {
|
match keyring_entry()?.delete_credential() {
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
@@ -259,10 +276,50 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
|||||||
|
|
||||||
register_refresh_handler(&session, password.clone(), key_salts.clone());
|
register_refresh_handler(&session, password.clone(), key_salts.clone());
|
||||||
|
|
||||||
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
|
let client = tune(
|
||||||
|
ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts),
|
||||||
|
password.as_bytes(),
|
||||||
|
);
|
||||||
Ok((client, session))
|
Ok((client, session))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The client settings every front-end wants, applied wherever a
|
||||||
|
/// [`ProtonDriveClient`] is built for real work.
|
||||||
|
///
|
||||||
|
/// - **Telemetry** into `tracing`, so the SDK's own spans (transfers, block
|
||||||
|
/// storage, per-request timings) land in the daemon's log next to the
|
||||||
|
/// daemon's, instead of being dropped by the default no-op sink.
|
||||||
|
/// - **Small-file uploads**: a file that fits in one block is uploaded as a
|
||||||
|
/// single atomic request instead of the multi-step draft/block/commit dance.
|
||||||
|
/// The SDK makes this opt-in because it has no remote feature-flag provider;
|
||||||
|
/// the drain queue writes plenty of small files, and the shorter path is one
|
||||||
|
/// failure point per write instead of three.
|
||||||
|
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
|
||||||
|
/// re-fetch and re-decrypt the tree the previous run already walked. It is
|
||||||
|
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox
|
||||||
|
/// password, so the decrypted node names it holds are not readable from the
|
||||||
|
/// file alone — and a password change simply reads as a cold cache, since the
|
||||||
|
/// SDK treats an undecryptable entry as a miss and clears the store.
|
||||||
|
///
|
||||||
|
/// Opening it is best-effort: a store this process cannot open costs cache
|
||||||
|
/// hits, not the session, so the client falls back to the SDK's in-memory
|
||||||
|
/// default.
|
||||||
|
fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient {
|
||||||
|
let client = client
|
||||||
|
.with_telemetry(TracingTelemetry::shared())
|
||||||
|
.with_small_file_upload(true);
|
||||||
|
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
|
||||||
|
Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared(
|
||||||
|
cache,
|
||||||
|
mailbox_password.to_vec(),
|
||||||
|
)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
|
||||||
|
client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Write the session's current tokens back to the keyring.
|
/// Write the session's current tokens back to the keyring.
|
||||||
///
|
///
|
||||||
/// The session does not carry the mailbox password (it is only needed to rebuild
|
/// The session does not carry the mailbox password (it is only needed to rebuild
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Per-node outcomes of the SDK's batch node operations.
|
||||||
|
//!
|
||||||
|
//! `trash_nodes`, `restore_nodes`, `delete_nodes` and `move_nodes` report one
|
||||||
|
//! outcome per node instead of failing the whole call: a node the server
|
||||||
|
//! rejects leaves the rest of the batch applied. The daemon's callers fall into
|
||||||
|
//! two shapes, and both are here so neither has to re-derive the fold:
|
||||||
|
//!
|
||||||
|
//! - one node in, one answer out — [`into_unit`], which collapses the single
|
||||||
|
//! outcome back to a `Result`;
|
||||||
|
//! - a user-supplied list — [`split`], which keeps the successes and hands back
|
||||||
|
//! the failures to report.
|
||||||
|
|
||||||
|
use proton_sdk::error::ProtonError;
|
||||||
|
use proton_sdk::ids::NodeUid;
|
||||||
|
|
||||||
|
/// One node's outcome, as the SDK batch operations report it.
|
||||||
|
pub type Outcome = (NodeUid, Result<(), ProtonError>);
|
||||||
|
|
||||||
|
/// Collapse a batch result to the first per-node failure, for the callers that
|
||||||
|
/// pass exactly one node and want the old all-or-nothing answer.
|
||||||
|
///
|
||||||
|
/// An empty outcome list is `Ok`: the server accepted the request and reported
|
||||||
|
/// on nothing, which is not an error the caller can act on.
|
||||||
|
pub fn into_unit(outcomes: Vec<Outcome>) -> Result<(), ProtonError> {
|
||||||
|
for (_, outcome) in outcomes {
|
||||||
|
outcome?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a batch result into the nodes that succeeded and the ones that failed.
|
||||||
|
pub fn split(outcomes: Vec<Outcome>) -> (Vec<NodeUid>, Vec<(NodeUid, ProtonError)>) {
|
||||||
|
let mut done = Vec::with_capacity(outcomes.len());
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
for (uid, outcome) in outcomes {
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => done.push(uid),
|
||||||
|
Err(e) => failed.push((uid, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(done, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use proton_sdk::ids::{LinkId, VolumeId};
|
||||||
|
|
||||||
|
fn uid(link: &str) -> NodeUid {
|
||||||
|
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure() -> ProtonError {
|
||||||
|
ProtonError::invalid_operation("nope")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn into_unit_is_ok_when_every_node_succeeded() {
|
||||||
|
assert!(into_unit(vec![(uid("a"), Ok(())), (uid("b"), Ok(()))]).is_ok());
|
||||||
|
assert!(
|
||||||
|
into_unit(Vec::new()).is_ok(),
|
||||||
|
"nothing reported is not a failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn into_unit_surfaces_a_failed_node() {
|
||||||
|
let outcomes = vec![(uid("a"), Ok(())), (uid("b"), Err(failure()))];
|
||||||
|
assert!(into_unit(outcomes).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_keeps_successes_and_failures_apart() {
|
||||||
|
let outcomes = vec![
|
||||||
|
(uid("a"), Ok(())),
|
||||||
|
(uid("b"), Err(failure())),
|
||||||
|
(uid("c"), Ok(())),
|
||||||
|
];
|
||||||
|
let (done, failed) = split(outcomes);
|
||||||
|
assert_eq!(done, vec![uid("a"), uid("c")]);
|
||||||
|
assert_eq!(failed.len(), 1);
|
||||||
|
assert_eq!(failed[0].0, uid("b"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+364
-70
@@ -16,17 +16,20 @@
|
|||||||
//! [`AppDirs::cache_dir`]: crate::config::AppDirs::cache_dir
|
//! [`AppDirs::cache_dir`]: crate::config::AppDirs::cache_dir
|
||||||
//! [`Node`]: proton_drive_rs::Node
|
//! [`Node`]: proton_drive_rs::Node
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
use crate::db::{CacheEntryInput, Db};
|
use crate::db::{CacheEntryInput, Db};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
@@ -74,6 +77,18 @@ fn now_millis() -> i64 {
|
|||||||
/// `download_range` fetch with no straddling.
|
/// `download_range` fetch with no straddling.
|
||||||
pub const BLOCK_SIZE: u64 = 1 << 22;
|
pub const BLOCK_SIZE: u64 = 1 << 22;
|
||||||
|
|
||||||
|
/// Buffered LRU touches that trigger a flush. Sized so a sequential read of a
|
||||||
|
/// large file flushes a few times rather than per block, while an idle cache
|
||||||
|
/// never holds more than a few hundred pending keys.
|
||||||
|
const TOUCH_FLUSH_AT: usize = 256;
|
||||||
|
|
||||||
|
/// How long block `idx` of a file of `size` bytes must be: a full
|
||||||
|
/// [`BLOCK_SIZE`] except for the last one, which holds the remainder.
|
||||||
|
fn block_len(size: u64, idx: u64) -> u64 {
|
||||||
|
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||||
|
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
/// How many eviction candidates to pull per batch when a pool is over budget.
|
/// How many eviction candidates to pull per batch when a pool is over budget.
|
||||||
/// Large enough that a normal overshoot (a store or two past the cap) is settled
|
/// Large enough that a normal overshoot (a store or two past the cap) is settled
|
||||||
/// in one query, small enough that a badly-over-budget cache does not read its
|
/// in one query, small enough that a badly-over-budget cache does not read its
|
||||||
@@ -102,6 +117,16 @@ pub struct Pin {
|
|||||||
/// file. Defaulted so a legacy `pins.json` (file pins only) still parses.
|
/// file. Defaulted so a legacy `pins.json` (file pins only) still parses.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recursive: bool,
|
pub recursive: bool,
|
||||||
|
/// Whether the pinned node is a folder, when the node cache knows.
|
||||||
|
///
|
||||||
|
/// `recursive` is pin *policy*, not node kind: a folder pinned
|
||||||
|
/// non-recursively has `recursive: false`, and a front-end reading it as
|
||||||
|
/// "this is a file" draws a file icon and sends an invalid `OpenFile`. This
|
||||||
|
/// is resolved from the `nodes` row at list time, so it is `None` for a pin
|
||||||
|
/// whose node is not cached — and absent from an older daemon's reply,
|
||||||
|
/// which is why it is defaulted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_dir: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||||
@@ -121,6 +146,19 @@ struct PinFile {
|
|||||||
/// in the gaps. Uploading that as-is would silently corrupt the file.
|
/// in the gaps. Uploading that as-is would silently corrupt the file.
|
||||||
/// `authored` says which ranges are real; `complete` says whether the file can
|
/// `authored` says which ranges are real; `complete` says whether the file can
|
||||||
/// be uploaded as it stands.
|
/// be uploaded as it stands.
|
||||||
|
/// What `staging/` currently holds, from [`ContentCache::staging_usage`].
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct StagingUsage {
|
||||||
|
/// Bytes across every staged blob.
|
||||||
|
pub bytes: u64,
|
||||||
|
/// Staged blobs, not counting their sidecars.
|
||||||
|
pub files: u64,
|
||||||
|
/// Age of the oldest staged blob, in seconds. Zero when there are none —
|
||||||
|
/// what makes a figure here interesting is it being *large*, which is the
|
||||||
|
/// shape of a write that is never going to drain by itself.
|
||||||
|
pub oldest_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct StagedWrite {
|
pub struct StagedWrite {
|
||||||
/// Node the write targeted, in `volume~link` display form.
|
/// Node the write targeted, in `volume~link` display form.
|
||||||
@@ -253,6 +291,23 @@ pub struct ContentCache {
|
|||||||
/// store/read/evict updates it, and the budget enforcers query it instead of
|
/// store/read/evict updates it, and the budget enforcers query it instead of
|
||||||
/// scanning the cache directories (plan.md P4).
|
/// scanning the cache directories (plan.md P4).
|
||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
|
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
||||||
|
///
|
||||||
|
/// A cache hit only reorders eviction candidates, so it does not have to be
|
||||||
|
/// durable — but as one write transaction per hit it was 32 transactions per
|
||||||
|
/// 4 MiB block delivered, taken on the read path, under the connection mutex
|
||||||
|
/// every `lookup` also needs. Buffered here and flushed in one transaction
|
||||||
|
/// once [`TOUCH_FLUSH_AT`] have accumulated, and always before an eviction
|
||||||
|
/// pass reads the ordering these describe.
|
||||||
|
touches: Mutex<HashMap<String, i64>>,
|
||||||
|
/// Whether `recovery/` may hold a write the daemon has not queued yet.
|
||||||
|
///
|
||||||
|
/// The drain asks for recovered writes on every idle pass — a `read_dir` of
|
||||||
|
/// a directory that is empty in every run that did not crash. The directory
|
||||||
|
/// gains entries in exactly two places (the rescue at open, and the release
|
||||||
|
/// path that parks a still-authoritative scratch file), both of which set
|
||||||
|
/// this; a scan that finds nothing actionable clears it.
|
||||||
|
recovery_pending: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContentCache {
|
impl ContentCache {
|
||||||
@@ -315,6 +370,9 @@ impl ContentCache {
|
|||||||
block_bytes: AtomicU64::new(0),
|
block_bytes: AtomicU64::new(0),
|
||||||
thumb_bytes: AtomicU64::new(0),
|
thumb_bytes: AtomicU64::new(0),
|
||||||
db,
|
db,
|
||||||
|
touches: Mutex::new(HashMap::new()),
|
||||||
|
// The rescue above has already run, so start by looking once.
|
||||||
|
recovery_pending: AtomicBool::new(true),
|
||||||
};
|
};
|
||||||
cache.reconcile()?;
|
cache.reconcile()?;
|
||||||
// After reconcile, so the totals describe the index that now describes
|
// After reconcile, so the totals describe the index that now describes
|
||||||
@@ -463,7 +521,7 @@ impl ContentCache {
|
|||||||
let blob = self.valid_blob(uid, mtime, size)?;
|
let blob = self.valid_blob(uid, mtime, size)?;
|
||||||
// Record the access for LRU in the cache index. Best effort — a failed
|
// Record the access for LRU in the cache index. Best effort — a failed
|
||||||
// update only makes eviction order slightly less accurate.
|
// update only makes eviction order slightly less accurate.
|
||||||
let _ = self.db.cache_accessed(&Self::key(uid), now_millis());
|
self.touch(Self::key(uid));
|
||||||
let mut f = std::fs::File::open(&blob).ok()?;
|
let mut f = std::fs::File::open(&blob).ok()?;
|
||||||
if offset >= size {
|
if offset >= size {
|
||||||
return Some(Vec::new());
|
return Some(Vec::new());
|
||||||
@@ -553,22 +611,38 @@ impl ContentCache {
|
|||||||
.join(format!("{}.b{idx}.meta", Self::key(uid)))
|
.join(format!("{}.b{idx}.meta", Self::key(uid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether block `idx` of `uid` is cached and fresh for `(mtime, size)`,
|
||||||
|
/// without reading it. For a caller deciding whether a block is worth
|
||||||
|
/// *fetching* — reading 4 MiB to answer that would defeat the point.
|
||||||
|
pub fn has_block(&self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> bool {
|
||||||
|
self.block_tag(uid, idx) == Some(Meta { mtime, size })
|
||||||
|
&& std::fs::metadata(self.block_blob(uid, idx))
|
||||||
|
.is_ok_and(|m| m.len() == block_len(size, idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `(mtime, size)` a cached block claims to belong to, from its sidecar.
|
||||||
|
fn block_tag(&self, uid: &NodeUid, idx: u64) -> Option<Meta> {
|
||||||
|
serde_json::from_slice(&std::fs::read(self.block_meta(uid, idx)).ok()?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve cached block `idx` (a [`BLOCK_SIZE`]-aligned chunk) of `uid`, or
|
/// Serve cached block `idx` (a [`BLOCK_SIZE`]-aligned chunk) of `uid`, or
|
||||||
/// `None` on miss/stale. Validated against `(mtime, size)` like a whole-file
|
/// `None` on miss/stale. Validated against `(mtime, size)` like a whole-file
|
||||||
/// blob, so a new revision (which bumps the mtime) is detected. Bumps the
|
/// blob, so a new revision (which bumps the mtime) is detected. Bumps the
|
||||||
/// block's mtime for LRU, best effort.
|
/// block's mtime for LRU, best effort.
|
||||||
pub fn cached_block(&self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Vec<u8>> {
|
pub fn cached_block(&self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Vec<u8>> {
|
||||||
let want = Meta { mtime, size };
|
if self.block_tag(uid, idx)? != (Meta { mtime, size }) {
|
||||||
let meta: Meta =
|
|
||||||
serde_json::from_slice(&std::fs::read(self.block_meta(uid, idx)).ok()?).ok()?;
|
|
||||||
if meta != want {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
|
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
|
||||||
// LRU touch in the cache index (best effort).
|
// The blob is not fsynced on the way in (see `store_block`), so a crash
|
||||||
let _ = self
|
// can leave a short one behind the sidecar that describes it. Length is
|
||||||
.db
|
// what makes that detectable, the same check `valid_blob` applies to a
|
||||||
.cache_accessed(&self.block_key(uid, idx), now_millis());
|
// whole-file blob.
|
||||||
|
if f.metadata().ok()?.len() != block_len(size, idx) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// LRU touch, buffered rather than written (see `touch`).
|
||||||
|
self.touch(self.block_key(uid, idx));
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
f.read_to_end(&mut buf).ok()?;
|
f.read_to_end(&mut buf).ok()?;
|
||||||
Some(buf)
|
Some(buf)
|
||||||
@@ -593,7 +667,10 @@ impl ContentCache {
|
|||||||
let mut f = std::fs::File::create(&tmp)?;
|
let mut f = std::fs::File::create(&tmp)?;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
f.write_all(bytes)?;
|
f.write_all(bytes)?;
|
||||||
f.sync_all()?;
|
// Deliberately not fsynced. This runs inline on the thread a kernel
|
||||||
|
// read is waiting on, and the block cache is disposable: the worst a
|
||||||
|
// crash can do is leave a short blob, which `cached_block` rejects
|
||||||
|
// on length and refetches.
|
||||||
}
|
}
|
||||||
std::fs::rename(&tmp, &blob)?;
|
std::fs::rename(&tmp, &blob)?;
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -612,6 +689,40 @@ impl ContentCache {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a cache hit for LRU ordering, without touching the database.
|
||||||
|
///
|
||||||
|
/// Later touches of the same key overwrite earlier ones — only the most
|
||||||
|
/// recent matters — so a hot file costs one map entry however many times it
|
||||||
|
/// is read.
|
||||||
|
fn touch(&self, key: String) {
|
||||||
|
let flush = {
|
||||||
|
let mut touches = self.touches.lock();
|
||||||
|
touches.insert(key, now_millis());
|
||||||
|
touches.len() >= TOUCH_FLUSH_AT
|
||||||
|
};
|
||||||
|
if flush {
|
||||||
|
self.flush_touches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the buffered LRU touches out. Best effort: losing them costs
|
||||||
|
/// eviction accuracy, nothing else.
|
||||||
|
///
|
||||||
|
/// Public because eviction ordering is only as good as what has been
|
||||||
|
/// flushed, and the daemon flushes on its idle passes as well as here.
|
||||||
|
pub fn flush_touches(&self) {
|
||||||
|
let batch: Vec<(String, i64)> = {
|
||||||
|
let mut touches = self.touches.lock();
|
||||||
|
if touches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
touches.drain().collect()
|
||||||
|
};
|
||||||
|
if let Err(error) = self.db.cache_accessed_batch(&batch) {
|
||||||
|
debug!(?error, "flushing cache LRU touches failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-read every pool total from the cache index, which is authoritative.
|
/// Re-read every pool total from the cache index, which is authoritative.
|
||||||
fn reseed_totals(&self) {
|
fn reseed_totals(&self) {
|
||||||
for kind in [KIND_BLOB, KIND_BLOCK, KIND_THUMB] {
|
for kind in [KIND_BLOB, KIND_BLOCK, KIND_THUMB] {
|
||||||
@@ -679,7 +790,10 @@ impl ContentCache {
|
|||||||
return; // the common case: one atomic load, no query
|
return; // the common case: one atomic load, no query
|
||||||
}
|
}
|
||||||
|
|
||||||
// Over budget by the running count — now do the accurate work.
|
// Over budget by the running count — now do the accurate work. The
|
||||||
|
// buffered hits are what say which entries are cold, so they have to
|
||||||
|
// reach the index before it is asked.
|
||||||
|
self.flush_touches();
|
||||||
let mut running = match self.db.cache_total_bytes(kind) {
|
let mut running = match self.db.cache_total_bytes(kind) {
|
||||||
Ok(n) => n,
|
Ok(n) => n,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
@@ -717,6 +831,70 @@ impl ContentCache {
|
|||||||
self.reseed_totals();
|
self.reseed_totals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Duplicate `src` onto `dst`, preferring a reflink.
|
||||||
|
///
|
||||||
|
/// The caller is copying a staged revision — a whole file, up to whatever
|
||||||
|
/// the user stores — so the difference between the three strategies is the
|
||||||
|
/// difference between instant and minutes:
|
||||||
|
///
|
||||||
|
/// 1. `FICLONE` shares the extents copy-on-write. On btrfs, XFS with
|
||||||
|
/// `reflink=1`, and bcachefs this is O(1) and consumes no extra space.
|
||||||
|
/// 2. `copy_file_range` still copies, but entirely inside the kernel — no
|
||||||
|
/// round trip through a user-space buffer.
|
||||||
|
/// 3. `std::fs::copy` is the portable fallback, and what this used to
|
||||||
|
/// always do.
|
||||||
|
///
|
||||||
|
/// `dst` is truncated. Returns the number of bytes the destination ends up
|
||||||
|
/// holding.
|
||||||
|
pub fn clone_or_copy(src: &Path, dst: &Path) -> Result<u64> {
|
||||||
|
use std::os::fd::AsRawFd as _;
|
||||||
|
|
||||||
|
// `_IOW(0x94, 9, int)` — the whole-file reflink ioctl.
|
||||||
|
const FICLONE: libc::c_ulong = 0x4004_9409;
|
||||||
|
|
||||||
|
let source = std::fs::File::open(src)?;
|
||||||
|
let len = source.metadata()?.len();
|
||||||
|
let target = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(dst)?;
|
||||||
|
|
||||||
|
// SAFETY: both descriptors are owned and open; FICLONE takes the source
|
||||||
|
// fd by value and writes nothing back through the pointer-shaped arg.
|
||||||
|
if unsafe { libc::ioctl(target.as_raw_fd(), FICLONE, source.as_raw_fd()) } == 0 {
|
||||||
|
return Ok(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut copied = 0u64;
|
||||||
|
while copied < len {
|
||||||
|
let want = (len - copied) as usize;
|
||||||
|
// SAFETY: owned descriptors; null offsets advance the file offsets,
|
||||||
|
// which is what a whole-file copy wants.
|
||||||
|
let n = unsafe {
|
||||||
|
libc::copy_file_range(
|
||||||
|
source.as_raw_fd(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
target.as_raw_fd(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
want,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if n <= 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
copied += n as u64;
|
||||||
|
}
|
||||||
|
if copied == len {
|
||||||
|
return Ok(len);
|
||||||
|
}
|
||||||
|
// A short or refused `copy_file_range` (cross-filesystem on older
|
||||||
|
// kernels, an unsupported fs) leaves a partial destination; the
|
||||||
|
// portable copy truncates and rewrites it from scratch.
|
||||||
|
Ok(std::fs::copy(src, dst)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a fresh, empty read-write scratch file for a disk-backed write
|
/// Create a fresh, empty read-write scratch file for a disk-backed write
|
||||||
/// handle. Returns the open file and its path (for cleanup on release).
|
/// handle. Returns the open file and its path (for cleanup on release).
|
||||||
pub fn create_scratch(&self) -> Result<(std::fs::File, PathBuf)> {
|
pub fn create_scratch(&self) -> Result<(std::fs::File, PathBuf)> {
|
||||||
@@ -796,8 +974,9 @@ impl ContentCache {
|
|||||||
let _ = std::fs::remove_file(Self::scratch_sidecar(scratch));
|
let _ = std::fs::remove_file(Self::scratch_sidecar(scratch));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move every scratch file that carries a readable sidecar into `recovery`,
|
/// Move every scratch file that carries a sidecar into `recovery`, before the
|
||||||
/// before the caller empties the scratch directory.
|
/// caller empties the scratch directory. A sidecar that does not parse is
|
||||||
|
/// dropped and its blob moved anyway, unaccompanied — see below.
|
||||||
///
|
///
|
||||||
/// Blob first, then sidecar: a blob in recovery without its sidecar is
|
/// Blob first, then sidecar: a blob in recovery without its sidecar is
|
||||||
/// dropped on the next open (indistinguishable from rubbish), whereas a
|
/// dropped on the next open (indistinguishable from rubbish), whereas a
|
||||||
@@ -818,43 +997,28 @@ impl ContentCache {
|
|||||||
// Not marked durable, or durability marker was explicitly cleared on release
|
// Not marked durable, or durability marker was explicitly cleared on release
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Parsed if valid; if sidecar exists but is corrupted, build synthetic StagedWrite
|
// A sidecar that does not parse describes nothing, and inventing one
|
||||||
|
// is worse than having none: a fabricated uid parses, so recovery
|
||||||
|
// would queue an op against a node that does not exist and retry it
|
||||||
|
// forever, while a fabricated `complete: true` claims the blob is the
|
||||||
|
// whole file and stops any gap-fill. The blob still moves to
|
||||||
|
// recovery — sidecar-less, which `recovered_writes` skips and so
|
||||||
|
// leaves on disk for a human, the right outcome for bytes we cannot
|
||||||
|
// describe.
|
||||||
let has_valid_sidecar = std::fs::read(&side)
|
let has_valid_sidecar = std::fs::read(&side)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|b| serde_json::from_slice::<StagedWrite>(&b).ok())
|
.and_then(|b| serde_json::from_slice::<StagedWrite>(&b).ok())
|
||||||
.is_some();
|
.is_some();
|
||||||
if !has_valid_sidecar {
|
|
||||||
if let Ok(meta) = std::fs::metadata(&blob)
|
|
||||||
&& meta.len() > 0
|
|
||||||
{
|
|
||||||
let blob_name = blob
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let synthetic = StagedWrite {
|
|
||||||
uid: format!("recovered~{blob_name}"),
|
|
||||||
len: meta.len(),
|
|
||||||
base_size: meta.len(),
|
|
||||||
base_mtime: 0,
|
|
||||||
authored: vec![(0, meta.len())],
|
|
||||||
complete: true,
|
|
||||||
based_on: None,
|
|
||||||
};
|
|
||||||
if let Ok(data) = serde_json::to_vec(&synthetic) {
|
|
||||||
let _ = std::fs::write(&side, data);
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Some(name) = blob.file_name() else {
|
let Some(name) = blob.file_name() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if std::fs::rename(&blob, recovery_dir.join(name)).is_err() {
|
if std::fs::rename(&blob, recovery_dir.join(name)).is_err() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !has_valid_sidecar {
|
||||||
|
let _ = std::fs::remove_file(&side);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Some(side_name) = side.file_name() else {
|
let Some(side_name) = side.file_name() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -909,7 +1073,14 @@ impl ContentCache {
|
|||||||
/// daemon to hand to the upload queue. A blob whose sidecar did not survive
|
/// daemon to hand to the upload queue. A blob whose sidecar did not survive
|
||||||
/// is not actionable automatically, but is retained for manual recovery:
|
/// is not actionable automatically, but is retained for manual recovery:
|
||||||
/// unexplained bytes are never evidence that user data is disposable.
|
/// unexplained bytes are never evidence that user data is disposable.
|
||||||
|
///
|
||||||
|
/// Asked for on every idle drain pass, so the usual answer — a directory
|
||||||
|
/// that has been empty since the last clean shutdown — costs an atomic load
|
||||||
|
/// rather than a `read_dir` (see [`recovery_pending`](Self::recovery_pending)).
|
||||||
pub fn recovered_writes(&self) -> Vec<(PathBuf, StagedWrite)> {
|
pub fn recovered_writes(&self) -> Vec<(PathBuf, StagedWrite)> {
|
||||||
|
if !self.recovery_pending.load(Ordering::Relaxed) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
let Ok(entries) = std::fs::read_dir(&self.recovery_dir) else {
|
let Ok(entries) = std::fs::read_dir(&self.recovery_dir) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -926,6 +1097,39 @@ impl ContentCache {
|
|||||||
out.push((blob, meta));
|
out.push((blob, meta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Blobs whose sidecar did not survive stay here for a human and are
|
||||||
|
// never actionable, so "nothing to queue" is the condition that stops
|
||||||
|
// the polling — not "the directory is empty".
|
||||||
|
if out.is_empty() {
|
||||||
|
self.recovery_pending.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every blob in `staging/`, paired with its sidecar when it has a readable
|
||||||
|
/// one.
|
||||||
|
///
|
||||||
|
/// For the startup reconcile: staging holds the only copy of writes the
|
||||||
|
/// kernel has already been told succeeded, and three failure paths can
|
||||||
|
/// leave one there with no `pending_op` row pointing at it. Nothing walked
|
||||||
|
/// this directory, so those bytes were invisible to the daemon and to the
|
||||||
|
/// user both — `hydrate_pending` walks the op table and
|
||||||
|
/// [`recovered_writes`](Self::recovered_writes) walks `recovery/`.
|
||||||
|
pub fn staged_writes(&self) -> Vec<(PathBuf, Option<StagedWrite>)> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&self.staging_dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let blob = entry.path();
|
||||||
|
if blob.extension().is_some_and(|e| e == "json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let meta = std::fs::read(Self::scratch_sidecar(&blob))
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice::<StagedWrite>(&b).ok());
|
||||||
|
out.push((blob, meta));
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,6 +1300,10 @@ impl ContentCache {
|
|||||||
if std::fs::rename(scratch, &recovery).is_ok() {
|
if std::fs::rename(scratch, &recovery).is_ok() {
|
||||||
let _ = std::fs::File::open(&self.recovery_dir)
|
let _ = std::fs::File::open(&self.recovery_dir)
|
||||||
.and_then(|dir| dir.sync_all());
|
.and_then(|dir| dir.sync_all());
|
||||||
|
// These bytes are unaccompanied, but re-arm the
|
||||||
|
// scan anyway: the flag exists to skip a directory
|
||||||
|
// nothing has written to, and something just did.
|
||||||
|
self.recovery_pending.store(true, Ordering::Relaxed);
|
||||||
return Err(retained(
|
return Err(retained(
|
||||||
&recovery,
|
&recovery,
|
||||||
Error::Other(format!(
|
Error::Other(format!(
|
||||||
@@ -1200,9 +1408,7 @@ impl ContentCache {
|
|||||||
// Record the access for LRU, as the blob and block readers do. Without
|
// Record the access for LRU, as the blob and block readers do. Without
|
||||||
// it the pool evicts in insertion order, so scrolling back up a gallery
|
// it the pool evicts in insertion order, so scrolling back up a gallery
|
||||||
// would drop exactly the tiles being looked at.
|
// would drop exactly the tiles being looked at.
|
||||||
let _ = self
|
self.touch(self.thumb_key(uid, ttype));
|
||||||
.db
|
|
||||||
.cache_accessed(&self.thumb_key(uid, ttype), now_millis());
|
|
||||||
Some(bytes)
|
Some(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,9 +1439,7 @@ impl ContentCache {
|
|||||||
if !blob.exists() {
|
if !blob.exists() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let _ = self
|
self.touch(self.thumb_key(uid, ttype));
|
||||||
.db
|
|
||||||
.cache_accessed(&self.thumb_key(uid, ttype), now_millis());
|
|
||||||
Some(blob)
|
Some(blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1348,10 +1552,11 @@ impl ContentCache {
|
|||||||
.pin_list()
|
.pin_list()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(uid, path, recursive)| Pin {
|
.map(|row| Pin {
|
||||||
uid,
|
uid: row.uid,
|
||||||
path,
|
path: row.path,
|
||||||
recursive,
|
recursive: row.recursive,
|
||||||
|
is_dir: row.is_dir,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -1377,6 +1582,44 @@ impl ContentCache {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bytes and file count held in `staging/`, with the age of the oldest
|
||||||
|
/// entry in seconds.
|
||||||
|
///
|
||||||
|
/// Staging is not part of [`usage`](Self::usage) and is not subject to the
|
||||||
|
/// budget — it holds the *only* copy of writes that have not been uploaded,
|
||||||
|
/// so nothing here may ever be evicted to reclaim space. That is precisely
|
||||||
|
/// why it has to be reportable: a parked transient create (three abandoned
|
||||||
|
/// 4 GiB downloads, say) otherwise occupies disk with nothing in any UI
|
||||||
|
/// accounting for it.
|
||||||
|
///
|
||||||
|
/// A `read_dir` per call, which is fine for the callers it has: a status
|
||||||
|
/// request, polled every couple of seconds against a directory that is
|
||||||
|
/// empty whenever the queue is drained.
|
||||||
|
pub fn staging_usage(&self) -> StagingUsage {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&self.staging_dir) else {
|
||||||
|
return StagingUsage::default();
|
||||||
|
};
|
||||||
|
let mut usage = StagingUsage::default();
|
||||||
|
let now = std::time::SystemTime::now();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
// Sidecars describe the blob beside them; counting both would double
|
||||||
|
// the file count and report metadata as user bytes.
|
||||||
|
if entry.path().extension().is_some_and(|e| e == "json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(meta) = entry.metadata() else { continue };
|
||||||
|
usage.files += 1;
|
||||||
|
usage.bytes += meta.len();
|
||||||
|
if let Ok(age) = meta.modified().and_then(|m| {
|
||||||
|
now.duration_since(m)
|
||||||
|
.map_err(|_| std::io::Error::other("modified in the future"))
|
||||||
|
}) {
|
||||||
|
usage.oldest_secs = usage.oldest_secs.max(age.as_secs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usage
|
||||||
|
}
|
||||||
|
|
||||||
/// Configured soft byte cap (`0` = unlimited), for display alongside
|
/// Configured soft byte cap (`0` = unlimited), for display alongside
|
||||||
/// [`usage`](Self::usage). This is the whole-cache figure; internally it is
|
/// [`usage`](Self::usage). This is the whole-cache figure; internally it is
|
||||||
/// divided between the pools (see [`SPLIT_BLOB`]), but the user set one
|
/// divided between the pools (see [`SPLIT_BLOB`]), but the user set one
|
||||||
@@ -1993,45 +2236,63 @@ mod tests {
|
|||||||
fn block_store_then_read() {
|
fn block_store_then_read() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
let u = uid("a");
|
let u = uid("a");
|
||||||
c.store_block(&u, 100, 4096, 0, b"block-zero").unwrap();
|
// A 10-byte file is one block of 10 bytes: the length a reader expects
|
||||||
assert_eq!(c.cached_block(&u, 100, 4096, 0).unwrap(), b"block-zero");
|
// is derived from the file size, so the fixture has to be geometrically
|
||||||
|
// real or the torn-write check rejects it.
|
||||||
|
c.store_block(&u, 100, 10, 0, b"block-zero").unwrap();
|
||||||
|
assert_eq!(c.cached_block(&u, 100, 10, 0).unwrap(), b"block-zero");
|
||||||
// A different index is a separate cache entry, absent here.
|
// A different index is a separate cache entry, absent here.
|
||||||
assert!(c.cached_block(&u, 100, 4096, 1).is_none());
|
assert!(c.cached_block(&u, 100, 10, 1).is_none());
|
||||||
// A new revision (mtime/size bump) invalidates the block.
|
// A new revision (mtime/size bump) invalidates the block.
|
||||||
assert!(c.cached_block(&u, 101, 4096, 0).is_none());
|
assert!(c.cached_block(&u, 101, 10, 0).is_none());
|
||||||
assert!(c.cached_block(&u, 100, 5000, 0).is_none());
|
assert!(c.cached_block(&u, 100, 5000, 0).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The block cache is written without an fsync, so a crash can leave a blob
|
||||||
|
/// shorter than the sidecar beside it claims. Serving that as content would
|
||||||
|
/// hand the reader a hole; it has to read as a miss.
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_block_reads_as_a_miss() {
|
||||||
|
let (c, _d) = cache();
|
||||||
|
let u = uid("a");
|
||||||
|
c.store_block(&u, 100, 10, 0, b"block-zero").unwrap();
|
||||||
|
std::fs::write(c.block_blob(&u, 0), b"block").unwrap();
|
||||||
|
assert!(c.cached_block(&u, 100, 10, 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn evict_drops_blocks() {
|
fn evict_drops_blocks() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
let u = uid("a");
|
let u = uid("a");
|
||||||
c.store_block(&u, 1, 8, 0, b"aaaa").unwrap();
|
// Both blocks are stored at their real geometry (a 4-byte file is one
|
||||||
c.store_block(&u, 1, 8, 1, b"bbbb").unwrap();
|
// block), so this fails if `evict` misses them rather than because the
|
||||||
|
// fixture was unreadable to begin with.
|
||||||
|
c.store_block(&u, 1, 4, 0, b"aaaa").unwrap();
|
||||||
|
assert!(c.cached_block(&u, 1, 4, 0).is_some());
|
||||||
c.evict(&u);
|
c.evict(&u);
|
||||||
assert!(c.cached_block(&u, 1, 8, 0).is_none());
|
assert!(c.cached_block(&u, 1, 4, 0).is_none());
|
||||||
assert!(c.cached_block(&u, 1, 8, 1).is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn block_budget_evicts_lru() {
|
fn block_budget_evicts_lru() {
|
||||||
// Block pool fits two 4-byte blocks but not three.
|
// Block pool fits two 4-byte blocks but not three. One block per file,
|
||||||
|
// so each fixture is a geometrically real single-block file.
|
||||||
let (c, _d) = cache_with_pool_cap(KIND_BLOCK, 8);
|
let (c, _d) = cache_with_pool_cap(KIND_BLOCK, 8);
|
||||||
let u = uid("a");
|
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
|
||||||
c.store_block(&u, 1, 12, 0, b"aaaa").unwrap();
|
c.store_block(&a, 1, 4, 0, b"aaaa").unwrap();
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
c.store_block(&u, 1, 12, 1, b"bbbb").unwrap();
|
c.store_block(&b, 1, 4, 0, b"bbbb").unwrap();
|
||||||
// Touch block 0 so block 1 is the least-recently-used.
|
// Touch a's block so b's is the least-recently-used.
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
assert!(c.cached_block(&u, 1, 12, 0).is_some());
|
assert!(c.cached_block(&a, 1, 4, 0).is_some());
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
c.store_block(&u, 1, 12, 2, b"cccc").unwrap();
|
c.store_block(&d, 1, 4, 0, b"cccc").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
c.cached_block(&u, 1, 12, 0).is_some(),
|
c.cached_block(&a, 1, 4, 0).is_some(),
|
||||||
"recently-read survives"
|
"recently-read survives"
|
||||||
);
|
);
|
||||||
assert!(c.cached_block(&u, 1, 12, 1).is_none(), "LRU block evicted");
|
assert!(c.cached_block(&b, 1, 4, 0).is_none(), "LRU block evicted");
|
||||||
assert!(c.cached_block(&u, 1, 12, 2).is_some(), "newest survives");
|
assert!(c.cached_block(&d, 1, 4, 0).is_some(), "newest survives");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
|
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
|
||||||
@@ -2174,6 +2435,39 @@ mod tests {
|
|||||||
drop(reopened);
|
drop(reopened);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `clone_or_copy` has three strategies and only the last one is portable,
|
||||||
|
/// so what matters is that the destination is byte-identical whichever the
|
||||||
|
/// filesystem under `/tmp` happens to take — including when it already held
|
||||||
|
/// longer content, which a reflink or a short `copy_file_range` must not
|
||||||
|
/// leave a tail of.
|
||||||
|
#[test]
|
||||||
|
fn clone_or_copy_reproduces_the_source_over_any_existing_destination() {
|
||||||
|
let dir = TempDir::new();
|
||||||
|
let src = dir.path().join("src");
|
||||||
|
let dst = dir.path().join("dst");
|
||||||
|
std::fs::write(&src, b"the new content").unwrap();
|
||||||
|
std::fs::write(&dst, vec![b'x'; 4096]).unwrap();
|
||||||
|
|
||||||
|
let len = ContentCache::clone_or_copy(&src, &dst).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(len, 15);
|
||||||
|
assert_eq!(std::fs::read(&dst).unwrap(), b"the new content");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty staged blob is a real case — a file truncated to zero and
|
||||||
|
/// closed — and `FICLONE` of a zero-length source is a documented `EINVAL`.
|
||||||
|
#[test]
|
||||||
|
fn clone_or_copy_handles_an_empty_source() {
|
||||||
|
let dir = TempDir::new();
|
||||||
|
let src = dir.path().join("empty");
|
||||||
|
let dst = dir.path().join("dst");
|
||||||
|
std::fs::write(&src, b"").unwrap();
|
||||||
|
std::fs::write(&dst, b"stale").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(ContentCache::clone_or_copy(&src, &dst).unwrap(), 0);
|
||||||
|
assert!(std::fs::read(&dst).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens
|
/// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens
|
||||||
/// at `release`. A scratch file marked durable must therefore outlive the
|
/// at `release`. A scratch file marked durable must therefore outlive the
|
||||||
/// open that clears the scratch directory — and an unmarked one must not,
|
/// open that clears the scratch directory — and an unmarked one must not,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use directories::ProjectDirs;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::menu::PromptConfig;
|
||||||
|
use crate::opener::OpenWith;
|
||||||
use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
||||||
|
|
||||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||||
@@ -106,6 +108,17 @@ pub struct AppConfig {
|
|||||||
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
|
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub conflict_sweep: Option<SweepMode>,
|
pub conflict_sweep: Option<SweepMode>,
|
||||||
|
/// How files opened from the prompt/browser are launched. `None` means
|
||||||
|
/// "hand everything to `xdg-open`", which is what the client did before
|
||||||
|
/// this existed. See [`crate::opener`]. Defaulted for configs predating the
|
||||||
|
/// field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub open_with: Option<OpenWith>,
|
||||||
|
/// `pdfs-prompt` behaviour, including the external launcher used by
|
||||||
|
/// `--dmenu`. See [`crate::menu`]. Defaulted for configs predating the
|
||||||
|
/// field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt: Option<PromptConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -118,6 +131,8 @@ impl Default for AppConfig {
|
|||||||
ignore_patterns: None,
|
ignore_patterns: None,
|
||||||
device_uid: None,
|
device_uid: None,
|
||||||
conflict_sweep: None,
|
conflict_sweep: None,
|
||||||
|
open_with: None,
|
||||||
|
prompt: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +144,18 @@ impl AppConfig {
|
|||||||
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The effective opener policy: the user's rules, or the all-`xdg-open`
|
||||||
|
/// default. Cloned rather than borrowed because callers hold it for the
|
||||||
|
/// life of a window, long after the config value is gone.
|
||||||
|
pub fn resolved_open_with(&self) -> OpenWith {
|
||||||
|
self.open_with.clone().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective prompt settings, defaulted when the section is absent.
|
||||||
|
pub fn resolved_prompt(&self) -> PromptConfig {
|
||||||
|
self.prompt.clone().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
|
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
|
||||||
/// it parses, then the user's stored choice, then [`SweepMode::default`].
|
/// it parses, then the user's stored choice, then [`SweepMode::default`].
|
||||||
///
|
///
|
||||||
@@ -448,6 +475,8 @@ mod tests {
|
|||||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||||
device_uid: Some("dev-uid".to_string()),
|
device_uid: Some("dev-uid".to_string()),
|
||||||
conflict_sweep: Some(SweepMode::Enforce),
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
|
open_with: None,
|
||||||
|
prompt: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ pub enum Request {
|
|||||||
/// timeline. Like `kind`, the offset is relative to the filtered set.
|
/// timeline. Like `kind`, the offset is relative to the filtered set.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
|
/// Restrict the page to favourited photos. Older front-ends omit it and
|
||||||
|
/// get the whole timeline, as before.
|
||||||
|
#[serde(default)]
|
||||||
|
favorites: bool,
|
||||||
},
|
},
|
||||||
/// The months the timeline spans (newest first, with per-month counts) so a
|
/// The months the timeline spans (newest first, with per-month counts) so a
|
||||||
/// front-end can build a date scrubber without paging the whole library.
|
/// front-end can build a date scrubber without paging the whole library.
|
||||||
@@ -91,6 +95,14 @@ pub enum Request {
|
|||||||
PhotoThumbs { uids: Vec<String> },
|
PhotoThumbs { uids: Vec<String> },
|
||||||
/// Download a photo's full content into the cache; replies with its path.
|
/// Download a photo's full content into the cache; replies with its path.
|
||||||
OpenPhoto { uid: String },
|
OpenPhoto { uid: String },
|
||||||
|
/// Add or remove Proton's `Favorite` tag on a photo. Replies with
|
||||||
|
/// [`Response::Ok`].
|
||||||
|
///
|
||||||
|
/// Favouriting a photo that is *not* on this account's own photos volume —
|
||||||
|
/// one shared with us, or one that lives only in an album — needs it
|
||||||
|
/// re-encrypted for our timeline, which the SDK does not implement yet; the
|
||||||
|
/// daemon reports that as an error rather than silently doing nothing.
|
||||||
|
SetPhotoFavorite { uid: String, favorite: bool },
|
||||||
/// Upload the photo at `source_path` under the given name and media type.
|
/// Upload the photo at `source_path` under the given name and media type.
|
||||||
///
|
///
|
||||||
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
||||||
@@ -108,7 +120,18 @@ pub enum Request {
|
|||||||
/// Download a Drive file's full content into the cache; replies with the
|
/// Download a Drive file's full content into the cache; replies with the
|
||||||
/// on-disk path so the front-end can open it with the default app. `path`
|
/// on-disk path so the front-end can open it with the default app. `path`
|
||||||
/// is mountpoint-relative.
|
/// is mountpoint-relative.
|
||||||
OpenFile { path: String },
|
///
|
||||||
|
/// `uid` addresses the same node directly and is preferred when present: a
|
||||||
|
/// search hit's path comes from the metadata index, which also indexes
|
||||||
|
/// nodes the primary mount's tree does not expose (an on-demand sync
|
||||||
|
/// folder, a mirror), and walking such a path from the mount root fails
|
||||||
|
/// with "no such file or folder". Defaulted for wire-compat with clients
|
||||||
|
/// predating the field; the daemon falls back to `path` without it.
|
||||||
|
OpenFile {
|
||||||
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
uid: Option<String>,
|
||||||
|
},
|
||||||
/// Full-text search node names against the daemon's local metadata index.
|
/// Full-text search node names against the daemon's local metadata index.
|
||||||
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
||||||
Search { query: String, limit: usize },
|
Search { query: String, limit: usize },
|
||||||
@@ -325,6 +348,39 @@ pub enum Request {
|
|||||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||||
RemovePublicLinkByUid { uid: String, id: String },
|
RemovePublicLinkByUid { uid: String, id: String },
|
||||||
|
|
||||||
|
// ---- revisions --------------------------------------------------------
|
||||||
|
/// List the version history of the file at mountpoint-relative `path`,
|
||||||
|
/// newest first. Replies with [`Response::Revisions`].
|
||||||
|
ListRevisions { path: String },
|
||||||
|
/// The by-uid twin of [`Request::ListRevisions`].
|
||||||
|
ListRevisionsByUid { uid: String },
|
||||||
|
/// Make revision `revision_id` of the file at `path` current again. The
|
||||||
|
/// server applies this asynchronously, so the restored content may take a
|
||||||
|
/// moment to appear. Replies with [`Response::Ok`].
|
||||||
|
RestoreRevision { path: String, revision_id: String },
|
||||||
|
/// The by-uid twin of [`Request::RestoreRevision`].
|
||||||
|
RestoreRevisionByUid { uid: String, revision_id: String },
|
||||||
|
/// Permanently delete revision `revision_id` of the file at `path`. The
|
||||||
|
/// content is unrecoverable, and the server refuses to delete the revision
|
||||||
|
/// that is currently active. Replies with [`Response::Ok`].
|
||||||
|
DeleteRevision { path: String, revision_id: String },
|
||||||
|
/// The by-uid twin of [`Request::DeleteRevision`].
|
||||||
|
DeleteRevisionByUid { uid: String, revision_id: String },
|
||||||
|
/// Write revision `revision_id` of the file at `path` to the absolute local
|
||||||
|
/// path `dest`, leaving the file's current content untouched. Replies with
|
||||||
|
/// [`Response::Ok`].
|
||||||
|
SaveRevisionAs {
|
||||||
|
path: String,
|
||||||
|
revision_id: String,
|
||||||
|
dest: String,
|
||||||
|
},
|
||||||
|
/// The by-uid twin of [`Request::SaveRevisionAs`].
|
||||||
|
SaveRevisionAsByUid {
|
||||||
|
uid: String,
|
||||||
|
revision_id: String,
|
||||||
|
dest: String,
|
||||||
|
},
|
||||||
|
|
||||||
// ---- shared by me -----------------------------------------------------
|
// ---- shared by me -----------------------------------------------------
|
||||||
/// List the nodes I have shared with others — collaborative shares that still
|
/// List the nodes I have shared with others — collaborative shares that still
|
||||||
/// have members, pending invitations or a public link. Replies with
|
/// have members, pending invitations or a public link. Replies with
|
||||||
@@ -608,6 +664,36 @@ pub struct SharedItem {
|
|||||||
pub link: Option<PublicLinkInfo>,
|
pub link: Option<PublicLinkInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One entry of a file's version history (in [`Response::Revisions`]).
|
||||||
|
///
|
||||||
|
/// The size and modification time are the ones the *uploader* claimed in the
|
||||||
|
/// revision's extended attributes, so an old client that wrote none leaves them
|
||||||
|
/// `None`; `size_on_storage` is always known but counts ciphertext, which is
|
||||||
|
/// larger than the file. A front-end shows the claimed size when it has one and
|
||||||
|
/// falls back to the storage size.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct RevisionInfo {
|
||||||
|
/// Server-assigned id — the handle for [`Request::RestoreRevision`],
|
||||||
|
/// [`Request::DeleteRevision`] and [`Request::SaveRevisionAs`].
|
||||||
|
pub id: String,
|
||||||
|
/// True for the revision that is the file's current content. It cannot be
|
||||||
|
/// deleted, and restoring it is a no-op.
|
||||||
|
pub is_active: bool,
|
||||||
|
/// When the revision was created, epoch seconds.
|
||||||
|
pub created: i64,
|
||||||
|
/// Encrypted size on Proton's storage, in bytes.
|
||||||
|
pub size_on_storage: i64,
|
||||||
|
/// Plaintext size as claimed by the uploader, when it wrote one.
|
||||||
|
pub claimed_size: Option<i64>,
|
||||||
|
/// Modification time as claimed by the uploader (ISO-8601, verbatim).
|
||||||
|
pub claimed_modified: Option<String>,
|
||||||
|
/// The address that signed the revision, when it was not signed anonymously
|
||||||
|
/// by the node key.
|
||||||
|
pub signed_by: Option<String>,
|
||||||
|
/// Whether the revision carries thumbnails.
|
||||||
|
pub has_thumbnails: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to
|
/// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to
|
||||||
/// an icon and a verb, and the human detail lives in the entry's fields.
|
/// an icon and a verb, and the human detail lives in the entry's fields.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -1014,6 +1100,11 @@ pub struct PhotoItem {
|
|||||||
/// the split omit it; a front-end then treats everything as a still photo.
|
/// the split omit it; a front-end then treats everything as a still photo.
|
||||||
#[serde(default = "default_photo_kind")]
|
#[serde(default = "default_photo_kind")]
|
||||||
pub kind: PhotoKind,
|
pub kind: PhotoKind,
|
||||||
|
/// Whether the photo is favourited. Older daemons omit it, which a front-end
|
||||||
|
/// reads as "not favourited" — the same thing it would show for a photo whose
|
||||||
|
/// tags it has not learned yet.
|
||||||
|
#[serde(default)]
|
||||||
|
pub favorite: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon too old to classify a timeline entry is assumed to have served a
|
/// A daemon too old to classify a timeline entry is assumed to have served a
|
||||||
@@ -1102,6 +1193,28 @@ pub enum Response {
|
|||||||
/// calling a queued `mkdir` an upload is a lie.
|
/// calling a queued `mkdir` an upload is a lie.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pending_changes: u64,
|
pending_changes: u64,
|
||||||
|
/// Queued writes held back because the file still wears a transient
|
||||||
|
/// name (a browser's `*.crdownload`, an editor's `*.swp`). They are not
|
||||||
|
/// waiting on the network and will not drain until the finalising
|
||||||
|
/// rename, so they are counted apart from `pending_uploads`.
|
||||||
|
#[serde(default)]
|
||||||
|
parked_uploads: u64,
|
||||||
|
/// Queued ops that have failed enough times to be considered stuck.
|
||||||
|
/// They keep retrying forever — correctly, since their staged bytes are
|
||||||
|
/// the only copy — which is why they have to be visible.
|
||||||
|
#[serde(default)]
|
||||||
|
failing_ops: u64,
|
||||||
|
/// The most recent error from a stuck op, when there is one.
|
||||||
|
#[serde(default)]
|
||||||
|
failing_error: Option<String>,
|
||||||
|
/// Bytes held in `staging/`: writes accepted from the kernel whose only
|
||||||
|
/// copy is on local disk. Never evictable, never counted against the
|
||||||
|
/// cache budget — hence reported separately.
|
||||||
|
#[serde(default)]
|
||||||
|
staged_bytes: u64,
|
||||||
|
/// Age of the oldest staged write, in seconds.
|
||||||
|
#[serde(default)]
|
||||||
|
staged_oldest_secs: u64,
|
||||||
},
|
},
|
||||||
/// A human-readable success message.
|
/// A human-readable success message.
|
||||||
Ok { message: String },
|
Ok { message: String },
|
||||||
@@ -1126,6 +1239,12 @@ pub enum Response {
|
|||||||
/// Whether the integrity check actually ran, so an empty problem list
|
/// Whether the integrity check actually ran, so an empty problem list
|
||||||
/// is not mistaken for a clean bill of health it never gave.
|
/// is not mistaken for a clean bill of health it never gave.
|
||||||
integrity_checked: bool,
|
integrity_checked: bool,
|
||||||
|
/// Whether a deep check is running right now. It reads every page of
|
||||||
|
/// the database under the daemon's only connection, so it is answered
|
||||||
|
/// asynchronously: this says "ask again shortly", which is a different
|
||||||
|
/// thing to say than either of the two above.
|
||||||
|
#[serde(default)]
|
||||||
|
integrity_running: bool,
|
||||||
},
|
},
|
||||||
/// The pin registry.
|
/// The pin registry.
|
||||||
Pins { pins: Vec<Pin> },
|
Pins { pins: Vec<Pin> },
|
||||||
@@ -1209,6 +1328,9 @@ pub enum Response {
|
|||||||
AccountQuota { max_space: i64, used_space: i64 },
|
AccountQuota { max_space: i64, used_space: i64 },
|
||||||
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
|
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
|
||||||
SharedByMe { items: Vec<SharedItem> },
|
SharedByMe { items: Vec<SharedItem> },
|
||||||
|
/// A file's version history, newest first (reply to
|
||||||
|
/// [`Request::ListRevisions`]).
|
||||||
|
Revisions { items: Vec<RevisionInfo> },
|
||||||
/// The daemon's recent activity, newest first (reply to
|
/// The daemon's recent activity, newest first (reply to
|
||||||
/// [`Request::ListActivity`]).
|
/// [`Request::ListActivity`]).
|
||||||
Activity { items: Vec<ActivityEntry> },
|
Activity { items: Vec<ActivityEntry> },
|
||||||
@@ -1858,6 +1980,101 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The revision requests come in path and by-uid pairs like the share ones,
|
||||||
|
/// and carry a revision id that must survive the round-trip verbatim: acting
|
||||||
|
/// on the wrong revision deletes content the user meant to keep.
|
||||||
|
#[test]
|
||||||
|
fn revision_requests_roundtrip_in_both_addressing_forms() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
Request::ListRevisions { path: "a/b".into() },
|
||||||
|
r#"{"ListRevisions":{"path":"a/b"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::ListRevisionsByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
},
|
||||||
|
r#"{"ListRevisionsByUid":{"uid":"vol~link"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::RestoreRevision {
|
||||||
|
path: "a/b".into(),
|
||||||
|
revision_id: "rev-1".into(),
|
||||||
|
},
|
||||||
|
r#"{"RestoreRevision":{"path":"a/b","revision_id":"rev-1"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::RestoreRevisionByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
revision_id: "rev-1".into(),
|
||||||
|
},
|
||||||
|
r#"{"RestoreRevisionByUid":{"uid":"vol~link","revision_id":"rev-1"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::DeleteRevision {
|
||||||
|
path: "a/b".into(),
|
||||||
|
revision_id: "rev-2".into(),
|
||||||
|
},
|
||||||
|
r#"{"DeleteRevision":{"path":"a/b","revision_id":"rev-2"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::DeleteRevisionByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
revision_id: "rev-2".into(),
|
||||||
|
},
|
||||||
|
r#"{"DeleteRevisionByUid":{"uid":"vol~link","revision_id":"rev-2"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::SaveRevisionAs {
|
||||||
|
path: "a/b".into(),
|
||||||
|
revision_id: "rev-3".into(),
|
||||||
|
dest: "/tmp/out.bin".into(),
|
||||||
|
},
|
||||||
|
r#"{"SaveRevisionAs":{"path":"a/b","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::SaveRevisionAsByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
revision_id: "rev-3".into(),
|
||||||
|
dest: "/tmp/out.bin".into(),
|
||||||
|
},
|
||||||
|
r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (request, wire) in cases {
|
||||||
|
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
||||||
|
let decoded: Request = serde_json::from_str(wire).unwrap();
|
||||||
|
assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A front-end that predates the favourites filter sends no `favorites`
|
||||||
|
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||||
|
#[test]
|
||||||
|
fn timeline_requests_without_the_favorites_filter_still_decode() {
|
||||||
|
let decoded: Request =
|
||||||
|
serde_json::from_str(r#"{"PhotosTimeline":{"offset":0,"limit":50}}"#).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
decoded,
|
||||||
|
Request::PhotosTimeline {
|
||||||
|
favorites: false,
|
||||||
|
kind: None,
|
||||||
|
range: None,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let wire = serde_json::to_string(&Request::SetPhotoFavorite {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
favorite: true,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
wire,
|
||||||
|
r#"{"SetPhotoFavorite":{"uid":"vol~link","favorite":true}}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn path_share_requests_keep_their_existing_wire_shape() {
|
fn path_share_requests_keep_their_existing_wire_shape() {
|
||||||
let cases = [
|
let cases = [
|
||||||
@@ -1995,6 +2212,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
||||||
|
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
|
||||||
|
let Request::OpenFile { path, uid } = serde_json::from_str::<Request>(legacy).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("decoded into the wrong variant");
|
||||||
|
};
|
||||||
|
assert_eq!(path, "Documents/ONBOARDING.md");
|
||||||
|
assert_eq!(uid, None);
|
||||||
|
|
||||||
|
let with_uid = serde_json::to_string(&Request::OpenFile {
|
||||||
|
path: "Documents/ONBOARDING.md".into(),
|
||||||
|
uid: Some("vol~link".into()),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(with_uid.contains("vol~link"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_daemons_reject_new_by_uid_variants() {
|
fn legacy_daemons_reject_new_by_uid_variants() {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ impl Db {
|
|||||||
/// whose stored `kind` no longer parses (written by an older build) are
|
/// whose stored `kind` no longer parses (written by an older build) are
|
||||||
/// skipped rather than failing the whole read.
|
/// skipped rather than failing the whole read.
|
||||||
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT time, kind, target, detail, ok FROM activity
|
"SELECT time, kind, target, detail, ok FROM activity
|
||||||
ORDER BY id DESC LIMIT ?1",
|
ORDER BY id DESC LIMIT ?1",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ impl Db {
|
|||||||
|
|
||||||
/// The persisted album listing, in stored order.
|
/// The persisted album listing, in stored order.
|
||||||
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
||||||
FROM albums ORDER BY seq",
|
FROM albums ORDER BY seq",
|
||||||
@@ -98,7 +98,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Number of albums in the persisted listing.
|
/// Number of albums in the persisted listing.
|
||||||
pub fn albums_count(&self) -> Result<usize> {
|
pub fn albums_count(&self) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
||||||
Ok(n.max(0) as usize)
|
Ok(n.max(0) as usize)
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ impl Db {
|
|||||||
offset: usize,
|
offset: usize,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||||
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
||||||
@@ -178,6 +178,10 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
|
// Album rows have no favourite state of their own: an album
|
||||||
|
// photo on someone else's volume is not in our timeline, and
|
||||||
|
// favouriting one is not supported yet anyway.
|
||||||
|
favorite: false,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
@@ -186,7 +190,7 @@ impl Db {
|
|||||||
|
|
||||||
/// How many photos of `album_uid` are persisted.
|
/// How many photos of `album_uid` are persisted.
|
||||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row(
|
let n: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
||||||
[album_uid],
|
[album_uid],
|
||||||
@@ -203,7 +207,7 @@ impl Db {
|
|||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let placeholders = vec!["?"; uids.len()].join(",");
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||||
@@ -218,6 +222,10 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
|
// Album rows have no favourite state of their own: an album
|
||||||
|
// photo on someone else's volume is not in our timeline, and
|
||||||
|
// favouriting one is not supported yet anyway.
|
||||||
|
favorite: false,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
|||||||
@@ -42,6 +42,30 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bump `last_accessed` for many keys in one transaction.
|
||||||
|
///
|
||||||
|
/// The read path touches a key per 128 KiB the kernel asks for — 32 times
|
||||||
|
/// per 4 MiB block — and each of those was its own write transaction under
|
||||||
|
/// the single connection mutex, contending with every `lookup` and
|
||||||
|
/// `readdir`. Callers accumulate touches in memory and flush them here.
|
||||||
|
pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> {
|
||||||
|
if touches.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare_cached(
|
||||||
|
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
|
||||||
|
)?;
|
||||||
|
for (key, at) in touches {
|
||||||
|
stmt.execute(params![key, at])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop a single cache-entry row (one evicted blob or block).
|
/// Drop a single cache-entry row (one evicted blob or block).
|
||||||
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
@@ -75,7 +99,7 @@ impl Db {
|
|||||||
/// because `enforce_block_budget` runs on every cached block — once per
|
/// because `enforce_block_budget` runs on every cached block — once per
|
||||||
/// 4 MiB of every cold read — while holding the shared connection.
|
/// 4 MiB of every cold read — while holding the shared connection.
|
||||||
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let total: i64 = conn.query_row(
|
let total: i64 = conn.query_row(
|
||||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
||||||
params![kind],
|
params![kind],
|
||||||
@@ -95,7 +119,7 @@ impl Db {
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<(String, u64)>> {
|
) -> Result<Vec<(String, u64)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare_cached(
|
let mut stmt = conn.prepare_cached(
|
||||||
"SELECT cache_key, size_bytes FROM cache_entries
|
"SELECT cache_key, size_bytes FROM cache_entries
|
||||||
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
||||||
@@ -115,7 +139,7 @@ impl Db {
|
|||||||
/// (`clear_unpinned`); incremental eviction wants
|
/// (`clear_unpinned`); incremental eviction wants
|
||||||
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
||||||
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT cache_key, size_bytes FROM cache_entries
|
"SELECT cache_key, size_bytes FROM cache_entries
|
||||||
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub struct StoredDevice {
|
|||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -41,32 +41,43 @@ impl Db {
|
|||||||
Ok(next)
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write one batch of walked entries under scan generation `generation`. The
|
/// Write one batch of walked entries under scan generation `generation`,
|
||||||
/// FTS index is *not* touched here — it is rebuilt once in
|
/// keeping the FTS index in step with them.
|
||||||
/// [`local_finish_scan`](Self::local_finish_scan).
|
///
|
||||||
|
/// The index is maintained incrementally rather than rebuilt at the end of
|
||||||
|
/// the scan, because a rebuild reads and re-tokenises every row in the table
|
||||||
|
/// — trigrams over a few hundred thousand paths — inside one transaction on
|
||||||
|
/// the single shared connection, which stalls the whole mount for as long as
|
||||||
|
/// it takes. Incremental maintenance is possible here for a reason specific
|
||||||
|
/// to this table: `local_fts` covers `name` and `path`, `path` is the
|
||||||
|
/// table's key, and `name` is derived from it, so an existing row's indexed
|
||||||
|
/// content can never change. Only insertions and deletions touch the index;
|
||||||
|
/// a re-scan that finds the same files does no index work at all.
|
||||||
|
///
|
||||||
|
/// Hence the update-then-insert shape below rather than an upsert: the
|
||||||
|
/// distinction between "already indexed" and "new row" is exactly what
|
||||||
|
/// decides whether the index needs a write.
|
||||||
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare_cached(
|
let mut update = tx.prepare_cached(
|
||||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
"UPDATE local_files
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
SET name = ?2, is_dir = ?3, size = ?4, mtime = ?5, scan_gen = ?6
|
||||||
ON CONFLICT(path) DO UPDATE SET
|
WHERE path = ?1",
|
||||||
name = excluded.name,
|
|
||||||
is_dir = excluded.is_dir,
|
|
||||||
size = excluded.size,
|
|
||||||
mtime = excluded.mtime,
|
|
||||||
scan_gen = excluded.scan_gen",
|
|
||||||
)?;
|
)?;
|
||||||
|
let mut insert = tx.prepare_cached(
|
||||||
|
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
)?;
|
||||||
|
let mut index =
|
||||||
|
tx.prepare_cached("INSERT INTO local_fts (rowid, name, path) VALUES (?1, ?2, ?3)")?;
|
||||||
for e in entries {
|
for e in entries {
|
||||||
stmt.execute(params![
|
let row = params![e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation];
|
||||||
e.path,
|
if update.execute(row)? == 0 {
|
||||||
e.name,
|
insert.execute(row)?;
|
||||||
e.is_dir as i64,
|
index.execute(params![tx.last_insert_rowid(), e.name, e.path])?;
|
||||||
e.size,
|
}
|
||||||
e.mtime,
|
|
||||||
generation
|
|
||||||
])?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
@@ -74,16 +85,25 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Close scan generation `generation`: drop every row an older scan wrote
|
/// Close scan generation `generation`: drop every row an older scan wrote
|
||||||
/// (those paths are gone from disk), rebuild the FTS index over what remains,
|
/// (those paths are gone from disk), drop their FTS entries, and stamp the
|
||||||
/// and stamp the completion time. Returns the number of indexed entries.
|
/// completion time. Returns the number of indexed entries.
|
||||||
|
///
|
||||||
|
/// The FTS deletes name the old values explicitly, which is how an
|
||||||
|
/// external-content FTS5 table is told to retract a row — see
|
||||||
|
/// [`local_upsert_batch`](Self::local_upsert_batch) for why this is done
|
||||||
|
/// per-row instead of by rebuilding the index.
|
||||||
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO local_fts (local_fts, rowid, name, path)
|
||||||
|
SELECT 'delete', id, name, path FROM local_files WHERE scan_gen != ?1",
|
||||||
|
params![generation],
|
||||||
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM local_files WHERE scan_gen != ?1",
|
"DELETE FROM local_files WHERE scan_gen != ?1",
|
||||||
params![generation],
|
params![generation],
|
||||||
)?;
|
)?;
|
||||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -98,7 +118,7 @@ impl Db {
|
|||||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||||
/// needs an immediate rescan or can serve the existing index.
|
/// needs an immediate rescan or can serve the existing index.
|
||||||
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||||
@@ -118,7 +138,7 @@ impl Db {
|
|||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt;
|
let mut stmt;
|
||||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
@@ -157,7 +177,7 @@ impl Db {
|
|||||||
if query.is_empty() || limit == 0 {
|
if query.is_empty() || limit == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let terms = candidate_trigrams(query);
|
let terms = candidate_trigrams(query);
|
||||||
let lane_limit = limit.div_ceil(2);
|
let lane_limit = limit.div_ceil(2);
|
||||||
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
||||||
@@ -182,16 +202,36 @@ impl Db {
|
|||||||
if !terms.is_empty()
|
if !terms.is_empty()
|
||||||
&& let Some(first) = query.chars().next()
|
&& let Some(first) = query.chars().next()
|
||||||
{
|
{
|
||||||
let prefix = format!("{}%", like_escape(&first.to_string()));
|
// Trigram rank says nothing about how well a name *starts* with the
|
||||||
let mut stmt = conn.prepare(
|
// query, so on a home directory full of dependency trees the pool
|
||||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
// fills with vendored files before the obvious answer is reached
|
||||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
// (measured: 73% of the 500 best-ranked candidates for "test" came
|
||||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
// from one Go module cache). Give whole-query prefix matches their
|
||||||
)?;
|
// own lane, newest first, and keep the single-character lane —
|
||||||
let fallback = stmt
|
// which recovers a typo that destroyed every trigram — behind it.
|
||||||
.query_map(params![prefix, lane_limit as i64], local_hit)?
|
let mut lanes = vec![(
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
format!("{}%", like_escape(query)),
|
||||||
for hit in fallback {
|
"ORDER BY mtime DESC LIMIT ?2",
|
||||||
|
)];
|
||||||
|
lanes.push((
|
||||||
|
format!("{}%", like_escape(&first.to_string())),
|
||||||
|
"ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||||
|
));
|
||||||
|
let mut extra = Vec::new();
|
||||||
|
for (pattern, order) in lanes {
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||||
|
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' {order}"
|
||||||
|
))?;
|
||||||
|
extra.extend(
|
||||||
|
stmt.query_map(params![pattern, lane_limit as i64], local_hit)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Prefix hits go in front: `rows` is truncated to `limit` below, and
|
||||||
|
// the lane exists precisely because trigram rank was burying them.
|
||||||
|
let trigram = std::mem::take(&mut rows);
|
||||||
|
for hit in extra.into_iter().chain(trigram) {
|
||||||
if !rows.iter().any(|existing| existing.path == hit.path) {
|
if !rows.iter().any(|existing| existing.path == hit.path) {
|
||||||
rows.push(hit);
|
rows.push(hit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 19;
|
pub(super) const SCHEMA_VERSION: i64 = 25;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -98,6 +98,69 @@ impl Db {
|
|||||||
if current < 19 {
|
if current < 19 {
|
||||||
tx.execute_batch(MIGRATION_V19)?;
|
tx.execute_batch(MIGRATION_V19)?;
|
||||||
}
|
}
|
||||||
|
if current < 20 {
|
||||||
|
tx.execute_batch(MIGRATION_V20)?;
|
||||||
|
}
|
||||||
|
if current < 21 {
|
||||||
|
// A V17-and-older fixture may carry only the tables its own test
|
||||||
|
// cares about; there is nothing to reindex without `nodes`.
|
||||||
|
let has_nodes: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_nodes {
|
||||||
|
tx.execute_batch(MIGRATION_V21)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 22 {
|
||||||
|
// Older fixtures (and a database that never grew a table because
|
||||||
|
// its feature was never used) may be missing any of these, and an
|
||||||
|
// index on an absent table is a hard error rather than a no-op.
|
||||||
|
for (table, sql) in MIGRATION_V22 {
|
||||||
|
let present: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if present {
|
||||||
|
tx.execute_batch(sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `activity_time` indexes `time DESC` while both activity queries
|
||||||
|
// order by `id`: it has only ever cost writes.
|
||||||
|
tx.execute_batch("DROP INDEX IF EXISTS activity_time;")?;
|
||||||
|
}
|
||||||
|
if current < 23 {
|
||||||
|
// One last full rebuild of the local index, because from here on the
|
||||||
|
// scan maintains it incrementally (`local_upsert_batch`) and so can
|
||||||
|
// no longer repair a `local_fts` that drifted from `local_files`.
|
||||||
|
// Whatever it costs, it costs once, at startup, before the mount
|
||||||
|
// serves anything.
|
||||||
|
let present: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'local_fts'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if present {
|
||||||
|
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 24 {
|
||||||
|
tx.execute_batch(MIGRATION_V24)?;
|
||||||
|
}
|
||||||
|
if current < 25 {
|
||||||
|
// Same guard as V21: an old fixture may not have `nodes` at all,
|
||||||
|
// and there is no path to materialise without it.
|
||||||
|
let has_nodes: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_nodes {
|
||||||
|
tx.execute_batch(MIGRATION_V25)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -511,3 +574,154 @@ CREATE TABLE album_photos (
|
|||||||
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// V20: whether a photo is a favourite.
|
||||||
|
///
|
||||||
|
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||||
|
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||||
|
/// server's own classification, and the Photos page already derives its tabs
|
||||||
|
/// from the media type). Defaulting to 0 means an existing timeline stays
|
||||||
|
/// intact and each row learns its real value on the next refresh, the same way
|
||||||
|
/// `media_type` did.
|
||||||
|
const MIGRATION_V20: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V21: rebuild the node search index after fixing what counts as indexable.
|
||||||
|
///
|
||||||
|
/// The v16 backfill rooted its path walk at any node whose parent is null *or*
|
||||||
|
/// not cached, but the write path's `node_is_indexable_tx` demanded a
|
||||||
|
/// null-parent ancestor. Only the My Files root has one — a device folder's
|
||||||
|
/// root is stored in `device`/`sync_folder`, never in `nodes` — so every node
|
||||||
|
/// under a device folder was dropped from `nodes_fts` the first time it was
|
||||||
|
/// upserted after the backfill, and was unfindable from the prompt (2,705 of
|
||||||
|
/// 9,252 nodes on the account this was diagnosed against).
|
||||||
|
///
|
||||||
|
/// The rule now matches the backfill, so this replays it: same recursive walk
|
||||||
|
/// as v16, over the current `nodes`.
|
||||||
|
/// Two details the v16 backfill did not carry, both cheap here: a node under a
|
||||||
|
/// trashed folder stays out (the write path has always excluded it, and
|
||||||
|
/// `tombstone_subtree_tx` only marks a subtree it was told about), and the walk
|
||||||
|
/// is depth-capped so a parent cycle in a corrupted database cannot spin the
|
||||||
|
/// migration forever.
|
||||||
|
const MIGRATION_V21: &str = "
|
||||||
|
DROP TABLE IF EXISTS nodes_fts;
|
||||||
|
CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
||||||
|
name, path, tokenize='trigram'
|
||||||
|
);
|
||||||
|
INSERT INTO nodes_fts (rowid, name, path)
|
||||||
|
WITH RECURSIVE paths(rowid, uid, path, trashed, depth) AS (
|
||||||
|
-- Only a true root (no parent at all) contributes nothing to the path: its
|
||||||
|
-- name is the mount. A row whose parent is merely uncached is an ordinary
|
||||||
|
-- folder and keeps its name, which is what `path_of` does at runtime.
|
||||||
|
SELECT n.rowid, n.uid,
|
||||||
|
CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, n.trashed, 0
|
||||||
|
FROM nodes n
|
||||||
|
WHERE n.parent_uid IS NULL
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.rowid, n.uid,
|
||||||
|
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||||
|
MAX(paths.trashed, n.trashed), paths.depth + 1
|
||||||
|
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||||
|
WHERE paths.depth < 256
|
||||||
|
)
|
||||||
|
SELECT n.rowid, n.name, paths.path
|
||||||
|
FROM nodes n JOIN paths ON paths.rowid = n.rowid
|
||||||
|
WHERE paths.trashed = 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V22: indexes for the lookups that were scanning, each paired with the table
|
||||||
|
/// it needs to exist for.
|
||||||
|
///
|
||||||
|
/// Each backs a query on a hot path that had no index to use:
|
||||||
|
///
|
||||||
|
/// - `sync_entry(remote_uid)` — the reconcile resolves a remote uid to its
|
||||||
|
/// local entry once per node, per pass.
|
||||||
|
/// - `local_files(scan_gen)` — the sweep that retires the previous generation
|
||||||
|
/// after a local index scan.
|
||||||
|
/// - `nodes(parent_uid) WHERE trashed = 0` — every listing, with the partial
|
||||||
|
/// index keeping it to the live rows a listing actually wants.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* here: an index on `pending_op(next_attempt_at, id)`.
|
||||||
|
/// `next_due_op` orders by `id`, which is the rowid, so it already stops at the
|
||||||
|
/// first due row; offering the planner a `next_attempt_at` index makes it scan
|
||||||
|
/// that index and then sort the matches by `id` instead —
|
||||||
|
/// `next_due_op_does_not_scale_with_queue_length` measures the regression.
|
||||||
|
const MIGRATION_V22: [(&str, &str); 3] = [
|
||||||
|
(
|
||||||
|
"sync_entry",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sync_entry_remote_uid ON sync_entry(remote_uid);",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"local_files",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_local_files_scan_gen ON local_files(scan_gen);",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"nodes",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// V24: the revisions this daemon sealed itself, so restarting does not reopen
|
||||||
|
/// the conflict hole B70 layer B closed.
|
||||||
|
///
|
||||||
|
/// `Core::own_sealed_revs` held these in memory: a queued write whose baseline
|
||||||
|
/// names an earlier revision is a conflict *unless* the remote sits at a
|
||||||
|
/// revision we sealed, in which case it is one writer stalling and resuming and
|
||||||
|
/// must supersede rather than fork a `(sync-conflict)` copy. Losing the map at
|
||||||
|
/// shutdown meant the first drain after a restart could not tell the two apart.
|
||||||
|
///
|
||||||
|
/// `sealed_at` is what bounds the table: entries older than the window in which
|
||||||
|
/// a stall→resume is plausible are pruned on write.
|
||||||
|
const MIGRATION_V24: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS own_sealed_rev (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
revision_id TEXT NOT NULL,
|
||||||
|
sealed_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V25: `nodes.path` — a node's mountpoint-relative path, stored rather than
|
||||||
|
/// walked.
|
||||||
|
///
|
||||||
|
/// `path_of` was a recursive CTE per node, and search ran it once per candidate
|
||||||
|
/// (up to a thousand per keystroke) on top of the one the write path already ran
|
||||||
|
/// per descendant of every folder upsert. The value only changes when a node's
|
||||||
|
/// name or parent changes, which is exactly when the write path is already
|
||||||
|
/// walking the subtree — so it belongs in a column.
|
||||||
|
///
|
||||||
|
/// The backfill is the same walk V21 used for `nodes_fts`, minus the trashed
|
||||||
|
/// filter: a trashed node stays out of the index but still has a path. Roots get
|
||||||
|
/// `''` (their name is the mount), and a node whose parent is merely uncached
|
||||||
|
/// keeps its own name — both matching what `path_of` returned at runtime.
|
||||||
|
///
|
||||||
|
/// Materialised into a temp table rather than written by a correlated subquery,
|
||||||
|
/// so the recursive walk runs once instead of once per row.
|
||||||
|
const MIGRATION_V25: &str = "
|
||||||
|
ALTER TABLE nodes ADD COLUMN path TEXT;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE node_paths (uid TEXT PRIMARY KEY, path TEXT NOT NULL);
|
||||||
|
|
||||||
|
INSERT INTO node_paths (uid, path)
|
||||||
|
WITH RECURSIVE paths(uid, path, depth) AS (
|
||||||
|
SELECT n.uid, CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, 0
|
||||||
|
FROM nodes n
|
||||||
|
WHERE n.parent_uid IS NULL
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.uid,
|
||||||
|
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||||
|
paths.depth + 1
|
||||||
|
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||||
|
WHERE paths.depth < 256
|
||||||
|
)
|
||||||
|
SELECT uid, path FROM paths;
|
||||||
|
|
||||||
|
UPDATE nodes
|
||||||
|
SET path = (SELECT p.path FROM node_paths p WHERE p.uid = nodes.uid)
|
||||||
|
WHERE EXISTS (SELECT 1 FROM node_paths p WHERE p.uid = nodes.uid);
|
||||||
|
|
||||||
|
DROP TABLE node_paths;
|
||||||
|
";
|
||||||
@@ -2,9 +2,17 @@
|
|||||||
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
||||||
//!
|
//!
|
||||||
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
||||||
//! the same data through the control socket. The connection is wrapped in a
|
//! the same data through the control socket. There is one write connection
|
||||||
//! `Mutex` because the FUSE callbacks are synchronous and already serialize
|
//! behind a `Mutex` — SQLite allows exactly one writer, and the single-instance
|
||||||
//! behind the `State` lock, so a connection pool would be overkill.
|
//! `flock` makes sure it is ours — and a small pool of read-only connections
|
||||||
|
//! behind [`Db::read`], which every `SELECT`-only method uses.
|
||||||
|
//!
|
||||||
|
//! The pool is what makes WAL worth having. The original justification for a
|
||||||
|
//! single connection was that "FUSE callbacks already serialize behind the
|
||||||
|
//! `State` lock"; that stopped being true when reads moved onto an 11-thread
|
||||||
|
//! worker pool, and a lookup then queued behind whatever listing happened to be
|
||||||
|
//! committing. Under WAL a reader never blocks the writer and vice versa, so the
|
||||||
|
//! only thing left serialising them was this process's own mutex.
|
||||||
//!
|
//!
|
||||||
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
||||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||||
@@ -18,8 +26,10 @@
|
|||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::Result;
|
use crate::{Error, Result};
|
||||||
use std::path::Path;
|
use std::fs::File;
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
mod activity;
|
mod activity;
|
||||||
mod albums;
|
mod albums;
|
||||||
@@ -49,7 +59,8 @@ pub use ops::{
|
|||||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||||
|
pub use pins::PinRow;
|
||||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||||
pub use trash::StoredTrash;
|
pub use trash::StoredTrash;
|
||||||
|
|
||||||
@@ -65,18 +76,198 @@ const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
|
|||||||
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
||||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Page cache, in KiB (applied negated, which is how SQLite reads a size in KiB
|
||||||
|
/// rather than in pages). 32 MiB against a database that is typically tens of
|
||||||
|
/// MiB: large enough to hold the working set of a listing-heavy session, small
|
||||||
|
/// enough to be unremarkable in a desktop daemon's RSS.
|
||||||
|
const CACHE_SIZE_KIB: i64 = 32 * 1024;
|
||||||
|
|
||||||
|
/// Ceiling on the memory-mapped window over the database file. 256 MiB covers
|
||||||
|
/// any realistic index while keeping the mapping bounded.
|
||||||
|
const MMAP_SIZE: i64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// WAL pages between automatic checkpoints (default 1000, i.e. ~4 MiB). Halved
|
||||||
|
/// so the synchronous checkpoint a committing thread inherits stays short.
|
||||||
|
const WAL_AUTOCHECKPOINT_PAGES: i64 = 500;
|
||||||
|
|
||||||
/// Handle to the unified metadata database.
|
/// Handle to the unified metadata database.
|
||||||
///
|
///
|
||||||
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
||||||
/// the inner `Mutex<Connection>`.
|
/// the inner `Mutex<Connection>`.
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
|
/// Read-only connections for `SELECT`-only methods. `None` for an in-memory
|
||||||
|
/// database, where a second connection would open a different, empty one —
|
||||||
|
/// those fall back to the write connection.
|
||||||
|
readers: Option<ReadPool>,
|
||||||
|
/// The single-writer lock, held open for as long as this handle lives.
|
||||||
|
///
|
||||||
|
/// Never read; dropping the `File` is what releases the `flock`, so it has
|
||||||
|
/// to be owned here rather than by `open`'s stack frame. `None` for
|
||||||
|
/// in-memory databases, which nothing else can reach.
|
||||||
|
_single_writer: Option<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle read-only connections, opened on demand and kept for reuse.
|
||||||
|
///
|
||||||
|
/// Not a bounded pool: the number in flight is bounded by the number of threads
|
||||||
|
/// that can be inside a read at once (the FUSE worker lanes plus the control
|
||||||
|
/// handlers), and blocking one of those on a permit would reintroduce exactly
|
||||||
|
/// the queueing this exists to remove. Only the *idle* set is capped, so a burst
|
||||||
|
/// does not leave a connection per thread parked forever.
|
||||||
|
struct ReadPool {
|
||||||
|
path: PathBuf,
|
||||||
|
idle: Mutex<Vec<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many idle read connections to keep. Enough that the steady state never
|
||||||
|
/// reopens, small enough to be unremarkable.
|
||||||
|
const MAX_IDLE_READERS: usize = 4;
|
||||||
|
|
||||||
|
impl ReadPool {
|
||||||
|
fn take(&self) -> Option<Connection> {
|
||||||
|
if let Some(conn) = self.idle.lock().pop() {
|
||||||
|
return Some(conn);
|
||||||
|
}
|
||||||
|
match open_read_only(&self.path) {
|
||||||
|
Ok(conn) => Some(conn),
|
||||||
|
Err(error) => {
|
||||||
|
// Falling back to the write connection is correct, just slower,
|
||||||
|
// so this is a warning and not a failed read.
|
||||||
|
tracing::warn!(%error, "could not open a read-only connection");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(&self, conn: Connection) {
|
||||||
|
let mut idle = self.idle.lock();
|
||||||
|
if idle.len() < MAX_IDLE_READERS {
|
||||||
|
idle.push(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection to read through: one borrowed from [`ReadPool`], or the write
|
||||||
|
/// connection when there is no pool (in-memory) or opening one failed.
|
||||||
|
///
|
||||||
|
/// Derefs to `Connection`, so a method converts by swapping `self.conn.lock()`
|
||||||
|
/// for `self.read()` and nothing else.
|
||||||
|
pub(super) struct Reader<'a> {
|
||||||
|
pooled: Option<Connection>,
|
||||||
|
writer: Option<parking_lot::MutexGuard<'a, Connection>>,
|
||||||
|
pool: Option<&'a ReadPool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for Reader<'_> {
|
||||||
|
type Target = Connection;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Connection {
|
||||||
|
self.pooled
|
||||||
|
.as_ref()
|
||||||
|
.or(self.writer.as_deref())
|
||||||
|
.expect("a Reader always holds one of the two")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Reader<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let (Some(conn), Some(pool)) = (self.pooled.take(), self.pool) {
|
||||||
|
pool.put(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a second connection to the same file for reads only.
|
||||||
|
///
|
||||||
|
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||||
|
/// a routing mistake at the file, the pragma stops it at the statement. The rest
|
||||||
|
/// mirror [`Db::open_configured`] — a read connection has its own page cache,
|
||||||
|
/// its own temp store and its own busy timeout, and gets nothing from the
|
||||||
|
/// writer's.
|
||||||
|
///
|
||||||
|
/// `journal_mode` is deliberately absent: it is a property of the database file,
|
||||||
|
/// which the writer already set to WAL, and a read-only connection cannot change
|
||||||
|
/// it anyway.
|
||||||
|
fn open_read_only(path: &Path) -> Result<Connection> {
|
||||||
|
use rusqlite::OpenFlags;
|
||||||
|
let conn = Connection::open_with_flags(
|
||||||
|
path,
|
||||||
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
|
)?;
|
||||||
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||||
|
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||||
|
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||||
|
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||||
|
conn.pragma_update(None, "query_only", true)?;
|
||||||
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
||||||
/// the schema up to [`SCHEMA_VERSION`].
|
/// the schema up to [`SCHEMA_VERSION`].
|
||||||
|
///
|
||||||
|
/// Takes the single-instance lock first (see
|
||||||
|
/// [`acquire_single_writer_lock`]), and rebuilds from empty if what is on
|
||||||
|
/// disk turns out not to be a readable database (see [`is_corrupt`]).
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
let single_writer = acquire_single_writer_lock(path)?;
|
||||||
|
let conn = match Self::open_configured(path) {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(e) if is_corrupt(&e) => {
|
||||||
|
// Every byte in here is derived from the account or from the
|
||||||
|
// content cache directory, so the recovery is to start over —
|
||||||
|
// loudly, and keeping the damaged file for a post-mortem rather
|
||||||
|
// than deleting evidence.
|
||||||
|
tracing::error!(
|
||||||
|
db = %path.display(),
|
||||||
|
error = %e,
|
||||||
|
"cache database is corrupt — moving it aside and rebuilding \
|
||||||
|
(metadata will be re-fetched; no user data is stored here)"
|
||||||
|
);
|
||||||
|
quarantine_corrupt_db(path)?;
|
||||||
|
Self::open_configured(path)?
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
// No pool for `:memory:`. A second connection to that name is a
|
||||||
|
// second, empty database rather than another view of this one, so
|
||||||
|
// every routed read would find a schema that does not exist.
|
||||||
|
readers: (!is_in_memory(path)).then(|| ReadPool {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
idle: Mutex::new(Vec::new()),
|
||||||
|
}),
|
||||||
|
_single_writer: single_writer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection for a `SELECT`-only method.
|
||||||
|
///
|
||||||
|
/// Reads see the last committed state, which is the same thing they saw
|
||||||
|
/// through the write connection: every method here commits before it
|
||||||
|
/// returns, and nothing holds a transaction open across calls.
|
||||||
|
pub(super) fn read(&self) -> Reader<'_> {
|
||||||
|
if let Some(pool) = &self.readers
|
||||||
|
&& let Some(conn) = pool.take()
|
||||||
|
{
|
||||||
|
return Reader {
|
||||||
|
pooled: Some(conn),
|
||||||
|
writer: None,
|
||||||
|
pool: Some(pool),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Reader {
|
||||||
|
pooled: None,
|
||||||
|
writer: Some(self.conn.lock()),
|
||||||
|
pool: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the file, apply every PRAGMA, and migrate. Split out of
|
||||||
|
/// [`open`](Self::open) so the corruption path can retry it verbatim.
|
||||||
|
fn open_configured(path: &Path) -> Result<Connection> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
// WAL: readers never block the single writer. NORMAL sync is the
|
// WAL: readers never block the single writer. NORMAL sync is the
|
||||||
// standard durability/throughput tradeoff for WAL.
|
// standard durability/throughput tradeoff for WAL.
|
||||||
@@ -101,12 +292,39 @@ impl Db {
|
|||||||
// (Bare SQLite does default to 0, which is where the belief that this
|
// (Bare SQLite does default to 0, which is where the belief that this
|
||||||
// was unset came from. That default is not what we get.)
|
// was unset came from. That default is not what we get.)
|
||||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||||
|
// Page cache. The default is 2 MiB, against a schema whose hot table
|
||||||
|
// stores a JSON blob per node — a listing of a few thousand files
|
||||||
|
// evicts itself while it is being read.
|
||||||
|
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||||
|
// Sorts and recursive CTEs materialise their intermediate results.
|
||||||
|
// Every `ORDER BY … COLLATE NOCASE` listing and every ancestor walk
|
||||||
|
// spills those to a file in `/tmp` by default; they are small and
|
||||||
|
// short-lived, so memory is both faster and less surprising.
|
||||||
|
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||||
|
// Read the database through the page cache of the OS rather than
|
||||||
|
// `pread` per page. Bounded rather than unlimited so the daemon's RSS
|
||||||
|
// does not track the database size.
|
||||||
|
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||||
|
// Checkpointing is synchronous in whichever thread happens to commit
|
||||||
|
// the transaction that crosses the threshold — often a FUSE callback.
|
||||||
|
// A smaller autocheckpoint makes that debt smaller and more frequent
|
||||||
|
// rather than rare and multi-second.
|
||||||
|
conn.pragma_update(None, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES)?;
|
||||||
|
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
|
readers: None,
|
||||||
|
_single_writer: None,
|
||||||
};
|
};
|
||||||
db.migrate()?;
|
db.migrate()?;
|
||||||
Ok(db)
|
Ok(db.into_conn())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap the connection back out of a temporary handle. Only
|
||||||
|
/// [`open_configured`](Self::open_configured) uses it, to run `migrate`
|
||||||
|
/// (which is written against `&self`) before the real handle exists.
|
||||||
|
fn into_conn(self) -> Connection {
|
||||||
|
self.conn.into_inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an in-memory database. For tests.
|
/// Open an in-memory database. For tests.
|
||||||
@@ -119,6 +337,8 @@ impl Db {
|
|||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
|
readers: None,
|
||||||
|
_single_writer: None,
|
||||||
};
|
};
|
||||||
db.migrate()?;
|
db.migrate()?;
|
||||||
Ok(db)
|
Ok(db)
|
||||||
@@ -132,5 +352,102 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `path` names the in-memory database rather than a file. Both
|
||||||
|
/// spellings SQLite accepts are checked, because neither has a lock file or a
|
||||||
|
/// corruption story.
|
||||||
|
fn is_in_memory(path: &Path) -> bool {
|
||||||
|
let p = path.as_os_str();
|
||||||
|
p == ":memory:" || p.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the single-instance write lock for the database at `path`.
|
||||||
|
///
|
||||||
|
/// The daemon is the only writer by design — the front-ends go through the
|
||||||
|
/// control socket — but nothing stopped a hand-run `pdfs mount` from starting
|
||||||
|
/// alongside the systemd unit, at which point two processes own the same inode
|
||||||
|
/// space, the same content cache, and the same drain queue. SQLite's own
|
||||||
|
/// locking makes each *statement* safe and does nothing about that.
|
||||||
|
///
|
||||||
|
/// `flock` on a sibling `.lock` file rather than on the database itself:
|
||||||
|
/// SQLite uses POSIX record locks, so the two never interact, and a lock file
|
||||||
|
/// left behind carries no state — the lock lives in the kernel and dies with
|
||||||
|
/// the process, however it exits.
|
||||||
|
fn acquire_single_writer_lock(path: &Path) -> Result<Option<File>> {
|
||||||
|
if is_in_memory(path) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let lock_path = lock_path_for(path);
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.truncate(false)
|
||||||
|
.write(true)
|
||||||
|
.open(&lock_path)?;
|
||||||
|
// SAFETY: `file` owns the descriptor and outlives the call.
|
||||||
|
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||||
|
if rc != 0 {
|
||||||
|
let e = std::io::Error::last_os_error();
|
||||||
|
if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||||
|
return Err(Error::Other(format!(
|
||||||
|
"another Proton Drive daemon is already using {} — \
|
||||||
|
stop it first (`systemctl --user stop proton-drive.service`)",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Err(Error::Io(e));
|
||||||
|
}
|
||||||
|
Ok(Some(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_path_for(path: &Path) -> PathBuf {
|
||||||
|
let mut name = path.as_os_str().to_owned();
|
||||||
|
name.push(".lock");
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `e` says the file is not a usable database, as opposed to any other
|
||||||
|
/// failure. These are the only two conditions rebuilding fixes; a disk error, a
|
||||||
|
/// permission problem or a newer-than-supported schema must all still fail.
|
||||||
|
fn is_corrupt(e: &Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
Error::Db(rusqlite::Error::SqliteFailure(inner, _))
|
||||||
|
if inner.code == rusqlite::ErrorCode::DatabaseCorrupt
|
||||||
|
|| inner.code == rusqlite::ErrorCode::NotADatabase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||||
|
/// open starts from empty.
|
||||||
|
///
|
||||||
|
/// Renamed rather than deleted: this is a cache, but it is also the only
|
||||||
|
/// evidence of what went wrong, and it costs one file the user can remove.
|
||||||
|
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let mut from = path.as_os_str().to_owned();
|
||||||
|
from.push(suffix);
|
||||||
|
let from = PathBuf::from(from);
|
||||||
|
if !from.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut to = path.as_os_str().to_owned();
|
||||||
|
to.push(format!(".corrupt-{stamp}{suffix}"));
|
||||||
|
let to = PathBuf::from(to);
|
||||||
|
// A failure to rename the sidecars is survivable — SQLite discards a
|
||||||
|
// WAL whose database header no longer matches — but failing to move the
|
||||||
|
// database itself is not, because the retry would hit the same file.
|
||||||
|
match std::fs::rename(&from, &to) {
|
||||||
|
Ok(()) => tracing::warn!(from = %from.display(), to = %to.display(), "quarantined"),
|
||||||
|
Err(e) if suffix.is_empty() => return Err(Error::Io(e)),
|
||||||
|
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -74,7 +74,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Every local location, with device fields joined from `sync_folder`.
|
/// Every local location, with device fields joined from `sync_folder`.
|
||||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
m.id,
|
m.id,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use crate::{Access, Result};
|
|||||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use proton_drive_rs::{Node, NodeKind};
|
use proton_drive_rs::{Node, NodeKind};
|
||||||
|
|
||||||
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
|
use super::utils::{
|
||||||
|
HitRow, TRIGRAM_MIN, collect_hits, hit_row, join_path, like_escape, path_of, walk_path_of,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct StoredNode {
|
pub struct StoredNode {
|
||||||
pub node: Node,
|
pub node: Node,
|
||||||
@@ -59,7 +61,7 @@ impl Db {
|
|||||||
/// flag. Synthetic shared listings use this to serve the last completed
|
/// flag. Synthetic shared listings use this to serve the last completed
|
||||||
/// snapshot while offline, including after a live event expired its TTL.
|
/// snapshot while offline, including after a live event expired its TTL.
|
||||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json FROM nodes
|
"SELECT node_json FROM nodes
|
||||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
||||||
@@ -285,7 +287,7 @@ impl Db {
|
|||||||
/// Check if a folder node has any non-trashed children in the database.
|
/// Check if a folder node has any non-trashed children in the database.
|
||||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||||
let uid_str = parent_uid.to_string();
|
let uid_str = parent_uid.to_string();
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
||||||
params![uid_str],
|
params![uid_str],
|
||||||
@@ -294,6 +296,25 @@ impl Db {
|
|||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node's mountpoint-relative path, or `None` when it is not cached.
|
||||||
|
///
|
||||||
|
/// One indexed read of the stored `path`. Callers that need to relate many
|
||||||
|
/// nodes to a handful of roots — search decorating each hit with its
|
||||||
|
/// mountpoint — resolve the roots once through this and do the rest as
|
||||||
|
/// string arithmetic, instead of a `path_relative_to` walk per pair.
|
||||||
|
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let exists: Option<i64> = conn
|
||||||
|
.query_row("SELECT 1 FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(path_of(&conn, uid)?))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve `uid` relative to an ancestor node.
|
/// Resolve `uid` relative to an ancestor node.
|
||||||
///
|
///
|
||||||
/// This is used when a remote subtree has its own local sync mount: the
|
/// This is used when a remote subtree has its own local sync mount: the
|
||||||
@@ -302,7 +323,7 @@ impl Db {
|
|||||||
/// chain keeps callers from accidentally joining a Drive-wide path onto the
|
/// chain keeps callers from accidentally joining a Drive-wide path onto the
|
||||||
/// wrong mountpoint. The ancestor itself resolves to the empty path.
|
/// wrong mountpoint. The ancestor itself resolves to the empty path.
|
||||||
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
@@ -338,15 +359,15 @@ impl Db {
|
|||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let rows: Vec<(String, String)> = if query.chars().count() < TRIGRAM_MIN {
|
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||||
ORDER BY name LIMIT ?2",
|
ORDER BY name LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
// Escape double quotes and quote each term, then combine with AND so
|
// Escape double quotes and quote each term, then combine with AND so
|
||||||
// all terms must match but can appear in any order or position.
|
// all terms must match but can appear in any order or position.
|
||||||
@@ -356,18 +377,21 @@ impl Db {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" AND ");
|
.join(" AND ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT n.node_json, n.uid
|
"SELECT n.node_json, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![phrase, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut hits = Vec::with_capacity(rows.len());
|
let mut hits = Vec::with_capacity(rows.len());
|
||||||
for (json, uid) in rows {
|
for (json, uid, path) in rows {
|
||||||
let node: Node = serde_json::from_str(&json)?;
|
let node: Node = serde_json::from_str(&json)?;
|
||||||
let path = path_of(&conn, &uid)?;
|
let path = match path {
|
||||||
|
Some(path) => path,
|
||||||
|
None => walk_path_of(&conn, &uid)?,
|
||||||
|
};
|
||||||
hits.push(SearchHit { node, path });
|
hits.push(SearchHit { node, path });
|
||||||
}
|
}
|
||||||
Ok(hits)
|
Ok(hits)
|
||||||
@@ -382,54 +406,65 @@ impl Db {
|
|||||||
if query.is_empty() || limit == 0 {
|
if query.is_empty() || limit == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let terms = candidate_trigrams(query);
|
let terms = candidate_trigrams(query);
|
||||||
let lane_limit = limit.div_ceil(2);
|
let lane_limit = limit.div_ceil(2);
|
||||||
let mut rows: Vec<(String, String)> = if terms.is_empty() {
|
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||||
ORDER BY name LIMIT ?2",
|
ORDER BY name LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
let expression = terms.join(" OR ");
|
let expression = terms.join(" OR ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT n.node_json, n.uid
|
"SELECT n.node_json, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![expression, lane_limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
||||||
};
|
};
|
||||||
// A short substitution can destroy every trigram (`vedio` vs `video`).
|
// Trigram rank does not know that a name *starts* with the query, and a
|
||||||
// Feed the scorer an additional indexed same-initial lane so it can
|
// short substitution can destroy every trigram outright (`vedio` vs
|
||||||
// recover those candidates without scanning the full node table.
|
// `video`). Two indexed lanes cover both without scanning the node
|
||||||
|
// table: whole-query prefix first, then same-initial.
|
||||||
if !terms.is_empty()
|
if !terms.is_empty()
|
||||||
&& let Some(first) = query.chars().next()
|
&& let Some(first) = query.chars().next()
|
||||||
{
|
{
|
||||||
let prefix = format!("{}%", like_escape(&first.to_string()));
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||||
AND trashed = 0 AND node_json IS NOT NULL
|
AND trashed = 0 AND node_json IS NOT NULL
|
||||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
let fallback =
|
let mut extra = Vec::new();
|
||||||
collect_pairs(stmt.query_map(params![prefix, lane_limit as i64], pair)?)?;
|
for pattern in [
|
||||||
for row in fallback {
|
format!("{}%", like_escape(query)),
|
||||||
if !rows.iter().any(|(_, uid)| uid == &row.1) {
|
format!("{}%", like_escape(&first.to_string())),
|
||||||
|
] {
|
||||||
|
extra.extend(collect_hits(
|
||||||
|
stmt.query_map(params![pattern, lane_limit as i64], hit_row)?,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
let trigram = std::mem::take(&mut rows);
|
||||||
|
for row in extra.into_iter().chain(trigram) {
|
||||||
|
if !rows.iter().any(|(_, uid, _)| uid == &row.1) {
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows.truncate(limit);
|
rows.truncate(limit);
|
||||||
}
|
}
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(json, uid)| {
|
.map(|(json, uid, path)| {
|
||||||
Ok(SearchHit {
|
Ok(SearchHit {
|
||||||
node: serde_json::from_str(&json)?,
|
node: serde_json::from_str(&json)?,
|
||||||
path: path_of(&conn, &uid)?,
|
path: match path {
|
||||||
|
Some(path) => path,
|
||||||
|
None => walk_path_of(&conn, &uid)?,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -449,7 +484,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Load every persisted node for cold-start hydration of the `State` maps.
|
/// Load every persisted node for cold-start hydration of the `State` maps.
|
||||||
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt =
|
let mut stmt =
|
||||||
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
@@ -470,7 +505,7 @@ impl Db {
|
|||||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
/// when the API is unreachable, so the mount can still serve the cached tree
|
||||||
/// (offline.md Phase 1).
|
/// (offline.md Phase 1).
|
||||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let json: Option<String> = conn
|
let json: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
||||||
@@ -488,7 +523,7 @@ impl Db {
|
|||||||
/// The daemon resumes from this on restart instead of reseeding to the
|
/// The daemon resumes from this on restart instead of reseeding to the
|
||||||
/// server head, so changes made while unmounted are still applied (P2).
|
/// server head, so changes made while unmounted are still applied (P2).
|
||||||
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let listed: Option<i64> = conn
|
let listed: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
"SELECT listed FROM nodes WHERE uid = ?1",
|
||||||
@@ -521,13 +556,57 @@ impl Db {
|
|||||||
// in sync on every store/read/evict, so it is authoritative for eviction.
|
// in sync on every store/read/evict, so it is authoritative for eviction.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a node's row looked like before this upsert, for the fields that decide
|
||||||
|
/// whether the subtree below it has to be touched at all.
|
||||||
|
struct PriorRow {
|
||||||
|
parent_uid: Option<String>,
|
||||||
|
name: String,
|
||||||
|
path: Option<String>,
|
||||||
|
trashed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||||
let json = serde_json::to_string(node)?;
|
let json = serde_json::to_string(node)?;
|
||||||
let uid = node.uid.to_string();
|
let uid = node.uid.to_string();
|
||||||
|
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
||||||
|
|
||||||
|
let prior: Option<PriorRow> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT parent_uid, name, path, trashed FROM nodes WHERE uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| {
|
||||||
|
Ok(PriorRow {
|
||||||
|
parent_uid: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
path: row.get(2)?,
|
||||||
|
trashed: row.get::<_, i64>(3)? != 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
// A node's path is its parent's path plus its own name — one indexed lookup,
|
||||||
|
// where resolving it from scratch is a recursive walk to the root. A node
|
||||||
|
// whose parent is not cached (a device folder's root lives in `device`, never
|
||||||
|
// in `nodes`) starts a path of its own, which is what the walk did too.
|
||||||
|
let path = match &parent_uid {
|
||||||
|
None => String::new(),
|
||||||
|
Some(parent) => {
|
||||||
|
let parent_path: Option<Option<String>> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT path FROM nodes WHERE uid = ?1",
|
||||||
|
params![parent],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO nodes
|
"INSERT INTO nodes
|
||||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
ON CONFLICT(uid) DO UPDATE SET
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
parent_uid = excluded.parent_uid,
|
parent_uid = excluded.parent_uid,
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
@@ -535,59 +614,116 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
mtime = excluded.mtime,
|
mtime = excluded.mtime,
|
||||||
trashed = excluded.trashed,
|
trashed = excluded.trashed,
|
||||||
node_json = excluded.node_json",
|
node_json = excluded.node_json,
|
||||||
|
path = excluded.path",
|
||||||
params![
|
params![
|
||||||
uid,
|
uid,
|
||||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
parent_uid,
|
||||||
node.name,
|
node.name,
|
||||||
node.is_folder() as i64,
|
node.is_folder() as i64,
|
||||||
node_size(node),
|
node_size(node),
|
||||||
node.modification_time,
|
node.modification_time,
|
||||||
node.trashed as i64,
|
node.trashed as i64,
|
||||||
json,
|
json,
|
||||||
|
path,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
// FTS5 has no UPSERT. Folder moves also change every descendant path, so
|
|
||||||
// refresh the whole affected subtree while keeping rowid deletes indexed.
|
|
||||||
let rowid: i64 = tx.query_row(
|
let rowid: i64 = tx.query_row(
|
||||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||||
params![uid],
|
params![uid],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
let mut affected = vec![rowid];
|
|
||||||
if node.is_folder() {
|
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
||||||
let mut stmt = tx.prepare(
|
let indexable = node_is_indexable_tx(tx, &uid)?;
|
||||||
"WITH RECURSIVE descendants(rowid, uid) AS (
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||||
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
|
if indexable {
|
||||||
UNION ALL
|
|
||||||
SELECT n.rowid, n.uid FROM nodes n
|
|
||||||
JOIN descendants d ON n.parent_uid = d.uid
|
|
||||||
) SELECT rowid FROM descendants",
|
|
||||||
)?;
|
|
||||||
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
|
|
||||||
affected.push(descendant?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for affected_rowid in affected {
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM nodes_fts WHERE rowid = ?1",
|
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||||
params![affected_rowid],
|
params![rowid, node.name, path],
|
||||||
)?;
|
)?;
|
||||||
let indexed: Option<(String, String)> = tx
|
}
|
||||||
.query_row(
|
|
||||||
"SELECT name, uid FROM nodes WHERE rowid = ?1",
|
// Everything below only matters when the subtree's paths or reachability
|
||||||
params![affected_rowid],
|
// actually moved. Re-listing a folder upserts it unchanged, and paying for a
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
// full descendant walk on each of those was most of what a large tree spent
|
||||||
)
|
// its time on.
|
||||||
.optional()?;
|
let subtree_moved = prior.is_none_or(|prior| {
|
||||||
if let Some((name, indexed_uid)) = indexed
|
prior.parent_uid != parent_uid
|
||||||
&& node_is_indexable_tx(tx, &indexed_uid)?
|
|| prior.name != node.name
|
||||||
{
|
|| prior.path.as_deref() != Some(path.as_str())
|
||||||
let path = path_of(tx, &indexed_uid)?;
|
|| prior.trashed != node.trashed
|
||||||
tx.execute(
|
});
|
||||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
if node.is_folder() && subtree_moved {
|
||||||
params![affected_rowid, name, path],
|
reindex_subtree_tx(tx, &uid, &path, indexable)?;
|
||||||
)?;
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite every descendant's stored path and search-index row after `folder`
|
||||||
|
/// moved, was renamed, or changed reachability.
|
||||||
|
///
|
||||||
|
/// One recursive walk carries both the new path and whether anything on the way
|
||||||
|
/// down is trashed, so each descendant costs two trivial statements instead of a
|
||||||
|
/// `path_of` walk plus an ancestor walk of its own. `folder_indexable` is the
|
||||||
|
/// answer `node_is_indexable_tx` gave for the folder itself: what is above the
|
||||||
|
/// subtree is the same for every node in it.
|
||||||
|
///
|
||||||
|
/// The depth cap is a liveness guard, not a policy: `UNION ALL` over a parent
|
||||||
|
/// cycle (a's parent is b, b's parent is a — corrupt data, but the API can hand
|
||||||
|
/// it to us) never terminates, and this runs inside the write transaction that
|
||||||
|
/// holds the daemon's only SQLite connection. Real Drive trees are nowhere near
|
||||||
|
/// this deep.
|
||||||
|
fn reindex_subtree_tx(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
folder: &str,
|
||||||
|
folder_path: &str,
|
||||||
|
folder_indexable: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let descendants: Vec<(i64, String, bool)> = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
|
||||||
|
SELECT n.rowid, n.uid, n.name,
|
||||||
|
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
||||||
|
n.trashed, 0
|
||||||
|
FROM nodes n WHERE n.parent_uid = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.rowid, n.uid, n.name,
|
||||||
|
sub.path || '/' || n.name,
|
||||||
|
MAX(sub.blocked, n.trashed), sub.depth + 1
|
||||||
|
FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||||
|
WHERE sub.depth < 256
|
||||||
|
)
|
||||||
|
SELECT rowid, path, blocked FROM sub",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![folder, folder_path], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, i64>(2)? != 0,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
out.push(row?);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
if descendants.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
|
||||||
|
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
|
||||||
|
let mut index = tx.prepare(
|
||||||
|
"INSERT INTO nodes_fts (rowid, name, path)
|
||||||
|
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
|
||||||
|
)?;
|
||||||
|
for (rowid, path, blocked) in descendants {
|
||||||
|
set_path.execute(params![rowid, path])?;
|
||||||
|
unindex.execute(params![rowid])?;
|
||||||
|
if folder_indexable && !blocked {
|
||||||
|
index.execute(params![rowid, path])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -603,13 +739,27 @@ fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String
|
|||||||
Ok(uids)
|
Ok(uids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a node belongs in the search index: it exists, and nothing on the
|
||||||
|
/// way up to the top of its cached chain is trashed.
|
||||||
|
///
|
||||||
|
/// The chain does *not* have to reach a `parent_uid IS NULL` row. Only the My
|
||||||
|
/// Files root is stored that way; a device folder's root lives in `device` /
|
||||||
|
/// `sync_folder` and never gets a `nodes` row, so requiring a null-parent
|
||||||
|
/// ancestor silently excluded every device-folder subtree from search — 29% of
|
||||||
|
/// the nodes on the account this was found on. A subtree whose topmost cached
|
||||||
|
/// row has an uncached parent is ordinary, reachable data.
|
||||||
|
///
|
||||||
|
/// What is still excluded is a *cycle*: the walk's own guard stops it, but
|
||||||
|
/// [`path_of`] has no such guard, so an indexed cycle would hang a search. A
|
||||||
|
/// clean chain ends at a row whose parent is null or simply not cached; a cycle
|
||||||
|
/// ends at one whose parent is a row we have already visited.
|
||||||
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||||
let indexable: i64 = tx.query_row(
|
let indexable: i64 = tx.query_row(
|
||||||
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, path) AS (
|
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, depth, path) AS (
|
||||||
SELECT uid, parent_uid, trashed, char(31) || uid || char(31)
|
SELECT uid, parent_uid, trashed, 0, char(31) || uid || char(31)
|
||||||
FROM nodes WHERE uid = ?1
|
FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, n.trashed,
|
SELECT n.uid, n.parent_uid, n.trashed, a.depth + 1,
|
||||||
a.path || n.uid || char(31)
|
a.path || n.uid || char(31)
|
||||||
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
||||||
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
||||||
@@ -617,7 +767,12 @@ fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
|||||||
SELECT CASE
|
SELECT CASE
|
||||||
WHEN COUNT(*) = 0 THEN 0
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
WHEN MAX(trashed) != 0 THEN 0
|
WHEN MAX(trashed) != 0 THEN 0
|
||||||
WHEN MAX(CASE WHEN parent_uid IS NULL THEN 1 ELSE 0 END) = 0 THEN 0
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM ancestors a
|
||||||
|
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
|
||||||
|
AND a.parent_uid IS NOT NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM nodes p WHERE p.uid = a.parent_uid)
|
||||||
|
) THEN 0
|
||||||
ELSE 1
|
ELSE 1
|
||||||
END
|
END
|
||||||
FROM ancestors",
|
FROM ancestors",
|
||||||
|
|||||||
+127
-10
@@ -119,15 +119,32 @@ pub struct PendingOp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How much work the queue owes the server, by whether it carries bytes.
|
/// How much work the queue owes the server, by whether it carries bytes.
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PendingCounts {
|
pub struct PendingCounts {
|
||||||
/// Queued `create`/`revision` ops: files whose content is not on the remote.
|
/// Queued `create`/`revision` ops: files whose content is not on the remote.
|
||||||
pub uploads: i64,
|
pub uploads: i64,
|
||||||
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
|
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
|
||||||
/// told about.
|
/// told about.
|
||||||
pub changes: i64,
|
pub changes: i64,
|
||||||
|
/// Ops parked at [`PARK_UNTIL`]: a transient file's bytes, held back until
|
||||||
|
/// the rename that gives it its finished name. They are queued but will
|
||||||
|
/// never drain on their own, so counting them as "waiting to upload" would
|
||||||
|
/// have the front-end promise progress that is not coming.
|
||||||
|
pub parked: i64,
|
||||||
|
/// Ops that have failed at least [`FAILING_ATTEMPTS`] times. A permanently
|
||||||
|
/// failing op never wedges the queue — the backoff sees to that — which is
|
||||||
|
/// exactly why it needs surfacing: it retries forever, invisibly.
|
||||||
|
pub failing: i64,
|
||||||
|
/// The most recent error text from a failing op, for the front-end to show
|
||||||
|
/// instead of the bare count.
|
||||||
|
pub last_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Failed attempts after which an op is reported as failing rather than merely
|
||||||
|
/// retrying. Six is where the backoff has reached its ceiling, so an op past it
|
||||||
|
/// is retrying at the slowest rate it ever will and has been for minutes.
|
||||||
|
pub const FAILING_ATTEMPTS: i64 = 6;
|
||||||
|
|
||||||
/// The outcome of folding freshly written bytes into a queued create.
|
/// The outcome of folding freshly written bytes into a queued create.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AttachedBlob {
|
pub struct AttachedBlob {
|
||||||
@@ -140,7 +157,7 @@ pub struct AttachedBlob {
|
|||||||
impl Db {
|
impl Db {
|
||||||
/// Whether a specific desired-state operation is still queued for a node.
|
/// Whether a specific desired-state operation is still queued for a node.
|
||||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS(
|
"SELECT EXISTS(
|
||||||
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
||||||
@@ -153,7 +170,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read an existing operation's metadata before a superseding enqueue.
|
/// Read an existing operation's metadata before a superseding enqueue.
|
||||||
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||||
params![uid, kind],
|
params![uid, kind],
|
||||||
@@ -352,7 +369,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Check if a queued create or mkdir op exists for `uid`.
|
/// Check if a queued create or mkdir op exists for `uid`.
|
||||||
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
|
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
|
||||||
params![uid, OP_CREATE, OP_MKDIR],
|
params![uid, OP_CREATE, OP_MKDIR],
|
||||||
@@ -368,7 +385,7 @@ impl Db {
|
|||||||
/// bytes are still owed an upload, and dropping it would discard the staged
|
/// bytes are still owed an upload, and dropping it would discard the staged
|
||||||
/// blob that holds them (`docs/BUGS.md` B71).
|
/// blob that holds them (`docs/BUGS.md` B71).
|
||||||
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
||||||
params![uid],
|
params![uid],
|
||||||
@@ -464,7 +481,7 @@ impl Db {
|
|||||||
/// sort, and no separate index to maintain. The common case (something is
|
/// sort, and no separate index to maintain. The common case (something is
|
||||||
/// due) exits on the first row.
|
/// due) exits on the first row.
|
||||||
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare_cached(&format!(
|
let mut stmt = conn.prepare_cached(&format!(
|
||||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
attempts, last_error, next_attempt_at
|
attempts, last_error, next_attempt_at
|
||||||
@@ -500,7 +517,7 @@ impl Db {
|
|||||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||||
/// idle-poll interval.
|
/// idle-poll interval.
|
||||||
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let ts: Option<i64> = conn
|
let ts: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
@@ -520,7 +537,7 @@ impl Db {
|
|||||||
/// Every queued op, oldest first. For status read-outs and the CLI — the
|
/// Every queued op, oldest first. For status read-outs and the CLI — the
|
||||||
/// drain wants [`next_due_op`](Self::next_due_op) instead.
|
/// drain wants [`next_due_op`](Self::next_due_op) instead.
|
||||||
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
|
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
attempts, last_error, next_attempt_at
|
attempts, last_error, next_attempt_at
|
||||||
@@ -553,7 +570,7 @@ impl Db {
|
|||||||
/// mount owes the server, but it carries no bytes and reporting it as an
|
/// mount owes the server, but it carries no bytes and reporting it as an
|
||||||
/// upload is simply untrue.
|
/// upload is simply untrue.
|
||||||
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
|
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let uploads = conn.query_row(
|
let uploads = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
||||||
params![OP_REVISION, OP_CREATE],
|
params![OP_REVISION, OP_CREATE],
|
||||||
@@ -564,7 +581,33 @@ impl Db {
|
|||||||
params![OP_REVISION, OP_CREATE],
|
params![OP_REVISION, OP_CREATE],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
Ok(PendingCounts { uploads, changes })
|
let parked = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pending_op WHERE next_attempt_at >= ?1",
|
||||||
|
params![PARK_UNTIL],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let failing = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pending_op WHERE attempts >= ?1",
|
||||||
|
params![FAILING_ATTEMPTS],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let last_error = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT last_error FROM pending_op
|
||||||
|
WHERE attempts >= ?1 AND last_error IS NOT NULL
|
||||||
|
ORDER BY attempts DESC LIMIT 1",
|
||||||
|
params![FAILING_ATTEMPTS],
|
||||||
|
|r| r.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.flatten();
|
||||||
|
Ok(PendingCounts {
|
||||||
|
uploads,
|
||||||
|
changes,
|
||||||
|
parked,
|
||||||
|
failing,
|
||||||
|
last_error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
||||||
@@ -609,4 +652,78 @@ impl Db {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every staged blob the queue still refers to.
|
||||||
|
///
|
||||||
|
/// The startup staging reconcile subtracts this from what is actually in
|
||||||
|
/// `staging/`: the difference is bytes the kernel was told were written and
|
||||||
|
/// that nothing is going to upload.
|
||||||
|
pub fn op_blob_paths(&self) -> Result<std::collections::HashSet<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT blob_path FROM pending_op WHERE blob_path IS NOT NULL")?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
|
let mut out = std::collections::HashSet::new();
|
||||||
|
for row in rows {
|
||||||
|
out.insert(row?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that this daemon sealed `revision_id` on `uid`, and prune entries
|
||||||
|
/// past [`OWN_SEALED_TTL_MS`].
|
||||||
|
///
|
||||||
|
/// One row per node, overwritten as the node advances. See
|
||||||
|
/// [`own_sealed_rev`](Self::own_sealed_rev) for what reads it.
|
||||||
|
pub fn set_own_sealed_rev(&self, uid: &str, revision_id: &str, now_ms: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO own_sealed_rev (uid, revision_id, sealed_at)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
|
revision_id = excluded.revision_id,
|
||||||
|
sealed_at = excluded.sealed_at",
|
||||||
|
params![uid, revision_id, now_ms],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM own_sealed_rev WHERE sealed_at < ?1",
|
||||||
|
params![now_ms - OWN_SEALED_TTL_MS],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last revision of `uid` this daemon sealed itself, if it is still
|
||||||
|
/// within [`OWN_SEALED_TTL_MS`].
|
||||||
|
///
|
||||||
|
/// The drain asks before treating a moved-on remote as a conflict: a
|
||||||
|
/// revision we sealed means one writer stalled and resumed, which
|
||||||
|
/// supersedes rather than forking a `(sync-conflict)` copy (docs/BUGS.md
|
||||||
|
/// B70 layer B).
|
||||||
|
pub fn own_sealed_rev(&self, uid: &str, now_ms: i64) -> Result<Option<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT revision_id FROM own_sealed_rev
|
||||||
|
WHERE uid = ?1 AND sealed_at >= ?2",
|
||||||
|
params![uid, now_ms - OWN_SEALED_TTL_MS],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget what we sealed on `uid` — it has been trashed, so there is no
|
||||||
|
/// revision left to chain onto.
|
||||||
|
pub fn clear_own_sealed_rev(&self, uid: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute("DELETE FROM own_sealed_rev WHERE uid = ?1", params![uid])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a revision this daemon sealed stays recognisable as its own.
|
||||||
|
///
|
||||||
|
/// Bounds a table that would otherwise grow with every write for the life of
|
||||||
|
/// the install. A week is far longer than any stall→resume — a browser download
|
||||||
|
/// that pauses and continues, an editor that reopens its handle — and short
|
||||||
|
/// enough that the table tracks recent work rather than accumulating history.
|
||||||
|
pub const OWN_SEALED_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||||
@@ -32,13 +32,21 @@ pub struct StoredPhoto {
|
|||||||
/// Which Photos-page tab this entry belongs to, derived from its name and
|
/// Which Photos-page tab this entry belongs to, derived from its name and
|
||||||
/// media type when the timeline was last replaced.
|
/// media type when the timeline was last replaced.
|
||||||
pub kind: crate::control::PhotoKind,
|
pub kind: crate::control::PhotoKind,
|
||||||
|
/// Whether the photo carries Proton's `Favorite` tag.
|
||||||
|
pub favorite: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
|
||||||
|
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
||||||
|
/// `None` keeps whatever is already stored rather than clearing it.
|
||||||
|
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn photos_replace(
|
/// Replace the timeline wholesale. Each item is
|
||||||
&self,
|
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
||||||
items: &[(String, i64, Option<String>, Option<String>)],
|
/// when the refresh could not resolve that photo's node, in which case the
|
||||||
) -> Result<()> {
|
/// flag already stored is kept rather than silently cleared.
|
||||||
|
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
||||||
@@ -46,10 +54,14 @@ impl Db {
|
|||||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
// not know a photo's media type yet when it replaces the timeline. Keep
|
||||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
// any previously learned value so the Photos/Videos/Raw split survives a
|
||||||
// refresh instead of collapsing back to name-extension guesses.
|
// refresh instead of collapsing back to name-extension guesses.
|
||||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
||||||
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
|
let mut stmt =
|
||||||
|
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
Ok((
|
||||||
|
r.get::<_, String>(0)?,
|
||||||
|
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
rows.collect::<rusqlite::Result<_>>()?
|
rows.collect::<rusqlite::Result<_>>()?
|
||||||
};
|
};
|
||||||
@@ -58,16 +70,16 @@ impl Db {
|
|||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO photos
|
"INSERT INTO photos
|
||||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
|
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
)?;
|
)?;
|
||||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
||||||
let (ratio, thumb_state, learned_media) =
|
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
||||||
learned
|
.get(uid)
|
||||||
.get(uid)
|
.cloned()
|
||||||
.cloned()
|
.unwrap_or((None, THUMB_UNKNOWN, None, false));
|
||||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
|
||||||
let media_type = media_type.clone().or(learned_media);
|
let media_type = media_type.clone().or(learned_media);
|
||||||
|
let favorite = favorite.unwrap_or(learned_favorite);
|
||||||
// The tab this photo lands in is derived here, once, so a page or
|
// The tab this photo lands in is derived here, once, so a page or
|
||||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||||
// reclassification of every row.
|
// reclassification of every row.
|
||||||
@@ -82,6 +94,7 @@ impl Db {
|
|||||||
seq as i64,
|
seq as i64,
|
||||||
media_type,
|
media_type,
|
||||||
kind.as_i64(),
|
kind.as_i64(),
|
||||||
|
favorite as i64,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,18 +106,21 @@ impl Db {
|
|||||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||||
|
/// `favorites` restricts the page to favourited photos.
|
||||||
pub fn photos_page(
|
pub fn photos_page(
|
||||||
&self,
|
&self,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
|
favorites: bool,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
// Built up so any combination of the optional filters is one indexed
|
// Built up so any combination of the optional filters is one indexed
|
||||||
// query rather than a statement per case.
|
// query rather than a statement per case.
|
||||||
let mut sql =
|
let mut sql = String::from(
|
||||||
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
||||||
|
);
|
||||||
let mut binds: Vec<i64> = Vec::new();
|
let mut binds: Vec<i64> = Vec::new();
|
||||||
let mut conds: Vec<String> = Vec::new();
|
let mut conds: Vec<String> = Vec::new();
|
||||||
if let Some(k) = kind {
|
if let Some(k) = kind {
|
||||||
@@ -117,6 +133,9 @@ impl Db {
|
|||||||
binds.push(to);
|
binds.push(to);
|
||||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
conds.push(format!("capture_time < ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
|
if favorites {
|
||||||
|
conds.push("favorite = 1".to_string());
|
||||||
|
}
|
||||||
if !conds.is_empty() {
|
if !conds.is_empty() {
|
||||||
sql.push_str(" WHERE ");
|
sql.push_str(" WHERE ");
|
||||||
sql.push_str(&conds.join(" AND "));
|
sql.push_str(&conds.join(" AND "));
|
||||||
@@ -139,6 +158,7 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
|
favorite: r.get::<_, i64>(6)? != 0,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
@@ -154,7 +174,7 @@ impl Db {
|
|||||||
&self,
|
&self,
|
||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
) -> Result<Vec<(i32, i32, usize)>> {
|
) -> Result<Vec<(i32, i32, usize)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
||||||
None => ("", Vec::new()),
|
None => ("", Vec::new()),
|
||||||
@@ -181,7 +201,7 @@ impl Db {
|
|||||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||||
use crate::control::PhotoKind;
|
use crate::control::PhotoKind;
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
||||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||||
@@ -203,10 +223,10 @@ impl Db {
|
|||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let placeholders = vec!["?"; uids.len()].join(",");
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
||||||
WHERE uid IN ({placeholders})"
|
WHERE uid IN ({placeholders})"
|
||||||
))?;
|
))?;
|
||||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||||
@@ -217,6 +237,7 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
|
favorite: r.get::<_, i64>(6)? != 0,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut photos = Vec::new();
|
let mut photos = Vec::new();
|
||||||
@@ -226,6 +247,18 @@ impl Db {
|
|||||||
Ok(photos)
|
Ok(photos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a photo's favourite flag locally, after the server accepted the
|
||||||
|
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||||
|
/// someone else's volume is never in our own `photos` table.
|
||||||
|
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
|
||||||
|
params![uid, favorite as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Record what a thumbnail attempt learned: whether the photo now has one
|
/// Record what a thumbnail attempt learned: whether the photo now has one
|
||||||
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
|
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
|
||||||
/// seen. A `None` ratio leaves any previously learned one alone.
|
/// seen. A `None` ratio leaves any previously learned one alone.
|
||||||
@@ -240,7 +273,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Number of photos in the persisted timeline.
|
/// Number of photos in the persisted timeline.
|
||||||
pub fn photos_count(&self) -> Result<usize> {
|
pub fn photos_count(&self) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||||
Ok(n.max(0) as usize)
|
Ok(n.max(0) as usize)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,21 @@
|
|||||||
use rusqlite::{OptionalExtension, params};
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
|
use super::utils::MAX_PATH_DEPTH;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
|
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
|
||||||
|
/// subtree is kept — while `is_dir` is the node's kind as the node cache knows
|
||||||
|
/// it, or `None` for a pin whose node is not cached. Reading the former as the
|
||||||
|
/// latter is what made a non-recursively pinned folder render as a file.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PinRow {
|
||||||
|
pub uid: String,
|
||||||
|
pub path: String,
|
||||||
|
pub recursive: bool,
|
||||||
|
pub is_dir: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
|
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
@@ -25,16 +38,23 @@ impl Db {
|
|||||||
Ok(n > 0)
|
Ok(n > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every directly-pinned entry `(uid, path, recursive)`, ordered by uid.
|
/// Every directly-pinned entry, ordered by uid.
|
||||||
pub fn pin_list(&self) -> Result<Vec<(String, String, bool)>> {
|
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT uid, path, recursive FROM pins ORDER BY uid")?;
|
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
||||||
|
// NULL for a pin whose node was never cached.
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT p.uid, p.path, p.recursive, n.is_dir
|
||||||
|
FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||||
|
ORDER BY p.uid",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((
|
Ok(PinRow {
|
||||||
r.get::<_, String>(0)?,
|
uid: r.get(0)?,
|
||||||
r.get::<_, String>(1)?,
|
path: r.get(1)?,
|
||||||
r.get::<_, i64>(2)? != 0,
|
recursive: r.get::<_, i64>(2)? != 0,
|
||||||
))
|
is_dir: r.get::<_, Option<i64>>(3)?.map(|is_dir| is_dir != 0),
|
||||||
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for r in rows {
|
for r in rows {
|
||||||
@@ -47,8 +67,13 @@ impl Db {
|
|||||||
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
||||||
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
||||||
/// yet (e.g. a CLI that never hydrates the node cache).
|
/// yet (e.g. a CLI that never hydrates the node cache).
|
||||||
|
///
|
||||||
|
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid`
|
||||||
|
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
|
||||||
|
/// terminates — while holding the daemon's only SQLite connection, on a path
|
||||||
|
/// that runs per cached read.
|
||||||
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let direct: Option<i64> = conn
|
let direct: Option<i64> = conn
|
||||||
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
||||||
r.get(0)
|
r.get(0)
|
||||||
@@ -59,14 +84,18 @@ impl Db {
|
|||||||
}
|
}
|
||||||
let anc: Option<i64> = conn
|
let anc: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid) AS (
|
&format!(
|
||||||
SELECT uid, parent_uid FROM nodes WHERE uid = ?1
|
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
|
||||||
UNION ALL
|
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
|
||||||
SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
UNION ALL
|
||||||
)
|
SELECT n.uid, n.parent_uid, anc.depth + 1
|
||||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||||
WHERE a.uid != ?1 AND p.recursive = 1
|
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||||
LIMIT 1",
|
)
|
||||||
|
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||||
|
WHERE a.uid != ?1 AND p.recursive = 1
|
||||||
|
LIMIT 1"
|
||||||
|
),
|
||||||
params![uid],
|
params![uid],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
@@ -78,7 +107,7 @@ impl Db {
|
|||||||
/// every descendant of a recursively-pinned folder. Used to build the
|
/// every descendant of a recursively-pinned folder. Used to build the
|
||||||
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
||||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid) AS (
|
||||||
SELECT uid FROM pins WHERE recursive = 1
|
SELECT uid FROM pins WHERE recursive = 1
|
||||||
@@ -100,7 +129,7 @@ impl Db {
|
|||||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
||||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid) AS (
|
||||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
SELECT uid FROM nodes WHERE parent_uid = ?1
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ impl Db {
|
|||||||
/// remains the restart/offline authority and every explicit role change is
|
/// remains the restart/offline authority and every explicit role change is
|
||||||
/// written through before this cache is updated.
|
/// written through before this cache is updated.
|
||||||
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
@@ -42,7 +42,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read the effective access recorded for a shared-tree root.
|
/// Read the effective access recorded for a shared-tree root.
|
||||||
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let value = conn
|
let value = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||||
@@ -96,7 +96,7 @@ impl Db {
|
|||||||
/// share root. `None` means the node itself is not persisted, which callers
|
/// share root. `None` means the node itself is not persisted, which callers
|
||||||
/// must not treat as owned: stale handles need to fail closed.
|
/// must not treat as owned: stale handles need to fail closed.
|
||||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let exists: bool = conn.query_row(
|
let exists: bool = conn.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||||
[uid.to_string()],
|
[uid.to_string()],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::Result;
|
|||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v = conn
|
let v = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
||||||
@@ -32,7 +32,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read a `sync_state` value as a string.
|
/// Read a `sync_state` value as a string.
|
||||||
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v = conn
|
let v = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = ?1",
|
"SELECT value FROM sync_state WHERE key = ?1",
|
||||||
@@ -66,7 +66,7 @@ impl Db {
|
|||||||
/// dropping it would leave a file that exists nowhere but this machine with
|
/// dropping it would leave a file that exists nowhere but this machine with
|
||||||
/// nothing left to create it. (Writes to a node that is itself still queued
|
/// nothing left to create it. (Writes to a node that is itself still queued
|
||||||
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v: Option<String> = conn
|
let v: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = ?1",
|
"SELECT value FROM sync_state WHERE key = ?1",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Every synced folder, oldest first.
|
/// Every synced folder, oldest first.
|
||||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||||
FROM sync_folder ORDER BY id",
|
FROM sync_folder ORDER BY id",
|
||||||
@@ -83,7 +83,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Look up one synced folder by id.
|
/// Look up one synced folder by id.
|
||||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||||
FROM sync_folder WHERE id = ?1",
|
FROM sync_folder WHERE id = ?1",
|
||||||
@@ -166,7 +166,7 @@ impl Db {
|
|||||||
|
|
||||||
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
||||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
||||||
FROM sync_entry WHERE folder_id = ?1",
|
FROM sync_entry WHERE folder_id = ?1",
|
||||||
@@ -196,7 +196,7 @@ impl Db {
|
|||||||
/// `sync_folder` and descendants come from the last successful
|
/// `sync_folder` and descendants come from the last successful
|
||||||
/// `sync_entry` baseline.
|
/// `sync_entry` baseline.
|
||||||
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS(
|
"SELECT EXISTS(
|
||||||
SELECT 1
|
SELECT 1
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json
|
|||||||
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
db.photos_replace(&[
|
db.photos_replace(&[
|
||||||
("p1".into(), 300, None, Some("video/mp4".into())),
|
("p1".into(), 300, None, Some("video/mp4".into()), Some(true)),
|
||||||
("p2".into(), 200, None, None),
|
("p2".into(), 200, None, None, None),
|
||||||
("p3".into(), 100, None, None),
|
("p3".into(), 100, None, None, None),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -126,13 +126,13 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
|
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
|
||||||
// p1's media type arrives as `None` this time and must not be forgotten.
|
// p1's media type arrives as `None` this time and must not be forgotten.
|
||||||
db.photos_replace(&[
|
db.photos_replace(&[
|
||||||
("p0".into(), 400, Some("new.jpg".into()), None),
|
("p0".into(), 400, Some("new.jpg".into()), None, Some(false)),
|
||||||
("p1".into(), 300, None, None),
|
("p1".into(), 300, None, None, None),
|
||||||
("p2".into(), 200, None, None),
|
("p2".into(), 200, None, None, None),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let page = db.photos_page(0, 10, None, None).unwrap();
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p0", "p1", "p2"],
|
["p0", "p1", "p2"],
|
||||||
@@ -155,7 +155,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
// The counts break down by tab, and a filtered page returns only its tab.
|
// The counts break down by tab, and a filtered page returns only its tab.
|
||||||
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
||||||
let videos = db
|
let videos = db
|
||||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None)
|
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
@@ -167,7 +167,9 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
|
|
||||||
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
|
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
|
||||||
// not p0 at 400. Combined with a kind filter both conditions apply.
|
// not p0 at 400. Combined with a kind filter both conditions apply.
|
||||||
let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap();
|
let ranged = db
|
||||||
|
.photos_page(0, 10, None, Some((150, 350)), false)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p1", "p2"]
|
["p1", "p2"]
|
||||||
@@ -178,6 +180,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
10,
|
10,
|
||||||
Some(crate::control::PhotoKind::Video),
|
Some(crate::control::PhotoKind::Video),
|
||||||
Some((150, 350)),
|
Some((150, 350)),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -193,20 +196,52 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
assert_eq!(months[0].2, 3);
|
assert_eq!(months[0].2, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
("p1".into(), 300, None, None, Some(true)),
|
||||||
|
("p2".into(), 200, None, None, Some(false)),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["p1"],
|
||||||
|
"only the favourited photo is in a favourites page"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A local toggle survives a refresh that could not resolve the photo's node
|
||||||
|
// (`None`), and loses to one that could.
|
||||||
|
db.photos_set_favorite("p2", true).unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
("p1".into(), 300, None, None, Some(false)),
|
||||||
|
("p2".into(), 200, None, None, None),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["p2"],
|
||||||
|
"the server's answer wins where it has one; the local flag holds where it doesn't"
|
||||||
|
);
|
||||||
|
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn photos_page_slices_the_timeline_in_order() {
|
fn photos_page_slices_the_timeline_in_order() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
let items: Vec<_> = (0..5)
|
let items: Vec<_> = (0..5)
|
||||||
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>))
|
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>, None))
|
||||||
.collect();
|
.collect();
|
||||||
db.photos_replace(&items).unwrap();
|
db.photos_replace(&items).unwrap();
|
||||||
|
|
||||||
let page = db.photos_page(2, 2, None, None).unwrap();
|
let page = db.photos_page(2, 2, None, None, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p2", "p3"]
|
["p2", "p3"]
|
||||||
);
|
);
|
||||||
assert!(db.photos_page(9, 2, None, None).unwrap().is_empty());
|
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -832,6 +867,67 @@ fn shared_root_pinned_name_survives_database_reopen() {
|
|||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two writers on one database is the failure mode the single-instance lock
|
||||||
|
/// exists to prevent — a hand-run `pdfs mount` next to the systemd unit gives
|
||||||
|
/// two daemons the same inode space, content cache, and drain queue. The second
|
||||||
|
/// open has to fail, and it has to stop failing once the first handle is gone.
|
||||||
|
#[test]
|
||||||
|
fn a_second_writer_is_refused_while_the_first_holds_the_database() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-single-writer-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
now_test_id()
|
||||||
|
));
|
||||||
|
let first = Db::open(&path).unwrap();
|
||||||
|
let second = Db::open(&path);
|
||||||
|
assert!(second.is_err(), "a second writer was allowed in");
|
||||||
|
|
||||||
|
drop(first);
|
||||||
|
// The lock lives in the kernel and dies with the descriptor, so nothing has
|
||||||
|
// to be cleaned up for the next daemon to start.
|
||||||
|
Db::open(&path).expect("reopen after the first handle is dropped");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cache.db` is derived state — every row can be re-fetched or re-scanned — so
|
||||||
|
/// a file that is not a database at all must not stop the daemon starting. It
|
||||||
|
/// is moved aside (not deleted: it is the only evidence) and rebuilt.
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_database_is_moved_aside_and_rebuilt() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-corrupt-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
now_test_id()
|
||||||
|
));
|
||||||
|
std::fs::write(&path, b"this is not an SQLite database").unwrap();
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
db.set_state_str("shared_with_me_name", "rebuilt").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.state_str("shared_with_me_name").unwrap().as_deref(),
|
||||||
|
Some("rebuilt")
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
let dir = path.parent().unwrap();
|
||||||
|
let stem = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
let quarantined: Vec<_> = std::fs::read_dir(dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.filter(|name| name.starts_with(&format!("{stem}.corrupt-")))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(quarantined.len(), 1, "the damaged file was not kept");
|
||||||
|
|
||||||
|
for name in quarantined {
|
||||||
|
let _ = std::fs::remove_file(dir.join(name));
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||||
|
}
|
||||||
|
|
||||||
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
||||||
LocalEntry {
|
LocalEntry {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
@@ -876,6 +972,25 @@ fn local_scan_indexes_then_prunes_stale_paths() {
|
|||||||
assert_eq!(db.search_local("notes", 10).unwrap().len(), 1);
|
assert_eq!(db.search_local("notes", 10).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The index is maintained incrementally rather than rebuilt at the end of a
|
||||||
|
/// scan, so the case that used to be covered for free — a file the scan sees
|
||||||
|
/// again — needs its own test: a re-scan must neither lose the entry nor
|
||||||
|
/// duplicate it.
|
||||||
|
#[test]
|
||||||
|
fn rescanning_an_unchanged_file_keeps_exactly_one_index_entry() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
for (generation, mtime) in [(1_000, 5), (2_000, 9)] {
|
||||||
|
let g = db.local_begin_scan().unwrap();
|
||||||
|
let mut e = local("/home/u/docs/report.pdf", "report.pdf", false);
|
||||||
|
e.mtime = mtime;
|
||||||
|
db.local_upsert_batch(g, &[e]).unwrap();
|
||||||
|
assert_eq!(db.local_finish_scan(g, generation).unwrap(), 1);
|
||||||
|
let hits = db.search_local("report", 10).unwrap();
|
||||||
|
assert_eq!(hits.len(), 1, "generation {generation}");
|
||||||
|
assert_eq!(hits[0].mtime, mtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
||||||
#[test]
|
#[test]
|
||||||
fn local_search_short_query_like_fallback() {
|
fn local_search_short_query_like_fallback() {
|
||||||
@@ -954,6 +1069,62 @@ fn search_excludes_trashed_and_respects_limit() {
|
|||||||
assert_eq!(db.search("alph", 1).unwrap().len(), 1);
|
assert_eq!(db.search("alph", 1).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A device folder's root is stored in `device`/`sync_folder`, never in
|
||||||
|
/// `nodes`, so its children's `parent_uid` points at a row that does not exist.
|
||||||
|
/// Requiring the ancestor walk to reach a `parent_uid IS NULL` row therefore
|
||||||
|
/// excluded every device-folder subtree from search: 2,705 of 9,252 nodes on
|
||||||
|
/// the account this was found on, including everything under `~/Downloads`.
|
||||||
|
#[test]
|
||||||
|
fn search_finds_nodes_below_an_uncached_parent() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
// "device-root" is deliberately never upserted.
|
||||||
|
db.upsert_node(&folder("downloads", Some("device-root"), "Downloads"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("t", "downloads", "tickets.pdf", 1))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let hits = db.search("tickets", 10).unwrap();
|
||||||
|
assert_eq!(hits.len(), 1, "a device folder's files must be searchable");
|
||||||
|
assert_eq!(hits[0].path, "Downloads/tickets.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relaxed rule must not also start indexing trash: a node whose *parent*
|
||||||
|
/// is trashed stays out even though the walk now terminates happily.
|
||||||
|
#[test]
|
||||||
|
fn search_excludes_a_node_under_a_trashed_ancestor() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
let mut folder_gone = folder("boxed", Some("root"), "Boxed");
|
||||||
|
folder_gone.trashed = true;
|
||||||
|
db.upsert_node(&folder_gone).unwrap();
|
||||||
|
db.upsert_node(&file("f1", "boxed", "buried.txt", 1))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(db.search("buried", 10).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parent cycle is corrupt data the API can still hand us. It must stay out
|
||||||
|
/// of the index, and — because the short-query `LIKE` lane reads `nodes`
|
||||||
|
/// directly and never consults the index — resolving one's path must terminate
|
||||||
|
/// rather than spin inside the daemon's only SQLite connection.
|
||||||
|
#[test]
|
||||||
|
fn a_parent_cycle_is_unindexed_and_never_hangs_a_search() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("a", Some("b"), "Alphaville"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&folder("b", Some("a"), "Betamax")).unwrap();
|
||||||
|
|
||||||
|
// Index lane: the cycle is not indexed, so it cannot be found.
|
||||||
|
assert!(db.search("alphaville", 10).unwrap().is_empty());
|
||||||
|
assert!(db.search("betamax", 10).unwrap().is_empty());
|
||||||
|
|
||||||
|
// LIKE lane (below the trigram minimum): the rows are reachable, and what
|
||||||
|
// matters is that resolving their paths returns at all.
|
||||||
|
let hits = db.search("al", 10).unwrap();
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert!(hits[0].path.ends_with("Alphaville"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_short_query_like_fallback() {
|
fn search_short_query_like_fallback() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -1336,6 +1507,9 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
|||||||
DROP TABLE share_access;
|
DROP TABLE share_access;
|
||||||
DROP TABLE album_photos;
|
DROP TABLE album_photos;
|
||||||
DROP TABLE albums;
|
DROP TABLE albums;
|
||||||
|
DROP INDEX idx_photos_favorite;
|
||||||
|
ALTER TABLE photos DROP COLUMN favorite;
|
||||||
|
ALTER TABLE nodes DROP COLUMN path;
|
||||||
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1402,6 +1576,18 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
|
|||||||
VALUES
|
VALUES
|
||||||
(41, '/home/me/Existing', 'vol~existing', 'share-existing',
|
(41, '/home/me/Existing', 'vol~existing', 'share-existing',
|
||||||
'ondemand', 'syncing', 123);
|
'ondemand', 'syncing', 123);
|
||||||
|
-- The timeline as V17 held it: later migrations add columns to it,
|
||||||
|
-- so a fixture without it isn't a database any release produced.
|
||||||
|
CREATE TABLE photos (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
capture_time INTEGER NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
ratio REAL,
|
||||||
|
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
media_type TEXT,
|
||||||
|
kind INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
CREATE TABLE share_access (
|
CREATE TABLE share_access (
|
||||||
root_uid TEXT PRIMARY KEY,
|
root_uid TEXT PRIMARY KEY,
|
||||||
access TEXT NOT NULL
|
access TEXT NOT NULL
|
||||||
@@ -2423,14 +2609,41 @@ fn pin_add_list_remove_roundtrip() {
|
|||||||
db.pin_add("vol~d", "docs", true).unwrap();
|
db.pin_add("vol~d", "docs", true).unwrap();
|
||||||
let list = db.pin_list().unwrap();
|
let list = db.pin_list().unwrap();
|
||||||
assert_eq!(list.len(), 2);
|
assert_eq!(list.len(), 2);
|
||||||
assert_eq!(list[0], ("vol~a".into(), "docs/a.txt".into(), false));
|
// No `nodes` row for either pin, so the kind is unknown rather than guessed
|
||||||
assert_eq!(list[1], ("vol~d".into(), "docs".into(), true));
|
// from the recursive flag.
|
||||||
|
assert_eq!(
|
||||||
|
list[0],
|
||||||
|
PinRow {
|
||||||
|
uid: "vol~a".into(),
|
||||||
|
path: "docs/a.txt".into(),
|
||||||
|
recursive: false,
|
||||||
|
is_dir: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
list[1],
|
||||||
|
PinRow {
|
||||||
|
uid: "vol~d".into(),
|
||||||
|
path: "docs".into(),
|
||||||
|
recursive: true,
|
||||||
|
is_dir: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Once the node is cached, the pin reports the real kind — a folder pinned
|
||||||
|
// non-recursively must not look like a file.
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&folder("d", Some("root"), "docs")).unwrap();
|
||||||
|
db.pin_add("vol~d", "docs", false).unwrap();
|
||||||
|
let list = db.pin_list().unwrap();
|
||||||
|
assert!(!list[1].recursive, "pin policy stays non-recursive");
|
||||||
|
assert_eq!(list[1].is_dir, Some(true), "but the node is still a folder");
|
||||||
|
|
||||||
// Re-pin refreshes path/flag, not a duplicate row.
|
// Re-pin refreshes path/flag, not a duplicate row.
|
||||||
db.pin_add("vol~a", "moved/a.txt", false).unwrap();
|
db.pin_add("vol~a", "moved/a.txt", false).unwrap();
|
||||||
let list = db.pin_list().unwrap();
|
let list = db.pin_list().unwrap();
|
||||||
assert_eq!(list.len(), 2);
|
assert_eq!(list.len(), 2);
|
||||||
assert_eq!(list[0].1, "moved/a.txt");
|
assert_eq!(list[0].path, "moved/a.txt");
|
||||||
|
|
||||||
assert!(db.pin_remove("vol~a").unwrap());
|
assert!(db.pin_remove("vol~a").unwrap());
|
||||||
assert!(!db.pin_remove("vol~a").unwrap());
|
assert!(!db.pin_remove("vol~a").unwrap());
|
||||||
@@ -2496,6 +2709,26 @@ fn non_recursive_folder_pin_does_not_cover_children() {
|
|||||||
assert!(!db.is_pinned(&du("rep")).unwrap());
|
assert!(!db.is_pinned(&du("rep")).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `parent_uid` cycle is corrupt data the API can hand us, and `is_pinned`'s
|
||||||
|
/// ancestor walk is `UNION ALL` — uncapped it never terminates, while holding
|
||||||
|
/// the daemon's only SQLite connection, on a path that runs per cached read.
|
||||||
|
/// The answer here matters less than the fact that there is one.
|
||||||
|
#[test]
|
||||||
|
fn is_pinned_terminates_on_a_parent_cycle() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let du = |l: &str| uid(l).to_string();
|
||||||
|
// a -> b -> a, with nothing pinned above either.
|
||||||
|
db.upsert_node(&folder("a", Some("b"), "A")).unwrap();
|
||||||
|
db.upsert_node(&folder("b", Some("a"), "B")).unwrap();
|
||||||
|
db.upsert_node(&file("leaf", "a", "leaf.txt", 1)).unwrap();
|
||||||
|
|
||||||
|
assert!(!db.is_pinned(&du("leaf")).unwrap());
|
||||||
|
|
||||||
|
// And a pin *inside* the cycle is still found rather than walked past.
|
||||||
|
db.pin_add(&du("a"), "A", true).unwrap();
|
||||||
|
assert!(db.is_pinned(&du("leaf")).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_objects_exist() {
|
fn schema_objects_exist() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -3050,3 +3283,265 @@ fn clear_state_prefix_takes_only_the_prefixed_keys() {
|
|||||||
"the listing's own stamp is not a per-album one"
|
"the listing's own stamp is not a per-album one"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- `nodes.path`, and the subtree work it replaces (DB3) -------------------
|
||||||
|
|
||||||
|
/// Read the stored path column directly: the point of these tests is that the
|
||||||
|
/// column itself is right, not that `path_of` can still fall back to a walk.
|
||||||
|
fn stored_path(db: &Db, link: &str) -> Option<String> {
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT path FROM nodes WHERE uid = ?1",
|
||||||
|
[uid(link).to_string()],
|
||||||
|
|row| row.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.flatten())
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_stores_its_path_when_it_is_written() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f1", "docs", "report.pdf", 1))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The root's name is the mount, so it contributes nothing.
|
||||||
|
assert_eq!(stored_path(&db, "root").as_deref(), Some(""));
|
||||||
|
assert_eq!(stored_path(&db, "docs").as_deref(), Some("Documents"));
|
||||||
|
assert_eq!(
|
||||||
|
stored_path(&db, "f1").as_deref(),
|
||||||
|
Some("Documents/report.pdf")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_below_an_uncached_parent_starts_its_own_path() {
|
||||||
|
// A device folder's root lives in `device`/`sync_folder` and never gets a
|
||||||
|
// `nodes` row, so its children are the top of the cached chain.
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("orphan", Some("never-cached"), "Work"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f1", "orphan", "notes.txt", 1))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stored_path(&db, "orphan").as_deref(), Some("Work"));
|
||||||
|
assert_eq!(stored_path(&db, "f1").as_deref(), Some("Work/notes.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renaming_a_folder_rewrites_every_descendant_path() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&folder("a", Some("root"), "Before"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&folder("b", Some("a"), "Inner")).unwrap();
|
||||||
|
db.upsert_node(&file("f1", "b", "deep.txt", 1)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
stored_path(&db, "f1").as_deref(),
|
||||||
|
Some("Before/Inner/deep.txt")
|
||||||
|
);
|
||||||
|
|
||||||
|
db.upsert_node(&folder("a", Some("root"), "After")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stored_path(&db, "a").as_deref(), Some("After"));
|
||||||
|
assert_eq!(stored_path(&db, "b").as_deref(), Some("After/Inner"));
|
||||||
|
assert_eq!(
|
||||||
|
stored_path(&db, "f1").as_deref(),
|
||||||
|
Some("After/Inner/deep.txt")
|
||||||
|
);
|
||||||
|
let hit = &db.search("deep.txt", 10).unwrap()[0];
|
||||||
|
assert_eq!(
|
||||||
|
hit.path, "After/Inner/deep.txt",
|
||||||
|
"the search index must carry the rewritten path too"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moving_a_folder_rewrites_every_descendant_path() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&folder("old", Some("root"), "Old")).unwrap();
|
||||||
|
db.upsert_node(&folder("new", Some("root"), "New")).unwrap();
|
||||||
|
db.upsert_node(&folder("moved", Some("old"), "Moved"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f1", "moved", "deep.txt", 1)).unwrap();
|
||||||
|
|
||||||
|
db.upsert_node(&folder("moved", Some("new"), "Moved"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stored_path(&db, "moved").as_deref(), Some("New/Moved"));
|
||||||
|
assert_eq!(
|
||||||
|
stored_path(&db, "f1").as_deref(),
|
||||||
|
Some("New/Moved/deep.txt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trashing_a_folder_drops_its_subtree_from_the_index() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&folder("a", Some("root"), "Holder"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f1", "a", "hidden.txt", 1)).unwrap();
|
||||||
|
assert_eq!(db.search("hidden", 10).unwrap().len(), 1);
|
||||||
|
|
||||||
|
let mut trashed = folder("a", Some("root"), "Holder");
|
||||||
|
trashed.trashed = true;
|
||||||
|
db.upsert_node(&trashed).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
db.search("hidden", 10).unwrap().len(),
|
||||||
|
0,
|
||||||
|
"a file under a trashed folder is not reachable and must not be findable"
|
||||||
|
);
|
||||||
|
|
||||||
|
// …and comes back when the folder does.
|
||||||
|
db.upsert_node(&folder("a", Some("root"), "Holder"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.search("hidden", 10).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The re-listing case, which is most of what the write path actually does: a
|
||||||
|
/// folder upserted with the same name and parent must not touch its subtree.
|
||||||
|
///
|
||||||
|
/// Written as a ratio between two subtree sizes, so it fails on the scaling
|
||||||
|
/// regression rather than on a slow machine. Before this, every re-listing paid
|
||||||
|
/// a `path_of` walk plus an ancestor walk per descendant.
|
||||||
|
#[test]
|
||||||
|
fn re_listing_a_folder_does_not_scale_with_its_subtree() {
|
||||||
|
fn elapsed_for(children: usize) -> std::time::Duration {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&folder("big", Some("root"), "Big")).unwrap();
|
||||||
|
let kids: Vec<Node> = (0..children)
|
||||||
|
.map(|i| file(&format!("k{i}"), "big", &format!("child-{i}.txt"), 1))
|
||||||
|
.collect();
|
||||||
|
db.upsert_nodes(&kids).unwrap();
|
||||||
|
|
||||||
|
let unchanged = folder("big", Some("root"), "Big");
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
for _ in 0..20 {
|
||||||
|
db.upsert_node(&unchanged).unwrap();
|
||||||
|
}
|
||||||
|
start.elapsed()
|
||||||
|
}
|
||||||
|
|
||||||
|
let small = elapsed_for(100);
|
||||||
|
let large = elapsed_for(2_000);
|
||||||
|
assert!(
|
||||||
|
large < small * 8,
|
||||||
|
"re-listing a folder scaled with its subtree: {small:?} for 100 children, \
|
||||||
|
{large:?} for 2000 (20x the descendants)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v25_backfills_paths_for_an_existing_tree() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-v24-fixture-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
{
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f1", "docs", "report.pdf", 1))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&folder("orphan", Some("never-cached"), "Work"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f2", "orphan", "notes.txt", 1))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Put the file back in the state a released V24 database was in.
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE nodes DROP COLUMN path;
|
||||||
|
UPDATE sync_state SET value = '24' WHERE key = 'schema_version';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
assert_eq!(stored_path(&db, "root").as_deref(), Some(""));
|
||||||
|
assert_eq!(stored_path(&db, "docs").as_deref(), Some("Documents"));
|
||||||
|
assert_eq!(
|
||||||
|
stored_path(&db, "f1").as_deref(),
|
||||||
|
Some("Documents/report.pdf"),
|
||||||
|
"an existing tree must come out of the migration with the same paths \
|
||||||
|
the walk used to produce"
|
||||||
|
);
|
||||||
|
// A subtree whose top has an uncached parent is ordinary data, not an error.
|
||||||
|
assert_eq!(stored_path(&db, "orphan").as_deref(), Some("Work"));
|
||||||
|
assert_eq!(stored_path(&db, "f2").as_deref(), Some("Work/notes.txt"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- read-only connection pool (DB2) ---------------------------------------
|
||||||
|
|
||||||
|
/// A read must not queue behind whatever happens to be writing.
|
||||||
|
///
|
||||||
|
/// The write connection is held for the whole measurement — which is what a
|
||||||
|
/// large listing's commit does to it — while a reader asks for a search. Before
|
||||||
|
/// the pool this was the same mutex, so the read waited for the writer;
|
||||||
|
/// `busy_timeout` never entered into it, because the contention was this
|
||||||
|
/// process's own lock rather than SQLite's.
|
||||||
|
#[test]
|
||||||
|
fn a_read_does_not_wait_for_the_write_connection() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-readpool-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let db = std::sync::Arc::new(Db::open(&path).unwrap());
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&file("f1", "root", "findme.txt", 1))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let held = std::time::Duration::from_millis(500);
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let writer = {
|
||||||
|
let db = db.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
db.with_conn(|_conn| {
|
||||||
|
tx.send(()).unwrap();
|
||||||
|
std::thread::sleep(held);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
rx.recv().unwrap();
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let hits = db.search("findme", 10).unwrap();
|
||||||
|
let waited = start.elapsed();
|
||||||
|
writer.join().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(hits.len(), 1, "the read must still see committed data");
|
||||||
|
assert!(
|
||||||
|
waited < held / 2,
|
||||||
|
"the read waited {waited:?} for a write connection held {held:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ impl Db {
|
|||||||
/// The persisted trash listing, folders first then by name — the order the
|
/// The persisted trash listing, folders first then by name — the order the
|
||||||
/// Trash page shows it in.
|
/// Trash page shows it in.
|
||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
"SELECT uid, name, is_dir, size, mtime FROM trash
|
||||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Small query helpers shared across the table modules.
|
//! Small query helpers shared across the table modules.
|
||||||
|
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
@@ -8,14 +8,19 @@ use crate::Result;
|
|||||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||||
pub(super) const TRIGRAM_MIN: usize = 3;
|
pub(super) const TRIGRAM_MIN: usize = 3;
|
||||||
|
|
||||||
pub(super) fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> {
|
/// One raw search row: the node's JSON, its uid, and its stored path — `None`
|
||||||
Ok((row.get(0)?, row.get(1)?))
|
/// only for a row written before the `path` column existed, which
|
||||||
|
/// [`path_of`] repairs by walking.
|
||||||
|
pub(super) type HitRow = (String, String, Option<String>);
|
||||||
|
|
||||||
|
pub(super) fn hit_row(row: &rusqlite::Row) -> rusqlite::Result<HitRow> {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors.
|
/// Drain a `query_map` of [`hit_row`] rows into a `Vec`, propagating row errors.
|
||||||
pub(super) fn collect_pairs(
|
pub(super) fn collect_hits(
|
||||||
rows: impl Iterator<Item = rusqlite::Result<(String, String)>>,
|
rows: impl Iterator<Item = rusqlite::Result<HitRow>>,
|
||||||
) -> Result<Vec<(String, String)>> {
|
) -> Result<Vec<HitRow>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for r in rows {
|
for r in rows {
|
||||||
out.push(r?);
|
out.push(r?);
|
||||||
@@ -31,19 +36,58 @@ pub(super) fn like_escape(s: &str) -> String {
|
|||||||
.replace('_', "\\_")
|
.replace('_', "\\_")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a node's mountpoint-relative path by walking `parent_uid` to the
|
/// How deep the parent walk may go before it gives up. Real Drive trees are
|
||||||
/// root via a recursive CTE. The root (the node with no parent) is excluded, so
|
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
|
||||||
/// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`.
|
/// path of every search result, including the `LIKE` lane that reads `nodes`
|
||||||
|
/// directly and so never passed through the index's cycle check.
|
||||||
|
pub(super) const MAX_PATH_DEPTH: usize = 256;
|
||||||
|
|
||||||
|
/// Append `name` to a parent's stored path. An empty parent path is the root,
|
||||||
|
/// whose name is the mount and so contributes nothing.
|
||||||
|
pub(super) fn join_path(parent_path: &str, name: &str) -> String {
|
||||||
|
if parent_path.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{parent_path}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a node's mountpoint-relative path. The root (the node with no
|
||||||
|
/// parent) is excluded, so a top-level file `report.pdf` yields
|
||||||
|
/// `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||||
|
///
|
||||||
|
/// Reads the stored `nodes.path`, which the write path maintains (see
|
||||||
|
/// `upsert_node_tx`). Falls back to [`walk_path_of`] for a row that predates the
|
||||||
|
/// column or was never written through — a search serving a keystroke used to
|
||||||
|
/// run that walk once per candidate, which is the reason the column exists.
|
||||||
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||||
let mut stmt = conn.prepare(
|
let stored: Option<Option<String>> = conn
|
||||||
|
.query_row("SELECT path FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
match stored.flatten() {
|
||||||
|
Some(path) => Ok(path),
|
||||||
|
None => walk_path_of(conn, uid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
|
||||||
|
///
|
||||||
|
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
||||||
|
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
||||||
|
/// and returns the truncated path rather than hanging the caller.
|
||||||
|
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
||||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||||
|
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||||
)
|
)
|
||||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC",
|
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
|
||||||
)?;
|
))?;
|
||||||
let rows = stmt.query_map(params![uid], |r| {
|
let rows = stmt.query_map(params![uid], |r| {
|
||||||
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
|
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -45,6 +45,27 @@ pub enum Error {
|
|||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Whether this is the local disk being full (`ENOSPC`), as opposed to any
|
||||||
|
/// other I/O failure.
|
||||||
|
///
|
||||||
|
/// Worth distinguishing because it is the one storage failure the user can
|
||||||
|
/// act on, and because the caller's response differs: a full disk is met by
|
||||||
|
/// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a
|
||||||
|
/// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this
|
||||||
|
/// compares the raw code.
|
||||||
|
pub fn is_disk_full(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC),
|
||||||
|
// SQLite reports it as its own code rather than surfacing the errno.
|
||||||
|
Error::Db(rusqlite::Error::SqliteFailure(e, _)) => {
|
||||||
|
e.code == rusqlite::ErrorCode::DiskFull
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// A failure on its way to a front-end: prose for the user, plus the
|
/// A failure on its way to a front-end: prose for the user, plus the
|
||||||
|
|||||||
@@ -4,14 +4,18 @@
|
|||||||
|
|
||||||
pub mod access;
|
pub mod access;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod batch;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
|
pub mod opener;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod sdkcache;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
|||||||
@@ -26,10 +26,20 @@ const SKIP_DIRS: &[&str] = &[
|
|||||||
"venv",
|
"venv",
|
||||||
".venv",
|
".venv",
|
||||||
"vendor",
|
"vendor",
|
||||||
|
"dist",
|
||||||
"dist-newstyle",
|
"dist-newstyle",
|
||||||
|
"build",
|
||||||
"Trash",
|
"Trash",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Directory *paths* skipped when they end with one of these, for caches whose
|
||||||
|
/// own name is too generic to blacklist (`mod`). The Go module cache was 63,399
|
||||||
|
/// of the 110,187 entries indexed on the machine this was measured on — 58% of
|
||||||
|
/// the index, crowding real files out of the bounded candidate pool the fuzzy
|
||||||
|
/// scorer draws from. The other big dependency caches (`.cargo`, `.rustup`,
|
||||||
|
/// `.gradle`, `.m2`) are already skipped by the hidden-file filter.
|
||||||
|
const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
|
||||||
|
|
||||||
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||||
/// database without bound. Reaching it stops the walk early.
|
/// database without bound. Reaching it stops the walk early.
|
||||||
const MAX_ENTRIES: usize = 400_000;
|
const MAX_ENTRIES: usize = 400_000;
|
||||||
@@ -90,7 +100,10 @@ pub fn scan(
|
|||||||
if excludes.iter().any(|e| path.starts_with(e)) {
|
if excludes.iter().any(|e| path.starts_with(e)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
|
if matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!is_skipped_path(path)
|
||||||
});
|
});
|
||||||
|
|
||||||
let state = parking_lot::Mutex::new(SinkState {
|
let state = parking_lot::Mutex::new(SinkState {
|
||||||
@@ -150,6 +163,17 @@ impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `path` is (or is inside) one of the [`SKIP_PATH_SUFFIXES`] caches.
|
||||||
|
/// Matched on the path's own components so a directory merely *named* `mod`
|
||||||
|
/// keeps being indexed.
|
||||||
|
fn is_skipped_path(path: &Path) -> bool {
|
||||||
|
let path = path.to_string_lossy();
|
||||||
|
SKIP_PATH_SUFFIXES.iter().any(|suffix| {
|
||||||
|
path.strip_suffix(suffix)
|
||||||
|
.is_some_and(|head| head.is_empty() || head.ends_with('/'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
||||||
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
||||||
/// JSON wire format) is UTF-8 only.
|
/// JSON wire format) is UTF-8 only.
|
||||||
@@ -221,4 +245,27 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&tmp).unwrap();
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Go module cache is the single largest source of index noise on a
|
||||||
|
/// developer's machine (58% of the entries on the one this was measured
|
||||||
|
/// on), and it cannot be pruned by directory name: `mod` is far too
|
||||||
|
/// common a name to blacklist outright.
|
||||||
|
#[test]
|
||||||
|
fn the_go_module_cache_is_pruned_but_a_plain_mod_dir_is_not() {
|
||||||
|
let tmp = std::env::temp_dir().join(format!("pdfs-scan-gomod-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
std::fs::create_dir_all(tmp.join("go/pkg/mod/example.com/lib")).unwrap();
|
||||||
|
std::fs::create_dir_all(tmp.join("kernel/mod")).unwrap();
|
||||||
|
std::fs::write(tmp.join("go/pkg/mod/example.com/lib/vendored.go"), b"x").unwrap();
|
||||||
|
std::fs::write(tmp.join("kernel/mod/mine.conf"), b"x").unwrap();
|
||||||
|
|
||||||
|
let mut got = Vec::new();
|
||||||
|
scan(std::slice::from_ref(&tmp), &[], |batch| got.extend(batch));
|
||||||
|
|
||||||
|
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert!(!names.contains(&"vendored.go"), "{names:?}");
|
||||||
|
assert!(names.contains(&"mine.conf"), "{names:?}");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
//! Driving an external dmenu-style launcher (fuzzel, rofi, wofi, …).
|
||||||
|
//!
|
||||||
|
//! `pdfs-prompt` ships its own GTK HUD, but on a tiling-WM desktop the user
|
||||||
|
//! already has a launcher bound to a key, themed to match everything else. This
|
||||||
|
//! module lets the prompt borrow it: entries go out on the child's stdin, the
|
||||||
|
//! chosen line comes back on stdout.
|
||||||
|
//!
|
||||||
|
//! Two things vary between launchers and are the only reasons this is not four
|
||||||
|
//! lines of `Command`:
|
||||||
|
//!
|
||||||
|
//! * the flag that turns on filter mode and the one that sets the prompt text;
|
||||||
|
//! * icon support, which fuzzel and rofi both spell `text\0icon\x1fNAME` and
|
||||||
|
//! everything else would render as literal garbage.
|
||||||
|
//!
|
||||||
|
//! Selection is matched back by label rather than by index, because the one
|
||||||
|
//! feature no launcher agrees on is index output. A line that matches nothing is
|
||||||
|
//! the user's own typing — [`MenuChoice::Custom`] — which is what makes
|
||||||
|
//! type-then-search work at all.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// Separates the label from its icon name in the fuzzel/rofi protocol.
|
||||||
|
const ICON_SEPARATOR: &str = "\0icon\x1f";
|
||||||
|
|
||||||
|
/// Launchers we know how to invoke, most-preferred first:
|
||||||
|
/// `(program, filter-mode args, prompt flag, icon protocol)`.
|
||||||
|
///
|
||||||
|
/// The prompt flag is written as it is passed: fuzzel wants `--prompt=TEXT` as
|
||||||
|
/// one argument, the rest take the text as a separate one.
|
||||||
|
const KNOWN_MENUS: [MenuFlavor; 6] = [
|
||||||
|
MenuFlavor {
|
||||||
|
program: "fuzzel",
|
||||||
|
mode: &["--dmenu"],
|
||||||
|
prompt: PromptFlag::Joined("--prompt="),
|
||||||
|
icons: true,
|
||||||
|
},
|
||||||
|
MenuFlavor {
|
||||||
|
program: "rofi",
|
||||||
|
mode: &["-dmenu"],
|
||||||
|
prompt: PromptFlag::Separate("-p"),
|
||||||
|
icons: true,
|
||||||
|
},
|
||||||
|
MenuFlavor {
|
||||||
|
program: "wofi",
|
||||||
|
mode: &["--dmenu"],
|
||||||
|
prompt: PromptFlag::Separate("--prompt"),
|
||||||
|
icons: false,
|
||||||
|
},
|
||||||
|
MenuFlavor {
|
||||||
|
program: "tofi",
|
||||||
|
mode: &[],
|
||||||
|
prompt: PromptFlag::Joined("--prompt-text="),
|
||||||
|
icons: false,
|
||||||
|
},
|
||||||
|
MenuFlavor {
|
||||||
|
program: "bemenu",
|
||||||
|
mode: &[],
|
||||||
|
prompt: PromptFlag::Separate("-p"),
|
||||||
|
icons: false,
|
||||||
|
},
|
||||||
|
MenuFlavor {
|
||||||
|
program: "dmenu",
|
||||||
|
mode: &[],
|
||||||
|
prompt: PromptFlag::Separate("-p"),
|
||||||
|
icons: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum PromptFlag {
|
||||||
|
/// `--prompt=TEXT`
|
||||||
|
Joined(&'static str),
|
||||||
|
/// `-p TEXT`
|
||||||
|
Separate(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct MenuFlavor {
|
||||||
|
program: &'static str,
|
||||||
|
mode: &'static [&'static str],
|
||||||
|
prompt: PromptFlag,
|
||||||
|
icons: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt behaviour from `config.json`, under `prompt`.
|
||||||
|
///
|
||||||
|
/// ```json
|
||||||
|
/// "prompt": { "menu": ["fuzzel", "--dmenu", "--width=60"], "menu_limit": 40 }
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PromptConfig {
|
||||||
|
/// Which front end `pdfs-prompt` uses when invoked with no mode flag, so a
|
||||||
|
/// desktop shortcut does not have to be re-bound to switch. `None` is
|
||||||
|
/// [`PromptMode::Gtk`], the built-in HUD.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mode: Option<PromptMode>,
|
||||||
|
/// Launcher argv for `pdfs-prompt --dmenu`. Unset means "find one" — see
|
||||||
|
/// [`resolve_menu`]. A `{prompt}` token anywhere in the argv is replaced by
|
||||||
|
/// the prompt text; without one the launcher's own prompt flag is appended.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub menu: Option<Vec<String>>,
|
||||||
|
/// How many results to feed the launcher. Unset means
|
||||||
|
/// [`DEFAULT_MENU_LIMIT`].
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub menu_limit: Option<usize>,
|
||||||
|
/// Show icons when the launcher understands them. Defaults to true; set
|
||||||
|
/// false for a launcher that advertises support but is themed without them.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub menu_icons: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which front end the prompt presents.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PromptMode {
|
||||||
|
/// The built-in GTK HUD.
|
||||||
|
#[default]
|
||||||
|
Gtk,
|
||||||
|
/// The user's own launcher, driven over stdin/stdout.
|
||||||
|
Dmenu,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
||||||
|
/// than the GTK prompt's cap because filtering happens launcher-side.
|
||||||
|
pub const DEFAULT_MENU_LIMIT: usize = 50;
|
||||||
|
|
||||||
|
impl PromptConfig {
|
||||||
|
pub fn resolved_mode(&self) -> PromptMode {
|
||||||
|
self.mode.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_menu_limit(&self) -> usize {
|
||||||
|
self.menu_limit.unwrap_or(DEFAULT_MENU_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_menu_icons(&self) -> bool {
|
||||||
|
self.menu_icons.unwrap_or(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line offered to the launcher.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MenuItem {
|
||||||
|
/// What the user sees, and what identifies the choice on the way back.
|
||||||
|
pub label: String,
|
||||||
|
/// Icon theme name, used only by launchers that support the protocol.
|
||||||
|
pub icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuItem {
|
||||||
|
pub fn new(label: impl Into<String>, icon: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
icon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What came back from the launcher.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum MenuChoice {
|
||||||
|
/// Index into the items that were passed in.
|
||||||
|
Item(usize),
|
||||||
|
/// Text the user typed that matched no item — treat it as a query.
|
||||||
|
Custom(String),
|
||||||
|
/// Escape, or an empty selection.
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The launcher to run: the configured one, or the first [`KNOWN_MENUS`] entry
|
||||||
|
/// on `PATH`. Returns the argv as it should be spawned, filter-mode flags
|
||||||
|
/// included.
|
||||||
|
pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||||
|
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
|
||||||
|
return Some(argv.clone());
|
||||||
|
}
|
||||||
|
let flavor = KNOWN_MENUS.iter().find(|f| on_path(f.program))?;
|
||||||
|
let mut argv = vec![flavor.program.to_string()];
|
||||||
|
argv.extend(flavor.mode.iter().map(|f| (*f).to_string()));
|
||||||
|
Some(argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show `items` in the launcher named by `argv` and wait for a choice.
|
||||||
|
///
|
||||||
|
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
|
||||||
|
/// is this round-trip.
|
||||||
|
pub fn run(argv: &[String], prompt: &str, items: &[MenuItem]) -> Result<MenuChoice> {
|
||||||
|
let argv = with_prompt(argv, prompt);
|
||||||
|
let Some((program, args)) = argv.split_first() else {
|
||||||
|
return Err(Error::Other("no launcher command configured".into()));
|
||||||
|
};
|
||||||
|
let icons = supports_icons(program);
|
||||||
|
|
||||||
|
let mut child = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| Error::Other(format!("cannot run launcher {program}: {e}")))?;
|
||||||
|
|
||||||
|
// Write on a worker: a launcher that starts reading only after it maps its
|
||||||
|
// window would otherwise deadlock against a full pipe buffer.
|
||||||
|
let payload = render(items, icons);
|
||||||
|
let mut stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| Error::Other("launcher stdin unavailable".into()))?;
|
||||||
|
let writer = std::thread::spawn(move || {
|
||||||
|
let _ = stdin.write_all(payload.as_bytes());
|
||||||
|
let _ = stdin.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut selected = String::new();
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
// Only the first line matters; multi-select is not a thing here, and a
|
||||||
|
// launcher that emits more must not leave us blocked on a full pipe.
|
||||||
|
let mut reader = BufReader::new(stdout);
|
||||||
|
let mut line = String::new();
|
||||||
|
while reader.read_line(&mut line).unwrap_or(0) > 0 {
|
||||||
|
if selected.is_empty() {
|
||||||
|
selected = line.trim_end_matches('\n').to_string();
|
||||||
|
}
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = writer.join();
|
||||||
|
|
||||||
|
Ok(interpret(&selected, items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the launcher's output back to a choice. Exposed for tests; the ambiguity
|
||||||
|
/// it resolves (label vs. free text) is the whole contract with the launcher.
|
||||||
|
fn interpret(selected: &str, items: &[MenuItem]) -> MenuChoice {
|
||||||
|
// Some launchers echo the icon protocol back verbatim.
|
||||||
|
let selected = selected
|
||||||
|
.split(ICON_SEPARATOR)
|
||||||
|
.next()
|
||||||
|
.unwrap_or(selected)
|
||||||
|
.trim_end();
|
||||||
|
if selected.is_empty() {
|
||||||
|
return MenuChoice::Cancelled;
|
||||||
|
}
|
||||||
|
match items.iter().position(|item| item.label == selected) {
|
||||||
|
Some(index) => MenuChoice::Item(index),
|
||||||
|
None => MenuChoice::Custom(selected.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stdin payload. Labels are stripped of newlines rather than escaped —
|
||||||
|
/// there is no escape in this protocol, and a file name with a newline in it
|
||||||
|
/// would otherwise become two entries.
|
||||||
|
fn render(items: &[MenuItem], icons: bool) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for item in items {
|
||||||
|
let label = item.label.replace(['\n', '\r'], " ");
|
||||||
|
out.push_str(&label);
|
||||||
|
if icons && let Some(icon) = &item.icon {
|
||||||
|
out.push_str(ICON_SEPARATOR);
|
||||||
|
out.push_str(icon);
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the prompt text: substituted for `{prompt}` if the user placed one,
|
||||||
|
/// otherwise appended using the launcher's own flag. An unrecognised launcher
|
||||||
|
/// gets no prompt rather than a guessed flag that might not parse.
|
||||||
|
fn with_prompt(argv: &[String], prompt: &str) -> Vec<String> {
|
||||||
|
if argv.iter().any(|arg| arg.contains("{prompt}")) {
|
||||||
|
return argv
|
||||||
|
.iter()
|
||||||
|
.map(|arg| arg.replace("{prompt}", prompt))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
let mut argv = argv.to_vec();
|
||||||
|
if prompt.is_empty() {
|
||||||
|
return argv;
|
||||||
|
}
|
||||||
|
if let Some(flavor) = argv.first().and_then(|program| flavor_of(program)) {
|
||||||
|
match flavor.prompt {
|
||||||
|
PromptFlag::Joined(flag) => argv.push(format!("{flag}{prompt}")),
|
||||||
|
PromptFlag::Separate(flag) => {
|
||||||
|
argv.push(flag.to_string());
|
||||||
|
argv.push(prompt.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
argv
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_icons(program: &str) -> bool {
|
||||||
|
flavor_of(program).is_some_and(|flavor| flavor.icons)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
|
||||||
|
let base = Path::new(program).file_name()?.to_str()?;
|
||||||
|
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_path(program: &str) -> bool {
|
||||||
|
let Some(path) = std::env::var_os("PATH") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn items() -> Vec<MenuItem> {
|
||||||
|
vec![
|
||||||
|
MenuItem::new(
|
||||||
|
"notes.md · My files / Docs",
|
||||||
|
Some("text-x-generic".into()),
|
||||||
|
),
|
||||||
|
MenuItem::new("photo.png · Home / Pictures", None),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_matching_line_is_an_item_and_anything_else_is_a_query() {
|
||||||
|
let items = items();
|
||||||
|
assert_eq!(
|
||||||
|
interpret("notes.md · My files / Docs", &items),
|
||||||
|
MenuChoice::Item(0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
interpret("invoice", &items),
|
||||||
|
MenuChoice::Custom("invoice".into())
|
||||||
|
);
|
||||||
|
assert_eq!(interpret("", &items), MenuChoice::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_echoed_icon_field_does_not_break_the_match() {
|
||||||
|
let items = items();
|
||||||
|
assert_eq!(
|
||||||
|
interpret(
|
||||||
|
"notes.md · My files / Docs\0icon\x1ftext-x-generic",
|
||||||
|
&items
|
||||||
|
),
|
||||||
|
MenuChoice::Item(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icons_are_only_rendered_for_launchers_that_understand_them() {
|
||||||
|
let items = items();
|
||||||
|
assert_eq!(
|
||||||
|
render(&items, true),
|
||||||
|
"notes.md · My files / Docs\0icon\x1ftext-x-generic\nphoto.png · Home / Pictures\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render(&items, false),
|
||||||
|
"notes.md · My files / Docs\nphoto.png · Home / Pictures\n"
|
||||||
|
);
|
||||||
|
assert!(supports_icons("fuzzel"));
|
||||||
|
assert!(supports_icons("/usr/bin/rofi"));
|
||||||
|
assert!(!supports_icons("wofi"));
|
||||||
|
assert!(!supports_icons("mystery-menu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_label_with_a_newline_stays_one_entry() {
|
||||||
|
let sneaky = vec![MenuItem::new("two\nlines.txt", None)];
|
||||||
|
assert_eq!(render(&sneaky, false), "two lines.txt\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_prompt_uses_each_launchers_own_flag() {
|
||||||
|
assert_eq!(
|
||||||
|
with_prompt(&["fuzzel".into(), "--dmenu".into()], "Drive"),
|
||||||
|
vec!["fuzzel", "--dmenu", "--prompt=Drive"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
with_prompt(&["rofi".into(), "-dmenu".into()], "Drive"),
|
||||||
|
vec!["rofi", "-dmenu", "-p", "Drive"]
|
||||||
|
);
|
||||||
|
// Unknown launcher: no guessed flag.
|
||||||
|
assert_eq!(
|
||||||
|
with_prompt(&["mystery-menu".into()], "Drive"),
|
||||||
|
vec!["mystery-menu"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_placeholder_wins_over_the_flag() {
|
||||||
|
assert_eq!(
|
||||||
|
with_prompt(
|
||||||
|
&[
|
||||||
|
"rofi".into(),
|
||||||
|
"-dmenu".into(),
|
||||||
|
"-p".into(),
|
||||||
|
"{prompt} >".into()
|
||||||
|
],
|
||||||
|
"Drive"
|
||||||
|
),
|
||||||
|
vec!["rofi", "-dmenu", "-p", "Drive >"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_configured_launcher_is_used_verbatim() {
|
||||||
|
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
||||||
|
assert_eq!(resolve_menu(Some(&configured)), Some(configured.clone()));
|
||||||
|
// An empty list is "not configured", not "run nothing".
|
||||||
|
assert_eq!(
|
||||||
|
resolve_menu(Some(&Vec::new())),
|
||||||
|
resolve_menu(None),
|
||||||
|
"an empty menu list must fall back to detection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
//! How a file chosen in the prompt (or the browser) is actually opened.
|
||||||
|
//!
|
||||||
|
//! The default is `xdg-open`, which routes through the desktop's MIME
|
||||||
|
//! associations. That is the right answer for a GNOME session and the wrong one
|
||||||
|
//! for a tiling-WM setup where "open a text file" means "run `nvim` inside my
|
||||||
|
//! terminal" — there is no desktop file for that, and pointing `xdg-open` at a
|
||||||
|
//! terminal editor globally breaks every graphical caller.
|
||||||
|
//!
|
||||||
|
//! So the rules live in `config.json` under `open_with`: an ordered list of
|
||||||
|
//! name/class patterns mapped to argv, each optionally wrapped in a terminal.
|
||||||
|
//! First match wins; anything unmatched falls through to `default` (itself
|
||||||
|
//! `xdg-open` unless overridden). Nothing here is required — an absent
|
||||||
|
//! `open_with` reproduces the previous behaviour exactly.
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! "open_with": {
|
||||||
|
//! "terminal": ["alacritty", "-e"],
|
||||||
|
//! "rules": [
|
||||||
|
//! { "match": ["@text", "*.rs", "*.toml"], "command": ["nvim"], "terminal": true },
|
||||||
|
//! { "match": ["*.png"], "command": ["imv"] }
|
||||||
|
//! ]
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Environment override for the terminal used by `"terminal": true` rules.
|
||||||
|
/// Checked before `$TERMINAL` so a support case can redirect it without editing
|
||||||
|
/// config.
|
||||||
|
pub const TERMINAL_ENV: &str = "PDFS_TERMINAL";
|
||||||
|
|
||||||
|
/// Terminals we know how to hand a command line to, most-preferred first, with
|
||||||
|
/// the argument that means "run this, don't parse it as options". `kitty` and
|
||||||
|
/// `wezterm start --` take the command positionally.
|
||||||
|
const KNOWN_TERMINALS: [(&str, &[&str]); 11] = [
|
||||||
|
("alacritty", &["-e"]),
|
||||||
|
("foot", &["-e"]),
|
||||||
|
("kitty", &[]),
|
||||||
|
("ghostty", &["-e"]),
|
||||||
|
("wezterm", &["start", "--"]),
|
||||||
|
("gnome-terminal", &["--"]),
|
||||||
|
("konsole", &["-e"]),
|
||||||
|
("xfce4-terminal", &["-x"]),
|
||||||
|
("urxvt", &["-e"]),
|
||||||
|
("st", &["-e"]),
|
||||||
|
("xterm", &["-e"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The opener policy as it appears in `config.json`.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OpenWith {
|
||||||
|
/// Terminal argv used by rules with `"terminal": true`, e.g.
|
||||||
|
/// `["alacritty", "-e"]`. Unset means "resolve one" — see
|
||||||
|
/// [`resolve_terminal`].
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub terminal: Option<Vec<String>>,
|
||||||
|
/// What to run when no rule matches. Unset means `xdg-open`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default: Option<Vec<String>>,
|
||||||
|
/// Ordered; first match wins.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub rules: Vec<OpenRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `pattern set -> command` mapping.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OpenRule {
|
||||||
|
/// Patterns this rule answers to. Either a shell-style glob against the file
|
||||||
|
/// name (`*.md`, `notes-*.txt`) or one of the classes `@dir`, `@text`,
|
||||||
|
/// `@document`, `@image`, `@media`, `@any`. Matching is case-insensitive.
|
||||||
|
#[serde(rename = "match", default)]
|
||||||
|
pub patterns: Vec<String>,
|
||||||
|
/// argv to run. A `{}` token is replaced by the path; without one the path
|
||||||
|
/// is appended. `$VAR`/`${VAR}` tokens expand from the environment, so
|
||||||
|
/// `["$EDITOR"]` follows the user's editor.
|
||||||
|
#[serde(default)]
|
||||||
|
pub command: Vec<String>,
|
||||||
|
/// Wrap [`command`](Self::command) in the terminal emulator.
|
||||||
|
#[serde(default)]
|
||||||
|
pub terminal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenWith {
|
||||||
|
/// The argv that opens `path`, terminal wrapping included. Never fails: an
|
||||||
|
/// unmatched path — or a rule whose terminal cannot be resolved — degrades
|
||||||
|
/// to the default opener rather than doing nothing.
|
||||||
|
pub fn command_for(&self, path: &Path, is_dir: bool) -> Vec<String> {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
self.command_for_named(path, &name, is_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`command_for`](Self::command_for) with the rules matched against `name`
|
||||||
|
/// instead of the last component of `path`.
|
||||||
|
///
|
||||||
|
/// A materialised Drive file lives in the content cache under its content
|
||||||
|
/// hash — no extension, no original name — so matching the on-disk path
|
||||||
|
/// classifies every download as "unknown" and hands it to `xdg-open`, which
|
||||||
|
/// sniffs the bytes and picks whatever claims `text/plain` (LibreOffice, for
|
||||||
|
/// a Markdown file). The rules are about *what the user opened*, so the
|
||||||
|
/// caller passes that name and the argv still gets the real path.
|
||||||
|
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
|
||||||
|
for rule in &self.rules {
|
||||||
|
if rule.command.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let argv = expand(&rule.command, path);
|
||||||
|
if !rule.terminal {
|
||||||
|
return argv;
|
||||||
|
}
|
||||||
|
match self.terminal_argv() {
|
||||||
|
Some(mut terminal) => {
|
||||||
|
terminal.extend(argv);
|
||||||
|
return terminal;
|
||||||
|
}
|
||||||
|
// A terminal rule with no terminal to run it in would silently
|
||||||
|
// launch a TUI with no tty. Fall through to the desktop instead.
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
"no terminal found for a terminal open rule; using the default opener"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback = self
|
||||||
|
.default
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| vec!["xdg-open".to_string()]);
|
||||||
|
expand(&fallback, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configured terminal, or a resolved one.
|
||||||
|
fn terminal_argv(&self) -> Option<Vec<String>> {
|
||||||
|
match &self.terminal {
|
||||||
|
Some(argv) if !argv.is_empty() => Some(argv.clone()),
|
||||||
|
_ => resolve_terminal(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch `path` under `policy`. Fire-and-forget: the child outlives us, and a
|
||||||
|
/// spawn failure is logged rather than propagated — every caller is a UI action
|
||||||
|
/// with nowhere useful to report to.
|
||||||
|
pub fn open(policy: &OpenWith, path: &Path, is_dir: bool) {
|
||||||
|
let argv = policy.command_for(path, is_dir);
|
||||||
|
spawn(&argv, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`open`] for a path whose own file name says nothing about its type — see
|
||||||
|
/// [`OpenWith::command_for_named`].
|
||||||
|
pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
|
||||||
|
let argv = policy.command_for_named(path, name, is_dir);
|
||||||
|
spawn(&argv, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn(argv: &[String], path: &Path) {
|
||||||
|
let Some((program, args)) = argv.split_first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
||||||
|
if let Err(e) = Command::new(program).args(args).spawn() {
|
||||||
|
tracing::error!(command = ?argv, "open failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`open`] with the policy from the user's `config.json`, read once per
|
||||||
|
/// process. For callers that open the occasional path and have no reason to
|
||||||
|
/// carry a policy around; long-lived windows should hold their own copy.
|
||||||
|
pub fn open_default(path: &Path, is_dir: bool) {
|
||||||
|
open(default_policy(), path, is_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`open_default`] matching the rules against `name` — see
|
||||||
|
/// [`OpenWith::command_for_named`].
|
||||||
|
pub fn open_default_named(path: &Path, name: &str, is_dir: bool) {
|
||||||
|
open_named(default_policy(), path, name, is_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_policy() -> &'static OpenWith {
|
||||||
|
static POLICY: std::sync::OnceLock<OpenWith> = std::sync::OnceLock::new();
|
||||||
|
POLICY.get_or_init(|| {
|
||||||
|
crate::config::AppDirs::new()
|
||||||
|
.map(|dirs| dirs.load_config().resolved_open_with())
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$PDFS_TERMINAL`, then `$TERMINAL`, then the first [`KNOWN_TERMINALS`] entry
|
||||||
|
/// on `PATH`. An env value may be a full command line (`"foot -e"`), in which
|
||||||
|
/// case it is used verbatim; a bare program name gains that terminal's "run
|
||||||
|
/// this" argument so `TERMINAL=alacritty` does the obvious thing.
|
||||||
|
pub fn resolve_terminal() -> Option<Vec<String>> {
|
||||||
|
for key in [TERMINAL_ENV, "TERMINAL"] {
|
||||||
|
let Ok(value) = std::env::var(key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let argv: Vec<String> = value.split_whitespace().map(str::to_string).collect();
|
||||||
|
match argv.len() {
|
||||||
|
0 => continue,
|
||||||
|
1 => return Some(with_exec_flag(&argv[0])),
|
||||||
|
_ => return Some(argv),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KNOWN_TERMINALS
|
||||||
|
.iter()
|
||||||
|
.find(|(program, _)| on_path(program))
|
||||||
|
.map(|(program, _)| with_exec_flag(program))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare terminal name plus whatever it needs before a command.
|
||||||
|
fn with_exec_flag(program: &str) -> Vec<String> {
|
||||||
|
let base = Path::new(program)
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or(program);
|
||||||
|
let mut argv = vec![program.to_string()];
|
||||||
|
let flags = KNOWN_TERMINALS
|
||||||
|
.iter()
|
||||||
|
.find(|(known, _)| *known == base)
|
||||||
|
.map_or(&["-e"][..], |(_, flags)| *flags);
|
||||||
|
argv.extend(flags.iter().map(|f| (*f).to_string()));
|
||||||
|
argv
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_path(program: &str) -> bool {
|
||||||
|
let Some(path) = std::env::var_os("PATH") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Substitute `{}` (or append) the path, and expand `$VAR` tokens. Tokens that
|
||||||
|
/// expand to nothing are dropped, so `["$EDITOR"]` with no `EDITOR` set leaves
|
||||||
|
/// an empty argv and falls back at the call site.
|
||||||
|
fn expand(argv: &[String], path: &Path) -> Vec<String> {
|
||||||
|
let target = path.display().to_string();
|
||||||
|
let mut out = Vec::with_capacity(argv.len() + 1);
|
||||||
|
let mut substituted = false;
|
||||||
|
for token in argv {
|
||||||
|
if token.contains("{}") {
|
||||||
|
out.push(token.replace("{}", &target));
|
||||||
|
substituted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match expand_env(token) {
|
||||||
|
Some(value) => out.push(value),
|
||||||
|
None => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return vec!["xdg-open".to_string(), target];
|
||||||
|
}
|
||||||
|
if !substituted {
|
||||||
|
out.push(target);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$VAR` / `${VAR}` for a whole token, `None` when the variable is unset or
|
||||||
|
/// empty so the token drops out. A token that is not a variable reference is
|
||||||
|
/// returned unchanged; this is deliberately not a shell — no word splitting, no
|
||||||
|
/// quoting rules.
|
||||||
|
fn expand_env(token: &str) -> Option<String> {
|
||||||
|
let Some(name) = token
|
||||||
|
.strip_prefix("${")
|
||||||
|
.and_then(|rest| rest.strip_suffix('}'))
|
||||||
|
.or_else(|| token.strip_prefix('$'))
|
||||||
|
else {
|
||||||
|
return Some(token.to_string());
|
||||||
|
};
|
||||||
|
if name.is_empty() {
|
||||||
|
return Some(token.to_string());
|
||||||
|
}
|
||||||
|
// Follow `$EDITOR`'s convention of preferring the graphical-capable one.
|
||||||
|
let value = if name == "EDITOR" {
|
||||||
|
std::env::var("VISUAL")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or_else(|| std::env::var("EDITOR").ok())
|
||||||
|
} else {
|
||||||
|
std::env::var(name).ok()
|
||||||
|
};
|
||||||
|
value.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `pattern` — a glob or an `@class` — matches this name.
|
||||||
|
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool {
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
match pattern.to_lowercase().as_str() {
|
||||||
|
"@any" => true,
|
||||||
|
"@dir" => is_dir,
|
||||||
|
"@text" => !is_dir && class_text(&name_lower),
|
||||||
|
"@document" => !is_dir && class_document(&name_lower),
|
||||||
|
"@image" => !is_dir && class_image(&name_lower),
|
||||||
|
"@media" => !is_dir && class_media(&name_lower),
|
||||||
|
other if other.starts_with('@') => false,
|
||||||
|
other => glob_match(other, &name_lower),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extension(name: &str) -> &str {
|
||||||
|
Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain-text-ish files a terminal editor is a sensible default for. Extensions
|
||||||
|
/// only: the daemon's hits may name files that are not on this machine yet, so
|
||||||
|
/// there is nothing to sniff.
|
||||||
|
fn class_text(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
extension(name),
|
||||||
|
"txt"
|
||||||
|
| "md"
|
||||||
|
| "markdown"
|
||||||
|
| "rst"
|
||||||
|
| "org"
|
||||||
|
| "log"
|
||||||
|
| "conf"
|
||||||
|
| "cfg"
|
||||||
|
| "ini"
|
||||||
|
| "toml"
|
||||||
|
| "yaml"
|
||||||
|
| "yml"
|
||||||
|
| "json"
|
||||||
|
| "xml"
|
||||||
|
| "csv"
|
||||||
|
| "tsv"
|
||||||
|
| "env"
|
||||||
|
| "sh"
|
||||||
|
| "bash"
|
||||||
|
| "zsh"
|
||||||
|
| "fish"
|
||||||
|
| "rs"
|
||||||
|
| "py"
|
||||||
|
| "go"
|
||||||
|
| "c"
|
||||||
|
| "h"
|
||||||
|
| "cpp"
|
||||||
|
| "hpp"
|
||||||
|
| "js"
|
||||||
|
| "ts"
|
||||||
|
| "tsx"
|
||||||
|
| "jsx"
|
||||||
|
| "css"
|
||||||
|
| "html"
|
||||||
|
| "sql"
|
||||||
|
| "lua"
|
||||||
|
| "vim"
|
||||||
|
| "nix"
|
||||||
|
| "patch"
|
||||||
|
| "diff"
|
||||||
|
) || name == "makefile"
|
||||||
|
|| name == "dockerfile"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class_document(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
extension(name),
|
||||||
|
"pdf"
|
||||||
|
| "doc"
|
||||||
|
| "docx"
|
||||||
|
| "odt"
|
||||||
|
| "rtf"
|
||||||
|
| "xls"
|
||||||
|
| "xlsx"
|
||||||
|
| "ods"
|
||||||
|
| "ppt"
|
||||||
|
| "pptx"
|
||||||
|
| "odp"
|
||||||
|
| "epub"
|
||||||
|
) || class_text(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class_image(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
extension(name),
|
||||||
|
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class_media(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
extension(name),
|
||||||
|
"mp4"
|
||||||
|
| "mkv"
|
||||||
|
| "webm"
|
||||||
|
| "mov"
|
||||||
|
| "avi"
|
||||||
|
| "mp3"
|
||||||
|
| "flac"
|
||||||
|
| "wav"
|
||||||
|
| "ogg"
|
||||||
|
| "opus"
|
||||||
|
| "m4a"
|
||||||
|
| "aac"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `*` and `?` globbing, no character classes and no path semantics — patterns
|
||||||
|
/// are matched against a bare file name. Iterative with backtracking so a
|
||||||
|
/// pathological pattern cannot blow the stack.
|
||||||
|
fn glob_match(pattern: &str, name: &str) -> bool {
|
||||||
|
let pattern: Vec<char> = pattern.chars().collect();
|
||||||
|
let name: Vec<char> = name.chars().collect();
|
||||||
|
let (mut p, mut n) = (0usize, 0usize);
|
||||||
|
// Where to resume if the current `*` turns out to have matched too little.
|
||||||
|
let (mut star, mut resume) = (None, 0usize);
|
||||||
|
|
||||||
|
while n < name.len() {
|
||||||
|
match pattern.get(p) {
|
||||||
|
Some('*') => {
|
||||||
|
star = Some(p);
|
||||||
|
resume = n;
|
||||||
|
p += 1;
|
||||||
|
}
|
||||||
|
Some('?') => {
|
||||||
|
p += 1;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
Some(c) if *c == name[n] => {
|
||||||
|
p += 1;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
_ => match star {
|
||||||
|
Some(s) => {
|
||||||
|
p = s + 1;
|
||||||
|
resume += 1;
|
||||||
|
n = resume;
|
||||||
|
}
|
||||||
|
None => return false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pattern[p..].iter().all(|c| *c == '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
|
||||||
|
OpenRule {
|
||||||
|
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
|
||||||
|
command: command.iter().map(|c| (*c).to_string()).collect(),
|
||||||
|
terminal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cache_blob_is_classified_by_the_name_it_was_opened_as() {
|
||||||
|
let policy = OpenWith {
|
||||||
|
terminal: Some(vec!["alacritty".into(), "-e".into()]),
|
||||||
|
default: None,
|
||||||
|
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||||
|
};
|
||||||
|
// What the daemon hands back: content-hash name, no extension. Matched
|
||||||
|
// on its own it is "unknown" and would land in xdg-open's lap.
|
||||||
|
let blob = Path::new("/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6");
|
||||||
|
assert_eq!(
|
||||||
|
policy.command_for(blob, false),
|
||||||
|
vec![
|
||||||
|
"xdg-open",
|
||||||
|
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
policy.command_for_named(blob, "ONBOARDING.md", false),
|
||||||
|
vec![
|
||||||
|
"alacritty",
|
||||||
|
"-e",
|
||||||
|
"nvim",
|
||||||
|
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_policy_is_xdg_open() {
|
||||||
|
let policy = OpenWith::default();
|
||||||
|
assert_eq!(
|
||||||
|
policy.command_for(Path::new("/tmp/a.md"), false),
|
||||||
|
vec!["xdg-open".to_string(), "/tmp/a.md".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_terminal_rule_wraps_the_command() {
|
||||||
|
let policy = OpenWith {
|
||||||
|
terminal: Some(vec!["alacritty".into(), "-e".into()]),
|
||||||
|
default: None,
|
||||||
|
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
policy.command_for(Path::new("/tmp/notes.md"), false),
|
||||||
|
vec!["alacritty", "-e", "nvim", "/tmp/notes.md"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_first_matching_rule_wins_and_globs_are_case_insensitive() {
|
||||||
|
let policy = OpenWith {
|
||||||
|
terminal: None,
|
||||||
|
default: None,
|
||||||
|
rules: vec![
|
||||||
|
rule(&["*.PNG"], &["imv"], false),
|
||||||
|
rule(&["@image"], &["eog"], false),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
policy.command_for(Path::new("/tmp/shot.png"), false),
|
||||||
|
vec!["imv", "/tmp/shot.png"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_placeholder_positions_the_path_instead_of_appending_it() {
|
||||||
|
let policy = OpenWith {
|
||||||
|
terminal: None,
|
||||||
|
default: None,
|
||||||
|
rules: vec![rule(&["@any"], &["sh", "-c", "cat {} | less"], false)],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
policy.command_for(Path::new("/tmp/a.log"), false),
|
||||||
|
vec!["sh", "-c", "cat /tmp/a.log | less"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directories_only_match_the_dir_class() {
|
||||||
|
let policy = OpenWith {
|
||||||
|
terminal: None,
|
||||||
|
default: None,
|
||||||
|
rules: vec![
|
||||||
|
rule(&["@text"], &["nvim"], false),
|
||||||
|
rule(&["@dir"], &["nautilus"], false),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
policy.command_for(Path::new("/tmp/notes.md"), true),
|
||||||
|
vec!["nautilus", "/tmp/notes.md"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rule_that_cannot_find_a_terminal_falls_back_to_the_default() {
|
||||||
|
// A terminal that is spelled out in config is always honoured; this is
|
||||||
|
// the other path — resolution must not invent one from the test host.
|
||||||
|
let policy = OpenWith {
|
||||||
|
terminal: Some(Vec::new()),
|
||||||
|
default: Some(vec!["gio".into(), "open".into()]),
|
||||||
|
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||||
|
};
|
||||||
|
let argv = policy.command_for(Path::new("/tmp/a.md"), false);
|
||||||
|
// Either a terminal exists on this machine (wrapped nvim) or it does
|
||||||
|
// not (the default). Both are correct; what must not happen is a bare
|
||||||
|
// `nvim` with no tty.
|
||||||
|
assert!(
|
||||||
|
argv == vec!["gio", "open", "/tmp/a.md"]
|
||||||
|
|| argv.ends_with(&["nvim".to_string(), "/tmp/a.md".to_string()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn globs_backtrack() {
|
||||||
|
assert!(glob_match("*.md", "notes.md"));
|
||||||
|
assert!(glob_match("*notes*.md", "my-notes-2024.md"));
|
||||||
|
assert!(glob_match("a?c.txt", "abc.txt"));
|
||||||
|
assert!(!glob_match("*.md", "notes.mdx"));
|
||||||
|
assert!(!glob_match("a?c.txt", "ac.txt"));
|
||||||
|
assert!(glob_match("*", "anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_terminal_name_gains_its_exec_flag() {
|
||||||
|
assert_eq!(with_exec_flag("alacritty"), vec!["alacritty", "-e"]);
|
||||||
|
assert_eq!(with_exec_flag("kitty"), vec!["kitty"]);
|
||||||
|
assert_eq!(
|
||||||
|
with_exec_flag("/usr/bin/wezterm"),
|
||||||
|
vec!["/usr/bin/wezterm", "start", "--"]
|
||||||
|
);
|
||||||
|
// Unknown terminals get the near-universal `-e`.
|
||||||
|
assert_eq!(with_exec_flag("mystery-term"), vec!["mystery-term", "-e"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
//! A persistent [`CacheRepository`] for the SDK's Drive entity cache.
|
||||||
|
//!
|
||||||
|
//! The SDK caches decrypted node metadata (name, size, parent, the share whose
|
||||||
|
//! membership signs operations on it) so navigating a tree does not re-fetch and
|
||||||
|
//! re-decrypt what it already resolved. That cache defaults to memory, which
|
||||||
|
//! means every daemon restart pays for the whole walk again. This backs it with
|
||||||
|
//! SQLite so it survives one.
|
||||||
|
//!
|
||||||
|
//! **Its own database file, not `cache.db`.** The daemon's `Db` is a single
|
||||||
|
//! `Mutex<Connection>` shared by every FUSE thread and the control socket; SDK
|
||||||
|
//! cache traffic is frequent, small, and entirely reconstructible, so putting it
|
||||||
|
//! on that mutex would add contention to the daemon's hottest lock (and to a
|
||||||
|
//! write-ahead log whose growth is already sensitive to how long that lock is
|
||||||
|
//! held) for no durability benefit. A separate file also means no schema
|
||||||
|
//! migration: this store can be deleted and rebuilt at any time.
|
||||||
|
//!
|
||||||
|
//! **What it holds.** Decrypted metadata, never key material — the SDK keeps
|
||||||
|
//! node keys and hash keys in a separate in-memory secret cache that nothing
|
||||||
|
//! here can reach. The same names and sizes are already persisted in `cache.db`
|
||||||
|
//! (`nodes`), so this file widens no exposure that the state directory did not
|
||||||
|
//! already have; `AppDirs::ensure` is what keeps that directory private.
|
||||||
|
//!
|
||||||
|
//! **Staleness.** A persisted cache outlives the process, so an entry can
|
||||||
|
//! describe a node another client has since changed. The daemon's event loop is
|
||||||
|
//! what closes that: it resumes from a persisted cursor and calls the SDK's
|
||||||
|
//! `invalidate_caches_for_event` for every event it replays, including the ones
|
||||||
|
//! that happened while the daemon was down. The one case that leaves no trail is
|
||||||
|
//! a *seeded* cursor — a first-ever mount, or a cursor the daemon lost — where
|
||||||
|
//! nothing says what changed, so the caller [`clear`](SdkCache::clear)s this
|
||||||
|
//! store instead of trusting it.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use proton_sdk::cache::CacheRepository;
|
||||||
|
use proton_sdk::error::{ProtonError, Result as SdkResult};
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
|
use crate::config::AppDirs;
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Process-wide handle, so the daemon's event loop can clear the same store the
|
||||||
|
/// client is writing through without threading it down every call.
|
||||||
|
static SHARED: OnceLock<Arc<SdkCache>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// SQLite-backed entity cache for the SDK.
|
||||||
|
pub struct SdkCache {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SdkCache {
|
||||||
|
/// Open (creating if needed) the entity cache under the app's state
|
||||||
|
/// directory. The first call wins; later calls return the same handle, which
|
||||||
|
/// is what lets the event loop clear the store the Drive client is using.
|
||||||
|
pub fn shared(dirs: &AppDirs) -> Result<Arc<Self>> {
|
||||||
|
if let Some(existing) = SHARED.get() {
|
||||||
|
return Ok(existing.clone());
|
||||||
|
}
|
||||||
|
let cache = Arc::new(Self::open(&dirs.state_dir().join("sdk_cache.db"))?);
|
||||||
|
Ok(SHARED.get_or_init(|| cache).clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The already-open shared cache, if one was opened in this process.
|
||||||
|
pub fn opened() -> Option<Arc<Self>> {
|
||||||
|
SHARED.get().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the store at `path`, creating its schema.
|
||||||
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
|
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
|
// Tags cascade with their entry, so a `set` that replaces a value cannot
|
||||||
|
// leave the old value's tags behind pointing at the new one.
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS entry (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tag (
|
||||||
|
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (key, tag)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);",
|
||||||
|
)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every entry. Used when the daemon cannot prove the cache is current
|
||||||
|
/// — a seeded event cursor — and on logout.
|
||||||
|
pub fn clear_now(&self) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute_batch("DELETE FROM tag; DELETE FROM entry;")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_now(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO entry (key, value) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![key, value],
|
||||||
|
)?;
|
||||||
|
tx.execute("DELETE FROM tag WHERE key = ?1", params![key])?;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare("INSERT INTO tag (key, tag) VALUES (?1, ?2)")?;
|
||||||
|
for tag in tags {
|
||||||
|
stmt.execute(params![key, tag])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_now(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let mut stmt = conn.prepare("SELECT value FROM entry WHERE key = ?1")?;
|
||||||
|
let mut rows = stmt.query(params![key])?;
|
||||||
|
match rows.next()? {
|
||||||
|
Some(row) => Ok(Some(row.get(0)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_now(&self, key: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute("DELETE FROM entry WHERE key = ?1", params![key])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_by_tag_now(&self, tag: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM entry WHERE key IN (SELECT key FROM tag WHERE tag = ?1)",
|
||||||
|
params![tag],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries carrying **all** of `tags` — the set intersection the SDK's
|
||||||
|
/// `get_by_tags` contract specifies, not a union.
|
||||||
|
fn get_by_tags_now(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
|
||||||
|
if tags.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let placeholders = vec!["?"; tags.len()].join(",");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT e.key, e.value FROM entry e
|
||||||
|
JOIN tag t ON t.key = e.key
|
||||||
|
WHERE t.tag IN ({placeholders})
|
||||||
|
GROUP BY e.key
|
||||||
|
HAVING COUNT(DISTINCT t.tag) = {}",
|
||||||
|
tags.len()
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt.query_map(rusqlite::params_from_iter(tags), |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?))
|
||||||
|
})?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
out.push(row?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cache failure is never fatal to the operation that triggered it — the SDK
|
||||||
|
/// treats this repository as best-effort — but it still has to come back as the
|
||||||
|
/// SDK's error type.
|
||||||
|
fn sdk_error(e: crate::error::Error) -> ProtonError {
|
||||||
|
ProtonError::invalid_operation(format!("sdk entity cache: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every method is a local SQLite statement on its own connection: microseconds,
|
||||||
|
// no network, no shared lock with the daemon's database. Running them inline on
|
||||||
|
// the caller's task is cheaper than the thread hop that moving them off would
|
||||||
|
// cost.
|
||||||
|
#[async_trait]
|
||||||
|
impl CacheRepository for SdkCache {
|
||||||
|
async fn set(&self, key: &str, value: &str, tags: &[String]) -> SdkResult<()> {
|
||||||
|
self.set_now(key, value, tags).map_err(sdk_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, key: &str) -> SdkResult<Option<String>> {
|
||||||
|
self.get_now(key).map_err(sdk_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(&self, key: &str) -> SdkResult<()> {
|
||||||
|
self.remove_now(key).map_err(sdk_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_by_tag(&self, tag: &str) -> SdkResult<()> {
|
||||||
|
self.remove_by_tag_now(tag).map_err(sdk_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear(&self) -> SdkResult<()> {
|
||||||
|
self.clear_now().map_err(sdk_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_tags(&self, tags: &[String]) -> SdkResult<Vec<(String, String)>> {
|
||||||
|
self.get_by_tags_now(tags).map_err(sdk_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn cache() -> SdkCache {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE entry (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
CREATE TABLE tag (
|
||||||
|
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (key, tag)
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
SdkCache {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|t| t.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_replaces_the_value_and_its_tags() {
|
||||||
|
let cache = cache();
|
||||||
|
cache.set_now("k", "one", &tags(&["a", "b"])).unwrap();
|
||||||
|
cache.set_now("k", "two", &tags(&["b"])).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(cache.get_now("k").unwrap().as_deref(), Some("two"));
|
||||||
|
// The tag dropped by the second write must not still select the entry —
|
||||||
|
// that is how a stale invalidation would miss it.
|
||||||
|
assert!(cache.get_by_tags_now(&tags(&["a"])).unwrap().is_empty());
|
||||||
|
assert_eq!(cache.get_by_tags_now(&tags(&["b"])).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_by_tags_intersects_rather_than_unions() {
|
||||||
|
let cache = cache();
|
||||||
|
cache.set_now("both", "1", &tags(&["x", "y"])).unwrap();
|
||||||
|
cache.set_now("one", "2", &tags(&["x"])).unwrap();
|
||||||
|
|
||||||
|
let hits = cache.get_by_tags_now(&tags(&["x", "y"])).unwrap();
|
||||||
|
assert_eq!(hits, vec![("both".to_string(), "1".to_string())]);
|
||||||
|
// An empty tag list selects nothing, matching the SDK contract.
|
||||||
|
assert!(cache.get_by_tags_now(&[]).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_by_tag_takes_the_entry_and_its_tags() {
|
||||||
|
let cache = cache();
|
||||||
|
cache.set_now("k", "v", &tags(&["gone"])).unwrap();
|
||||||
|
cache.remove_by_tag_now("gone").unwrap();
|
||||||
|
|
||||||
|
assert!(cache.get_now("k").unwrap().is_none());
|
||||||
|
assert!(cache.get_by_tags_now(&tags(&["gone"])).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_empties_the_store() {
|
||||||
|
let cache = cache();
|
||||||
|
cache.set_now("a", "1", &tags(&["t"])).unwrap();
|
||||||
|
cache.set_now("b", "2", &[]).unwrap();
|
||||||
|
cache.clear_now().unwrap();
|
||||||
|
|
||||||
|
assert!(cache.get_now("a").unwrap().is_none());
|
||||||
|
assert!(cache.get_now("b").unwrap().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ description = "FUSE on-demand filesystem for Proton Drive"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
pdfs-core.workspace = true
|
pdfs-core.workspace = true
|
||||||
proton-drive-rs.workspace = true
|
proton-drive-rs.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
fuser.workspace = true
|
fuser.workspace = true
|
||||||
|
|||||||
@@ -8,35 +8,20 @@ use super::*;
|
|||||||
///
|
///
|
||||||
/// Returns whether this mount actually held the node, so the caller can evict
|
/// Returns whether this mount actually held the node, so the caller can evict
|
||||||
/// its content blob once if *any* mount did.
|
/// its content blob once if *any* mount did.
|
||||||
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||||
// Capture the inode before `forget` clears the uid mapping.
|
// Capture the inode before `forget` clears the uid mapping.
|
||||||
let child = st.by_uid.get(uid).copied();
|
let child = st.by_uid.get(uid).copied();
|
||||||
let Some((parent, name)) = st.forget(uid) else {
|
let Some((parent, name)) = st.forget(uid) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if let Some(notifier) = notifier {
|
match child {
|
||||||
match child {
|
Some(child) => notify.delete(parent, child, name),
|
||||||
Some(child) => {
|
None => notify.inval_entry(parent, name),
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
|
fn hide_foreign_deleted_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) {
|
||||||
let Some(notifier) = notifier else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for &ino in changed {
|
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) {
|
|
||||||
let child = st.by_uid.get(uid).copied();
|
let child = st.by_uid.get(uid).copied();
|
||||||
let dentry = child.and_then(|ino| {
|
let dentry = child.and_then(|ino| {
|
||||||
st.entries
|
st.entries
|
||||||
@@ -45,9 +30,9 @@ fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>,
|
|||||||
});
|
});
|
||||||
let changed = st.downgrade_shared_subtree(uid);
|
let changed = st.downgrade_shared_subtree(uid);
|
||||||
st.hide_foreign_subtree(uid);
|
st.hide_foreign_subtree(uid);
|
||||||
invalidate_access_changes(notifier, &changed);
|
notify.extend_inodes(&changed);
|
||||||
if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) {
|
if let (Some((parent, name)), Some(child)) = (dentry, child) {
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
notify.delete(parent, child, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,18 +117,18 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
&& foreign_delete
|
&& foreign_delete
|
||||||
{
|
{
|
||||||
apply_foreign_delete(&core.db, node_uid, |uid| {
|
apply_foreign_delete(&core.db, node_uid, |uid| {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
hide_foreign_deleted_and_notify(st, notifier, uid);
|
hide_foreign_deleted_and_notify(st, notify, uid);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
apply_access_downgrade(&core.db, event, |root| {
|
apply_access_downgrade(&core.db, event, |root| {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
let changed = match root {
|
let changed = match root {
|
||||||
Some(uid) => st.downgrade_shared_subtree(uid),
|
Some(uid) => st.downgrade_shared_subtree(uid),
|
||||||
None => st.downgrade_known_shared_access(),
|
None => st.downgrade_known_shared_access(),
|
||||||
};
|
};
|
||||||
invalidate_access_changes(notifier, &changed);
|
notify.extend_inodes(&changed);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -173,19 +158,17 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||||
}
|
}
|
||||||
let mut had_node = false;
|
let mut had_node = false;
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
if *is_trashed {
|
if *is_trashed {
|
||||||
// Trashing makes a node vanish from its parent listing.
|
// Trashing makes a node vanish from its parent listing.
|
||||||
had_node |= forget_and_notify(st, notifier, node_uid);
|
had_node |= forget_and_notify(st, notify, node_uid);
|
||||||
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
||||||
// Known node changed: drop its cached attrs/data (and
|
// Known node changed: drop its cached attrs/data (and
|
||||||
// listing if it is a directory) so the next access
|
// listing if it is a directory) so the next access
|
||||||
// re-fetches. Its content blob may now be stale too.
|
// re-fetches. Its content blob may now be stale too.
|
||||||
had_node = true;
|
had_node = true;
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
if let Some(notifier) = notifier {
|
notify.inval_inode(ino);
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Once, not once per mount: the content cache is per-daemon.
|
// Once, not once per mount: the content cache is per-daemon.
|
||||||
@@ -209,8 +192,8 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||||
core.cache.evict(node_uid);
|
core.cache.evict(node_uid);
|
||||||
if !foreign_delete {
|
if !foreign_delete {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
forget_and_notify(st, notifier, node_uid);
|
forget_and_notify(st, notify, node_uid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,13 +206,11 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
for &ino in &dirs {
|
for &ino in &dirs {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
if let Some(notifier) = notifier {
|
notify.inval_inode(ino);
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -303,18 +284,16 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
|||||||
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
core.for_each_mount(|st, notifier| {
|
fn drop_listing(st: &mut State, notify: &mut NotifyBatch, parent_uid: &NodeUid) {
|
||||||
let drop_listing = |st: &mut State, parent_uid: &NodeUid| {
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||||
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
return;
|
||||||
return;
|
|
||||||
};
|
|
||||||
st.invalidate_listing(parent);
|
|
||||||
if let Some(notifier) = notifier {
|
|
||||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
st.invalidate_listing(parent);
|
||||||
|
notify.inval_inode(parent);
|
||||||
|
}
|
||||||
|
core.for_each_mount(|st, notify| {
|
||||||
for parent_uid in &dirty.foreign {
|
for parent_uid in &dirty.foreign {
|
||||||
drop_listing(st, parent_uid);
|
drop_listing(st, notify, parent_uid);
|
||||||
}
|
}
|
||||||
for (parent_uid, children) in &dirty.ours {
|
for (parent_uid, children) in &dirty.ours {
|
||||||
if dirty.foreign.contains(parent_uid) {
|
if dirty.foreign.contains(parent_uid) {
|
||||||
@@ -328,7 +307,7 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
drop_listing(st, parent_uid);
|
drop_listing(st, notify, parent_uid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -358,6 +337,14 @@ pub(super) async fn run_event_sync(
|
|||||||
// started offline (offline.md Phase 1) — so retry rather than giving up,
|
// started offline (offline.md Phase 1) — so retry rather than giving up,
|
||||||
// which used to disable live sync for the life of the daemon.
|
// which used to disable live sync for the life of the daemon.
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
// Nothing says what changed before this cursor, so a persisted SDK
|
||||||
|
// entity cache from an earlier run cannot be trusted: drop it rather
|
||||||
|
// than serve metadata no event will ever invalidate.
|
||||||
|
if let Some(cache) = pdfs_core::sdkcache::SdkCache::opened()
|
||||||
|
&& let Err(e) = cache.clear_now()
|
||||||
|
{
|
||||||
|
warn!(error = %e, "clearing the persisted SDK entity cache failed");
|
||||||
|
}
|
||||||
let mut delay = ONLINE_PROBE_MIN;
|
let mut delay = ONLINE_PROBE_MIN;
|
||||||
loop {
|
loop {
|
||||||
match client.enumerate_events(&scope, None).await {
|
match client.enumerate_events(&scope, None).await {
|
||||||
|
|||||||
+156
-37
@@ -29,7 +29,23 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
|||||||
|
|
||||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// A client that connects, sends a request and then never reads its reply used
|
||||||
|
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||||
|
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||||
|
/// responses (photo bytes) over a local socket, finite so a wedged peer can't
|
||||||
|
/// hold a permit.
|
||||||
|
const CONTROL_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
const MAX_CONTROL_HANDLERS: usize = 64;
|
const MAX_CONTROL_HANDLERS: usize = 64;
|
||||||
|
/// Ceiling on any client-supplied row `limit`. Every in-tree caller asks for far
|
||||||
|
/// less (200 for activity, 60 for a photo page), but the value goes straight
|
||||||
|
/// into `SQL LIMIT ?` under the daemon's single connection — an unclamped one
|
||||||
|
/// materialises the whole account, and then serialises it into one JSON line.
|
||||||
|
const MAX_ROW_LIMIT: usize = 1000;
|
||||||
|
|
||||||
|
/// Clamp a client-supplied row limit to [`MAX_ROW_LIMIT`].
|
||||||
|
fn clamp_limit(limit: usize) -> usize {
|
||||||
|
limit.min(MAX_ROW_LIMIT)
|
||||||
|
}
|
||||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
struct ControlHandlerPermit;
|
struct ControlHandlerPermit;
|
||||||
@@ -109,6 +125,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
warn!(error = %e, "control: could not set request timeout");
|
warn!(error = %e, "control: could not set request timeout");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let Err(e) = stream.set_write_timeout(Some(CONTROL_WRITE_TIMEOUT)) {
|
||||||
|
warn!(error = %e, "control: could not set response timeout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let mut reader = BufReader::new(match stream.try_clone() {
|
let mut reader = BufReader::new(match stream.try_clone() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -128,7 +148,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(CtlRequest::Status) => {
|
Ok(CtlRequest::Status) => {
|
||||||
let pins = core.cache.list_pins();
|
let pins = core.cache.list_pins();
|
||||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||||
|
let staging = core.cache.staging_usage();
|
||||||
CtlResponse::Status {
|
CtlResponse::Status {
|
||||||
|
parked_uploads: queued.parked.max(0) as u64,
|
||||||
|
failing_ops: queued.failing.max(0) as u64,
|
||||||
|
failing_error: queued.last_error.clone(),
|
||||||
|
staged_bytes: staging.bytes,
|
||||||
|
staged_oldest_secs: staging.oldest_secs,
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
mountpoint: mountpoint.display().to_string(),
|
mountpoint: mountpoint.display().to_string(),
|
||||||
pinned: pins.len(),
|
pinned: pins.len(),
|
||||||
@@ -203,7 +229,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
limit,
|
limit,
|
||||||
kind,
|
kind,
|
||||||
range,
|
range,
|
||||||
}) => match core.photos_timeline(offset, limit, kind, range) {
|
favorites,
|
||||||
|
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
|
||||||
Ok(Some(items)) => CtlResponse::Photos {
|
Ok(Some(items)) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
items,
|
items,
|
||||||
@@ -216,6 +243,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
||||||
|
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: if favorite {
|
||||||
|
"added to favourites".to_string()
|
||||||
|
} else {
|
||||||
|
"removed from favourites".to_string()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("invalid uid: {uid}"))),
|
||||||
|
},
|
||||||
Ok(CtlRequest::PhotoAlbums) => match core.albums() {
|
Ok(CtlRequest::PhotoAlbums) => match core.albums() {
|
||||||
Ok(Some(items)) => CtlResponse::Albums {
|
Ok(Some(items)) => CtlResponse::Albums {
|
||||||
available: true,
|
available: true,
|
||||||
@@ -231,7 +271,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
// An album page is a timeline page as far as the front-end is
|
// An album page is a timeline page as far as the front-end is
|
||||||
// concerned, so it comes back in the same reply — with `counts`
|
// concerned, so it comes back in the same reply — with `counts`
|
||||||
// unset, which describe the whole timeline rather than this album.
|
// unset, which describe the whole timeline rather than this album.
|
||||||
Some(album) => match core.album_photos(&album, offset, limit) {
|
Some(album) => match core.album_photos(&album, offset, clamp_limit(limit)) {
|
||||||
Ok(items) => CtlResponse::Photos {
|
Ok(items) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
items,
|
items,
|
||||||
@@ -318,31 +358,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
|
// A uid addresses the node wherever it lives; the path only works for
|
||||||
Ok(rel) => match core.open_file(&rel) {
|
// the primary mount's tree. Prefer the uid when the client sent one.
|
||||||
|
Ok(CtlRequest::OpenFile { path, uid }) => {
|
||||||
|
let opened = match uid.as_deref() {
|
||||||
|
Some(uid) => core.open_file_uid(uid),
|
||||||
|
None => rel_to_mount(mountpoint, &path).and_then(|rel| core.open_file(&rel)),
|
||||||
|
};
|
||||||
|
match opened {
|
||||||
Ok(p) => CtlResponse::FilePath {
|
Ok(p) => CtlResponse::FilePath {
|
||||||
path: p.display().to_string(),
|
path: p.display().to_string(),
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
}
|
||||||
Err(e) => CtlResponse::error(e),
|
}
|
||||||
},
|
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(limit)) {
|
||||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
|
|
||||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) {
|
Ok(CtlRequest::SearchLocal { query, limit }) => {
|
||||||
Ok(hits) => CtlResponse::LocalResults {
|
match core.search_local(&query, clamp_limit(limit)) {
|
||||||
hits,
|
Ok(hits) => CtlResponse::LocalResults {
|
||||||
indexing: core.indexing.load(Ordering::Relaxed),
|
hits,
|
||||||
},
|
indexing: core.indexing.load(Ordering::Relaxed),
|
||||||
Err(e) => CtlResponse::error(e),
|
},
|
||||||
},
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::SearchV2 {
|
Ok(CtlRequest::SearchV2 {
|
||||||
query,
|
query,
|
||||||
limit,
|
limit,
|
||||||
filters,
|
filters,
|
||||||
}) => match core.search_v2(&query, limit, &filters) {
|
}) => match core.search_v2(&query, clamp_limit(limit), &filters) {
|
||||||
Ok((drive_hits, local_hits)) => CtlResponse::SearchResultsV2 {
|
Ok((drive_hits, local_hits)) => CtlResponse::SearchResultsV2 {
|
||||||
drive_hits,
|
drive_hits,
|
||||||
local_hits,
|
local_hits,
|
||||||
@@ -521,19 +568,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
Ok(CtlRequest::CacheInspect { deep }) => match core.db.stats() {
|
Ok(CtlRequest::CacheInspect { deep }) => match core.db.stats() {
|
||||||
Ok(stats) => {
|
Ok(stats) => {
|
||||||
// The deep check reads every page, so it runs only on request;
|
// The deep check reads every page — tens of seconds on a real
|
||||||
// `integrity_checked` tells the caller which answer it is
|
// database, all of it holding the connection every FUSE handler
|
||||||
// holding, since "no problems found" and "never looked" are
|
// needs — so it runs on a thread and this request returns
|
||||||
// both an empty list.
|
// whatever the last completed check found. `integrity_checked`
|
||||||
let (integrity_problems, integrity_checked) = if deep {
|
// tells the caller which answer it is holding, since "no
|
||||||
match core.db.integrity_check() {
|
// problems found" and "never looked" are both an empty list;
|
||||||
Ok(problems) => (problems, true),
|
// `integrity_running` says a fresh answer is on its way.
|
||||||
Err(e) => (vec![format!("integrity check failed: {e:?}")], true),
|
let (integrity_problems, integrity_checked, integrity_running) = if deep {
|
||||||
}
|
core.start_or_read_integrity_check()
|
||||||
} else {
|
} else {
|
||||||
(Vec::new(), false)
|
(Vec::new(), false, false)
|
||||||
};
|
};
|
||||||
CtlResponse::CacheReport {
|
CtlResponse::CacheReport {
|
||||||
|
integrity_running,
|
||||||
schema_version: stats.schema_version,
|
schema_version: stats.schema_version,
|
||||||
db_bytes: stats.total_bytes(),
|
db_bytes: stats.total_bytes(),
|
||||||
db_reclaimable_bytes: stats.reclaimable_bytes(),
|
db_reclaimable_bytes: stats.reclaimable_bytes(),
|
||||||
@@ -546,17 +594,16 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
Err(e) => CtlResponse::error(CoreError::internal(format!("read db stats: {e:?}"))),
|
Err(e) => CtlResponse::error(CoreError::internal(format!("read db stats: {e:?}"))),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::CacheVacuum) => match core.db.vacuum() {
|
// Ack and vacuum on a thread. `VACUUM` rewrites the whole database
|
||||||
Ok(outcome) => CtlResponse::Ok {
|
// under the connection mutex — minutes on a large one — and the
|
||||||
message: format!(
|
// request's own socket timeout is 10 s, so running it inline both froze
|
||||||
"vacuumed: {:.1} MiB freed ({:.1} MiB → {:.1} MiB), {} WAL frame(s) checkpointed",
|
// the mount and lost its own answer. Progress shows up as a job in
|
||||||
outcome.freed_bytes() as f64 / 1_048_576.0,
|
// `GetQueueStatus`; the outcome goes to the log.
|
||||||
outcome.before_bytes as f64 / 1_048_576.0,
|
Ok(CtlRequest::CacheVacuum) => match core.start_vacuum() {
|
||||||
outcome.after_bytes as f64 / 1_048_576.0,
|
Ok(()) => CtlResponse::Ok {
|
||||||
outcome.wal_frames_checkpointed,
|
message: "compacting the cache database in the background".into(),
|
||||||
),
|
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(CoreError::internal(format!("vacuum: {e:?}"))),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
|
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
|
||||||
items: core.transfers.snapshot(),
|
items: core.transfers.snapshot(),
|
||||||
@@ -863,6 +910,78 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.list_revisions(&rel) {
|
||||||
|
Ok(items) => CtlResponse::Revisions { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ListRevisionsByUid { uid }) => match core.list_revisions_by_uid(&uid) {
|
||||||
|
Ok(items) => CtlResponse::Revisions { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RestoreRevision { path, revision_id }) => {
|
||||||
|
match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.restore_revision(&rel, &revision_id) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("restoring version {revision_id} of {path}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::RestoreRevisionByUid { uid, revision_id }) => {
|
||||||
|
match core.restore_revision_by_uid(&uid, &revision_id) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("restoring version {revision_id}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::DeleteRevision { path, revision_id }) => {
|
||||||
|
match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.delete_revision(&rel, &revision_id) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("deleted version {revision_id} of {path}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::DeleteRevisionByUid { uid, revision_id }) => {
|
||||||
|
match core.delete_revision_by_uid(&uid, &revision_id) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("deleted version {revision_id}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::SaveRevisionAs {
|
||||||
|
path,
|
||||||
|
revision_id,
|
||||||
|
dest,
|
||||||
|
}) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.save_revision_as(&rel, &revision_id, Path::new(&dest)) {
|
||||||
|
Ok(written) => CtlResponse::Ok {
|
||||||
|
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::SaveRevisionAsByUid {
|
||||||
|
uid,
|
||||||
|
revision_id,
|
||||||
|
dest,
|
||||||
|
}) => match core.save_revision_as_by_uid(&uid, &revision_id, Path::new(&dest)) {
|
||||||
|
Ok(written) => CtlResponse::Ok {
|
||||||
|
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
|
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
|
||||||
Ok(entries) => CtlResponse::Entries { entries },
|
Ok(entries) => CtlResponse::Entries { entries },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -932,7 +1051,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
|
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
|
||||||
items: core.list_activity(limit),
|
items: core.list_activity(clamp_limit(limit)),
|
||||||
},
|
},
|
||||||
// The request did not parse: the caller sent something malformed.
|
// The request did not parse: the caller sent something malformed.
|
||||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use pdfs_core::batch;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase,
|
ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase,
|
||||||
SyncProgress,
|
SyncProgress,
|
||||||
@@ -535,7 +536,14 @@ impl Core {
|
|||||||
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
||||||
// Tear it down first — including before trashing the remote tree it
|
// Tear it down first — including before trashing the remote tree it
|
||||||
// serves, which would otherwise leave it answering for deleted nodes.
|
// serves, which would otherwise leave it answering for deleted nodes.
|
||||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
// Taken in its own statement so the registry lock is released before the
|
||||||
|
// teardown: an edition-2024 `if let` chain holds the guard for the whole
|
||||||
|
// body, and `teardown` unmounts and joins the session thread — which can
|
||||||
|
// be inside a multi-second transfer. The shutdown path takes the same
|
||||||
|
// lock, so holding it here risks systemd's stop timeout landing mid-
|
||||||
|
// unmount and stranding the endpoint.
|
||||||
|
let taken = self.mounts.lock().remove(&id);
|
||||||
|
if let Some(mount) = taken
|
||||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||||
{
|
{
|
||||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||||
@@ -557,7 +565,10 @@ impl Core {
|
|||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
if delete_remote
|
if delete_remote
|
||||||
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
||||||
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
|
&& let Err(e) = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.trash_nodes(&[uid]))
|
||||||
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
warn!(id, error = %e, "delete remote device folder failed");
|
warn!(id, error = %e, "delete remote device folder failed");
|
||||||
}
|
}
|
||||||
@@ -580,19 +591,8 @@ impl Core {
|
|||||||
pub(crate) fn fork_state(&self) -> Core {
|
pub(crate) fn fork_state(&self) -> Core {
|
||||||
let mut fork = self.clone();
|
let mut fork = self.clone();
|
||||||
fork.primary = false;
|
fork.primary = false;
|
||||||
let share_access = self.state.lock().share_access.clone();
|
let share_access = self.state().share_access.clone();
|
||||||
fork.state = Arc::new(Mutex::new(State {
|
fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access, 2)));
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 2,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: std::collections::HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
db: self.db.clone(),
|
|
||||||
}));
|
|
||||||
// A fresh inode space needs a fresh notification channel: this fork's
|
// A fresh inode space needs a fresh notification channel: this fork's
|
||||||
// session is the only one that knows these inodes, so it must be the one
|
// session is the only one that knows these inodes, so it must be the one
|
||||||
// notified about them. Filled in by `spawn_ondemand_mount`.
|
// notified about them. Filled in by `spawn_ondemand_mount`.
|
||||||
@@ -862,8 +862,11 @@ impl Core {
|
|||||||
Ok(format!("{} is now on-demand", local.display()))
|
Ok(format!("{} is now on-demand", local.display()))
|
||||||
}
|
}
|
||||||
ApplicableMountMode::Mirror => {
|
ApplicableMountMode::Mirror => {
|
||||||
// ondemand→mirror: tear down the secondary mount first.
|
// ondemand→mirror: tear down the secondary mount first. Taken
|
||||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
// before the teardown so the registry lock is not held across
|
||||||
|
// the unmount and thread join (see `remove_sync_folder`).
|
||||||
|
let taken = self.mounts.lock().remove(&id);
|
||||||
|
if let Some(mount) = taken
|
||||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||||
{
|
{
|
||||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use pdfs_core::batch;
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
||||||
@@ -135,6 +136,10 @@ impl Core {
|
|||||||
let due = self.db.next_due_op(now).unwrap_or_default();
|
let due = self.db.next_due_op(now).unwrap_or_default();
|
||||||
|
|
||||||
let Some(op) = due.filter(|_| self.online.load(Ordering::Relaxed)) else {
|
let Some(op) = due.filter(|_| self.online.load(Ordering::Relaxed)) else {
|
||||||
|
// Nothing to upload is exactly when the connection is free, so
|
||||||
|
// this is where the read path's buffered LRU touches get written
|
||||||
|
// (`ContentCache::flush_touches`). Cheap and a no-op when empty.
|
||||||
|
self.cache.flush_touches();
|
||||||
self.recover_fsynced_writes();
|
self.recover_fsynced_writes();
|
||||||
// A debounced or backed-off op may be waiting: sleep only
|
// A debounced or backed-off op may be waiting: sleep only
|
||||||
// until it becomes due rather than the full idle-poll.
|
// until it becomes due rather than the full idle-poll.
|
||||||
@@ -340,6 +345,7 @@ impl Core {
|
|||||||
match self
|
match self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
|
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||||
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) if is_gone(&e) => {
|
Err(e) if is_gone(&e) => {
|
||||||
@@ -349,6 +355,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
self.db.complete_trash_op(op.id, &uid)?;
|
self.db.complete_trash_op(op.id, &uid)?;
|
||||||
self.own_sealed_revs.lock().remove(&uid);
|
self.own_sealed_revs.lock().remove(&uid);
|
||||||
|
if let Err(e) = self.db.clear_own_sealed_rev(&uid.to_string()) {
|
||||||
|
debug!(%uid, error = %e, "clearing the sealed-revision record failed");
|
||||||
|
}
|
||||||
self.invalidate_trash();
|
self.invalidate_trash();
|
||||||
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
||||||
info!(%uid, name, "pending trash landed");
|
info!(%uid, name, "pending trash landed");
|
||||||
@@ -623,7 +632,16 @@ impl Core {
|
|||||||
// instead of forking — so the finished bytes win the name rather than
|
// instead of forking — so the finished bytes win the name rather than
|
||||||
// being exiled to a `(sync-conflict)` copy.
|
// being exiled to a `(sync-conflict)` copy.
|
||||||
let remote_rev = node_revision_id(&node);
|
let remote_rev = node_revision_id(&node);
|
||||||
let own_rev = self.own_sealed_revs.lock().get(uid).cloned();
|
// In memory first, then the table it is written through to: the map is
|
||||||
|
// this process's cache of it, and after a restart only the table has
|
||||||
|
// the answer (which is the whole reason it is persisted).
|
||||||
|
let own_rev = match self.own_sealed_revs.lock().get(uid).cloned() {
|
||||||
|
Some(rev) => Some(rev),
|
||||||
|
None => self
|
||||||
|
.db
|
||||||
|
.own_sealed_rev(&uid.to_string(), now_millis())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
if is_own_self_supersede(meta.complete, remote_rev.as_deref(), own_rev.as_deref()) {
|
if is_own_self_supersede(meta.complete, remote_rev.as_deref(), own_rev.as_deref()) {
|
||||||
debug!(%uid, ?remote_rev,
|
debug!(%uid, ?remote_rev,
|
||||||
"queued write chains onto our own sealed revision; not a conflict");
|
"queued write chains onto our own sealed revision; not a conflict");
|
||||||
@@ -707,8 +725,9 @@ impl Core {
|
|||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
// Dropping the pending entry hands the node back to the remote's truth:
|
// Dropping the pending entry hands the node back to the remote's truth:
|
||||||
// reads stop coming from the staged blob, and the event sync stops
|
// reads stop coming from the staged blob, and the event sync stops
|
||||||
// skipping it as "ahead of the server" (offline.md Phase 3a).
|
// skipping it as "ahead of the server" (offline.md Phase 3a). Only if it
|
||||||
self.pending.lock().remove(uid);
|
// is still *this* write's entry — see [`Core::release_pending`].
|
||||||
|
self.release_pending(uid, blob);
|
||||||
self.cache.discard_staged(blob);
|
self.cache.discard_staged(blob);
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
@@ -746,7 +765,7 @@ impl Core {
|
|||||||
error!(%uid, name, reason, staged = %blob.display(),
|
error!(%uid, name, reason, staged = %blob.display(),
|
||||||
"cannot place a conflicted write; bytes kept in staging");
|
"cannot place a conflicted write; bytes kept in staging");
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
self.pending.lock().remove(uid);
|
self.release_pending(uid, blob);
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
@@ -758,6 +777,24 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop `uid`'s pending entry, but only while it still names `blob`.
|
||||||
|
///
|
||||||
|
/// A supersede can replace the entry while the op that owned it is in
|
||||||
|
/// flight, so an unconditional `remove` retires a *newer* write's staged
|
||||||
|
/// blob: reads fall back to a stale remote, the next `remote_baseline` forks
|
||||||
|
/// a spurious `(sync-conflict)` copy of bytes that were never in conflict,
|
||||||
|
/// and the incomplete-write interlock — which keys off the pending entry —
|
||||||
|
/// is disarmed, letting a gap-fill read from a revision that no longer
|
||||||
|
/// describes the file.
|
||||||
|
pub(crate) fn release_pending(&self, uid: &NodeUid, blob: &Path) {
|
||||||
|
let mut pending = self.pending.lock();
|
||||||
|
if let Some(current) = pending.get(uid)
|
||||||
|
&& current.path == blob
|
||||||
|
{
|
||||||
|
pending.remove(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a folder a queued op targets has stopped being a place a node can
|
/// Whether a folder a queued op targets has stopped being a place a node can
|
||||||
/// go. A failure to ask is not an answer: only a definite "trashed" or "not
|
/// go. A failure to ask is not an answer: only a definite "trashed" or "not
|
||||||
/// there" counts, so a network fault leaves the op to retry normally.
|
/// there" counts, so a network fault leaves the op to retry normally.
|
||||||
@@ -824,7 +861,7 @@ impl Core {
|
|||||||
/// root yet, which is not where the drain runs; the drain must not panic
|
/// root yet, which is not where the drain runs; the drain must not panic
|
||||||
/// over it regardless, since that would stop the queue for good.
|
/// over it regardless, since that would stop the queue for good.
|
||||||
pub(crate) fn root_uid(&self) -> Option<NodeUid> {
|
pub(crate) fn root_uid(&self) -> Option<NodeUid> {
|
||||||
let st = self.state.lock();
|
let st = self.state();
|
||||||
st.entries.get(&ROOT_INO).map(|e| e.uid.clone())
|
st.entries.get(&ROOT_INO).map(|e| e.uid.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,14 +921,7 @@ impl Core {
|
|||||||
// an orphaned file (harmless), whereas the reverse would leave a queued
|
// an orphaned file (harmless), whereas the reverse would leave a queued
|
||||||
// op pointing at nothing.
|
// op pointing at nothing.
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
{
|
self.release_pending(&uid, &blob);
|
||||||
let mut p = self.pending.lock();
|
|
||||||
if let Some(op_in_mem) = p.get(&uid)
|
|
||||||
&& op_in_mem.path == blob
|
|
||||||
{
|
|
||||||
p.remove(&uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The staged blob now matches the sealed revision, so a pinned file keeps
|
// The staged blob now matches the sealed revision, so a pinned file keeps
|
||||||
// it as its cached content rather than re-downloading what we just sent.
|
// it as its cached content rather than re-downloading what we just sent.
|
||||||
@@ -967,7 +997,16 @@ impl Core {
|
|||||||
// change and does not fork (B70 layer B; consulted in
|
// change and does not fork (B70 layer B; consulted in
|
||||||
// `revision_conflict`).
|
// `revision_conflict`).
|
||||||
if let Some(rev) = sealed_rev.clone() {
|
if let Some(rev) = sealed_rev.clone() {
|
||||||
self.own_sealed_revs.lock().insert(uid.clone(), rev);
|
self.own_sealed_revs.lock().insert(uid.clone(), rev.clone());
|
||||||
|
// Write through: losing this at shutdown reopened the fork
|
||||||
|
// window B70 layer B closed, for the first drain after a
|
||||||
|
// restart.
|
||||||
|
if let Err(e) = self
|
||||||
|
.db
|
||||||
|
.set_own_sealed_rev(&uid.to_string(), &rev, now_millis())
|
||||||
|
{
|
||||||
|
warn!(%uid, error = %e, "persisting our sealed revision failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
for aw in st.active_writes.values_mut() {
|
for aw in st.active_writes.values_mut() {
|
||||||
|
|||||||
+361
-227
@@ -3,6 +3,73 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl Filesystem for ProtonFs {
|
impl Filesystem for ProtonFs {
|
||||||
|
/// Negotiate kernel-side behaviour once, before any other callback.
|
||||||
|
///
|
||||||
|
/// Everything here is a hint the kernel is free to decline, so each is
|
||||||
|
/// requested and its refusal logged rather than treated as fatal — an older
|
||||||
|
/// kernel that lacks a capability must still mount.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* requested: `FUSE_ATOMIC_O_TRUNC` and
|
||||||
|
/// `FUSE_DO_READDIRPLUS` change what the kernel sends (truncation folded
|
||||||
|
/// into `open`, `readdirplus` instead of `readdir` + N lookups), so they
|
||||||
|
/// need the matching handlers first; and `FOPEN_KEEP_CACHE` needs the write
|
||||||
|
/// path to invalidate page cache on every locally staged revision, not just
|
||||||
|
/// on remote events.
|
||||||
|
fn init(&mut self, _req: &Request, config: &mut KernelConfig) -> std::io::Result<()> {
|
||||||
|
// Readahead the kernel issues per file. Matching the block size means a
|
||||||
|
// sequential reader's readahead lands inside the block a demand read is
|
||||||
|
// already fetching, instead of one 128 KiB slice at a time.
|
||||||
|
if let Err(nearest) = config.set_max_readahead(BLOCK_SIZE as u32) {
|
||||||
|
debug!(nearest, "kernel capped max_readahead");
|
||||||
|
let _ = config.set_max_readahead(nearest);
|
||||||
|
}
|
||||||
|
if let Err(nearest) = config.set_max_background(MAX_BACKGROUND) {
|
||||||
|
debug!(nearest, "kernel capped max_background");
|
||||||
|
}
|
||||||
|
// Without this the kernel serialises lookups within one directory, so a
|
||||||
|
// cold `ls` of a folder is a chain of round trips rather than a fan-out.
|
||||||
|
if let Err(unsupported) = config.add_capabilities(InitFlags::FUSE_PARALLEL_DIROPS) {
|
||||||
|
debug!(?unsupported, "kernel does not support parallel dirops");
|
||||||
|
}
|
||||||
|
if let Err(unsupported) = config.add_capabilities(InitFlags::FUSE_CACHE_SYMLINKS) {
|
||||||
|
debug!(?unsupported, "kernel does not support symlink caching");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free and total space, as `df` and every "will this fit?" preflight ask.
|
||||||
|
///
|
||||||
|
/// Reported in [`REPORTED_BLKSIZE`] units against the *account* quota, not
|
||||||
|
/// the local cache: what a copy into the mount consumes is Drive storage.
|
||||||
|
/// The default implementation answers all-zeroes, which reads as a full
|
||||||
|
/// filesystem and makes installers and file managers refuse to write.
|
||||||
|
///
|
||||||
|
/// On a worker, because a cold answer is a round trip
|
||||||
|
/// ([`Core::account_quota_cached`] serves a warm one from memory).
|
||||||
|
fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) {
|
||||||
|
let core = self.core.clone();
|
||||||
|
self.core.workers.run(Lane::Meta, move || {
|
||||||
|
let (max_space, used_space) = core.account_quota_cached().unwrap_or((0, 0));
|
||||||
|
let bsize = u64::from(REPORTED_BLKSIZE);
|
||||||
|
let blocks = (max_space.max(0) as u64).div_ceil(bsize);
|
||||||
|
let used = (used_space.max(0) as u64).div_ceil(bsize);
|
||||||
|
let free = blocks.saturating_sub(used);
|
||||||
|
// `files`/`ffree` are unknowable — Drive has no inode budget — so
|
||||||
|
// they are reported as "plenty" rather than as zero, which some
|
||||||
|
// tools read as "no more files may be created".
|
||||||
|
reply.statfs(
|
||||||
|
blocks,
|
||||||
|
free,
|
||||||
|
free,
|
||||||
|
0,
|
||||||
|
u64::MAX,
|
||||||
|
REPORTED_BLKSIZE,
|
||||||
|
MAX_NAME_LEN,
|
||||||
|
REPORTED_BLKSIZE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
|
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
|
||||||
let parent = parent.0;
|
let parent = parent.0;
|
||||||
let name = match fuse_name(name) {
|
let name = match fuse_name(name) {
|
||||||
@@ -27,13 +94,13 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn forget(&self, _req: &Request, ino: INodeNo, nlookup: u64) {
|
fn forget(&self, _req: &Request, ino: INodeNo, nlookup: u64) {
|
||||||
let mut st = self.core.state.lock();
|
let mut st = self.core.state();
|
||||||
st.forget_lookup(ino.0, nlookup);
|
st.forget_lookup(ino.0, nlookup);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) {
|
fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) {
|
||||||
let (attr, provisional_in) = {
|
let (attr, provisional_in) = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
match st.entries.get(&ino.0) {
|
match st.entries.get(&ino.0) {
|
||||||
Some(e) => {
|
Some(e) => {
|
||||||
// A file still carrying the cheap enumeration's placeholder
|
// A file still carrying the cheap enumeration's placeholder
|
||||||
@@ -69,23 +136,30 @@ impl Filesystem for ProtonFs {
|
|||||||
// single upgrade. This is why B12's split is still worth having — the
|
// single upgrade. This is why B12's split is still worth having — the
|
||||||
// plain listing never comes here at all.
|
// plain listing never comes here at all.
|
||||||
//
|
//
|
||||||
// Off the dispatch loop, because it goes to the network: blocking there
|
// Starting the batch is local work — a `state` read and a thread spawn —
|
||||||
// would stall every other operation on the mount (the B5 lesson).
|
// so it happens here; what goes to the network is the batch itself, and
|
||||||
|
// this reply is parked on it rather than waiting on any thread of the
|
||||||
|
// mount's (audit F4).
|
||||||
|
let ino = ino.0;
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
let respond = move || {
|
||||||
fs.core.upgrade_sizes_for_parent(ino.0, &uid, parent);
|
|
||||||
// Re-read: the upgrade writes through `state`, and on timeout or
|
// Re-read: the upgrade writes through `state`, and on timeout or
|
||||||
// failure this is simply the provisional attr we already had.
|
// failure this is simply the provisional attr we already had.
|
||||||
let attr = {
|
let attr = {
|
||||||
let st = fs.core.state.lock();
|
let st = fs.core.state();
|
||||||
match st.entries.get(&ino.0) {
|
match st.entries.get(&ino) {
|
||||||
Some(e) => fs.attr(ino.0, &e.node, e.access),
|
Some(e) => fs.attr(ino, &e.node, e.access),
|
||||||
// Forgotten while we waited.
|
// Forgotten while we waited.
|
||||||
None => attr,
|
None => attr,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reply.attr(&TTL, &attr);
|
reply.attr(&TTL, &attr);
|
||||||
});
|
};
|
||||||
|
match self.core.upgrade_sizes_for_parent(ino, &uid, parent) {
|
||||||
|
Some(slot) => self.core.await_size(ino, slot, respond),
|
||||||
|
// Nothing to upgrade, so nothing to wait for.
|
||||||
|
None => respond(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn readdir(
|
fn readdir(
|
||||||
@@ -109,109 +183,19 @@ impl Filesystem for ProtonFs {
|
|||||||
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
|
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A read-only open is a map lookup and answers inline; a write open has to
|
||||||
|
/// allocate a scratch file and, when a queued revision is the base, duplicate
|
||||||
|
/// a blob that is the whole file — so it is served from a worker rather than
|
||||||
|
/// stalling every other request on the mount behind it.
|
||||||
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||||
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
|
||||||
let (uid, base_mtime, base_size, base_revision_id) = {
|
|
||||||
let mut st = self.core.state.lock();
|
|
||||||
match st.entries.get_mut(&ino.0) {
|
|
||||||
Some(e) if e.node.is_file() => {
|
|
||||||
if write_requested && !e.writable() {
|
|
||||||
reply.error(Errno::EACCES);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.open_count = e.open_count.saturating_add(1);
|
|
||||||
(
|
|
||||||
e.uid.clone(),
|
|
||||||
e.node.modification_time,
|
|
||||||
node_size(&e.node),
|
|
||||||
node_revision_id(&e.node),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some(_) => {
|
|
||||||
reply.error(Errno::EISDIR);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
reply.error(Errno::ENOENT);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
debug!(ino = ino.0, ?flags, base_size, base_mtime, "open");
|
|
||||||
// Read-only opens stay stateless (fh 0). A write open allocates a
|
|
||||||
// disk-backed handle; bytes are authored into a scratch file and the
|
|
||||||
// untouched remainder is pulled from the base lazily (on read / commit).
|
|
||||||
if flags.acc_mode() == OpenAccMode::O_RDONLY {
|
if flags.acc_mode() == OpenAccMode::O_RDONLY {
|
||||||
reply.opened(FileHandle(0), FopenFlags::empty());
|
self.serve_open(ino, flags, reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A previous close may already have newer bytes queued locally while
|
let fs = self.clone();
|
||||||
// the server still holds the older revision. That staged blob, not the
|
self.core
|
||||||
// remote, is the base for this handle. In particular, `truncate file`
|
.workers
|
||||||
// opens for writing and then shrinks via setattr; starting its scratch
|
.run(Lane::Transfer, move || fs.serve_open(ino, flags, reply));
|
||||||
// as a sparse zero file would turn the preserved prefix into zeros.
|
|
||||||
//
|
|
||||||
// Complete pending blobs are the common case and can be copied without
|
|
||||||
// any network access. An incomplete blob still has gaps referring to
|
|
||||||
// the remote base; stacking another write on it cannot be represented
|
|
||||||
// safely by WriteHandle, so fail the open rather than risk corruption.
|
|
||||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
|
||||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
|
||||||
if let Some(entry) = self.core.state.lock().entries.get_mut(&ino.0) {
|
|
||||||
entry.open_count = entry.open_count.saturating_sub(1);
|
|
||||||
}
|
|
||||||
error!(%uid, "refusing write over incomplete queued revision");
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (file, path) = match self.core.cache.create_scratch() {
|
|
||||||
Ok(x) => x,
|
|
||||||
Err(e) => {
|
|
||||||
if let Some(entry) = self.core.state.lock().entries.get_mut(&ino.0) {
|
|
||||||
entry.open_count = entry.open_count.saturating_sub(1);
|
|
||||||
}
|
|
||||||
error!(%uid, error = %e, "create scratch file failed");
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut initial_written = Intervals::default();
|
|
||||||
if let Some(pending) = &pending_base {
|
|
||||||
if let Err(e) = std::fs::copy(&pending.path, &path) {
|
|
||||||
if let Some(entry) = self.core.state.lock().entries.get_mut(&ino.0) {
|
|
||||||
entry.open_count = entry.open_count.saturating_sub(1);
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
error!(%uid, source = %pending.path.display(), error = %e,
|
|
||||||
"copy queued revision into write scratch failed");
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
initial_written.add(0, pending.meta.len);
|
|
||||||
}
|
|
||||||
let mut st = self.core.state.lock();
|
|
||||||
let fh = st.next_fh;
|
|
||||||
st.next_fh += 1;
|
|
||||||
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
|
||||||
WriteHandle {
|
|
||||||
ino: ino.0,
|
|
||||||
uid,
|
|
||||||
file: Arc::new(file),
|
|
||||||
path,
|
|
||||||
written: initial_written,
|
|
||||||
// Starts at the current size; reads in [0, base_size) come from
|
|
||||||
// the base until overwritten.
|
|
||||||
len: base_size,
|
|
||||||
base_size,
|
|
||||||
base_mtime,
|
|
||||||
base_revision_id,
|
|
||||||
dirty: false,
|
|
||||||
open_count: 0,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
aw.open_count += 1;
|
|
||||||
st.handles.insert(fh, ino.0);
|
|
||||||
reply.opened(FileHandle(fh), FopenFlags::empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(
|
fn read(
|
||||||
@@ -234,7 +218,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// has always had between a write and a read of the same handle; a mostly
|
// has always had between a write and a read of the same handle; a mostly
|
||||||
// local read is not worth reasoning about that for.
|
// local read is not worth reasoning about that for.
|
||||||
let handle = {
|
let handle = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
st.active_writes.get(&ino.0).map(|h| {
|
st.active_writes.get(&ino.0).map(|h| {
|
||||||
(
|
(
|
||||||
h.file.clone(),
|
h.file.clone(),
|
||||||
@@ -263,7 +247,7 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (uid, mtime, fsize, is_video) = {
|
let (uid, mtime, fsize, is_video) = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
match st.entries.get(&ino.0) {
|
match st.entries.get(&ino.0) {
|
||||||
Some(e) if e.node.is_file() => {
|
Some(e) if e.node.is_file() => {
|
||||||
let media_type = match &e.node.kind {
|
let media_type = match &e.node.kind {
|
||||||
@@ -363,7 +347,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// Stage the bytes straight into the scratch file (no base download): only
|
// Stage the bytes straight into the scratch file (no base download): only
|
||||||
// the untouched remainder is pulled from the remote, and only at commit.
|
// the untouched remainder is pulled from the remote, and only at commit.
|
||||||
let file = {
|
let file = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
||||||
Some(aw) => aw.file.clone(),
|
Some(aw) => aw.file.clone(),
|
||||||
None => {
|
None => {
|
||||||
@@ -374,11 +358,20 @@ impl Filesystem for ProtonFs {
|
|||||||
};
|
};
|
||||||
if let Err(e) = file.write_all_at(data, offset) {
|
if let Err(e) = file.write_all_at(data, offset) {
|
||||||
error!(ino = ino.0, fh, error = %e, "scratch write failed");
|
error!(ino = ino.0, fh, error = %e, "scratch write failed");
|
||||||
reply.error(Errno::EIO);
|
// A full local disk is not a broken file: answer `ENOSPC` so the
|
||||||
|
// application can say so (and so `cp`/editors take their own
|
||||||
|
// out-of-space path), and free what the cache is holding — the
|
||||||
|
// scratch file the next write goes to shares that filesystem.
|
||||||
|
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||||
|
self.core.cache.emergency_evict();
|
||||||
|
reply.error(Errno::ENOSPC);
|
||||||
|
} else {
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let new_len = {
|
let new_len = {
|
||||||
let mut st = self.core.state.lock();
|
let mut st = self.core.state();
|
||||||
let Some(aw) = st
|
let Some(aw) = st
|
||||||
.handles
|
.handles
|
||||||
.get(&fh)
|
.get(&fh)
|
||||||
@@ -435,7 +428,7 @@ impl Filesystem for ProtonFs {
|
|||||||
if let Some(size) = size {
|
if let Some(size) = size {
|
||||||
let fh = fh.map(|f| f.0).filter(|&f| f != 0);
|
let fh = fh.map(|f| f.0).filter(|&f| f != 0);
|
||||||
let handled = {
|
let handled = {
|
||||||
let mut st = self.core.state.lock();
|
let mut st = self.core.state();
|
||||||
match st.active_writes.get_mut(&ino.0) {
|
match st.active_writes.get_mut(&ino.0) {
|
||||||
Some(aw) => {
|
Some(aw) => {
|
||||||
if size < aw.len {
|
if size < aw.len {
|
||||||
@@ -478,7 +471,7 @@ impl Filesystem for ProtonFs {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.core.state.lock().set_size(ino.0, size);
|
self.core.state().set_size(ino.0, size);
|
||||||
}
|
}
|
||||||
self.reply_attr(ino.0, reply);
|
self.reply_attr(ino.0, reply);
|
||||||
}
|
}
|
||||||
@@ -503,7 +496,7 @@ impl Filesystem for ProtonFs {
|
|||||||
let is_punch_hole = (mode & 0x02) != 0; // FALLOC_FL_PUNCH_HOLE
|
let is_punch_hole = (mode & 0x02) != 0; // FALLOC_FL_PUNCH_HOLE
|
||||||
|
|
||||||
let res = {
|
let res = {
|
||||||
let mut st = self.core.state.lock();
|
let mut st = self.core.state();
|
||||||
match st
|
match st
|
||||||
.handles
|
.handles
|
||||||
.get(&_fh.0)
|
.get(&_fh.0)
|
||||||
@@ -566,6 +559,12 @@ impl Filesystem for ProtonFs {
|
|||||||
/// for fsync wants its bytes to survive a crash, and blocking it on an upload
|
/// for fsync wants its bytes to survive a crash, and blocking it on an upload
|
||||||
/// (which offline would never finish) buys nothing the queue does not already
|
/// (which offline would never finish) buys nothing the queue does not already
|
||||||
/// guarantee.
|
/// guarantee.
|
||||||
|
///
|
||||||
|
/// Served on a worker: `sync_all` is a disk barrier and the sidecar write is
|
||||||
|
/// two more file operations, and an application that fsyncs per record would
|
||||||
|
/// otherwise stall every request on the mount at its barrier rate. Nothing
|
||||||
|
/// orders this against other callbacks — the kernel waits for the reply
|
||||||
|
/// either way, which is the whole contract.
|
||||||
fn fsync(
|
fn fsync(
|
||||||
&self,
|
&self,
|
||||||
_req: &Request,
|
_req: &Request,
|
||||||
@@ -574,76 +573,10 @@ impl Filesystem for ProtonFs {
|
|||||||
_datasync: bool,
|
_datasync: bool,
|
||||||
reply: ReplyEmpty,
|
reply: ReplyEmpty,
|
||||||
) {
|
) {
|
||||||
// Everything the durability sidecar needs, copied out so no lock is held
|
let fs = self.clone();
|
||||||
// across the sync.
|
self.core
|
||||||
let handle = {
|
.workers
|
||||||
let st = self.core.state.lock();
|
.run(Lane::Transfer, move || fs.serve_fsync(fh, reply));
|
||||||
let ino = st.handles.get(&fh.0).copied();
|
|
||||||
ino.and_then(|i| st.active_writes.get(&i)).map(|aw| {
|
|
||||||
(
|
|
||||||
aw.file.clone(),
|
|
||||||
aw.path.clone(),
|
|
||||||
aw.uid.clone(),
|
|
||||||
aw.dirty,
|
|
||||||
aw.written.clone(),
|
|
||||||
aw.len,
|
|
||||||
aw.base_size,
|
|
||||||
aw.base_mtime,
|
|
||||||
aw.base_revision_id.clone(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
|
|
||||||
handle
|
|
||||||
else {
|
|
||||||
reply.error(Errno::EBADF);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Err(e) = f.sync_all() {
|
|
||||||
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A clean handle has nothing on it that the remote does not already
|
|
||||||
// hold, so there is nothing for a crash to lose.
|
|
||||||
if !dirty {
|
|
||||||
reply.ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The bytes are on stable storage, but only the scratch file knows that,
|
|
||||||
// and the scratch directory is cleared at open. Record what the blob is
|
|
||||||
// so a restart can hand it to the upload queue instead of deleting it.
|
|
||||||
//
|
|
||||||
// Deliberately *not* the full release path: staging the blob here would
|
|
||||||
// move the file out from under a handle the application still has open,
|
|
||||||
// and queueing an upload per `fsync` would push a revision for every
|
|
||||||
// barrier in a write loop. This only makes the existing file findable.
|
|
||||||
let authored: Vec<(u64, u64)> = written
|
|
||||||
.segments(0, len)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|&(_, _, authored)| authored)
|
|
||||||
.map(|(s, e, _)| (s, e))
|
|
||||||
.collect();
|
|
||||||
let meta = StagedWrite {
|
|
||||||
uid: uid.to_string(),
|
|
||||||
len,
|
|
||||||
base_size,
|
|
||||||
base_mtime,
|
|
||||||
complete: authored == [(0, len)],
|
|
||||||
authored,
|
|
||||||
based_on: self
|
|
||||||
.core
|
|
||||||
.remote_baseline(&uid, base_mtime, base_size, base_revision_id),
|
|
||||||
};
|
|
||||||
// A failed sidecar means the write is not durable, and `fsync` promising
|
|
||||||
// otherwise is the defect this exists to fix — so it is an error, not a
|
|
||||||
// warning.
|
|
||||||
if let Err(e) = self.core.cache.mark_scratch_durable(&path, &meta) {
|
|
||||||
error!(%uid, error = %e, "recording fsync durability failed");
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reply.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn release(
|
fn release(
|
||||||
@@ -657,7 +590,7 @@ impl Filesystem for ProtonFs {
|
|||||||
reply: ReplyEmpty,
|
reply: ReplyEmpty,
|
||||||
) {
|
) {
|
||||||
let (handle, unlinked_uid) = {
|
let (handle, unlinked_uid) = {
|
||||||
let mut st = self.core.state.lock();
|
let mut st = self.core.state();
|
||||||
let unlinked_uid = release_unlinked_entry(&mut st, _ino.0);
|
let unlinked_uid = release_unlinked_entry(&mut st, _ino.0);
|
||||||
let ino = st.handles.remove(&fh.0);
|
let ino = st.handles.remove(&fh.0);
|
||||||
let h = ino.and_then(|i| {
|
let h = ino.and_then(|i| {
|
||||||
@@ -680,10 +613,11 @@ impl Filesystem for ProtonFs {
|
|||||||
// acceptance suite's concurrency case failed with "concurrent file 1
|
// acceptance suite's concurrency case failed with "concurrent file 1
|
||||||
// mismatch" on all three runs the moment this moved.
|
// mismatch" on all three runs the moment this moved.
|
||||||
//
|
//
|
||||||
// The cost is real — `queue_revision` gap-fills a partial write from the
|
// That inherited ordering is now affordable because the network came out
|
||||||
// remote — so moving it off still wants doing, but it needs the ordering
|
// of this path: `queue_revision` no longer downloads the ranges an edit
|
||||||
// made explicit first (a per-node "staging in flight" barrier that reads
|
// did not touch, it stages an incomplete blob and lets the drain thread
|
||||||
// wait on) rather than inherited from the dispatch loop.
|
// finish it (audit F1). What is left is a rename, two fsyncs, a database
|
||||||
|
// insert, and at most a bounded copy out of the local cache.
|
||||||
self.finish_release(handle, unlinked_uid, reply);
|
self.finish_release(handle, unlinked_uid, reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,7 +736,7 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) {
|
fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) {
|
||||||
let state = self.core.state.lock();
|
let state = self.core.state();
|
||||||
let Some(entry) = state.entries.get(&ino.0) else {
|
let Some(entry) = state.entries.get(&ino.0) else {
|
||||||
reply.error(Errno::ENOENT);
|
reply.error(Errno::ENOENT);
|
||||||
return;
|
return;
|
||||||
@@ -987,7 +921,7 @@ impl ProtonFs {
|
|||||||
/// Build the filesystem rooted at `root` (the user's My Files folder).
|
/// Build the filesystem rooted at `root` (the user's My Files folder).
|
||||||
pub(super) fn new(core: Core, root: Node) -> Self {
|
pub(super) fn new(core: Core, root: Node) -> Self {
|
||||||
{
|
{
|
||||||
let mut st = core.state.lock();
|
let mut st = core.state();
|
||||||
if let Err(e) = st.db.upsert_node(&root) {
|
if let Err(e) = st.db.upsert_node(&root) {
|
||||||
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
||||||
}
|
}
|
||||||
@@ -1029,7 +963,7 @@ impl ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let hit = {
|
let hit = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
st.children.get(&parent).and_then(|kids| {
|
st.children.get(&parent).and_then(|kids| {
|
||||||
kids.iter().copied().find_map(|ino| {
|
kids.iter().copied().find_map(|ino| {
|
||||||
st.entries
|
st.entries
|
||||||
@@ -1074,16 +1008,22 @@ impl ProtonFs {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.core.upgrade_sizes_for_parent(ino, &uid, grandparent);
|
let fs = self.clone();
|
||||||
// Re-read: on timeout or failure this is the provisional attr again,
|
let respond = move || {
|
||||||
// which is no worse than what this path used to always send.
|
// Re-read: on timeout or failure this is the provisional attr again,
|
||||||
let attr = {
|
// which is no worse than what this path used to always send.
|
||||||
let st = self.core.state.lock();
|
let attr = {
|
||||||
st.entries
|
let st = fs.core.state();
|
||||||
.get(&ino)
|
st.entries
|
||||||
.map_or(attr, |e| self.attr(ino, &e.node, e.access))
|
.get(&ino)
|
||||||
|
.map_or(attr, |e| fs.attr(ino, &e.node, e.access))
|
||||||
|
};
|
||||||
|
reply.entry(&TTL, &attr, Generation(0));
|
||||||
};
|
};
|
||||||
reply.entry(&TTL, &attr, Generation(0));
|
match self.core.upgrade_sizes_for_parent(ino, &uid, grandparent) {
|
||||||
|
Some(slot) => self.core.await_size(ino, slot, respond),
|
||||||
|
None => respond(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The body of [`Filesystem::readdir`], on whichever thread ends up serving it.
|
/// The body of [`Filesystem::readdir`], on whichever thread ends up serving it.
|
||||||
@@ -1092,7 +1032,7 @@ impl ProtonFs {
|
|||||||
reply.error(e);
|
reply.error(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
||||||
|
|
||||||
// "." and ".." occupy offsets 0 and 1; real children follow.
|
// "." and ".." occupy offsets 0 and 1; real children follow.
|
||||||
@@ -1146,7 +1086,7 @@ impl ProtonFs {
|
|||||||
uid: self.uid,
|
uid: self.uid,
|
||||||
gid: self.gid,
|
gid: self.gid,
|
||||||
rdev: 0,
|
rdev: 0,
|
||||||
blksize: 512,
|
blksize: REPORTED_BLKSIZE,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1154,6 +1094,202 @@ impl ProtonFs {
|
|||||||
/// Trash the child `name` under `parent` on the remote, then drop it from the
|
/// Trash the child `name` under `parent` on the remote, then drop it from the
|
||||||
/// local cache. Backs both `unlink` and `rmdir` (Proton trashes whole
|
/// local cache. Backs both `unlink` and `rmdir` (Proton trashes whole
|
||||||
/// subtrees, so an `rmdir` of a non-empty dir behaves the same).
|
/// subtrees, so an `rmdir` of a non-empty dir behaves the same).
|
||||||
|
/// The body of [`Filesystem::open`], run off the dispatch loop for a write
|
||||||
|
/// open.
|
||||||
|
fn serve_open(&self, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||||
|
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
||||||
|
let (uid, base_mtime, base_size, base_revision_id) = {
|
||||||
|
let mut st = self.core.state();
|
||||||
|
match st.entries.get_mut(&ino.0) {
|
||||||
|
Some(e) if e.node.is_file() => {
|
||||||
|
if write_requested && !e.writable() {
|
||||||
|
reply.error(Errno::EACCES);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.open_count = e.open_count.saturating_add(1);
|
||||||
|
(
|
||||||
|
e.uid.clone(),
|
||||||
|
e.node.modification_time,
|
||||||
|
node_size(&e.node),
|
||||||
|
node_revision_id(&e.node),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
reply.error(Errno::EISDIR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
reply.error(Errno::ENOENT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
debug!(ino = ino.0, ?flags, base_size, base_mtime, "open");
|
||||||
|
// Read-only opens stay stateless (fh 0). A write open allocates a
|
||||||
|
// disk-backed handle; bytes are authored into a scratch file and the
|
||||||
|
// untouched remainder is pulled from the base lazily (on read / commit).
|
||||||
|
if flags.acc_mode() == OpenAccMode::O_RDONLY {
|
||||||
|
reply.opened(FileHandle(0), FopenFlags::empty());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A previous close may already have newer bytes queued locally while
|
||||||
|
// the server still holds the older revision. That staged blob, not the
|
||||||
|
// remote, is the base for this handle. In particular, `truncate file`
|
||||||
|
// opens for writing and then shrinks via setattr; starting its scratch
|
||||||
|
// as a sparse zero file would turn the preserved prefix into zeros.
|
||||||
|
//
|
||||||
|
// Complete pending blobs are the common case and can be copied without
|
||||||
|
// any network access. An incomplete blob still has gaps referring to
|
||||||
|
// the remote base; stacking another write on it cannot be represented
|
||||||
|
// safely by WriteHandle, so fail the open rather than risk corruption.
|
||||||
|
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||||
|
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||||
|
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||||
|
entry.open_count = entry.open_count.saturating_sub(1);
|
||||||
|
}
|
||||||
|
error!(%uid, "refusing write over incomplete queued revision");
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (file, path) = match self.core.cache.create_scratch() {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||||
|
entry.open_count = entry.open_count.saturating_sub(1);
|
||||||
|
}
|
||||||
|
error!(%uid, error = %e, "create scratch file failed");
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut initial_written = Intervals::default();
|
||||||
|
if let Some(pending) = &pending_base {
|
||||||
|
// Reflink where the filesystem can: this blob is the whole file, and
|
||||||
|
// a byte-for-byte copy of a multi-GB pending revision is the kind of
|
||||||
|
// work `open` must not do (`ContentCache::clone_or_copy`).
|
||||||
|
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
||||||
|
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||||
|
entry.open_count = entry.open_count.saturating_sub(1);
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
error!(%uid, source = %pending.path.display(), error = %e,
|
||||||
|
"copy queued revision into write scratch failed");
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initial_written.add(0, pending.meta.len);
|
||||||
|
}
|
||||||
|
let mut st = self.core.state();
|
||||||
|
let fh = st.next_fh;
|
||||||
|
st.next_fh += 1;
|
||||||
|
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
||||||
|
WriteHandle {
|
||||||
|
ino: ino.0,
|
||||||
|
uid,
|
||||||
|
file: Arc::new(file),
|
||||||
|
path,
|
||||||
|
written: initial_written,
|
||||||
|
// Starts at the current size; reads in [0, base_size) come from
|
||||||
|
// the base until overwritten.
|
||||||
|
len: base_size,
|
||||||
|
base_size,
|
||||||
|
base_mtime,
|
||||||
|
base_revision_id,
|
||||||
|
dirty: false,
|
||||||
|
open_count: 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
aw.open_count += 1;
|
||||||
|
st.handles.insert(fh, ino.0);
|
||||||
|
reply.opened(FileHandle(fh), FopenFlags::empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body of [`Filesystem::fsync`], run off the dispatch loop.
|
||||||
|
fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) {
|
||||||
|
// Everything the durability sidecar needs, copied out so no lock is held
|
||||||
|
// across the sync.
|
||||||
|
let handle = {
|
||||||
|
let st = self.core.state();
|
||||||
|
let ino = st.handles.get(&fh.0).copied();
|
||||||
|
ino.and_then(|i| st.active_writes.get(&i)).map(|aw| {
|
||||||
|
(
|
||||||
|
aw.file.clone(),
|
||||||
|
aw.path.clone(),
|
||||||
|
aw.uid.clone(),
|
||||||
|
aw.dirty,
|
||||||
|
aw.written.clone(),
|
||||||
|
aw.len,
|
||||||
|
aw.base_size,
|
||||||
|
aw.base_mtime,
|
||||||
|
aw.base_revision_id.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
|
||||||
|
handle
|
||||||
|
else {
|
||||||
|
reply.error(Errno::EBADF);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = f.sync_all() {
|
||||||
|
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
||||||
|
// Delayed allocation means a full disk usually surfaces here rather
|
||||||
|
// than at `write(2)`; `ENOSPC` is what an application checks for.
|
||||||
|
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||||
|
self.core.cache.emergency_evict();
|
||||||
|
reply.error(Errno::ENOSPC);
|
||||||
|
} else {
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A clean handle has nothing on it that the remote does not already
|
||||||
|
// hold, so there is nothing for a crash to lose.
|
||||||
|
if !dirty {
|
||||||
|
reply.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The bytes are on stable storage, but only the scratch file knows that,
|
||||||
|
// and the scratch directory is cleared at open. Record what the blob is
|
||||||
|
// so a restart can hand it to the upload queue instead of deleting it.
|
||||||
|
//
|
||||||
|
// Deliberately *not* the full release path: staging the blob here would
|
||||||
|
// move the file out from under a handle the application still has open,
|
||||||
|
// and queueing an upload per `fsync` would push a revision for every
|
||||||
|
// barrier in a write loop. This only makes the existing file findable.
|
||||||
|
let authored: Vec<(u64, u64)> = written
|
||||||
|
.segments(0, len)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|&(_, _, authored)| authored)
|
||||||
|
.map(|(s, e, _)| (s, e))
|
||||||
|
.collect();
|
||||||
|
let meta = StagedWrite {
|
||||||
|
uid: uid.to_string(),
|
||||||
|
len,
|
||||||
|
base_size,
|
||||||
|
base_mtime,
|
||||||
|
complete: authored == [(0, len)],
|
||||||
|
authored,
|
||||||
|
based_on: self
|
||||||
|
.core
|
||||||
|
.remote_baseline(&uid, base_mtime, base_size, base_revision_id),
|
||||||
|
};
|
||||||
|
// A failed sidecar means the write is not durable, and `fsync` promising
|
||||||
|
// otherwise is the defect this exists to fix — so it is an error, not a
|
||||||
|
// warning.
|
||||||
|
if let Err(e) = self.core.cache.mark_scratch_durable(&path, &meta) {
|
||||||
|
error!(%uid, error = %e, "recording fsync durability failed");
|
||||||
|
if e.is_disk_full() {
|
||||||
|
self.core.cache.emergency_evict();
|
||||||
|
reply.error(Errno::ENOSPC);
|
||||||
|
} else {
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reply.ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// The body of [`Filesystem::create`], run off the dispatch loop.
|
/// The body of [`Filesystem::create`], run off the dispatch loop.
|
||||||
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
||||||
if let Err(error) = self.core.require_writable(parent) {
|
if let Err(error) = self.core.require_writable(parent) {
|
||||||
@@ -1165,7 +1301,7 @@ impl ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let parent_uid = {
|
let parent_uid = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
match st.entries.get(&parent) {
|
match st.entries.get(&parent) {
|
||||||
Some(e) => e.uid.clone(),
|
Some(e) => e.uid.clone(),
|
||||||
None => {
|
None => {
|
||||||
@@ -1247,7 +1383,7 @@ impl ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut st = self.core.state.lock();
|
let mut st = self.core.state();
|
||||||
let ino = st.intern(parent, node);
|
let ino = st.intern(parent, node);
|
||||||
if let Some(entry) = st.entries.get_mut(&ino) {
|
if let Some(entry) = st.entries.get_mut(&ino) {
|
||||||
entry.open_count = entry.open_count.saturating_add(1);
|
entry.open_count = entry.open_count.saturating_add(1);
|
||||||
@@ -1302,7 +1438,7 @@ impl ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let parent_uid = {
|
let parent_uid = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
match st.entries.get(&parent) {
|
match st.entries.get(&parent) {
|
||||||
Some(e) => e.uid.clone(),
|
Some(e) => e.uid.clone(),
|
||||||
None => {
|
None => {
|
||||||
@@ -1352,7 +1488,7 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut st = self.core.state.lock();
|
let mut st = self.core.state();
|
||||||
let local = is_local_uid(&node.uid);
|
let local = is_local_uid(&node.uid);
|
||||||
let uid = node.uid.clone();
|
let uid = node.uid.clone();
|
||||||
let ino = st.intern(parent, node);
|
let ino = st.intern(parent, node);
|
||||||
@@ -1384,7 +1520,7 @@ impl ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
|
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
if let Some(entry) = st.entries.get(&ino)
|
if let Some(entry) = st.entries.get(&ino)
|
||||||
&& entry.node.is_folder()
|
&& entry.node.is_folder()
|
||||||
{
|
{
|
||||||
@@ -1403,7 +1539,7 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
|
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
|
||||||
{
|
{
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
if st
|
if st
|
||||||
.entries
|
.entries
|
||||||
.get(&ino)
|
.get(&ino)
|
||||||
@@ -1420,7 +1556,7 @@ impl ProtonFs {
|
|||||||
reply.error(e);
|
reply.error(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.core.state.lock().has_children(ino) {
|
if self.core.state().has_children(ino) {
|
||||||
reply.error(Errno::ENOTEMPTY);
|
reply.error(Errno::ENOTEMPTY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1501,7 +1637,7 @@ impl ProtonFs {
|
|||||||
};
|
};
|
||||||
// Ancestor cycle check: moving a directory into itself or one of its descendants is forbidden by POSIX (EINVAL).
|
// Ancestor cycle check: moving a directory into itself or one of its descendants is forbidden by POSIX (EINVAL).
|
||||||
{
|
{
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
if let Some(entry) = st.entries.get(&ino)
|
if let Some(entry) = st.entries.get(&ino)
|
||||||
&& entry.node.is_folder()
|
&& entry.node.is_folder()
|
||||||
&& st.is_ancestor_of(ino, newparent)
|
&& st.is_ancestor_of(ino, newparent)
|
||||||
@@ -1520,7 +1656,7 @@ impl ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (old_parent_uid, new_parent_uid) = {
|
let (old_parent_uid, new_parent_uid) = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
match (st.entries.get(&parent), st.entries.get(&newparent)) {
|
match (st.entries.get(&parent), st.entries.get(&newparent)) {
|
||||||
(Some(old), Some(new)) => (old.uid.clone(), new.uid.clone()),
|
(Some(old), Some(new)) => (old.uid.clone(), new.uid.clone()),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -1561,7 +1697,7 @@ impl ProtonFs {
|
|||||||
// check has to happen before anything is trashed: getting it wrong
|
// check has to happen before anything is trashed: getting it wrong
|
||||||
// turns a refusal into the destruction of the destination.
|
// turns a refusal into the destruction of the destination.
|
||||||
let (src_dir, dst_dir) = {
|
let (src_dir, dst_dir) = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
let is_dir = |i: &u64| {
|
let is_dir = |i: &u64| {
|
||||||
st.entries
|
st.entries
|
||||||
.get(i)
|
.get(i)
|
||||||
@@ -1624,12 +1760,9 @@ impl ProtonFs {
|
|||||||
newname,
|
newname,
|
||||||
) {
|
) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
self.core.state.lock().rename_in_place(
|
self.core
|
||||||
ino,
|
.state()
|
||||||
newparent,
|
.rename_in_place(ino, newparent, &new_parent_uid, newname);
|
||||||
&new_parent_uid,
|
|
||||||
newname,
|
|
||||||
);
|
|
||||||
// Finalize: a transient scratch file (its create parked, bytes
|
// Finalize: a transient scratch file (its create parked, bytes
|
||||||
// held back) renamed to a finished name is the moment the
|
// held back) renamed to a finished name is the moment the
|
||||||
// completed file is meant to reach Drive. Un-park the create so
|
// completed file is meant to reach Drive. Un-park the create so
|
||||||
@@ -1770,7 +1903,7 @@ impl ProtonFs {
|
|||||||
if let Err(e) = moved {
|
if let Err(e) = moved {
|
||||||
error!(%uid, attempts, error = %e, "move after rename failed");
|
error!(%uid, attempts, error = %e, "move after rename failed");
|
||||||
// The rename half landed in the source directory.
|
// The rename half landed in the source directory.
|
||||||
let mut state = self.core.state.lock();
|
let mut state = self.core.state();
|
||||||
let old_parent_uid = state
|
let old_parent_uid = state
|
||||||
.entries
|
.entries
|
||||||
.get(&parent)
|
.get(&parent)
|
||||||
@@ -1796,7 +1929,7 @@ impl ProtonFs {
|
|||||||
/// Answer with an inode's current attributes, or `ENOENT` if it went away
|
/// Answer with an inode's current attributes, or `ENOENT` if it went away
|
||||||
/// while the caller was doing something slower.
|
/// while the caller was doing something slower.
|
||||||
fn reply_attr(&self, ino: u64, reply: ReplyAttr) {
|
fn reply_attr(&self, ino: u64, reply: ReplyAttr) {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
match st.entries.get(&ino) {
|
match st.entries.get(&ino) {
|
||||||
Some(e) => {
|
Some(e) => {
|
||||||
let attr = self.attr(ino, &e.node, e.access);
|
let attr = self.attr(ino, &e.node, e.access);
|
||||||
@@ -1833,7 +1966,7 @@ impl ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.core.state.lock().forget_or_unlink(&uid);
|
self.core.state().forget_or_unlink(&uid);
|
||||||
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
||||||
reply.ok();
|
reply.ok();
|
||||||
return;
|
return;
|
||||||
@@ -1856,6 +1989,7 @@ impl ProtonFs {
|
|||||||
.core
|
.core
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.core.client.trash_nodes(std::slice::from_ref(&uid)))
|
.block_on(self.core.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||||
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
error!(%uid, error = %e, "trash failed");
|
error!(%uid, error = %e, "trash failed");
|
||||||
self.core
|
self.core
|
||||||
@@ -1866,7 +2000,7 @@ impl ProtonFs {
|
|||||||
// Only remove the namespace entry after Drive accepted the mutation.
|
// Only remove the namespace entry after Drive accepted the mutation.
|
||||||
// Otherwise an EIO response still made the path disappear locally.
|
// Otherwise an EIO response still made the path disappear locally.
|
||||||
self.core.hidden.lock().insert(uid.clone());
|
self.core.hidden.lock().insert(uid.clone());
|
||||||
self.core.state.lock().forget_or_unlink(&uid);
|
self.core.state().forget_or_unlink(&uid);
|
||||||
if !open_now {
|
if !open_now {
|
||||||
if let Err(error) = self.core.discard_queued_ops(&uid) {
|
if let Err(error) = self.core.discard_queued_ops(&uid) {
|
||||||
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
|
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
|
||||||
@@ -1930,7 +2064,7 @@ impl ProtonFs {
|
|||||||
/// request for an unadvertised name, so nothing becomes unreachable.
|
/// request for an unadvertised name, so nothing becomes unreachable.
|
||||||
fn serve_listxattr(&self, ino: INodeNo, size: u32, reply: ReplyXattr) {
|
fn serve_listxattr(&self, ino: INodeNo, size: u32, reply: ReplyXattr) {
|
||||||
let thumbnailable = {
|
let thumbnailable = {
|
||||||
let st = self.core.state.lock();
|
let st = self.core.state();
|
||||||
match st.entries.get(&ino.0) {
|
match st.entries.get(&ino.0) {
|
||||||
Some(e) => match &e.node.kind {
|
Some(e) => match &e.node.kind {
|
||||||
NodeKind::File { media_type, .. } => {
|
NodeKind::File { media_type, .. } => {
|
||||||
|
|||||||
+1344
-253
File diff suppressed because it is too large.
Load diff
@@ -255,6 +255,14 @@ pub(super) fn spawn_session(
|
|||||||
root: Node,
|
root: Node,
|
||||||
) -> std::io::Result<BackgroundSession> {
|
) -> std::io::Result<BackgroundSession> {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
|
// `n_threads` stays at fuser's default of 1 (with `clone_fd` off) on
|
||||||
|
// purpose, for now. A concurrent dispatch loop is worth having, but
|
||||||
|
// `release` currently relies on being serialised against the `open`+`read`
|
||||||
|
// an application issues right after `close(2)` — see the comment on
|
||||||
|
// `Filesystem::release`. Raising it before that ordering is made explicit
|
||||||
|
// (a per-node staging-in-flight barrier) reintroduces the acceptance
|
||||||
|
// suite's "concurrent file 1 mismatch". Kernel-side caps that do *not*
|
||||||
|
// depend on that ordering are negotiated in `ProtonFs::init`.
|
||||||
config.mount_options = vec![
|
config.mount_options = vec![
|
||||||
MountOption::FSName("protondrive".to_string()),
|
MountOption::FSName("protondrive".to_string()),
|
||||||
MountOption::Subtype("protondrive".to_string()),
|
MountOption::Subtype("protondrive".to_string()),
|
||||||
@@ -349,23 +357,16 @@ pub fn mount(
|
|||||||
let core = Core {
|
let core = Core {
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
rt: rt.clone(),
|
rt: rt.clone(),
|
||||||
|
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||||
primary_root_uid: root.uid.clone(),
|
primary_root_uid: root.uid.clone(),
|
||||||
primary: true,
|
primary: true,
|
||||||
state: Arc::new(Mutex::new(State {
|
state: Arc::new(Mutex::new(State::new(db.clone(), share_access, 2))),
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 2,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
db: db.clone(),
|
|
||||||
})),
|
|
||||||
cache: Arc::new(cache),
|
cache: Arc::new(cache),
|
||||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
|
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||||
|
block_flight: Arc::new(BlockFlight::default()),
|
||||||
|
prefetch: Arc::new(Prefetch::default()),
|
||||||
|
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||||
db,
|
db,
|
||||||
shared_publication: Arc::new(Mutex::new(())),
|
shared_publication: Arc::new(Mutex::new(())),
|
||||||
@@ -385,7 +386,9 @@ pub fn mount(
|
|||||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
quota: Arc::new(Mutex::new(None)),
|
||||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
size_waiters: Arc::new(Default::default()),
|
||||||
notifier: Arc::new(OnceLock::new()),
|
notifier: Arc::new(OnceLock::new()),
|
||||||
session_live: Arc::new(AtomicBool::new(false)),
|
session_live: Arc::new(AtomicBool::new(false)),
|
||||||
transfers: TransferRegistry::new(),
|
transfers: TransferRegistry::new(),
|
||||||
@@ -407,6 +410,9 @@ pub fn mount(
|
|||||||
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
||||||
// see an already-queued write to the same node.
|
// see an already-queued write to the same node.
|
||||||
core.recover_fsynced_writes();
|
core.recover_fsynced_writes();
|
||||||
|
// And finally the staged blobs neither of those accounts for: bytes a
|
||||||
|
// failed release left behind with nothing pointing at them.
|
||||||
|
core.reconcile_staging();
|
||||||
{
|
{
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use pdfs_core::{CoreError, CoreResult};
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use proton_drive_rs::{NodeKind, ThumbnailType};
|
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -97,6 +97,7 @@ impl Core {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
kind: Option<PhotoKind>,
|
kind: Option<PhotoKind>,
|
||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
|
favorites: bool,
|
||||||
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
||||||
let count = self.db.photos_count().map_err(CoreError::from)?;
|
let count = self.db.photos_count().map_err(CoreError::from)?;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -116,7 +117,7 @@ impl Core {
|
|||||||
|
|
||||||
let page = self
|
let page = self
|
||||||
.db
|
.db
|
||||||
.photos_page(offset, limit, kind, range)
|
.photos_page(offset, limit, kind, range, favorites)
|
||||||
.map_err(CoreError::from)?;
|
.map_err(CoreError::from)?;
|
||||||
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
||||||
}
|
}
|
||||||
@@ -139,9 +140,42 @@ impl Core {
|
|||||||
ratio: photo.ratio,
|
ratio: photo.ratio,
|
||||||
no_thumb: photo.thumb_state == db::THUMB_NONE,
|
no_thumb: photo.thumb_state == db::THUMB_NONE,
|
||||||
kind: photo.kind,
|
kind: photo.kind,
|
||||||
|
favorite: photo.favorite,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add or remove Proton's `Favorite` tag on a photo, then record the change
|
||||||
|
/// locally so the gallery reflects it without waiting for a timeline refresh.
|
||||||
|
///
|
||||||
|
/// The SDK reports one outcome per photo rather than failing the call, so the
|
||||||
|
/// single outcome is unwrapped back into this request's result.
|
||||||
|
pub(crate) fn set_photo_favorite(&self, uid: &NodeUid, favorite: bool) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let update = PhotoTagsUpdate {
|
||||||
|
node_uid: uid.clone(),
|
||||||
|
tags_to_add: if favorite {
|
||||||
|
vec![PhotoTag::Favorite]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
},
|
||||||
|
tags_to_remove: if favorite {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![PhotoTag::Favorite]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.update_photos(std::slice::from_ref(&update)))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||||||
|
pdfs_core::batch::into_unit(outcomes)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||||||
|
self.db
|
||||||
|
.photos_set_favorite(&uid.to_string(), favorite)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Thumbnails for `uids`, served from the cache, fetched from the server for
|
/// Thumbnails for `uids`, served from the cache, fetched from the server for
|
||||||
/// whatever is missing, and — for the photos the server has no thumbnail for
|
/// whatever is missing, and — for the photos the server has no thumbnail for
|
||||||
/// at all — *generated locally* from the full file (see
|
/// at all — *generated locally* from the full file (see
|
||||||
@@ -381,7 +415,7 @@ impl Core {
|
|||||||
// learned before (or classifies from nothing, i.e. a still photo), so a
|
// learned before (or classifies from nothing, i.e. a still photo), so a
|
||||||
// partial resolve never blanks the timeline.
|
// partial resolve never blanks the timeline.
|
||||||
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
|
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
|
||||||
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
|
let mut meta: HashMap<String, (Option<String>, Option<String>, bool)> = HashMap::new();
|
||||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||||
match photos.enumerate_nodes(chunk).await {
|
match photos.enumerate_nodes(chunk).await {
|
||||||
Ok(nodes) => {
|
Ok(nodes) => {
|
||||||
@@ -390,19 +424,35 @@ impl Core {
|
|||||||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||||
NodeKind::Folder => None,
|
NodeKind::Folder => None,
|
||||||
};
|
};
|
||||||
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
|
// The favourite state is a tag on the photo node, so it
|
||||||
|
// rides along with the metadata resolve rather than
|
||||||
|
// costing a listing of its own.
|
||||||
|
let favorite = node
|
||||||
|
.photo
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
|
||||||
|
meta.insert(
|
||||||
|
node.uid.to_string(),
|
||||||
|
(Some(node.name), media_type, favorite),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
|
let rows: Vec<db::TimelineRow> = items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|it| {
|
.map(|it| {
|
||||||
let key = it.uid.to_string();
|
let key = it.uid.to_string();
|
||||||
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
|
// An unresolved photo keeps whatever was learned before, for the
|
||||||
(key, it.capture_time, name, media_type)
|
// favourite flag as much as for the name and media type.
|
||||||
|
match meta.get(&key).cloned() {
|
||||||
|
Some((name, media_type, favorite)) => {
|
||||||
|
(key, it.capture_time, name, media_type, Some(favorite))
|
||||||
|
}
|
||||||
|
None => (key, it.capture_time, None, None, None),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
self.db.photos_replace(&rows).map_err(CoreError::from)?;
|
self.db.photos_replace(&rows).map_err(CoreError::from)?;
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ impl Core {
|
|||||||
.pin_list()
|
.pin_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(uid, path, recursive)| ProfilePin {
|
.map(|row| ProfilePin {
|
||||||
uid,
|
uid: row.uid,
|
||||||
path,
|
path: row.path,
|
||||||
recursive,
|
recursive: row.recursive,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let cfg = pdfs_core::config::AppDirs::new()
|
let cfg = pdfs_core::config::AppDirs::new()
|
||||||
|
|||||||
+540
-85
@@ -10,18 +10,30 @@ use super::*;
|
|||||||
/// usual.
|
/// usual.
|
||||||
pub(super) const STREAM_BYPASS_MIN: u64 = 256 * 1024 * 1024;
|
pub(super) const STREAM_BYPASS_MIN: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
/// How many bytes of streamed blocks the in-memory ring keeps. Bypassing the
|
/// How many bytes of decrypted blocks the in-memory ring keeps.
|
||||||
/// on-disk cache must not mean re-fetching: the kernel asks for a streamed file
|
///
|
||||||
/// in reads far smaller than [`BLOCK_SIZE`], so without a ring every 128 KiB the
|
/// The kernel asks for a file in reads far smaller than [`BLOCK_SIZE`], so
|
||||||
/// player consumes would download and decrypt a whole 4 MiB block again. Sized
|
/// without a ring every 128 KiB it consumes costs a whole 4 MiB block again —
|
||||||
/// for a handful of blocks per concurrently-streamed file.
|
/// downloaded and decrypted for a streaming file, or re-read off disk and
|
||||||
const STREAM_RING_BYTES: u64 = 128 * 1024 * 1024;
|
/// re-allocated for a cached one (audit T3: 128 MiB of I/O to deliver 4 MiB).
|
||||||
|
/// Sized for a handful of blocks per concurrently-read file. Bounded and only
|
||||||
|
/// ever filled by active reads.
|
||||||
|
const RING_BYTES: u64 = 128 * 1024 * 1024;
|
||||||
|
|
||||||
/// Blocks fetched *past* the one a sequential streaming read needed, warmed into
|
/// Deepest read-ahead window, in blocks, for a read that has proved sequential.
|
||||||
/// the ring in the background so the player's next read is already in memory
|
const PREFETCH_MAX: u64 = 8;
|
||||||
/// instead of waiting a round-trip. Kept small so it cannot crowd out the
|
|
||||||
/// in-flight block governor.
|
/// Window a sequential read starts at, and returns to after a seek.
|
||||||
const STREAM_READAHEAD: u64 = 4;
|
const PREFETCH_MIN: u64 = 2;
|
||||||
|
|
||||||
|
/// Blocks that may be prefetching at once, across every file. Prefetch takes a
|
||||||
|
/// permit and gives up rather than queueing, so it can never sit in front of a
|
||||||
|
/// demand read in the SDK's flat block semaphore.
|
||||||
|
pub(super) const PREFETCH_BUDGET: usize = 8;
|
||||||
|
|
||||||
|
/// Cap on tracked sequential streams. Only reads in progress matter, so the map
|
||||||
|
/// is cleared wholesale rather than aged — losing the ramp costs one read.
|
||||||
|
const MAX_PREFETCH_STREAMS: usize = 256;
|
||||||
|
|
||||||
/// How many [`RevisionReader`]s stay open at once.
|
/// How many [`RevisionReader`]s stay open at once.
|
||||||
///
|
///
|
||||||
@@ -76,35 +88,39 @@ pub(super) struct PendingOpen {
|
|||||||
/// does; ids are never compared for order.
|
/// does; ids are never compared for order.
|
||||||
static NEXT_OPEN_ID: AtomicU64 = AtomicU64::new(0);
|
static NEXT_OPEN_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
/// Drive client, a Tokio handle to bridge the synchronous FUSE/socket threads
|
/// In-memory LRU of decrypted 4 MiB blocks, in front of both block paths: the
|
||||||
/// to the async SDK, the inode bookkeeping, and the on-disk content cache.
|
/// on-disk block cache, and the disk-bypassing stream of a large unpinned video
|
||||||
///
|
/// (see [`STREAM_BYPASS_MIN`], which stops one film evicting the whole disk
|
||||||
/// Cheaply cloneable (every field is a handle/`Arc`), so the control-socket task
|
/// LRU). Either way the point is the same — the kernel reads a block out in ~32
|
||||||
/// gets its own copy while the FUSE session keeps another.
|
/// pieces, and none of them should cost the block again.
|
||||||
/// In-memory LRU of blocks belonging to a file streaming past the on-disk cache
|
|
||||||
/// (see [`STREAM_BYPASS_MIN`]). Bypassing the disk stops one film evicting the
|
|
||||||
/// block LRU; it must not also mean the same 4 MiB block is downloaded once per
|
|
||||||
/// 128 KiB the player reads out of it.
|
|
||||||
///
|
///
|
||||||
/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node
|
/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node
|
||||||
/// whose tag no longer matches has its blocks dropped rather than served, so a
|
/// whose tag no longer matches has its blocks dropped rather than served, so a
|
||||||
/// new revision can never be stitched together from the old one.
|
/// new revision can never be stitched together from the old one.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct StreamRing {
|
pub(super) struct BlockRing {
|
||||||
blocks: HashMap<(NodeUid, u64), Arc<Vec<u8>>>,
|
blocks: HashMap<(NodeUid, u64), Arc<Vec<u8>>>,
|
||||||
/// Keys oldest-first, for eviction.
|
/// Keys least-recently-used first, for eviction.
|
||||||
order: VecDeque<(NodeUid, u64)>,
|
order: VecDeque<(NodeUid, u64)>,
|
||||||
/// Per-node validity tag, `(mtime, size)`.
|
/// Per-node validity tag, `(mtime, size)`.
|
||||||
tags: HashMap<NodeUid, (i64, u64)>,
|
tags: HashMap<NodeUid, (i64, u64)>,
|
||||||
bytes: u64,
|
bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamRing {
|
impl BlockRing {
|
||||||
fn get(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Arc<Vec<u8>>> {
|
fn get(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Arc<Vec<u8>>> {
|
||||||
if self.tags.get(uid) != Some(&(mtime, size)) {
|
if self.tags.get(uid) != Some(&(mtime, size)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.blocks.get(&(uid.clone(), idx)).cloned()
|
let key = (uid.clone(), idx);
|
||||||
|
let hit = self.blocks.get(&key).cloned()?;
|
||||||
|
// Move to the young end: a block being read repeatedly (the whole reason
|
||||||
|
// this exists) must not age out under a sequential read passing through.
|
||||||
|
if let Some(at) = self.order.iter().position(|k| *k == key) {
|
||||||
|
self.order.remove(at);
|
||||||
|
self.order.push_back(key);
|
||||||
|
}
|
||||||
|
Some(hit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64, bytes: Arc<Vec<u8>>) {
|
fn insert(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64, bytes: Arc<Vec<u8>>) {
|
||||||
@@ -121,13 +137,18 @@ impl StreamRing {
|
|||||||
self.bytes += bytes.len() as u64;
|
self.bytes += bytes.len() as u64;
|
||||||
self.blocks.insert(key.clone(), bytes);
|
self.blocks.insert(key.clone(), bytes);
|
||||||
self.order.push_back(key);
|
self.order.push_back(key);
|
||||||
while self.bytes > STREAM_RING_BYTES {
|
while self.bytes > RING_BYTES {
|
||||||
let Some(victim) = self.order.pop_front() else {
|
let Some(victim) = self.order.pop_front() else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
if let Some(dropped) = self.blocks.remove(&victim) {
|
if let Some(dropped) = self.blocks.remove(&victim) {
|
||||||
self.bytes -= dropped.len() as u64;
|
self.bytes -= dropped.len() as u64;
|
||||||
}
|
}
|
||||||
|
// A node whose last block just aged out keeps no tag: otherwise the
|
||||||
|
// tag map is the one thing here that grows with every file ever read.
|
||||||
|
if !self.blocks.keys().any(|(u, _)| *u == victim.0) {
|
||||||
|
self.tags.remove(&victim.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +168,87 @@ impl StreamRing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The result of fetching one block, shared between everyone who wanted it.
|
||||||
|
type BlockResult = Result<Arc<Vec<u8>>, Errno>;
|
||||||
|
|
||||||
|
/// Identifies a block *of a revision*: node, validity tag, index.
|
||||||
|
type BlockKey = (NodeUid, i64, u64, u64);
|
||||||
|
|
||||||
|
/// A claim on a block key: who owns the fetch, and where its result is published.
|
||||||
|
type Claim = (u64, tokio::sync::watch::Receiver<Option<BlockResult>>);
|
||||||
|
|
||||||
|
/// Block fetches in flight, so a block is downloaded and decrypted once however
|
||||||
|
/// many readers want it at that moment (audit T1).
|
||||||
|
///
|
||||||
|
/// With `FUSE_ASYNC_READ` the demand read and the kernel's read-ahead land in
|
||||||
|
/// the same 4 MiB block for 31 of every 32 reads, and read-ahead of our own
|
||||||
|
/// (see [`Core::prefetch`]) aims at blocks a demand read may be about to ask
|
||||||
|
/// for. Without this each of them pays the full round-trip and decrypt and all
|
||||||
|
/// but one result is discarded — roughly double the bandwidth and CPU in steady
|
||||||
|
/// state. Same shape as [`ReaderSlot::Pending`] one level up.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct BlockFlight {
|
||||||
|
inflight: Mutex<HashMap<BlockKey, Claim>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinguishes attempts at the same key, so a leader only retires its own.
|
||||||
|
static NEXT_FLIGHT_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// The leader's claim on a key. Retires it on drop — including when the task is
|
||||||
|
/// cancelled mid-fetch, which is what keeps a dropped `JoinSet` from leaving a
|
||||||
|
/// key nobody will ever publish.
|
||||||
|
struct Flight {
|
||||||
|
flight: Arc<BlockFlight>,
|
||||||
|
key: BlockKey,
|
||||||
|
id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Flight {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut inflight = self.flight.inflight.lock();
|
||||||
|
if inflight
|
||||||
|
.get(&self.key)
|
||||||
|
.is_some_and(|(id, _)| *id == self.id)
|
||||||
|
{
|
||||||
|
inflight.remove(&self.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where each file's reader is expected to read next, so sequential reads can be
|
||||||
|
/// told from random ones and only the former prefetched (audit T2).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Prefetch {
|
||||||
|
/// Per node: the block index a sequential reader should ask for next, and
|
||||||
|
/// the window depth it has earned.
|
||||||
|
streams: Mutex<HashMap<NodeUid, (u64, u64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Prefetch {
|
||||||
|
/// How far past `last` to read ahead for a read of blocks `[first, last]`,
|
||||||
|
/// and record where that read leaves the file's cursor.
|
||||||
|
///
|
||||||
|
/// A read that starts where the last one stopped doubles the window (from
|
||||||
|
/// [`PREFETCH_MIN`], up to [`PREFETCH_MAX`]); anything else is a seek and
|
||||||
|
/// earns nothing, so random access — a database file, a linker — never pays
|
||||||
|
/// for speculative blocks it will not read. The first read of a file counts
|
||||||
|
/// as sequential: `(0, 0)` is both the initial cursor and block zero.
|
||||||
|
fn window(&self, uid: &NodeUid, first: u64, last: u64) -> u64 {
|
||||||
|
let mut streams = self.streams.lock();
|
||||||
|
if streams.len() > MAX_PREFETCH_STREAMS {
|
||||||
|
streams.clear();
|
||||||
|
}
|
||||||
|
let stream = streams.entry(uid.clone()).or_insert((0, 0));
|
||||||
|
let depth = if stream.0 == first {
|
||||||
|
(stream.1 * 2).clamp(PREFETCH_MIN, PREFETCH_MAX)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
*stream = (last + 1, depth);
|
||||||
|
depth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
/// An open [`RevisionReader`] for `uid`, reusing the cached one when it is
|
/// An open [`RevisionReader`] for `uid`, reusing the cached one when it is
|
||||||
/// still valid for `(mtime, fsize)` and opening a fresh one otherwise.
|
/// still valid for `(mtime, fsize)` and opening a fresh one otherwise.
|
||||||
@@ -304,6 +406,10 @@ impl Core {
|
|||||||
/// revision is no longer the truth.
|
/// revision is no longer the truth.
|
||||||
pub(super) fn evict_reader(&self, uid: &NodeUid) {
|
pub(super) fn evict_reader(&self, uid: &NodeUid) {
|
||||||
self.readers.lock().remove(uid);
|
self.readers.lock().remove(uid);
|
||||||
|
// The ring holds decrypted bytes of the same revision, and an eviction is
|
||||||
|
// exactly the statement that those are no longer the file's content.
|
||||||
|
self.block_ring.lock().drop_node(uid);
|
||||||
|
self.prefetch.streams.lock().remove(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
|
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
|
||||||
@@ -389,20 +495,141 @@ impl Core {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Warm the [`STREAM_READAHEAD`] blocks after `last` into the ring, in the
|
/// Fetch block `bidx`, joining a fetch already in flight for it rather than
|
||||||
/// background, so a player reading forward finds its next block already in
|
/// issuing a second one, and publish the result to everyone who joined.
|
||||||
/// memory rather than paying a fetch + decrypt round-trip at each boundary.
|
|
||||||
///
|
///
|
||||||
/// Fire-and-forget: a failure here is not an error, it only means the read
|
/// See [`BlockFlight`]. A follower whose leader disappears (a panic, or a
|
||||||
/// that needs those bytes will fetch them itself.
|
/// cancelled `JoinSet`) fetches the block itself rather than failing — the
|
||||||
fn stream_readahead(&self, uid: &NodeUid, mtime: i64, fsize: u64, last: u64) {
|
/// leader's claim is gone by then, so this cannot recurse.
|
||||||
let last_block = (fsize.saturating_sub(1)) / BLOCK_SIZE;
|
async fn fetch_block(
|
||||||
let wanted: Vec<u64> = ((last + 1)..=(last + STREAM_READAHEAD).min(last_block))
|
&self,
|
||||||
|
reader: &Arc<RevisionReader>,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
fsize: u64,
|
||||||
|
bidx: u64,
|
||||||
|
cache_blocks: bool,
|
||||||
|
) -> BlockResult {
|
||||||
|
let key: BlockKey = (uid.clone(), mtime, fsize, bidx);
|
||||||
|
let claim = {
|
||||||
|
let mut inflight = self.block_flight.inflight.lock();
|
||||||
|
match inflight.get(&key) {
|
||||||
|
Some((_, rx)) => Err(rx.clone()),
|
||||||
|
None => {
|
||||||
|
let id = NEXT_FLIGHT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||||
|
inflight.insert(key.clone(), (id, rx));
|
||||||
|
Ok((
|
||||||
|
tx,
|
||||||
|
Flight {
|
||||||
|
flight: self.block_flight.clone(),
|
||||||
|
key: key.clone(),
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (tx, flight) = match claim {
|
||||||
|
Ok(lead) => lead,
|
||||||
|
Err(mut rx) => {
|
||||||
|
loop {
|
||||||
|
if let Some(result) = rx.borrow_and_update().clone() {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if rx.changed().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
.read_block(reader, uid, mtime, fsize, bidx, cache_blocks)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result = self
|
||||||
|
.read_block(reader, uid, mtime, fsize, bidx, cache_blocks)
|
||||||
|
.await;
|
||||||
|
// Retire the claim before publishing, so a racer that wakes and re-enters
|
||||||
|
// finds no stale entry to join.
|
||||||
|
drop(flight);
|
||||||
|
let _ = tx.send(Some(result.clone()));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and decrypt one block, then put it where the next reader will
|
||||||
|
/// find it: the in-memory ring always, the on-disk block cache too unless
|
||||||
|
/// this is a disk-bypassing stream.
|
||||||
|
///
|
||||||
|
/// The disk store is handed to a blocking thread — it is a 4 MiB write plus
|
||||||
|
/// a cache-index update, and neither the reactor nor the kernel read waiting
|
||||||
|
/// on these bytes should be held up by it.
|
||||||
|
async fn read_block(
|
||||||
|
&self,
|
||||||
|
reader: &Arc<RevisionReader>,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
fsize: u64,
|
||||||
|
bidx: u64,
|
||||||
|
cache_blocks: bool,
|
||||||
|
) -> BlockResult {
|
||||||
|
let bstart = bidx * BLOCK_SIZE;
|
||||||
|
let blen = BLOCK_SIZE.min(fsize - bstart);
|
||||||
|
let bytes = reader.read_at(bstart, blen).await.map_err(|e| {
|
||||||
|
warn!(%uid, bstart, blen, error = %e, "block read failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
let bytes = Arc::new(bytes);
|
||||||
|
if cache_blocks {
|
||||||
|
let cache = self.cache.clone();
|
||||||
|
let uid = uid.clone();
|
||||||
|
let bytes = bytes.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let _ = cache.store_block(&uid, mtime, fsize, bidx, &bytes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.block_ring
|
||||||
|
.lock()
|
||||||
|
.insert(uid, mtime, fsize, bidx, bytes.clone());
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warm the blocks after a sequential read into the caches, in the
|
||||||
|
/// background, so the next read finds them instead of paying a round-trip.
|
||||||
|
///
|
||||||
|
/// A read that continues where the last one stopped doubles the window, up
|
||||||
|
/// to [`PREFETCH_MAX`]; a seek drops it back to nothing and one more
|
||||||
|
/// sequential read is needed to earn it again. Without this every read is a
|
||||||
|
/// serial fetch/serve/stall chain: on a 100 ms link that ceilings a file at
|
||||||
|
/// ~24 MB/s of the ~125 MB/s available, and the SDK's block concurrency is
|
||||||
|
/// never exercised at all.
|
||||||
|
///
|
||||||
|
/// Fire-and-forget, and permit-bounded ([`PREFETCH_BUDGET`]) so speculative
|
||||||
|
/// work never sits in front of a read someone is waiting on. A failure here
|
||||||
|
/// is not an error: the read that needs those bytes will fetch them itself.
|
||||||
|
fn prefetch_ahead(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
fsize: u64,
|
||||||
|
first: u64,
|
||||||
|
last: u64,
|
||||||
|
cache_blocks: bool,
|
||||||
|
) {
|
||||||
|
let depth = self.prefetch.window(uid, first, last);
|
||||||
|
if depth == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let last_block = fsize.saturating_sub(1) / BLOCK_SIZE;
|
||||||
|
let wanted: Vec<u64> = ((last + 1)..=(last + depth).min(last_block))
|
||||||
.filter(|&bidx| {
|
.filter(|&bidx| {
|
||||||
self.stream_ring
|
// Blocks already in memory need nothing; blocks already on disk
|
||||||
|
// are cheap enough to serve that spending a round-trip to move
|
||||||
|
// them into the ring would be the wrong trade.
|
||||||
|
self.block_ring
|
||||||
.lock()
|
.lock()
|
||||||
.get(uid, mtime, fsize, bidx)
|
.get(uid, mtime, fsize, bidx)
|
||||||
.is_none()
|
.is_none()
|
||||||
|
&& !(cache_blocks && self.cache.has_block(uid, mtime, fsize, bidx))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if wanted.is_empty() {
|
if wanted.is_empty() {
|
||||||
@@ -416,26 +643,25 @@ impl Core {
|
|||||||
};
|
};
|
||||||
// Concurrently, for the same reason the demand path fetches its
|
// Concurrently, for the same reason the demand path fetches its
|
||||||
// misses concurrently: serially awaited blocks cost the sum of their
|
// misses concurrently: serially awaited blocks cost the sum of their
|
||||||
// round-trips, and the player catches up with the readahead before it
|
// round-trips, and the reader catches up with the window before it
|
||||||
// finishes. The SDK's in-flight block governor bounds the fan-out.
|
// finishes.
|
||||||
let mut set = tokio::task::JoinSet::new();
|
let mut set = tokio::task::JoinSet::new();
|
||||||
for bidx in wanted {
|
for bidx in wanted {
|
||||||
let reader = reader.clone();
|
let Ok(permit) = core.prefetch_budget.clone().try_acquire_owned() else {
|
||||||
let bstart = bidx * BLOCK_SIZE;
|
break; // the budget is spent; the demand path can have it
|
||||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
};
|
||||||
set.spawn(async move { (bidx, reader.read_at(bstart, blen).await) });
|
let (core, reader, uid) = (core.clone(), reader.clone(), uid.clone());
|
||||||
}
|
set.spawn(async move {
|
||||||
while let Some(joined) = set.join_next().await {
|
let _permit = permit;
|
||||||
let Ok((bidx, read)) = joined else { continue };
|
if let Err(error) = core
|
||||||
match read {
|
.fetch_block(&reader, &uid, mtime, fsize, bidx, cache_blocks)
|
||||||
Ok(bytes) => {
|
.await
|
||||||
core.stream_ring
|
{
|
||||||
.lock()
|
debug!(%uid, bidx, ?error, "prefetch failed");
|
||||||
.insert(&uid, mtime, fsize, bidx, Arc::new(bytes))
|
|
||||||
}
|
}
|
||||||
Err(e) => debug!(%uid, bidx, error = %e, "stream readahead failed"),
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
while set.join_next().await.is_some() {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,17 +699,23 @@ impl Core {
|
|||||||
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity((last - first + 1) as usize);
|
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity((last - first + 1) as usize);
|
||||||
let mut misses: Vec<u64> = Vec::new();
|
let mut misses: Vec<u64> = Vec::new();
|
||||||
for bidx in first..=last {
|
for bidx in first..=last {
|
||||||
// A streaming read keeps its blocks only in memory, so the ring is
|
// The in-memory ring comes first on both paths: a streaming read has
|
||||||
// the only cache it has — check it before the disk (which will not
|
// no other cache at all, and a cached one would otherwise pay two
|
||||||
// hold them) and before the network (which would refetch the same
|
// opens, a JSON parse and a 4 MiB read+alloc for every 128 KiB the
|
||||||
// 4 MiB block for every 128 KiB the player consumes).
|
// kernel asks for (audit T3).
|
||||||
let hit = if cache_blocks {
|
let hit = self.block_ring.lock().get(uid, mtime, fsize, bidx);
|
||||||
self.cache
|
let hit = hit.or_else(|| {
|
||||||
.cached_block(uid, mtime, fsize, bidx)
|
if !cache_blocks {
|
||||||
.map(Arc::new)
|
return None;
|
||||||
} else {
|
}
|
||||||
self.stream_ring.lock().get(uid, mtime, fsize, bidx)
|
let bytes = Arc::new(self.cache.cached_block(uid, mtime, fsize, bidx)?);
|
||||||
};
|
// Promote it, so the other ~31 reads of this block are answered
|
||||||
|
// from memory.
|
||||||
|
self.block_ring
|
||||||
|
.lock()
|
||||||
|
.insert(uid, mtime, fsize, bidx, bytes.clone());
|
||||||
|
Some(bytes)
|
||||||
|
});
|
||||||
match hit {
|
match hit {
|
||||||
Some(b) => blocks.push(Some(b)),
|
Some(b) => blocks.push(Some(b)),
|
||||||
None => {
|
None => {
|
||||||
@@ -502,19 +734,11 @@ impl Core {
|
|||||||
|
|
||||||
let mut set = tokio::task::JoinSet::new();
|
let mut set = tokio::task::JoinSet::new();
|
||||||
for &bidx in &misses {
|
for &bidx in &misses {
|
||||||
let reader = reader.clone();
|
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
||||||
let uid = uid.clone();
|
|
||||||
let bstart = bidx * BLOCK_SIZE;
|
|
||||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
|
||||||
set.spawn(async move {
|
set.spawn(async move {
|
||||||
reader
|
core.fetch_block(&reader, &uid, mtime, fsize, bidx, cache_blocks)
|
||||||
.read_at(bstart, blen)
|
|
||||||
.await
|
.await
|
||||||
.map(|bytes| (bidx, bytes))
|
.map(|bytes| (bidx, bytes))
|
||||||
.map_err(|e| {
|
|
||||||
warn!(%uid, bstart, blen, error = %e, "block read failed");
|
|
||||||
Errno::EIO
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut out = Vec::with_capacity(misses.len());
|
let mut out = Vec::with_capacity(misses.len());
|
||||||
@@ -525,23 +749,12 @@ impl Core {
|
|||||||
Ok::<_, Errno>(out)
|
Ok::<_, Errno>(out)
|
||||||
})?;
|
})?;
|
||||||
for (bidx, bytes) in fetched {
|
for (bidx, bytes) in fetched {
|
||||||
// A streaming read (large unpinned video) skips the on-disk cache
|
|
||||||
// so it can't evict the rest of it; it keeps the block in the
|
|
||||||
// in-memory ring instead, which is bounded and dies with the read.
|
|
||||||
let bytes = Arc::new(bytes);
|
|
||||||
if cache_blocks {
|
|
||||||
let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes);
|
|
||||||
} else {
|
|
||||||
self.stream_ring
|
|
||||||
.lock()
|
|
||||||
.insert(uid, mtime, fsize, bidx, bytes.clone());
|
|
||||||
}
|
|
||||||
blocks[(bidx - first) as usize] = Some(bytes);
|
blocks[(bidx - first) as usize] = Some(bytes);
|
||||||
}
|
}
|
||||||
if !cache_blocks {
|
|
||||||
self.stream_readahead(uid, mtime, fsize, last);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Also after a wholly-cached read: that is what keeps the window sliding
|
||||||
|
// ahead of a sequential reader instead of stalling once it catches up.
|
||||||
|
self.prefetch_ahead(uid, mtime, fsize, first, last, cache_blocks);
|
||||||
|
|
||||||
for (i, block) in blocks.into_iter().enumerate() {
|
for (i, block) in blocks.into_iter().enumerate() {
|
||||||
let bidx = first + i as u64;
|
let bidx = first + i as u64;
|
||||||
@@ -605,6 +818,106 @@ impl Core {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
|
||||||
|
/// caches while a `release(2)` is in progress.
|
||||||
|
///
|
||||||
|
/// The point of the cached fill is that editing a file the mount already
|
||||||
|
/// holds costs nothing extra; it is not to reconstruct arbitrarily large
|
||||||
|
/// files on the dispatch loop. Past this budget the blob stays incomplete
|
||||||
|
/// and the drain finishes it, which is the same work in a place where it
|
||||||
|
/// blocks nobody.
|
||||||
|
const CACHED_FILL_BUDGET: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Bytes `[offset, offset + len)` of `uid`'s base revision, but only if the
|
||||||
|
/// local caches can answer in full — the whole-file blob first, then every
|
||||||
|
/// overlapping block. `None` the moment anything would have to be fetched.
|
||||||
|
fn cached_range(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
fsize: u64,
|
||||||
|
offset: u64,
|
||||||
|
len: u64,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
if len == 0 {
|
||||||
|
return Some(Vec::new());
|
||||||
|
}
|
||||||
|
if let Some(bytes) = self.cache.read_range(uid, mtime, fsize, offset, len) {
|
||||||
|
return Some(bytes);
|
||||||
|
}
|
||||||
|
let end = offset.checked_add(len)?.min(fsize);
|
||||||
|
if offset >= end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
|
for bidx in (offset / BLOCK_SIZE)..=((end - 1) / BLOCK_SIZE) {
|
||||||
|
let ring = self.block_ring.lock().get(uid, mtime, fsize, bidx);
|
||||||
|
let block = match ring {
|
||||||
|
Some(bytes) => bytes,
|
||||||
|
None => Arc::new(self.cache.cached_block(uid, mtime, fsize, bidx)?),
|
||||||
|
};
|
||||||
|
let bstart = bidx * BLOCK_SIZE;
|
||||||
|
let s = (offset.max(bstart) - bstart) as usize;
|
||||||
|
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
||||||
|
if s >= e {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&block[s..e]);
|
||||||
|
}
|
||||||
|
(out.len() as u64 == end - offset).then_some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill what [`Core::fill_gaps`] would, but only from bytes already on this
|
||||||
|
/// machine, and only up to [`Core::CACHED_FILL_BUDGET`]. Ranges past the
|
||||||
|
/// base are zeros the caller's `set_len` already wrote, so they count as
|
||||||
|
/// filled. `written` is extended with everything that ends up resolved.
|
||||||
|
///
|
||||||
|
/// This is the release-time half of gap filling. Doing the network half
|
||||||
|
/// there as well is what froze the whole mount for the length of a download
|
||||||
|
/// (audit F1): the closing file's untouched ranges are allowed to stay
|
||||||
|
/// unresolved in the staged blob — `StagedWrite::complete` is false, reads
|
||||||
|
/// resolve the gaps against the base, and `drain_revision` fills them on the
|
||||||
|
/// drain thread before it uploads. What is left here is the case where
|
||||||
|
/// filling is free, which is also the common one: a file that was read
|
||||||
|
/// before it was edited.
|
||||||
|
pub(super) fn fill_gaps_cached(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
file: &File,
|
||||||
|
len: u64,
|
||||||
|
base_mtime: i64,
|
||||||
|
base_size: u64,
|
||||||
|
written: &mut Intervals,
|
||||||
|
) {
|
||||||
|
let mut budget = Self::CACHED_FILL_BUDGET;
|
||||||
|
for (s, e, authored) in written.clone().segments(0, len) {
|
||||||
|
if authored {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bend = e.min(base_size);
|
||||||
|
if s < bend {
|
||||||
|
let want = bend - s;
|
||||||
|
if want > budget {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(bytes) = self.cached_range(uid, base_mtime, base_size, s, want) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Err(error) = file.write_all_at(&bytes, s) {
|
||||||
|
warn!(%uid, %error, "cached gap-fill write failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
budget -= want;
|
||||||
|
written.add(s, bend);
|
||||||
|
}
|
||||||
|
// Past the base there is nothing to fetch: the resize zeroed it, so
|
||||||
|
// those bytes are already the file's content.
|
||||||
|
if e > base_size {
|
||||||
|
written.add(s.max(base_size), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fill every unauthored range of a scratch/staged file that overlaps its
|
/// Fill every unauthored range of a scratch/staged file that overlaps its
|
||||||
/// base with the base's bytes, so the file becomes the complete new content.
|
/// base with the base's bytes, so the file becomes the complete new content.
|
||||||
///
|
///
|
||||||
@@ -643,3 +956,145 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod block_ring_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn uid(link: &str) -> NodeUid {
|
||||||
|
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One block's worth of bytes, so byte accounting is exercised at the scale
|
||||||
|
/// the ring actually sees.
|
||||||
|
fn block(byte: u8) -> Arc<Vec<u8>> {
|
||||||
|
Arc::new(vec![byte; BLOCK_SIZE as usize])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many whole blocks fit before the ring starts evicting.
|
||||||
|
fn capacity() -> u64 {
|
||||||
|
RING_BYTES / BLOCK_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_block_is_served_back() {
|
||||||
|
let mut ring = BlockRing::default();
|
||||||
|
let node = uid("a");
|
||||||
|
ring.insert(&node, 7, 100, 3, block(0xab));
|
||||||
|
assert_eq!(ring.get(&node, 7, 100, 3).map(|b| b[0]), Some(0xab));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_block_of_another_revision_is_never_served() {
|
||||||
|
let mut ring = BlockRing::default();
|
||||||
|
let node = uid("a");
|
||||||
|
ring.insert(&node, 7, 100, 0, block(1));
|
||||||
|
// Same node, newer revision: the tag moved, so the old bytes go.
|
||||||
|
assert!(ring.get(&node, 8, 100, 0).is_none());
|
||||||
|
assert!(ring.get(&node, 7, 101, 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_revision_drops_what_the_old_one_left() {
|
||||||
|
let mut ring = BlockRing::default();
|
||||||
|
let node = uid("a");
|
||||||
|
ring.insert(&node, 7, 100, 0, block(1));
|
||||||
|
ring.insert(&node, 8, 100, 0, block(2));
|
||||||
|
assert_eq!(ring.get(&node, 8, 100, 0).map(|b| b[0]), Some(2));
|
||||||
|
assert_eq!(ring.bytes, BLOCK_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_ring_evicts_the_least_recently_used_block() {
|
||||||
|
let mut ring = BlockRing::default();
|
||||||
|
let node = uid("a");
|
||||||
|
for idx in 0..capacity() {
|
||||||
|
ring.insert(&node, 1, u64::MAX, idx, block(idx as u8));
|
||||||
|
}
|
||||||
|
// Re-reading block 0 makes block 1 the oldest instead.
|
||||||
|
assert!(ring.get(&node, 1, u64::MAX, 0).is_some());
|
||||||
|
ring.insert(&node, 1, u64::MAX, capacity(), block(0xff));
|
||||||
|
assert!(ring.get(&node, 1, u64::MAX, 0).is_some());
|
||||||
|
assert!(ring.get(&node, 1, u64::MAX, 1).is_none());
|
||||||
|
assert_eq!(ring.bytes, RING_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_whose_last_block_ages_out_keeps_no_tag() {
|
||||||
|
let mut ring = BlockRing::default();
|
||||||
|
let (old, new) = (uid("old"), uid("new"));
|
||||||
|
ring.insert(&old, 1, u64::MAX, 0, block(1));
|
||||||
|
for idx in 0..capacity() {
|
||||||
|
ring.insert(&new, 1, u64::MAX, idx, block(2));
|
||||||
|
}
|
||||||
|
assert!(!ring.tags.contains_key(&old));
|
||||||
|
assert!(ring.tags.contains_key(&new));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_a_node_frees_only_its_own_bytes() {
|
||||||
|
let mut ring = BlockRing::default();
|
||||||
|
let (a, b) = (uid("a"), uid("b"));
|
||||||
|
ring.insert(&a, 1, 100, 0, block(1));
|
||||||
|
ring.insert(&b, 1, 100, 0, block(2));
|
||||||
|
ring.drop_node(&a);
|
||||||
|
assert!(ring.get(&a, 1, 100, 0).is_none());
|
||||||
|
assert_eq!(ring.get(&b, 1, 100, 0).map(|x| x[0]), Some(2));
|
||||||
|
assert_eq!(ring.bytes, BLOCK_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod prefetch_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn uid(link: &str) -> NodeUid {
|
||||||
|
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sequential_reader_ramps_up_to_the_cap() {
|
||||||
|
let prefetch = Prefetch::default();
|
||||||
|
let node = uid("a");
|
||||||
|
let depths: Vec<u64> = (0..6)
|
||||||
|
.map(|next| prefetch.window(&node, next, next))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(depths, vec![2, 4, 8, 8, 8, 8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_seek_forfeits_the_window() {
|
||||||
|
let prefetch = Prefetch::default();
|
||||||
|
let node = uid("a");
|
||||||
|
assert_eq!(prefetch.window(&node, 0, 0), PREFETCH_MIN);
|
||||||
|
assert_eq!(prefetch.window(&node, 40, 40), 0);
|
||||||
|
// And has to be earned again from the bottom.
|
||||||
|
assert_eq!(prefetch.window(&node, 41, 41), PREFETCH_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_multi_block_read_advances_the_cursor_past_all_of_it() {
|
||||||
|
let prefetch = Prefetch::default();
|
||||||
|
let node = uid("a");
|
||||||
|
assert_eq!(prefetch.window(&node, 0, 3), PREFETCH_MIN);
|
||||||
|
assert_eq!(prefetch.window(&node, 4, 7), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn files_are_tracked_independently() {
|
||||||
|
let prefetch = Prefetch::default();
|
||||||
|
let (a, b) = (uid("a"), uid("b"));
|
||||||
|
assert_eq!(prefetch.window(&a, 0, 0), PREFETCH_MIN);
|
||||||
|
assert_eq!(prefetch.window(&b, 9, 9), 0);
|
||||||
|
assert_eq!(prefetch.window(&a, 1, 1), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_stream_map_cannot_grow_without_bound() {
|
||||||
|
let prefetch = Prefetch::default();
|
||||||
|
for n in 0..=MAX_PREFETCH_STREAMS + 1 {
|
||||||
|
prefetch.window(&uid(&n.to_string()), 0, 0);
|
||||||
|
}
|
||||||
|
assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! File version history: listing a file's revisions, restoring one, deleting
|
||||||
|
//! one, and writing an old one out to a local path.
|
||||||
|
//!
|
||||||
|
//! Proton Drive keeps every revision a client ever committed, and until now the
|
||||||
|
//! daemon only ever addressed the active one — a file that a sync pass or a
|
||||||
|
//! `(sync-conflict)` fork overwrote could only be recovered from whatever the
|
||||||
|
//! local `recovery/` directory happened to hold. This is the remote equivalent,
|
||||||
|
//! and it is the reason a *restore* here is not a download-then-upload: the
|
||||||
|
//! server re-points the file at an existing revision, so no content crosses the
|
||||||
|
//! wire and nothing lands in the drain queue.
|
||||||
|
//!
|
||||||
|
//! Two things about the SDK surface leak into every method here:
|
||||||
|
//!
|
||||||
|
//! - **A restore is asynchronous.** The server answers 202 and swaps the active
|
||||||
|
//! revision in the background, so the daemon drops its cached content for the
|
||||||
|
//! file rather than trying to describe the new one. The next read repopulates
|
||||||
|
//! from whatever is live by then.
|
||||||
|
//! - **The active revision cannot be deleted**, and the server — not this code —
|
||||||
|
//! is what enforces that. [`Core::delete_revision_for_uid`] refuses locally
|
||||||
|
//! too, so the user gets a sentence instead of an API code.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use pdfs_core::control::{ActivityKind, RevisionInfo};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::Revision;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
|
||||||
|
/// A [`Revision`] as the control protocol reports it.
|
||||||
|
fn revision_info(revision: Revision) -> RevisionInfo {
|
||||||
|
RevisionInfo {
|
||||||
|
is_active: revision.is_active(),
|
||||||
|
id: revision.revision_id,
|
||||||
|
created: revision.creation_time,
|
||||||
|
size_on_storage: revision.size_on_storage,
|
||||||
|
claimed_size: revision.claimed_size,
|
||||||
|
claimed_modified: revision.claimed_modification_time,
|
||||||
|
// An empty signer means the node key signed it anonymously, which is not
|
||||||
|
// an address a front-end can show.
|
||||||
|
signed_by: revision.signature_email.filter(|email| !email.is_empty()),
|
||||||
|
has_thumbnails: revision.has_thumbnails,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// The version history of the file at mountpoint-relative `rel`.
|
||||||
|
pub(crate) fn list_revisions(&self, rel: &Path) -> CoreResult<Vec<RevisionInfo>> {
|
||||||
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.list_revisions_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::list_revisions`] for a node addressed through any daemon location.
|
||||||
|
pub(crate) fn list_revisions_by_uid(&self, uid: &str) -> CoreResult<Vec<RevisionInfo>> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.list_revisions_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_revisions_for_uid(&self, uid: &NodeUid) -> CoreResult<Vec<RevisionInfo>> {
|
||||||
|
let revisions = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.enumerate_revisions(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
|
||||||
|
Ok(revisions.into_iter().map(revision_info).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `revision_id` the current content of the file at `rel`.
|
||||||
|
pub(crate) fn restore_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
|
||||||
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
let name = rel
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| uid.to_string());
|
||||||
|
self.restore_revision_for_uid(&uid, revision_id, &name)?;
|
||||||
|
// The size and mtime a listing shows describe the revision that was
|
||||||
|
// active a moment ago.
|
||||||
|
self.invalidate_parent_listing(rel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::restore_revision`] for a node addressed through any daemon
|
||||||
|
/// location.
|
||||||
|
pub(crate) fn restore_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
let name = uid.to_string();
|
||||||
|
self.restore_revision_for_uid(&uid, revision_id, &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_revision_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
revision_id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
// Restoring rewrites the file's content: the same authority a write
|
||||||
|
// needs, not the one a read needs.
|
||||||
|
self.require_uid_writable(uid)
|
||||||
|
.map_err(|error| self.errno_error(error, "restore revision"))?;
|
||||||
|
if let Err(e) = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.restore_revision(uid, revision_id))
|
||||||
|
{
|
||||||
|
let error = CoreError::from_api(&e, "restore revision");
|
||||||
|
self.log_activity(ActivityKind::Restore, name, &error, false);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
// The active revision is changing behind our back — anything describing
|
||||||
|
// the old one (cached blocks, an open reader, the size in a listing) is
|
||||||
|
// stale from here on, and the server may not have applied the swap yet.
|
||||||
|
self.cache.evict(uid);
|
||||||
|
self.evict_reader(uid);
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Restore,
|
||||||
|
name,
|
||||||
|
format!("restored version {revision_id}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
info!(%uid, revision_id, name, "restored an earlier revision");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently delete `revision_id` from the history of the file at `rel`.
|
||||||
|
pub(crate) fn delete_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
|
||||||
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
let name = rel
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| uid.to_string());
|
||||||
|
self.delete_revision_for_uid(&uid, revision_id, &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::delete_revision`] for a node addressed through any daemon
|
||||||
|
/// location.
|
||||||
|
pub(crate) fn delete_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
let name = uid.to_string();
|
||||||
|
self.delete_revision_for_uid(&uid, revision_id, &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_revision_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
revision_id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
self.require_uid_writable(uid)
|
||||||
|
.map_err(|error| self.errno_error(error, "delete revision"))?;
|
||||||
|
// The server rejects deleting the active revision; saying so here is a
|
||||||
|
// sentence the user can act on instead of an API code, and it costs one
|
||||||
|
// listing the front-end already made to show the version list.
|
||||||
|
let revisions = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.enumerate_revisions(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
|
||||||
|
let target = revisions
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.revision_id == revision_id)
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no version {revision_id} on {name}")))?;
|
||||||
|
if target.is_active() {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"version {revision_id} is the current content of {name}; restore another version first"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.rt
|
||||||
|
.block_on(self.client.delete_revision(uid, revision_id))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "delete revision"))?;
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::DeleteForever,
|
||||||
|
name,
|
||||||
|
format!("deleted version {revision_id}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
info!(%uid, revision_id, name, "deleted a revision");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `revision_id` of the file at `rel` to the absolute local path
|
||||||
|
/// `dest`, leaving the file itself untouched.
|
||||||
|
pub(crate) fn save_revision_as(
|
||||||
|
&self,
|
||||||
|
rel: &Path,
|
||||||
|
revision_id: &str,
|
||||||
|
dest: &Path,
|
||||||
|
) -> CoreResult<u64> {
|
||||||
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
let name = rel
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| uid.to_string());
|
||||||
|
self.save_revision_for_uid(&uid, revision_id, &name, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::save_revision_as`] for a node addressed through any daemon
|
||||||
|
/// location.
|
||||||
|
pub(crate) fn save_revision_as_by_uid(
|
||||||
|
&self,
|
||||||
|
uid: &str,
|
||||||
|
revision_id: &str,
|
||||||
|
dest: &Path,
|
||||||
|
) -> CoreResult<u64> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
let name = uid.to_string();
|
||||||
|
self.save_revision_for_uid(&uid, revision_id, &name, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_revision_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
revision_id: &str,
|
||||||
|
name: &str,
|
||||||
|
dest: &Path,
|
||||||
|
) -> CoreResult<u64> {
|
||||||
|
if !dest.is_absolute() {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"destination must be an absolute path: {}",
|
||||||
|
dest.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Never silently replace something the user already has: an old version
|
||||||
|
// written over the wrong file is exactly the loss this feature exists to
|
||||||
|
// undo.
|
||||||
|
if dest.exists() {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"{} already exists",
|
||||||
|
dest.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let file = std::fs::File::create(dest)
|
||||||
|
.map_err(|e| CoreError::internal(format!("create {}: {e}", dest.display())))?;
|
||||||
|
let mut out = std::io::BufWriter::new(file);
|
||||||
|
let written = match self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.download_revision_to(uid, revision_id, &mut out))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "download revision"))
|
||||||
|
.and_then(|()| {
|
||||||
|
use std::io::Write as _;
|
||||||
|
out.flush()
|
||||||
|
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))?;
|
||||||
|
let written = std::fs::metadata(dest)
|
||||||
|
.map(|m| m.len())
|
||||||
|
.map_err(|e| CoreError::internal(format!("stat {}: {e}", dest.display())))?;
|
||||||
|
Ok(written)
|
||||||
|
}) {
|
||||||
|
Ok(written) => written,
|
||||||
|
Err(error) => {
|
||||||
|
// A half-written file looks like a successful export to every
|
||||||
|
// tool that opens it afterwards.
|
||||||
|
let _ = std::fs::remove_file(dest);
|
||||||
|
self.log_activity(ActivityKind::Download, name, &error, false);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Download,
|
||||||
|
name,
|
||||||
|
format!("saved version {revision_id} to {}", dest.display()),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
info!(%uid, revision_id, dest = %dest.display(), "wrote an earlier revision to disk");
|
||||||
|
Ok(written)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ use pdfs_core::control::{
|
|||||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::QUOTA_TTL;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use proton_drive_rs::{MemberRole, NodeKind};
|
use proton_drive_rs::{MemberRole, NodeKind};
|
||||||
|
|
||||||
@@ -533,9 +536,33 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.quota())
|
.block_on(self.client.quota())
|
||||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
||||||
|
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
||||||
Ok((q.max_space, q.used_space))
|
Ok((q.max_space, q.used_space))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The account quota as `statfs(2)` needs it: cached for [`QUOTA_TTL`],
|
||||||
|
/// refetched after that, and `None` when there is no usable answer at all —
|
||||||
|
/// offline with nothing cached.
|
||||||
|
///
|
||||||
|
/// Never blocks on the network for a *fresh* value when a recent one exists,
|
||||||
|
/// because the callers are `df` and every "is there room?" preflight, and a
|
||||||
|
/// stale byte count is worth more to them than a stalled syscall. Offline,
|
||||||
|
/// the last known figures keep being served rather than degrading to zero.
|
||||||
|
pub(crate) fn account_quota_cached(&self) -> Option<(i64, i64)> {
|
||||||
|
if let Some((at, max_space, used_space)) = *self.quota.lock()
|
||||||
|
&& at.elapsed() < QUOTA_TTL
|
||||||
|
{
|
||||||
|
return Some((max_space, used_space));
|
||||||
|
}
|
||||||
|
match self.account_quota() {
|
||||||
|
Ok(q) => Some(q),
|
||||||
|
Err(error) => {
|
||||||
|
debug!(%error, "account quota refresh failed; serving the last known figures");
|
||||||
|
self.quota.lock().map(|(_, max, used)| (max, used))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- shared by me -----------------------------------------------------
|
// ---- shared by me -----------------------------------------------------
|
||||||
|
|
||||||
/// List the nodes I have shared with others, each with a summary of its share
|
/// List the nodes I have shared with others, each with a summary of its share
|
||||||
|
|||||||
+251
-85
@@ -166,13 +166,163 @@ pub(crate) struct State {
|
|||||||
/// on every explicit role/tombstone change. Descendant inheritance reads
|
/// on every explicit role/tombstone change. Descendant inheritance reads
|
||||||
/// this map instead of issuing one SQLite query per interned inode.
|
/// this map instead of issuing one SQLite query per interned inode.
|
||||||
pub(crate) share_access: HashMap<NodeUid, Access>,
|
pub(crate) share_access: HashMap<NodeUid, Access>,
|
||||||
/// Unified SQLite metadata cache. Every map mutation below writes through to
|
/// Unified SQLite metadata cache. Read from here (`has_children`) but never
|
||||||
/// it inside the `State` lock so the DB stays the authoritative copy across
|
/// written: mutations go to [`State::outbox`] and are applied after the lock
|
||||||
/// restarts (see plan.md P1).
|
/// is released. See [`StateGuard`].
|
||||||
pub(crate) db: Arc<Db>,
|
pub(crate) db: Arc<Db>,
|
||||||
|
/// Write-throughs this mutation owes SQLite, applied by [`StateGuard`] once
|
||||||
|
/// the inode lock is released.
|
||||||
|
///
|
||||||
|
/// The DB stays the authoritative copy across restarts, so every map
|
||||||
|
/// mutation still writes through — it just no longer does so with the
|
||||||
|
/// whole-mount lock held. A single listing's write-through is one
|
||||||
|
/// transaction over up to a few thousand rows, and every other FUSE callback
|
||||||
|
/// used to wait behind it for no reason: the maps were already updated
|
||||||
|
/// before the first row was written.
|
||||||
|
outbox: Vec<DbWrite>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A write-through queued by a `State` mutation, applied after the lock drops.
|
||||||
|
///
|
||||||
|
/// Deliberately a small closed set rather than a boxed closure: these run
|
||||||
|
/// outside the lock, and what they can touch should be readable here.
|
||||||
|
pub(crate) enum DbWrite {
|
||||||
|
/// One node or a whole listing. Both go through `upsert_nodes`, which is a
|
||||||
|
/// single transaction either way, so there is nothing to gain by splitting
|
||||||
|
/// them — and a `Node` is large enough that a variant holding one inline
|
||||||
|
/// would set the size of every other.
|
||||||
|
Upsert(Vec<Node>),
|
||||||
|
Delete(NodeUid),
|
||||||
|
SetListed(NodeUid, bool),
|
||||||
|
SetShareAccess(NodeUid, Access),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbWrite {
|
||||||
|
fn apply(self, db: &Db) {
|
||||||
|
let outcome = match &self {
|
||||||
|
Self::Upsert(nodes) => db.upsert_nodes(nodes),
|
||||||
|
Self::Delete(uid) => db.delete_node(uid),
|
||||||
|
Self::SetListed(uid, listed) => db.set_listed(uid, *listed),
|
||||||
|
Self::SetShareAccess(uid, access) => db.set_share_access(uid, *access),
|
||||||
|
};
|
||||||
|
if let Err(error) = outcome {
|
||||||
|
let what = match &self {
|
||||||
|
Self::Upsert(_) => "upsert_nodes",
|
||||||
|
Self::Delete(_) => "delete_node",
|
||||||
|
Self::SetListed(..) => "set_listed",
|
||||||
|
Self::SetShareAccess(..) => "set_share_access",
|
||||||
|
};
|
||||||
|
warn!(%error, write = what, "db write-through failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inode lock, held for the duration of a mutation, plus the write-throughs
|
||||||
|
/// that mutation owed the database.
|
||||||
|
///
|
||||||
|
/// Dropping it releases the lock *first* and only then writes, which is the
|
||||||
|
/// whole point: interning a five-thousand-child listing commits one transaction
|
||||||
|
/// — several fsyncs' worth — and nothing about that needs the maps frozen. The
|
||||||
|
/// write still happens synchronously on this thread before the caller proceeds,
|
||||||
|
/// so a read that follows a mutation still sees it.
|
||||||
|
pub(crate) struct StateGuard<'a> {
|
||||||
|
guard: Option<parking_lot::MutexGuard<'a, State>>,
|
||||||
|
db: &'a Arc<Db>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a mount's inode lock through a [`StateGuard`], for the loops that walk
|
||||||
|
/// every live mount rather than this `Core`'s own.
|
||||||
|
pub(crate) fn lock_state<'a>(
|
||||||
|
state: &'a parking_lot::Mutex<State>,
|
||||||
|
db: &'a Arc<Db>,
|
||||||
|
) -> StateGuard<'a> {
|
||||||
|
StateGuard::new(state.lock(), db)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StateGuard<'a> {
|
||||||
|
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
|
||||||
|
Self {
|
||||||
|
guard: Some(guard),
|
||||||
|
db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for StateGuard<'_> {
|
||||||
|
type Target = State;
|
||||||
|
|
||||||
|
fn deref(&self) -> &State {
|
||||||
|
self.guard.as_ref().expect("held until drop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::DerefMut for StateGuard<'_> {
|
||||||
|
fn deref_mut(&mut self) -> &mut State {
|
||||||
|
self.guard.as_mut().expect("held until drop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StateGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some(mut guard) = self.guard.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let writes = std::mem::take(&mut guard.outbox);
|
||||||
|
// The lock goes before the writes do. That ordering is the feature.
|
||||||
|
drop(guard);
|
||||||
|
for write in writes {
|
||||||
|
write.apply(self.db);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
|
/// A fresh, empty inode space over `db`.
|
||||||
|
///
|
||||||
|
/// `next_ino` differs by caller: a real mount hands out [`crate::ROOT_INO`]
|
||||||
|
/// to its root and starts allocating above it, while a bare state under test
|
||||||
|
/// has no root and starts at 1.
|
||||||
|
/// Apply the write-throughs this state owes SQLite, here and now.
|
||||||
|
///
|
||||||
|
/// Production code never calls this: [`StateGuard`] releases the inode lock
|
||||||
|
/// first and then applies them, which is the whole point. A test driving a
|
||||||
|
/// bare `State` has no guard, so it calls this where the daemon would have
|
||||||
|
/// dropped one.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn flush_outbox(&mut self) {
|
||||||
|
let db = self.db.clone();
|
||||||
|
for write in std::mem::take(&mut self.outbox) {
|
||||||
|
write.apply(&db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The database, with everything this state owes it already written.
|
||||||
|
///
|
||||||
|
/// What a test means by `st.db` is almost always "the database as of now",
|
||||||
|
/// which in the daemon is the state after the guard dropped. Handing back an
|
||||||
|
/// owned `Arc` rather than a borrow keeps it usable inside an `assert_eq!`
|
||||||
|
/// that also touches `st`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn flushed_db(&mut self) -> Arc<Db> {
|
||||||
|
self.flush_outbox();
|
||||||
|
self.db.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>, next_ino: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
by_uid: HashMap::new(),
|
||||||
|
children: HashMap::new(),
|
||||||
|
next_ino,
|
||||||
|
active_writes: HashMap::new(),
|
||||||
|
handles: HashMap::new(),
|
||||||
|
next_fh: 1,
|
||||||
|
access_changes: HashSet::new(),
|
||||||
|
share_access,
|
||||||
|
db,
|
||||||
|
outbox: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Whether `uid` names an owned node currently reachable from this mount's
|
/// Whether `uid` names an owned node currently reachable from this mount's
|
||||||
/// root. Resident entries can outlive their dentries for open-handle and
|
/// root. Resident entries can outlive their dentries for open-handle and
|
||||||
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
||||||
@@ -278,11 +428,9 @@ impl State {
|
|||||||
|| stored.unwrap_or(Access::Viewer),
|
|| stored.unwrap_or(Access::Viewer),
|
||||||
|membership| access_for(membership.role_exact(), parent_access, true),
|
|membership| access_for(membership.role_exact(), parent_access, true),
|
||||||
);
|
);
|
||||||
if let Err(error) = self.db.set_share_access(&node.uid, access) {
|
self.outbox
|
||||||
warn!(uid = %node.uid, ?access, %error, "db set_share_access failed");
|
.push(DbWrite::SetShareAccess(node.uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(node.uid.clone(), access);
|
||||||
self.share_access.insert(node.uid.clone(), access);
|
|
||||||
}
|
|
||||||
return access;
|
return access;
|
||||||
}
|
}
|
||||||
parent_access
|
parent_access
|
||||||
@@ -313,11 +461,9 @@ impl State {
|
|||||||
None => (None, Access::Unknown),
|
None => (None, Access::Unknown),
|
||||||
};
|
};
|
||||||
let access = access_for(role, parent_access, true);
|
let access = access_for(role, parent_access, true);
|
||||||
if let Err(error) = self.db.set_share_access(uid, access) {
|
self.outbox
|
||||||
warn!(%uid, ?access, %error, "db set observed share access failed");
|
.push(DbWrite::SetShareAccess(uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(uid.clone(), access);
|
||||||
self.share_access.insert(uid.clone(), access);
|
|
||||||
}
|
|
||||||
let Some(root) = root else {
|
let Some(root) = root else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -602,18 +748,14 @@ impl State {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
for (uid, access) in discovered {
|
for (uid, access) in discovered {
|
||||||
if let Err(error) = self.db.set_share_access(&uid, access) {
|
self.outbox
|
||||||
warn!(%uid, ?access, %error, "db set hydrated share access failed");
|
.push(DbWrite::SetShareAccess(uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(uid, access);
|
||||||
self.share_access.insert(uid, access);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
||||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
|
|
||||||
}
|
|
||||||
self.intern_mem(parent, node)
|
self.intern_mem(parent, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,9 +827,7 @@ impl State {
|
|||||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||||
/// listing, rather than one autocommit — and one fsync — per child.
|
/// listing, rather than one autocommit — and one fsync — per child.
|
||||||
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||||
if let Err(e) = self.db.upsert_nodes(&nodes) {
|
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||||
warn!(error = %e, "db upsert_nodes failed");
|
|
||||||
}
|
|
||||||
nodes
|
nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|node| self.intern_mem(parent, node))
|
.map(|node| self.intern_mem(parent, node))
|
||||||
@@ -769,9 +909,7 @@ impl State {
|
|||||||
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
||||||
/// the kernel.
|
/// the kernel.
|
||||||
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
||||||
if let Err(e) = self.db.delete_node(uid) {
|
self.outbox.push(DbWrite::Delete(uid.clone()));
|
||||||
warn!(%uid, error = %e, "db delete_node failed");
|
|
||||||
}
|
|
||||||
self.forget_mem(uid)
|
self.forget_mem(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,9 +972,7 @@ impl State {
|
|||||||
kids.push(ino);
|
kids.push(ino);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
self.outbox.push(DbWrite::Upsert(vec![node]));
|
||||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed for a queued rename");
|
|
||||||
}
|
|
||||||
self.recompute_descendant_access(ino);
|
self.recompute_descendant_access(ino);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,9 +987,7 @@ impl State {
|
|||||||
self.children.remove(&ino);
|
self.children.remove(&ino);
|
||||||
if let Some(e) = self.entries.get(&ino) {
|
if let Some(e) = self.entries.get(&ino) {
|
||||||
let uid = e.uid.clone();
|
let uid = e.uid.clone();
|
||||||
if let Err(err) = self.db.set_listed(&uid, false) {
|
self.outbox.push(DbWrite::SetListed(uid, false));
|
||||||
warn!(%uid, error = %err, "db set_listed(false) failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,14 +1054,15 @@ impl State {
|
|||||||
/// file is as long as the caller was told it is. Without it a restart serves
|
/// file is as long as the caller was told it is. Without it a restart serves
|
||||||
/// the stale size and the file reads as truncated — or empty — while its
|
/// the stale size and the file reads as truncated — or empty — while its
|
||||||
/// bytes sit safely in staging (offline.md Phase 3).
|
/// bytes sit safely in staging (offline.md Phase 3).
|
||||||
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) {
|
/// Returns the node to persist rather than queueing it in the outbox, and is
|
||||||
|
/// the one mutation here whose write-through the caller must do itself:
|
||||||
|
/// failure has to be reported, not logged. Dropping the row silently means
|
||||||
|
/// the daemon acknowledged a write and then serves the old size for it, so
|
||||||
|
/// the caller has to be able to turn it into a failed `release`.
|
||||||
|
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) -> Option<Node> {
|
||||||
self.set_size(ino, size);
|
self.set_size(ino, size);
|
||||||
self.touch_mtime(ino, mtime);
|
self.touch_mtime(ino, mtime);
|
||||||
if let Some(e) = self.entries.get(&ino)
|
self.entries.get(&ino).map(|e| e.node.clone())
|
||||||
&& let Err(err) = self.db.upsert_node(&e.node)
|
|
||||||
{
|
|
||||||
warn!(uid = %e.uid, error = %err, "db upsert_node failed for a queued write");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,18 +1143,7 @@ mod tests {
|
|||||||
let dir = TempDir::new();
|
let dir = TempDir::new();
|
||||||
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
||||||
let share_access = db.all_share_access().unwrap();
|
let share_access = db.all_share_access().unwrap();
|
||||||
let st = State {
|
let st = State::new(Arc::new(db), share_access, 1);
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 1,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
db: Arc::new(db),
|
|
||||||
};
|
|
||||||
(st, dir)
|
(st, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,8 +1156,8 @@ mod tests {
|
|||||||
let f = st.intern(src, node("f", "src", "f.txt", false));
|
let f = st.intern(src, node("f", "src", "f.txt", false));
|
||||||
st.children.insert(src, vec![f]);
|
st.children.insert(src, vec![f]);
|
||||||
st.children.insert(dst, vec![]);
|
st.children.insert(dst, vec![]);
|
||||||
st.db.set_listed(&uid("src"), true).unwrap();
|
st.flushed_db().set_listed(&uid("src"), true).unwrap();
|
||||||
st.db.set_listed(&uid("dst"), true).unwrap();
|
st.flushed_db().set_listed(&uid("dst"), true).unwrap();
|
||||||
(st, dir, src, dst)
|
(st, dir, src, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,11 +1171,17 @@ mod tests {
|
|||||||
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
st.db.children_if_listed(&uid("dst")).unwrap().is_none(),
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("dst"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"destination re-enumerates: it gained a child whose row was just deleted"
|
"destination re-enumerates: it gained a child whose row was just deleted"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
st.db.children_if_listed(&uid("src")).unwrap().is_none(),
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("src"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"source re-enumerates: its listing predates the move, and a stale \
|
"source re-enumerates: its listing predates the move, and a stale \
|
||||||
`listed` flag would hide every later remote change under it"
|
`listed` flag would hide every later remote change under it"
|
||||||
);
|
);
|
||||||
@@ -1074,12 +1204,41 @@ mod tests {
|
|||||||
let (mut st, _dir, src, _dst) = two_folders();
|
let (mut st, _dir, src, _dst) = two_folders();
|
||||||
let f = st.by_uid[&uid("f")];
|
let f = st.by_uid[&uid("f")];
|
||||||
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
||||||
assert!(st.db.children_if_listed(&uid("src")).unwrap().is_none());
|
assert!(
|
||||||
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("src"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
assert!(!st.children.contains_key(&src));
|
assert!(!st.children.contains_key(&src));
|
||||||
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
||||||
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DB1: a mutation's write-through must not happen with the inode lock
|
||||||
|
/// held. Interning a listing commits a transaction over every child, and
|
||||||
|
/// every other FUSE callback used to wait behind that for no reason — the
|
||||||
|
/// maps it needs were updated before the first row was written.
|
||||||
|
#[test]
|
||||||
|
fn a_mutation_writes_through_only_after_the_lock_is_released() {
|
||||||
|
let (st, _dir) = state();
|
||||||
|
let db = st.db.clone();
|
||||||
|
let root = uid("root").to_string();
|
||||||
|
let state = parking_lot::Mutex::new(st);
|
||||||
|
{
|
||||||
|
let mut guard = lock_state(&state, &db);
|
||||||
|
guard.intern(0, node("root", "none", "My Files", true));
|
||||||
|
assert!(
|
||||||
|
db.node_by_uid(&root).unwrap().is_none(),
|
||||||
|
"the row must not be written while the lock is held"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
db.node_by_uid(&root).unwrap().is_some(),
|
||||||
|
"…and must be written by the time the guard is gone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The failure mode the fix exists for, stated directly: forgetting the node
|
/// The failure mode the fix exists for, stated directly: forgetting the node
|
||||||
/// prunes it from the resident listing but leaves the DB claiming the source
|
/// prunes it from the resident listing but leaves the DB claiming the source
|
||||||
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
||||||
@@ -1087,7 +1246,7 @@ mod tests {
|
|||||||
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
||||||
let (mut st, _dir, _src, _dst) = two_folders();
|
let (mut st, _dir, _src, _dst) = two_folders();
|
||||||
st.forget(&uid("f"));
|
st.forget(&uid("f"));
|
||||||
let listed = st.db.children_if_listed(&uid("src")).unwrap();
|
let listed = st.flushed_db().children_if_listed(&uid("src")).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
listed.is_some(),
|
listed.is_some(),
|
||||||
"forget does not clear the flag — which is exactly why relocate must"
|
"forget does not clear the flag — which is exactly why relocate must"
|
||||||
@@ -1136,7 +1295,7 @@ mod tests {
|
|||||||
assert!(!st.has_children(folder), "cleared memory children");
|
assert!(!st.has_children(folder), "cleared memory children");
|
||||||
|
|
||||||
// DB children
|
// DB children
|
||||||
st.db
|
st.flushed_db()
|
||||||
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
|
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1162,7 +1321,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
||||||
assert_eq!(st.entries[&child].access, Access::Editor);
|
assert_eq!(st.entries[&child].access, Access::Editor);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Editor)
|
Some(Access::Editor)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1174,8 +1333,8 @@ mod tests {
|
|||||||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||||||
let mut virtual_root = node("placeholder", "root", "Shared with me", true);
|
let mut virtual_root = node("placeholder", "root", "Shared with me", true);
|
||||||
virtual_root.uid = virtual_uid.clone();
|
virtual_root.uid = virtual_uid.clone();
|
||||||
st.db.upsert_node(&virtual_root).unwrap();
|
st.flushed_db().upsert_node(&virtual_root).unwrap();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&virtual_uid, Access::Viewer)
|
.set_share_access(&virtual_uid, Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer);
|
let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer);
|
||||||
@@ -1183,15 +1342,17 @@ mod tests {
|
|||||||
let mut shared = node("shared", "placeholder", "Retained", true);
|
let mut shared = node("shared", "placeholder", "Retained", true);
|
||||||
shared.parent_uid = Some(virtual_uid.clone());
|
shared.parent_uid = Some(virtual_uid.clone());
|
||||||
shared.membership = Some(membership(6));
|
shared.membership = Some(membership(6));
|
||||||
st.db.upsert_node(&shared).unwrap();
|
st.flushed_db().upsert_node(&shared).unwrap();
|
||||||
st.db.set_share_access(&shared.uid, Access::Editor).unwrap();
|
st.flushed_db()
|
||||||
|
.set_share_access(&shared.uid, Access::Editor)
|
||||||
|
.unwrap();
|
||||||
let shared_ino =
|
let shared_ino =
|
||||||
st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor);
|
st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor);
|
||||||
let child = st.intern(
|
let child = st.intern(
|
||||||
shared_ino,
|
shared_ino,
|
||||||
node("shared-child", "shared", "pending.txt", false),
|
node("shared-child", "shared", "pending.txt", false),
|
||||||
);
|
);
|
||||||
st.db
|
st.flushed_db()
|
||||||
.enqueue_op(&pdfs_core::db::PendingOp {
|
.enqueue_op(&pdfs_core::db::PendingOp {
|
||||||
id: 0,
|
id: 0,
|
||||||
kind: pdfs_core::db::OP_RENAME.to_string(),
|
kind: pdfs_core::db::OP_RENAME.to_string(),
|
||||||
@@ -1209,20 +1370,20 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
||||||
assert_eq!(st.entries[&child].access, Access::Editor);
|
assert_eq!(st.entries[&child].access, Access::Editor);
|
||||||
|
|
||||||
st.db
|
st.flushed_db()
|
||||||
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
|
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let snapshot = st.db.visible_children(&virtual_uid).unwrap();
|
let snapshot = st.flushed_db().visible_children(&virtual_uid).unwrap();
|
||||||
assert_eq!(snapshot.len(), 1);
|
assert_eq!(snapshot.len(), 1);
|
||||||
st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer);
|
st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&shared.uid).unwrap(),
|
st.flushed_db().share_access(&shared.uid).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
assert_eq!(st.entries[&shared_ino].access, Access::Viewer);
|
assert_eq!(st.entries[&shared_ino].access, Access::Viewer);
|
||||||
assert_eq!(st.entries[&child].access, Access::Viewer);
|
assert_eq!(st.entries[&child].access, Access::Viewer);
|
||||||
assert_eq!(st.db.pending_ops().unwrap().len(), 1);
|
assert_eq!(st.flushed_db().pending_ops().unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1233,12 +1394,12 @@ mod tests {
|
|||||||
let child_node = node("offline-child", "offline-share", "nested folder", true);
|
let child_node = node("offline-child", "offline-share", "nested folder", true);
|
||||||
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
|
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
|
||||||
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
|
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
|
||||||
st.db.upsert_node(node).unwrap();
|
st.flushed_db().upsert_node(node).unwrap();
|
||||||
}
|
}
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("offline-share"), Access::Viewer)
|
.set_share_access(&uid("offline-share"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
|
|
||||||
// Reconstruct in deliberately non-topological order, matching the
|
// Reconstruct in deliberately non-topological order, matching the
|
||||||
// database hydration path that first allocates every inode and only
|
// database hydration path that first allocates every inode and only
|
||||||
@@ -1296,7 +1457,7 @@ mod tests {
|
|||||||
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
|
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
|
||||||
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
|
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
st.db.upsert_node(node).unwrap();
|
st.flushed_db().upsert_node(node).unwrap();
|
||||||
}
|
}
|
||||||
let inos: Vec<u64> = nodes
|
let inos: Vec<u64> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1394,10 +1555,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn persisted_authority_outranks_the_own_volume_fail_open() {
|
fn persisted_authority_outranks_the_own_volume_fail_open() {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("device-root"), Access::Viewer)
|
.set_share_access(&uid("device-root"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
let nodes = [
|
let nodes = [
|
||||||
node("root", "none", "My Files", true),
|
node("root", "none", "My Files", true),
|
||||||
node("documents", "device-root", "Documents", true),
|
node("documents", "device-root", "Documents", true),
|
||||||
@@ -1431,7 +1592,7 @@ mod tests {
|
|||||||
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
|
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1441,8 +1602,10 @@ mod tests {
|
|||||||
for persisted in [Access::Viewer, Access::Editor] {
|
for persisted in [Access::Viewer, Access::Editor] {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
||||||
st.db.set_share_access(&uid("shared"), persisted).unwrap();
|
st.flushed_db()
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
.set_share_access(&uid("shared"), persisted)
|
||||||
|
.unwrap();
|
||||||
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
let shared = st.intern(
|
let shared = st.intern(
|
||||||
owned_root,
|
owned_root,
|
||||||
node("shared", "root", "Shared without membership", true),
|
node("shared", "root", "Shared without membership", true),
|
||||||
@@ -1454,7 +1617,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(st.entries[&shared].access, persisted);
|
assert_eq!(st.entries[&shared].access, persisted);
|
||||||
assert_eq!(st.entries[&child].access, persisted);
|
assert_eq!(st.entries[&child].access, persisted);
|
||||||
assert_eq!(st.db.share_access(&uid("shared")).unwrap(), Some(persisted));
|
assert_eq!(
|
||||||
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
|
Some(persisted)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,7 +1628,7 @@ mod tests {
|
|||||||
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
|
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("shared"), Access::Editor)
|
.set_share_access(&uid("shared"), Access::Editor)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
|
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
|
||||||
@@ -1474,7 +1640,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||||
assert_eq!(st.entries[&child].access, Access::Viewer);
|
assert_eq!(st.entries[&child].access, Access::Viewer);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
assert!(changed.contains(&shared));
|
assert!(changed.contains(&shared));
|
||||||
@@ -1491,7 +1657,7 @@ mod tests {
|
|||||||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||||||
let mut virtual_node = node("sharedwithme", "root", "Shared with me", true);
|
let mut virtual_node = node("sharedwithme", "root", "Shared with me", true);
|
||||||
virtual_node.uid = virtual_uid.clone();
|
virtual_node.uid = virtual_uid.clone();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&virtual_uid, Access::Viewer)
|
.set_share_access(&virtual_uid, Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access.insert(virtual_uid.clone(), Access::Viewer);
|
st.share_access.insert(virtual_uid.clone(), Access::Viewer);
|
||||||
@@ -1558,7 +1724,7 @@ mod tests {
|
|||||||
let child = st.intern(shared, node("child", "shared", "folder", true));
|
let child = st.intern(shared, node("child", "shared", "folder", true));
|
||||||
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
|
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
|
||||||
|
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("shared"), Access::Viewer)
|
.set_share_access(&uid("shared"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let changed = st.downgrade_shared_subtree(&uid("shared"));
|
let changed = st.downgrade_shared_subtree(&uid("shared"));
|
||||||
@@ -1572,7 +1738,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&leaf].access, Access::Viewer);
|
assert_eq!(st.entries[&leaf].access, Access::Viewer);
|
||||||
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
|
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.effective_node_access(&uid("leaf")).unwrap(),
|
st.flushed_db().effective_node_access(&uid("leaf")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1628,7 +1794,7 @@ mod tests {
|
|||||||
let shared = st.intern(owned_root, shared_node);
|
let shared = st.intern(owned_root, shared_node);
|
||||||
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
|
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
|
||||||
|
|
||||||
st.db.downgrade_all_share_access().unwrap();
|
st.flushed_db().downgrade_all_share_access().unwrap();
|
||||||
let changed = st.downgrade_known_shared_access();
|
let changed = st.downgrade_known_shared_access();
|
||||||
|
|
||||||
assert!(changed.contains(&shared));
|
assert!(changed.contains(&shared));
|
||||||
@@ -1652,7 +1818,7 @@ mod tests {
|
|||||||
let parent_link = st.entries[&parent].uid.link_id.to_string();
|
let parent_link = st.entries[&parent].uid.link_id.to_string();
|
||||||
parent = st.intern(parent, node(&link, &parent_link, &link, true));
|
parent = st.intern(parent, node(&link, &parent_link, &link, true));
|
||||||
}
|
}
|
||||||
st.db.downgrade_all_share_access().unwrap();
|
st.flushed_db().downgrade_all_share_access().unwrap();
|
||||||
|
|
||||||
let changed = st.downgrade_known_shared_access();
|
let changed = st.downgrade_known_shared_access();
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use pdfs_core::batch;
|
||||||
use pdfs_core::config::SweepMode;
|
use pdfs_core::config::SweepMode;
|
||||||
use pdfs_core::control::ActivityKind;
|
use pdfs_core::control::ActivityKind;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
@@ -137,9 +138,9 @@ impl Core {
|
|||||||
/// enumerated every node up front and has been doing network I/O since. Before
|
/// enumerated every node up front and has been doing network I/O since. Before
|
||||||
/// touching anything remote this re-reads the node and refuses to act unless
|
/// touching anything remote this re-reads the node and refuses to act unless
|
||||||
/// it is *still* the same revision, still untrashed, still unwritten and still
|
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||||
/// unopened. Without that, a user edit landing inside the window is destroyed
|
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
||||||
/// — `discard_queued_ops` below drops the queued write *and its staged blob*,
|
/// or its staged blob: a write that arrives after that check is left for the
|
||||||
/// which may hold the only copy of those bytes (`docs/BUGS.md` B71).
|
/// drain to resolve against the now-trashed node.
|
||||||
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||||
let uid = &snapshot.uid;
|
let uid = &snapshot.uid;
|
||||||
let name = snapshot.name.as_str();
|
let name = snapshot.name.as_str();
|
||||||
@@ -149,6 +150,7 @@ impl Core {
|
|||||||
match self
|
match self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
||||||
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) if super::is_gone(&e) => {}
|
Err(e) if super::is_gone(&e) => {}
|
||||||
@@ -164,10 +166,13 @@ impl Core {
|
|||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
st.forget_or_unlink(uid);
|
st.forget_or_unlink(uid);
|
||||||
});
|
});
|
||||||
if let Err(error) = self.discard_queued_ops(uid) {
|
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
||||||
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
|
// proved the queue empty before the trash above, but that proof is stale
|
||||||
return;
|
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
||||||
}
|
// that window stages bytes, and discarding here would unlink the staged
|
||||||
|
// blob holding the only copy of them. Leaving the op queued is the
|
||||||
|
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
||||||
|
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
if let Err(e) = self.db.delete_node(uid) {
|
if let Err(e) = self.db.delete_node(uid) {
|
||||||
@@ -192,8 +197,8 @@ impl Core {
|
|||||||
let uid = &snapshot.uid;
|
let uid = &snapshot.uid;
|
||||||
let name = snapshot.name.as_str();
|
let name = snapshot.name.as_str();
|
||||||
|
|
||||||
// A queued op means bytes are still owed an upload. `discard_queued_ops`
|
// A queued op means bytes are still owed an upload; trashing the node
|
||||||
// would throw those away along with the staged blob holding them.
|
// under them turns an ordinary upload into a conflict fork.
|
||||||
match self.db.has_any_op(&uid.to_string()) {
|
match self.db.has_any_op(&uid.to_string()) {
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use tracing::{debug, info, warn};
|
|||||||
|
|
||||||
use crate::transfers::{CountingWriter, OwnedCountingReader};
|
use crate::transfers::{CountingWriter, OwnedCountingReader};
|
||||||
use crate::{Core, now_secs, parse_uid};
|
use crate::{Core, now_secs, parse_uid};
|
||||||
|
use pdfs_core::batch;
|
||||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||||
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||||
use pdfs_core::syncignore::IgnoreRules;
|
use pdfs_core::syncignore::IgnoreRules;
|
||||||
@@ -513,7 +514,11 @@ impl Core {
|
|||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) {
|
if let Err(e) = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.trash_nodes(&[uid]))
|
||||||
|
.and_then(batch::into_unit)
|
||||||
|
{
|
||||||
warn!(rel, error = %e, "sync: trash remote failed");
|
warn!(rel, error = %e, "sync: trash remote failed");
|
||||||
self.log_activity(ActivityKind::Trash, base_name(rel), e.to_string(), false);
|
self.log_activity(ActivityKind::Trash, base_name(rel), e.to_string(), false);
|
||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
@@ -754,6 +759,7 @@ impl Core {
|
|||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&remote.uid)))
|
.block_on(self.client.trash_nodes(std::slice::from_ref(&remote.uid)))
|
||||||
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
warn!(rel, error = %e, "sync: trash remote failed");
|
warn!(rel, error = %e, "sync: trash remote failed");
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
@@ -808,7 +814,11 @@ impl Core {
|
|||||||
}
|
}
|
||||||
delete_remote_dirs.sort_by_key(|(p, _)| std::cmp::Reverse(p.matches('/').count()));
|
delete_remote_dirs.sort_by_key(|(p, _)| std::cmp::Reverse(p.matches('/').count()));
|
||||||
for (rel, uid) in delete_remote_dirs {
|
for (rel, uid) in delete_remote_dirs {
|
||||||
if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) {
|
if let Err(e) = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.trash_nodes(&[uid]))
|
||||||
|
.and_then(batch::into_unit)
|
||||||
|
{
|
||||||
warn!(rel, error = %e, "sync: trash remote folder failed");
|
warn!(rel, error = %e, "sync: trash remote folder failed");
|
||||||
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ impl Core {
|
|||||||
stats.bytes += size;
|
stats.bytes += size;
|
||||||
match self.fetch_node(&uid) {
|
match self.fetch_node(&uid) {
|
||||||
Ok(node) => {
|
Ok(node) => {
|
||||||
let mut st = self.state.lock();
|
let mut st = self.state();
|
||||||
let ino = st.intern(parent_ino, node);
|
let ino = st.intern(parent_ino, node);
|
||||||
if let Some(kids) = st.children.get_mut(&parent_ino)
|
if let Some(kids) = st.children.get_mut(&parent_ino)
|
||||||
&& !kids.contains(&ino)
|
&& !kids.contains(&ino)
|
||||||
@@ -216,7 +216,7 @@ impl Core {
|
|||||||
self.ensure_children(pino)
|
self.ensure_children(pino)
|
||||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
let existing = {
|
let existing = {
|
||||||
let st = self.state.lock();
|
let st = self.state();
|
||||||
st.children.get(&pino).and_then(|kids| {
|
st.children.get(&pino).and_then(|kids| {
|
||||||
kids.iter().find_map(|ino| {
|
kids.iter().find_map(|ino| {
|
||||||
st.entries
|
st.entries
|
||||||
@@ -243,7 +243,7 @@ impl Core {
|
|||||||
let node = self
|
let node = self
|
||||||
.fetch_node(&new_uid)
|
.fetch_node(&new_uid)
|
||||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||||
let mut st = self.state.lock();
|
let mut st = self.state();
|
||||||
let ino = st.intern(pino, node);
|
let ino = st.intern(pino, node);
|
||||||
if let Some(kids) = st.children.get_mut(&pino)
|
if let Some(kids) = st.children.get_mut(&pino)
|
||||||
&& !kids.contains(&ino)
|
&& !kids.contains(&ino)
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
//! Shared Drive-result activation policy for the GTK browser and quick prompt.
|
//! Shared Drive-result activation policy for the GTK browser and quick prompt.
|
||||||
//!
|
//!
|
||||||
//! Media should be handed to its mounted path so players can issue range reads
|
//! Media should be handed to its mounted path so players can issue range reads
|
||||||
//! through FUSE. Other files retain the materialize-then-open behaviour because
|
//! through FUSE. Everything else opens through the mount too whenever the mount
|
||||||
//! many desktop applications expect a stable ordinary file.
|
//! exposes it, and only falls back to materialize-then-open when it does not —
|
||||||
|
//! see [`mounted_target`].
|
||||||
|
//!
|
||||||
|
//! Both binaries `#[path]`-include this file, so each compiles its own copy and
|
||||||
|
//! anything only one of them calls looks dead in the other. That is what the
|
||||||
|
//! `dead_code` allowances below are for — not code nobody uses.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use pdfs_core::control::PhotoKind;
|
use pdfs_core::control::{PhotoKind, SearchHit};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum DriveActivation {
|
pub(crate) enum DriveActivation {
|
||||||
@@ -29,6 +34,49 @@ pub(crate) fn mounted_path(mountpoint: &Path, relative_path: &str) -> PathBuf {
|
|||||||
mountpoint.join(relative_path)
|
mountpoint.join(relative_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a Drive hit should be opened from, when a mount exposes it.
|
||||||
|
///
|
||||||
|
/// Materialised content is a *copy*: it lives in the cache under its content
|
||||||
|
/// hash, so an editor saving into it writes somewhere Drive will never look,
|
||||||
|
/// and the cache is free to evict it afterwards. The mounted path is the real
|
||||||
|
/// file — reads pull 4 MiB blocks on demand and a write becomes a new revision
|
||||||
|
/// — so it wins for every kind of file, not just streamable media.
|
||||||
|
///
|
||||||
|
/// [`Request::OpenFile`](pdfs_core::control::Request::OpenFile) stays the
|
||||||
|
/// fallback for hits no mount exposes: the search index also covers nodes
|
||||||
|
/// outside the mounted tree (a mirror folder's contents, a stale row), and
|
||||||
|
/// those can still be read, just not edited in place.
|
||||||
|
///
|
||||||
|
/// The `stat` is why this returns an `Option` rather than a path: it is the
|
||||||
|
/// only way to tell those two cases apart, and it costs one syscall on a path
|
||||||
|
/// the caller is about to hand to an application anyway.
|
||||||
|
#[allow(dead_code)] // prompt only
|
||||||
|
pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option<PathBuf> {
|
||||||
|
present(mounted_or_relative(mountpoint, hit))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`mounted_target`] for callers holding a mountpoint-relative path rather
|
||||||
|
/// than a search hit — the browser's listing entries, which are by definition
|
||||||
|
/// paths in the primary mount's tree.
|
||||||
|
#[allow(dead_code)] // browser only
|
||||||
|
pub(crate) fn mounted_target_rel(mountpoint: &Path, relative_path: &str) -> Option<PathBuf> {
|
||||||
|
present(mounted_path(mountpoint, relative_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn present(path: PathBuf) -> Option<PathBuf> {
|
||||||
|
path.symlink_metadata().is_ok().then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mount path for a hit, whether or not anything is there — for callers
|
||||||
|
/// that have no fallback (a folder cannot be materialised) or already know the
|
||||||
|
/// entry came from a mount listing.
|
||||||
|
#[allow(dead_code)] // prompt only
|
||||||
|
pub(crate) fn mounted_or_relative(mountpoint: &Path, hit: &SearchHit) -> PathBuf {
|
||||||
|
hit.mounted_path
|
||||||
|
.as_deref()
|
||||||
|
.map_or_else(|| mounted_path(mountpoint, &hit.path), PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_streamable_media(name: &str) -> bool {
|
fn is_streamable_media(name: &str) -> bool {
|
||||||
if PhotoKind::classify(Some(name), None) == PhotoKind::Video {
|
if PhotoKind::classify(Some(name), None) == PhotoKind::Video {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use pages::trash::*;
|
|||||||
use pages::verify::*;
|
use pages::verify::*;
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
|
use widgets::versions_dialog::*;
|
||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
|
|
||||||
@@ -424,6 +425,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
dates: gallery_widgets.dates.clone(),
|
dates: gallery_widgets.dates.clone(),
|
||||||
date_ranges: RefCell::new(vec![None]),
|
date_ranges: RefCell::new(vec![None]),
|
||||||
range: Cell::new(None),
|
range: Cell::new(None),
|
||||||
|
favorites_btn: gallery_widgets.favorites_btn.clone(),
|
||||||
|
favorites: Cell::new(false),
|
||||||
date_suppress: Cell::new(false),
|
date_suppress: Cell::new(false),
|
||||||
loading: Cell::new(false),
|
loading: Cell::new(false),
|
||||||
width: Cell::new(0),
|
width: Cell::new(0),
|
||||||
@@ -993,11 +996,18 @@ fn capitalize(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a local path with the user's default handler.
|
/// Open a local path with the handler the user configured — `xdg-open` unless
|
||||||
|
/// `open_with` in `config.json` overrides it for this kind of file.
|
||||||
fn open_path(path: &str) {
|
fn open_path(path: &str) {
|
||||||
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
|
let path = Path::new(path);
|
||||||
tracing::error!("xdg-open {path} failed: {e}");
|
pdfs_core::opener::open_default(path, path.is_dir());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`open_path`] for a file the daemon materialised into the content cache,
|
||||||
|
/// where the on-disk name is a content hash and carries no extension for the
|
||||||
|
/// rules (or `xdg-open`) to key off. `name` is the Drive name it was opened as.
|
||||||
|
pub(crate) fn open_named_path(path: &str, name: &str) {
|
||||||
|
pdfs_core::opener::open_default_named(Path::new(path), name, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
|
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::activation::{DriveActivation, drive_activation, mounted_path};
|
use crate::activation::{DriveActivation, drive_activation, mounted_path, mounted_target_rel};
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct BrowserState {
|
pub(crate) struct BrowserState {
|
||||||
@@ -659,21 +659,35 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a file's full content into the cache and hand it to the user's
|
/// Hand a file to the user's default application: through the mount when it is
|
||||||
/// default application. The download-and-open path behind both a plain
|
/// there, otherwise downloaded into the cache first. The open path behind both
|
||||||
/// double-click and the context menu's "Open" (including for a video, when the
|
/// a plain double-click and the context menu's "Open" (including for a video,
|
||||||
/// user explicitly wants a local copy rather than to stream it).
|
/// when the user wants their default application rather than the player).
|
||||||
|
///
|
||||||
|
/// The mount comes first because the cache blob is a *copy* keyed by content
|
||||||
|
/// hash — an application saving into it writes where Drive will never look, and
|
||||||
|
/// the cache may evict it afterwards. See
|
||||||
|
/// [`mounted_target`](crate::activation::mounted_target).
|
||||||
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
|
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||||
|
if let Some(path) = mounted_target_rel(&mountpoint, &rel) {
|
||||||
|
open_path(&path.to_string_lossy());
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Ignore a repeat activation of a file already downloading, so an impatient
|
// Ignore a repeat activation of a file already downloading, so an impatient
|
||||||
// double-click doesn't kick off a second round-trip.
|
// double-click doesn't kick off a second round-trip.
|
||||||
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
|
let name = entry.name.clone();
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
ui.dirs.control_socket(),
|
ui.dirs.control_socket(),
|
||||||
Request::OpenFile { path: rel.clone() },
|
Request::OpenFile {
|
||||||
|
path: rel.clone(),
|
||||||
|
uid: Some(entry.uid.clone()),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -681,7 +695,9 @@ pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
ui.opening.borrow_mut().remove(&rel);
|
ui.opening.borrow_mut().remove(&rel);
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
// A cache blob is named by content hash: match the open rules
|
||||||
|
// against the Drive name the user clicked, not that path.
|
||||||
|
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't open file", &message, kind)
|
toast_failure(&ui, "Couldn't open file", &message, kind)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ pub(crate) struct Viewer {
|
|||||||
pub(crate) info_map: gtk4::Button,
|
pub(crate) info_map: gtk4::Button,
|
||||||
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
|
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
|
||||||
pub(crate) coords: RefCell<Option<(f64, f64)>>,
|
pub(crate) coords: RefCell<Option<(f64, f64)>>,
|
||||||
|
/// The favourite toggle in the top bar, and whether it is being set from
|
||||||
|
/// the model rather than by the user — the same suppression the details pane
|
||||||
|
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||||
|
pub(crate) favorite: gtk4::ToggleButton,
|
||||||
|
pub(crate) favorite_suppress: Cell<bool>,
|
||||||
/// uid of the photo currently on screen.
|
/// uid of the photo currently on screen.
|
||||||
pub(crate) uid: RefCell<String>,
|
pub(crate) uid: RefCell<String>,
|
||||||
/// On-disk path of the full-size photo, once it has been downloaded.
|
/// On-disk path of the full-size photo, once it has been downloaded.
|
||||||
@@ -41,6 +46,38 @@ pub(crate) struct ExifInfo {
|
|||||||
pub(crate) coords: Option<(f64, f64)>,
|
pub(crate) coords: Option<(f64, f64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paint the favourite toggle without firing its handler.
|
||||||
|
pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
|
||||||
|
viewer.favorite_suppress.set(true);
|
||||||
|
viewer.favorite.set_active(favorite);
|
||||||
|
set_favorite_icon(&viewer.favorite, favorite);
|
||||||
|
viewer.favorite_suppress.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filled star for a favourite, outline for the rest.
|
||||||
|
fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) {
|
||||||
|
button.set_icon_name(if favorite {
|
||||||
|
"starred-symbolic"
|
||||||
|
} else {
|
||||||
|
"non-starred-symbolic"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a favourite change back into the loaded gallery page, so returning to
|
||||||
|
/// the grid (or reopening the photo) shows what was just set without a reload.
|
||||||
|
fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
||||||
|
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut item = boxed.borrow::<PhotoItem>().clone();
|
||||||
|
item.favorite = favorite;
|
||||||
|
ui.gallery.model.remove(idx);
|
||||||
|
ui.gallery.model.insert(idx, &BoxedAnyObject::new(item));
|
||||||
|
}
|
||||||
|
|
||||||
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
|
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
|
||||||
/// so the lightbox never opens on a blank screen, ask the daemon for the
|
/// so the lightbox never opens on a blank screen, ask the daemon for the
|
||||||
/// full-size file, and swap it in — plus its EXIF — when it lands.
|
/// full-size file, and swap it in — plus its EXIF — when it lands.
|
||||||
@@ -52,6 +89,14 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
*viewer.path.borrow_mut() = None;
|
*viewer.path.borrow_mut() = None;
|
||||||
clear_info(viewer);
|
clear_info(viewer);
|
||||||
|
|
||||||
|
// The favourite state comes from the timeline page the gallery already has,
|
||||||
|
// so the button is right on the first frame instead of after a round-trip.
|
||||||
|
let favorite = find_photo_index(&ui.gallery.model, &uid)
|
||||||
|
.and_then(|idx| ui.gallery.model.item(idx))
|
||||||
|
.and_downcast::<BoxedAnyObject>()
|
||||||
|
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
|
||||||
|
show_favorite(viewer, favorite);
|
||||||
|
|
||||||
// The thumbnail the gallery already decoded stands in for the full photo
|
// The thumbnail the gallery already decoded stands in for the full photo
|
||||||
// while it downloads: blurry for a moment beats black for a second.
|
// while it downloads: blurry for a moment beats black for a second.
|
||||||
match ui.gallery.photo_tex.borrow().get(&uid) {
|
match ui.gallery.photo_tex.borrow().get(&uid) {
|
||||||
@@ -450,6 +495,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
info_toggle.add_css_class("flat");
|
info_toggle.add_css_class("flat");
|
||||||
info_toggle.add_css_class("viewer-action-btn");
|
info_toggle.add_css_class("viewer-action-btn");
|
||||||
|
|
||||||
|
let favorite_btn = gtk4::ToggleButton::builder()
|
||||||
|
.icon_name("non-starred-symbolic")
|
||||||
|
.tooltip_text("Favourite")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
favorite_btn.add_css_class("flat");
|
||||||
|
favorite_btn.add_css_class("viewer-action-btn");
|
||||||
|
|
||||||
let download_btn = action("document-save-symbolic", "Save a copy…");
|
let download_btn = action("document-save-symbolic", "Save a copy…");
|
||||||
let open_ext_btn = action("document-open-symbolic", "Open with another app");
|
let open_ext_btn = action("document-open-symbolic", "Open with another app");
|
||||||
let close_btn = action("window-close-symbolic", "Close (Esc)");
|
let close_btn = action("window-close-symbolic", "Close (Esc)");
|
||||||
@@ -460,6 +513,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
top_bar.set_valign(gtk4::Align::Start);
|
top_bar.set_valign(gtk4::Align::Start);
|
||||||
top_bar.set_hexpand(true);
|
top_bar.set_hexpand(true);
|
||||||
top_bar.append(&titles);
|
top_bar.append(&titles);
|
||||||
|
top_bar.append(&favorite_btn);
|
||||||
top_bar.append(&info_toggle);
|
top_bar.append(&info_toggle);
|
||||||
top_bar.append(&download_btn);
|
top_bar.append(&download_btn);
|
||||||
top_bar.append(&open_ext_btn);
|
top_bar.append(&open_ext_btn);
|
||||||
@@ -558,6 +612,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
info_revealer: info_revealer.clone(),
|
info_revealer: info_revealer.clone(),
|
||||||
info_rows,
|
info_rows,
|
||||||
info_map: info_map.clone(),
|
info_map: info_map.clone(),
|
||||||
|
favorite: favorite_btn.clone(),
|
||||||
|
favorite_suppress: Cell::new(false),
|
||||||
coords: RefCell::new(None),
|
coords: RefCell::new(None),
|
||||||
uid: RefCell::new(initial_uid.clone()),
|
uid: RefCell::new(initial_uid.clone()),
|
||||||
path: RefCell::new(None),
|
path: RefCell::new(None),
|
||||||
@@ -601,6 +657,44 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let ui_fav = ui.clone();
|
||||||
|
let viewer_fav = viewer.clone();
|
||||||
|
favorite_btn.connect_toggled(move |btn| {
|
||||||
|
if viewer_fav.favorite_suppress.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let favorite = btn.is_active();
|
||||||
|
set_favorite_icon(btn, favorite);
|
||||||
|
let uid = viewer_fav.uid.borrow().clone();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui_fav.dirs.control_socket(),
|
||||||
|
Request::SetPhotoFavorite {
|
||||||
|
uid: uid.clone(),
|
||||||
|
favorite,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let ui_result = ui_fav.clone();
|
||||||
|
let viewer_result = viewer_fav.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let failed = match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
set_gallery_favorite(&ui_result, &uid, favorite);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
||||||
|
_ => Some("The mount service didn't respond.".to_string()),
|
||||||
|
};
|
||||||
|
if let Some(detail) = failed {
|
||||||
|
toast_error(&ui_result, "Couldn't change the favourite", &detail);
|
||||||
|
// The server refused, so the button must go back to describing
|
||||||
|
// what is actually stored — without firing this handler again.
|
||||||
|
if *viewer_result.uid.borrow() == uid {
|
||||||
|
show_favorite(&viewer_result, !favorite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
let w_download = window.clone();
|
let w_download = window.clone();
|
||||||
let viewer_download = viewer.clone();
|
let viewer_download = viewer.clone();
|
||||||
download_btn.connect_clicked(move |_| {
|
download_btn.connect_clicked(move |_| {
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ pub(crate) struct GalleryState {
|
|||||||
/// The capture-time window the timeline is currently filtered to, or `None`
|
/// The capture-time window the timeline is currently filtered to, or `None`
|
||||||
/// for the whole span. Read by [`load_gallery`], set by the date dropdown.
|
/// for the whole span. Read by [`load_gallery`], set by the date dropdown.
|
||||||
pub(crate) range: Cell<Option<(i64, i64)>>,
|
pub(crate) range: Cell<Option<(i64, i64)>>,
|
||||||
|
/// The favourites toggle, and whether it is on. When on, the timeline is
|
||||||
|
/// restricted to photos carrying Proton's `Favorite` tag.
|
||||||
|
pub(crate) favorites_btn: gtk4::ToggleButton,
|
||||||
|
pub(crate) favorites: Cell<bool>,
|
||||||
/// Set while the date dropdown is being repopulated, so resetting its model
|
/// Set while the date dropdown is being repopulated, so resetting its model
|
||||||
/// doesn't fire the selection handler and kick off a spurious reload.
|
/// doesn't fire the selection handler and kick off a spurious reload.
|
||||||
pub(crate) date_suppress: Cell<bool>,
|
pub(crate) date_suppress: Cell<bool>,
|
||||||
@@ -188,6 +192,7 @@ pub(crate) struct GalleryWidgets {
|
|||||||
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
||||||
/// to [`kind_for_tab`]).
|
/// to [`kind_for_tab`]).
|
||||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||||
|
pub(crate) favorites_btn: gtk4::ToggleButton,
|
||||||
/// The date-jump dropdown, populated with the timeline's months.
|
/// The date-jump dropdown, populated with the timeline's months.
|
||||||
pub(crate) dates: gtk4::DropDown,
|
pub(crate) dates: gtk4::DropDown,
|
||||||
}
|
}
|
||||||
@@ -314,6 +319,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
tab_group.append(btn);
|
tab_group.append(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Favourites: a filter, not a tab — it cuts across Photos / Videos / Raw, so
|
||||||
|
// it stays outside the segmented control rather than becoming a fifth option
|
||||||
|
// that would silently drop the kind the user picked.
|
||||||
|
let favorites_btn = gtk4::ToggleButton::builder()
|
||||||
|
.icon_name("starred-symbolic")
|
||||||
|
.tooltip_text("Show only favourites")
|
||||||
|
.build();
|
||||||
|
favorites_btn.add_css_class("pill");
|
||||||
|
|
||||||
// Date jump: "All dates" plus a row per month, filled in once the timeline's
|
// Date jump: "All dates" plus a row per month, filled in once the timeline's
|
||||||
// months are known (see [`refresh_photo_months`]). Pushed to the far end of
|
// months are known (see [`refresh_photo_months`]). Pushed to the far end of
|
||||||
// the filter row, opposite the kind toggles.
|
// the filter row, opposite the kind toggles.
|
||||||
@@ -326,6 +340,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
filters.set_hexpand(true);
|
filters.set_hexpand(true);
|
||||||
filters.append(&tab_group);
|
filters.append(&tab_group);
|
||||||
|
filters.append(&favorites_btn);
|
||||||
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
spacer.set_hexpand(true);
|
spacer.set_hexpand(true);
|
||||||
filters.append(&spacer);
|
filters.append(&spacer);
|
||||||
@@ -415,6 +430,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
upload,
|
upload,
|
||||||
refresh,
|
refresh,
|
||||||
tabs,
|
tabs,
|
||||||
|
favorites_btn,
|
||||||
dates,
|
dates,
|
||||||
albums,
|
albums,
|
||||||
albums_stack,
|
albums_stack,
|
||||||
@@ -679,6 +695,19 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Favourites: reload the timeline restricted to favourites (or back to all).
|
||||||
|
// Independent of the kind tabs and the date jump, both of which keep their
|
||||||
|
// current value across the toggle.
|
||||||
|
let ui_fav = ui.clone();
|
||||||
|
ui.gallery.favorites_btn.connect_toggled(move |btn| {
|
||||||
|
let on = btn.is_active();
|
||||||
|
if ui_fav.gallery.favorites.get() == on {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui_fav.gallery.favorites.set(on);
|
||||||
|
load_gallery(&ui_fav, false);
|
||||||
|
});
|
||||||
|
|
||||||
// Date jump: selecting a month loads that window; "All dates" (row 0) clears
|
// Date jump: selecting a month loads that window; "All dates" (row 0) clears
|
||||||
// it. Skipped while the model is being repopulated (see `gallery_date_suppress`).
|
// it. Skipped while the model is being repopulated (see `gallery_date_suppress`).
|
||||||
let ui_dates = ui.clone();
|
let ui_dates = ui.clone();
|
||||||
@@ -1378,6 +1407,7 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
limit: PHOTOS_PAGE,
|
limit: PHOTOS_PAGE,
|
||||||
kind: ui.gallery.kind.get(),
|
kind: ui.gallery.kind.get(),
|
||||||
range: ui.gallery.range.get(),
|
range: ui.gallery.range.get(),
|
||||||
|
favorites: ui.gallery.favorites.get(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), request);
|
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||||
|
|||||||
@@ -388,12 +388,15 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
);
|
);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
let uid = uid.to_string();
|
let uid = uid.to_string();
|
||||||
|
let name = name.to_string();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
ui.opening.borrow_mut().remove(&uid);
|
ui.opening.borrow_mut().remove(&uid);
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
// A cache blob is named by content hash, so the open rules key off
|
||||||
|
// the shared node's name instead.
|
||||||
|
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't open file", &message, kind)
|
toast_failure(&ui, "Couldn't open file", &message, kind)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub(crate) struct DetailsWidgets {
|
|||||||
pub(crate) pin_row: adw::SwitchRow,
|
pub(crate) pin_row: adw::SwitchRow,
|
||||||
pub(crate) open_button: gtk4::Button,
|
pub(crate) open_button: gtk4::Button,
|
||||||
pub(crate) rename_button: gtk4::Button,
|
pub(crate) rename_button: gtk4::Button,
|
||||||
|
/// Files only — folders have no revision history.
|
||||||
|
pub(crate) versions_button: gtk4::Button,
|
||||||
pub(crate) trash_button: gtk4::Button,
|
pub(crate) trash_button: gtk4::Button,
|
||||||
pub(crate) close_button: gtk4::Button,
|
pub(crate) close_button: gtk4::Button,
|
||||||
}
|
}
|
||||||
@@ -96,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
open_button.add_css_class("pill");
|
open_button.add_css_class("pill");
|
||||||
let rename_button = gtk4::Button::builder().label("Rename").build();
|
let rename_button = gtk4::Button::builder().label("Rename").build();
|
||||||
rename_button.add_css_class("pill");
|
rename_button.add_css_class("pill");
|
||||||
|
let versions_button = gtk4::Button::builder().label("Versions").build();
|
||||||
|
versions_button.add_css_class("pill");
|
||||||
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
|
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
|
||||||
trash_button.add_css_class("destructive-action");
|
trash_button.add_css_class("destructive-action");
|
||||||
trash_button.add_css_class("pill");
|
trash_button.add_css_class("pill");
|
||||||
@@ -104,6 +108,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
actions.set_margin_top(6);
|
actions.set_margin_top(6);
|
||||||
actions.append(&open_button);
|
actions.append(&open_button);
|
||||||
actions.append(&rename_button);
|
actions.append(&rename_button);
|
||||||
|
actions.append(&versions_button);
|
||||||
actions.append(&trash_button);
|
actions.append(&trash_button);
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
@@ -137,6 +142,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
pin_row,
|
pin_row,
|
||||||
open_button,
|
open_button,
|
||||||
rename_button,
|
rename_button,
|
||||||
|
versions_button,
|
||||||
trash_button,
|
trash_button,
|
||||||
close_button,
|
close_button,
|
||||||
},
|
},
|
||||||
@@ -190,6 +196,16 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
|||||||
prompt_rename(&ui_rename, &entry);
|
prompt_rename(&ui_rename, &entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let ui_versions = ui.clone();
|
||||||
|
ui.details
|
||||||
|
.details
|
||||||
|
.versions_button
|
||||||
|
.connect_clicked(move |_| {
|
||||||
|
let entry = ui_versions.details.details_entry.borrow().clone();
|
||||||
|
if let Some(entry) = entry {
|
||||||
|
open_versions_dialog(&ui_versions, &entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
let ui_trash = ui.clone();
|
let ui_trash = ui.clone();
|
||||||
ui.details.details.trash_button.connect_clicked(move |_| {
|
ui.details.details.trash_button.connect_clicked(move |_| {
|
||||||
let entry = ui_trash.details.details_entry.borrow().clone();
|
let entry = ui_trash.details.details_entry.borrow().clone();
|
||||||
@@ -246,6 +262,9 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||||
d.open_button
|
d.open_button
|
||||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||||
|
// Only files have revisions, and only a connected daemon can fetch them.
|
||||||
|
d.versions_button.set_visible(!entry.is_dir);
|
||||||
|
d.versions_button.set_sensitive(*ui.mounted.borrow());
|
||||||
ui.details.details_suppress.set(false);
|
ui.details.details_suppress.set(false);
|
||||||
|
|
||||||
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
|
pub(crate) mod versions_dialog;
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
//! The per-file **Versions** dialog: what Proton Drive still holds of a file's
|
||||||
|
//! earlier content, and the three things a user can do with one.
|
||||||
|
//!
|
||||||
|
//! Every row is one revision the server kept. Restoring re-points the file at an
|
||||||
|
//! old revision (server-side, so nothing is uploaded and the mount may take a
|
||||||
|
//! moment to catch up); saving writes an old revision out to a local file
|
||||||
|
//! without touching the live one; deleting is permanent, and the active revision
|
||||||
|
//! cannot be deleted at all — that row simply has no delete button.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
use pdfs_core::control::RevisionInfo;
|
||||||
|
|
||||||
|
/// How an open Versions dialog addresses its file — the same split as
|
||||||
|
/// [`ShareTarget`](super::share_dialog::ShareTarget), for the same reason: a
|
||||||
|
/// node reached from Shared or a device folder may have no path in the primary
|
||||||
|
/// mount's namespace, and an older daemon must reject the request rather than
|
||||||
|
/// resolve an empty path to the mount root.
|
||||||
|
pub(crate) enum VersionTarget {
|
||||||
|
Path(String),
|
||||||
|
Uid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VersionTarget {
|
||||||
|
fn list(&self) -> Request {
|
||||||
|
match self {
|
||||||
|
VersionTarget::Path(path) => Request::ListRevisions { path: path.clone() },
|
||||||
|
VersionTarget::Uid(uid) => Request::ListRevisionsByUid { uid: uid.clone() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore(&self, revision_id: String) -> Request {
|
||||||
|
match self {
|
||||||
|
VersionTarget::Path(path) => Request::RestoreRevision {
|
||||||
|
path: path.clone(),
|
||||||
|
revision_id,
|
||||||
|
},
|
||||||
|
VersionTarget::Uid(uid) => Request::RestoreRevisionByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
revision_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, revision_id: String) -> Request {
|
||||||
|
match self {
|
||||||
|
VersionTarget::Path(path) => Request::DeleteRevision {
|
||||||
|
path: path.clone(),
|
||||||
|
revision_id,
|
||||||
|
},
|
||||||
|
VersionTarget::Uid(uid) => Request::DeleteRevisionByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
revision_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_as(&self, revision_id: String, dest: String) -> Request {
|
||||||
|
match self {
|
||||||
|
VersionTarget::Path(path) => Request::SaveRevisionAs {
|
||||||
|
path: path.clone(),
|
||||||
|
revision_id,
|
||||||
|
dest,
|
||||||
|
},
|
||||||
|
VersionTarget::Uid(uid) => Request::SaveRevisionAsByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
revision_id,
|
||||||
|
dest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state behind an open Versions dialog, so an action can rebuild the list
|
||||||
|
/// in place instead of tearing the dialog down.
|
||||||
|
pub(crate) struct VersionsDialog {
|
||||||
|
pub(crate) ui: Rc<Ui>,
|
||||||
|
pub(crate) target: VersionTarget,
|
||||||
|
/// The file's name, for the save dialog's suggested filename and for toasts.
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
|
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the Versions dialog for a file.
|
||||||
|
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
if !*ui.mounted.borrow() {
|
||||||
|
toast_error(ui, "Can't load versions", "Proton Drive isn't connected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if entry.is_dir {
|
||||||
|
toast_error(ui, "No versions", "Folders don't have versions.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let target = if entry.path.is_empty() {
|
||||||
|
VersionTarget::Uid(entry.uid.clone())
|
||||||
|
} else {
|
||||||
|
VersionTarget::Path(entry_rel(ui, entry))
|
||||||
|
};
|
||||||
|
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name)));
|
||||||
|
toolbar.add_top_bar(&header);
|
||||||
|
|
||||||
|
let group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Version history")
|
||||||
|
.description("Proton Drive keeps earlier versions of a file until you delete them.")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
|
content.set_margin_top(12);
|
||||||
|
content.set_margin_bottom(12);
|
||||||
|
content.set_margin_start(12);
|
||||||
|
content.set_margin_end(12);
|
||||||
|
content.append(&group);
|
||||||
|
let clamp = adw::Clamp::builder().child(&content).build();
|
||||||
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.child(&clamp)
|
||||||
|
.build();
|
||||||
|
toolbar.set_content(Some(&scroll));
|
||||||
|
|
||||||
|
let dialog = adw::Dialog::builder()
|
||||||
|
.title("Versions")
|
||||||
|
.content_width(520)
|
||||||
|
.content_height(520)
|
||||||
|
.child(&toolbar)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let state = Rc::new(VersionsDialog {
|
||||||
|
ui: ui.clone(),
|
||||||
|
target,
|
||||||
|
name: entry.name.clone(),
|
||||||
|
group,
|
||||||
|
rows: RefCell::new(Vec::new()),
|
||||||
|
});
|
||||||
|
|
||||||
|
versions_dialog_reload(&state);
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-fetch the file's revisions and rebuild the rows.
|
||||||
|
pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
|
||||||
|
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
|
||||||
|
let state = state.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
toast_error(&state.ui, "Couldn't load versions", &message)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&state.ui,
|
||||||
|
"Couldn't load versions",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the version rows from a fresh listing.
|
||||||
|
fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||||
|
for row in state.rows.borrow_mut().drain(..) {
|
||||||
|
state.group.remove(&row);
|
||||||
|
}
|
||||||
|
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||||
|
if items.is_empty() {
|
||||||
|
let row = dim_row("No earlier versions.");
|
||||||
|
state.group.add(&row);
|
||||||
|
rows.push(row.upcast());
|
||||||
|
*state.rows.borrow_mut() = rows;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
// The uploader's claimed plaintext size is the one that matches what the
|
||||||
|
// user sees in a listing; the storage figure counts ciphertext.
|
||||||
|
let size = item
|
||||||
|
.claimed_size
|
||||||
|
.filter(|size| *size >= 0)
|
||||||
|
.unwrap_or(item.size_on_storage)
|
||||||
|
.max(0) as u64;
|
||||||
|
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
|
||||||
|
if let Some(email) = &item.signed_by {
|
||||||
|
subtitle.push_str(" · ");
|
||||||
|
subtitle.push_str(email);
|
||||||
|
}
|
||||||
|
let title = if item.is_active {
|
||||||
|
"Current version".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Version {}", item.id)
|
||||||
|
};
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(title)
|
||||||
|
.subtitle(subtitle)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let save = gtk4::Button::builder()
|
||||||
|
.icon_name("document-save-symbolic")
|
||||||
|
.tooltip_text("Save a copy…")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
save.add_css_class("flat");
|
||||||
|
let state_save = state.clone();
|
||||||
|
let id = item.id.clone();
|
||||||
|
save.connect_clicked(move |_| prompt_save_version(&state_save, &id));
|
||||||
|
row.add_suffix(&save);
|
||||||
|
|
||||||
|
if !item.is_active {
|
||||||
|
let restore = gtk4::Button::builder()
|
||||||
|
.icon_name("edit-undo-symbolic")
|
||||||
|
.tooltip_text("Restore this version")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
restore.add_css_class("flat");
|
||||||
|
let state_restore = state.clone();
|
||||||
|
let id = item.id.clone();
|
||||||
|
restore.connect_clicked(move |_| prompt_restore_version(&state_restore, &id));
|
||||||
|
row.add_suffix(&restore);
|
||||||
|
|
||||||
|
let delete = gtk4::Button::builder()
|
||||||
|
.icon_name("user-trash-symbolic")
|
||||||
|
.tooltip_text("Delete this version permanently")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
delete.add_css_class("flat");
|
||||||
|
let state_delete = state.clone();
|
||||||
|
let id = item.id.clone();
|
||||||
|
delete.connect_clicked(move |_| prompt_delete_version(&state_delete, &id));
|
||||||
|
row.add_suffix(&delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.group.add(&row);
|
||||||
|
rows.push(row.upcast());
|
||||||
|
}
|
||||||
|
*state.rows.borrow_mut() = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm, then restore. Confirmed because it replaces the file's content for
|
||||||
|
/// every device on the account, not only this one.
|
||||||
|
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Restore version")
|
||||||
|
.body(format!(
|
||||||
|
"Make this version the current content of “{}”? The version it replaces stays in the history.",
|
||||||
|
state.name
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("restore", "Restore");
|
||||||
|
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
|
let window = ui_window(&state.ui);
|
||||||
|
let state = state.clone();
|
||||||
|
let revision_id = revision_id.to_string();
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp != "restore" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
versions_dialog_op(
|
||||||
|
&state,
|
||||||
|
state.target.restore(revision_id.clone()),
|
||||||
|
// The server applies the swap in the background, so the wording
|
||||||
|
// promises the request, not the result.
|
||||||
|
"Restoring that version",
|
||||||
|
"Couldn't restore that version",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
dialog.present(window.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm, then permanently delete one revision.
|
||||||
|
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Delete version")
|
||||||
|
.body("Delete this version permanently? Its content can't be recovered.")
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("delete", "Delete");
|
||||||
|
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
|
let window = ui_window(&state.ui);
|
||||||
|
let state = state.clone();
|
||||||
|
let revision_id = revision_id.to_string();
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp != "delete" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
versions_dialog_op(
|
||||||
|
&state,
|
||||||
|
state.target.delete(revision_id.clone()),
|
||||||
|
"Version deleted",
|
||||||
|
"Couldn't delete that version",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
dialog.present(window.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask where to write an old version, then have the daemon download it there.
|
||||||
|
///
|
||||||
|
/// The daemon writes the file, so the chosen location has to be one it can see:
|
||||||
|
/// a path from the portal-free `FileDialog` is a real filesystem path, which it
|
||||||
|
/// can.
|
||||||
|
fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||||
|
let window = ui_window(&state.ui);
|
||||||
|
let dialog = gtk4::FileDialog::builder()
|
||||||
|
.title("Save version")
|
||||||
|
.initial_name(&state.name)
|
||||||
|
.build();
|
||||||
|
let state = state.clone();
|
||||||
|
let revision_id = revision_id.to_string();
|
||||||
|
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||||
|
let Ok(file) = res else { return };
|
||||||
|
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
|
||||||
|
toast_error(
|
||||||
|
&state.ui,
|
||||||
|
"Couldn't save that version",
|
||||||
|
"That location isn't a local file.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
versions_dialog_op(
|
||||||
|
&state,
|
||||||
|
state.target.save_as(revision_id.clone(), dest),
|
||||||
|
"Version saved",
|
||||||
|
"Couldn't save that version",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one version request, toast the outcome, and refresh the list.
|
||||||
|
fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str, failed: &str) {
|
||||||
|
let rx = spawn_request(state.ui.dirs.control_socket(), request);
|
||||||
|
let state = state.clone();
|
||||||
|
let done = done.to_string();
|
||||||
|
let failed = failed.to_string();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
toast(&state.ui, &done);
|
||||||
|
versions_dialog_reload(&state);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
|
||||||
|
_ => toast_error(&state.ui, &failed, "The mount service didn't respond."),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher.
|
||||||
|
//!
|
||||||
|
//! Everything here is blocking and GTK-free — the process exists for one
|
||||||
|
//! round-trip and then exits, so there is no main loop to keep off. It reuses
|
||||||
|
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive
|
||||||
|
//! activation policy, so the two front ends cannot drift on what "open" means.
|
||||||
|
//!
|
||||||
|
//! Launchers filter a static list; they cannot call back for a new one per
|
||||||
|
//! keystroke. So the interaction is a loop: the first menu shows pinned files
|
||||||
|
//! and accepts free text, and any text that matches no entry becomes the next
|
||||||
|
//! search. Escape (an empty selection) ends it.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use pdfs_core::config::AppDirs;
|
||||||
|
use pdfs_core::control::{
|
||||||
|
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
||||||
|
};
|
||||||
|
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
|
||||||
|
use pdfs_core::opener::{self, OpenWith};
|
||||||
|
|
||||||
|
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||||
|
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
|
||||||
|
|
||||||
|
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
||||||
|
/// without it a scripted (non-interactive) menu could spin forever.
|
||||||
|
const MAX_ROUNDS: usize = 64;
|
||||||
|
|
||||||
|
pub(crate) struct Options {
|
||||||
|
/// Launcher argv from `--menu`, overriding the configured/detected one.
|
||||||
|
pub menu: Option<Vec<String>>,
|
||||||
|
/// Skip the pinned-files round and search for this immediately.
|
||||||
|
pub query: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the launcher flow. Returns an error only for conditions the user must
|
||||||
|
/// act on (no daemon, no launcher); a plain cancel is `Ok`.
|
||||||
|
pub(crate) fn run(options: Options) -> Result<(), String> {
|
||||||
|
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
|
||||||
|
let config = dirs.load_config();
|
||||||
|
let prompt: PromptConfig = config.resolved_prompt();
|
||||||
|
let policy: OpenWith = config.resolved_open_with();
|
||||||
|
let socket = dirs.control_socket();
|
||||||
|
|
||||||
|
let menu_argv =
|
||||||
|
menu::resolve_menu(options.menu.as_ref().or(prompt.menu.as_ref())).ok_or_else(|| {
|
||||||
|
"no launcher found — install fuzzel/rofi/wofi, or set prompt.menu in config.json"
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mountpoint = match request(&socket, Request::Status)? {
|
||||||
|
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
|
||||||
|
_ => dirs.default_mountpoint(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut query = options
|
||||||
|
.query
|
||||||
|
.map(|q| q.trim().to_string())
|
||||||
|
.filter(|q| !q.is_empty());
|
||||||
|
for _ in 0..MAX_ROUNDS {
|
||||||
|
let hits = match &query {
|
||||||
|
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?,
|
||||||
|
None => pins(&socket)?,
|
||||||
|
};
|
||||||
|
let items: Vec<MenuItem> = if hits.is_empty() {
|
||||||
|
vec![MenuItem::new(placeholder(query.as_deref()), None)]
|
||||||
|
} else {
|
||||||
|
label_all(&hits)
|
||||||
|
};
|
||||||
|
|
||||||
|
match menu::run(&menu_argv, &title(query.as_deref()), &items).map_err(|e| e.to_string())? {
|
||||||
|
MenuChoice::Item(index) => {
|
||||||
|
let Some(hit) = hits.get(index) else {
|
||||||
|
// The placeholder row. Selecting it is not a cancel — the
|
||||||
|
// user pressed Enter on "type to search", so start over
|
||||||
|
// with the pinned list rather than quitting on them.
|
||||||
|
query = None;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
return open(&socket, &mountpoint, &policy, hit, query.is_none());
|
||||||
|
}
|
||||||
|
// No entry matched what was typed: treat it as the next query.
|
||||||
|
MenuChoice::Custom(text) => query = Some(text),
|
||||||
|
MenuChoice::Cancelled => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The launcher's prompt string.
|
||||||
|
///
|
||||||
|
/// A launcher renders this immediately left of the input, so it is the only
|
||||||
|
/// place to say what typing here does. "Drive" alone read as a label and left
|
||||||
|
/// the two-step nature of the flow — type, Enter, *then* results — invisible;
|
||||||
|
/// the verb and the chevron make it a search box, and echoing the current query
|
||||||
|
/// shows which round you are in.
|
||||||
|
fn title(query: Option<&str>) -> String {
|
||||||
|
match query {
|
||||||
|
Some(text) => format!("Drive: {text} › "),
|
||||||
|
None => "Search Drive › ".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text for the one row shown when there is nothing to list.
|
||||||
|
///
|
||||||
|
/// A launcher handed an empty list shows a bare prompt, and some refuse to
|
||||||
|
/// return anything at all from an empty menu — which would strand the user on
|
||||||
|
/// the very first round, since a fresh account has no pins. A single row keeps
|
||||||
|
/// the menu non-empty and says why it is empty.
|
||||||
|
///
|
||||||
|
/// A fuzzy launcher can match this row against what the user types, and the
|
||||||
|
/// selection comes back as a label with no way to tell the two apart — so the
|
||||||
|
/// wording is parenthesised and avoids words that read like a filename query.
|
||||||
|
fn placeholder(query: Option<&str>) -> String {
|
||||||
|
match query {
|
||||||
|
Some(text) => format!("(no matches for “{text}”)"),
|
||||||
|
None => "(no pinned files — type to search)".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
|
||||||
|
/// with, and the only listing the daemon offers without a query.
|
||||||
|
fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||||
|
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
Ok(pins
|
||||||
|
.into_iter()
|
||||||
|
.map(|pin| {
|
||||||
|
Hit::Drive(SearchHit {
|
||||||
|
name: file_name(&pin.path),
|
||||||
|
path: pin.path,
|
||||||
|
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||||
|
size: 0,
|
||||||
|
modified: 0,
|
||||||
|
pinned: true,
|
||||||
|
uid: pin.uid,
|
||||||
|
mounted_path: None,
|
||||||
|
score: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
|
||||||
|
let reply = request(
|
||||||
|
socket,
|
||||||
|
Request::SearchV2 {
|
||||||
|
query: query.to_string(),
|
||||||
|
limit,
|
||||||
|
filters: SearchFilters {
|
||||||
|
sources: vec![SearchSource::Drive, SearchSource::Local],
|
||||||
|
kind: SearchKind::All,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let Response::SearchResultsV2 {
|
||||||
|
drive_hits,
|
||||||
|
local_hits,
|
||||||
|
..
|
||||||
|
} = reply
|
||||||
|
else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
|
||||||
|
hits.extend(local_hits.into_iter().map(Hit::Local));
|
||||||
|
rank_hits(&mut hits);
|
||||||
|
hits.truncate(limit);
|
||||||
|
Ok(hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One launcher line per hit. Labels must be unique: they are how the selection
|
||||||
|
/// is matched back, and two files of the same name in the same folder listing
|
||||||
|
/// would otherwise be indistinguishable.
|
||||||
|
fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
|
||||||
|
let mut items: Vec<MenuItem> = Vec::with_capacity(hits.len());
|
||||||
|
for hit in hits {
|
||||||
|
let mut label = format!("{} · {}", hit.name(), hit.location());
|
||||||
|
let mut suffix = 2;
|
||||||
|
while items.iter().any(|item| item.label == label) {
|
||||||
|
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
items.push(MenuItem::new(label, Some(icon_name(hit))));
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icon theme name for a hit.
|
||||||
|
///
|
||||||
|
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
|
||||||
|
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
|
||||||
|
/// an empty buffer answers `application-x-zerosize` for everything. The generic
|
||||||
|
/// names below exist in every icon theme.
|
||||||
|
fn icon_name(hit: &Hit) -> String {
|
||||||
|
if hit.is_dir() {
|
||||||
|
return "folder".to_string();
|
||||||
|
}
|
||||||
|
let name = hit.name();
|
||||||
|
if is_image(name) {
|
||||||
|
"image-x-generic"
|
||||||
|
} else if is_media(name) {
|
||||||
|
"video-x-generic"
|
||||||
|
} else if is_document(name) {
|
||||||
|
"text-x-generic"
|
||||||
|
} else {
|
||||||
|
"application-x-generic"
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
|
||||||
|
/// disk, a Drive folder or streamable file goes through the mount, and anything
|
||||||
|
/// else is materialised by the daemon first.
|
||||||
|
fn open(
|
||||||
|
socket: &Path,
|
||||||
|
mountpoint: &Path,
|
||||||
|
policy: &OpenWith,
|
||||||
|
hit: &Hit,
|
||||||
|
from_pins: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match hit {
|
||||||
|
Hit::Local(local) => {
|
||||||
|
opener::open(policy, Path::new(&local.path), local.is_dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Hit::Drive(drive) => {
|
||||||
|
// A folder has no materialised form, and a pin row's `is_dir` is pin
|
||||||
|
// policy rather than node kind, so both go to the mount unchecked.
|
||||||
|
let unconditional = from_pins
|
||||||
|
|| matches!(
|
||||||
|
drive_activation(&drive.name, drive.is_dir),
|
||||||
|
DriveActivation::Folder | DriveActivation::MountedMedia
|
||||||
|
);
|
||||||
|
if unconditional {
|
||||||
|
opener::open(
|
||||||
|
policy,
|
||||||
|
&mounted_or_relative(mountpoint, drive),
|
||||||
|
drive.is_dir,
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Anything else the mount exposes opens there too, so an editor
|
||||||
|
// saves back into Drive instead of into a cache blob.
|
||||||
|
if let Some(path) = mounted_target(mountpoint, drive) {
|
||||||
|
opener::open(policy, &path, false);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match request(
|
||||||
|
socket,
|
||||||
|
Request::OpenFile {
|
||||||
|
path: drive.path.clone(),
|
||||||
|
uid: Some(drive.uid.clone()),
|
||||||
|
},
|
||||||
|
)? {
|
||||||
|
Response::FilePath { path } => {
|
||||||
|
// The reply is a content-hash blob; the rules key off the
|
||||||
|
// Drive name, which is what the user actually opened.
|
||||||
|
opener::open_named(policy, Path::new(&path), &drive.name, false);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Response::Error { message, .. } => Err(format!("could not open: {message}")),
|
||||||
|
_ => Err("unexpected reply from the daemon".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One control-socket round-trip, with the daemon-is-down case phrased for a
|
||||||
|
/// terminal rather than a status page.
|
||||||
|
fn request(socket: &Path, request: Request) -> Result<Response, String> {
|
||||||
|
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::LocalHit;
|
||||||
|
|
||||||
|
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
|
||||||
|
Hit::Drive(SearchHit {
|
||||||
|
name: name.into(),
|
||||||
|
path: path.into(),
|
||||||
|
is_dir,
|
||||||
|
size: 0,
|
||||||
|
modified: 0,
|
||||||
|
pinned: false,
|
||||||
|
uid: "uid".into(),
|
||||||
|
mounted_path: None,
|
||||||
|
score: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_are_unique_so_a_selection_maps_back_to_one_hit() {
|
||||||
|
let hits = vec![
|
||||||
|
drive("notes.md", "Docs/notes.md", false),
|
||||||
|
Hit::Local(LocalHit {
|
||||||
|
name: "notes.md".into(),
|
||||||
|
path: "/home/me/Docs/notes.md".into(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 0,
|
||||||
|
modified: 0,
|
||||||
|
score: 0,
|
||||||
|
}),
|
||||||
|
drive("notes.md", "Docs/notes.md", false),
|
||||||
|
];
|
||||||
|
let items = label_all(&hits);
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
assert_ne!(items[0].label, items[2].label);
|
||||||
|
let unique: std::collections::HashSet<_> =
|
||||||
|
items.iter().map(|item| item.label.clone()).collect();
|
||||||
|
assert_eq!(unique.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_prompt_says_a_search_is_what_enter_does() {
|
||||||
|
let first = title(None);
|
||||||
|
assert!(first.to_lowercase().contains("search"), "{first}");
|
||||||
|
// The launcher renders the input immediately after the prompt, so the
|
||||||
|
// prompt has to carry its own separator or it runs into what is typed.
|
||||||
|
assert!(first.ends_with(' '), "{first:?}");
|
||||||
|
|
||||||
|
let refined = title(Some("md"));
|
||||||
|
assert!(refined.contains("md"), "{refined}");
|
||||||
|
assert!(refined.ends_with(' '), "{refined:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_round_still_has_a_row_explaining_itself() {
|
||||||
|
assert!(placeholder(None).contains("type to search"));
|
||||||
|
assert!(placeholder(Some("md")).contains("md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icons_are_theme_names_the_launcher_can_look_up() {
|
||||||
|
assert_eq!(icon_name(&drive("Photos", "Photos", true)), "folder");
|
||||||
|
assert_eq!(
|
||||||
|
icon_name(&drive("a.png", "a.png", false)),
|
||||||
|
"image-x-generic"
|
||||||
|
);
|
||||||
|
assert_eq!(icon_name(&drive("a.md", "a.md", false)), "text-x-generic");
|
||||||
|
assert_eq!(
|
||||||
|
icon_name(&drive("a.bin", "a.bin", false)),
|
||||||
|
"application-x-generic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,9 +134,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_folder(mountpoint: &Path) {
|
fn open_folder(mountpoint: &Path) {
|
||||||
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
|
pdfs_core::opener::open_default(mountpoint, true);
|
||||||
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
|
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
|
||||||
|
|||||||
+209
-44
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -20,9 +19,12 @@ use pdfs_core::config::AppDirs;
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
||||||
};
|
};
|
||||||
|
use pdfs_core::menu::PromptMode;
|
||||||
|
use pdfs_core::opener::{self, OpenWith};
|
||||||
|
|
||||||
mod activation;
|
mod activation;
|
||||||
use activation::{DriveActivation, drive_activation, mounted_path};
|
mod dmenu;
|
||||||
|
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||||
|
|
||||||
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
||||||
|
|
||||||
@@ -145,29 +147,26 @@ impl Hit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The dimmed second line: the containing folder, as the user thinks of it.
|
/// The dimmed second line: the containing folder, as the user thinks of it.
|
||||||
|
///
|
||||||
|
/// A Drive hit is only "My files" when it really sits in the primary mount.
|
||||||
|
/// A node inside a device folder resolves to a path on this machine
|
||||||
|
/// (`~/Downloads/…`) — the daemon has already worked that out in
|
||||||
|
/// `mounted_path`, and saying "My files / Downloads" for it names a folder
|
||||||
|
/// the user cannot find and does not match where activating the row opens.
|
||||||
fn location(&self) -> String {
|
fn location(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Hit::Drive(h) => {
|
Hit::Drive(h) => match h.mounted_path.as_deref() {
|
||||||
let parent = parent_of(&h.path);
|
Some(mounted) => home_relative(&parent_of(mounted)),
|
||||||
if parent.is_empty() {
|
None => {
|
||||||
"My files".to_string()
|
let parent = parent_of(&h.path);
|
||||||
} else {
|
if parent.is_empty() {
|
||||||
format!("My files / {}", parent.replace('/', " / "))
|
"My files".to_string()
|
||||||
|
} else {
|
||||||
|
format!("My files / {}", parent.replace('/', " / "))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
Hit::Local(h) => {
|
Hit::Local(h) => home_relative(&parent_of(&h.path)),
|
||||||
let parent = parent_of(&h.path);
|
|
||||||
match dirs_home().and_then(|home| {
|
|
||||||
Path::new(&parent)
|
|
||||||
.strip_prefix(&home)
|
|
||||||
.ok()
|
|
||||||
.map(|rel| rel.display().to_string())
|
|
||||||
}) {
|
|
||||||
Some(rel) if rel.is_empty() => "Home".to_string(),
|
|
||||||
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
|
||||||
None => parent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +219,10 @@ fn rank_hits(hits: &mut [Hit]) {
|
|||||||
struct Ui {
|
struct Ui {
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
mountpoint: RefCell<PathBuf>,
|
mountpoint: RefCell<PathBuf>,
|
||||||
|
/// How a chosen file is launched. Read once at window build: the prompt is
|
||||||
|
/// resident, but a config edit is rare enough that a restart is a fair
|
||||||
|
/// price for not re-reading the file on every Enter.
|
||||||
|
opener: OpenWith,
|
||||||
|
|
||||||
entry: gtk4::Entry,
|
entry: gtk4::Entry,
|
||||||
spinner: gtk4::Spinner,
|
spinner: gtk4::Spinner,
|
||||||
@@ -328,6 +331,71 @@ impl Section {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const USAGE: &str = "\
|
||||||
|
pdfs-prompt — search Proton Drive and this computer
|
||||||
|
|
||||||
|
Usage: pdfs-prompt [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dmenu Present the results in an external launcher (fuzzel,
|
||||||
|
rofi, …) instead of the built-in GTK window. Also
|
||||||
|
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
|
||||||
|
in config.json.
|
||||||
|
--gtk Force the built-in window, overriding that setting.
|
||||||
|
--menu <COMMAND> Launcher command line for --dmenu, e.g.
|
||||||
|
--menu 'fuzzel --dmenu --width 60'. Overrides
|
||||||
|
\"prompt\": { \"menu\": [...] }.
|
||||||
|
--query <TEXT> Search for TEXT immediately instead of opening on the
|
||||||
|
pinned-files list (--dmenu only).
|
||||||
|
-h, --help Show this help.
|
||||||
|
";
|
||||||
|
|
||||||
|
/// What the command line asked for, before any GTK setup happens: the launcher
|
||||||
|
/// path must not pay for — or fail on — a display connection it never uses.
|
||||||
|
struct Args {
|
||||||
|
mode: Option<PromptMode>,
|
||||||
|
menu: Option<Vec<String>>,
|
||||||
|
query: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||||
|
let mut args = Args {
|
||||||
|
mode: None,
|
||||||
|
menu: None,
|
||||||
|
query: None,
|
||||||
|
};
|
||||||
|
let mut argv = argv.peekable();
|
||||||
|
while let Some(arg) = argv.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
|
||||||
|
"--gtk" => args.mode = Some(PromptMode::Gtk),
|
||||||
|
// A launcher command is one shell-ish string so it can live in a
|
||||||
|
// keybinding; splitting on whitespace is enough for flags, and a
|
||||||
|
// launcher argument needing spaces belongs in config.json.
|
||||||
|
"--menu" => {
|
||||||
|
let value = argv
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "--menu needs a command".to_string())?;
|
||||||
|
let parts: Vec<String> = value.split_whitespace().map(str::to_string).collect();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return Err("--menu needs a command".to_string());
|
||||||
|
}
|
||||||
|
args.menu = Some(parts);
|
||||||
|
// A launcher was named explicitly; that only makes sense here.
|
||||||
|
args.mode.get_or_insert(PromptMode::Dmenu);
|
||||||
|
}
|
||||||
|
"--query" => {
|
||||||
|
args.query = Some(
|
||||||
|
argv.next()
|
||||||
|
.ok_or_else(|| "--query needs a search term".to_string())?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => return Err(format!("unrecognised argument: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(args)
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
@@ -335,6 +403,37 @@ fn main() -> glib::ExitCode {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
if raw.iter().any(|arg| arg == "-h" || arg == "--help") {
|
||||||
|
print!("{USAGE}");
|
||||||
|
return glib::ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
let args = match parse_args(raw.into_iter()) {
|
||||||
|
Ok(args) => args,
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("pdfs-prompt: {message}\n\n{USAGE}");
|
||||||
|
return glib::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The stored mode makes an existing keybinding switch front ends without
|
||||||
|
// being re-bound; an explicit flag still wins.
|
||||||
|
let stored = AppDirs::new()
|
||||||
|
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if args.mode.unwrap_or(stored) == PromptMode::Dmenu {
|
||||||
|
return match dmenu::run(dmenu::Options {
|
||||||
|
menu: args.menu,
|
||||||
|
query: args.query,
|
||||||
|
}) {
|
||||||
|
Ok(()) => glib::ExitCode::SUCCESS,
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("pdfs-prompt: {message}");
|
||||||
|
glib::ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||||
// GtkApplication normally remains active while it owns a window, including
|
// GtkApplication normally remains active while it owns a window, including
|
||||||
// when that window is hidden. Hold it explicitly as well: residency is a
|
// when that window is hidden. Hold it explicitly as well: residency is a
|
||||||
@@ -354,7 +453,8 @@ fn main() -> glib::ExitCode {
|
|||||||
};
|
};
|
||||||
ui.activate();
|
ui.activate();
|
||||||
});
|
});
|
||||||
app.run()
|
// GTK must not try to parse our own flags; they were consumed above.
|
||||||
|
app.run_with_args::<&str>(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||||
@@ -478,6 +578,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
|||||||
let ui = Rc::new(Ui {
|
let ui = Rc::new(Ui {
|
||||||
socket: dirs.control_socket(),
|
socket: dirs.control_socket(),
|
||||||
mountpoint: RefCell::new(dirs.default_mountpoint()),
|
mountpoint: RefCell::new(dirs.default_mountpoint()),
|
||||||
|
opener: dirs.load_config().resolved_open_with(),
|
||||||
entry: entry.clone(),
|
entry: entry.clone(),
|
||||||
spinner,
|
spinner,
|
||||||
stack,
|
stack,
|
||||||
@@ -686,6 +787,20 @@ impl Ui {
|
|||||||
self.entry.grab_focus();
|
self.entry.grab_focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hand a resolved path to the configured opener — `xdg-open` unless
|
||||||
|
/// `open_with` in `config.json` says otherwise (a terminal editor for text
|
||||||
|
/// files, say).
|
||||||
|
fn open_path(&self, path: &Path, is_dir: bool) {
|
||||||
|
opener::open(&self.opener, path, is_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a file the daemon just materialised into the content cache. The
|
||||||
|
/// blob is named after its content hash, so the open rules have to be
|
||||||
|
/// matched against the Drive name instead.
|
||||||
|
fn open_materialized(&self, path: &Path, name: &str) {
|
||||||
|
opener::open_named(&self.opener, path, name, false);
|
||||||
|
}
|
||||||
|
|
||||||
/// Dismiss without destroying the application window. GTK continues to
|
/// Dismiss without destroying the application window. GTK continues to
|
||||||
/// associate the hidden window with the single application instance, so a
|
/// associate the hidden window with the single application instance, so a
|
||||||
/// later desktop shortcut invocation is a cheap `activate` round-trip.
|
/// later desktop shortcut invocation is a cheap `activate` round-trip.
|
||||||
@@ -751,7 +866,7 @@ impl Ui {
|
|||||||
.map(|pin| SearchHit {
|
.map(|pin| SearchHit {
|
||||||
name: file_name(&pin.path),
|
name: file_name(&pin.path),
|
||||||
path: pin.path,
|
path: pin.path,
|
||||||
is_dir: pin.recursive,
|
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -1081,7 +1196,7 @@ impl Ui {
|
|||||||
|
|
||||||
match hit {
|
match hit {
|
||||||
Hit::Local(local) => {
|
Hit::Local(local) => {
|
||||||
xdg_open(Path::new(&local.path));
|
self.open_path(Path::new(&local.path), local.is_dir);
|
||||||
self.dismiss();
|
self.dismiss();
|
||||||
}
|
}
|
||||||
Hit::Drive(drive) => {
|
Hit::Drive(drive) => {
|
||||||
@@ -1091,25 +1206,28 @@ impl Ui {
|
|||||||
// this handles non-recursively pinned folders without mistaking
|
// this handles non-recursively pinned folders without mistaking
|
||||||
// them for files and sending an invalid OpenFile request.
|
// them for files and sending an invalid OpenFile request.
|
||||||
if self.rendered_query.borrow().is_empty() {
|
if self.rendered_query.borrow().is_empty() {
|
||||||
let path = drive.mounted_path.as_deref().map_or_else(
|
let path = mounted_or_relative(&self.mountpoint.borrow(), &drive);
|
||||||
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
|
self.open_path(&path, drive.is_dir);
|
||||||
PathBuf::from,
|
|
||||||
);
|
|
||||||
xdg_open(&path);
|
|
||||||
self.dismiss();
|
self.dismiss();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match drive_activation(&drive.name, drive.is_dir) {
|
match drive_activation(&drive.name, drive.is_dir) {
|
||||||
DriveActivation::Folder | DriveActivation::MountedMedia => {
|
DriveActivation::Folder | DriveActivation::MountedMedia => {
|
||||||
let path = drive.mounted_path.as_deref().map_or_else(
|
let path = mounted_or_relative(&self.mountpoint.borrow(), &drive);
|
||||||
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
|
self.open_path(&path, drive.is_dir);
|
||||||
PathBuf::from,
|
|
||||||
);
|
|
||||||
xdg_open(&path);
|
|
||||||
self.dismiss();
|
self.dismiss();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DriveActivation::Materialize => {}
|
// The mount holds the real file; the cache holds a copy an
|
||||||
|
// editor would save into for nothing. Prefer the mount, and
|
||||||
|
// materialise only what it does not expose.
|
||||||
|
DriveActivation::Materialize => {
|
||||||
|
if let Some(path) = mounted_target(&self.mountpoint.borrow(), &drive) {
|
||||||
|
self.open_path(&path, false);
|
||||||
|
self.dismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.opening.set(true);
|
self.opening.set(true);
|
||||||
@@ -1121,6 +1239,7 @@ impl Ui {
|
|||||||
self.socket.clone(),
|
self.socket.clone(),
|
||||||
Request::OpenFile {
|
Request::OpenFile {
|
||||||
path: drive.path.clone(),
|
path: drive.path.clone(),
|
||||||
|
uid: Some(drive.uid.clone()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let ui = self.clone();
|
let ui = self.clone();
|
||||||
@@ -1131,7 +1250,7 @@ impl Ui {
|
|||||||
ui.opening.set(false);
|
ui.opening.set(false);
|
||||||
match reply {
|
match reply {
|
||||||
Ok(Ok(Response::FilePath { path })) => {
|
Ok(Ok(Response::FilePath { path })) => {
|
||||||
xdg_open(Path::new(&path));
|
ui.open_materialized(Path::new(&path), &drive.name);
|
||||||
ui.dismiss();
|
ui.dismiss();
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => {
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
@@ -1224,13 +1343,6 @@ fn spawn_request(
|
|||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
|
|
||||||
fn xdg_open(path: &Path) {
|
|
||||||
tracing::info!(path = %path.display(), "opening");
|
|
||||||
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
|
|
||||||
tracing::error!("xdg-open failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dirs_home() -> Option<PathBuf> {
|
fn dirs_home() -> Option<PathBuf> {
|
||||||
AppDirs::new().ok().and_then(|dirs| dirs.home_dir())
|
AppDirs::new().ok().and_then(|dirs| dirs.home_dir())
|
||||||
}
|
}
|
||||||
@@ -1243,6 +1355,21 @@ fn file_name(path: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An absolute directory path as "Home / …", falling back to the path itself
|
||||||
|
/// for anything outside the home directory.
|
||||||
|
fn home_relative(parent: &str) -> String {
|
||||||
|
match dirs_home().and_then(|home| {
|
||||||
|
Path::new(parent)
|
||||||
|
.strip_prefix(&home)
|
||||||
|
.ok()
|
||||||
|
.map(|rel| rel.display().to_string())
|
||||||
|
}) {
|
||||||
|
Some(rel) if rel.is_empty() => "Home".to_string(),
|
||||||
|
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
||||||
|
None => parent.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parent_of(path: &str) -> String {
|
fn parent_of(path: &str) -> String {
|
||||||
Path::new(path)
|
Path::new(path)
|
||||||
.parent()
|
.parent()
|
||||||
@@ -1466,4 +1593,42 @@ mod tests {
|
|||||||
assert!(matches!(&hits[0], Hit::Local(hit) if hit.name == "better.pdf"));
|
assert!(matches!(&hits[0], Hit::Local(hit) if hit.name == "better.pdf"));
|
||||||
assert!(matches!(&hits[1], Hit::Drive(hit) if hit.name == "alpha.pdf"));
|
assert!(matches!(&hits[1], Hit::Drive(hit) if hit.name == "alpha.pdf"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse(args: &[&str]) -> Result<Args, String> {
|
||||||
|
parse_args(args.iter().map(|a| (*a).to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_arguments_defer_the_mode_to_config() {
|
||||||
|
let args = parse(&[]).unwrap();
|
||||||
|
assert_eq!(args.mode, None);
|
||||||
|
assert_eq!(args.menu, None);
|
||||||
|
assert_eq!(args.query, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn naming_a_launcher_implies_dmenu_but_an_explicit_mode_still_wins() {
|
||||||
|
let args = parse(&["--menu", "fuzzel --dmenu"]).unwrap();
|
||||||
|
assert_eq!(args.mode, Some(PromptMode::Dmenu));
|
||||||
|
assert_eq!(
|
||||||
|
args.menu,
|
||||||
|
Some(vec!["fuzzel".to_string(), "--dmenu".to_string()])
|
||||||
|
);
|
||||||
|
|
||||||
|
let args = parse(&["--gtk", "--menu", "rofi -dmenu"]).unwrap();
|
||||||
|
assert_eq!(args.mode, Some(PromptMode::Gtk));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flag_without_its_value_is_an_error_not_a_default() {
|
||||||
|
assert!(parse(&["--menu"]).is_err());
|
||||||
|
assert!(parse(&["--query"]).is_err());
|
||||||
|
assert!(parse(&["--nonsense"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_query_is_taken_verbatim_including_spaces() {
|
||||||
|
let args = parse(&["--dmenu", "--query", "tax return 2024"]).unwrap();
|
||||||
|
assert_eq!(args.query.as_deref(), Some("tax return 2024"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+113
@@ -12,6 +12,119 @@ Conventions:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## B82 — A `parent_uid` cycle hangs the daemon on any short search
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified) 2026-08-12 — unit-tested, not driven against a
|
||||||
|
real corrupted database (nobody has one).
|
||||||
|
**Found:** 2026-08-12, writing a test for B80. The test built a two-node cycle
|
||||||
|
and called `search`; `cargo test -p pdfs-core` then sat at
|
||||||
|
`search_excludes_a_parent_cycle` for 29 minutes with **zero CPU** before it was
|
||||||
|
killed. Two earlier runs had already been abandoned to the same stall without
|
||||||
|
the cause being understood — it looks exactly like a cargo lock contention
|
||||||
|
problem, which is what it was first blamed on.
|
||||||
|
**Where:** `path_of` (`crates/pdfs-core/src/db/utils.rs`), `upsert_node_tx`'s
|
||||||
|
`descendants` CTE (`db/nodes.rs`).
|
||||||
|
|
||||||
|
**Root cause.** Both walks are recursive CTEs using `UNION ALL`, which does not
|
||||||
|
deduplicate, so `a.parent = b, b.parent = a` never terminates. The index side
|
||||||
|
was never exposed — `node_is_indexable_tx` rejects a cycle — but a query shorter
|
||||||
|
than `TRIGRAM_MIN` (3 chars) takes the `LIKE` lane, and **that lane reads
|
||||||
|
`nodes` directly and never consults the index**. Every row it returns is then
|
||||||
|
handed to `path_of`. So typing two characters into the prompt was enough, and
|
||||||
|
the spin happens while holding the daemon's only SQLite connection: the mount,
|
||||||
|
the drain, and every control request stop with it.
|
||||||
|
|
||||||
|
The API is what would produce such a cycle (a rename/move race, a malformed
|
||||||
|
event), so this is not a "corrupt your own DB" scenario only.
|
||||||
|
|
||||||
|
**Fix.** `path_of` is depth-capped at 256 and returns the truncated path rather
|
||||||
|
than hanging; `path_relative_to` already carried a 1024 cap, which is the
|
||||||
|
precedent. The `descendants` CTE in `upsert_node_tx` got the same guard for the
|
||||||
|
write side. Cycles also stay out of the index (see B80).
|
||||||
|
|
||||||
|
**Still open:** `pin_is_pinned`'s ancestor walk (`db/pins.rs`) has the same
|
||||||
|
shape and no guard. Not reached from search, so it was left alone.
|
||||||
|
|
||||||
|
## B81 — Prompt labels every Drive hit "My files", including device folders
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified) 2026-08-12 — needs a GUI/dmenu run.
|
||||||
|
**Found:** 2026-08-12, reading the prompt while fixing B80: with B80 fixed, a
|
||||||
|
device-folder hit would render as `My files / Downloads`, a folder that does not
|
||||||
|
exist under the primary mount, while activating the same row opens
|
||||||
|
`~/Downloads/…`. Label and action disagreed.
|
||||||
|
**Where:** `Hit::location()` (`crates/pdfs-gui/src/prompt.rs`), shared by the
|
||||||
|
GTK prompt and the dmenu front-end.
|
||||||
|
|
||||||
|
The label was built from `SearchHit::path` with a hardcoded `My files /` prefix.
|
||||||
|
The daemon already resolves a device/sync-folder hit to a real local path in
|
||||||
|
`mounted_path` (`Core::mounted_search_path`), which is also what activation
|
||||||
|
uses; the label now derives from that when it is set, and falls back to
|
||||||
|
`My files` only for the primary mount.
|
||||||
|
|
||||||
|
**Second defect, same area.** Pin rows set `is_dir` from `Pin::recursive`
|
||||||
|
(`prompt.rs`, `dmenu.rs`). That flag is pin *policy* — whether the subtree is
|
||||||
|
kept on disk — not node kind, so a folder pinned non-recursively drew a file
|
||||||
|
icon and sent an invalid `OpenFile`. `Pin` now carries an `is_dir: Option<bool>`
|
||||||
|
resolved from the `nodes` row by `pin_list`, serde-defaulted so an older daemon
|
||||||
|
(which omits it) still parses; front-ends fall back to `recursive` only when the
|
||||||
|
node is not cached.
|
||||||
|
|
||||||
|
## B80 — 29% of the account is absent from the search index
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified) 2026-08-12 — migration replayed against a
|
||||||
|
read-only copy of the live DB; not yet run by the daemon itself.
|
||||||
|
**Found:** 2026-08-12, auditing `cache.db` after a user report that search felt
|
||||||
|
unreliable. `pdfs search tickets` returned only the My Files folder
|
||||||
|
`Documents/Tickets`, never the `tickets.pdf` sitting in the device folder
|
||||||
|
`Downloads`. Counting the tables explained it:
|
||||||
|
|
||||||
|
```
|
||||||
|
nodes 9252
|
||||||
|
nodes_fts 6547 ← 2705 rows never indexed
|
||||||
|
nodes in orphan subtrees 2705 ← exactly the missing set
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where:** `node_is_indexable_tx` (`crates/pdfs-core/src/db/nodes.rs`).
|
||||||
|
|
||||||
|
**Root cause — the same one as B79.** A node was indexed only if walking
|
||||||
|
`parent_uid` reached a row with `parent_uid IS NULL`. Exactly one row is stored
|
||||||
|
that way (the My Files root). A device folder's root lives in `device` /
|
||||||
|
`sync_folder` and is **never persisted as a `nodes` row**, so `Downloads`,
|
||||||
|
`Pictures`, `Documents`, `Music` and `Videos` walk up to a parent that does not
|
||||||
|
exist and were judged unindexable, along with everything beneath them. Stale
|
||||||
|
`pdfs-acceptance-*` roots hit the same path.
|
||||||
|
|
||||||
|
The v16 backfill did *not* share the bug — it rooted its walk at any node whose
|
||||||
|
parent is null **or absent**. So the index was correct immediately after
|
||||||
|
migrating and then lost each subtree the first time one of its nodes was
|
||||||
|
rewritten. That divergence between backfill and write path is why this survived:
|
||||||
|
a fresh install looks fine.
|
||||||
|
|
||||||
|
**Fix.** Indexability now requires only that the walk terminates with nothing
|
||||||
|
trashed on the way; a cycle is still rejected explicitly (B82). `MIGRATION_V21`
|
||||||
|
(`SCHEMA_VERSION` → 21) rebuilds `nodes_fts` so existing installs recover
|
||||||
|
without waiting for each node to be rewritten, and fixes two gaps in the v16
|
||||||
|
walk it replays: descendants of a trashed folder stay out, and the walk is
|
||||||
|
depth-capped.
|
||||||
|
|
||||||
|
Replayed against a `.backup` of the live DB: **9276 non-trashed nodes → 9276
|
||||||
|
indexed** (from 6547), 300 rows under `Downloads`, `tickets.pdf` at
|
||||||
|
`Downloads/tickets.pdf`.
|
||||||
|
|
||||||
|
**Watch for.** That path is the second thing this uncovered: the backfill first
|
||||||
|
seeded an uncached-parent root with an empty path, so the migration indexed
|
||||||
|
`tickets.pdf` while a later upsert indexed `Downloads/tickets.pdf` — the same
|
||||||
|
node with two paths depending on which code wrote it. Any future rebuild of this
|
||||||
|
index has to match `path_of`, not invent its own root rule.
|
||||||
|
|
||||||
|
**Related, not a defect.** The local half of the prompt indexes 110,187 files,
|
||||||
|
63,399 of them (58%) the Go module cache. Candidates are capped at 1000 and
|
||||||
|
scored *after* SQLite, so for a common term the pool filled with vendored files
|
||||||
|
before a real one was considered — for "test", 367 of the 500 best-ranked
|
||||||
|
candidates came from `go/pkg/mod`. `SKIP_DIRS` gained `dist`/`build`, a new
|
||||||
|
`SKIP_PATH_SUFFIXES` covers `go/pkg/mod`, and both candidate functions gained a
|
||||||
|
whole-query prefix lane ahead of the existing single-character one.
|
||||||
|
|
||||||
## B79 — Every write in an on-demand device-folder mount fails EACCES
|
## B79 — Every write in an on-demand device-folder mount fails EACCES
|
||||||
|
|
||||||
**Status:** Fixed (verified live 2026-07-31, running in a locally rebuilt 1.2.1
|
**Status:** Fixed (verified live 2026-07-31, running in a locally rebuilt 1.2.1
|
||||||
|
|||||||
+41
-3
@@ -7,21 +7,20 @@ This document summarizes the work still pending after the 1.0.0 release branch a
|
|||||||
## 1. Core Feature Roadmap (Horizon Tasks)
|
## 1. Core Feature Roadmap (Horizon Tasks)
|
||||||
Consolidated from the historical feature roadmap; current bug status lives in [`BUGS.md`](BUGS.md).
|
Consolidated from the historical feature roadmap; current bug status lives in [`BUGS.md`](BUGS.md).
|
||||||
|
|
||||||
* **Local Cache & Metadata Encryption at Rest (Priority 5 / Horizon 1)**: Encrypt the SQLite database `cache.db` on disk using SQLCipher (`rusqlite` feature `bundled-sqlcipher`), and encrypt raw content cache blocks (`content/blocks/` and `content/scratch/`) using a fast symmetric scheme like AES-GCM or ChaCha20-Poly1305. The encryption key should be derived from the OS Keyring.
|
* **Local Cache & Metadata Encryption at Rest (Priority 5 / Horizon 1)**: Encrypt the SQLite database `cache.db` on disk using SQLCipher (`rusqlite` feature `bundled-sqlcipher`), and encrypt raw content cache blocks (`content/blocks/` and `content/scratch/`) using a fast symmetric scheme like AES-GCM or ChaCha20-Poly1305. The encryption key should be derived from the OS Keyring. (The SDK entity cache in `sdk_cache.db` is already encrypted at rest — see *Persistent SDK Entity Cache* below — but `cache.db` and the content cache are not.)
|
||||||
* **Active Bandwidth Throttling & Traffic Shaping (Horizon 1)**: Add speed governors for daemon uploads and downloads. Support configuration variables `max_upload_rate_kbps` and `max_download_rate_kbps` in `config.json` that can be dynamically adjusted over the Unix control socket.
|
* **Active Bandwidth Throttling & Traffic Shaping (Horizon 1)**: Add speed governors for daemon uploads and downloads. Support configuration variables `max_upload_rate_kbps` and `max_download_rate_kbps` in `config.json` that can be dynamically adjusted over the Unix control socket.
|
||||||
* **Interactive & Policy-Based Conflict Resolution (Horizon 1)**: Implement configurable conflict resolution policies (`rename-local`, `rename-remote`, `prefer-local`, `prefer-remote`, `interactive`). Add a "Conflicts" tab to the GUI to notify the user, block sync, and launch external visual diff tools (like Meld or KDiff3).
|
* **Interactive & Policy-Based Conflict Resolution (Horizon 1)**: Implement configurable conflict resolution policies (`rename-local`, `rename-remote`, `prefer-local`, `prefer-remote`, `interactive`). Add a "Conflicts" tab to the GUI to notify the user, block sync, and launch external visual diff tools (like Meld or KDiff3).
|
||||||
* **Multi-Account / Multi-Profile Support (Horizon 3)**: Enable running multiple profiles concurrently. Support separate configuration namespaces (e.g. `profiles/<profile_name>/`), independent SQLite databases, distinct mount paths (e.g. `~/ProtonDrive/Personal` and `~/ProtonDrive/Work`), and multiple daemon processes.
|
* **Multi-Account / Multi-Profile Support (Horizon 3)**: Enable running multiple profiles concurrently. Support separate configuration namespaces (e.g. `profiles/<profile_name>/`), independent SQLite databases, distinct mount paths (e.g. `~/ProtonDrive/Personal` and `~/ProtonDrive/Work`), and multiple daemon processes.
|
||||||
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
|
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
|
||||||
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
|
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
|
||||||
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
|
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
|
||||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Extract and display Exif metadata (camera model, exposure, coordinates) from photos locally, support creating and editing Proton Photo Albums, build a date-based timeline scrollbar, and create an auto-upload Pictures folder pipeline.
|
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Support creating and editing Proton Photo Albums (album *writes* are unported in the SDK too) and create an auto-upload Pictures folder pipeline. Exif display, the date-based timeline scrubber and favourites are implemented.
|
||||||
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
|
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
|
||||||
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
|
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
|
||||||
* **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs.
|
* **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs.
|
||||||
* **Shutdown Safety & Write Queue Visibility (Horizon 1 / P4)**: Register systemd inhibitor locks in `pdfs-fuse` when there are outstanding staging writes to prevent shutdown or sleep, pop up warnings when exiting the GUI, and build a blocking `pdfs sync flush` command.
|
* **Shutdown Safety & Write Queue Visibility (Horizon 1 / P4)**: Register systemd inhibitor locks in `pdfs-fuse` when there are outstanding staging writes to prevent shutdown or sleep, pop up warnings when exiting the GUI, and build a blocking `pdfs sync flush` command.
|
||||||
* **Block-Level Deduplication (Horizon 3)**: Transition to a Content-Addressable block cache where cached blocks are stored on disk by their SHA-256 hash. Map logical ranges in the database: `(node_uid, block_idx) -> block_hash` to avoid storing or downloading identical blocks.
|
* **Block-Level Deduplication (Horizon 3)**: Transition to a Content-Addressable block cache where cached blocks are stored on disk by their SHA-256 hash. Map logical ranges in the database: `(node_uid, block_idx) -> block_hash` to avoid storing or downloading identical blocks.
|
||||||
* **Pre-emptive Sync Debouncing & File System Events (Horizon 3)**: Group filesystem write events on paths and delay sync queue insertion until a quiet period has elapsed (e.g. 5 seconds of inactivity) to prevent thrashing the Proton API.
|
* **Pre-emptive Sync Debouncing & File System Events (Horizon 3)**: Group filesystem write events on paths and delay sync queue insertion until a quiet period has elapsed (e.g. 5 seconds of inactivity) to prevent thrashing the Proton API.
|
||||||
* **File Version History (Horizon 2)**: Expose past file revisions and version history inside the GUI browser tabs.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +121,45 @@ Derived from: [`BUGS.md`](BUGS.md)
|
|||||||
|
|
||||||
## 6. Recently Implemented Features
|
## 6. Recently Implemented Features
|
||||||
|
|
||||||
|
### File Version History (Implemented)
|
||||||
|
**Files**: [`revisions.rs`](../crates/pdfs-fuse/src/revisions.rs), [`control.rs`](../crates/pdfs-core/src/control.rs), [`versions_dialog.rs`](../crates/pdfs-gui/src/app/widgets/versions_dialog.rs)
|
||||||
|
|
||||||
|
Proton Drive keeps every revision a client committed; the daemon only ever addressed the active one, so a file overwritten by a sync pass could be recovered only from whatever the local `recovery/` directory happened to hold.
|
||||||
|
|
||||||
|
The control protocol gained `ListRevisions` / `RestoreRevision` / `DeleteRevision` / `SaveRevisionAs` (each with a `…ByUid` twin for nodes the primary mount cannot name), the CLI gained `pdfs versions list|restore|save|rm`, and the browser's details pane gained a **Versions** button opening a per-file dialog.
|
||||||
|
|
||||||
|
Three properties that shape the code:
|
||||||
|
|
||||||
|
- **A restore is server-side and asynchronous.** No content crosses the wire and nothing enters the drain queue; the server answers 202 and swaps the active revision in the background, so the daemon evicts the file's cached blocks and open readers rather than describing the new state, and the UI wording promises the request, not the result.
|
||||||
|
- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button.
|
||||||
|
- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards.
|
||||||
|
|
||||||
|
### Photo Favourites (Implemented)
|
||||||
|
**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||||
|
|
||||||
|
The gallery can mark and filter favourites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favourites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||||
|
|
||||||
|
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
|
||||||
|
|
||||||
|
### Persistent SDK Entity Cache (Implemented)
|
||||||
|
**Files**: [`sdkcache.rs`](../crates/pdfs-core/src/sdkcache.rs), [`auth.rs`](../crates/pdfs-core/src/auth.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
|
||||||
|
|
||||||
|
The SDK's Drive entity cache (decrypted node metadata: name, size, parent, signing share) defaulted to memory, so every daemon restart re-fetched and re-decrypted the tree the previous run had already walked. It is now backed by SQLite.
|
||||||
|
|
||||||
|
- **Its own file** (`sdk_cache.db` in the state directory), not `cache.db`: the daemon's `Db` is one `Mutex<Connection>` shared by every FUSE thread and the control socket, and this traffic is frequent, small and entirely reconstructible. A separate file also means no schema migration — the store can be deleted at any time.
|
||||||
|
- **Encrypted at rest** by wrapping it in the SDK's `EncryptedCacheRepository`, keyed by the mailbox password. A password change reads as a cold cache (the SDK treats an undecryptable entry as a miss and clears the store), not as an error.
|
||||||
|
- **Staleness** is closed by the event loop, which already replays from a persisted cursor and calls `invalidate_caches_for_event` for every event — including those raised while the daemon was down. The one case with no trail is a *seeded* cursor (first-ever mount, or a lost cursor), where the seed path now clears the store instead of trusting it. `auth::logout` removes the file.
|
||||||
|
|
||||||
|
Needed a small additive SDK change (`ProtonDriveClient::with_entity_repository`, 0.5.1): `with_entity_cache` is a constructor and so could not be combined with `with_key_salts`, which the daemon requires.
|
||||||
|
|
||||||
|
### Per-node Batch Outcomes (Implemented)
|
||||||
|
**File**: [`batch.rs`](../crates/pdfs-core/src/batch.rs)
|
||||||
|
|
||||||
|
SDK 0.5.0 made `trash_nodes` / `restore_nodes` / `delete_nodes` report one outcome per node instead of failing the whole call (mirroring upstream's streamed `NodeActionResult`). `pdfs_core::batch::into_unit` collapses the single-node calls back to a `Result`; `batch::split` handles a collected batch.
|
||||||
|
|
||||||
|
The trash view's restore and permanent-delete use the SDK's **streaming** variants (`restore_nodes_streaming` / `delete_nodes_streaming`) and apply local state per node as each batch lands rather than after the last one. For a permanent delete — which is irreversible — that means a daemon interrupted mid-batch has forgotten exactly the nodes the server destroyed, no more and no fewer. Both report per-node failures and count only what actually succeeded; only a batch where nothing succeeded is an error.
|
||||||
|
|
||||||
|
|
||||||
### Open-for-Write Deferral for Mirror Sync (Implemented)
|
### Open-for-Write Deferral for Mirror Sync (Implemented)
|
||||||
**File**: [`sync.rs`](../crates/pdfs-fuse/src/sync.rs)
|
**File**: [`sync.rs`](../crates/pdfs-fuse/src/sync.rs)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Search index + prompt display fixes
|
||||||
|
|
||||||
|
Working plan. Findings come from an audit of a real `cache.db`
|
||||||
|
(9,252 Drive nodes, 110,187 local files) on 2026-08-12.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
```
|
||||||
|
nodes 9252
|
||||||
|
nodes_fts 6547 ← 2705 rows never indexed
|
||||||
|
nodes in orphan subtrees 2705 ← exactly the missing set
|
||||||
|
local_files 110187
|
||||||
|
/home/narl/go/pkg/mod 63399 (58% of the local index)
|
||||||
|
query "test" 46405 local matches, candidate pool capped at 1000
|
||||||
|
top 500 by fts rank 367 (73%) from go/pkg/mod
|
||||||
|
```
|
||||||
|
|
||||||
|
`pdfs search tickets` returns only `Documents/Tickets` (My Files); the
|
||||||
|
`tickets.pdf` sitting in the device folder `Downloads` is invisible.
|
||||||
|
|
||||||
|
## P1 — 29% of Drive is unsearchable
|
||||||
|
|
||||||
|
`node_is_indexable_tx` (`crates/pdfs-core/src/db/nodes.rs:606`) indexes a node
|
||||||
|
only when the ancestor walk terminates at a row with `parent_uid IS NULL`. The
|
||||||
|
DB holds exactly one such row (the My Files root). Device roots live in
|
||||||
|
`device`/`sync_folder`, never in `nodes`, so every device-folder subtree
|
||||||
|
(`Downloads`, `Pictures`, `Documents`, `Music`, `Videos` under device
|
||||||
|
*nthinkpad*) walks up to a `parent_uid` with no row and is judged unindexable.
|
||||||
|
Stale `pdfs-acceptance-*` roots hit the same path.
|
||||||
|
|
||||||
|
- [x] Relax the rule to "the walk terminates and no ancestor is trashed",
|
||||||
|
keeping the existing cycle guard. A subtree whose top row's parent is
|
||||||
|
simply not cached is still real, reachable data.
|
||||||
|
- [x] `MIGRATION_V21`: rebuild `nodes_fts` under the new rule, so existing
|
||||||
|
installs recover the missing rows instead of waiting for each node to be
|
||||||
|
upserted again.
|
||||||
|
- [x] Tests: a node under an uncached parent is indexed; a trashed ancestor
|
||||||
|
still excludes; the migration backfills.
|
||||||
|
|
||||||
|
### Found while testing: a parent cycle hangs the daemon
|
||||||
|
|
||||||
|
Writing the cycle test surfaced a live defect, not a test artefact. A query
|
||||||
|
below the trigram minimum takes the `LIKE` lane, which reads `nodes` directly
|
||||||
|
and never consults the index, and every row it returns goes through `path_of` —
|
||||||
|
whose parent walk is an unguarded `UNION ALL`. On a `parent_uid` cycle it never
|
||||||
|
terminates, while holding the daemon's only SQLite connection.
|
||||||
|
|
||||||
|
- [x] Depth-cap `path_of` (`db/utils.rs`), matching the 1024 cap
|
||||||
|
`path_relative_to` already carried.
|
||||||
|
- [x] Depth-cap the `descendants` CTE in `upsert_node_tx`, which has the same
|
||||||
|
exposure on the write side.
|
||||||
|
- [x] Keep cycles out of the index (part of the P1 rule above).
|
||||||
|
|
||||||
|
Still unguarded, not touched here: `pin_is_pinned`'s ancestor walk.
|
||||||
|
|
||||||
|
## P2 — local index noise crowds real files out of the candidate pool
|
||||||
|
|
||||||
|
`SKIP_DIRS` (`crates/pdfs-core/src/localindex.rs:23`) misses the Go module
|
||||||
|
cache and build output dirs. `search_v2` clamps candidates to ≤1000 and scores
|
||||||
|
*after* SQLite, so for any common term the pool is exhausted by vendored files
|
||||||
|
before a real one is considered.
|
||||||
|
|
||||||
|
- [x] Skip `go/pkg/mod` (path-relative, not a bare name), `dist`, `build`,
|
||||||
|
`.cache`-style artefact dirs.
|
||||||
|
- [x] Give `search_local_candidates` a name-prefix lane, mirroring what
|
||||||
|
`search_candidates` already does for Drive, so an exact-prefix match
|
||||||
|
cannot be starved by trigram rank.
|
||||||
|
- [x] Tests for both.
|
||||||
|
|
||||||
|
## P3 — display lies about where a hit lives
|
||||||
|
|
||||||
|
- [x] `Hit::location()` (`crates/pdfs-gui/src/prompt.rs:150`) hardcodes
|
||||||
|
`My files / …` for every Drive hit. Derive the label from
|
||||||
|
`mounted_path` when the daemon resolved one (device folder, sync folder)
|
||||||
|
and fall back to `My files` only for the primary mount.
|
||||||
|
- [x] Pin rows set `is_dir: pin.recursive` (`dmenu.rs:133`, `prompt.rs:872`) —
|
||||||
|
a non-recursively pinned folder renders and activates as a file.
|
||||||
|
- [ ] `pdfs locations` prints `Device folder 1..5` rather than the device and
|
||||||
|
folder name. (Cosmetic; deferred.)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Done, 2026-08-12:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo fmt --all -- --check clean
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings clean
|
||||||
|
cargo test --workspace --locked 439 passed, 0 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
The V21 backfill was also replayed against a read-only `.backup` of the live
|
||||||
|
`cache.db`:
|
||||||
|
|
||||||
|
```
|
||||||
|
live_nodes 9276
|
||||||
|
indexed 9276 (was 6547)
|
||||||
|
downloads_subtree 300
|
||||||
|
tickets.pdf -> Downloads/tickets.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
That run is what caught the last defect: the backfill originally seeded an
|
||||||
|
uncached-parent root with an empty path, so a node indexed by the migration
|
||||||
|
(`tickets.pdf`) and the same node after a later upsert (`Downloads/tickets.pdf`)
|
||||||
|
disagreed. The seed now keeps the name for any row that has a parent, matching
|
||||||
|
`path_of`.
|
||||||
|
|
||||||
|
Still to do, on the live system: rebuild, restart `proton-drive.service`, and
|
||||||
|
confirm `pdfs search tickets` returns the device folder's `tickets.pdf`. The
|
||||||
|
schema move to 21 is one-way — an older `pdfs` refuses a v21 database. The
|
||||||
|
`local_files` shrink (~110k → ~47k) lands with the next background scan.
|
||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=1.3.0
|
pkgver=1.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
Reference in new issue
Block a user