Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin
This commit is contained in:
@@ -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 { }
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
2
desktop/angular/src/app/shared/action-indicator/index.ts
Normal file
2
desktop/angular/src/app/shared/action-indicator/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './action-indicator.service';
|
||||
export * from './action-indicator.module';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
desktop/angular/src/app/shared/action-indicator/indicator.ts
Normal file
78
desktop/angular/src/app/shared/action-indicator/indicator.ts
Normal 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();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
111
desktop/angular/src/app/shared/animations.ts
Normal file
111
desktop/angular/src/app/shared/animations.ts
Normal 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 })
|
||||
]),
|
||||
]
|
||||
)
|
||||
118
desktop/angular/src/app/shared/app-icon/app-icon-resolver.ts
Normal file
118
desktop/angular/src/app/shared/app-icon/app-icon-resolver.ts
Normal 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$);
|
||||
}
|
||||
}
|
||||
9
desktop/angular/src/app/shared/app-icon/app-icon.html
Normal file
9
desktop/angular/src/app/shared/app-icon/app-icon.html
Normal 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>
|
||||
23
desktop/angular/src/app/shared/app-icon/app-icon.module.ts
Normal file
23
desktop/angular/src/app/shared/app-icon/app-icon.module.ts
Normal 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 { }
|
||||
28
desktop/angular/src/app/shared/app-icon/app-icon.scss
Normal file
28
desktop/angular/src/app/shared/app-icon/app-icon.scss
Normal 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%;
|
||||
}
|
||||
312
desktop/angular/src/app/shared/app-icon/app-icon.ts
Normal file
312
desktop/angular/src/app/shared/app-icon/app-icon.ts
Normal 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(
|
||||
/( |<([^>]+)>)/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)',
|
||||
];
|
||||
2
desktop/angular/src/app/shared/app-icon/index.ts
Normal file
2
desktop/angular/src/app/shared/app-icon/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppIconComponent } from './app-icon';
|
||||
export { SfngAppIconModule } from './app-icon.module';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './basic-setting';
|
||||
111
desktop/angular/src/app/shared/config/config-settings.html
Normal file
111
desktop/angular/src/app/shared/config/config-settings.html
Normal 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>
|
||||
95
desktop/angular/src/app/shared/config/config-settings.scss
Normal file
95
desktop/angular/src/app/shared/config/config-settings.scss
Normal 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;
|
||||
}
|
||||
606
desktop/angular/src/app/shared/config/config-settings.ts
Normal file
606
desktop/angular/src/app/shared/config/config-settings.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
77
desktop/angular/src/app/shared/config/config.module.ts
Normal file
77
desktop/angular/src/app/shared/config/config.module.ts
Normal 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 { }
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { FilterListComponent } from './filter-list';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './generic-setting';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)" />
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
185
desktop/angular/src/app/shared/config/import-dialog/selection.ts
Normal file
185
desktop/angular/src/app/shared/config/import-dialog/selection.ts
Normal 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('')
|
||||
}
|
||||
8
desktop/angular/src/app/shared/config/index.ts
Normal file
8
desktop/angular/src/app/shared/config/index.ts
Normal 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';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OrderedListComponent } from './ordered-list';
|
||||
export { OrderedListItemComponent } from './item';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
desktop/angular/src/app/shared/config/ordererd-list/item.ts
Normal file
87
desktop/angular/src/app/shared/config/ordererd-list/item.ts
Normal 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) { }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
2
desktop/angular/src/app/shared/config/rule-list/index.ts
Normal file
2
desktop/angular/src/app/shared/config/rule-list/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './list-item';
|
||||
export * from './rule-list';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
221
desktop/angular/src/app/shared/config/rule-list/list-item.ts
Normal file
221
desktop/angular/src/app/shared/config/rule-list/list-item.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
226
desktop/angular/src/app/shared/config/rule-list/rule-list.ts
Normal file
226
desktop/angular/src/app/shared/config/rule-list/rule-list.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
21
desktop/angular/src/app/shared/config/safe.pipe.ts
Normal file
21
desktop/angular/src/app/shared/config/safe.pipe.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<span class="counter">{{ count | prettyCount }}</span>
|
||||
<div class="pill">
|
||||
<div class="percentage" [style.width.%]="allowedPercentage"></div>
|
||||
</div>
|
||||
@@ -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 { }
|
||||
@@ -0,0 +1,8 @@
|
||||
@import '../../../theme/mixins/_pill.scss';
|
||||
|
||||
:host {
|
||||
@include pill-container;
|
||||
@apply pl-2;
|
||||
@apply bg-buttons-dark;
|
||||
@apply w-20;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
18
desktop/angular/src/app/shared/count-indicator/count.pipe.ts
Normal file
18
desktop/angular/src/app/shared/count-indicator/count.pipe.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
2
desktop/angular/src/app/shared/count-indicator/index.ts
Normal file
2
desktop/angular/src/app/shared/count-indicator/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './count-indicator';
|
||||
export * from './count-indicator.module';
|
||||
45
desktop/angular/src/app/shared/country-flag/country-flag.ts
Normal file
45
desktop/angular/src/app/shared/country-flag/country-flag.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CountryFlagDirective } from './country-flag';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CountryFlagDirective
|
||||
],
|
||||
exports: [
|
||||
CountryFlagDirective,
|
||||
]
|
||||
})
|
||||
export class CountryFlagModule { }
|
||||
2
desktop/angular/src/app/shared/country-flag/index.ts
Normal file
2
desktop/angular/src/app/shared/country-flag/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './country-flag';
|
||||
export * from './country.module';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './edit-profile-dialog';
|
||||
19
desktop/angular/src/app/shared/exit-screen/exit-screen.html
Normal file
19
desktop/angular/src/app/shared/exit-screen/exit-screen.html
Normal 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>
|
||||
68
desktop/angular/src/app/shared/exit-screen/exit-screen.scss
Normal file
68
desktop/angular/src/app/shared/exit-screen/exit-screen.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
desktop/angular/src/app/shared/exit-screen/exit-screen.ts
Normal file
52
desktop/angular/src/app/shared/exit-screen/exit-screen.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
146
desktop/angular/src/app/shared/exit-screen/exit.service.ts
Normal file
146
desktop/angular/src/app/shared/exit-screen/exit.service.ts
Normal 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',
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
2
desktop/angular/src/app/shared/exit-screen/index.ts
Normal file
2
desktop/angular/src/app/shared/exit-screen/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './exit.service';
|
||||
export * from './exit-screen';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
38
desktop/angular/src/app/shared/expertise/expertise-switch.ts
Normal file
38
desktop/angular/src/app/shared/expertise/expertise-switch.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
desktop/angular/src/app/shared/expertise/expertise.module.ts
Normal file
24
desktop/angular/src/app/shared/expertise/expertise.module.ts
Normal 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 { }
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
desktop/angular/src/app/shared/expertise/index.ts
Normal file
3
desktop/angular/src/app/shared/expertise/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './expertise-directive';
|
||||
export * from './expertise-switch';
|
||||
export * from './expertise.service';
|
||||
53
desktop/angular/src/app/shared/external-link.directive.ts
Normal file
53
desktop/angular/src/app/shared/external-link.directive.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
106
desktop/angular/src/app/shared/feature-scout/feature-scout.html
Normal file
106
desktop/angular/src/app/shared/feature-scout/feature-scout.html
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
1
desktop/angular/src/app/shared/feature-scout/index.ts
Normal file
1
desktop/angular/src/app/shared/feature-scout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './feature-scout';
|
||||
32
desktop/angular/src/app/shared/focus/focus.directive.ts
Normal file
32
desktop/angular/src/app/shared/focus/focus.directive.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
desktop/angular/src/app/shared/focus/focus.module.ts
Normal file
16
desktop/angular/src/app/shared/focus/focus.module.ts
Normal 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 { }
|
||||
2
desktop/angular/src/app/shared/focus/index.ts
Normal file
2
desktop/angular/src/app/shared/focus/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AutoFocusDirective } from './focus.directive';
|
||||
export * from './focus.module';
|
||||
105
desktop/angular/src/app/shared/fuzzySearch/fuse.service.ts
Normal file
105
desktop/angular/src/app/shared/fuzzySearch/fuse.service.ts
Normal 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;
|
||||
}
|
||||
4
desktop/angular/src/app/shared/fuzzySearch/index.ts
Normal file
4
desktop/angular/src/app/shared/fuzzySearch/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
export { FuseSearchOpts, FuzzySearchService } from './fuse.service';
|
||||
export { FuzzySearchPipe } from './search-pipe';
|
||||
19
desktop/angular/src/app/shared/fuzzySearch/search-pipe.ts
Normal file
19
desktop/angular/src/app/shared/fuzzySearch/search-pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
desktop/angular/src/app/shared/loading/index.ts
Normal file
1
desktop/angular/src/app/shared/loading/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LoadingComponent } from './loading';
|
||||
3
desktop/angular/src/app/shared/loading/loading.html
Normal file
3
desktop/angular/src/app/shared/loading/loading.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
52
desktop/angular/src/app/shared/loading/loading.scss
Normal file
52
desktop/angular/src/app/shared/loading/loading.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
14
desktop/angular/src/app/shared/loading/loading.ts
Normal file
14
desktop/angular/src/app/shared/loading/loading.ts
Normal 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) { }
|
||||
}
|
||||
2
desktop/angular/src/app/shared/menu/index.ts
Normal file
2
desktop/angular/src/app/shared/menu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MenuComponent, MenuTriggerComponent, MenuItemComponent, MenuGroupComponent } from './menu';
|
||||
export * from './menu.module';
|
||||
13
desktop/angular/src/app/shared/menu/menu-group.scss
Normal file
13
desktop/angular/src/app/shared/menu/menu-group.scss
Normal 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;
|
||||
}
|
||||
17
desktop/angular/src/app/shared/menu/menu-item.scss
Normal file
17
desktop/angular/src/app/shared/menu/menu-item.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
14
desktop/angular/src/app/shared/menu/menu-trigger.html
Normal file
14
desktop/angular/src/app/shared/menu/menu-trigger.html
Normal 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>
|
||||
41
desktop/angular/src/app/shared/menu/menu-trigger.scss
Normal file
41
desktop/angular/src/app/shared/menu/menu-trigger.scss
Normal 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;
|
||||
}
|
||||
6
desktop/angular/src/app/shared/menu/menu.html
Normal file
6
desktop/angular/src/app/shared/menu/menu.html
Normal 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>
|
||||
26
desktop/angular/src/app/shared/menu/menu.module.ts
Normal file
26
desktop/angular/src/app/shared/menu/menu.module.ts
Normal 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 { }
|
||||
111
desktop/angular/src/app/shared/menu/menu.ts
Normal file
111
desktop/angular/src/app/shared/menu/menu.ts
Normal 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;
|
||||
}
|
||||
3
desktop/angular/src/app/shared/multi-switch/index.ts
Normal file
3
desktop/angular/src/app/shared/multi-switch/index.ts
Normal 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
Reference in New Issue
Block a user