Merge branch 'v2.0' into task/refactor-spn

This commit is contained in:
Natanael Rodriguez Ramos
2025-05-18 20:59:38 +01:00
47 changed files with 947 additions and 8269 deletions

View File

@@ -1,4 +1,4 @@
name: Release
name: Release v2.X
on:
push:
@@ -36,6 +36,8 @@ jobs:
if-no-files-found: error
installer-linux:
#JOB DISABLED FOR NOW
if: false
name: Installer linux
runs-on: ubuntu-latest
needs: release-prep
@@ -63,6 +65,8 @@ jobs:
if-no-files-found: error
installer-windows:
#JOB DISABLED FOR NOW
if: false
name: Installer windows
runs-on: windows-latest
needs: release-prep

7
.gitignore vendored
View File

@@ -57,3 +57,10 @@ windows_core_dll/.vs/windows_core_dll
#windows_core_dll
windows_core_dll/x64/
windows_core_dll/portmaster-core/x64/
#Tauri-generated files
desktop/tauri/src-tauri/gen/
#Binaries used for installer gereneration for Windows
desktop/tauri/src-tauri/binary/
desktop/tauri/src-tauri/intel/

View File

@@ -308,7 +308,7 @@ angular-base:
COPY desktop/angular/ .
# Remove symlink and copy assets directly.
RUN rm ./assets
COPY assets/data ./assets
# COPY assets/data ./assets # Do not include the assets folder into portmaster.zip, we use the assets.zip instead
IF [ "${configuration}" = "production" ]
RUN --no-cache npm run build-libs
@@ -603,6 +603,10 @@ installer-linux:
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/bundle/deb/*.deb" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/"
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/bundle/rpm/*.rpm" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/"
all-artifacts:
BUILD +release-prep
BUILD +installer-linux
kext-build:
FROM ${rust_builder_image}

View File

@@ -94,6 +94,7 @@ func (s *WindowsSystemService) Execute(args []string, changeRequests <-chan svc.
syscall.SIGTERM,
)
isShuttingDown := false
// Wait for shutdown signal.
waitSignal:
for {
@@ -119,12 +120,16 @@ waitSignal:
}
case <-s.instance.ShuttingDown():
isShuttingDown = true
break waitSignal
}
}
// Trigger shutdown.
s.instance.Shutdown()
// Trigger shutdown,
// but only if we are not already shutting down.
if !isShuttingDown {
s.instance.Shutdown()
}
// Notify the service host that service is in shutting down state.
changes <- svc.Status{State: svc.StopPending}

View File

@@ -1,12 +1,12 @@
{
"name": "portmaster",
"version": "2.0.1",
"version": "2.0.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "portmaster",
"version": "2.0.1",
"version": "2.0.14",
"dependencies": {
"@angular/animations": "^16.0.1",
"@angular/cdk": "^16.0.1",
@@ -28,11 +28,10 @@
"@tauri-apps/plugin-cli": ">=2.0.0",
"@tauri-apps/plugin-clipboard-manager": ">=2.0.0",
"@tauri-apps/plugin-dialog": ">=2.0.0",
"@tauri-apps/plugin-http": ">=2.2.0",
"@tauri-apps/plugin-notification": ">=2.0.0",
"@tauri-apps/plugin-os": ">=2.0.0",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-websocket": ">=2.2.0",
"@tauri-apps/plugin-websocket": "^2.3.0",
"autoprefixer": "^10.4.14",
"d3": "^7.8.4",
"data-urls": "^5.0.0",
@@ -1190,9 +1189,9 @@
}
},
"node_modules/@ant-design/fast-color/node_modules/@babel/runtime": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
@@ -1662,39 +1661,39 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
"integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.26.9",
"@babel/types": "^7.26.9"
"@babel/template": "^7.27.0",
"@babel/types": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers/node_modules/@babel/template": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@babel/parser": "^7.26.9",
"@babel/types": "^7.26.9"
"@babel/parser": "^7.27.0",
"@babel/types": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
"integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.9"
"@babel/types": "^7.27.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -3125,9 +3124,9 @@
}
},
"node_modules/@babel/types": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
"integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
@@ -4846,15 +4845,6 @@
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.4.2.tgz",
"integrity": "sha512-deoafidYelei/fmd4AQoHa2aCA9N2DvnnQrF/91QNjE0xCCTuVpPhIQdVRgdHDhFehEal9uI14OTvERBpcfHrg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.2.1.tgz",
@@ -4874,9 +4864,9 @@
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.2.0.tgz",
"integrity": "sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.2.1.tgz",
"integrity": "sha512-G1GFYyWe/KlCsymuLiNImUgC8zGY0tI0Y3p8JgBCWduR5IEXlIJS+JuG1qtveitwYXlfJrsExt3enhv5l2/yhA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
@@ -7075,9 +7065,9 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11924,9 +11914,9 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17424,9 +17414,9 @@
}
},
"node_modules/prismjs": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"

View File

@@ -1,6 +1,6 @@
{
"name": "portmaster",
"version": "2.0.1",
"version": "2.0.14",
"scripts": {
"ng": "ng",
"start": "npm install && npm run build-libs:dev && ng serve --proxy-config ./proxy.json",
@@ -45,8 +45,7 @@
"@tauri-apps/plugin-notification": ">=2.0.0",
"@tauri-apps/plugin-os": ">=2.0.0",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-http": ">=2.2.0",
"@tauri-apps/plugin-websocket": ">=2.2.0",
"@tauri-apps/plugin-websocket": "^2.3.0",
"autoprefixer": "^10.4.14",
"d3": "^7.8.4",
"data-urls": "^5.0.0",
@@ -105,4 +104,4 @@
"webpack-ext-reloader": "^1.1.9",
"zip-a-folder": "^1.1.5"
}
}
}

View File

@@ -15,9 +15,7 @@ import {
} from './portapi.service';
import { Process } from './portapi.types';
@Injectable({
providedIn: 'root',
})
@Injectable()
export class AppProfileService {
private watchedProfiles = new Map<string, Observable<AppProfile>>();

View File

@@ -43,7 +43,7 @@ export interface AuthKeyResponse {
export class MetaAPI {
constructor(
private http: HttpClient,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) @Optional() private httpEndpoint: string = 'http://localhost:817/api',
@Inject(PORTMASTER_HTTP_API_ENDPOINT) @Optional() private httpEndpoint: string = 'http://127.0.0.1:817/api',
) { }
listEndpoints(): Observable<MetaEndpoint[]> {

View File

@@ -7,12 +7,37 @@ import { Netquery } from "./netquery.service";
import { PortapiService, PORTMASTER_HTTP_API_ENDPOINT, PORTMASTER_WS_API_ENDPOINT } from "./portapi.service";
import { SPNService } from "./spn.service";
import { WebsocketService } from "./websocket.service";
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { TauriHttpInterceptor } from "./platform-specific/tauri/tauri-http-interceptor";
import { IsTauriEnvironment } from "./platform-specific/utils";
export interface ModuleConfig {
httpAPI?: string;
websocketAPI?: string;
}
// Factory function to provide the appropriate HTTP client configuration
//
// This function determines the appropriate HTTP client configuration based on the runtime environment.
// If the application is running in a Tauri environment, it uses the TauriHttpInterceptor to ensure
// that all HTTP requests are made from the application binary instead of the WebView instance.
// This allows for more direct and controlled communication with the Portmaster API.
// In other environments (e.g., browser, Electron), the standard HttpClient is used without any interceptors.
export function HttpClientProviderFactory() {
if (IsTauriEnvironment())
{
console.log("[portmaster-api] Running under Tauri - using TauriHttpClient");
return provideHttpClient(
withInterceptors([TauriHttpInterceptor])
);
}
else
{
console.log("[portmaster-api] Running in browser - using default HttpClient");
return provideHttpClient();
}
}
@NgModule({})
export class PortmasterAPIModule {
@@ -23,15 +48,16 @@ export class PortmasterAPIModule {
*/
static forRoot(cfg: ModuleConfig = {}): ModuleWithProviders<PortmasterAPIModule> {
if (cfg.httpAPI === undefined) {
cfg.httpAPI = `http://${window.location.host}/api`;
cfg.httpAPI = `http://127.0.0.1:817/api`;
}
if (cfg.websocketAPI === undefined) {
cfg.websocketAPI = `ws://${window.location.host}/api/database/v1`;
cfg.websocketAPI = `ws://127.0.0.1:817/api/database/v1`;
}
return {
ngModule: PortmasterAPIModule,
providers: [
HttpClientProviderFactory(),
PortapiService,
WebsocketService,
MetaAPI,

View File

@@ -0,0 +1,172 @@
import { HttpEvent, HttpHandlerFn, HttpRequest, HttpResponse, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { from, Observable, switchMap, map, catchError, throwError } from 'rxjs';
import { invoke } from '@tauri-apps/api/core'
/**
* TauriHttpInterceptor intercepts HTTP requests and routes them through Tauri's `@tauri-apps/plugin-http` API.
*
* This allows HTTP requests to be executed from the Tauri application binary instead of the WebView,
* enabling more secure and direct communication with external APIs.
*
* The interceptor handles various response types (e.g., JSON, text, blob, arraybuffer) and ensures
* that headers and response data are properly mapped to Angular's HttpResponse format.
*
* References:
* - https://angular.dev/guide/http/interceptors
*/
export function TauriHttpInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> {
const fetchOptions: RequestInit = {
method: req.method,
headers: req.headers.keys().reduce((acc: Record<string, string>, key) => {
acc[key] = req.headers.get(key) || '';
return acc;
}, {}),
body: getRequestBody(req),
};
//console.log('[TauriHttpInterceptor] Fetching:', req.url, "Headers:", fetchOptions.headers);
return from(send_tauri_http_request(req.url, fetchOptions)).pipe(
switchMap(response => {
// Copy all response headers
const headerMap: Record<string, string> = {};
response.headers.forEach((value: string, key: string) => {
headerMap[key] = value;
});
const headers = new HttpHeaders(headerMap);
// Check if response status is ok (2xx)
if (!response.ok) {
// Get the error content
return from(response.text()).pipe(
map(errorText => {
throw new HttpErrorResponse({
error: errorText,
headers: headers,
status: response.status,
statusText: response.statusText,
url: req.url
});
})
);
}
// Get the response type from the request
const responseType = req.responseType || 'json';
// Helper function to create HttpResponse from body
const createResponse = (body: any): HttpEvent<unknown> => {
return new HttpResponse({
body,
status: response.status,
headers: headers,
url: req.url
}) as HttpEvent<unknown>;
};
switch (responseType) {
case 'text':
return from(response.text()).pipe(map(createResponse));
case 'arraybuffer':
return from(response.arrayBuffer()).pipe(map(createResponse));
case 'blob':
return from(response.blob()).pipe(
map(blob => {
const contentType = response.headers.get('content-type') || '';
// Create a new blob with the proper MIME type
if (contentType && (!blob.type || blob.type === 'application/octet-stream')) {
const typedBlob = new Blob([blob], { type: contentType });
return createResponse(typedBlob);
}
return createResponse(blob);
})
);
case 'json':
default:
return from(response.text()).pipe(
map(body => {
let parsedBody: any;
try {
// Only attempt to parse as JSON if we have content
// and either explicitly requested JSON or content-type is JSON
if (body && (responseType === 'json' ||
(response.headers.get('content-type') || '').includes('application/json'))) {
parsedBody = JSON.parse(body);
} else {
parsedBody = body;
}
} catch (e) {
console.warn('[TauriHttpInterceptor] Failed to parse JSON response:', e);
parsedBody = body;
}
return createResponse(parsedBody);
})
);
}
}),
catchError(error => {
console.error('[TauriHttpInterceptor] Request failed:', error);
// If it's already an HttpErrorResponse, just return it
if (error instanceof HttpErrorResponse) {
return throwError(() => error);
}
// Otherwise create a new HttpErrorResponse with available information
return throwError(() => new HttpErrorResponse({
error: error.message || 'Unknown error occurred',
status: error.status || 0,
statusText: error.statusText || 'Unknown Error',
url: req.url,
headers: error.headers ? new HttpHeaders(error.headers) : new HttpHeaders()
}));
})
);
}
function getRequestBody(req: HttpRequest<unknown>): any {
if (!req.body) {
return undefined;
}
// Handle different body types properly
if (req.body instanceof FormData ||
req.body instanceof Blob ||
req.body instanceof ArrayBuffer ||
req.body instanceof URLSearchParams) {
return req.body;
}
// Default to JSON stringify for object data
return JSON.stringify(req.body);
}
export async function send_tauri_http_request(
url: string,
init: RequestInit = {}
): Promise<Response> {
// Extract method, headers, and body buffer
const method = init.method || 'GET';
const headers = [...(init.headers instanceof Headers
? (() => {
const headerArray: [string, string][] = [];
init.headers.forEach((value, key) => headerArray.push([key, value]));
return headerArray;
})()
: Object.entries(init.headers || {}))];
const body = init.body
? new Uint8Array(await new Response(init.body as any).arrayBuffer())
: undefined;
const res = await invoke<{
status: number;
status_text: string;
headers: [string, string][];
body: number[];
}>('send_tauri_http_request', { url, opts: { method, headers, body } });
return new Response(new Uint8Array(res.body), {
status: res.status,
statusText: res.status_text,
headers: res.headers,
});
}

View File

@@ -0,0 +1,221 @@
import WebSocket, { ConnectionConfig, Message } from '@tauri-apps/plugin-websocket';
import { Subject, Observable } from 'rxjs';
import { WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket';
import { NgZone } from '@angular/core';
const LOG_PREFIX = '[tauri_ws]';
/**
* Creates a WebSocket connection using the Tauri WebSocket API and wraps it in an RxJS WebSocketSubject-compatible interface.
*
* @template T - The type of messages sent and received through the WebSocket.
* @param {WebSocketSubjectConfig<T>} opts - Configuration options for the WebSocket connection.
* @param {NgZone} ngZone - Angular's NgZone to ensure change detection runs properly.
* @returns {WebSocketSubject<T>} - An RxJS WebSocketSubject-compatible object for interacting with the WebSocket.
* @throws {Error} If the `serializer` or `deserializer` functions are not provided.
*
* @example
* const wsSubject = createTauriWsConnection({
* url: 'ws://example.com',
* serializer: JSON.stringify,
* deserializer: JSON.parse,
* }, ngZone);
*/
export function createTauriWsConnection<T>(opts: WebSocketSubjectConfig<T>, ngZone: NgZone): WebSocketSubject<T> {
if (!opts.serializer) throw new Error(`${LOG_PREFIX} Messages Serializer not provided!`);
if (!opts.deserializer) throw new Error(`${LOG_PREFIX} Messages Deserializer not provided!`);
const serializer = opts.serializer;
const deserializer = opts.deserializer;
let wsConnection: WebSocket | null = null;
const messageSubject = new Subject<T>();
const observable$ = messageSubject.asObservable();
// A queue for messages that need to be sent before the connection is established
const pendingMessages: T[] = [];
const notifySubjectError = (descriptionToLog: string, error: Error | any | null = null) => {
if (!descriptionToLog) return;
if (!error) error = new Error(descriptionToLog);
console.error(`${LOG_PREFIX} ${descriptionToLog}:`, error);
// Run inside NgZone to ensure Angular detects this change
ngZone.run(() => {
// This completes the observable and prevents further messages from being processed.
messageSubject.error(error);
});
}
//////////////////////////////////////////////////////////////
// RxJS WebSocketSubject-compatible implementation
//////////////////////////////////////////////////////////////
const webSocketSubject = {
// Standard Observer interface methods
next: (message: T) => {
if (!wsConnection) {
if (pendingMessages.length >= 1000) {
console.error(`${LOG_PREFIX} Too many pending messages, skipping message`);
return;
}
pendingMessages.push(message);
console.log(`${LOG_PREFIX} Connection not established yet, message queued`);
return;
}
let serializedMessage: any;
try {
serializedMessage = serializer(message);
// 'string' type is enough here, since default serializer for portmaster message returns string
if (typeof serializedMessage !== 'string')
throw new Error('Serialized message is not a string');
} catch (error) {
console.error(`${LOG_PREFIX} Error serializing message:`, error);
return;
}
// Run outside NgZone for better performance during send operations
ngZone.runOutsideAngular(() => {
try {
wsConnection!.send(serializedMessage).catch((err: Error) => {
notifySubjectError('Error sending text message', err);
});
} catch (error) {
notifySubjectError('Error sending message', error);
}
});
},
complete: () => {
if (wsConnection) {
console.log(`${LOG_PREFIX} Closing connection`);
// Run inside NgZone to ensure Angular detects this change
ngZone.run(() => {
if (opts.closingObserver?.next) {
opts.closingObserver.next(undefined);
}
wsConnection!.disconnect().catch((err: Error) => console.error(`${LOG_PREFIX} Error closing connection:`, err));
wsConnection = null;
messageSubject.complete();
});
} else {
messageSubject.complete();
}
},
// RxJS Observable methods required for compatibility
pipe: function(): Observable<any> {
// @ts-ignore - Ignore the parameter type mismatch
return observable$.pipe(...arguments);
},
};
//////////////////////////////////////////////////////////////
// Connect to WebSocket
//////////////////////////////////////////////////////////////
console.log(`${LOG_PREFIX} Connecting to WebSocket:`, opts.url);
// Connect outside of Angular zone for better performance
ngZone.runOutsideAngular(() => {
WebSocket.connect(opts.url)
.then((ws) => {
wsConnection = ws;
console.log(`${LOG_PREFIX} Connection established`);
// Run inside NgZone to ensure Angular detects this connection event
ngZone.run(() => {
// Create a mock Event for the openObserver
if (opts.openObserver) {
const mockEvent = new Event('open') as Event;
opts.openObserver.next(mockEvent);
}
// Send any pending messages
while (pendingMessages.length > 0) {
const message = pendingMessages.shift();
if (message) webSocketSubject.next(message);
}
});
try {
// Add a single listener for ALL message types according to Tauri WebSocket API
ws.addListener((message: Message) => {
// Process message inside ngZone to trigger change detection
ngZone.run(() => {
try {
// Handle different message types from Tauri
switch (message.type) {
case 'Text':
const textData = message.data as string;
try {
const deserializedMessage = deserializer({ data: textData } as any);
messageSubject.next(deserializedMessage);
} catch (err) {
notifySubjectError('Error deserializing text message', err);
}
break;
case 'Binary':
const binaryData = message.data as number[];
try {
const uint8Array = new Uint8Array(binaryData);
const buffer = uint8Array.buffer;
const deserializedMessage = deserializer({ data: buffer } as any);
messageSubject.next(deserializedMessage);
} catch (err) {
notifySubjectError('Error deserializing binary message', err);
}
break;
case 'Close':
// Handle close message
const closeData = message.data as { code: number; reason: string } | null;
console.log(`${LOG_PREFIX} Connection closed by server`, closeData);
if (opts.closeObserver) {
const closeEvent = {
code: closeData?.code || 1000,
reason: closeData?.reason || '',
wasClean: true,
type: 'close',
target: null
} as unknown as CloseEvent;
opts.closeObserver.next(closeEvent);
}
messageSubject.complete();
wsConnection = null;
break;
case 'Ping':
console.log(`${LOG_PREFIX} Received ping`);
break;
case 'Pong':
console.log(`${LOG_PREFIX} Received pong`);
break;
}
} catch (error) {
console.error(`${LOG_PREFIX} Error processing message:`, error);
// Don't error the subject on message processing errors to keep connection alive
}
});
});
console.log(`${LOG_PREFIX} Listener added successfully`);
} catch (error) {
notifySubjectError('Error adding message listener', error);
}
})
.catch((error: Error) => {
notifySubjectError('Connection failed', error);
});
});
// Cast to WebSocketSubject<T>
return webSocketSubject as unknown as WebSocketSubject<T>;
}

View File

@@ -0,0 +1,4 @@
// Simple function to detect if the app is running in a Tauri environment
export function IsTauriEnvironment(): boolean {
return '__TAURI__' in window;
}

View File

@@ -6,7 +6,7 @@ import { FeatureID } from "./features";
import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from './portapi.service';
import { Feature, Pin, SPNStatus, UserProfile } from "./spn.types";
@Injectable({ providedIn: 'root' })
@Injectable()
export class SPNService {
/** Emits the SPN status whenever it changes */

View File

@@ -1,9 +1,11 @@
import { Injectable } from '@angular/core';
import { Injectable, NgZone } from '@angular/core';
import { webSocket, WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket';
import { createTauriWsConnection } from './platform-specific/tauri/tauri-websocket-subject';
import { IsTauriEnvironment } from './platform-specific/utils';
@Injectable()
export class WebsocketService {
constructor() { }
constructor(private ngZone: NgZone) { }
/**
* createConnection creates a new websocket connection using opts.
@@ -11,7 +13,12 @@ export class WebsocketService {
* @param opts Options for the websocket connection.
*/
createConnection<T>(opts: WebSocketSubjectConfig<T>): WebSocketSubject<T> {
return webSocket(opts);
}
}
if (IsTauriEnvironment()) {
console.log('[portmaster-api] Running under Tauri - Using Tauri WebSocket');
return createTauriWsConnection<T>(opts, this.ngZone);
}
console.log('[portmaster-api] Running in browser - Using RxJS WebSocket');
return webSocket<T>(opts);
}
}

View File

@@ -4,7 +4,6 @@ import { PortalModule } from '@angular/cdk/portal';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { CdkTableModule } from '@angular/cdk/table';
import { CommonModule, registerLocaleData } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, LOCALE_ID, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@@ -174,7 +173,6 @@ const localeConfig = {
PortalModule,
CdkTableModule,
DragDropModule,
HttpClientModule,
MarkdownModule.forRoot(),
ScrollingModule,
SfngAccordionModule,

View File

@@ -24,7 +24,7 @@ function asyncInvoke<T>(method: string, args: object): Promise<T> {
return new Promise<T>((resolve, reject) => {
const eventId = uuid();
once<T & { error: string }>(eventId, (event) => {
const listenerPromise = once<T & { error: string }>(eventId, (event) => {
if (typeof event.payload === 'object' && 'error' in event.payload) {
reject(event.payload);
return
@@ -33,14 +33,17 @@ function asyncInvoke<T>(method: string, args: object): Promise<T> {
resolve(event.payload);
})
invoke<string>(method, {
...args,
responseId: eventId,
}).catch((err: any) => {
console.error("tauri:invoke rejected: ", method, args, err);
reject(err)
});
})
// Only make the invoke call after the listener is registered
listenerPromise.then(() => {
invoke<string>(method, {
...args,
responseId: eventId,
}).catch((err: any) => {
console.error("tauri:invoke rejected: ", method, args, err);
reject(err)
});
})
});
}
export type ServiceManagerStatus = 'Running' | 'Stopped' | 'NotFound' | 'unsupported service manager' | 'unsupported operating system';

View File

@@ -348,11 +348,13 @@ export class DashboardPageComponent implements OnInit, AfterViewInit {
)
.subscribe(response => {
// bandwidth bar chart
const barChartData = response.bwBarChart
.filter(value => (value.sent + value.received) > 0)
.sort((a, b) => (b.sent + b.received) - (a.sent + a.received))
.slice(0, 10);
this.bandwidthBarData = splitQueryResult(barChartData, ['sent', 'received']) as BandwidthBarData[]
if (response?.bwBarChart){
const barChartData = response.bwBarChart
.filter(value => (value.sent + value.received) > 0)
.sort((a, b) => (b.sent + b.received) - (a.sent + a.received))
.slice(0, 10);
this.bandwidthBarData = splitQueryResult(barChartData, ['sent', 'received']) as BandwidthBarData[]
}
// profileCount
this.blockedConnections = 0;

View File

@@ -80,8 +80,8 @@ if (location.pathname !== "/prompt") {
providers: [
provideHttpClient(),
importProvidersFrom(PortmasterAPIModule.forRoot({
websocketAPI: "ws://localhost:817/api/database/v1",
httpAPI: "http://localhost:817/api"
websocketAPI: "ws://127.0.0.1:817/api/database/v1",
httpAPI: "http://127.0.0.1:817/api"
})),
NotificationsService,
{

View File

@@ -1,6 +1,7 @@
use std::str::FromStr;
/// Struct representing an RGB color
#[allow(dead_code)] // Suppress warnings for unused fields in this struct only
pub(crate) struct Rgb(pub(crate) u32, pub(crate) u32, pub(crate) u32);
impl FromStr for Rgb {

View File

@@ -1227,12 +1227,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
[[package]]
name = "data-url"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a"
[[package]]
name = "dataurl"
version = "0.1.2"
@@ -2026,10 +2020,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -2039,11 +2031,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
]
[[package]]
@@ -2381,7 +2371,6 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.2",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -3956,7 +3945,7 @@ dependencies = [
[[package]]
name = "portmaster"
version = "2.0.0"
version = "2.0.14"
dependencies = [
"assert_matches",
"cached",
@@ -3988,7 +3977,6 @@ dependencies = [
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-notification",
"tauri-plugin-os",
@@ -4158,60 +4146,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012"
dependencies = [
"bytes",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.25",
"socket2 0.5.9",
"thiserror 2.0.12",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
dependencies = [
"bytes",
"getrandom 0.3.2",
"rand 0.9.0",
"ring",
"rustc-hash",
"rustls 0.23.25",
"rustls-pki-types",
"slab",
"thiserror 2.0.12",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.5.9",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "quote"
version = "1.0.40"
@@ -4465,10 +4399,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.25",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
@@ -4476,7 +4407,6 @@ dependencies = [
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.2",
"tokio-util",
"tower",
"tower-service",
@@ -4485,7 +4415,6 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
"windows-registry",
]
@@ -4601,12 +4530,6 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -4676,7 +4599,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.1",
"subtle",
@@ -4697,9 +4619,6 @@ name = "rustls-pki-types"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"
dependencies = [
"web-time",
]
[[package]]
name = "rustls-webpki"
@@ -5561,28 +5480,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "tauri-plugin-http"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "696ef548befeee6c6c17b80ef73e7c41205b6c2204e87ef78ccc231212389a5c"
dependencies = [
"data-url",
"http",
"regex",
"reqwest",
"schemars",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.12",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-log"
version = "2.3.1"
@@ -6682,16 +6579,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.1"

View File

@@ -1,6 +1,6 @@
[package]
name = "portmaster"
version = "2.0.0"
version = "2.0.14"
description = "Portmaster UI"
authors = ["Safing"]
license = ""
@@ -25,8 +25,7 @@ tauri-plugin-single-instance = "2.2.1"
tauri-plugin-notification = "2.2.1"
tauri-plugin-log = "2.2.1"
tauri-plugin-window-state = "2.2.1"
tauri-plugin-http = "2.2.1"
tauri-plugin-websocket = "2.2.1"
tauri-plugin-websocket = "2"
clap_lex = "0.7.2"
@@ -49,7 +48,7 @@ http = "1.0.0"
url = "2.5.0"
thiserror = "1.0"
log = "0.4.21"
reqwest = { version = "0.12" }
reqwest = { version = "0.12", features = ["cookies", "json"] }
rfd = { version = "*", default-features = false, features = [ "tokio", "gtk3", "common-controls-v6" ] }
open = "5.1.3"

View File

@@ -8,7 +8,7 @@
],
"remote": {
"urls": [
"http://localhost:817"
"http://127.0.0.1:817"
]
},
"permissions": [
@@ -33,6 +33,7 @@
"window-state:allow-save-window-state",
"window-state:allow-restore-state",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
"clipboard-manager:allow-write-text",
"websocket:default"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Capability for the main window","remote":{"urls":["http://localhost:817"]},"local":true,"windows":["main","splash"],"permissions":["core:path:default","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit","core:event:allow-emit-to","core:window:allow-hide","core:window:allow-show","core:window:allow-is-visible","core:window:allow-set-focus","core:window:allow-close","core:window:allow-get-all-windows","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default","shell:allow-open","notification:default","window-state:allow-save-window-state","window-state:allow-restore-state","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
pub mod tauri_http;

View File

@@ -0,0 +1,75 @@
use tauri::State;
use reqwest::{Client, Method};
use serde::{Deserialize, Serialize};
/// Creates and configures a shared HTTP client for application-wide use.
///
/// Returns a reqwest Client configured with:
/// - Connection pooling
/// - Persistent cookie store
///
/// Client can be accessed from UI through the exposed Tauri command `send_tauri_http_request(...)`
/// Such requests execute directly from the Tauri app binary, not from the WebView process
pub fn create_http_client() -> Client {
Client::builder()
// Maximum idle connections per host
.pool_max_idle_per_host(10)
// Enable cookie support
.cookie_store(true)
.user_agent("Portmaster UI")
.build()
.expect("failed to build HTTP client")
}
#[derive(Deserialize)]
pub struct HttpRequestOptions {
method: String,
headers: Vec<(String, String)>,
body: Option<Vec<u8>>,
}
#[derive(Serialize)]
pub struct HttpResponse {
status: u16,
status_text: String,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
#[tauri::command]
pub async fn send_tauri_http_request(
client: State<'_, Client>,
url: String,
opts: HttpRequestOptions
) -> Result<HttpResponse, String> {
//println!("URL: {}", url);
// Build the request
let mut req = client
.request(Method::from_bytes(opts.method.as_bytes()).map_err(|e| e.to_string())?, &url);
// Apply headers
for (k, v) in opts.headers {
req = req.header(&k, &v);
}
// Attach body if present
if let Some(body) = opts.body {
req = req.body(body);
}
// Send and await the response
let resp = req.send().await.map_err(|e| e.to_string())?;
// Read status, headers, and body
let status = resp.status().as_u16();
let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
let headers = resp
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body = resp.bytes().await.map_err(|e| e.to_string())?.to_vec();
Ok(HttpResponse { status, status_text, headers, body })
}

View File

@@ -1,7 +1,7 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{env, path::Path, time::Duration};
use std::{env, time::Duration};
use tauri::{AppHandle, Emitter, Listener, Manager, RunEvent, WindowEvent};
@@ -18,12 +18,14 @@ mod config;
mod portmaster;
mod traymenu;
mod window;
mod commands;
use log::{debug, error, info};
use portmaster::PortmasterExt;
use tauri_plugin_log::RotationStrategy;
use traymenu::setup_tray_menu;
use window::{close_splash_window, create_main_window, hide_splash_window};
use tauri_plugin_window_state::StateFlags;
#[macro_use]
extern crate lazy_static;
@@ -139,13 +141,22 @@ fn main() {
// TODO(vladimir): Permission for logs/app2 folder are not guaranteed. Use the default location for now.
#[cfg(target_os = "windows")]
let log_target = if let Some(data_dir) = cli_args.data {
let log_target = if let Some(_) = cli_args.data {
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None })
} else {
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout)
};
// Create a single HTTP client that:
// - Pools and reuses connections for better performance
// - Is exposed to UI through 'send_tauri_http_request()' command
// - Such requests execute directly from the Tauri app binary, not from the WebView process
let http_client = commands::tauri_http::create_http_client();
let app = tauri::Builder::default()
// make HTTP client accessible in commands ('send_tauri_http_request()')
.manage(http_client)
.plugin(tauri_plugin_websocket::init())
// Shell plugin for open_external support
.plugin(tauri_plugin_shell::init())
// Initialize Logging plugin.
@@ -164,7 +175,12 @@ fn main() {
// OS Version and Architecture support
.plugin(tauri_plugin_os::init())
// Initialize save windows state plugin.
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_window_state::Builder::default()
// Don't save visibility state, so it will not interfere with "--background" command line argument
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
// Don't save splash window state
.with_denylist(&["splash",])
.build())
// Single instance guard
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
// Send info to already dunning instance.
@@ -179,7 +195,8 @@ fn main() {
portmaster::commands::get_state,
portmaster::commands::set_state,
portmaster::commands::should_show,
portmaster::commands::should_handle_prompts
portmaster::commands::should_handle_prompts,
commands::tauri_http::send_tauri_http_request,
])
// Setup the app an any listeners
.setup(move |app| {

View File

@@ -2,7 +2,6 @@ use crate::portapi::client::*;
use crate::portapi::message::*;
use crate::portapi::models::notification::*;
use crate::portapi::types::*;
use log::debug;
use log::error;
use serde_json::json;
use tauri::async_runtime;

View File

@@ -181,15 +181,15 @@ pub fn may_navigate_to_ui(win: &mut WebviewWindow, force: bool) {
// Only for dev build
// Allow connection to http://localhost:4200
let capabilities = include_str!("../capabilities/default.json")
.replace("http://localhost:817", "http://localhost:4200");
.replace("http://127.0.0.1:817", "http://127.0.0.1:4200");
let _ = win.add_capability(capabilities);
debug!("[tauri] navigating to http://localhost:4200");
_ = win.navigate("http://localhost:4200".parse().unwrap());
debug!("[tauri] navigating to http://127.0.0.1:4200");
_ = win.navigate("http://127.0.0.1:4200".parse().unwrap());
}
#[cfg(not(debug_assertions))]
{
_ = win.navigate("http://localhost:817".parse().unwrap());
_ = win.navigate("http://127.0.0.1:817".parse().unwrap());
}
} else {
error!(

View File

@@ -81,6 +81,15 @@ var dataDir
SimpleSC::SetServiceDescription "PortmasterCore" "Portmaster Application Firewall - Core Service"
;
; Auto start the UI
;
DetailPrint "Creating registry entry for autostart"
WriteRegStr HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" "Portmaster" '"$INSTDIR\portmaster.exe" --with-prompts --with-notifications --background'
;
; MIGRATION FROM PMv1 TO PMv2
;
StrCpy $oldInstallationDir "$COMMONPROGRAMDATA\Safing\Portmaster"
StrCpy $dataDir "$COMMONPROGRAMDATA\Portmaster"
@@ -168,6 +177,10 @@ var dataDir
Delete /REBOOTOK "$INSTDIR\assets.zip"
RMDir /r /REBOOTOK "$INSTDIR"
; remove the registry entry for the autostart
DetailPrint "Removing registry entry for autostart"
DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
; delete data files
Delete /REBOOTOK "$COMMONPROGRAMDATA\Portmaster\databases\history.db"
RMDir /r /REBOOTOK "$COMMONPROGRAMDATA\Portmaster\databases\cache"

7
go.mod
View File

@@ -2,9 +2,6 @@ module github.com/safing/portmaster
go 1.22.0
// TODO: Remove when https://github.com/tc-hib/winres/pull/4 is released.
replace github.com/tc-hib/winres => github.com/dhaavi/winres v0.2.2
require (
github.com/VictoriaMetrics/metrics v1.35.1
github.com/Xuanwo/go-locale v1.1.1
@@ -35,7 +32,6 @@ require (
github.com/jackc/puddle/v2 v2.2.1
github.com/lmittmann/tint v1.0.5
github.com/maruel/panicparse/v2 v2.3.1
github.com/mat/besticon v3.12.0+incompatible
github.com/mattn/go-colorable v0.1.13
github.com/mattn/go-isatty v0.0.20
github.com/miekg/dns v1.1.62
@@ -70,6 +66,8 @@ require (
zombiezen.com/go/sqlite v1.3.0
)
require github.com/sergeymakinen/go-bmp v1.0.0 // indirect
require (
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/aead/ecdh v0.2.0 // indirect
@@ -101,6 +99,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/seehuhn/sha256d v1.0.0 // indirect
github.com/sergeymakinen/go-ico v1.0.0-beta.0
github.com/spf13/pflag v1.0.5 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect

10
go.sum
View File

@@ -53,8 +53,6 @@ github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWa
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dhaavi/winres v0.2.2 h1:SUago7FwhgLSMyDdeuV6enBZ+ZQSl0KwcnbWzvlfBls=
github.com/dhaavi/winres v0.2.2/go.mod h1:1NTs+/DtKP1BplIL1+XQSoq4X1PUfLczexS7gf3x9T4=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
@@ -173,8 +171,6 @@ github.com/magiconair/properties v1.7.4-0.20170902060319-8d7837e64d3c/go.mod h1:
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/maruel/panicparse/v2 v2.3.1 h1:NtJavmbMn0DyzmmSStE8yUsmPZrZmudPH7kplxBinOA=
github.com/maruel/panicparse/v2 v2.3.1/go.mod h1:s3UmQB9Fm/n7n/prcD2xBGDkwXD6y2LeZnhbEXvs9Dg=
github.com/mat/besticon v3.12.0+incompatible h1:1KTD6wisfjfnX+fk9Kx/6VEZL+MAW1LhCkL9Q47H9Bg=
github.com/mat/besticon v3.12.0+incompatible/go.mod h1:mA1auQYHt6CW5e7L9HJLmqVQC8SzNk2gVwouO0AbiEU=
github.com/mattn/go-colorable v0.0.10-0.20170816031813-ad5389df28cd/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -254,6 +250,10 @@ github.com/seehuhn/fortuna v1.0.1 h1:lu9+CHsmR0bZnx5Ay646XvCSRJ8PJTi5UYJwDBX68H0
github.com/seehuhn/fortuna v1.0.1/go.mod h1:LX8ubejCnUoT/hX+1aKUtbKls2H6DRkqzkc7TdR3iis=
github.com/seehuhn/sha256d v1.0.0 h1:TXTsAuEWr02QjRm153Fnvvb6fXXDo7Bmy1FizxarGYw=
github.com/seehuhn/sha256d v1.0.0/go.mod h1:PEuxg9faClSveVuFXacQmi+NtDI/PX8bpKjtNzf2+s4=
github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
@@ -286,6 +286,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tannerryan/ring v1.1.2 h1:iXayOjqHQOLzuy9GwSKuG3nhWfzQkldMlQivcgIr7gQ=
github.com/tannerryan/ring v1.1.2/go.mod h1:DkELJEjbZhJBtFKR9Xziwj3HKZnb/knRgljNqp65vH4=
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA=
github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=

View File

@@ -3,6 +3,7 @@ Name=Portmaster
GenericName=Application Firewall
Exec={{exec}} --data=/opt/safing/portmaster --with-prompts --with-notifications
Icon={{icon}}
StartupWMClass=portmaster
Terminal=false
Type=Application
Categories=System

View File

@@ -23,6 +23,7 @@ Environment=LOGLEVEL=info
Environment=PORTMASTER_ARGS=
EnvironmentFile=-/etc/default/portmaster
ProtectSystem=true
ReadWritePaths=/usr/lib/portmaster
RestrictAddressFamilies=AF_UNIX AF_NETLINK AF_INET AF_INET6
RestrictNamespaces=yes
ProtectHome=read-only

View File

@@ -0,0 +1,79 @@
# This script builds the Angular project for the Portmaster application and packages it into a zip file.
# The script assumes that all necessary dependencies are installed and available.
# Output file: dist/portmaster.zip
[CmdletBinding()]
param (
[Parameter(Mandatory=$false)]
[Alias("d")]
[switch]$Development,
[Parameter(Mandatory=$false)]
[Alias("i")]
[switch]$Interactive
)
# Store original directory and find project root
$originalDir = Get-Location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = (Get-Item $scriptDir).Parent.Parent.Parent.FullName
try {
# Create output directory
$outputDir = Join-Path $scriptDir "dist"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# Navigate to Angular project
Set-Location (Join-Path $projectRoot "desktop\angular")
# npm install - always run in non-interactive mode, ask in interactive mode
if (!$Interactive -or (Read-Host "Run 'npm install'? (Y/N, default: Y)") -notmatch '^[Nn]$') {
npm install
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# build libs - always run in non-interactive mode, ask in interactive mode
if (!$Interactive -or (Read-Host "Build shared libraries? (Y/N, default: Y)") -notmatch '^[Nn]$') {
if ($Development) {
Write-Host "Building shared libraries in development mode" -ForegroundColor Yellow
npm run build-libs:dev
} else {
Write-Host "Building shared libraries in production mode" -ForegroundColor Yellow
npm run build-libs
}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# Build Angular project
if ($Development) {
Write-Host "Building Angular project in development mode" -ForegroundColor Yellow
ng build --configuration development --base-href /ui/modules/portmaster/ portmaster
} else {
Write-Host "Building Angular project in production mode" -ForegroundColor Yellow
ng build --configuration production --base-href /ui/modules/portmaster/ portmaster
}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# Create zip archive
Write-Host "Creating zip archive" -ForegroundColor Yellow
Set-Location dist
$destinationZip = Join-Path $outputDir "portmaster.zip"
if ($PSVersionTable.PSVersion.Major -ge 5) {
# Option 1: Use .NET Framework directly (faster than Compress-Archive)
Write-Host "Using System.IO.Compression for faster archiving" -ForegroundColor Yellow
if (Test-Path $destinationZip) { Remove-Item $destinationZip -Force } # Remove existing zip if it exists
Add-Type -AssemblyName System.IO.Compression.FileSystem
$compressionLevel = [System.IO.Compression.CompressionLevel]::Optimal
[System.IO.Compression.ZipFile]::CreateFromDirectory((Get-Location), $destinationZip, $compressionLevel, $false)
}
else {
# Fall back to Compress-Archive
Compress-Archive -Path * -DestinationPath $destinationZip -Force
}
Write-Host "Build completed successfully: $(Join-Path $outputDir "portmaster.zip")" -ForegroundColor Green
}
finally {
# Return to original directory - this will execute even if Ctrl+C is pressed
Set-Location $originalDir
}

View File

@@ -0,0 +1,38 @@
# This script builds the Tauri application for Portmaster on Windows.
# It optionally builds the required Angular tauri-builtin project first.
# The script assumes that all necessary dependencies (Node.js, Rust, etc.) are installed.
# Output file: dist/portmaster.exe
# Store original directory and find project root
$originalDir = Get-Location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = (Get-Item $scriptDir).Parent.Parent.Parent.FullName
# Create output directory
$outputDir = Join-Path $scriptDir "dist"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# Ask if user wants to build the Angular tauri-builtin project
if ((Read-Host "Build Angular tauri-builtin project? (Y/N, default: Y)") -notmatch '^[Nn]$') {
# Navigate to Angular project
Set-Location (Join-Path $projectRoot "desktop\angular")
# Build tauri-builtin project
ng build --configuration production --base-href / tauri-builtin
if ($LASTEXITCODE -ne 0) { Set-Location $originalDir; exit $LASTEXITCODE }
}
# Navigate to Tauri project directory
Set-Location (Join-Path $projectRoot "desktop\tauri\src-tauri")
# Build Tauri project for Windows
cargo tauri build --no-bundle
if ($LASTEXITCODE -ne 0) { Set-Location $originalDir; exit $LASTEXITCODE }
# Copy the output files to the script's dist directory
$tauriOutput = Join-Path (Get-Location) "target\release"
Copy-Item -Path "$tauriOutput\portmaster.exe" -Destination $outputDir -Force
# Return to original directory
Set-Location $originalDir
Write-Host "Build completed successfully: $outputDir\portmaster.exe" -ForegroundColor Green

View File

@@ -92,7 +92,8 @@ func (sc *ServiceConfig) Init() error {
return nil
}
func getCurrentBinaryFolder() (string, error) {
// returns the absolute path of the currently running executable
func getCurrentBinaryPath() (string, error) {
// Get the path of the currently running executable
exePath, err := os.Executable()
if err != nil {
@@ -105,6 +106,16 @@ func getCurrentBinaryFolder() (string, error) {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
return absPath, nil
}
func getCurrentBinaryFolder() (string, error) {
// Get the absolute path of the currently running executable
absPath, err := getCurrentBinaryPath()
if err != nil {
return "", err
}
// Get the directory of the executable
installDir := filepath.Dir(absPath)
@@ -119,8 +130,8 @@ func MakeUpdateConfigs(svcCfg *ServiceConfig) (binaryUpdateConfig, intelUpdateCo
Directory: svcCfg.BinDir,
DownloadDirectory: filepath.Join(svcCfg.DataDir, "download_binaries"),
PurgeDirectory: filepath.Join(svcCfg.BinDir, "upgrade_obsolete_binaries"),
Ignore: []string{"databases", "intel", "config.json"},
IndexURLs: svcCfg.BinariesIndexURLs, // May be changed by config during instance startup.
Ignore: []string{"uninstall.exe"}, // "databases", "intel" and "config.json" not needed here since they are not in the bin dir.
IndexURLs: svcCfg.BinariesIndexURLs, // May be changed by config during instance startup.
IndexFile: "index.json",
Verify: svcCfg.VerifyBinaryUpdates,
AutoCheck: true, // May be changed by config during instance startup.
@@ -150,7 +161,7 @@ func MakeUpdateConfigs(svcCfg *ServiceConfig) (binaryUpdateConfig, intelUpdateCo
Directory: svcCfg.BinDir,
DownloadDirectory: filepath.Join(svcCfg.DataDir, "download_binaries"),
PurgeDirectory: filepath.Join(svcCfg.DataDir, "upgrade_obsolete_binaries"),
Ignore: []string{"databases", "intel", "config.json"},
Ignore: []string{}, // "databases", "intel" and "config.json" not needed here since they are not in the bin dir.
IndexURLs: svcCfg.BinariesIndexURLs, // May be changed by config during instance startup.
IndexFile: "index.json",
Verify: svcCfg.VerifyBinaryUpdates,
@@ -160,6 +171,21 @@ func MakeUpdateConfigs(svcCfg *ServiceConfig) (binaryUpdateConfig, intelUpdateCo
NeedsRestart: true,
Notify: true,
}
if binPath, err := getCurrentBinaryPath(); err == nil {
binaryUpdateConfig.PostUpgradeCommands = []updates.UpdateCommandConfig{
// Restore SELinux context for the new core binary after upgrade
// (`restorecon /usr/lib/portmaster/portmaster-core`)
{
Command: "restorecon",
Args: []string{binPath},
TriggerArtifactFName: binPath,
FailOnError: false, // Ignore error: 'restorecon' may not be available on a non-SELinux systems.
},
}
} else {
return nil, nil, fmt.Errorf("failed to get current binary path: %w", err)
}
intelUpdateConfig = &updates.Config{
Name: configure.DefaultIntelIndexName,
Directory: filepath.Join(svcCfg.DataDir, "intel"),

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
@@ -72,7 +73,7 @@ func (p *Process) getSpecialProfileID() (specialProfileID string) {
specialProfileID = profile.PortmasterProfileID
default:
// Check if this is another Portmaster component.
if module.portmasterUIPath != "" && p.Path == module.portmasterUIPath {
if p.IsPortmasterUi(context.Background()) {
specialProfileID = profile.PortmasterAppProfileID
}
// Check if this is the system resolver.
@@ -104,3 +105,37 @@ func (p *Process) getSpecialProfileID() (specialProfileID string) {
return specialProfileID
}
// IsPortmasterUi checks if the process is the Portmaster UI or its child (up to 3 parent levels).
func (p *Process) IsPortmasterUi(ctx context.Context) bool {
if module.portmasterUIPath == "" {
return false
}
// Find parent for up to two levels, if we don't match the path.
const checkLevels = 3
var previousPid int
proc := p
for i := 0; i < checkLevels; i++ {
if proc.Pid == UnidentifiedProcessID || proc.Pid == SystemProcessID {
break
}
realPath, err := filepath.EvalSymlinks(proc.Path)
if err == nil && realPath == module.portmasterUIPath {
return true
}
if i < checkLevels-1 { // no need to check parent if we are at the last level
previousPid = proc.Pid
proc, err = GetOrFindProcess(ctx, proc.ParentPid)
if err != nil || proc.Pid == previousPid {
break
}
}
}
return false
}

View File

@@ -3,17 +3,22 @@ package binmeta
import (
"bytes"
"fmt"
"image"
_ "image/png" // Register png support for image package
"github.com/fogleman/gg"
_ "github.com/mat/besticon/ico" // Register ico support for image package
// Import the specialized ICO decoder package
// This package seems to work better than "github.com/mat/besticon/ico" with ICO files
// extracted from Windows binaries, particularly those containing cursor-related data
ico "github.com/sergeymakinen/go-ico"
)
// ConvertICOtoPNG converts a an .ico to a .png image.
func ConvertICOtoPNG(ico []byte) (png []byte, err error) {
// Decode the ICO.
icon, _, err := image.Decode(bytes.NewReader(ico))
func ConvertICOtoPNG(icoBytes []byte) (png []byte, err error) {
// Decode ICO image.
// Note: The standard approach with `image.Decode(bytes.NewReader(icoBytes))` sometimes fails
// when processing certain ICO files (particularly those with cursor data),
// as it reads initial bytes for format detection before passing the stream to the decoder.
icon, err := ico.Decode(bytes.NewReader(icoBytes))
if err != nil {
return nil, fmt.Errorf("failed to decode ICO: %w", err)
}

View File

@@ -538,13 +538,14 @@ func (profile *Profile) updateMetadataFromSystem(ctx context.Context, md Matchin
}
// Apply new icon if found.
if newIcon != nil {
if newIcon != nil && !profile.iconExists(newIcon) {
if len(profile.Icons) == 0 {
profile.Icons = []binmeta.Icon{*newIcon}
} else {
profile.Icons = append(profile.Icons, *newIcon)
profile.Icons = binmeta.SortAndCompactIcons(profile.Icons)
}
changed = true
}
}()
@@ -559,3 +560,13 @@ func (profile *Profile) updateMetadataFromSystem(ctx context.Context, md Matchin
return nil
}
// Checks if the given icon already assigned to the profile.
func (profile *Profile) iconExists(newIcon *binmeta.Icon) bool {
for _, icon := range profile.Icons {
if icon.Value == newIcon.Value && icon.Type == newIcon.Type && icon.Source == newIcon.Source {
return true
}
}
return false
}

View File

@@ -184,7 +184,7 @@ func Resolve(ctx context.Context, q *Query) (rrCache *RRCache, err error) {
case !rrCache.Expired():
// Return non-expired cached entry immediately.
return rrCache, nil
case useStaleCache():
case rrCache.RCode == dns.RcodeSuccess && useStaleCache():
// Return expired cache if we should use stale cache entries,
// but start an async query instead.
log.Tracer(ctx).Tracef(

View File

@@ -107,15 +107,21 @@ func (tr *TCPResolver) UseTLS() *TCPResolver {
}
func (tr *TCPResolver) getOrCreateResolverConn(ctx context.Context) (*tcpResolverConn, error) {
var existingConn *tcpResolverConn
// Minimize the time we hold the lock to avoid blocking other threads.
tr.Lock()
defer tr.Unlock()
if tr.resolverConn != nil && tr.resolverConn.abandoned.IsNotSet() {
existingConn = tr.resolverConn
}
tr.Unlock()
// Check if we have a resolver.
if tr.resolverConn != nil && tr.resolverConn.abandoned.IsNotSet() {
if existingConn != nil {
// If there is one, check if it's alive!
select {
case tr.resolverConn.heartbeat <- struct{}{}:
return tr.resolverConn, nil
case existingConn.heartbeat <- struct{}{}:
return existingConn, nil
case <-time.After(heartbeatTimeout):
log.Warningf("resolver: heartbeat for dns client %s failed", tr.resolver.Info.DescriptiveName())
case <-ctx.Done():
@@ -162,6 +168,10 @@ func (tr *TCPResolver) getOrCreateResolverConn(ctx context.Context) (*tcpResolve
tr.resolver.Info.DescriptiveName(),
)
// Thread-safe resolverConn creation.
tr.Lock()
defer tr.Unlock()
// Create resolver connection.
tr.resolverConnInstanceID++
resolverConn := &tcpResolverConn{

View File

@@ -50,6 +50,22 @@ var (
ErrActionRequired = errors.New("action required")
)
// UpdateCommandConfig defines the configuration for a shell command
// that is executed when an update is applied
type UpdateCommandConfig struct {
// Shell command to execute
Command string
// Arguments to pass to the command
Args []string
// Execute triggers: if not empty, the command will be executed only if specified file was updated
// if empty, the command will be executed always
TriggerArtifactFName string
// FailOnError defines whether the upgrade should fail if the command fails
// true - upgrade will fail if the command fails
// false - upgrade will continue even if the command fails
FailOnError bool
}
// Config holds the configuration for the updates module.
type Config struct {
// Name of the updater.
@@ -87,6 +103,9 @@ type Config struct {
// Notify defines whether the user shall be informed about events via notifications.
// If enabled, disables automatic restart after upgrade.
Notify bool
// list of shell commands needed to run after the upgrade (if any)
PostUpgradeCommands []UpdateCommandConfig
}
// Check looks for obvious configuration errors.
@@ -404,7 +423,7 @@ func (u *Updater) updateAndUpgrade(w *mgr.WorkerCtx, indexURLs []string, ignoreV
Type: notifications.ActionTypeWebhook,
Payload: notifications.ActionTypeWebhookPayload{
Method: "POST",
URL: "updates/apply",
URL: "core/restart",
},
},
},

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
@@ -24,12 +25,20 @@ func (u *Updater) upgrade(downloader *Downloader, ignoreVersion bool) error {
}
}
// Unload UI assets to be able to move files on Windows.
u.instance.UI().EnableUpgradeLock()
defer u.instance.UI().DisableUpgradeLock()
// If we are running in a UI instance, we need to unload the UI assets
if u.instance != nil {
u.instance.UI().EnableUpgradeLock()
defer u.instance.UI().DisableUpgradeLock()
}
// Execute the upgrade.
upgradeError := u.upgradeMoveFiles(downloader)
if upgradeError == nil {
// Files upgraded successfully.
// Applying post-upgrade tasks, if any.
upgradeError = u.applyPostUpgradeCommands()
}
if upgradeError == nil {
return nil
}
@@ -207,3 +216,41 @@ func (u *Updater) deleteUnfinishedFiles(dir string) error {
return nil
}
func (u *Updater) applyPostUpgradeCommands() error {
// At this point, we assume that the upgrade was successful and all files are in place.
// We need to execute the post-upgrade commands, if any.
if len(u.cfg.PostUpgradeCommands) == 0 {
return nil
}
// collect full paths to files that were upgraded, required to check the trigger.
upgradedFiles := make(map[string]struct{})
for _, artifact := range u.index.Artifacts {
upgradedFiles[filepath.Join(u.cfg.Directory, artifact.Filename)] = struct{}{}
}
// Execute post-upgrade commands.
for _, puCmd := range u.cfg.PostUpgradeCommands {
// Check trigger to ensure that we need to run this command.
if len(puCmd.TriggerArtifactFName) > 0 {
if _, ok := upgradedFiles[puCmd.TriggerArtifactFName]; !ok {
continue
}
}
log.Debugf("updates/%s: executing post-upgrade command: '%s %s'", u.cfg.Name, puCmd.Command, strings.Join(puCmd.Args, " "))
output, err := exec.Command(puCmd.Command, puCmd.Args...).CombinedOutput()
if err != nil {
if puCmd.FailOnError {
return fmt.Errorf("post-upgrade command '%s %s' failed: %w, output: %s", puCmd.Command, strings.Join(puCmd.Args, " "), err, string(output))
}
log.Warningf("updates/%s: post-upgrade command '%s %s' failed, but ignored. Error: %s", u.cfg.Name, puCmd.Command, strings.Join(puCmd.Args, " "), err)
}
}
return nil
}