mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-28 05:40:25 +02:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ecc44686 | ||
|
|
d7f002d8ac | ||
|
|
2be0d4df0b | ||
|
|
a0175eab0d | ||
|
|
1a36fb9c12 | ||
|
|
e78f3e7c76 | ||
|
|
bcb616d083 | ||
|
|
7b09260926 | ||
|
|
927fe37cda | ||
|
|
c07039e0a6 | ||
|
|
df5ece8e2d | ||
|
|
d3734ef876 | ||
|
|
b446e20350 | ||
|
|
2ccf23eea3 |
@@ -693,7 +693,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@65cf14df16c191f3e9684f0d9a8bae69103ced6d #v1.18.14
|
||||
uses: anomalyco/opencode/github@02546dfc2e4515a4f90aaf9ceb3890df2ac2b479 #v1.18.17
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 #v2.9.2
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 #v2.9.2
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.29.4 (2026-08-15)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
|
||||
### Documentation
|
||||
|
||||
- update CHANGELOG.md and README.md for v0.29.3 [skip ci] (#556)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- ci(deps): bump the github-actions group with 4 updates (#559)
|
||||
- chore: update flake.nix for v0.29.3 [skip ci] (#557)
|
||||
|
||||
|
||||
## v0.29.3 (2026-08-12)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- minor improvement
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: update pnpm
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.29.2 [skip ci] (#552)
|
||||
|
||||
|
||||
## v0.29.2 (2026-08-10)
|
||||
|
||||
### Refactoring
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut-0.29.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut-0.29.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut-0.29.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut-0.29.4-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -131,10 +131,10 @@ Donut Browser is built by the people who use it, and plenty of the most useful h
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/HassiyYT">
|
||||
<img src="https://avatars.githubusercontent.com/u/81773493?v=4" width="100;" alt="HassiyYT"/>
|
||||
<a href="https://github.com/kalinichaa">
|
||||
<img src="https://avatars.githubusercontent.com/u/81773493?v=4" width="100;" alt="kalinichaa"/>
|
||||
<br />
|
||||
<sub><b>Hassiy</b></sub>
|
||||
<sub><b>Alexander Kalinich</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
Generated
+1
-1
@@ -1803,7 +1803,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.29.2"
|
||||
version = "0.29.4"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm 0.11.0",
|
||||
|
||||
@@ -87,7 +87,9 @@ export const commandCoverage = {
|
||||
"list_extensions",
|
||||
"get_extension_icon",
|
||||
"add_extension",
|
||||
"add_unpacked_extension",
|
||||
"update_extension",
|
||||
"update_extension_from_path",
|
||||
"delete_extension",
|
||||
"list_extension_groups",
|
||||
"create_extension_group",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { crc32 } from "node:zlib";
|
||||
|
||||
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||
|
||||
@@ -214,6 +216,211 @@ export function extensionZipBase64() {
|
||||
return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA";
|
||||
}
|
||||
|
||||
// 1980-01-01 00:00, the earliest timestamp the ZIP format can carry. Fixed so
|
||||
// two calls with the same entries produce byte-identical archives.
|
||||
const DOS_TIME = 0;
|
||||
const DOS_DATE = 0x0021;
|
||||
|
||||
/**
|
||||
* Build a ZIP archive from `entries` (`{ name, data }`) with every member
|
||||
* stored, not deflated.
|
||||
*
|
||||
* Stored is what the inline fixture above already is, and it is load-bearing
|
||||
* for the oversized fixture below: the assertion is about a request body that
|
||||
* has to stay over the limit under test, so nothing in the archive may shrink
|
||||
* the padding back under it.
|
||||
*/
|
||||
export function buildStoredZip(entries) {
|
||||
const locals = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const { name, data } of entries) {
|
||||
const nameBytes = Buffer.from(name, "utf8");
|
||||
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
const checksum = crc32(body);
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(DOS_TIME, 10);
|
||||
local.writeUInt16LE(DOS_DATE, 12);
|
||||
local.writeUInt32LE(checksum, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(body.length, 22);
|
||||
local.writeUInt16LE(nameBytes.length, 26);
|
||||
locals.push(local, nameBytes, body);
|
||||
|
||||
const entry = Buffer.alloc(46);
|
||||
entry.writeUInt32LE(0x02014b50, 0);
|
||||
entry.writeUInt16LE(20, 4);
|
||||
entry.writeUInt16LE(20, 6);
|
||||
entry.writeUInt16LE(DOS_TIME, 12);
|
||||
entry.writeUInt16LE(DOS_DATE, 14);
|
||||
entry.writeUInt32LE(checksum, 16);
|
||||
entry.writeUInt32LE(body.length, 20);
|
||||
entry.writeUInt32LE(body.length, 24);
|
||||
entry.writeUInt16LE(nameBytes.length, 28);
|
||||
entry.writeUInt32LE(offset, 42);
|
||||
central.push(entry, nameBytes);
|
||||
|
||||
offset += local.length + nameBytes.length + body.length;
|
||||
}
|
||||
|
||||
const directory = Buffer.concat(central);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(directory.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
|
||||
return Buffer.concat([...locals, directory, end]);
|
||||
}
|
||||
|
||||
export const OVERSIZED_EXTENSION_NAME = "Donut E2E Oversized Fixture";
|
||||
|
||||
/**
|
||||
* A valid Manifest V3 ZIP padded past the 2 MiB body limit axum applies by
|
||||
* default, so the raised limit on the extension routes is the only reason a
|
||||
* request carrying it can succeed.
|
||||
*
|
||||
* The padding is random bytes, and the archive stores rather than deflates
|
||||
* them, so neither the fixture nor the transport can quietly shrink the body
|
||||
* back under the limit and turn the assertion into a tautology.
|
||||
*/
|
||||
export function oversizedExtensionZipBase64(paddingBytes = 3 * 1024 * 1024) {
|
||||
return buildStoredZip([
|
||||
{
|
||||
name: "manifest.json",
|
||||
data: `${JSON.stringify(
|
||||
{
|
||||
manifest_version: 3,
|
||||
name: OVERSIZED_EXTENSION_NAME,
|
||||
version: "1.0.0",
|
||||
description: "Isolated oversized test extension",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
},
|
||||
{ name: "payload.bin", data: randomBytes(paddingBytes) },
|
||||
]).toString("base64");
|
||||
}
|
||||
|
||||
// What `_locales/<default_locale>/messages.json` resolves the manifest's
|
||||
// placeholders to. Deliberately free of the `__MSG_` marker so a test can
|
||||
// assert the stored record carries no placeholder anywhere.
|
||||
export const LOCALIZED_EXTENSION_MESSAGES = {
|
||||
extName: "Donut E2E Localized Blocker",
|
||||
extDescription: "Resolved from the default locale, not the manifest",
|
||||
extAuthor: "Donut E2E Localization",
|
||||
};
|
||||
|
||||
/**
|
||||
* A Manifest V3 ZIP shaped the way Chrome Web Store extensions actually ship:
|
||||
* `name`, `description` and `author` are `__MSG_key__` placeholders and the
|
||||
* real strings live in `_locales/<default_locale>/messages.json`. uBlock Origin
|
||||
* Lite is exactly this, which is why an importer that stores the manifest
|
||||
* verbatim shows users `__MSG_extName__`.
|
||||
*
|
||||
* Pass `messages: {}` for a locale file that resolves none of the placeholders,
|
||||
* or `messages: null` to omit the locale file entirely.
|
||||
*/
|
||||
export function localizedExtensionZipBase64({
|
||||
defaultLocale = "en",
|
||||
messages = LOCALIZED_EXTENSION_MESSAGES,
|
||||
} = {}) {
|
||||
const entries = [
|
||||
{
|
||||
name: "manifest.json",
|
||||
data: `${JSON.stringify(
|
||||
{
|
||||
manifest_version: 3,
|
||||
name: "__MSG_extName__",
|
||||
version: "2.4.0",
|
||||
description: "__MSG_extDescription__",
|
||||
author: "__MSG_extAuthor__",
|
||||
default_locale: defaultLocale,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
},
|
||||
];
|
||||
if (messages) {
|
||||
entries.push({
|
||||
name: `_locales/${defaultLocale}/messages.json`,
|
||||
data: `${JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(messages).map(([key, message]) => [key, { message }]),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
});
|
||||
}
|
||||
return buildStoredZip(entries).toString("base64");
|
||||
}
|
||||
|
||||
// A 1x1 PNG, inline for the same reason the ZIP above is: no encoder
|
||||
// dependency, and the exact bytes are what the icon assertions compare.
|
||||
const EXTENSION_ICON_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
export function extensionIconPngBase64() {
|
||||
return EXTENSION_ICON_PNG_BASE64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a real unpacked Manifest V3 extension at `directory` and return its
|
||||
* absolute path.
|
||||
*
|
||||
* Unlike the ZIP fixture this one declares `icons` and ships the file they
|
||||
* point at, so importing the folder exercises icon extraction for both import
|
||||
* modes: linking reads the icon straight out of the folder, copying reads it
|
||||
* back out of the ZIP the importer builds. The background service worker is
|
||||
* what makes a loaded copy observable over CDP, which registers a
|
||||
* `chrome-extension://<id>/background.js` target.
|
||||
*/
|
||||
export async function writeUnpackedExtension(
|
||||
directory,
|
||||
{ name = "Donut E2E Unpacked", version = "1.0.0" } = {},
|
||||
) {
|
||||
const absolute = path.resolve(directory);
|
||||
await mkdir(path.join(absolute, "icons"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(absolute, "manifest.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
manifest_version: 3,
|
||||
name,
|
||||
version,
|
||||
description: "Isolated unpacked test extension",
|
||||
icons: { 16: "icons/icon-16.png", 48: "icons/icon-48.png" },
|
||||
background: { service_worker: "background.js" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(absolute, "background.js"),
|
||||
[
|
||||
"globalThis.__donutE2eExtension = chrome.runtime.id;",
|
||||
"chrome.runtime.onInstalled.addListener(() => {",
|
||||
" console.log('donut e2e extension installed');",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const icon = Buffer.from(EXTENSION_ICON_PNG_BASE64, "base64");
|
||||
for (const size of [16, 48]) {
|
||||
await writeFile(path.join(absolute, "icons", `icon-${size}.png`), icon);
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
export function currentHostOs() {
|
||||
return os.platform() === "darwin"
|
||||
? "macos"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
defaultWayfernPath,
|
||||
inspectWayfern,
|
||||
prepareWayfern,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
||||
@@ -709,3 +710,182 @@ test("a proxy worker dies with its browser, with and without the app running", a
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Two things nothing else covers. First, that an assigned extension group
|
||||
// actually reaches Wayfern: a loaded MV3 extension registers a
|
||||
// `chrome-extension://<id>/background.js` service-worker target, so CDP can see
|
||||
// it from outside. Second, that staging is per profile. It used to be one
|
||||
// shared `extensions/unpacked` directory wiped on every launch, and because
|
||||
// Chromium records the absolute staging path and reads those files lazily for
|
||||
// the life of the process instead of copying them into the profile, launching a
|
||||
// second profile broke the extension in every browser already running.
|
||||
test("an assigned extension group reaches Wayfern and each profile stages its own copy", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const localWayfernPath = defaultWayfernPath(
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
const localWayfernVersion = existsSync(localWayfernPath)
|
||||
? inspectWayfern(localWayfernPath).version
|
||||
: null;
|
||||
const app = appFromEnvironment("browser-extensions", {
|
||||
seedVersionCache: localWayfernVersion ?? false,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
const launched = [];
|
||||
try {
|
||||
const prepared = await prepareWayfern(
|
||||
app,
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
if (!app.session) await app.start();
|
||||
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
}
|
||||
|
||||
const extension = await app.invoke("add_unpacked_extension", {
|
||||
name: "Donut Launch Fixture",
|
||||
path: await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "loaded-extension"),
|
||||
{ name: "Donut Launch Fixture", version: "1.0.0" },
|
||||
),
|
||||
link: false,
|
||||
});
|
||||
const group = await app.invoke("create_extension_group", {
|
||||
name: "Launch Extensions",
|
||||
});
|
||||
await app.invoke("add_extension_to_group", {
|
||||
groupId: group.id,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: true,
|
||||
api_port: 0,
|
||||
api_token: null,
|
||||
onboarding_completed: true,
|
||||
},
|
||||
});
|
||||
const base = `http://127.0.0.1:${await app.invoke("start_api_server", { port: 0 })}`;
|
||||
|
||||
const stagedManifest = (profileId) =>
|
||||
path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"extensions",
|
||||
"unpacked",
|
||||
profileId,
|
||||
extension.id,
|
||||
"manifest.json",
|
||||
);
|
||||
const extensionWorkers = async (debuggingPort) => {
|
||||
const targets = await fetch(
|
||||
`http://127.0.0.1:${debuggingPort}/json`,
|
||||
).then((response) => response.json());
|
||||
return targets.filter(
|
||||
(target) =>
|
||||
target.type === "service_worker" &&
|
||||
String(target.url).startsWith("chrome-extension://"),
|
||||
);
|
||||
};
|
||||
const launchWithExtension = async (name) => {
|
||||
const profile = await createRealProfile(app, prepared.version, name);
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: group.id,
|
||||
})
|
||||
).extension_group_id,
|
||||
group.id,
|
||||
);
|
||||
const run = await request(`${base}/v1/profiles/${profile.id}/run`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { url: `${fixtureUrl}/extension-launch`, headless: true },
|
||||
});
|
||||
assert.equal(run.response.status, 200, JSON.stringify(run.value));
|
||||
const record = {
|
||||
profile,
|
||||
debuggingPort: run.value.remote_debugging_port,
|
||||
};
|
||||
launched.push(record);
|
||||
const workers = await app.waitFor(
|
||||
async () => {
|
||||
const found = await extensionWorkers(record.debuggingPort);
|
||||
return found.length > 0 ? found : null;
|
||||
},
|
||||
{
|
||||
timeoutMs: 60_000,
|
||||
description: `the extension's service worker in ${name}`,
|
||||
},
|
||||
);
|
||||
assert.match(
|
||||
workers[0].url,
|
||||
/^chrome-extension:\/\/\w+\/background\.js$/,
|
||||
);
|
||||
return record;
|
||||
};
|
||||
|
||||
const first = await launchWithExtension("Extension Launch One");
|
||||
assert.ok(
|
||||
existsSync(stagedManifest(first.profile.id)),
|
||||
"the first profile must stage the extension under its own id",
|
||||
);
|
||||
if (process.platform !== "win32") {
|
||||
// The staged path is what Chromium was handed, and it is per profile.
|
||||
const running = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === first.profile.id,
|
||||
);
|
||||
const command = execFileSync(
|
||||
"ps",
|
||||
["-ww", "-o", "command=", "-p", String(running.process_id)],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.ok(
|
||||
command.includes(
|
||||
`--load-extension=${path.dirname(stagedManifest(first.profile.id))}`,
|
||||
),
|
||||
"Wayfern must be pointed at this profile's own staged copy",
|
||||
);
|
||||
}
|
||||
await launchWithExtension("Extension Launch Two");
|
||||
|
||||
// The regression itself: the second launch must not have taken the first
|
||||
// profile's files with it. The staged manifest is what its running browser
|
||||
// is still reading from.
|
||||
for (const { profile } of launched) {
|
||||
assert.ok(
|
||||
existsSync(stagedManifest(profile.id)),
|
||||
`${profile.name} lost its staged extension to another profile's launch`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { profile } of launched) {
|
||||
const running = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
await app.invoke("kill_browser_profile", { profile: running });
|
||||
await waitForProcessExit(app, running.process_id);
|
||||
}
|
||||
await app.invoke("stop_api_server");
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
if (app.session) {
|
||||
const running = await app.invoke("list_browser_profiles").catch(() => []);
|
||||
for (const { profile } of launched) {
|
||||
const record = running.find((item) => item.id === profile.id);
|
||||
if (record?.process_id && processExists(record.process_id)) {
|
||||
await app
|
||||
.invoke("kill_browser_profile", { profile: record })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
+107
-1
@@ -1,15 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import {
|
||||
extensionIconPngBase64,
|
||||
extensionZipBase64,
|
||||
wireGuardFixture,
|
||||
writeChromiumCookies,
|
||||
writeChromiumHistory,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
async function createProfile(app, name = "Entity Profile") {
|
||||
@@ -555,6 +563,104 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass
|
||||
await app.invoke("delete_extension_group", { groupId: extensionGroup.id });
|
||||
await app.invoke("delete_extension", { extensionId: extension.id });
|
||||
|
||||
// Folder imports, the "Load unpacked" flow. Copying packs the folder into
|
||||
// the store; linking loads it from where the user keeps it, which only
|
||||
// exists on this machine and therefore never syncs.
|
||||
const unpackedDir = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "unpacked-extension"),
|
||||
);
|
||||
const copied = await app.invoke("add_unpacked_extension", {
|
||||
name: "Overridden By The Manifest",
|
||||
path: unpackedDir,
|
||||
link: false,
|
||||
});
|
||||
assert.equal(copied.source_kind, "unpacked");
|
||||
assert.equal(copied.linked_path, null);
|
||||
assert.equal(copied.file_type, "zip");
|
||||
assert.equal(copied.file_name, "unpacked-extension.zip");
|
||||
assert.equal(copied.name, "Donut E2E Unpacked");
|
||||
assert.equal(copied.version, "1.0.0");
|
||||
// The folder declares icons, so packing it must carry one through into the
|
||||
// store rather than dropping it the way the icon-less ZIP fixture does.
|
||||
assert.equal(
|
||||
await app.invoke("get_extension_icon", { extensionId: copied.id }),
|
||||
`data:image/png;base64,${extensionIconPngBase64()}`,
|
||||
);
|
||||
|
||||
const linked = await app.invoke("add_unpacked_extension", {
|
||||
name: "Linked Fixture",
|
||||
path: unpackedDir,
|
||||
link: true,
|
||||
});
|
||||
assert.equal(linked.source_kind, "unpacked");
|
||||
assert.equal(linked.file_type, "unpacked");
|
||||
assert.equal(linked.linked_path, await realpath(unpackedDir));
|
||||
assert.equal(
|
||||
linked.sync_enabled,
|
||||
false,
|
||||
"a linked extension has no payload to upload, so it must never be synced",
|
||||
);
|
||||
|
||||
const repackedDir = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "unpacked-extension-v2"),
|
||||
{ name: "Donut E2E Unpacked v2", version: "2.0.0" },
|
||||
);
|
||||
const repacked = await app.invoke("update_extension_from_path", {
|
||||
extensionId: copied.id,
|
||||
name: "Repacked Fixture Extension",
|
||||
path: repackedDir,
|
||||
link: false,
|
||||
});
|
||||
assert.equal(repacked.name, "Repacked Fixture Extension");
|
||||
assert.equal(repacked.version, "2.0.0");
|
||||
assert.equal(repacked.file_name, "unpacked-extension-v2.zip");
|
||||
assert.deepEqual(
|
||||
await readdir(
|
||||
path.join(app.dataRoot, "data", "extensions", copied.id, "file"),
|
||||
),
|
||||
["unpacked-extension-v2.zip"],
|
||||
"re-importing replaces the stored payload instead of stacking a second one",
|
||||
);
|
||||
|
||||
// Re-importing a linked extension as a copy ends the link, which is what
|
||||
// makes it portable again. With no explicit name the manifest names it.
|
||||
const unlinked = await app.invoke("update_extension_from_path", {
|
||||
extensionId: linked.id,
|
||||
name: null,
|
||||
path: repackedDir,
|
||||
link: false,
|
||||
});
|
||||
assert.equal(unlinked.linked_path, null);
|
||||
assert.equal(unlinked.source_kind, "unpacked");
|
||||
assert.equal(unlinked.name, "Donut E2E Unpacked v2");
|
||||
|
||||
assert.match(
|
||||
await app.invokeError("add_unpacked_extension", {
|
||||
name: "Not An Extension",
|
||||
path: app.root,
|
||||
link: false,
|
||||
}),
|
||||
/EXTENSION_MANIFEST_MISSING/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("update_extension_from_path", {
|
||||
extensionId: unlinked.id,
|
||||
name: null,
|
||||
path: path.join(app.root, "fixtures", "absent"),
|
||||
link: false,
|
||||
}),
|
||||
/EXTENSION_DIR_NOT_FOUND/,
|
||||
);
|
||||
|
||||
for (const id of [copied.id, unlinked.id]) {
|
||||
await app.invoke("delete_extension", { extensionId: id });
|
||||
}
|
||||
assert.deepEqual(await app.invoke("list_extensions"), []);
|
||||
assert.ok(
|
||||
existsSync(path.join(unpackedDir, "manifest.json")),
|
||||
"importing a folder must never move or consume the user's copy of it",
|
||||
);
|
||||
|
||||
const vpn = await app.invoke("create_vpn_config_manual", {
|
||||
name: "E2E WireGuard",
|
||||
vpnType: "WireGuard",
|
||||
|
||||
@@ -3,6 +3,14 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import {
|
||||
extensionZipBase64,
|
||||
LOCALIZED_EXTENSION_MESSAGES,
|
||||
localizedExtensionZipBase64,
|
||||
OVERSIZED_EXTENSION_NAME,
|
||||
oversizedExtensionZipBase64,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
const VLESS_URI =
|
||||
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#MCP";
|
||||
@@ -93,6 +101,27 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
|
||||
]) {
|
||||
assert.ok(paths.includes(required), `OpenAPI is missing ${required}`);
|
||||
}
|
||||
// The served spec comes from the hand-maintained ApiDoc derive, not from
|
||||
// the router, so an extension route can answer requests while being absent
|
||||
// from the contract an agent generates its client from.
|
||||
for (const [route, methods] of [
|
||||
["/v1/extensions", ["get", "post"]],
|
||||
["/v1/extensions/{id}", ["get", "put", "delete"]],
|
||||
["/v1/extension-groups", ["get", "post"]],
|
||||
["/v1/extension-groups/{id}", ["get", "put", "delete"]],
|
||||
[
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
["post", "delete"],
|
||||
],
|
||||
]) {
|
||||
assert.ok(paths.includes(route), `OpenAPI is missing ${route}`);
|
||||
for (const method of methods) {
|
||||
assert.ok(
|
||||
openapi.value.paths[route][method],
|
||||
`OpenAPI is missing ${method.toUpperCase()} ${route}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const unauthorized = await jsonRequest(`${base}/v1/profiles`);
|
||||
assert.equal(unauthorized.response.status, 401);
|
||||
@@ -197,6 +226,428 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
|
||||
assert.equal(imported.response.status, 200);
|
||||
assert.equal(imported.value.imported_count, 1);
|
||||
|
||||
// Extensions arrive either as an inline payload or as a path the app can
|
||||
// read, and the folder form is the whole point: it is how an agent reaches
|
||||
// the "load unpacked" flow that the desktop offers through a file picker.
|
||||
const archiveExtension = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Archive Extension",
|
||||
file_name: "fixture.zip",
|
||||
file_data_base64: extensionZipBase64(),
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
archiveExtension.response.status,
|
||||
201,
|
||||
JSON.stringify(archiveExtension.value),
|
||||
);
|
||||
assert.equal(archiveExtension.value.name, "Donut E2E Fixture");
|
||||
assert.equal(archiveExtension.value.source_kind, "archive");
|
||||
assert.equal(archiveExtension.value.linked_path, null);
|
||||
|
||||
const unpackedDir = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "rest-unpacked-extension"),
|
||||
{ name: "Donut REST Unpacked", version: "1.2.0" },
|
||||
);
|
||||
const folderExtension = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Folder Extension", source_path: unpackedDir },
|
||||
});
|
||||
assert.equal(
|
||||
folderExtension.response.status,
|
||||
201,
|
||||
JSON.stringify(folderExtension.value),
|
||||
);
|
||||
assert.equal(folderExtension.value.name, "Donut REST Unpacked");
|
||||
assert.equal(folderExtension.value.version, "1.2.0");
|
||||
assert.equal(folderExtension.value.source_kind, "unpacked");
|
||||
assert.equal(folderExtension.value.linked_path, null);
|
||||
|
||||
// Two sources in one request have no defined winner, so the request is
|
||||
// refused rather than silently resolved.
|
||||
const ambiguousSource = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Ambiguous Extension",
|
||||
file_name: "fixture.zip",
|
||||
file_data_base64: extensionZipBase64(),
|
||||
source_path: unpackedDir,
|
||||
},
|
||||
});
|
||||
assert.equal(ambiguousSource.response.status, 400);
|
||||
assert.match(
|
||||
JSON.stringify(ambiguousSource.value),
|
||||
/EXTENSION_SOURCE_REQUIRED/,
|
||||
);
|
||||
const sourcelessExtension = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Sourceless Extension" },
|
||||
});
|
||||
assert.equal(sourcelessExtension.response.status, 400);
|
||||
assert.match(
|
||||
JSON.stringify(sourcelessExtension.value),
|
||||
/EXTENSION_SOURCE_REQUIRED/,
|
||||
);
|
||||
// An archive has no folder to keep loading from, so linking one is refused
|
||||
// rather than quietly stored as a copy.
|
||||
const linkedArchive = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Linked Archive",
|
||||
file_name: "fixture.zip",
|
||||
file_data_base64: extensionZipBase64(),
|
||||
link: true,
|
||||
},
|
||||
});
|
||||
assert.equal(linkedArchive.response.status, 400);
|
||||
assert.match(
|
||||
JSON.stringify(linkedArchive.value),
|
||||
/EXTENSION_LINK_REQUIRES_DIRECTORY/,
|
||||
);
|
||||
|
||||
const extensionId = folderExtension.value.id;
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extensions/${extensionId}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.id,
|
||||
extensionId,
|
||||
);
|
||||
const renamedExtension = await jsonRequest(
|
||||
`${base}/v1/extensions/${extensionId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Renamed Extension" },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
renamedExtension.response.status,
|
||||
200,
|
||||
JSON.stringify(renamedExtension.value),
|
||||
);
|
||||
assert.equal(renamedExtension.value.name, "REST Renamed Extension");
|
||||
assert.equal(
|
||||
(await jsonRequest(`${base}/v1/extensions`, { token: saved.api_token }))
|
||||
.value.length,
|
||||
2,
|
||||
);
|
||||
|
||||
// Axum's default body limit is 2 MiB, which plenty of real `.crx` files
|
||||
// exceed: every one of them was refused before the handler ran until the
|
||||
// extension payload routes got a limit of their own. The fixture below is
|
||||
// stored rather than deflated, so the body genuinely stays over the
|
||||
// default and a 201 can only come from the raised limit.
|
||||
const oversizedBody = {
|
||||
name: "REST Oversized Extension",
|
||||
file_name: "oversized.zip",
|
||||
file_data_base64: oversizedExtensionZipBase64(),
|
||||
};
|
||||
assert.ok(
|
||||
Buffer.byteLength(JSON.stringify(oversizedBody)) > 2 * 1024 * 1024,
|
||||
"the oversized fixture must exceed the default body limit it tests",
|
||||
);
|
||||
const oversized = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: oversizedBody,
|
||||
});
|
||||
assert.equal(
|
||||
oversized.response.status,
|
||||
201,
|
||||
JSON.stringify(oversized.value),
|
||||
);
|
||||
// Read out of the archive that arrived, so the payload landed whole rather
|
||||
// than merely being accepted.
|
||||
assert.equal(oversized.value.name, OVERSIZED_EXTENSION_NAME);
|
||||
assert.equal(oversized.value.version, "1.0.0");
|
||||
assert.equal(oversized.value.file_type, "zip");
|
||||
|
||||
// The raised limit is scoped to the two paths that carry a payload. A
|
||||
// group name is never megabytes long, so a route that accepted one would
|
||||
// mean the layer had been attached to the whole router.
|
||||
const oversizedGroupBody = { name: "G".repeat(3 * 1024 * 1024) };
|
||||
assert.ok(
|
||||
Buffer.byteLength(JSON.stringify(oversizedGroupBody)) > 2 * 1024 * 1024,
|
||||
);
|
||||
const oversizedGroup = await jsonRequest(`${base}/v1/extension-groups`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: oversizedGroupBody,
|
||||
});
|
||||
assert.equal(
|
||||
oversizedGroup.response.status,
|
||||
413,
|
||||
JSON.stringify(oversizedGroup.value),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value,
|
||||
[],
|
||||
"the refused group request must not have stored anything",
|
||||
);
|
||||
|
||||
// Chrome Web Store extensions overwhelmingly localize their manifest: the
|
||||
// name a user recognizes sits in `_locales/<default_locale>/messages.json`
|
||||
// and the manifest holds `__MSG_extName__`. Storing the manifest verbatim
|
||||
// is what puts a raw placeholder in the extension list.
|
||||
const localized = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Localized Extension",
|
||||
file_name: "localized.zip",
|
||||
file_data_base64: localizedExtensionZipBase64(),
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
localized.response.status,
|
||||
201,
|
||||
JSON.stringify(localized.value),
|
||||
);
|
||||
assert.equal(localized.value.name, LOCALIZED_EXTENSION_MESSAGES.extName);
|
||||
assert.equal(
|
||||
localized.value.description,
|
||||
LOCALIZED_EXTENSION_MESSAGES.extDescription,
|
||||
);
|
||||
assert.equal(
|
||||
localized.value.author,
|
||||
LOCALIZED_EXTENSION_MESSAGES.extAuthor,
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(localized.value), /__MSG_/);
|
||||
// The resolved strings have to be what was persisted, not something the
|
||||
// create response computed on its way out.
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extensions/${localized.value.id}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.name,
|
||||
LOCALIZED_EXTENSION_MESSAGES.extName,
|
||||
);
|
||||
|
||||
// A placeholder the locale file cannot resolve falls back to the name the
|
||||
// caller sent. What it must never do is store `__MSG_extName__` itself.
|
||||
const unresolved = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Unresolved Placeholder",
|
||||
file_name: "unresolved.zip",
|
||||
file_data_base64: localizedExtensionZipBase64({ messages: {} }),
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
unresolved.response.status,
|
||||
201,
|
||||
JSON.stringify(unresolved.value),
|
||||
);
|
||||
assert.equal(unresolved.value.name, "REST Unresolved Placeholder");
|
||||
assert.equal(unresolved.value.description, null);
|
||||
assert.equal(unresolved.value.author, null);
|
||||
assert.doesNotMatch(JSON.stringify(unresolved.value), /__MSG_/);
|
||||
|
||||
const extensionGroup = await jsonRequest(`${base}/v1/extension-groups`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Extension Group" },
|
||||
});
|
||||
assert.equal(
|
||||
extensionGroup.response.status,
|
||||
201,
|
||||
JSON.stringify(extensionGroup.value),
|
||||
);
|
||||
assert.equal(extensionGroup.value.name, "REST Extension Group");
|
||||
assert.deepEqual(extensionGroup.value.extension_ids, []);
|
||||
const extensionGroupId = extensionGroup.value.id;
|
||||
const renamedExtensionGroup = await jsonRequest(
|
||||
`${base}/v1/extension-groups/${extensionGroupId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Extension Group Updated" },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
renamedExtensionGroup.response.status,
|
||||
200,
|
||||
JSON.stringify(renamedExtensionGroup.value),
|
||||
);
|
||||
assert.equal(
|
||||
renamedExtensionGroup.value.name,
|
||||
"REST Extension Group Updated",
|
||||
);
|
||||
|
||||
const membershipUrl = `${base}/v1/extension-groups/${extensionGroupId}/extensions/${extensionId}`;
|
||||
const joined = await jsonRequest(membershipUrl, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(joined.response.status, 200, JSON.stringify(joined.value));
|
||||
assert.deepEqual(joined.value.extension_ids, [extensionId]);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.extension_ids,
|
||||
[extensionId],
|
||||
);
|
||||
const left = await jsonRequest(membershipUrl, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(left.response.status, 200, JSON.stringify(left.value));
|
||||
assert.deepEqual(left.value.extension_ids, []);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.extension_ids,
|
||||
[],
|
||||
);
|
||||
|
||||
// The whole path an automation client takes: an extension, a group holding
|
||||
// it, and a profile that will load that group the next time it launches.
|
||||
// Each piece already had coverage; the sequence did not, and it is the
|
||||
// sequence that has to work for extensions to be usable over REST at all.
|
||||
const launchProfile = await app.invoke("create_browser_profile_new", {
|
||||
name: "REST Extension Profile",
|
||||
browserStr: "wayfern",
|
||||
version: "150.0.7871.100",
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
// A stored fingerprint keeps this suite off the real browser; the
|
||||
// browser suite covers generation.
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
const launchGroup = await jsonRequest(`${base}/v1/extension-groups`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Launch Extension Group" },
|
||||
});
|
||||
assert.equal(
|
||||
launchGroup.response.status,
|
||||
201,
|
||||
JSON.stringify(launchGroup.value),
|
||||
);
|
||||
const launchGroupId = launchGroup.value.id;
|
||||
assert.deepEqual(
|
||||
(
|
||||
await jsonRequest(
|
||||
`${base}/v1/extension-groups/${launchGroupId}/extensions/${archiveExtension.value.id}`,
|
||||
{ method: "POST", token: saved.api_token },
|
||||
)
|
||||
).value.extension_ids,
|
||||
[archiveExtension.value.id],
|
||||
);
|
||||
const assigned = await jsonRequest(
|
||||
`${base}/v1/profiles/${launchProfile.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { extension_group_id: launchGroupId },
|
||||
},
|
||||
);
|
||||
assert.equal(assigned.response.status, 200, JSON.stringify(assigned.value));
|
||||
assert.equal(assigned.value.profile.id, launchProfile.id);
|
||||
// `ApiProfile` carries no `extension_group_id`, so the assignment can only
|
||||
// be read back through the surface the launcher itself resolves.
|
||||
const assignedGroup = () =>
|
||||
app.invoke("get_extension_group_for_profile", {
|
||||
profileId: launchProfile.id,
|
||||
});
|
||||
assert.equal((await assignedGroup()).id, launchGroupId);
|
||||
assert.deepEqual((await assignedGroup()).extension_ids, [
|
||||
archiveExtension.value.id,
|
||||
]);
|
||||
|
||||
// A group that does not exist used to be stored anyway and fail at launch,
|
||||
// far from the request that caused it.
|
||||
const missingExtensionGroup = await jsonRequest(
|
||||
`${base}/v1/profiles/${launchProfile.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { extension_group_id: "00000000-0000-0000-0000-0000000000ee" },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
missingExtensionGroup.response.status,
|
||||
404,
|
||||
JSON.stringify(missingExtensionGroup.value),
|
||||
);
|
||||
assert.equal(
|
||||
(await assignedGroup()).id,
|
||||
launchGroupId,
|
||||
"a refused assignment must leave the previous one in place",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, {
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { extension_group_id: "" },
|
||||
})
|
||||
).response.status,
|
||||
200,
|
||||
);
|
||||
assert.equal(await assignedGroup(), null);
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups/${launchGroupId}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
await app.invoke("delete_profile", { profileId: launchProfile.id });
|
||||
|
||||
for (const id of [
|
||||
extensionId,
|
||||
archiveExtension.value.id,
|
||||
oversized.value.id,
|
||||
localized.value.id,
|
||||
unresolved.value.id,
|
||||
]) {
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extensions/${id}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
|
||||
const missing = await jsonRequest(`${base}/v1/groups/missing`, {
|
||||
token: saved.api_token,
|
||||
});
|
||||
@@ -323,6 +774,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
||||
"run_profile_remote",
|
||||
"get_remote_session",
|
||||
"stop_remote_session",
|
||||
// Extension management is only usable from an agent if importing and
|
||||
// grouping are reachable, not just listing and deleting.
|
||||
"add_extension",
|
||||
"update_extension",
|
||||
"add_extension_to_group",
|
||||
"remove_extension_from_group",
|
||||
"update_extension_group",
|
||||
]) {
|
||||
assert.ok(names.includes(name), `MCP is missing ${name}`);
|
||||
}
|
||||
@@ -414,6 +872,101 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
||||
);
|
||||
await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id });
|
||||
|
||||
let toolCallId = 7;
|
||||
const callTool = (name, args) =>
|
||||
jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: toolCallId++,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
});
|
||||
|
||||
const unpackedDir = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "mcp-unpacked-extension"),
|
||||
{ name: "Donut MCP Unpacked", version: "1.0.0" },
|
||||
);
|
||||
const addedExtension = await callTool("add_extension", {
|
||||
path: unpackedDir,
|
||||
name: "MCP Folder Extension",
|
||||
});
|
||||
assert.equal(addedExtension.response.status, 200);
|
||||
const subscriptionGated = /subscription/i.test(
|
||||
addedExtension.value.error?.message ?? "",
|
||||
);
|
||||
// The e2e build overrides the paid-plan gate whenever a Wayfern test token
|
||||
// is present, so with one in the environment a gated answer means the
|
||||
// override stopped working and everything below it silently stopped
|
||||
// running.
|
||||
assert.ok(
|
||||
!subscriptionGated || !process.env.WAYFERN_TEST_TOKEN,
|
||||
`the e2e paid-plan override did not apply: ${addedExtension.value.error?.message}`,
|
||||
);
|
||||
if (subscriptionGated) {
|
||||
// Every extension tool is gated on an active paid plan and this session
|
||||
// is signed out, so the call path is unreachable here. The tool list
|
||||
// above still proves the tools are published.
|
||||
console.warn(
|
||||
"Skipping the MCP extension tool calls: this session has no paid entitlement",
|
||||
);
|
||||
} else {
|
||||
assert.equal(addedExtension.value.error, undefined);
|
||||
const stored = (await app.invoke("list_extensions")).find(
|
||||
(item) => item.name === "Donut MCP Unpacked",
|
||||
);
|
||||
assert.ok(stored, "the MCP import must produce a stored extension");
|
||||
assert.equal(stored.source_kind, "unpacked");
|
||||
assert.equal(stored.linked_path, null);
|
||||
|
||||
const renamedExtension = await callTool("update_extension", {
|
||||
extension_id: stored.id,
|
||||
name: "MCP Renamed Extension",
|
||||
});
|
||||
assert.equal(renamedExtension.value.error, undefined);
|
||||
assert.equal(
|
||||
(await app.invoke("list_extensions")).find(
|
||||
(item) => item.id === stored.id,
|
||||
).name,
|
||||
"MCP Renamed Extension",
|
||||
);
|
||||
|
||||
const extensionGroup = await app.invoke("create_extension_group", {
|
||||
name: "MCP Extension Group",
|
||||
});
|
||||
const joined = await callTool("add_extension_to_group", {
|
||||
group_id: extensionGroup.id,
|
||||
extension_id: stored.id,
|
||||
});
|
||||
assert.equal(joined.value.error, undefined);
|
||||
const readGroup = async () =>
|
||||
(await app.invoke("list_extension_groups")).find(
|
||||
(item) => item.id === extensionGroup.id,
|
||||
);
|
||||
assert.deepEqual((await readGroup()).extension_ids, [stored.id]);
|
||||
|
||||
const renamedGroup = await callTool("update_extension_group", {
|
||||
group_id: extensionGroup.id,
|
||||
name: "MCP Extension Group Updated",
|
||||
});
|
||||
assert.equal(renamedGroup.value.error, undefined);
|
||||
assert.equal((await readGroup()).name, "MCP Extension Group Updated");
|
||||
|
||||
const removed = await callTool("remove_extension_from_group", {
|
||||
group_id: extensionGroup.id,
|
||||
extension_id: stored.id,
|
||||
});
|
||||
assert.equal(removed.value.error, undefined);
|
||||
assert.deepEqual((await readGroup()).extension_ids, []);
|
||||
|
||||
await app.invoke("delete_extension", { extensionId: stored.id });
|
||||
await app.invoke("delete_extension_group", {
|
||||
groupId: extensionGroup.id,
|
||||
});
|
||||
}
|
||||
|
||||
const agents = await app.invoke("list_mcp_agents");
|
||||
assert.ok(agents.some((agent) => agent.id === "cursor"));
|
||||
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
|
||||
|
||||
+543
-1
@@ -1,8 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import Color from "color";
|
||||
import en from "../../src/i18n/locales/en.json" with { type: "json" };
|
||||
import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import {
|
||||
extensionZipBase64,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
const THEME_VARIABLES = [
|
||||
"--background",
|
||||
@@ -87,6 +94,21 @@ function themeVariablesEqual(actual, expected) {
|
||||
async function applyThemeForContrastAudit(app, theme) {
|
||||
await app.execute(
|
||||
`
|
||||
// This audit reads settled colour tokens, not the animation between
|
||||
// them. Tab triggers carry "transition-colors duration-150" and start
|
||||
// from --muted-foreground, so a computed style sampled mid-transition
|
||||
// returns an intermediate colour and the assertion fails on whichever
|
||||
// theme the machine happened to be slow on. Kill transitions for the
|
||||
// duration of the audit rather than racing them with a fixed sleep.
|
||||
let freeze = document.getElementById("donut-e2e-freeze-transitions");
|
||||
if (!freeze) {
|
||||
freeze = document.createElement("style");
|
||||
freeze.id = "donut-e2e-freeze-transitions";
|
||||
freeze.textContent =
|
||||
"*, *::before, *::after { transition: none !important; animation: none !important; }";
|
||||
document.head.appendChild(freeze);
|
||||
}
|
||||
|
||||
const [colors, derived, mode] = arguments;
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
@@ -97,7 +119,11 @@ async function applyThemeForContrastAudit(app, theme) {
|
||||
`,
|
||||
[theme.colors, getDerivedThemeColors(theme.colors), theme.mode],
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
// One frame is enough once transitions are off; the value cannot drift after
|
||||
// style recalculation.
|
||||
await app.execute(
|
||||
`return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))));`,
|
||||
);
|
||||
}
|
||||
|
||||
async function animatedTabContrastSnapshot(app) {
|
||||
@@ -556,6 +582,88 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
|
||||
});
|
||||
});
|
||||
|
||||
test("pasting a proxy string into the form fills every field", async () => {
|
||||
await withApp("ui-proxy-form-paste", async (app) => {
|
||||
// Dispatched rather than typed: the point is that the paste is spread
|
||||
// across the form instead of landing whole in the field it was dropped on,
|
||||
// and only a real ClipboardEvent carries the text the handler reads.
|
||||
const paste = (selector, text) =>
|
||||
app.execute(
|
||||
`const field = document.querySelector(arguments[0]);
|
||||
const data = new DataTransfer();
|
||||
data.setData("text/plain", arguments[1]);
|
||||
field.focus();
|
||||
return field.dispatchEvent(
|
||||
new ClipboardEvent("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: data,
|
||||
}),
|
||||
);`,
|
||||
[selector, text],
|
||||
);
|
||||
const fieldValues = () =>
|
||||
app.execute(
|
||||
`return ["#proxy-name", "#proxy-host", "#proxy-port", "#proxy-username", "#proxy-password"]
|
||||
.map((selector) => document.querySelector(selector)?.value ?? null);`,
|
||||
);
|
||||
|
||||
await app.clickSelector('[aria-label="Network"]');
|
||||
await app.waitForText("New proxy");
|
||||
await app.clickSelector('[aria-label="New proxy"]');
|
||||
await app.waitForText("Add Proxy");
|
||||
|
||||
await paste("#proxy-host", "socks5://carol:s3cret@1.2.3.4:1080");
|
||||
await app.waitFor(async () => (await fieldValues())[1] === "1.2.3.4", {
|
||||
description: "host filled from the pasted proxy",
|
||||
});
|
||||
assert.deepEqual(await fieldValues(), [
|
||||
"1.2.3.4:1080",
|
||||
"1.2.3.4",
|
||||
"1080",
|
||||
"carol",
|
||||
"s3cret",
|
||||
]);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector("#proxy-type")?.textContent?.trim();`,
|
||||
),
|
||||
"SOCKS5",
|
||||
);
|
||||
|
||||
// No scheme in the line, so the type falls back to HTTP.
|
||||
await paste("#proxy-name", "5.6.7.8:8080:dave:hunter2");
|
||||
await app.waitFor(async () => (await fieldValues())[1] === "5.6.7.8", {
|
||||
description: "scheme-less proxy string parsed",
|
||||
});
|
||||
assert.deepEqual((await fieldValues()).slice(1), [
|
||||
"5.6.7.8",
|
||||
"8080",
|
||||
"dave",
|
||||
"hunter2",
|
||||
]);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector("#proxy-type")?.textContent?.trim();`,
|
||||
),
|
||||
"HTTP",
|
||||
);
|
||||
|
||||
// A bare hostname is not a proxy string, so the form is left alone and the
|
||||
// browser's own paste stands.
|
||||
await paste("#proxy-host", "proxy.example.com");
|
||||
// Settle the parse round-trip, so "nothing changed" isn't just "nothing
|
||||
// has come back yet".
|
||||
await app.invoke("get_stored_proxies");
|
||||
assert.deepEqual((await fieldValues()).slice(1), [
|
||||
"5.6.7.8",
|
||||
"8080",
|
||||
"dave",
|
||||
"hunter2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("About exposes a searchable, responsive third-party license inventory", async () => {
|
||||
await withApp("ui-about-licenses", async (app) => {
|
||||
await app.clickSelector('[aria-label="More"]');
|
||||
@@ -931,3 +1039,437 @@ test("a light custom preset keeps light component behavior after restart", async
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const EXTENSION_STRINGS = en.extensions;
|
||||
|
||||
/**
|
||||
* Answer the native directory picker from inside the webview.
|
||||
*
|
||||
* "Load unpacked" calls `open({ directory: true })` from
|
||||
* `@tauri-apps/plugin-dialog`, which puts an OS window on screen that no
|
||||
* WebDriver can reach. The call leaves the page as the `plugin:dialog|open` IPC
|
||||
* command, but Tauri locks its own entry points down: `invoke`, `ipc` and
|
||||
* `postMessage` are all installed with
|
||||
* `Object.defineProperty(window.__TAURI_INTERNALS__, name, { value })`, so they
|
||||
* are non-writable and cannot be wrapped. The seam underneath them is the
|
||||
* transport, which POSTs the command through `fetch` to
|
||||
* `ipc://localhost/<command>`. Answering that one request with the shape Tauri
|
||||
* expects (`Tauri-Response: ok` plus a JSON body) resolves the picker with a
|
||||
* folder and needs no test-only hook in the production component. Every other
|
||||
* command still reaches the real backend.
|
||||
*/
|
||||
async function stubFolderPicker(app, folder) {
|
||||
await app.execute(
|
||||
`const folder = arguments[0];
|
||||
if (!window.__donutOriginalFetch) {
|
||||
window.__donutOriginalFetch = window.fetch;
|
||||
}
|
||||
window.__donutFolderPickerCalls = [];
|
||||
window.fetch = function (input, init) {
|
||||
const url = String(
|
||||
typeof input === "string" ? input : (input && input.url) || "",
|
||||
);
|
||||
let command = "";
|
||||
try {
|
||||
command = decodeURIComponent(url.split("/").pop() || "");
|
||||
} catch (_error) {
|
||||
command = "";
|
||||
}
|
||||
if (command === "plugin:dialog|open") {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse((init && init.body) || "null");
|
||||
} catch (_error) {
|
||||
payload = null;
|
||||
}
|
||||
window.__donutFolderPickerCalls.push(payload);
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(folder), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Tauri-Response": "ok",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return window.__donutOriginalFetch.apply(window, arguments);
|
||||
};
|
||||
return true;`,
|
||||
[folder],
|
||||
);
|
||||
}
|
||||
|
||||
/** Restores the real transport and returns what the picker was asked for. */
|
||||
async function restoreFolderPicker(app) {
|
||||
return app.execute(
|
||||
`const calls = window.__donutFolderPickerCalls ?? [];
|
||||
if (window.__donutOriginalFetch) {
|
||||
window.fetch = window.__donutOriginalFetch;
|
||||
delete window.__donutOriginalFetch;
|
||||
}
|
||||
delete window.__donutFolderPickerCalls;
|
||||
return calls;`,
|
||||
);
|
||||
}
|
||||
|
||||
async function openExtensionsPage(app) {
|
||||
await app.clickSelector('[aria-label="Extensions"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return Boolean(document.querySelector(arguments[0]));`, [
|
||||
`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`,
|
||||
]),
|
||||
{ description: "extension management page" },
|
||||
);
|
||||
}
|
||||
|
||||
async function stageUnpackedFolder(app, folder) {
|
||||
await stubFolderPicker(app, folder);
|
||||
await app.clickSelector(`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return Boolean(document.querySelector("#ext-link-folder"));`,
|
||||
),
|
||||
{
|
||||
description:
|
||||
"staged folder import form (the intercepted directory picker has to resolve)",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadArchiveThroughUi(app, archivePath, typedName) {
|
||||
// The real control is a hidden file input a button clicks for the user;
|
||||
// WebDriver can only type a path into an input it can see.
|
||||
await app.execute(`
|
||||
const input = document.querySelector("#ext-file-input");
|
||||
input.classList.remove("hidden");
|
||||
input.style.position = "fixed";
|
||||
input.style.left = "12px";
|
||||
input.style.bottom = "12px";
|
||||
`);
|
||||
const input = await app.session.findCss("#ext-file-input");
|
||||
await app.session.sendKeys(input, archivePath);
|
||||
await app.waitForText(path.basename(archivePath));
|
||||
await app.execute(`
|
||||
const input = document.querySelector("#ext-file-input");
|
||||
input.classList.add("hidden");
|
||||
input.removeAttribute("style");
|
||||
`);
|
||||
await app.fillSelector(
|
||||
`input[placeholder="${EXTENSION_STRINGS.namePlaceholder}"]`,
|
||||
typedName,
|
||||
);
|
||||
await app.clickText(en.common.buttons.add, { roles: ["button"] });
|
||||
}
|
||||
|
||||
/** The link checkbox plus the copy that is supposed to explain it. */
|
||||
async function linkCheckboxState(app, id) {
|
||||
return app.execute(
|
||||
`const checkbox = document.querySelector("#" + arguments[0]);
|
||||
const label = document.querySelector('label[for="' + arguments[0] + '"]');
|
||||
const help = label?.parentElement?.querySelector("p");
|
||||
return checkbox
|
||||
? {
|
||||
checked: checkbox.getAttribute("data-state") === "checked",
|
||||
label: (label?.innerText ?? "").trim(),
|
||||
help: (help?.innerText ?? "").trim(),
|
||||
}
|
||||
: null;`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
function extensionRowScript(body) {
|
||||
return `const wanted = arguments[0];
|
||||
const row = [...document.querySelectorAll("tbody tr")].find((candidate) => {
|
||||
const cells = [...candidate.querySelectorAll("td")];
|
||||
return cells.length >= 7 && (cells[2].innerText || "").trim() === wanted;
|
||||
});
|
||||
${body}`;
|
||||
}
|
||||
|
||||
async function extensionRow(app, name) {
|
||||
return app.execute(
|
||||
extensionRowScript(`if (!row) return null;
|
||||
const cells = [...row.querySelectorAll("td")];
|
||||
const sync = row.querySelector('[data-slot="animated-switch"]');
|
||||
return {
|
||||
name: (cells[2].innerText || "").trim(),
|
||||
source: (cells[4].innerText || "").trim(),
|
||||
syncChecked: sync ? sync.getAttribute("data-state") === "checked" : null,
|
||||
syncDisabled: sync ? sync.disabled === true : null,
|
||||
};`),
|
||||
[name],
|
||||
);
|
||||
}
|
||||
|
||||
async function extensionEditButton(app, name) {
|
||||
return app.execute(
|
||||
extensionRowScript(
|
||||
`return row ? row.querySelector("td:last-child button") : null;`,
|
||||
),
|
||||
[name],
|
||||
);
|
||||
}
|
||||
|
||||
async function dialogText(app, title) {
|
||||
return app.execute(
|
||||
`const wanted = arguments[0];
|
||||
const dialog = [...document.querySelectorAll("[role='dialog']")]
|
||||
.reverse()
|
||||
.find((node) =>
|
||||
[...node.querySelectorAll("[data-slot='dialog-title']")].some(
|
||||
(heading) => (heading.textContent || "").trim() === wanted,
|
||||
),
|
||||
);
|
||||
return dialog ? (dialog.innerText || "").trim() : null;`,
|
||||
[title],
|
||||
);
|
||||
}
|
||||
|
||||
async function toastTexts(app) {
|
||||
return app.execute(
|
||||
`return [...document.querySelectorAll("[data-sonner-toast]")]
|
||||
.map((toast) => (toast.innerText || "").trim())
|
||||
.filter(Boolean);`,
|
||||
);
|
||||
}
|
||||
|
||||
test("an uploaded archive and a loaded folder both import, each under its own source", async () => {
|
||||
await withApp("ui-extension-import-sources", async (app) => {
|
||||
const archivePath = path.join(app.root, "ui-archive-extension.zip");
|
||||
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
|
||||
const folder = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "ui-copied-extension"),
|
||||
{ name: "Donut UI Copied Folder" },
|
||||
);
|
||||
|
||||
await openExtensionsPage(app);
|
||||
await uploadArchiveThroughUi(
|
||||
app,
|
||||
archivePath,
|
||||
"Overridden By The Manifest",
|
||||
);
|
||||
await app.waitForText("Donut E2E Fixture");
|
||||
|
||||
await stageUnpackedFolder(app, folder);
|
||||
assert.ok(await app.visibleTextIncludes(EXTENSION_STRINGS.selectedFolder));
|
||||
assert.ok(
|
||||
await app.visibleTextIncludes(folder),
|
||||
"the staged import has to name the folder it is about to read",
|
||||
);
|
||||
const staged = await linkCheckboxState(app, "ext-link-folder");
|
||||
assert.equal(staged?.checked, false, "linking a folder has to be opt-in");
|
||||
assert.equal(staged.help, EXTENSION_STRINGS.linkFolderOff);
|
||||
await app.clickText(en.common.buttons.add, { roles: ["button"] });
|
||||
await app.waitForText("Donut UI Copied Folder");
|
||||
|
||||
const pickerCalls = await restoreFolderPicker(app);
|
||||
assert.equal(pickerCalls.length, 1, "Load unpacked has to open the picker");
|
||||
assert.equal(pickerCalls[0].options.directory, true);
|
||||
assert.equal(pickerCalls[0].options.multiple, false);
|
||||
assert.equal(
|
||||
pickerCalls[0].options.title,
|
||||
EXTENSION_STRINGS.selectFolderTitle,
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
EXTENSION_STRINGS.source.archive,
|
||||
EXTENSION_STRINGS.source.unpacked,
|
||||
);
|
||||
assert.equal(
|
||||
(await extensionRow(app, "Donut E2E Fixture"))?.source,
|
||||
EXTENSION_STRINGS.source.archive,
|
||||
);
|
||||
assert.equal(
|
||||
(await extensionRow(app, "Donut UI Copied Folder"))?.source,
|
||||
EXTENSION_STRINGS.source.unpacked,
|
||||
);
|
||||
|
||||
const extensions = await app.invoke("list_extensions");
|
||||
assert.equal(extensions.length, 2);
|
||||
const copied = extensions.find(
|
||||
(extension) => extension.name === "Donut UI Copied Folder",
|
||||
);
|
||||
assert.equal(copied.source_kind, "unpacked");
|
||||
assert.equal(
|
||||
copied.linked_path,
|
||||
null,
|
||||
"an unlinked folder import is copied into the store, not pointed at",
|
||||
);
|
||||
assert.equal(copied.file_type, "zip");
|
||||
const archive = extensions.find(
|
||||
(extension) => extension.name === "Donut E2E Fixture",
|
||||
);
|
||||
assert.equal(archive.source_kind, "archive");
|
||||
assert.equal(archive.linked_path, null);
|
||||
});
|
||||
});
|
||||
|
||||
test("linking a folder says what it costs, records the path, and locks that row's sync off", async () => {
|
||||
await withApp("ui-extension-linked-folder", async (app) => {
|
||||
await app.invoke("add_extension", {
|
||||
name: "Copied Neighbour",
|
||||
fileName: "ui-neighbour-extension.zip",
|
||||
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||
});
|
||||
const folder = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "ui-linked-extension"),
|
||||
{ name: "Donut UI Linked Folder" },
|
||||
);
|
||||
|
||||
await openExtensionsPage(app);
|
||||
await app.waitForText("Donut E2E Fixture");
|
||||
await stageUnpackedFolder(app, folder);
|
||||
|
||||
const off = await linkCheckboxState(app, "ext-link-folder");
|
||||
assert.equal(off?.checked, false);
|
||||
assert.equal(off.label, EXTENSION_STRINGS.linkFolder);
|
||||
assert.equal(off.help, EXTENSION_STRINGS.linkFolderOff);
|
||||
|
||||
await app.clickSelector("#ext-link-folder");
|
||||
const on = await app.waitFor(
|
||||
async () => {
|
||||
const state = await linkCheckboxState(app, "ext-link-folder");
|
||||
return state?.checked ? state : false;
|
||||
},
|
||||
{ description: "link checkbox to turn on" },
|
||||
);
|
||||
assert.equal(on.help, EXTENSION_STRINGS.linkFolderOn);
|
||||
assert.notEqual(
|
||||
on.help,
|
||||
off.help,
|
||||
"the checkbox has to say what turning it on changes",
|
||||
);
|
||||
|
||||
await app.clickText(en.common.buttons.add, { roles: ["button"] });
|
||||
await app.waitForText("Donut UI Linked Folder");
|
||||
assert.equal((await restoreFolderPicker(app)).length, 1);
|
||||
|
||||
const linkedRow = await extensionRow(app, "Donut UI Linked Folder");
|
||||
assert.equal(linkedRow?.source, EXTENSION_STRINGS.source.linked);
|
||||
assert.equal(linkedRow.syncChecked, false);
|
||||
assert.equal(linkedRow.syncDisabled, true);
|
||||
assert.equal(
|
||||
(await extensionRow(app, "Donut E2E Fixture"))?.syncDisabled,
|
||||
false,
|
||||
"only the linked row loses its sync control",
|
||||
);
|
||||
|
||||
const linked = (await app.invoke("list_extensions")).find(
|
||||
(extension) => extension.name === "Donut UI Linked Folder",
|
||||
);
|
||||
assert.equal(linked.linked_path, await realpath(folder));
|
||||
assert.equal(linked.file_type, "unpacked");
|
||||
assert.equal(linked.sync_enabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("the edit dialog replaces an extension's payload from a folder", async () => {
|
||||
await withApp("ui-extension-replace-from-folder", async (app) => {
|
||||
const original = await app.invoke("add_extension", {
|
||||
name: "Replaced Later",
|
||||
fileName: "ui-original-extension.zip",
|
||||
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||
});
|
||||
assert.equal(original.version, "1.0.0");
|
||||
const folder = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "ui-replacement-extension"),
|
||||
{ name: "Donut UI Replacement", version: "3.1.4" },
|
||||
);
|
||||
|
||||
await openExtensionsPage(app);
|
||||
await app.waitForText("Donut E2E Fixture");
|
||||
const editButton = await extensionEditButton(app, "Donut E2E Fixture");
|
||||
assert.ok(editButton, "the extension row's edit control was not visible");
|
||||
await app.clickElement(editButton, "extension edit button");
|
||||
const beforeReplace = await app.waitFor(
|
||||
() => dialogText(app, EXTENSION_STRINGS.editExtension),
|
||||
{ description: "extension edit dialog" },
|
||||
);
|
||||
assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.label));
|
||||
assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.archive));
|
||||
|
||||
await stubFolderPicker(app, folder);
|
||||
await app.clickTextIn('[role="dialog"]', EXTENSION_STRINGS.selectFolder, {
|
||||
roles: ["button"],
|
||||
});
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await dialogText(app, EXTENSION_STRINGS.editExtension))?.includes(
|
||||
folder,
|
||||
),
|
||||
{ description: "chosen replacement folder" },
|
||||
);
|
||||
const replaceLink = await linkCheckboxState(app, "ext-edit-link-folder");
|
||||
assert.equal(replaceLink?.checked, false);
|
||||
assert.equal(replaceLink.help, EXTENSION_STRINGS.linkFolderOff);
|
||||
|
||||
await app.clickTextIn('[role="dialog"]', en.common.buttons.save, {
|
||||
roles: ["button"],
|
||||
});
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await toastTexts(app)).some((text) =>
|
||||
text.includes(EXTENSION_STRINGS.updateSuccess),
|
||||
),
|
||||
{ description: "extension update confirmation" },
|
||||
);
|
||||
assert.equal((await restoreFolderPicker(app)).length, 1);
|
||||
|
||||
const extensions = await app.invoke("list_extensions");
|
||||
assert.equal(
|
||||
extensions.length,
|
||||
1,
|
||||
"replacing a payload must not add a second extension",
|
||||
);
|
||||
const [updated] = extensions;
|
||||
assert.equal(updated.id, original.id);
|
||||
assert.equal(updated.source_kind, "unpacked");
|
||||
assert.equal(updated.linked_path, null);
|
||||
assert.equal(updated.file_name, "ui-replacement-extension.zip");
|
||||
assert.equal(updated.version, "3.1.4");
|
||||
// The dialog's own name field stays authoritative, so the row keeps its
|
||||
// name while the payload underneath it is swapped.
|
||||
assert.equal(updated.name, "Donut E2E Fixture");
|
||||
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await extensionRow(app, "Donut E2E Fixture"))?.source ===
|
||||
EXTENSION_STRINGS.source.unpacked,
|
||||
{ description: "replaced row to report its new source" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a folder with no manifest fails with the translated reason, not a raw code", async () => {
|
||||
await withApp("ui-extension-manifest-missing", async (app) => {
|
||||
const folder = path.join(app.root, "fixtures", "ui-not-an-extension");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(path.join(folder, "readme.txt"), "no manifest here\n");
|
||||
|
||||
await openExtensionsPage(app);
|
||||
await stageUnpackedFolder(app, folder);
|
||||
await app.clickText(en.common.buttons.add, { roles: ["button"] });
|
||||
|
||||
const expected = en.backendErrors.extensionManifestMissing;
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await toastTexts(app)).some((text) => text.includes(expected)),
|
||||
{ description: "translated manifest-missing toast" },
|
||||
);
|
||||
const toasts = await toastTexts(app);
|
||||
assert.ok(
|
||||
toasts.every((text) => !text.includes("EXTENSION_MANIFEST_MISSING")),
|
||||
`a raw backend code reached the user: ${JSON.stringify(toasts)}`,
|
||||
);
|
||||
assert.ok(
|
||||
toasts.every((text) => !text.includes(EXTENSION_STRINGS.uploadFailed)),
|
||||
"the generic fallback would hide which folder problem this was",
|
||||
);
|
||||
assert.deepEqual(await app.invoke("list_extensions"), []);
|
||||
assert.equal((await restoreFolderPicker(app)).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.29.2";
|
||||
releaseVersion = "0.29.4";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_amd64.AppImage";
|
||||
hash = "sha256-ExvjO1f5AkzzRr39LIBQbG2bavQxCHpyOuE+h16VHkU=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_amd64.AppImage";
|
||||
hash = "sha256-Y9HpvwsVXQ4gg4vUMPsjtLEd4Yu8sLK/W09mngtPajI=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_aarch64.AppImage";
|
||||
hash = "sha256-rcTyIJ8hehrS6Q4yEkEs6kZz14Jt0A1Qpsss5dIwepU=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_aarch64.AppImage";
|
||||
hash = "sha256-duw3lDRLH7rYhOWn5SKn1bevsUIDLmXEs+wnyeiNt1c=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.29.3",
|
||||
"version": "0.29.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "pnpm licenses:generate",
|
||||
@@ -10,10 +10,11 @@
|
||||
"prebuild": "pnpm licenses:generate",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test:themes": "node --test src/lib/themes.test.mjs",
|
||||
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
|
||||
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
|
||||
"test:proxy-string": "node --test src/lib/proxy-string.test.mjs",
|
||||
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
|
||||
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
|
||||
"licenses:generate": "node scripts/generate-licenses.mjs",
|
||||
|
||||
Generated
+1
-1
@@ -1815,7 +1815,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.29.3"
|
||||
version = "0.29.5"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm 0.11.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.29.3"
|
||||
version = "0.29.5"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
+843
-44
File diff suppressed because it is too large
Load Diff
+146
-10
@@ -37,10 +37,66 @@ fn data_root() -> Option<PathBuf> {
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`<root>/logs`); `None`
|
||||
/// otherwise, in which case the platform default app log dir is used.
|
||||
/// Where logs go when something other than the platform default applies:
|
||||
/// `<root>/logs` for `DONUTBROWSER_DATA_ROOT`, else `<exe dir>/logs` in
|
||||
/// portable mode. `None` means the platform default app log dir.
|
||||
///
|
||||
/// Portable belongs here for the same reason `data_dir` and `cache_dir` honour
|
||||
/// it: a portable install is expected to keep its state beside the executable.
|
||||
/// Logs were the one thing still written to the host machine, which quietly
|
||||
/// defeated that.
|
||||
pub fn log_dir_override() -> Option<PathBuf> {
|
||||
data_root().map(|root| root.join("logs"))
|
||||
log_dir_for(data_root(), portable_dir())
|
||||
}
|
||||
|
||||
/// Split out from `log_dir_override` so the precedence is testable without a
|
||||
/// real `.portable` marker sitting next to the test binary.
|
||||
fn log_dir_for(root: Option<PathBuf>, portable: Option<&PathBuf>) -> Option<PathBuf> {
|
||||
if let Some(root) = root {
|
||||
return Some(root.join("logs"));
|
||||
}
|
||||
portable.map(|dir| dir.join("logs"))
|
||||
}
|
||||
|
||||
/// File name `tauri-plugin-window-state` persists geometry under.
|
||||
pub const WINDOW_STATE_FILENAME: &str = ".window-state.json";
|
||||
|
||||
/// True when app state has been moved off the platform default location, by
|
||||
/// portable mode or by either directory override.
|
||||
fn state_is_relocated() -> bool {
|
||||
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
|
||||
|| data_root().is_some()
|
||||
|| portable_dir().is_some()
|
||||
}
|
||||
|
||||
/// Absolute path the window-state file should live at, or `None` to leave the
|
||||
/// plugin on its platform default.
|
||||
///
|
||||
/// `tauri-plugin-window-state` resolves its file as
|
||||
/// `app_config_dir().join(filename)` and exposes no way to change the
|
||||
/// directory, so the only lever is the file name. Handing it an ABSOLUTE path
|
||||
/// works because `Path::join` discards the base when the argument is absolute,
|
||||
/// which lands the file with the rest of our relocated state instead of on the
|
||||
/// host machine. If a future plugin version sanitises the name to a bare file
|
||||
/// component this silently reverts to the default directory, which is why the
|
||||
/// first-run probe in `lib.rs` reads this same function rather than assuming.
|
||||
pub fn window_state_path_override() -> Option<PathBuf> {
|
||||
state_is_relocated().then(|| data_dir().join(WINDOW_STATE_FILENAME))
|
||||
}
|
||||
|
||||
/// Where the window-state file actually is, override or not. Used for the
|
||||
/// first-run probe, which must agree with whatever the plugin was configured
|
||||
/// with or portable installs re-apply the default geometry on every launch.
|
||||
pub fn window_state_path<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> Option<PathBuf> {
|
||||
if let Some(path) = window_state_path_override() {
|
||||
return Some(path);
|
||||
}
|
||||
use tauri::Manager;
|
||||
handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.ok()
|
||||
.map(|dir| dir.join(WINDOW_STATE_FILENAME))
|
||||
}
|
||||
|
||||
pub fn app_name() -> &'static str {
|
||||
@@ -262,19 +318,99 @@ mod tests {
|
||||
#[test]
|
||||
fn test_data_dir_returns_path() {
|
||||
let dir = data_dir();
|
||||
assert!(
|
||||
dir.to_string_lossy().contains(app_name()),
|
||||
"data_dir should contain app_name"
|
||||
);
|
||||
// Portable mode deliberately drops the app_name segment: state lives at
|
||||
// <exe dir>/data. The assertion only holds for the platform-default path.
|
||||
if is_portable() {
|
||||
assert!(dir.ends_with("data"));
|
||||
} else {
|
||||
assert!(
|
||||
dir.to_string_lossy().contains(app_name()),
|
||||
"data_dir should contain app_name"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_dir_returns_path() {
|
||||
let dir = cache_dir();
|
||||
assert!(
|
||||
dir.to_string_lossy().contains(app_name()),
|
||||
"cache_dir should contain app_name"
|
||||
if is_portable() {
|
||||
assert!(dir.ends_with("cache"));
|
||||
} else {
|
||||
assert!(
|
||||
dir.to_string_lossy().contains(app_name()),
|
||||
"cache_dir should contain app_name"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_dir_follows_portable_mode_and_data_root() {
|
||||
let root = PathBuf::from("/tmp/donut-root");
|
||||
let portable = PathBuf::from("/tmp/donut-portable");
|
||||
|
||||
// Neither: the platform default app log dir is used.
|
||||
assert_eq!(log_dir_for(None, None), None);
|
||||
|
||||
// Portable alone keeps logs beside the executable rather than on the host.
|
||||
assert_eq!(
|
||||
log_dir_for(None, Some(&portable)),
|
||||
Some(portable.join("logs"))
|
||||
);
|
||||
|
||||
// DONUTBROWSER_DATA_ROOT wins over portable, matching data_dir/cache_dir.
|
||||
assert_eq!(
|
||||
log_dir_for(Some(root.clone()), Some(&portable)),
|
||||
Some(root.join("logs"))
|
||||
);
|
||||
assert_eq!(
|
||||
log_dir_for(Some(root.clone()), None),
|
||||
Some(root.join("logs"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_filename_escapes_the_plugin_base_dir() {
|
||||
// The whole window-state redirect rests on this std behaviour: joining an
|
||||
// absolute path discards the base. tauri-plugin-window-state does
|
||||
// `app_config_dir().join(filename)`, so an absolute "filename" relocates
|
||||
// the file. If this ever stops holding, the redirect silently stops too.
|
||||
let base = PathBuf::from("/Users/someone/Library/Application Support/com.donutbrowser");
|
||||
let absolute = PathBuf::from("/Volumes/Stick/Donut/data").join(WINDOW_STATE_FILENAME);
|
||||
assert_eq!(base.join(&absolute), absolute);
|
||||
assert!(!base.join(&absolute).starts_with(&base));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_state_stays_at_the_platform_default_for_a_normal_install() {
|
||||
// A normal install must not be relocated: moving it would drop the window
|
||||
// geometry every existing user already has.
|
||||
if !state_is_relocated() {
|
||||
assert_eq!(window_state_path_override(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_state_follows_a_relocated_data_dir() {
|
||||
let tmp = PathBuf::from("/tmp/donut-relocated");
|
||||
let _guard = set_test_data_dir(tmp.clone());
|
||||
// data_dir is overridden, so the file tracks it rather than app_config_dir.
|
||||
assert_eq!(
|
||||
data_dir().join(WINDOW_STATE_FILENAME),
|
||||
tmp.join(".window-state.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_keeps_data_cache_and_logs_under_one_root() {
|
||||
// The three state directories must agree on where portable state lives, so
|
||||
// a portable install leaves nothing behind on the host.
|
||||
let portable = PathBuf::from("/tmp/donut-portable");
|
||||
assert_eq!(
|
||||
log_dir_for(None, Some(&portable)),
|
||||
Some(portable.join("logs"))
|
||||
);
|
||||
assert!(portable.join("data").starts_with(&portable));
|
||||
assert!(portable.join("cache").starts_with(&portable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1316,13 +1316,25 @@ impl BrowserRunner {
|
||||
// disk instead of the previous snapshot.
|
||||
crate::profile::password::complete_after_quit_and_wait(profile).await;
|
||||
} else if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
let id = profile.id.to_string();
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&id);
|
||||
// The per-domain traffic tracker writes to the cache dir on real disk
|
||||
// regardless of where the profile itself lives, so an "in memory only"
|
||||
// session still left a full record of everywhere it connected.
|
||||
crate::traffic_stats::delete_traffic_stats(&id);
|
||||
} else if profile.clear_on_close {
|
||||
// Awaited for the same reason as re-encryption above: a queued sync
|
||||
// must see the cleared dir, not the pre-clear snapshot.
|
||||
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
|
||||
}
|
||||
|
||||
// The browser held these open for the life of the process; nothing reads
|
||||
// them once it has exited, and they are plaintext extension code sitting
|
||||
// on real disk even for an ephemeral profile.
|
||||
crate::extension_manager::ExtensionManager::cleanup_unpacked_for_profile(
|
||||
&profile.id.to_string(),
|
||||
);
|
||||
|
||||
log::info!(
|
||||
"Wayfern process cleanup completed for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
|
||||
@@ -804,6 +804,13 @@ impl CloudAuthManager {
|
||||
/// Account is in a paid/active state. Used for the "any active plan" gates
|
||||
/// (sync token); per-feature access uses the capability helpers.
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
if crate::e2e_automation_enabled()
|
||||
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
self.entitlements().await.map(|e| e.active).unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -1138,6 +1145,11 @@ impl CloudAuthManager {
|
||||
/// is nothing to fetch and nothing wrong.
|
||||
pub async fn request_wayfern_token(&self) -> Result<(), String> {
|
||||
if !self.is_entitled_to_wayfern_token().await {
|
||||
// Ok(()) here means callers log nothing, so a session that declined to
|
||||
// mint left no trace at all and looked identical to one that succeeded.
|
||||
log::info!(
|
||||
"Skipping wayfern token request: the cached plan does not include browser automation"
|
||||
);
|
||||
self.clear_wayfern_token().await;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1273,9 +1285,11 @@ impl CloudAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh profile data periodically
|
||||
// Refresh profile data periodically. A failure here leaves the cached
|
||||
// plan stale, which silently gates paid features, so it belongs at warn
|
||||
// rather than debug where the shipped log level hides it.
|
||||
if let Err(e) = CLOUD_AUTH.fetch_profile().await {
|
||||
log::debug!("Failed to refresh cloud profile: {e}");
|
||||
log::warn!("Failed to refresh cloud profile: {e}");
|
||||
}
|
||||
|
||||
// Reconnect profile lock manager if needed
|
||||
@@ -1291,7 +1305,14 @@ impl CloudAuthManager {
|
||||
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop).
|
||||
// request_wayfern_token owns the entitlement check and clears the cached
|
||||
// token when the plan doesn't include automation.
|
||||
if wayfern_refresh_counter >= 60 {
|
||||
//
|
||||
// Also mint one as soon as the plan starts granting it. `fetch_profile`
|
||||
// above picks up an upgrade within ten minutes, but nothing watched that
|
||||
// transition, so a session that signed in before upgrading stayed
|
||||
// tokenless for up to ten hours while reporting the feature as unlocked.
|
||||
let missing_entitled_token = CLOUD_AUTH.is_entitled_to_wayfern_token().await
|
||||
&& CLOUD_AUTH.get_wayfern_token().await.is_none();
|
||||
if wayfern_refresh_counter >= 60 || missing_entitled_token {
|
||||
wayfern_refresh_counter = 0;
|
||||
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to refresh wayfern token: {e}");
|
||||
@@ -1411,6 +1432,20 @@ pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
||||
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
let mut user = CLOUD_AUTH.fetch_profile().await?;
|
||||
user.entitlements = Some(user.entitlements());
|
||||
|
||||
// Minting the token is what actually unlocks cross-OS fingerprints, and it
|
||||
// only happened at login, at startup and once every 10 hours. An account
|
||||
// that upgraded after its last sign-in therefore refreshed into the correct
|
||||
// entitlements while still holding no token, and "Refresh" did not fix it.
|
||||
// Only mint when one is genuinely missing, so this stays a no-op afterwards.
|
||||
if CLOUD_AUTH.is_entitled_to_wayfern_token().await
|
||||
&& CLOUD_AUTH.get_wayfern_token().await.is_none()
|
||||
{
|
||||
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Refresh could not obtain a wayfern token: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
|
||||
+273
-37
@@ -4,44 +4,110 @@ use std::sync::Mutex;
|
||||
|
||||
use crate::profile::BrowserProfile;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
|
||||
/// Whether an ephemeral directory is genuinely in memory, or was downgraded to
|
||||
/// real disk because RAM backing could not be obtained.
|
||||
///
|
||||
/// This has to be recorded when the directory is created, not guessed when it
|
||||
/// is destroyed: by teardown time the RAM disk may have been unmounted, and a
|
||||
/// path alone cannot say what it used to be. The erase path reads it to decide
|
||||
/// whether overwriting is meaningful or just page churn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EphemeralBacking {
|
||||
Ram,
|
||||
Disk,
|
||||
}
|
||||
|
||||
/// Get or create the RAM-backed base directory for ephemeral profiles.
|
||||
/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk RAM disk.
|
||||
fn get_ephemeral_base_dir() -> Result<PathBuf, String> {
|
||||
impl EphemeralBacking {
|
||||
/// Overwriting only means something when freed disk blocks are involved.
|
||||
fn needs_zeroing(self) -> bool {
|
||||
matches!(self, EphemeralBacking::Disk)
|
||||
}
|
||||
}
|
||||
|
||||
struct EphemeralEntry {
|
||||
path: PathBuf,
|
||||
backing: EphemeralBacking,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, EphemeralEntry>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Test-only redirect for the ephemeral base.
|
||||
///
|
||||
/// Without this the unit tests call the real resolver, which on macOS runs
|
||||
/// `hdiutil attach` + `diskutil erasevolume` and never detaches: a plain
|
||||
/// `cargo test` left a 256 MB RAM disk mounted on the developer's machine
|
||||
/// indefinitely. Deliberately compiled out of release builds, because an
|
||||
/// env-var redirect for a directory holding decrypted profile data is a
|
||||
/// capability nobody should be able to reach in a shipped binary.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
fn ephemeral_base_override() -> Option<PathBuf> {
|
||||
std::env::var_os("DONUTBROWSER_EPHEMERAL_ROOT")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(not(any(test, debug_assertions)))]
|
||||
fn ephemeral_base_override() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get or create the base directory for ephemeral profiles, and report whether
|
||||
/// it is actually RAM-backed.
|
||||
///
|
||||
/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk
|
||||
/// RAM disk, which is a third-party driver this app does not ship, so on most
|
||||
/// Windows machines the disk fallback is the normal path rather than an edge
|
||||
/// case. Callers must treat `Disk` as a downgrade and erase accordingly.
|
||||
fn get_ephemeral_base_dir() -> Result<(PathBuf, EphemeralBacking), String> {
|
||||
if let Some(base) = ephemeral_base_override() {
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create overridden ephemeral base: {e}"))?;
|
||||
return Ok((base, EphemeralBacking::Disk));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let base = PathBuf::from("/dev/shm/donut-ephemeral");
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create ephemeral base in /dev/shm: {e}"))?;
|
||||
Ok(base)
|
||||
Ok((base, EphemeralBacking::Ram))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let ramdisk_error: String;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(mount) = get_or_create_macos_ramdisk() {
|
||||
return Ok(mount);
|
||||
match get_or_create_macos_ramdisk() {
|
||||
Ok(mount) => return Ok((mount, EphemeralBacking::Ram)),
|
||||
Err(e) => ramdisk_error = e,
|
||||
}
|
||||
log::warn!("Failed to create macOS RAM disk, ephemeral profiles may use disk");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(mount) = get_or_create_windows_ramdisk() {
|
||||
return Ok(mount);
|
||||
match get_or_create_windows_ramdisk() {
|
||||
Ok(mount) => return Ok((mount, EphemeralBacking::Ram)),
|
||||
Err(e) => ramdisk_error = e,
|
||||
}
|
||||
log::warn!("Failed to create Windows RAM disk, ephemeral profiles may use disk");
|
||||
}
|
||||
|
||||
// Fallback
|
||||
// Downgraded to real disk. This is logged at error, with the cause and the
|
||||
// destination, because the profile no longer keeps the promise its name
|
||||
// makes and the previous "may use disk" wording was logged unconditionally
|
||||
// right before disk was used, so it read as speculative when it was
|
||||
// certain. The cause used to be discarded entirely.
|
||||
let base = std::env::temp_dir().join("donut-ephemeral");
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create ephemeral base dir: {e}"))?;
|
||||
Ok(base)
|
||||
log::error!(
|
||||
"No RAM disk available ({ramdisk_error}); ephemeral profiles are being written to disk at {} and will be securely erased on teardown instead",
|
||||
base.display()
|
||||
);
|
||||
Ok((base, EphemeralBacking::Disk))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +201,7 @@ fn get_or_create_windows_ramdisk() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
|
||||
let base = get_ephemeral_base_dir()?;
|
||||
let (base, backing) = get_ephemeral_base_dir()?;
|
||||
let dir_path = base.join(profile_id);
|
||||
|
||||
std::fs::create_dir_all(&dir_path).map_err(|e| format!("Failed to create ephemeral dir: {e}"))?;
|
||||
@@ -143,10 +209,23 @@ pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
|
||||
EPHEMERAL_DIRS
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock ephemeral dirs: {e}"))?
|
||||
.insert(profile_id.to_string(), dir_path.clone());
|
||||
.insert(
|
||||
profile_id.to_string(),
|
||||
EphemeralEntry {
|
||||
path: dir_path.clone(),
|
||||
backing,
|
||||
},
|
||||
);
|
||||
|
||||
// State the backing on every launch. Previously only the failure path said
|
||||
// anything, so a log could not be used to tell a RAM-backed session from a
|
||||
// disk-backed one after the fact.
|
||||
log::info!(
|
||||
"Created ephemeral dir for profile {}: {}",
|
||||
"Created {} ephemeral dir for profile {}: {}",
|
||||
match backing {
|
||||
EphemeralBacking::Ram => "RAM-backed",
|
||||
EphemeralBacking::Disk => "DISK-backed (not in memory)",
|
||||
},
|
||||
profile_id,
|
||||
dir_path.display()
|
||||
);
|
||||
@@ -155,26 +234,67 @@ pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
pub fn get_ephemeral_dir(profile_id: &str) -> Option<PathBuf> {
|
||||
EPHEMERAL_DIRS.lock().ok()?.get(profile_id).cloned()
|
||||
Some(EPHEMERAL_DIRS.lock().ok()?.get(profile_id)?.path.clone())
|
||||
}
|
||||
|
||||
pub fn remove_ephemeral_dir(profile_id: &str) {
|
||||
let dir = EPHEMERAL_DIRS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut map| map.remove(profile_id));
|
||||
/// Destroy a profile's ephemeral directory, zeroing it first when it is on real
|
||||
/// disk.
|
||||
///
|
||||
/// Returns false when data was knowingly left behind. The mapping is only
|
||||
/// dropped on success: removing it first (as this used to) meant a failure,
|
||||
/// which on Windows is as ordinary as a file still being locked by an exiting
|
||||
/// browser, discarded the only handle to the directory and guaranteed nothing
|
||||
/// would ever retry it.
|
||||
pub fn remove_ephemeral_dir(profile_id: &str) -> bool {
|
||||
let entry = match EPHEMERAL_DIRS.lock() {
|
||||
Ok(map) => map.get(profile_id).map(|e| (e.path.clone(), e.backing)),
|
||||
Err(e) => {
|
||||
log::error!("Failed to lock ephemeral dirs while removing {profile_id}: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(dir_path) = dir {
|
||||
if dir_path.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&dir_path) {
|
||||
log::warn!("Failed to remove ephemeral dir {}: {e}", dir_path.display());
|
||||
} else {
|
||||
log::info!(
|
||||
"Removed ephemeral dir for profile {}: {}",
|
||||
profile_id,
|
||||
dir_path.display()
|
||||
);
|
||||
let Some((dir_path, backing)) = entry else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if !dir_path.exists() {
|
||||
if let Ok(mut map) = EPHEMERAL_DIRS.lock() {
|
||||
map.remove(profile_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let zero = backing.needs_zeroing();
|
||||
let started = std::time::Instant::now();
|
||||
match crate::fs_secure::secure_remove_dir_all(&dir_path, zero) {
|
||||
Ok(files) => {
|
||||
if let Ok(mut map) = EPHEMERAL_DIRS.lock() {
|
||||
map.remove(profile_id);
|
||||
}
|
||||
log::info!(
|
||||
"Removed {} ephemeral dir for profile {} ({} files, {} ms): {}",
|
||||
if zero {
|
||||
"and zeroed disk-backed"
|
||||
} else {
|
||||
"RAM-backed"
|
||||
},
|
||||
profile_id,
|
||||
files,
|
||||
started.elapsed().as_millis(),
|
||||
dir_path.display()
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// Error, not warn: this is the case where the user's browsing data is
|
||||
// knowingly still on the machine. The mapping is kept so a later sweep
|
||||
// can try again.
|
||||
log::error!(
|
||||
"Failed to remove ephemeral dir {} for profile {profile_id}: {e}. Profile data is still on disk.",
|
||||
dir_path.display()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,7 +305,7 @@ pub fn remove_ephemeral_dir(profile_id: &str) {
|
||||
pub fn recover_ephemeral_dirs() {
|
||||
cleanup_legacy_dirs();
|
||||
|
||||
let base = match get_ephemeral_base_dir() {
|
||||
let (base, backing) = match get_ephemeral_base_dir() {
|
||||
Ok(base) => base,
|
||||
Err(e) => {
|
||||
log::warn!("Cannot recover ephemeral dirs: {e}");
|
||||
@@ -193,6 +313,12 @@ pub fn recover_ephemeral_dirs() {
|
||||
}
|
||||
};
|
||||
|
||||
// Sweep the disk fallback even when this run resolved to a RAM disk. A
|
||||
// previous run that fell back left a full profile tree in the temp dir, and
|
||||
// once RAM backing works again the base points elsewhere and that residue
|
||||
// would never be looked at again.
|
||||
sweep_disk_fallback_residue(&base);
|
||||
|
||||
let entries = match std::fs::read_dir(&base) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
@@ -207,7 +333,15 @@ pub fn recover_ephemeral_dirs() {
|
||||
if entry.path().is_dir() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if uuid::Uuid::parse_str(name).is_ok() {
|
||||
dirs.insert(name.to_string(), entry.path());
|
||||
dirs.insert(
|
||||
name.to_string(),
|
||||
EphemeralEntry {
|
||||
path: entry.path(),
|
||||
// Judge a recovered directory by the base it was found under, not
|
||||
// by what some earlier run happened to resolve.
|
||||
backing,
|
||||
},
|
||||
);
|
||||
log::info!("Recovered ephemeral dir for profile {}", name);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +349,28 @@ pub fn recover_ephemeral_dirs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Securely erase leftovers from a run that was downgraded to the disk
|
||||
/// fallback, unless that fallback is the base being used right now (in which
|
||||
/// case `recover_ephemeral_dirs` is about to adopt them instead).
|
||||
fn sweep_disk_fallback_residue(current_base: &Path) {
|
||||
let fallback = std::env::temp_dir().join("donut-ephemeral");
|
||||
if !fallback.exists() || fallback == current_base {
|
||||
return;
|
||||
}
|
||||
|
||||
match crate::fs_secure::secure_remove_dir_all(&fallback, true) {
|
||||
Ok(files) if files > 0 => log::info!(
|
||||
"Securely erased {files} file(s) of disk-backed ephemeral residue at {}",
|
||||
fallback.display()
|
||||
),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::error!(
|
||||
"Failed to erase disk-backed ephemeral residue at {}: {e}",
|
||||
fallback.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove old-format ephemeral dirs from /tmp (pre-tmpfs migration).
|
||||
fn cleanup_legacy_dirs() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
@@ -225,8 +381,10 @@ fn cleanup_legacy_dirs() {
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
// These are always in the system temp dir by construction, so they are
|
||||
// always on real disk and always worth zeroing.
|
||||
if name.starts_with("donut-ephemeral-") && entry.path().is_dir() {
|
||||
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
|
||||
if let Err(e) = crate::fs_secure::secure_remove_dir_all(&entry.path(), true) {
|
||||
log::warn!("Failed to clean up legacy ephemeral dir: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
@@ -286,9 +444,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the ephemeral base at a scratch directory for the duration of a
|
||||
/// test. Without this the tests call the real resolver, which on macOS
|
||||
/// attaches a 256 MB RAM disk that nothing ever detaches, so running
|
||||
/// `cargo test` left one mounted on the developer's machine indefinitely.
|
||||
struct BaseGuard(tempfile::TempDir);
|
||||
|
||||
impl BaseGuard {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", tmp.path());
|
||||
BaseGuard(tmp)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BaseGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_ephemeral_dir_lifecycle() {
|
||||
let _base = BaseGuard::new();
|
||||
// Clear global state to avoid interference from other tests
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
|
||||
@@ -321,7 +504,8 @@ mod tests {
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_recover_ephemeral_dirs() {
|
||||
let base = get_ephemeral_base_dir().unwrap();
|
||||
let _base = BaseGuard::new();
|
||||
let (base, _) = get_ephemeral_base_dir().unwrap();
|
||||
let test_id = uuid::Uuid::new_v4().to_string();
|
||||
let test_dir = base.join(&test_id);
|
||||
std::fs::create_dir_all(&test_dir).unwrap();
|
||||
@@ -336,4 +520,56 @@ mod tests {
|
||||
// Clean up
|
||||
remove_ephemeral_dir(&test_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn disk_backed_dirs_are_zeroed_and_ram_backed_ones_are_not() {
|
||||
let base = BaseGuard::new();
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
|
||||
// The override always reports Disk, which is the fail-safe: an unverified
|
||||
// base must be treated as if it were on a platter.
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let dir = create_ephemeral_dir(&id).unwrap();
|
||||
// Proves the override actually took effect, so this test can never be
|
||||
// silently exercising the developer's real RAM disk.
|
||||
assert!(dir.starts_with(base.path()));
|
||||
assert_eq!(
|
||||
EPHEMERAL_DIRS.lock().unwrap().get(&id).map(|e| e.backing),
|
||||
Some(EphemeralBacking::Disk)
|
||||
);
|
||||
assert!(EphemeralBacking::Disk.needs_zeroing());
|
||||
assert!(!EphemeralBacking::Ram.needs_zeroing());
|
||||
|
||||
std::fs::write(dir.join("Cookies"), b"session=secret").unwrap();
|
||||
assert!(remove_ephemeral_dir(&id));
|
||||
assert!(!dir.exists());
|
||||
assert!(get_ephemeral_dir(&id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn removing_an_unknown_profile_succeeds_without_doing_anything() {
|
||||
let _base = BaseGuard::new();
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
assert!(remove_ephemeral_dir(&uuid::Uuid::new_v4().to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn the_mapping_survives_a_failed_removal_so_it_can_be_retried() {
|
||||
// The old code popped the entry before attempting the delete, so a failure
|
||||
// (a locked file on Windows, say) threw away the only handle to the
|
||||
// directory and nothing could ever retry it.
|
||||
let _base = BaseGuard::new();
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let dir = create_ephemeral_dir(&id).unwrap();
|
||||
assert!(dir.exists());
|
||||
|
||||
// A successful removal is the one that clears the mapping.
|
||||
assert!(remove_ephemeral_dir(&id));
|
||||
assert!(get_ephemeral_dir(&id).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+1134
-204
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
//! Best-effort secure deletion.
|
||||
//!
|
||||
//! "Best-effort" is load-bearing and is not a hedge. On copy-on-write
|
||||
//! filesystems (APFS, Btrfs, ZFS, ReFS) and on any SSD with wear levelling, the
|
||||
//! blocks holding the old contents may survive an overwrite entirely, because
|
||||
//! the write lands somewhere else. RAM-backed storage can also be paged out,
|
||||
//! and zeroing a file cannot reach the swap slot that held it. Treat these
|
||||
//! helpers as raising the cost of recovery, never as a guarantee of erasure.
|
||||
//!
|
||||
//! The only reliable erasure this codebase has is not writing plaintext to disk
|
||||
//! in the first place, which is what the RAM-backed ephemeral directories are
|
||||
//! for. These helpers exist for the paths where that failed.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// Zero a file's bytes and flush before unlinking, so the contents are not
|
||||
/// trivially recoverable from the freed blocks.
|
||||
///
|
||||
/// The overwrite must never gate the unlink. A write that fails part-way
|
||||
/// (ENOSPC on a copy-on-write volume, EIO) would otherwise leave the file both
|
||||
/// un-wiped and un-deleted, which is strictly worse than the plain remove this
|
||||
/// replaces, because callers report success either way and the data would
|
||||
/// silently survive.
|
||||
pub fn secure_remove_file(path: &Path) -> std::io::Result<()> {
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let len = meta.len();
|
||||
if len > 0 {
|
||||
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
|
||||
let zeros = vec![0u8; 64 * 1024];
|
||||
let mut remaining = len;
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(zeros.len() as u64) as usize;
|
||||
if f.write_all(&zeros[..chunk]).is_err() {
|
||||
break;
|
||||
}
|
||||
remaining -= chunk as u64;
|
||||
}
|
||||
// One flush per file, not per chunk: syncing every 64 KiB turns a
|
||||
// profile teardown into thousands of barriers for no extra safety.
|
||||
let _ = f.flush();
|
||||
let _ = f.sync_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
|
||||
/// Whether zeroing this file would even mean anything.
|
||||
///
|
||||
/// A file with more than one hard link is still reachable through the other
|
||||
/// link, so overwriting it destroys live data somewhere else and erases
|
||||
/// nothing here.
|
||||
#[cfg(unix)]
|
||||
fn is_last_link(meta: &fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
meta.nlink() <= 1
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_last_link(_meta: &fs::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Recursively delete a directory, optionally zeroing regular files first.
|
||||
///
|
||||
/// `zero` should be false for RAM-backed storage (tmpfs, a real RAM disk):
|
||||
/// there are no freed disk blocks to scrub, so overwriting is pure page churn
|
||||
/// and on a small fixed-size volume can hit ENOSPC. Pass true only when the
|
||||
/// tree is genuinely on disk.
|
||||
///
|
||||
/// Symlinks are unlinked, never followed and never zeroed: following one would
|
||||
/// destroy a target outside the tree.
|
||||
///
|
||||
/// Returns the number of files removed. The tree is removed even when
|
||||
/// individual steps fail, because leaving a half-wiped directory in place is
|
||||
/// the worst outcome available.
|
||||
pub fn secure_remove_dir_all(root: &Path, zero: bool) -> std::io::Result<u64> {
|
||||
let mut removed = 0u64;
|
||||
if !root.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
remove_tree(root, zero, &mut removed);
|
||||
|
||||
// Unconditional backstop: a walk that failed part-way must still not leave
|
||||
// the directory behind.
|
||||
match fs::remove_dir_all(root) {
|
||||
Ok(()) => Ok(removed),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(removed),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_tree(dir: &Path, zero: bool, removed: &mut u64) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
log::warn!("Secure erase could not read {}: {e}", dir.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// file_type() on the DirEntry is lstat-based, so a symlink reports as a
|
||||
// symlink rather than as whatever it points at.
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if file_type.is_symlink() {
|
||||
let _ = fs::remove_file(&path);
|
||||
*removed += 1;
|
||||
} else if file_type.is_dir() {
|
||||
remove_tree(&path, zero, removed);
|
||||
let _ = fs::remove_dir(&path);
|
||||
} else {
|
||||
let should_zero = zero
|
||||
&& fs::symlink_metadata(&path)
|
||||
.map(|m| is_last_link(&m))
|
||||
.unwrap_or(false);
|
||||
let outcome = if should_zero {
|
||||
secure_remove_file(&path)
|
||||
} else {
|
||||
fs::remove_file(&path)
|
||||
};
|
||||
if outcome.is_ok() {
|
||||
*removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zeroing_erase_removes_a_nested_tree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let nested = tmp.path().join("Default/Network");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
let cookies = nested.join("Cookies");
|
||||
fs::write(&cookies, b"session=supersecretvalue").unwrap();
|
||||
|
||||
let removed = secure_remove_dir_all(tmp.path(), true).unwrap();
|
||||
assert!(
|
||||
removed >= 1,
|
||||
"expected at least the cookie file to be counted"
|
||||
);
|
||||
assert!(!tmp.path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erase_without_zeroing_still_removes_everything() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("a"), b"x").unwrap();
|
||||
fs::create_dir_all(tmp.path().join("d")).unwrap();
|
||||
fs::write(tmp.path().join("d/b"), b"y").unwrap();
|
||||
|
||||
secure_remove_dir_all(tmp.path(), false).unwrap();
|
||||
assert!(!tmp.path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_root_is_not_an_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let absent = tmp.path().join("never-existed");
|
||||
assert_eq!(secure_remove_dir_all(&absent, true).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_is_unlinked_without_touching_its_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
// The target lives OUTSIDE the tree being erased. Following the link would
|
||||
// destroy a user's real file, which is the failure this guards against.
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let target = outside.path().join("precious");
|
||||
fs::write(&target, b"must survive intact").unwrap();
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
symlink(&target, tmp.path().join("link")).unwrap();
|
||||
|
||||
secure_remove_dir_all(tmp.path(), true).unwrap();
|
||||
|
||||
assert!(!tmp.path().exists());
|
||||
assert_eq!(fs::read(&target).unwrap(), b"must survive intact");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_second_hard_link_is_not_zeroed_through() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let inside = tmp.path().join("shared");
|
||||
fs::write(&inside, b"still referenced elsewhere").unwrap();
|
||||
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let other = outside.path().join("other-name");
|
||||
fs::hard_link(&inside, &other).unwrap();
|
||||
|
||||
secure_remove_dir_all(tmp.path(), true).unwrap();
|
||||
|
||||
// The link inside the tree is gone, and the surviving link still holds the
|
||||
// original bytes rather than a run of zeros.
|
||||
assert!(!tmp.path().exists());
|
||||
assert_eq!(fs::read(&other).unwrap(), b"still referenced elsewhere");
|
||||
}
|
||||
}
|
||||
+34
-14
@@ -70,6 +70,7 @@ mod ephemeral_dirs;
|
||||
mod extension_manager;
|
||||
mod extraction;
|
||||
mod fingerprint_consistency;
|
||||
mod fs_secure;
|
||||
mod geoip_downloader;
|
||||
mod geolocation;
|
||||
mod group_manager;
|
||||
@@ -187,9 +188,10 @@ use profile_importer::{
|
||||
};
|
||||
|
||||
use extension_manager::{
|
||||
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
|
||||
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
|
||||
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
|
||||
add_extension, add_extension_to_group, add_unpacked_extension, assign_extension_group_to_profile,
|
||||
create_extension_group, delete_extension, delete_extension_group,
|
||||
get_extension_group_for_profile, get_extension_icon, list_extension_groups, list_extensions,
|
||||
remove_extension_from_group, update_extension, update_extension_from_path,
|
||||
update_extension_group,
|
||||
};
|
||||
|
||||
@@ -1715,8 +1717,9 @@ pub fn run_with_builder(
|
||||
|
||||
let log_file_name = app_dirs::app_name();
|
||||
|
||||
// Honor DONUTBROWSER_DATA_ROOT: when set, logs go to <root>/logs instead of
|
||||
// the platform default app log dir, so all on-disk state lives under one root.
|
||||
// Honor DONUTBROWSER_DATA_ROOT and portable mode: logs go to <root>/logs or
|
||||
// <exe dir>/logs instead of the platform default app log dir, so all on-disk
|
||||
// state lives under one root rather than leaking onto the host machine.
|
||||
let file_log_target = match app_dirs::log_dir_override() {
|
||||
Some(path) => Target::new(TargetKind::Folder {
|
||||
path,
|
||||
@@ -1789,9 +1792,23 @@ pub fn run_with_builder(
|
||||
// (the green button zooms instead) — the maximized flag captures the
|
||||
// "filled screen" state, including green-button zoom on macOS.
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
{
|
||||
let mut window_state = tauri_plugin_window_state::Builder::default();
|
||||
// Keep window geometry with the rest of the relocated state instead of
|
||||
// the host's app-config dir. The plugin only lets us name the file, so
|
||||
// the name is an absolute path; see `window_state_path_override`.
|
||||
if let Some(path) = app_dirs::window_state_path_override() {
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Failed to create the window-state directory: {e}");
|
||||
}
|
||||
}
|
||||
window_state = window_state.with_filename(path.to_string_lossy().into_owned());
|
||||
}
|
||||
window_state
|
||||
}
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN
|
||||
// Whether the window is decorated is decided per-session by
|
||||
@@ -1799,8 +1816,8 @@ pub fn run_with_builder(
|
||||
// a previous run saved. Restoring it would put a real titlebar back
|
||||
// on top of the one the app draws — or strip both.
|
||||
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
|
||||
builder.setup(|app| {
|
||||
@@ -1907,10 +1924,11 @@ pub fn run_with_builder(
|
||||
// saved, that geometry is the user's and has already been restored —
|
||||
// re-applying the default here would move and resize their window on
|
||||
// every launch, and the plugin would then persist the reset.
|
||||
let has_saved_geometry = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map(|dir| dir.join(".window-state.json").exists())
|
||||
// Must resolve through the same helper the plugin was configured with:
|
||||
// probing the platform default while the plugin writes elsewhere would
|
||||
// read "first run" on every launch and reset the user's window.
|
||||
let has_saved_geometry = app_dirs::window_state_path(app.handle())
|
||||
.map(|path| path.exists())
|
||||
.unwrap_or(false);
|
||||
if window_decorations::use_client_side_decorations() && !has_saved_geometry {
|
||||
if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) {
|
||||
@@ -2733,7 +2751,9 @@ pub fn run_with_builder(
|
||||
list_extensions,
|
||||
get_extension_icon,
|
||||
add_extension,
|
||||
add_unpacked_extension,
|
||||
update_extension,
|
||||
update_extension_from_path,
|
||||
delete_extension,
|
||||
list_extension_groups,
|
||||
create_extension_group,
|
||||
|
||||
+262
-2
@@ -1307,6 +1307,33 @@ impl McpServer {
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "add_extension".to_string(),
|
||||
description: "Add a managed browser extension from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so edits to it apply on the next browser start and the extension is machine-local and never synced. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "Path on the machine running Donut to a .crx/.zip file or to an unpacked extension folder" },
|
||||
"name": { "type": "string", "description": "Display name, used only when the manifest carries no name of its own" },
|
||||
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_extension".to_string(),
|
||||
description: "Rename a managed extension and/or replace its payload from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so the extension becomes machine-local and never syncs. At least one of name or path must be given. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"extension_id": { "type": "string", "description": "The extension ID to update" },
|
||||
"name": { "type": "string", "description": "New display name" },
|
||||
"path": { "type": "string", "description": "Path on the machine running Donut to the .crx/.zip file or unpacked extension folder to replace the payload with" },
|
||||
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
|
||||
},
|
||||
"required": ["extension_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "create_extension_group".to_string(),
|
||||
description: "Create a new extension group. Requires Pro subscription.".to_string(),
|
||||
@@ -1318,6 +1345,47 @@ impl McpServer {
|
||||
"required": ["name"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_extension_group".to_string(),
|
||||
description: "Rename an extension group and/or replace its membership with an exact list of extension IDs. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "The extension group ID to update" },
|
||||
"name": { "type": "string", "description": "New name for the extension group" },
|
||||
"extension_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "The complete set of extension IDs the group should contain, replacing the current membership"
|
||||
}
|
||||
},
|
||||
"required": ["group_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "add_extension_to_group".to_string(),
|
||||
description: "Add an extension to an extension group. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "The extension group ID" },
|
||||
"extension_id": { "type": "string", "description": "The extension ID to add to the group" }
|
||||
},
|
||||
"required": ["group_id", "extension_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "remove_extension_from_group".to_string(),
|
||||
description: "Remove an extension from an extension group. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "The extension group ID" },
|
||||
"extension_id": { "type": "string", "description": "The extension ID to remove from the group" }
|
||||
},
|
||||
"required": ["group_id", "extension_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "delete_extension".to_string(),
|
||||
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
|
||||
@@ -2215,7 +2283,12 @@ impl McpServer {
|
||||
// Extension management
|
||||
"list_extensions" => self.handle_list_extensions().await,
|
||||
"list_extension_groups" => self.handle_list_extension_groups().await,
|
||||
"add_extension" => self.handle_add_extension(arguments).await,
|
||||
"update_extension" => self.handle_update_extension(arguments).await,
|
||||
"create_extension_group" => self.handle_create_extension_group(arguments).await,
|
||||
"update_extension_group" => self.handle_update_extension_group(arguments).await,
|
||||
"add_extension_to_group" => self.handle_add_extension_to_group(arguments).await,
|
||||
"remove_extension_from_group" => self.handle_remove_extension_from_group(arguments).await,
|
||||
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
|
||||
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
|
||||
"assign_extension_group_to_profile" => {
|
||||
@@ -4402,6 +4475,88 @@ impl McpServer {
|
||||
Ok(serde_json::to_value(groups).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_add_extension(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let path = arguments
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: path".to_string(),
|
||||
})?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let link = arguments
|
||||
.get("link")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let extension = mgr
|
||||
.add_extension_from_path(name, std::path::Path::new(path), link)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to add extension: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(extension).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_update_extension(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let extension_id = arguments
|
||||
.get("extension_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: extension_id".to_string(),
|
||||
})?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let path = arguments.get("path").and_then(|v| v.as_str());
|
||||
if name.is_none() && path.is_none() {
|
||||
return Err(McpError {
|
||||
code: -32602,
|
||||
message: "Provide at least one of: name, path".to_string(),
|
||||
});
|
||||
}
|
||||
let link = arguments
|
||||
.get("link")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let extension = match path {
|
||||
Some(path) => {
|
||||
mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link)
|
||||
}
|
||||
None => mgr.update_extension(extension_id, name, None, None),
|
||||
}
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update extension: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(extension).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_create_extension_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
@@ -4427,6 +4582,106 @@ impl McpServer {
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_update_extension_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let group_id = arguments
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: group_id".to_string(),
|
||||
})?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let extension_ids = arguments
|
||||
.get("extension_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|ids| {
|
||||
ids
|
||||
.iter()
|
||||
.filter_map(|id| id.as_str().map(str::to_string))
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let group = mgr
|
||||
.update_group(group_id, name, extension_ids)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update extension group: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_add_extension_to_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let group = mgr
|
||||
.add_extension_to_group(group_id, extension_id)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to add extension to group: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_remove_extension_from_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let group = mgr
|
||||
.remove_extension_from_group(group_id, extension_id)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to remove extension from group: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
fn group_and_extension_ids(arguments: &serde_json::Value) -> Result<(&str, &str), McpError> {
|
||||
let group_id = arguments
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: group_id".to_string(),
|
||||
})?;
|
||||
let extension_id = arguments
|
||||
.get("extension_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: extension_id".to_string(),
|
||||
})?;
|
||||
Ok((group_id, extension_id))
|
||||
}
|
||||
|
||||
async fn handle_delete_extension_mcp(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
@@ -6035,9 +6290,9 @@ mod tests {
|
||||
let server = McpServer::new();
|
||||
let tools = server.get_tools();
|
||||
|
||||
// Should have at least 54 tools (34 + 7 browser interaction + 13 remote
|
||||
// Should have at least 59 tools (39 + 7 browser interaction + 13 remote
|
||||
// fleet and cookie-bot tools)
|
||||
assert!(tools.len() >= 54);
|
||||
assert!(tools.len() >= 59);
|
||||
|
||||
// Names are the contract an MCP client is written against, so a duplicate
|
||||
// silently shadows one of the two in dispatch and the tool that loses is
|
||||
@@ -6092,7 +6347,12 @@ mod tests {
|
||||
// Extension tools
|
||||
assert!(tool_names.contains(&"list_extensions"));
|
||||
assert!(tool_names.contains(&"list_extension_groups"));
|
||||
assert!(tool_names.contains(&"add_extension"));
|
||||
assert!(tool_names.contains(&"update_extension"));
|
||||
assert!(tool_names.contains(&"create_extension_group"));
|
||||
assert!(tool_names.contains(&"update_extension_group"));
|
||||
assert!(tool_names.contains(&"add_extension_to_group"));
|
||||
assert!(tool_names.contains(&"remove_extension_from_group"));
|
||||
assert!(tool_names.contains(&"delete_extension"));
|
||||
assert!(tool_names.contains(&"delete_extension_group"));
|
||||
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::browser::{create_browser, BrowserType};
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::events;
|
||||
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
|
||||
use crate::profile::types::{get_host_os, is_host_os, BrowserProfile, SyncMode};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::WayfernConfig;
|
||||
use std::fs::{self, create_dir_all};
|
||||
@@ -384,11 +384,23 @@ impl ProfileManager {
|
||||
};
|
||||
|
||||
// Backfill host_os from browser config for profiles created before
|
||||
// the field existed (or synced without it).
|
||||
if profile.host_os.is_none() {
|
||||
let inferred_os = profile.resolved_os().map(str::to_string);
|
||||
if let Some(os) = inferred_os {
|
||||
profile.host_os = Some(os);
|
||||
// the field existed (or synced without it), and repair any profile
|
||||
// already stamped with a fingerprint-only OS.
|
||||
//
|
||||
// Only a real host OS may be stored here. The fallback in
|
||||
// `resolved_os` reads `wayfern_config.os`, which is a fingerprint OS
|
||||
// and may be "android"/"ios". Persisting that made `is_cross_os`
|
||||
// permanently true and locked the profile out of every local launch,
|
||||
// with no way to undo it from the UI. Leaving `host_os` as None keeps
|
||||
// the profile launchable, which is what it was before the field.
|
||||
let needs_repair = profile.host_os.as_deref().is_some_and(|os| !is_host_os(os));
|
||||
if profile.host_os.is_none() || needs_repair {
|
||||
let inferred_os = profile
|
||||
.resolved_os()
|
||||
.filter(|os| is_host_os(os))
|
||||
.map(str::to_string);
|
||||
if inferred_os != profile.host_os {
|
||||
profile.host_os = inferred_os;
|
||||
if let Ok(json) = serde_json::to_string_pretty(&profile) {
|
||||
let _ = atomic_write(&metadata_file, json.as_bytes());
|
||||
}
|
||||
@@ -483,6 +495,18 @@ impl ProfileManager {
|
||||
// so nothing else would ever clean them up.
|
||||
crate::launch_gate_prefs::forget_profile(profile_id);
|
||||
|
||||
// Deleting the profile never touched its ephemeral directory, so a
|
||||
// decrypted or in-memory copy outlived the profile it belonged to with
|
||||
// nothing left that knew to reap it. The running-browser guard above only
|
||||
// rejects a live process_id, and the keep-decrypted path deliberately
|
||||
// clears process_id while leaving the plaintext tree populated. No-ops
|
||||
// when the profile has no ephemeral directory.
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
|
||||
|
||||
// Per-domain traffic history lives outside the profile directory, so it
|
||||
// survives the delete otherwise. It is already zero-overwritten on removal.
|
||||
crate::traffic_stats::delete_traffic_stats(profile_id);
|
||||
|
||||
// Remember sync mode before deleting local files
|
||||
let was_sync_enabled = profile.is_sync_enabled();
|
||||
|
||||
@@ -1546,7 +1570,11 @@ impl ProfileManager {
|
||||
None => {
|
||||
// No running instance found, clear process ID if set
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
let id = profile.id.to_string();
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&id);
|
||||
// Destination history is kept outside the profile dir, so erasing
|
||||
// the profile alone still left the session's domains on disk.
|
||||
crate::traffic_stats::delete_traffic_stats(&id);
|
||||
}
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
@@ -1924,7 +1952,7 @@ pub async fn create_browser_profile_new(
|
||||
.is_fingerprint_os_allowed(fingerprint_os)
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
}
|
||||
|
||||
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
|
||||
@@ -1968,7 +1996,7 @@ pub async fn update_wayfern_config(
|
||||
.is_fingerprint_os_allowed(config.os.as_deref())
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
@@ -103,6 +103,16 @@ pub fn get_host_os() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a value is one `get_host_os` can actually return.
|
||||
///
|
||||
/// A fingerprint OS is a wider set than a host OS: `"android"` and `"ios"` are
|
||||
/// valid fingerprints but no machine ever reports them as its host. Storing one
|
||||
/// in `host_os` makes `is_cross_os` permanently true, which bars the profile
|
||||
/// from every local launch path on the very machine that created it.
|
||||
pub fn is_host_os(value: &str) -> bool {
|
||||
matches!(value, "macos" | "windows" | "linux")
|
||||
}
|
||||
|
||||
impl BrowserProfile {
|
||||
/// Get the path to the profile data directory (profiles/{uuid}/profile)
|
||||
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
|
||||
@@ -138,3 +148,28 @@ impl BrowserProfile {
|
||||
self.sync_mode == SyncMode::Encrypted
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_os_is_always_a_valid_host_os() {
|
||||
// The invariant the host_os backfill guard rests on: whatever this machine
|
||||
// reports must satisfy is_host_os, on every platform.
|
||||
assert!(is_host_os(&get_host_os()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_fingerprint_targets_are_not_host_operating_systems() {
|
||||
// Backfilling host_os from a fingerprint OS used to store these, and since
|
||||
// get_host_os can never return them, is_cross_os stayed true forever and
|
||||
// the profile could not be launched on the machine that created it.
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
assert!(is_host_os(os), "{os} must count as a host OS");
|
||||
}
|
||||
for os in ["android", "ios", "", "Windows", "chromeos"] {
|
||||
assert!(!is_host_os(os), "{os} must not be stored as a host OS");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+106
-47
@@ -1375,53 +1375,7 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
// 4 parts: could be host:port:user:pass OR user:pass:host:port
|
||||
4 => {
|
||||
// Try to detect which format
|
||||
let port_at_1 = parts[1].parse::<u16>().is_ok();
|
||||
let port_at_3 = parts[3].parse::<u16>().is_ok();
|
||||
|
||||
match (port_at_1, port_at_3) {
|
||||
// host:port:user:pass
|
||||
(true, false) => {
|
||||
let port = parts[1].parse::<u16>().unwrap();
|
||||
ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: "http".to_string(),
|
||||
host: parts[0].to_string(),
|
||||
port,
|
||||
username: Some(parts[2].to_string()),
|
||||
password: Some(parts[3].to_string()),
|
||||
vless_uri: None,
|
||||
original_line: line.to_string(),
|
||||
})
|
||||
}
|
||||
// user:pass:host:port
|
||||
(false, true) => {
|
||||
let port = parts[3].parse::<u16>().unwrap();
|
||||
ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: "http".to_string(),
|
||||
host: parts[2].to_string(),
|
||||
port,
|
||||
username: Some(parts[0].to_string()),
|
||||
password: Some(parts[1].to_string()),
|
||||
vless_uri: None,
|
||||
original_line: line.to_string(),
|
||||
})
|
||||
}
|
||||
// Both could be ports - ambiguous
|
||||
(true, true) => ProxyParseResult::Ambiguous {
|
||||
line: line.to_string(),
|
||||
possible_formats: vec![
|
||||
"host:port:username:password".to_string(),
|
||||
"username:password:host:port".to_string(),
|
||||
],
|
||||
},
|
||||
// Neither is a valid port
|
||||
(false, false) => ProxyParseResult::Invalid {
|
||||
line: line.to_string(),
|
||||
reason: "No valid port number found".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
4 => Self::parse_colon_separated_quad(&parts, "http", line),
|
||||
_ => ProxyParseResult::Invalid {
|
||||
line: line.to_string(),
|
||||
reason: format!("Unexpected format with {} parts", parts.len()),
|
||||
@@ -1429,6 +1383,51 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a four-part colon-separated body, which is either
|
||||
// host:port:username:password or username:password:host:port. The port
|
||||
// position tells the two apart; when both positions parse as a port the
|
||||
// caller has to ask the user.
|
||||
fn parse_colon_separated_quad(parts: &[&str], proxy_type: &str, line: &str) -> ProxyParseResult {
|
||||
let port_at_1 = parts[1].parse::<u16>().ok();
|
||||
let port_at_3 = parts[3].parse::<u16>().ok();
|
||||
|
||||
match (port_at_1, port_at_3) {
|
||||
// host:port:user:pass
|
||||
(Some(port), None) => ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: parts[0].to_string(),
|
||||
port,
|
||||
username: Some(parts[2].to_string()),
|
||||
password: Some(parts[3].to_string()),
|
||||
vless_uri: None,
|
||||
original_line: line.to_string(),
|
||||
}),
|
||||
// user:pass:host:port
|
||||
(None, Some(port)) => ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: parts[2].to_string(),
|
||||
port,
|
||||
username: Some(parts[0].to_string()),
|
||||
password: Some(parts[1].to_string()),
|
||||
vless_uri: None,
|
||||
original_line: line.to_string(),
|
||||
}),
|
||||
// Both could be ports - ambiguous
|
||||
(Some(_), Some(_)) => ProxyParseResult::Ambiguous {
|
||||
line: line.to_string(),
|
||||
possible_formats: vec![
|
||||
"host:port:username:password".to_string(),
|
||||
"username:password:host:port".to_string(),
|
||||
],
|
||||
},
|
||||
// Neither is a valid port
|
||||
(None, None) => ProxyParseResult::Invalid {
|
||||
line: line.to_string(),
|
||||
reason: "No valid port number found".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse URL format: protocol://username:password@host:port
|
||||
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
|
||||
if line.starts_with("vless://") {
|
||||
@@ -1500,6 +1499,15 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Vendors also hand out the colon-separated body behind a scheme, as in
|
||||
// socks5://host:port:user:pass, so try that before plain host:port. An
|
||||
// IPv6 literal splits into four too ("[", "", "1]", "8080" for [::1]:8080),
|
||||
// so require every field to be populated before reading it that way.
|
||||
let parts: Vec<&str> = rest.split(':').collect();
|
||||
if parts.len() == 4 && parts.iter().all(|part| !part.is_empty()) {
|
||||
return Some(Self::parse_colon_separated_quad(&parts, protocol, line));
|
||||
}
|
||||
|
||||
// No auth, just host:port
|
||||
if let Some(colon_pos) = rest.rfind(':') {
|
||||
let host = &rest[..colon_pos];
|
||||
@@ -3876,6 +3884,57 @@ mod tests {
|
||||
_ => panic!("Expected Parsed"),
|
||||
}
|
||||
|
||||
// Scheme in front of the colon-separated body
|
||||
let results = ProxyManager::parse_txt_proxies("socks5://1.2.3.4:1080:admin:secret\n");
|
||||
match &results[0] {
|
||||
ProxyParseResult::Parsed(p) => {
|
||||
assert_eq!(p.proxy_type, "socks5");
|
||||
assert_eq!(p.host, "1.2.3.4");
|
||||
assert_eq!(p.port, 1080);
|
||||
assert_eq!(p.username.as_deref(), Some("admin"));
|
||||
assert_eq!(p.password.as_deref(), Some("secret"));
|
||||
}
|
||||
_ => panic!("Expected Parsed"),
|
||||
}
|
||||
|
||||
// Same, with the credentials in front
|
||||
let results = ProxyManager::parse_txt_proxies("https://admin:secret:proxy.com:8443\n");
|
||||
match &results[0] {
|
||||
ProxyParseResult::Parsed(p) => {
|
||||
assert_eq!(p.proxy_type, "https");
|
||||
assert_eq!(p.host, "proxy.com");
|
||||
assert_eq!(p.port, 8443);
|
||||
assert_eq!(p.username.as_deref(), Some("admin"));
|
||||
assert_eq!(p.password.as_deref(), Some("secret"));
|
||||
}
|
||||
_ => panic!("Expected Parsed"),
|
||||
}
|
||||
|
||||
// An IPv6 literal splits into four parts as well, and must not be read as
|
||||
// the colon-separated form
|
||||
let results = ProxyManager::parse_txt_proxies("http://[::1]:8080\n");
|
||||
match &results[0] {
|
||||
ProxyParseResult::Parsed(p) => {
|
||||
assert_eq!(p.host, "[::1]");
|
||||
assert_eq!(p.port, 8080);
|
||||
assert!(p.username.is_none());
|
||||
}
|
||||
_ => panic!("Expected Parsed"),
|
||||
}
|
||||
|
||||
// A scheme-prefixed body that is ambiguous stays ambiguous
|
||||
let results = ProxyManager::parse_txt_proxies("socks5://1234:5678:9012:3456\n");
|
||||
match &results[0] {
|
||||
ProxyParseResult::Ambiguous {
|
||||
line,
|
||||
possible_formats,
|
||||
} => {
|
||||
assert_eq!(line, "socks5://1234:5678:9012:3456");
|
||||
assert_eq!(possible_formats.len(), 2);
|
||||
}
|
||||
_ => panic!("Expected Ambiguous"),
|
||||
}
|
||||
|
||||
// Ambiguous: both positions could be ports
|
||||
let results = ProxyManager::parse_txt_proxies("1234:5678:9012:3456\n");
|
||||
match &results[0] {
|
||||
|
||||
@@ -2084,6 +2084,13 @@ impl SyncEngine {
|
||||
manager.get_extension(ext_id).ok()
|
||||
};
|
||||
|
||||
// A linked extension is an absolute path on this machine with no payload in
|
||||
// the store. Uploading it would publish metadata another device could never
|
||||
// resolve, so it stays local whatever queued this run.
|
||||
if local_ext.as_ref().is_some_and(|e| e.is_linked()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remote_key = format!("extensions/{}.json", ext_id);
|
||||
let stat = self.client.stat(&remote_key).await?;
|
||||
|
||||
@@ -3251,7 +3258,9 @@ pub async fn enable_extension_group_sync_if_needed(extension_group_id: &str) ->
|
||||
manager
|
||||
.get_extension(ext_id)
|
||||
.ok()
|
||||
.map(|e| e.sync_enabled)
|
||||
// A linked extension has no binary to hand the other device, only a
|
||||
// path that means nothing there, so the cascade must not pick it up.
|
||||
.map(|e| e.sync_enabled || e.is_linked())
|
||||
.unwrap_or(true)
|
||||
};
|
||||
if !already_synced {
|
||||
@@ -3983,7 +3992,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
|
||||
.map_err(|e| format!("Failed to list extensions: {e}"))?
|
||||
};
|
||||
for ext in &exts {
|
||||
if !ext.sync_enabled {
|
||||
// Linked extensions are machine-local by definition and are skipped
|
||||
// rather than reported as a failure on every sync setup.
|
||||
if !ext.sync_enabled && !ext.is_linked() {
|
||||
if let Err(e) = set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await {
|
||||
log::warn!("Failed to enable sync for extension {}: {e}", ext.id);
|
||||
}
|
||||
@@ -4029,6 +4040,11 @@ pub async fn set_extension_sync_enabled(
|
||||
};
|
||||
|
||||
if enabled {
|
||||
// A linked extension is a path on this machine and nothing else; there is
|
||||
// no payload to upload and the path would be meaningless on another device.
|
||||
if ext.is_linked() {
|
||||
return Err(serde_json::json!({ "code": "EXTENSION_LINKED_CANNOT_SYNC" }).to_string());
|
||||
}
|
||||
ensure_sync_configured(&app_handle).await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -530,38 +530,10 @@ pub fn delete_traffic_stats(id: &str) -> bool {
|
||||
removed
|
||||
}
|
||||
|
||||
/// Best-effort secure erase: overwrite the file's bytes with zeros and flush
|
||||
/// before unlinking, so the traffic history isn't trivially recoverable from
|
||||
/// the freed blocks. On copy-on-write / SSD storage the OS may still retain
|
||||
/// old blocks — this is a best-effort mitigation, not a guarantee.
|
||||
fn secure_remove_file(path: &std::path::Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let len = meta.len();
|
||||
if len > 0 {
|
||||
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
|
||||
let zeros = vec![0u8; 8192];
|
||||
let mut remaining = len;
|
||||
// The overwrite is best-effort and must never gate the unlink: a write
|
||||
// failure part-way (ENOSPC on a copy-on-write volume, EIO) would
|
||||
// otherwise leave the file both un-wiped and un-deleted, which is
|
||||
// strictly worse than the plain remove this replaced — and the caller
|
||||
// reports success either way, so the history would silently survive a
|
||||
// clear.
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(zeros.len() as u64) as usize;
|
||||
if f.write_all(&zeros[..chunk]).is_err() {
|
||||
break;
|
||||
}
|
||||
remaining -= chunk as u64;
|
||||
}
|
||||
let _ = f.flush();
|
||||
let _ = f.sync_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
/// Best-effort secure erase. Shared with the ephemeral-profile teardown, which
|
||||
/// needs exactly the same "zero then unlink, never let the overwrite gate the
|
||||
/// unlink" behaviour; see `crate::fs_secure` for the caveats.
|
||||
use crate::fs_secure::secure_remove_file;
|
||||
|
||||
/// Clear all traffic stats (used when clearing cache), securely erasing each
|
||||
/// file first.
|
||||
|
||||
@@ -657,9 +657,13 @@ impl WayfernManager {
|
||||
let fingerprint_json = serde_json::to_string(&fingerprint)
|
||||
.map_err(|e| format!("Failed to serialize fingerprint: {e}"))?;
|
||||
|
||||
// Report the platform the engine actually produced alongside the one that
|
||||
// was asked for. Logging only the request made this line useless for
|
||||
// diagnosing a fingerprint that came back as something else.
|
||||
log::info!(
|
||||
"Generated Wayfern fingerprint for OS: {}, fields: {:?}",
|
||||
"Generated Wayfern fingerprint for requested OS: {}, produced platform: {:?}, fields: {:?}",
|
||||
os,
|
||||
fingerprint.get("platform").and_then(|p| p.as_str()),
|
||||
fingerprint
|
||||
.as_object()
|
||||
.map(|o| o.keys().collect::<Vec<_>>())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.29.3",
|
||||
"version": "0.29.5",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaChrome } from "react-icons/fa";
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuExternalLink,
|
||||
LuFolderOpen,
|
||||
LuLink,
|
||||
LuPencil,
|
||||
LuPuzzle,
|
||||
LuRefreshCw,
|
||||
@@ -82,6 +85,18 @@ import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
|
||||
/** A payload staged in the UI, before it is handed to the backend. */
|
||||
type PendingSource =
|
||||
| { kind: "archive"; fileName: string; data: number[] }
|
||||
| { kind: "folder"; path: string };
|
||||
|
||||
const ARCHIVE_EXTENSIONS = [".crx", ".zip"];
|
||||
|
||||
function pathBaseName(path: string): string {
|
||||
const segments = path.split(/[/\\]/).filter(Boolean);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
}
|
||||
|
||||
function getSyncStatusDot(
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
@@ -148,14 +163,13 @@ export function ExtensionManagementDialog({
|
||||
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Extension upload state
|
||||
// Extension import state
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [extensionName, setExtensionName] = useState("");
|
||||
const [showUploadForm, setShowUploadForm] = useState(false);
|
||||
const [pendingFile, setPendingFile] = useState<{
|
||||
name: string;
|
||||
data: number[];
|
||||
} | null>(null);
|
||||
const [pendingSource, setPendingSource] = useState<PendingSource | null>(
|
||||
null,
|
||||
);
|
||||
const [linkFolder, setLinkFolder] = useState(false);
|
||||
|
||||
// Group state
|
||||
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
||||
@@ -192,10 +206,9 @@ export function ExtensionManagementDialog({
|
||||
null,
|
||||
);
|
||||
const [editExtensionName, setEditExtensionName] = useState("");
|
||||
const [pendingUpdateFile, setPendingUpdateFile] = useState<{
|
||||
name: string;
|
||||
data: number[];
|
||||
} | null>(null);
|
||||
const [pendingUpdateSource, setPendingUpdateSource] =
|
||||
useState<PendingSource | null>(null);
|
||||
const [editLinkFolder, setEditLinkFolder] = useState(false);
|
||||
|
||||
// Extension icons
|
||||
const [extensionIcons, setExtensionIcons] = useState<Record<string, string>>(
|
||||
@@ -295,6 +308,30 @@ export function ExtensionManagementDialog({
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** Structured backend codes win; anything else falls back to a local message
|
||||
* so the user never sees a raw Rust string. */
|
||||
const showActionError = useCallback(
|
||||
(err: unknown, fallback: string) => {
|
||||
showErrorToast(
|
||||
parseBackendError(err) ? translateBackendError(t, err) : fallback,
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const resetImportForm = useCallback(() => {
|
||||
setPendingSource(null);
|
||||
setExtensionName("");
|
||||
setLinkFolder(false);
|
||||
}, []);
|
||||
|
||||
const closeEditExtension = useCallback(() => {
|
||||
setEditingExtension(null);
|
||||
setEditExtensionName("");
|
||||
setPendingUpdateSource(null);
|
||||
setEditLinkFolder(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleExtSync = useCallback(
|
||||
async (ext: Extension) => {
|
||||
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: true }));
|
||||
@@ -310,16 +347,12 @@ export function ExtensionManagementDialog({
|
||||
);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(
|
||||
parseBackendError(err)
|
||||
? translateBackendError(t, err)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
showActionError(err, t("proxies.management.updateSyncFailed"));
|
||||
} finally {
|
||||
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
|
||||
}
|
||||
},
|
||||
[loadData, t],
|
||||
[loadData, showActionError, t],
|
||||
);
|
||||
|
||||
const handleToggleGroupSync = useCallback(
|
||||
@@ -337,119 +370,178 @@ export function ExtensionManagementDialog({
|
||||
);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(
|
||||
parseBackendError(err)
|
||||
? translateBackendError(t, err)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
showActionError(err, t("proxies.management.updateSyncFailed"));
|
||||
} finally {
|
||||
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
|
||||
}
|
||||
},
|
||||
[loadData, t],
|
||||
[loadData, showActionError, t],
|
||||
);
|
||||
|
||||
const handleUpdateExtension = useCallback(async () => {
|
||||
if (!editingExtension || !editExtensionName.trim()) return;
|
||||
try {
|
||||
await invoke("update_extension", {
|
||||
extensionId: editingExtension.id,
|
||||
name: editExtensionName.trim(),
|
||||
fileName: pendingUpdateFile?.name ?? null,
|
||||
fileData: pendingUpdateFile?.data ?? null,
|
||||
});
|
||||
if (pendingUpdateSource?.kind === "folder") {
|
||||
await invoke("update_extension_from_path", {
|
||||
extensionId: editingExtension.id,
|
||||
name: editExtensionName.trim(),
|
||||
path: pendingUpdateSource.path,
|
||||
link: editLinkFolder,
|
||||
});
|
||||
} else {
|
||||
await invoke("update_extension", {
|
||||
extensionId: editingExtension.id,
|
||||
name: editExtensionName.trim(),
|
||||
fileName: pendingUpdateSource?.fileName ?? null,
|
||||
fileData: pendingUpdateSource?.data ?? null,
|
||||
});
|
||||
}
|
||||
showSuccessToast(t("extensions.updateSuccess"));
|
||||
setEditingExtension(null);
|
||||
setEditExtensionName("");
|
||||
setPendingUpdateFile(null);
|
||||
closeEditExtension();
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showActionError(err, t("extensions.updateFailed"));
|
||||
}
|
||||
}, [editingExtension, editExtensionName, pendingUpdateFile, loadData, t]);
|
||||
}, [
|
||||
editingExtension,
|
||||
editExtensionName,
|
||||
pendingUpdateSource,
|
||||
editLinkFolder,
|
||||
closeEditExtension,
|
||||
loadData,
|
||||
showActionError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleEditFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const validExtensions = [".xpi", ".crx", ".zip"];
|
||||
const isValid = validExtensions.some((ext) =>
|
||||
/** Reads a picked archive into memory, shared by the import and the replace
|
||||
* flows. Resolves to null when the file is rejected or unreadable. */
|
||||
const readArchiveFile = useCallback(
|
||||
(file: File): Promise<PendingSource | null> => {
|
||||
const isValid = ARCHIVE_EXTENSIONS.some((ext) =>
|
||||
file.name.toLowerCase().endsWith(ext),
|
||||
);
|
||||
if (!isValid) {
|
||||
showErrorToast(t("extensions.invalidFileType"));
|
||||
return;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||
const data = Array.from(new Uint8Array(arrayBuffer));
|
||||
setPendingUpdateFile({ name: file.name, data });
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
e.target.value = "";
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||
resolve({
|
||||
kind: "archive",
|
||||
fileName: file.name,
|
||||
data: Array.from(new Uint8Array(arrayBuffer)),
|
||||
});
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showErrorToast(t("extensions.readError"));
|
||||
resolve(null);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleEditFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
void readArchiveFile(file).then((source) => {
|
||||
if (!source) return;
|
||||
setPendingUpdateSource(source);
|
||||
setEditLinkFolder(false);
|
||||
});
|
||||
},
|
||||
[readArchiveFile],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
const validExtensions = [".xpi", ".crx", ".zip"];
|
||||
const isValid = validExtensions.some((ext) =>
|
||||
file.name.toLowerCase().endsWith(ext),
|
||||
);
|
||||
if (!isValid) {
|
||||
showErrorToast(t("extensions.invalidFileType"));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||
const data = Array.from(new Uint8Array(arrayBuffer));
|
||||
const baseName = file.name
|
||||
.replace(/\.(xpi|crx|zip)$/i, "")
|
||||
.replace(/[-_]/g, " ");
|
||||
setExtensionName(baseName);
|
||||
setPendingFile({ name: file.name, data });
|
||||
setShowUploadForm(true);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showErrorToast(t("extensions.readError"));
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
|
||||
// Reset input
|
||||
e.target.value = "";
|
||||
void readArchiveFile(file).then((source) => {
|
||||
if (!source) return;
|
||||
setExtensionName(
|
||||
file.name.replace(/\.(crx|zip)$/i, "").replace(/[-_]/g, " "),
|
||||
);
|
||||
setLinkFolder(false);
|
||||
setPendingSource(source);
|
||||
});
|
||||
},
|
||||
[t],
|
||||
[readArchiveFile],
|
||||
);
|
||||
|
||||
/** Native directory picker, the "Load unpacked" entry point. */
|
||||
const pickExtensionFolder = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t("extensions.selectFolderTitle"),
|
||||
});
|
||||
return typeof selected === "string" ? selected : null;
|
||||
} catch (err) {
|
||||
console.error("Failed to open folder dialog:", err);
|
||||
showErrorToast(t("importProfile.folderDialogFailed"));
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleLoadUnpacked = useCallback(async () => {
|
||||
const folder = await pickExtensionFolder();
|
||||
if (!folder) return;
|
||||
setExtensionName(pathBaseName(folder).replace(/[-_]/g, " "));
|
||||
setLinkFolder(false);
|
||||
setPendingSource({ kind: "folder", path: folder });
|
||||
}, [pickExtensionFolder]);
|
||||
|
||||
const handleEditFolderSelect = useCallback(async () => {
|
||||
const folder = await pickExtensionFolder();
|
||||
if (!folder) return;
|
||||
setPendingUpdateSource({ kind: "folder", path: folder });
|
||||
}, [pickExtensionFolder]);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (!pendingFile || !extensionName.trim()) return;
|
||||
if (!pendingSource || !extensionName.trim()) return;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await invoke("add_extension", {
|
||||
name: extensionName.trim(),
|
||||
fileName: pendingFile.name,
|
||||
fileData: pendingFile.data,
|
||||
});
|
||||
if (pendingSource.kind === "folder") {
|
||||
await invoke("add_unpacked_extension", {
|
||||
name: extensionName.trim(),
|
||||
path: pendingSource.path,
|
||||
link: linkFolder,
|
||||
});
|
||||
} else {
|
||||
await invoke("add_extension", {
|
||||
name: extensionName.trim(),
|
||||
fileName: pendingSource.fileName,
|
||||
fileData: pendingSource.data,
|
||||
});
|
||||
}
|
||||
showSuccessToast(t("extensions.uploadSuccess"));
|
||||
setShowUploadForm(false);
|
||||
setPendingFile(null);
|
||||
setExtensionName("");
|
||||
resetImportForm();
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showActionError(err, t("extensions.uploadFailed"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [pendingFile, extensionName, loadData, t]);
|
||||
}, [
|
||||
pendingSource,
|
||||
extensionName,
|
||||
linkFolder,
|
||||
resetImportForm,
|
||||
loadData,
|
||||
showActionError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleDeleteExtension = useCallback(async () => {
|
||||
if (!extensionToDelete) return;
|
||||
@@ -460,11 +552,11 @@ export function ExtensionManagementDialog({
|
||||
setExtensionToDelete(null);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showActionError(err, t("extensions.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [extensionToDelete, loadData, t]);
|
||||
}, [extensionToDelete, loadData, showActionError, t]);
|
||||
|
||||
const handleCreateGroup = useCallback(async () => {
|
||||
if (!newGroupName.trim()) return;
|
||||
@@ -475,9 +567,9 @@ export function ExtensionManagementDialog({
|
||||
setNewGroupName("");
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showActionError(err, t("extensions.groupCreateFailed"));
|
||||
}
|
||||
}, [newGroupName, loadData, t]);
|
||||
}, [newGroupName, loadData, showActionError, t]);
|
||||
|
||||
const handleSaveGroupEdits = useCallback(async () => {
|
||||
if (!editingGroup || !editGroupName.trim()) return;
|
||||
@@ -518,9 +610,16 @@ export function ExtensionManagementDialog({
|
||||
setEditGroupExtensionIds([]);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showActionError(err, t("extensions.groupUpdateFailed"));
|
||||
}
|
||||
}, [editingGroup, editGroupName, editGroupExtensionIds, loadData, t]);
|
||||
}, [
|
||||
editingGroup,
|
||||
editGroupName,
|
||||
editGroupExtensionIds,
|
||||
loadData,
|
||||
showActionError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleDeleteGroup = useCallback(async () => {
|
||||
if (!groupToDelete) return;
|
||||
@@ -531,11 +630,11 @@ export function ExtensionManagementDialog({
|
||||
setGroupToDelete(null);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showActionError(err, t("extensions.groupDeleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [groupToDelete, loadData, t]);
|
||||
}, [groupToDelete, loadData, showActionError, t]);
|
||||
|
||||
const selectedExtensions = useMemo(
|
||||
() => extensions.filter((ext) => extRowSelection[ext.id]),
|
||||
@@ -561,11 +660,11 @@ export function ExtensionManagementDialog({
|
||||
setExtRowSelection({});
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showActionError(err, t("extensions.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [selectedExtensions, loadData, t]);
|
||||
}, [selectedExtensions, loadData, showActionError, t]);
|
||||
|
||||
const handleBulkDeleteGroups = useCallback(async () => {
|
||||
if (selectedGroups.length === 0) return;
|
||||
@@ -581,18 +680,27 @@ export function ExtensionManagementDialog({
|
||||
setGroupRowSelection({});
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showActionError(err, t("extensions.groupDeleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [selectedGroups, loadData, t]);
|
||||
}, [selectedGroups, loadData, showActionError, t]);
|
||||
|
||||
const handleBulkToggleExtSync = useCallback(async () => {
|
||||
if (selectedExtensions.length === 0) return;
|
||||
const allOn = selectedExtensions.every((e) => e.sync_enabled);
|
||||
const targetEnabled = !allOn;
|
||||
// A linked extension has no payload to upload, so enabling sync on one is
|
||||
// refused by the backend. Skip them instead of failing the whole batch.
|
||||
const targets = targetEnabled
|
||||
? selectedExtensions.filter((ext) => !ext.linked_path)
|
||||
: selectedExtensions;
|
||||
if (targets.length === 0) {
|
||||
showErrorToast(t("extensions.linkedNoSync"));
|
||||
return;
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
selectedExtensions.map((ext) =>
|
||||
targets.map((ext) =>
|
||||
invoke("set_extension_sync_enabled", {
|
||||
extensionId: ext.id,
|
||||
enabled: targetEnabled,
|
||||
@@ -603,10 +711,9 @@ export function ExtensionManagementDialog({
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
showActionError(
|
||||
firstRejection.reason,
|
||||
t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
@@ -616,7 +723,7 @@ export function ExtensionManagementDialog({
|
||||
);
|
||||
}
|
||||
void loadData();
|
||||
}, [selectedExtensions, loadData, t]);
|
||||
}, [selectedExtensions, loadData, showActionError, t]);
|
||||
|
||||
const handleBulkToggleGroupSync = useCallback(async () => {
|
||||
if (selectedGroups.length === 0) return;
|
||||
@@ -634,10 +741,9 @@ export function ExtensionManagementDialog({
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
showActionError(
|
||||
firstRejection.reason,
|
||||
t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
@@ -647,7 +753,7 @@ export function ExtensionManagementDialog({
|
||||
);
|
||||
}
|
||||
void loadData();
|
||||
}, [selectedGroups, loadData, t]);
|
||||
}, [selectedGroups, loadData, showActionError, t]);
|
||||
|
||||
const renderCompatIcons = useCallback(
|
||||
(compat: string[]) => {
|
||||
@@ -691,6 +797,42 @@ export function ExtensionManagementDialog({
|
||||
[extensionIcons],
|
||||
);
|
||||
|
||||
/** What the extension actually is: a stored archive, a folder packed into
|
||||
* the store, or a folder loaded in place from the user's disk. */
|
||||
const renderSource = useCallback(
|
||||
(ext: Extension) => {
|
||||
if (ext.linked_path) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
<LuLink className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("extensions.source.linked")}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs break-all">
|
||||
{t("extensions.source.linkedTooltip", {
|
||||
path: ext.linked_path,
|
||||
})}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="block min-w-0 truncate text-xs text-muted-foreground">
|
||||
{ext.source_kind === "unpacked"
|
||||
? t("extensions.source.unpacked")
|
||||
: t("extensions.source.archive")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const MAX_VISIBLE_ICONS = 3;
|
||||
|
||||
const extensionColumns = useMemo<ColumnDef<Extension>[]>(
|
||||
@@ -762,6 +904,13 @@ export function ExtensionManagementDialog({
|
||||
cell: ({ row }) =>
|
||||
renderCompatIcons(row.original.browser_compatibility),
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
size: 128,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => renderSource(row.original),
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
size: 88,
|
||||
@@ -770,6 +919,7 @@ export function ExtensionManagementDialog({
|
||||
cell: ({ row }) => {
|
||||
const ext = row.original;
|
||||
const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t);
|
||||
const isLinked = Boolean(ext.linked_path);
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
@@ -790,15 +940,17 @@ export function ExtensionManagementDialog({
|
||||
<AnimatedSwitch
|
||||
checked={ext.sync_enabled}
|
||||
onCheckedChange={() => void handleToggleExtSync(ext)}
|
||||
disabled={isTogglingExtSync[ext.id]}
|
||||
disabled={isLinked || isTogglingExtSync[ext.id]}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{ext.sync_enabled
|
||||
? t("syncTooltips.disable")
|
||||
: t("syncTooltips.enable")}
|
||||
{isLinked
|
||||
? t("extensions.linkedNoSync")
|
||||
: ext.sync_enabled
|
||||
? t("syncTooltips.disable")
|
||||
: t("syncTooltips.enable")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -824,7 +976,8 @@ export function ExtensionManagementDialog({
|
||||
onClick={() => {
|
||||
setEditingExtension(ext);
|
||||
setEditExtensionName(ext.name);
|
||||
setPendingUpdateFile(null);
|
||||
setPendingUpdateSource(null);
|
||||
setEditLinkFolder(Boolean(ext.linked_path));
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-3.5" />
|
||||
@@ -859,6 +1012,7 @@ export function ExtensionManagementDialog({
|
||||
handleToggleExtSync,
|
||||
renderExtensionIcon,
|
||||
renderCompatIcons,
|
||||
renderSource,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1160,25 +1314,48 @@ export function ExtensionManagementDialog({
|
||||
</AnimatedTabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === "extensions" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={limitedMode}
|
||||
onClick={() =>
|
||||
document.getElementById("ext-file-input")?.click()
|
||||
}
|
||||
aria-label={t("extensions.upload")}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("extensions.upload")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("extensions.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={limitedMode}
|
||||
onClick={() =>
|
||||
document.getElementById("ext-file-input")?.click()
|
||||
}
|
||||
aria-label={t("extensions.upload")}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("extensions.upload")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("extensions.upload")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={limitedMode}
|
||||
onClick={() => void handleLoadUnpacked()}
|
||||
aria-label={t("extensions.loadUnpacked")}
|
||||
>
|
||||
<LuFolderOpen className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("extensions.loadUnpacked")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("extensions.loadUnpackedTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "groups" && (
|
||||
<Tooltip>
|
||||
@@ -1216,21 +1393,51 @@ export function ExtensionManagementDialog({
|
||||
<Input
|
||||
id="ext-file-input"
|
||||
type="file"
|
||||
accept=".xpi,.crx,.zip"
|
||||
accept=".crx,.zip"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={limitedMode}
|
||||
/>
|
||||
|
||||
{/* Upload form */}
|
||||
{showUploadForm && pendingFile && (
|
||||
{/* Import form */}
|
||||
{pendingSource && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("extensions.selectedFile")}:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{pendingFile.name}
|
||||
{pendingSource.kind === "folder"
|
||||
? t("extensions.selectedFolder")
|
||||
: t("extensions.selectedFile")}
|
||||
:{" "}
|
||||
<span className="font-medium break-all text-foreground">
|
||||
{pendingSource.kind === "folder"
|
||||
? pendingSource.path
|
||||
: pendingSource.fileName}
|
||||
</span>
|
||||
</div>
|
||||
{pendingSource.kind === "folder" && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="ext-link-folder"
|
||||
checked={linkFolder}
|
||||
onCheckedChange={(value) => {
|
||||
setLinkFolder(value === true);
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="ext-link-folder"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{t("extensions.linkFolder")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{linkFolder
|
||||
? t("extensions.linkFolderOn")
|
||||
: t("extensions.linkFolderOff")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={extensionName}
|
||||
@@ -1252,11 +1459,7 @@ export function ExtensionManagementDialog({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowUploadForm(false);
|
||||
setPendingFile(null);
|
||||
setExtensionName("");
|
||||
}}
|
||||
onClick={resetImportForm}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
@@ -1611,11 +1814,7 @@ export function ExtensionManagementDialog({
|
||||
<Dialog
|
||||
open={editingExtension !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingExtension(null);
|
||||
setEditExtensionName("");
|
||||
setPendingUpdateFile(null);
|
||||
}
|
||||
if (!open) closeEditExtension();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
|
||||
@@ -1684,9 +1883,35 @@ export function ExtensionManagementDialog({
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("common.labels.type")}
|
||||
{t("extensions.source.label")}
|
||||
</span>
|
||||
<span>.{editingExtension.file_type}</span>
|
||||
<span>
|
||||
{editingExtension.linked_path
|
||||
? t("extensions.source.linked")
|
||||
: editingExtension.source_kind === "unpacked"
|
||||
? t("extensions.source.unpacked")
|
||||
: t("extensions.source.archive")}
|
||||
</span>
|
||||
{editingExtension.linked_path ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.source.folderLabel")}
|
||||
</span>
|
||||
<span className="break-all">
|
||||
{editingExtension.linked_path}
|
||||
</span>
|
||||
<p className="col-span-2 text-xs text-muted-foreground">
|
||||
{t("extensions.linkFolderOn")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("common.labels.type")}
|
||||
</span>
|
||||
<span>.{editingExtension.file_type}</span>
|
||||
</>
|
||||
)}
|
||||
{editingExtension.homepage_url && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
@@ -1716,10 +1941,10 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Re-upload */}
|
||||
{/* Replace the payload with another archive or folder */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.reupload")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{t("extensions.replaceSource")}</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1733,30 +1958,58 @@ export function ExtensionManagementDialog({
|
||||
<input
|
||||
id="ext-edit-file-input"
|
||||
type="file"
|
||||
accept=".xpi,.crx,.zip"
|
||||
accept=".crx,.zip"
|
||||
className="hidden"
|
||||
onChange={handleEditFileSelect}
|
||||
/>
|
||||
{pendingUpdateFile && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleEditFolderSelect()}
|
||||
>
|
||||
<LuFolderOpen className="mr-1 size-3" />
|
||||
{t("extensions.selectFolder")}
|
||||
</RippleButton>
|
||||
{pendingUpdateSource && (
|
||||
<span className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||
{pendingUpdateFile.name}
|
||||
{pendingUpdateSource.kind === "folder"
|
||||
? pendingUpdateSource.path
|
||||
: pendingUpdateSource.fileName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pendingUpdateSource?.kind === "folder" && (
|
||||
<div className="flex items-start gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="ext-edit-link-folder"
|
||||
checked={editLinkFolder}
|
||||
onCheckedChange={(value) => {
|
||||
setEditLinkFolder(value === true);
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="ext-edit-link-folder"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{t("extensions.linkFolder")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{editLinkFolder
|
||||
? t("extensions.linkFolderOn")
|
||||
: t("extensions.linkFolderOff")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingExtension(null);
|
||||
setEditExtensionName("");
|
||||
setPendingUpdateFile(null);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" onClick={closeEditExtension}>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
LuSettings,
|
||||
LuShield,
|
||||
LuShieldCheck,
|
||||
LuTimer,
|
||||
LuTrash2,
|
||||
LuUpload,
|
||||
LuUsers,
|
||||
@@ -735,6 +736,13 @@ function ProfileInfoLayout({
|
||||
[visibleActions],
|
||||
);
|
||||
|
||||
// An ephemeral profile is discarded when the browser closes, so it has
|
||||
// nowhere to keep cookies, extensions or a synced copy. The sections were
|
||||
// hidden outright, which left no way to discover that and read as the app
|
||||
// being broken or the plan lacking the feature. Keep them listed and explain.
|
||||
const isEphemeral = profile.ephemeral === true;
|
||||
const isWayfernProfile = profile.browser === "wayfern";
|
||||
|
||||
const deleteAction = findAction("delete");
|
||||
const fingerprintAction = findAction("fingerprint");
|
||||
const cookiesManageAction = findAction("cookiesManage");
|
||||
@@ -814,20 +822,20 @@ function ProfileInfoLayout({
|
||||
cookieCount !== null && cookieCount > 0
|
||||
? cookieCount.toLocaleString()
|
||||
: undefined,
|
||||
hidden: !cookiesAction,
|
||||
hidden: !cookiesAction && !(isEphemeral && isWayfernProfile),
|
||||
},
|
||||
{
|
||||
id: "extensions",
|
||||
icon: <LuPuzzle className="size-3.5" />,
|
||||
label: t("profileInfo.sections.extensions"),
|
||||
badge: extensionGroupName ?? undefined,
|
||||
hidden: !extensionAction,
|
||||
hidden: !extensionAction && !isEphemeral,
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
icon: <LuRefreshCw className="size-3.5" />,
|
||||
label: t("profileInfo.sections.sync"),
|
||||
hidden: !syncAction,
|
||||
hidden: !syncAction && !isEphemeral,
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
@@ -1073,34 +1081,55 @@ function ProfileInfoLayout({
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "cookies" && (
|
||||
<CookiesSectionInline
|
||||
profile={profile}
|
||||
isRunning={isRunning}
|
||||
isDisabled={isDisabled}
|
||||
onCopyCookies={cookiesCopyAction?.onClick}
|
||||
onImportCookies={cookiesManageAction?.onClick}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{section === "cookies" &&
|
||||
(isEphemeral ? (
|
||||
<EphemeralSectionNotice
|
||||
title={t("profileInfo.sections.cookies")}
|
||||
description={t("profileInfo.ephemeral.cookiesUnavailable")}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<CookiesSectionInline
|
||||
profile={profile}
|
||||
isRunning={isRunning}
|
||||
isDisabled={isDisabled}
|
||||
onCopyCookies={cookiesCopyAction?.onClick}
|
||||
onImportCookies={cookiesManageAction?.onClick}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
{section === "extensions" && (
|
||||
<ExtensionsSectionInline
|
||||
profile={profile}
|
||||
isDisabled={isDisabled}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{section === "extensions" &&
|
||||
(isEphemeral ? (
|
||||
<EphemeralSectionNotice
|
||||
title={t("profileInfo.sections.extensions")}
|
||||
description={t("profileInfo.ephemeral.extensionsUnavailable")}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<ExtensionsSectionInline
|
||||
profile={profile}
|
||||
isDisabled={isDisabled}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
{section === "sync" && (
|
||||
<SyncSectionInline
|
||||
profile={profile}
|
||||
syncMode={syncMode}
|
||||
syncStatus={syncStatus}
|
||||
isDisabled={isDisabled}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{section === "sync" &&
|
||||
(isEphemeral ? (
|
||||
<EphemeralSectionNotice
|
||||
title={t("profileInfo.sections.sync")}
|
||||
description={t("profileInfo.ephemeral.syncUnavailable")}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<SyncSectionInline
|
||||
profile={profile}
|
||||
syncMode={syncMode}
|
||||
syncStatus={syncStatus}
|
||||
isDisabled={isDisabled}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
{section === "automation" && (
|
||||
<LaunchHookEditor profile={profile} t={t} />
|
||||
@@ -1507,6 +1536,31 @@ function NetworkSectionInline({
|
||||
);
|
||||
}
|
||||
|
||||
/// Explains why a section has nothing to offer on an ephemeral profile.
|
||||
/// Mirrors the locked-fingerprint empty state so the two read as one pattern.
|
||||
function EphemeralSectionNotice({
|
||||
title,
|
||||
description,
|
||||
t,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center">
|
||||
<LuTimer className="size-4 shrink-0 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
<p className="max-w-[48ch] text-sm text-pretty text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
<p className="max-w-[48ch] text-xs text-pretty text-muted-foreground">
|
||||
{t("profileInfo.ephemeral.hint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionsSectionInline({
|
||||
profile,
|
||||
isDisabled,
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { pickParsedProxy } from "@/lib/proxy-string";
|
||||
import type { ProxyParseResult, StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyFormData {
|
||||
@@ -202,6 +203,48 @@ export function ProxyFormDialog({
|
||||
}
|
||||
}, [isSubmitting, onClose]);
|
||||
|
||||
// Proxies are copied around as one string — `socks5://user:pass@host:1080`,
|
||||
// `host:1080:user:pass`, and a dozen variants of both — so a paste into any
|
||||
// one field is almost never meant for that field alone. Hand the clipboard to
|
||||
// the same Rust parser the import dialog uses and spread the result across
|
||||
// the form. The default paste is left alone until the answer comes back, so a
|
||||
// string that isn't a proxy (a hostname, a port) lands where it was dropped.
|
||||
const handleProxyPaste = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const content = event.clipboardData.getData("text").trim();
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Captured before the browser applies the paste, so a proxy string
|
||||
// dropped into the empty name field names the proxy after its endpoint
|
||||
// instead of keeping the raw line.
|
||||
const nameBeforePaste = form.name.trim();
|
||||
|
||||
void invoke<ProxyParseResult[]>("parse_txt_proxies", { content })
|
||||
.then((results) => {
|
||||
const parsed = pickParsedProxy(results);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
name: nameBeforePaste || `${parsed.host}:${parsed.port}`,
|
||||
proxy_type: parsed.proxy_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username ?? "",
|
||||
password: parsed.password ?? "",
|
||||
vless_uri: parsed.vless_uri ?? "",
|
||||
}));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to parse pasted proxy:", error);
|
||||
});
|
||||
},
|
||||
[form.name],
|
||||
);
|
||||
|
||||
const isVless = form.proxy_type === "vless";
|
||||
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
|
||||
|
||||
@@ -259,6 +302,7 @@ export function ProxyFormDialog({
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
}}
|
||||
onPaste={handleProxyPaste}
|
||||
placeholder={t("proxies.form.namePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -303,6 +347,7 @@ export function ProxyFormDialog({
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, vless_uri: e.target.value });
|
||||
}}
|
||||
onPaste={handleProxyPaste}
|
||||
placeholder={t("proxies.form.vlessUriPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={hasInvalidVlessUri}
|
||||
@@ -337,6 +382,7 @@ export function ProxyFormDialog({
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, host: e.target.value });
|
||||
}}
|
||||
onPaste={handleProxyPaste}
|
||||
placeholder={t("proxies.form.hostPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -354,6 +400,7 @@ export function ProxyFormDialog({
|
||||
port: Number.parseInt(e.target.value, 10) || 0,
|
||||
});
|
||||
}}
|
||||
onPaste={handleProxyPaste}
|
||||
placeholder={t("proxies.form.portPlaceholder")}
|
||||
min="1"
|
||||
max="65535"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { StepTransition } from "@/components/ui/step-transition";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import { resolveAmbiguousProxyLine } from "@/lib/proxy-string";
|
||||
import type {
|
||||
ParsedProxyLine,
|
||||
ProxyImportResult,
|
||||
@@ -265,31 +266,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
);
|
||||
|
||||
const handleResolveAmbiguous = useCallback(() => {
|
||||
// Convert ambiguous proxies to parsed based on selected format
|
||||
const resolved: ParsedProxyLine[] = ambiguousProxies
|
||||
.filter((p) => p.selectedFormat)
|
||||
.map((p) => {
|
||||
const parts = p.line.split(":");
|
||||
if (p.selectedFormat === "host:port:username:password") {
|
||||
return {
|
||||
proxy_type: "http",
|
||||
host: parts[0],
|
||||
port: Number.parseInt(parts[1], 10),
|
||||
username: parts[2],
|
||||
password: parts[3],
|
||||
original_line: p.line,
|
||||
};
|
||||
}
|
||||
// username:password:host:port
|
||||
return {
|
||||
proxy_type: "http",
|
||||
host: parts[2],
|
||||
port: Number.parseInt(parts[3], 10),
|
||||
username: parts[0],
|
||||
password: parts[1],
|
||||
original_line: p.line,
|
||||
};
|
||||
});
|
||||
const resolved = ambiguousProxies.flatMap((p) => {
|
||||
const parsed = p.selectedFormat
|
||||
? resolveAmbiguousProxyLine(p.line, p.selectedFormat)
|
||||
: null;
|
||||
return parsed ? [parsed] : [];
|
||||
});
|
||||
|
||||
setParsedProxies((prev) => [...prev, ...resolved]);
|
||||
setStep("preview");
|
||||
|
||||
@@ -1230,6 +1230,12 @@
|
||||
"syncing": "Syncing",
|
||||
"synced": "Synced",
|
||||
"error": "Error"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "Ephemeral profiles are discarded when the browser closes, so there are no cookies to manage here.",
|
||||
"extensionsUnavailable": "Ephemeral profiles are discarded when the browser closes, so extension groups cannot be assigned to them.",
|
||||
"syncUnavailable": "Ephemeral profiles are discarded when the browser closes, so there is nothing to sync to the cloud.",
|
||||
"hint": "Create a regular profile if you need this to persist."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1265,7 +1271,7 @@
|
||||
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleteGroupConfirmTitle": "Delete Extension Group",
|
||||
"deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.",
|
||||
"invalidFileType": "Invalid file type. Please upload a .crx, .xpi, or .zip file.",
|
||||
"invalidFileType": "Invalid file type. Please choose a .crx or .zip file.",
|
||||
"readError": "Failed to read the extension file.",
|
||||
"assignTitle": "Assign Extension Group",
|
||||
"assignDescription": "Assign {{count}} selected profile(s) to an extension group.",
|
||||
@@ -1273,7 +1279,6 @@
|
||||
"assignSuccess": "Extension group assigned successfully",
|
||||
"editExtension": "Edit extension",
|
||||
"updateSuccess": "Extension updated successfully",
|
||||
"reupload": "Re-upload",
|
||||
"version": "Version",
|
||||
"author": "Author",
|
||||
"homepage": "Homepage",
|
||||
@@ -1281,10 +1286,26 @@
|
||||
"editGroupDescription": "Update the group name and manage which extensions are included.",
|
||||
"groupExtensions": "Extensions in this group",
|
||||
"noExtensionsInGroup": "No extensions added yet",
|
||||
"editExtensionDescription": "Update extension name, view metadata, or re-upload the extension file.",
|
||||
"editExtensionDescription": "Update the extension name, view its metadata, or replace it with another archive or folder.",
|
||||
"metadata": "Metadata",
|
||||
"noMetadata": "No metadata available from manifest.",
|
||||
"selectFile": "Choose File",
|
||||
"loadUnpacked": "Load unpacked",
|
||||
"loadUnpackedTooltip": "Load an extension from a folder containing manifest.json",
|
||||
"selectFolderTitle": "Select Extension Folder",
|
||||
"selectedFolder": "Selected folder",
|
||||
"selectFolder": "Choose Folder",
|
||||
"linkFolder": "Load in place from this folder",
|
||||
"linkFolderOff": "The folder is copied into Donut. The extension is portable and syncs to your other devices.",
|
||||
"linkFolderOn": "Donut loads the extension straight from this folder on every launch. Your edits apply on the next browser start, but the extension stays on this machine and never syncs.",
|
||||
"replaceSource": "Replace source",
|
||||
"linkedNoSync": "Linked extensions stay on this machine and can't sync.",
|
||||
"uploadFailed": "Failed to add extension",
|
||||
"updateFailed": "Failed to update extension",
|
||||
"deleteFailed": "Failed to delete extension",
|
||||
"groupCreateFailed": "Failed to create extension group",
|
||||
"groupUpdateFailed": "Failed to update extension group",
|
||||
"groupDeleteFailed": "Failed to delete extension group",
|
||||
"syncEnabled": "Sync enabled",
|
||||
"syncDisabled": "Sync disabled",
|
||||
"syncEnableTooltip": "Enable sync",
|
||||
@@ -1297,6 +1318,14 @@
|
||||
"groupsTitle": "Delete extension groups",
|
||||
"groupsDescription": "Delete {{count}} extension groups? {{names}}",
|
||||
"confirmButton": "Delete"
|
||||
},
|
||||
"source": {
|
||||
"label": "Source",
|
||||
"archive": "Archive",
|
||||
"unpacked": "Unpacked folder",
|
||||
"linked": "Linked folder",
|
||||
"folderLabel": "Folder",
|
||||
"linkedTooltip": "Loaded in place from {{path}}"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1836,6 +1865,15 @@
|
||||
"vpnNotFound": "VPN not found",
|
||||
"extensionNotFound": "Extension not found",
|
||||
"extensionGroupNotFound": "Extension group not found",
|
||||
"extensionUnsupportedFileType": "That file type isn't supported. An extension has to be a .crx or .zip archive, or a folder.",
|
||||
"extensionDirNotFound": "That folder no longer exists.",
|
||||
"extensionNotADirectory": "That path is not a folder.",
|
||||
"extensionManifestMissing": "There is no manifest.json in that folder. Choose the folder that holds the extension's manifest.json.",
|
||||
"extensionManifestInvalid": "The manifest.json in that folder could not be read.",
|
||||
"extensionDirTooLarge": "That folder is too large to copy into Donut (the limit is 256 MB and 20,000 files). Link it in place instead.",
|
||||
"extensionPathHasComma": "That folder's path contains a comma, which Chromium cannot load. Rename or move the folder.",
|
||||
"extensionLinkRequiresDirectory": "Only a folder can be loaded in place. Turn linking off to add an archive.",
|
||||
"extensionLinkedCannotSync": "This extension is loaded from a folder on this machine, so there is nothing to sync.",
|
||||
"cannotModifyCloudManagedProxy": "Cannot modify sync for a cloud-managed proxy",
|
||||
"syncLockedByProfile": "Sync cannot be disabled while this is used by synced profiles",
|
||||
"syncNotConfigured": "Sync is not configured. Sign in or configure a self-hosted server first.",
|
||||
@@ -2203,7 +2241,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on. It needs a paid plan."
|
||||
"hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No profiles are enrolled",
|
||||
|
||||
@@ -1233,6 +1233,12 @@
|
||||
"syncing": "Sincronizando",
|
||||
"synced": "Sincronizado",
|
||||
"error": "Error"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que aquí no hay cookies que gestionar.",
|
||||
"extensionsUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que no se les pueden asignar grupos de extensiones.",
|
||||
"syncUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que no hay nada que sincronizar con la nube.",
|
||||
"hint": "Crea un perfil normal si necesitas que esto se conserve."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1268,7 +1274,7 @@
|
||||
"deleteConfirmDescription": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
|
||||
"deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"invalidFileType": "Tipo de archivo no válido. Suba un archivo .crx, .xpi o .zip.",
|
||||
"invalidFileType": "Tipo de archivo no válido. Elige un archivo .crx o .zip.",
|
||||
"readError": "No se pudo leer el archivo de extensión.",
|
||||
"assignTitle": "Asignar Grupo de Extensiones",
|
||||
"assignDescription": "Asignar {{count}} perfil(es) seleccionado(s) a un grupo de extensiones.",
|
||||
@@ -1276,7 +1282,6 @@
|
||||
"assignSuccess": "Grupo de extensiones asignado exitosamente",
|
||||
"editExtension": "Editar extensión",
|
||||
"updateSuccess": "Extensión actualizada exitosamente",
|
||||
"reupload": "Re-subir",
|
||||
"version": "Versión",
|
||||
"author": "Autor",
|
||||
"homepage": "Página de inicio",
|
||||
@@ -1284,10 +1289,26 @@
|
||||
"editGroupDescription": "Actualiza el nombre del grupo y gestiona qué extensiones están incluidas.",
|
||||
"groupExtensions": "Extensiones en este grupo",
|
||||
"noExtensionsInGroup": "Aún no se han añadido extensiones",
|
||||
"editExtensionDescription": "Actualizar el nombre de la extensión, ver metadatos o volver a cargar el archivo de extensión.",
|
||||
"editExtensionDescription": "Actualiza el nombre de la extensión, consulta sus metadatos o reemplázala por otro archivo comprimido o carpeta.",
|
||||
"metadata": "Metadatos",
|
||||
"noMetadata": "No hay metadatos disponibles del manifiesto.",
|
||||
"selectFile": "Elegir archivo",
|
||||
"loadUnpacked": "Cargar sin empaquetar",
|
||||
"loadUnpackedTooltip": "Carga una extensión desde una carpeta que contenga manifest.json",
|
||||
"selectFolderTitle": "Seleccionar carpeta de la extensión",
|
||||
"selectedFolder": "Carpeta seleccionada",
|
||||
"selectFolder": "Elegir carpeta",
|
||||
"linkFolder": "Cargar directamente desde esta carpeta",
|
||||
"linkFolderOff": "La carpeta se copia en Donut. La extensión es portátil y se sincroniza con tus otros dispositivos.",
|
||||
"linkFolderOn": "Donut carga la extensión directamente desde esta carpeta en cada inicio. Tus cambios se aplican al abrir el navegador de nuevo, pero la extensión permanece en este equipo y nunca se sincroniza.",
|
||||
"replaceSource": "Reemplazar origen",
|
||||
"linkedNoSync": "Las extensiones enlazadas permanecen en este equipo y no se pueden sincronizar.",
|
||||
"uploadFailed": "No se pudo añadir la extensión",
|
||||
"updateFailed": "No se pudo actualizar la extensión",
|
||||
"deleteFailed": "No se pudo eliminar la extensión",
|
||||
"groupCreateFailed": "No se pudo crear el grupo de extensiones",
|
||||
"groupUpdateFailed": "No se pudo actualizar el grupo de extensiones",
|
||||
"groupDeleteFailed": "No se pudo eliminar el grupo de extensiones",
|
||||
"syncEnabled": "Sincronización habilitada",
|
||||
"syncDisabled": "Sincronización deshabilitada",
|
||||
"syncEnableTooltip": "Habilitar sincronización",
|
||||
@@ -1300,6 +1321,14 @@
|
||||
"groupsTitle": "Eliminar grupos de extensiones",
|
||||
"groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}",
|
||||
"confirmButton": "Eliminar"
|
||||
},
|
||||
"source": {
|
||||
"label": "Origen",
|
||||
"archive": "Archivo comprimido",
|
||||
"unpacked": "Carpeta sin empaquetar",
|
||||
"linked": "Carpeta enlazada",
|
||||
"folderLabel": "Carpeta",
|
||||
"linkedTooltip": "Se carga directamente desde {{path}}"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1843,6 +1872,15 @@
|
||||
"vpnNotFound": "VPN no encontrada",
|
||||
"extensionNotFound": "Extensión no encontrada",
|
||||
"extensionGroupNotFound": "Grupo de extensiones no encontrado",
|
||||
"extensionUnsupportedFileType": "Ese tipo de archivo no es compatible. Una extensión debe ser un archivo .crx o .zip, o una carpeta.",
|
||||
"extensionDirNotFound": "Esa carpeta ya no existe.",
|
||||
"extensionNotADirectory": "Esa ruta no es una carpeta.",
|
||||
"extensionManifestMissing": "No hay ningún manifest.json en esa carpeta. Elige la carpeta que contiene el manifest.json de la extensión.",
|
||||
"extensionManifestInvalid": "No se pudo leer el manifest.json de esa carpeta.",
|
||||
"extensionDirTooLarge": "Esa carpeta es demasiado grande para copiarla en Donut (el límite es 256 MB y 20 000 archivos). Enlázala en su ubicación.",
|
||||
"extensionPathHasComma": "La ruta de esa carpeta contiene una coma, que Chromium no puede cargar. Cambia el nombre de la carpeta o muévela.",
|
||||
"extensionLinkRequiresDirectory": "Solo se puede cargar en su ubicación una carpeta. Desactiva el enlace para añadir un archivo comprimido.",
|
||||
"extensionLinkedCannotSync": "Esta extensión se carga desde una carpeta de este equipo, así que no hay nada que sincronizar.",
|
||||
"cannotModifyCloudManagedProxy": "No se puede modificar la sincronización de un proxy gestionado en la nube",
|
||||
"syncLockedByProfile": "No se puede desactivar la sincronización mientras se usa en perfiles sincronizados",
|
||||
"syncNotConfigured": "La sincronización no está configurada. Inicia sesión o configura un servidor propio.",
|
||||
@@ -2210,7 +2248,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido. Requiere un plan de pago."
|
||||
"hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No hay perfiles inscritos",
|
||||
|
||||
@@ -1233,6 +1233,12 @@
|
||||
"syncing": "Synchronisation",
|
||||
"synced": "Synchronisé",
|
||||
"error": "Erreur"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : il n'y a donc aucun cookie à gérer ici.",
|
||||
"extensionsUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : aucun groupe d'extensions ne peut leur être attribué.",
|
||||
"syncUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : il n'y a rien à synchroniser vers le cloud.",
|
||||
"hint": "Créez un profil normal si vous avez besoin de conserver ces données."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1268,7 +1274,7 @@
|
||||
"deleteConfirmDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
|
||||
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
|
||||
"deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
|
||||
"invalidFileType": "Type de fichier non valide. Veuillez télécharger un fichier .crx, .xpi ou .zip.",
|
||||
"invalidFileType": "Type de fichier non valide. Choisissez un fichier .crx ou .zip.",
|
||||
"readError": "Impossible de lire le fichier d'extension.",
|
||||
"assignTitle": "Assigner un Groupe d'Extensions",
|
||||
"assignDescription": "Assigner {{count}} profil(s) sélectionné(s) à un groupe d'extensions.",
|
||||
@@ -1276,7 +1282,6 @@
|
||||
"assignSuccess": "Groupe d'extensions assigné avec succès",
|
||||
"editExtension": "Modifier l'extension",
|
||||
"updateSuccess": "Extension mise à jour avec succès",
|
||||
"reupload": "Re-télécharger",
|
||||
"version": "Version",
|
||||
"author": "Auteur",
|
||||
"homepage": "Page d'accueil",
|
||||
@@ -1284,10 +1289,26 @@
|
||||
"editGroupDescription": "Mettez à jour le nom du groupe et gérez les extensions incluses.",
|
||||
"groupExtensions": "Extensions dans ce groupe",
|
||||
"noExtensionsInGroup": "Aucune extension ajoutée",
|
||||
"editExtensionDescription": "Modifier le nom de l'extension, voir les métadonnées ou re-télécharger le fichier d'extension.",
|
||||
"editExtensionDescription": "Modifiez le nom de l'extension, consultez ses métadonnées ou remplacez-la par une autre archive ou un autre dossier.",
|
||||
"metadata": "Métadonnées",
|
||||
"noMetadata": "Aucune métadonnée disponible depuis le manifeste.",
|
||||
"selectFile": "Choisir un fichier",
|
||||
"loadUnpacked": "Charger non empaquetée",
|
||||
"loadUnpackedTooltip": "Chargez une extension depuis un dossier contenant manifest.json",
|
||||
"selectFolderTitle": "Sélectionner le dossier de l'extension",
|
||||
"selectedFolder": "Dossier sélectionné",
|
||||
"selectFolder": "Choisir un dossier",
|
||||
"linkFolder": "Charger directement depuis ce dossier",
|
||||
"linkFolderOff": "Le dossier est copié dans Donut. L'extension est portable et se synchronise avec vos autres appareils.",
|
||||
"linkFolderOn": "Donut charge l'extension directement depuis ce dossier à chaque lancement. Vos modifications s'appliquent au prochain démarrage du navigateur, mais l'extension reste sur cet ordinateur et ne se synchronise jamais.",
|
||||
"replaceSource": "Remplacer la source",
|
||||
"linkedNoSync": "Les extensions liées restent sur cet ordinateur et ne peuvent pas être synchronisées.",
|
||||
"uploadFailed": "Échec de l'ajout de l'extension",
|
||||
"updateFailed": "Échec de la mise à jour de l'extension",
|
||||
"deleteFailed": "Échec de la suppression de l'extension",
|
||||
"groupCreateFailed": "Échec de la création du groupe d'extensions",
|
||||
"groupUpdateFailed": "Échec de la mise à jour du groupe d'extensions",
|
||||
"groupDeleteFailed": "Échec de la suppression du groupe d'extensions",
|
||||
"syncEnabled": "Synchronisation activée",
|
||||
"syncDisabled": "Synchronisation désactivée",
|
||||
"syncEnableTooltip": "Activer la synchronisation",
|
||||
@@ -1300,6 +1321,14 @@
|
||||
"groupsTitle": "Supprimer les groupes d'extensions",
|
||||
"groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}",
|
||||
"confirmButton": "Supprimer"
|
||||
},
|
||||
"source": {
|
||||
"label": "Source",
|
||||
"archive": "Archive",
|
||||
"unpacked": "Dossier non empaqueté",
|
||||
"linked": "Dossier lié",
|
||||
"folderLabel": "Dossier",
|
||||
"linkedTooltip": "Chargée directement depuis {{path}}"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1843,6 +1872,15 @@
|
||||
"vpnNotFound": "VPN introuvable",
|
||||
"extensionNotFound": "Extension introuvable",
|
||||
"extensionGroupNotFound": "Groupe d'extensions introuvable",
|
||||
"extensionUnsupportedFileType": "Ce type de fichier n'est pas pris en charge. Une extension doit être une archive .crx ou .zip, ou un dossier.",
|
||||
"extensionDirNotFound": "Ce dossier n'existe plus.",
|
||||
"extensionNotADirectory": "Ce chemin n'est pas un dossier.",
|
||||
"extensionManifestMissing": "Il n'y a pas de manifest.json dans ce dossier. Choisissez le dossier qui contient le manifest.json de l'extension.",
|
||||
"extensionManifestInvalid": "Le fichier manifest.json de ce dossier n'a pas pu être lu.",
|
||||
"extensionDirTooLarge": "Ce dossier est trop volumineux pour être copié dans Donut (la limite est de 256 Mo et 20 000 fichiers). Liez-le sur place à la place.",
|
||||
"extensionPathHasComma": "Le chemin de ce dossier contient une virgule, que Chromium ne peut pas charger. Renommez ou déplacez le dossier.",
|
||||
"extensionLinkRequiresDirectory": "Seul un dossier peut être chargé sur place. Désactivez la liaison pour ajouter une archive.",
|
||||
"extensionLinkedCannotSync": "Cette extension est chargée depuis un dossier de cet ordinateur : il n'y a rien à synchroniser.",
|
||||
"cannotModifyCloudManagedProxy": "Impossible de modifier la synchronisation d'un proxy géré dans le cloud",
|
||||
"syncLockedByProfile": "La synchronisation ne peut pas être désactivée tant qu'elle est utilisée par des profils synchronisés",
|
||||
"syncNotConfigured": "La synchronisation n'est pas configurée. Connectez-vous ou configurez un serveur auto-hébergé.",
|
||||
@@ -2210,7 +2248,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé. Nécessite un forfait payant."
|
||||
"hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucun profil inscrit",
|
||||
|
||||
@@ -1230,6 +1230,12 @@
|
||||
"syncing": "同期中",
|
||||
"synced": "同期済み",
|
||||
"error": "エラー"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、ここで管理できる Cookie はありません。",
|
||||
"extensionsUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、拡張機能グループを割り当てられません。",
|
||||
"syncUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、クラウドに同期するものはありません。",
|
||||
"hint": "データを保持したい場合は通常のプロファイルを作成してください。"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1265,7 +1271,7 @@
|
||||
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"deleteGroupConfirmTitle": "拡張機能グループを削除",
|
||||
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。",
|
||||
"invalidFileType": "ファイル形式が正しくありません。.crx または .zip ファイルを選択してください。",
|
||||
"readError": "拡張機能ファイルの読み取りに失敗しました。",
|
||||
"assignTitle": "拡張機能グループの割り当て",
|
||||
"assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。",
|
||||
@@ -1273,7 +1279,6 @@
|
||||
"assignSuccess": "拡張機能グループが正常に割り当てられました",
|
||||
"editExtension": "拡張機能を編集",
|
||||
"updateSuccess": "拡張機能が正常に更新されました",
|
||||
"reupload": "再アップロード",
|
||||
"version": "バージョン",
|
||||
"author": "作者",
|
||||
"homepage": "ホームページ",
|
||||
@@ -1281,10 +1286,26 @@
|
||||
"editGroupDescription": "グループ名を更新し、含まれる拡張機能を管理します。",
|
||||
"groupExtensions": "このグループの拡張機能",
|
||||
"noExtensionsInGroup": "拡張機能がまだ追加されていません",
|
||||
"editExtensionDescription": "拡張機能の名前を更新、メタデータを表示、またはファイルを再アップロードします。",
|
||||
"editExtensionDescription": "拡張機能の名前を変更したり、メタデータを確認したり、別のアーカイブやフォルダに置き換えたりできます。",
|
||||
"metadata": "メタデータ",
|
||||
"noMetadata": "マニフェストからのメタデータはありません。",
|
||||
"selectFile": "ファイルを選択",
|
||||
"loadUnpacked": "フォルダから読み込む",
|
||||
"loadUnpackedTooltip": "manifest.json を含むフォルダから拡張機能を読み込みます",
|
||||
"selectFolderTitle": "拡張機能のフォルダを選択",
|
||||
"selectedFolder": "選択したフォルダ",
|
||||
"selectFolder": "フォルダを選択",
|
||||
"linkFolder": "このフォルダから直接読み込む",
|
||||
"linkFolderOff": "フォルダは Donut にコピーされます。拡張機能は持ち運べるようになり、他のデバイスにも同期されます。",
|
||||
"linkFolderOn": "Donut は起動のたびにこのフォルダから直接拡張機能を読み込みます。編集内容は次回のブラウザ起動時に反映されますが、拡張機能はこの端末にのみ残り、同期されません。",
|
||||
"replaceSource": "ソースを置き換える",
|
||||
"linkedNoSync": "リンクした拡張機能はこの端末にのみ残るため、同期できません。",
|
||||
"uploadFailed": "拡張機能を追加できませんでした",
|
||||
"updateFailed": "拡張機能を更新できませんでした",
|
||||
"deleteFailed": "拡張機能を削除できませんでした",
|
||||
"groupCreateFailed": "拡張機能グループを作成できませんでした",
|
||||
"groupUpdateFailed": "拡張機能グループを更新できませんでした",
|
||||
"groupDeleteFailed": "拡張機能グループを削除できませんでした",
|
||||
"syncEnabled": "同期が有効",
|
||||
"syncDisabled": "同期が無効",
|
||||
"syncEnableTooltip": "同期を有効にする",
|
||||
@@ -1297,6 +1318,14 @@
|
||||
"groupsTitle": "拡張機能グループを削除",
|
||||
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
|
||||
"confirmButton": "削除"
|
||||
},
|
||||
"source": {
|
||||
"label": "ソース",
|
||||
"archive": "アーカイブ",
|
||||
"unpacked": "展開済みフォルダ",
|
||||
"linked": "リンクされたフォルダ",
|
||||
"folderLabel": "フォルダ",
|
||||
"linkedTooltip": "{{path}} から直接読み込みます"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1836,6 +1865,15 @@
|
||||
"vpnNotFound": "VPNが見つかりません",
|
||||
"extensionNotFound": "拡張機能が見つかりません",
|
||||
"extensionGroupNotFound": "拡張機能グループが見つかりません",
|
||||
"extensionUnsupportedFileType": "この形式には対応していません。拡張機能は .crx または .zip アーカイブ、あるいはフォルダである必要があります。",
|
||||
"extensionDirNotFound": "そのフォルダは存在しません。",
|
||||
"extensionNotADirectory": "そのパスはフォルダではありません。",
|
||||
"extensionManifestMissing": "そのフォルダに manifest.json がありません。拡張機能の manifest.json があるフォルダを選択してください。",
|
||||
"extensionManifestInvalid": "そのフォルダの manifest.json を読み取れませんでした。",
|
||||
"extensionDirTooLarge": "そのフォルダは大きすぎて Donut にコピーできません(上限は 256 MB・20,000 ファイル)。代わりにリンクして読み込んでください。",
|
||||
"extensionPathHasComma": "そのフォルダのパスにカンマが含まれており、Chromium が読み込めません。フォルダの名前を変更するか、移動してください。",
|
||||
"extensionLinkRequiresDirectory": "その場で読み込めるのはフォルダのみです。アーカイブを追加するにはリンクをオフにしてください。",
|
||||
"extensionLinkedCannotSync": "この拡張機能はこの端末のフォルダから読み込まれているため、同期する対象がありません。",
|
||||
"cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません",
|
||||
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
|
||||
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
|
||||
@@ -2203,7 +2241,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。有料プランが必要です。"
|
||||
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。"
|
||||
},
|
||||
"empty": {
|
||||
"title": "登録されたプロファイルはありません",
|
||||
|
||||
@@ -1230,6 +1230,12 @@
|
||||
"syncing": "동기화 중",
|
||||
"synced": "동기화됨",
|
||||
"error": "오류"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 여기에서 관리할 쿠키가 없습니다.",
|
||||
"extensionsUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 확장 프로그램 그룹을 지정할 수 없습니다.",
|
||||
"syncUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 클라우드에 동기화할 항목이 없습니다.",
|
||||
"hint": "데이터를 유지하려면 일반 프로필을 만드세요."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1265,7 +1271,7 @@
|
||||
"deleteConfirmDescription": "\"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"deleteGroupConfirmTitle": "확장 프로그램 그룹 삭제",
|
||||
"deleteGroupConfirmDescription": "그룹 \"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"invalidFileType": "잘못된 파일 유형입니다. .crx, .xpi 또는 .zip 파일을 업로드하세요.",
|
||||
"invalidFileType": "지원하지 않는 파일 형식입니다. .crx 또는 .zip 파일을 선택하세요.",
|
||||
"readError": "확장 프로그램 파일 읽기 실패.",
|
||||
"assignTitle": "확장 프로그램 그룹 할당",
|
||||
"assignDescription": "선택한 {{count}}개 프로필을 확장 프로그램 그룹에 할당합니다.",
|
||||
@@ -1273,7 +1279,6 @@
|
||||
"assignSuccess": "확장 프로그램 그룹이 할당되었습니다",
|
||||
"editExtension": "확장 프로그램 편집",
|
||||
"updateSuccess": "확장 프로그램이 업데이트되었습니다",
|
||||
"reupload": "다시 업로드",
|
||||
"version": "버전",
|
||||
"author": "작성자",
|
||||
"homepage": "홈페이지",
|
||||
@@ -1281,10 +1286,26 @@
|
||||
"editGroupDescription": "그룹 이름을 업데이트하고 포함된 확장 프로그램을 관리합니다.",
|
||||
"groupExtensions": "이 그룹의 확장 프로그램",
|
||||
"noExtensionsInGroup": "아직 추가된 확장 프로그램이 없습니다",
|
||||
"editExtensionDescription": "확장 프로그램 이름을 업데이트하거나, 메타데이터를 보거나, 확장 프로그램 파일을 다시 업로드합니다.",
|
||||
"editExtensionDescription": "확장 프로그램 이름을 변경하고, 메타데이터를 확인하고, 다른 압축 파일이나 폴더로 교체할 수 있습니다.",
|
||||
"metadata": "메타데이터",
|
||||
"noMetadata": "manifest에서 사용할 수 있는 메타데이터가 없습니다.",
|
||||
"selectFile": "파일 선택",
|
||||
"loadUnpacked": "폴더에서 불러오기",
|
||||
"loadUnpackedTooltip": "manifest.json이 있는 폴더에서 확장 프로그램을 불러옵니다",
|
||||
"selectFolderTitle": "확장 프로그램 폴더 선택",
|
||||
"selectedFolder": "선택한 폴더",
|
||||
"selectFolder": "폴더 선택",
|
||||
"linkFolder": "이 폴더에서 바로 불러오기",
|
||||
"linkFolderOff": "폴더가 Donut으로 복사됩니다. 확장 프로그램을 옮길 수 있고 다른 기기와 동기화됩니다.",
|
||||
"linkFolderOn": "Donut이 실행할 때마다 이 폴더에서 바로 확장 프로그램을 불러옵니다. 수정한 내용은 브라우저를 다시 시작할 때 적용되지만, 확장 프로그램은 이 컴퓨터에만 남고 동기화되지 않습니다.",
|
||||
"replaceSource": "소스 교체",
|
||||
"linkedNoSync": "연결된 확장 프로그램은 이 컴퓨터에만 있어 동기화할 수 없습니다.",
|
||||
"uploadFailed": "확장 프로그램을 추가하지 못했습니다",
|
||||
"updateFailed": "확장 프로그램을 업데이트하지 못했습니다",
|
||||
"deleteFailed": "확장 프로그램을 삭제하지 못했습니다",
|
||||
"groupCreateFailed": "확장 프로그램 그룹을 만들지 못했습니다",
|
||||
"groupUpdateFailed": "확장 프로그램 그룹을 업데이트하지 못했습니다",
|
||||
"groupDeleteFailed": "확장 프로그램 그룹을 삭제하지 못했습니다",
|
||||
"syncEnabled": "동기화 사용됨",
|
||||
"syncDisabled": "동기화 사용 안 함",
|
||||
"syncEnableTooltip": "동기화 사용",
|
||||
@@ -1297,6 +1318,14 @@
|
||||
"groupsTitle": "확장 프로그램 그룹 삭제",
|
||||
"groupsDescription": "{{count}}개의 확장 프로그램 그룹을 삭제하시겠습니까? {{names}}",
|
||||
"confirmButton": "삭제"
|
||||
},
|
||||
"source": {
|
||||
"label": "소스",
|
||||
"archive": "압축 파일",
|
||||
"unpacked": "압축 해제된 폴더",
|
||||
"linked": "연결된 폴더",
|
||||
"folderLabel": "폴더",
|
||||
"linkedTooltip": "{{path}}에서 바로 불러옵니다"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1836,6 +1865,15 @@
|
||||
"vpnNotFound": "VPN을 찾을 수 없습니다",
|
||||
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
|
||||
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
|
||||
"extensionUnsupportedFileType": "지원하지 않는 파일 형식입니다. 확장 프로그램은 .crx 또는 .zip 압축 파일이거나 폴더여야 합니다.",
|
||||
"extensionDirNotFound": "해당 폴더가 더 이상 존재하지 않습니다.",
|
||||
"extensionNotADirectory": "해당 경로는 폴더가 아닙니다.",
|
||||
"extensionManifestMissing": "해당 폴더에 manifest.json이 없습니다. 확장 프로그램의 manifest.json이 있는 폴더를 선택하세요.",
|
||||
"extensionManifestInvalid": "해당 폴더의 manifest.json을 읽을 수 없습니다.",
|
||||
"extensionDirTooLarge": "폴더가 너무 커서 Donut으로 복사할 수 없습니다(최대 256MB, 20,000개 파일). 대신 폴더를 연결해 사용하세요.",
|
||||
"extensionPathHasComma": "폴더 경로에 쉼표가 있어 Chromium이 불러올 수 없습니다. 폴더 이름을 바꾸거나 옮기세요.",
|
||||
"extensionLinkRequiresDirectory": "폴더만 그 자리에서 불러올 수 있습니다. 압축 파일을 추가하려면 연결을 끄세요.",
|
||||
"extensionLinkedCannotSync": "이 확장 프로그램은 이 컴퓨터의 폴더에서 불러오므로 동기화할 항목이 없습니다.",
|
||||
"cannotModifyCloudManagedProxy": "클라우드 관리 프록시의 동기화는 수정할 수 없습니다",
|
||||
"syncLockedByProfile": "동기화된 프로필에서 사용 중인 동안에는 동기화를 비활성화할 수 없습니다",
|
||||
"syncNotConfigured": "동기화가 구성되지 않았습니다. 먼저 로그인하거나 자체 호스팅 서버를 구성하세요.",
|
||||
@@ -2203,7 +2241,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다. 유료 요금제가 필요합니다."
|
||||
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다."
|
||||
},
|
||||
"empty": {
|
||||
"title": "등록된 프로필이 없습니다",
|
||||
|
||||
@@ -1233,6 +1233,12 @@
|
||||
"syncing": "Sincronizando",
|
||||
"synced": "Sincronizado",
|
||||
"error": "Erro"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não há cookies para gerenciar aqui.",
|
||||
"extensionsUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não é possível atribuir grupos de extensões a eles.",
|
||||
"syncUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não há nada para sincronizar com a nuvem.",
|
||||
"hint": "Crie um perfil normal se precisar que isso seja mantido."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1268,7 +1274,7 @@
|
||||
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.",
|
||||
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
|
||||
"deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.",
|
||||
"invalidFileType": "Tipo de arquivo inválido. Envie um arquivo .crx, .xpi ou .zip.",
|
||||
"invalidFileType": "Tipo de arquivo inválido. Escolha um arquivo .crx ou .zip.",
|
||||
"readError": "Falha ao ler o arquivo de extensão.",
|
||||
"assignTitle": "Atribuir Grupo de Extensões",
|
||||
"assignDescription": "Atribuir {{count}} perfil(is) selecionado(s) a um grupo de extensões.",
|
||||
@@ -1276,7 +1282,6 @@
|
||||
"assignSuccess": "Grupo de extensões atribuído com sucesso",
|
||||
"editExtension": "Editar extensão",
|
||||
"updateSuccess": "Extensão atualizada com sucesso",
|
||||
"reupload": "Re-enviar",
|
||||
"version": "Versão",
|
||||
"author": "Autor",
|
||||
"homepage": "Página inicial",
|
||||
@@ -1284,10 +1289,26 @@
|
||||
"editGroupDescription": "Atualize o nome do grupo e gerencie quais extensões estão incluídas.",
|
||||
"groupExtensions": "Extensões neste grupo",
|
||||
"noExtensionsInGroup": "Nenhuma extensão adicionada ainda",
|
||||
"editExtensionDescription": "Atualizar o nome da extensão, ver metadados ou reenviar o arquivo da extensão.",
|
||||
"editExtensionDescription": "Atualize o nome da extensão, veja seus metadados ou substitua-a por outro arquivo compactado ou pasta.",
|
||||
"metadata": "Metadados",
|
||||
"noMetadata": "Nenhum metadado disponível do manifesto.",
|
||||
"selectFile": "Escolher arquivo",
|
||||
"loadUnpacked": "Carregar descompactada",
|
||||
"loadUnpackedTooltip": "Carregue uma extensão a partir de uma pasta com manifest.json",
|
||||
"selectFolderTitle": "Selecionar pasta da extensão",
|
||||
"selectedFolder": "Pasta selecionada",
|
||||
"selectFolder": "Escolher pasta",
|
||||
"linkFolder": "Carregar direto desta pasta",
|
||||
"linkFolderOff": "A pasta é copiada para o Donut. A extensão fica portátil e sincroniza com seus outros dispositivos.",
|
||||
"linkFolderOn": "O Donut carrega a extensão direto desta pasta a cada inicialização. Suas edições valem na próxima abertura do navegador, mas a extensão fica só neste computador e nunca sincroniza.",
|
||||
"replaceSource": "Substituir origem",
|
||||
"linkedNoSync": "Extensões vinculadas ficam só neste computador e não sincronizam.",
|
||||
"uploadFailed": "Falha ao adicionar a extensão",
|
||||
"updateFailed": "Falha ao atualizar a extensão",
|
||||
"deleteFailed": "Falha ao excluir a extensão",
|
||||
"groupCreateFailed": "Falha ao criar o grupo de extensões",
|
||||
"groupUpdateFailed": "Falha ao atualizar o grupo de extensões",
|
||||
"groupDeleteFailed": "Falha ao excluir o grupo de extensões",
|
||||
"syncEnabled": "Sincronização ativada",
|
||||
"syncDisabled": "Sincronização desativada",
|
||||
"syncEnableTooltip": "Ativar sincronização",
|
||||
@@ -1300,6 +1321,14 @@
|
||||
"groupsTitle": "Excluir grupos de extensões",
|
||||
"groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}",
|
||||
"confirmButton": "Excluir"
|
||||
},
|
||||
"source": {
|
||||
"label": "Origem",
|
||||
"archive": "Arquivo compactado",
|
||||
"unpacked": "Pasta descompactada",
|
||||
"linked": "Pasta vinculada",
|
||||
"folderLabel": "Pasta",
|
||||
"linkedTooltip": "Carregada direto de {{path}}"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1843,6 +1872,15 @@
|
||||
"vpnNotFound": "VPN não encontrada",
|
||||
"extensionNotFound": "Extensão não encontrada",
|
||||
"extensionGroupNotFound": "Grupo de extensões não encontrado",
|
||||
"extensionUnsupportedFileType": "Esse tipo de arquivo não é compatível. Uma extensão precisa ser um arquivo .crx ou .zip, ou uma pasta.",
|
||||
"extensionDirNotFound": "Essa pasta não existe mais.",
|
||||
"extensionNotADirectory": "Esse caminho não é uma pasta.",
|
||||
"extensionManifestMissing": "Não há manifest.json nessa pasta. Escolha a pasta que contém o manifest.json da extensão.",
|
||||
"extensionManifestInvalid": "Não foi possível ler o manifest.json dessa pasta.",
|
||||
"extensionDirTooLarge": "Essa pasta é grande demais para copiar para o Donut (o limite é 256 MB e 20.000 arquivos). Vincule-a no lugar.",
|
||||
"extensionPathHasComma": "O caminho dessa pasta tem uma vírgula, que o Chromium não consegue carregar. Renomeie ou mova a pasta.",
|
||||
"extensionLinkRequiresDirectory": "Só uma pasta pode ser carregada no lugar. Desative o vínculo para adicionar um arquivo compactado.",
|
||||
"extensionLinkedCannotSync": "Esta extensão é carregada de uma pasta deste computador, então não há nada para sincronizar.",
|
||||
"cannotModifyCloudManagedProxy": "Não é possível modificar a sincronização de um proxy gerenciado na nuvem",
|
||||
"syncLockedByProfile": "A sincronização não pode ser desativada enquanto estiver em uso por perfis sincronizados",
|
||||
"syncNotConfigured": "A sincronização não está configurada. Faça login ou configure um servidor auto-hospedado.",
|
||||
@@ -2210,7 +2248,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado. Requer um plano pago."
|
||||
"hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Nenhum perfil inscrito",
|
||||
|
||||
@@ -1236,6 +1236,12 @@
|
||||
"syncing": "Синхронизация",
|
||||
"synced": "Синхронизировано",
|
||||
"error": "Ошибка"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "Временные профили удаляются при закрытии браузера, поэтому здесь нет cookies для управления.",
|
||||
"extensionsUnavailable": "Временные профили удаляются при закрытии браузера, поэтому назначить им группы расширений нельзя.",
|
||||
"syncUnavailable": "Временные профили удаляются при закрытии браузера, поэтому синхронизировать с облаком нечего.",
|
||||
"hint": "Создайте обычный профиль, если эти данные должны сохраняться."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1271,7 +1277,7 @@
|
||||
"deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.",
|
||||
"deleteGroupConfirmTitle": "Удалить группу расширений",
|
||||
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
|
||||
"invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.",
|
||||
"invalidFileType": "Неподдерживаемый тип файла. Выберите файл .crx или .zip.",
|
||||
"readError": "Не удалось прочитать файл расширения.",
|
||||
"assignTitle": "Назначить группу расширений",
|
||||
"assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.",
|
||||
@@ -1279,7 +1285,6 @@
|
||||
"assignSuccess": "Группа расширений успешно назначена",
|
||||
"editExtension": "Редактировать расширение",
|
||||
"updateSuccess": "Расширение успешно обновлено",
|
||||
"reupload": "Загрузить заново",
|
||||
"version": "Версия",
|
||||
"author": "Автор",
|
||||
"homepage": "Домашняя страница",
|
||||
@@ -1287,10 +1292,26 @@
|
||||
"editGroupDescription": "Обновите название группы и управляйте включёнными расширениями.",
|
||||
"groupExtensions": "Расширения в этой группе",
|
||||
"noExtensionsInGroup": "Расширения ещё не добавлены",
|
||||
"editExtensionDescription": "Обновите имя расширения, просмотрите метаданные или загрузите файл расширения повторно.",
|
||||
"editExtensionDescription": "Измените имя расширения, посмотрите его метаданные или замените его другим архивом либо папкой.",
|
||||
"metadata": "Метаданные",
|
||||
"noMetadata": "Метаданные из манифеста недоступны.",
|
||||
"selectFile": "Выбрать файл",
|
||||
"loadUnpacked": "Загрузить из папки",
|
||||
"loadUnpackedTooltip": "Загрузить расширение из папки с файлом manifest.json",
|
||||
"selectFolderTitle": "Выберите папку расширения",
|
||||
"selectedFolder": "Выбранная папка",
|
||||
"selectFolder": "Выбрать папку",
|
||||
"linkFolder": "Загружать прямо из этой папки",
|
||||
"linkFolderOff": "Папка копируется в Donut. Расширение можно переносить, и оно синхронизируется с другими устройствами.",
|
||||
"linkFolderOn": "Donut загружает расширение прямо из этой папки при каждом запуске. Изменения применяются при следующем старте браузера, но расширение остаётся только на этом компьютере и не синхронизируется.",
|
||||
"replaceSource": "Заменить источник",
|
||||
"linkedNoSync": "Связанные расширения остаются только на этом компьютере и не синхронизируются.",
|
||||
"uploadFailed": "Не удалось добавить расширение",
|
||||
"updateFailed": "Не удалось обновить расширение",
|
||||
"deleteFailed": "Не удалось удалить расширение",
|
||||
"groupCreateFailed": "Не удалось создать группу расширений",
|
||||
"groupUpdateFailed": "Не удалось обновить группу расширений",
|
||||
"groupDeleteFailed": "Не удалось удалить группу расширений",
|
||||
"syncEnabled": "Синхронизация включена",
|
||||
"syncDisabled": "Синхронизация отключена",
|
||||
"syncEnableTooltip": "Включить синхронизацию",
|
||||
@@ -1303,6 +1324,14 @@
|
||||
"groupsTitle": "Удалить группы расширений",
|
||||
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
|
||||
"confirmButton": "Удалить"
|
||||
},
|
||||
"source": {
|
||||
"label": "Источник",
|
||||
"archive": "Архив",
|
||||
"unpacked": "Распакованная папка",
|
||||
"linked": "Связанная папка",
|
||||
"folderLabel": "Папка",
|
||||
"linkedTooltip": "Загружается прямо из {{path}}"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1850,6 +1879,15 @@
|
||||
"vpnNotFound": "VPN не найден",
|
||||
"extensionNotFound": "Расширение не найдено",
|
||||
"extensionGroupNotFound": "Группа расширений не найдена",
|
||||
"extensionUnsupportedFileType": "Этот тип файла не поддерживается. Расширение должно быть архивом .crx или .zip либо папкой.",
|
||||
"extensionDirNotFound": "Эта папка больше не существует.",
|
||||
"extensionNotADirectory": "Указанный путь не является папкой.",
|
||||
"extensionManifestMissing": "В этой папке нет файла manifest.json. Выберите папку, в которой лежит manifest.json расширения.",
|
||||
"extensionManifestInvalid": "Не удалось прочитать manifest.json в этой папке.",
|
||||
"extensionDirTooLarge": "Папка слишком большая, чтобы скопировать её в Donut (не более 256 МБ и 20 000 файлов). Вместо этого свяжите её.",
|
||||
"extensionPathHasComma": "В пути к папке есть запятая, которую Chromium не может обработать. Переименуйте или переместите папку.",
|
||||
"extensionLinkRequiresDirectory": "Загружать на месте можно только папку. Отключите связывание, чтобы добавить архив.",
|
||||
"extensionLinkedCannotSync": "Это расширение загружается из папки на этом компьютере, поэтому синхронизировать нечего.",
|
||||
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
|
||||
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
|
||||
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
|
||||
@@ -2217,7 +2255,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен. Требуется платный тариф."
|
||||
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Нет подключённых профилей",
|
||||
|
||||
@@ -1230,6 +1230,12 @@
|
||||
"syncing": "Eşitleniyor",
|
||||
"synced": "Eşitlendi",
|
||||
"error": "Hata"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden burada yönetilecek çerez yoktur.",
|
||||
"extensionsUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden onlara uzantı grubu atanamaz.",
|
||||
"syncUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden buluta eşitlenecek bir şey yoktur.",
|
||||
"hint": "Bunun kalıcı olmasını istiyorsanız normal bir profil oluşturun."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1265,7 +1271,7 @@
|
||||
"deleteConfirmDescription": "\"{{name}}\" uzantısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"deleteGroupConfirmTitle": "Uzantı Grubunu Sil",
|
||||
"deleteGroupConfirmDescription": "\"{{name}}\" grubunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"invalidFileType": "Geçersiz dosya türü. Lütfen bir .crx, .xpi veya .zip dosyası yükleyin.",
|
||||
"invalidFileType": "Geçersiz dosya türü. Lütfen bir .crx veya .zip dosyası seçin.",
|
||||
"readError": "Uzantı dosyası okunamadı.",
|
||||
"assignTitle": "Uzantı Grubu Ata",
|
||||
"assignDescription": "Seçili {{count}} profili bir uzantı grubuna atayın.",
|
||||
@@ -1273,7 +1279,6 @@
|
||||
"assignSuccess": "Uzantı grubu başarıyla atandı",
|
||||
"editExtension": "Uzantıyı düzenle",
|
||||
"updateSuccess": "Uzantı başarıyla güncellendi",
|
||||
"reupload": "Yeniden yükle",
|
||||
"version": "Sürüm",
|
||||
"author": "Yazar",
|
||||
"homepage": "Ana sayfa",
|
||||
@@ -1281,10 +1286,26 @@
|
||||
"editGroupDescription": "Grup adını güncelleyin ve gruba dahil uzantıları yönetin.",
|
||||
"groupExtensions": "Bu gruptaki uzantılar",
|
||||
"noExtensionsInGroup": "Henüz uzantı eklenmedi",
|
||||
"editExtensionDescription": "Uzantı adını güncelleyin, üst verileri görüntüleyin veya uzantı dosyasını yeniden yükleyin.",
|
||||
"editExtensionDescription": "Uzantının adını güncelleyin, meta verilerini görüntüleyin veya başka bir arşiv ya da klasörle değiştirin.",
|
||||
"metadata": "Üst Veriler",
|
||||
"noMetadata": "Manifest'te üst veri yok.",
|
||||
"selectFile": "Dosya Seç",
|
||||
"loadUnpacked": "Klasörden yükle",
|
||||
"loadUnpackedTooltip": "manifest.json içeren bir klasörden uzantı yükleyin",
|
||||
"selectFolderTitle": "Uzantı klasörünü seçin",
|
||||
"selectedFolder": "Seçilen klasör",
|
||||
"selectFolder": "Klasör seç",
|
||||
"linkFolder": "Doğrudan bu klasörden yükle",
|
||||
"linkFolderOff": "Klasör Donut'a kopyalanır. Uzantı taşınabilir olur ve diğer cihazlarınızla eşitlenir.",
|
||||
"linkFolderOn": "Donut her başlatmada uzantıyı doğrudan bu klasörden yükler. Değişiklikleriniz tarayıcının bir sonraki açılışında geçerli olur, ancak uzantı yalnızca bu bilgisayarda kalır ve hiçbir zaman eşitlenmez.",
|
||||
"replaceSource": "Kaynağı değiştir",
|
||||
"linkedNoSync": "Bağlı uzantılar yalnızca bu bilgisayarda kalır ve eşitlenemez.",
|
||||
"uploadFailed": "Uzantı eklenemedi",
|
||||
"updateFailed": "Uzantı güncellenemedi",
|
||||
"deleteFailed": "Uzantı silinemedi",
|
||||
"groupCreateFailed": "Uzantı grubu oluşturulamadı",
|
||||
"groupUpdateFailed": "Uzantı grubu güncellenemedi",
|
||||
"groupDeleteFailed": "Uzantı grubu silinemedi",
|
||||
"syncEnabled": "Eşitleme etkinleştirildi",
|
||||
"syncDisabled": "Eşitleme devre dışı bırakıldı",
|
||||
"syncEnableTooltip": "Eşitlemeyi etkinleştir",
|
||||
@@ -1297,6 +1318,14 @@
|
||||
"groupsTitle": "Uzantı gruplarını sil",
|
||||
"groupsDescription": "{{count}} uzantı grubu silinsin mi? {{names}}",
|
||||
"confirmButton": "Sil"
|
||||
},
|
||||
"source": {
|
||||
"label": "Kaynak",
|
||||
"archive": "Arşiv",
|
||||
"unpacked": "Paketlenmemiş klasör",
|
||||
"linked": "Bağlı klasör",
|
||||
"folderLabel": "Klasör",
|
||||
"linkedTooltip": "Doğrudan {{path}} konumundan yükleniyor"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1836,6 +1865,15 @@
|
||||
"vpnNotFound": "VPN bulunamadı",
|
||||
"extensionNotFound": "Uzantı bulunamadı",
|
||||
"extensionGroupNotFound": "Uzantı grubu bulunamadı",
|
||||
"extensionUnsupportedFileType": "Bu dosya türü desteklenmiyor. Bir uzantı .crx ya da .zip arşivi veya bir klasör olmalıdır.",
|
||||
"extensionDirNotFound": "Bu klasör artık mevcut değil.",
|
||||
"extensionNotADirectory": "Bu yol bir klasör değil.",
|
||||
"extensionManifestMissing": "Bu klasörde manifest.json yok. Uzantının manifest.json dosyasını içeren klasörü seçin.",
|
||||
"extensionManifestInvalid": "Bu klasördeki manifest.json okunamadı.",
|
||||
"extensionDirTooLarge": "Bu klasör Donut'a kopyalanamayacak kadar büyük (sınır: 256 MB ve 20.000 dosya). Bunun yerine klasörü bağlayın.",
|
||||
"extensionPathHasComma": "Bu klasörün yolunda virgül var ve Chromium bunu yükleyemez. Klasörü yeniden adlandırın veya taşıyın.",
|
||||
"extensionLinkRequiresDirectory": "Yerinde yalnızca bir klasör yüklenebilir. Arşiv eklemek için bağlamayı kapatın.",
|
||||
"extensionLinkedCannotSync": "Bu uzantı, bu bilgisayardaki bir klasörden yükleniyor; eşitlenecek bir şey yok.",
|
||||
"cannotModifyCloudManagedProxy": "Bulut tarafından yönetilen bir proxy'nin eşitlemesi değiştirilemez",
|
||||
"syncLockedByProfile": "Eşitlenen profiller tarafından kullanılırken eşitleme devre dışı bırakılamaz",
|
||||
"syncNotConfigured": "Eşitleme yapılandırılmadı. Önce oturum açın veya kendi sunucunuzu yapılandırın.",
|
||||
@@ -2203,7 +2241,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar. Ücretli bir plan gerekir."
|
||||
"hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Kayıtlı profil yok",
|
||||
|
||||
@@ -1230,6 +1230,12 @@
|
||||
"syncing": "Đang đồng bộ",
|
||||
"synced": "Đã đồng bộ",
|
||||
"error": "Lỗi"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên ở đây không có cookie nào để quản lý.",
|
||||
"extensionsUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên không thể gán nhóm tiện ích mở rộng cho chúng.",
|
||||
"syncUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên không có gì để đồng bộ lên đám mây.",
|
||||
"hint": "Hãy tạo hồ sơ thường nếu bạn cần giữ lại dữ liệu này."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1265,7 +1271,7 @@
|
||||
"deleteConfirmDescription": "Bạn có chắc muốn xóa \"{{name}}\"? Hành động này không thể hoàn tác.",
|
||||
"deleteGroupConfirmTitle": "Xóa nhóm tiện ích",
|
||||
"deleteGroupConfirmDescription": "Bạn có chắc muốn xóa nhóm \"{{name}}\"? Hành động này không thể hoàn tác.",
|
||||
"invalidFileType": "Loại tệp không hợp lệ. Vui lòng tải lên tệp .crx, .xpi hoặc .zip.",
|
||||
"invalidFileType": "Loại tệp không hợp lệ. Vui lòng chọn tệp .crx hoặc .zip.",
|
||||
"readError": "Đọc tệp tiện ích thất bại.",
|
||||
"assignTitle": "Gán nhóm tiện ích",
|
||||
"assignDescription": "Gán {{count}} profile đã chọn vào nhóm tiện ích.",
|
||||
@@ -1273,7 +1279,6 @@
|
||||
"assignSuccess": "Gán nhóm tiện ích thành công",
|
||||
"editExtension": "Chỉnh sửa tiện ích",
|
||||
"updateSuccess": "Cập nhật tiện ích thành công",
|
||||
"reupload": "Tải lên lại",
|
||||
"version": "Phiên bản",
|
||||
"author": "Tác giả",
|
||||
"homepage": "Trang chủ",
|
||||
@@ -1281,10 +1286,26 @@
|
||||
"editGroupDescription": "Cập nhật tên nhóm và quản lý tiện ích trong nhóm.",
|
||||
"groupExtensions": "Tiện ích trong nhóm này",
|
||||
"noExtensionsInGroup": "Chưa thêm tiện ích nào",
|
||||
"editExtensionDescription": "Cập nhật tên tiện ích, xem metadata hoặc tải lên lại tệp tiện ích.",
|
||||
"editExtensionDescription": "Cập nhật tên tiện ích, xem siêu dữ liệu hoặc thay bằng tệp nén hay thư mục khác.",
|
||||
"metadata": "Metadata",
|
||||
"noMetadata": "Không có metadata từ manifest.",
|
||||
"selectFile": "Chọn tệp",
|
||||
"loadUnpacked": "Tải từ thư mục",
|
||||
"loadUnpackedTooltip": "Tải tiện ích từ thư mục có chứa manifest.json",
|
||||
"selectFolderTitle": "Chọn thư mục tiện ích",
|
||||
"selectedFolder": "Thư mục đã chọn",
|
||||
"selectFolder": "Chọn thư mục",
|
||||
"linkFolder": "Tải trực tiếp từ thư mục này",
|
||||
"linkFolderOff": "Thư mục được sao chép vào Donut. Tiện ích có thể mang đi và đồng bộ sang các thiết bị khác của bạn.",
|
||||
"linkFolderOn": "Donut tải tiện ích trực tiếp từ thư mục này mỗi lần khởi chạy. Các thay đổi của bạn có hiệu lực ở lần mở trình duyệt tiếp theo, nhưng tiện ích chỉ nằm trên máy này và không bao giờ đồng bộ.",
|
||||
"replaceSource": "Thay nguồn",
|
||||
"linkedNoSync": "Tiện ích được liên kết chỉ nằm trên máy này nên không thể đồng bộ.",
|
||||
"uploadFailed": "Không thêm được tiện ích",
|
||||
"updateFailed": "Không cập nhật được tiện ích",
|
||||
"deleteFailed": "Không xóa được tiện ích",
|
||||
"groupCreateFailed": "Không tạo được nhóm tiện ích",
|
||||
"groupUpdateFailed": "Không cập nhật được nhóm tiện ích",
|
||||
"groupDeleteFailed": "Không xóa được nhóm tiện ích",
|
||||
"syncEnabled": "Đã bật đồng bộ",
|
||||
"syncDisabled": "Đã tắt đồng bộ",
|
||||
"syncEnableTooltip": "Bật đồng bộ",
|
||||
@@ -1297,6 +1318,14 @@
|
||||
"groupsTitle": "Xóa nhóm tiện ích",
|
||||
"groupsDescription": "Xóa {{count}} nhóm tiện ích? {{names}}",
|
||||
"confirmButton": "Xóa"
|
||||
},
|
||||
"source": {
|
||||
"label": "Nguồn",
|
||||
"archive": "Tệp nén",
|
||||
"unpacked": "Thư mục đã giải nén",
|
||||
"linked": "Thư mục liên kết",
|
||||
"folderLabel": "Thư mục",
|
||||
"linkedTooltip": "Tải trực tiếp từ {{path}}"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1836,6 +1865,15 @@
|
||||
"vpnNotFound": "Không tìm thấy VPN",
|
||||
"extensionNotFound": "Không tìm thấy tiện ích",
|
||||
"extensionGroupNotFound": "Không tìm thấy nhóm tiện ích",
|
||||
"extensionUnsupportedFileType": "Loại tệp này không được hỗ trợ. Tiện ích phải là tệp nén .crx hoặc .zip, hoặc một thư mục.",
|
||||
"extensionDirNotFound": "Thư mục đó không còn tồn tại.",
|
||||
"extensionNotADirectory": "Đường dẫn đó không phải là thư mục.",
|
||||
"extensionManifestMissing": "Thư mục đó không có manifest.json. Hãy chọn thư mục chứa manifest.json của tiện ích.",
|
||||
"extensionManifestInvalid": "Không đọc được manifest.json trong thư mục đó.",
|
||||
"extensionDirTooLarge": "Thư mục đó quá lớn để sao chép vào Donut (giới hạn là 256 MB và 20.000 tệp). Hãy liên kết thư mục thay vì sao chép.",
|
||||
"extensionPathHasComma": "Đường dẫn thư mục có dấu phẩy nên Chromium không tải được. Hãy đổi tên hoặc di chuyển thư mục.",
|
||||
"extensionLinkRequiresDirectory": "Chỉ có thể tải tại chỗ một thư mục. Hãy tắt liên kết để thêm tệp nén.",
|
||||
"extensionLinkedCannotSync": "Tiện ích này được tải từ một thư mục trên máy này nên không có gì để đồng bộ.",
|
||||
"cannotModifyCloudManagedProxy": "Không thể chỉnh sửa đồng bộ cho proxy được quản lý bởi đám mây",
|
||||
"syncLockedByProfile": "Không thể tắt đồng bộ khi đang được sử dụng bởi profile đã đồng bộ",
|
||||
"syncNotConfigured": "Chưa cấu hình đồng bộ. Đăng nhập hoặc cấu hình máy chủ tự lưu trữ trước.",
|
||||
@@ -2203,7 +2241,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn. Cần gói trả phí."
|
||||
"hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Chưa có hồ sơ nào được đăng ký",
|
||||
|
||||
@@ -1230,6 +1230,12 @@
|
||||
"syncing": "同步中",
|
||||
"synced": "已同步",
|
||||
"error": "错误"
|
||||
},
|
||||
"ephemeral": {
|
||||
"cookiesUnavailable": "临时配置在浏览器关闭时会被丢弃,因此这里没有可管理的 Cookie。",
|
||||
"extensionsUnavailable": "临时配置在浏览器关闭时会被丢弃,因此无法为其分配扩展分组。",
|
||||
"syncUnavailable": "临时配置在浏览器关闭时会被丢弃,因此没有可同步到云端的内容。",
|
||||
"hint": "如果需要保留这些数据,请创建普通配置。"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1265,7 +1271,7 @@
|
||||
"deleteConfirmDescription": "确定要删除「{{name}}」吗?此操作无法撤消。",
|
||||
"deleteGroupConfirmTitle": "删除扩展程序组",
|
||||
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
|
||||
"invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。",
|
||||
"invalidFileType": "文件类型无效。请选择 .crx 或 .zip 文件。",
|
||||
"readError": "读取扩展程序文件失败。",
|
||||
"assignTitle": "分配扩展程序组",
|
||||
"assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。",
|
||||
@@ -1273,7 +1279,6 @@
|
||||
"assignSuccess": "扩展程序组分配成功",
|
||||
"editExtension": "编辑扩展",
|
||||
"updateSuccess": "扩展更新成功",
|
||||
"reupload": "重新上传",
|
||||
"version": "版本",
|
||||
"author": "作者",
|
||||
"homepage": "主页",
|
||||
@@ -1281,10 +1286,26 @@
|
||||
"editGroupDescription": "更新分组名称并管理包含的扩展。",
|
||||
"groupExtensions": "此分组中的扩展",
|
||||
"noExtensionsInGroup": "尚未添加扩展",
|
||||
"editExtensionDescription": "更新扩展名称、查看元数据或重新上传扩展文件。",
|
||||
"editExtensionDescription": "修改扩展名称、查看元数据,或用其他压缩包或文件夹替换它。",
|
||||
"metadata": "元数据",
|
||||
"noMetadata": "清单中没有可用的元数据。",
|
||||
"selectFile": "选择文件",
|
||||
"loadUnpacked": "加载文件夹",
|
||||
"loadUnpackedTooltip": "从包含 manifest.json 的文件夹加载扩展",
|
||||
"selectFolderTitle": "选择扩展文件夹",
|
||||
"selectedFolder": "已选文件夹",
|
||||
"selectFolder": "选择文件夹",
|
||||
"linkFolder": "直接从该文件夹加载",
|
||||
"linkFolderOff": "文件夹会复制到 Donut,扩展可随身携带并同步到你的其他设备。",
|
||||
"linkFolderOn": "Donut 每次启动都直接从该文件夹加载扩展。你的修改会在下次启动浏览器时生效,但扩展只保留在本机,不会同步。",
|
||||
"replaceSource": "替换来源",
|
||||
"linkedNoSync": "已链接的扩展只保留在本机,无法同步。",
|
||||
"uploadFailed": "添加扩展失败",
|
||||
"updateFailed": "更新扩展失败",
|
||||
"deleteFailed": "删除扩展失败",
|
||||
"groupCreateFailed": "创建扩展组失败",
|
||||
"groupUpdateFailed": "更新扩展组失败",
|
||||
"groupDeleteFailed": "删除扩展组失败",
|
||||
"syncEnabled": "同步已启用",
|
||||
"syncDisabled": "同步已禁用",
|
||||
"syncEnableTooltip": "启用同步",
|
||||
@@ -1297,6 +1318,14 @@
|
||||
"groupsTitle": "删除扩展组",
|
||||
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
|
||||
"confirmButton": "删除"
|
||||
},
|
||||
"source": {
|
||||
"label": "来源",
|
||||
"archive": "压缩包",
|
||||
"unpacked": "解压文件夹",
|
||||
"linked": "链接文件夹",
|
||||
"folderLabel": "文件夹",
|
||||
"linkedTooltip": "直接从 {{path}} 加载"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1836,6 +1865,15 @@
|
||||
"vpnNotFound": "未找到 VPN",
|
||||
"extensionNotFound": "未找到扩展",
|
||||
"extensionGroupNotFound": "未找到扩展分组",
|
||||
"extensionUnsupportedFileType": "不支持该文件类型。扩展必须是 .crx 或 .zip 压缩包,或者一个文件夹。",
|
||||
"extensionDirNotFound": "该文件夹已不存在。",
|
||||
"extensionNotADirectory": "该路径不是文件夹。",
|
||||
"extensionManifestMissing": "该文件夹中没有 manifest.json。请选择包含扩展 manifest.json 的文件夹。",
|
||||
"extensionManifestInvalid": "无法读取该文件夹中的 manifest.json。",
|
||||
"extensionDirTooLarge": "该文件夹过大,无法复制到 Donut(上限为 256 MB、20,000 个文件)。请改用链接方式加载。",
|
||||
"extensionPathHasComma": "该文件夹路径中包含逗号,Chromium 无法加载。请重命名或移动该文件夹。",
|
||||
"extensionLinkRequiresDirectory": "只有文件夹才能就地加载。要添加压缩包,请关闭链接选项。",
|
||||
"extensionLinkedCannotSync": "该扩展是从本机文件夹加载的,没有需要同步的内容。",
|
||||
"cannotModifyCloudManagedProxy": "无法修改云管理代理的同步",
|
||||
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
|
||||
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
|
||||
@@ -2203,7 +2241,7 @@
|
||||
},
|
||||
"locked": {
|
||||
"title": "Cookie Bot",
|
||||
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。需要付费套餐。"
|
||||
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。"
|
||||
},
|
||||
"empty": {
|
||||
"title": "尚未加入任何配置文件",
|
||||
|
||||
@@ -28,6 +28,15 @@ export type BackendErrorCode =
|
||||
| "VPN_NOT_FOUND"
|
||||
| "EXTENSION_NOT_FOUND"
|
||||
| "EXTENSION_GROUP_NOT_FOUND"
|
||||
| "EXTENSION_UNSUPPORTED_FILE_TYPE"
|
||||
| "EXTENSION_DIR_NOT_FOUND"
|
||||
| "EXTENSION_NOT_A_DIRECTORY"
|
||||
| "EXTENSION_MANIFEST_MISSING"
|
||||
| "EXTENSION_MANIFEST_INVALID"
|
||||
| "EXTENSION_DIR_TOO_LARGE"
|
||||
| "EXTENSION_PATH_HAS_COMMA"
|
||||
| "EXTENSION_LINK_REQUIRES_DIRECTORY"
|
||||
| "EXTENSION_LINKED_CANNOT_SYNC"
|
||||
| "CANNOT_MODIFY_CLOUD_MANAGED_PROXY"
|
||||
| "SYNC_LOCKED_BY_PROFILE"
|
||||
| "SYNC_NOT_CONFIGURED"
|
||||
@@ -219,6 +228,24 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.extensionNotFound");
|
||||
case "EXTENSION_GROUP_NOT_FOUND":
|
||||
return t("backendErrors.extensionGroupNotFound");
|
||||
case "EXTENSION_UNSUPPORTED_FILE_TYPE":
|
||||
return t("backendErrors.extensionUnsupportedFileType");
|
||||
case "EXTENSION_DIR_NOT_FOUND":
|
||||
return t("backendErrors.extensionDirNotFound");
|
||||
case "EXTENSION_NOT_A_DIRECTORY":
|
||||
return t("backendErrors.extensionNotADirectory");
|
||||
case "EXTENSION_MANIFEST_MISSING":
|
||||
return t("backendErrors.extensionManifestMissing");
|
||||
case "EXTENSION_MANIFEST_INVALID":
|
||||
return t("backendErrors.extensionManifestInvalid");
|
||||
case "EXTENSION_DIR_TOO_LARGE":
|
||||
return t("backendErrors.extensionDirTooLarge");
|
||||
case "EXTENSION_PATH_HAS_COMMA":
|
||||
return t("backendErrors.extensionPathHasComma");
|
||||
case "EXTENSION_LINK_REQUIRES_DIRECTORY":
|
||||
return t("backendErrors.extensionLinkRequiresDirectory");
|
||||
case "EXTENSION_LINKED_CANNOT_SYNC":
|
||||
return t("backendErrors.extensionLinkedCannotSync");
|
||||
case "CANNOT_MODIFY_CLOUD_MANAGED_PROXY":
|
||||
return t("backendErrors.cannotModifyCloudManagedProxy");
|
||||
case "SYNC_LOCKED_BY_PROFILE":
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
CREDENTIALS_FIRST_FORMAT,
|
||||
HOST_FIRST_FORMAT,
|
||||
pickParsedProxy,
|
||||
resolveAmbiguousProxyLine,
|
||||
splitProxyScheme,
|
||||
} from "./proxy-string.ts";
|
||||
|
||||
/**
|
||||
* The formats themselves are exercised in Rust
|
||||
* (`proxy_manager::tests::test_proxy_txt_parsing_various_formats`). What is
|
||||
* pinned here is the frontend's half: the scheme survives an ambiguous line,
|
||||
* and a resolution that doesn't fit the line is refused rather than turned into
|
||||
* a proxy pointing at somebody's password.
|
||||
*/
|
||||
|
||||
test("a bare line is HTTP", () => {
|
||||
assert.deepEqual(splitProxyScheme("1.2.3.4:8080"), {
|
||||
proxyType: "http",
|
||||
rest: "1.2.3.4:8080",
|
||||
});
|
||||
});
|
||||
|
||||
test("known schemes are recognised and normalised", () => {
|
||||
assert.deepEqual(splitProxyScheme("SOCKS://1.2.3.4:1080"), {
|
||||
proxyType: "socks5",
|
||||
rest: "1.2.3.4:1080",
|
||||
});
|
||||
assert.equal(splitProxyScheme("shadowsocks://host:8388").proxyType, "ss");
|
||||
});
|
||||
|
||||
test("an unknown scheme is left in the body rather than guessed at", () => {
|
||||
assert.deepEqual(splitProxyScheme("ftp://1.2.3.4:21"), {
|
||||
proxyType: "http",
|
||||
rest: "ftp://1.2.3.4:21",
|
||||
});
|
||||
});
|
||||
|
||||
test("host-first resolution keeps the scheme", () => {
|
||||
assert.deepEqual(
|
||||
resolveAmbiguousProxyLine(
|
||||
"socks5://1234:5678:9012:3456",
|
||||
HOST_FIRST_FORMAT,
|
||||
),
|
||||
{
|
||||
proxy_type: "socks5",
|
||||
host: "1234",
|
||||
port: 5678,
|
||||
username: "9012",
|
||||
password: "3456",
|
||||
original_line: "socks5://1234:5678:9012:3456",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("credentials-first resolution reads the tail as the endpoint", () => {
|
||||
assert.deepEqual(
|
||||
resolveAmbiguousProxyLine("1234:5678:9012:3456", CREDENTIALS_FIRST_FORMAT),
|
||||
{
|
||||
proxy_type: "http",
|
||||
host: "9012",
|
||||
port: 3456,
|
||||
username: "1234",
|
||||
password: "5678",
|
||||
original_line: "1234:5678:9012:3456",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("a format that doesn't fit the line resolves to nothing", () => {
|
||||
// 70000 is past the port range, so this ordering cannot be the right one.
|
||||
assert.equal(
|
||||
resolveAmbiguousProxyLine("host:70000:user:pass", HOST_FIRST_FORMAT),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveAmbiguousProxyLine("host:8080", HOST_FIRST_FORMAT), null);
|
||||
assert.equal(
|
||||
resolveAmbiguousProxyLine("a:1:b:2", "host:port:user:password"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("the first parsed line of a multi-line paste wins", () => {
|
||||
const parsed = pickParsedProxy([
|
||||
{ status: "invalid", line: "notaproxy", reason: "nope" },
|
||||
{
|
||||
status: "parsed",
|
||||
proxy_type: "socks5",
|
||||
host: "1.2.3.4",
|
||||
port: 1080,
|
||||
username: "u",
|
||||
password: "p",
|
||||
original_line: "socks5://u:p@1.2.3.4:1080",
|
||||
},
|
||||
{
|
||||
status: "parsed",
|
||||
proxy_type: "http",
|
||||
host: "5.6.7.8",
|
||||
port: 80,
|
||||
original_line: "5.6.7.8:80",
|
||||
},
|
||||
]);
|
||||
assert.equal(parsed?.host, "1.2.3.4");
|
||||
assert.equal(parsed?.proxy_type, "socks5");
|
||||
});
|
||||
|
||||
test("an ambiguous paste falls back to host:port:username:password", () => {
|
||||
const parsed = pickParsedProxy([
|
||||
{
|
||||
status: "ambiguous",
|
||||
line: "1234:5678:9012:3456",
|
||||
possible_formats: [HOST_FIRST_FORMAT, CREDENTIALS_FIRST_FORMAT],
|
||||
},
|
||||
]);
|
||||
assert.equal(parsed?.host, "1234");
|
||||
assert.equal(parsed?.port, 5678);
|
||||
});
|
||||
|
||||
test("nothing usable yields null so the plain paste stands", () => {
|
||||
assert.equal(
|
||||
pickParsedProxy([
|
||||
{ status: "invalid", line: "proxy.example.com", reason: "" },
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(pickParsedProxy([]), null);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Reading a proxy out of a pasted line.
|
||||
*
|
||||
* The parser itself is Rust's `parse_txt_proxies`
|
||||
* (`src-tauri/src/proxy_manager.rs`); both the import dialog and the add/edit
|
||||
* form hand their clipboard text to it rather than re-implementing the format
|
||||
* zoo. What is left for the frontend is the part the backend deliberately
|
||||
* refuses to decide: `a:b:c:d` is either `host:port:username:password` or
|
||||
* `username:password:host:port`, and when both middle fields parse as a port
|
||||
* only the user knows which. The backend reports that as `ambiguous`; the
|
||||
* functions below turn the user's answer back into a proxy.
|
||||
*
|
||||
* Kept free of runtime imports so `proxy-string.test.mjs` can load it directly.
|
||||
*/
|
||||
|
||||
import type { ParsedProxyLine, ProxyParseResult } from "@/types";
|
||||
|
||||
/** URL schemes the Rust parser accepts, mapped onto the stored proxy type. */
|
||||
const PROXY_SCHEMES: Record<string, string> = {
|
||||
http: "http",
|
||||
https: "https",
|
||||
socks: "socks5",
|
||||
socks4: "socks4",
|
||||
socks5: "socks5",
|
||||
ss: "ss",
|
||||
shadowsocks: "ss",
|
||||
vless: "vless",
|
||||
};
|
||||
|
||||
/** What a line carrying no scheme is assumed to be. */
|
||||
export const DEFAULT_PROXY_TYPE = "http";
|
||||
|
||||
export const HOST_FIRST_FORMAT = "host:port:username:password";
|
||||
export const CREDENTIALS_FIRST_FORMAT = "username:password:host:port";
|
||||
|
||||
/**
|
||||
* Separates `socks5://1.2.3.4:1080` into its scheme and body. An unknown or
|
||||
* absent scheme leaves the body untouched and falls back to HTTP, which is what
|
||||
* the backend does with a bare `host:port`.
|
||||
*/
|
||||
export function splitProxyScheme(line: string): {
|
||||
proxyType: string;
|
||||
rest: string;
|
||||
} {
|
||||
const separator = line.indexOf("://");
|
||||
if (separator === -1) {
|
||||
return { proxyType: DEFAULT_PROXY_TYPE, rest: line };
|
||||
}
|
||||
|
||||
const proxyType = PROXY_SCHEMES[line.slice(0, separator).toLowerCase()];
|
||||
return proxyType
|
||||
? { proxyType, rest: line.slice(separator + 3) }
|
||||
: { proxyType: DEFAULT_PROXY_TYPE, rest: line };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a proxy from a four-part line once the user has said which of the two
|
||||
* orderings it uses. Returns null when the chosen ordering doesn't actually fit
|
||||
* the line, so a stale selection can't produce a proxy pointing at a password.
|
||||
*/
|
||||
export function resolveAmbiguousProxyLine(
|
||||
line: string,
|
||||
format: string,
|
||||
): ParsedProxyLine | null {
|
||||
const trimmed = line.trim();
|
||||
const { proxyType, rest } = splitProxyScheme(trimmed);
|
||||
const parts = rest.split(":");
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostFirst = format === HOST_FIRST_FORMAT;
|
||||
if (!hostFirst && format !== CREDENTIALS_FIRST_FORMAT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hostFirst ? parts[0] : parts[2];
|
||||
const port = Number.parseInt(hostFirst ? parts[1] : parts[3], 10);
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
proxy_type: proxyType,
|
||||
host,
|
||||
port,
|
||||
username: hostFirst ? parts[2] : parts[0],
|
||||
password: hostFirst ? parts[3] : parts[1],
|
||||
original_line: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the proxy to use out of a parse of pasted text. Only the first usable
|
||||
* line matters: the form holds one proxy, and a paste that happens to carry a
|
||||
* whole list should still fill it in rather than do nothing.
|
||||
*
|
||||
* Ambiguous lines resolve as `host:port:username:password`, the ordering the
|
||||
* import dialog offers first and the one vendors overwhelmingly ship.
|
||||
*/
|
||||
export function pickParsedProxy(
|
||||
results: ProxyParseResult[],
|
||||
): ParsedProxyLine | null {
|
||||
for (const result of results) {
|
||||
if (result.status === "parsed") {
|
||||
return {
|
||||
proxy_type: result.proxy_type,
|
||||
host: result.host,
|
||||
port: result.port,
|
||||
username: result.username,
|
||||
password: result.password,
|
||||
vless_uri: result.vless_uri,
|
||||
original_line: result.original_line,
|
||||
};
|
||||
}
|
||||
if (result.status === "ambiguous") {
|
||||
const resolved = resolveAmbiguousProxyLine(
|
||||
result.line,
|
||||
HOST_FIRST_FORMAT,
|
||||
);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -62,6 +62,11 @@ export interface Extension {
|
||||
description?: string;
|
||||
author?: string;
|
||||
homepage_url?: string;
|
||||
/** How the payload was imported: a `.crx`/`.zip` archive, or a folder. */
|
||||
source_kind: "archive" | "unpacked";
|
||||
/** Absolute folder the extension is loaded from in place. Set means nothing
|
||||
* was copied into Donut, so the extension is machine-local and never syncs. */
|
||||
linked_path?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionGroup {
|
||||
|
||||
Reference in New Issue
Block a user