Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin

This commit is contained in:
Patrick Pacher
2024-03-20 10:43:29 +01:00
parent 66381baa1a
commit 4b77945517
922 changed files with 84071 additions and 26 deletions

View File

@@ -0,0 +1,13 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { IndicatorComponent } from "./indicator";
@NgModule({
imports: [
CommonModule,
],
declarations: [
IndicatorComponent,
]
})
export class ActionIndicatorModule { }

View File

@@ -0,0 +1,284 @@
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { Injectable, InjectionToken, Injector, isDevMode } from '@angular/core';
import { interval, PartialObserver, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { IndicatorComponent } from './indicator';
export interface ActionIndicator {
title: string;
message?: string;
status: 'info' | 'success' | 'error';
timeout?: number;
}
export const ACTION_REF = new InjectionToken<ActionIndicatorRef>('ActionIndicatorRef')
export class ActionIndicatorRef implements ActionIndicator {
title: string;
message?: string;
status: 'info' | 'success' | 'error';
timeout?: number;
onClose = new Subject<void>();
onCloseReplace = new Subject<void>();
constructor(opts: ActionIndicator, private _overlayRef: OverlayRef) {
this.title = opts.title;
this.message = opts.message;
this.status = opts.status;
this.timeout = opts.timeout;
}
close() {
this._overlayRef.detach();
this.onClose.next();
this.onClose.complete();
}
}
@Injectable({ providedIn: 'root' })
export class ActionIndicatorService {
private _activeIndicatorRef: ActionIndicatorRef | null = null;
constructor(
private _injector: Injector,
private overlay: Overlay,
) { }
/**
* Returns an observer that parses the HTTP API response
* and shows a success/error action indicator.
*/
httpObserver(successTitle?: string, errorTitle?: string): PartialObserver<HttpResponse<ArrayBuffer | string>> {
return {
next: resp => {
let msg = this.getErrorMessgae(resp)
if (!successTitle) {
successTitle = msg;
msg = '';
}
this.success(successTitle || '', msg)
},
error: err => {
let msg = this.getErrorMessgae(err);
if (!errorTitle) {
errorTitle = msg;
msg = '';
}
this.error(errorTitle || '', msg);
}
}
}
info(title: string, message?: string, timeout?: number) {
this.create({
title,
message: this.ensureMessage(message),
timeout,
status: 'info'
})
}
error(title: string, message?: string | any, timeout?: number) {
this.create({
title,
message: this.ensureMessage(message),
timeout,
status: 'error'
})
}
success(title: string, message?: string, timeout?: number) {
this.create({
title,
message: this.ensureMessage(message),
timeout,
status: 'success'
})
}
/**
* Creates a new user action indicator.
*
* @param msg The action indicator message to show
*/
async create(msg: ActionIndicator) {
if (!!this._activeIndicatorRef) {
this._activeIndicatorRef.onCloseReplace.next();
await this._activeIndicatorRef.onClose.toPromise();
}
const cfg = new OverlayConfig({
scrollStrategy: this.overlay
.scrollStrategies.noop(),
positionStrategy: this.overlay
.position()
.global()
.bottom('2rem')
.left('5rem'),
});
const overlayRef = this.overlay.create(cfg);
const ref = new ActionIndicatorRef(msg, overlayRef);
ref.onClose.pipe(take(1)).subscribe(() => {
if (ref === this._activeIndicatorRef) {
this._activeIndicatorRef = null;
}
})
// close after the specified time our (or 5000 seconds).
const timeout = msg.timeout || 5000;
interval(timeout).pipe(
takeUntil(ref.onClose),
take(1),
).subscribe(() => {
ref.close();
})
const injector = this.createInjector(ref);
const portal = new ComponentPortal(
IndicatorComponent,
undefined,
injector
);
this._activeIndicatorRef = ref;
overlayRef.attach(portal);
}
/**
* Creates a new dependency injector that provides msg as
* ACTION_MESSAGE.
*/
private createInjector(ref: ActionIndicatorRef): Injector {
return Injector.create({
providers: [
{
provide: ACTION_REF,
useValue: ref,
}
],
parent: this._injector,
})
}
/**
* Tries to extract a meaningful error message from msg.
*/
private ensureMessage(msg: string | any): string | undefined {
if (msg === undefined || msg === null) {
return undefined;
}
if (msg instanceof HttpErrorResponse) {
return msg.message;
}
if (typeof msg === 'string') {
return msg;
}
if (typeof msg === 'object') {
if ('message' in msg) {
return msg.message;
}
if ('error' in msg) {
return this.ensureMessage(msg.error);
}
if ('toString' in msg) {
return msg.toString();
}
}
return JSON.stringify(msg);
}
/**
* Coverts an untyped body received by the HTTP API to a string.
*/
private stringifyBody(body: any): string {
if (typeof body === 'string') {
return body;
}
if (body instanceof ArrayBuffer) {
return new TextDecoder('utf-8').decode(body);
}
if (typeof body === 'object') {
return this.ensureMessage(body) || '';
}
console.error('unsupported body', body);
return '';
}
/**
* @deprecated use the version without a typo ...
*/
getErrorMessgae(resp: HttpResponse<ArrayBuffer | string> | HttpErrorResponse | Error): string {
return this.getErrorMessage(resp)
}
/**
* Parses a HTTP or HTTP Error response and returns a
* message that can be displayed to the user.
*/
getErrorMessage(resp: HttpResponse<ArrayBuffer | string> | HttpErrorResponse | Error): string {
try {
let body: string | null = null;
if (typeof resp === 'string') {
return resp
}
if (resp instanceof Error) {
return resp.message;
}
if (resp instanceof HttpErrorResponse) {
// A client-side or network error occured.
if (resp.error instanceof Error) {
body = resp.error.message;
} else {
body = this.stringifyBody(resp.error);
}
if (!!body) {
body = body[0].toLocaleUpperCase() + body.slice(1)
return body
}
}
if (resp instanceof HttpResponse) {
let msg = '';
const ct = resp.headers.get('content-type') || '';
body = this.stringifyBody(resp.body);
if (/application\/json/.test(ct)) {
if (!!body) {
msg = body;
}
} else if (/text\/plain/.test(ct)) {
msg = body;
}
// Make the first letter uppercase
if (!!msg) {
msg = msg[0].toLocaleUpperCase() + msg.slice(1)
return msg;
}
}
console.error(`Unexpected error type`, resp)
return `Unknown error: ${resp}`
} catch (err: any) {
console.error(err)
return `Unknown error: ${resp}`
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './action-indicator.service';
export * from './action-indicator.module';

View File

@@ -0,0 +1,30 @@
<ng-container [ngSwitch]="ref.status">
<div class="icon" *ngSwitchCase="'success'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</div>
<div class="icon error" *ngSwitchCase="'error'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</ng-container>
<div class="indicator-content">
<h1 *ngIf="!!ref.title">{{ ref.title }}</h1>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 close-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
<div class="message" *ngIf="ref.message">
<span>
{{ ref.message }}
</span>
</div>
</div>

View File

@@ -0,0 +1,74 @@
:host {
box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.75);
@apply bg-gray-200;
@apply p-4;
@apply rounded;
position: relative;
width: 20rem;
display: flex;
cursor: pointer;
border-left: 2px solid transparent;
.icon {
display: flex;
align-items: flex-start;
flex-shrink: 1;
margin-right: 1rem;
padding-top: 2px;
}
&.error {
@apply border-yellow;
.icon {
@apply text-yellow
}
}
.indicator-content {
display: flex;
flex-direction: column;
align-items: flex-start;
h1 {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0;
}
.message,
h1 {
flex-shrink: 0;
text-overflow: ellipsis;
}
.message {
font-size: 0.7rem;
flex-grow: 1;
opacity: .5;
span {
display: block;
height: 100%;
word-break: keep-all;
}
}
.close-icon {
position: absolute;
top: 1rem;
right: 1rem;
opacity: .7;
cursor: pointer;
&:hover {
opacity: 1;
}
}
h1~.message {
margin-top: .5rem;
}
}
}

View File

@@ -0,0 +1,78 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, HostListener, Inject, OnInit } from '@angular/core';
import { takeUntil } from 'rxjs/operators';
import { ActionIndicatorRef, ACTION_REF } from './action-indicator.service';
@Component({
templateUrl: './indicator.html',
styleUrls: ['./indicator.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('slideIn', [
state('void', style({
opacity: 0,
transform: 'translateY(32px)'
})),
state('showing', style({
opacity: 1,
transform: 'translateY(0px)'
})),
state('replace', style({
transform: 'translateY(0px) rotate(-3deg)',
zIndex: -100,
})),
transition('showing => replace', animate('10ms cubic-bezier(0, 0, 0.2, 1)')),
transition('void => *', animate('220ms cubic-bezier(0, 0, 0.2, 1)')),
transition('showing => void', animate('220ms cubic-bezier(0, 0, 0.2, 1)', style({
opacity: 0,
transform: 'translateX(-100%)'
}))),
transition('replace => void', animate('220ms cubic-bezier(0, 0, 0.2, 1)', style({
opacity: 0,
transform: 'translateY(-64px) rotate(-3deg)'
})))
])
]
})
export class IndicatorComponent implements OnInit {
constructor(
@Inject(ACTION_REF)
public ref: ActionIndicatorRef,
public cdr: ChangeDetectorRef,
) { }
@HostBinding('@slideIn')
state = 'showing';
@HostBinding('class.error')
isError = this.ref.status === 'error';
@HostListener('click')
closeIndicator() {
this.ref.close();
}
@HostListener('@slideIn.done', ['$event'])
onAnimationDone() {
if (this.state === 'replace') {
this.ref.close();
}
}
ngOnInit() {
this.ref.onCloseReplace
.pipe(
takeUntil(this.ref.onClose),
)
.subscribe(state => {
this.state = 'replace';
this.cdr.detectChanges();
})
}
}

View File

@@ -0,0 +1,111 @@
import { animate, query, stagger, style, transition, trigger } from '@angular/animations';
export const fadeInAnimation = trigger(
'fadeIn',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateY(-5px)' }),
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
style({ opacity: 1, transform: 'translateY(0px)' }))
]
),
]
);
export const fadeOutAnimation = trigger(
'fadeOut',
[
transition(
':leave',
[
style({ opacity: 1, transform: 'translateY(0px)' }),
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
style({ opacity: 0, transform: 'translateY(-5px)' }))
]
),
]
);
export const fadeInListAnimation = trigger(
'fadeInList',
[
transition(':enter, * => 0, * => -1', []),
transition(':increment', [
query(':enter', [
style({ opacity: 0 }),
stagger(5, [
animate('300ms ease-out', style({ opacity: 1 })),
]),
], { optional: true })
]),
]
)
export const moveInOutLeftAnimation = trigger(
'moveInOutLeft',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateX(-100%)' }),
animate('.1s ease-in',
style({ opacity: 1, transform: 'translateX(0%)' }))
]
),
transition(
':leave',
[
style({ opacity: 1, 'z-index': -100 }),
animate('.1s ease-out',
style({ opacity: 0, transform: 'translateX(-100%)' }))
]
)
]
)
export const moveInOutAnimation = trigger(
'moveInOut',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateX(100%)' }),
animate('.2s ease-in',
style({ opacity: 1, transform: 'translateX(0%)' }))
]
),
transition(
':leave',
[
style({ opacity: 1 }),
animate('.2s ease-out',
style({ opacity: 0, transform: 'translateX(100%)' }))
]
)
]
)
export const moveInOutListAnimation = trigger(
'moveInOutList',
[
transition(':enter, * => 0, * => -1', []),
transition(':increment', [
query(':enter', [
style({ opacity: 0, transform: 'translateX(100%)' }),
stagger(50, [
animate('200ms ease-out', style({ opacity: 1, transform: 'translateX(0%)' })),
]),
], { optional: true })
]),
transition(':decrement', [
query(':leave', [
stagger(-50, [
animate('200ms ease-out', style({ opacity: 0, transform: 'translateX(100%)' })),
]),
], { optional: true })
]),
]
)

View File

@@ -0,0 +1,118 @@
import { Injectable, inject, isDevMode } from "@angular/core";
import { AppProfile, AppProfileService, deepClone } from "@safing/portmaster-api";
import { firstValueFrom, map, switchMap } from "rxjs";
import { INTEGRATION_SERVICE, ProcessInfo } from "src/app/integration";
import * as parseDataURL from 'data-urls';
export abstract class AppIconResolver {
abstract resolveIcon(profile: AppProfile): void;
}
@Injectable()
export class DefaultIconResolver extends AppIconResolver {
private integration = inject(INTEGRATION_SERVICE);
private profileService = inject(AppProfileService);
private pendingResolvers = new Map<string, Promise<void>>();
resolveIcon(profile: AppProfile): void {
const key = `${profile.Source}/${profile.ID}`;
// if there's already a promise in flight, abort.
if (this.pendingResolvers.has(key)) {
if (isDevMode()) {
console.log(`[icon:${profile.Name}] loading icon already in progress ...`)
}
return;
}
let promise = new Promise<void>((resolve) => {
this.profileService
.getProcessesByProfile(profile)
.pipe(
map(processes => {
// if we there are no running processes for this profile,
// we try to find the icon based on the information stored in
// the profile.
let info: ProcessInfo[] = [{
execPath: profile.LinkedPath,
cmdline: profile.PresentationPath,
pid: -1,
matchingPath: profile.PresentationPath,
}]
processes?.forEach(process => {
// BUG: Portmaster sometimes runs a null entry, skip it here.
if (!process) {
return;
}
// insert at the beginning since the process data might reveal
// better results than the profile one.
info.splice(0, 0, {
execPath: process.Path,
cmdline: process.CmdLine,
pid: process.Pid,
matchingPath: process.MatchingPath,
})
})
return info;
})
).subscribe(async (processInfos) => {
for (const info of processInfos) {
try {
await this.loadAndSaveIcon(info, profile);
// success, abort now
resolve();
return;
} catch (err) {
// continue using the next one
}
}
// we failed to find an icon, still resolve the promise here
// because nobody actually cares ....
resolve();
})
});
this.pendingResolvers.set(key, promise);
promise.finally(() => this.pendingResolvers.delete(key));
}
private async loadAndSaveIcon(info: ProcessInfo, profile: AppProfile): Promise<void> {
const icon = await this.integration.getAppIcon(info);
const dataURL = parseDataURL(icon);
if (!dataURL) {
throw new Error("invalid data url");
}
const blob = new Blob([dataURL.body], {
type: dataURL.mimeType.essence,
})
const body = await blob.arrayBuffer();
const save$ = this.profileService
.setProfileIcon(body, blob.type)
.pipe(switchMap(result => {
// save the profile icon
profile = deepClone(profile);
profile.Icons = [
...(profile.Icons || []),
{
Value: result.filename,
Type: 'api',
Source: 'ui'
}
];
return this.profileService.saveProfile(profile)
}));
await firstValueFrom(save$);
}
}

View File

@@ -0,0 +1,9 @@
<span *ngIf="!src && !isIgnoredProfile">
{{letter}}
</span>
<img [attr.src]="src" *ngIf="!!src" loading="lazy">
<svg *ngIf="!src && isIgnoredProfile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-full h-full text-gray-700">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>

View File

@@ -0,0 +1,23 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { AppIconComponent } from "./app-icon";
import { AppIconResolver, DefaultIconResolver } from "./app-icon-resolver";
@NgModule({
imports: [
CommonModule
],
declarations: [
AppIconComponent,
],
exports: [
AppIconComponent,
],
providers: [
{
provide: AppIconResolver,
useClass: DefaultIconResolver,
}
]
})
export class SfngAppIconModule { }

View File

