[desktop] API requests from the UI are now sent from the app binary instead of the WebView.

This makes it easier to identify the API request initiator, which is important for authenticating the UI process.

Note: Requests that do not require authentication (e.g., images, fonts, styles) may still be made from the WebView.

Merge branch 'feature/ui-connections-trough-tauri-bin' into feature/ui-security

# Conflicts:
#	desktop/angular/package-lock.json
#	desktop/angular/package.json
#	desktop/tauri/src-tauri/Cargo.lock
#	desktop/tauri/src-tauri/Cargo.toml
#	desktop/tauri/src-tauri/gen/schemas/acl-manifests.json
#	desktop/tauri/src-tauri/gen/schemas/desktop-schema.json
#	desktop/tauri/src-tauri/gen/schemas/windows-schema.json
This commit is contained in:
Alexandr Stelnykovych
2025-04-24 15:02:05 +03:00
25 changed files with 1633 additions and 8335 deletions

3
.gitignore vendored
View File

@@ -57,3 +57,6 @@ 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/

View File

@@ -1,12 +1,12 @@
{
"name": "portmaster",
"version": "0.8.11",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "portmaster",
"version": "0.8.11",
"version": "2.0.1",
"dependencies": {
"@angular/animations": "^16.0.1",
"@angular/cdk": "^16.0.1",
@@ -31,6 +31,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-websocket": "^2.3.0",
"autoprefixer": "^10.4.14",
"d3": "^7.8.4",
"data-urls": "^5.0.0",
@@ -4871,6 +4872,15 @@
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-websocket": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-websocket/-/plugin-websocket-2.3.0.tgz",
"integrity": "sha512-eAwRGe3tnqDeQYE0wq4g1PUKbam9tYvlC4uP/au12Y/z7MP4lrS4ylv+aoZ5Ly+hTlBdi7hDkhHomwF/UeBesA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",

View File

@@ -45,6 +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-websocket": "^2.3.0",
"autoprefixer": "^10.4.14",
"d3": "^7.8.4",
"data-urls": "^5.0.0",
@@ -103,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

@@ -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 {
@@ -32,6 +57,7 @@ export class PortmasterAPIModule {
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,196 @@
import WebSocket, { Message } from '@tauri-apps/plugin-websocket';
import { Subject, Observable } from 'rxjs';
import { WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket';
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.
* @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,
* });
*/
export function createTauriWsConnection<T>(opts: WebSocketSubjectConfig<T>): 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);
// 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;
}
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`);
opts.closingObserver?.next(undefined);
wsConnection.disconnect().catch((err: Error) => console.error(`${LOG_PREFIX} Error closing connection:`, err));
wsConnection = null;
}
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
//////////////////////////////////////////////////////////////
const connectOptions: Record<string, any> = {};
console.log(`${LOG_PREFIX} Connecting to WebSocket:`, opts.url, connectOptions);
WebSocket.connect(opts.url, connectOptions)
.then((ws) => {
wsConnection = ws;
console.log(`${LOG_PREFIX} Connection established`);
// 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
// The addListener method takes a single callback function that receives messages
ws.addListener((message: Message) => {
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,5 +1,7 @@
import { Injectable } 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 {
@@ -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);
}
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

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +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-websocket = "2"
clap_lex = "0.7.2"
@@ -47,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

@@ -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

@@ -18,6 +18,7 @@ mod config;
mod portmaster;
mod traymenu;
mod window;
mod commands;
use log::{debug, error, info};
use portmaster::PortmasterExt;
@@ -145,7 +146,16 @@ fn main() {
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.
@@ -179,7 +189,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

@@ -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