mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-11-02 19:24:03 +01:00
fix: remote sync password
This commit is contained in:
1 parent
24e38d6bf3
commit
74e650a965
22 files changed
+227
-83
No files matched your search
@@ -3837,7 +3837,7 @@ pub async fn remote_launch_precondition(
|
||||
if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() {
|
||||
return Err(refusal);
|
||||
}
|
||||
remote_launch_profile_rules(profile)?;
|
||||
remote_launch_profile_rules(profile, crate::sync::encryption::has_e2e_password())?;
|
||||
|
||||
// The manifest is written last, so a host pulling mid-upload gets files
|
||||
// that are about to be replaced and a manifest that does not describe them.
|
||||
@@ -3861,6 +3861,7 @@ pub async fn remote_launch_precondition(
|
||||
/// scheduler, and so the live check above cannot be reached without them.
|
||||
pub fn remote_launch_profile_rules(
|
||||
profile: &crate::profile::types::BrowserProfile,
|
||||
sync_password_stored: bool,
|
||||
) -> Result<(), String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Err(
|
||||
@@ -3879,6 +3880,16 @@ pub fn remote_launch_profile_rules(
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if crate::sync::encryption::remote_seal(profile, sync_password_stored)
|
||||
== Some(crate::sync::encryption::RemoteSeal::SyncPassword)
|
||||
{
|
||||
return Err(
|
||||
"the account's sync password seals the profile's synced settings, its \
|
||||
proxy included; a remote host cannot decrypt them. Remove the sync \
|
||||
password to run the profile remotely."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if profile.remote_platform().is_none() {
|
||||
return Err(
|
||||
"profile has no recorded operating system, so it cannot be scheduled \
|
||||
@@ -5849,12 +5860,12 @@ mod tests {
|
||||
// would launch an empty browser and push that emptiness over the real one.
|
||||
#[test]
|
||||
fn remote_launch_requires_cloud_sync() {
|
||||
let err = remote_launch_profile_rules(&profile_with(SyncMode::Disabled, Some("macos")))
|
||||
let err = remote_launch_profile_rules(&profile_with(SyncMode::Disabled, Some("macos")), false)
|
||||
.expect_err("a non-synced profile must be refused");
|
||||
assert!(err.contains("cloud sync"), "unhelpful message: {err}");
|
||||
|
||||
assert!(
|
||||
remote_launch_profile_rules(&profile_with(SyncMode::Regular, Some("macos"))).is_ok(),
|
||||
remote_launch_profile_rules(&profile_with(SyncMode::Regular, Some("macos")), false).is_ok(),
|
||||
"a synced profile must be allowed"
|
||||
);
|
||||
}
|
||||
@@ -5866,7 +5877,7 @@ mod tests {
|
||||
// the corruption back over the user's real profile. Refusing here also
|
||||
// saves taking the profile lock and a slot on leased hardware for a
|
||||
// session that cannot possibly work.
|
||||
let err = remote_launch_profile_rules(&profile_with(SyncMode::Encrypted, Some("macos")))
|
||||
let err = remote_launch_profile_rules(&profile_with(SyncMode::Encrypted, Some("macos")), false)
|
||||
.expect_err("an encrypted profile must be refused");
|
||||
assert!(
|
||||
err.contains("encrypted") && err.contains("Regular"),
|
||||
@@ -5874,11 +5885,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_launch_refuses_a_profile_sealed_by_the_sync_password() {
|
||||
// The password seals the profile's metadata and proxy even on Regular
|
||||
// sync: a host would launch it without its proxy, or not at all.
|
||||
let profile = profile_with(SyncMode::Regular, Some("macos"));
|
||||
let err = remote_launch_profile_rules(&profile, true)
|
||||
.expect_err("a profile sealed by the sync password must be refused");
|
||||
assert!(err.contains("sync password"), "unhelpful message: {err}");
|
||||
assert!(remote_launch_profile_rules(&profile, false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_launch_requires_a_known_operating_system() {
|
||||
// Without one a Linux host has no OS to present the profile as, and a
|
||||
// guess would change the OS the profile reports to sites.
|
||||
assert!(remote_launch_profile_rules(&profile_with(SyncMode::Regular, None)).is_err());
|
||||
assert!(remote_launch_profile_rules(&profile_with(SyncMode::Regular, None), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5892,14 +5914,14 @@ mod tests {
|
||||
let foreign = profile_with(SyncMode::Regular, Some(other));
|
||||
// A Wayfern profile from another OS runs locally too, and remotely.
|
||||
assert!(!foreign.is_cross_os());
|
||||
assert!(remote_launch_profile_rules(&foreign).is_ok());
|
||||
assert!(remote_launch_profile_rules(&foreign, false).is_ok());
|
||||
|
||||
let mut bound = foreign.clone();
|
||||
bound.browser = "camoufox".to_string();
|
||||
assert!(bound.is_cross_os(), "test setup: profile should be foreign");
|
||||
// Local /run refuses this; running it remotely on a host of its own OS is
|
||||
// exactly what /run-remote is for.
|
||||
assert!(remote_launch_profile_rules(&bound).is_ok());
|
||||
assert!(remote_launch_profile_rules(&bound, false).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5910,6 +5932,8 @@ mod tests {
|
||||
// over the real profile.
|
||||
let scheduler = std::sync::Arc::new(crate::sync::SyncScheduler::new());
|
||||
crate::sync::set_global_scheduler(scheduler.clone());
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let _data = crate::app_dirs::set_test_data_dir(data.path().to_path_buf());
|
||||
|
||||
let mut profile = profile_with(SyncMode::Regular, Some("macos"));
|
||||
profile.id = uuid::Uuid::new_v4();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
use crate::cloud_errors::{self, BackendFailure, FailureCodes};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::sync::encryption::{remote_seal, RemoteSeal};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
@@ -615,19 +616,13 @@ pub fn run_precondition(profile: &BrowserProfile) -> Result<(), String> {
|
||||
if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() {
|
||||
return Err(refusal);
|
||||
}
|
||||
if sync_sealed(profile, crate::sync::encryption::has_e2e_password()) {
|
||||
return Err(error("COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED", &[]));
|
||||
let seal = remote_seal(profile, crate::sync::encryption::has_e2e_password());
|
||||
if seal == Some(RemoteSeal::SyncPassword) {
|
||||
return Err(error("SYNC_PASSWORD_BLOCKS_REMOTE_RUN", &[]));
|
||||
}
|
||||
bot_precondition(profile, &exit_reachability(profile))
|
||||
}
|
||||
|
||||
/// Whether the profile's synced copy is sealed with a key only the user's
|
||||
/// machines hold. A sync password seals every synced file of the account,
|
||||
/// including the metadata and the proxy of a profile on regular sync.
|
||||
fn sync_sealed(profile: &BrowserProfile, sync_password_stored: bool) -> bool {
|
||||
profile.is_encrypted_sync() || (profile.is_sync_enabled() && sync_password_stored)
|
||||
}
|
||||
|
||||
/// Whether this profile could ever be warmed by the bot.
|
||||
///
|
||||
/// The server is authoritative — it re-checks all of this and owns the parts
|
||||
@@ -719,7 +714,7 @@ fn profile_state_from(
|
||||
) -> ProfileState {
|
||||
ProfileState {
|
||||
sync_enabled: profile.is_sync_enabled(),
|
||||
encrypted_sync: sync_sealed(profile, sync_password_stored),
|
||||
encrypted_sync: remote_seal(profile, sync_password_stored).is_some(),
|
||||
// A VPN is an exit node just as much as a proxy is; the server only asks
|
||||
// whether the traffic leaves through something the user brought.
|
||||
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
|
||||
@@ -1848,11 +1843,12 @@ mod tests {
|
||||
let profile = eligible_profile();
|
||||
assert!(profile_state_from(&profile, true, true).encrypted_sync);
|
||||
assert!(!profile_state_from(&profile, false, true).encrypted_sync);
|
||||
assert!(sync_sealed(&profile, true));
|
||||
assert_eq!(remote_seal(&profile, true), Some(RemoteSeal::SyncPassword));
|
||||
assert_eq!(remote_seal(&profile, false), None);
|
||||
|
||||
let mut local = eligible_profile();
|
||||
local.sync_mode = SyncMode::Disabled;
|
||||
assert!(!sync_sealed(&local, true), "nothing of it is uploaded");
|
||||
assert_eq!(remote_seal(&local, true), None, "nothing of it is uploaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1329,11 +1329,14 @@ fn emit_stream_status(app: &AppHandle, connected: bool, reason: Option<&str>) {
|
||||
|
||||
/// The profile rules of `api_server::remote_launch_profile_rules`, as the
|
||||
/// codes the window translates.
|
||||
fn remote_rule_code(profile: &BrowserProfile) -> Option<&'static str> {
|
||||
fn remote_rule_code(profile: &BrowserProfile, sync_password_stored: bool) -> Option<&'static str> {
|
||||
use crate::sync::encryption::{remote_seal, RemoteSeal};
|
||||
if !profile.is_sync_enabled() {
|
||||
Some("REMOTE_REQUIRES_CLOUD_SYNC")
|
||||
} else if profile.is_encrypted_sync() {
|
||||
Some("REMOTE_ENCRYPTED_SYNC_UNSUPPORTED")
|
||||
} else if remote_seal(profile, sync_password_stored) == Some(RemoteSeal::SyncPassword) {
|
||||
Some("SYNC_PASSWORD_BLOCKS_REMOTE_RUN")
|
||||
} else if profile.remote_platform().is_none() {
|
||||
Some("REMOTE_UNKNOWN_PLATFORM")
|
||||
} else {
|
||||
@@ -1364,7 +1367,7 @@ pub async fn start_profile_remote_session(
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?;
|
||||
if let Some(code) = remote_rule_code(&profile) {
|
||||
if let Some(code) = remote_rule_code(&profile, crate::sync::encryption::has_e2e_password()) {
|
||||
return Err(crate::backend_error(code));
|
||||
}
|
||||
// The profile rules passed above, so what is left to refuse here is coded.
|
||||
@@ -1395,12 +1398,12 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
remote_rule_code(&profile),
|
||||
remote_rule_code(&profile, false),
|
||||
Some("REMOTE_REQUIRES_CLOUD_SYNC")
|
||||
);
|
||||
profile.sync_mode = crate::profile::types::SyncMode::Encrypted;
|
||||
assert_eq!(
|
||||
remote_rule_code(&profile),
|
||||
remote_rule_code(&profile, false),
|
||||
Some("REMOTE_ENCRYPTED_SYNC_UNSUPPORTED")
|
||||
);
|
||||
profile.sync_mode = crate::profile::types::SyncMode::Regular;
|
||||
@@ -1409,11 +1412,20 @@ mod tests {
|
||||
} else {
|
||||
Some("REMOTE_UNKNOWN_PLATFORM")
|
||||
};
|
||||
assert_eq!(remote_rule_code(&profile), expected);
|
||||
assert_eq!(remote_rule_code(&profile, false), expected);
|
||||
assert_eq!(
|
||||
remote_rule_code(&profile).is_none(),
|
||||
crate::api_server::remote_launch_profile_rules(&profile).is_ok()
|
||||
remote_rule_code(&profile, false).is_none(),
|
||||
crate::api_server::remote_launch_profile_rules(&profile, false).is_ok()
|
||||
);
|
||||
|
||||
profile.browser = "wayfern".to_string();
|
||||
profile.host_os = Some("macos".to_string());
|
||||
assert_eq!(remote_rule_code(&profile, false), None);
|
||||
assert_eq!(
|
||||
remote_rule_code(&profile, true),
|
||||
Some("SYNC_PASSWORD_BLOCKS_REMOTE_RUN")
|
||||
);
|
||||
assert!(crate::api_server::remote_launch_profile_rules(&profile, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -228,6 +228,30 @@ pub fn maybe_unseal_after_download(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Ok(raw.to_vec())
|
||||
}
|
||||
|
||||
/// What keeps a remote host (a cookie bot run, a remote session) from
|
||||
/// reading a profile's synced copy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteSeal {
|
||||
/// Encrypted sync seals the profile's files.
|
||||
EncryptedSync,
|
||||
/// A sync password seals every synced settings file of the account, the
|
||||
/// profile's metadata and proxy included, whatever its own sync mode.
|
||||
SyncPassword,
|
||||
}
|
||||
|
||||
pub fn remote_seal(
|
||||
profile: &crate::profile::types::BrowserProfile,
|
||||
sync_password_stored: bool,
|
||||
) -> Option<RemoteSeal> {
|
||||
if profile.is_encrypted_sync() {
|
||||
Some(RemoteSeal::EncryptedSync)
|
||||
} else if profile.is_sync_enabled() && sync_password_stored {
|
||||
Some(RemoteSeal::SyncPassword)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
@@ -237,8 +261,7 @@ pub async fn set_e2e_password(password: String) -> Result<(), String> {
|
||||
}
|
||||
enforce_team_owner_for_encryption_change().await?;
|
||||
store_e2e_password(&password)?;
|
||||
// The password seals every synced profile, so the cookie bot must hear it.
|
||||
tauri::async_runtime::spawn(crate::cookie_bot::report_enrolled_profiles());
|
||||
password_changed();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -259,10 +282,17 @@ pub fn verify_e2e_password(password: String) -> Result<bool, String> {
|
||||
pub async fn delete_e2e_password() -> Result<(), String> {
|
||||
enforce_team_owner_for_encryption_change().await?;
|
||||
remove_e2e_password()?;
|
||||
tauri::async_runtime::spawn(crate::cookie_bot::report_enrolled_profiles());
|
||||
password_changed();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The password seals every synced profile, so the cookie bot and the
|
||||
/// window's remote actions must hear it.
|
||||
fn password_changed() {
|
||||
tauri::async_runtime::spawn(crate::cookie_bot::report_enrolled_profiles());
|
||||
let _ = crate::events::emit("sync-password-changed", ());
|
||||
}
|
||||
|
||||
/// Only the team owner may flip the E2E password state — otherwise members
|
||||
/// could lock each other out by changing the key.
|
||||
async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { useSyncPasswordSet } from "@/hooks/use-sync-password";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
type CookieBotConflict,
|
||||
@@ -422,9 +423,14 @@ export function CookieBotEnrolDialog({
|
||||
if (chosen?.typical_minutes) setMaxMinutes(chosen.typical_minutes);
|
||||
}, [preset, presetList, maxMinutesTouched]);
|
||||
|
||||
const syncPasswordSet = useSyncPasswordSet();
|
||||
const targets: EnrolTarget[] = useMemo(
|
||||
() => profiles.map((profile) => ({ profile, check: preflight(profile) })),
|
||||
[profiles],
|
||||
() =>
|
||||
profiles.map((profile) => ({
|
||||
profile,
|
||||
check: preflight(profile, syncPasswordSet),
|
||||
})),
|
||||
[profiles, syncPasswordSet],
|
||||
);
|
||||
const eligible = useMemo(
|
||||
() => targets.filter((target) => target.check.eligible),
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { useSyncPasswordSet } from "@/hooks/use-sync-password";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
type CookieBotRun,
|
||||
@@ -499,13 +500,14 @@ function EnrolPickerDialog({
|
||||
setSelected(new Set());
|
||||
}, [isOpen]);
|
||||
|
||||
const syncPasswordSet = useSyncPasswordSet();
|
||||
const rows = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return profiles
|
||||
.filter((profile) => profile.name.toLowerCase().includes(needle))
|
||||
.map((profile) => ({
|
||||
profile,
|
||||
check: preflight(profile),
|
||||
check: preflight(profile, syncPasswordSet),
|
||||
enrolled: enrolledIds.has(profile.id),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
@@ -514,7 +516,7 @@ function EnrolPickerDialog({
|
||||
}
|
||||
return a.profile.name.localeCompare(b.profile.name);
|
||||
});
|
||||
}, [profiles, search, enrolledIds]);
|
||||
}, [profiles, search, enrolledIds, syncPasswordSet]);
|
||||
|
||||
const chosen = useMemo(
|
||||
() => profiles.filter((profile) => selected.has(profile.id)),
|
||||
|
||||
@@ -431,6 +431,7 @@ export function TickingNumber({
|
||||
export type PreflightCode =
|
||||
| "syncOff"
|
||||
| "encrypted"
|
||||
| "syncPassword"
|
||||
| "unknownPlatform"
|
||||
| "unsupportedPlatform"
|
||||
| "noExitNode";
|
||||
@@ -460,12 +461,15 @@ export function resolvedOs(profile: BrowserProfile): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact refusals `cookie_bot::bot_precondition` applies, evaluated here so
|
||||
* The exact refusals `cookie_bot::run_precondition` applies, evaluated here so
|
||||
* a user finds out at enrolment rather than at the first run. Keeping the
|
||||
* two in step matters: a profile this says is fine but the backend refuses
|
||||
* would burn a schedule row and a run.
|
||||
*/
|
||||
export function preflight(profile: BrowserProfile): PreflightResult {
|
||||
export function preflight(
|
||||
profile: BrowserProfile,
|
||||
syncPasswordSet: boolean,
|
||||
): PreflightResult {
|
||||
const syncMode = profile.sync_mode ?? "Disabled";
|
||||
if (syncMode === "Disabled") {
|
||||
return { eligible: false, code: "syncOff", params: {}, fix: "sync" };
|
||||
@@ -478,6 +482,9 @@ export function preflight(profile: BrowserProfile): PreflightResult {
|
||||
fix: "syncSettings",
|
||||
};
|
||||
}
|
||||
if (syncPasswordSet) {
|
||||
return { eligible: false, code: "syncPassword", params: {}, fix: null };
|
||||
}
|
||||
const os = resolvedOs(profile);
|
||||
if (!os) {
|
||||
return {
|
||||
@@ -501,30 +508,14 @@ export function preflight(profile: BrowserProfile): PreflightResult {
|
||||
return ELIGIBLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this profile could be launched on a remote host.
|
||||
*
|
||||
* A strict subset of {@link preflight}: a remote session needs the profile to
|
||||
* exist in cloud storage in a form a host can read, and nothing more. The bot's
|
||||
* extra requirement — an exit node — exists because a night of unattended
|
||||
* traffic from a datacenter address is worse for the profile than not warming
|
||||
* it, and that reasoning does not apply to a session the user is driving.
|
||||
*
|
||||
* Mirrors `remote_launch_profile_rules` in `api_server.rs`, which is
|
||||
* authoritative; this only avoids offering an action that would be refused.
|
||||
*/
|
||||
export function canLaunchRemotely(profile: BrowserProfile): boolean {
|
||||
const syncMode = profile.sync_mode ?? "Disabled";
|
||||
if (syncMode === "Disabled" || syncMode === "Encrypted") return false;
|
||||
return resolvedOs(profile) !== null;
|
||||
}
|
||||
|
||||
export function preflightReason(t: TFunction, result: PreflightResult): string {
|
||||
switch (result.code) {
|
||||
case "syncOff":
|
||||
return t("cookieBot.preflight.reasonSync");
|
||||
case "encrypted":
|
||||
return t("cookieBot.preflight.reasonEncrypted");
|
||||
case "syncPassword":
|
||||
return t("cookieBot.preflight.reasonSyncPassword");
|
||||
case "unknownPlatform":
|
||||
return t("cookieBot.preflight.reasonNoFingerprint");
|
||||
case "unsupportedPlatform":
|
||||
|
||||
@@ -153,6 +153,7 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { type ProxyHealth, useProxyHealth } from "@/hooks/use-proxy-health";
|
||||
import { useRemoteHandoff } from "@/hooks/use-remote-handoff";
|
||||
import { useScrollFade } from "@/hooks/use-scroll-fade";
|
||||
import { useSyncPasswordSet } from "@/hooks/use-sync-password";
|
||||
import { useTablePreferences } from "@/hooks/use-table-preferences";
|
||||
import { useTableKeyboard } from "@/hooks/use-table-tools";
|
||||
import { useTeamLocks } from "@/hooks/use-team-locks";
|
||||
@@ -184,6 +185,7 @@ import {
|
||||
} from "@/lib/motion";
|
||||
import { isMacOS } from "@/lib/platform";
|
||||
import type { RemoteHandoffState } from "@/lib/remote-sessions";
|
||||
import { isKey } from "@/lib/shortcuts";
|
||||
import { resolveTableVisibility } from "@/lib/table-tools";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -373,6 +375,8 @@ interface TableMeta {
|
||||
getProfileBotState: (profileId: string) => ProfileBotState;
|
||||
/** A run this desktop has just asked for, before the stream confirms it. */
|
||||
botPendingProfiles: Set<string>;
|
||||
/** A sync encryption password seals every synced profile for remote hosts. */
|
||||
syncPasswordSet: boolean;
|
||||
onBotEnrol: (profile: BrowserProfile) => void;
|
||||
onBotEdit: (profile: BrowserProfile, schedule: CookieBotSchedule) => void;
|
||||
onBotRunNow: (profile: BrowserProfile) => void;
|
||||
@@ -1752,7 +1756,7 @@ const BotCell = React.memo<{
|
||||
// `TFunction`, and every other cell in this file resolves it the same way.
|
||||
const { t } = useTranslation();
|
||||
const { schedule, liveSession } = meta.getProfileBotState(profile.id);
|
||||
const check = preflight(profile);
|
||||
const check = preflight(profile, meta.syncPasswordSet);
|
||||
const isPending = meta.botPendingProfiles.has(profile.id);
|
||||
const isLive = liveSession !== null;
|
||||
const nextRun = schedule ? formatNextRun(schedule) : null;
|
||||
@@ -2308,6 +2312,7 @@ export function ProfilesDataTable({
|
||||
const [botPendingProfiles, setBotPendingProfiles] = React.useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const syncPasswordSet = useSyncPasswordSet();
|
||||
const [botScheduleDialog, setBotScheduleDialog] = React.useState<{
|
||||
profiles: BrowserProfile[];
|
||||
existing: CookieBotSchedule | null;
|
||||
@@ -2583,7 +2588,9 @@ export function ProfilesDataTable({
|
||||
const handleBulkCookieBotEnrol = React.useCallback(() => {
|
||||
const targets = profiles.filter((p) => selectedProfiles.includes(p.id));
|
||||
if (targets.length === 0) return;
|
||||
const eligible = targets.filter((p) => preflight(p).eligible);
|
||||
const eligible = targets.filter(
|
||||
(p) => preflight(p, syncPasswordSet).eligible,
|
||||
);
|
||||
// Same guard as bulk run: an action that can touch nothing says so instead
|
||||
// of opening a dialog whose only outcome is a refusal.
|
||||
if (eligible.length === 0) {
|
||||
@@ -2598,7 +2605,7 @@ export function ProfilesDataTable({
|
||||
return;
|
||||
}
|
||||
setBotScheduleDialog({ profiles: targets, existing: null });
|
||||
}, [profiles, selectedProfiles, t]);
|
||||
}, [profiles, selectedProfiles, syncPasswordSet, t]);
|
||||
|
||||
// Use shared browser state hook
|
||||
const browserState = useBrowserState(
|
||||
@@ -3061,6 +3068,7 @@ export function ProfilesDataTable({
|
||||
cookieBotCompact,
|
||||
getProfileBotState,
|
||||
botPendingProfiles,
|
||||
syncPasswordSet,
|
||||
onBotEnrol: handleBotEnrol,
|
||||
onBotEdit: handleBotEdit,
|
||||
onBotRunNow: (profile: BrowserProfile) => {
|
||||
@@ -3135,6 +3143,7 @@ export function ProfilesDataTable({
|
||||
cookieBotCompact,
|
||||
getProfileBotState,
|
||||
botPendingProfiles,
|
||||
syncPasswordSet,
|
||||
handleBotEnrol,
|
||||
handleBotEdit,
|
||||
handleBotRunNow,
|
||||
@@ -4139,7 +4148,12 @@ export function ProfilesDataTable({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mod && !event.shiftKey && event.key.toLowerCase() === "a") {
|
||||
if (
|
||||
mod &&
|
||||
!event.shiftKey &&
|
||||
isKey(event) &&
|
||||
event.key.toLowerCase() === "a"
|
||||
) {
|
||||
event.preventDefault();
|
||||
onSelectedProfilesChange(sortedRows.map((row) => row.id));
|
||||
return;
|
||||
@@ -4944,19 +4958,25 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
title={t("cookieBot.enrol.confirmBulkTitle", {
|
||||
count:
|
||||
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
|
||||
pendingBulkEnrol?.filter(
|
||||
(p) => preflight(p, syncPasswordSet).eligible,
|
||||
).length ?? 0,
|
||||
})}
|
||||
description={t("cookieBot.enrol.confirmBulkDescription", {
|
||||
count:
|
||||
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
|
||||
pendingBulkEnrol?.filter(
|
||||
(p) => preflight(p, syncPasswordSet).eligible,
|
||||
).length ?? 0,
|
||||
})}
|
||||
confirmButtonText={t("cookieBot.enrol.confirmBulkButton", {
|
||||
count:
|
||||
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
|
||||
pendingBulkEnrol?.filter(
|
||||
(p) => preflight(p, syncPasswordSet).eligible,
|
||||
).length ?? 0,
|
||||
})}
|
||||
confirmButtonVariant="default"
|
||||
profileIds={pendingBulkEnrol
|
||||
?.filter((p) => preflight(p).eligible)
|
||||
?.filter((p) => preflight(p, syncPasswordSet).eligible)
|
||||
.map((p) => p.id)}
|
||||
profiles={pendingBulkEnrol?.map((p) => ({ id: p.id, name: p.name }))}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useMotionAllowed } from "@/hooks/use-motion-allowed";
|
||||
import { useSyncPasswordSet } from "@/hooks/use-sync-password";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { MOTION_SPRING } from "@/lib/motion";
|
||||
@@ -107,6 +108,7 @@ export function useCanRunRemote(
|
||||
): boolean {
|
||||
const { user } = useCloudAuth();
|
||||
const entitlements = getEntitlements(user);
|
||||
const syncPasswordSet = useSyncPasswordSet();
|
||||
return (
|
||||
Boolean(user) &&
|
||||
entitlements.active &&
|
||||
@@ -114,6 +116,7 @@ export function useCanRunRemote(
|
||||
profile.browser === "wayfern" &&
|
||||
profile.ephemeral !== true &&
|
||||
profile.sync_mode === "Regular" &&
|
||||
!syncPasswordSet &&
|
||||
!isRunning &&
|
||||
!handoffState
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Whether this device stores a sync encryption password. The password seals
|
||||
* the synced settings of every profile, so a remote host cannot open one.
|
||||
* False until the first read answers.
|
||||
*/
|
||||
export function useSyncPasswordSet(): boolean {
|
||||
const [set, setSet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const read = () => {
|
||||
void invoke<boolean>("check_has_e2e_password")
|
||||
.then((stored) => {
|
||||
if (!disposed) setSet(stored);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to read the sync password state:", error);
|
||||
});
|
||||
};
|
||||
read();
|
||||
const unlisten = listen("sync-password-changed", read);
|
||||
return () => {
|
||||
disposed = true;
|
||||
void unlisten.then((stop) => stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return set;
|
||||
}
|
||||
@@ -2935,7 +2935,8 @@
|
||||
"proxyUpstreamTimeout": "Der Proxy antwortet nicht.",
|
||||
"proxyAuthFailed": "Der Proxy hat Benutzername oder Passwort abgelehnt.",
|
||||
"vpnUnreachable": "Der VPN-Server antwortet nicht.",
|
||||
"entityInUse": "Wird von {{count}} Profilen verwendet. Weisen Sie ihnen zuerst einen anderen Proxy oder ein anderes VPN zu."
|
||||
"entityInUse": "Wird von {{count}} Profilen verwendet. Weisen Sie ihnen zuerst einen anderen Proxy oder ein anderes VPN zu.",
|
||||
"syncPasswordBlocksRemoteRun": "Das Passwort der Sync-Verschlüsselung verschlüsselt die synchronisierten Einstellungen dieses Profils, und ein Remote-Host kann sie nicht entschlüsseln. Um es remote auszuführen, entfernen Sie das Passwort in den Einstellungen der Sync-Verschlüsselung."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -3586,7 +3587,8 @@
|
||||
"fixEncrypted": "Sync-Einstellungen öffnen",
|
||||
"fixProxy": "Proxy verknüpfen",
|
||||
"fixFailed": "Diese Korrektur konnte nicht angewendet werden.",
|
||||
"exitNodeHint": "Ohne Proxy oder VPN geht der Durchlauf von der eigenen Rechenzentrumsadresse der Remote-Rechner aus. Stundenlanger Datenverkehr aus einem Hosting-Netz schadet der Identität des Profils mehr, als es gar nicht aufzuwärmen."
|
||||
"exitNodeHint": "Ohne Proxy oder VPN geht der Durchlauf von der eigenen Rechenzentrumsadresse der Remote-Rechner aus. Stundenlanger Datenverkehr aus einem Hosting-Netz schadet der Identität des Profils mehr, als es gar nicht aufzuwärmen.",
|
||||
"reasonSyncPassword": "Passwort der Sync-Verschlüsselung ist festgelegt"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} wärmt dieses Profil bereits um {{time}} auf",
|
||||
|
||||
@@ -2935,7 +2935,8 @@
|
||||
"proxyUpstreamTimeout": "The proxy does not answer.",
|
||||
"proxyAuthFailed": "The proxy refused the username or password.",
|
||||
"vpnUnreachable": "The VPN server does not answer.",
|
||||
"entityInUse": "In use by {{count}} profiles. Move them to another proxy or VPN first."
|
||||
"entityInUse": "In use by {{count}} profiles. Move them to another proxy or VPN first.",
|
||||
"syncPasswordBlocksRemoteRun": "The sync encryption password encrypts this profile's synced settings, and a remote host cannot decrypt them. To run it remotely, remove the password in the Sync encryption settings."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -3586,7 +3587,8 @@
|
||||
"fixEncrypted": "Open sync settings",
|
||||
"fixProxy": "Attach a proxy",
|
||||
"fixFailed": "Could not apply that fix.",
|
||||
"exitNodeHint": "Without a proxy or VPN the run leaves from the fleet's own datacenter address. Hours of traffic from a hosting network damages the profile's identity more than not warming it at all."
|
||||
"exitNodeHint": "Without a proxy or VPN the run leaves from the fleet's own datacenter address. Hours of traffic from a hosting network damages the profile's identity more than not warming it at all.",
|
||||
"reasonSyncPassword": "Sync encryption password is set"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} already warms this profile at {{time}}",
|
||||
|
||||
@@ -2955,7 +2955,8 @@
|
||||
"proxyUpstreamTimeout": "El proxy no responde.",
|
||||
"proxyAuthFailed": "El proxy rechazó el usuario o la contraseña.",
|
||||
"vpnUnreachable": "El servidor VPN no responde.",
|
||||
"entityInUse": "Lo usan {{count}} perfiles. Asígnales primero otro proxy o VPN."
|
||||
"entityInUse": "Lo usan {{count}} perfiles. Asígnales primero otro proxy o VPN.",
|
||||
"syncPasswordBlocksRemoteRun": "La contraseña del cifrado de sincronización cifra los ajustes sincronizados de este perfil, y un host remoto no puede descifrarlos. Para ejecutarlo en remoto, elimina la contraseña en los ajustes de Cifrado de sincronización."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -3623,7 +3624,8 @@
|
||||
"fixEncrypted": "Abrir los ajustes de sincronización",
|
||||
"fixProxy": "Asignar un proxy",
|
||||
"fixFailed": "No se pudo aplicar esa corrección.",
|
||||
"exitNodeHint": "Sin proxy ni VPN, la ejecución sale por la propia dirección del centro de datos de la flota. Horas de tráfico desde una red de alojamiento dañan la identidad del perfil más que no calentarlo en absoluto."
|
||||
"exitNodeHint": "Sin proxy ni VPN, la ejecución sale por la propia dirección del centro de datos de la flota. Horas de tráfico desde una red de alojamiento dañan la identidad del perfil más que no calentarlo en absoluto.",
|
||||
"reasonSyncPassword": "Hay una contraseña de cifrado de sincronización"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} ya calienta este perfil a las {{time}}",
|
||||
|
||||
@@ -2955,7 +2955,8 @@
|
||||
"proxyUpstreamTimeout": "Le proxy ne répond pas.",
|
||||
"proxyAuthFailed": "Le proxy a refusé le nom d'utilisateur ou le mot de passe.",
|
||||
"vpnUnreachable": "Le serveur VPN ne répond pas.",
|
||||
"entityInUse": "Utilisé par {{count}} profils. Attribuez-leur d'abord un autre proxy ou VPN."
|
||||
"entityInUse": "Utilisé par {{count}} profils. Attribuez-leur d'abord un autre proxy ou VPN.",
|
||||
"syncPasswordBlocksRemoteRun": "Le mot de passe du chiffrement de synchronisation chiffre les réglages synchronisés de ce profil, et un hôte distant ne peut pas les déchiffrer. Pour l'exécuter à distance, supprimez le mot de passe dans les réglages Chiffrement de synchronisation."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -3623,7 +3624,8 @@
|
||||
"fixEncrypted": "Ouvrir les réglages de synchronisation",
|
||||
"fixProxy": "Associer un proxy",
|
||||
"fixFailed": "Impossible d'appliquer ce correctif.",
|
||||
"exitNodeHint": "Sans proxy ni VPN, l'exécution sort par l'adresse du centre de données de la flotte. Des heures de trafic depuis un réseau d'hébergement abîment l'identité du profil plus que de ne pas le chauffer du tout."
|
||||
"exitNodeHint": "Sans proxy ni VPN, l'exécution sort par l'adresse du centre de données de la flotte. Des heures de trafic depuis un réseau d'hébergement abîment l'identité du profil plus que de ne pas le chauffer du tout.",
|
||||
"reasonSyncPassword": "Mot de passe de chiffrement de synchronisation défini"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} chauffe déjà ce profil à {{time}}",
|
||||
|
||||
@@ -2935,7 +2935,8 @@
|
||||
"proxyUpstreamTimeout": "プロキシが応答しません。",
|
||||
"proxyAuthFailed": "プロキシがユーザー名またはパスワードを拒否しました。",
|
||||
"vpnUnreachable": "VPN サーバーが応答しません。",
|
||||
"entityInUse": "{{count}} 件のプロファイルで使用中です。先に別のプロキシまたはVPNに切り替えてください。"
|
||||
"entityInUse": "{{count}} 件のプロファイルで使用中です。先に別のプロキシまたはVPNに切り替えてください。",
|
||||
"syncPasswordBlocksRemoteRun": "同期暗号化のパスワードがこのプロファイルの同期済み設定を暗号化しているため、リモートホストでは復号できません。リモートで実行するには、同期暗号化の設定でパスワードを削除してください。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -3586,7 +3587,8 @@
|
||||
"fixEncrypted": "同期設定を開く",
|
||||
"fixProxy": "プロキシを割り当てる",
|
||||
"fixFailed": "その修正を適用できませんでした。",
|
||||
"exitNodeHint": "プロキシも VPN もない場合、通信はマシン群自身のデータセンターのアドレスから出ていきます。ホスティングネットワークからの長時間のトラフィックは、ウォームアップしない場合よりもプロファイルの信頼性を損ないます。"
|
||||
"exitNodeHint": "プロキシも VPN もない場合、通信はマシン群自身のデータセンターのアドレスから出ていきます。ホスティングネットワークからの長時間のトラフィックは、ウォームアップしない場合よりもプロファイルの信頼性を損ないます。",
|
||||
"reasonSyncPassword": "同期暗号化のパスワードが設定済み"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} がすでにこのプロファイルを {{time}} にウォームアップしています",
|
||||
|
||||
@@ -2935,7 +2935,8 @@
|
||||
"proxyUpstreamTimeout": "프록시가 응답하지 않습니다.",
|
||||
"proxyAuthFailed": "프록시가 사용자 이름 또는 비밀번호를 거부했습니다.",
|
||||
"vpnUnreachable": "VPN 서버가 응답하지 않습니다.",
|
||||
"entityInUse": "프로필 {{count}}개에서 사용 중입니다. 먼저 다른 프록시나 VPN으로 바꾸세요."
|
||||
"entityInUse": "프로필 {{count}}개에서 사용 중입니다. 먼저 다른 프록시나 VPN으로 바꾸세요.",
|
||||
"syncPasswordBlocksRemoteRun": "동기화 암호화 비밀번호가 이 프로필의 동기화된 설정을 암호화하므로 원격 호스트가 복호화할 수 없습니다. 원격으로 실행하려면 동기화 암호화 설정에서 비밀번호를 제거하세요."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -3586,7 +3587,8 @@
|
||||
"fixEncrypted": "동기화 설정 열기",
|
||||
"fixProxy": "프록시 연결",
|
||||
"fixFailed": "해당 수정을 적용하지 못했습니다.",
|
||||
"exitNodeHint": "프록시나 VPN이 없으면 실행 트래픽이 머신 풀 자체의 데이터센터 주소로 나갑니다. 호스팅 네트워크에서 몇 시간씩 발생하는 트래픽은 예열을 아예 하지 않는 것보다 프로필 신뢰도를 더 크게 해칩니다."
|
||||
"exitNodeHint": "프록시나 VPN이 없으면 실행 트래픽이 머신 풀 자체의 데이터센터 주소로 나갑니다. 호스팅 네트워크에서 몇 시간씩 발생하는 트래픽은 예열을 아예 하지 않는 것보다 프로필 신뢰도를 더 크게 해칩니다.",
|
||||
"reasonSyncPassword": "동기화 암호화 비밀번호가 설정됨"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} 님이 이미 이 프로필을 {{time}}에 예열합니다",
|
||||
|
||||
@@ -2955,7 +2955,8 @@
|
||||
"proxyUpstreamTimeout": "O proxy não responde.",
|
||||
"proxyAuthFailed": "O proxy recusou o usuário ou a senha.",
|
||||
"vpnUnreachable": "O servidor VPN não responde.",
|
||||
"entityInUse": "Em uso por {{count}} perfis. Mude-os para outro proxy ou VPN primeiro."
|
||||
"entityInUse": "Em uso por {{count}} perfis. Mude-os para outro proxy ou VPN primeiro.",
|
||||
"syncPasswordBlocksRemoteRun": "A senha da criptografia de sincronização criptografa as configurações sincronizadas deste perfil, e um host remoto não consegue descriptografá-las. Para executá-lo remotamente, remova a senha nas configurações de Criptografia de sincronização."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -3623,7 +3624,8 @@
|
||||
"fixEncrypted": "Abrir as configurações de sincronização",
|
||||
"fixProxy": "Anexar um proxy",
|
||||
"fixFailed": "Não foi possível aplicar essa correção.",
|
||||
"exitNodeHint": "Sem proxy nem VPN, a execução sai pelo próprio endereço do data center da frota. Horas de tráfego vindo de uma rede de hospedagem danificam a identidade do perfil mais do que não aquecê-lo."
|
||||
"exitNodeHint": "Sem proxy nem VPN, a execução sai pelo próprio endereço do data center da frota. Horas de tráfego vindo de uma rede de hospedagem danificam a identidade do perfil mais do que não aquecê-lo.",
|
||||
"reasonSyncPassword": "Senha de criptografia de sincronização definida"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} já aquece este perfil às {{time}}",
|
||||
|
||||
@@ -2975,7 +2975,8 @@
|
||||
"proxyUpstreamTimeout": "Прокси не отвечает.",
|
||||
"proxyAuthFailed": "Прокси отклонил имя пользователя или пароль.",
|
||||
"vpnUnreachable": "VPN-сервер не отвечает.",
|
||||
"entityInUse": "Используется в {{count}} профилях. Сначала переключите их на другой прокси или VPN."
|
||||
"entityInUse": "Используется в {{count}} профилях. Сначала переключите их на другой прокси или VPN.",
|
||||
"syncPasswordBlocksRemoteRun": "Пароль шифрования синхронизации шифрует синхронизированные настройки этого профиля, и удалённый хост не может их расшифровать. Чтобы запустить профиль удалённо, удалите пароль в настройках шифрования синхронизации."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -3662,7 +3663,8 @@
|
||||
"fixEncrypted": "Открыть настройки синхронизации",
|
||||
"fixProxy": "Назначить прокси",
|
||||
"fixFailed": "Не удалось применить это исправление.",
|
||||
"exitNodeHint": "Без прокси или VPN трафик выходит с собственного адреса дата-центра нашего парка машин. Часы трафика из хостинговой сети вредят репутации профиля сильнее, чем полный отказ от прогрева."
|
||||
"exitNodeHint": "Без прокси или VPN трафик выходит с собственного адреса дата-центра нашего парка машин. Часы трафика из хостинговой сети вредят репутации профиля сильнее, чем полный отказ от прогрева.",
|
||||
"reasonSyncPassword": "Задан пароль шифрования синхронизации"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} уже прогревает этот профиль в {{time}}",
|
||||
|
||||
@@ -2935,7 +2935,8 @@
|
||||
"proxyUpstreamTimeout": "Proxy yanıt vermiyor.",
|
||||
"proxyAuthFailed": "Proxy kullanıcı adını veya parolayı reddetti.",
|
||||
"vpnUnreachable": "VPN sunucusu yanıt vermiyor.",
|
||||
"entityInUse": "{{count}} profil tarafından kullanılıyor. Önce onları başka bir proxy'ye veya VPN'e geçirin."
|
||||
"entityInUse": "{{count}} profil tarafından kullanılıyor. Önce onları başka bir proxy'ye veya VPN'e geçirin.",
|
||||
"syncPasswordBlocksRemoteRun": "Eşitleme şifrelemesi parolası bu profilin eşitlenen ayarlarını şifreler ve uzak sunucu bunları çözemez. Uzakta çalıştırmak için Eşitleme şifrelemesi ayarlarından parolayı kaldırın."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
@@ -3586,7 +3587,8 @@
|
||||
"fixEncrypted": "Eşitleme ayarlarını aç",
|
||||
"fixProxy": "Bir proxy ata",
|
||||
"fixFailed": "Bu düzeltme uygulanamadı.",
|
||||
"exitNodeHint": "Proxy veya VPN olmadan çalışma, filonun kendi veri merkezi adresinden çıkar. Bir barındırma ağından saatlerce gelen trafik, profilin kimliğine hiç ısıtmamaktan daha çok zarar verir."
|
||||
"exitNodeHint": "Proxy veya VPN olmadan çalışma, filonun kendi veri merkezi adresinden çıkar. Bir barındırma ağından saatlerce gelen trafik, profilin kimliğine hiç ısıtmamaktan daha çok zarar verir.",
|
||||
"reasonSyncPassword": "Eşitleme şifrelemesi parolası ayarlı"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} bu profili zaten {{time}} saatinde ısıtıyor",
|
||||
|
||||
@@ -2935,7 +2935,8 @@
|
||||
"proxyUpstreamTimeout": "Proxy không phản hồi.",
|
||||
"proxyAuthFailed": "Proxy đã từ chối tên người dùng hoặc mật khẩu.",
|
||||
"vpnUnreachable": "Máy chủ VPN không phản hồi.",
|
||||
"entityInUse": "Đang được {{count}} hồ sơ sử dụng. Hãy chuyển chúng sang proxy hoặc VPN khác trước."
|
||||
"entityInUse": "Đang được {{count}} hồ sơ sử dụng. Hãy chuyển chúng sang proxy hoặc VPN khác trước.",
|
||||
"syncPasswordBlocksRemoteRun": "Mật khẩu mã hóa đồng bộ mã hóa các cài đặt đã đồng bộ của hồ sơ này, và máy chủ từ xa không thể giải mã chúng. Để chạy từ xa, hãy xóa mật khẩu trong cài đặt Mã hóa đồng bộ."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -3586,7 +3587,8 @@
|
||||
"fixEncrypted": "Mở cài đặt đồng bộ",
|
||||
"fixProxy": "Gán một proxy",
|
||||
"fixFailed": "Không áp dụng được cách khắc phục đó.",
|
||||
"exitNodeHint": "Không có proxy hoặc VPN, lần chạy sẽ đi ra từ chính địa chỉ trung tâm dữ liệu của hệ thống máy. Nhiều giờ lưu lượng từ một mạng lưu trữ gây hại cho danh tính hồ sơ hơn cả việc không làm ấm."
|
||||
"exitNodeHint": "Không có proxy hoặc VPN, lần chạy sẽ đi ra từ chính địa chỉ trung tâm dữ liệu của hệ thống máy. Nhiều giờ lưu lượng từ một mạng lưu trữ gây hại cho danh tính hồ sơ hơn cả việc không làm ấm.",
|
||||
"reasonSyncPassword": "Đã đặt mật khẩu mã hóa đồng bộ"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} đã làm ấm hồ sơ này lúc {{time}}",
|
||||
|
||||
@@ -2935,7 +2935,8 @@
|
||||
"proxyUpstreamTimeout": "代理没有响应。",
|
||||
"proxyAuthFailed": "代理拒绝了用户名或密码。",
|
||||
"vpnUnreachable": "VPN 服务器没有响应。",
|
||||
"entityInUse": "有 {{count}} 个配置文件正在使用。请先将它们改用其他代理或 VPN。"
|
||||
"entityInUse": "有 {{count}} 个配置文件正在使用。请先将它们改用其他代理或 VPN。",
|
||||
"syncPasswordBlocksRemoteRun": "同步加密密码加密了此配置文件已同步的设置,远程主机无法解密。要远程运行,请在同步加密设置中删除密码。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -3586,7 +3587,8 @@
|
||||
"fixEncrypted": "打开同步设置",
|
||||
"fixProxy": "绑定代理",
|
||||
"fixFailed": "无法应用该修复。",
|
||||
"exitNodeHint": "没有代理或 VPN 时,流量会从机器集群自己的数据中心地址发出。来自托管网络的长时间流量对配置文件身份的损害,比完全不养号还要大。"
|
||||
"exitNodeHint": "没有代理或 VPN 时,流量会从机器集群自己的数据中心地址发出。来自托管网络的长时间流量对配置文件身份的损害,比完全不养号还要大。",
|
||||
"reasonSyncPassword": "已设置同步加密密码"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "{{email}} 已在 {{time}} 养这个配置文件",
|
||||
|
||||
@@ -226,6 +226,7 @@ export type BackendErrorCode =
|
||||
| "REMOTE_HOURS_EXHAUSTED"
|
||||
| "REMOTE_REQUIRES_CLOUD_SYNC"
|
||||
| "REMOTE_ENCRYPTED_SYNC_UNSUPPORTED"
|
||||
| "SYNC_PASSWORD_BLOCKS_REMOTE_RUN"
|
||||
| "REMOTE_UNKNOWN_PLATFORM"
|
||||
| "PROFILE_RUNNING_REMOTELY"
|
||||
| "PROFILE_REMOTE_SYNC_PENDING"
|
||||
@@ -947,6 +948,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.remoteRequiresCloudSync");
|
||||
case "REMOTE_ENCRYPTED_SYNC_UNSUPPORTED":
|
||||
return t("backendErrors.remoteEncryptedSyncUnsupported");
|
||||
case "SYNC_PASSWORD_BLOCKS_REMOTE_RUN":
|
||||
return t("backendErrors.syncPasswordBlocksRemoteRun");
|
||||
case "REMOTE_UNKNOWN_PLATFORM":
|
||||
return t("backendErrors.remoteUnknownPlatform");
|
||||
case "PROFILE_RUNNING_REMOTELY":
|
||||
|
||||
Reference in new issue
Block a user