@@ -0,0 +1,28 @@
:host {
border-radius: 50%;
user-select: none;
height: var(--app-icon-size, 25px);
width: var(--app-icon-size, 25px);
flex-shrink: 0;
@apply mr-2;
display: inline-flex;
justify-content: center;
align-items: center;
}
span,
img {
@apply text-primary;
@apply font-medium;
@apply rounded-full;
text-shadow: rgba(0, 0, 0, .8) 0px 0px 1px;
font-size: calc(var(--app-icon-size, 25px) / 6 * 4);
}
img {
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,312 @@
import { Min } from './../../../../dist-lib/safing/portmaster-api/lib/netquery.service.d';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostBinding,
Inject,
Input,
OnDestroy,
OnInit,
SkipSelf,
inject,
} from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import {
AppProfileService,
PORTMASTER_HTTP_API_ENDPOINT,
PortapiService,
Record,
deepClone,
} from '@safing/portmaster-api';
import { Subscription, map, of, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { INTEGRATION_SERVICE, ProcessInfo } from 'src/app/integration';
import { AppIconResolver } from './app-icon-resolver';
// Interface that must be satisfied for the profile-input
// of app-icon.
export interface IDandName {
// ID of the profile.
ID?: string;
// Source is the source of the profile.
Source?: string;
// Name of the profile.
Name: string;
}
// Some icons we don't want to show on the UI.
// Note that this works on a best effort basis and might
// start breaking with updates to the built-in icons...
const iconsToIngore = [
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABU0lEQVRYhe2WTUrEQBCF36i4ctm4FsdTKF5AEFxL0knuILgQXAy4ELxDfgTXguAFRG/hDXKCAbtcOB3aSVenMjPRTb5NvdCE97oq3QQYGflnJlbc3T/QXxrfXF9NAGBraKPTk2Nvtey4D1l8OUiIo8ODX/Xt/cMfQCk1SAAi8upWgLquWy8rpbB7+yk2m8+mYvNWAAB4fnlt9MX5WaP397ZhCPgygCFa1IUmwJifCgB5nrMBtdbhAK6pi9QcALIs8+5c1AEOqTmwZge4EUjNiQhpmjbarcvaG4AbgcTcUhSFfwFAHMfhABxScwBIkgRA9wnwBgiOQGBORCjLkl2PoigcgB2BwNzifmi97wEOqTkRoaoqdr2zA9wIJOYWrTW785VPQR+WO2B3vdYIpBBRc9Qkp2Cw/4GVR+BjPpt23u19tUXUgU2aBzuQPz5J8oyMjGyUb9+FOUOmulVPAAAAAElFTkSuQmCC',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAACLElEQVR4nO2av07DMBDGP1DFxtaFmbeg6gtUqtQZtU3yDkgMSAxIDEi8Q/8gMVdC4m1YYO0TMNQspErdOG3Md25c7rc0st3E353v7EsLKIqiKIqiKMq/5MRueHx6NoeYSCjubm82NJ8eaiISdDtX6HauKq9tWsFmF4DPr6+d1zalBshG18RpNYfJy+tW21GFgA+lK6DdboeeBwVjyvO3qx1wGGC5XO71wCYZykc8QEqCZ/cfjNs4+X64rOz3FQ/sMMDi7R2Dfg+Lt/eN9kG/tzX24rwFA8AYYGXM+nr9aQADs9mG37FWW3HsqqBhMpnsFFRGkiTOvkoD5ELLBNtIiLcdmGXZ5jP/4Pkc2i4gIb5KRl3xrnbaQSiEeN8QGI/Hzj5aDgjh+SzLaJ7P4eWAiJZ9EVoIhBA/nU695uYdAnUI4fk0TUvbXeP3gZcDhMS7CLIL1DsHyIv3DYHRaOTs44YAZD2fpik9EfIOQohn2Rch5wBZ8bPZzOObfwiBurWAtOftoqaO511jaSEgJd4FQzwgmAQlxPuGwHA4dPbJ1QICnk+ShOb5HJlaoOHLvgi/FhAUP5/P9xpbteRtyDlA1vN2UVPH8+K7gJR45/MI4gHyK7HYxANsA7BuVvkcnniAXAtIwxYPRPTboIR4IBIDMMSL7wIhYZbF0RmgsS9EQtDY1+L5r7esCUrGvA3xHBCfeIBkgBjEi+0CMYsHHDmg7N9UiqIoiqIoiqIcFT++NKIXgDvowAAAAABJRU5ErkJggg==',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAABqUlEQVRYhe2XP2rDMBSHfymhU0dDD5BbJOQCgUDmEv+7Q6FDoUOgQ6F3cJxC50Agt+nSrD5BBr8OqVyrtfWkl8ShoG+SjJE+/95DwoDH4/nf9NTg+eWVLinym8eH+x4AXF1i8/FoiPFoaBwr+p3bAfjc7dixQhNMw7szatmTvb1XY00wCILOZYjIONcEi6JoXSgIAlw/fYhF9ouBsxzQ0IPrzRaz6QTrzbZ6NptOqvHtTR8EQklAWQIl4WdOQEkEqsaHefm9b5Zl7IfEcWwWVDJ1Ke0rHeXqmaRpeljDIrlWQQ5XufreNglGUWQW5EoslQOAJEm0uagHuRJL5YgIy+Wycc06bIIcEjmFStCUnPGYASxKLJQDYJVgGIZmQZsSS+SAv0eIKblWQQ6pHBEhz3N2fTZBrsQSOYVK0JQc24N2JXaXA2CV4Hw+NwtySOUA/QixvU1kPSiQIyKsViv2vaMTlMgpoihik2N7kEMqB6AxwXpiVlfduSAi7Qix7cGL/DS5XHWdC7rIAY4l3i8GTk1+zLsKpwS7lnMS7ErOeMzU/0c9Ho/nNHwBdUH2gB9vJRsAAAAASUVORK5CYII=',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAByElEQVRYhe1WQUoDQRCsmSh4CAreo3/w4CdE8JirLzCKGhRERPBqfISQx3j0BcaDJxHNRWS7PWRmtmdmJ9mNiSuYOmyYbOiqruoeAizw36G6p0e3WulOHeTE1NO/Qb6zu1f4qZXuqLPuMV9d38xbQyEuL86ha2EWWJKHfr+P4XAIAGg2m2i32wCA7fsXPH9kABjMgHkADP87cW6tNvCwvzG2biRAvpAYvH+54mCAmUcvmI0Yq4nM74DBG02sGwlIgqigS/ZEgdkcrSAuVbpUBEyjTiP7JSkDzKZrdo+xdSMBKas4y4K8befSiVxcLnR83UhACtYBV9TOgbBbOX4TF2YZQZY5Yi9/MYwkXQjy/3EEtjp7LgQzAeOUVSo0zCACcgOnwjUEC2LE7kxApS0AGFRgP4vZ8M5VBaQjoNGKuQ20Q2ney8Gr0H0kIAU7hK4zYiPCJxtFZYRMIyAdAQWrFgyicMSfj4oCkheRmQFyIoq2IRcy9T2QhNmCfN/FVcwMBSWu4XlsQUZe5tZmZW0HBXGU4o4FpCJorS3j6fXTEOVdUrgNApvrK9UFpPB4vlWq2DSo/S+Z6p4c9rRuHNRBTsR3dfAu8LfwDdGgu25Uax8RAAAAAElFTkSuQmCC',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAByUlEQVRYhe1WQUoDQRCs2UTwEBS8R//gwU+I4DFXX2AENRgQEcGr8RFCHuPRFxgPnkQ0F9Ht9rAzsz0zO8luTFzB1GHDZENXdVX3EGCJ/w7VO+3eJKrZrYOc+GuQ/Ab57t5+4Weiml111jvmy6vrRWsoxMV5H0ktzAJNeRgOhxiPxwCAVquFTqcDANi5e8bTewqAwQzoB8BwvxPn9loD9webE+sGAuQLidHbpy0OBpg5e8GsxRhNpH8HjF5pat1AQBREBV2yIwrM+mgEcanSpSJgyjoN7JekDDDrrtk+JtYNBMSs4jT18jadSydycbnQyXUDATEYB2xRMwfCbmX5dVyYZwRpaomd/MUwknTBy//HEZjq7LjgzQS0U0ap0DCHCMgOnPLXECyIEbozBZW2AGBQgf0sZsM5VxUQj4CyFbMbaIZSv5eDV6H7QEAMZghtZ8RahEuWRaWFzCIgHgF5q+YNonDEnY+KAqIXkZ4BsiKKtiEXMvM9EIXegnzfxVXMDAUlruFFbEFKTubGZmVsB3lxlOIOBcQiaK+v4PHlQxPlXZK/DQJbG6vVBcTw0N8uVWwW1P6XTPVOjgZJ0jisg5yIb+vgXeJv4RvrxrtwzfCUqAAAAABJRU5ErkJggg==',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADo0lEQVRYhe2Wu28cVRTGf+fcuzZeY0NCUqTgD3C8mzRU0KDQEgmqoBSGhoKWAlFEKYyQAAkrTRRqOpCQkOhAkUCio4D4IYSAzkkKB+wQbLy7c8+hmNmd2WecDQoFfNJodGfn7vc4Z84M/I9/GfLeB1cutdqH7zxSUli9fOntd4EsmrVXL1xcodVqAf6PEl37+AveWDk/dP78s08vA1eBvSgSDnd3bs49DJGKICIg+dod3J3XXn6Ogz9+49WXnu07F1gA9mOWJRqNBrNRcJ8mAQF8ZHYyuBYhI/DlV9cBAqARnBAj2agdjwARoBaETnK+/eY7NMwfaaPZPueefwaA73+4MfKeM80GAC+8+QkA19cukCQOC+ga1zDPR1//jIgjWhzBEQWNBupoNESdldNn2dm5w/FjT/SIpkEcvLAwX0PUQRwNXQGOBCvXoVpxZ31jc2ICEwWY+1y19AvzEQr3GgAtiLUUo8F690tB5DhC3sgiw800f2p/fAJ/tTtoyMOo1yOqnscdnINOIqNDO+vQbrdwMTRWEnBhfXNyAvOn9qmfOBgvwKxwC9TnAskTN3f32PnzHi1robEbv6HFUVGQJ+AOIvkQgL4U6icOqC9OSKCKu4cH/HT7Nh3P0GiEWkEcc+LBEhylB+qL+ywe+328gGrFNre3kWiE6EjsOi5EqPVS6EGEZrOJW0JVR5KMIy8TqCjQmlUcl7GLlvGrlgLcYWNzY2ICk1CUoFSgtdRPHAwtYteQeimUCuDsmebEMX7l3Pv3E1BCY+lUgqNaFZJ663ID3Fh/6ARKhFrqNVq15lVy1dRP1FjGRaZ6lQwnEKqkw+Si/QLMATwnHxhA7o65k2UJM0NwanOP30dATAPkhmjlmuYiuhCcja0fR7prNhqA4W5Fjwz3ydBTEGLZaKoV99p13y8AnGZjeeT4dfd8LrnnCYyoUQTQQsGtW7/y+tPnR7oZxPb2LywvncRd2dzaGnnP6aUlzBLJvKt1tIAsObUAF195kZ2dO0cSsLx0EgAz6yWQO3aSGeZOJ8swS5gNj+c+AeYwE4QgxlPHF6nNzkBKpGQ4EGMAnSksOGCA41nisJP/eTfuVIjAHQRCCITiPaPjBAC0kwMKMkvW7vuJTgZQffSkOBRCLqeL0cN4PKLA6trah2/FGB97wL05oSohKCEEzMBSRkpp4gf+3d3dq+SOTIAZ4Enyz+QwjYgpkIB7wF6RIxGo8eAJTgsDOpB/jP+38TcKdstukjAxWQAAAABJRU5ErkJggg==',
];
const profilesToIgnore = ['local/_unidentified', 'local/_unsolicited'];
@Component({
selector: 'app-icon',
templateUrl: './app-icon.html',
styleUrls: ['./app-icon.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppIconComponent implements OnInit, OnDestroy {
private sub = Subscription.EMPTY;
private initDone = false;
private resovler = inject(AppIconResolver);
/** @private The data-URL for the app-icon if available */
src: SafeUrl | string = '';
/** The profile for which to show the app-icon */
@Input()
set profile(p: IDandName | null | undefined | string) {
if (typeof p === 'string') {
const parts = p.split("/")
p = {
Source: parts[0],
ID: parts[1],
Name: '',
}
}
if (!!this._profile && !!p && this._profile.ID === p.ID) {
// skip if this is the same profile
return;
}
this._profile = p || null;
if (this.initDone) {
this.updateView();
}
}
get profile(): IDandName | null | undefined {
return this._profile;
}
private _profile: IDandName | null = null;
/** isIgnoredProfile is set to true if the profile is part of profilesToIgnore */
isIgnoredProfile = false;
/** If not icon is available, this holds the first - uppercased - letter of the app - name */
letter: string = '';
/** @private The background color of the component, based on icon availability and generated by ID */
@HostBinding('style.background-color')
color: string = 'var(--text-tertiary)';
constructor(
private profileService: AppProfileService,
private changeDetectorRef: ChangeDetectorRef,
private portapi: PortapiService,
// @HostBinding() is not evaluated in our change-detection run but rather
// checked by the parent component during updateRenderer.
// Since we want the background color to change immediately after we set the
// src path we need to tell the parent (which ever it is) to update as wel.
@SkipSelf() private parentCdr: ChangeDetectorRef,
private sanitzier: DomSanitizer,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string
) { }
/** Updates the view of the app-icon and tries to find the actual application icon */
private requestedAnimationFrame: number | null = null;
private updateView(skipIcon = false) {
if (this.requestedAnimationFrame !== null) {
cancelAnimationFrame(this.requestedAnimationFrame);
}
this.requestedAnimationFrame = requestAnimationFrame(() => {
this.__updateView();
})
}
ngOnInit(): void {
this.updateView();
this.initDone = true;
}
private __updateView(skipIcon = false) {
this.requestedAnimationFrame = null;
const p = this.profile;
const sourceAndId = this.getIDAndSource();
if (!!p && sourceAndId !== null) {
let idx = 0;
for (let i = 0; i < (p.ID || p.Name).length; i++) {
idx += (p.ID || p.Name).charCodeAt(i);
}
const combinedID = `${sourceAndId[0]}/${sourceAndId[1]}`;
this.isIgnoredProfile = profilesToIgnore.includes(combinedID);
this.updateLetter(p);
if (!this.isIgnoredProfile) {
this.color = AppColors[idx % AppColors.length];
} else {
this.color = 'transparent';
}
if (!skipIcon) {
this.tryGetSystemIcon(p);
}
} else {
this.isIgnoredProfile = false;
this.color = 'var(--text-tertiary)';
}
this.changeDetectorRef.markForCheck();
this.parentCdr.markForCheck();
}
private updateLetter(p: IDandName) {
if (p.Name !== '') {
if (p.Name[0] === '<') {
// we might get the name with search-highlighting which
// will then include <em> tags. If the first character is a <
// make sure to strip all HTML tags before getting [0].
this.letter = p.Name.replace(
/(&nbsp;|<([^>]+)>)/gi,
''
)[0].toLocaleUpperCase();
} else {
this.letter = p.Name[0];
}
this.letter = this.letter.toLocaleUpperCase();
} else {
this.letter = '?';
}
}
getIDAndSource(): [string, string] | null {
if (!this.profile) {
return null;
}
let id = this.profile.ID;
if (!id) {
return null;
}
// if there's a source ID only holds the profile ID
if (!!this.profile.Source) {
return [this.profile.Source, id];
}
// otherwise, ID likely contains the source
let [source, ...rest] = id.split('/');
if (rest.length > 0) {
return [source, rest.join('/')];
}
// id does not contain a forward-slash so we
// assume the source is local
return ['local', id];
}
/**
* Tries to get the application icon form the system.
* Requires the app to be running in the electron wrapper.
*/
private tryGetSystemIcon(p: IDandName) {
const sourceAndId = this.getIDAndSource();
if (sourceAndId === null) {
return;
}
this.sub.unsubscribe();
this.sub = this.profileService
.watchAppProfile(sourceAndId[0], sourceAndId[1])
.pipe(
switchMap((profile) => {
this.updateLetter(profile);
if (!!profile.Icons?.length) {
const firstIcon = profile.Icons[0];
console.log(`profile ${profile.Name} has icon of from source ${firstIcon.Source} stored in ${firstIcon.Type}`)
switch (firstIcon.Type) {
case 'database':
return this.portapi
.get<Record & { iconData: string }>(firstIcon.Value)
.pipe(
map((result) => {
return result.iconData;
})
);
case 'api':
return of(`${this.httpAPI}/v1/profile/icon/${firstIcon.Value}`);
default:
console.error(`Icon type ${firstIcon.Type} not yet supported`);
}
}
this.resovler.resolveIcon(profile);
// return an empty icon here. If the resolver manages to find an icon
// the profle will get updated and we'll run again here.
return of('');
})
)
.subscribe({
next: (icon) => {
if (iconsToIngore.some((i) => i === icon)) {
icon = '';
}
if (icon !== '') {
this.src = this.sanitzier.bypassSecurityTrustUrl(icon);
this.color = 'unset';
} else {
this.src = '';
this.color =
this.color === 'unset' ? 'var(--text-tertiary)' : this.color;
}
this.changeDetectorRef.detectChanges();
this.parentCdr.markForCheck();
},
error: (err) => console.error(err),
});
}
ngOnDestroy(): void {
this.sub.unsubscribe();
}
}
export const AppColors: string[] = [
'rgba(244, 67, 54, .7)',
'rgba(233, 30, 99, .7)',
'rgba(156, 39, 176, .7)',
'rgba(103, 58, 183, .7)',
'rgba(63, 81, 181, .7)',
'rgba(33, 150, 243, .7)',
'rgba(3, 169, 244, .7)',
'rgba(0, 188, 212, .7)',
'rgba(0, 150, 136, .7)',
'rgba(76, 175, 80, .7)',
'rgba(139, 195, 74, .7)',
'rgba(205, 220, 57, .7)',
'rgba(255, 235, 59, .7)',
'rgba(255, 193, 7, .7)',
'rgba(255, 152, 0, .7)',
'rgba(255, 87, 34, .7)',
'rgba(121, 85, 72, .7)',
'rgba(158, 158, 158, .7)',
'rgba(96, 125, 139, .7)',
];

View File

@@ -0,0 +1,2 @@
export { AppIconComponent } from './app-icon';
export { SfngAppIconModule } from './app-icon.module';

View File

@@ -0,0 +1,69 @@
<ng-container *ngIf="!!setting">
<ng-container [ngSwitch]="_type">
<ng-container *ngSwitchCase="'string'">
<!--
Dropdowns for a limited set of allowed values. Either using PossibleValues (the new way)
or by parsing the settings validation regex (deprecated)
-->
<sfng-select *ngIf="externalOptType(setting) === optionHints.OneOf; else: simpleTextInput" [ngModel]="_value"
(ngModelChange)="setInternalValue($event); touched()" [disabled]="_disabled">
<ng-container *ngIf="!!setting.PossibleValues; else: noPossibleValues">
<ng-container *ngFor="let opt of setting.PossibleValues">
<sfng-select-item *sfngSelectValue="opt.Value; description: opt.Description">{{opt.Name}}</sfng-select-item>
</ng-container>
</ng-container>
<ng-template #noPossibleValues>
<ng-container *ngFor="let opt of parseSupportedValues(setting)">
<sfng-select-item *sfngSelectValue="opt">{{opt}}</sfng-select-item>
</ng-container>
</ng-template>
</sfng-select>
<!--
A simple text input
-->
<ng-template #simpleTextInput>
<div class="input-container">
<input type="text" [ngModel]="_value" (ngModelChange)="setInternalValue($event)" [disabled]="_disabled" #input
(blur)="touched()" (click)="input.focus()">
<span *ngIf="!!unit" class="suffix" #suffixElement>
{{ unit }}
</span>
</div>
</ng-template>
</ng-container>
<!--
A number input
-->
<div class="input-container" *ngSwitchCase="'number'">
<input type="number" [ngModel]="_value" (ngModelChange)="setInternalValue($event)" [disabled]="_disabled" #input
(blur)="touched()">
<span *ngIf="!!unit" class="suffix" #suffixElement (click)="input.focus()">
{{ unit }}
</span>
</div>
<!--
Toggle switch (On/Off)
-->
<ng-container *ngSwitchCase="'boolean'">
<sfng-toggle id="check-{{setting.Key}}" name="check" [ngModel]="_value"
(ngModelChange)="setInternalValue($event); touched()" [disabled]="_disabled">
</sfng-toggle>
</ng-container>
<!--
Multi-line text input
Mainly used as a fallback if we don't support the given input type
yet.
This allows direct manipulatoin of the JSON encoded value
-->
<textarea *ngSwitchDefault [attr.rows]="lineCount(_value)" [ngModel]="_value"
(ngModelChange)="setInternalValue($event)" [disabled]="_disabled" (blur)="touched()">
</textarea>
</ng-container>
</ng-container>

View File

@@ -0,0 +1,28 @@
label {
@apply text-sm;
}
input[type="checkbox"] {
float: right;
user-select: none;
}
.input-container {
display: block;
position: relative;
font-size: 0.75rem;
input {
font-size: inherit;
}
.suffix {
user-select: none;
position: absolute;
left: 0;
top: calc(50% - 0.55rem);
padding-left: 0.3rem;
color: #aaa;
font: inherit;
}
}

View File

@@ -0,0 +1,333 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { DOCUMENT } from '@angular/common';
import { AfterViewChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, forwardRef, Inject, Input, Output, ViewChild } from '@angular/core';
import { AbstractControl, ControlValueAccessor, NgModel, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator } from '@angular/forms';
import { BaseSetting, ExternalOptionHint, OptionType, parseSupportedValues, SettingValueType, WellKnown } from '@safing/portmaster-api';
@Component({
selector: 'app-basic-setting',
templateUrl: './basic-setting.html',
styleUrls: ['./basic-setting.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: forwardRef(() => BasicSettingComponent),
},
{
provide: NG_VALIDATORS,
multi: true,
useExisting: forwardRef(() => BasicSettingComponent),
}
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BasicSettingComponent<S extends BaseSetting<any, any>> implements ControlValueAccessor, Validator, AfterViewChecked {
/** @private template-access to all external option hits */
readonly optionHints = ExternalOptionHint;
/** @private template-access to parseSupportedValues */
readonly parseSupportedValues = parseSupportedValues;
@ViewChild('suffixElement', { static: false, read: ElementRef })
suffixElement?: ElementRef<HTMLSpanElement>;
/** Cached canvas element used by getTextWidth */
private cachedCanvas?: HTMLCanvasElement;
/** Returns the value of external-option hint annotation */
externalOptType(opt: S): ExternalOptionHint | null {
return opt.Annotations?.["safing/portbase:ui:display-hint"] || null;
}
/** Whether or not the input should be currently disabled. */
@Input()
set disabled(v: any) {
const disabled = coerceBooleanProperty(v);
this.setDisabledState(disabled);
}
get disabled() {
return this._disabled;
}
/** The setting to display */
@Input()
setting: S | null = null;
/** Emits when the user activates focus on this component */
@Output()
blured = new EventEmitter<void>();
/** @private The ngModel in our view used to display the value */
@ViewChild(NgModel)
model: NgModel | null = null;
/** The unit of the setting */
get unit() {
if (!this.setting) {
return '';
}
return this.setting.Annotations[WellKnown.Unit] || '';
}
/**
* Holds the value as it is presented to the user.
* That is, a JSON encoded object or array is dumped as a
* JSON string. Strings, numbers and booleans are presented
* as they are.
*/
_value: string | number | boolean = "";
/**
* Describes the type of the original settings value
* as passed to writeValue().
* This may be anything that can be returned from `typeof v`.
* If set to "string", "number" or "boolean" then _value is emitted
* as it is.
* If it's set anything else (like "object") than _value is JSON.parse`d
* before being emitted.
*/
_type: string = '';
/* Returns true if the current _type and _value is managed as JSON */
get isJSON(): boolean {
return this._type !== 'string'
&& this._type !== 'number'
&& this._type !== 'boolean'
}
/*
* _onChange is set using registerOnChange by @angular/forms
* and satisfies the ControlValueAccessor.
*/
private _onChange: (_: SettingValueType<S>) => void = () => { };
/* _onTouch is set using registerOnTouched by @angular/forms
* and satisfies the ControlValueAccessor.
*/
private _onTouch: () => void = () => { };
private _onValidatorChange: () => void = () => { };
/* Whether or not the input field is disabled. Set by setDisabledState
* from @angular/forms
*/
_disabled: boolean = false;
private _valid: boolean = true;
// We are using ChangeDetectionStrategy.OnPush so angular does not
// update ourself when writeValue or setDisabledState is called.
// Using the changeDetectorRef we can take care of that ourself.
constructor(
@Inject(DOCUMENT) private document: Document,
private _changeDetectorRef: ChangeDetectorRef
) { }
ngAfterViewChecked() {
// update the suffix position everytime angular has
// checked our view for changes.
this.updateUnitSuffixPosition();
}
/**
* Sets the user-presented value and emits a change.
* Used by our view. Not meant to be used from outside!
* Use writeValue instead.
* @private
*
* @param value The value to set
*/
setInternalValue(value: string | number | boolean) {
let toEmit: any = value;
try {
if (!this.isJSON) {
toEmit = value;
} else {
toEmit = JSON.parse(value as string);
}
} catch (err) {
this._valid = false;
this._onValidatorChange();
return;
}
this._valid = true;
this._value = value;
this._onChange(toEmit);
this.updateUnitSuffixPosition();
}
/**
* Updates the position of the value's unit suffix element
*/
private updateUnitSuffixPosition() {
if (!!this.unit && !!this.suffixElement) {
const input = this.suffixElement.nativeElement.previousSibling! as HTMLInputElement;
const style = window.getComputedStyle(input);
let paddingleft = parseInt(style.paddingLeft.slice(0, -2))
// we need to use `input.value` instead of `value` as we need to
// get preceding zeros of the number input as well, while still
// using the value as a fallback.
let value = input.value || (this._value as string);
const width = this.getTextWidth(value, style.font) + paddingleft;
this.suffixElement.nativeElement.style.left = `${width}px`;
}
}
/**
* Validates if "value" matches the settings requirements.
* It satisfies the NG_VALIDATORS interface and validates the
* value for THIS component.
*
* @param param0 The AbstractControl to validate
*/
validate({ value }: AbstractControl): ValidationErrors | null {
if (!this._valid) {
return {
jsonParseError: true
}
}
if (this._type === 'string' || value === null) {
if (!!this.setting?.DefaultValue && !value) {
return {
required: true,
}
}
}
if (!!this.setting?.ValidationRegex) {
const re = new RegExp(this.setting.ValidationRegex);
if (!this.isJSON) {
if (!re.test(`${value}`)) {
return {
pattern: `"${value}"`
}
}
} else {
if (!Array.isArray(value)) {
return {
invalidType: true
}
}
const invalidLines = value.filter(v => !re.test(v));
if (invalidLines.length) {
return {
pattern: invalidLines
}
}
}
}
return null;
}
/**
* Writes a new value and satisfies the ControlValueAccessor
*
* @param v The new value to write
*/
writeValue(v: SettingValueType<S>) {
// the following is a super ugly work-around for the migration
// from security-settings to booleans.
//
// In order to not mess and hide an actual portmaster issue
// we only convert v to a boolean if it's a number value and marked as a security setting.
// In all other cases we don't mangle it.
//
// TODO(ppacher): Remove in v1.8?
// BOM
if (this.setting?.OptType === OptionType.Bool && this.setting?.Annotations[WellKnown.DisplayHint] === ExternalOptionHint.SecurityLevel) {
if (typeof v === 'number') {
(v as any) = v === 7;
}
}
// EOM
let t = typeof v;
this._type = t;
if (this.isJSON) {
this._value = JSON.stringify(v, undefined, 2);
} else {
this._value = v;
}
this.updateUnitSuffixPosition();
this._changeDetectorRef.markForCheck();
}
registerOnValidatorChange(fn: () => void) {
this._onValidatorChange = fn;
}
/**
* Registers the onChange function requred by the
* ControlValueAccessor
*
* @param fn The fn to register
*/
registerOnChange(fn: (_: SettingValueType<S>) => void) {
this._onChange = fn;
}
/**
* @private
* Called when the input-component used for the setting is touched/focused.
*/
touched() {
this._onTouch();
this.blured.next();
}
/**
* Registers the onTouch function requred by the
* ControlValueAccessor
*
* @param fn The fn to register
*/
registerOnTouched(fn: () => void) {
this._onTouch = fn;
}
/**
* Enable or disable the component. Required for the
* ControlValueAccessor.
*
* @param disable Whether or not the component is disabled
*/
setDisabledState(disable: boolean) {
this._disabled = disable;
this._changeDetectorRef.markForCheck();
}
/**
* @private
* Returns the number of lines in value. If value is not
* a string 1 is returned.
*/
lineCount(value: string | number | boolean) {
if (typeof value === 'string') {
return value.split('\n').length
}
return 1
}
/**
* Calculates the amount of pixel a text requires when being rendered.
* It uses canvas.measureText on a dummy (no attached) element
*
* @param text The text that would be rendered
* @param font The CSS font descriptor that would be used for the text
*/
private getTextWidth(text: string, font: string): number {
let canvas = this.cachedCanvas || this.document.createElement('canvas');
this.cachedCanvas = canvas;
let context = canvas.getContext("2d")!;
context.font = font;
let metrics = context.measureText(text);
return metrics.width;
}
}

View File

@@ -0,0 +1 @@
export * from './basic-setting';

View File

@@ -0,0 +1,111 @@
<!-- navigation for the settings -->
<div class="settings-nav hidden sfng-md:block" *ngIf="!loading" [@fadeIn]
[ngClass]="{'w-48 pl-12': !compactView, 'w-36 pl-3': compactView}">
<ul>
<ng-container *ngFor="let subsys of subsystems; trackBy: trackSubsystem">
<ng-template [appExpertiseLevel]="subsys.minimumExpertise" [appExpertiseLevelData]="subsys"
[appExpertiseLevelOverwrite]="mustShowSubsystem">
<li [class.active]="activeSection === subsys.ConfigKeySpace" [class.separated]="subsys.ID === 'core'">
<span (click)="scrollTo(subsys.ConfigKeySpace)" class="relative">
{{subsys.Name}}
<span *ngIf="subsys.hasUserDefinedValues && userSettingsMarker" class="user-defined-value"></span>
</span>
<ul class="settings">
<ng-container *ngFor="let cat of settings.get(subsys.ConfigKeySpace); trackBy: trackCategory">
<li [class.active]="activeCategory === cat.name" class="category"
*appExpertiseLevel="cat.minimumExpertise; data: cat; overwrite: mustShowCategory"
(click)="scrollTo(subsys.ConfigKeySpace + ':' + cat.name, cat)">
<span class="relative">
{{cat.name}}
<span *ngIf="cat.hasUserDefinedValues && userSettingsMarker"
class="user-defined-value category"></span>
</span>
</li>
</ng-container>
</ul>
</li>
</ng-template>
</ng-container>
<li *ngIf="!!others" (click)="scrollTo('config:other')" [class.active]="activeSection === 'config:other'">
Other
</li>
</ul>
<ul class="bottom-0 mb-7">
<li class="mt-3" *ngIf="!exportMode">
<button class="bg-grey text-white w-full" (click)="openImportDialog()">Import Settings</button>
</li>
<li *ngIf="exportMode" class="mt-3">
<button class="bg-grey text-white w-full" (click)="generateExport()">Save</button>
</li>
<li>
<button [ngClass]="{'bg-grey': !exportMode, 'bg-gray-400': exportMode}" class="text-white w-full" (click)="toggleExportMode()">{{ !exportMode ? 'Export Settings' : 'Cancel Export' }}</button>
</li>
</ul>
</div>
<div class="flex justify-center flex-grow px-0 pb-24 pr-4 overflow-auto whitespace-normal" cdkScrollable>
<fa-icon icon="spinner" [spin]="true" *ngIf="loading"></fa-icon>
<div class="w-full space-y-4" *ngIf="!loading">
<!-- actual settings -->
<ng-container *ngFor="let subsys of subsystems; trackBy: trackSubsystem">
<ng-template [appExpertiseLevel]="subsys.minimumExpertise" [appExpertiseLevelData]="subsys"
[appExpertiseLevelOverwrite]="mustShowSubsystem">
<h2 class="w-full px-0 ml-0 text-xl text-primary" [attr.id]="subsys.ConfigKeySpace">
{{subsys.Name}}
</h2>
<ng-container *ngFor="let cat of settings.get(subsys.ConfigKeySpace); trackBy: trackCategory; let index=index">
<div class="max-w-screen-lg space-y-4" #navLink anchor="top" [attr.subsystem]="subsys.ConfigKeySpace"
[attr.category]="cat.name"
*appExpertiseLevel="cat.minimumExpertise; data: cat; overwrite: mustShowCategory">
<div (click)="cat.collapsed = !cat.collapsed" [attr.id]="subsys.ConfigKeySpace +':' + cat.name"
class="flex items-center justify-between p-3 px-5 bg-gray-300 rounded cursor-pointer select-none">
<h4 class="text-md text-primary">{{cat.name}}</h4>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 transition-transform duration-150 transform"
[class.rotate-90]="!cat.collapsed" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div *ngIf="!cat.collapsed" [@fadeIn] [@fadeOut] class="space-y-4">
<ng-container *ngFor="let setting of cat.settings; trackBy: configService.trackBy">
<div class="ml-6"
*appExpertiseLevel="setting.ExpertiseLevel; data: setting; overwrite: mustShowSetting">
<app-generic-setting [class.highlighted]="highlightKey === setting.Key" [setting]="setting"
[attr.id]="setting.Key" [resetLabelText]="resetLabelText" (save)="saveSetting($event, setting)"
[lockDefaults]="lockDefaults" [displayStackable]="displayStackable" [selectMode]="exportMode" [(selected)]="selectedSettings[setting.Key]">
</app-generic-setting>
</div>
</ng-container>
</div>
</div>
</ng-container>
</ng-template>
</ng-container>
<ng-container *ngIf="!!others">
<h2 id="config:other" #navLink>
Other
</h2>
<div class="category">
<ng-container *ngFor="let setting of others; trackBy: configService.trackBy">
<app-generic-setting *appExpertiseLevel="setting.ExpertiseLevel; data: setting; overwrite: mustShowSetting"
[setting]="setting" [resetLabelText]="resetLabelText" (save)="saveSetting($event, setting)"
[lockDefaults]="lockDefaults" [displayStackable]="displayStackable" [selectMode]="exportMode" [(selected)]="selectedSettings[setting.Key]">
</app-generic-setting>
</ng-container>
</div>
</ng-container>
<div class="pb-24"></div>
</div>
</div>

View File

@@ -0,0 +1,95 @@
:host {
display: flex;
overflow: hidden;
}
fa-icon[icon="spinner"] {
@apply text-3xl;
display: block;
width: 100%;
text-align: center;
height: 6rem;
}
div.settings-nav {
@apply mt-4;
flex-shrink: 0;
overflow: visible;
white-space: nowrap;
transition: height cubic-bezier(0.25, 0.46, 0.45, 0.94) .5s;
@apply text-xs;
ul {
position: fixed;
li {
@apply font-medium;
&.separated {
margin-top: 1.25rem;
}
}
&>li {
@apply mb-1;
@apply text-tertiary;
span {
cursor: pointer;
display: block;
}
&:hover,
&.active {
@apply text-primary;
}
&.active {
&.category:before {
content: "";
width: 1px;
height: 1rem;
@apply bg-white block absolute;
left: 0.5rem;
}
ul.settings {
display: inline-block;
}
}
ul.settings {
position: unset;
@apply mt-2;
@apply ml-2;
@apply pl-3;
@apply text-xs;
@apply border-l;
@apply border-cards-tertiary;
display: none;
li {
cursor: pointer;
margin-top: 0;
}
}
}
}
}
.user-defined-value:before {
content: "";
height: 1rem;
@apply bg-blue block absolute rounded-full w-1 h-1;
top: 0.45rem;
left: -1rem;
}
.user-defined-value.category:before {
left: -2rem;
top: 0.35rem;
}

View File

@@ -0,0 +1,606 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ScrollDispatcher } from '@angular/cdk/overlay';
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
TrackByFunction,
ViewChildren,
} from '@angular/core';
import {
ConfigService,
ExpertiseLevelNumber,
PortapiService,
Setting,
StringSetting,
releaseLevelFromName,
} from '@safing/portmaster-api';
import { BehaviorSubject, Subscription, combineLatest } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { StatusService, Subsystem } from 'src/app/services';
import {
fadeInAnimation,
fadeInListAnimation,
fadeOutAnimation,
} from 'src/app/shared/animations';
import { FuzzySearchService } from 'src/app/shared/fuzzySearch';
import { ExpertiseLevelOverwrite } from '../expertise/expertise-directive';
import { SaveSettingEvent } from './generic-setting/generic-setting';
import { ActionIndicatorService } from '../action-indicator';
import { SfngDialogService } from '@safing/ui';
import {
ExportConfig,
ExportDialogComponent,
} from './export-dialog/export-dialog.component';
import {
ImportConfig,
ImportDialogComponent,
} from './import-dialog/import-dialog.component';
interface Category {
name: string;
settings: Setting[];
minimumExpertise: ExpertiseLevelNumber;
collapsed: boolean;
hasUserDefinedValues: boolean;
}
interface SubsystemWithExpertise extends Subsystem {
minimumExpertise: ExpertiseLevelNumber;
isDisabled: boolean;
hasUserDefinedValues: boolean;
}
@Component({
selector: 'app-settings-view',
templateUrl: './config-settings.html',
styleUrls: ['./config-settings.scss'],
animations: [fadeInAnimation, fadeOutAnimation, fadeInListAnimation],
})
export class ConfigSettingsViewComponent
implements OnInit, OnDestroy, AfterViewInit {
subsystems: SubsystemWithExpertise[] = [];
others: Setting[] | null = null;
settings: Map<string, Category[]> = new Map();
/** A list of all selected settings for export */
selectedSettings: { [key: string]: boolean } = {};
/** Whether or not we are currently in "export" mode */
exportMode = false;
activeSection = '';
activeCategory = '';
loading = true;
@Input()
resetLabelText = 'Reset to system default';
@Input()
set compactView(v: any) {
this._compactView = coerceBooleanProperty(v);
}
get compactView() {
return this._compactView;
}
private _compactView = false;
@Input()
set lockDefaults(v: any) {
this._lockDefaults = coerceBooleanProperty(v);
}
get lockDefaults() {
return this._lockDefaults;
}
private _lockDefaults = false;
@Input()
set userSettingsMarker(v: any) {
this._userSettingsMarker = coerceBooleanProperty(v);
}
get userSettingsMarker() {
return this._userSettingsMarker;
}
private _userSettingsMarker = true;
@Input()
set searchTerm(v: string) {
this.onSearch.next(v);
}
@Input()
set availableSettings(v: Setting[]) {
this.onSettingsChange.next(v);
}
@Input()
set scope(scope: 'global' | string) {
this._scope = scope;
}
get scope() {
return this._scope;
}
private _scope: 'global' | string = 'global';
@Input()
displayStackable: string | boolean = false;
@Input()
set highlightKey(key: string | null) {
this._highlightKey = key || null;
this._scrolledToHighlighted = false;
// If we already loaded the settings then instruct the window
// to scroll the setting into the view.
if (!!key && !!this.settings && this.settings.size > 0) {
this.scrollTo(key);
this._scrolledToHighlighted = true;
}
}
get highlightKey() {
return this._highlightKey;
}
private _highlightKey: string | null = null;
private _scrolledToHighlighted = false;
mustShowSetting: ExpertiseLevelOverwrite<Setting> = (
lvl: ExpertiseLevelNumber,
s: Setting
) => {
if (lvl >= s.ExpertiseLevel) {
// this setting is shown anyway.
return false;
}
if (s.Key === this.highlightKey) {
return true;
}
// the user is searching for settings so make sure we even show advanced or developer settings
if (this.onSearch.getValue() !== '') {
return true;
}
if (s.Value === undefined) {
// no value set
return false;
}
return true;
};
mustShowCategory: ExpertiseLevelOverwrite<Category> = (
lvl: ExpertiseLevelNumber,
cat: Category
) => {
return cat.settings.some((setting) => this.mustShowSetting(lvl, setting));
};
mustShowSubsystem: ExpertiseLevelOverwrite<SubsystemWithExpertise> = (
lvl: ExpertiseLevelNumber,
subsys: SubsystemWithExpertise
) => {
return !!this.settings
.get(subsys.ConfigKeySpace)
?.some((cat) => this.mustShowCategory(lvl, cat));
};
@Output()
save = new EventEmitter<SaveSettingEvent>();
private onSearch = new BehaviorSubject<string>('');
private onSettingsChange = new BehaviorSubject<Setting[]>([]);
@ViewChildren('navLink', { read: ElementRef })
navLinks: QueryList<ElementRef> | null = null;
private subscription = Subscription.EMPTY;
constructor(
public statusService: StatusService,
public configService: ConfigService,
private elementRef: ElementRef,
private changeDetectorRef: ChangeDetectorRef,
private scrollDispatcher: ScrollDispatcher,
private searchService: FuzzySearchService,
private actionIndicator: ActionIndicatorService,
private portapi: PortapiService,
private dialog: SfngDialogService
) { }
openImportDialog() {
const importConfig: ImportConfig = {
type: 'setting',
key: this.scope,
};
this.dialog.create(ImportDialogComponent, {
data: importConfig,
autoclose: false,
backdrop: 'light',
});
}
toggleExportMode() {
this.exportMode = !this.exportMode;
if (this.exportMode) {
this.actionIndicator.info(
'Settings Export',
'Please select all settings you want to export and press "Save" to generate the export. Note that settings with system defaults cannot be exported and are hidden.'
);
}
}
generateExport() {
let selectedKeys = Object.keys(this.selectedSettings).reduce((sum, key) => {
if (this.selectedSettings[key]) {
sum.push(key);
}
return sum;
}, [] as string[]);
if (selectedKeys.length === 0) {
selectedKeys = Array.from(this.settings.values()).reduce(
(sum, current) => {
current.forEach((cat) => {
cat.settings.forEach((s) => {
if (s.Value !== undefined) {
sum.push(s.Key);
}
});
});
return sum;
},
[] as string[]
);
}
this.portapi.exportSettings(selectedKeys, this.scope).subscribe({
next: (exportBlob) => {
const exportConfig: ExportConfig = {
type: 'setting',
content: exportBlob,
};
this.dialog.create(ExportDialogComponent, {
data: exportConfig,
backdrop: 'light',
autoclose: true,
});
this.exportMode = false;
},
error: (err) => {
const msg = this.actionIndicator.getErrorMessgae(err);
this.actionIndicator.error('Failed To Generate Export', msg);
},
});
}
saveSetting(event: SaveSettingEvent, s: Setting) {
this.save.next(event);
const subsys = this.subsystems.find(
(subsys) => s.Key === subsys.ToggleOptionKey
);
if (!!subsys) {
// trigger a reload of the page as we now might need to show more
// settings.
this.onSettingsChange.next(this.onSettingsChange.getValue());
}
}
trackSubsystem: TrackByFunction<SubsystemWithExpertise> =
this.statusService.trackSubsystem;
trackCategory(_: number, cat: Category) {
return cat.name;
}
ngOnInit(): void {
this.subscription = combineLatest([
this.onSettingsChange,
this.statusService.querySubsystem(),
this.onSearch.pipe(debounceTime(250)),
this.configService.watch<StringSetting>('core/releaseLevel'),
])
.pipe(debounceTime(10))
.subscribe(
([settings, subsystems, searchTerm, currentReleaseLevelSetting]) => {
this.subsystems = subsystems.map((s) => ({
...s,
// we start with developer and decrease to the lowest number required
// while grouping the settings.
minimumExpertise: ExpertiseLevelNumber.developer,
isDisabled: false,
hasUserDefinedValues: false,
}));
this.others = [];
this.settings = new Map();
// Get the current release level as a number (fallback to 'stable' is something goes wrong)
const currentReleaseLevel = releaseLevelFromName(
currentReleaseLevelSetting || ('stable' as any)
);
// Make sure we only display settings that are allowed by the releaselevel setting.
settings = settings.filter(
(setting) => setting.ReleaseLevel <= currentReleaseLevel
);
// Use fuzzy-search to limit the number of settings shown.
const filtered = this.searchService.searchList(settings, searchTerm, {
ignoreLocation: true,
ignoreFieldNorm: true,
threshold: 0.1,
minMatchCharLength: 3,
keys: [
{ name: 'Name', weight: 3 },
{ name: 'Description', weight: 2 },
],
});
// The search service wraps the items in a search-result object.
// Unwrap them now.
settings = filtered.map((res) => res.item);
// use order-annotations to sort the settings. This affects the order of
// the categories as well as the settings inside the categories.
settings.sort((a, b) => {
const orderA = a.Annotations?.['safing/portbase:ui:order'] || 0;
const orderB = b.Annotations?.['safing/portbase:ui:order'] || 0;
return orderA - orderB;
});
settings.forEach((setting) => {
let pushed = false;
this.subsystems.forEach((subsys) => {
if (
setting.Key.startsWith(
subsys.ConfigKeySpace.slice('config:'.length)
)
) {
// get the category name annotation and fallback to 'others'
let catName = 'other';
if (
!!setting.Annotations &&
!!setting.Annotations['safing/portbase:ui:category']
) {
catName = setting.Annotations['safing/portbase:ui:category'];
}
// ensure we have a category array for the subsystem.
let categories = this.settings.get(subsys.ConfigKeySpace);
if (!categories) {
categories = [];
this.settings.set(subsys.ConfigKeySpace, categories);
}
// find or create the appropriate category object.
let cat = categories.find((c) => c.name === catName);
if (!cat) {
cat = {
name: catName,
minimumExpertise: ExpertiseLevelNumber.developer,
settings: [],
collapsed: false,
hasUserDefinedValues: false,
};
categories.push(cat);
}
// add the setting to the category object and update
// the minimum expertise required for the category.
cat.settings.push(setting);
if (setting.ExpertiseLevel < cat.minimumExpertise) {
cat.minimumExpertise = setting.ExpertiseLevel;
}
pushed = true;
}
});
// if we did not push the setting to some subsystem
// we need to push it to "others"
if (!pushed) {
this.others!.push(setting);
}
});
if (this.others.length === 0) {
this.others = null;
}
// Reduce the subsystem array to only contain subsystems that
// actually have settings to show.
// Also update the minimumExpertiseLevel for those subsystems
this.subsystems = this.subsystems
.filter((subsys) => {
return !!this.settings.get(subsys.ConfigKeySpace);
})
.map((subsys) => {
let categories = this.settings.get(subsys.ConfigKeySpace)!;
let hasUserDefinedValues = false;
categories.forEach((c) => {
c.hasUserDefinedValues = c.settings.some(
(s) => s.Value !== undefined
);
hasUserDefinedValues =
c.hasUserDefinedValues || hasUserDefinedValues;
});
subsys.hasUserDefinedValues = hasUserDefinedValues;
let toggleOption: Setting | undefined = undefined;
for (let c of categories) {
toggleOption = c.settings.find(
(s) => s.Key === subsys.ToggleOptionKey
);
if (!!toggleOption) {
if (
(toggleOption.Value !== undefined && !toggleOption.Value) ||
(toggleOption.Value === undefined &&
!toggleOption.DefaultValue)
) {
subsys.isDisabled = true;
// remove all settings for all subsystem categories
// except for the ToggleOption.
categories = categories
.map((c) => ({
...c,
settings: c.settings.filter(
(s) => s.Key === toggleOption!.Key
),
}))
.filter((cat) => cat.settings.length > 0);
this.settings.set(subsys.ConfigKeySpace, categories);
}
break;
}
}
// reduce the categories to find the smallest expertise level requirement.
subsys.minimumExpertise = categories.reduce((min, current) => {
if (current.minimumExpertise < min) {
return current.minimumExpertise;
}
return min;
}, ExpertiseLevelNumber.developer as ExpertiseLevelNumber);
return subsys;
});
// Force the core subsystem to the end.
if (this.subsystems.length >= 2 && this.subsystems[0].ID === 'core') {
this.subsystems.push(
this.subsystems.shift() as SubsystemWithExpertise
);
}
// Notify the user interface that we're done loading
// the settings.
this.loading = false;
// If there's a highlightKey set and we have not yet scrolled
// to it (because it was set during component bootstrap) we
// need to scroll there now.
if (this._highlightKey !== null && !this._scrolledToHighlighted) {
this._scrolledToHighlighted = true;
// Use the next animation frame for scrolling
window.requestAnimationFrame(() => {
this.scrollTo(this._highlightKey || '');
});
}
}
);
}
ngAfterViewInit() {
this.subscription = new Subscription();
// Whenever our scroll-container is scrolled we might
// need to update which setting is currently highlighted
// in the settings-navigation.
this.subscription.add(
this.scrollDispatcher
.scrolled(10)
.subscribe(() => this.intersectionCallback())
);
// Also, entries in the settings-navigation might become
// visible with expertise/release level changes so make
// sure to recalculate the current one whenever a change
// happens.
this.subscription.add(
this.navLinks?.changes.subscribe(() => {
this.intersectionCallback();
this.changeDetectorRef.detectChanges();
})
);
}
ngOnDestroy() {
this.subscription.unsubscribe();
this.onSearch.complete();
}
/**
* Calculates which navigation entry should be highlighted
* depending on the scroll position.
*/
private intersectionCallback() {
// search our parents for the element that's scrollable
let elem: HTMLElement = this.elementRef.nativeElement;
while (!!elem) {
if (elem.scrollTop > 0) {
break;
}
elem = elem.parentElement!;
}
// if there's no scrolled/scrollable parent element
// our content itself is scrollable so use our own
// host element as the anchor for the calculation.
if (!elem) {
elem = this.elementRef.nativeElement;
}
// get the elements offset to page-top
var offsetTop = 0;
if (!!elem) {
const viewRect = elem.getBoundingClientRect();
offsetTop = viewRect.top;
}
this.navLinks?.some((link) => {
const subsystem = link.nativeElement.getAttribute('subsystem');
const category = link.nativeElement.getAttribute('category');
const lastChild = (link.nativeElement as HTMLElement)
.lastElementChild as HTMLElement;
if (!lastChild) {
return false;
}
const rect = lastChild.getBoundingClientRect();
const styleBox = getComputedStyle(lastChild);
const offset =
rect.top +
rect.height -
parseInt(styleBox.marginBottom) -
parseInt(styleBox.paddingBottom);
if (offset >= offsetTop) {
this.activeSection = subsystem;
this.activeCategory = category;
return true;
}
return false;
});
this.changeDetectorRef.detectChanges();
}
/**
* @private
* Performs a smooth-scroll to the given anchor element ID.
*
* @param id The ID of the anchor element to scroll to.
*/
scrollTo(id: string, cat?: Category) {
if (!!cat) {
cat.collapsed = false;
}
document.getElementById(id)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest',
});
}
}

View File

@@ -0,0 +1,77 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import {
SfngSelectModule,
SfngTipUpModule,
SfngToggleSwitchModule,
SfngTooltipModule,
} from '@safing/ui';
import { MarkdownModule } from 'ngx-markdown';
import { ExpertiseModule } from '../expertise/expertise.module';
import { SfngFocusModule } from '../focus';
import { SfngMenuModule } from '../menu';
import { SfngMultiSwitchModule } from '../multi-switch';
import { BasicSettingComponent } from './basic-setting/basic-setting';
import { ConfigSettingsViewComponent } from './config-settings';
import { FilterListComponent } from './filter-lists';
import { GenericSettingComponent } from './generic-setting';
import {
OrderedListComponent,
OrderedListItemComponent,
} from './ordererd-list';
import { RuleListItemComponent } from './rule-list/list-item';
import { RuleListComponent } from './rule-list/rule-list';
import { SafePipe } from './safe.pipe';
import { ExportDialogComponent } from './export-dialog/export-dialog.component';
import { ImportDialogComponent } from './import-dialog/import-dialog.component';
import { SfngAppIconModule } from '../app-icon';
@NgModule({
imports: [
CommonModule,
FormsModule,
DragDropModule,
SfngTooltipModule,
SfngSelectModule,
SfngMultiSwitchModule,
SfngFocusModule,
SfngMenuModule,
SfngTipUpModule,
FontAwesomeModule,
MarkdownModule,
RouterModule,
ExpertiseModule,
SfngToggleSwitchModule,
MarkdownModule,
SfngAppIconModule
],
declarations: [
BasicSettingComponent,
FilterListComponent,
OrderedListComponent,
OrderedListItemComponent,
RuleListComponent,
RuleListItemComponent,
ConfigSettingsViewComponent,
GenericSettingComponent,
SafePipe,
ExportDialogComponent,
ImportDialogComponent,
],
exports: [
BasicSettingComponent,
FilterListComponent,
OrderedListComponent,
OrderedListItemComponent,
RuleListComponent,
RuleListItemComponent,
ConfigSettingsViewComponent,
GenericSettingComponent,
SafePipe,
],
})
export class ConfigModule { }

View File

@@ -0,0 +1,19 @@
<header class="flex flex-row items-center justify-between mb-4">
<h1 class="text-sm font-light m-0">
{{ dialogRef.data.type === "setting" ? "Settings" : "Profile" }} Export
</h1>
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"
class="w-3 h-3 text-secondary hover:text-primary cursor-pointer" (click)="dialogRef.close()">
<path fill="currentColor"
d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z">
</path>
</svg>
</header>
<markdown lineNumbers [data]="content" emoji class="overflow-auto"></markdown>
<div class="flex flex-row justify-end gap-2 items-center">
<button (click)="copyToClipboard()">Copy To Clipboard</button>
<button (click)="download()">Save</button>
</div>

View File

@@ -0,0 +1,67 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
OnInit,
inject,
} from '@angular/core';
import { SFNG_DIALOG_REF, SfngDialogRef } from '@safing/ui';
import { ActionIndicatorService } from '../../action-indicator';
import { INTEGRATION_SERVICE } from 'src/app/integration';
export interface ExportConfig {
content: string;
type: 'setting' | 'profile';
}
@Component({
templateUrl: './export-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
@apply flex flex-col gap-2 overflow-hidden;
min-height: 24rem;
min-width: 24rem;
max-height: 40rem;
max-width: 40rem;
}
`,
],
})
export class ExportDialogComponent implements OnInit {
readonly dialogRef: SfngDialogRef<
ExportDialogComponent,
unknown,
ExportConfig
> = inject(SFNG_DIALOG_REF);
private readonly elementRef: ElementRef<HTMLElement> = inject(ElementRef);
private readonly document = inject(DOCUMENT);
private readonly uai = inject(ActionIndicatorService);
private readonly integration = inject(INTEGRATION_SERVICE);
content = '';
ngOnInit(): void {
this.content = '```yaml\n' + this.dialogRef.data.content + '\n```';
}
download() {
const blob = new Blob([this.dialogRef.data.content], { type: 'text/yaml' });
const elem = this.document.createElement('a');
elem.href = window.URL.createObjectURL(blob);
elem.download = 'export.yaml';
this.elementRef.nativeElement.appendChild(elem);
elem.click();
this.elementRef.nativeElement.removeChild(elem);
}
copyToClipboard() {
this.integration.writeToClipboard(this.dialogRef.data.content)
.then(() => this.uai.success('Copied to Clipboard'))
.catch(() => this.uai.error('Failed to Copy to Clipboard'));
}
}

View File

@@ -0,0 +1,55 @@
<div class="scrollable">
<ng-template #treeNode let-node>
<div class="node">
<div class="relative head">
<span *ngIf="node.hasSelectedChildren"
class="relative block w-1 h-1 rounded-full -left-2.5 -mr-1 -top-0.5 bg-blue"></span>
<input type="checkbox" [ngModel]="node.selected" (ngModelChange)="updateNode(node, $event)">
<label>
<span class="flex flex-row items-center gap-2 name">
{{node.name}}
<span class="id">({{ node.id }})</span>
</span>
<span class="description">{{ node.description }}</span>
</label>
<span class="details">
{{ !!node.license ? 'License: ' + node.license : '' }}
</span>
<span class="details">
<a *ngIf="!!node.website" href="{{node.website}}">
<fa-icon icon="external-link-square-alt"></fa-icon>
</a>
</span>
</div>
<div class="children" *ngIf="node.children.length > 0">
<div class="expand" (click)="node.expanded = !node.expanded">
<ng-container *ngIf="!node.expanded">
<fa-icon icon="chevron-right"></fa-icon>
Expand
</ng-container>
<ng-container *ngIf="node.expanded">
<fa-icon icon="chevron-down"></fa-icon>
Collapse
</ng-container>
</div>
</div>
<div class="children" *ngIf="node.children.length > 0"
[@moveInOutList]="(node.expanded ? node.children : []).length">
<div class="border" *ngIf="node.expanded"></div>
<ng-container *ngFor="let child of (node.expanded ? node.children : []); trackBy: trackNode">
<ng-container *ngTemplateOutlet="treeNode; context: {$implicit: child}"></ng-container>
</ng-container>
</div>
</div>
</ng-template>
<ng-container *ngFor="let node of nodes; trackBy: trackNode">
<ng-container *ngTemplateOutlet="treeNode; context: {$implicit: node}"></ng-container>
</ng-container>
</div>

View File

@@ -0,0 +1,101 @@
:host {
display: block;
overflow: hidden;
@apply bg-cards-secondary;
@apply rounded;
@apply p-2;
@apply h-full;
}
.node {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
@apply py-1;
.head {
display: flex;
flex-direction: row;
align-items: baseline;
input {
@apply mr-2;
position: relative;
top: 2px;
}
label {
display: flex;
flex-direction: column;
flex-grow: 1;
}
span.details {
opacity: 0;
text-transform: capitalize;
font-size: 0.9em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 6rem;
@apply text-tertiary;
}
&:hover {
span.details {
opacity: 1;
}
}
}
span.name {
@apply text-primary;
.id {
@apply text-tertiary;
font-style: italic;
}
}
.description {
position: relative;
top: -2px;
@apply text-tertiary;
}
div.expand {
cursor: pointer;
@apply text-secondary;
display: flex;
flex-direction: row;
align-items: center;
@apply pb-2;
fa-icon {
margin-right: 0.25rem;
}
}
.children {
display: flex;
flex-direction: column;
margin-left: 1.25rem;
}
.border {
position: absolute;
top: 1.2rem;
bottom: 0.5rem;
width: 0.7rem;
margin-left: -0.85rem;
border: 1px solid;
border-right: none;
border-top: none;
@apply border-cards-tertiary;
}
}

View File

@@ -0,0 +1,293 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostListener, OnDestroy, OnInit } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { PortapiService, Record } from '@safing/portmaster-api';
import { Subscription } from 'rxjs';
import { moveInOutListAnimation } from '../../animations';
interface Category {
name: string;
id: string;
description: string;
parent?: string | null;
}
interface Source {
name: string;
id: string;
description: string;
category: string;
// urls: Resource[]; // we don't care about the actual URLs here.
website: string;
contribute: string;
license: string;
}
interface FilterListIndex extends Record {
version: string;
schemaVersion: string;
categories: Category[];
sources: Source[];
}
interface TreeNode {
id: string;
name: string;
description: string;
children: TreeNode[];
expanded: boolean;
selected: boolean;
parent?: TreeNode;
website?: string;
license?: string;
hasSelectedChildren: boolean;
}
@Component({
selector: 'app-filter-list',
templateUrl: './filter-list.html',
styleUrls: ['./filter-list.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FilterListComponent),
multi: true,
}
],
animations: [
moveInOutListAnimation,
]
})
export class FilterListComponent implements OnInit, OnDestroy, ControlValueAccessor {
/** The actual filter-list index as loaded from the portmaster. */
private index: FilterListIndex | null = null;
/** @private a list of "tree-nodes" to render */
nodes: TreeNode[] = [];
/** A lookup map for fast ID to TreeNode lookups */
private lookupMap: Map<string, TreeNode> = new Map();
/** @private forward blur events to the onTouch callback. */
@HostListener('blur')
onBlur() {
this.onTouch();
}
/** The currently selected IDs. */
private selectedIDs: string[] = [];
/** Subscription to watch the filterlist index. */
private watchSubscription = Subscription.EMPTY;
constructor(private portapi: PortapiService,
private changeDetectorRef: ChangeDetectorRef) { }
ngOnInit() {
this.watchSubscription =
this.portapi.watch<FilterListIndex>("cache:intel/filterlists/index")
.subscribe(
index => this.updateIndex(index),
err => {
// Filter list index not yet loaded.
console.error(`failed to get fitlerlist index`, err);
}
);
}
ngOnDestroy() {
this.watchSubscription.unsubscribe();
}
/** The onChange callback registered by ngModel or form controls */
private _onChange: (v: string[]) => void = () => { };
/** Registers the onChange callback required by ControlValueAccessor */
registerOnChange(fn: (v: string[]) => void) {
this._onChange = fn;
}
/** The _onTouch callback registered by ngModel and form controls */
private onTouch: () => void = () => { };
/** Registeres the onTouch callback required by ControlValueAccessor. */
registerOnTouched(fn: () => void) {
this.onTouch = fn;
}
/**
* Update the currently selected IDs. Used by ngModel
* and form controls. Implements ControlValueAccessor.
*
* @param ids A list of selected IDs
*/
writeValue(ids: string[]) {
this.selectedIDs = ids;
if (!!this.index) {
this.updateIndex(this.index);
}
}
/**
*
* @param index The filter list index.
*/
private updateIndex(index: FilterListIndex) {
this.index = index;
var nodes: TreeNode[] = [];
let lm = new Map<string, TreeNode>();
let childCategories: Category[] = [];
// Create a tree-node for each category
this.index.categories.forEach(category => {
let tn: TreeNode = {
id: category.id,
description: category.description,
name: category.name,
children: [],
expanded: this.lookupMap.get(category.id)?.expanded || false, // keep it expanded if the user did not change anything.
selected: false,
hasSelectedChildren: false,
};
lm.set(category.id, tn)
// if the category does not have a parent
// it's a root node.
if (!category.parent) {
nodes.push(tn);
} else {
// we need to handle child-categories later.
childCategories.push(category);
}
});
// iterate over all "child" categories and add
// them to the correct parent (which must be in lm already.)
childCategories.forEach(category => {
const tn = lm.get(category.id)!;
const parent = lm.get(category.parent!);
// if the parent category does not exist ignore it
if (!parent) {
return;
}
parent.children.push(tn);
tn.parent = parent;
});
this.index.sources.forEach(source => {
let category = lm.get(source.category);
if (!category) {
return;
}
let tn: TreeNode = {
id: source.id,
name: source.name,
description: source.description,
children: [],
expanded: false,
selected: false,
parent: category,
website: source.website,
license: source.license,
hasSelectedChildren: false
}
// Add the source to the lookup-map
lm.set(source.id, tn);
category.children.push(tn);
});
// make sure we expand all parent categories for
// all selected IDs so they are actually visible.
this.selectedIDs.forEach(id => {
const tn = lm.get(id);
if (!tn) {
return;
}
this.updateNode(tn, true, true, true, false);
let parent = tn.parent;
while (!!parent) {
parent.expanded = true;
parent.hasSelectedChildren = true;
parent = parent.parent;
}
});
this.nodes = nodes;
this.lookupMap = lm;
this.changeDetectorRef.markForCheck();
}
/** Returns all actually selected IDs. */
private getIDs() {
let ids: string[] = [];
let collectIds = (n: TreeNode) => {
if (n.selected) {
// If the parent is selected we can ignore the
// childs because they must be selected as well.
ids.push(n.id);
return;
}
n.children.forEach(child => collectIds(child));
}
this.nodes.forEach(node => collectIds(node))
return ids;
}
updateNode(node: TreeNode, selected: boolean, updateChildren = true, updateParents = true, emit = true) {
if (node.selected === selected) {
// Nothing changed
return;
}
// update the node an all children
node.selected = selected;
if (updateChildren) {
node.children.forEach(child => this.updateNode(child, selected, true, false, false));
}
// if we have a parent we might need to update
// the parent as well.
if (!!node.parent && updateParents) {
if (selected) {
// if we are now selected we might need to "select" the
// parent if all children are selected now.
const hasUnselected = node.parent.children.some(sibling => !sibling.selected);
if (!hasUnselected) {
// We need to update all parents but updating children
// is useless.
this.updateNode(node.parent, true, false, true, false);
}
} else if (node.parent.selected) {
// if we are unselected now we might need to "unselect" the parent
// but select siblings directly
const selectedSiblings = node.parent.children.filter(sibling => sibling.selected && sibling !== node);
this.updateNode(node.parent, false, false, true, false)
}
}
if (emit) {
const ids = this.getIDs();
this.selectedIDs = ids;
this._onChange(this.selectedIDs);
}
}
/** @private TrackByFunction for tree nodes. */
trackNode(_: number, node: TreeNode) {
return node.id;
}
}

View File

@@ -0,0 +1 @@
export { FilterListComponent } from './filter-list';

View File

@@ -0,0 +1,204 @@
<div
class="relative flex flex-row flex-wrap items-center h-full px-5 py-5 bg-gray-200 border-l border-transparent rounded-r"
[class.pr-8]="lockDefaults" [ngClass]="{
'border-blue': enableActiveBorder && _setting?.Value !== undefined && !rejected,
'border-red': enableActiveBorder && rejected,
'border-yellow-300': selected,
'rounded-l': !rejected && _setting?.Value === undefined,
'hidden': selectMode && !userConfigured
}">
<input class="absolute -left-5 my-auto" type="checkbox" *ngIf="selectMode && userConfigured" [(ngModel)]="selected"
(ngModelChange)="selectedChange.next($event)">
<div class="flex flex-col flex-grow">
<div class="flex flex-row items-center justify-start space-x-2 w-fit" *ngIf="showHeader">
<h3 [innerHTML]="setting?.Name | safe:'html'" class="mb-0 name"></h3>
<sfng-tipup *ngIf="setting?.Description" [key]="setting!.Key" [text]="setting?.Description"
[buttons]="sfngTipUpButtons" [title]="setting?.Name"></sfng-tipup>
<span *ngIf="changeAccepted || restartPending || (changeAccepted && uiReloadRequired)" (click)="restartNow()"
class="px-1.5 py-0.5 border rounded inline-flex justify-evenly items-center text-xxs mb-0.5" [ngClass]="{
'border-green-300 text-green-300': !_setting?.RequiresRestart,
'border-yellow text-yellow cursor-pointer hover:bg-yellow hover:text-gray-200': _setting?.RequiresRestart
}" [@fadeIn]>
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-4 h-4 mr-1" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
Saved {{ _setting?.RequiresRestart ? ' - Restart required' : (uiReloadRequired ? ' - Reload required' : '') }}
</span>
<span *ngIf="rejected" (click)="abortChange()"
class="px-1.5 py-0.5 border-red-300 border text-red-300 rounded inline-flex justify-evenly items-center text-xxs hover:bg-red hover:text-white mb-0.5 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-4 h-4 mr-1" *ngIf="rejected" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Invalid Value: {{ rejected }}
</span>
<span *ngIf="_upgradeRequired" (click)="openAccountDetails()"
class="px-1.5 py-0.5 border-red-300 border text-red-300 rounded inline-flex justify-evenly items-center text-xxs hover:bg-red hover:text-white mb-0.5 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
This feature requires a subscription.
</span>
</div>
<span class="flex flex-row items-center justify-start space-x-2" *ngIf="setting?.ReleaseLevel !== releaseLevel.Stable ||
setting?.ExpertiseLevel !== expertise.user ||
(expertiseLevel|async) === expertiseNames.Developer">
<span class="inline-block px-1.5 py-0.5 bg-gray-400 rounded text-xxs text-secondary"
*appExpertiseLevel="'developer'">{{setting?.Key}}</span>
<span class="inline-block px-1.5 py-0.5 text-gray-100 bg-yellow-300 rounded text-xxs"
*ngIf="setting?.ReleaseLevel === releaseLevel.Beta">Beta</span>
<span class="inline-block px-1.5 py-0.5 text-white bg-red-300 rounded text-xxs"
*ngIf="setting?.ReleaseLevel === releaseLevel.Experimental">Experimental</span>
<span class="inline-block px-1.5 py-0.5 bg-gray-400 rounded text-xxs text-secondary"
*ngIf="setting?.ExpertiseLevel === expertise.expert">Advanced</span>
<span class="inline-block px-1.5 py-0.5 text-gray-100 bg-yellow-300 rounded text-xxs"
*ngIf="setting?.ExpertiseLevel === expertise.developer">Developer</span>
</span>
</div>
<!-- Quick Settings -->
<div *ngIf="(quickSettings || []).length > 0 && !disabled">
<app-menu-trigger [menu]="quickSettingsMenu" useContent="true" class="text-secondary hover:text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
<span class="ml-1 text-xs">Quick Settings</span>
</app-menu-trigger>
<app-menu #quickSettingsMenu>
<app-menu-item *ngFor="let quick of quickSettings" (click)="applyQuickSetting(quick)">
{{quick.Name}}
</app-menu-item>
</app-menu>
</div>
<!-- Actual settings input -->
<ng-container [ngSwitch]="externalOptType(setting)">
<!-- Rule lists -->
<ng-container *ngSwitchCase="optionHint.EndpointList">
<app-rule-list class="w-full mt-4" [readonly]="disabled" [ngModel]="_currentValue"
(ngModelChange)="updateValue($event, true)" [symbolMap]="symbolMap"></app-rule-list>
<div class="stacked-values" *ngIf="showStackable">
<h4>This setting stacks on top of the following <a class="underline text-tertiary hover:text-primary"
routerLink="/settings" [queryParams]="{setting: setting?.Key}">global setting</a>:</h4>
<app-rule-list class="w-full mt-4" [readonly]="true" [symbolMap]="symbolMap" [ngModel]="setting?.GlobalDefault">
</app-rule-list>
</div>
</ng-container>
<!-- Filter lists -->
<ng-container *ngSwitchCase="optionHint.FilterList">
<app-filter-list class="w-full" [class.mt-4]="showHeader" [ngModel]="_currentValue"
(ngModelChange)="updateValue($event, true)">
</app-filter-list>
</ng-container>
<!-- Ordered string lists -->
<ng-container *ngSwitchCase="optionHint.OrderedList">
<app-ordered-list class="w-full mt-4" [ngModel]="_currentValue" (ngModelChange)="updateValue($event, true)"
[readonly]="disabled"></app-ordered-list>
<div class="stacked-values" *ngIf="showStackable">
<h4>This setting stacks on top of the following <a class="underline text-tertiary hover:text-primary"
routerLink="/settings" [queryParams]="{setting: setting?.Key}">global setting</a>:</h4>
<app-ordered-list class="w-full mt-4" [ngModel]="setting?.GlobalDefault" [readonly]="true"></app-ordered-list>
</div>
</ng-container>
<!-- Default if no display hint is given -->
<ng-container *ngSwitchDefault>
<!-- basic string array with fixed order on optional stacking -->
<ng-container *ngIf="setting?.OptType === optionType.StringArray; else: basicSetting">
<app-ordered-list class="w-full mt-4" fixedOrder="true" [ngModel]="_currentValue"
(ngModelChange)="updateValue($event, true)" [readonly]="disabled">
</app-ordered-list>
<div class="stacked-values" *ngIf="showStackable">
<h4>This setting stacks on top of the following <a class="underline text-tertiary hover:text-primary"
routerLink="/settings" [queryParams]="{setting: setting?.Key}">global setting</a>:</h4>
<app-ordered-list class="w-full mt-4" fixedOrder="true" [ngModel]="setting?.GlobalDefault" [readonly]="true">
</app-ordered-list>
</div>
</ng-container>
<ng-template #basicSetting>
<!-- basic inputs -->
<app-basic-setting class="block" [setting]="_setting" [disabled]="disabled" [ngModel]="_currentValue"
(ngModelChange)="updateValue($event)"
(blured)="updateValue(_basicSettingsValueCache!, _currentValue !== _basicSettingsValueCache)">
</app-basic-setting>
</ng-template>
</ng-container>
</ng-container>
<span class="unlock-button" [class.bg-blue]="!isLocked" [class.bg-gray-500]="isLocked" *ngIf="lockDefaults"
snfgTooltipPosition="left" [sfng-tooltip]="lockTooltip" (click)="toggleLock()">
<svg viewBox="0 0 24 24" class="w-4 h-4 text-white" stroke="currentColor" *ngIf="isLocked">
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="inner">
<path shape-rendering="geometricPrecision"
d="M13.7678 10.2322c.976311.976311.976311 2.55922 0 3.53553-.976311.976311-2.55922.976311-3.53553 0-.976311-.976311-.976311-2.55922 0-3.53553.976311-.976311 2.55922-.976311 3.53553 0" />
<path shape-rendering="geometricPrecision"
d="M14.849 4.12l.583.194c.534.178.895.678.895 1.241v.837c0 .712.568 1.293 1.28 1.308l.838.018c.485.01.925.289 1.142.723l.275.55c.252.504.153 1.112-.245 1.51l-.592.592c-.503.503-.512 1.316-.02 1.83l.58.606c.336.351.45.858.296 1.319l-.194.583c-.178.534-.678.895-1.241.895h-.837c-.712 0-1.293.568-1.308 1.28l-.018.838c-.01.485-.289.925-.723 1.142l-.55.275c-.504.252-1.112.153-1.51-.245l-.592-.592c-.503-.503-1.316-.512-1.83-.02l-.606.58c-.351.336-.858.45-1.319.296l-.583-.194c-.534-.178-.895-.678-.895-1.241v-.837c0-.712-.568-1.293-1.28-1.308l-.838-.018c-.485-.01-.925-.289-1.142-.723l-.275-.55c-.252-.504-.153-1.112.245-1.51l.592-.592c.503-.503.512-1.316.02-1.83l-.58-.606c-.337-.352-.451-.86-.297-1.32l.194-.583c.178-.534.678-.895 1.241-.895h.837c.712 0 1.293-.568 1.308-1.28l.018-.838c.012-.485.29-.925.724-1.142l.55-.275c.504-.252 1.112-.153 1.51.245l.592.592c.503.503 1.316.512 1.83.02l.606-.58c.351-.335.859-.449 1.319-.295z" />
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 24 24" class="w-4 h-4 text-white"
fill="none" stroke=" currentColor" *ngIf="!isLocked">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" fill="currentColor"
d="M19 21h-3a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2Z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9h-3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2ZM5 3h3a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2ZM5 15h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2Z" />
</svg>
</span>
<ng-template #lockTooltip>
<ng-template [ngIf]="isLocked" [ngIfElse]="unlockedTooltip">
Inherited from <a [routerLink]="['/settings']" [queryParams]="{setting: setting?.Key}"
class="cursor-pointer hover:underline">Global Settings</a>
</ng-template>
<ng-template #unlockedTooltip>
App specific configuration
</ng-template>
</ng-template>
</div>
<div *ngIf="userConfigured" class="flex justify-end mt-2" [@fadeIn] [@fadeOut]>
<span class="cursor-pointer text-tertiary hover:text-yellow" tabindex="0" (click)="resetValue()">
{{resetLabelText}}
</span>
</div>
<ng-template #helpTemplate>
<div class="relative flex flex-col overflow-hidden max-w-1/3vw">
<fa-icon class="absolute top-0 right-0 mt-2 mr-2 opacity-50 cursor-pointer hover:opacity" icon="times"
(click)="closeHelpDialog()"></fa-icon>
<h1>{{ _setting?.Name }}</h1>
<markdown emoji [data]="_setting?.Help" class="flex-grow block overflow-auto"></markdown>
</div>
</ng-template>

View File

@@ -0,0 +1,97 @@
:host {
@apply block;
&.ng-invalid {
@apply border border-red border-opacity-50;
}
&.rejected {
.release-level.rejected {
opacity: 1;
}
}
&.highlighted:not(.touched) {
.name {
animation: fade-color 5s ease-out;
}
}
}
.stacked-values {
margin-top: 0.5rem;
opacity: 0.7;
@apply w-full;
}
.unlock-button {
@apply flex w-6 h-6 rounded-full;
justify-content: center;
align-items: center;
cursor: pointer;
position: absolute;
right: calc(-1.5rem/2);
top: calc(50% - 1.5rem/2);
&:hover {
@apply bg-blue;
}
}
.description,
.help-text {
display: block;
@apply text-secondary;
}
.help-text {
@apply mb-2;
}
.notice {
display: block;
padding-left: 0.5rem;
padding-right: 0.5rem;
@apply mb-4;
@apply text-secondary;
fa-icon {
@apply mr-2;
}
}
.help-text {
@apply p-4;
@apply bg-cards-secondary;
@apply rounded;
.toggle {
position: relative;
left: -0.25rem;
cursor: pointer;
fa-icon {
@apply pr-1;
}
&:hover {
@apply text-primary;
}
}
}
@keyframes fade-color {
0% {
@apply text-blue;
}
90% {
@apply text-blue;
}
100% {
@apply text-primary;
}
}

View File

@@ -0,0 +1,715 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { TemplatePortal } from '@angular/cdk/portal';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, HostBinding, Input, OnInit, Output, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgModel } from '@angular/forms';
import { BaseSetting, ConfigService, ExpertiseLevel, ExpertiseLevelNumber, ExternalOptionHint, OptionType, PortapiService, QuickSetting, ReleaseLevel, SPNService, SettingValueType, UserProfile, WellKnown, applyQuickSetting } from '@safing/portmaster-api';
import { SfngDialogRef, SfngDialogService } from '@safing/ui';
import { Button } from 'js-yaml-loader!../../../i18n/helptexts.yaml';
import { Subject } from 'rxjs';
import { debounceTime, tap } from 'rxjs/operators';
import { ActionIndicatorService } from '../../action-indicator';
import { fadeInAnimation, fadeOutAnimation } from '../../animations';
import { ExpertiseService } from '../../expertise/expertise.service';
import { SPNAccountDetailsComponent } from '../../spn-account-details';
export interface SaveSettingEvent<S extends BaseSetting<any, any> = any> {
key: string;
value: SettingValueType<S>;
isDefault: boolean;
rejected?: (err: any) => void
accepted?: () => void
}
@Component({
selector: 'app-generic-setting',
templateUrl: './generic-setting.html',
exportAs: 'appGenericSetting',
styleUrls: ['./generic-setting.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeInAnimation,
fadeOutAnimation
]
})
export class GenericSettingComponent<S extends BaseSetting<any, any>> implements OnInit {
//
// Constants used in the template.
//
readonly optionHint = ExternalOptionHint;
readonly expertiseNames = ExpertiseLevel
readonly expertise = ExpertiseLevelNumber;
readonly optionType = OptionType;
readonly releaseLevel = ReleaseLevel;
readonly wellKnown = WellKnown;
@ViewChild('helpTemplate', { read: TemplateRef, static: true })
helpTemplate: TemplateRef<any> | null = null;
private helpDialogRef: SfngDialogRef<any> | null = null;
// Whether or not the user needs to upgrade his/her account before
// this setting is valid.
_upgradeRequired = false;
/**
* Whether or not the component/setting is disabled and should
* be read-only.
*/
@Input()
@HostBinding('class.disabled')
set disabled(v: any) {
this._disabled = coerceBooleanProperty(v);
}
get disabled() {
return this._disabled || this._upgradeRequired;
}
private _disabled: boolean = false;
/** Returns the symbolMap annoation for endpoint-lists */
get symbolMap() {
return this.setting?.Annotations[WellKnown.EndpointListVerdictNames] || {
'+': 'Allow',
'-': 'Block'
};
}
/** Whether or not the setting should be in select mode */
@Input()
set selectMode(v: any) {
this._selectMode = coerceBooleanProperty(v)
if (!this.selectMode) {
this.selected = false;
this.selectedChange.next(false);
}
}
get selectMode() { return this._selectMode }
private _selectMode = false;
/** Whether or not the setting has been selected */
@Input()
set selected(v: any) {
this._selected = coerceBooleanProperty(v)
}
get selected() { return this._selected }
private _selected = false;
/** Emits when the user (de-) selectes the setting. Can be used for two-way binding */
@Output()
selectedChange = new EventEmitter<boolean>();
/** Controls whether or not header with the setting name and success/failure markers is shown */
@Input()
set showHeader(v: any) {
this._showHeader = coerceBooleanProperty(v);
}
get showHeader() { return this._showHeader }
private _showHeader = true;
/** Controls whether or not the blue or red status borders are shown */
@Input()
set enableActiveBorder(v: any) {
this._enableActiveBorder = coerceBooleanProperty(v);
}
get enableActiveBorder() { return this._enableActiveBorder }
private _enableActiveBorder = true;
/**
* Whether or not the component should be displayed as "locked"
* when the default value is used (that is, no 'Value' property
* in the setting)
*/
@Input()
set lockDefaults(v: any) {
this._lockDefaults = coerceBooleanProperty(v);
}
get lockDefaults() {
return this._lockDefaults;
}
private _lockDefaults: boolean = false;
/** The label to display in the reset-value button */
@Input()
resetLabelText = 'Reset';
/** Emits an event whenever the setting should be saved. */
@Output()
save = new EventEmitter<SaveSettingEvent<S>>();
/** Wether or not stackable values should be displayed. */
@Input()
set displayStackable(v: any) {
this._displayStackable = coerceBooleanProperty(v);
}
get displayStackable() {
return this._displayStackable;
}
private _displayStackable = false;
/**
* Whether or not the help text is currently shown
*/
@Input()
set showHelp(v: any) {
this._showHelp = coerceBooleanProperty(v);
}
get showHelp() {
return this._showHelp;
}
private _showHelp = false;
/** Used internally to publish save events. */
private triggerSave = new Subject<void>();
/** Whether or not the value was reset. */
wasReset = false;
/** Whether or not a save request was rejected */
@HostBinding('class.rejected')
get rejected() {
return this._rejected;
}
private _rejected = null;
@HostBinding('class.saved')
get changeAccepted() {
return this._changeAccepted;
}
private _changeAccepted = false;
/**
* @private
* Returns the external option type hint from a setting.
*
* @param opt The setting for with to return the external option hint
*/
externalOptType(opt: S | null): ExternalOptionHint | null {
return opt?.Annotations?.[WellKnown.DisplayHint] || null;
}
/**
* @private
* Returns whether or not a restart is pending for this setting
* to apply.
*/
get restartPending(): boolean {
return !!this._setting?.Annotations?.[WellKnown.RestartPending];
}
/**
* @private
* Returns whether or not a UI reload is required for this setting
* to apply
*/
get uiReloadRequired(): boolean {
return this._setting?.Annotations?.[WellKnown.RequiresUIReload] !== undefined;
}
/**
* Returns true if the setting has been touched (modified) by the user
* since the component has been rendered.
*/
@HostBinding('class.touched')
get touched() {
return this._touched;
}
private _touched = false;
/**
* Returns true if the settings is currently locked.
*/
@HostBinding('class.locked')
get isLocked() {
return (this.wasReset || !this.userConfigured) && this.lockDefaults;
}
/**
* Returns true if the user has configured the setting on their
* own or if the default value is being used.
*/
@HostBinding('class.changed')
get userConfigured() {
return this.setting?.Value !== undefined;
}
/**
* Returns true if the setting is dirty. That is, the user
* has changed the setting in the view but it has not yet
* been saved.
*/
@HostBinding('class.dirty')
get dirty() {
if (typeof this._currentValue !== 'object') {
return this._currentValue !== this._savedValue;
}
// JSON object (OptionType.StringArray) require will
// not be the same reference so we need to compare their
// string representations. That's a bit more costly but should
// still be fast enough.
// TODO(ppacher): calculate this only when required.
return JSON.stringify(this._currentValue) !== JSON.stringify(this._savedValue)
}
/**
* Returns true if the setting is pristine. That is, the
* settings default value is used and the user has not yet
* changed the value inside the view.
*/
@HostBinding('class.pristine')
get pristine() {
return !this.dirty && !this.userConfigured
}
/** A list of buttons for the tip-up */
sfngTipUpButtons: Button[] = [];
/**
* Unlock the setting if it is locked. Unlocking will
* emit the default value to be safed for the setting.
*/
unlock() {
if (!this.isLocked || !this.setting) {
return;
}
this._touched = true;
this.wasReset = false;
let value = this.defaultValue;
if (this.stackable) {
// TODO(ppacher): fix this one once string[] options can be
// stackable
value = [] as SettingValueType<S>;
}
this.updateValue(value, true);
// update the settings value now so the UI
// responds immediately.
this.setting!.Value = value;
}
/** True if the current setting is stackable */
get stackable() {
return !!this.setting?.Annotations[WellKnown.Stackable];
}
/** Wether or not stackable values should be shown right now */
get showStackable() {
return this.stackable && this.displayStackable;
}
/**
* @private
* Toggle Whether or not the help text is displayed
*/
toggleHelp() {
this.showHelp = !this.showHelp;
}
/**
* @private
* Toggle Whether or not the setting is currently locked.
*/
toggleLock() {
if (this.isLocked) {
this.unlock();
return;
}
this.resetValue();
}
/**
* @private
* Closes the help dialog.
*/
closeHelpDialog() {
this.helpDialogRef?.close();
}
@ViewChild(NgModel, { static: false })
model: NgModel | null = null;
/**
* The actual setting that should be managed.
* The setter also updates the "currently" used
* value (which is either user configured or
* the default). See {@property userConfigured}.
*/
@Input()
set setting(s: S | null) {
this.sfngTipUpButtons = [];
this._setting = s;
if (!s) {
this._currentValue = null;
return;
}
if (this._setting?.Help) {
this.sfngTipUpButtons = [
{
name: 'Show More',
action: {
ID: '',
Text: '',
Type: 'ui',
Run: async () => {
if (!this.helpTemplate) {
return;
}
// close any existing help dialog for THIS setting.
if (!!this.helpDialogRef) {
this.helpDialogRef.close();
}
// Create a new dialog form the helpTemplate
const portal = new TemplatePortal(this.helpTemplate, this.viewRef);
const ref = this.dialog.create(portal, {
// we don't use a backdrop and make the dialog dragable so the user can
// move it somewhere else and keep it open while configuring the setting.
backdrop: false,
dragable: true,
});
// make sure we reset the helpDialogRef to null once it get's clsoed.
this.helpDialogRef = ref;
this.helpDialogRef.onClose.subscribe(() => {
// but only if helpDialogRef still points to the same
// dialog reference. Otherwise we got closed because the user
// opened a new one and helpDialogRef already points to the new
// dialog.
if (this.helpDialogRef === ref) {
this.helpDialogRef = null;
}
});
},
Payload: undefined,
},
},
]
}
this.updateActualValue();
}
get setting(): S | null {
return this._setting;
}
/**
* The defaultValue input allows to overwrite the default
* value of the setting.
*/
@Input()
set defaultValue(val: SettingValueType<S>) {
this._defaultValue = val;
this.updateActualValue();
}
get defaultValue() {
// Return cached value.
if (this._defaultValue !== null) {
return this._defaultValue;
}
// Stackable options are displayed differently.
if (this.stackable) {
if (this.setting?.GlobalDefault === undefined && this.setting?.DefaultValue !== null) {
return this.setting?.DefaultValue;
}
return [] as SettingValueType<S>;
}
// Return global, then default value.
if (this.setting?.GlobalDefault !== undefined) {
return this.setting.GlobalDefault
}
return this.setting?.DefaultValue
}
/* An optional default value overwrite */
_defaultValue: SettingValueType<S> | null = null;
/* Whether or not the setting has been saved */
saved = true;
/* The settings value, updated by the setting() setter */
_setting: S | null = null;
/* The currently configured value. Updated by the setting() setter */
_currentValue: SettingValueType<S> | null = null;
/* The currently saved value. Updated by the setting() setter */
_savedValue: SettingValueType<S> | null = null;
/* Used to cache the value of a basic-setting because we only want to save that on blur */
_basicSettingsValueCache: SettingValueType<S> | null = null
/** Whether or not the network rating system is enabled. */
networkRatingEnabled$ = this.configService.networkRatingEnabled$;
get expertiseLevel() {
return this.expertiseService.change;
}
constructor(
private expertiseService: ExpertiseService,
private configService: ConfigService,
private portapi: PortapiService,
private dialog: SfngDialogService,
private changeDetectorRef: ChangeDetectorRef,
private actionIndicator: ActionIndicatorService,
private spn: SPNService,
private viewRef: ViewContainerRef,
private destryoRef: DestroyRef,
) { }
ngOnInit() {
this.triggerSave
.pipe(
debounceTime(500),
takeUntilDestroyed(this.destryoRef),
)
.subscribe(() => this.emitSaveRequest())
// watch the SPN user profile so we know which feature_ids
// are available for the user.
this.spn.profile$
.pipe(takeUntilDestroyed(this.destryoRef))
.subscribe((profile: UserProfile | null) => {
let value = this.setting?.Annotations[WellKnown.RequiresFeatureID]
if (value === undefined) {
this._upgradeRequired = false;
} else {
if (!Array.isArray(value)) {
value = [value];
}
this._upgradeRequired = value.some(val => !(profile?.current_plan?.feature_ids || []).includes(val))
}
this.changeDetectorRef.markForCheck();
})
}
/**
* @private
* Resets the value of setting by discarding any user
* configured values and reverting back to the default
* value.
*/
resetValue() {
if (!this._setting) {
return;
}
this._touched = true;
this._currentValue = this.defaultValue;
this.wasReset = true;
this.triggerSave.next();
}
/**
* @private
* Aborts/reverts the current change to the value that's
* already saved.
*/
abortChange() {
this._currentValue = this._savedValue;
this._touched = true;
this._rejected = null;
}
/**
* @private
* Update the current value by applying a quick-setting.
*
* @param qs The quick-settting to apply
*/
applyQuickSetting(qs: QuickSetting<SettingValueType<S>>) {
if (this.disabled) {
return;
}
const value = applyQuickSetting(this._currentValue, qs);
if (value === null) {
return;
}
this.updateValue(value, true);
}
openAccountDetails() {
this.dialog.create(SPNAccountDetailsComponent, {
autoclose: true,
backdrop: 'light'
})
}
restartNow() {
if (this._setting?.RequiresRestart) {
this.dialog.confirm({
header: 'Restart Portmaster',
message: 'Do you want to restart the Portmaster now?',
buttons: [
{
id: 'no',
text: 'Maybe Later',
class: 'outline',
},
{
id: 'restart',
text: 'Restart',
class: 'danger'
}
]
})
.onAction('restart', () =>
this.portapi.restartPortmaster()
.subscribe(this.actionIndicator.httpObserver(
'Restarting ...',
'Failed to Restart',
))
)
.onAction('no', () => {
this._changeAccepted = false;
this.changeDetectorRef.markForCheck();
});
return;
}
if (this.uiReloadRequired) {
this.portapi.reloadUI()
.pipe(
tap(() => {
setTimeout(() => window.location.reload(), 1000)
})
)
.subscribe(this.actionIndicator.httpObserver(
'Reloading UI ...',
'Failed to Reload UI',
))
}
}
/**
* Emits a save request to the parent component.
*/
private _saveInterval: any;
private emitSaveRequest() {
const isDefault = this.wasReset;
let value = this._setting!['Value'];
if (isDefault) {
delete (this._setting!['Value']);
} else {
this._setting!.Value = this._currentValue;
}
let wasReset = this.wasReset;
this.wasReset = false;
this._rejected = null;
this._changeAccepted = false;
if (!!this._saveInterval) {
clearTimeout(this._saveInterval);
}
this.save.next({
key: this.setting!.Key,
isDefault: isDefault,
value: this._setting!.Value,
rejected: (err: any) => {
this._setting!['Value'] = value;
this._rejected = err;
this.changeDetectorRef.markForCheck();
},
accepted: () => {
if (!wasReset) {
this._changeAccepted = true;
// if no restart is required fade the "✔️ Saved" out after
// a few seconds.
if (!this._setting?.RequiresRestart) {
this._saveInterval = setTimeout(() => {
this._changeAccepted = false;
this._saveInterval = null;
this.changeDetectorRef.markForCheck();
}, 4000);
}
}
this.changeDetectorRef.markForCheck();
}
})
}
/**
* @private
* Used in our view as a ngModelChange callback to
* update the value.
*
* @param value The new value as emitted by the view
*/
updateValue(value: SettingValueType<S>, save = false) {
this._touched = true;
this._changeAccepted = false;
this._rejected = null;
if (!!this._saveInterval) {
clearTimeout(this._saveInterval);
}
if (save) {
this._currentValue = value;
this.triggerSave.next();
} else {
this._basicSettingsValueCache = value;
}
}
/**
* @private
* A list of quick-settings available for the setting.
* The getter makes sure to always return an array.
*/
get quickSettings(): QuickSetting<SettingValueType<S>>[] {
if (!this.setting || !this.setting.Annotations[WellKnown.QuickSetting]) {
return [];
}
const quickSettings = this.setting.Annotations[WellKnown.QuickSetting]!;
return Array.isArray(quickSettings)
? quickSettings
: [quickSettings];
}
/**
* Determine the current, actual value of the setting
* by taking the settings Value, default Value or global
* default into account.
*/
private updateActualValue() {
if (!this.setting) {
return
}
this.wasReset = false;
const s = this.setting;
const value = s.Value === undefined
? this.defaultValue
: s.Value;
this._currentValue = value;
this._savedValue = value;
this._basicSettingsValueCache = value;
}
}

View File

@@ -0,0 +1 @@
export * from './generic-setting';

View File

@@ -0,0 +1,90 @@
// Credit to Liam (Stack Overflow)
// https://stackoverflow.com/a/41034697/3480193
export class Cursor {
static getCurrentCursorPosition(parentElement: Node) {
var selection = window.getSelection(),
charCount = -1,
node;
if (selection?.focusNode) {
if (Cursor._isChildOf(selection.focusNode, parentElement)) {
node = selection.focusNode;
charCount = selection.focusOffset;
while (node) {
if (node === parentElement) {
break;
}
if (node.previousSibling) {
node = node.previousSibling;
charCount += node.textContent?.length || 0
} else {
node = node.parentNode;
if (node === null) {
break;
}
}
}
}
}
return charCount;
}
static setCurrentCursorPosition(chars: number, element: Node) {
if (chars >= 0) {
var selection = window.getSelection();
let range = Cursor._createRange(element, { count: chars });
if (range) {
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}
static _createRange(node: Node, chars: { count: number }, range?: Range): Range {
if (!range) {
range = document.createRange()
range.selectNode(node);
range.setStart(node, 0);
}
if (chars.count === 0) {
range.setEnd(node, chars.count);
} else if (node && chars.count > 0) {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent!.length < chars.count) {
chars.count -= node.textContent!.length;
} else {
range.setEnd(node, chars.count);
chars.count = 0;
}
} else {
for (var lp = 0; lp < node.childNodes.length; lp++) {
range = Cursor._createRange(node.childNodes[lp], chars, range);
if (chars.count === 0) {
break;
}
}
}
}
return range;
}
static _isChildOf(node: Node, parentElement: Node) {
while (node !== null) {
if (node === parentElement) {
return true;
}
node = node.parentNode!;
}
return false;
}
}

View File

@@ -0,0 +1,99 @@
<header class="flex flex-row justify-between items-center mb-2">
<h1 class="m-0 text-sm font-light">
Import {{ dialogRef.data.type === "setting" ? "Settings" : "Profile" }}
</h1>
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"
class="w-3 h-3 cursor-pointer text-secondary hover:text-primary" (click)="dialogRef.close()">
<path fill="currentColor"
d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z">
</path>
</svg>
</header>
<span class="text-xs font-light">Please paste the "Export Content" or use "Choose File" to select one from
your hard disk.</span>
<pre tabindex="0" class="block flex-grow w-full rounded border border-gray-500 language-yaml overflow-auto outline-none"
#codeBlock id="yaml" contenteditable="true" (blur)="onBlur()" (mouseleave)="onBlur()" (paste)="onPaste($event)"></pre>
<fieldset class="p-2 text-xs font-light bg-gray-400 rounded border border-gray-500 border-solid">
<legend class="px-2 py-1 m-0 text-xs w-fit">Configuration</legend>
<div class="p-2 space-y-2">
<div class="flex flex-row gap-2" *ngIf="dialogRef.data.type === 'setting'">
<input type="checkbox" [(ngModel)]="reset" id="reset" />
<label class="text-primary" for="reset">Reset all settings to default before importing</label>
</div>
<div class="flex flex-row gap-2" *ngIf="result?.containsUnknown">
<input type="checkbox" id="allowUnknown" [(ngModel)]="allowUnknown" />
<label class="text-primary" for="allowUnknown">Allow unknown settings</label>
</div>
<!-- Replacing existing profile must be explicitly accepted for profile (but not for settings...) -->
<div class="flex flex-row gap-2" *ngIf="result?.replacesExisting && dialogRef.data.type === 'profile'">
<input type="checkbox" id="allowUnknown" [(ngModel)]="allowReplace" />
<label class="text-primary" for="allowUnknown">Allow replacing an existing profile</label>
</div>
<div class="flex flex-row gap-2" *ngIf="result?.restartRequired">
<input type="checkbox" id="restart" [(ngModel)]="triggerRestart" />
<label class="text-primary" for="restart">Automatically restart Portmaster after a successfull import</label>
</div>
</div>
</fieldset>
<fieldset class="p-2 text-xs font-light bg-gray-400 rounded border border-gray-500 border-solid" *ngIf="
errorMessage ||
(result &&
(result.containsUnknown ||
result.replacesExisting ||
result.restartRequired))
">
<legend class="px-2 py-1 m-0 text-xs w-fit">Warning</legend>
<div *ngIf="!!errorMessage" class="flex flex-row gap-2 items-center p-2 w-full text-xs font-normal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6 text-red">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span>{{ errorMessage }}</span>
</div>
<ul *ngIf="result" class="px-2 py-2 pl-7 list-disc">
<li *ngIf="result.containsUnknown">
This export contains unknown settings. To import it, you must enable
"Allow unknown settings".
</li>
<li *ngIf="result.replacesExisting">
{{
dialogRef.data.type === "setting"
? "This export will overwrite settings that have been changed by you."
: "This export will overwrite an existing profile."
}}
<ng-container *ngIf="replacedProfiles.length as count">
And deletes {{ count }} previously merged profile{{ count > 1 ? 's' : '' }}
</ng-container>
</li>
<li *ngIf="result.restartRequired">
This export will require a restart of the Portmaster to take effect.
</li>
</ul>
</fieldset>
<div class="flex flex-row justify-between">
<button>
<label class="block" for="avatarInput"> Choose File </label>
</button>
<button class="text-white bg-blue" (click)="import()" [disabled]="!result">
Import
</button>
</div>
<input name="avatarInput" id="avatarInput" class="!hidden" type="file" (change)="loadFile($event)" />

View File

@@ -0,0 +1,201 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
ViewChild,
inject,
} from '@angular/core';
import { ImportResult, PortapiService, ProfileImportResult } from '@safing/portmaster-api';
import { SFNG_DIALOG_REF, SfngDialogRef } from '@safing/ui';
import { ActionIndicatorService } from '../../action-indicator';
import { getSelectionOffset, setSelectionOffset } from './selection';
import { Observable } from 'rxjs';
export interface ImportConfig {
key: string;
type: 'setting' | 'profile';
}
@Component({
templateUrl: './import-dialog.component.html',
styles: [
`
:host {
@apply flex flex-col gap-2 overflow-hidden;
min-height: 24rem;
min-width: 24rem;
max-height: 40rem;
max-width: 40rem;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImportDialogComponent {
readonly dialogRef: SfngDialogRef<
ImportDialogComponent,
unknown,
ImportConfig
> = inject(SFNG_DIALOG_REF);
private readonly portapi = inject(PortapiService);
private readonly uai = inject(ActionIndicatorService);
private readonly cdr = inject(ChangeDetectorRef);
@ViewChild('codeBlock', { static: true, read: ElementRef })
codeBlockElement!: ElementRef<HTMLElement>;
result: ImportResult | ProfileImportResult | null = null;
reset = false;
allowUnknown = false;
triggerRestart = false;
allowReplace = false;
get replacedProfiles() {
if (this.result === null) {
return []
}
if ('replacesProfiles' in this.result) {
return this.result.replacesProfiles || [];
}
return [];
}
errorMessage: string = '';
get scope() {
return this.dialogRef.data;
}
onBlur() {
const text = this.codeBlockElement.nativeElement.innerText;
this.updateAndValidate(text);
}
onPaste(event: ClipboardEvent) {
event.stopPropagation();
event.preventDefault();
// Get pasted data via clipboard API
const clipboardData = event.clipboardData || (window as any).clipboardData;
const text = clipboardData.getData('Text');
this.updateAndValidate(text);
}
import() {
const text = this.codeBlockElement.nativeElement.innerText;
let saveFunc: Observable<ImportResult>;
if (this.dialogRef.data.type === 'setting') {
saveFunc = this.portapi.importSettings(
text,
this.dialogRef.data.key,
'text/yaml',
this.reset,
this.allowUnknown
);
} else {
saveFunc = this.portapi.importProfile(
text,
'text/yaml',
this.reset,
this.allowUnknown,
this.allowReplace
);
}
saveFunc.subscribe({
next: (result) => {
let msg = '';
if (result.restartRequired) {
if (this.triggerRestart) {
this.portapi.restartPortmaster().subscribe();
msg = 'Portmaster will be restarted now.';
} else {
msg = 'Please restart Portmaster to apply the new settings.';
}
}
this.uai.success('Settings Imported Successfully', msg);
this.dialogRef.close();
},
error: (err) => {
this.uai.error(
'Failed To Import Settings',
this.uai.getErrorMessgae(err)
);
},
});
}
updateAndValidate(content: string) {
const [start, end] = getSelectionOffset(
this.codeBlockElement.nativeElement
);
const p = (window as any).Prism;
const blob = p.highlight(content, p.languages.yaml, 'yaml');
this.codeBlockElement.nativeElement.innerHTML = blob;
setSelectionOffset(this.codeBlockElement.nativeElement, start, end);
if (content === '') {
return;
}
window.getSelection()?.removeAllRanges();
let validateFunc: Observable<ImportResult>;
if (this.dialogRef.data.type === 'setting') {
validateFunc = this.portapi.validateSettingsImport(
content,
this.dialogRef.data.key,
'text/yaml'
);
} else {
validateFunc = this.portapi.validateProfileImport(content, 'text/yaml');
}
validateFunc.subscribe({
next: (result) => {
this.result = result;
this.errorMessage = '';
this.cdr.markForCheck();
},
error: (err) => {
const msg = this.uai.getErrorMessgae(err);
this.errorMessage = msg;
this.result = null;
this.cdr.markForCheck();
},
});
}
loadFile(event: Event) {
const file: File = (event.target as any).files[0];
if (!file) {
this.updateAndValidate('');
return;
}
const reader = new FileReader();
reader.onload = (data) => {
(event.target as any).value = '';
let content = (data.target as any).result;
this.updateAndValidate(content);
};
reader.readAsText(file);
}
}

View File

@@ -0,0 +1,185 @@
/** return true if node found */
function searchNode(
container: Node,
startNode: Node,
predicate: (node: Node) => boolean,
excludeSibling?: boolean,
): boolean {
if (predicate(startNode as Text)) {
return true
}
for (let i = 0, len = startNode.childNodes.length; i < len; i++) {
if (searchNode(startNode, startNode.childNodes[i], predicate, true)) {
return true
}
}
if (!excludeSibling) {
let parentNode = startNode
while (parentNode && parentNode !== container) {
let nextSibling = parentNode.nextSibling
while (nextSibling) {
if (searchNode(container, nextSibling, predicate, true)) {
return true
}
nextSibling = nextSibling.nextSibling
}
parentNode = parentNode.parentNode!
}
}
return false
}
function createRange(container: Node, start: number, end: number): Range {
let startNode: any;
searchNode(container, container, node => {
if (node.nodeType === Node.TEXT_NODE) {
const dataLength = (node as Text).data.length
if (start <= dataLength) {
startNode = node
return true
}
start -= dataLength
end -= dataLength
}
return false
})
let endNode: any;
if (startNode) {
searchNode(container, startNode, node => {
if (node.nodeType === Node.TEXT_NODE) {
const dataLength = (node as Text).data.length
if (end <= dataLength) {
endNode = node
return true
}
end -= dataLength
}
return false
})
}
const range = document.createRange()
if (startNode) {
if (start < startNode.data.length) {
range.setStart(startNode, start)
} else {
range.setStartAfter(startNode)
}
} else {
if (start === 0) {
range.setStart(container, 0)
} else {
range.setStartAfter(container)
}
}
if (endNode) {
if (end < endNode.data.length) {
range.setEnd(endNode, end)
} else {
range.setEndAfter(endNode)
}
} else {
if (end === 0) {
range.setEnd(container, 0)
} else {
range.setEndAfter(container)
}
}
return range
}
export function setSelectionOffset(node: Node, start: number, end: number) {
const range = createRange(node, start, end)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(range)
}
function getAbsoluteOffset(container: Node, offset: number) {
if (container.nodeType === Node.TEXT_NODE) {
return offset
}
let absoluteOffset = 0
for (let i = 0, len = Math.min(container.childNodes.length, offset); i < len; i++) {
const childNode = container.childNodes[i]
searchNode(childNode, childNode, node => {
if (node.nodeType === Node.TEXT_NODE) {
absoluteOffset += (node as Text).data.length
}
return false
})
}
return absoluteOffset
}
export function getSelectionOffset(container: Node): [number, number] {
let start = 0
let end = 0
const selection = window.getSelection()!
for (let i = 0, len = selection.rangeCount; i < len; i++) {
const range = selection.getRangeAt(i)
if (range.intersectsNode(container)) {
const startNode = range.startContainer
searchNode(container, container, node => {
if (startNode === node) {
start += getAbsoluteOffset(node, range.startOffset)
return true
}
const dataLength = node.nodeType === Node.TEXT_NODE
? (node as Text).data.length
: 0
start += dataLength
end += dataLength
return false
})
const endNode = range.endContainer
searchNode(container, startNode, node => {
if (endNode === node) {
end += getAbsoluteOffset(node, range.endOffset)
return true
}
const dataLength = node.nodeType === Node.TEXT_NODE
? (node as Text).data.length
: 0
end += dataLength
return false
})
break
}
}
return [start, end]
}
export function getInnerText(container: Node): string {
const buffer: any = []
searchNode(container, container, node => {
if (node.nodeType === Node.TEXT_NODE) {
buffer.push((node as Text).data)
}
return false
})
return buffer.join('')
}

View File

@@ -0,0 +1,8 @@
export * from './basic-setting';
export * from './config-settings';
export * from './config.module';
export * from './filter-lists';
export * from './generic-setting';
export * from './ordererd-list';
export * from './rule-list';
export * from './safe.pipe';

View File

@@ -0,0 +1,2 @@
export { OrderedListComponent } from './ordered-list';
export { OrderedListItemComponent } from './item';

View File

@@ -0,0 +1,14 @@
<div class="value" [class.edit]="_edit">
<span *ngIf="!_edit; else: editValue" class="flex-grow">
{{value}}
</span>
<ng-template #editValue>
<input type="text" [(ngModel)]="_value">
</ng-template>
<div class="buttons" *ngIf="!readonly">
<fa-icon [icon]="_edit ? 'check' : 'edit'" (click)="toggleEdit()"></fa-icon>
<fa-icon icon="times" (click)="reset()"></fa-icon>
</div>
</div>

View File

@@ -0,0 +1,56 @@
:host {
@apply flex outline-none;
@apply space-x-2;
&>* {
@apply rounded;
@apply bg-gray-300;
}
}
div.value {
@apply border-gray-500 border;
@apply p-1;
@apply px-2;
&.edit {
@apply p-0;
@apply bg-gray-400;
input {
margin: 0;
width: auto;
flex-grow: 1;
border: none;
@apply shadow-none;
}
input:focus+.buttons {
@apply bg-gray-500 border-gray-600 bg-opacity-75 border-opacity-75;
}
}
flex-grow : 1;
display : flex;
justify-content: space-between;
align-items : center;
.buttons {
flex-shrink: 0;
height: 100%;
width: 4rem;
@apply flex items-center justify-evenly;
fa-icon {
cursor: pointer;
@apply text-primary;
@apply p-1;
opacity: 0.7;
font-size: 0.6rem;
&:hover {
opacity: 1;
}
}
}
}

View File

@@ -0,0 +1,87 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
@Component({
selector: 'app-ordered-list-item',
templateUrl: './item.html',
styleUrls: ['./item.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderedListItemComponent implements OnInit {
@Input()
set readonly(v: any) {
this._readonly = coerceBooleanProperty(v);
}
get readonly() {
return this._readonly;
}
private _readonly = false;
@Input()
set value(v: string) {
this._value = v;
this._savedValue = v;
}
get value() {
return this._value;
}
_value = '';
private _savedValue = '';
@Output()
readonly valueChange = new EventEmitter<string>();
@Output()
readonly delete = new EventEmitter<void>();
@Input()
set edit(v: any) {
this._edit = coerceBooleanProperty(v);
}
get edit() {
return this._edit;
}
_edit = false;
@Output()
readonly editChange = new EventEmitter<boolean>();
ngOnInit() {
if (this._value === '' && this._savedValue === '') {
this.edit = true;
}
}
toggleEdit() {
const wasEdit = this._edit;
this._edit = !wasEdit;
this.editChange.next(this._edit);
if (!wasEdit) {
return;
}
if (this._value !== this._savedValue) {
this._value = this._value.trim()
this.valueChange.next(this.value);
this._savedValue = this._value;
}
this.changeDetectorRef.markForCheck();
}
reset() {
if (this._edit) {
if (this._value !== '' || this._savedValue !== '') {
this._value = this._savedValue;
this.changeDetectorRef.markForCheck();
return;
}
}
this.delete.next();
}
constructor(private changeDetectorRef: ChangeDetectorRef) { }
}

View File

@@ -0,0 +1,23 @@
<div class="list-items" cdkDropList (cdkDropListDropped)="drop($event)">
<div class="item" *ngFor="let entry of entries; let index=index; trackBy: trackBy" cdkDrag
[cdkDragDisabled]="readonly || fixedOrder">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 mr-2 text-secondary" fill="none" viewBox="0 0 24 24"
stroke="currentColor" cdkDragHandle [class.opacity-0]="readonly || fixedOrder"
[class.cusor-move]="!readonly && !fixedOrder">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
<app-ordered-list-item [readonly]="readonly" [value]="entry" (valueChange)="updateValue(index, $event)"
(delete)="deleteEntry(index)">
</app-ordered-list-item>
</div>
</div>
<div class="button-list" *ngIf="!readonly">
<button class="new-entry" (click)="addEntry()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
<span>Add</span>
</button>
</div>

View File

@@ -0,0 +1,77 @@
:host {
outline: none;
}
.item,
.cdk-drag-preview {
display: flex;
align-items: center;
padding: 3px;
fa-icon {
cursor: pointer;
@apply text-tertiary;
@apply text-lg;
@apply mr-2;
}
app-ordered-list-item {
flex-grow: 1;
}
}
.cdk-drag-placeholder {
left: -4px;
padding: 1px;
padding-left: 4px;
}
// TODO(ppacher9): move this transition to a mixin
.list-items.cdk-drop-list-dragging .list:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drag-preview {
left: -4px;
padding: 1px;
padding-left: 4px;
}
.button-list {
@apply mt-2;
@apply ml-8;
}
.new-entry {
position: relative;
cursor: pointer;
@apply w-full;
@apply rounded;
@apply p-1;
@apply border-2;
@apply border-dashed;
@apply border-buttons-light;
@apply bg-background;
@apply text-secondary;
span {
@apply font-medium;
}
fa-icon {
font-size: 1rem;
}
&:hover {
@apply text-primary;
@apply bg-cards-secondary;
span {
@apply text-primary;
}
}
display : flex;
align-items : center;
justify-content: center;
}

View File

@@ -0,0 +1,111 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostBinding, HostListener, Input } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-ordered-list',
templateUrl: './ordered-list.html',
styleUrls: ['./ordered-list.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => OrderedListComponent),
multi: true,
}
]
})
export class OrderedListComponent implements ControlValueAccessor {
@HostBinding('tabindex')
readonly tabindex = 0;
@HostListener('blur')
onBlur() {
this.onTouch();
}
@Input()
set readonly(v: any) {
this._readonly = coerceBooleanProperty(v);
}
get readonly() {
return this._readonly;
}
_readonly = false;
@Input()
set fixedOrder(v: any) {
this._fixedOrder = coerceBooleanProperty(v);
}
get fixedOrder() {
return this._fixedOrder;
}
private _fixedOrder = false;
entries: string[] = [];
constructor(private changeDetector: ChangeDetectorRef) { }
updateValue(index: number, newValue: string) {
// we need to make a new object copy here.
this.entries = [
...this.entries,
];
this.entries[index] = newValue;
this.onChange(this.entries);
}
deleteEntry(index: number) {
this.entries = [...this.entries];
this.entries.splice(index, 1);
this.onChange(this.entries);
}
addEntry() {
// if there's already one empty entry abort
if (this.entries.some(e => e.trim() === '')) {
return;
}
this.entries = [...this.entries];
this.entries.push('');
//this.onChange(this.entries);
}
writeValue(value: string[]) {
this.entries = value;
this.changeDetector.markForCheck();
}
onChange = (_: string[]): void => { };
registerOnChange(fn: (value: string[]) => void) {
this.onChange = fn;
}
onTouch = (): void => { };
registerOnTouched(fn: () => void) {
this.onTouch = fn;
}
drop(event: CdkDragDrop<string[]>) {
if (this._readonly) {
return;
}
// create a copy of the array
this.entries = [...this.entries];
moveItemInArray(this.entries, event.previousIndex, event.currentIndex);
this.changeDetector.markForCheck();
this.onChange(this.entries);
}
trackBy(idx: number, value: string) {
return `${value}`;
}
}

