From 746151224fd2e4b5e5fb75f70c7fecb8989a0149 Mon Sep 17 00:00:00 2001 From: Patrick Pacher Date: Mon, 20 Nov 2023 12:16:14 +0100 Subject: [PATCH] Add support for profile import/export and update to new icons api --- .../src/lib/app-profile.service.ts | 113 +-- .../src/lib/app-profile.types.ts | 55 +- .../portmaster-api/src/lib/portapi.service.ts | 665 +++++++++++------ .../src/app/pages/app-view/app-view.html | 102 +-- .../src/app/pages/app-view/app-view.ts | 676 ++++++++++-------- .../src/app/pages/app-view/overview.html | 147 +++- .../src/app/pages/app-view/overview.ts | 289 ++++---- .../qs-history/qs-history.component.ts | 46 +- .../app-view/qs-select-exit/qs-select-exit.ts | 59 +- .../src/app/shared/app-icon/app-icon.ts | 142 ++-- .../src/app/shared/config/config-settings.ts | 306 +++++--- .../src/app/shared/config/config.module.ts | 64 +- .../export-dialog.component.html | 18 +- .../export-dialog/export-dialog.component.ts | 49 +- .../import-dialog.component.html | 133 +++- .../import-dialog/import-dialog.component.ts | 188 +++-- .../edit-profile-dialog.html | 304 +++++--- .../edit-profile-dialog.ts | 278 ++++--- 18 files changed, 2310 insertions(+), 1324 deletions(-) diff --git a/modules/portmaster/projects/safing/portmaster-api/src/lib/app-profile.service.ts b/modules/portmaster/projects/safing/portmaster-api/src/lib/app-profile.service.ts index 13c6668d..8f126533 100644 --- a/modules/portmaster/projects/safing/portmaster-api/src/lib/app-profile.service.ts +++ b/modules/portmaster/projects/safing/portmaster-api/src/lib/app-profile.service.ts @@ -1,12 +1,21 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Inject, Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { filter, finalize, map, mergeMap, share, take } from 'rxjs/operators'; -import { AppProfile, FlatConfigObject, LayeredProfile, TagDescription, flattenProfileConfig } from './app-profile.types'; -import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from './portapi.service'; +import { + AppProfile, + FlatConfigObject, + LayeredProfile, + TagDescription, + flattenProfileConfig, +} from './app-profile.types'; +import { + PORTMASTER_HTTP_API_ENDPOINT, + PortapiService, +} from './portapi.service'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class AppProfileService { private watchedProfiles = new Map>(); @@ -14,8 +23,8 @@ export class AppProfileService { constructor( private portapi: PortapiService, private http: HttpClient, - @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string, - ) { } + @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string + ) {} /** * Returns the database key of a profile. @@ -41,7 +50,7 @@ export class AppProfileService { if (!!id) { key = `core:profiles/${idOrSourceOrProfile}/${id}`; - }; + } return key; } @@ -61,46 +70,61 @@ export class AppProfileService { */ getAppProfile(source: string, id: string): Observable; - getAppProfile(sourceOrSourceAndID: string, id?: string): Observable { + getAppProfile( + sourceOrSourceAndID: string, + id?: string + ): Observable { let source = sourceOrSourceAndID; if (id !== undefined) { - source += "/" + id + source += '/' + id; } - const key = `core:profiles/${source}` + const key = `core:profiles/${source}`; if (this.watchedProfiles.has(key)) { - return this.watchedProfiles.get(key)! - .pipe( - take(1) - ) + return this.watchedProfiles.get(key)!.pipe(take(1)); } return this.getAppProfileFromKey(key); } + setProfileIcon( + content: string | ArrayBuffer, + mimeType: string + ): Observable<{ filename: string }> { + return this.http.post<{ filename: string }>( + `${this.httpAPI}/v1/profile/icon`, + content, + { + headers: new HttpHeaders({ + 'Content-Type': mimeType, + }), + } + ); + } + /** * Loads an application profile by it's database key. * * @param key The key of the application profile. */ getAppProfileFromKey(key: string): Observable { - return this.portapi.get(key) + return this.portapi.get(key); } /** * Loads the global-configuration profile. */ globalConfig(): Observable { - return this.getAppProfile('special', 'global-config') - .pipe( - map(profile => flattenProfileConfig(profile.Config)), - ) + return this.getAppProfile('special', 'global-config').pipe( + map((profile) => flattenProfileConfig(profile.Config)) + ); } /** Returns all possible process tags. */ tagDescriptions(): Observable { - return this.http.get<{ Tags: TagDescription[] }>(`${this.httpAPI}/v1/process/tags`) - .pipe(map(result => result.Tags)) + return this.http + .get<{ Tags: TagDescription[] }>(`${this.httpAPI}/v1/process/tags`) + .pipe(map((result) => result.Tags)); } /** @@ -122,9 +146,9 @@ export class AppProfileService { let key = ''; if (id === undefined) { - key = sourceAndId - if (!key.startsWith("core:profiles/")) { - key = `core:profiles/${key}` + key = sourceAndId; + if (!key.startsWith('core:profiles/')) { + key = `core:profiles/${key}`; } } else { key = `core:profiles/${sourceAndId}/${id}`; @@ -134,17 +158,20 @@ export class AppProfileService { return this.watchedProfiles.get(key)!; } - const stream = - this.portapi.get(key) - .pipe( - mergeMap(() => this.portapi.watch(key)), - finalize(() => { - console.log("watchAppProfile: removing cached profile stream for " + key) - this.watchedProfiles.delete(key); - }), - share({ connector: () => new BehaviorSubject(null), resetOnRefCountZero: true }), - filter(profile => profile !== null), - ) as Observable; + const stream = this.portapi.get(key).pipe( + mergeMap(() => this.portapi.watch(key)), + finalize(() => { + console.log( + 'watchAppProfile: removing cached profile stream for ' + key + ); + this.watchedProfiles.delete(key); + }), + share({ + connector: () => new BehaviorSubject(null), + resetOnRefCountZero: true, + }), + filter((profile) => profile !== null) + ) as Observable; this.watchedProfiles.set(key, stream); @@ -162,15 +189,18 @@ export class AppProfileService { * @param profile The profile to save */ saveProfile(profile: AppProfile): Observable { - profile.LastEdited = Math.floor((new Date()).getTime() / 1000); - return this.portapi.update(`core:profiles/${profile.Source}/${profile.ID}`, profile); + profile.LastEdited = Math.floor(new Date().getTime() / 1000); + return this.portapi.update( + `core:profiles/${profile.Source}/${profile.ID}`, + profile + ); } /** * Watch all application profiles */ watchProfiles(): Observable { - return this.portapi.watchAll('core:profiles/') + return this.portapi.watchAll('core:profiles/'); } watchLayeredProfile(source: string, id: string): Observable; @@ -183,8 +213,11 @@ export class AppProfileService { */ watchLayeredProfile(profile: AppProfile): Observable; - watchLayeredProfile(profileOrSource: string | AppProfile, id?: string): Observable { - if (typeof profileOrSource == "object") { + watchLayeredProfile( + profileOrSource: string | AppProfile, + id?: string + ): Observable { + if (typeof profileOrSource == 'object') { id = profileOrSource.ID; profileOrSource = profileOrSource.Source; } diff --git a/modules/portmaster/projects/safing/portmaster-api/src/lib/app-profile.types.ts b/modules/portmaster/projects/safing/portmaster-api/src/lib/app-profile.types.ts index cb237bd1..83fa6546 100644 --- a/modules/portmaster/projects/safing/portmaster-api/src/lib/app-profile.types.ts +++ b/modules/portmaster/projects/safing/portmaster-api/src/lib/app-profile.types.ts @@ -23,16 +23,16 @@ export interface LayeredProfile extends Record { } export enum FingerprintType { - Tag = "tag", - Cmdline = "cmdline", - Env = "env", - Path = "path" + Tag = 'tag', + Cmdline = 'cmdline', + Env = 'env', + Path = 'path', } export enum FingerpringOperation { - Equal = "equals", - Prefix = "prefix", - Regex = "regex", + Equal = 'equals', + Prefix = 'prefix', + Regex = 'regex', } export interface Fingerprint { @@ -49,7 +49,7 @@ export interface TagDescription { } export interface Icon { - Type: 'database' | 'path'; + Type: 'database' | 'path' | 'api'; Value: string; } @@ -74,13 +74,14 @@ export interface AppProfile extends Record { // flattenProfileConfig returns a flat version of a nested ConfigMap where each property // can be used as the database key for the associated setting. -export function flattenProfileConfig(p: ConfigMap, prefix = ''): FlatConfigObject { +export function flattenProfileConfig( + p: ConfigMap, + prefix = '' +): FlatConfigObject { let result: FlatConfigObject = {}; - Object.keys(p).forEach(key => { - const childPrefix = prefix === '' - ? key - : `${prefix}/${key}`; + Object.keys(p).forEach((key) => { + const childPrefix = prefix === '' ? key : `${prefix}/${key}`; const prop = p[key]; @@ -91,7 +92,7 @@ export function flattenProfileConfig(p: ConfigMap, prefix = ''): FlatConfigObjec } result[childPrefix] = prop; - }) + }); return result; } @@ -103,7 +104,10 @@ export function flattenProfileConfig(p: ConfigMap, prefix = ''): FlatConfigObjec * @param obj The ConfigMap object * @param path The path of the setting separated by foward slashes. */ -export function getAppSetting(obj: ConfigMap, path: string): T | null { +export function getAppSetting( + obj: ConfigMap, + path: string +): T | null { const parts = path.split('/'); let iter = obj; @@ -124,12 +128,13 @@ export function getAppSetting(obj: ConfigMap, path: s } iter = value; - } return null; } -export function getActualValue>(s: S): SettingValueType { +export function getActualValue>( + s: S +): SettingValueType { if (s.Value !== undefined) { return s.Value; } @@ -139,7 +144,6 @@ export function getActualValue>(s: S): SettingVa return s.DefaultValue; } - /** * Sets the value of a settings inside the nested config object. * @@ -159,11 +163,11 @@ export function setAppSetting(obj: ConfigObject, path: string, value: any) { if (idx === parts.length - 1) { if (value === undefined) { - delete (iter[propName]) + delete iter[propName]; } else { iter[propName] = value; } - return + return; } if (iter[propName] === undefined) { @@ -186,13 +190,16 @@ function isConfigMap(v: any): v is ConfigMap { * @param a The first config object * @param b The second config object */ -function mergeObjects(a: FlatConfigObject, b: FlatConfigObject): FlatConfigObject { +function mergeObjects( + a: FlatConfigObject, + b: FlatConfigObject +): FlatConfigObject { var res: FlatConfigObject = {}; - Object.keys(a).forEach(key => { + Object.keys(a).forEach((key) => { res[key] = a[key]; }); - Object.keys(b).forEach(key => { + Object.keys(b).forEach((key) => { res[key] = b[key]; - }) + }); return res; } diff --git a/modules/portmaster/projects/safing/portmaster-api/src/lib/portapi.service.ts b/modules/portmaster/projects/safing/portmaster-api/src/lib/portapi.service.ts index 6ff938e4..20dd8a28 100644 --- a/modules/portmaster/projects/safing/portmaster-api/src/lib/portapi.service.ts +++ b/modules/portmaster/projects/safing/portmaster-api/src/lib/portapi.service.ts @@ -1,14 +1,48 @@ import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; -import { Inject, Injectable, InjectionToken, isDevMode, NgZone } from '@angular/core'; +import { + Inject, + Injectable, + InjectionToken, + isDevMode, + NgZone, +} from '@angular/core'; import { BehaviorSubject, Observable, Observer, of } from 'rxjs'; -import { concatMap, delay, filter, map, retryWhen, takeWhile, tap } from 'rxjs/operators'; +import { + concatMap, + delay, + filter, + map, + retryWhen, + takeWhile, + tap, +} from 'rxjs/operators'; import { WebSocketSubject } from 'rxjs/webSocket'; -import { DataReply, deserializeMessage, DoneReply, ImportResult, InspectedActiveRequest, isCancellable, isDataReply, Record, ReplyMessage, Requestable, RequestMessage, RequestType, RetryableOpts, retryPipeline, serializeMessage, WatchOpts } from './portapi.types'; +import { + DataReply, + deserializeMessage, + DoneReply, + ImportResult, + InspectedActiveRequest, + isCancellable, + isDataReply, + Record, + ReplyMessage, + Requestable, + RequestMessage, + RequestType, + RetryableOpts, + retryPipeline, + serializeMessage, + WatchOpts, +} from './portapi.types'; import { WebsocketService } from './websocket.service'; -export const PORTMASTER_WS_API_ENDPOINT = new InjectionToken('PortmasterWebsocketEndpoint'); -export const PORTMASTER_HTTP_API_ENDPOINT = new InjectionToken('PortmasterHttpApiEndpoint') - +export const PORTMASTER_WS_API_ENDPOINT = new InjectionToken( + 'PortmasterWebsocketEndpoint' +); +export const PORTMASTER_HTTP_API_ENDPOINT = new InjectionToken( + 'PortmasterHttpApiEndpoint' +); export const RECONNECT_INTERVAL = 2000; @@ -39,16 +73,17 @@ export class PortapiService { } /** @private DEBUGGING ONLY - keeps track of current requests and supports injecting messages */ - readonly activeRequests = new BehaviorSubject<{ [key: string]: InspectedActiveRequest }>({}); + readonly activeRequests = new BehaviorSubject<{ + [key: string]: InspectedActiveRequest; + }>({}); constructor( private websocketFactory: WebsocketService, private ngZone: NgZone, private http: HttpClient, @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpEndpoint: string, - @Inject(PORTMASTER_WS_API_ENDPOINT) private wsEndpoint: string, + @Inject(PORTMASTER_WS_API_ENDPOINT) private wsEndpoint: string ) { - // create a new websocket connection that will auto-connect // on the first subscription and will automatically reconnect // with consecutive subscribers. @@ -56,23 +91,29 @@ export class PortapiService { // no need to keep a reference to the subscription as we're not going // to unsubscribe ... - this.ws$.pipe( - retryWhen(errors => errors.pipe( - // use concatMap to keep the errors in order and make sure - // they don't execute in parallel. - concatMap((e, i) => of(e).pipe( - // We need to forward the error to all streams here because - // due to the retry feature the subscriber below won't see - // any error at all. - tap(() => { - this._streams$.forEach(observer => observer.error(e)); - this._streams$.clear(); - }), - delay(1000) - )) - ))) + this.ws$ + .pipe( + retryWhen((errors) => + errors.pipe( + // use concatMap to keep the errors in order and make sure + // they don't execute in parallel. + concatMap((e, i) => + of(e).pipe( + // We need to forward the error to all streams here because + // due to the retry feature the subscriber below won't see + // any error at all. + tap(() => { + this._streams$.forEach((observer) => observer.error(e)); + this._streams$.clear(); + }), + delay(1000) + ) + ) + ) + ) + ) .subscribe( - msg => { + (msg) => { const observer = this._streams$.get(msg.id); if (!observer) { // it's expected that we receive done messages from time to time here @@ -80,7 +121,10 @@ export class PortapiService { // and we already remove the observer from _streams$ if the subscription // is unsubscribed. So just hide that warning message for "done" if (msg.type !== 'done') { - console.warn(`Received message for unknown request id ${msg.id} (type=${msg.type})`, msg); + console.warn( + `Received message for unknown request id ${msg.id} (type=${msg.type})`, + msg + ); } return; } @@ -92,49 +136,77 @@ export class PortapiService { () => { // This should actually never happen but if, make sure // we handle it ... - this._streams$.forEach(observer => observer.complete()); + this._streams$.forEach((observer) => observer.complete()); this._streams$.clear(); - }); + } + ); } /** Triggers a restart of the portmaster service */ restartPortmaster(): Observable { - return this.http.post(`${this.httpEndpoint}/v1/core/restart`, undefined, { observe: 'response', responseType: 'arraybuffer' }) + return this.http.post(`${this.httpEndpoint}/v1/core/restart`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); } /** Triggers a shutdown of the portmaster service */ shutdownPortmaster(): Observable { - return this.http.post(`${this.httpEndpoint}/v1/core/shutdown`, undefined, { observe: 'response', responseType: 'arraybuffer' }) + return this.http.post(`${this.httpEndpoint}/v1/core/shutdown`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); } /** Force the portmaster to check for updates */ checkForUpdates(): Observable { - return this.http.post(`${this.httpEndpoint}/v1/updates/check`, undefined, { observe: 'response', responseType: 'arraybuffer', reportProgress: false }) + return this.http.post(`${this.httpEndpoint}/v1/updates/check`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + reportProgress: false, + }); } /** Force a reload of the UI assets */ reloadUI(): Observable { - return this.http.post(`${this.httpEndpoint}/v1/ui/reload`, undefined, { observe: 'response', responseType: 'arraybuffer' }) + return this.http.post(`${this.httpEndpoint}/v1/ui/reload`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); } /** Clear DNS cache */ clearDNSCache(): Observable { - return this.http.post(`${this.httpEndpoint}/v1/dns/clear`, undefined, { observe: 'response', responseType: 'arraybuffer' }) + return this.http.post(`${this.httpEndpoint}/v1/dns/clear`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); } /** Reset the broadcast notifications state */ resetBroadcastState(): Observable { - return this.http.post(`${this.httpEndpoint}/v1/broadcasts/reset-state`, undefined, { observe: 'response', responseType: 'arraybuffer' }) + return this.http.post( + `${this.httpEndpoint}/v1/broadcasts/reset-state`, + undefined, + { observe: 'response', responseType: 'arraybuffer' } + ); } /** Re-initialize the SPN */ reinitSPN(): Observable { - return this.http.post(`${this.httpEndpoint}/v1/spn/reinit`, undefined, { observe: 'response', responseType: 'arraybuffer' }) + return this.http.post(`${this.httpEndpoint}/v1/spn/reinit`, undefined, { + observe: 'response', + responseType: 'arraybuffer', + }); } /** Cleans up the history database by applying history retention settings */ cleanupHistory(): Observable { - return this.http.post(`${this.httpEndpoint}/v1/netquery/history/cleanup`, undefined, { observe: 'response', responseType: 'arraybuffer' }) + return this.http.post( + `${this.httpEndpoint}/v1/netquery/history/cleanup`, + undefined, + { observe: 'response', responseType: 'arraybuffer' } + ); } /** Requests a resource from the portmaster as application/json and automatically parses the response body*/ @@ -143,68 +215,152 @@ export class PortapiService { /** Requests a resource from the portmaster as text */ getResource(resource: string, type: string): Observable>; - getResource(resource: string, type?: string): Observable | any> { + getResource( + resource: string, + type?: string + ): Observable | any> { if (type !== undefined) { const headers = new HttpHeaders({ - 'Accept': type - }) + Accept: type, + }); return this.http.get(`${this.httpEndpoint}/v1/updates/get/${resource}`, { - headers: new HttpHeaders({ 'Accept': type }), + headers: new HttpHeaders({ Accept: type }), observe: 'response', responseType: 'text', - }) + }); } - return this.http.get(`${this.httpEndpoint}/v1/updates/get/${resource}`, { - headers: new HttpHeaders({ 'Accept': 'application/json' }), - responseType: 'json', - }); + return this.http.get( + `${this.httpEndpoint}/v1/updates/get/${resource}`, + { + headers: new HttpHeaders({ Accept: 'application/json' }), + responseType: 'json', + } + ); } /** Export one or more settings, either from global settings or a specific profile */ - exportSettings(keys: string[], from: 'global' | string = 'global'): Observable { - return this.http.post(`${this.httpEndpoint}/v1/sync/settings/export`, { - from, - keys, - }, { - headers: new HttpHeaders({ 'Accept': 'text/yaml' }), - responseType: 'text', - observe: 'body', - }) + exportSettings( + keys: string[], + from: 'global' | string = 'global' + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/settings/export`, + { + from, + keys, + }, + { + headers: new HttpHeaders({ Accept: 'text/yaml' }), + responseType: 'text', + observe: 'body', + } + ); } /** Validate a settings import for a given target */ - validateSettingsImport(blob: string | Blob, target: string | 'global' = 'global', mimeType: string = 'text/yaml'): Observable { - return this.http.post(`${this.httpEndpoint}/v1/sync/settings/import`, { - target, - rawExport: blob.toString(), - rawMime: mimeType, - validateOnly: true - }) + validateSettingsImport( + blob: string | Blob, + target: string | 'global' = 'global', + mimeType: string = 'text/yaml' + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/settings/import`, + { + target, + rawExport: blob.toString(), + rawMime: mimeType, + validateOnly: true, + } + ); } /** Import settings into a given target */ - importSettings(blob: string | Blob, target: string | 'global' = 'global', mimeType: string = 'text/yaml', reset = false, allowUnknown = false): Observable { - return this.http.post(`${this.httpEndpoint}/v1/sync/settings/import`, { - target, - rawExport: blob.toString(), - rawMime: mimeType, - validateOnly: false, - reset, - allowUnknown, - }) + importSettings( + blob: string | Blob, + target: string | 'global' = 'global', + mimeType: string = 'text/yaml', + reset = false, + allowUnknown = false + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/settings/import`, + { + target, + rawExport: blob.toString(), + rawMime: mimeType, + validateOnly: false, + reset, + allowUnknown, + } + ); + } + + /** Import a profile */ + importProfile( + blob: string | Blob, + mimeType: string = 'text/yaml', + reset = false, + allowUnknown = false, + allowReplaceProfiles = false + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/profile/import`, + { + rawExport: blob.toString(), + rawMime: mimeType, + validateOnly: false, + reset, + allowUnknown, + allowReplaceProfiles, + } + ); + } + + /** Import a profile */ + validateProfileImport( + blob: string | Blob, + mimeType: string = 'text/yaml' + ): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/profile/import`, + { + rawExport: blob.toString(), + rawMime: mimeType, + validateOnly: true, + } + ); + } + + /** Export one or more settings, either from global settings or a specific profile */ + exportProfile(id: string): Observable { + return this.http.post( + `${this.httpEndpoint}/v1/sync/profile/export`, + { + id, + }, + { + headers: new HttpHeaders({ Accept: 'text/yaml' }), + responseType: 'text', + observe: 'body', + } + ); } /** Merge multiple profiles into one primary profile. */ - mergeProfiles(name: string, primary: string, secondaries: string[]): Observable { - return this.http.post<{ new: string }>(`${this.httpEndpoint}/v1/profile/merge`, { - name: name, - to: primary, - from: secondaries, - }).pipe( - map(response => response.new) - ) + mergeProfiles( + name: string, + primary: string, + secondaries: string[] + ): Observable { + return this.http + .post<{ new: string }>(`${this.httpEndpoint}/v1/profile/merge`, { + name: name, + to: primary, + from: secondaries, + }) + .pipe(map((response) => response.new)); } /** @@ -219,8 +375,7 @@ export class PortapiService { bridgeAPI(call: string, method: string): Observable { return this.create(`api:${call}`, { Method: method, - }) - .pipe(map(() => { })) + }).pipe(map(() => {})); } /** @@ -235,11 +390,14 @@ export class PortapiService { this.ws$!.next(req.request); this._streams$.set(req.request.id, req.observer); this._pendingCalls$.delete(key); - }) + }); } catch (err) { // we failed to send the pending calls because the // websocket connection just broke. - console.error(`Failed to flush pending calls, ${this._pendingCalls$.size} left: `, err); + console.error( + `Failed to flush pending calls, ${this._pendingCalls$.size} left: `, + err + ); } console.log(`Successfully flushed all (${count}) pending calles`); @@ -259,10 +417,7 @@ export class PortapiService { * @param key The database key of the entry to load. */ get(key: string): Observable { - return this.request('get', { key }) - .pipe( - map(res => res.data) - ); + return this.request('get', { key }).pipe(map((res) => res.data)); } /** @@ -281,9 +436,11 @@ export class PortapiService { * * @param query The query use to subscribe. */ - sub(query: string, opts: RetryableOpts = {}): Observable> { - return this.request('sub', { query }) - .pipe(retryPipeline(opts)); + sub( + query: string, + opts: RetryableOpts = {} + ): Observable> { + return this.request('sub', { query }).pipe(retryPipeline(opts)); } /** @@ -293,11 +450,23 @@ export class PortapiService { * @param query The query use to subscribe. * @todo(ppacher): check what a ok/done message mean here. */ - qsub(query: string, opts?: RetryableOpts): Observable>; - qsub(query: string, opts: RetryableOpts, _: { forwardDone: true }): Observable | DoneReply>; - qsub(query: string, opts: RetryableOpts = {}, { forwardDone }: { forwardDone?: true } = {}): Observable> { - return this.request('qsub', { query }, { forwardDone }) - .pipe(retryPipeline(opts)) + qsub( + query: string, + opts?: RetryableOpts + ): Observable>; + qsub( + query: string, + opts: RetryableOpts, + _: { forwardDone: true } + ): Observable | DoneReply>; + qsub( + query: string, + opts: RetryableOpts = {}, + { forwardDone }: { forwardDone?: true } = {} + ): Observable> { + return this.request('qsub', { query }, { forwardDone }).pipe( + retryPipeline(opts) + ); } /** @@ -312,8 +481,7 @@ export class PortapiService { */ create(key: string, data: any): Observable { data = this.stripMeta(data); - return this.request('create', { key, data }) - .pipe(map(() => { })); + return this.request('create', { key, data }).pipe(map(() => {})); } /** @@ -324,8 +492,7 @@ export class PortapiService { */ update(key: string, data: any): Observable { data = this.stripMeta(data); - return this.request('update', { key, data }) - .pipe(map(() => { })) + return this.request('update', { key, data }).pipe(map(() => {})); } /** @@ -337,8 +504,7 @@ export class PortapiService { */ insert(key: string, data: any): Observable { data = this.stripMeta(data); - return this.request('insert', { key, data }) - .pipe(map(() => { })); + return this.request('insert', { key, data }).pipe(map(() => {})); } /** @@ -347,8 +513,7 @@ export class PortapiService { * @param key The key of the database entry to delete. */ delete(key: string): Observable { - return this.request('delete', { key }) - .pipe(map(() => { })); + return this.request('delete', { key }).pipe(map(() => {})); } /** @@ -371,60 +536,80 @@ export class PortapiService { * @param forwardDone: Whether or not the "done" message should be forwarded */ watch(key: string, opts?: WatchOpts): Observable; - watch(key: string, opts?: WatchOpts & { ignoreDelete: true }): Observable; - watch(key: string, opts: WatchOpts, _: { forwardDone: true }): Observable; - watch(key: string, opts: WatchOpts & { ignoreDelete: true }, _: { forwardDone: true }): Observable; - watch(key: string, opts: WatchOpts = {}, { forwardDone }: { forwardDone?: boolean } = {}): Observable { - return this.qsub(key, opts, { forwardDone } as any) - .pipe( - filter(reply => reply.type !== 'done' || forwardDone === true), - filter(reply => reply.type === 'done' || reply.key === key), - takeWhile(reply => opts.ignoreDelete || reply.type !== 'del'), - filter(reply => { - return !opts.ingoreNew || reply.type !== 'new' - }), - map(reply => { - if (reply.type === 'del') { - return null; - } + watch( + key: string, + opts?: WatchOpts & { ignoreDelete: true } + ): Observable; + watch( + key: string, + opts: WatchOpts, + _: { forwardDone: true } + ): Observable; + watch( + key: string, + opts: WatchOpts & { ignoreDelete: true }, + _: { forwardDone: true } + ): Observable; + watch( + key: string, + opts: WatchOpts = {}, + { forwardDone }: { forwardDone?: boolean } = {} + ): Observable { + return this.qsub(key, opts, { forwardDone } as any).pipe( + filter((reply) => reply.type !== 'done' || forwardDone === true), + filter((reply) => reply.type === 'done' || reply.key === key), + takeWhile((reply) => opts.ignoreDelete || reply.type !== 'del'), + filter((reply) => { + return !opts.ingoreNew || reply.type !== 'new'; + }), + map((reply) => { + if (reply.type === 'del') { + return null; + } - if (reply.type === 'done') { - return reply; - } - return reply.data; - }), - ); + if (reply.type === 'done') { + return reply; + } + return reply.data; + }) + ); } - watchAll(query: string, opts?: RetryableOpts): Observable { - return new Observable(observer => { + watchAll( + query: string, + opts?: RetryableOpts + ): Observable { + return new Observable((observer) => { let values: T[] = []; let keys: string[] = []; let doneReceived = false; - const sub = this.request('qsub', { query }, { forwardDone: true }) - .subscribe({ - next: value => { - if ((value as any).type === 'done') { - doneReceived = true; - observer.next(values); - return - } + const sub = this.request( + 'qsub', + { query }, + { forwardDone: true } + ).subscribe({ + next: (value) => { + if ((value as any).type === 'done') { + doneReceived = true; + observer.next(values); + return; + } - if (!doneReceived) { - values.push(value.data); - keys.push(value.key); - return; - } + if (!doneReceived) { + values.push(value.data); + keys.push(value.key); + return; + } - const idx = keys.findIndex(k => k === value.key); - switch (value.type) { - case 'new': - if (idx < 0) { - values.push(value.data); - keys.push(value.key); - } else { - /* + const idx = keys.findIndex((k) => k === value.key); + switch (value.type) { + case 'new': + if (idx < 0) { + values.push(value.data); + keys.push(value.key); + } else { + /* const existing = values[idx]._meta!; const existingTs = existing.Modified || existing.Created; const newTs = (value.data as Record)?._meta?.Modified || (value.data as Record)?._meta?.Created || 0; @@ -438,36 +623,36 @@ export class PortapiService { return; } */ - values[idx] = value.data; - } - break; - case 'del': - if (idx >= 0) { - keys.splice(idx, 1); - values.splice(idx, 1); - } - break; - case 'upd': - if (idx >= 0) { - values[idx] = value.data; - } - break; - } - - observer.next(values); - }, - error: err => { - observer.error(err); - }, - complete: () => { - observer.complete(); + values[idx] = value.data; + } + break; + case 'del': + if (idx >= 0) { + keys.splice(idx, 1); + values.splice(idx, 1); + } + break; + case 'upd': + if (idx >= 0) { + values[idx] = value.data; + } + break; } - }) + + observer.next(values); + }, + error: (err) => { + observer.error(err); + }, + complete: () => { + observer.complete(); + }, + }); return () => { sub.unsubscribe(); - } - }).pipe(retryPipeline(opts)) + }; + }).pipe(retryPipeline(opts)); } /** @@ -483,31 +668,35 @@ export class PortapiService { this.ws$ = null; } - request(method: M, attrs: Partial>, { forwardDone }: { forwardDone?: boolean } = {}): Observable> { - return new Observable(observer => { + request( + method: M, + attrs: Partial>, + { forwardDone }: { forwardDone?: boolean } = {} + ): Observable> { + return new Observable((observer) => { const id = `${++uniqueRequestId}`; if (!this.ws$) { - observer.error("No websocket connection"); - return + observer.error('No websocket connection'); + return; } let shouldCancel = isCancellable(method); - let unsub: (() => RequestMessage | null) = () => { + let unsub: () => RequestMessage | null = () => { if (shouldCancel) { return { id: id, - type: 'cancel' - } + type: 'cancel', + }; } - return null - } + return null; + }; const request: any = { ...attrs, id: id, type: method, - } + }; let inspected: InspectedActiveRequest = { type: method, @@ -516,30 +705,33 @@ export class PortapiService { payload: request, lastData: null, lastKey: '', - } + }; if (isDevMode()) { this.activeRequests.next({ ...this.inspectActiveRequests(), [id]: inspected, - }) + }); } - let stream$: Observable> = this.multiplex(request, unsub); + let stream$: Observable> = this.multiplex( + request, + unsub + ); if (isDevMode()) { // in development mode we log all replys for the different // methods. This also includes updates to subscriptions. stream$ = stream$.pipe( tap( - msg => { }, + (msg) => {}, //msg => console.log(`[portapi] reply for ${method} ${id}: `, msg), - err => console.error(`[portapi] error in ${method} ${id}: `, err), + (err) => console.error(`[portapi] error in ${method} ${id}: `, err) ) - ) + ); } const subscription = stream$?.subscribe({ - next: data => { + next: (data) => { inspected.messagesReceived++; // in all cases, an `error` message type @@ -549,13 +741,15 @@ export class PortapiService { shouldCancel = false; observer.error(data.message); - return + return; } - if (method === 'create' - || method === 'update' - || method === 'insert' - || method === 'delete') { + if ( + method === 'create' || + method === 'update' || + method === 'insert' || + method === 'delete' + ) { // for data-manipulating methods success // ends the stream. if (data.type === 'success') { @@ -565,9 +759,7 @@ export class PortapiService { } } - if (method === 'query' - || method === 'sub' - || method === 'qsub') { + if (method === 'query' || method === 'sub' || method === 'qsub') { if (data.type === 'warning') { console.warn(data.message); return; @@ -593,8 +785,10 @@ export class PortapiService { } if (!isDataReply(data)) { - console.error(`Received unexpected message type ${data.type} in a ${method} operation`); - return + console.error( + `Received unexpected message type ${data.type} in a ${method} operation` + ); + return; } inspected.lastData = data.data; @@ -605,37 +799,40 @@ export class PortapiService { // for a `get` method the first `ok` message // also marks the end of the stream. if (method === 'get' && data.type === 'ok') { - shouldCancel = false + shouldCancel = false; observer.complete(); } }, - error: err => { + error: (err) => { console.error(err, attrs); observer.error(err); }, complete: () => { observer.complete(); - } - }) + }, + }); if (isDevMode()) { // make sure we remove the "active" request when the subscription // goes down subscription.add(() => { const active = this.inspectActiveRequests(); - delete (active[request.id]); + delete active[request.id]; this.activeRequests.next(active); - }) + }); } return () => { subscription.unsubscribe(); - } + }; }); } - private multiplex(req: RequestMessage, cancel: (() => RequestMessage | null) | null): Observable { - return new Observable(observer => { + private multiplex( + req: RequestMessage, + cancel: (() => RequestMessage | null) | null + ): Observable { + return new Observable((observer) => { if (this.connectedSubject.getValue()) { // Try to directly send the request to the backend this._streams$.set(req.id, observer); @@ -644,10 +841,12 @@ export class PortapiService { // in case of an error we just add the request as // "pending" and wait for the connection to be // established. - console.warn(`Failed to send request ${req.id}:${req.type}, marking as pending ...`) + console.warn( + `Failed to send request ${req.id}:${req.type}, marking as pending ...` + ); this._pendingCalls$.set(req.id, { request: req, - observer: observer + observer: observer, }); } @@ -661,12 +860,12 @@ export class PortapiService { this.ws$!.next(cancelMsg); } } - } catch (err) { } + } catch (err) {} this._pendingCalls$.delete(req.id); this._streams$.delete(req.id); - } - }) + }; + }); } /** @@ -681,11 +880,11 @@ export class PortapiService { this.ngZone.runTask(() => { const req = this.activeRequests.getValue()[id]; if (!req) { - return + return; } - req.observer.next(msg as DataReply) - }) + req.observer.next(msg as DataReply); + }); } /** @@ -713,7 +912,7 @@ export class PortapiService { } const newPayload = mergeDeep({}, req.lastData, data); - this._injectData(id, newPayload, req.lastKey) + this._injectData(id, newPayload, req.lastKey); } private stripMeta(obj: T): T { @@ -731,28 +930,30 @@ export class PortapiService { * @private */ private createWebsocket(): WebSocketSubject { - return this.websocketFactory.createConnection({ + return this.websocketFactory.createConnection< + ReplyMessage | RequestMessage + >({ url: this.wsEndpoint, - serializer: msg => { + serializer: (msg) => { try { return serializeMessage(msg); } catch (err) { console.error('serialize message', err); return { - type: 'error' - } + type: 'error', + }; } }, // deserializeMessage also supports RequestMessage so cast as any deserializer: ((msg: any) => { try { - const res = deserializeMessage(msg) - return res + const res = deserializeMessage(msg); + return res; } catch (err) { console.error('deserialize message', err); return { - type: 'error' - } + type: 'error', + }; } }), binaryType: 'arraybuffer', @@ -761,7 +962,7 @@ export class PortapiService { console.log('[portapi] connection to portmaster established'); this.connectedSubject.next(true); this._flushPendingMethods(); - } + }, }, closeObserver: { next: () => { @@ -773,25 +974,25 @@ export class PortapiService { next: () => { console.log('[portapi] connection to portmaster closing'); }, - } - }) + }, + }); } } // Counts the number of "truthy" datafields in obj. function countTruthyDataFields(obj: { [key: string]: any }): number { let count = 0; - Object.keys(obj).forEach(key => { + Object.keys(obj).forEach((key) => { let value = obj[key]; if (!!value) { count++; } - }) + }); return count; } function isObject(item: any): item is Object { - return (item && typeof item === 'object' && !Array.isArray(item)); + return item && typeof item === 'object' && !Array.isArray(item); } export function mergeDeep(target: any, ...sources: any): any { diff --git a/modules/portmaster/src/app/pages/app-view/app-view.html b/modules/portmaster/src/app/pages/app-view/app-view.html index 9187be42..dc6e21a7 100644 --- a/modules/portmaster/src/app/pages/app-view/app-view.html +++ b/modules/portmaster/src/app/pages/app-view/app-view.html @@ -1,6 +1,6 @@ -
+
Apps @@ -19,17 +19,14 @@
-
+
-
+
-

+

-
- {{appProfile!.Name}} - Edit -
+ + {{appProfile!.Name}}

@@ -65,7 +62,7 @@

@@ -79,17 +76,32 @@

+ + + + Edit Profile + Export Profile + Delete Profile + +

-
+ class="flex flex-col justify-center items-center px-4 py-1 w-24 bg-gray-300 bg-opacity-75 rounded border border-gray-300 shadow transition-all duration-200">

{{ stats!.size | prettyCount }}

Connections
@@ -97,7 +109,7 @@

{{ stats!.size | pretty
+ class="flex flex-col justify-center items-center px-4 py-1 w-24 bg-gray-300 bg-opacity-75 rounded border border-gray-300 shadow transition-all duration-200">

{{ (100 / stats!.size) * (stats!.size - stats!.countAllowed) | number:'1.0-1' }}%

Blocked @@ -106,14 +118,14 @@

{{ (100 / stats!.size)
+ class="flex flex-col justify-center items-center px-4 py-1 w-24 bg-gray-300 bg-opacity-75 rounded border border-gray-300 shadow transition-all duration-200">

+ class="p-0 m-0 text-lg whitespace-nowrap sfng-lg:text-xl text-primary"> {{ stats.bytes_received | bytes }}

+ class="p-0 pb-2.5 m-0 text-opacity-50 whitespace-nowrap text-xxs sfng-lg:text-xs text-tertiary hover:underline"> Available in Plus @@ -123,9 +135,9 @@

{{ (100 / stats!.size)
+ class="flex flex-col justify-center items-center px-4 py-1 w-24 bg-gray-300 bg-opacity-75 rounded border border-gray-300 shadow transition-all duration-200">

+ class="p-0 m-0 text-lg whitespace-nowrap sfng-lg:text-xl text-primary"> {{ stats.bytes_sent | bytes }}

Sent @@ -134,7 +146,7 @@

{{ (100 / stats!.size)

-
@@ -143,7 +155,7 @@

{{ (100 / stats!.size)

- +
@@ -154,13 +166,13 @@

{{ (100 / stats!.size) -
+
+ class="flex flex-row gap-1 justify-center items-center self-stretch px-2 whitespace-nowrap bg-gray-300 rounded hover:bg-gray-200 text-blue"> {{ (100 / stats!.size) -
+
@@ -287,35 +299,35 @@

{{ (100 / stats!.size)
-

- + Description - +

+ class="block self-stretch p-4 -mb-4 ml-2 w-auto h-auto text-secondary">
-

- + Warning - +

+ class="block self-stretch p-4 ml-2 w-auto h-auto border-l text-secondary border-yellow"> updated {{ appProfile.WarningLastUpdated | timeAgo }} @@ -323,29 +335,29 @@

-

+

+ stroke="currentColor" class="mr-1 w-5 h-5"> Fingerprints - +

This profile will be applied to processes that match one of the following fingerprints:
- - + + - - + + {{ fp.Type }} @@ -362,14 +374,14 @@

-

- + Delete Profile - +

You can completely delete this profile to get rid of any settings. The profile @@ -382,8 +394,8 @@

-

- +

+ @@ -391,7 +403,7 @@

Debugging - +

When reporting issues with this app please make sure to include the diff --git a/modules/portmaster/src/app/pages/app-view/app-view.ts b/modules/portmaster/src/app/pages/app-view/app-view.ts index 061a7ba2..cfd44b90 100644 --- a/modules/portmaster/src/app/pages/app-view/app-view.ts +++ b/modules/portmaster/src/app/pages/app-view/app-view.ts @@ -1,10 +1,54 @@ -import { ChangeDetectorRef, Component, DestroyRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + ChangeDetectorRef, + Component, + DestroyRef, + OnDestroy, + OnInit, + ViewChild, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { AppProfile, AppProfileService, BandwidthChartResult, ChartResult, Condition, ConfigService, Database, DebugAPI, ExpertiseLevel, FeatureID, FlatConfigObject, IProfileStats, LayeredProfile, Netquery, ProfileBandwidthChartResult, SPNService, Setting, flattenProfileConfig, setAppSetting } from '@safing/portmaster-api'; +import { + AppProfile, + AppProfileService, + BandwidthChartResult, + ChartResult, + Condition, + ConfigService, + Database, + DebugAPI, + ExpertiseLevel, + FeatureID, + FlatConfigObject, + IProfileStats, + LayeredProfile, + Netquery, + PortapiService, + ProfileBandwidthChartResult, + SPNService, + Setting, + flattenProfileConfig, + setAppSetting, +} from '@safing/portmaster-api'; import { SfngDialogService } from '@safing/ui'; -import { BehaviorSubject, Observable, Subscription, combineLatest, interval, of, throwError } from 'rxjs'; -import { catchError, distinctUntilChanged, map, mergeMap, startWith, switchMap } from 'rxjs/operators'; +import { + BehaviorSubject, + Observable, + Subscription, + combineLatest, + interval, + of, + throwError, +} from 'rxjs'; +import { + catchError, + distinctUntilChanged, + map, + mergeMap, + startWith, + switchMap, +} from 'rxjs/operators'; import { SessionDataService } from 'src/app/services'; import { ActionIndicatorService } from 'src/app/shared/action-indicator'; import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations'; @@ -14,14 +58,15 @@ import { SfngNetqueryViewer } from 'src/app/shared/netquery'; import { EditProfileDialog } from './../../shared/edit-profile-dialog/edit-profile-dialog'; import { formatDuration } from 'src/app/shared/pipes'; import { BytesPipe } from 'src/app/shared/pipes/bytes.pipe'; +import { + ExportConfig, + ExportDialogComponent, +} from 'src/app/shared/config/export-dialog/export-dialog.component'; @Component({ templateUrl: './app-view.html', styleUrls: ['../page.scss', './app-view.scss'], - animations: [ - fadeOutAnimation, - fadeInAnimation, - ] + animations: [fadeOutAnimation, fadeInAnimation], }) export class AppViewComponent implements OnInit, OnDestroy { @ViewChild(SfngNetqueryViewer) @@ -62,7 +107,7 @@ export class AppViewComponent implements OnInit, OnDestroy { * Whether or not the overview componet should be rendered. */ get showOverview() { - return this.appProfile == null && !this._loading + return this.appProfile == null && !this._loading; } /** @@ -107,7 +152,7 @@ export class AppViewComponent implements OnInit, OnDestroy { * @private * The name of the binary */ - binaryName = '' + binaryName = ''; /** * @private @@ -137,16 +182,15 @@ export class AppViewComponent implements OnInit, OnDestroy { */ get viewSetting(): 'all' | 'active' { return this.viewSettingChange.getValue(); - }; + } /** A lookup map from tag ID to tag Name */ tagNames: { - [tagID: string]: string - } = {} + [tagID: string]: string; + } = {}; collapseHeader = false; - constructor( public sessionDataService: SessionDataService, private profileService: AppProfileService, @@ -159,7 +203,8 @@ export class AppViewComponent implements OnInit, OnDestroy { private dialog: SfngDialogService, private debugAPI: DebugAPI, private expertiseService: ExpertiseService, - ) { } + private portapi: PortapiService + ) {} /** * @private @@ -184,23 +229,43 @@ export class AppViewComponent implements OnInit, OnDestroy { } // Actually safe the profile - this.profileService.saveProfile(this.appProfile!) - .subscribe({ - next: () => { - if (!!event.accepted) { - event.accepted(); - } - }, - error: err => { - // if there's a callback function for errors call it. - if (!!event.rejected) { - event.rejected(err); - } + this.profileService.saveProfile(this.appProfile!).subscribe({ + next: () => { + if (!!event.accepted) { + event.accepted(); + } + }, + error: (err) => { + // if there's a callback function for errors call it. + if (!!event.rejected) { + event.rejected(err); + } - console.error(err); - this.actionIndicator.error('Failed to save setting', err); - }, - }) + console.error(err); + this.actionIndicator.error('Failed to save setting', err); + }, + }); + } + + exportProfile() { + if (!this.appProfile) { + return; + } + + this.portapi + .exportProfile(`${this.appProfile.Source}/${this.appProfile.ID}`) + .subscribe((exportBlob) => { + const exportConfig: ExportConfig = { + type: 'profile', + content: exportBlob, + }; + + this.dialog.create(ExportDialogComponent, { + data: exportConfig, + autoclose: false, + backdrop: true, + }); + }); } editProfile() { @@ -208,293 +273,312 @@ export class AppViewComponent implements OnInit, OnDestroy { return; } - this.dialog.create(EditProfileDialog, { - backdrop: true, - autoclose: false, - data: `${this.appProfile.Source}/${this.appProfile.ID}`, - }).onAction('deleted', () => { - // navigate to the app overview if it has been deleted. - this.router.navigate(['/app/']) - }) + this.dialog + .create(EditProfileDialog, { + backdrop: true, + autoclose: false, + data: `${this.appProfile.Source}/${this.appProfile.ID}`, + }) + .onAction('deleted', () => { + // navigate to the app overview if it has been deleted. + this.router.navigate(['/app/']); + }); } cleanProfileHistory() { if (!this.appProfile) { - return + return; } - const observer = this.actionIndicator.httpObserver('History successfully removed', 'Failed to remove history') + const observer = this.actionIndicator.httpObserver( + 'History successfully removed', + 'Failed to remove history' + ); - this.netquery.cleanProfileHistory(this.appProfile.Source + "/" + this.appProfile.ID) + this.netquery + .cleanProfileHistory(this.appProfile.Source + '/' + this.appProfile.ID) .subscribe({ - next: res => { - observer.next!(res) + next: (res) => { + observer.next!(res); this.historyAvailableSince = null; this.connectionsInHistory = 0; this.cdr.markForCheck(); }, - error: err => { + error: (err) => { observer.error!(err); - } - }) + }, + }); } ngOnInit() { - this.profileService.tagDescriptions() - .subscribe(tags => { - tags.forEach(t => { - this.tagNames[t.ID] = t.Name - this.cdr.markForCheck(); - }) - }) + this.profileService.tagDescriptions().subscribe((tags) => { + tags.forEach((t) => { + this.tagNames[t.ID] = t.Name; + this.cdr.markForCheck(); + }); + }); // watch the route parameters and start watching the referenced // application profile, it's layer profile and polling the stats. - const profileStream: Observable<[AppProfile, LayeredProfile | null, IProfileStats | null] | null> - = this.route.paramMap - .pipe( - switchMap(params => { - // Get the profile source and id. If one is unset (null) - // than return a"null" emit-once stream. - const source = params.get("source"); - const id = params.get("id") - if (source === null || id === null) { - this._loading = false; - return of(null); + const profileStream: Observable< + [AppProfile, LayeredProfile | null, IProfileStats | null] | null + > = this.route.paramMap.pipe( + switchMap((params) => { + // Get the profile source and id. If one is unset (null) + // than return a"null" emit-once stream. + const source = params.get('source'); + const id = params.get('id'); + if (source === null || id === null) { + this._loading = false; + return of(null); + } + this._loading = true; + + this.historyAvailableSince = null; + this.connectionsInHistory = 0; + this.appProfile = null; + this.stats = null; + + // Start watching the application profile. + // switchMap will unsubscribe automatically if + // we start watching a different profile. + return this.profileService.getAppProfile(source, id).pipe( + catchError((err) => { + if (typeof err === 'string') { + err = new Error(err); } - this._loading = true; - - this.historyAvailableSince = null; - this.connectionsInHistory = 0; - this.appProfile = null; - this.stats = null; - - // Start watching the application profile. - // switchMap will unsubscribe automatically if - // we start watching a different profile. - return this.profileService.getAppProfile(source, id) - .pipe( - catchError(err => { - if (typeof err === 'string') { - err = new Error(err) - } - - this.router.navigate(['/app/overview'], { onSameUrlNavigation: 'reload' }) - - this.actionIndicator.error('Failed To Get Profile', this.actionIndicator.getErrorMessgae(err)) - - return throwError(() => err) - }), - mergeMap(() => { - return combineLatest([ - this.profileService.watchAppProfile(source, id), - this.profileService.watchLayeredProfile(source, id) - .pipe(startWith(null)), - interval(10000) - .pipe( - startWith(-1), - mergeMap(() => this.netquery.getProfileStats({ - profile: `${source}/${id}`, - }).pipe(map(result => result?.[0]))), - startWith(null), - ) - ]) - }) - ) + + this.router.navigate(['/app/overview'], { + onSameUrlNavigation: 'reload', + }); + + this.actionIndicator.error( + 'Failed To Get Profile', + this.actionIndicator.getErrorMessgae(err) + ); + + return throwError(() => err); + }), + mergeMap(() => { + return combineLatest([ + this.profileService.watchAppProfile(source, id), + this.profileService + .watchLayeredProfile(source, id) + .pipe(startWith(null)), + interval(10000).pipe( + startWith(-1), + mergeMap(() => + this.netquery + .getProfileStats({ + profile: `${source}/${id}`, + }) + .pipe(map((result) => result?.[0])) + ), + startWith(null) + ), + ]); }) ); + }) + ); // used to track changes to the object identity of the global configuration let prevousGlobal: FlatConfigObject = {}; - this.subscription = - combineLatest([ - profileStream, // emits the current app profile everytime it changes - this.route.queryParamMap, // for changes to the settings= query parameter - this.profileService.globalConfig(), // for changes to ghe global profile - this.configService.query(""), // get ALL settings (once, only the defintion is of intereset) - this.viewSettingChange.pipe( // watch the current "settings-view" setting, but only if it changes - distinctUntilChanged(), - ), - ]) - .subscribe(async ([profile, queryMap, global, allSettings, viewSetting]) => { - const previousProfile = this.appProfile; - - if (!!profile) { - const key = profile![0].Source + "/" + profile![0].ID; - - const query: Condition = { - profile: key - } - - // ignore internal connections if the user is not in developer mode. - if (this.expertiseService.currentLevel !== ExpertiseLevel.Developer) { - query.internal = { - $eq: false, - }; - } + this.subscription = combineLatest([ + profileStream, // emits the current app profile everytime it changes + this.route.queryParamMap, // for changes to the settings= query parameter + this.profileService.globalConfig(), // for changes to ghe global profile + this.configService.query(''), // get ALL settings (once, only the defintion is of intereset) + this.viewSettingChange.pipe( + // watch the current "settings-view" setting, but only if it changes + distinctUntilChanged() + ), + ]).subscribe( + async ([profile, queryMap, global, allSettings, viewSetting]) => { + const previousProfile = this.appProfile; + + if (!!profile) { + const key = profile![0].Source + '/' + profile![0].ID; + + const query: Condition = { + profile: key, + }; + + // ignore internal connections if the user is not in developer mode. + if (this.expertiseService.currentLevel !== ExpertiseLevel.Developer) { + query.internal = { + $eq: false, + }; + } - this.netquery.query({ - select: [ - { - $min: { - field: 'started', - as: 'first_connection' + this.netquery + .query( + { + select: [ + { + $min: { + field: 'started', + as: 'first_connection', + }, }, + { + $count: { + field: '*', + as: 'totalCount', + }, + }, + ], + groupBy: ['profile'], + query: { + profile: `${profile[0].Source}/${profile[0].ID}`, }, - { - $count: { - field: '*', - as: 'totalCount', - } - }, - ], - groupBy: ['profile'], - query: { - profile: `${profile[0].Source}/${profile[0].ID}`, + databases: [Database.History], }, - databases: [Database.History], - }, 'app-view-get-first-connection') - .subscribe(result => { - if (result.length > 0) { - this.historyAvailableSince = new Date(result[0].first_connection!) - this.connectionsInHistory = result[0].totalCount; - } else { - this.historyAvailableSince = null; - this.connectionsInHistory = 0; - } - - this.cdr.markForCheck(); - }) - - this.appProfile = profile[0] || null; - this.layeredProfile = profile[1] || null; - this.stats = profile[2] || null; - } else { - this.appProfile = null; - this.layeredProfile = null; - this.stats = null; - } - - this.displayWarning = false; + 'app-view-get-first-connection' + ) + .subscribe((result) => { + if (result.length > 0) { + this.historyAvailableSince = new Date( + result[0].first_connection! + ); + this.connectionsInHistory = result[0].totalCount; + } else { + this.historyAvailableSince = null; + this.connectionsInHistory = 0; + } + + this.cdr.markForCheck(); + }); + + this.appProfile = profile[0] || null; + this.layeredProfile = profile[1] || null; + this.stats = profile[2] || null; + } else { + this.appProfile = null; + this.layeredProfile = null; + this.stats = null; + } - if (this.appProfile?.WarningLastUpdated) { - const now = new Date().getTime() - const diff = now - new Date(this.appProfile.WarningLastUpdated).getTime() - this.displayWarning = diff < 1000 * 60 * 60 * 24 * 7; - } + this.displayWarning = false; - if (!!this.netqueryViewer && this._loading) { - this.netqueryViewer.performSearch(); - } + if (this.appProfile?.WarningLastUpdated) { + const now = new Date().getTime(); + const diff = + now - new Date(this.appProfile.WarningLastUpdated).getTime(); + this.displayWarning = diff < 1000 * 60 * 60 * 24 * 7; + } - this._loading = false; + if (!!this.netqueryViewer && this._loading) { + this.netqueryViewer.performSearch(); + } - if (!!this.appProfile?.PresentationPath) { - let parts: string[] = []; - let sep = '/' - if (this.appProfile.PresentationPath[0] === '/') { - // linux, darwin, bsd ... - sep = '/' - } else { - // windows ... - sep = '\\' - } - parts = this.appProfile.PresentationPath.split(sep) + this._loading = false; - this.binaryName = parts.pop()!; - this.applicationDirectory = parts.join(sep) + if (!!this.appProfile?.PresentationPath) { + let parts: string[] = []; + let sep = '/'; + if (this.appProfile.PresentationPath[0] === '/') { + // linux, darwin, bsd ... + sep = '/'; } else { - this.applicationDirectory = ''; - this.binaryName = ''; + // windows ... + sep = '\\'; } + parts = this.appProfile.PresentationPath.split(sep); + + this.binaryName = parts.pop()!; + this.applicationDirectory = parts.join(sep); + } else { + this.applicationDirectory = ''; + this.binaryName = ''; + } + this.highlightSettingKey = queryMap.get('setting'); + let profileConfig: FlatConfigObject = {}; - this.highlightSettingKey = queryMap.get('setting'); - let profileConfig: FlatConfigObject = {}; + // if we have a profile flatten it's configuration map to something + // more useful. + if (!!this.appProfile) { + profileConfig = flattenProfileConfig(this.appProfile.Config); + } - // if we have a profile flatten it's configuration map to something - // more useful. - if (!!this.appProfile) { - profileConfig = flattenProfileConfig(this.appProfile.Config); + // if we should highlight a setting make sure to switch the + // viewSetting to all if it's the "global" default (that is, no + // value is set). Otherwise the setting won't render and we cannot + // highlight it. + // We need to keep this even though we default to "all" now since + // the following might happen: + // - user already navigated to an app-page and selected "View Active". + // - a notification comes in that has a "show setting" action + // - the user clicks the action button and the setting should be displayed + // - since the requested setting has not been changed it is not available + // in "View Active" so we need to switch back to "View All". Otherwise + // the action button would fail and the user would not notice something + // changing. + // + if (!!this.highlightSettingKey) { + if (profileConfig[this.highlightSettingKey] === undefined) { + this.viewSettingChange.next('all'); } + } - // if we should highlight a setting make sure to switch the - // viewSetting to all if it's the "global" default (that is, no - // value is set). Otherwise the setting won't render and we cannot - // highlight it. - // We need to keep this even though we default to "all" now since - // the following might happen: - // - user already navigated to an app-page and selected "View Active". - // - a notification comes in that has a "show setting" action - // - the user clicks the action button and the setting should be displayed - // - since the requested setting has not been changed it is not available - // in "View Active" so we need to switch back to "View All". Otherwise - // the action button would fail and the user would not notice something - // changing. - // - if (!!this.highlightSettingKey) { - if (profileConfig[this.highlightSettingKey] === undefined) { - this.viewSettingChange.next('all'); + // check if we got new values for the profile or the settings. In both cases, we need to update the + // profile settings displayed as there might be new values to show. + const profileChanged = previousProfile !== this.appProfile; + const settingsChanged = allSettings !== this.allSettings; + const globalChanged = global !== prevousGlobal; + + const settingsNeedUpdate = + profileChanged || settingsChanged || globalChanged; + + // save the current global config object so we can compare for identity changes + // the next time we're executed + prevousGlobal = global; + + if (!!this.appProfile && settingsNeedUpdate) { + // filter the settings and remove all settings that are not + // profile specific (i.e. not part of the global config). Also + // update the current settings value (from the app profile) and + // the default value (from the global profile). + this.profileSettings = allSettings.map((setting) => { + setting.Value = profileConfig[setting.Key]; + setting.GlobalDefault = global[setting.Key]; + + return setting; + }); + + this.settings = this.profileSettings.filter((setting) => { + if (!(setting.Key in global)) { + return false; } - } - // check if we got new values for the profile or the settings. In both cases, we need to update the - // profile settings displayed as there might be new values to show. - const profileChanged = (previousProfile !== this.appProfile); - const settingsChanged = (allSettings !== this.allSettings); - const globalChanged = (global !== prevousGlobal); - - const settingsNeedUpdate = profileChanged || settingsChanged || globalChanged; - - // save the current global config object so we can compare for identity changes - // the next time we're executed - prevousGlobal = global; - - - if (!!this.appProfile && settingsNeedUpdate) { - // filter the settings and remove all settings that are not - // profile specific (i.e. not part of the global config). Also - // update the current settings value (from the app profile) and - // the default value (from the global profile). - this.profileSettings = allSettings - .map(setting => { - setting.Value = profileConfig[setting.Key]; - setting.GlobalDefault = global[setting.Key]; - - return setting; - }) - - this.settings = this.profileSettings - .filter(setting => { - if (!(setting.Key in global)) { - return false; - } - - const isModified = setting.Value !== undefined; - if (this.viewSetting === 'all') { - return true; - } - return isModified; - }); - - this.allSettings = allSettings; - } + const isModified = setting.Value !== undefined; + if (this.viewSetting === 'all') { + return true; + } + return isModified; + }); - this.cdr.markForCheck(); - }); + this.allSettings = allSettings; + } - this.spn.profile$ - .pipe( - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: (profile) => { - this.canUseHistory = profile?.current_plan?.feature_ids?.includes(FeatureID.History) || false; - this.canViewBW = profile?.current_plan?.feature_ids?.includes(FeatureID.Bandwidth) || false; - this.canUseSPN = profile?.current_plan?.feature_ids?.includes(FeatureID.SPN) || false; - }, - }) + this.cdr.markForCheck(); + } + ); + + this.spn.profile$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (profile) => { + this.canUseHistory = + profile?.current_plan?.feature_ids?.includes(FeatureID.History) || + false; + this.canViewBW = + profile?.current_plan?.feature_ids?.includes(FeatureID.Bandwidth) || + false; + this.canUseSPN = + profile?.current_plan?.feature_ids?.includes(FeatureID.SPN) || false; + }, + }); } /** @@ -507,15 +591,16 @@ export class AppViewComponent implements OnInit, OnDestroy { return; } - this.debugAPI.getProfileDebugInfo(this.appProfile.Source, this.appProfile.ID) - .subscribe(data => { + this.debugAPI + .getProfileDebugInfo(this.appProfile.Source, this.appProfile.ID) + .subscribe((data) => { console.log(data); // Copy to clip-board if supported if (!!navigator.clipboard) { navigator.clipboard.writeText(data); - this.actionIndicator.success('Copied to Clipboard') + this.actionIndicator.success('Copied to Clipboard'); } - }) + }); } ngOnDestroy() { @@ -531,23 +616,26 @@ export class AppViewComponent implements OnInit, OnDestroy { return; } - this.dialog.confirm({ - canCancel: true, - caption: 'Caution', - header: 'Deleting Profile ' + this.appProfile.Name, - message: 'Do you really want to delete this profile? All settings will be lost.', - buttons: [ - { id: '', text: 'Cancel', class: 'outline' }, - { id: 'delete', class: 'danger', text: 'Yes, delete it' }, - ] - }) - .onAction('delete', () => { - this.profileService.deleteProfile(this.appProfile!) - .subscribe(() => { - this.router.navigate(['/app/overview']) - this.actionIndicator.success('Profile Deleted', 'Successfully deleted profile ' - + this.appProfile?.Name); - }) + this.dialog + .confirm({ + canCancel: true, + caption: 'Caution', + header: 'Deleting Profile ' + this.appProfile.Name, + message: + 'Do you really want to delete this profile? All settings will be lost.', + buttons: [ + { id: '', text: 'Cancel', class: 'outline' }, + { id: 'delete', class: 'danger', text: 'Yes, delete it' }, + ], }) + .onAction('delete', () => { + this.profileService.deleteProfile(this.appProfile!).subscribe(() => { + this.router.navigate(['/app/overview']); + this.actionIndicator.success( + 'Profile Deleted', + 'Successfully deleted profile ' + this.appProfile?.Name + ); + }); + }); } } diff --git a/modules/portmaster/src/app/pages/app-view/overview.html b/modules/portmaster/src/app/pages/app-view/overview.html index 127dddde..d711ef62 100644 --- a/modules/portmaster/src/app/pages/app-view/overview.html +++ b/modules/portmaster/src/app/pages/app-view/overview.html @@ -1,6 +1,11 @@ -
- +
+
@@ -13,25 +18,46 @@

Create profile - Merge or Delete profiles + Import Profile + Merge or Delete profiles
- +
Manage - - + +
- - Merge Profiles - Delete Profiles + Merge Profiles + Delete Profiles Abort @@ -39,86 +65,129 @@

{{ selectedProfileCount}} selected - - + + -

-

-

- Active -

+

Active

- +
-

- Recently Edited -

+

Recently Edited

- +
-

- All -

+

All

- +
-
+ [routerLink]="selectMode ? null : ['/app', profile.Source, profile.ID]" + > - + - - - + + +
- +
- +
- +
- +
-
+
No applications match your search term.
diff --git a/modules/portmaster/src/app/pages/app-view/overview.ts b/modules/portmaster/src/app/pages/app-view/overview.ts index 8bb19164..47be8a98 100644 --- a/modules/portmaster/src/app/pages/app-view/overview.ts +++ b/modules/portmaster/src/app/pages/app-view/overview.ts @@ -1,15 +1,34 @@ -import { ChangeDetectorRef, Component, OnDestroy, OnInit, TrackByFunction } from '@angular/core'; -import { AppProfile, AppProfileService, Netquery, trackById } from '@safing/portmaster-api'; +import { + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + TrackByFunction, +} from '@angular/core'; +import { + AppProfile, + AppProfileService, + Netquery, + trackById, +} from '@safing/portmaster-api'; import { SfngDialogService } from '@safing/ui'; import { BehaviorSubject, Subscription, combineLatest, forkJoin } from 'rxjs'; import { debounceTime, filter, startWith } from 'rxjs/operators'; -import { fadeInAnimation, fadeInListAnimation, moveInOutListAnimation } from 'src/app/shared/animations'; +import { + fadeInAnimation, + fadeInListAnimation, + moveInOutListAnimation, +} from 'src/app/shared/animations'; import { FuzzySearchService } from 'src/app/shared/fuzzySearch'; import { EditProfileDialog } from './../../shared/edit-profile-dialog/edit-profile-dialog'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { MergeProfileDialogComponent } from './merge-profile-dialog/merge-profile-dialog.component'; import { ActionIndicatorService } from 'src/app/shared/action-indicator'; import { Router } from '@angular/router'; +import { + ImportConfig, + ImportDialogComponent, +} from 'src/app/shared/config/import-dialog/import-dialog.component'; interface LocalAppProfile extends AppProfile { hasConfigChanges: boolean; @@ -20,11 +39,7 @@ interface LocalAppProfile extends AppProfile { selector: 'app-settings-overview', templateUrl: './overview.html', styleUrls: ['../page.scss', './overview.scss'], - animations: [ - fadeInAnimation, - fadeInListAnimation, - moveInOutListAnimation - ] + animations: [fadeInAnimation, fadeInListAnimation, moveInOutListAnimation], }) export class AppOverviewComponent implements OnInit, OnDestroy { private subscription = Subscription.EMPTY; @@ -53,15 +68,19 @@ export class AppOverviewComponent implements OnInit, OnDestroy { // reset all previous profile selections if (!this._selectMode) { - this.profiles.forEach(profile => profile.selected = false) + this.profiles.forEach((profile) => (profile.selected = false)); } } - get selectMode() { return this._selectMode } + get selectMode() { + return this._selectMode; + } private _selectMode = false; get selectedProfileCount() { - return this.profiles - .reduce((sum, profile) => profile.selected ? sum + 1 : sum, 0) + return this.profiles.reduce( + (sum, profile) => (profile.selected ? sum + 1 : sum), + 0 + ); } /** Observable emitting the search term */ @@ -77,19 +96,19 @@ export class AppOverviewComponent implements OnInit, OnDestroy { private netquery: Netquery, private dialog: SfngDialogService, private actionIndicator: ActionIndicatorService, - private router: Router, - ) { } + private router: Router + ) {} handleProfileClick(profile: LocalAppProfile, event: MouseEvent) { if (event.shiftKey) { // stay on the same page as clicking the app actually triggers // a navigation before this handler is executed. - this.router.navigate(['/app/overview']) + this.router.navigate(['/app/overview']); - this.selectMode = true + this.selectMode = true; - event.preventDefault() - event.stopImmediatePropagation() + event.preventDefault(); + event.stopImmediatePropagation(); event.stopPropagation(); } @@ -98,55 +117,75 @@ export class AppOverviewComponent implements OnInit, OnDestroy { } if (event.shiftKey && this.selectedProfileCount === 0) { - this.selectMode = false + this.selectMode = false; } } + importProfile() { + const importConfig: ImportConfig = { + type: 'profile', + key: '', + }; + + this.dialog.create(ImportDialogComponent, { + data: importConfig, + autoclose: false, + backdrop: 'light', + }); + } + openMergeDialog() { this.dialog.create(MergeProfileDialogComponent, { autoclose: true, backdrop: 'light', - data: this.profiles - .filter(p => p.selected), - }) + data: this.profiles.filter((p) => p.selected), + }); this.selectMode = false; } deleteSelectedProfiles() { - this.dialog.confirm({ - header: "Confirm Profile Deletion", - message: `Are you sure you want to delete all ${this.selectedProfileCount} selected profiles?`, - caption: 'Attention', - buttons: [ - { - id: 'no', - text: 'Abort', - class: 'outline' - }, - { - id: 'yes', - text: 'Delete', - class: 'danger' - }, - ] - }) + this.dialog + .confirm({ + header: 'Confirm Profile Deletion', + message: `Are you sure you want to delete all ${this.selectedProfileCount} selected profiles?`, + caption: 'Attention', + buttons: [ + { + id: 'no', + text: 'Abort', + class: 'outline', + }, + { + id: 'yes', + text: 'Delete', + class: 'danger', + }, + ], + }) .onAction('yes', () => { forkJoin( this.profiles - .filter(profile => profile.selected) - .map(p => this.profileService.deleteProfile(p)) + .filter((profile) => profile.selected) + .map((p) => this.profileService.deleteProfile(p)) ).subscribe({ next: () => { - this.actionIndicator.success('Selected Profiles Delete', 'All selected profiles have been deleted') + this.actionIndicator.success( + 'Selected Profiles Delete', + 'All selected profiles have been deleted' + ); }, - error: err => { - this.actionIndicator.error('Failed To Delete Profiles', `An error occured while deleting some profiles: ${this.actionIndicator.getErrorMessgae(err)}`) - } - }) + error: (err) => { + this.actionIndicator.error( + 'Failed To Delete Profiles', + `An error occured while deleting some profiles: ${this.actionIndicator.getErrorMessgae( + err + )}` + ); + }, + }); }) - .onClose - .subscribe(() => this.selectMode = false) + .onClose.subscribe(() => (this.selectMode = false)); } ngOnInit() { @@ -156,72 +195,78 @@ export class AppOverviewComponent implements OnInit, OnDestroy { this.profileService.watchProfiles(), this.onSearch.pipe(debounceTime(100), startWith('')), this.netquery.getActiveProfileIDs().pipe(startWith([] as string[])), - ]) - .subscribe( - ([profiles, searchTerm, activeProfiles]) => { - this.loading = false; - - // find all profiles that match the search term. For searchTerm="" thsi - // will return all profiles. - const filtered = this.searchService.searchList(profiles, searchTerm, { - ignoreLocation: true, - ignoreFieldNorm: true, - threshold: 0.1, - minMatchCharLength: 3, - keys: ['Name', 'PresentationPath'] - }); - - // create a lookup map of all profiles we already loaded so we don't loose - // selection state when a profile has been updated. - const oldProfiles = new Map(this.profiles.map(profile => [`${profile.Source}/${profile.ID}`, profile])); - - // Prepare new, empty lists for our groups - this.profiles = []; - this.runningProfiles = []; - this.recentlyEdited = []; - - // calcualte the threshold for "recently-used" (1 week). - const recentlyUsedThreshold = new Date().valueOf() / 1000 - (60 * 60 * 24 * 7); - - // flatten the filtered profiles, sort them by name and group them into - // our "app-groups" (active, recentlyUsed, others) - this.total = filtered.length; - filtered - .map(item => item.item) - .sort((a, b) => { - const aName = a.Name.toLocaleLowerCase(); - const bName = b.Name.toLocaleLowerCase(); - - if (aName > bName) { - return 1; - } - - if (aName < bName) { - return -1; - } - - return 0; - }) - .forEach(profile => { - const local: LocalAppProfile = { - ...profile, - hasConfigChanges: profile.LastEdited > 0 && Object.keys(profile.Config).length > 0, - selected: oldProfiles.get(`${profile.Source}/${profile.ID}`)?.selected || false, - }; - - if (activeProfiles.includes(profile.Source + "/" + profile.ID)) { - this.runningProfiles.push(local); - } else if (profile.LastEdited >= recentlyUsedThreshold) { - this.recentlyEdited.push(local); - } - - // we always add the profile to "All Apps" - this.profiles.push(local); - }); - - this.changeDetector.markForCheck(); - } - ) + ]).subscribe(([profiles, searchTerm, activeProfiles]) => { + this.loading = false; + + // find all profiles that match the search term. For searchTerm="" thsi + // will return all profiles. + const filtered = this.searchService.searchList(profiles, searchTerm, { + ignoreLocation: true, + ignoreFieldNorm: true, + threshold: 0.1, + minMatchCharLength: 3, + keys: ['Name', 'PresentationPath'], + }); + + // create a lookup map of all profiles we already loaded so we don't loose + // selection state when a profile has been updated. + const oldProfiles = new Map( + this.profiles.map((profile) => [ + `${profile.Source}/${profile.ID}`, + profile, + ]) + ); + + // Prepare new, empty lists for our groups + this.profiles = []; + this.runningProfiles = []; + this.recentlyEdited = []; + + // calcualte the threshold for "recently-used" (1 week). + const recentlyUsedThreshold = + new Date().valueOf() / 1000 - 60 * 60 * 24 * 7; + + // flatten the filtered profiles, sort them by name and group them into + // our "app-groups" (active, recentlyUsed, others) + this.total = filtered.length; + filtered + .map((item) => item.item) + .sort((a, b) => { + const aName = a.Name.toLocaleLowerCase(); + const bName = b.Name.toLocaleLowerCase(); + + if (aName > bName) { + return 1; + } + + if (aName < bName) { + return -1; + } + + return 0; + }) + .forEach((profile) => { + const local: LocalAppProfile = { + ...profile, + hasConfigChanges: + profile.LastEdited > 0 && Object.keys(profile.Config).length > 0, + selected: + oldProfiles.get(`${profile.Source}/${profile.ID}`)?.selected || + false, + }; + + if (activeProfiles.includes(profile.Source + '/' + profile.ID)) { + this.runningProfiles.push(local); + } else if (profile.LastEdited >= recentlyUsedThreshold) { + this.recentlyEdited.push(local); + } + + // we always add the profile to "All Apps" + this.profiles.push(local); + }); + + this.changeDetector.markForCheck(); + }); } /** @@ -245,15 +290,13 @@ export class AppOverviewComponent implements OnInit, OnDestroy { const ref = this.dialog.create(EditProfileDialog, { backdrop: true, autoclose: false, - }) - - ref.onClose - .pipe(filter(action => action === 'saved')) - .subscribe(() => { - // reset the search and reload to make sure the new - // profile shows up - this.searchApps(''); - }) + }); + + ref.onClose.pipe(filter((action) => action === 'saved')).subscribe(() => { + // reset the search and reload to make sure the new + // profile shows up + this.searchApps(''); + }); } ngOnDestroy() { diff --git a/modules/portmaster/src/app/pages/app-view/qs-history/qs-history.component.ts b/modules/portmaster/src/app/pages/app-view/qs-history/qs-history.component.ts index 41904b39..24e6296a 100644 --- a/modules/portmaster/src/app/pages/app-view/qs-history/qs-history.component.ts +++ b/modules/portmaster/src/app/pages/app-view/qs-history/qs-history.component.ts @@ -1,6 +1,21 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + inject, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { BoolSetting, FeatureID, SPNService, Setting, getActualValue } from '@safing/portmaster-api'; +import { + BoolSetting, + FeatureID, + SPNService, + Setting, + getActualValue, +} from '@safing/portmaster-api'; import { BehaviorSubject, Observable, map } from 'rxjs'; import { share } from 'rxjs/operators'; import { SaveSettingEvent } from 'src/app/shared/config'; @@ -8,20 +23,19 @@ import { SaveSettingEvent } from 'src/app/shared/config'; @Component({ selector: 'app-qs-history', templateUrl: './qs-history.component.html', - styleUrls: ['./qs-history.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class QsHistoryComponent implements OnChanges { currentValue = false; - historyFeatureAllowed: Observable = inject(SPNService) - .profile$ - .pipe( - takeUntilDestroyed(), - map(profile => { - return (profile?.current_plan?.feature_ids?.includes(FeatureID.History)) || false; - }), - share({ connector: () => new BehaviorSubject(false) }) - ) + historyFeatureAllowed: Observable = inject(SPNService).profile$.pipe( + takeUntilDestroyed(), + map((profile) => { + return ( + profile?.current_plan?.feature_ids?.includes(FeatureID.History) || false + ); + }), + share({ connector: () => new BehaviorSubject(false) }) + ); @Input() canUse: boolean = true; @@ -34,7 +48,9 @@ export class QsHistoryComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { if ('settings' in changes) { - const historySetting = this.settings.find(s => s.Key === 'history/enable') as (BoolSetting | undefined); + const historySetting = this.settings.find( + (s) => s.Key === 'history/enable' + ) as BoolSetting | undefined; if (historySetting) { this.currentValue = getActualValue(historySetting); } @@ -46,6 +62,6 @@ export class QsHistoryComponent implements OnChanges { isDefault: false, key: 'history/enable', value: enabled, - }) + }); } } diff --git a/modules/portmaster/src/app/pages/app-view/qs-select-exit/qs-select-exit.ts b/modules/portmaster/src/app/pages/app-view/qs-select-exit/qs-select-exit.ts index d2d67105..698607b6 100644 --- a/modules/portmaster/src/app/pages/app-view/qs-select-exit/qs-select-exit.ts +++ b/modules/portmaster/src/app/pages/app-view/qs-select-exit/qs-select-exit.ts @@ -1,15 +1,35 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { BoolSetting, StringArraySetting, CountrySelectionQuickSetting, ConfigService, Setting, getActualValue } from "@safing/portmaster-api"; -import { SaveSettingEvent } from "src/app/shared/config/generic-setting/generic-setting"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + BoolSetting, + StringArraySetting, + CountrySelectionQuickSetting, + ConfigService, + Setting, + getActualValue, +} from '@safing/portmaster-api'; +import { SaveSettingEvent } from 'src/app/shared/config/generic-setting/generic-setting'; @Component({ selector: 'app-qs-select-exit', templateUrl: './qs-select-exit.html', - styleUrls: ['./qs-select-exit.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class QuickSettingSelectExitButtonComponent implements OnInit, OnChanges { +export class QuickSettingSelectExitButtonComponent + implements OnInit, OnChanges +{ private destroyRef = inject(DestroyRef); @Input() @@ -30,21 +50,21 @@ export class QuickSettingSelectExitButtonComponent implements OnInit, OnChanges constructor( private configService: ConfigService, private cdr: ChangeDetectorRef - ) { } + ) {} updateExitRules(newExitRules: string) { this.selectedExitRules = newExitRules; let newConfigValue: string[] = []; if (!!newExitRules) { - newConfigValue = newExitRules.split(",") + newConfigValue = newExitRules.split(','); } this.save.next({ isDefault: false, key: 'spn/exitHubPolicy', value: newConfigValue, - }) + }); } ngOnChanges(changes: SimpleChanges): void { @@ -52,7 +72,9 @@ export class QuickSettingSelectExitButtonComponent implements OnInit, OnChanges this.exitRuleSetting = null; this.selectedExitRules = undefined; - const exitRuleSetting = this.settings.find(s => s.Key == 'spn/exitHubPolicy') as (StringArraySetting | undefined); + const exitRuleSetting = this.settings.find( + (s) => s.Key == 'spn/exitHubPolicy' + ) as StringArraySetting | undefined; if (exitRuleSetting) { this.exitRuleSetting = exitRuleSetting; this.updateOptions(); @@ -61,23 +83,24 @@ export class QuickSettingSelectExitButtonComponent implements OnInit, OnChanges } ngOnInit() { - this.configService.watch('spn/enable') + this.configService + .watch('spn/enable') .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(value => { + .subscribe((value) => { this.spnEnabled = value; this.updateOptions(); - }) + }); } private updateOptions() { if (!this.exitRuleSetting) { this.selectedExitRules = undefined; this.availableExitRules = null; - return + return; } if (!!this.exitRuleSetting.Value && this.exitRuleSetting.Value.length > 0) { - this.selectedExitRules = this.exitRuleSetting.Value.join(",") + this.selectedExitRules = this.exitRuleSetting.Value.join(','); } this.availableExitRules = this.getQuickSettings(); @@ -89,7 +112,9 @@ export class QuickSettingSelectExitButtonComponent implements OnInit, OnChanges return []; } - let val = this.exitRuleSetting.Annotations["safing/portbase:ui:quick-setting"] as CountrySelectionQuickSetting[]; + let val = this.exitRuleSetting.Annotations[ + 'safing/portbase:ui:quick-setting' + ] as CountrySelectionQuickSetting[]; if (val === undefined) { return []; } diff --git a/modules/portmaster/src/app/shared/app-icon/app-icon.ts b/modules/portmaster/src/app/shared/app-icon/app-icon.ts index d9ec8117..40e8a57b 100644 --- a/modules/portmaster/src/app/shared/app-icon/app-icon.ts +++ b/modules/portmaster/src/app/shared/app-icon/app-icon.ts @@ -1,6 +1,20 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, OnDestroy, SkipSelf } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + HostBinding, + Inject, + Input, + OnDestroy, + SkipSelf, +} from '@angular/core'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; -import { AppProfileService, PortapiService, Record } from '@safing/portmaster-api'; +import { + AppProfileService, + PORTMASTER_HTTP_API_ENDPOINT, + PortapiService, + Record, +} from '@safing/portmaster-api'; import { Subscription, map, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; @@ -21,18 +35,15 @@ export interface IDandName { // 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" -] + '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', @@ -44,7 +55,7 @@ export class AppIconComponent implements OnDestroy { private sub = Subscription.EMPTY; /** @private The data-URL for the app-icon if available */ - src: SafeUrl | string = '' + src: SafeUrl | string = ''; /** The profile for which to show the app-icon */ @Input() @@ -57,7 +68,9 @@ export class AppIconComponent implements OnDestroy { this._profile = p || null; this.updateView(); } - get profile() { return this._profile; } + get profile() { + return this._profile; + } private _profile: IDandName | null = null; /** isIgnoredProfile is set to true if the profile is part of profilesToIgnore */ @@ -80,12 +93,13 @@ export class AppIconComponent implements OnDestroy { // 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 updateView() { const p = this.profile; - const sourceAndId = this.getIDAndSource() + const sourceAndId = this.getIDAndSource(); if (!!p && sourceAndId !== null) { let idx = 0; @@ -93,15 +107,18 @@ export class AppIconComponent implements OnDestroy { idx += (p.ID || p.Name).charCodeAt(i); } - const combinedID = `${sourceAndId[0]}/${sourceAndId[1]}` + const combinedID = `${sourceAndId[0]}/${sourceAndId[1]}`; this.isIgnoredProfile = profilesToIgnore.includes(combinedID); - if (p.Name !== "") { + if (p.Name !== '') { if (p.Name[0] === '<') { // we might get the name with search-highlighting which // will then include tags. If the first character is a < // make sure to strip all HTML tags before getting [0]. - this.letter = p.Name.replace(/( |<([^>]+)>)/ig, "")[0].toLocaleUpperCase(); + this.letter = p.Name.replace( + /( |<([^>]+)>)/gi, + '' + )[0].toLocaleUpperCase(); } else { this.letter = p.Name[0]; } @@ -139,18 +156,18 @@ export class AppIconComponent implements OnDestroy { // if there's a source ID only holds the profile ID if (!!this.profile.Source) { - return [this.profile.Source, id] + return [this.profile.Source, id]; } // otherwise, ID likely contains the source - let [source, ...rest] = id.split("/") + let [source, ...rest] = id.split('/'); if (rest.length > 0) { - return [source, rest.join('/')] + return [source, rest.join('/')]; } // id does not contain a forward-slash so we // assume the source is local - return ['local', id] + return ['local', id]; } /** @@ -158,27 +175,35 @@ export class AppIconComponent implements OnDestroy { * Requires the app to be running in the electron wrapper. */ private tryGetSystemIcon(p: IDandName) { - const sourceAndId = this.getIDAndSource() + const sourceAndId = this.getIDAndSource(); if (sourceAndId === null) { return; } this.sub.unsubscribe(); - this.sub = this.profileService.watchAppProfile(sourceAndId[0], sourceAndId[1]) + this.sub = this.profileService + .watchAppProfile(sourceAndId[0], sourceAndId[1]) .pipe( - switchMap(profile => { + switchMap((profile) => { if (!!profile.Icons?.length) { const firstIcon = profile.Icons[0]; switch (firstIcon.Type) { case 'database': - return this.portapi.get(firstIcon.Value) - .pipe(map(result => { - return result.iconData - })) + return this.portapi + .get(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`) + console.error(`Icon type ${firstIcon.Type} not yet supported`); } } @@ -186,25 +211,26 @@ export class AppIconComponent implements OnDestroy { return window.app.getFileIcon(profile.PresentationPath); } - return of('') + return of(''); }) ) .subscribe({ - next: icon => { - if (iconsToIngore.some(i => i === icon)) { - icon = ""; + 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.color = + this.color === 'unset' ? 'var(--text-tertiary)' : this.color; } this.changeDetectorRef.detectChanges(); this.parentCdr.markForCheck(); }, - error: err => console.error(err) + error: (err) => console.error(err), }); } @@ -214,23 +240,23 @@ export class AppIconComponent implements OnDestroy { } 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)", + '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)', ]; diff --git a/modules/portmaster/src/app/shared/config/config-settings.ts b/modules/portmaster/src/app/shared/config/config-settings.ts index 44228dc8..214ef44a 100644 --- a/modules/portmaster/src/app/shared/config/config-settings.ts +++ b/modules/portmaster/src/app/shared/config/config-settings.ts @@ -1,18 +1,48 @@ 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 { + 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 { + 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 { SettingsExportDialogComponent } from './export-dialog/export-dialog.component'; -import { SettingsImportDialogComponent } from './import-dialog/import-dialog.component'; +import { + ExportConfig, + ExportDialogComponent, +} from './export-dialog/export-dialog.component'; +import { + ImportConfig, + ImportDialogComponent, +} from './import-dialog/import-dialog.component'; interface Category { name: string; @@ -32,11 +62,13 @@ interface SubsystemWithExpertise extends Subsystem { selector: 'app-settings-view', templateUrl: './config-settings.html', styleUrls: ['./config-settings.scss'], - animations: [fadeInAnimation, fadeOutAnimation, fadeInListAnimation] + animations: [fadeInAnimation, fadeOutAnimation, fadeInListAnimation], }) -export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterViewInit { +export class ConfigSettingsViewComponent + implements OnInit, OnDestroy, AfterViewInit +{ subsystems: SubsystemWithExpertise[] = []; - others: Setting[] | null = null + others: Setting[] | null = null; settings: Map = new Map(); /** A list of all selected settings for export */ @@ -57,7 +89,7 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView this._compactView = coerceBooleanProperty(v); } get compactView() { - return this._compactView + return this._compactView; } private _compactView = false; @@ -74,7 +106,9 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView set userSettingsMarker(v: any) { this._userSettingsMarker = coerceBooleanProperty(v); } - get userSettingsMarker() { return this._userSettingsMarker } + get userSettingsMarker() { + return this._userSettingsMarker; + } private _userSettingsMarker = true; @Input() @@ -91,7 +125,9 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView set scope(scope: 'global' | string) { this._scope = scope; } - get scope() { return this._scope } + get scope() { + return this._scope; + } private _scope: 'global' | string = 'global'; @Input() @@ -114,7 +150,10 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView private _highlightKey: string | null = null; private _scrolledToHighlighted = false; - mustShowSetting: ExpertiseLevelOverwrite = (lvl: ExpertiseLevelNumber, s: Setting) => { + mustShowSetting: ExpertiseLevelOverwrite = ( + lvl: ExpertiseLevelNumber, + s: Setting + ) => { if (lvl >= s.ExpertiseLevel) { // this setting is shown anyway. return false; @@ -131,15 +170,23 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView return false; } return true; - } - - mustShowCategory: ExpertiseLevelOverwrite = (lvl: ExpertiseLevelNumber, cat: Category) => { - return cat.settings.some(setting => this.mustShowSetting(lvl, setting)); - } - - mustShowSubsystem: ExpertiseLevelOverwrite = (lvl: ExpertiseLevelNumber, subsys: SubsystemWithExpertise) => { - return !!this.settings.get(subsys.ConfigKeySpace)?.some(cat => this.mustShowCategory(lvl, cat)) - } + }; + + mustShowCategory: ExpertiseLevelOverwrite = ( + lvl: ExpertiseLevelNumber, + cat: Category + ) => { + return cat.settings.some((setting) => this.mustShowSetting(lvl, setting)); + }; + + mustShowSubsystem: ExpertiseLevelOverwrite = ( + lvl: ExpertiseLevelNumber, + subsys: SubsystemWithExpertise + ) => { + return !!this.settings + .get(subsys.ConfigKeySpace) + ?.some((cat) => this.mustShowCategory(lvl, cat)); + }; @Output() save = new EventEmitter(); @@ -162,70 +209,84 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView private actionIndicator: ActionIndicatorService, private portapi: PortapiService, private dialog: SfngDialogService - ) { } + ) {} openImportDialog() { - this.dialog.create(SettingsImportDialogComponent, { - data: this.scope, + 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 setttings you want to export and press "Save" to generate the export. Note that settings with system defaults cannot be exported and are hidden.') + this.actionIndicator.info( + 'Settings Export', + 'Please select all setttings 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) - } + let selectedKeys = Object.keys(this.selectedSettings).reduce((sum, key) => { + if (this.selectedSettings[key]) { + sum.push(key); + } - return sum - }, [] as string[]) + return sum; + }, [] as string[]); if (selectedKeys.length === 0) { - selectedKeys = Array.from(this.settings.values()) - .reduce((sum, current) => { - current.forEach(cat => { - cat.settings.forEach(s => { + selectedKeys = Array.from(this.settings.values()).reduce( + (sum, current) => { + current.forEach((cat) => { + cat.settings.forEach((s) => { if (s.Value !== undefined) { - sum.push(s.Key) + sum.push(s.Key); } - }) - }) + }); + }); - return sum - }, [] as string[]) + return sum; + }, + [] as string[] + ); } - this.portapi.exportSettings(selectedKeys, this.scope) - .subscribe({ - next: exportBlob => { - this.dialog.create(SettingsExportDialogComponent, { - data: exportBlob, - backdrop: 'light', - autoclose: true, - }) - - this.exportMode = false; - }, - error: err => { - const msg = this.actionIndicator.getErrorMessgae(err) - this.actionIndicator.error('Failed To Generate Export', msg) - } - }) + this.portapi.exportSettings(selectedKeys, this.scope).subscribe({ + next: (exportBlob) => { + const exportConfig: ExportConfig = { + type: 'setting', + content: exportBlob, + }; + + this.dialog.create(ExportDialogComponent, { + data: exportBlob, + 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) + 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. @@ -233,7 +294,8 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView } } - trackSubsystem: TrackByFunction = this.statusService.trackSubsystem; + trackSubsystem: TrackByFunction = + this.statusService.trackSubsystem; trackCategory(_: number, cat: Category) { return cat.name; @@ -244,12 +306,12 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView this.onSettingsChange, this.statusService.querySubsystem(), this.onSearch.pipe(debounceTime(250)), - this.configService.watch("core/releaseLevel"), + this.configService.watch('core/releaseLevel'), ]) .pipe(debounceTime(10)) .subscribe( ([settings, subsystems, searchTerm, currentReleaseLevelSetting]) => { - this.subsystems = subsystems.map(s => ({ + this.subsystems = subsystems.map((s) => ({ ...s, // we start with developer and decrease to the lowest number required // while grouping the settings. @@ -262,11 +324,13 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView // Get the current release level as a number (fallback to 'stable' is something goes wrong) const currentReleaseLevel = releaseLevelFromName( - currentReleaseLevelSetting || 'stable' as any + currentReleaseLevelSetting || ('stable' as any) ); // Make sure we only display settings that are allowed by the releaselevel setting. - settings = settings.filter(setting => setting.ReleaseLevel <= currentReleaseLevel); + settings = settings.filter( + (setting) => setting.ReleaseLevel <= currentReleaseLevel + ); // Use fuzzy-search to limit the number of settings shown. const filtered = this.searchService.searchList(settings, searchTerm, { @@ -277,32 +341,36 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView 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); + 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; + const orderA = a.Annotations?.['safing/portbase:ui:order'] || 0; + const orderB = b.Annotations?.['safing/portbase:ui:order'] || 0; return orderA - orderB; }); - - settings.forEach(setting => { + settings.forEach((setting) => { let pushed = false; - this.subsystems.forEach(subsys => { - if (setting.Key.startsWith(subsys.ConfigKeySpace.slice("config:".length))) { - + 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"] + 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. @@ -313,7 +381,7 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView } // find or create the appropriate category object. - let cat = categories.find(c => c.name === catName) + let cat = categories.find((c) => c.name === catName); if (!cat) { cat = { name: catName, @@ -321,27 +389,27 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView 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) + 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; @@ -351,42 +419,51 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView // actually have settings to show. // Also update the minimumExpertiseLevel for those subsystems this.subsystems = this.subsystems - .filter(subsys => { + .filter((subsys) => { return !!this.settings.get(subsys.ConfigKeySpace); }) - .map(subsys => { - let categories = 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; + 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) + toggleOption = c.settings.find( + (s) => s.Key === subsys.ToggleOptionKey + ); if (!!toggleOption) { - if (toggleOption.Value !== undefined && !toggleOption.Value || (toggleOption.Value === undefined && !toggleOption.DefaultValue)) { + 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 => ({ + .map((c) => ({ ...c, - settings: c.settings.filter(s => s.Key === toggleOption!.Key), + settings: c.settings.filter( + (s) => s.Key === toggleOption!.Key + ), })) - .filter(cat => cat.settings.length > 0) + .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) { @@ -396,11 +473,13 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView }, 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); + 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 @@ -416,10 +495,10 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView // Use the next animation frame for scrolling window.requestAnimationFrame(() => { this.scrollTo(this._highlightKey || ''); - }) + }); } } - ) + ); } ngAfterViewInit() { @@ -429,9 +508,10 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView // need to update which setting is currently highlighted // in the settings-navigation. this.subscription.add( - this.scrollDispatcher.scrolled(10) - .subscribe(() => this.intersectionCallback()), - ) + this.scrollDispatcher + .scrolled(10) + .subscribe(() => this.intersectionCallback()) + ); // Also, entries in the settings-navigation might become // visible with expertise/release level changes so make @@ -478,12 +558,12 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView offsetTop = viewRect.top; } - this.navLinks?.some(link => { - const subsystem = link.nativeElement.getAttribute("subsystem"); - const category = link.nativeElement.getAttribute("category"); - + 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; + const lastChild = (link.nativeElement as HTMLElement) + .lastElementChild as HTMLElement; if (!lastChild) { return false; } @@ -491,7 +571,11 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView const rect = lastChild.getBoundingClientRect(); const styleBox = getComputedStyle(lastChild); - const offset = rect.top + rect.height - parseInt(styleBox.marginBottom) - parseInt(styleBox.paddingBottom); + const offset = + rect.top + + rect.height - + parseInt(styleBox.marginBottom) - + parseInt(styleBox.paddingBottom); if (offset >= offsetTop) { this.activeSection = subsystem; @@ -500,7 +584,7 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView } return false; - }) + }); this.changeDetectorRef.detectChanges(); } @@ -518,6 +602,6 @@ export class ConfigSettingsViewComponent implements OnInit, OnDestroy, AfterView behavior: 'smooth', block: 'start', inline: 'nearest', - }) + }); } } diff --git a/modules/portmaster/src/app/shared/config/config.module.ts b/modules/portmaster/src/app/shared/config/config.module.ts index 0fe6dd23..4632bcfb 100644 --- a/modules/portmaster/src/app/shared/config/config.module.ts +++ b/modules/portmaster/src/app/shared/config/config.module.ts @@ -1,26 +1,34 @@ -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 { SecuritySettingComponent } from "./security-setting/security-setting"; -import { SettingsExportDialogComponent } from "./export-dialog/export-dialog.component"; -import { SettingsImportDialogComponent } from "./import-dialog/import-dialog.component"; +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 { SecuritySettingComponent } from './security-setting/security-setting'; +import { ExportDialogComponent } from './export-dialog/export-dialog.component'; +import { ImportDialogComponent } from './import-dialog/import-dialog.component'; @NgModule({ imports: [ @@ -38,7 +46,7 @@ import { SettingsImportDialogComponent } from "./import-dialog/import-dialog.com RouterModule, ExpertiseModule, SfngToggleSwitchModule, - MarkdownModule + MarkdownModule, ], declarations: [ BasicSettingComponent, @@ -51,8 +59,8 @@ import { SettingsImportDialogComponent } from "./import-dialog/import-dialog.com ConfigSettingsViewComponent, GenericSettingComponent, SafePipe, - SettingsExportDialogComponent, - SettingsImportDialogComponent + ExportDialogComponent, + ImportDialogComponent, ], exports: [ BasicSettingComponent, @@ -65,6 +73,6 @@ import { SettingsImportDialogComponent } from "./import-dialog/import-dialog.com ConfigSettingsViewComponent, GenericSettingComponent, SafePipe, - ] + ], }) -export class ConfigModule { } +export class ConfigModule {} diff --git a/modules/portmaster/src/app/shared/config/export-dialog/export-dialog.component.html b/modules/portmaster/src/app/shared/config/export-dialog/export-dialog.component.html index fab1a3f8..87322dc7 100644 --- a/modules/portmaster/src/app/shared/config/export-dialog/export-dialog.component.html +++ b/modules/portmaster/src/app/shared/config/export-dialog/export-dialog.component.html @@ -1,8 +1,20 @@
-

Settings Export

+

+ {{ dialogRef.data.type === "setting" ? "Settings" : "Profile" }} Export +

-
diff --git a/modules/portmaster/src/app/shared/config/export-dialog/export-dialog.component.ts b/modules/portmaster/src/app/shared/config/export-dialog/export-dialog.component.ts index 7ab192bc..c57521fd 100644 --- a/modules/portmaster/src/app/shared/config/export-dialog/export-dialog.component.ts +++ b/modules/portmaster/src/app/shared/config/export-dialog/export-dialog.component.ts @@ -1,43 +1,62 @@ -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 { 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'; + +export interface ExportConfig { + content: string; + type: 'setting' | 'profile'; +} @Component({ templateUrl: './export-dialog.component.html', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SettingsExportDialogComponent implements OnInit { - readonly dialogRef: SfngDialogRef = inject(SFNG_DIALOG_REF) +export class ExportDialogComponent implements OnInit { + readonly dialogRef: SfngDialogRef< + ExportDialogComponent, + unknown, + ExportConfig + > = inject(SFNG_DIALOG_REF); private readonly elementRef: ElementRef = inject(ElementRef); private readonly document = inject(DOCUMENT); - private readonly uai = inject(ActionIndicatorService) + private readonly uai = inject(ActionIndicatorService); content = ''; ngOnInit(): void { - this.content = "```yaml\n" + this.dialogRef.data + "\n```" + this.content = '```yaml\n' + this.dialogRef.data.content + '\n```'; } download() { - const blob = new Blob([this.dialogRef.data], { type: 'text/yaml' }); + 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" + elem.download = 'export.yaml'; this.elementRef.nativeElement.appendChild(elem); - elem.click() + elem.click(); this.elementRef.nativeElement.removeChild(elem); } copyToClipboard() { if (!!navigator.clipboard) { - navigator.clipboard.writeText(this.dialogRef.data) - .then(() => this.uai.success("Copied to Clipboard")) + navigator.clipboard + .writeText(this.dialogRef.data.content) + .then(() => this.uai.success('Copied to Clipboard')) .catch(() => this.uai.error('Failed to Copy to Clipboard')); } else { - this.uai.info('Failed to Copy to Clipboard', 'Copy to clipboard is not supported by your browser') + this.uai.info( + 'Failed to Copy to Clipboard', + 'Copy to clipboard is not supported by your browser' + ); } } } diff --git a/modules/portmaster/src/app/shared/config/import-dialog/import-dialog.component.html b/modules/portmaster/src/app/shared/config/import-dialog/import-dialog.component.html index 364530e8..05564133 100644 --- a/modules/portmaster/src/app/shared/config/import-dialog/import-dialog.component.html +++ b/modules/portmaster/src/app/shared/config/import-dialog/import-dialog.component.html @@ -1,52 +1,123 @@ -
-

Import Settings

+
+

+ Import {{ dialogRef.data.type === "setting" ? "Settings" : "Profile" }} +

-
-Please paste the "Settings Export" or use "Choose File" to select one from your hard disk. -

+Please paste the "Export Content" or use "Choose File" to select one from
+  your hard disk.
+

 
-
- Configuration +
+ Configuration -
+
- - + +
- - + + +
+ + +
+ +
- - + +
-
- Warning +
+ Warning -
- - +
+ + {{ errorMessage }}
-
    +
    • - This export contains unknown settings. To import it, you must enable "Allow unknown settings". + This export contains unknown settings. To import it, you must enable + "Allow unknown settings".
    • - This export will overwrite settings that have been changed by you. + {{ + dialogRef.data.type === "setting" + ? "This export will overwrite settings that have been changed by you." + : "This export will overwrite an existing profile" + }}
    • @@ -57,12 +128,18 @@

      Import Settings

      - +
      - + diff --git a/modules/portmaster/src/app/shared/config/import-dialog/import-dialog.component.ts b/modules/portmaster/src/app/shared/config/import-dialog/import-dialog.component.ts index b7aa4c0a..c84bb5f7 100644 --- a/modules/portmaster/src/app/shared/config/import-dialog/import-dialog.component.ts +++ b/modules/portmaster/src/app/shared/config/import-dialog/import-dialog.component.ts @@ -1,33 +1,47 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, OnInit, ViewChild, inject } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { SFNG_DIALOG_REF, SfngDialogRef } from "@safing/ui"; -import { Subject, debounceTime, distinctUntilChanged, takeUntil } from "rxjs"; -import { Cursor } from "./cursor"; -import { getSelectionOffset, setSelectionOffset } from "./selection"; -import { ImportResult, PortapiService } from "@safing/portmaster-api"; -import { HttpErrorResponse } from "@angular/common/http"; -import { ActionIndicatorService } from "../../action-indicator"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + ViewChild, + inject, +} from '@angular/core'; +import { ImportResult, PortapiService } 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; - min-height: 24rem; - min-width: 24rem; - max-height: 40rem; - max-width: 40rem; - } - ` + :host { + @apply flex flex-col gap-2; + min-height: 24rem; + min-width: 24rem; + max-height: 40rem; + max-width: 40rem; + } + `, ], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SettingsImportDialogComponent { - readonly dialogRef: SfngDialogRef = inject(SFNG_DIALOG_REF); - private readonly portapi = inject(PortapiService) - private readonly uai = inject(ActionIndicatorService) - private readonly cdr = inject(ChangeDetectorRef) +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; @@ -36,14 +50,17 @@ export class SettingsImportDialogComponent { reset = false; allowUnknown = false; triggerRestart = false; + allowReplace = false; errorMessage: string = ''; - get scope() { return this.dialogRef.data } + get scope() { + return this.dialogRef.data; + } onBlur() { const text = this.codeBlockElement.nativeElement.innerText; - this.updateAndValidate(text) + this.updateAndValidate(text); } onPaste(event: ClipboardEvent) { @@ -54,84 +71,119 @@ export class SettingsImportDialogComponent { const clipboardData = event.clipboardData || (window as any).clipboardData; const text = clipboardData.getData('Text'); - this.updateAndValidate(text) + this.updateAndValidate(text); } - importSetting() { + import() { const text = this.codeBlockElement.nativeElement.innerText; - this.portapi.importSettings(text, this.dialogRef.data, 'text/yaml', this.reset, this.allowUnknown) - .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.' - } - } + let saveFunc: Observable; + + 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 + ); + } - this.uai.success('Settings Imported Successfully', msg) - this.dialogRef.close(); - }, - error: err => { - this.uai.error('Failed To Import Settings', this.uai.getErrorMessgae(err)) + 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 [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) + setSelectionOffset(this.codeBlockElement.nativeElement, start, end); if (content === '') { - return + return; } window.getSelection()?.removeAllRanges(); - this.portapi.validateSettingsImport(content, this.dialogRef.data, 'text/yaml') - .subscribe({ - next: result => { - this.result = result; - this.errorMessage = ''; + let validateFunc: Observable; - this.cdr.markForCheck(); - }, - error: err => { - const msg = this.uai.getErrorMessgae(err) - this.errorMessage = msg; - this.result = null; + 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'); + } - this.cdr.markForCheck(); - } - }) + 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(""); + this.updateAndValidate(''); return; } - const reader = new FileReader() + const reader = new FileReader(); reader.onload = (data) => { (event.target as any).value = ''; - let content = (data.target as any).result - this.updateAndValidate(content) - } + let content = (data.target as any).result; + this.updateAndValidate(content); + }; - reader.readAsText(file) + reader.readAsText(file); } } diff --git a/modules/portmaster/src/app/shared/edit-profile-dialog/edit-profile-dialog.html b/modules/portmaster/src/app/shared/edit-profile-dialog/edit-profile-dialog.html index 5669de93..f49cff52 100644 --- a/modules/portmaster/src/app/shared/edit-profile-dialog/edit-profile-dialog.html +++ b/modules/portmaster/src/app/shared/edit-profile-dialog/edit-profile-dialog.html @@ -1,118 +1,211 @@ -

      - +

      + {{ isEditMode ? 'Edit Profile' : 'Create a new Profile' }}

      -
      - Configure basic profile information like the profile name, it's description and optionally - the profile icon. + Configure basic profile information like the profile name, it's + description and optionally the profile icon.
      - +
      - +
      - +
      -
      +
      -
      - The icon must be smaller than 10kB and it's dimensions must - not - exceed - 512x512 px. Only JPG and PNG files are supported. + The icon must be smaller than 10kB and it's dimensions must not + exceed 512x512 px. Only JPG and PNG files are supported.
      - - {{ imageError }} + + {{ imageError }}
      - +
      -
      - This profile will be applied to processes that match one of the following - fingerprints: + This profile will be applied to processes that match one of the + following fingerprints: -
      No fingerprints configured. Please press "Add New" to get started. +
      + No fingerprints configured. Please press "Add New" to get started.
      -
      +
      - - - + 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" + > + + - - + + - - + + Tag - Command Line + Command Line - Environment + Environment - Path + Path - + - + - {{ tag.Name }} + {{ tag.Name }} - - Equals - Prefix - Regex + + Equals + Prefix + Regex - + -
      @@ -120,59 +213,110 @@

      -
      -
      - Select a Profile to copy settings from: +
      + Select a Profile to copy settings from:
      - + - + {{ p.Name }} - +
      -
      -
      - - +
      +
      + + {{ p.Name }}
      -
      - Settings will be copied from all specified profiles in order with settings from higher profiles taking - precedence.
      + Settings will be copied from all specified profiles in order with + settings from higher profiles taking precedence.
      Existing settings may be overwritten.
      - -
      - +
      +
      - +
      diff --git a/modules/portmaster/src/app/shared/edit-profile-dialog/edit-profile-dialog.ts b/modules/portmaster/src/app/shared/edit-profile-dialog/edit-profile-dialog.ts index e2c26bf3..20ca98f1 100644 --- a/modules/portmaster/src/app/shared/edit-profile-dialog/edit-profile-dialog.ts +++ b/modules/portmaster/src/app/shared/edit-profile-dialog/edit-profile-dialog.ts @@ -1,14 +1,32 @@ -import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop"; -import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit, TrackByFunction } from "@angular/core"; -import { AppProfile, AppProfileService, FingerpringOperation, Fingerprint, FingerprintType, PortapiService, Record, TagDescription, mergeDeep } from '@safing/portmaster-api'; +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, of, switchMap, takeUntil } from 'rxjs'; +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'] + styleUrls: ['./edit-profile-dialog.scss'], }) // eslint-disable-next-line @angular-eslint/component-class-suffix export class EditProfileDialog implements OnInit, OnDestroy { @@ -24,8 +42,10 @@ export class EditProfileDialog implements OnInit, OnDestroy { }; isEditMode = false; - iconBase64: string = ''; + iconData: string | ArrayBuffer = ''; + iconType: string = ''; iconChanged = false; + iconObjectURL = ''; imageError: string | null = null; allProfiles: AppProfile[] = []; @@ -38,39 +58,52 @@ export class EditProfileDialog implements OnInit, OnDestroy { fingerPrintOperations = FingerpringOperation; processTags: TagDescription[] = []; - trackFingerPrint: TrackByFunction = (_: number, fp: Fingerprint) => `${fp.Type}-${fp.Key}-${fp.Operation}-${fp.Value}`; + trackFingerPrint: TrackByFunction = ( + _: number, + fp: Fingerprint + ) => `${fp.Type}-${fp.Key}-${fp.Operation}-${fp.Value}`; constructor( - @Inject(SFNG_DIALOG_REF) private dialgoRef: SfngDialogRef, + @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.tagDescriptions().subscribe((result) => { + this.processTags = result; + this.cdr.markForCheck(); + }); - this.profileService.watchProfiles() + this.profileService + .watchProfiles() .pipe(takeUntil(this.destory$)) - .subscribe(profiles => { + .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.profileService + .getAppProfile(this.dialgoRef.data) + .subscribe((profile) => { this.profile = profile; this.loadIcon(); }); - } else if (!!this.dialgoRef.data && typeof this.dialgoRef.data === 'object') { + } else if ( + !!this.dialgoRef.data && + typeof this.dialgoRef.data === 'object' + ) { this.profile = this.dialgoRef.data; this.loadIcon(); } @@ -86,16 +119,25 @@ export class EditProfileDialog implements OnInit, OnDestroy { // get the current icon of the profile switch (firstIcon.Type) { case 'database': - this.portapi.get(firstIcon.Value) - .subscribe(data => { - this.iconBase64 = data.iconData; + this.portapi + .get(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}`) + console.error(`Unsupported icon type ${firstIcon.Type}`); } + this.cdr.markForCheck(); } @@ -109,100 +151,114 @@ export class EditProfileDialog implements OnInit, OnDestroy { Key: '', Operation: FingerpringOperation.Equal, Value: '', - Type: FingerprintType.Path - }) + Type: FingerprintType.Path, + }); } removeFingerprint(idx: number) { - this.profile.Fingerprints?.splice(idx, 1) - this.profile.Fingerprints = [ - ...this.profile.Fingerprints!, - ] + this.profile.Fingerprints?.splice(idx, 1); + this.profile.Fingerprints = [...this.profile.Fingerprints!]; } removeCopyFrom(idx: number) { - this.copySettingsFrom.splice(idx, 1) - this.copySettingsFrom = [ - ...this.copySettingsFrom - ] + this.copySettingsFrom.splice(idx, 1); + this.copySettingsFrom = [...this.copySettingsFrom]; } addCopyFrom() { - this.copySettingsFrom = [ - ...this.copySettingsFrom, - this.selectedCopyFrom!, - ] + this.copySettingsFrom = [...this.copySettingsFrom, this.selectedCopyFrom!]; this.selectedCopyFrom = null; } drop(event: CdkDragDrop) { // create a copy of the array this.copySettingsFrom = [...this.copySettingsFrom]; - moveItemInArray(this.copySettingsFrom, event.previousIndex, event.currentIndex); + 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: 'Abort' - } - ] - }).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) - } - }) - }) + 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: 'Abort', + }, + ], + }) + .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.iconBase64 = ''; + this.iconData = ''; + this.iconType = ''; + this.iconObjectURL = ''; } save() { if (!this.profile.ID) { - this.profile.ID = this.uuidv4() + this.profile.ID = this.uuidv4(); } if (!this.profile.Source) { - this.profile.Source = 'local' + this.profile.Source = 'local'; } let updateIcon: Observable = of(undefined); if (this.iconChanged) { // delete any previously set icon - this.profile.Icons?.forEach(icon => { + this.profile.Icons?.forEach((icon) => { if (icon.Type === 'database') { - this.portapi.delete(icon.Value).subscribe() + this.portapi.delete(icon.Value).subscribe(); } - }) - if (this.iconBase64 !== '') { + // FIXME(ppacher): we cannot yet delete API based icons ... + }); + + if (this.iconData !== '') { // save the new icon in the cache database - this.profile.Icons = [{ - Value: `cache:icons/${this.uuidv4()}`, - Type: 'database' - }] - updateIcon = this.portapi.update(this.profile.Icons[0]!.Value, { iconData: this.iconBase64 }); + // 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, + }, + ]; + }) + ); // FIXME(ppacher): reset presentationpath } else { @@ -214,63 +270,79 @@ export class EditProfileDialog implements OnInit, OnDestroy { if (this.profile.Fingerprints!.length > 1) { this.profile.PresentationPath = ''; } - const oldConfig = this.profile.Config || {} - this.profile.Config = {} + const oldConfig = this.profile.Config || {}; + this.profile.Config = {}; - mergeDeep(this.profile.Config, ...[...this.copySettingsFrom.map(p => (p.Config || {})), oldConfig]) + mergeDeep( + this.profile.Config, + ...[...this.copySettingsFrom.map((p) => p.Config || {}), oldConfig] + ); updateIcon .pipe( switchMap(() => { - return this.profileService.saveProfile(this.profile as AppProfile) + return this.profileService.saveProfile(this.profile as AppProfile); }) ) .subscribe({ next: () => { - this.actionIndicator.success(this.profile.Name!, 'Profile saved successfully') + this.actionIndicator.success( + this.profile.Name!, + 'Profile saved successfully' + ); this.dialgoRef.close('saved'); }, - error: err => { - this.actionIndicator.error('Failed to save profile', err) - } - }) + error: (err) => { + this.actionIndicator.error('Failed to save profile', err); + }, + }); } abort() { - this.dialgoRef.close('abort') + this.dialgoRef.close('abort'); } fileChangeEvent(fileInput: any) { this.imageError = null; - this.iconBase64 = ''; + 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']; + 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 (fileInput.target.files[0].size > max_size) { - this.imageError = - 'Maximum size allowed is ' + max_size / 1000 + 'KB'; + if (file.size > max_size) { + this.imageError = 'Maximum size allowed is ' + max_size / 1000 + 'KB'; } - if (!allowed_types.includes(fileInput.target.files[0].type)) { - this.imageError = 'Only JPG and PNG is allowed'; + 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: any) => { + reader.onload = (e: ProgressEvent) => { + const content: ArrayBuffer = e.target!.result! as ArrayBuffer; + const blob = new Blob([content]); + const image = new Image(); - image.src = e.target.result; + 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']; - console.log(img_height, img_width); - - if (img_height > max_height && img_width > max_width) { this.imageError = 'Maximum dimentions allowed ' + @@ -278,10 +350,8 @@ export class EditProfileDialog implements OnInit, OnDestroy { '*' + max_width + 'px'; - } else { - const imgBase64Path = e.target.result; - this.iconBase64 = imgBase64Path; + this.iconData = content; } this.cdr.markForCheck(); @@ -290,18 +360,18 @@ export class EditProfileDialog implements OnInit, OnDestroy { this.cdr.markForCheck(); }; - reader.readAsDataURL(fileInput.target.files[0]); + reader.readAsArrayBuffer(fileInput.target.files[0]); } } private uuidv4(): string { if (typeof crypto.randomUUID === 'function') { - return crypto.randomUUID() + 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()") + console.warn('Using browser with lacking support for crypto.randomUUID()'); return Date.now().toString(36) + Math.random().toString(36).substring(2); }