feat(UI): add Split Tunnel quick-setting to app profile view

Adds a "Split Tunnel" toggle to the app profile quick-settings bar,
mirroring splittun/use per-app setting.

Shows an interference dot when:
- splittun/usagePolicy has Exclude rules (yellow)
- SPN is active and routes all traffic, fully bypassing Split Tunnel (red)
- SPN is active and partially bypasses Split Tunnel (yellow)

Dot and interference checks are suppressed when the Split Tunneling
or SPN module is globally disabled.
This commit is contained in:
Alexandr Stelnykovych
2026-05-13 14:50:52 +03:00
parent 2d5628a309
commit b51e59a1c1
4 changed files with 164 additions and 0 deletions
+2
View File
@@ -29,6 +29,7 @@ import { AppOverviewComponent, AppViewComponent, QuickSettingInternetButtonCompo
import { QsHistoryComponent } from './pages/app-view/qs-history/qs-history.component';
import { QuickSettingSelectExitButtonComponent } from './pages/app-view/qs-select-exit/qs-select-exit';
import { QuickSettingUseSPNButtonComponent } from './pages/app-view/qs-use-spn/qs-use-spn';
import { QuickSettingUseSplitTunButtonComponent } from './pages/app-view/qs-use-splittun/qs-use-splittun';
import { DashboardPageComponent } from './pages/dashboard/dashboard.component';
import { FeatureCardComponent } from './pages/dashboard/feature-card/feature-card.component';
import { MonitorPageComponent } from './pages/monitor';
@@ -138,6 +139,7 @@ const localeConfig = {
QuickSettingInternetButtonComponent,
QuickSettingUseSPNButtonComponent,
QuickSettingSelectExitButtonComponent,
QuickSettingUseSplitTunButtonComponent,
AppOverviewComponent,
PlaceholderComponent,
LoadingComponent,
@@ -76,6 +76,9 @@
<app-qs-select-exit [canUse]="canUseSPN" [settings]="profileSettings" (save)="saveSetting($event)">
</app-qs-select-exit>
<app-qs-use-splittun [settings]="profileSettings" (save)="saveSetting($event)">
</app-qs-use-splittun>
<button class="flex flex-row gap-2 items-center px-4 bg-gray-300 btn" cdkOverlayOrigin #overlayOrigin="cdkOverlayOrigin" (click)="profileMenu.dropdown.toggle(overlayOrigin)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" />
@@ -0,0 +1,40 @@
<div
class="relative flex flex-wrap items-center justify-center w-full h-full gap-2 px-3 py-2 bg-gray-300 border border-gray-300 rounded shadow"
snfgTooltipPosition="right"
[sfng-tooltip]="(interferingSettings.length > 0 || spnEnabled) ? tooltipTemplate : null">
<span class="text-primary" [class.cursor-pointer]="interferingSettings.length > 0 || spnEnabled">
Split Tunnel
</span>
<sfng-toggle *ngIf="splitTunModuleEnabled === true"
[ngModel]="currentValue" (ngModelChange)="updateUseSplitTun($event)">
</sfng-toggle>
<span *ngIf="splitTunModuleEnabled === false"
routerLink="/settings" [queryParams]="{setting: 'splittun/enable'}" class="cursor-pointer text-tertiary hover:underline"
sfng-tooltip="Enable Split Tunneling to use.">
Disabled
</span>
<ng-template *ngIf="splitTunModuleEnabled === null">
<fa-icon icon="spinner" [spin]="true"></fa-icon>
</ng-template>
<span class="absolute right-0 block w-2 h-2 bg-yellow-300 border border-gray-100 rounded opacity-75"
[style.background-color]="spnFullOverride ? '#ef4444' : null"
style="top: 2px; transform: translateX(-2px)" *ngIf="interferingSettings.length > 0 || spnEnabled"></span>
</div>
<ng-template #tooltipTemplate>
Settings that may interfere with Split Tunnel:
<ul class="pl-4 list-disc">
<li *ngIf="spnEnabled && spnFullOverride">SPN is routing all traffic, fully bypassing Split Tunnel</li>
<li *ngIf="spnEnabled && !spnFullOverride">SPN is routing non-excluded connections, partially bypassing Split Tunnel</li>
<ng-container *ngFor="let setting of interferingSettings">
<li class="cursor-pointer hover:underline" [routerLink]="[]"
[queryParams]="{setting: setting.Key, tab: 'settings'}">
{{ setting.Name }}
</li>
</ng-container>
</ul>
</ng-template>
@@ -0,0 +1,119 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BoolSetting, ConfigService, Setting, StringArraySetting, getActualValue } from "@safing/portmaster-api";
import { SaveSettingEvent } from "src/app/shared/config/generic-setting/generic-setting";
const configKeys = {
splitTunUse: 'splittun/use',
splitTunEnable: 'splittun/enable',
splitTunUsagePolicy: 'splittun/usagePolicy',
spnUse: 'spn/use',
spnEnable: 'spn/enable',
spnUsagePolicy: 'spn/usagePolicy',
} as const;
@Component({
selector: 'app-qs-use-splittun',
templateUrl: './qs-use-splittun.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuickSettingUseSplitTunButtonComponent implements OnInit, OnChanges {
private destroyRef = inject(DestroyRef);
@Input()
settings: Setting[] = [];
@Output()
save = new EventEmitter<SaveSettingEvent>();
currentValue = false;
/** App-level settings (Exclude rules in usagePolicy) that may interfere. */
interferingSettings: Setting[] = [];
/** Whether the Split Tunneling module is globally enabled. null = not yet loaded. */
splitTunModuleEnabled: boolean | null = null;
/** Whether SPN is enabled for this app — overrides split tunnel for SPN-routed connections. */
spnEnabled = false;
/** Whether SPN fully overrides split tunnel: SPN in use with no Exclude rules in spn/usagePolicy. */
spnFullOverride = false;
/** Whether the SPN module is globally enabled. */
private spnModuleEnabled = false;
constructor(
private configService: ConfigService,
private cdr: ChangeDetectorRef
) { }
ngOnChanges(changes: SimpleChanges): void {
if ('settings' in changes) {
this.currentValue = false;
const useSetting = this.settings.find(s => s.Key === configKeys.splitTunUse) as BoolSetting | undefined;
if (useSetting) {
this.currentValue = getActualValue(useSetting);
}
this.updateInterfering();
}
}
ngOnInit(): void {
this.configService.watch<BoolSetting>(configKeys.splitTunEnable)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(value => {
this.splitTunModuleEnabled = !!value;
this.updateInterfering();
this.cdr.markForCheck();
});
this.configService.watch<BoolSetting>(configKeys.spnEnable)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(value => {
this.spnModuleEnabled = !!value;
this.updateInterfering();
this.cdr.markForCheck();
});
}
updateUseSplitTun(enabled: boolean): void {
this.save.next({
isDefault: false,
key: configKeys.splitTunUse,
value: enabled,
});
}
private updateInterfering(): void {
this.interferingSettings = [];
this.spnEnabled = false;
this.spnFullOverride = false;
if (!this.currentValue || !this.splitTunModuleEnabled) {
return;
}
const spnUseSetting = this.settings.find(s => s.Key === configKeys.spnUse) as BoolSetting | undefined;
this.spnEnabled = this.spnModuleEnabled && !!spnUseSetting && !!getActualValue(spnUseSetting);
// If SPN is enabled, check if it fully overrides Split Tunnel (no Exclude rules in SPN policy)
if (this.spnEnabled) {
const spnPolicy = this.settings.find(s => s.Key === configKeys.spnUsagePolicy) as StringArraySetting | undefined;
const spnPolicyValue = spnPolicy ? getActualValue(spnPolicy) : [];
const hasSpnExcludeRules = Array.isArray(spnPolicyValue) && spnPolicyValue.some(rule => rule.startsWith('- ') || rule === '-');
this.spnFullOverride = !hasSpnExcludeRules;
}
// Exclude rules in usagePolicy may prevent some connections from being tunneled
const usagePolicy = this.settings.find(s => s.Key === configKeys.splitTunUsagePolicy) as StringArraySetting | undefined;
if (usagePolicy) {
const value = getActualValue(usagePolicy);
if (Array.isArray(value) && value.some(rule => rule.startsWith('- ') || rule === '-')) {
this.interferingSettings.push(usagePolicy);
}
}
}
}