View File

@@ -0,0 +1,2 @@
export * from './list-item';
export * from './rule-list';

View File

@@ -0,0 +1,29 @@
<div class="flex items-center action justify-evenly" [class.text-green-300]="isAllow" [class.text-red]="isBlock">
<ng-container *ngIf="!edit; else: selectAction">
<span *ngIf="isAllow">{{ symbolMap["+"] }}</span>
<span *ngIf="isBlock">{{ symbolMap["-"] }}</span>
</ng-container>
<ng-template #selectAction>
<sfng-select [ngModel]="currentAction" (ngModelChange)="setAction($event)" mode="single" dynamicValues="false">
<sfng-select-item *sfngSelectValue="'+'">{{ symbolMap["+"] }}</sfng-select-item>
<sfng-select-item *sfngSelectValue="'-'">{{ symbolMap["-"] }}</sfng-select-item>
</sfng-select>
</ng-template>
</div>
<div class="value" [class.edit]="edit">
<ng-container *ngIf="!edit; else: editValue">
{{ display }}
</ng-container>
<ng-template #editValue>
<input type="text" [ngModel]="display" (ngModelChange)="setEntity($event)" (keydown.enter)="toggleEdit()">
</ng-template>
<div class="buttons" *ngIf="!readonly">
<fa-icon [icon]="edit ? 'check' : 'edit'" (click)="toggleEdit()"></fa-icon>
<fa-icon *ngIf="edit" icon="times" (click)="reset()"></fa-icon>
<input type="checkbox" *ngIf="!edit" [(ngModel)]="selected" (ngModelChange)="selectedChange.next($event)">
</div>
</div>

