summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md3
-rw-r--r--gui/locales/messages.pot4
-rw-r--r--gui/src/main/daemon-rpc.ts7
-rw-r--r--gui/src/main/index.ts3
-rw-r--r--gui/src/renderer/app.tsx2
-rw-r--r--gui/src/renderer/components/SmallButton.tsx4
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettings.tsx76
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx6
-rw-r--r--gui/src/shared/ipc-schema.ts3
-rw-r--r--mullvad-daemon/src/management_interface.rs12
-rw-r--r--mullvad-management-interface/proto/management_interface.proto3
-rw-r--r--talpid-core/src/split_tunnel/macos/mod.rs4
-rw-r--r--talpid-core/src/split_tunnel/macos/process.rs79
13 files changed, 185 insertions, 21 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8964416c6b..dd0f105425 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 517423d420..4d588a5a9e 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 c86bed047f..8cea0d4008 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<boolean> {
+ const needFullDiskPermissions = await this.callEmpty<BoolValue>(
+ this.client.needFullDiskPermissions,
+ );
+ return needFullDiskPermissions.getValue();
+ }
+
public async checkVolumes(): Promise<void> {
await this.callEmpty(this.client.checkVolumes);
}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 400fe39d2a..c9067c78e2 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 d36c27aa14..ddbb43aab7 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 e88d719952..c91fbbdb20 100644
--- a/gui/src/renderer/components/SmallButton.tsx
+++ b/gui/src/renderer/components/SmallButton.tsx
@@ -53,6 +53,10 @@ const StyledSmallButton = styled.button<StyledSmallButtonProps>(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 ed999ba867..7e8830e6f8 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<ISplitTunnelingApplication[]>();
+ 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
<SettingsHeader>
<StyledHeaderTitleContainer>
<StyledHeaderTitle>{strings.splitTunneling}</StyledHeaderTitle>
- <Switch isOn={splitTunnelingEnabled} onChange={setSplitTunnelingState} />
+ <Switch
+ isOn={splitTunnelingEnabled}
+ disabled={!splitTunnelingAvailable}
+ onChange={setSplitTunnelingState}
+ />
</StyledHeaderTitleContainer>
- <HeaderSubTitle>
- {messages.pgettext(
- 'split-tunneling-view',
- 'Choose the apps you want to exclude from the VPN tunnel.',
- )}
- </HeaderSubTitle>
+ <MacOsSplitTunnelingAvailability
+ needFullDiskPermissions={
+ window.env.platform === 'darwin' && splitTunnelingAvailable === false
+ }
+ />
+ {splitTunnelingAvailable ? (
+ <HeaderSubTitle>
+ {messages.pgettext(
+ 'split-tunneling-view',
+ 'Choose the apps you want to exclude from the VPN tunnel.',
+ )}
+ </HeaderSubTitle>
+ ) : null}
</SettingsHeader>
{splitTunnelingEnabled && (
@@ -495,6 +527,34 @@ export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsPro
);
}
+interface MacOsSplitTunnelingAvailabilityProps {
+ needFullDiskPermissions: boolean;
+}
+
+function MacOsSplitTunnelingAvailability({
+ needFullDiskPermissions,
+}: MacOsSplitTunnelingAvailabilityProps) {
+ const { showFullDiskAccessSettings } = useAppContext();
+
+ return (
+ <>
+ {needFullDiskPermissions === true ? (
+ <>
+ <HeaderSubTitle>
+ {messages.pgettext(
+ 'split-tunneling-view',
+ 'To use split tunneling please enable “Full disk access” for “Mullvad VPN” in the macOS system settings.',
+ )}
+ </HeaderSubTitle>
+ <StyledSystemSettingsButton onClick={showFullDiskAccessSettings}>
+ Open System Settings
+ </StyledSystemSettingsButton>
+ </>
+ ) : null}
+ </>
+ );
+}
+
interface IApplicationListProps<T extends IApplication> {
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 1aea5108a1..a2019fba8d 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 954dce1680..a2282e2849 100644
--- a/gui/src/shared/ipc-schema.ts
+++ b/gui/src/shared/ipc-schema.ts
@@ -240,6 +240,9 @@ export const ipcSchema = {
getApplications: invoke<void, ILinuxSplitTunnelingApplication[]>(),
launchApplication: invoke<ILinuxSplitTunnelingApplication | string, LaunchApplicationResult>(),
},
+ macOsSplitTunneling: {
+ needFullDiskPermissions: invoke<void, boolean>(),
+ },
splitTunneling: {
'': notifyRenderer<ISplitTunnelingApplication[]>(),
setState: invoke<boolean, void>(),
diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs
index 6a11357d6b..817dc85200 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<bool> {
+ 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<bool> {
+ 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 62740438fb..c71c35b17a 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 38c4201ea0..4227a7fc5d 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 7069e5fdd8..3edf9fb8ff 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<MacosVersion> =
LazyLock::new(|| MacosVersion::from_raw_version("13.0.0").unwrap());
@@ -75,21 +78,16 @@ pub struct ProcessMonitorHandle {
impl ProcessMonitor {
pub async fn spawn() -> Result<ProcessMonitorHandle, Error> {
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::Sender<_>>) =
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<bool> = 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<bool, Error> {
+ 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,