mirror of
https://github.com/safing/portmaster.git
synced 2026-05-20 20:40:36 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user