View File

@@ -0,0 +1,65 @@
:host {
display: flex;
outline: none;
@apply space-x-2;
&>* {
@apply rounded;
@apply bg-gray-300;
}
}
div.action {
@apply border-gray-500 border;
flex-shrink: 0;
min-width: 6rem;
text-align: center;
}
div.value {
@apply border-gray-500 border;
@apply p-1.5;
@apply px-2;
&.edit {
@apply p-0;
@apply bg-gray-400;
input {
margin: 0;
width: auto;
height: 100%;
flex-grow: 1;
border: none;
@apply shadow-none;
}
input:focus+.buttons {
@apply bg-gray-500 border-gray-600 bg-opacity-75 border-opacity-75;
}
}
flex-grow : 1;
display : flex;
justify-content: space-between;
align-items : center;
.buttons {
flex-shrink: 0;
height: 100%;
width: 4rem;
@apply flex items-center justify-evenly;
fa-icon {
cursor: pointer;
@apply text-primary;
@apply p-1;
opacity: 0.7;
font-size: 0.6rem;
&:hover {
opacity: 1;
}
}
}
}

View File

@@ -0,0 +1,221 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnInit, Output } from '@angular/core';
import { fadeInAnimation, fadeOutAnimation } from '../../animations';
@Component({
selector: 'app-rule-list-item',
templateUrl: 'list-item.html',
styleUrls: ['list-item.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeInAnimation,
fadeOutAnimation
]
})
export class RuleListItemComponent implements OnInit {
/** The host element is going to fade in/out */
@HostBinding('@fadeIn')
@HostBinding('@fadeOut')
readonly animation = true;
@Input()
symbolMap: { [key: string]: string } = {}
/**
* The current value (rule) displayed by this component.
* Supports two-way bindings.
*/
@Input()
set value(v: string) {
this.updateValue(v);
this._savedValue = this._value;
}
private _value = '';
/** The last actually saved value of this rule. Required for resets */
private _savedValue = '';
/**
* Emits whenever the rule value changes.
* Supports two-way-bindings on ([value])
*/
@Output()
valueChange = new EventEmitter<string>();
/** Whether or not the rule list item is selected */
@Input()
set selected(v: any) {
this._selected = coerceBooleanProperty(v)
}
get selected() {
return this._selected;
}
private _selected = false;
@Output()
selectedChange = new EventEmitter<boolean>();
/**
* Whether or not the component is in edit mode.
* Supports two-way-bindings on ([edit])
*/
@Input()
set edit(v: any) {
this._edit = coerceBooleanProperty(v);
}
get edit() {
return this._edit;
}
private _edit: boolean = false;
/**
* Emits whenever the component switch to or away from edit
* mode.
* Supports two-way-bindings on ([edit])
*/
@Output()
editChange = new EventEmitter<boolean>();
/**
* Whether or not the component should be in read-only mode.
*/
@Input()
set readonly(v: any) {
this._readonly = coerceBooleanProperty(v);
}
get readonly() {
return this._readonly;
}
private _readonly: boolean = false;
/**
* Emits when the user presses the delete button of
* this rule component.
*/
@Output()
delete = new EventEmitter<void>();
/** @private Whether or not this rule is a "Allow" rule - we default to allow since this is what most rules are used for */
isAllow = true;
/** @private Whether or not this rule is a "Deny" rule */
isBlock = false;
/** @private the actually displayed rule value (without the verdict) */
display = '';
/** @private the character representation of the current verdict */
get currentAction() {
if (this.isBlock) {
return '-';
}
if (this.isAllow) {
return '+';
}
return '';
}
constructor(private cdr: ChangeDetectorRef) { }
ngOnInit() {
// new entries always start in edit mode
if (!this.isAllow && !this.isBlock) {
this._edit = true;
}
}
/**
* @private
* Toggle between edit and view mode. When switching from
* edit to view mode, the current value is emitted to the
* parent element in case it has been changed.
*/
toggleEdit() {
if (this._edit) {
// do nothing if the rule is obviously invalid (no verdict or value).
if (this.display === '' || !(this.isAllow || this.isBlock)) {
return;
}
if (this._value !== this._savedValue) {
this.valueChange.next(this._value);
}
}
this._edit = !this._edit;
this.editChange.next(this._edit);
}
toggleSelection() {
this.selected = !this.selected;
this.selectedChange.next(this.selected);
this.cdr.markForCheck();
}
/**
* @private
* Sets the new rule action. Used as a callback in the drop-down.
*
* @param action The new action
*/
setAction(action: '+' | '-') {
this.updateValue(`${action} ${this.display}`);
}
/**
* @private
* Update the actual value of the rule.
*
* @param entity The new rule value
*/
setEntity(entity: string) {
const action = this.isAllow ? '+' : '-';
this.updateValue(`${action} ${entity}`);
}
/**
* @private
*
* Reset the value to it's previously saved value if it was changed.
* If the value is unchanged a reset counts as a delete and triggers
* on our delete output.
*/
reset() {
if (this._edit) {
// if the user did not change anything we can immediately
// delete it.
if (this._savedValue !== '') {
this.value = this._savedValue;
this._edit = false;
return;
}
}
this.delete.next();
}
/**
* Updates our internal states to correctly display the rule.
*
* @param v The actual rule value
*/
private updateValue(v: string) {
this._value = v.trim();
switch (this._value[0]) {
case '+':
this.isAllow = true;
this.isBlock = false;
break;
case '-':
this.isAllow = false;
this.isBlock = true;
break;
default:
// not yet set
this.isBlock = this.isAllow = false;
}
this.display = this._value.slice(1).trim();
}
}

