diff --git a/CHANGELOG.md b/CHANGELOG.md index 8964416c6b2d..dd0f105425f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ Line wrap the file at 100 chars. Th #### Windows - Add experimental support for Windows ARM64. +#### macOS +- Detect whether full disk access is enabled in the split tunneling view. + ### Changed - Replace the draft key encapsulation mechanism Kyber (round 3) with the standardized ML-KEM (FIPS 203) dito in the handshake for Quantum-resistant tunnels. diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 517423d42078..4d588a5a9eda 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -1600,6 +1600,10 @@ msgctxt "split-tunneling-view" msgid "Please try again or send a problem report." msgstr "" +msgctxt "split-tunneling-view" +msgid "To use split tunneling please enable “Full disk access” for “Mullvad VPN” in the macOS system settings." +msgstr "" + #. Error message showed in a dialog when an application fails to launch. msgctxt "split-tunneling-view" msgid "Unable to launch selection. %(detailedErrorMessage)s" diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index c86bed047f13..8cea0d4008a6 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -438,6 +438,13 @@ export class DaemonRpc extends GrpcClient { await this.callBool(this.client.setSplitTunnelState, enabled); } + public async needFullDiskPermissions(): Promise { + const needFullDiskPermissions = await this.callEmpty( + this.client.needFullDiskPermissions, + ); + return needFullDiskPermissions.getValue(); + } + public async checkVolumes(): Promise { await this.callEmpty(this.client.checkVolumes); } diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 400fe39d2ad7..c9067c78e218 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -832,6 +832,9 @@ class ApplicationMain splitTunneling!.removeApplicationFromCache(application); return Promise.resolve(); }); + IpcMainEventChannel.macOsSplitTunneling.handleNeedFullDiskPermissions(() => { + return this.daemonRpc.needFullDiskPermissions(); + }); IpcMainEventChannel.app.handleQuit(() => this.disconnectAndQuit()); IpcMainEventChannel.app.handleOpenUrl(async (url) => { diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index d36c27aa14c8..ddbb43aab7b1 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -345,6 +345,8 @@ export default class AppRenderer { IpcRendererEventChannel.splitTunneling.addApplication(application); public forgetManuallyAddedSplitTunnelingApplication = (application: ISplitTunnelingApplication) => IpcRendererEventChannel.splitTunneling.forgetManuallyAddedApplication(application); + public needFullDiskPermissions = () => + IpcRendererEventChannel.macOsSplitTunneling.needFullDiskPermissions(); public setObfuscationSettings = (obfuscationSettings: ObfuscationSettings) => IpcRendererEventChannel.settings.setObfuscationSettings(obfuscationSettings); public setEnableDaita = (value: boolean) => diff --git a/gui/src/renderer/components/SmallButton.tsx b/gui/src/renderer/components/SmallButton.tsx index e88d71995248..c91fbbdb204c 100644 --- a/gui/src/renderer/components/SmallButton.tsx +++ b/gui/src/renderer/components/SmallButton.tsx @@ -53,6 +53,10 @@ const StyledSmallButton = styled.button(smallText, (prop alignItems: 'center', justifyContent: 'center', + '&&:not(& + &&)': { + marginLeft: '0px', + }, + [`${SmallButtonGroupStart} &&`]: { marginLeft: 0, marginRight: `${BUTTON_GROUP_GAP}px`, diff --git a/gui/src/renderer/components/SplitTunnelingSettings.tsx b/gui/src/renderer/components/SplitTunnelingSettings.tsx index ed999ba867a5..7e8830e6f89d 100644 --- a/gui/src/renderer/components/SplitTunnelingSettings.tsx +++ b/gui/src/renderer/components/SplitTunnelingSettings.tsx @@ -43,6 +43,7 @@ import { StyledPageCover, StyledSearchBar, StyledSpinnerRow, + StyledSystemSettingsButton, } from './SplitTunnelingSettingsStyles'; import Switch from './Switch'; @@ -313,9 +314,12 @@ export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsPro removeSplitTunnelingApplication, forgetManuallyAddedSplitTunnelingApplication, getSplitTunnelingApplications, + needFullDiskPermissions, setSplitTunnelingState, } = useAppContext(); - const splitTunnelingEnabled = useSelector((state: IReduxState) => state.settings.splitTunneling); + const splitTunnelingEnabledValue = useSelector( + (state: IReduxState) => state.settings.splitTunneling, + ); const splitTunnelingApplications = useSelector( (state: IReduxState) => state.settings.splitTunnelingApplications, ); @@ -323,6 +327,23 @@ export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsPro const [searchTerm, setSearchTerm] = useState(''); const [applications, setApplications] = useState(); + const [splitTunnelingAvailable, setSplitTunnelingAvailable] = useState( + window.env.platform === 'darwin' ? undefined : true, + ); + + const splitTunnelingEnabled = splitTunnelingEnabledValue && (splitTunnelingAvailable ?? false); + + const fetchNeedFullDiskPermissions = useCallback(async () => { + const needPermissions = await needFullDiskPermissions(); + setSplitTunnelingAvailable(!needPermissions); + }, [needFullDiskPermissions]); + + useEffect((): void | (() => void) => { + if (window.env.platform === 'darwin') { + void fetchNeedFullDiskPermissions(); + } + }, [fetchNeedFullDiskPermissions]); + const onMount = useEffectEvent(async () => { const { fromCache, applications } = await getSplitTunnelingApplications(); setApplications(applications); @@ -441,14 +462,25 @@ export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsPro {strings.splitTunneling} - + - - {messages.pgettext( - 'split-tunneling-view', - 'Choose the apps you want to exclude from the VPN tunnel.', - )} - + + {splitTunnelingAvailable ? ( + + {messages.pgettext( + 'split-tunneling-view', + 'Choose the apps you want to exclude from the VPN tunnel.', + )} + + ) : null} {splitTunnelingEnabled && ( @@ -495,6 +527,34 @@ export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsPro ); } +interface MacOsSplitTunnelingAvailabilityProps { + needFullDiskPermissions: boolean; +} + +function MacOsSplitTunnelingAvailability({ + needFullDiskPermissions, +}: MacOsSplitTunnelingAvailabilityProps) { + const { showFullDiskAccessSettings } = useAppContext(); + + return ( + <> + {needFullDiskPermissions === true ? ( + <> + + {messages.pgettext( + 'split-tunneling-view', + 'To use split tunneling please enable “Full disk access” for “Mullvad VPN” in the macOS system settings.', + )} + + + Open System Settings + + + ) : null} + + ); +} + interface IApplicationListProps { applications: T[] | undefined; rowRenderer: (application: T) => React.ReactElement; diff --git a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx index 1aea5108a14b..a2019fba8dcc 100644 --- a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx +++ b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx @@ -8,6 +8,7 @@ import ImageView from './ImageView'; import { NavigationScrollbars } from './NavigationBar'; import SearchBar from './SearchBar'; import { HeaderTitle } from './SettingsHeader'; +import { SmallButton } from './SmallButton'; export const StyledPageCover = styled.div<{ $show: boolean }>((props) => ({ position: 'absolute', @@ -122,3 +123,8 @@ export const StyledSearchBar = styled(SearchBar)({ marginRight: measurements.viewMargin, marginBottom: measurements.buttonVerticalMargin, }); + +export const StyledSystemSettingsButton = styled(SmallButton)({ + width: '100%', + marginTop: '24px', +}); diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts index 954dce168014..a2282e28498b 100644 --- a/gui/src/shared/ipc-schema.ts +++ b/gui/src/shared/ipc-schema.ts @@ -240,6 +240,9 @@ export const ipcSchema = { getApplications: invoke(), launchApplication: invoke(), }, + macOsSplitTunneling: { + needFullDiskPermissions: invoke(), + }, splitTunneling: { '': notifyRenderer(), setState: invoke(), diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs index 6a11357d6bf8..817dc8520015 100644 --- a/mullvad-daemon/src/management_interface.rs +++ b/mullvad-daemon/src/management_interface.rs @@ -979,6 +979,18 @@ impl ManagementService for ManagementServiceImpl { })) } + #[cfg(target_os = "macos")] + async fn need_full_disk_permissions(&self, _: Request<()>) -> ServiceResult { + log::debug!("need_full_disk_permissions"); + let has_access = talpid_core::split_tunnel::has_full_disk_access().await; + Ok(Response::new(!has_access)) + } + + #[cfg(not(target_os = "macos"))] + async fn need_full_disk_permissions(&self, _: Request<()>) -> ServiceResult { + Ok(Response::new(false)) + } + #[cfg(windows)] async fn check_volumes(&self, _: Request<()>) -> ServiceResult<()> { log::debug!("check_volumes"); diff --git a/mullvad-management-interface/proto/management_interface.proto b/mullvad-management-interface/proto/management_interface.proto index 62740438fbfa..c71c35b17ac7 100644 --- a/mullvad-management-interface/proto/management_interface.proto +++ b/mullvad-management-interface/proto/management_interface.proto @@ -112,6 +112,9 @@ service ManagementService { rpc InitPlayPurchase(google.protobuf.Empty) returns (PlayPurchasePaymentToken) {} rpc VerifyPlayPurchase(PlayPurchase) returns (google.protobuf.Empty) {} + // Check whether the app needs TCC approval for split tunneling (macOS) + rpc NeedFullDiskPermissions(google.protobuf.Empty) returns (google.protobuf.BoolValue) {} + // Notify the split tunnel monitor that a volume was mounted or dismounted // (Windows). rpc CheckVolumes(google.protobuf.Empty) returns (google.protobuf.Empty) {} diff --git a/talpid-core/src/split_tunnel/macos/mod.rs b/talpid-core/src/split_tunnel/macos/mod.rs index 38c4201ea071..4227a7fc5d2f 100644 --- a/talpid-core/src/split_tunnel/macos/mod.rs +++ b/talpid-core/src/split_tunnel/macos/mod.rs @@ -20,6 +20,10 @@ mod tun; use crate::tunnel_state_machine::TunnelCommand; pub use tun::VpnInterface; +/// Check whether the current process has full-disk access enabled. +/// This is required by the process monitor. +pub use process::has_full_disk_access; + /// Errors caused by split tunneling #[derive(Debug, Clone)] pub struct Error { diff --git a/talpid-core/src/split_tunnel/macos/process.rs b/talpid-core/src/split_tunnel/macos/process.rs index 7069e5fdd828..3edf9fb8ffd7 100644 --- a/talpid-core/src/split_tunnel/macos/process.rs +++ b/talpid-core/src/split_tunnel/macos/process.rs @@ -19,10 +19,13 @@ use std::{ use talpid_macos::process::{list_pids, process_path}; use talpid_platform_metadata::MacosVersion; use talpid_types::tunnel::ErrorStateCause; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + sync::OnceCell, +}; const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); -const EARLY_FAIL_TIMEOUT: Duration = Duration::from_millis(500); +const EARLY_FAIL_TIMEOUT: Duration = Duration::from_millis(100); static MIN_OS_VERSION: LazyLock = LazyLock::new(|| MacosVersion::from_raw_version("13.0.0").unwrap()); @@ -75,21 +78,16 @@ pub struct ProcessMonitorHandle { impl ProcessMonitor { pub async fn spawn() -> Result { check_os_version_support()?; - let states = ProcessStates::new()?; + if !has_full_disk_access().await { + return Err(Error::NeedFullDiskPermissions); + } + + let states = ProcessStates::new()?; let proc = spawn_eslogger()?; let (stop_proc_tx, stop_rx): (_, oneshot::Receiver>) = oneshot::channel(); - let mut proc_task = tokio::spawn(handle_eslogger_output(proc, states.clone(), stop_rx)); - - match tokio::time::timeout(EARLY_FAIL_TIMEOUT, &mut proc_task).await { - // On timeout, all is well - Err(_) => (), - // The process returned an error - Ok(Ok(Err(error))) => return Err(error), - Ok(Ok(Ok(()))) => unreachable!("process monitor stopped prematurely"), - Ok(Err(_)) => unreachable!("process monitor panicked"), - } + let proc_task = tokio::spawn(handle_eslogger_output(proc, states.clone(), stop_rx)); Ok(ProcessMonitorHandle { stop_proc_tx: Some(stop_proc_tx), @@ -99,6 +97,61 @@ impl ProcessMonitor { } } +/// Return whether the process has full-disk access +pub async fn has_full_disk_access() -> bool { + static HAS_TCC_APPROVAL: OnceCell = OnceCell::const_new(); + *HAS_TCC_APPROVAL + .get_or_try_init(|| async { has_full_disk_access_inner().await }) + .await + .unwrap_or(&true) +} + +async fn has_full_disk_access_inner() -> Result { + let mut proc = spawn_eslogger()?; + + let stdout = proc.stdout.take().unwrap(); + let stderr = proc.stderr.take().unwrap(); + + let stderr = BufReader::new(stderr); + let mut stderr_lines = stderr.lines(); + + let stdout = BufReader::new(stdout); + let mut stdout_lines = stdout.lines(); + + let mut find_err = tokio::spawn(async move { + tokio::select! { + Ok(Some(line)) = stderr_lines.next_line() => { + !matches!( + parse_eslogger_error(&line), + Some(Error::NeedFullDiskPermissions), + ) + } + Ok(Some(_)) = stdout_lines.next_line() => { + // Received output, but not an err + true + } + else => true, + } + }); + + drop(proc.stdin.take()); + + let proc = tokio::time::timeout(EARLY_FAIL_TIMEOUT, proc.wait()); + + tokio::select! { + // Received standard err/out + found_err = &mut find_err => { + Ok(found_err.expect("find_err panicked")) + } + // Process exited + Ok(Ok(_exit_status)) = proc => { + Ok(find_err.await.expect("find_err panicked")) + } + // Timeout + else => Ok(true), + } +} + /// Run until the process exits or `stop_rx` is signaled async fn handle_eslogger_output( mut proc: tokio::process::Child,