mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
initial sandbox implementation for mac app store builds
This commit is contained in:
1
apps/desktop/desktop_native/Cargo.lock
generated
1
apps/desktop/desktop_native/Cargo.lock
generated
@@ -610,6 +610,7 @@ dependencies = [
|
||||
"cbc",
|
||||
"dirs",
|
||||
"hex",
|
||||
"libc",
|
||||
"oo7",
|
||||
"pbkdf2",
|
||||
"rand 0.9.1",
|
||||
|
||||
@@ -25,6 +25,7 @@ tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { workspace = true, features = [
|
||||
@@ -39,3 +40,6 @@ oo7 = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
sandbox = []
|
||||
|
||||
@@ -59,10 +59,23 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
|
||||
fn get_installed_browsers() -> Result<Vec<String>> {
|
||||
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
|
||||
|
||||
#[allow(unused_variables)] // config only used in non-sandbox mode
|
||||
for (browser, config) in SUPPORTED_BROWSER_MAP.iter() {
|
||||
let data_dir = get_browser_data_dir(config)?;
|
||||
if data_dir.exists() {
|
||||
browsers.push((*browser).to_string());
|
||||
#[cfg(all(target_os = "macos", feature = "sandbox"))]
|
||||
{
|
||||
// macOS sandbox mode: check if we have stored security-scoped bookmark
|
||||
if platform::ScopedBrowserAccess::has_stored_access(browser) {
|
||||
browsers.push((*browser).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(target_os = "macos", feature = "sandbox")))]
|
||||
{
|
||||
// All other platforms OR macOS without sandbox: check file system directly
|
||||
let data_dir = get_browser_data_dir(config)?;
|
||||
if data_dir.exists() {
|
||||
browsers.push((*browser).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +88,21 @@ pub fn get_available_profiles(browser_name: &String) -> Result<Vec<ProfileInfo>>
|
||||
Ok(get_profile_info(&local_state))
|
||||
}
|
||||
|
||||
/// Request access to browser directory (sandbox mode only)
|
||||
#[cfg(all(target_os = "macos", feature = "sandbox"))]
|
||||
pub fn request_browser_access(browser_name: &String) -> Result<()> {
|
||||
let _access = platform::ScopedBrowserAccess::request_and_start(browser_name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn import_logins(
|
||||
browser_name: &String,
|
||||
profile_id: &String,
|
||||
) -> Result<Vec<LoginImportResult>> {
|
||||
// In sandbox mode, resume access to browser directory
|
||||
#[cfg(all(target_os = "macos", feature = "sandbox"))]
|
||||
let _access = platform::ScopedBrowserAccess::resume(browser_name)?;
|
||||
|
||||
let (data_dir, local_state) = load_local_state_for_browser(browser_name)?;
|
||||
|
||||
let mut crypto_service = platform::get_crypto_service(browser_name, &local_state)
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use security_framework::passwords::get_generic_password;
|
||||
use std::ffi::CString;
|
||||
use std::os::raw::c_char;
|
||||
|
||||
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
|
||||
use crate::util;
|
||||
|
||||
//
|
||||
// Sandbox
|
||||
//
|
||||
|
||||
// FFI declarations
|
||||
extern "C" {
|
||||
fn requestBrowserAccess(browser_name: *const c_char) -> *mut c_char;
|
||||
fn hasStoredBrowserAccess(browser_name: *const c_char) -> bool;
|
||||
fn startBrowserAccess(browser_name: *const c_char) -> *mut c_char;
|
||||
fn stopBrowserAccess(browser_name: *const c_char);
|
||||
}
|
||||
|
||||
pub struct ScopedBrowserAccess {
|
||||
browser_name: String,
|
||||
}
|
||||
|
||||
impl ScopedBrowserAccess {
|
||||
/// Request permission from user and start accessing browser directory
|
||||
pub fn request_and_start(browser_name: &str) -> Result<Self> {
|
||||
let c_name = CString::new(browser_name)?;
|
||||
|
||||
// Request permission (shows dialog)
|
||||
let bookmark_ptr = unsafe { requestBrowserAccess(c_name.as_ptr()) };
|
||||
if bookmark_ptr.is_null() {
|
||||
return Err(anyhow!("User declined access or browser not found"));
|
||||
}
|
||||
unsafe { libc::free(bookmark_ptr as *mut libc::c_void) };
|
||||
|
||||
// Start accessing
|
||||
let path_ptr = unsafe { startBrowserAccess(c_name.as_ptr()) };
|
||||
if path_ptr.is_null() {
|
||||
return Err(anyhow!("Failed to start accessing browser directory"));
|
||||
}
|
||||
unsafe { libc::free(path_ptr as *mut libc::c_void) };
|
||||
|
||||
Ok(Self {
|
||||
browser_name: browser_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resume access using previously stored bookmark
|
||||
pub fn resume(browser_name: &str) -> Result<Self> {
|
||||
let c_name = CString::new(browser_name)?;
|
||||
|
||||
if !unsafe { hasStoredBrowserAccess(c_name.as_ptr()) } {
|
||||
return Err(anyhow!("No stored access for this browser"));
|
||||
}
|
||||
|
||||
let path_ptr = unsafe { startBrowserAccess(c_name.as_ptr()) };
|
||||
if path_ptr.is_null() {
|
||||
return Err(anyhow!("Failed to resume access (bookmark may be stale)"));
|
||||
}
|
||||
unsafe { libc::free(path_ptr as *mut libc::c_void) };
|
||||
|
||||
Ok(Self {
|
||||
browser_name: browser_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if we have stored access (doesn't verify validity)
|
||||
pub fn has_stored_access(browser_name: &str) -> bool {
|
||||
let Ok(c_name) = CString::new(browser_name) else {
|
||||
return false;
|
||||
};
|
||||
unsafe { hasStoredBrowserAccess(c_name.as_ptr()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScopedBrowserAccess {
|
||||
fn drop(&mut self) {
|
||||
let Ok(c_name) = CString::new(self.browser_name.as_str()) else {
|
||||
return;
|
||||
};
|
||||
unsafe { stopBrowserAccess(c_name.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Existing Public API
|
||||
// TODO the rest of this file exactly matches macos.rs, move sandbox code there and cfg gate it behind sandbox feature
|
||||
//
|
||||
|
||||
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
|
||||
BrowserConfig {
|
||||
name: "Chrome",
|
||||
data_dir: "Library/Application Support/Google/Chrome",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chromium",
|
||||
data_dir: "Library/Application Support/Chromium",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Microsoft Edge",
|
||||
data_dir: "Library/Application Support/Microsoft Edge",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Brave",
|
||||
data_dir: "Library/Application Support/BraveSoftware/Brave-Browser",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Arc",
|
||||
data_dir: "Library/Application Support/Arc/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Opera",
|
||||
data_dir: "Library/Application Support/com.operasoftware.Opera",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Vivaldi",
|
||||
data_dir: "Library/Application Support/Vivaldi",
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn get_crypto_service(
|
||||
browser_name: &String,
|
||||
_local_state: &LocalState,
|
||||
) -> Result<Box<dyn CryptoService>> {
|
||||
let config = KEYCHAIN_CONFIG
|
||||
.iter()
|
||||
.find(|b| b.browser == browser_name)
|
||||
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
|
||||
|
||||
Ok(Box::new(MacCryptoService::new(config)))
|
||||
}
|
||||
|
||||
//
|
||||
// Private
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
struct KeychainConfig {
|
||||
browser: &'static str,
|
||||
service: &'static str,
|
||||
account: &'static str,
|
||||
}
|
||||
|
||||
const KEYCHAIN_CONFIG: [KeychainConfig; SUPPORTED_BROWSERS.len()] = [
|
||||
KeychainConfig {
|
||||
browser: "Chrome",
|
||||
service: "Chrome Safe Storage",
|
||||
account: "Chrome",
|
||||
},
|
||||
KeychainConfig {
|
||||
browser: "Chromium",
|
||||
service: "Chromium Safe Storage",
|
||||
account: "Chromium",
|
||||
},
|
||||
KeychainConfig {
|
||||
browser: "Microsoft Edge",
|
||||
service: "Microsoft Edge Safe Storage",
|
||||
account: "Microsoft Edge",
|
||||
},
|
||||
KeychainConfig {
|
||||
browser: "Brave",
|
||||
service: "Brave Safe Storage",
|
||||
account: "Brave",
|
||||
},
|
||||
KeychainConfig {
|
||||
browser: "Arc",
|
||||
service: "Arc Safe Storage",
|
||||
account: "Arc",
|
||||
},
|
||||
KeychainConfig {
|
||||
browser: "Opera",
|
||||
service: "Opera Safe Storage",
|
||||
account: "Opera",
|
||||
},
|
||||
KeychainConfig {
|
||||
browser: "Vivaldi",
|
||||
service: "Vivaldi Safe Storage",
|
||||
account: "Vivaldi",
|
||||
},
|
||||
];
|
||||
|
||||
const IV: [u8; 16] = [0x20; 16]; // 16 bytes of 0x20 (space character)
|
||||
|
||||
//
|
||||
// CryptoService
|
||||
//
|
||||
|
||||
struct MacCryptoService {
|
||||
config: &'static KeychainConfig,
|
||||
master_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MacCryptoService {
|
||||
fn new(config: &'static KeychainConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
master_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CryptoService for MacCryptoService {
|
||||
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
|
||||
if encrypted.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
// On macOS only v10 is supported
|
||||
let (_, no_prefix) = util::split_encrypted_string_and_validate(encrypted, &["v10"])?;
|
||||
|
||||
// This might bring up the admin password prompt
|
||||
if self.master_key.is_none() {
|
||||
self.master_key = Some(get_master_key(self.config.service, self.config.account)?);
|
||||
}
|
||||
|
||||
let key = self
|
||||
.master_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
|
||||
let plaintext = util::decrypt_aes_128_cbc(key, &IV, no_prefix)
|
||||
.map_err(|e| anyhow!("Failed to decrypt: {}", e))?;
|
||||
let plaintext =
|
||||
String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8: {}", e))?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_master_key(service: &str, account: &str) -> Result<Vec<u8>> {
|
||||
let master_password = get_master_password(service, account)?;
|
||||
let key = util::derive_saltysalt(&master_password, 1003)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn get_master_password(service: &str, account: &str) -> Result<Vec<u8>> {
|
||||
let password = get_generic_password(service, account)
|
||||
.map_err(|e| anyhow!("Failed to get password from keychain: {}", e))?;
|
||||
|
||||
Ok(password)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
// Platform-specific code
|
||||
#[cfg_attr(target_os = "linux", path = "linux.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows/mod.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
#[cfg_attr(all(target_os = "macos", not(feature = "sandbox")), path = "macos.rs")]
|
||||
|
||||
// TODO rework this to place sandbox code in macos and cfg gate it there
|
||||
#[cfg_attr(all(target_os = "macos", feature = "sandbox"), path = "macos_sandbox.rs")]
|
||||
|
||||
mod native;
|
||||
|
||||
// Windows exposes public const
|
||||
#[allow(unused_imports)]
|
||||
pub use native::*;
|
||||
pub use native::*;
|
||||
@@ -12,6 +12,7 @@ crate-type = ["cdylib"]
|
||||
[features]
|
||||
default = []
|
||||
manual_test = []
|
||||
sandbox = ["chromium_importer/sandbox"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
|
||||
@@ -12,3 +12,12 @@ if (isRelease) {
|
||||
}
|
||||
|
||||
execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env });
|
||||
|
||||
|
||||
/* Mac App Store build with sandboxing - Does this belong here?
|
||||
|
||||
const target = process.env.npm_config_target || '';
|
||||
const featuresArg = target.includes('mas') ? '--features sandbox' : '';
|
||||
execSync(`napi build --platform --js false ${featuresArg}`, { stdio: 'inherit', env: process.env });
|
||||
|
||||
*/
|
||||
|
||||
@@ -1181,6 +1181,13 @@ pub mod chromium_importer {
|
||||
.map(|logins| logins.into_iter().map(LoginImportResult::from).collect())
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg_attr(all(target_os = "macos", feature = "sandbox"), napi)]
|
||||
#[cfg(all(target_os = "macos", feature = "sandbox"))]
|
||||
pub fn request_browser_access(browser: String) -> napi::Result<()> {
|
||||
chromium_importer::chromium::request_browser_access(&browser)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
|
||||
@@ -11,7 +11,7 @@ default = []
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -1,9 +1,56 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
use glob::glob;
|
||||
use std::process::Command;
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
|
||||
// Compile Swift files FIRST (generates Bitwarden-Swift.h for browser_access.m)
|
||||
let swift_files: Vec<String> = glob("src/native/**/*.swift")
|
||||
.expect("Failed to read Swift glob pattern")
|
||||
.filter_map(Result::ok)
|
||||
.map(|p| {
|
||||
println!("cargo::rerun-if-changed={}", p.display());
|
||||
p.to_str().unwrap().to_string()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !swift_files.is_empty() {
|
||||
// Compile Swift into a static library
|
||||
let status = Command::new("swiftc")
|
||||
.args(&[
|
||||
"-emit-library",
|
||||
"-static",
|
||||
"-module-name",
|
||||
"Bitwarden",
|
||||
"-import-objc-header",
|
||||
"src/native/bridging-header.h",
|
||||
"-emit-objc-header-path",
|
||||
&format!("{}/Bitwarden-Swift.h", out_dir),
|
||||
"-o",
|
||||
&format!("{}/libbitwarden_swift.a", out_dir),
|
||||
])
|
||||
.args(&swift_files)
|
||||
.status()
|
||||
.expect("Failed to compile Swift code");
|
||||
|
||||
if !status.success() {
|
||||
panic!("Swift compilation failed");
|
||||
}
|
||||
|
||||
// Tell cargo to link the Swift library
|
||||
println!("cargo:rustc-link-search=native={}", out_dir);
|
||||
println!("cargo:rustc-link-lib=static=bitwarden_swift");
|
||||
|
||||
// Link required Swift/Foundation frameworks
|
||||
println!("cargo:rustc-link-lib=framework=Foundation");
|
||||
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||
}
|
||||
|
||||
// Compile Objective-C files (Bitwarden-Swift.h exists now)
|
||||
let mut builder = cc::Build::new();
|
||||
|
||||
// Auto compile all .m files in the src/native directory
|
||||
// Compile all .m files in the src/native directory
|
||||
for entry in glob("src/native/**/*.m").expect("Failed to read glob pattern") {
|
||||
let path = entry.expect("Failed to read glob entry");
|
||||
builder.file(path.clone());
|
||||
@@ -11,8 +58,9 @@ fn main() {
|
||||
}
|
||||
|
||||
builder
|
||||
.include(&out_dir) // Add OUT_DIR to include path so Bitwarden-Swift.h can be found
|
||||
.flag("-fobjc-arc") // Enable Auto Reference Counting (ARC)
|
||||
.compile("autofill");
|
||||
.compile("objc_code");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import "utils.h"
|
||||
#import "interop.h"
|
||||
@@ -0,0 +1,22 @@
|
||||
#ifndef BROWSER_ACCESS_H
|
||||
#define BROWSER_ACCESS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
// Request user permission to access browser directory
|
||||
// Returns base64-encoded bookmark data, or NULL if declined
|
||||
// Caller must free returned string
|
||||
char* requestBrowserAccess(const char* browserName);
|
||||
|
||||
// Check if we have stored bookmark (doesn't verify validity)
|
||||
bool hasStoredBrowserAccess(const char* browserName);
|
||||
|
||||
// Start accessing browser using stored bookmark
|
||||
// Returns resolved path, or NULL if bookmark invalid
|
||||
// Caller must free returned string and call stopBrowserAccess when done
|
||||
char* startBrowserAccess(const char* browserName);
|
||||
|
||||
// Stop accessing browser (MUST be called after startBrowserAccess)
|
||||
void stopBrowserAccess(const char* browserName);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,58 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import "browser_access.h"
|
||||
#import "../utils.h"
|
||||
|
||||
// Import the Swift-generated header
|
||||
// The name matches the module-name in build.rs: "Bitwarden"
|
||||
#import "Bitwarden-Swift.h"
|
||||
|
||||
static BrowserAccessManager* sharedManager = nil;
|
||||
|
||||
static BrowserAccessManager* getManager() {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sharedManager = [[BrowserAccessManager alloc] init];
|
||||
});
|
||||
return sharedManager;
|
||||
}
|
||||
|
||||
char* requestBrowserAccess(const char* browserName) {
|
||||
@autoreleasepool {
|
||||
NSString* name = [NSString stringWithUTF8String:browserName];
|
||||
// Note: Matches the Swift method name with typo "Broswer"
|
||||
NSString* result = [getManager() requestAccessToBroswerDir:name];
|
||||
|
||||
if (result == nil) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return strdup([result UTF8String]);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasStoredBrowserAccess(const char* browserName) {
|
||||
@autoreleasepool {
|
||||
NSString* name = [NSString stringWithUTF8String:browserName];
|
||||
return [getManager() hasStoredAccess:name];
|
||||
}
|
||||
}
|
||||
|
||||
char* startBrowserAccess(const char* browserName) {
|
||||
@autoreleasepool {
|
||||
NSString* name = [NSString stringWithUTF8String:browserName];
|
||||
NSString* result = [getManager() startAccessingBrowser:name];
|
||||
|
||||
if (result == nil) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return strdup([result UTF8String]);
|
||||
}
|
||||
}
|
||||
|
||||
void stopBrowserAccess(const char* browserName) {
|
||||
@autoreleasepool {
|
||||
NSString* name = [NSString stringWithUTF8String:browserName];
|
||||
[getManager() stopAccessingBrowser:name];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
@objc public class BrowserAccessManager: NSObject {
|
||||
private let bookmarkKey = "com.bitwarden.chromiumImporter.bookmarks"
|
||||
|
||||
private let browserPaths: [String: String] = [
|
||||
"Chrome": "Library/Application Support/Google/Chrome",
|
||||
"Chromium": "Library/Application Support/Chromium",
|
||||
"Microsoft Edge": "Library/Application Support/Microsoft Edge",
|
||||
"Brave": "Library/Application Support/BraveSoftware/Brave-Browser",
|
||||
"Arc": "Library/Application Support/Arc/User Data",
|
||||
"Opera": "Library/Application Support/com.operasoftware.Opera",
|
||||
"Vivaldi": "Library/Application Support/Vivaldi",
|
||||
]
|
||||
|
||||
/// Request access to a specific browser's directory
|
||||
/// Returns security bookmark data (used to persist permissions) as base64 string, or nil if user declined
|
||||
@objc public func requestAccessToBroswerDir(_ browserName: String) -> String? {
|
||||
guard let relativePath = browserPaths[browserName] else {
|
||||
NSLog("Unknown browser: \(browserName)")
|
||||
return nil
|
||||
}
|
||||
|
||||
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
||||
let browserPath = homeDir.appendingPathComponent(relativePath)
|
||||
|
||||
// Open file picker at home directory and provide instructions to grant access to browserPath
|
||||
// Mac OS will automatically request permission to access this location from the sandbox when the location is selected here
|
||||
let openPanel = NSOpenPanel()
|
||||
openPanel.message =
|
||||
"Please select your \(browserName) data folder\n\nExpected location:\n\(browserPath.path)"
|
||||
openPanel.prompt = "Grant Access"
|
||||
openPanel.allowsMultipleSelection = false
|
||||
openPanel.canChooseDirectories = true
|
||||
openPanel.canChooseFiles = false
|
||||
openPanel.directoryURL = browserPath.deletingLastPathComponent() // home directory
|
||||
|
||||
guard openPanel.runModal() == .OK, let url = openPanel.url else {
|
||||
NSLog("User cancelled access request")
|
||||
return nil
|
||||
}
|
||||
|
||||
let localStatePath = url.appendingPathComponent("Local State")
|
||||
guard FileManager.default.fileExists(atPath: localStatePath.path) else {
|
||||
NSLog("Selected folder doesn't appear to be a valid \(browserName) directory")
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Invalid Folder"
|
||||
alert.informativeText =
|
||||
"The selected folder doesn't appear to be a valid \(browserName) data directory. Please select the correct folder."
|
||||
alert.alertStyle = .warning
|
||||
alert.runModal()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// access is temporary right now, persist it by creating a security bookmark
|
||||
do {
|
||||
let bookmarkData = try url.bookmarkData(
|
||||
options: .withSecurityScope,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
|
||||
saveBookmark(bookmarkData, forBrowser: browserName)
|
||||
return bookmarkData.base64EncodedString()
|
||||
} catch {
|
||||
NSLog("Failed to create bookmark: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we have stored bookmark for browser (doesn't verify it's still valid)
|
||||
@objc public func hasStoredAccess(_ browserName: String) -> Bool {
|
||||
return loadBookmark(forBrowser: browserName) != nil
|
||||
}
|
||||
|
||||
/// Start accessing a browser directory using stored bookmark
|
||||
/// Returns the resolved path, or nil if bookmark is invalid/revoked
|
||||
/*
|
||||
This could return nil if:
|
||||
The user doesn’t have access to this URL
|
||||
The URL isn’t a security scoped URL
|
||||
T The directory doesn’t need it (~/Downloads)
|
||||
|
||||
https://benscheirman.com/2019/10/troubleshooting-appkit-file-permissions.html
|
||||
*/
|
||||
@objc public func startAccessingBrowser(_ browserName: String) -> String? {
|
||||
guard let bookmarkData = loadBookmark(forBrowser: browserName) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
var isStale = false
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: .withSecurityScope,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
|
||||
if isStale {
|
||||
NSLog("Security bookmark for \(browserName) is stale, attempting to re-create it")
|
||||
do {
|
||||
let newBookmarkData = try url.bookmarkData(
|
||||
options: .withSecurityScope,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
|
||||
saveBookmark(newBookmarkData, forBrowser: browserName)
|
||||
} catch {
|
||||
NSLog("Failed to create bookmark: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
NSLog("Failed to start accessing security-scoped resource")
|
||||
return nil
|
||||
}
|
||||
|
||||
return url.path
|
||||
} catch {
|
||||
NSLog("Failed to resolve bookmark: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop accessing a browser directory (must be called after startAccessingBrowser)
|
||||
@objc public func stopAccessingBrowser(_ browserName: String) {
|
||||
guard let bookmarkData = loadBookmark(forBrowser: browserName) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
var isStale = false
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: .withSecurityScope,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
} catch {
|
||||
NSLog("Failed to resolve bookmark for stop: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func bookmarkKeyFor(_ browserName: String) -> String {
|
||||
return "\(bookmarkKey).\(browserName)"
|
||||
}
|
||||
|
||||
private func saveBookmark(_ data: Data, forBrowser browserName: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
let key = bookmarkKeyFor(browserName)
|
||||
defaults.set(data, forKey: key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
private func loadBookmark(forBrowser browserName: String) -> Data? {
|
||||
let defaults = UserDefaults.standard
|
||||
let key = bookmarkKeyFor(browserName)
|
||||
return defaults.data(forKey: key)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user