View File

@@ -0,0 +1,46 @@
<div class="list-items" cdkDropList (cdkDropListDropped)="drop($event)">
<div class="item" *ngFor="let entry of entries; let index=index; trackBy: trackBy" cdkDrag
[cdkDragDisabled]="readonly">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 mr-2 text-secondary" fill="none" viewBox="0 0 24 24"
stroke="currentColor" cdkDragHandle [class.opacity-0]="readonly" [class.cusor-move]="!readonly">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
<app-rule-list-item [symbolMap]="symbolMap" [readonly]="readonly" [value]="entry"
(valueChange)="updateValue(index, $event)" (selectedChange)="selectItem(index, $event)"
(delete)="deleteEntry(index)">
</app-rule-list-item>
</div>
</div>
<div class="button-list" *ngIf="selectedItems.length === 0">
<div class="dotted" *ngIf="!entries?.length && readonly">
No entries available
</div>
<button class="new-entry dotted" (click)="addEntry()"
*ngIf="!readonly && (!entries?.length || entries[entries.length-1] !== '')">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
<span>Add Rule</span>
</button>
</div>
<div class="flex justify-end button-list" *ngIf="selectedItems.length > 0">
<span>
<app-menu-trigger [menu]="selectionMenu" [useContent]="true">
{{ selectedItems.length }} Rule{{ selectedItems.length > 1 ? 's' : ''}} selected
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd" />
</svg>
</app-menu-trigger>
</span>
<app-menu #selectionMenu>
<app-menu-item (click)="removeSelectedItems()">Remove Rules</app-menu-item>
<app-menu-item (click)="abortSelection()">Cancel</app-menu-item>
</app-menu>
</div>

