mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 06:28:19 +02:00
refactor: cleanup
This commit is contained in:
1 parent
1510c42ac9
commit
130cfda791
27 files changed
+962
-365
No files matched your search
+3
-3
@@ -77,8 +77,8 @@ runner redirects:
|
||||
- `TMPDIR`, `TMP`, and `TEMP`;
|
||||
- the Tauri WebView store (incognito for WKWebView, whose persistent data-directory API is not
|
||||
honored);
|
||||
- all REST, MCP, WebDriver, fixture, MinIO, and sync-server ports;
|
||||
- each sync test to a new MinIO bucket and random token.
|
||||
- all REST, MCP, WebDriver, fixture, S3, and sync-server ports;
|
||||
- each sync test to a new S3 bucket (served by `rclone serve s3`) and random token.
|
||||
|
||||
The E2E feature suppresses automatic updater/download traffic, but explicit browser tests still
|
||||
exercise published Wayfern downloads whenever the cache fixture is missing or holds a different
|
||||
@@ -90,7 +90,7 @@ the WebDriver plugin or this fallback.
|
||||
|
||||
`.github/workflows/app-e2e.yml` runs smoke tests on macOS, Linux/Xvfb, and Windows for pull
|
||||
requests. Pushes to `main`, weekly schedules, and manual runs execute the full macOS suite,
|
||||
including MinIO-backed sync and real Wayfern automation, plus a Linux/Docker job for residential
|
||||
including S3-backed sync and real Wayfern automation, plus a Linux/Docker job for residential
|
||||
proxy and local WireGuard browser traffic.
|
||||
|
||||
Every job restores the compiled driver from an `actions/cache` entry keyed by `e2e/app/Cargo.lock`,
|
||||
|
||||
+28
-74
@@ -10,7 +10,6 @@ import {
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
@@ -26,6 +25,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ensureRclone, serveS3Args } from "../scripts/rclone.mjs";
|
||||
import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
|
||||
import { extensionCrx3 } from "./lib/fixtures.mjs";
|
||||
import { DRIVER_COMMAND_TIMEOUT_SECONDS } from "./lib/limits.mjs";
|
||||
@@ -483,22 +483,6 @@ async function ensureMmdbFixture(
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function minioUrl() {
|
||||
const arch = os.arch() === "arm64" ? "arm64" : "amd64";
|
||||
if (process.platform === "darwin") {
|
||||
return `https://dl.min.io/server/minio/release/darwin-${arch}/minio`;
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return `https://dl.min.io/server/minio/release/linux-${arch}/minio`;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return "https://dl.min.io/server/minio/release/windows-amd64/minio.exe";
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MinIO platform ${process.platform}-${os.arch()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function download(url, destination) {
|
||||
const transport = url.startsWith("https:") ? https : http;
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -526,73 +510,43 @@ async function download(url, destination) {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureMinio() {
|
||||
if (process.env.DONUT_E2E_MINIO_BIN) {
|
||||
return path.resolve(process.env.DONUT_E2E_MINIO_BIN);
|
||||
async function ensureS3Server() {
|
||||
if (process.env.DONUT_E2E_RCLONE_BIN) {
|
||||
return path.resolve(process.env.DONUT_E2E_RCLONE_BIN);
|
||||
}
|
||||
const existingHarnessBinary = path.join(
|
||||
projectRoot,
|
||||
".cache",
|
||||
"sync-test",
|
||||
isWindows ? "minio.exe" : "minio",
|
||||
// The same cache as scripts/sync-test-harness.mjs, so one download serves both.
|
||||
return ensureRclone(
|
||||
path.join(projectRoot, ".cache", "sync-test"),
|
||||
download,
|
||||
log,
|
||||
);
|
||||
if (existsSync(existingHarnessBinary)) {
|
||||
return existingHarnessBinary;
|
||||
}
|
||||
const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools");
|
||||
const binary = path.join(
|
||||
toolsDir,
|
||||
`minio-${process.platform}-${os.arch()}${executableSuffix}`,
|
||||
);
|
||||
await mkdir(toolsDir, { recursive: true });
|
||||
if (!existsSync(binary)) {
|
||||
log("Downloading isolated MinIO test dependency");
|
||||
const temporary = `${binary}.${process.pid}.tmp`;
|
||||
await download(minioUrl(), temporary);
|
||||
await chmod(temporary, 0o755);
|
||||
await rm(binary, { force: true });
|
||||
await import("node:fs/promises").then(({ rename }) =>
|
||||
rename(temporary, binary),
|
||||
);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
async function startSyncInfrastructure(runRoot, options, records) {
|
||||
const minioBinary = await ensureMinio();
|
||||
const minioPort = await freePort();
|
||||
const minioConsolePort = await freePort();
|
||||
const s3Binary = await ensureS3Server();
|
||||
const s3Port = await freePort();
|
||||
const syncPort = await freePort();
|
||||
const syncToken = "donut-e2e-sync-token-0123456789abcdef";
|
||||
const minio = startProcess(
|
||||
"minio",
|
||||
minioBinary,
|
||||
[
|
||||
"server",
|
||||
path.join(runRoot, "minio-data"),
|
||||
"--address",
|
||||
`127.0.0.1:${minioPort}`,
|
||||
"--console-address",
|
||||
`127.0.0.1:${minioConsolePort}`,
|
||||
],
|
||||
const s3DataDir = path.join(runRoot, "s3-data");
|
||||
await mkdir(s3DataDir, { recursive: true });
|
||||
const s3 = startProcess(
|
||||
"s3",
|
||||
s3Binary,
|
||||
serveS3Args({
|
||||
dataDir: s3DataDir,
|
||||
address: `127.0.0.1:${s3Port}`,
|
||||
accessKey: "minioadmin",
|
||||
secretKey: "minioadmin",
|
||||
}),
|
||||
{
|
||||
cwd: projectRoot,
|
||||
runRoot,
|
||||
verbose: options.verbose,
|
||||
env: {
|
||||
...process.env,
|
||||
MINIO_ROOT_USER: "minioadmin",
|
||||
MINIO_ROOT_PASSWORD: "minioadmin",
|
||||
MINIO_BROWSER: "off",
|
||||
},
|
||||
env: { ...process.env },
|
||||
},
|
||||
);
|
||||
records.push(minio);
|
||||
await waitForUrl(
|
||||
`http://127.0.0.1:${minioPort}/minio/health/live`,
|
||||
30_000,
|
||||
minio,
|
||||
);
|
||||
records.push(s3);
|
||||
await waitForPort(s3Port, 30_000, s3);
|
||||
|
||||
const syncRoot = path.join(projectRoot, "donut-sync");
|
||||
await rm(path.join(syncRoot, "tsconfig.build.tsbuildinfo"), { force: true });
|
||||
@@ -606,7 +560,7 @@ async function startSyncInfrastructure(runRoot, options, records) {
|
||||
...process.env,
|
||||
PORT: String(syncPort),
|
||||
SYNC_TOKEN: syncToken,
|
||||
S3_ENDPOINT: `http://127.0.0.1:${minioPort}`,
|
||||
S3_ENDPOINT: `http://127.0.0.1:${s3Port}`,
|
||||
S3_REGION: "us-east-1",
|
||||
S3_ACCESS_KEY_ID: "minioadmin",
|
||||
S3_SECRET_ACCESS_KEY: "minioadmin",
|
||||
@@ -617,7 +571,7 @@ async function startSyncInfrastructure(runRoot, options, records) {
|
||||
records.push(sync);
|
||||
await waitForUrl(`http://127.0.0.1:${syncPort}/health`, 30_000, sync);
|
||||
return {
|
||||
minioUrl: `http://127.0.0.1:${minioPort}`,
|
||||
s3Url: `http://127.0.0.1:${s3Port}`,
|
||||
syncUrl: `http://127.0.0.1:${syncPort}`,
|
||||
syncToken,
|
||||
};
|
||||
@@ -1080,7 +1034,7 @@ async function main() {
|
||||
localValues.RESIDENTIAL_PROXY_URL_ONE_HTTP ?? "",
|
||||
DONUT_E2E_SYNC_URL: sync.syncUrl ?? "",
|
||||
DONUT_E2E_SYNC_TOKEN: sync.syncToken ?? "",
|
||||
DONUT_E2E_MINIO_URL: sync.minioUrl ?? "",
|
||||
DONUT_E2E_S3_URL: sync.s3Url ?? "",
|
||||
DONUT_E2E_WIREGUARD_CONFIG_BASE64: wireGuard
|
||||
? Buffer.from(wireGuard.config).toString("base64")
|
||||
: "",
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
@@ -1136,6 +1137,7 @@ test("deleted profiles land in the trash and come back intact on restore", async
|
||||
assert.equal(trashed[0].version, "150.0.7871.100");
|
||||
assert.equal(trashed[0].group_id, group.id);
|
||||
assert.equal(trashed[0].password_protected, false);
|
||||
assert.equal(trashed[0].sync_enabled, false);
|
||||
assert.equal(
|
||||
trashed[0].expires_at - trashed[0].deleted_at,
|
||||
30 * 24 * 60 * 60,
|
||||
@@ -1277,6 +1279,78 @@ test("deleted profiles land in the trash and come back intact on restore", async
|
||||
});
|
||||
});
|
||||
|
||||
test("Delete at once erases a profile on the spot and keeps nothing in the trash", async () => {
|
||||
await withApp("entities-delete-at-once", async (app) => {
|
||||
const profilesDir = path.join(app.dataRoot, "data", "profiles");
|
||||
const trashDir = path.join(app.dataRoot, "data", "trash");
|
||||
const secret = "secret-cookie-db";
|
||||
const seed = async (profile) => {
|
||||
const dir = path.join(profilesDir, profile.id, "profile", "Default");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, "Cookies"), secret);
|
||||
return path.join(dir, "Cookies");
|
||||
};
|
||||
|
||||
// Switching to "Delete at once" empties what the trash already holds.
|
||||
const earlier = await createProfile(app, "Already Trashed");
|
||||
await seed(earlier);
|
||||
await app.invoke("delete_profile", { profileId: earlier.id });
|
||||
assert.equal((await app.invoke("list_trashed_profiles")).length, 1);
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
await app.invoke("save_app_settings", {
|
||||
settings: { ...settings, trash_retention_days: 0 },
|
||||
});
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await app.invoke("list_trashed_profiles")).length === 0 &&
|
||||
!existsSync(path.join(trashDir, earlier.id)),
|
||||
{
|
||||
description: "the trash empties once it is switched off",
|
||||
timeoutMs: 20_000,
|
||||
intervalMs: 250,
|
||||
},
|
||||
);
|
||||
|
||||
const doomed = await createProfile(app, "Delete At Once");
|
||||
const cookies = await seed(doomed);
|
||||
// A handle opened before the delete still reads the file after the
|
||||
// unlink, so it shows whether the bytes were overwritten first. Windows
|
||||
// cannot remove a directory while a file in it is open.
|
||||
const handle = process.platform === "win32" ? null : await open(cookies);
|
||||
try {
|
||||
await app.invoke("delete_profile", { profileId: doomed.id });
|
||||
assert.equal(
|
||||
existsSync(path.join(profilesDir, doomed.id)),
|
||||
false,
|
||||
"the profile is erased before the delete returns",
|
||||
);
|
||||
assert.equal(existsSync(path.join(trashDir, doomed.id)), false);
|
||||
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
||||
assert.equal(
|
||||
(await app.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === doomed.id,
|
||||
),
|
||||
false,
|
||||
);
|
||||
if (handle) {
|
||||
const { buffer, bytesRead } = await handle.read(
|
||||
Buffer.alloc(64),
|
||||
0,
|
||||
64,
|
||||
0,
|
||||
);
|
||||
assert.equal(bytesRead, secret.length);
|
||||
assert.ok(
|
||||
buffer.subarray(0, bytesRead).every((byte) => byte === 0),
|
||||
"the file is zeroed before it is unlinked",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("proxies distribute one to one, and group bookmarks reach the profile's Bookmarks file", async () => {
|
||||
await withApp("entities-distribution-bookmarks", async (app) => {
|
||||
const profiles = [];
|
||||
|
||||
+138
-1
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -625,6 +625,143 @@ test("global config sealing and encrypted profile sync reject a wrong password,
|
||||
}
|
||||
});
|
||||
|
||||
test("a synced profile restored from the trash keeps its sync, and a delete made offline is not undone by the next sync", async () => {
|
||||
assert.ok(syncUrl && syncToken, "Sync infrastructure was not started");
|
||||
const app = appFromEnvironment("sync-trash");
|
||||
try {
|
||||
await app.start();
|
||||
await configureSync(app);
|
||||
|
||||
const remoteHas = async (key) =>
|
||||
(await listRemote(key)).some((object) => object.key === key);
|
||||
const manifestKey = (id) => `profiles/${id}/manifest.json`;
|
||||
const tombstoneKey = (id) => `tombstones/profiles/${id}.json`;
|
||||
const isLive = async (id) =>
|
||||
(await app.invoke("list_browser_profiles")).some(
|
||||
(profile) => profile.id === id,
|
||||
);
|
||||
const pendingFile = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"pending_profile_deletes.json",
|
||||
);
|
||||
// The pipeline logs this once its reconcile at sync start has finished.
|
||||
const logDir = path.join(app.dataRoot, "logs");
|
||||
const pipelineStarts = async () => {
|
||||
let count = 0;
|
||||
for (const name of await readdir(logDir)) {
|
||||
if (!name.endsWith(".log")) continue;
|
||||
const text = await readFile(path.join(logDir, name), "utf8");
|
||||
count += text.split("Sync scheduler started").length - 1;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const restartSync = async (description) => {
|
||||
const before = await pipelineStarts();
|
||||
await app.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
app,
|
||||
async () => (await pipelineStarts()) > before,
|
||||
description,
|
||||
);
|
||||
};
|
||||
const syncedProfile = async (name) => {
|
||||
const profile = await createProfile(app, name);
|
||||
const data = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
"profile",
|
||||
"Default",
|
||||
);
|
||||
await mkdir(data, { recursive: true });
|
||||
await writeFile(path.join(data, "Preferences"), JSON.stringify({ name }));
|
||||
await app.invoke("set_profile_sync_mode", {
|
||||
profileId: profile.id,
|
||||
syncMode: "Regular",
|
||||
});
|
||||
await app.invoke("request_profile_sync", { profileId: profile.id });
|
||||
await waitFor(
|
||||
app,
|
||||
() => remoteHas(manifestKey(profile.id)),
|
||||
`${name} uploaded`,
|
||||
);
|
||||
return profile;
|
||||
};
|
||||
|
||||
// Restored at once, while the cloud delete may still be running. The
|
||||
// restore waits for that delete and clears its tombstone before sync is
|
||||
// back on, so nothing can erase the restored profile afterwards.
|
||||
const kept = await syncedProfile("Trash Round Trip");
|
||||
await app.invoke("delete_profile", { profileId: kept.id });
|
||||
const [entry] = await app.invoke("list_trashed_profiles");
|
||||
assert.equal(entry.id, kept.id);
|
||||
assert.equal(entry.sync_enabled, true);
|
||||
const restored = await app.invoke("restore_trashed_profile", {
|
||||
profileId: kept.id,
|
||||
});
|
||||
assert.equal(restored.sync_mode, "Regular");
|
||||
assert.equal(
|
||||
await remoteHas(tombstoneKey(kept.id)),
|
||||
false,
|
||||
"the restore clears the tombstone of the delete",
|
||||
);
|
||||
await waitFor(
|
||||
app,
|
||||
() => remoteHas(manifestKey(kept.id)),
|
||||
"the restored profile uploads again",
|
||||
);
|
||||
await restartSync("the reconcile after the restore finishes");
|
||||
assert.equal(
|
||||
await isLive(kept.id),
|
||||
true,
|
||||
"the reconcile at sync start keeps the restored profile",
|
||||
);
|
||||
|
||||
// Deleted while the server cannot be reached: the delete is owed, and the
|
||||
// next sync start carries it out instead of downloading the profile back.
|
||||
const offline = await syncedProfile("Deleted Offline");
|
||||
await app.invoke("save_sync_settings", {
|
||||
syncServerUrl: "http://127.0.0.1:9",
|
||||
syncToken,
|
||||
});
|
||||
await app.invoke("delete_profile", { profileId: offline.id });
|
||||
await waitFor(
|
||||
app,
|
||||
async () =>
|
||||
JSON.parse(await readFile(pendingFile, "utf8")).some(
|
||||
(pending) => pending.profile_id === offline.id,
|
||||
),
|
||||
"the unreachable delete is recorded",
|
||||
);
|
||||
assert.equal(await remoteHas(manifestKey(offline.id)), true);
|
||||
|
||||
await app.invoke("save_sync_settings", {
|
||||
syncServerUrl: syncUrl,
|
||||
syncToken,
|
||||
});
|
||||
await restartSync("the reconcile with the owed delete finishes");
|
||||
assert.equal(await remoteHas(tombstoneKey(offline.id)), true);
|
||||
assert.equal(await remoteHas(manifestKey(offline.id)), false);
|
||||
assert.equal(
|
||||
await isLive(offline.id),
|
||||
false,
|
||||
"the profile deleted offline does not come back",
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await app.invoke("list_trashed_profiles")).map((item) => item.id),
|
||||
[offline.id],
|
||||
);
|
||||
await assert.rejects(readFile(pendingFile, "utf8"), { code: "ENOENT" });
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A self-hosted server reaches its storage over an address only it can
|
||||
// resolve — the documented compose file points S3_ENDPOINT at
|
||||
// http://minio:9000, a Docker service name that exists on the compose network
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// rclone's S3 server, the storage under both sync test harnesses
|
||||
// (scripts/sync-test-harness.mjs and e2e/run.mjs).
|
||||
//
|
||||
// MinIO used to play this part, but MinIO withdrew its community binaries and
|
||||
// every dl.min.io path now answers 410. rclone still publishes a single binary
|
||||
// per platform, and `rclone serve s3` speaks enough of the API for these tests:
|
||||
// path-style addressing, a static key pair, and bucket creation (a bucket is a
|
||||
// directory under the served root). It has no health endpoint, so wait for its
|
||||
// port to accept connections.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Pinned so a fresh rclone release cannot change what CI runs underneath us.
|
||||
export const RCLONE_VERSION = "v1.75.1";
|
||||
|
||||
function rcloneAsset() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch() === "arm64" ? "arm64" : "amd64";
|
||||
if (platform === "darwin") return `rclone-${RCLONE_VERSION}-osx-${arch}`;
|
||||
if (platform === "linux") return `rclone-${RCLONE_VERSION}-linux-${arch}`;
|
||||
if (platform === "win32") return `rclone-${RCLONE_VERSION}-windows-amd64`;
|
||||
throw new Error(`Unsupported platform: ${platform}-${os.arch()}`);
|
||||
}
|
||||
|
||||
// Mirrors src-tauri/download-xray.mjs: PowerShell owns zip extraction on
|
||||
// Windows, `unzip` everywhere else. Both are present on every runner these
|
||||
// harnesses target.
|
||||
function extractZip(archive, destinationDir) {
|
||||
const result =
|
||||
os.platform() === "win32"
|
||||
? spawnSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Expand-Archive -LiteralPath $env:DONUT_RCLONE_ARCHIVE -DestinationPath $env:DONUT_RCLONE_DESTINATION -Force",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
DONUT_RCLONE_ARCHIVE: archive,
|
||||
DONUT_RCLONE_DESTINATION: destinationDir,
|
||||
},
|
||||
},
|
||||
)
|
||||
: spawnSync(
|
||||
"unzip",
|
||||
["-qq", "-j", "-o", archive, "*/rclone", "-d", destinationDir],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to extract the rclone archive");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rclone binary cached in `cacheDir`, fetched with `download(url, dest)`
|
||||
* the first time.
|
||||
*/
|
||||
export async function ensureRclone(cacheDir, download, log) {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const binName = isWindows ? "rclone.exe" : "rclone";
|
||||
const bin = path.join(cacheDir, binName);
|
||||
if (existsSync(bin)) {
|
||||
return bin;
|
||||
}
|
||||
|
||||
log("Downloading rclone binary...");
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const asset = rcloneAsset();
|
||||
const archive = path.join(cacheDir, `${asset}.zip`);
|
||||
await download(
|
||||
`https://github.com/rclone/rclone/releases/download/${RCLONE_VERSION}/${asset}.zip`,
|
||||
archive,
|
||||
);
|
||||
extractZip(archive, cacheDir);
|
||||
|
||||
if (isWindows) {
|
||||
// Expand-Archive keeps the archive's own directory, so lift the binary out.
|
||||
const nested = path.join(cacheDir, asset, binName);
|
||||
if (existsSync(nested)) {
|
||||
renameSync(nested, bin);
|
||||
}
|
||||
}
|
||||
if (!existsSync(bin)) {
|
||||
throw new Error(`rclone archive did not contain ${binName}`);
|
||||
}
|
||||
if (!isWindows) {
|
||||
chmodSync(bin, 0o755);
|
||||
}
|
||||
log("rclone binary downloaded");
|
||||
return bin;
|
||||
}
|
||||
|
||||
/** Arguments for `rclone serve s3` over `dataDir` at `address` (host:port). */
|
||||
export function serveS3Args({ dataDir, address, accessKey, secretKey }) {
|
||||
return [
|
||||
"serve",
|
||||
"s3",
|
||||
dataDir,
|
||||
"--addr",
|
||||
address,
|
||||
"--auth-key",
|
||||
`${accessKey},${secretKey}`,
|
||||
"--force-path-style",
|
||||
"--vfs-cache-mode",
|
||||
"writes",
|
||||
];
|
||||
}
|
||||
+10
-116
@@ -11,14 +11,8 @@
|
||||
* Usage: node scripts/sync-test-harness.mjs
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync, execSync } from "child_process";
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
chmodSync,
|
||||
renameSync,
|
||||
} from "fs";
|
||||
import { spawn, execSync } from "child_process";
|
||||
import { createWriteStream, existsSync } from "fs";
|
||||
import { mkdir, rm, writeFile } from "fs/promises";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
@@ -26,6 +20,7 @@ import os from "os";
|
||||
import path from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { fileURLToPath } from "url";
|
||||
import { ensureRclone, serveS3Args } from "./rclone.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||
@@ -34,8 +29,6 @@ const CACHE_DIR = path.join(ROOT_DIR, ".cache", "sync-test");
|
||||
const S3_PORT = 9876;
|
||||
const S3_ACCESS_KEY = "donuttestaccesskey";
|
||||
const S3_SECRET_KEY = "donuttestsecretkey";
|
||||
// Pinned so a fresh rclone release cannot change what CI runs underneath us.
|
||||
const RCLONE_VERSION = "v1.75.1";
|
||||
const SYNC_PORT = 3456;
|
||||
// Must be >= 24 chars and not a known default — the server's validateEnv()
|
||||
// rejects short/placeholder tokens and exits at startup otherwise.
|
||||
@@ -94,103 +87,10 @@ async function downloadFile(url, dest) {
|
||||
});
|
||||
}
|
||||
|
||||
function getRcloneAsset() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
if (platform === "darwin") {
|
||||
return `rclone-${RCLONE_VERSION}-osx-${arch === "arm64" ? "arm64" : "amd64"}`;
|
||||
} else if (platform === "linux") {
|
||||
return `rclone-${RCLONE_VERSION}-linux-${arch === "arm64" ? "arm64" : "amd64"}`;
|
||||
} else if (platform === "win32") {
|
||||
return `rclone-${RCLONE_VERSION}-windows-amd64`;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
||||
function ensureS3Binary() {
|
||||
return ensureRclone(CACHE_DIR, downloadFile, log);
|
||||
}
|
||||
|
||||
// Mirrors src-tauri/download-xray.mjs: PowerShell owns zip extraction on
|
||||
// Windows, `unzip` everywhere else. Both are present on every runner this
|
||||
// harness targets.
|
||||
function extractZip(archive, destinationDir) {
|
||||
if (os.platform() === "win32") {
|
||||
const result = spawnSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Expand-Archive -LiteralPath $env:DONUT_RCLONE_ARCHIVE -DestinationPath $env:DONUT_RCLONE_DESTINATION -Force",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
DONUT_RCLONE_ARCHIVE: archive,
|
||||
DONUT_RCLONE_DESTINATION: destinationDir,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to extract the rclone archive");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
"unzip",
|
||||
["-qq", "-j", "-o", archive, "*/rclone", "-d", destinationDir],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to extract the rclone archive");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureS3Binary() {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const binName = isWindows ? "rclone.exe" : "rclone";
|
||||
const bin = path.join(CACHE_DIR, binName);
|
||||
|
||||
if (existsSync(bin)) {
|
||||
log("rclone binary already cached");
|
||||
return bin;
|
||||
}
|
||||
|
||||
log("Downloading rclone binary...");
|
||||
mkdirSync(CACHE_DIR, { recursive: true });
|
||||
|
||||
const asset = getRcloneAsset();
|
||||
const archive = path.join(CACHE_DIR, `${asset}.zip`);
|
||||
await downloadFile(
|
||||
`https://github.com/rclone/rclone/releases/download/${RCLONE_VERSION}/${asset}.zip`,
|
||||
archive,
|
||||
);
|
||||
extractZip(archive, CACHE_DIR);
|
||||
|
||||
if (isWindows) {
|
||||
// Expand-Archive keeps the archive's own directory, so lift the binary out.
|
||||
const nested = path.join(CACHE_DIR, asset, binName);
|
||||
if (existsSync(nested)) {
|
||||
renameSync(nested, bin);
|
||||
}
|
||||
}
|
||||
if (!existsSync(bin)) {
|
||||
throw new Error(`rclone archive did not contain ${binName}`);
|
||||
}
|
||||
if (!isWindows) {
|
||||
chmodSync(bin, 0o755);
|
||||
}
|
||||
|
||||
log("rclone binary downloaded");
|
||||
return bin;
|
||||
}
|
||||
|
||||
// MinIO used to play this part, but MinIO withdrew its community binaries and
|
||||
// every dl.min.io path now answers 410, which took this job down. rclone still
|
||||
// publishes a single binary per platform, and `rclone serve s3` speaks enough
|
||||
// of the API for these tests: path-style addressing, a static key pair, and
|
||||
// bucket creation (a bucket is a directory under the served root).
|
||||
async function startS3(bin) {
|
||||
const dataDir = path.join(CACHE_DIR, "s3-data");
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
@@ -199,18 +99,12 @@ async function startS3(bin) {
|
||||
|
||||
const proc = spawn(
|
||||
bin,
|
||||
[
|
||||
"serve",
|
||||
"s3",
|
||||
serveS3Args({
|
||||
dataDir,
|
||||
"--addr",
|
||||
`127.0.0.1:${S3_PORT}`,
|
||||
"--auth-key",
|
||||
`${S3_ACCESS_KEY},${S3_SECRET_KEY}`,
|
||||
"--force-path-style",
|
||||
"--vfs-cache-mode",
|
||||
"writes",
|
||||
],
|
||||
address: `127.0.0.1:${S3_PORT}`,
|
||||
accessKey: S3_ACCESS_KEY,
|
||||
secretKey: S3_SECRET_KEY,
|
||||
}),
|
||||
{
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -460,8 +460,12 @@ pub async fn delete_selected_profiles(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
profile_manager
|
||||
.delete_multiple_profiles(&app_handle, profile_ids)
|
||||
.map_err(|e| format!("Failed to delete profiles: {e}"))
|
||||
// Off the async runtime: a delete can erase every file of every profile.
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::profile::ProfileManager::instance()
|
||||
.delete_multiple_profiles(&app_handle, profile_ids)
|
||||
.map_err(|e| format!("Failed to delete profiles: {e}"))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete profiles: {e}"))?
|
||||
}
|
||||
@@ -754,7 +754,11 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
// An ephemeral profile keeps its data in RAM; there is nothing to trash.
|
||||
let permanent = permanent || profile.ephemeral;
|
||||
// "Delete at once" (a retention of 0 days) turns the trash off: the
|
||||
// profile is erased where it lies and never passes through the trash.
|
||||
let retention_days = crate::profile::trash::retention_days();
|
||||
let permanent = permanent || profile.ephemeral || retention_days == 0;
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
|
||||
// A decrypted or in-memory copy must not outlive the profile it belonged
|
||||
// to. The running-browser guard above only rejects a live process, and
|
||||
@@ -772,29 +776,21 @@ impl ProfileManager {
|
||||
if permanent {
|
||||
self.forget_profile_side_state(profile_id);
|
||||
if profile_uuid_dir.exists() {
|
||||
log::info!("Deleting profile directory: {}", profile_uuid_dir.display());
|
||||
fs::remove_dir_all(&profile_uuid_dir)?;
|
||||
log::info!("Erasing profile directory: {}", profile_uuid_dir.display());
|
||||
crate::fs_secure::secure_remove_dir_all(&profile_uuid_dir, true)?;
|
||||
}
|
||||
if profile_uuid_dir.exists() {
|
||||
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
|
||||
}
|
||||
} else {
|
||||
let retention_days = crate::profile::trash::retention_days();
|
||||
{
|
||||
let _guard = crate::profile::trash::mutation_lock();
|
||||
crate::profile::trash::trash_profile(
|
||||
&profiles_dir,
|
||||
&crate::profile::trash::trash_dir(),
|
||||
&profile,
|
||||
crate::proxy_manager::now_secs(),
|
||||
retention_days,
|
||||
)?;
|
||||
}
|
||||
// "Keep for 0 days" means the trash is off: the entry goes right away,
|
||||
// through the same purge an expired entry takes.
|
||||
if retention_days == 0 {
|
||||
self.purge_expired_trash();
|
||||
}
|
||||
let _guard = crate::profile::trash::mutation_lock();
|
||||
crate::profile::trash::trash_profile(
|
||||
&profiles_dir,
|
||||
&crate::profile::trash::trash_dir(),
|
||||
&profile,
|
||||
now,
|
||||
retention_days,
|
||||
)?;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -815,20 +811,24 @@ impl ProfileManager {
|
||||
crate::team_lock::release_team_lock_if_needed(&lock_profile).await;
|
||||
});
|
||||
|
||||
// From the cloud's point of view a trashed profile is deleted.
|
||||
// From the cloud's point of view a trashed profile is deleted. The delete
|
||||
// stays owed until it lands, so one that cannot reach the server is
|
||||
// retried when sync next starts instead of the profile downloading back.
|
||||
if was_sync_enabled {
|
||||
crate::sync::pending_deletes::record(profile_id, now);
|
||||
let profile_id_owned = profile_id.to_string();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
match crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine.delete_profile(&profile_id_owned).await {
|
||||
log::warn!(
|
||||
"Failed to delete profile {} from sync: {}",
|
||||
"Failed to delete profile {} from sync, retrying when sync next starts: {}",
|
||||
profile_id_owned,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
crate::sync::pending_deletes::forget(&profile_id_owned);
|
||||
log::info!("Profile {} deleted from S3 sync storage", profile_id_owned);
|
||||
}
|
||||
}
|
||||
@@ -837,6 +837,7 @@ impl ProfileManager {
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::sync::pending_deletes::track(profile_id, task);
|
||||
}
|
||||
|
||||
if emit_events {
|
||||
@@ -878,13 +879,13 @@ impl ProfileManager {
|
||||
|
||||
/// Move a trashed profile back into the live list under its original id.
|
||||
///
|
||||
/// Sync is NOT re-enabled here: the caller (the Tauri command) routes the
|
||||
/// restored profile through `set_profile_sync_mode`, which clears the
|
||||
/// tombstone the trash wrote and queues the re-upload.
|
||||
/// The profile comes back with sync off, next to the sync mode it had. The
|
||||
/// caller (the Tauri command) turns sync back on once the tombstone the
|
||||
/// delete wrote is gone from the cloud.
|
||||
pub fn restore_trashed_profile(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
) -> Result<(BrowserProfile, SyncMode), Box<dyn std::error::Error>> {
|
||||
let _guard = crate::profile::trash::mutation_lock();
|
||||
let live = self.list_profiles()?;
|
||||
let groups: std::collections::HashSet<String> = crate::group_manager::GROUP_MANAGER
|
||||
@@ -898,7 +899,7 @@ impl ProfileManager {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let profile = crate::profile::trash::restore_profile(
|
||||
let (profile, sync_mode) = crate::profile::trash::restore_profile(
|
||||
&self.get_profiles_dir(),
|
||||
&crate::profile::trash::trash_dir(),
|
||||
profile_id,
|
||||
@@ -920,7 +921,7 @@ impl ProfileManager {
|
||||
if let Err(e) = events::emit_empty("trash-changed") {
|
||||
log::warn!("Warning: Failed to emit trash-changed event: {e}");
|
||||
}
|
||||
Ok(profile)
|
||||
Ok((profile, sync_mode))
|
||||
}
|
||||
|
||||
pub fn purge_trashed_profile(&self, profile_id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -968,15 +969,21 @@ impl ProfileManager {
|
||||
|
||||
/// Delete a profile from the local filesystem only, without triggering remote sync deletion.
|
||||
/// Used when a profile was deleted on another device and the local copy should be cleaned up.
|
||||
/// The copy is erased the same way a local permanent delete erases it.
|
||||
pub fn delete_profile_local_only(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
|
||||
if let Ok(uuid) = uuid::Uuid::parse_str(profile_id) {
|
||||
crate::profile::encryption::drop_cached_key(&uuid);
|
||||
}
|
||||
self.forget_profile_side_state(profile_id);
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_dir = profiles_dir.join(profile_id);
|
||||
if profile_dir.exists() {
|
||||
fs::remove_dir_all(&profile_dir)?;
|
||||
log::info!("Deleted local profile {} (tombstoned remotely)", profile_id);
|
||||
crate::fs_secure::secure_remove_dir_all(&profile_dir, true)?;
|
||||
log::info!("Erased local profile {} (tombstoned remotely)", profile_id);
|
||||
}
|
||||
|
||||
if let Err(e) = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
|
||||
@@ -2577,26 +2584,32 @@ pub fn clone_profile(profile_id: String, name: Option<String>) -> Result<Browser
|
||||
}
|
||||
|
||||
/// Move a profile to the trash. `permanent: true` destroys it instead.
|
||||
///
|
||||
/// Async so the erase, which overwrites every file, runs off the main thread.
|
||||
#[tauri::command]
|
||||
pub fn delete_profile(
|
||||
pub async fn delete_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
permanent: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
let manager = ProfileManager::instance();
|
||||
let result = if permanent.unwrap_or(false) {
|
||||
manager.delete_profile_permanently(&app_handle, &profile_id)
|
||||
} else {
|
||||
manager.delete_profile(&app_handle, &profile_id)
|
||||
};
|
||||
result.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let manager = ProfileManager::instance();
|
||||
let result = if permanent.unwrap_or(false) {
|
||||
manager.delete_profile_permanently(&app_handle, &profile_id)
|
||||
} else {
|
||||
format!("Failed to delete profile: {msg}")
|
||||
}
|
||||
manager.delete_profile(&app_handle, &profile_id)
|
||||
};
|
||||
result.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("Failed to delete profile: {msg}")
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete profile: {e}"))?
|
||||
}
|
||||
|
||||
static PROFILE_MANAGER: std::sync::LazyLock<ProfileManager> =
|
||||
|
||||
+168
-53
@@ -16,10 +16,15 @@
|
||||
//!
|
||||
//! From the cloud's point of view a trashed profile is deleted: the sync
|
||||
//! tombstone is written by the same path a permanent delete uses. Restoring
|
||||
//! re-registers the profile under its original id and routes it through the
|
||||
//! normal sync-enable path so it wins the stale tombstone.
|
||||
//! re-registers the profile under its original id with sync off, then turns
|
||||
//! sync back on only once that tombstone is gone from the cloud, because a
|
||||
//! tombstone erases any synced profile it names.
|
||||
//!
|
||||
//! What leaves the trash for good (expiry, "Delete forever", emptying it, or
|
||||
//! the whole trash when it is set to "Delete at once") is erased with every
|
||||
//! file overwritten first, as far as the disk allows (see `fs_secure`).
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::profile::types::{BrowserProfile, SyncMode};
|
||||
use crate::profile::ProfileManager;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
@@ -75,6 +80,10 @@ pub struct TrashedProfileSummary {
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>,
|
||||
pub password_protected: bool,
|
||||
/// Whether the profile synced when it was deleted. A restore turns sync
|
||||
/// back on, or says it could not.
|
||||
#[serde(default)]
|
||||
pub sync_enabled: bool,
|
||||
}
|
||||
|
||||
pub fn trash_dir() -> PathBuf {
|
||||
@@ -106,6 +115,19 @@ fn err_internal(e: impl std::fmt::Display) -> String {
|
||||
crate::backend_error_with_detail("INTERNAL_ERROR", e)
|
||||
}
|
||||
|
||||
/// The entry directory for `profile_id`. Only a canonical profile id names
|
||||
/// one, so an id from a caller can never point the erase outside the trash.
|
||||
fn entry_dir(trash_root: &Path, profile_id: &str) -> Result<PathBuf, String> {
|
||||
match uuid::Uuid::parse_str(profile_id) {
|
||||
Ok(id) if id.to_string() == profile_id => Ok(trash_root.join(profile_id)),
|
||||
_ => Err(crate::backend_error("TRASH_ENTRY_NOT_FOUND")),
|
||||
}
|
||||
}
|
||||
|
||||
fn erase_dir(dir: &Path) -> std::io::Result<()> {
|
||||
crate::fs_secure::secure_remove_dir_all(dir, true).map(|_| ())
|
||||
}
|
||||
|
||||
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(value).map_err(err_internal)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
@@ -118,8 +140,8 @@ fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
serde_json::from_str(&content).map_err(err_internal)
|
||||
}
|
||||
|
||||
/// Remove the cache-only directories from a profile data directory. Returns
|
||||
/// the directories that were actually removed.
|
||||
/// Erase the cache-only directories of a profile data directory. Returns the
|
||||
/// directories that were actually removed.
|
||||
pub fn prune_cache_dirs(data_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut removed = Vec::new();
|
||||
for relative in CACHE_DIRS {
|
||||
@@ -127,7 +149,7 @@ pub fn prune_cache_dirs(data_dir: &Path) -> Vec<PathBuf> {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
match fs::remove_dir_all(&dir) {
|
||||
match erase_dir(&dir) {
|
||||
Ok(()) => removed.push(dir),
|
||||
Err(e) => log::warn!("Could not prune cache dir {}: {e}", dir.display()),
|
||||
}
|
||||
@@ -252,7 +274,7 @@ pub fn read_entry(
|
||||
trash_root: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<(BrowserProfile, TrashManifest), String> {
|
||||
let entry_dir = trash_root.join(profile_id);
|
||||
let entry_dir = entry_dir(trash_root, profile_id)?;
|
||||
let profile_file = entry_dir.join(PROFILE_FILE);
|
||||
let manifest_file = entry_dir.join(MANIFEST_FILE);
|
||||
if !profile_file.is_file() || !manifest_file.is_file() {
|
||||
@@ -290,6 +312,7 @@ pub fn summaries(trash_root: &Path, retention_days: u32) -> Vec<TrashedProfileSu
|
||||
list_entries(trash_root)
|
||||
.into_iter()
|
||||
.map(|(profile, manifest)| TrashedProfileSummary {
|
||||
sync_enabled: profile.is_sync_enabled(),
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name,
|
||||
browser: profile.browser,
|
||||
@@ -331,8 +354,9 @@ pub fn unique_restored_name(name: &str, taken: &HashSet<String>) -> String {
|
||||
}
|
||||
|
||||
/// Move a trashed profile back under `profiles_dir` and return the profile as
|
||||
/// it must be saved: same id, identity, proxy and tags; the group only when it
|
||||
/// still exists; a fresh `updated_at` so it wins any stale sync tombstone.
|
||||
/// it must be saved, next to the sync mode it had: same id, identity, proxy
|
||||
/// and tags; the group only when it still exists; a fresh `updated_at`; and
|
||||
/// sync off, so no tombstone can erase it before the caller clears one.
|
||||
///
|
||||
/// `TRASH_RESTORE_CONFLICT` when a live profile already carries the id.
|
||||
pub fn restore_profile(
|
||||
@@ -342,7 +366,7 @@ pub fn restore_profile(
|
||||
live_profiles: &[BrowserProfile],
|
||||
group_exists: &dyn Fn(&str) -> bool,
|
||||
now: u64,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
) -> Result<(BrowserProfile, SyncMode), String> {
|
||||
let (mut profile, _manifest) = read_entry(trash_root, profile_id)?;
|
||||
|
||||
if live_profiles.iter().any(|live| live.id == profile.id) {
|
||||
@@ -372,22 +396,23 @@ pub fn restore_profile(
|
||||
}
|
||||
profile.process_id = None;
|
||||
profile.updated_at = Some(now);
|
||||
let sync_mode = std::mem::replace(&mut profile.sync_mode, SyncMode::Disabled);
|
||||
|
||||
let entry_dir = trash_root.join(profile_id);
|
||||
move_dir(&entry_dir, &target_dir).map_err(err_internal)?;
|
||||
let _ = fs::remove_file(target_dir.join(PROFILE_FILE));
|
||||
let _ = fs::remove_file(target_dir.join(MANIFEST_FILE));
|
||||
write_json(&target_dir.join(METADATA_FILE), &profile)?;
|
||||
Ok(profile)
|
||||
Ok((profile, sync_mode))
|
||||
}
|
||||
|
||||
/// Destroy one entry for good. `TRASH_ENTRY_NOT_FOUND` when absent.
|
||||
/// Erase one entry for good. `TRASH_ENTRY_NOT_FOUND` when absent.
|
||||
pub fn purge_entry(trash_root: &Path, profile_id: &str) -> Result<(), String> {
|
||||
let entry_dir = trash_root.join(profile_id);
|
||||
let entry_dir = entry_dir(trash_root, profile_id)?;
|
||||
if !entry_dir.is_dir() {
|
||||
return Err(crate::backend_error("TRASH_ENTRY_NOT_FOUND"));
|
||||
}
|
||||
fs::remove_dir_all(&entry_dir).map_err(err_internal)
|
||||
erase_dir(&entry_dir).map_err(err_internal)
|
||||
}
|
||||
|
||||
/// Destroy every entry. Returns the ids that were removed.
|
||||
@@ -445,7 +470,10 @@ pub fn start_expiry_sweeper() {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(PURGE_INTERVAL_SECS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let purged = ProfileManager::instance().purge_expired_trash();
|
||||
let purged =
|
||||
tauri::async_runtime::spawn_blocking(|| ProfileManager::instance().purge_expired_trash())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if purged > 0 {
|
||||
log::info!(
|
||||
"Purged {purged} expired trash entr{}",
|
||||
@@ -466,33 +494,37 @@ pub async fn restore_trashed_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let manager = ProfileManager::instance();
|
||||
let mut profile = manager
|
||||
.restore_trashed_profile(&profile_id)
|
||||
.map_err(|e| command_error(e, "Failed to restore profile"))?;
|
||||
// The cloud delete of this profile may still be running, and the server
|
||||
// writes its tombstone last. It has to land before the restore clears it.
|
||||
crate::sync::pending_deletes::wait_for(&profile_id).await;
|
||||
|
||||
if profile.is_sync_enabled() {
|
||||
// The cloud saw a delete (a tombstone was written when the profile was
|
||||
// trashed). Re-enabling through the normal path clears that tombstone
|
||||
// and queues the re-upload. When that path refuses (sync no longer
|
||||
// configured, a cross-OS copy), sync is switched off on the restored
|
||||
// profile so the next reconcile keeps the local copy instead of
|
||||
// honouring the tombstone.
|
||||
let mode = if profile.is_encrypted_sync() {
|
||||
"Encrypted"
|
||||
} else {
|
||||
"Regular"
|
||||
};
|
||||
if let Err(e) =
|
||||
crate::sync::set_profile_sync_mode(app_handle.clone(), profile_id.clone(), mode.to_string())
|
||||
.await
|
||||
{
|
||||
log::warn!("Restored profile {profile_id} could not re-enable sync ({e}); leaving sync off");
|
||||
profile.sync_mode = crate::profile::types::SyncMode::Disabled;
|
||||
manager
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| command_error(e, "Failed to save restored profile"))?;
|
||||
let _ = crate::events::emit_empty("profiles-changed");
|
||||
let id = profile_id.clone();
|
||||
let (mut profile, sync_mode) = tauri::async_runtime::spawn_blocking(move || {
|
||||
ProfileManager::instance()
|
||||
.restore_trashed_profile(&id)
|
||||
.map_err(|e| command_error(e, "Failed to restore profile"))
|
||||
})
|
||||
.await
|
||||
.map_err(err_internal)??;
|
||||
|
||||
// The profile is back, so the cloud no longer owes its delete.
|
||||
crate::sync::pending_deletes::forget(&profile_id);
|
||||
|
||||
if sync_mode != SyncMode::Disabled {
|
||||
// Sync comes back only once the tombstone is gone. When that cannot be
|
||||
// done (offline, signed out, a cross-OS copy), sync stays off, the local
|
||||
// copy is safe, and the trash page tells the user.
|
||||
match crate::sync::resume_profile_sync(app_handle, profile_id.clone(), sync_mode).await {
|
||||
Ok(()) => {
|
||||
if let Some(current) = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.ok()
|
||||
.and_then(|all| all.into_iter().find(|p| p.id == profile.id))
|
||||
{
|
||||
profile = current;
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Restored profile {profile_id} keeps sync off: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,17 +532,25 @@ pub async fn restore_trashed_profile(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trashed_profile(profile_id: String) -> Result<(), String> {
|
||||
ProfileManager::instance()
|
||||
.purge_trashed_profile(&profile_id)
|
||||
.map_err(|e| command_error(e, "Failed to delete trashed profile"))
|
||||
pub async fn purge_trashed_profile(profile_id: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
ProfileManager::instance()
|
||||
.purge_trashed_profile(&profile_id)
|
||||
.map_err(|e| command_error(e, "Failed to delete trashed profile"))
|
||||
})
|
||||
.await
|
||||
.map_err(err_internal)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn empty_trash() -> Result<usize, String> {
|
||||
ProfileManager::instance()
|
||||
.empty_trash()
|
||||
.map_err(|e| command_error(e, "Failed to empty trash"))
|
||||
pub async fn empty_trash() -> Result<usize, String> {
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
ProfileManager::instance()
|
||||
.empty_trash()
|
||||
.map_err(|e| command_error(e, "Failed to empty trash"))
|
||||
})
|
||||
.await
|
||||
.map_err(err_internal)?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -607,7 +647,7 @@ mod tests {
|
||||
assert_eq!(listed[0].group_id.as_deref(), Some("group-1"));
|
||||
assert!(!listed[0].password_protected);
|
||||
|
||||
let restored = restore_profile(
|
||||
let (restored, _) = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
@@ -648,6 +688,81 @@ mod tests {
|
||||
assert!(summaries(&trash_root, DAYS).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_synced_profile_comes_back_with_sync_off_and_reports_its_mode() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let mut profile = sample_profile("Synced");
|
||||
profile.sync_mode = SyncMode::Encrypted;
|
||||
let profiles_dir = seed_profile(root.path(), &profile, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
|
||||
assert!(summaries(&trash_root, DAYS)[0].sync_enabled);
|
||||
|
||||
let (restored, sync_mode) = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
&[],
|
||||
&group_exists,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sync_mode, SyncMode::Encrypted);
|
||||
assert_eq!(restored.sync_mode, SyncMode::Disabled);
|
||||
let on_disk: BrowserProfile = serde_json::from_str(
|
||||
&fs::read_to_string(
|
||||
profiles_dir
|
||||
.join(profile.id.to_string())
|
||||
.join("metadata.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
on_disk.sync_mode,
|
||||
SyncMode::Disabled,
|
||||
"no tombstone may find a synced profile before the caller clears it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_canonical_profile_id_names_an_entry() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let trash_root = root.path().join("trash");
|
||||
fs::create_dir_all(&trash_root).unwrap();
|
||||
let outside = root.path().join("profiles");
|
||||
fs::create_dir_all(&outside).unwrap();
|
||||
fs::write(outside.join("keep"), b"live data").unwrap();
|
||||
|
||||
let err = purge_entry(&trash_root, "../profiles").unwrap_err();
|
||||
assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}");
|
||||
assert_eq!(fs::read(outside.join("keep")).unwrap(), b"live data");
|
||||
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let upper = id.to_string().to_uppercase();
|
||||
fs::create_dir_all(trash_root.join(&upper)).unwrap();
|
||||
let err = purge_entry(&trash_root, &upper).unwrap_err();
|
||||
assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}");
|
||||
assert!(trash_root.join(&upper).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_erases_the_whole_entry() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let profile = sample_profile("Erased");
|
||||
let profiles_dir = seed_profile(root.path(), &profile, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
|
||||
let entry = trash_root.join(profile.id.to_string());
|
||||
let cookies = entry.join("profile").join("Default").join("Cookies");
|
||||
assert!(cookies.is_file());
|
||||
|
||||
purge_entry(&trash_root, &profile.id.to_string()).unwrap();
|
||||
assert!(!entry.exists());
|
||||
let err = purge_entry(&trash_root, &profile.id.to_string()).unwrap_err();
|
||||
assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_appends_suffix_when_a_live_profile_has_the_name() {
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -661,7 +776,7 @@ mod tests {
|
||||
let mut second_twin = sample_profile("Shop Account (restored)");
|
||||
second_twin.id = uuid::Uuid::new_v4();
|
||||
|
||||
let restored = restore_profile(
|
||||
let (restored, _) = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
@@ -733,7 +848,7 @@ mod tests {
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
|
||||
|
||||
let restored = restore_profile(
|
||||
let (restored, _) = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
@@ -765,7 +880,7 @@ mod tests {
|
||||
}
|
||||
assert!(summaries(&trash_root, DAYS)[0].password_protected);
|
||||
|
||||
let restored = restore_profile(
|
||||
let (restored, _) = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
|
||||
+118
-45
@@ -2045,6 +2045,40 @@ impl SyncEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the cloud still holds a tombstone for `profile`, personal or team.
|
||||
/// A failed check counts as no: a skipped erase is retried by the next
|
||||
/// reconcile, and an erase cannot be undone.
|
||||
pub async fn profile_tombstone_exists(&self, profile: &BrowserProfile) -> bool {
|
||||
for key in Self::profile_tombstone_keys(profile).await {
|
||||
match self.client.stat(&key).await {
|
||||
Ok(stat) if stat.exists => return true,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Could not check {key}: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn delete_profile_tombstones(&self, profile: &BrowserProfile) -> SyncResult<()> {
|
||||
for key in Self::profile_tombstone_keys(profile).await {
|
||||
self.client.delete(&key, None).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn profile_tombstone_keys(profile: &BrowserProfile) -> Vec<String> {
|
||||
let profile_id = profile.id.to_string();
|
||||
let mut keys = vec![format!("tombstones/profiles/{profile_id}.json")];
|
||||
let key_prefix = Self::get_team_key_prefix(profile).await;
|
||||
if !key_prefix.is_empty() {
|
||||
keys.push(format!("{key_prefix}tombstones/profiles/{profile_id}.json"));
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
pub async fn delete_proxy(&self, proxy_id: &str) -> SyncResult<()> {
|
||||
let remote_key = format!("proxies/{}.json", proxy_id);
|
||||
let tombstone_key = format!("tombstones/proxies/{}.json", proxy_id);
|
||||
@@ -2881,6 +2915,39 @@ impl SyncEngine {
|
||||
profiles_to_check.len()
|
||||
);
|
||||
|
||||
// Deletes this device made while the cloud was out of reach. They are
|
||||
// settled before anything downloads, or the download below would bring the
|
||||
// deleted profile back. A cloud copy changed after the delete was used on
|
||||
// another device since, and wins, as a newer edit does everywhere else.
|
||||
for pending in super::pending_deletes::list() {
|
||||
let profile_id = pending.profile_id;
|
||||
let Some(key_prefix) = profiles_to_check.remove(&profile_id) else {
|
||||
log::info!("Profile {profile_id} is already gone from the cloud");
|
||||
super::pending_deletes::forget(&profile_id);
|
||||
continue;
|
||||
};
|
||||
let manifest_key = format!("{key_prefix}profiles/{profile_id}/manifest.json");
|
||||
let changed_at = match self.client.stat(&manifest_key).await {
|
||||
Ok(stat) => stat.last_modified.as_deref().and_then(rfc3339_secs),
|
||||
Err(e) => {
|
||||
log::warn!("Could not check profile {profile_id} before its owed delete: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if changed_at.is_some_and(|changed| changed > pending.deleted_at) {
|
||||
log::info!(
|
||||
"Profile {profile_id} changed in the cloud after this device deleted it; keeping the newer copy"
|
||||
);
|
||||
super::pending_deletes::forget(&profile_id);
|
||||
profiles_to_check.insert(profile_id, key_prefix);
|
||||
continue;
|
||||
}
|
||||
match self.delete_profile(&profile_id).await {
|
||||
Ok(()) => super::pending_deletes::forget(&profile_id),
|
||||
Err(e) => log::warn!("The owed cloud delete of profile {profile_id} failed again: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// For each remote profile, check if it exists locally and download if missing.
|
||||
// Skip any profile that has a tombstone — a leftover manifest under a
|
||||
// tombstoned id means delete_prefix raced or partially failed, and
|
||||
@@ -2950,40 +3017,16 @@ impl SyncEngine {
|
||||
// Delete local synced profiles that have a remote tombstone (deleted on another device)
|
||||
{
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let local_synced: Vec<(String, Option<String>)> = profile_manager
|
||||
let local_synced: Vec<BrowserProfile> = profile_manager
|
||||
.list_profiles()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.into_iter()
|
||||
.filter(|p| p.is_sync_enabled())
|
||||
.map(|p| (p.id.to_string(), p.created_by_id.clone()))
|
||||
.collect();
|
||||
|
||||
let team_prefix = if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await {
|
||||
auth.user.team_id.map(|tid| format!("teams/{}/", tid))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (pid, created_by_id) in &local_synced {
|
||||
// Check personal tombstone
|
||||
let personal_tombstone = format!("tombstones/profiles/{}.json", pid);
|
||||
let has_personal_tombstone = matches!(
|
||||
self.client.stat(&personal_tombstone).await,
|
||||
Ok(stat) if stat.exists
|
||||
);
|
||||
|
||||
// Check team tombstone
|
||||
let has_team_tombstone = if let (Some(tp), Some(_)) = (&team_prefix, created_by_id) {
|
||||
let team_tombstone = format!("{}tombstones/profiles/{}.json", tp, pid);
|
||||
matches!(
|
||||
self.client.stat(&team_tombstone).await,
|
||||
Ok(stat) if stat.exists
|
||||
)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if has_personal_tombstone || has_team_tombstone {
|
||||
for profile in &local_synced {
|
||||
let pid = &profile.id.to_string();
|
||||
if self.profile_tombstone_exists(profile).await {
|
||||
// Originator guard: re-read the profile right before deleting. If the
|
||||
// local user disabled sync between the snapshot above and this stat
|
||||
// call, they're the one who wrote this tombstone — keep their local
|
||||
@@ -3466,7 +3509,28 @@ pub async fn set_profile_sync_mode(
|
||||
"Encrypted" => SyncMode::Encrypted,
|
||||
_ => return Err(format!("Invalid sync mode: {sync_mode}")),
|
||||
};
|
||||
apply_profile_sync_mode(app_handle, profile_id, new_mode, false).await
|
||||
}
|
||||
|
||||
/// Turn sync back on for a profile restored from the trash.
|
||||
///
|
||||
/// Unlike the command, it fails when the tombstone the delete wrote cannot be
|
||||
/// cleared, and leaves the profile as it was. Enabled with that tombstone in
|
||||
/// place, the profile would be erased by the next reconcile.
|
||||
pub async fn resume_profile_sync(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
mode: SyncMode,
|
||||
) -> Result<(), String> {
|
||||
apply_profile_sync_mode(app_handle, profile_id, mode, true).await
|
||||
}
|
||||
|
||||
async fn apply_profile_sync_mode(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
new_mode: SyncMode,
|
||||
require_tombstone_cleared: bool,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
@@ -3561,6 +3625,31 @@ pub async fn set_profile_sync_mode(
|
||||
}
|
||||
}
|
||||
|
||||
// When (re-)enabling sync, clear any stale tombstone from a previous
|
||||
// disable or delete first, while the profile is still local-only. Otherwise
|
||||
// the next reconcile on any device would see the tombstone and delete the
|
||||
// freshly re-uploaded data, and a tombstone event handled between the save
|
||||
// and a later clear would erase the profile that was just switched on.
|
||||
if enabling {
|
||||
let cleared = match SyncEngine::create_from_settings(&app_handle).await {
|
||||
Ok(engine) => engine
|
||||
.delete_profile_tombstones(&profile)
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = cleared {
|
||||
if require_tombstone_cleared {
|
||||
return Err(format!("Could not clear the profile's tombstone: {e}"));
|
||||
}
|
||||
log::warn!(
|
||||
"Failed to clear the tombstone of profile {}: {}",
|
||||
Plain(&profile_id),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
profile.sync_mode = new_mode;
|
||||
|
||||
profile_manager
|
||||
@@ -3576,22 +3665,6 @@ pub async fn set_profile_sync_mode(
|
||||
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
|
||||
// When (re-)enabling sync, clear any stale tombstone from a previous
|
||||
// disable on this device. Otherwise the next reconcile on another
|
||||
// device — or even a race on this one — would see the tombstone and
|
||||
// delete the freshly re-uploaded data.
|
||||
if enabling {
|
||||
if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await {
|
||||
let key_prefix = SyncEngine::get_team_key_prefix(&profile).await;
|
||||
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
|
||||
let _ = engine.client.delete(&personal_tombstone, None).await;
|
||||
if !key_prefix.is_empty() {
|
||||
let team_tombstone = format!("{}tombstones/profiles/{}.json", key_prefix, profile_id);
|
||||
let _ = engine.client.delete(&team_tombstone, None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if enabling {
|
||||
let is_running = profile.process_id.is_some();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ mod client;
|
||||
pub mod encryption;
|
||||
mod engine;
|
||||
pub mod manifest;
|
||||
pub mod pending_deletes;
|
||||
pub mod preflight;
|
||||
pub mod scheduler;
|
||||
pub mod subscription;
|
||||
@@ -17,10 +18,11 @@ pub use engine::{
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
|
||||
pull_profile_after_remote_session, request_profile_sync, rollover_encryption_for_all_entities,
|
||||
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
|
||||
trigger_sync_for_profile, ProfileSyncOutcome, SyncEngine,
|
||||
pull_profile_after_remote_session, request_profile_sync, resume_profile_sync,
|
||||
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
|
||||
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile,
|
||||
ProfileSyncOutcome, SyncEngine,
|
||||
};
|
||||
pub use manifest::{
|
||||
compute_diff, compute_diff_with_bias, generate_manifest, DiffBias, HashCache, ManifestDiff,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Cloud deletes of synced profiles that this device still owes.
|
||||
//!
|
||||
//! Deleting a synced profile removes it from the cloud in the background. A
|
||||
//! delete that cannot reach the server (offline, server down) is recorded here
|
||||
//! and retried by the next reconcile. Without the record, that reconcile finds
|
||||
//! the profile still in the cloud and downloads it back.
|
||||
//!
|
||||
//! The running delete is kept as well, so a restore of the same profile can
|
||||
//! wait for it. The server writes the tombstone only after it has removed every
|
||||
//! file, and a tombstone that lands after the restore turned sync back on
|
||||
//! erases the restored profile.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{LazyLock, Mutex, MutexGuard};
|
||||
|
||||
const FILE_NAME: &str = "pending_profile_deletes.json";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PendingDelete {
|
||||
pub profile_id: String,
|
||||
/// Unix seconds. A cloud copy changed after this wins over the delete.
|
||||
pub deleted_at: u64,
|
||||
}
|
||||
|
||||
static FILE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
static RUNNING: LazyLock<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>> =
|
||||
LazyLock::new(Default::default);
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn file() -> PathBuf {
|
||||
crate::app_dirs::data_dir().join(FILE_NAME)
|
||||
}
|
||||
|
||||
fn read() -> Vec<PendingDelete> {
|
||||
let Ok(content) = std::fs::read_to_string(file()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
log::warn!("Ignoring unreadable {FILE_NAME}: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
fn write(entries: &[PendingDelete]) {
|
||||
let path = file();
|
||||
if entries.is_empty() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
log::warn!("Could not remove {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result = serde_json::to_vec_pretty(entries)
|
||||
.map_err(std::io::Error::other)
|
||||
.and_then(|json| {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
});
|
||||
if let Err(e) = result {
|
||||
log::warn!("Could not write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list() -> Vec<PendingDelete> {
|
||||
let _guard = lock(&FILE_LOCK);
|
||||
read()
|
||||
}
|
||||
|
||||
/// Owe the cloud a delete of `profile_id`, replacing an older record.
|
||||
pub fn record(profile_id: &str, deleted_at: u64) {
|
||||
let _guard = lock(&FILE_LOCK);
|
||||
let mut entries = read();
|
||||
entries.retain(|entry| entry.profile_id != profile_id);
|
||||
entries.push(PendingDelete {
|
||||
profile_id: profile_id.to_string(),
|
||||
deleted_at,
|
||||
});
|
||||
write(&entries);
|
||||
}
|
||||
|
||||
/// The delete landed, or it no longer applies.
|
||||
pub fn forget(profile_id: &str) {
|
||||
let _guard = lock(&FILE_LOCK);
|
||||
let mut entries = read();
|
||||
let before = entries.len();
|
||||
entries.retain(|entry| entry.profile_id != profile_id);
|
||||
if entries.len() != before {
|
||||
write(&entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the task running the cloud delete of `profile_id`.
|
||||
pub fn track(profile_id: &str, handle: tauri::async_runtime::JoinHandle<()>) {
|
||||
let mut running = lock(&RUNNING);
|
||||
running.retain(|_, task| !task.inner().is_finished());
|
||||
running.insert(profile_id.to_string(), handle);
|
||||
}
|
||||
|
||||
/// Wait for a cloud delete of `profile_id` started in this session to end.
|
||||
pub async fn wait_for(profile_id: &str) {
|
||||
let handle = lock(&RUNNING).remove(profile_id);
|
||||
if let Some(handle) = handle {
|
||||
if let Err(e) = handle.await {
|
||||
log::warn!("The cloud delete of profile {profile_id} ended abnormally: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn records_replace_and_forget_by_profile() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
|
||||
assert!(list().is_empty());
|
||||
record("a", 10);
|
||||
record("b", 20);
|
||||
record("a", 30);
|
||||
assert_eq!(
|
||||
list(),
|
||||
vec![
|
||||
PendingDelete {
|
||||
profile_id: "b".to_string(),
|
||||
deleted_at: 20
|
||||
},
|
||||
PendingDelete {
|
||||
profile_id: "a".to_string(),
|
||||
deleted_at: 30
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
forget("missing");
|
||||
forget("b");
|
||||
assert_eq!(list().len(), 1);
|
||||
forget("a");
|
||||
assert!(list().is_empty());
|
||||
assert!(!file().exists(), "an empty record leaves no file behind");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_returns_once_the_delete_has_ended() {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let flag = done.clone();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
let _ = rx.await;
|
||||
flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
});
|
||||
track("waited-profile", handle);
|
||||
tx.send(()).unwrap();
|
||||
wait_for("waited-profile").await;
|
||||
assert!(done.load(std::sync::atomic::Ordering::SeqCst));
|
||||
// A second wait finds nothing and returns at once.
|
||||
wait_for("waited-profile").await;
|
||||
}
|
||||
}
|
||||
@@ -856,7 +856,7 @@ impl SyncScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) {
|
||||
async fn process_pending_tombstones(&self, app_handle: &tauri::AppHandle) {
|
||||
let tombstones: Vec<(String, String)> = {
|
||||
let mut pending = self.pending_tombstones.lock().await;
|
||||
std::mem::take(&mut *pending)
|
||||
@@ -875,30 +875,54 @@ impl SyncScheduler {
|
||||
match entity_type.as_str() {
|
||||
"profile" => {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let local_sync_enabled = {
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
|
||||
profile_uuid
|
||||
.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
|
||||
.is_some_and(|p| p.is_sync_enabled())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
let local = uuid::Uuid::parse_str(&entity_id)
|
||||
.ok()
|
||||
.and_then(|uuid| {
|
||||
profile_manager
|
||||
.list_profiles()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|p| p.id == uuid)
|
||||
})
|
||||
.filter(|p| p.is_sync_enabled());
|
||||
|
||||
if local_sync_enabled {
|
||||
log::info!(
|
||||
"Profile {} was deleted remotely, deleting locally",
|
||||
entity_id
|
||||
);
|
||||
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
|
||||
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
|
||||
}
|
||||
} else {
|
||||
let Some(local) = local else {
|
||||
log::info!(
|
||||
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
|
||||
entity_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// The event can be stale. A restore from the trash, or sync switched
|
||||
// off and on again, clears the tombstone after it was written, and
|
||||
// the profile it named is live again. Only a tombstone that is still
|
||||
// in the cloud erases the local copy.
|
||||
let still_tombstoned = match SyncEngine::create_from_settings(app_handle).await {
|
||||
Ok(engine) => engine.profile_tombstone_exists(&local).await,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Could not confirm the tombstone of profile {}: {}",
|
||||
entity_id,
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
if !still_tombstoned {
|
||||
log::info!(
|
||||
"Profile {} tombstone is no longer in the cloud — keeping local copy",
|
||||
entity_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Profile {} was deleted remotely, deleting locally",
|
||||
entity_id
|
||||
);
|
||||
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
|
||||
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
|
||||
}
|
||||
}
|
||||
"proxy" => {
|
||||
|
||||
@@ -518,7 +518,7 @@ function ExtCell({
|
||||
>
|
||||
<LuPuzzle className="size-3 shrink-0" />
|
||||
<span className="flex-1 truncate" title={label}>
|
||||
{label}
|
||||
{group?.name ?? "—"}
|
||||
</span>
|
||||
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -123,7 +123,14 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
|
||||
"restore_trashed_profile",
|
||||
{ profileId: entry.id },
|
||||
);
|
||||
showSuccessToast(t("trash.restored", { name: restored.name }));
|
||||
// The restore keeps sync off when the cloud still holds the
|
||||
// profile's tombstone; say so rather than let it look synced.
|
||||
const syncStayedOff =
|
||||
entry.sync_enabled && restored.sync_mode === "Disabled";
|
||||
showSuccessToast(t("trash.restored", { name: restored.name }), {
|
||||
description: syncStayedOff ? t("trash.restoredSyncOff") : undefined,
|
||||
duration: syncStayedOff ? 10000 : undefined,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to restore trashed profile:", err);
|
||||
showErrorToast(
|
||||
|
||||
@@ -3166,6 +3166,7 @@
|
||||
"confirmEmptyDescription_one": "{{count}} Profil und alle zugehörigen Daten werden vernichtet. Dies kann nicht rückgängig gemacht werden.",
|
||||
"confirmEmptyDescription_other": "{{count}} Profile und alle zugehörigen Daten werden vernichtet. Dies kann nicht rückgängig gemacht werden.",
|
||||
"restored": "„{{name}}“ wurde wiederhergestellt",
|
||||
"restoredSyncOff": "Die Synchronisierung konnte nicht wieder aktiviert werden, daher ist das Profil nur auf diesem Gerät. Legen Sie den Synchronisierungsmodus erneut fest, um es mit Ihren anderen Geräten zu teilen.",
|
||||
"restoreFailed": "Das Profil konnte nicht wiederhergestellt werden: {{error}}",
|
||||
"deletedForever": "„{{name}}“ wurde endgültig gelöscht",
|
||||
"deleteForeverFailed": "Das Profil konnte nicht gelöscht werden: {{error}}",
|
||||
|
||||
@@ -3166,6 +3166,7 @@
|
||||
"confirmEmptyDescription_one": "{{count}} profile and all of its data are destroyed. This cannot be undone.",
|
||||
"confirmEmptyDescription_other": "{{count}} profiles and all of their data are destroyed. This cannot be undone.",
|
||||
"restored": "\"{{name}}\" was restored",
|
||||
"restoredSyncOff": "Sync could not be turned back on, so the profile is on this device only. Set its sync mode again to share it with your other devices.",
|
||||
"restoreFailed": "Could not restore the profile: {{error}}",
|
||||
"deletedForever": "\"{{name}}\" was deleted forever",
|
||||
"deleteForeverFailed": "Could not delete the profile: {{error}}",
|
||||
|
||||
@@ -3200,6 +3200,7 @@
|
||||
"confirmEmptyDescription_other": "{{count}} perfiles y todos sus datos se destruirán. Esta acción no se puede deshacer.",
|
||||
"confirmEmptyDescription_many": "{{count}} perfiles y todos sus datos se destruirán. Esta acción no se puede deshacer.",
|
||||
"restored": "\"{{name}}\" se ha restaurado",
|
||||
"restoredSyncOff": "No se pudo volver a activar la sincronización, así que el perfil solo está en este dispositivo. Vuelve a elegir su modo de sincronización para compartirlo con tus otros dispositivos.",
|
||||
"restoreFailed": "No se pudo restaurar el perfil: {{error}}",
|
||||
"deletedForever": "\"{{name}}\" se ha eliminado definitivamente",
|
||||
"deleteForeverFailed": "No se pudo eliminar el perfil: {{error}}",
|
||||
|
||||
@@ -3200,6 +3200,7 @@
|
||||
"confirmEmptyDescription_other": "{{count}} profils et toutes leurs données seront détruits. Cette action est irréversible.",
|
||||
"confirmEmptyDescription_many": "{{count}} profils et toutes leurs données seront détruits. Cette action est irréversible.",
|
||||
"restored": "« {{name}} » a été restauré",
|
||||
"restoredSyncOff": "La synchronisation n'a pas pu être réactivée : le profil n'est que sur cet appareil. Choisissez de nouveau son mode de synchronisation pour le partager avec vos autres appareils.",
|
||||
"restoreFailed": "Impossible de restaurer le profil : {{error}}",
|
||||
"deletedForever": "« {{name}} » a été supprimé définitivement",
|
||||
"deleteForeverFailed": "Impossible de supprimer le profil : {{error}}",
|
||||
|
||||
@@ -3166,6 +3166,7 @@
|
||||
"confirmEmptyDescription_one": "{{count}}件のプロファイルとそのすべてのデータが破棄されます。この操作は元に戻せません。",
|
||||
"confirmEmptyDescription_other": "{{count}}件のプロファイルとそのすべてのデータが破棄されます。この操作は元に戻せません。",
|
||||
"restored": "「{{name}}」を復元しました",
|
||||
"restoredSyncOff": "同期を再開できなかったため、このプロファイルはこのデバイスにのみあります。ほかのデバイスと共有するには、同期モードをもう一度設定してください。",
|
||||
"restoreFailed": "プロファイルを復元できませんでした: {{error}}",
|
||||
"deletedForever": "「{{name}}」を完全に削除しました",
|
||||
"deleteForeverFailed": "プロファイルを削除できませんでした: {{error}}",
|
||||
|
||||
@@ -3166,6 +3166,7 @@
|
||||
"confirmEmptyDescription_one": "프로필 {{count}}개와 모든 데이터가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"confirmEmptyDescription_other": "프로필 {{count}}개와 모든 데이터가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"restored": "\"{{name}}\"을(를) 복원했습니다",
|
||||
"restoredSyncOff": "동기화를 다시 켜지 못해 이 프로필은 이 기기에만 있습니다. 다른 기기와 공유하려면 동기화 모드를 다시 설정하세요.",
|
||||
"restoreFailed": "프로필을 복원하지 못했습니다: {{error}}",
|
||||
"deletedForever": "\"{{name}}\"을(를) 영구 삭제했습니다",
|
||||
"deleteForeverFailed": "프로필을 삭제하지 못했습니다: {{error}}",
|
||||
|
||||
@@ -3200,6 +3200,7 @@
|
||||
"confirmEmptyDescription_other": "{{count}} perfis e todos os seus dados serão destruídos. Esta ação não pode ser desfeita.",
|
||||
"confirmEmptyDescription_many": "{{count}} perfis e todos os seus dados serão destruídos. Esta ação não pode ser desfeita.",
|
||||
"restored": "\"{{name}}\" foi restaurado",
|
||||
"restoredSyncOff": "Não foi possível reativar a sincronização, por isso o perfil está apenas neste dispositivo. Defina o modo de sincronização novamente para compartilhá-lo com seus outros dispositivos.",
|
||||
"restoreFailed": "Não foi possível restaurar o perfil: {{error}}",
|
||||
"deletedForever": "\"{{name}}\" foi excluído permanentemente",
|
||||
"deleteForeverFailed": "Não foi possível excluir o perfil: {{error}}",
|
||||
|
||||
@@ -3234,6 +3234,7 @@
|
||||
"confirmEmptyDescription_few": "{{count}} профиля и все их данные будут уничтожены. Это действие нельзя отменить.",
|
||||
"confirmEmptyDescription_many": "{{count}} профилей и все их данные будут уничтожены. Это действие нельзя отменить.",
|
||||
"restored": "«{{name}}» восстановлен",
|
||||
"restoredSyncOff": "Не удалось снова включить синхронизацию, поэтому профиль есть только на этом устройстве. Снова выберите режим синхронизации, чтобы он появился на других устройствах.",
|
||||
"restoreFailed": "Не удалось восстановить профиль: {{error}}",
|
||||
"deletedForever": "«{{name}}» удалён навсегда",
|
||||
"deleteForeverFailed": "Не удалось удалить профиль: {{error}}",
|
||||
|
||||
@@ -3166,6 +3166,7 @@
|
||||
"confirmEmptyDescription_one": "{{count}} profil ve tüm verileri yok edilecek. Bu işlem geri alınamaz.",
|
||||
"confirmEmptyDescription_other": "{{count}} profil ve tüm verileri yok edilecek. Bu işlem geri alınamaz.",
|
||||
"restored": "\"{{name}}\" geri yüklendi",
|
||||
"restoredSyncOff": "Senkronizasyon yeniden açılamadı, bu yüzden profil yalnızca bu cihazda. Diğer cihazlarınızla paylaşmak için senkronizasyon modunu yeniden ayarlayın.",
|
||||
"restoreFailed": "Profil geri yüklenemedi: {{error}}",
|
||||
"deletedForever": "\"{{name}}\" kalıcı olarak silindi",
|
||||
"deleteForeverFailed": "Profil silinemedi: {{error}}",
|
||||
|
||||
@@ -3166,6 +3166,7 @@
|
||||
"confirmEmptyDescription_one": "{{count}} hồ sơ và toàn bộ dữ liệu của chúng sẽ bị hủy. Không thể hoàn tác thao tác này.",
|
||||
"confirmEmptyDescription_other": "{{count}} hồ sơ và toàn bộ dữ liệu của chúng sẽ bị hủy. Không thể hoàn tác thao tác này.",
|
||||
"restored": "Đã khôi phục \"{{name}}\"",
|
||||
"restoredSyncOff": "Không thể bật lại đồng bộ, nên hồ sơ chỉ có trên thiết bị này. Hãy đặt lại chế độ đồng bộ để chia sẻ hồ sơ với các thiết bị khác của bạn.",
|
||||
"restoreFailed": "Không thể khôi phục hồ sơ: {{error}}",
|
||||
"deletedForever": "Đã xóa vĩnh viễn \"{{name}}\"",
|
||||
"deleteForeverFailed": "Không thể xóa hồ sơ: {{error}}",
|
||||
|
||||
@@ -3166,6 +3166,7 @@
|
||||
"confirmEmptyDescription_one": "{{count}} 个配置文件及其全部数据将被销毁。此操作无法撤销。",
|
||||
"confirmEmptyDescription_other": "{{count}} 个配置文件及其全部数据将被销毁。此操作无法撤销。",
|
||||
"restored": "已恢复“{{name}}”",
|
||||
"restoredSyncOff": "无法重新开启同步,因此该配置文件仅保存在此设备上。请重新设置其同步模式,以便在其他设备上使用。",
|
||||
"restoreFailed": "无法恢复配置文件:{{error}}",
|
||||
"deletedForever": "已永久删除“{{name}}”",
|
||||
"deleteForeverFailed": "无法删除配置文件:{{error}}",
|
||||
|
||||
@@ -987,4 +987,6 @@ export interface TrashedProfileSummary {
|
||||
size_bytes: number;
|
||||
group_id?: string;
|
||||
password_protected: boolean;
|
||||
/** Whether the profile synced when it was deleted. */
|
||||
sync_enabled: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user