mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 22:41:22 +02:00
feat: browser-side migration
This commit is contained in:
1 parent
295c0ae27b
commit
2346bbf2ba
8 files changed
+834
-10
No files matched your search
@@ -852,6 +852,12 @@ impl BrowserRunner {
|
||||
launch_started.elapsed().as_millis()
|
||||
);
|
||||
|
||||
// The browser returned a config to store, and it is on disk already.
|
||||
if let Some(stored) = wayfern_result.stored_profile.as_ref() {
|
||||
updated_profile.wayfern_config = stored.wayfern_config.clone();
|
||||
updated_profile.host_os = stored.host_os.clone();
|
||||
}
|
||||
|
||||
// Update profile with the process info
|
||||
updated_profile.process_id = Some(process_id);
|
||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||
|
||||
@@ -184,6 +184,138 @@ pub(crate) fn resolve_archive_i18n(
|
||||
/// Read an unpacked extension's `manifest.json` off disk. The directory
|
||||
/// equivalent of `read_manifest_from_archive`, so both payload shapes feed the
|
||||
/// same metadata and icon extraction below.
|
||||
/// The manifest `key` of a staged copy. Chromium derives the id of an unpacked
|
||||
/// extension from its absolute path unless the manifest has a `key`, and the
|
||||
/// staging path differs on every machine and OS, so a path id leaves the
|
||||
/// extension's storage and settings on the machine that staged it. The key is
|
||||
/// derived from the profile and the extension, so the id is the same on every
|
||||
/// machine and still differs between profiles.
|
||||
fn portable_manifest_key(profile_id: &str, ext_id: &str) -> Vec<u8> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"donut-staged-extension-key\0");
|
||||
hasher.update(profile_id.as_bytes());
|
||||
hasher.update([0u8]);
|
||||
hasher.update(ext_id.as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// The extension id Chromium derives from a manifest `key`: the first 16 bytes
|
||||
/// of its SHA-256, each nibble written as a letter from `a` to `p`.
|
||||
fn extension_id_for_key(key: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(key)[..16]
|
||||
.iter()
|
||||
.flat_map(|byte| [byte >> 4, byte & 0x0f])
|
||||
.map(|nibble| char::from(b'a' + nibble))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether two spellings name the same directory, as the browser records it.
|
||||
fn same_dir(recorded: &str, dir: &Path) -> bool {
|
||||
let recorded = Path::new(recorded);
|
||||
let canonical = dir.canonicalize().ok();
|
||||
[Some(dir.to_path_buf()), canonical]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|candidate| {
|
||||
let candidate = candidate.to_string_lossy().to_string();
|
||||
let candidate = candidate.strip_prefix(r"\\?\").unwrap_or(&candidate);
|
||||
if cfg!(windows) {
|
||||
recorded.to_string_lossy().eq_ignore_ascii_case(candidate)
|
||||
} else {
|
||||
recorded == Path::new(candidate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The id under which the profile already keeps state for the extension staged
|
||||
/// at `dir`, when that id is not `portable_id`. Such state was recorded before
|
||||
/// staged copies carried a key.
|
||||
fn recorded_path_id(profile_data_path: &Path, dir: &Path, portable_id: &str) -> Option<String> {
|
||||
let mut recorded_path_ids = Vec::new();
|
||||
for file in ["Preferences", "Secure Preferences"] {
|
||||
let Ok(contents) = fs::read_to_string(profile_data_path.join("Default").join(file)) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(prefs) = serde_json::from_str::<serde_json::Value>(&contents) else {
|
||||
continue;
|
||||
};
|
||||
let Some(settings) = prefs
|
||||
.pointer("/extensions/settings")
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if settings.contains_key(portable_id) {
|
||||
return None;
|
||||
}
|
||||
recorded_path_ids.extend(
|
||||
settings
|
||||
.iter()
|
||||
.filter(|(_, entry)| {
|
||||
entry
|
||||
.get("path")
|
||||
.and_then(|p| p.as_str())
|
||||
.is_some_and(|p| same_dir(p, dir))
|
||||
})
|
||||
.map(|(id, _)| id.clone()),
|
||||
);
|
||||
}
|
||||
recorded_path_ids.into_iter().next()
|
||||
}
|
||||
|
||||
/// Give the staged copy at `dir` a manifest `key`, so the browser gives it the
|
||||
/// same id on every machine. A manifest that already has a key keeps it. A
|
||||
/// profile that already keeps state under the copy's path id keeps that id, so
|
||||
/// an update never resets an extension on the machine it runs on.
|
||||
fn give_portable_id(dir: &Path, profile_id: &str, ext_id: &str, profile_data_path: &Path) {
|
||||
use base64::Engine;
|
||||
|
||||
let manifest_path = dir.join("manifest.json");
|
||||
let Ok(contents) = fs::read_to_string(&manifest_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut manifest) =
|
||||
serde_json::from_str::<serde_json::Value>(contents.trim_start_matches('\u{feff}'))
|
||||
else {
|
||||
log::warn!("Extension keeps a path id, its manifest is not plain JSON extension={ext_id}");
|
||||
return;
|
||||
};
|
||||
let Some(object) = manifest.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
if object.contains_key("key") {
|
||||
return;
|
||||
}
|
||||
let key = portable_manifest_key(profile_id, ext_id);
|
||||
let portable_id = extension_id_for_key(&key);
|
||||
if let Some(recorded) = recorded_path_id(profile_data_path, dir, &portable_id) {
|
||||
log::info!(
|
||||
"Extension keeps the id its data is under extension={ext_id} id={recorded} profile={profile_id}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
object.insert(
|
||||
"key".to_string(),
|
||||
serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(key)),
|
||||
);
|
||||
match serde_json::to_vec_pretty(&manifest) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = fs::write(&manifest_path, bytes) {
|
||||
log::warn!(
|
||||
"Extension keeps a path id, manifest write failed extension={ext_id} err=\"{e}\""
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Extension keeps a path id, manifest encode failed extension={ext_id} err=\"{e}\""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_manifest_from_dir(dir: &Path) -> Option<serde_json::Value> {
|
||||
let contents = fs::read_to_string(dir.join("manifest.json")).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
@@ -1582,6 +1714,12 @@ impl ExtensionManager {
|
||||
// Extract .crx or .zip
|
||||
match Self::unpack_extension(&src_file, &unpack_dir) {
|
||||
Ok(()) => {
|
||||
give_portable_id(
|
||||
&unpack_dir,
|
||||
&profile.id.to_string(),
|
||||
ext_id,
|
||||
_profile_data_path,
|
||||
);
|
||||
extension_paths.push(unpack_dir.to_string_lossy().to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -2011,6 +2149,83 @@ pub async fn get_extension_group_for_profile(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extension_ids_follow_the_browser_derivation() {
|
||||
assert_eq!(
|
||||
extension_id_for_key(b"test"),
|
||||
"jpignaibiiemhngfjkcpokkamffknabf"
|
||||
);
|
||||
assert_eq!(
|
||||
extension_id_for_key(b"_"),
|
||||
"ncocknphbhhlhkikpnnlmbcnbgdempcd"
|
||||
);
|
||||
}
|
||||
|
||||
fn staged_copy(root: &Path, name: &str, manifest: &str) -> PathBuf {
|
||||
let dir = root.join(name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("manifest.json"), manifest).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn manifest_key(dir: &Path) -> Option<String> {
|
||||
read_manifest_from_dir(dir)?
|
||||
.get("key")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_staged_copy_gets_one_key_per_profile_on_every_machine() {
|
||||
let machine_a = tempfile::tempdir().unwrap();
|
||||
let machine_b = tempfile::tempdir().unwrap();
|
||||
let manifest = r#"{"name":"x","version":"1","manifest_version":3}"#;
|
||||
let on_a = staged_copy(machine_a.path(), "staged", manifest);
|
||||
let on_b = staged_copy(machine_b.path(), "elsewhere/staged", manifest);
|
||||
let other_profile = staged_copy(machine_a.path(), "other", manifest);
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
|
||||
give_portable_id(&on_a, "profile-1", "ext-1", data.path());
|
||||
give_portable_id(&on_b, "profile-1", "ext-1", data.path());
|
||||
give_portable_id(&other_profile, "profile-2", "ext-1", data.path());
|
||||
|
||||
let key = manifest_key(&on_a).expect("a key is written");
|
||||
assert_eq!(Some(key.clone()), manifest_key(&on_b));
|
||||
assert_ne!(Some(key), manifest_key(&other_profile));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manifest_key_and_recorded_extension_state_are_kept() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let keyed = staged_copy(
|
||||
root.path(),
|
||||
"keyed",
|
||||
r#"{"name":"x","version":"1","manifest_version":3,"key":"AAAA"}"#,
|
||||
);
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
give_portable_id(&keyed, "profile-1", "ext-1", data.path());
|
||||
assert_eq!(manifest_key(&keyed).as_deref(), Some("AAAA"));
|
||||
|
||||
let used = staged_copy(
|
||||
root.path(),
|
||||
"used",
|
||||
r#"{"name":"x","version":"1","manifest_version":3}"#,
|
||||
);
|
||||
fs::create_dir_all(data.path().join("Default")).unwrap();
|
||||
let prefs = serde_json::json!({
|
||||
"extensions": {"settings": {"abcdefghijklmnopabcdefghijklmnop": {
|
||||
"path": used.to_string_lossy(),
|
||||
}}}
|
||||
});
|
||||
fs::write(
|
||||
data.path().join("Default").join("Preferences"),
|
||||
prefs.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
give_portable_id(&used, "profile-1", "ext-2", data.path());
|
||||
assert_eq!(manifest_key(&used), None, "the recorded path id stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_type() {
|
||||
assert_eq!(get_file_type("ext.crx"), Some("crx".to_string()));
|
||||
|
||||
@@ -79,6 +79,7 @@ pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"*.tmp",
|
||||
"**/*.donut-tmp",
|
||||
"**/wayfern-identity.json",
|
||||
"**/wayfern-identity-result.json",
|
||||
"**/wayfern-persona.json",
|
||||
BASELINE_FILE_NAME,
|
||||
];
|
||||
|
||||
@@ -350,6 +350,7 @@ fn the_sealed_exclusions_keep_what_a_database_needs() {
|
||||
"Default/Local Storage/leveldb/LOCK",
|
||||
"SingletonLock",
|
||||
"wayfern-identity.json",
|
||||
"wayfern-identity-result.json",
|
||||
BASELINE_FILE_NAME,
|
||||
"Default/x.donut-tmp",
|
||||
] {
|
||||
|
||||
@@ -153,8 +153,9 @@ pub fn is_excluded(relative: &str) -> bool {
|
||||
"CacheStorage",
|
||||
"ScriptCache",
|
||||
];
|
||||
const LAUNCH_FILES: [&str; 3] = [
|
||||
const LAUNCH_FILES: [&str; 4] = [
|
||||
"wayfern-identity.json",
|
||||
"wayfern-identity-result.json",
|
||||
"wayfern-persona.json",
|
||||
"window-icon.png",
|
||||
];
|
||||
@@ -246,6 +247,12 @@ pub fn export_to(
|
||||
.map_err(|e| err("profile", e))?;
|
||||
|
||||
if carries_data {
|
||||
// A profile created before it had a key file holds its key only in a form
|
||||
// this Windows account can open; the archive must carry the key file.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(e) = crate::profile_import::keyring::write_key_file_from_local_state(data_dir) {
|
||||
log::warn!("Profile export carries no key file err=\"{e}\"");
|
||||
}
|
||||
let mut written = 0u64;
|
||||
for (relative, absolute) in collect_files(data_dir)? {
|
||||
let data = match fs::read(&absolute) {
|
||||
|
||||
@@ -57,6 +57,7 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
// Rewritten by donut before every launch from the profile metadata that
|
||||
// already syncs; uploading it would only duplicate that state.
|
||||
"**/wayfern-identity.json",
|
||||
"**/wayfern-identity-result.json",
|
||||
"**/wayfern-persona.json",
|
||||
"**/SingletonSocket",
|
||||
"**/SingletonCookie",
|
||||
|
||||
@@ -2,10 +2,11 @@ use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::profile::types::get_host_os;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::wayfern_manager::{
|
||||
cdp_error_is_invalid_params, is_temporary_wayfern_failure, supports_wayfern_152, wayfern_failure,
|
||||
HeadlessWayfern, WayfernConfig, WayfernManager,
|
||||
cdp_error_is_invalid_params, is_temporary_wayfern_failure, supports_launch_result,
|
||||
supports_wayfern_152, wayfern_failure, HeadlessWayfern, WayfernConfig, WayfernManager,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
@@ -91,6 +92,7 @@ pub fn stored_shape(config: &WayfernConfig) -> StoredShape {
|
||||
pub fn needs_conversion(profile: &BrowserProfile) -> bool {
|
||||
profile.browser == "wayfern"
|
||||
&& supports_wayfern_152(&profile.version)
|
||||
&& !supports_launch_result(&profile.version)
|
||||
&& profile.wayfern_config.as_ref().is_some_and(|config| {
|
||||
config.randomize_fingerprint_on_launch != Some(true)
|
||||
&& matches!(
|
||||
@@ -221,6 +223,170 @@ fn write_backup(profile: &BrowserProfile) -> Result<(), String> {
|
||||
Err("no free backup name".to_string())
|
||||
}
|
||||
|
||||
/// The config fields the launch document carries, exactly as stored.
|
||||
fn stored_fields(config: &WayfernConfig) -> Map<String, Value> {
|
||||
[
|
||||
("fingerprint", &config.fingerprint),
|
||||
("identity_id", &config.identity_id),
|
||||
("identity_baseline", &config.identity_baseline),
|
||||
("identity_overrides", &config.identity_overrides),
|
||||
("os", &config.os),
|
||||
("location", &config.location),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| Some((key.to_string(), json!(value.as_ref()?))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The launch document keys that let the browser report the config to store.
|
||||
pub fn launch_document_keys(
|
||||
profile: &BrowserProfile,
|
||||
config: &WayfernConfig,
|
||||
result_file: &Path,
|
||||
account_known: bool,
|
||||
) -> Map<String, Value> {
|
||||
let mut keys = Map::new();
|
||||
keys.insert("profileSalt".to_string(), json!(profile.id.to_string()));
|
||||
keys.insert(
|
||||
"resultFile".to_string(),
|
||||
json!(result_file.to_string_lossy()),
|
||||
);
|
||||
keys.insert("nativeOs".to_string(), json!(profile.native_os()));
|
||||
keys.insert("accountKnown".to_string(), json!(account_known));
|
||||
keys.insert("stored".to_string(), Value::Object(stored_fields(config)));
|
||||
keys
|
||||
}
|
||||
|
||||
/// What the browser reported about a launch.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LaunchResult {
|
||||
pub status: String,
|
||||
/// The identity the browser launched on, when it launched on one.
|
||||
pub identity_id: Option<String>,
|
||||
pub operating_system: Option<String>,
|
||||
config: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
/// This launch's result for `profile_id`, or `None` when there is no
|
||||
/// readable result for that profile.
|
||||
pub fn read_launch_result(path: &Path, profile_id: &uuid::Uuid) -> Option<LaunchResult> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let result: Value = match serde_json::from_str(&content) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!("Launch result unreadable profile={profile_id} err=\"{e}\"");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if result.get("profileSalt").and_then(Value::as_str) != Some(profile_id.to_string().as_str()) {
|
||||
log::warn!("Launch result is for another profile profile={profile_id}");
|
||||
return None;
|
||||
}
|
||||
let text = |key: &str| {
|
||||
result
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let status = text("status")?;
|
||||
log::debug!("Launch result profile={profile_id} status={status}");
|
||||
Some(LaunchResult {
|
||||
identity_id: text("identityId"),
|
||||
operating_system: text("operatingSystem"),
|
||||
config: result.get("config").and_then(Value::as_object).cloned(),
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
/// `profile` with the config the browser returned, or `None` when the result
|
||||
/// carries no config to store.
|
||||
fn profile_with_result(profile: &BrowserProfile, result: &LaunchResult) -> Option<BrowserProfile> {
|
||||
if result.status != "converted" {
|
||||
return None;
|
||||
}
|
||||
let returned = result.config.as_ref()?;
|
||||
let text = |key: &str| {
|
||||
returned
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
let identity_id = text("identity_id").filter(|id| !id.trim().is_empty())?;
|
||||
let os = text("os")?;
|
||||
let os = WayfernManager::normalize_os_name(&os)?;
|
||||
|
||||
let mut updated = profile.clone();
|
||||
let config = updated
|
||||
.wayfern_config
|
||||
.get_or_insert_with(WayfernConfig::default);
|
||||
config.identity_id = Some(identity_id);
|
||||
config.os = Some(os.to_string());
|
||||
config.identity_overrides = text("identity_overrides");
|
||||
if let Some(location) = text("location") {
|
||||
config.location = Some(location);
|
||||
}
|
||||
config.fingerprint = None;
|
||||
config.identity_baseline = None;
|
||||
|
||||
let host_os = get_host_os();
|
||||
if updated.host_os.is_none() && os != host_os {
|
||||
updated.host_os = Some(host_os);
|
||||
}
|
||||
Some(updated)
|
||||
}
|
||||
|
||||
/// Store the config the browser returned, and return the stored profile.
|
||||
/// Nothing is stored when the profile changed since the launch began.
|
||||
pub fn store_launch_result(
|
||||
profile: &BrowserProfile,
|
||||
sent: &WayfernConfig,
|
||||
result: &LaunchResult,
|
||||
) -> Option<BrowserProfile> {
|
||||
if result.status != "converted" {
|
||||
return None;
|
||||
}
|
||||
let current = read_profile(&profile.id)?;
|
||||
let held = current.wayfern_config.clone().unwrap_or_default();
|
||||
if stored_fields(&held) != stored_fields(sent) {
|
||||
log::debug!(
|
||||
"Launch result not stored, profile changed profile={}",
|
||||
profile.id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let stored = profile_with_result(¤t, result)?;
|
||||
if let Err(e) = write_backup(¤t) {
|
||||
log::warn!(
|
||||
"Launch result not stored, backup failed profile={} err=\"{e}\"",
|
||||
profile.id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
match ProfileManager::instance().save_profile_unchanged(¤t, &stored) {
|
||||
Ok(true) => {
|
||||
log::info!("Profile stored as an identity profile={}", stored.id);
|
||||
let _ = crate::events::emit_empty("profiles-changed");
|
||||
Some(stored)
|
||||
}
|
||||
Ok(false) => {
|
||||
log::debug!(
|
||||
"Launch result not stored, profile changed profile={}",
|
||||
profile.id
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Launch result not stored profile={} err=\"{e}\"",
|
||||
profile.id
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_value(profile: &BrowserProfile) -> Value {
|
||||
serde_json::to_value(&profile.wayfern_config).unwrap_or(Value::Null)
|
||||
}
|
||||
@@ -680,6 +846,10 @@ mod tests {
|
||||
"150.0.7801.12",
|
||||
payload_only.clone()
|
||||
)));
|
||||
assert!(!needs_conversion(&profile_with(
|
||||
"155.0.8059.79",
|
||||
payload_only.clone()
|
||||
)));
|
||||
|
||||
let mut randomized = payload_only.clone();
|
||||
randomized.randomize_fingerprint_on_launch = Some(true);
|
||||
@@ -935,6 +1105,221 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_launch_document_carries_the_stored_config_verbatim() {
|
||||
let config = WayfernConfig {
|
||||
fingerprint: Some(format!(r#"{{"fingerprint":{}}}"#, payload())),
|
||||
identity_id: Some(format!(" {LEGACY_ID} ")),
|
||||
identity_overrides: Some("not json".to_string()),
|
||||
persona: Some("p".to_string()),
|
||||
proxy: Some("socks5://127.0.0.1:1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let profile = profile_with("155.0.8059.79", config.clone());
|
||||
let result_file = std::path::PathBuf::from("/tmp/p/wayfern-identity-result.json");
|
||||
let keys = launch_document_keys(&profile, &config, &result_file, true);
|
||||
|
||||
assert_eq!(keys["profileSalt"], json!(profile.id.to_string()));
|
||||
assert_eq!(
|
||||
keys["resultFile"],
|
||||
json!("/tmp/p/wayfern-identity-result.json")
|
||||
);
|
||||
assert_eq!(keys["nativeOs"], json!(profile.native_os()));
|
||||
assert_eq!(keys["accountKnown"], json!(true));
|
||||
let stored = keys["stored"].as_object().unwrap();
|
||||
assert_eq!(stored["fingerprint"], json!(config.fingerprint.unwrap()));
|
||||
assert_eq!(stored["identity_id"], json!(format!(" {LEGACY_ID} ")));
|
||||
assert_eq!(stored["identity_overrides"], json!("not json"));
|
||||
let mut names: Vec<&str> = stored.keys().map(String::as_str).collect();
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, ["fingerprint", "identity_id", "identity_overrides"]);
|
||||
|
||||
let empty = launch_document_keys(&profile, &WayfernConfig::default(), &result_file, false);
|
||||
assert_eq!(empty["stored"], json!({}));
|
||||
assert_eq!(empty["accountKnown"], json!(false));
|
||||
}
|
||||
|
||||
fn result_body(profile: &BrowserProfile, status: &str, config: Value) -> Value {
|
||||
json!({
|
||||
"version": 1,
|
||||
"profileSalt": profile.id.to_string(),
|
||||
"status": status,
|
||||
"identityId": CURRENT_ID,
|
||||
"operatingSystem": "windows",
|
||||
"config": config,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_result(dir: &Path, body: &Value) -> std::path::PathBuf {
|
||||
let path = dir.join(crate::wayfern_manager::LAUNCH_RESULT_FILE);
|
||||
std::fs::write(&path, body.to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_result_for_this_profile_is_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let profile = profile_with("155.0.8059.79", WayfernConfig::default());
|
||||
let missing = dir.path().join("absent.json");
|
||||
assert!(read_launch_result(&missing, &profile.id).is_none());
|
||||
|
||||
let path = write_result(dir.path(), &result_body(&profile, "unchanged", Value::Null));
|
||||
let result = read_launch_result(&path, &profile.id).unwrap();
|
||||
assert_eq!(result.status, "unchanged");
|
||||
assert_eq!(result.identity_id.as_deref(), Some(CURRENT_ID));
|
||||
assert_eq!(result.operating_system.as_deref(), Some("windows"));
|
||||
|
||||
let mut other = result_body(&profile, "converted", json!({}));
|
||||
other["profileSalt"] = json!(LEGACY_ID);
|
||||
let path = write_result(dir.path(), &other);
|
||||
assert!(read_launch_result(&path, &profile.id).is_none());
|
||||
|
||||
std::fs::write(&path, "{").unwrap();
|
||||
assert!(read_launch_result(&path, &profile.id).is_none());
|
||||
|
||||
let mut failed = result_body(&profile, "failed", Value::Null);
|
||||
let object = failed.as_object_mut().unwrap();
|
||||
object.remove("identityId");
|
||||
object.remove("operatingSystem");
|
||||
let path = write_result(dir.path(), &failed);
|
||||
let result = read_launch_result(&path, &profile.id).unwrap();
|
||||
assert_eq!(result.status, "failed");
|
||||
assert!(result.identity_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_converted_result_is_stored_as_returned() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _data = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
let sent = WayfernConfig {
|
||||
fingerprint: Some(payload()),
|
||||
identity_baseline: Some(payload()),
|
||||
identity_overrides: Some(r#"{"doNotTrack":"1"}"#.to_string()),
|
||||
location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()),
|
||||
persona: Some("p".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let profile = profile_with("155.0.8059.79", sent.clone());
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
|
||||
let returned = json!({
|
||||
"identity_id": CURRENT_ID,
|
||||
"os": "windows",
|
||||
"identity_overrides": null,
|
||||
});
|
||||
let path = write_result(dir.path(), &result_body(&profile, "converted", returned));
|
||||
let result = read_launch_result(&path, &profile.id).unwrap();
|
||||
let mut launch_copy = sent.clone();
|
||||
launch_copy.proxy = Some("socks5://127.0.0.1:1".to_string());
|
||||
let stored = store_launch_result(&profile, &launch_copy, &result).expect("stored");
|
||||
|
||||
let on_disk = read_profile(&profile.id).unwrap();
|
||||
assert_eq!(config_value(&on_disk), config_value(&stored));
|
||||
let config = on_disk.wayfern_config.unwrap();
|
||||
assert_eq!(config.identity_id.as_deref(), Some(CURRENT_ID));
|
||||
assert_eq!(config.os.as_deref(), Some("windows"));
|
||||
assert!(config.identity_overrides.is_none());
|
||||
assert!(config.fingerprint.is_none());
|
||||
assert!(config.identity_baseline.is_none());
|
||||
assert_eq!(
|
||||
config.location, sent.location,
|
||||
"an absent location keeps the stored one"
|
||||
);
|
||||
assert_eq!(config.persona.as_deref(), Some("p"));
|
||||
assert!(config.proxy.is_none());
|
||||
|
||||
let backup = crate::app_dirs::data_subdir()
|
||||
.join("wayfern-config-backups")
|
||||
.join(format!("{}.json", profile.id));
|
||||
let backup: Value = serde_json::from_slice(&std::fs::read(backup).unwrap()).unwrap();
|
||||
assert_eq!(backup["wayfernConfig"]["fingerprint"], json!(payload()));
|
||||
|
||||
// The stored config is no longer the one that was sent: nothing is stored.
|
||||
assert!(store_launch_result(&profile, &launch_copy, &result).is_none());
|
||||
assert_eq!(
|
||||
config_value(&read_profile(&profile.id).unwrap()),
|
||||
config_value(&stored)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_converted_result_with_a_config_is_stored() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _data = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
let sent = WayfernConfig {
|
||||
fingerprint: Some(payload()),
|
||||
..Default::default()
|
||||
};
|
||||
let profile = profile_with("155.0.8059.79", sent.clone());
|
||||
ProfileManager::instance().save_profile(&profile).unwrap();
|
||||
let returned = json!({ "identity_id": CURRENT_ID, "os": "windows" });
|
||||
|
||||
for status in ["unchanged", "deferred", "failed"] {
|
||||
let path = write_result(dir.path(), &result_body(&profile, status, returned.clone()));
|
||||
let result = read_launch_result(&path, &profile.id).unwrap();
|
||||
assert!(
|
||||
store_launch_result(&profile, &sent, &result).is_none(),
|
||||
"{status}"
|
||||
);
|
||||
}
|
||||
for unusable in [
|
||||
Value::Null,
|
||||
json!({ "os": "windows" }),
|
||||
json!({ "identity_id": CURRENT_ID, "os": "beos" }),
|
||||
] {
|
||||
let path = write_result(
|
||||
dir.path(),
|
||||
&result_body(&profile, "converted", unusable.clone()),
|
||||
);
|
||||
let result = read_launch_result(&path, &profile.id).unwrap();
|
||||
assert!(
|
||||
store_launch_result(&profile, &sent, &result).is_none(),
|
||||
"{unusable}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut edited = profile.clone();
|
||||
edited.wayfern_config.as_mut().unwrap().identity_overrides =
|
||||
Some(r#"{"doNotTrack":"1"}"#.to_string());
|
||||
ProfileManager::instance().save_profile(&edited).unwrap();
|
||||
let path = write_result(dir.path(), &result_body(&profile, "converted", returned));
|
||||
let result = read_launch_result(&path, &profile.id).unwrap();
|
||||
assert!(
|
||||
store_launch_result(&profile, &sent, &result).is_none(),
|
||||
"an edit made during the launch wins"
|
||||
);
|
||||
assert_eq!(
|
||||
config_value(&read_profile(&profile.id).unwrap()),
|
||||
config_value(&edited)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_returned_claim_off_the_host_keeps_the_profile_launchable_here() {
|
||||
let other = if get_host_os() == "macos" {
|
||||
"windows"
|
||||
} else {
|
||||
"macos"
|
||||
};
|
||||
let mut profile = profile_with("155.0.8059.79", WayfernConfig::default());
|
||||
profile.host_os = None;
|
||||
let result = LaunchResult {
|
||||
status: "converted".to_string(),
|
||||
identity_id: Some(CURRENT_ID.to_string()),
|
||||
operating_system: Some(other.to_string()),
|
||||
config: json!({ "identity_id": CURRENT_ID, "os": other, "location": "{}" })
|
||||
.as_object()
|
||||
.cloned(),
|
||||
};
|
||||
let updated = profile_with_result(&profile, &result).unwrap();
|
||||
assert_eq!(updated.host_os.as_deref(), Some(get_host_os().as_str()));
|
||||
assert!(!updated.is_cross_os());
|
||||
assert_eq!(
|
||||
updated.wayfern_config.unwrap().location.as_deref(),
|
||||
Some("{}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_refusals_are_permanent_and_everything_else_waits() {
|
||||
let invalid =
|
||||
|
||||
@@ -412,10 +412,26 @@ pub fn supports_wayfern_152(version: &str) -> bool {
|
||||
crate::api_client::compare_versions(version, WAYFERN_152_MIN_VERSION) != std::cmp::Ordering::Less
|
||||
}
|
||||
|
||||
/// First Wayfern version that reads the stored config from the launch document
|
||||
/// and writes the config to store back to a result file. Older browsers keep
|
||||
/// the launcher-side path in `wayfern_identity_storage`.
|
||||
const LAUNCH_RESULT_MIN_VERSION: &str = "155";
|
||||
|
||||
/// Whether `version` takes the stored config in the launch document and
|
||||
/// reports the config to store. Same rule as [`supports_wayfern_152`].
|
||||
pub fn supports_launch_result(version: &str) -> bool {
|
||||
crate::api_client::compare_versions(version, LAUNCH_RESULT_MIN_VERSION)
|
||||
!= std::cmp::Ordering::Less
|
||||
}
|
||||
|
||||
/// Where the launch-time identity document lives inside the profile directory.
|
||||
/// Rewritten before every launch; the sync manifest excludes it.
|
||||
pub const LAUNCH_IDENTITY_FILE: &str = "wayfern-identity.json";
|
||||
|
||||
/// Where the browser writes the config to store after a launch. Deleted before
|
||||
/// every launch, so a file that exists afterwards came from that launch.
|
||||
pub const LAUNCH_RESULT_FILE: &str = "wayfern-identity-result.json";
|
||||
|
||||
/// What kind of launch this is, from the browser's point of view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LaunchKind {
|
||||
@@ -797,10 +813,60 @@ pub fn persona_switch(
|
||||
Some(format!("--wayfern-profile-persona={}", path.display()))
|
||||
}
|
||||
|
||||
/// The folder in the profile directory that holds a copy of the camera source,
|
||||
/// so the source travels with the profile to every machine.
|
||||
const CARRIED_CAMERA_DIR: &str = "Camera Source";
|
||||
|
||||
/// The last component of a path written on any OS.
|
||||
fn path_file_name(path: &str) -> Option<&str> {
|
||||
path
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.filter(|name| !name.is_empty() && *name != "." && *name != "..")
|
||||
}
|
||||
|
||||
/// The camera source for a launch. The configured file is an absolute path on
|
||||
/// the machine where it was chosen. When it is here, a copy goes into the
|
||||
/// profile directory; when it is not, the copy that came with the profile is
|
||||
/// used.
|
||||
fn camera_source(profile_path: &Path, file: &str) -> String {
|
||||
let Some(name) = path_file_name(file) else {
|
||||
return file.to_string();
|
||||
};
|
||||
let carried_dir = profile_path.join(CARRIED_CAMERA_DIR);
|
||||
let carried = carried_dir.join(name);
|
||||
let source = Path::new(file);
|
||||
if source.is_file() {
|
||||
let current = match (std::fs::metadata(source), std::fs::metadata(&carried)) {
|
||||
(Ok(source), Ok(copy)) => {
|
||||
source.len() == copy.len()
|
||||
&& matches!(
|
||||
(source.modified(), copy.modified()),
|
||||
(Ok(source), Ok(copy)) if copy >= source
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !current {
|
||||
let _ = std::fs::remove_dir_all(&carried_dir);
|
||||
let copied =
|
||||
std::fs::create_dir_all(&carried_dir).and_then(|_| std::fs::copy(source, &carried));
|
||||
if let Err(e) = copied {
|
||||
log::warn!("Camera source not copied into the profile file={file} err=\"{e}\"");
|
||||
}
|
||||
}
|
||||
return file.to_string();
|
||||
}
|
||||
if carried.is_file() {
|
||||
return carried.to_string_lossy().to_string();
|
||||
}
|
||||
file.to_string()
|
||||
}
|
||||
|
||||
/// The camera switches for a launch. A file that is not there is still passed,
|
||||
/// so the camera serves dark frames rather than the computer's own camera; the
|
||||
/// log line here names the file.
|
||||
pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec<String> {
|
||||
pub fn camera_switches(version: &str, profile_path: &Path, config: &WayfernConfig) -> Vec<String> {
|
||||
if !supports_wayfern_152(version) {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -812,7 +878,8 @@ pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec<String> {
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !Path::new(file).is_file() {
|
||||
let file = camera_source(profile_path, file);
|
||||
if !Path::new(&file).is_file() {
|
||||
log::warn!("Camera source missing, camera serves dark frames file={file}");
|
||||
}
|
||||
let mut switches = vec![format!("--wayfern-camera-file={file}")];
|
||||
@@ -1327,6 +1394,10 @@ pub struct WayfernLaunchResult {
|
||||
pub profilePath: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub cdp_port: Option<u16>,
|
||||
/// The profile as stored after this launch, when the browser returned a
|
||||
/// config to store. The caller persists this copy instead of its own.
|
||||
#[serde(skip)]
|
||||
pub stored_profile: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
struct WayfernInstance {
|
||||
@@ -1882,6 +1953,50 @@ impl WayfernManager {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Delete the result file a previous launch left and return the absolute
|
||||
/// path the browser writes this launch's result to. `None` when the old file
|
||||
/// cannot be removed, so it can never be read as this launch's result.
|
||||
fn clear_launch_result(profile_path: &str, profile: &BrowserProfile) -> Option<PathBuf> {
|
||||
let dir = PathBuf::from(profile_path);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
log::warn!(
|
||||
"Profile directory not created for the launch result profile={} err=\"{e}\"",
|
||||
profile.id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let path = dir.canonicalize().unwrap_or(dir).join(LAUNCH_RESULT_FILE);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => Some(path),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(path),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Old launch result not removed profile={} err=\"{e}\"",
|
||||
profile.id
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read this launch's result and store the config it returns.
|
||||
fn store_launch_result(
|
||||
profile: &BrowserProfile,
|
||||
sent: &WayfernConfig,
|
||||
result_file: Option<&Path>,
|
||||
) -> (
|
||||
Option<crate::wayfern_identity_storage::LaunchResult>,
|
||||
Option<BrowserProfile>,
|
||||
) {
|
||||
let Some(result) = result_file
|
||||
.and_then(|path| crate::wayfern_identity_storage::read_launch_result(path, &profile.id))
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
let stored = crate::wayfern_identity_storage::store_launch_result(profile, sent, &result);
|
||||
(Some(result), stored)
|
||||
}
|
||||
|
||||
/// Whether the browser started on the identity the launcher handed it.
|
||||
///
|
||||
/// The browser's own stderr verdict is authoritative when present: a
|
||||
@@ -3002,12 +3117,20 @@ impl WayfernManager {
|
||||
// the profile's device rather than the host's. Older browsers, legacy
|
||||
// device payloads and a profile whose location carries no timezone keep
|
||||
// the post-launch CDP apply below.
|
||||
let launch_identity = if supports_wayfern_152(&profile.version) {
|
||||
let identity_document = if supports_wayfern_152(&profile.version) {
|
||||
Self::launch_identity_document(config, &profile.native_os())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if launch_identity.is_none()
|
||||
// A browser that reports a result also gets the stored config, and the
|
||||
// launcher stores what the result says after the launch.
|
||||
let result_file = if supports_launch_result(&profile.version) {
|
||||
Self::clear_launch_result(profile_path, profile)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if identity_document.is_none()
|
||||
&& result_file.is_none()
|
||||
&& supports_wayfern_152(&profile.version)
|
||||
&& config.identity_id.is_some()
|
||||
&& config.fingerprint.is_none()
|
||||
@@ -3017,6 +3140,27 @@ impl WayfernManager {
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
let launch_identity = match result_file.as_deref() {
|
||||
Some(result_file) => {
|
||||
let account_known = (wayfern_token.is_some()
|
||||
|| !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_entitled_to_wayfern_token()
|
||||
.await)
|
||||
&& crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||
let mut document = identity_document
|
||||
.as_ref()
|
||||
.and_then(|document| document.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
document.extend(crate::wayfern_identity_storage::launch_document_keys(
|
||||
profile,
|
||||
config,
|
||||
result_file,
|
||||
account_known,
|
||||
));
|
||||
Some(serde_json::Value::Object(document))
|
||||
}
|
||||
None => identity_document.clone(),
|
||||
};
|
||||
let identity_file = match &launch_identity {
|
||||
Some(document) => Some(
|
||||
Self::write_launch_identity(profile_path, document)
|
||||
@@ -3025,7 +3169,7 @@ impl WayfernManager {
|
||||
None => None,
|
||||
};
|
||||
let identity_at_launch =
|
||||
identity_file.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none());
|
||||
identity_document.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none());
|
||||
let pages = launch_pages(url, &profile.start_urls, kind);
|
||||
let restore_session = match session_restore_verdict(
|
||||
config,
|
||||
@@ -3093,7 +3237,11 @@ impl WayfernManager {
|
||||
&persona_seed,
|
||||
config.persona.as_deref(),
|
||||
));
|
||||
args.extend(camera_switches(&profile.version, config));
|
||||
args.extend(camera_switches(
|
||||
&profile.version,
|
||||
Path::new(profile_path),
|
||||
config,
|
||||
));
|
||||
args.extend(widevine_switch(
|
||||
&profile.version,
|
||||
&crate::app_dirs::data_dir(),
|
||||
@@ -3150,6 +3298,7 @@ impl WayfernManager {
|
||||
}
|
||||
kill_browser_process(pid);
|
||||
}
|
||||
Self::store_launch_result(profile, config, result_file.as_deref());
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -3161,6 +3310,30 @@ impl WayfernManager {
|
||||
page_targets.len()
|
||||
);
|
||||
|
||||
let (launch_result, stored_profile) =
|
||||
Self::store_launch_result(profile, config, result_file.as_deref());
|
||||
// The browser names the identity it launched on; that is the one to check.
|
||||
let verify_document = match launch_result
|
||||
.as_ref()
|
||||
.and_then(|result| result.identity_id.as_deref())
|
||||
{
|
||||
Some(identity_id) => {
|
||||
let mut document = identity_document
|
||||
.as_ref()
|
||||
.and_then(|document| document.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
document.insert("identityId".to_string(), json!(identity_id));
|
||||
if let Some(os) = launch_result
|
||||
.as_ref()
|
||||
.and_then(|result| result.operating_system.as_deref())
|
||||
{
|
||||
document.insert("operatingSystem".to_string(), json!(os));
|
||||
}
|
||||
Some(serde_json::Value::Object(document))
|
||||
}
|
||||
None => identity_document.clone(),
|
||||
};
|
||||
|
||||
// An identity-backed profile: the id, the user's overrides and the exit's
|
||||
// location are all the browser needs, and all the profile stores. The
|
||||
// device comes back in the response and is deliberately NOT persisted.
|
||||
@@ -3171,7 +3344,7 @@ impl WayfernManager {
|
||||
&& !supports_wayfern_152(&profile.version)
|
||||
&& config.identity_id.is_some()
|
||||
&& config.fingerprint.is_some();
|
||||
if let Some(document) = launch_identity.as_ref().filter(|_| identity_file.is_some()) {
|
||||
if let Some(document) = verify_document.as_ref() {
|
||||
// The identity travelled on the command line. The browser never fails
|
||||
// its own launch over it (a refusal only logs), so the launcher checks,
|
||||
// and a browser running on the wrong device is closed rather than
|
||||
@@ -3496,6 +3669,7 @@ impl WayfernManager {
|
||||
profilePath: Some(profile_path.to_string()),
|
||||
url: url.map(|s| s.to_string()),
|
||||
cdp_port: Some(port),
|
||||
stored_profile,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3722,6 +3896,7 @@ impl WayfernManager {
|
||||
profilePath: instance.profile_path.clone(),
|
||||
url: instance.url.clone(),
|
||||
cdp_port: instance.cdp_port,
|
||||
stored_profile: None,
|
||||
});
|
||||
} else {
|
||||
log::info!(
|
||||
@@ -3777,6 +3952,7 @@ impl WayfernManager {
|
||||
profilePath: Some(found_profile_path),
|
||||
url: None,
|
||||
cdp_port,
|
||||
stored_profile: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4903,6 +5079,9 @@ mod tests {
|
||||
camera_crop: crop.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
let profile = tempfile::tempdir().unwrap();
|
||||
let camera_switches =
|
||||
|version: &str, config: &WayfernConfig| camera_switches(version, profile.path(), config);
|
||||
assert!(camera_switches("151.0.7922.76", &config(Some(&path), None)).is_empty());
|
||||
assert!(camera_switches("152.0.7977.64", &config(None, Some("0,0,10,10"))).is_empty());
|
||||
assert_eq!(
|
||||
@@ -4927,6 +5106,35 @@ mod tests {
|
||||
assert!(valid_camera_crop(" 1, 2, 30, 40 "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_camera_source_travels_with_the_profile() {
|
||||
let chosen_on = tempfile::tempdir().unwrap();
|
||||
let file = chosen_on.path().join("frame.png");
|
||||
std::fs::write(&file, b"frame").unwrap();
|
||||
let path = file.to_string_lossy().to_string();
|
||||
let profile = tempfile::tempdir().unwrap();
|
||||
|
||||
assert_eq!(camera_source(profile.path(), &path), path);
|
||||
let carried = profile.path().join(CARRIED_CAMERA_DIR).join("frame.png");
|
||||
assert_eq!(std::fs::read(&carried).unwrap(), b"frame");
|
||||
|
||||
// Another machine has neither the folder nor the file, only the profile.
|
||||
drop(chosen_on);
|
||||
assert_eq!(
|
||||
camera_source(profile.path(), &path),
|
||||
carried.to_string_lossy().to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
camera_source(profile.path(), r"C:\Users\me\Pictures\frame.png"),
|
||||
carried.to_string_lossy().to_string(),
|
||||
"a path written on Windows names the same copy"
|
||||
);
|
||||
assert_eq!(
|
||||
camera_source(profile.path(), "/nope/other.png"),
|
||||
"/nope/other.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widevine_travels_only_when_a_cdm_has_been_provisioned() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in new issue
Block a user