View File

@@ -0,0 +1,75 @@
:host {
outline: none;
}
.item,
.cdk-drag-preview {
display: flex;
align-items: center;
padding: 3px;
fa-icon {
cursor: pointer;
@apply text-tertiary;
@apply text-lg;
@apply mr-2;
}
app-rule-list-item {
flex-grow: 1;
}
}
.cdk-drag-placeholder {
left: -4px;
padding: 1px;
padding-left: 4px;
}
// TODO(ppacher9): move this transition to a mixin
.list-items.cdk-drop-list-dragging .list:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drag-preview {
left: -4px;
padding: 1px;
padding-left: 4px;
}
.button-list {
@apply mt-2;
@apply ml-8;
}
.dotted {
@apply w-full;
@apply rounded;
@apply p-1;
@apply border-2;
@apply border-dashed;
@apply border-buttons-light;
@apply bg-background;
@apply text-secondary;
display: flex;
align-items: center;
justify-content: center;
span {
@apply font-medium;
}
}
.new-entry {
cursor: pointer;
&:hover {
@apply text-primary;
@apply bg-gray-300;
span {
@apply text-primary;
}
}
}

View File

@@ -0,0 +1,226 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostBinding, HostListener, Input, QueryList, ViewChildren } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { SfngDialogService } from '@safing/ui';
import { RuleListItemComponent } from './list-item';
@Component({
selector: 'app-rule-list',
templateUrl: './rule-list.html',
styleUrls: ['./rule-list.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RuleListComponent),
multi: true,
}
],
})
export class RuleListComponent implements ControlValueAccessor {
/** Add the host element into the tab-sequence */
@HostBinding('tabindex')
readonly tabindex = 0;
@ViewChildren(RuleListItemComponent)
renderedRules!: QueryList<RuleListItemComponent>;
/** A list of selected rule indexes */
selectedItems: number[] = [];
/**
* @private
* Mark the component as dirty by calling the onTouch callback of the control-value accessor
*/
@HostListener('blur')
onBlur() {
this.onTouch();
}
@Input()
symbolMap = {
'+': 'Allow',
'-': 'Block',
}
/**
* Whether or not the component should be displayed as read-only.
*/
@Input()
set readonly(v: any) {
this._readonly = coerceBooleanProperty(v);
}
get readonly() {
return this._readonly;
}
private _readonly = false;
/**
* @private
* The actual rule entries. Displayed as RuleListItemComponent.
*/
entries: string[] = [];
constructor(
private changeDetector: ChangeDetectorRef,
private dialog: SfngDialogService
) { }
/**
* @private
* Update the value of a rule-list entry. Used as a callback function
* for the valueChange output of the RuleListItemComponent.
*
* @param index The index of the rule list entry to update
* @param newValue The new value of the rule
*/
updateValue(index: number, newValue: string) {
// we need create a copy of the actual value as
// the parent component might still have a reference
// to the current values.
this.entries = [
...this.entries,
];
this.entries[index] = newValue;
// tell the control that we have a new value
this.onChange(this.entries);
}
/**
* @private
* Delete a rule list entry.
*
* @param index The index of the rule list entry to delete
*/
deleteEntry(index: number) {
this.entries = [...this.entries];
this.entries.splice(index, 1);
this.onChange(this.entries);
}
/**
* @private
* Add a new, empty rule list entry at the end of the
* list.
*
* This is a no-op if there's already an empty item
* available.
*/
addEntry() {
// if there's already one empty entry abort
if (this.entries.some(e => e.trim() === '')) {
return;
}
this.entries = [...this.entries];
this.entries.push('');
}
/**
* Set a new value for the rule list. This is the
* only way to configure the existing entries and is
* used by the control-value-accessor and ngModel.
*
* @param value The new value set via [ngModel]
*/
writeValue(value: string[]) {
this.entries = value;
this.changeDetector.markForCheck();
}
/** Toggles selection of a rule item */
selectItem(index: number, selected: boolean) {
if (selected && !this.selectedItems.includes(index)) {
this.selectedItems = [
...this.selectedItems,
index,
]
return;
}
if (!selected && this.selectedItems.includes(index)) {
this.selectedItems = this.selectedItems.filter(idx => idx !== index)
return;
}
}
/** Removes all selected items after displaying a confirmation dialog. */
removeSelectedItems() {
this.dialog.confirm({
buttons: [
{
id: 'abort',
text: 'Cancel',
class: 'outline'
},
{
id: 'delete',
text: 'Delete Rules',
class: 'danger'
}
],
canCancel: true,
caption: 'Caution',
header: 'Rule Deletion',
message: 'Do you want to delete the selected rules'
})
.onAction('delete', () => {
this.entries = this.entries.filter((_, idx: number) => !this.selectedItems.includes(idx))
this.abortSelection();
this.onChange(this.entries);
})
}
/** Aborts the current selection */
abortSelection() {
this.selectedItems.forEach(itemIdx => this.renderedRules.get(itemIdx)?.toggleSelection())
this.selectedItems = [];
}
/** @private onChange callback registered by ngModel and form controls */
onChange = (_: string[]): void => { };
/** Registers the onChange callback and required for the ControlValueAccessor interface */
registerOnChange(fn: (value: string[]) => void) {
this.onChange = fn;
}
/** @private onTouch callback registered by ngModel and form controls */
onTouch = (): void => { };
/** Registers the onChange callback and required for the ControlValueAccessor interface */
registerOnTouched(fn: () => void) {
this.onTouch = fn;
}
/**
* @private
* Used as a callback for the @angular/cdk drop component
* and used to update the actual order of the entries.
*
* @param event The drop-event
*/
drop(event: CdkDragDrop<string[]>) {
if (this._readonly) {
return;
}
// create a copy of the array
this.entries = [...this.entries];
moveItemInArray(this.entries, event.previousIndex, event.currentIndex);
this.changeDetector.markForCheck();
this.onChange(this.entries);
}
/** @private TrackByFunction for entries */
trackBy(idx: number, value: string) {
return `${value}`;
}
}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml, SafeStyle, SafeScript, SafeUrl, SafeResourceUrl } from '@angular/platform-browser';
@Pipe({
name: 'safe'
})
export class SafePipe implements PipeTransform {
constructor(protected sanitizer: DomSanitizer) { }
public transform(value: any, type: string): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl {
switch (type) {
case 'html': return this.sanitizer.bypassSecurityTrustHtml(value);
case 'style': return this.sanitizer.bypassSecurityTrustStyle(value);
case 'script': return this.sanitizer.bypassSecurityTrustScript(value);
case 'url': return this.sanitizer.bypassSecurityTrustUrl(value);
case 'resourceUrl': return this.sanitizer.bypassSecurityTrustResourceUrl(value);
default: throw new Error(`Invalid safe type specified: ${type}`);
}
}
}

View File

@@ -0,0 +1,4 @@
<span class="counter">{{ count | prettyCount }}</span>
<div class="pill">
<div class="percentage" [style.width.%]="allowedPercentage"></div>
</div>

View File

@@ -0,0 +1,15 @@
import { NgModule } from "@angular/core";
import { CountIndicatorComponent } from "./count-indicator";
import { PrettyCountPipe } from "./count.pipe";
@NgModule({
declarations: [
CountIndicatorComponent,
PrettyCountPipe,
],
exports: [
CountIndicatorComponent,
PrettyCountPipe,
]
})
export class CountIndicatorModule { }

View File

@@ -0,0 +1,8 @@
@import '../../../theme/mixins/_pill.scss';
:host {
@include pill-container;
@apply pl-2;
@apply bg-buttons-dark;
@apply w-20;
}

View File

@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
@Component({
selector: 'app-count-indicator',
templateUrl: './count-indicator.html',
styleUrls: ['./count-indicator.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CountIndicatorComponent implements OnChanges {
@Input()
count = 0;
@Input()
countAllowed: number = 0;
allowedPercentage: number = 0;
ngOnChanges() {
const ratio = (this.countAllowed / this.count) || 0;
this.allowedPercentage = Math.round(ratio * 100);
}
}

View File

@@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: 'prettyCount',
pure: true
})
export class PrettyCountPipe implements PipeTransform {
transform(value: number) {
if (value > 999) {
const v = Math.floor(value / 1000);
if (value === v * 1000) {
return `${v}k`;
}
return `${v}k+`
}
return `${value}`
}
}

View File

@@ -0,0 +1,2 @@
export * from './count-indicator';
export * from './count-indicator.module';

View File

