mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-11-03 03:34:05 +01:00
feat: site data repair
This commit is contained in:
1 parent
05ecfffe9c
commit
ef77e17946
24 files changed
+1629
-13
No files matched your search
@@ -90,6 +90,7 @@ donutbrowser/
|
||||
│ │ ├── vault.rs # Per-install key that seals local secrets; opens legacy build-password seals once
|
||||
│ │ ├── cookie_manager.rs # Cookie import/export
|
||||
│ │ ├── local_storage.rs # Local Storage LevelDB read/write, Playwright storageState import/export
|
||||
│ │ ├── site_storage.rs # Oversized per-site storage: measure, notice after an early exit, move aside, delete backup
|
||||
│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch)
|
||||
│ │ ├── profile_templates.rs # Synced profile templates and bulk create from a template
|
||||
│ │ ├── profile_statuses.rs # Synced profile statuses (name + color) set on profiles
|
||||
|
||||
@@ -193,6 +193,9 @@ export const commandCoverage = {
|
||||
"local_storage::get_profile_local_storage_stats",
|
||||
"local_storage::export_profile_storage_state",
|
||||
"local_storage::import_profile_storage_state",
|
||||
"site_storage::get_profile_site_storage",
|
||||
"site_storage::repair_profile_site_storage",
|
||||
"site_storage::delete_profile_site_storage_backup",
|
||||
"set_profile_password",
|
||||
"change_profile_password",
|
||||
"remove_profile_password",
|
||||
|
||||
@@ -1536,6 +1536,94 @@ test("a storage state file moves cookies and Local Storage into a closed profile
|
||||
});
|
||||
});
|
||||
|
||||
test("oversized site data is found, moved aside without touching logins, and its backup deleted", async () => {
|
||||
await withApp("entities-site-storage", async (app) => {
|
||||
const profile = await createProfile(app, "Site Storage");
|
||||
const dataDir = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
"profile",
|
||||
);
|
||||
const site = path.join(
|
||||
dataDir,
|
||||
"Default",
|
||||
"IndexedDB",
|
||||
"https_studio.example_0",
|
||||
);
|
||||
await mkdir(path.join(site, "1.indexeddb.blob"), { recursive: true });
|
||||
// Sparse: 3 GiB to the size check, nothing on the disk.
|
||||
const big = await open(path.join(site, "1.indexeddb.blob", "blob"), "w");
|
||||
await big.truncate(3 * 1024 ** 3);
|
||||
await big.close();
|
||||
await mkdir(path.join(dataDir, "Default", "Network"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dataDir, "Default", "Network", "Cookies"),
|
||||
"cookie-db",
|
||||
);
|
||||
await writeFile(path.join(dataDir, "Default", "Login Data"), "logins");
|
||||
|
||||
const report = await app.invoke("get_profile_site_storage", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.equal(report.threshold_bytes, 2 * 1024 ** 3);
|
||||
assert.equal(report.oversized.length, 1);
|
||||
const [oversized] = report.oversized;
|
||||
assert.equal(oversized.origin, "https://studio.example");
|
||||
assert.equal(oversized.kind, "indexed_db");
|
||||
assert.equal(oversized.bytes, 3 * 1024 ** 3);
|
||||
|
||||
assert.match(
|
||||
await app.invokeError("repair_profile_site_storage", {
|
||||
profileId: profile.id,
|
||||
storageIds: ["Default/IndexedDB/../../Local State"],
|
||||
}),
|
||||
/SITE_STORAGE_NOT_FOUND/,
|
||||
);
|
||||
|
||||
const repaired = await app.invoke("repair_profile_site_storage", {
|
||||
profileId: profile.id,
|
||||
storageIds: [oversized.id],
|
||||
});
|
||||
assert.deepEqual(repaired.oversized, []);
|
||||
assert.equal(repaired.backups.length, 1);
|
||||
assert.equal(repaired.backups[0].origin, "https://studio.example");
|
||||
assert.equal(existsSync(site), false);
|
||||
const idb = await readdir(path.join(dataDir, "Default", "IndexedDB"));
|
||||
assert.equal(idb.length, 1);
|
||||
assert.match(idb[0], /^https_studio\.example_0\.donut-backup-\d{8}-\d{6}$/);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(dataDir, "Default", "Network", "Cookies"),
|
||||
"utf8",
|
||||
),
|
||||
"cookie-db",
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(path.join(dataDir, "Default", "Login Data"), "utf8"),
|
||||
"logins",
|
||||
);
|
||||
|
||||
const cleaned = await app.invoke("delete_profile_site_storage_backup", {
|
||||
profileId: profile.id,
|
||||
backupId: repaired.backups[0].id,
|
||||
});
|
||||
assert.deepEqual(cleaned.backups, []);
|
||||
assert.deepEqual(
|
||||
await readdir(path.join(dataDir, "Default", "IndexedDB")),
|
||||
[],
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("delete_profile_site_storage_backup", {
|
||||
profileId: profile.id,
|
||||
backupId: repaired.backups[0].id,
|
||||
}),
|
||||
/SITE_STORAGE_NOT_FOUND/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("2FA keys stay sealed on this device, hand out codes, and go with their profile", async () => {
|
||||
await withApp("entities-two-factor", async (app) => {
|
||||
const secret = "JBSWY3DPEHPK3PXP";
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn origins_on_disk(profile_data: &Path) -> Vec<String> {
|
||||
origins
|
||||
}
|
||||
|
||||
fn origin_of_folder(name: &str) -> Option<String> {
|
||||
pub(crate) fn origin_of_folder(name: &str) -> Option<String> {
|
||||
// Older browsers name the folder `<origin>.indexeddb.leveldb`; 152 names it
|
||||
// after the origin alone and keeps one folder per database inside.
|
||||
if name.contains(".indexeddb.") && !name.ends_with(".indexeddb.leveldb") {
|
||||
|
||||
@@ -231,6 +231,7 @@ mod remote_exit;
|
||||
mod remote_handoff;
|
||||
mod remote_session;
|
||||
mod settings_manager;
|
||||
mod site_storage;
|
||||
pub mod socks5_local;
|
||||
pub mod sync;
|
||||
mod synchronizer;
|
||||
@@ -3413,6 +3414,7 @@ pub fn run_with_builder(
|
||||
if !is_running {
|
||||
crate::profile::clear_on_close::clear_profile_browsing_data(&profile)
|
||||
.await;
|
||||
crate::site_storage::check_after_exit(profile.clone());
|
||||
if let Some(pid) = profile.process_id {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.stop_worker_of_exited_browser(pid)
|
||||
@@ -3786,6 +3788,9 @@ pub fn run_with_builder(
|
||||
remove_mcp_from_agent,
|
||||
set_mcp_toolsets,
|
||||
local_storage::get_profile_local_storage_stats,
|
||||
site_storage::get_profile_site_storage,
|
||||
site_storage::repair_profile_site_storage,
|
||||
site_storage::delete_profile_site_storage_backup,
|
||||
local_storage::export_profile_storage_state,
|
||||
local_storage::import_profile_storage_state,
|
||||
agent_schedules::list_agent_schedules,
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
//! Site storage that has grown too large for the browser to open.
|
||||
//!
|
||||
//! One site's IndexedDB, Cache Storage or File System folders can grow to
|
||||
//! tens of GiB, and a browser then quits soon after each launch. This module
|
||||
//! measures each site's folders in a profile, and moves the folders of one
|
||||
//! site aside inside the profile, so the profile launches again with its
|
||||
//! cookies, logins, extensions and other sites intact. A moved folder stays
|
||||
//! until the person deletes it.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
|
||||
/// A site whose folders together pass this size is reported as oversized.
|
||||
pub const OVERSIZED_SITE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
/// A browser that exits this soon after its launch exited like a crash.
|
||||
pub const EARLY_EXIT_SECS: u64 = 5 * 60;
|
||||
/// How long a measurement answers the profile info page.
|
||||
const CACHE_MAX_AGE: Duration = Duration::from_secs(10 * 60);
|
||||
/// Put between a folder's name and the time it was moved aside.
|
||||
const BACKUP_MARKER: &str = ".donut-backup-";
|
||||
const STAMP_FORMAT: &str = "%Y%m%d-%H%M%S";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StorageKind {
|
||||
IndexedDb,
|
||||
CacheStorage,
|
||||
FileSystem,
|
||||
StorageBucket,
|
||||
}
|
||||
|
||||
impl StorageKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
StorageKind::IndexedDb => "indexed_db",
|
||||
StorageKind::CacheStorage => "cache_storage",
|
||||
StorageKind::FileSystem => "file_system",
|
||||
StorageKind::StorageBucket => "storage_bucket",
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder under the browser's data directory that holds one folder
|
||||
/// (or, for IndexedDB, up to two) per site.
|
||||
fn area(self) -> &'static str {
|
||||
match self {
|
||||
StorageKind::IndexedDb => "Default/IndexedDB",
|
||||
StorageKind::CacheStorage => "Default/Service Worker/CacheStorage",
|
||||
StorageKind::FileSystem => "Default/File System",
|
||||
StorageKind::StorageBucket => "Default/WebStorage",
|
||||
}
|
||||
}
|
||||
|
||||
/// The site a folder belongs to, or `None` when the folder holds no site.
|
||||
fn site_key(self, folder: &str) -> Option<String> {
|
||||
match self {
|
||||
StorageKind::IndexedDb => Some(
|
||||
folder
|
||||
.strip_suffix(".indexeddb.leveldb")
|
||||
.or_else(|| folder.strip_suffix(".indexeddb.blob"))
|
||||
.unwrap_or(folder)
|
||||
.to_string(),
|
||||
),
|
||||
StorageKind::CacheStorage => Some(folder.to_string()),
|
||||
// `Origins` and the like sit next to the numbered site folders.
|
||||
StorageKind::FileSystem | StorageKind::StorageBucket => (!folder.is_empty()
|
||||
&& folder.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| folder.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KINDS: [StorageKind; 4] = [
|
||||
StorageKind::IndexedDb,
|
||||
StorageKind::CacheStorage,
|
||||
StorageKind::FileSystem,
|
||||
StorageKind::StorageBucket,
|
||||
];
|
||||
|
||||
/// The folders of one site in one storage kind.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SiteStorage {
|
||||
/// Names the site to the commands: `<area>/<site key>`.
|
||||
pub id: String,
|
||||
pub kind: StorageKind,
|
||||
/// `https://host[:port]` when the folders tell it.
|
||||
pub origin: Option<String>,
|
||||
/// The site's folder name without the per-kind suffixes.
|
||||
pub folder: String,
|
||||
pub bytes: u64,
|
||||
#[serde(skip)]
|
||||
dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Site folders a repair moved aside.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SiteStorageBackup {
|
||||
pub id: String,
|
||||
pub kind: StorageKind,
|
||||
pub origin: Option<String>,
|
||||
pub folder: String,
|
||||
pub bytes: u64,
|
||||
/// When the folders were moved aside, in unix seconds.
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(skip)]
|
||||
dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct SiteStorageReport {
|
||||
pub oversized: Vec<SiteStorage>,
|
||||
pub backups: Vec<SiteStorageBackup>,
|
||||
pub threshold_bytes: u64,
|
||||
}
|
||||
|
||||
struct Scan {
|
||||
sites: Vec<SiteStorage>,
|
||||
backups: Vec<SiteStorageBackup>,
|
||||
}
|
||||
|
||||
/// The size of the files under `path`. Symbolic links are not followed, so a
|
||||
/// link out of the profile never adds to its size.
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
let mut total = 0u64;
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
stack.push(entry.path());
|
||||
} else if file_type.is_file() {
|
||||
total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// The site of a Cache Storage folder: its `index.txt` names the origin.
|
||||
fn cache_storage_origin(dir: &Path) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let mut head = Vec::new();
|
||||
std::fs::File::open(dir.join("index.txt"))
|
||||
.ok()?
|
||||
.take(64 * 1024)
|
||||
.read_to_end(&mut head)
|
||||
.ok()?;
|
||||
let start = find(&head, b"https://").or_else(|| find(&head, b"http://"))?;
|
||||
let origin: String = head[start..]
|
||||
.iter()
|
||||
.take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'/'))
|
||||
.map(|&b| b as char)
|
||||
.collect();
|
||||
let origin = origin.trim_end_matches('/');
|
||||
let host = origin.split_once("://")?.1;
|
||||
(!host.is_empty() && !host.contains('/')).then(|| origin.to_string())
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
/// The sites of the storage buckets, from the browser's quota database.
|
||||
fn bucket_origins(data_dir: &Path) -> HashMap<String, String> {
|
||||
let path = data_dir
|
||||
.join("Default")
|
||||
.join("WebStorage")
|
||||
.join("QuotaManager");
|
||||
let Ok(conn) = rusqlite::Connection::open_with_flags(
|
||||
&path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Ok(mut statement) = conn.prepare("SELECT id, storage_key FROM buckets") else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Ok(rows) = statement.query_map([], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
}) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
rows
|
||||
.flatten()
|
||||
.filter_map(|(id, key)| {
|
||||
let origin = key.split('^').next()?.trim_end_matches('/');
|
||||
origin
|
||||
.contains("://")
|
||||
.then(|| (id.to_string(), origin.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn origin_of(
|
||||
kind: StorageKind,
|
||||
key: &str,
|
||||
dirs: &[PathBuf],
|
||||
buckets: &HashMap<String, String>,
|
||||
) -> Option<String> {
|
||||
match kind {
|
||||
StorageKind::IndexedDb => crate::indexed_db::origin_of_folder(key),
|
||||
StorageKind::CacheStorage => dirs.iter().find_map(|dir| cache_storage_origin(dir)),
|
||||
StorageKind::FileSystem => None,
|
||||
StorageKind::StorageBucket => buckets.get(key).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp_time(stamp: &str) -> Option<i64> {
|
||||
let stamp = stamp.get(..15)?;
|
||||
let naive = chrono::NaiveDateTime::parse_from_str(stamp, STAMP_FORMAT).ok()?;
|
||||
naive
|
||||
.and_local_timezone(chrono::Local)
|
||||
.earliest()
|
||||
.map(|time| time.timestamp())
|
||||
}
|
||||
|
||||
/// Every site folder and every moved-aside folder in a browser data directory.
|
||||
fn scan(data_dir: &Path) -> Scan {
|
||||
let mut sites: BTreeMap<(StorageKind, String), Vec<PathBuf>> = BTreeMap::new();
|
||||
let mut backups: BTreeMap<(StorageKind, String, String), Vec<PathBuf>> = BTreeMap::new();
|
||||
for kind in KINDS {
|
||||
let Ok(entries) = std::fs::read_dir(data_dir.join(kind.area())) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
||||
continue;
|
||||
};
|
||||
match name.split_once(BACKUP_MARKER) {
|
||||
Some((base, stamp)) => {
|
||||
if let Some(key) = kind.site_key(base) {
|
||||
backups
|
||||
.entry((kind, key, stamp.to_string()))
|
||||
.or_default()
|
||||
.push(entry.path());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(key) = kind.site_key(&name) {
|
||||
sites.entry((kind, key)).or_default().push(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let wants_buckets = sites
|
||||
.keys()
|
||||
.any(|(kind, _)| *kind == StorageKind::StorageBucket)
|
||||
|| backups
|
||||
.keys()
|
||||
.any(|(kind, ..)| *kind == StorageKind::StorageBucket);
|
||||
let buckets = if wants_buckets {
|
||||
bucket_origins(data_dir)
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
Scan {
|
||||
sites: sites
|
||||
.into_iter()
|
||||
.map(|((kind, key), dirs)| SiteStorage {
|
||||
id: format!("{}/{key}", kind.area()),
|
||||
kind,
|
||||
origin: None,
|
||||
bytes: dirs.iter().map(|dir| dir_size(dir)).sum(),
|
||||
folder: key,
|
||||
dirs,
|
||||
})
|
||||
.map(|mut site| {
|
||||
site.origin = origin_of(site.kind, &site.folder, &site.dirs, &buckets);
|
||||
site
|
||||
})
|
||||
.collect(),
|
||||
backups: backups
|
||||
.into_iter()
|
||||
.map(|((kind, key, stamp), dirs)| SiteStorageBackup {
|
||||
id: format!("{}/{key}{BACKUP_MARKER}{stamp}", kind.area()),
|
||||
kind,
|
||||
origin: origin_of(kind, &key, &dirs, &buckets),
|
||||
bytes: dirs.iter().map(|dir| dir_size(dir)).sum(),
|
||||
created_at: stamp_time(&stamp),
|
||||
folder: key,
|
||||
dirs,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn report_of(scan: Scan, threshold: u64) -> SiteStorageReport {
|
||||
let mut oversized: Vec<SiteStorage> = scan
|
||||
.sites
|
||||
.into_iter()
|
||||
.filter(|site| site.bytes >= threshold)
|
||||
.collect();
|
||||
oversized.sort_by_key(|site| std::cmp::Reverse(site.bytes));
|
||||
let mut backups = scan.backups;
|
||||
backups.sort_by_key(|backup| std::cmp::Reverse(backup.created_at));
|
||||
SiteStorageReport {
|
||||
oversized,
|
||||
backups,
|
||||
threshold_bytes: threshold,
|
||||
}
|
||||
}
|
||||
|
||||
/// A short, stable name for a site in the log, which never holds the site.
|
||||
fn site_hash(id: &str) -> String {
|
||||
blake3::hash(id.as_bytes()).to_hex()[..12].to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum StorageError {
|
||||
NotFound,
|
||||
UnsafePath,
|
||||
Io(std::io::ErrorKind),
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
fn to_backend(&self) -> String {
|
||||
match self {
|
||||
StorageError::NotFound => crate::backend_error("SITE_STORAGE_NOT_FOUND"),
|
||||
StorageError::UnsafePath => crate::backend_error("SITE_STORAGE_UNSAFE_PATH"),
|
||||
StorageError::Io(kind) => crate::backend_error_with_detail("SITE_STORAGE_IO_FAILED", kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder that a repair or a delete may touch: a real folder (not a link)
|
||||
/// whose parent resolves inside the data directory.
|
||||
fn check_contained(root: &Path, dir: &Path) -> Result<(), StorageError> {
|
||||
let metadata = std::fs::symlink_metadata(dir).map_err(|_| StorageError::NotFound)?;
|
||||
if !metadata.file_type().is_dir() {
|
||||
return Err(StorageError::UnsafePath);
|
||||
}
|
||||
let parent = dir.parent().ok_or(StorageError::UnsafePath)?;
|
||||
let parent = parent
|
||||
.canonicalize()
|
||||
.map_err(|_| StorageError::UnsafePath)?;
|
||||
if !parent.starts_with(root) {
|
||||
return Err(StorageError::UnsafePath);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_root(data_dir: &Path) -> Result<PathBuf, StorageError> {
|
||||
data_dir.canonicalize().map_err(|_| StorageError::NotFound)
|
||||
}
|
||||
|
||||
/// A name next to `dir` that nothing uses yet.
|
||||
fn backup_path(dir: &Path, stamp: &str) -> Option<PathBuf> {
|
||||
let name = dir.file_name()?.to_str()?;
|
||||
let parent = dir.parent()?;
|
||||
(0..100).find_map(|n| {
|
||||
let candidate = if n == 0 {
|
||||
parent.join(format!("{name}{BACKUP_MARKER}{stamp}"))
|
||||
} else {
|
||||
parent.join(format!("{name}{BACKUP_MARKER}{stamp}-{n}"))
|
||||
};
|
||||
std::fs::symlink_metadata(&candidate)
|
||||
.is_err()
|
||||
.then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename the folders of the named sites aside, all or none: a failed rename
|
||||
/// moves back the folders already renamed.
|
||||
fn move_aside(
|
||||
data_dir: &Path,
|
||||
ids: &[String],
|
||||
stamp: &str,
|
||||
) -> Result<Vec<SiteStorage>, StorageError> {
|
||||
let root = canonical_root(data_dir)?;
|
||||
let sites = scan(data_dir).sites;
|
||||
let mut chosen = Vec::new();
|
||||
for id in ids {
|
||||
let site = sites
|
||||
.iter()
|
||||
.find(|site| &site.id == id)
|
||||
.ok_or(StorageError::NotFound)?;
|
||||
for dir in &site.dirs {
|
||||
check_contained(&root, dir)?;
|
||||
}
|
||||
chosen.push(site.clone());
|
||||
}
|
||||
|
||||
let mut done: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||
for dir in chosen.iter().flat_map(|site| site.dirs.iter()) {
|
||||
let result = backup_path(dir, stamp)
|
||||
.ok_or(StorageError::UnsafePath)
|
||||
.and_then(|target| {
|
||||
std::fs::rename(dir, &target)
|
||||
.map(|()| target)
|
||||
.map_err(|e| StorageError::Io(e.kind()))
|
||||
});
|
||||
match result {
|
||||
Ok(target) => done.push((dir.clone(), target)),
|
||||
Err(e) => {
|
||||
for (from, to) in done.iter().rev() {
|
||||
if let Err(undo) = std::fs::rename(to, from) {
|
||||
log::error!("Site storage move not undone err_kind={:?}", undo.kind());
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
|
||||
fn delete_backup_in(data_dir: &Path, id: &str) -> Result<SiteStorageBackup, StorageError> {
|
||||
let root = canonical_root(data_dir)?;
|
||||
let backup = scan(data_dir)
|
||||
.backups
|
||||
.into_iter()
|
||||
.find(|backup| backup.id == id)
|
||||
.ok_or(StorageError::NotFound)?;
|
||||
for dir in &backup.dirs {
|
||||
check_contained(&root, dir)?;
|
||||
}
|
||||
for dir in &backup.dirs {
|
||||
std::fs::remove_dir_all(dir).map_err(|e| StorageError::Io(e.kind()))?;
|
||||
}
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
// --- Measurements kept between calls ----------------------------------------
|
||||
|
||||
struct Cached {
|
||||
at: SystemTime,
|
||||
report: SiteStorageReport,
|
||||
}
|
||||
|
||||
static CACHE: LazyLock<Mutex<HashMap<String, Cached>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether a profile keeps site data on disk that this module can read.
|
||||
fn measurable(profile: &BrowserProfile) -> bool {
|
||||
profile.browser == "wayfern" && !profile.ephemeral && !profile.password_protected
|
||||
}
|
||||
|
||||
fn data_dir(profile: &BrowserProfile) -> PathBuf {
|
||||
profile.get_profile_data_path(&ProfileManager::instance().get_profiles_dir())
|
||||
}
|
||||
|
||||
/// Measure a profile now and keep the result.
|
||||
fn measure(profile: &BrowserProfile) -> SiteStorageReport {
|
||||
let report = if measurable(profile) {
|
||||
report_of(scan(&data_dir(profile)), OVERSIZED_SITE_BYTES)
|
||||
} else {
|
||||
SiteStorageReport {
|
||||
threshold_bytes: OVERSIZED_SITE_BYTES,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
if let Ok(mut cache) = CACHE.lock() {
|
||||
cache.insert(
|
||||
profile.id.to_string(),
|
||||
Cached {
|
||||
at: SystemTime::now(),
|
||||
report: report.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// The kept measurement while it is recent and newer than the last launch.
|
||||
fn cached(profile: &BrowserProfile) -> Option<SiteStorageReport> {
|
||||
let cache = CACHE.lock().ok()?;
|
||||
let entry = cache.get(&profile.id.to_string())?;
|
||||
let at_secs = entry.at.duration_since(UNIX_EPOCH).ok()?.as_secs();
|
||||
let fresh = entry.at.elapsed().is_ok_and(|age| age < CACHE_MAX_AGE)
|
||||
&& at_secs >= profile.last_launch.unwrap_or(0);
|
||||
fresh.then(|| entry.report.clone())
|
||||
}
|
||||
|
||||
fn find_profile(profile_id: &str) -> Result<BrowserProfile, String> {
|
||||
ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
|
||||
.into_iter()
|
||||
.find(|profile| profile.id.to_string() == profile_id)
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(
|
||||
work: impl FnOnce() -> T + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
crate::profile::manager::run_blocking(work)
|
||||
.await
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct OversizedNotice {
|
||||
profile_id: String,
|
||||
sites: Vec<SiteStorage>,
|
||||
}
|
||||
|
||||
/// Measure a profile whose browser just exited by itself. When it exited
|
||||
/// soon after its launch and a site is oversized, tell the person.
|
||||
pub fn check_after_exit(profile: BrowserProfile) {
|
||||
if !measurable(&profile) {
|
||||
return;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let started = std::time::Instant::now();
|
||||
let early = profile
|
||||
.last_launch
|
||||
.is_some_and(|launched| now_secs().saturating_sub(launched) <= EARLY_EXIT_SECS);
|
||||
let measured = profile.clone();
|
||||
let Ok(report) = blocking(move || measure(&measured)).await else {
|
||||
return;
|
||||
};
|
||||
let Some(largest) = report.oversized.first() else {
|
||||
return;
|
||||
};
|
||||
log::info!(
|
||||
"Oversized site storage profile={} sites={} largest_bytes={} early_exit={early} elapsed_ms={}",
|
||||
profile.id,
|
||||
report.oversized.len(),
|
||||
largest.bytes,
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
if early {
|
||||
let notice = OversizedNotice {
|
||||
profile_id: profile.id.to_string(),
|
||||
sites: report.oversized,
|
||||
};
|
||||
if let Err(e) = crate::events::emit("site-storage-oversized", ¬ice) {
|
||||
log::warn!("Event emit failed event=site-storage-oversized err=\"{e}\"");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Oversized sites and moved-aside folders of a profile.
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_site_storage(profile_id: String) -> Result<SiteStorageReport, String> {
|
||||
let profile = find_profile(&profile_id)?;
|
||||
if let Some(report) = cached(&profile) {
|
||||
return Ok(report);
|
||||
}
|
||||
blocking(move || measure(&profile)).await
|
||||
}
|
||||
|
||||
/// Hold off launches of a profile whose browser is not running. Moving a
|
||||
/// site's folders under a running browser would corrupt them.
|
||||
async fn hold_stopped(
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<tokio::sync::OwnedMutexGuard<()>, String> {
|
||||
let guard = crate::browser_runner::try_lock_profile_launch(&profile.id.to_string())
|
||||
.await
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_RUNNING"))?;
|
||||
if crate::wayfern_identity_storage::is_running(profile).await {
|
||||
return Err(crate::backend_error("PROFILE_RUNNING"));
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
/// Move the folders of the named sites aside. Refused while the profile's
|
||||
/// browser runs or launches.
|
||||
#[tauri::command]
|
||||
pub async fn repair_profile_site_storage(
|
||||
profile_id: String,
|
||||
storage_ids: Vec<String>,
|
||||
) -> Result<SiteStorageReport, String> {
|
||||
let profile = find_profile(&profile_id)?;
|
||||
if !measurable(&profile) {
|
||||
return Err(crate::backend_error("SITE_STORAGE_NOT_FOUND"));
|
||||
}
|
||||
let _launch_guard = hold_stopped(&profile).await?;
|
||||
let stamp = chrono::Local::now().format(STAMP_FORMAT).to_string();
|
||||
let dir = data_dir(&profile);
|
||||
let id = profile_id.clone();
|
||||
let moved = blocking(move || move_aside(&dir, &storage_ids, &stamp)).await?;
|
||||
match moved {
|
||||
Ok(sites) => {
|
||||
for site in &sites {
|
||||
log::info!(
|
||||
"Site storage moved aside profile={id} site={} kind={} bytes={} folders={}",
|
||||
site_hash(&site.id),
|
||||
site.kind.as_str(),
|
||||
site.bytes,
|
||||
site.dirs.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Site storage move failed profile={id} err={e:?}");
|
||||
return Err(e.to_backend());
|
||||
}
|
||||
}
|
||||
blocking(move || measure(&profile)).await
|
||||
}
|
||||
|
||||
/// Delete folders a repair moved aside.
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile_site_storage_backup(
|
||||
profile_id: String,
|
||||
backup_id: String,
|
||||
) -> Result<SiteStorageReport, String> {
|
||||
let profile = find_profile(&profile_id)?;
|
||||
if !measurable(&profile) {
|
||||
return Err(crate::backend_error("SITE_STORAGE_NOT_FOUND"));
|
||||
}
|
||||
let dir = data_dir(&profile);
|
||||
let deleted = blocking(move || delete_backup_in(&dir, &backup_id)).await?;
|
||||
match deleted {
|
||||
Ok(backup) => log::info!(
|
||||
"Site storage backup deleted profile={profile_id} site={} kind={} bytes={}",
|
||||
site_hash(&backup.id),
|
||||
backup.kind.as_str(),
|
||||
backup.bytes
|
||||
),
|
||||
Err(e) => {
|
||||
log::error!("Site storage backup delete failed profile={profile_id} err={e:?}");
|
||||
return Err(e.to_backend());
|
||||
}
|
||||
}
|
||||
blocking(move || measure(&profile)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
const STUDIO: &str = "Default/IndexedDB/https_studio.youtube.com_0";
|
||||
|
||||
fn sparse(path: &Path, len: u64) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::File::create(path).unwrap().set_len(len).unwrap();
|
||||
}
|
||||
|
||||
fn profile_with_studio(root: &Path, len: u64) {
|
||||
sparse(
|
||||
&root.join(STUDIO).join("1.indexeddb.blob").join("blob"),
|
||||
len,
|
||||
);
|
||||
sparse(
|
||||
&root
|
||||
.join(STUDIO)
|
||||
.join("1.indexeddb.leveldb")
|
||||
.join("000003.log"),
|
||||
10,
|
||||
);
|
||||
sparse(
|
||||
&root.join("Default/IndexedDB/https_mail.example_0/db/000003.log"),
|
||||
100,
|
||||
);
|
||||
sparse(&root.join("Default/Network/Cookies"), 4096);
|
||||
sparse(&root.join("Default/Login Data"), 2048);
|
||||
sparse(&root.join("Local State"), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_site_over_the_threshold_is_reported_with_its_origin() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
profile_with_studio(tmp.path(), 3 * 1024 * 1024 * 1024);
|
||||
let report = report_of(scan(tmp.path()), OVERSIZED_SITE_BYTES);
|
||||
assert_eq!(report.oversized.len(), 1);
|
||||
let site = &report.oversized[0];
|
||||
assert_eq!(site.id, STUDIO);
|
||||
assert_eq!(site.origin.as_deref(), Some("https://studio.youtube.com"));
|
||||
assert_eq!(site.kind, StorageKind::IndexedDb);
|
||||
assert_eq!(site.bytes, 3 * 1024 * 1024 * 1024 + 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_site_under_the_threshold_is_not_reported() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
profile_with_studio(tmp.path(), OVERSIZED_SITE_BYTES - 11);
|
||||
assert!(report_of(scan(tmp.path()), OVERSIZED_SITE_BYTES)
|
||||
.oversized
|
||||
.is_empty());
|
||||
assert_eq!(report_of(scan(tmp.path()), 1000).oversized.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_indexeddb_folders_of_one_site_count_together() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let idb = tmp.path().join("Default/IndexedDB");
|
||||
sparse(
|
||||
&idb.join("https_a.example_0.indexeddb.leveldb/000003.log"),
|
||||
600,
|
||||
);
|
||||
sparse(&idb.join("https_a.example_0.indexeddb.blob/1/00/1"), 500);
|
||||
let report = report_of(scan(tmp.path()), 1000);
|
||||
assert_eq!(report.oversized.len(), 1);
|
||||
assert_eq!(report.oversized[0].bytes, 1100);
|
||||
assert_eq!(report.oversized[0].dirs.len(), 2);
|
||||
assert_eq!(
|
||||
report.oversized[0].origin.as_deref(),
|
||||
Some("https://a.example")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_storage_kinds_are_measured() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = tmp
|
||||
.path()
|
||||
.join("Default/Service Worker/CacheStorage/abc123");
|
||||
sparse(&cache.join("data"), 5000);
|
||||
fs::write(
|
||||
cache.join("index.txt"),
|
||||
b"\x0a\x1chttps://cache.example/\x12\x00",
|
||||
)
|
||||
.unwrap();
|
||||
sparse(&tmp.path().join("Default/File System/000/p/00/1"), 5000);
|
||||
sparse(&tmp.path().join("Default/File System/Origins/LOG"), 5000);
|
||||
sparse(&tmp.path().join("Default/WebStorage/7/IndexedDB/x"), 5000);
|
||||
let report = report_of(scan(tmp.path()), 1000);
|
||||
let kinds: Vec<_> = report
|
||||
.oversized
|
||||
.iter()
|
||||
.map(|s| (s.kind, s.origin.clone()))
|
||||
.collect();
|
||||
assert_eq!(kinds.len(), 3, "{kinds:?}");
|
||||
assert!(kinds.contains(&(
|
||||
StorageKind::CacheStorage,
|
||||
Some("https://cache.example".into())
|
||||
)));
|
||||
assert!(kinds.contains(&(StorageKind::FileSystem, None)));
|
||||
assert!(kinds.contains(&(StorageKind::StorageBucket, None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_renames_only_the_site_and_keeps_the_rest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
profile_with_studio(tmp.path(), 3000);
|
||||
let moved = move_aside(tmp.path(), &[STUDIO.to_string()], "20261101-165020").unwrap();
|
||||
assert_eq!(moved.len(), 1);
|
||||
let backup = tmp
|
||||
.path()
|
||||
.join("Default/IndexedDB/https_studio.youtube.com_0.donut-backup-20261101-165020");
|
||||
assert!(backup.join("1.indexeddb.blob/blob").is_file());
|
||||
assert!(!tmp.path().join(STUDIO).exists());
|
||||
for kept in [
|
||||
"Default/Network/Cookies",
|
||||
"Default/Login Data",
|
||||
"Local State",
|
||||
"Default/IndexedDB/https_mail.example_0/db/000003.log",
|
||||
] {
|
||||
assert!(tmp.path().join(kept).is_file(), "{kept} must stay");
|
||||
}
|
||||
|
||||
let report = report_of(scan(tmp.path()), 1000);
|
||||
assert!(report.oversized.is_empty(), "a backup is not a site");
|
||||
assert_eq!(report.backups.len(), 1);
|
||||
assert_eq!(report.backups[0].bytes, 3010);
|
||||
assert_eq!(
|
||||
report.backups[0].origin.as_deref(),
|
||||
Some("https://studio.youtube.com")
|
||||
);
|
||||
assert!(report.backups[0].created_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_repair_in_the_same_second_gets_its_own_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
profile_with_studio(tmp.path(), 3000);
|
||||
move_aside(tmp.path(), &[STUDIO.to_string()], "20261101-165020").unwrap();
|
||||
profile_with_studio(tmp.path(), 3000);
|
||||
move_aside(tmp.path(), &[STUDIO.to_string()], "20261101-165020").unwrap();
|
||||
assert_eq!(scan(tmp.path()).backups.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_site_is_refused_and_nothing_moves() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
profile_with_studio(tmp.path(), 3000);
|
||||
let ids = [
|
||||
STUDIO.to_string(),
|
||||
"Default/IndexedDB/../../Local State".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
move_aside(tmp.path(), &ids, "20261101-165020").unwrap_err(),
|
||||
StorageError::NotFound
|
||||
);
|
||||
assert!(tmp.path().join(STUDIO).is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_backup_removes_only_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
profile_with_studio(tmp.path(), 3000);
|
||||
move_aside(tmp.path(), &[STUDIO.to_string()], "20261101-165020").unwrap();
|
||||
let id = scan(tmp.path()).backups[0].id.clone();
|
||||
assert_eq!(
|
||||
delete_backup_in(tmp.path(), STUDIO).unwrap_err(),
|
||||
StorageError::NotFound
|
||||
);
|
||||
delete_backup_in(tmp.path(), &id).unwrap();
|
||||
assert!(scan(tmp.path()).backups.is_empty());
|
||||
assert!(tmp
|
||||
.path()
|
||||
.join("Default/IndexedDB/https_mail.example_0")
|
||||
.is_dir());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_out_of_the_profile_is_neither_measured_nor_moved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
sparse(&outside.path().join("https_x.example_0/big"), 5000);
|
||||
let idb = tmp.path().join("Default/IndexedDB");
|
||||
fs::create_dir_all(&idb).unwrap();
|
||||
std::os::unix::fs::symlink(
|
||||
outside.path().join("https_x.example_0"),
|
||||
idb.join("https_x.example_0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
scan(tmp.path()).sites.is_empty(),
|
||||
"a linked site folder is skipped"
|
||||
);
|
||||
|
||||
// A linked area folder resolves out of the profile, so nothing in it moves.
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(other.path().join("Default")).unwrap();
|
||||
sparse(&outside.path().join("area/https_y.example_0/big"), 5000);
|
||||
std::os::unix::fs::symlink(
|
||||
outside.path().join("area"),
|
||||
other.path().join("Default/IndexedDB"),
|
||||
)
|
||||
.unwrap();
|
||||
let id = "Default/IndexedDB/https_y.example_0".to_string();
|
||||
assert_eq!(scan(other.path()).sites.len(), 1);
|
||||
assert_eq!(
|
||||
move_aside(other.path(), &[id], "20261101-165020").unwrap_err(),
|
||||
StorageError::UnsafePath
|
||||
);
|
||||
assert!(outside.path().join("area/https_y.example_0/big").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_is_refused_while_the_browser_runs_or_launches() {
|
||||
let running = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
process_id: Some(std::process::id()),
|
||||
..Default::default()
|
||||
};
|
||||
let refused = hold_stopped(&running).await.unwrap_err();
|
||||
assert!(refused.contains("PROFILE_RUNNING"), "{refused}");
|
||||
|
||||
let launching = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
process_id: Some(std::process::id()),
|
||||
..Default::default()
|
||||
};
|
||||
let _launch = crate::browser_runner::try_lock_profile_launch(&launching.id.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let refused = hold_stopped(&launching).await.unwrap_err();
|
||||
assert!(refused.contains("PROFILE_RUNNING"), "{refused}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stamp_reads_back_as_a_time() {
|
||||
assert!(stamp_time("20261101-165020").is_some());
|
||||
assert!(stamp_time("20261101-165020-1").is_some());
|
||||
assert!(stamp_time("garbage").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_name_of_a_site_hides_it() {
|
||||
let hash = site_hash(STUDIO);
|
||||
assert_eq!(hash.len(), 12);
|
||||
assert!(!hash.contains("youtube"));
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ pub(crate) fn read_profile(id: &uuid::Uuid) -> Option<BrowserProfile> {
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
async fn is_running(profile: &BrowserProfile) -> bool {
|
||||
pub(crate) async fn is_running(profile: &BrowserProfile) -> bool {
|
||||
if profile.process_id.is_some()
|
||||
|| crate::remote_handoff::state_for(&profile.id.to_string()).is_some()
|
||||
{
|
||||
|
||||
@@ -83,6 +83,7 @@ import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProfileStatuses } from "@/hooks/use-profile-statuses";
|
||||
import { useProfileTableFilters } from "@/hooks/use-profile-table-filters";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useSiteStorageNotices } from "@/hooks/use-site-storage-notices";
|
||||
import { useSyncSessions } from "@/hooks/use-sync-session";
|
||||
import { useTips } from "@/hooks/use-tips";
|
||||
import { confirmLeave, hasUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
@@ -182,6 +183,7 @@ export default function Home() {
|
||||
loadProfiles,
|
||||
} = useProfileEvents();
|
||||
useLaunchNotices(profiles);
|
||||
useSiteStorageNotices(profiles);
|
||||
|
||||
// First-run product tour: coach marks on the real controls.
|
||||
const [tour, setTour] = useState<{ open: boolean; step: TourStep }>({
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
useCanRunRemote,
|
||||
useProfileUnlocked,
|
||||
} from "@/components/profile-info/header-actions";
|
||||
import { SiteStoragePanel } from "@/components/profile-info/site-storage";
|
||||
import { ProfileLaunchActivity } from "@/components/profile-launch-activity";
|
||||
import { ProfileMetadataCard } from "@/components/profile-metadata-card";
|
||||
import { ExportProfileSection } from "@/components/profile-transfer";
|
||||
@@ -1445,6 +1446,9 @@ function ProfileInfoLayout({
|
||||
profileId={profile.id}
|
||||
running={isRunning}
|
||||
/>
|
||||
{!isEphemeral && (
|
||||
<SiteStoragePanel profile={profile} isRunning={isRunning} />
|
||||
)}
|
||||
|
||||
{/* ID */}
|
||||
<div className="flex items-center gap-2 rounded-lg bg-foreground/4 px-3 py-2">
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { formatBytes } from "@/lib/format-bytes";
|
||||
import {
|
||||
deleteSiteStorageBackup,
|
||||
getSiteStorage,
|
||||
repairSiteStorage,
|
||||
type SiteStorage,
|
||||
type SiteStorageBackup,
|
||||
type SiteStorageReport,
|
||||
siteLabel,
|
||||
} from "@/lib/site-storage";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
/**
|
||||
* Oversized site data of a profile, with the repair, and the data earlier
|
||||
* repairs moved aside, with its delete. Shows nothing for a healthy profile.
|
||||
*/
|
||||
export function SiteStoragePanel({
|
||||
profile,
|
||||
isRunning,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
isRunning: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [report, setReport] = React.useState<SiteStorageReport | null>(null);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [confirm, setConfirm] = React.useState<SiteStorageBackup | null>(null);
|
||||
|
||||
// A browser that stops may have grown a site, so measure again then.
|
||||
React.useEffect(() => {
|
||||
if (isRunning) return;
|
||||
let cancelled = false;
|
||||
getSiteStorage(profile.id)
|
||||
.then((next) => {
|
||||
if (!cancelled) setReport(next);
|
||||
})
|
||||
.catch(() => {
|
||||
// Nothing to show: the measurement is advice, not a state to fix.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [profile.id, isRunning]);
|
||||
|
||||
if (!report || (report.oversized.length === 0 && report.backups.length === 0))
|
||||
return null;
|
||||
|
||||
const repair = async (site: SiteStorage) => {
|
||||
setBusy(site.id);
|
||||
try {
|
||||
setReport(await repairSiteStorage(profile.id, [site.id]));
|
||||
showSuccessToast(t("siteStorage.repaired", { site: siteLabel(t, site) }));
|
||||
} catch (err) {
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBackup = async (backup: SiteStorageBackup) => {
|
||||
setBusy(backup.id);
|
||||
try {
|
||||
setReport(await deleteSiteStorageBackup(profile.id, backup.id));
|
||||
showSuccessToast(t("siteStorage.deleted"));
|
||||
setConfirm(null);
|
||||
} catch (err) {
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-slot="site-storage" className="flex flex-col gap-2">
|
||||
{report.oversized.length > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-warning/10 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<LuTriangleAlert className="size-4 shrink-0 text-warning" />
|
||||
{t("siteStorage.title")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("siteStorage.description", {
|
||||
size: formatBytes(report.threshold_bytes),
|
||||
})}
|
||||
</p>
|
||||
{report.oversized.map((site) => (
|
||||
<div
|
||||
key={site.id}
|
||||
data-slot="site-storage-oversized"
|
||||
data-storage-id={site.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm" title={siteLabel(t, site)}>
|
||||
{siteLabel(t, site)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`siteStorage.kinds.${site.kind}`)} ·{" "}
|
||||
{formatBytes(site.bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="soft"
|
||||
size="sm"
|
||||
data-slot="site-storage-repair"
|
||||
disabled={isRunning || busy !== null}
|
||||
title={isRunning ? t("siteStorage.stopFirst") : undefined}
|
||||
onClick={() => void repair(site)}
|
||||
>
|
||||
{t("siteStorage.repair")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{isRunning && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("siteStorage.stopFirst")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.backups.length > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-foreground/4 px-3 py-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("siteStorage.backupsTitle")}
|
||||
</p>
|
||||
{report.backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
data-slot="site-storage-backup"
|
||||
data-storage-id={backup.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm" title={siteLabel(t, backup)}>
|
||||
{siteLabel(t, backup)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{backup.created_at
|
||||
? t("siteStorage.backupMeta", {
|
||||
size: formatBytes(backup.bytes),
|
||||
date: new Date(backup.created_at * 1000).toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
},
|
||||
),
|
||||
})
|
||||
: formatBytes(backup.bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="soft"
|
||||
size="sm"
|
||||
data-slot="site-storage-delete-backup"
|
||||
disabled={busy !== null}
|
||||
onClick={() => setConfirm(backup)}
|
||||
>
|
||||
{t("common.buttons.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={confirm !== null}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={() => (confirm ? deleteBackup(confirm) : undefined)}
|
||||
title={t("siteStorage.deleteTitle", {
|
||||
site: confirm ? siteLabel(t, confirm) : "",
|
||||
})}
|
||||
description={t("siteStorage.deleteDescription", {
|
||||
size: confirm ? formatBytes(confirm.bytes) : "",
|
||||
})}
|
||||
confirmButtonText={t("common.buttons.delete")}
|
||||
isLoading={busy !== null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { formatBytes } from "@/lib/format-bytes";
|
||||
import {
|
||||
repairSiteStorage,
|
||||
type SiteStorage,
|
||||
siteLabel,
|
||||
} from "@/lib/site-storage";
|
||||
import { showErrorToast, showSuccessToast, showToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
interface OversizedNotice {
|
||||
profile_id: string;
|
||||
sites: SiteStorage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser quit soon after its launch while a site's data was oversized:
|
||||
* one toast per profile that names the sites and offers the repair.
|
||||
*/
|
||||
export function useSiteStorageNotices(profiles: readonly BrowserProfile[]) {
|
||||
const { t } = useTranslation();
|
||||
const latest = useRef({ t, profiles });
|
||||
latest.current = { t, profiles };
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const unlisten = listen<OversizedNotice>(
|
||||
"site-storage-oversized",
|
||||
(event) => {
|
||||
if (disposed) return;
|
||||
const { profile_id, sites } = event.payload;
|
||||
if (sites.length === 0) return;
|
||||
const { t: translate, profiles: known } = latest.current;
|
||||
const name =
|
||||
known.find((profile) => profile.id === profile_id)?.name ??
|
||||
translate("common.labels.unknownProfile");
|
||||
const site = sites.map((s) => siteLabel(translate, s)).join(", ");
|
||||
const size = formatBytes(sites.reduce((sum, s) => sum + s.bytes, 0));
|
||||
const id = `site-storage-${profile_id}`;
|
||||
showToast({
|
||||
type: "error",
|
||||
id,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
title: translate("siteStorage.notice.title", { name, site, size }),
|
||||
description: translate("siteStorage.notice.description"),
|
||||
action: {
|
||||
label: translate("siteStorage.repair"),
|
||||
onClick: () => {
|
||||
repairSiteStorage(
|
||||
profile_id,
|
||||
sites.map((s) => s.id),
|
||||
)
|
||||
.then(() =>
|
||||
showSuccessToast(
|
||||
translate("siteStorage.repaired", { site }),
|
||||
{ id },
|
||||
),
|
||||
)
|
||||
.catch((err: unknown) =>
|
||||
showErrorToast(translateBackendError(translate, err), {
|
||||
id,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
disposed = true;
|
||||
void unlisten.then((stop) => stop());
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -2969,7 +2969,10 @@
|
||||
"agentTooManyProfiles": "Zu viele Profile für einen Lauf. Das Limit ist 500.",
|
||||
"agentTaskNotFound": "Diese Aufgabe läuft nicht mehr.",
|
||||
"agentBatchNotFound": "Dieser Lauf ist nicht mehr in der Liste.",
|
||||
"agentActionInvalid": "Der Agent kennt diese Aktion nicht."
|
||||
"agentActionInvalid": "Der Agent kennt diese Aktion nicht.",
|
||||
"siteStorageNotFound": "Diese Websitedaten sind nicht mehr im Profil.",
|
||||
"siteStorageUnsafePath": "Diese Websitedaten verweisen außerhalb des Profilordners, daher hat Donut sie nicht geändert.",
|
||||
"siteStorageIoFailed": "Donut konnte die Websitedaten nicht ändern: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -4942,5 +4945,28 @@
|
||||
"onDisk": "Auf diesem Computer schreiben flüchtige Profile ihre Daten auf den Datenträger. Donut löscht sie, wenn der Browser geschlossen wird. Installieren Sie WinFsp, um die Daten im Arbeitsspeicher zu halten.",
|
||||
"winfspError": "WinFsp ist nicht gestartet, daher schreiben flüchtige Profile ihre Daten auf den Datenträger. Donut löscht sie, wenn der Browser geschlossen wird.",
|
||||
"download": "WinFsp herunterladen"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "Zu große Websitedaten",
|
||||
"description": "Jede Website unten speichert mehr als {{size}}. So viele Daten können den Browser kurz nach dem Start beenden. Reparieren verschiebt die Daten der Website innerhalb des Profils zur Seite. Cookies, Anmeldungen, Erweiterungen und andere Websites bleiben erhalten.",
|
||||
"repair": "Reparieren",
|
||||
"stopFirst": "Beende den Browser, um zu reparieren.",
|
||||
"repaired": "Daten von {{site}} zur Seite verschoben",
|
||||
"deleted": "Alte Websitedaten gelöscht",
|
||||
"backupsTitle": "Zur Seite verschobene Websitedaten",
|
||||
"backupMeta": "{{size}}, verschoben {{date}}",
|
||||
"deleteTitle": "Alte Daten von {{site}} löschen?",
|
||||
"deleteDescription": "Dadurch werden {{size}} Daten, die eine Reparatur zur Seite verschoben hat, endgültig gelöscht.",
|
||||
"unknownSite": "Unbekannte Website ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "Dateisystem",
|
||||
"storage_bucket": "Speicher-Bucket"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} wurde kurz nach dem Start beendet: {{site}} speichert {{size}}",
|
||||
"description": "So viele Daten einer Website können den Browser beenden. Reparieren verschiebt die Daten der Website im Profil zur Seite. Cookies, Anmeldungen und andere Websites bleiben erhalten."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2969,7 +2969,10 @@
|
||||
"agentTooManyProfiles": "Too many profiles for one run. The limit is 500.",
|
||||
"agentTaskNotFound": "That task is no longer running.",
|
||||
"agentBatchNotFound": "That run is no longer in the list.",
|
||||
"agentActionInvalid": "The agent does not know that action."
|
||||
"agentActionInvalid": "The agent does not know that action.",
|
||||
"siteStorageNotFound": "This site data is no longer in the profile.",
|
||||
"siteStorageUnsafePath": "This site data links outside the profile folder, so Donut did not change it.",
|
||||
"siteStorageIoFailed": "Donut could not change the site data: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -4942,5 +4945,28 @@
|
||||
"onDisk": "On this computer, ephemeral profiles write their data to disk. Donut erases it when the browser closes. Install WinFsp to keep the data in memory.",
|
||||
"winfspError": "WinFsp did not start, so ephemeral profiles write their data to disk. Donut erases it when the browser closes.",
|
||||
"download": "Download WinFsp"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "Oversized site data",
|
||||
"description": "Each site below stores more than {{size}}. A site this large can make the browser quit soon after it opens. Repair moves the site's data aside inside the profile. Cookies, logins, extensions and other sites stay.",
|
||||
"repair": "Repair",
|
||||
"stopFirst": "Stop the browser to repair.",
|
||||
"repaired": "Data of {{site}} moved aside",
|
||||
"deleted": "Old site data deleted",
|
||||
"backupsTitle": "Site data moved aside",
|
||||
"backupMeta": "{{size}}, moved {{date}}",
|
||||
"deleteTitle": "Delete the old data of {{site}}?",
|
||||
"deleteDescription": "This permanently deletes {{size}} of data that a repair moved aside.",
|
||||
"unknownSite": "Unknown site ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "File System",
|
||||
"storage_bucket": "Storage bucket"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} closed soon after launch: {{site}} stores {{size}}",
|
||||
"description": "A site this large can make the browser quit. Repair moves the site's data aside in the profile. Cookies, logins and other sites stay."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2989,7 +2989,10 @@
|
||||
"agentTooManyProfiles": "Demasiados perfiles para una ejecución. El límite es 500.",
|
||||
"agentTaskNotFound": "Esa tarea ya no se está ejecutando.",
|
||||
"agentBatchNotFound": "Esa ejecución ya no está en la lista.",
|
||||
"agentActionInvalid": "El agente no conoce esa acción."
|
||||
"agentActionInvalid": "El agente no conoce esa acción.",
|
||||
"siteStorageNotFound": "Estos datos del sitio ya no están en el perfil.",
|
||||
"siteStorageUnsafePath": "Estos datos del sitio apuntan fuera de la carpeta del perfil, así que Donut no los cambió.",
|
||||
"siteStorageIoFailed": "Donut no pudo cambiar los datos del sitio: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -4996,5 +4999,28 @@
|
||||
"onDisk": "En este equipo, los perfiles efímeros escriben sus datos en el disco. Donut los borra cuando se cierra el navegador. Instala WinFsp para mantener los datos en memoria.",
|
||||
"winfspError": "WinFsp no se inició, así que los perfiles efímeros escriben sus datos en el disco. Donut los borra cuando se cierra el navegador.",
|
||||
"download": "Descargar WinFsp"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "Datos de sitio demasiado grandes",
|
||||
"description": "Cada sitio de abajo guarda más de {{size}}. Un sitio tan grande puede hacer que el navegador se cierre poco después de abrirse. Reparar aparta los datos del sitio dentro del perfil. Las cookies, los inicios de sesión, las extensiones y los demás sitios se mantienen.",
|
||||
"repair": "Reparar",
|
||||
"stopFirst": "Detén el navegador para reparar.",
|
||||
"repaired": "Datos de {{site}} apartados",
|
||||
"deleted": "Datos antiguos del sitio eliminados",
|
||||
"backupsTitle": "Datos de sitio apartados",
|
||||
"backupMeta": "{{size}}, apartados el {{date}}",
|
||||
"deleteTitle": "¿Eliminar los datos antiguos de {{site}}?",
|
||||
"deleteDescription": "Esto elimina para siempre {{size}} de datos que una reparación apartó.",
|
||||
"unknownSite": "Sitio desconocido ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "Sistema de archivos",
|
||||
"storage_bucket": "Bucket de almacenamiento"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} se cerró poco después de iniciarse: {{site}} guarda {{size}}",
|
||||
"description": "Un sitio tan grande puede cerrar el navegador. Reparar aparta los datos del sitio en el perfil. Las cookies, los inicios de sesión y los demás sitios se mantienen."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2989,7 +2989,10 @@
|
||||
"agentTooManyProfiles": "Trop de profils pour une exécution. La limite est de 500.",
|
||||
"agentTaskNotFound": "Cette tâche n’est plus en cours.",
|
||||
"agentBatchNotFound": "Cette exécution n’est plus dans la liste.",
|
||||
"agentActionInvalid": "L’agent ne connaît pas cette action."
|
||||
"agentActionInvalid": "L’agent ne connaît pas cette action.",
|
||||
"siteStorageNotFound": "Ces données de site ne sont plus dans le profil.",
|
||||
"siteStorageUnsafePath": "Ces données de site pointent hors du dossier du profil, Donut ne les a donc pas modifiées.",
|
||||
"siteStorageIoFailed": "Donut n'a pas pu modifier les données du site : {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -4996,5 +4999,28 @@
|
||||
"onDisk": "Sur cet ordinateur, les profils éphémères écrivent leurs données sur le disque. Donut les efface à la fermeture du navigateur. Installez WinFsp pour garder les données en mémoire.",
|
||||
"winfspError": "WinFsp n'a pas démarré, donc les profils éphémères écrivent leurs données sur le disque. Donut les efface à la fermeture du navigateur.",
|
||||
"download": "Télécharger WinFsp"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "Données de site trop volumineuses",
|
||||
"description": "Chaque site ci-dessous stocke plus de {{size}}. Un site aussi volumineux peut faire quitter le navigateur peu après son ouverture. Réparer met de côté les données du site dans le profil. Les cookies, les connexions, les extensions et les autres sites restent.",
|
||||
"repair": "Réparer",
|
||||
"stopFirst": "Arrêtez le navigateur pour réparer.",
|
||||
"repaired": "Données de {{site}} mises de côté",
|
||||
"deleted": "Anciennes données du site supprimées",
|
||||
"backupsTitle": "Données de site mises de côté",
|
||||
"backupMeta": "{{size}}, mises de côté le {{date}}",
|
||||
"deleteTitle": "Supprimer les anciennes données de {{site}} ?",
|
||||
"deleteDescription": "Cela supprime définitivement {{size}} de données mises de côté par une réparation.",
|
||||
"unknownSite": "Site inconnu ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "Système de fichiers",
|
||||
"storage_bucket": "Bucket de stockage"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} s'est fermé peu après le lancement : {{site}} stocke {{size}}",
|
||||
"description": "Un site aussi volumineux peut faire quitter le navigateur. Réparer met de côté les données du site dans le profil. Les cookies, les connexions et les autres sites restent."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2969,7 +2969,10 @@
|
||||
"agentTooManyProfiles": "1 回の実行にはプロファイルが多すぎます。上限は 500 です。",
|
||||
"agentTaskNotFound": "そのタスクはもう実行されていません。",
|
||||
"agentBatchNotFound": "その実行はもう一覧にありません。",
|
||||
"agentActionInvalid": "エージェントはその操作を知りません。"
|
||||
"agentActionInvalid": "エージェントはその操作を知りません。",
|
||||
"siteStorageNotFound": "このサイトデータはもうプロファイルにありません。",
|
||||
"siteStorageUnsafePath": "このサイトデータはプロファイルフォルダーの外を指しているため、Donut は変更しませんでした。",
|
||||
"siteStorageIoFailed": "Donut はサイトデータを変更できませんでした: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -4942,5 +4945,28 @@
|
||||
"onDisk": "このコンピューターでは、一時的なプロファイルはデータをディスクに書き込みます。ブラウザを閉じると Donut がそのデータを消去します。データをメモリに保持するには WinFsp をインストールしてください。",
|
||||
"winfspError": "WinFsp が起動しなかったため、一時的なプロファイルはデータをディスクに書き込みます。ブラウザを閉じると Donut がそのデータを消去します。",
|
||||
"download": "WinFsp をダウンロード"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "サイトデータが大きすぎます",
|
||||
"description": "以下の各サイトは {{size}} を超えるデータを保存しています。これほど大きなサイトデータがあると、ブラウザーが起動直後に終了することがあります。「修復」はサイトのデータをプロファイル内で別の場所に移します。Cookie、ログイン、拡張機能、他のサイトはそのまま残ります。",
|
||||
"repair": "修復",
|
||||
"stopFirst": "修復するにはブラウザーを停止してください。",
|
||||
"repaired": "{{site}} のデータを移動しました",
|
||||
"deleted": "古いサイトデータを削除しました",
|
||||
"backupsTitle": "移動したサイトデータ",
|
||||
"backupMeta": "{{size}}、{{date}} に移動",
|
||||
"deleteTitle": "{{site}} の古いデータを削除しますか?",
|
||||
"deleteDescription": "修復で移動した {{size}} のデータを完全に削除します。",
|
||||
"unknownSite": "不明なサイト ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "ファイルシステム",
|
||||
"storage_bucket": "ストレージバケット"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} は起動直後に終了しました: {{site}} が {{size}} を保存しています",
|
||||
"description": "これほど大きなサイトデータはブラウザーを終了させることがあります。「修復」はサイトのデータをプロファイル内で別の場所に移します。Cookie、ログイン、他のサイトはそのまま残ります。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2969,7 +2969,10 @@
|
||||
"agentTooManyProfiles": "한 번 실행하기에 프로필이 너무 많습니다. 한도는 500개입니다.",
|
||||
"agentTaskNotFound": "그 작업은 더 이상 실행 중이 아닙니다.",
|
||||
"agentBatchNotFound": "그 실행은 더 이상 목록에 없습니다.",
|
||||
"agentActionInvalid": "에이전트가 모르는 동작입니다."
|
||||
"agentActionInvalid": "에이전트가 모르는 동작입니다.",
|
||||
"siteStorageNotFound": "이 사이트 데이터는 더 이상 프로필에 없습니다.",
|
||||
"siteStorageUnsafePath": "이 사이트 데이터가 프로필 폴더 밖을 가리켜 Donut이 변경하지 않았습니다.",
|
||||
"siteStorageIoFailed": "Donut이 사이트 데이터를 변경하지 못했습니다: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -4942,5 +4945,28 @@
|
||||
"onDisk": "이 컴퓨터에서는 임시 프로필이 데이터를 디스크에 기록합니다. 브라우저를 닫으면 Donut이 데이터를 지웁니다. 데이터를 메모리에 유지하려면 WinFsp를 설치하세요.",
|
||||
"winfspError": "WinFsp가 시작되지 않아 임시 프로필이 데이터를 디스크에 기록합니다. 브라우저를 닫으면 Donut이 데이터를 지웁니다.",
|
||||
"download": "WinFsp 다운로드"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "사이트 데이터가 너무 큽니다",
|
||||
"description": "아래의 각 사이트는 {{size}}보다 많은 데이터를 저장합니다. 이렇게 큰 사이트 데이터는 브라우저가 열린 직후 종료되게 할 수 있습니다. 복구는 사이트 데이터를 프로필 안의 다른 곳으로 옮깁니다. 쿠키, 로그인, 확장 프로그램과 다른 사이트는 그대로 유지됩니다.",
|
||||
"repair": "복구",
|
||||
"stopFirst": "복구하려면 브라우저를 중지하세요.",
|
||||
"repaired": "{{site}}의 데이터를 옮겼습니다",
|
||||
"deleted": "이전 사이트 데이터를 삭제했습니다",
|
||||
"backupsTitle": "옮겨 둔 사이트 데이터",
|
||||
"backupMeta": "{{size}}, {{date}}에 옮김",
|
||||
"deleteTitle": "{{site}}의 이전 데이터를 삭제할까요?",
|
||||
"deleteDescription": "복구로 옮겨 둔 {{size}}의 데이터를 영구히 삭제합니다.",
|
||||
"unknownSite": "알 수 없는 사이트 ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "파일 시스템",
|
||||
"storage_bucket": "스토리지 버킷"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}}이(가) 실행 직후 종료되었습니다: {{site}}이(가) {{size}}를 저장합니다",
|
||||
"description": "이렇게 큰 사이트 데이터는 브라우저를 종료시킬 수 있습니다. 복구는 사이트 데이터를 프로필 안의 다른 곳으로 옮깁니다. 쿠키, 로그인과 다른 사이트는 그대로 유지됩니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2989,7 +2989,10 @@
|
||||
"agentTooManyProfiles": "Perfis demais para uma execução. O limite é 500.",
|
||||
"agentTaskNotFound": "Essa tarefa não está mais em execução.",
|
||||
"agentBatchNotFound": "Essa execução não está mais na lista.",
|
||||
"agentActionInvalid": "O agente não conhece essa ação."
|
||||
"agentActionInvalid": "O agente não conhece essa ação.",
|
||||
"siteStorageNotFound": "Estes dados do site não estão mais no perfil.",
|
||||
"siteStorageUnsafePath": "Estes dados do site apontam para fora da pasta do perfil, então o Donut não os alterou.",
|
||||
"siteStorageIoFailed": "O Donut não conseguiu alterar os dados do site: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -4996,5 +4999,28 @@
|
||||
"onDisk": "Neste computador, os perfis efêmeros gravam seus dados no disco. O Donut os apaga quando o navegador fecha. Instale o WinFsp para manter os dados na memória.",
|
||||
"winfspError": "O WinFsp não iniciou, então os perfis efêmeros gravam seus dados no disco. O Donut os apaga quando o navegador fecha.",
|
||||
"download": "Baixar o WinFsp"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "Dados de site grandes demais",
|
||||
"description": "Cada site abaixo guarda mais de {{size}}. Um site tão grande pode fazer o navegador fechar logo depois de abrir. Reparar move os dados do site para o lado dentro do perfil. Cookies, logins, extensões e outros sites permanecem.",
|
||||
"repair": "Reparar",
|
||||
"stopFirst": "Pare o navegador para reparar.",
|
||||
"repaired": "Dados de {{site}} movidos para o lado",
|
||||
"deleted": "Dados antigos do site excluídos",
|
||||
"backupsTitle": "Dados de site movidos para o lado",
|
||||
"backupMeta": "{{size}}, movidos em {{date}}",
|
||||
"deleteTitle": "Excluir os dados antigos de {{site}}?",
|
||||
"deleteDescription": "Isto exclui permanentemente {{size}} de dados que um reparo moveu para o lado.",
|
||||
"unknownSite": "Site desconhecido ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "Sistema de arquivos",
|
||||
"storage_bucket": "Bucket de armazenamento"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} fechou logo após iniciar: {{site}} guarda {{size}}",
|
||||
"description": "Um site tão grande pode fazer o navegador fechar. Reparar move os dados do site para o lado no perfil. Cookies, logins e outros sites permanecem."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3009,7 +3009,10 @@
|
||||
"agentTooManyProfiles": "Слишком много профилей для одного запуска. Лимит — 500.",
|
||||
"agentTaskNotFound": "Эта задача больше не выполняется.",
|
||||
"agentBatchNotFound": "Этого запуска больше нет в списке.",
|
||||
"agentActionInvalid": "Агент не знает такого действия."
|
||||
"agentActionInvalid": "Агент не знает такого действия.",
|
||||
"siteStorageNotFound": "Этих данных сайта больше нет в профиле.",
|
||||
"siteStorageUnsafePath": "Эти данные сайта указывают за пределы папки профиля, поэтому Donut их не изменил.",
|
||||
"siteStorageIoFailed": "Donut не удалось изменить данные сайта: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -5052,5 +5055,28 @@
|
||||
"onDisk": "На этом компьютере временные профили записывают данные на диск. Donut стирает их, когда браузер закрывается. Установите WinFsp, чтобы хранить данные в памяти.",
|
||||
"winfspError": "WinFsp не запустился, поэтому временные профили записывают данные на диск. Donut стирает их, когда браузер закрывается.",
|
||||
"download": "Скачать WinFsp"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "Слишком большие данные сайта",
|
||||
"description": "Каждый сайт ниже хранит больше {{size}}. Такие большие данные могут закрыть браузер вскоре после запуска. «Исправить» перемещает данные сайта в сторону внутри профиля. Cookie, входы, расширения и другие сайты сохраняются.",
|
||||
"repair": "Исправить",
|
||||
"stopFirst": "Остановите браузер, чтобы исправить.",
|
||||
"repaired": "Данные {{site}} перемещены в сторону",
|
||||
"deleted": "Старые данные сайта удалены",
|
||||
"backupsTitle": "Перемещённые данные сайтов",
|
||||
"backupMeta": "{{size}}, перемещены {{date}}",
|
||||
"deleteTitle": "Удалить старые данные {{site}}?",
|
||||
"deleteDescription": "Это навсегда удалит {{size}} данных, которые были перемещены при исправлении.",
|
||||
"unknownSite": "Неизвестный сайт ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "Файловая система",
|
||||
"storage_bucket": "Хранилище (bucket)"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} закрылся вскоре после запуска: {{site}} хранит {{size}}",
|
||||
"description": "Такие большие данные сайта могут закрыть браузер. «Исправить» перемещает данные сайта в сторону внутри профиля. Cookie, входы и другие сайты сохраняются."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2969,7 +2969,10 @@
|
||||
"agentTooManyProfiles": "Bir çalıştırma için çok fazla profil. Sınır 500.",
|
||||
"agentTaskNotFound": "Bu görev artık çalışmıyor.",
|
||||
"agentBatchNotFound": "Bu çalıştırma artık listede değil.",
|
||||
"agentActionInvalid": "Ajan bu eylemi bilmiyor."
|
||||
"agentActionInvalid": "Ajan bu eylemi bilmiyor.",
|
||||
"siteStorageNotFound": "Bu site verisi artık profilde değil.",
|
||||
"siteStorageUnsafePath": "Bu site verisi profil klasörünün dışını gösteriyor, bu yüzden Donut onu değiştirmedi.",
|
||||
"siteStorageIoFailed": "Donut site verisini değiştiremedi: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
@@ -4942,5 +4945,28 @@
|
||||
"onDisk": "Bu bilgisayarda geçici profiller verilerini diske yazar. Donut, tarayıcı kapandığında bu verileri siler. Verileri bellekte tutmak için WinFsp'yi yükleyin.",
|
||||
"winfspError": "WinFsp başlamadı, bu yüzden geçici profiller verilerini diske yazar. Donut, tarayıcı kapandığında bu verileri siler.",
|
||||
"download": "WinFsp'yi indir"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "Site verisi çok büyük",
|
||||
"description": "Aşağıdaki her site {{size}} boyutundan fazla veri saklıyor. Bu kadar büyük bir site, tarayıcının açıldıktan kısa süre sonra kapanmasına neden olabilir. Onar, sitenin verisini profilin içinde kenara taşır. Çerezler, oturumlar, uzantılar ve diğer siteler kalır.",
|
||||
"repair": "Onar",
|
||||
"stopFirst": "Onarmak için tarayıcıyı durdurun.",
|
||||
"repaired": "{{site}} verisi kenara taşındı",
|
||||
"deleted": "Eski site verisi silindi",
|
||||
"backupsTitle": "Kenara taşınan site verisi",
|
||||
"backupMeta": "{{size}}, {{date}} tarihinde taşındı",
|
||||
"deleteTitle": "{{site}} için eski veri silinsin mi?",
|
||||
"deleteDescription": "Bu, bir onarımın kenara taşıdığı {{size}} veriyi kalıcı olarak siler.",
|
||||
"unknownSite": "Bilinmeyen site ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "Dosya Sistemi",
|
||||
"storage_bucket": "Depolama kovası"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} başlatıldıktan kısa süre sonra kapandı: {{site}} {{size}} saklıyor",
|
||||
"description": "Bu kadar büyük bir site tarayıcıyı kapatabilir. Onar, sitenin verisini profilde kenara taşır. Çerezler, oturumlar ve diğer siteler kalır."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2969,7 +2969,10 @@
|
||||
"agentTooManyProfiles": "Quá nhiều hồ sơ cho một lần chạy. Giới hạn là 500.",
|
||||
"agentTaskNotFound": "Tác vụ đó không còn chạy.",
|
||||
"agentBatchNotFound": "Lần chạy đó không còn trong danh sách.",
|
||||
"agentActionInvalid": "Tác tử không biết hành động đó."
|
||||
"agentActionInvalid": "Tác tử không biết hành động đó.",
|
||||
"siteStorageNotFound": "Dữ liệu trang này không còn trong hồ sơ.",
|
||||
"siteStorageUnsafePath": "Dữ liệu trang này trỏ ra ngoài thư mục hồ sơ, nên Donut không thay đổi nó.",
|
||||
"siteStorageIoFailed": "Donut không thể thay đổi dữ liệu trang: {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -4942,5 +4945,28 @@
|
||||
"onDisk": "Trên máy tính này, hồ sơ tạm thời ghi dữ liệu vào ổ đĩa. Donut xóa dữ liệu đó khi trình duyệt đóng. Hãy cài WinFsp để giữ dữ liệu trong bộ nhớ.",
|
||||
"winfspError": "WinFsp không khởi động được, nên hồ sơ tạm thời ghi dữ liệu vào ổ đĩa. Donut xóa dữ liệu đó khi trình duyệt đóng.",
|
||||
"download": "Tải WinFsp"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "Dữ liệu trang quá lớn",
|
||||
"description": "Mỗi trang bên dưới lưu hơn {{size}}. Một trang lớn như vậy có thể làm trình duyệt thoát ngay sau khi mở. Sửa sẽ chuyển dữ liệu của trang sang một bên trong hồ sơ. Cookie, đăng nhập, tiện ích và các trang khác được giữ nguyên.",
|
||||
"repair": "Sửa",
|
||||
"stopFirst": "Hãy dừng trình duyệt để sửa.",
|
||||
"repaired": "Đã chuyển dữ liệu của {{site}} sang một bên",
|
||||
"deleted": "Đã xóa dữ liệu trang cũ",
|
||||
"backupsTitle": "Dữ liệu trang đã chuyển sang một bên",
|
||||
"backupMeta": "{{size}}, chuyển lúc {{date}}",
|
||||
"deleteTitle": "Xóa dữ liệu cũ của {{site}}?",
|
||||
"deleteDescription": "Thao tác này xóa vĩnh viễn {{size}} dữ liệu mà lần sửa đã chuyển sang một bên.",
|
||||
"unknownSite": "Trang không xác định ({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "Hệ thống tệp",
|
||||
"storage_bucket": "Bucket lưu trữ"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} đã đóng ngay sau khi khởi chạy: {{site}} lưu {{size}}",
|
||||
"description": "Một trang lớn như vậy có thể làm trình duyệt thoát. Sửa sẽ chuyển dữ liệu của trang sang một bên trong hồ sơ. Cookie, đăng nhập và các trang khác được giữ nguyên."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2969,7 +2969,10 @@
|
||||
"agentTooManyProfiles": "单次运行的配置文件过多。上限为 500。",
|
||||
"agentTaskNotFound": "该任务已不在运行。",
|
||||
"agentBatchNotFound": "该运行已不在列表中。",
|
||||
"agentActionInvalid": "智能体不认识该操作。"
|
||||
"agentActionInvalid": "智能体不认识该操作。",
|
||||
"siteStorageNotFound": "此网站数据已不在配置文件中。",
|
||||
"siteStorageUnsafePath": "此网站数据指向配置文件夹之外,因此 Donut 未作更改。",
|
||||
"siteStorageIoFailed": "Donut 无法更改网站数据:{{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -4942,5 +4945,28 @@
|
||||
"onDisk": "在这台电脑上,临时配置文件会把数据写入磁盘。浏览器关闭时 Donut 会清除这些数据。安装 WinFsp 可将数据保留在内存中。",
|
||||
"winfspError": "WinFsp 未能启动,因此临时配置文件会把数据写入磁盘。浏览器关闭时 Donut 会清除这些数据。",
|
||||
"download": "下载 WinFsp"
|
||||
},
|
||||
"siteStorage": {
|
||||
"title": "网站数据过大",
|
||||
"description": "下面每个网站存储的数据都超过 {{size}}。这么大的网站数据可能会让浏览器在打开后不久退出。“修复”会把该网站的数据移到配置文件内的一旁。Cookie、登录信息、扩展和其他网站都会保留。",
|
||||
"repair": "修复",
|
||||
"stopFirst": "请先停止浏览器再修复。",
|
||||
"repaired": "已将 {{site}} 的数据移到一旁",
|
||||
"deleted": "已删除旧的网站数据",
|
||||
"backupsTitle": "已移到一旁的网站数据",
|
||||
"backupMeta": "{{size}},移动于 {{date}}",
|
||||
"deleteTitle": "删除 {{site}} 的旧数据?",
|
||||
"deleteDescription": "这会永久删除修复时移到一旁的 {{size}} 数据。",
|
||||
"unknownSite": "未知网站({{folder}})",
|
||||
"kinds": {
|
||||
"indexed_db": "IndexedDB",
|
||||
"cache_storage": "Cache Storage",
|
||||
"file_system": "文件系统",
|
||||
"storage_bucket": "存储桶"
|
||||
},
|
||||
"notice": {
|
||||
"title": "{{name}} 启动后不久就关闭了:{{site}} 存储了 {{size}}",
|
||||
"description": "这么大的网站数据可能会让浏览器退出。“修复”会把该网站的数据移到配置文件内的一旁。Cookie、登录信息和其他网站都会保留。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ export type BackendErrorCode =
|
||||
| "PROFILE_NOT_PROTECTED"
|
||||
| "PROFILE_ALREADY_PROTECTED"
|
||||
| "PROFILE_RUNNING"
|
||||
| "SITE_STORAGE_NOT_FOUND"
|
||||
| "SITE_STORAGE_UNSAFE_PATH"
|
||||
| "SITE_STORAGE_IO_FAILED"
|
||||
| "PROFILE_PAIRED_TWICE"
|
||||
| "URL_SCHEME_NOT_ALLOWED"
|
||||
| "TOOL_IS_LOCAL_ONLY"
|
||||
@@ -481,6 +484,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.urlSchemeNotAllowed");
|
||||
case "PROFILE_RUNNING":
|
||||
return t("backendErrors.profileRunning");
|
||||
case "SITE_STORAGE_NOT_FOUND":
|
||||
return t("backendErrors.siteStorageNotFound");
|
||||
case "SITE_STORAGE_UNSAFE_PATH":
|
||||
return t("backendErrors.siteStorageUnsafePath");
|
||||
case "SITE_STORAGE_IO_FAILED":
|
||||
return t("backendErrors.siteStorageIoFailed", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "PROFILE_PAIRED_TWICE":
|
||||
return t("backendErrors.profilePairedTwice");
|
||||
case "PROFILE_EPHEMERAL":
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export type SiteStorageKind =
|
||||
| "indexed_db"
|
||||
| "cache_storage"
|
||||
| "file_system"
|
||||
| "storage_bucket";
|
||||
|
||||
/** The folders of one site in one kind of storage. */
|
||||
export interface SiteStorage {
|
||||
id: string;
|
||||
kind: SiteStorageKind;
|
||||
origin: string | null;
|
||||
folder: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** Site folders a repair moved aside, kept until deleted. */
|
||||
export interface SiteStorageBackup extends SiteStorage {
|
||||
created_at: number | null;
|
||||
}
|
||||
|
||||
export interface SiteStorageReport {
|
||||
oversized: SiteStorage[];
|
||||
backups: SiteStorageBackup[];
|
||||
threshold_bytes: number;
|
||||
}
|
||||
|
||||
export function siteLabel(t: TFunction, site: SiteStorage): string {
|
||||
return site.origin ?? t("siteStorage.unknownSite", { folder: site.folder });
|
||||
}
|
||||
|
||||
export function getSiteStorage(profileId: string) {
|
||||
return invoke<SiteStorageReport>("get_profile_site_storage", { profileId });
|
||||
}
|
||||
|
||||
/** Moves the named sites' folders aside; refused while the browser runs. */
|
||||
export function repairSiteStorage(profileId: string, storageIds: string[]) {
|
||||
return invoke<SiteStorageReport>("repair_profile_site_storage", {
|
||||
profileId,
|
||||
storageIds,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSiteStorageBackup(profileId: string, backupId: string) {
|
||||
return invoke<SiteStorageReport>("delete_profile_site_storage_backup", {
|
||||
profileId,
|
||||
backupId,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user