@@ -0,0 +1,45 @@
import { AfterViewInit, Directive, ElementRef, HostBinding, Input, OnChanges, Renderer2, SimpleChanges } from '@angular/core';
@Directive({
selector: 'span[appCountryFlags]',
})
export class CountryFlagDirective implements AfterViewInit, OnChanges {
private readonly flagDir = "/assets/img/flags/";
private readonly OFFSET = 127397;
@HostBinding('style.text-shadow')
textShadow = 'rgba(255, 255, 255, .5) 0px 0px 1px';
@Input()
appCountryFlags: string = '';
constructor(
private el: ElementRef,
private renderer: Renderer2
) { }
ngOnChanges(changes: SimpleChanges): void {
if (!changes['appCountryFlags'].isFirstChange()) {
this.update();
}
}
ngAfterViewInit() {
this.update();
}
private update() {
const span = this.el.nativeElement as HTMLSpanElement;
const flag = this.toUnicodeFlag(this.appCountryFlags);
this.renderer.setAttribute(span, 'data-before', flag);
span.innerHTML = `<img style="display: inline" src="${this.flagDir}${this.appCountryFlags.toLocaleUpperCase()}.png">`;
}
private toUnicodeFlag(code: string) {
const base = 127462 - 65;
const cc = code.toUpperCase();
const res = String.fromCodePoint(...cc.split('').map(c => base + c.charCodeAt(0)));
return res;
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CountryFlagDirective } from './country-flag';
@NgModule({
declarations: [
CountryFlagDirective
],
exports: [
CountryFlagDirective,
]
})
export class CountryFlagModule { }

View File

@@ -0,0 +1,2 @@
export * from './country-flag';
export * from './country.module';

View File

@@ -0,0 +1,322 @@
<h1 class="flex flex-row gap-2 items-center">
<img
[src]="iconObjectURL"
*ngIf="imageError === null && !!iconData"
class="w-8 h-8 rounded-full border border-gray-400"
/>
{{ isEditMode ? 'Edit App Profile' : 'Create New App Profile' }}
</h1>
<form #profileForm="ngForm">
<sfng-tab-group customHeader="false" linkRouter="false">
<sfng-tab title="General">
<div *sfngTabContent class="tab-content">
<span class="py-2 text-secondary">
Configure basic profile information like the profile name, it's
description and optionally the profile icon.
</span>
<div class="input" *appExpertiseLevel="'developer'">
<label>ID</label>
<input
type="text"
name="id"
pattern="[a-zA-Z0-9\-\._]*"
[(ngModel)]="profile.ID"
placeholder="A unique identifier for profile. Leave empty to generate a random one."
/>
</div>
<div class="input">
<label>Name *</label>
<input
type="text"
required
name="name"
[(ngModel)]="profile.Name"
placeholder="A name for labele profile"
/>
</div>
<div class="input">
<label>Description</label>
<input
type="text"
name="description"
[(ngModel)]="profile.Description"
placeholder="An optional description of the profile"
/>
</div>
<div class="flex flex-row justify-between">
<div class="input">
<label>Icon</label>
<div class="flex flex-col gap-2 justify-start">
<div class="flex flex-row gap-3">
<label
for="icon-upload"
class="inline-block p-1 px-4 font-medium whitespace-nowrap bg-opacity-80 rounded-sm cursor-pointer outline-none bg-blue hover:bg-blue text-xxs w-fit"
>
Choose Icon
</label>
<button
*ngIf="!!iconData && profile.Icons?.length"
(click)="resetIcon()"
class="bg-red-300"
>
Reset Icon
</button>
</div>
<span class="pl-2 break-normal text-tertiary"
>The icon must be smaller than 10kB and it's dimensions must not
exceed 512x512 px. Only JPG and PNG files are supported.</span
>
</div>
<input
id="icon-upload"
class="hidden"
type="file"
(change)="fileChangeEvent($event)"
/>
<span *ngIf="imageError !== null" class="pl-2 text-red-300 text-xxs"
>{{ imageError }}</span
>
</div>
<img
[src]="iconObjectURL"
*ngIf="imageError === null && !!iconData"
class="w-12 h-12 rounded-full border border-gray-400"
/>
</div>
</div>
</sfng-tab>
<sfng-tab title="Process Matching">
<div *sfngTabContent class="flex flex-col tab-content text-xxs">
<span class="text-xs text-secondary"
>This profile will be applied to processes that match one of the
following fingerprints:</span
>
<div *ngIf="!profile.Fingerprints?.length">
No fingerprints configured. Please press "Add New" to get started.
</div>
<div class="flex overflow-auto flex-col flex-grow gap-2 px-1">
<div
class="flex relative flex-row gap-2 justify-evenly items-center p-2 bg-gray-200 border-r border-l border-gray-500"
*ngFor="let fp of profile.Fingerprints; let index=index"
>
<span
class="block absolute top-0 left-0 w-2 border-b border-gray-500"
></span>
<span
class="block absolute bottom-0 left-0 w-2 border-b border-gray-500"
></span>
<span
class="block absolute top-0 right-0 w-2 border-b border-gray-500"
></span>
<span
class="block absolute right-0 bottom-0 w-2 border-b border-gray-500"
></span>
<sfng-select
[(ngModel)]="fp.Type"
[ngModelOptions]="{standalone: true}"
>
<sfng-select-item
*sfngSelectValue="fingerPrintTypes.Tag; label: 'Tag'"
>
Tag
<sfng-tipup key="process-tags"></sfng-tipup>
</sfng-select-item>
<sfng-select-item
*sfngSelectValue="fingerPrintTypes.Cmdline; label: 'Command Line'"
>Command Line
</sfng-select-item>
<sfng-select-item
*sfngSelectValue="fingerPrintTypes.Env; label: 'Environment'"
>Environment
</sfng-select-item>
<sfng-select-item
*sfngSelectValue="fingerPrintTypes.Path; label: 'Path'"
>Path</sfng-select-item
>
</sfng-select>
<input
type="text"
[(ngModel)]="fp.Key"
placeholder="Key"
[ngModelOptions]="{standalone: true}"
*ngIf="fp.Type === 'env'"
/>
<sfng-select
[(ngModel)]="fp.Key"
placeholder="Key"
[ngModelOptions]="{standalone: true}"
*ngIf="fp.Type === 'tag'"
>
<ng-container *ngFor="let tag of processTags">
<sfng-select-item *sfngSelectValue="tag.ID; label: tag.Name"
>{{ tag.Name }}</sfng-select-item
>
</ng-container>
</sfng-select>
<sfng-select
[(ngModel)]="fp.Operation"
[ngModelOptions]="{standalone: true}"
>
<sfng-select-item *sfngSelectValue="fingerPrintOperations.Equal"
>Equals</sfng-select-item
>
<sfng-select-item *sfngSelectValue="fingerPrintOperations.Prefix"
>Prefix</sfng-select-item
>
<sfng-select-item *sfngSelectValue="fingerPrintOperations.Regex"
>Regex</sfng-select-item
>
</sfng-select>
<input
type="text"
[(ngModel)]="fp.Value"
placeholder="Value"
[ngModelOptions]="{standalone: true}"
/>
<button
class="bg-opacity-90 bg-red hover:bg-red"
(click)="removeFingerprint(index)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="my-0.5 w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
</div>
<button (click)="addFingerprint()">Add New</button>
</div>
</sfng-tab>
<sfng-tab title="Copy Settings">
<div *sfngTabContent class="flex flex-col gap-2 tab-content">
<div class="flex flex-row gap-2 items-center p-2 bg-gray-200">
<span class="text-secondary"
>Select a Profile to copy settings from:</span
>
<div class="flex-grow"></div>
<sfng-select
[(ngModel)]="selectedCopyFrom"
[ngModelOptions]="{standalone: true}"
class="flex-grow"
[allowSearch]="true"
searchPlaceholder="Search Profiles"
>
<ng-container *ngFor="let p of allProfiles">
<sfng-select-item
*sfngSelectValue="p; label:p.Name"
class="flex flex-row gap-2 items-center"
>
<app-icon [profile]="p"></app-icon>
{{ p.Name }}
</sfng-select-item>
</ng-container>
</sfng-select>
<button
[disabled]="selectedCopyFrom === null"
(click)="addCopyFrom()"
>
Add
</button>
</div>
<div
class="flex overflow-auto flex-col flex-grow gap-2"
cdkDropList
(cdkDropListDropped)="drop($event)"
>
<div
*ngFor="let p of copySettingsFrom; let index=index"
cdkDrag
class="flex flex-row items-center p-2 bg-gray-200 rounded-sm"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 w-5 h-5 cursor-move text-secondary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
cdkDragHandle
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
/>
</svg>
<app-icon [profile]="p"></app-icon>
{{ p.Name }}
<div class="flex-grow"></div>
<button
class="bg-opacity-90 bg-red hover:bg-red"
(click)="removeCopyFrom(index)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="my-0.5 w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
</div>
<span class="block text-center break-normal text-secondary text-xxs">
Settings will be copied from all specified profiles in order with
settings from higher profiles taking precedence. <br />
Existing settings may be overwritten.
</span>
</div>
</sfng-tab>
</sfng-tab-group>
</form>
<div class="flex flex-row gap-2 justify-end items-center">
<button *ngIf="isEditMode" (click)="deleteProfile()" class="bg-red">
Delete
</button>
<div class="flex-grow"></div>
<button (click)="abort()" class="">Cancel</button>
<button
(click)="save()"
[disabled]="!profileForm.valid"
class="bg-opacity-80 bg-blue hover:bg-blue"
>
Save
</button>
</div>

View File

@@ -0,0 +1,29 @@
:host {
@apply flex flex-col gap-4 max-w-2xl;
min-width: 500px;
width: 60vw;
}
.tab-content {
@apply flex flex-col gap-4 overflow-x-hidden h-96 pt-2;
}
.input {
@apply flex flex-col gap-1;
label {
@apply text-primary uppercase text-xxs relative left-1.5;
}
input[type="text"] {
@apply border border-gray-500;
&.ng-invalid.ng-dirty {
@apply border-red-200;
}
}
input[type="file"] {
display: none;
}
}

View File

@@ -0,0 +1,393 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import {
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit,
TrackByFunction,
} from '@angular/core';
import {
AppProfile,
AppProfileService,
FingerpringOperation,
Fingerprint,
FingerprintType,
PORTMASTER_HTTP_API_ENDPOINT,
PortapiService,
Record,
TagDescription,
mergeDeep,
} from '@safing/portmaster-api';
import { SFNG_DIALOG_REF, SfngDialogRef, SfngDialogService } from '@safing/ui';
import { Observable, Subject, map, of, switchMap, takeUntil } from 'rxjs';
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
@Component({
templateUrl: './edit-profile-dialog.html',
//changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./edit-profile-dialog.scss'],
})
// eslint-disable-next-line @angular-eslint/component-class-suffix
export class EditProfileDialog implements OnInit, OnDestroy {
private destory$ = new Subject<void>();
profile: Partial<AppProfile> = {
ID: '',
Source: 'local',
Name: '',
Description: '',
Icons: [],
Fingerprints: [],
};
isEditMode = false;
iconData: string | ArrayBuffer = '';
iconType: string = '';
iconChanged = false;
iconObjectURL = '';
imageError: string | null = null;
allProfiles: AppProfile[] = [];
copySettingsFrom: AppProfile[] = [];
selectedCopyFrom: AppProfile | null = null;
fingerPrintTypes = FingerprintType;
fingerPrintOperations = FingerpringOperation;
processTags: TagDescription[] = [];
trackFingerPrint: TrackByFunction<Fingerprint> = (
_: number,
fp: Fingerprint
) => `${fp.Type}-${fp.Key}-${fp.Operation}-${fp.Value}`;
constructor(
@Inject(SFNG_DIALOG_REF)
private dialgoRef: SfngDialogRef<
EditProfileDialog,
any,
string | null | AppProfile
>,
private profileService: AppProfileService,
private portapi: PortapiService,
private actionIndicator: ActionIndicatorService,
private dialog: SfngDialogService,
private cdr: ChangeDetectorRef,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string
) { }
ngOnInit(): void {
this.profileService.tagDescriptions().subscribe((result) => {
this.processTags = result;
this.cdr.markForCheck();
});
this.profileService
.watchProfiles()
.pipe(takeUntil(this.destory$))
.subscribe((profiles) => {
this.allProfiles = profiles;
this.cdr.markForCheck();
});
if (!!this.dialgoRef.data && typeof this.dialgoRef.data === 'string') {
this.isEditMode = true;
this.profileService
.getAppProfile(this.dialgoRef.data)
.subscribe((profile) => {
this.profile = profile;
this.loadIcon();
});
} else if (
!!this.dialgoRef.data &&
typeof this.dialgoRef.data === 'object'
) {
this.profile = this.dialgoRef.data;
this.loadIcon();
}
}
private loadIcon() {
if (!this.profile.Icons?.length) {
return;
}
const firstIcon = this.profile.Icons[0];
// get the current icon of the profile
switch (firstIcon.Type) {
case 'database':
this.portapi
.get<Record & { iconData: string }>(firstIcon.Value)
.subscribe((data) => {
this.iconData = data.iconData;
this.iconObjectURL = this.iconData;
this.cdr.markForCheck();
});
break;
case 'api':
this.iconData = `${this.httpAPI}/v1/profile/icon/${firstIcon.Value}`;
this.iconObjectURL = this.iconData;
break;
default:
console.error(`Unsupported icon type ${firstIcon.Type}`);
}
this.cdr.markForCheck();
}
ngOnDestroy() {
this.destory$.next();
this.destory$.complete();
}
addFingerprint() {
this.profile.Fingerprints?.push({
Key: '',
Operation: FingerpringOperation.Equal,
Value: '',
Type: FingerprintType.Path,
});
}
removeFingerprint(idx: number) {
this.profile.Fingerprints?.splice(idx, 1);
this.profile.Fingerprints = [...this.profile.Fingerprints!];
}
removeCopyFrom(idx: number) {
this.copySettingsFrom.splice(idx, 1);
this.copySettingsFrom = [...this.copySettingsFrom];
}
addCopyFrom() {
this.copySettingsFrom = [...this.copySettingsFrom, this.selectedCopyFrom!];
this.selectedCopyFrom = null;
}
drop(event: CdkDragDrop<string[]>) {
// create a copy of the array
this.copySettingsFrom = [...this.copySettingsFrom];
moveItemInArray(
this.copySettingsFrom,
event.previousIndex,
event.currentIndex
);
this.cdr.markForCheck();
}
deleteProfile() {
this.dialog
.confirm({
caption: 'Caution',
header: 'Confirm Profile Deletion',
message: 'Do you want to delete this profile?',
buttons: [
{
id: 'delete',
class: 'danger',
text: 'Delete',
},
{
id: 'abort',
class: 'outline',
text: 'Cancel',
},
],
})
.onAction('delete', () => {
this.profileService
.deleteProfile(this.profile as AppProfile)
.subscribe({
next: () => this.dialgoRef.close('deleted'),
error: (err) => {
this.actionIndicator.error('Failed to delete profile', err);
},
});
});
}
resetIcon() {
this.iconChanged = true;
this.iconData = '';
this.iconType = '';
this.iconObjectURL = '';
}
save() {
if (!this.profile.ID) {
this.profile.ID = this.uuidv4();
}
if (!this.profile.Source) {
this.profile.Source = 'local';
}
let updateIcon: Observable<any> = of(undefined);
if (this.iconChanged) {
// delete any previously set icon
this.profile.Icons?.forEach((icon) => {
if (icon.Type === 'database') {
this.portapi.delete(icon.Value).subscribe();
}
// FIXME(ppacher): we cannot yet delete API based icons ...
});
if (this.iconData !== '') {
// save the new icon in the cache database
// FIXME(ppacher): we currently need to calls because the icon API in portmaster
// does not update the profile but just saves the file and returns the filename.
// So we still need to update the profile manually.
updateIcon = this.profileService
.setProfileIcon(this.iconData, this.iconType)
.pipe(
map(({ filename }) => {
this.profile.Icons = [
{
Type: 'api',
Value: filename,
Source: 'user',
},
];
})
);
// FIXME(ppacher): reset presentationpath
} else {
// just clear out that there was an icon
this.profile.Icons = [];
}
}
if (this.profile.Fingerprints!.length > 1) {
this.profile.PresentationPath = '';
}
const oldConfig = this.profile.Config || {};
this.profile.Config = {};
mergeDeep(
this.profile.Config,
...[...this.copySettingsFrom.map((p) => p.Config || {}), oldConfig]
);
updateIcon
.pipe(
switchMap(() => {
return this.profileService.saveProfile(this.profile as AppProfile);
})
)
.subscribe({
next: () => {
this.actionIndicator.success(
this.profile.Name!,
'Profile saved successfully'
);
this.dialgoRef.close('saved');
},
error: (err) => {
this.actionIndicator.error('Failed to save profile', err);
},
});
}
abort() {
this.dialgoRef.close('abort');
}
fileChangeEvent(fileInput: any) {
this.imageError = null;
this.iconData = '';
this.iconChanged = true;
if (fileInput.target.files && fileInput.target.files[0]) {
const max_size = 10 * 1024;
const allowed_types = [
'image/png',
'image/jpeg',
'image/svg',
'image/gif',
'image/tiff',
];
const max_height = 512;
const max_width = 512;
const file: File = fileInput.target.files[0];
if (file.size > max_size) {
this.imageError = 'Maximum size allowed is ' + max_size / 1000 + 'KB';
}
if (!allowed_types.includes(file.type)) {
this.imageError = 'Only JPG, PNG, SVG, GIF or Tiff files are allowed';
}
this.iconType = file.type;
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
const content: ArrayBuffer = e.target!.result! as ArrayBuffer;
const blob = new Blob([content], { type: file.type });
const image = new Image();
image.src = URL.createObjectURL(blob);
this.iconObjectURL = image.src;
image.onload = (rs: any) => {
const img_height = rs.currentTarget['height']!;
const img_width = rs.currentTarget['width'];
if (img_height > max_height && img_width > max_width) {
this.imageError =
'Maximum dimentions allowed ' +
max_height +
'*' +
max_width +
'px';
} else {
this.iconData = content;
}
this.cdr.markForCheck();
};
image.onerror = (err: any) => {
this.actionIndicator.error(
'Failed to get image',
this.actionIndicator.getErrorMessgae(err)
);
};
this.cdr.markForCheck();
};
reader.onerror = (err: any) => {
this.actionIndicator.error(
'Failed to get image',
this.actionIndicator.getErrorMessgae(err)
);
};
reader.readAsArrayBuffer(fileInput.target.files[0]);
}
}
private uuidv4(): string {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// This one is not really random and not RFC compliant but serves enough for fallback
// purposes if the UI is opened in a browser that does not yet support randomUUID
console.warn('Using browser with lacking support for crypto.randomUUID()');
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
}

View File

@@ -0,0 +1 @@
export * from './edit-profile-dialog';

View File

@@ -0,0 +1,19 @@
<div class="content-wrapper">
<caption>Tip</caption>
<fa-icon class="close-icon" icon="times" (click)="cancel()"></fa-icon>
<h1>Close User Interface</h1>
<span class="message">Closing the User Interface does not shut down the Portmaster. You can shut down the Portmaster
in the Settings or the Tray Notifier.</span>
<div class="actions">
<span>
<input name="neveragain" id="neveragain" [(ngModel)]="neveragain" type="checkbox"> <label for="neveragain">Never
Show
Again</label>
</span>
<button (click)="closeUI()" type="button">Close UI</button>
</div>
</div>

View File

@@ -0,0 +1,68 @@
caption {
@apply text-sm;
opacity : .6;
font-size: .6rem;
}
.content-wrapper {
display : flex;
flex-direction: column;
align-items : flex-start;
h1 {
font-size : 0.85rem;
font-weight : 500;
margin-bottom: 1rem;
}
.message,
h1 {
flex-shrink : 0;
text-overflow: ellipsis;
word-break : normal;
}
.message {
font-size: 0.75rem;
flex-grow: 1;
opacity : .6;
}
.close-icon {
position: absolute;
top : 1rem;
right : 1rem;
opacity : .7;
cursor : pointer;
&:hover {
opacity: 1;
}
}
.actions {
margin-top : 1rem;
width : 100%;
display : flex;
justify-content: space-between;
align-items : center;
button {
@apply bg-info-blue;
&.danger {
@apply bg-info-red;
}
}
&>span {
display : flex;
align-items: center;
label {
margin-left: .5rem;
user-select: none;
}
}
}
}

View File

@@ -0,0 +1,52 @@
import { OverlayRef } from '@angular/cdk/overlay';
import { Component, Inject, InjectionToken } from '@angular/core';
import { SfngDialogRef, SFNG_DIALOG_REF } from '@safing/ui';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { UIStateService } from 'src/app/services';
import { fadeInAnimation, fadeOutAnimation } from '../animations';
export const OVERLAYREF = new InjectionToken<OverlayRef>('OverlayRef');
@Component({
templateUrl: './exit-screen.html',
styleUrls: ['./exit-screen.scss'],
animations: [
fadeInAnimation,
fadeOutAnimation,
]
})
export class ExitScreenComponent {
constructor(
@Inject(SFNG_DIALOG_REF) private _dialogRef: SfngDialogRef<any>,
private stateService: UIStateService,
) { }
/** @private - used as ngModel form the template */
neveragain: boolean = false;
closeUI() {
const closeObserver = {
next: () => {
this._dialogRef.close('exit');
}
}
let close: Observable<any> = of(null);
if (this.neveragain) {
close = this.stateService.uiState()
.pipe(
map(state => {
state.hideExitScreen = true;
return state;
}),
switchMap(state => this.stateService.saveState(state)),
)
}
close.subscribe(closeObserver)
}
cancel() {
this._dialogRef.close()
}
}

View File

@@ -0,0 +1,146 @@
import { IntegrationService } from './../../integration/integration';
import { Injectable, inject } from '@angular/core';
import { PortapiService } from '@safing/portmaster-api';
import { SfngDialogService } from '@safing/ui';
import { BehaviorSubject, merge, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, map, skip, switchMap, tap, timeout } from 'rxjs/operators';
import { UIStateService } from 'src/app/services';
import { ActionIndicatorService } from '../action-indicator';
import { ExitScreenComponent } from './exit-screen';
import { INTEGRATION_SERVICE } from 'src/app/integration';
const MessageConnecting = 'Connecting to Portmaster';
const MessageShutdown = 'Shutting Down Portmaster';
const MessageRestart = 'Restarting Portmaster';
const MessageHidden = '';
export type OverlayMessage = typeof MessageConnecting
| typeof MessageShutdown
| typeof MessageRestart
| typeof MessageHidden;
@Injectable({ providedIn: 'root' })
export class ExitService {
private integration = inject(INTEGRATION_SERVICE);
private hasOverlay = false;
private _showOverlay = new BehaviorSubject<OverlayMessage>(MessageConnecting);
/**
* Emits whenever the "Connecting to ..." or "Restarting ..." overlays
* should be shown. It actually emits the message that should be shown.
* An empty string indicates the overlay should be closed.
*/
get showOverlay$() { return this._showOverlay.asObservable() }
constructor(
private stateService: UIStateService,
private portapi: PortapiService,
private dialog: SfngDialogService,
private uai: ActionIndicatorService,
) {
this.portapi.connected$
.pipe(
distinctUntilChanged(),
)
.subscribe(connected => {
if (connected) {
this._showOverlay.next(MessageHidden);
} else if (this._showOverlay.getValue() !== MessageShutdown) {
this._showOverlay.next(MessageConnecting)
}
})
let restartInProgress = false;
merge<OverlayMessage[]>(
this.portapi.sub('runtime:modules/core/event/shutdown')
.pipe(map(() => MessageShutdown)),
this.portapi.sub('runtime:modules/core/event/restart')
.pipe(
tap(() => restartInProgress = true),
map(() => MessageRestart)
),
)
.pipe(
tap(msg => this._showOverlay.next(msg)),
switchMap(() => this.portapi.connected$),
distinctUntilChanged(),
skip(1),
debounceTime(1000), // make sure we display the "shutdown" overlay for at least a second
)
.subscribe(connected => {
if (this._showOverlay.getValue() === MessageShutdown) {
setTimeout(() => {
this.integration.exitApp();
}, 1000)
}
if (connected && restartInProgress) {
restartInProgress = false;
this.portapi.reloadUI()
.pipe(
tap(() => {
setTimeout(() => window.location.reload(), 1000)
})
)
.subscribe(this.uai.httpObserver(
'Reloading UI ...',
'Failed to Reload UI',
))
}
})
window.addEventListener('beforeunload', () => {
// best effort. may not work all the time depending on
// the current websocket buffer state
this.portapi.bridgeAPI('ui/reload', 'POST').subscribe();
})
this.integration.onExitRequest(() => {
this.stateService.uiState()
// make sure to not wait for the portmaster to start
.pipe(timeout(1000), catchError(() => of(null)))
.subscribe(state => {
if (state?.hideExitScreen) {
this.integration.exitApp();
return
}
if (this.hasOverlay) {
return;
}
this.hasOverlay = true;
this.dialog.create(ExitScreenComponent, { autoclose: true })
.onAction('exit', () => this.integration.exitApp())
.onClose.subscribe(() => this.hasOverlay = false);
})
})
}
shutdownPortmaster() {
this.dialog.confirm({
canCancel: true,
header: 'Shutting Down Portmaster',
message: 'Shutting down the Portmaster will stop all Portmaster components and will leave your system unprotected!',
caption: 'Caution',
buttons: [
{
id: 'shutdown',
class: 'danger',
text: 'Shut Down Portmaster'
}
]
})
.onAction('shutdown', () => {
this.portapi.shutdownPortmaster()
.subscribe(this.uai.httpObserver(
'Shutting Down ...',
'Failed to Shut Down',
))
})
}
}

View File

@@ -0,0 +1,2 @@
export * from './exit.service';
export * from './exit-screen';

View File

@@ -0,0 +1,93 @@
import { Directive, EmbeddedViewRef, Input, isDevMode, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { ExpertiseLevelNumber } from '@safing/portmaster-api';
import { Subscription } from 'rxjs';
import { ExpertiseService } from './expertise.service';
// ExpertiseLevelOverwrite may be called to display a DOM node decorated
// with [appExpertiseLevel] even if the current user setting does not
// match the required expertise.
export type ExpertiseLevelOverwrite<T> = (lvl: ExpertiseLevelNumber, data: T) => boolean;
@Directive({
selector: '[appExpertiseLevel]',
})
export class ExpertiseDirective<T> implements OnInit, OnDestroy {
private allowedValue: ExpertiseLevelNumber = ExpertiseLevelNumber.user;
private subscription = Subscription.EMPTY;
private view: EmbeddedViewRef<any> | null = null;
@Input()
set appExpertiseLevelOverwrite(fn: ExpertiseLevelOverwrite<T>) {
this._levelOverwriteFn = fn;
this.update();
}
private _levelOverwriteFn: ExpertiseLevelOverwrite<T> | null = null;
@Input()
set appExpertiseLevelData(d: T) {
this._data = d;
this.update();
}
private _data: T | undefined = undefined;
@Input()
set appExpertiseLevel(lvl: ExpertiseLevelNumber | string) {
if (typeof lvl === 'string') {
lvl = ExpertiseLevelNumber[lvl as any];
}
if (lvl === undefined) {
if (isDevMode()) {
throw new Error(`[appExpertiseLevel] got undefined expertise-level value`);
}
return;
}
if (lvl !== this.allowedValue) {
this.allowedValue = lvl as ExpertiseLevelNumber;
this.update();
}
}
private update() {
const current = ExpertiseLevelNumber[this.expertiseService.currentLevel];
let hide = current < this.allowedValue;
// if there's an overwrite function defined make sue to check that.
if (hide && !!this._levelOverwriteFn) {
hide = !this._levelOverwriteFn(current, this._data!);
if (!hide) {
console.log("overwritten", current, this._data);
}
}
if (hide) {
if (!!this.view) {
this.view.destroy();
this.viewContainer.clear();
this.view = null;
}
return
}
if (!!this.view) {
this.view.markForCheck();
return;
}
this.view = this.viewContainer.createEmbeddedView(this.templateRef);
this.view.detectChanges();
}
constructor(
private expertiseService: ExpertiseService,
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) { }
ngOnInit() {
this.subscription = this.expertiseService.change.subscribe(() => this.update())
}
ngOnDestroy() {
this.viewContainer.clear();
this.subscription.unsubscribe();
}
}

View File

@@ -0,0 +1,16 @@
<sfng-tipup key="uiMode" placement="left" [anchor]="host"></sfng-tipup>
<sfng-select [ngModel]="(currentLevel | async)" (ngModelChange)="selectLevel($event)" mode="single" sortItems="false">
<sfng-select-item *sfngSelectValue="expertiseLevels.User">
Simple Interface
</sfng-select-item>
<sfng-select-item *sfngSelectValue="expertiseLevels.Expert">
Advanced Interface
</sfng-select-item>
<ng-container *ngIf="savedLevel === expertiseLevels.Developer">
<sfng-select-item *sfngSelectValue="expertiseLevels.Developer">
Developer Interface
</sfng-select-item>
</ng-container>
</sfng-select>

View File

@@ -0,0 +1,12 @@
:host {
display: flex;
@apply pl-2;
user-select: none;
flex-direction: row;
align-items: center;
justify-content: center;
}
sfng-tipup {
margin-right: 0.5rem;
}

View File

@@ -0,0 +1,38 @@
import { Component, ElementRef } from '@angular/core';
import { ExpertiseLevel } from '@safing/portmaster-api';
import { ExpertiseService } from './expertise.service';
@Component({
selector: 'app-expertise',
templateUrl: './expertise-switch.html',
styleUrls: ['./expertise-switch.scss']
})
export class ExpertiseComponent {
/** @private provide the expertise-level enums to the template */
readonly expertiseLevels = ExpertiseLevel;
currentLevel = this.expertiseService.change;
/**
* @private
* Getter to access the expertise level as saved in the database
*/
get savedLevel() {
return this.expertiseService.savedLevel;
}
constructor(
private expertiseService: ExpertiseService,
public host: ElementRef<any>,
) { }
/**
* @private
* Configures a new expertise level
*
* @param lvl The new expertise level to use
*/
selectLevel(lvl: ExpertiseLevel) {
this.expertiseService.setLevel(lvl);
}
}

View File

@@ -0,0 +1,24 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { SfngSelectModule, SfngTipUpModule } from "@safing/ui";
import { ExpertiseDirective } from "./expertise-directive";
import { ExpertiseComponent } from "./expertise-switch";
@NgModule({
imports: [
SfngSelectModule,
CommonModule,
SfngTipUpModule,
FormsModule,
],
declarations: [
ExpertiseComponent,
ExpertiseDirective,
],
exports: [
ExpertiseComponent,
ExpertiseDirective,
]
})
export class ExpertiseModule { }

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@angular/core';
import { ConfigService, ExpertiseLevel, StringSetting } from '@safing/portmaster-api';
import { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged, map, repeat, share } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class ExpertiseService {
/** If the user overwrites the expertise level on a per-page setting we track that here */
private _localOverwrite: ExpertiseLevel | null = null;
private _currentLevel: ExpertiseLevel = ExpertiseLevel.User;
/** Watches the expertise level as saved in the configuration */
private _savedLevel$ = this.configService.watch<StringSetting>('core/expertiseLevel')
.pipe(
repeat({ delay: 2000 }),
map(upd => {
return upd as ExpertiseLevel;
}),
distinctUntilChanged(),
share(),
);
private level$ = new BehaviorSubject(ExpertiseLevel.User);
get currentLevel() {
return this._localOverwrite === null
? this._currentLevel
: this._localOverwrite;
}
get savedLevel() {
return this._currentLevel;
}
get change(): Observable<ExpertiseLevel> {
return this.level$.asObservable();
}
constructor(private configService: ConfigService) {
this._savedLevel$
.subscribe(lvl => {
this._currentLevel = lvl;
if (this._localOverwrite === null) {
this.level$.next(lvl);
}
});
}
setLevel(lvl: ExpertiseLevel | null) {
if (lvl === this._currentLevel) {
lvl = null;
}
this._localOverwrite = lvl;
if (!!lvl) {
this.level$.next(lvl);
} else {
this.level$.next(this._currentLevel!);
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './expertise-directive';
export * from './expertise-switch';
export * from './expertise.service';

View File

@@ -0,0 +1,53 @@
import { isPlatformBrowser } from '@angular/common';
import {
Directive,
HostBinding, HostListener, Inject,
Input, OnChanges, PLATFORM_ID, inject
} from '@angular/core';
import { INTEGRATION_SERVICE } from '../integration';
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: 'a[href]'
})
export class ExternalLinkDirective implements OnChanges {
private readonly integration = inject(INTEGRATION_SERVICE);
@HostBinding('attr.rel')
relAttr = '';
@HostBinding('attr.target')
targetAttr = '';
@HostBinding('attr.href')
hrefAttr = '';
@Input()
href: string = '';
constructor(@Inject(PLATFORM_ID) private platformId: string) { }
@HostListener('click', ['$event'])
onClick(event: Event) {
event.preventDefault();
event.stopPropagation();
this.integration.openExternal(this.href);
}
ngOnChanges() {
this.hrefAttr = this.href;
if (this.isLinkExternal()) {
this.relAttr = 'noopener';
this.targetAttr = '_blank';
}
}
private isLinkExternal() {
return (
isPlatformBrowser(this.platformId) &&
!this.href.includes(location.hostname)
);
}
}

View File

@@ -0,0 +1,106 @@
<ng-container *appExpertiseLevel="'developer'">
<div *ngIf="packageHasSPN || packageHasHistory" class="pb-4">
<div class="flex flex-row justify-center w-full gap-2" [routerLink]="['/dashboard']">
<span *ngIf="packageHasHistory">
<svg
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="18px" class="feature-icon" [class.feature-icon-off]="!historyEnabled">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</span>
<span *ngIf="packageHasSPN">
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor"
width="18px" class="feature-icon" [class.feature-icon-off]="!spnEnabled">
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
<path
d="M6.488 15.581c.782.781.782 2.048 0 2.829-.782.781-2.049.781-2.83 0-.782-.781-.782-2.048 0-2.829.781-.781 2.048-.781 2.83 0M13.415 3.586c.782.781.782 2.048 0 2.829-.782.781-2.049.781-2.83 0-.782-.781-.782-2.048 0-2.829.781-.781 2.049-.781 2.83 0M20.343 15.58c.782.781.782 2.048 0 2.829-.782.781-2.049.781-2.83 0-.782-.781-.782-2.048 0-2.829.781-.781 2.048-.781 2.83 0">
</path>
<path
d="M17.721 18.581C16.269 20.071 14.246 21 12 21c-1.146 0-2.231-.246-3.215-.68M4.293 15.152c-.56-1.999-.352-4.21.769-6.151.574-.995 1.334-1.814 2.205-2.449M13.975 5.254c2.017.512 3.834 1.799 4.957 3.743.569.985.899 2.041 1.018 3.103">
</path>
</g>
</svg>
</span>
</div>
<div *ngIf="spnEnabled" class="flex flex-row justify-center w-full gap-2 pt-2">
<div class="status-info" [routerLink]="['/spn']">
<span [ngSwitch]="spnStatus?.Status" [sfng-tooltip]="spnStatus?.Status === 'connected' ? spnTooltipTemplate : null">
<ng-template ngSwitchCase="disabled">
SPN is connecting...<br>
Fail-safe blocking enabled
</ng-template>
<ng-template ngSwitchCase="failed">
<span class="text-red-300">SPN failed to connect</span><br>
Fail-safe blocking enabled
</ng-template>
<ng-template ngSwitchCase="connecting">
SPN is connecting...<br>
Fail-safe blocking enabled
</ng-template>
<ng-template ngSwitchCase="connected">
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" fill="currentColor" viewBox="0 0 16 16"
class="text-tertiary" class="inline-block -mt-0.5">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-8 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
</svg>
{{ spnStatus?.HomeHubName }}
<span class="text-tertiary">
in
</span>
<span *ngIf="!!spnStatus?.ConnectedCountry?.Code" [appCountryFlags]="spnStatus!.ConnectedCountry!.Code"></span>
{{ spnStatus?.ConnectedCountry?.Name }}
</ng-template>
</span>
</div>
</div>
<!-- </div>
<h2 class="p-0 m-0 font-light outline-none cursor-pointer test-base" [routerLink]="['/spn']">SPN</h2>
<sfng-toggle [ngModel]="spnEnabled" (ngModelChange)="setSPNEnabled($event)" class="absolute top-1 right-0"></sfng-toggle>
<ng-container *ngIf="spnEnabled">
<span [ngSwitch]="spnStatus?.Status" class="-mt-1 text-xs font-medium text-secondary">
<ng-template ngSwitchCase="disabled">
Disabled
</ng-template>
<ng-template ngSwitchCase="failed">
Failed to connect<br>
Fail-safe blocking enabled
</ng-template>
<ng-template ngSwitchCase="connecting">
Connecting...<br>
Fail-safe blocking enabled
</ng-template>
<ng-template ngSwitchCase="connected">
You're protected
</ng-template>
</span>
<br>
<span class="text-secondary text-xxs" *appExpertiseLevel="'advanced'">
Home: <u>{{ spnStatus?.ConnectedIP }}</u> via <u>{{ spnStatus?.ConnectedTransport}}</u>
</span>
</ng-container>
</div>
<div *ngIf="packageHasHistory" class="relative mt-3">
<h2 class="p-0 m-0 font-light outline-none cursor-pointer test-base" [routerLink]="['/monitor']">History</h2>
<sfng-toggle [ngModel]="historyEnabled" (ngModelChange)="setHistoryEnabled($event)" class="absolute top-1 right-0"></sfng-toggle>
</div> -->
</div>
</ng-container>
<ng-template #spnTooltipTemplate>
SPN Home (Entry) Node
<ul class="pl-4 list-disc">
<li>Connected to {{ spnStatus?.ConnectedIP }}</li>
<li>Uplink is always encrypted</li>
<li>Built with transport/decoy {{ spnStatus?.ConnectedTransport }}</li>
</ul>
</ng-template>

View File

@@ -0,0 +1,15 @@
.feature-icon {
@apply text-primary text-opacity-80;
&.feature-icon-off {
opacity: 0.25;
}
}
.status-info {
@apply text-primary text-opacity-80 text-xxs text-center;
&:hover {
cursor: default;
}
}

View File

@@ -0,0 +1,98 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BoolSetting, ConfigService, FeatureID, Netquery, SPNService, SPNStatus, UserProfile } from "@safing/portmaster-api";
import { catchError, of } from "rxjs";
import { fadeInAnimation, fadeOutAnimation } from "../animations";
import { CountryFlagModule } from 'src/app/shared/country-flag';
@Component({
selector: 'app-feature-scout',
templateUrl: './feature-scout.html',
styleUrls: [
'./feature-scout.scss'
],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeInAnimation,
fadeOutAnimation,
]
})
export class FeatureScoutComponent implements OnInit {
private destroyRef = inject(DestroyRef);
/** The current SPN user profile */
profile: UserProfile | null = null;
/** Whether or not the SPN is currently enabled */
spnEnabled = false;
/** The current status of the SPN module */
spnStatus: SPNStatus | null = null;
/** Whether or not the Network History is currently enabled */
historyEnabled = false;
/** Returns whether or not the current package has the SPN feature */
get packageHasSPN() {
return this.profile?.current_plan?.feature_ids?.includes(FeatureID.SPN)
}
/** Returns whether or not the current package has the Network History feature */
get packageHasHistory() {
return this.profile?.current_plan?.feature_ids?.includes(FeatureID.History)
}
constructor(
private configService: ConfigService,
private spnService: SPNService,
private cdr: ChangeDetectorRef,
) { }
ngOnInit(): void {
this.spnService
.profile$
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError(() => of(null))
)
.subscribe(profile => {
this.profile = profile || null;
this.cdr.markForCheck();
});
this.spnService.status$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(status => {
this.spnStatus = status;
this.cdr.markForCheck();
})
this.configService.watch<BoolSetting>("spn/enable")
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(value => {
this.spnEnabled = value;
this.cdr.markForCheck();
});
this.configService.watch<BoolSetting>("history/enable")
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(value => {
this.historyEnabled = value;
this.cdr.markForCheck();
});
}
setSPNEnabled(v: boolean) {
this.configService.save(`spn/enable`, v)
.subscribe();
}
setHistoryEnabled(v: boolean) {
this.configService.save(`history/enable`, v)
.subscribe();
}
}

View File

@@ -0,0 +1 @@
export * from './feature-scout';

View File

@@ -0,0 +1,32 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Directive, ElementRef, Input, OnInit } from "@angular/core";
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: '[autoFocus]',
})
export class AutoFocusDirective implements OnInit {
private _focus = true;
private _afterInit = false;
@Input('autoFocus')
set focus(v: any) {
this._focus = coerceBooleanProperty(v) !== false;
if (this._afterInit && this.elementRef) {
this.elementRef.nativeElement.focus()
}
}
constructor(private elementRef: ElementRef) { }
ngOnInit(): void {
setTimeout(() => {
if (this._focus) {
this.elementRef.nativeElement.focus();
}
}, 100)
this._afterInit = true;
}
}

View File

@@ -0,0 +1,16 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { AutoFocusDirective } from "./focus.directive";
@NgModule({
imports: [
CommonModule,
],
declarations: [
AutoFocusDirective,
],
exports: [
AutoFocusDirective,
]
})
export class SfngFocusModule { }

View File

@@ -0,0 +1,2 @@
export { AutoFocusDirective } from './focus.directive';
export * from './focus.module';

View File

@@ -0,0 +1,105 @@
import { Injectable } from '@angular/core';
import { deepClone } from '@safing/portmaster-api';
import Fuse from 'fuse.js';
export type FuseResult<T> = Fuse.FuseResult<T & {
highlighted?: string;
}>;
export interface FuseSearchOpts<T> extends Fuse.IFuseOptions<T> {
minSearchTermLength?: number;
maximumScore?: number;
}
@Injectable({
providedIn: 'root'
})
export class FuzzySearchService {
readonly defaultOptions: FuseSearchOpts<any> = {
minMatchCharLength: 2,
includeMatches: true,
includeScore: true,
minSearchTermLength: 3,
};
searchList<T extends {}>(list: Array<T>, searchTerms: string, options: FuseSearchOpts<T> & { disableHighlight?: boolean } = {}): Array<FuseResult<T>> {
const opts: FuseSearchOpts<T> = {
...this.defaultOptions,
...options,
}
let result: FuseResult<T>[] = [];
if (searchTerms && searchTerms.length >= (opts.minSearchTermLength || 0)) {
let fuse = new Fuse(list, opts);
result = fuse.search(searchTerms);
} else {
result = list.map((item, index) => ({
item: item,
refIndex: index,
score: 0,
}))
}
if (!!options.disableHighlight) {
return result;
}
return this.handleHighlight(result, options);
}
private handleHighlight<T extends {}>(result: FuseResult<T>[], options: FuseSearchOpts<T>): FuseResult<T>[] {
return result.map(matchObject => {
matchObject.item = deepClone(matchObject.item);
if (!matchObject.matches) {
return matchObject;
}
for (let match of matchObject.matches!) {
const indices = match.indices;
let highlightOffset: number = 0;
for (let indice of indices) {
let initialValue = getFromMatch(matchObject, match);
const startOffset = indice[0] + highlightOffset;
const endOffset = indice[1] + highlightOffset + 1;
if (endOffset - startOffset < 4) {
continue
}
let highlightedTerm = initialValue.substring(startOffset, endOffset);
let newValue = initialValue.substring(0, startOffset) + '<em class="search-result">' + highlightedTerm + '</em>' + initialValue.substring(endOffset);
highlightOffset += '<em class="search-result"></em>'.length;
setOnMatch(matchObject, match, newValue);
}
}
return matchObject;
});
}
}
function getFromMatch<T>(result: Fuse.FuseResult<T>, match: Fuse.FuseResultMatch): string {
if (match.refIndex === undefined) {
return (result.item as any)[match.key!];
}
return (result.item as any)[match.key!][match.refIndex];
}
function setOnMatch<T>(result: Fuse.FuseResult<T>, match: Fuse.FuseResultMatch, value: string) {
if (match.refIndex === undefined) {
(result.item as any)[match.key!] = value;
return;
}
(result.item as any)[match.key!][match.refIndex] = value;
}

View File

@@ -0,0 +1,4 @@
import Fuse from 'fuse.js';
export { FuseSearchOpts, FuzzySearchService } from './fuse.service';
export { FuzzySearchPipe } from './search-pipe';

View File

@@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import { FuseResult, FuseSearchOpts, FuzzySearchService } from './fuse.service';
@Pipe({
name: 'fuzzySearch',
})
export class FuzzySearchPipe implements PipeTransform {
constructor(
private FusejsService: FuzzySearchService
) { }
transform<T extends object>(elements: Array<T>,
searchTerms: string,
options: FuseSearchOpts<T> = {}): Array<FuseResult<T>> {
return this.FusejsService.searchList(elements, searchTerms, options);
}
}

View File

@@ -0,0 +1 @@
export { LoadingComponent } from './loading';

View File

@@ -0,0 +1,3 @@
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>

View File

@@ -0,0 +1,52 @@
:host {
--internal-dot-size : var(--dot-size, 5px);
--internal-animation-speed: var(--animation-speed, 1.3s);
display : flex;
position : relative;
justify-content: space-evenly;
align-items : flex-end;
width : var(--animation-width, calc(var(--internal-dot-size) * 5));
height: calc(var(--internal-dot-size) * 3);
&.animate {
.dot {
display : block;
flex-shrink: 0;
flex-grow : 0;
width : var(--internal-dot-size);
height : var(--internal-dot-size);
@apply shadow-inner-xs;
@apply rounded-full;
@apply bg-buttons-icon;
animation: wave var(--internal-animation-speed) linear infinite;
&:nth-child(2) {
animation-delay: -1.1s;
}
&:nth-child(3) {
animation-delay: -0.9s;
}
}
}
}
@keyframes wave {
0%,
60%,
100% {
transform: initial;
@apply bg-buttons-light;
}
90% {
transform : translateY(var(--loading-height, -9px));
background-color: white;
}
}

View File

@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding } from '@angular/core';
@Component({
selector: 'app-loading',
templateUrl: './loading.html',
styleUrls: ['./loading.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoadingComponent {
@HostBinding('class.animate')
_animate = true;
constructor(private changeDetectorRef: ChangeDetectorRef) { }
}

View File

@@ -0,0 +1,2 @@
export { MenuComponent, MenuTriggerComponent, MenuItemComponent, MenuGroupComponent } from './menu';
export * from './menu.module';

View File

@@ -0,0 +1,13 @@
:host {
display: block;
width: 100%;
@apply p-1;
@apply px-4;
@apply text-secondary;
display: block;
text-transform: uppercase;
font-size: 0.7rem;
opacity: .7;
}

View File

@@ -0,0 +1,17 @@
:host {
@apply block w-full;
cursor: pointer;
@apply p-2;
@apply px-4 text-primary text-xxs;
font-weight: 500;
&:hover {
@apply bg-gray-300;
}
&.disabled {
cursor: not-allowed;
opacity: 0.5;
}
}

View File

@@ -0,0 +1,14 @@
<div (click)="toggle($event)" cdkOverlayOrigin>
<div class="text-opacity-75 dropdown text-primary hover:text-primary hover:text-opacity-100">
<ng-container *ngIf="!useContent">
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 2" viewBox="0 0 11.86 19.86" fill="currentColor">
<circle cx="1.93" cy="1.93" r="1.93" />
<circle cx="1.93" cy="9.93" r="1.93" />
<circle cx="1.93" cy="17.93" r="1.93" />
</svg>
</ng-container>
<ng-container *ngIf="useContent">
<ng-content></ng-content>
</ng-container>
</div>
</div>

View File

@@ -0,0 +1,41 @@
:host {
user-select: none;
margin-right: .5rem;
display: block;
@apply rounded-t-sm;
}
div {
cursor: pointer;
display: flex;
@apply rounded-t;
flex-grow: 0;
transition: all .1s ease-in-out;
justify-content: center;
align-items: center;
@apply py-1;
@apply px-3;
}
.dropdown {
margin-left: 1px;
height: auto;
padding: 0;
margin: 0;
svg {
opacity: 0.7;
fill: var(--text-primary);
width: 0.51rem;
transition: all cubic-bezier(0.175, 0.885, 0.32, 1.275) .2s;
transform: rotate(90deg);
position: relative;
top: 3px;
}
}
:host.active {
@apply bg-gray-400;
color: white !important;
}

View File

@@ -0,0 +1,6 @@
<sfng-dropdown externalTrigger="true" #dropdown="sfngDropdown" [offsetY]="offsetY || 0" [offsetX]="offsetX"
[overlayClass]="overlayClass || ''">
<div class="flex flex-col flex-grow w-full h-full bg-gray-400 shadow select-none">
<ng-content></ng-content>
</div>
</sfng-dropdown>

View File

@@ -0,0 +1,26 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { SfngDropDownModule } from "@safing/ui";
import { MenuComponent, MenuGroupComponent, MenuItemComponent, MenuTriggerComponent } from "./menu";
@NgModule({
imports: [
SfngDropDownModule,
CommonModule,
OverlayModule,
],
declarations: [
MenuComponent,
MenuGroupComponent,
MenuTriggerComponent,
MenuItemComponent,
],
exports: [
MenuComponent,
MenuGroupComponent,
MenuTriggerComponent,
MenuItemComponent,
],
})
export class SfngMenuModule { }

View File

@@ -0,0 +1,111 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { CdkOverlayOrigin } from '@angular/cdk/overlay';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, EventEmitter, HostBinding, HostListener, Input, Output, QueryList, ViewChild } from '@angular/core';
import { SfngDropdownComponent } from '@safing/ui';
@Component({
selector: 'app-menu-trigger',
templateUrl: './menu-trigger.html',
styleUrls: ['./menu-trigger.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MenuTriggerComponent {
@ViewChild(CdkOverlayOrigin, { static: true })
origin!: CdkOverlayOrigin;
@Input()
menu: MenuComponent | null = null;
@Input()
set useContent(v: any) {
this._useContent = coerceBooleanProperty(v);
}
get useContent() { return this._useContent; }
private _useContent: boolean = false;
@HostBinding('class.active')
get isOpen() {
if (!this.menu) {
return false;
}
return this.menu.dropdown.isOpen;
}
constructor(
public changeDetectorRef: ChangeDetectorRef,
) { }
toggle(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
this.menu?.dropdown.toggle(this.origin)
}
}
@Component({
selector: 'app-menu-item',
template: '<ng-content></ng-content>',
styleUrls: ['./menu-item.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MenuItemComponent {
@Input()
@HostBinding('class.disabled')
set disabled(v: any) {
this._disabled = coerceBooleanProperty(v);
}
get disabled() { return this._disabled; }
private _disabled: boolean = false;
@HostListener('click', ['$event'])
closeMenu(event: MouseEvent) {
if (this.disabled) {
return;
}
this.activate.next(event);
this.menu.dropdown.close();
}
/**
* activate fires when the menu item is clicked.
* Use activate rather than (click)="" if you want
* [disabled] to be considered.
*/
@Output()
activate = new EventEmitter<MouseEvent>();
constructor(private menu: MenuComponent) { }
}
@Component({
selector: 'app-menu-group',
template: '<ng-content></ng-content>',
styleUrls: ['./menu-group.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MenuGroupComponent { }
@Component({
selector: 'app-menu',
exportAs: 'appMenu',
templateUrl: './menu.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MenuComponent {
@ContentChildren(MenuItemComponent)
items: QueryList<MenuItemComponent> | null = null;
@ViewChild(SfngDropdownComponent, { static: true })
dropdown!: SfngDropdownComponent;
@Input()
offsetY?: string | number;
@Input()
offsetX?: string | number;
@Input()
overlayClass?: string;
}

View File

@@ -0,0 +1,3 @@
export { MultiSwitchComponent } from './multi-switch';
export { SwitchItemComponent } from './switch-item';
export * from './multi-switch.module';

Some files were not shown because too many files have changed in this diff Show More