Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/windows-portable.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Windows Portable

on:
workflow_dispatch:
push:
paths:
- 'src/**'
- 'src-tauri/**'
- 'package*.json'
- 'portable/**'
- 'ci/*portable*.ps1'
- '.github/workflows/windows-portable.yml'
pull_request:
paths:
- 'src/**'
- 'src-tauri/**'
- 'package*.json'
- 'portable/**'
- 'ci/*portable*.ps1'
- '.github/workflows/windows-portable.yml'

permissions:
contents: read

concurrency:
group: portable-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: npm
- name: Install stable Rust
shell: pwsh
run: |
rustup toolchain install stable --profile minimal
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
rustup default stable
- uses: Swatinem/rust-cache@e9d7b9f1e587218970671bac74e5511ed9506e6a # v2
with:
workspaces: src-tauri
cache-on-failure: true
- name: Test portable storage
shell: pwsh
run: |
rustc --edition 2021 --test src-tauri/src/storage.rs -o "$env:RUNNER_TEMP/storage-tests.exe"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& "$env:RUNNER_TEMP/storage-tests.exe"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Fetch pinned GeoIP database
shell: pwsh
run: |
Invoke-WebRequest 'https://github.com/openscopeproject/TrguiNG/releases/download/v1.5.1/dbip.mmdb' -OutFile src-tauri/dbip.mmdb
if ((Get-FileHash src-tauri/dbip.mmdb -Algorithm SHA256).Hash -ne 'e4f183d26a79e7750abb24cce0dcc595a0cb009f2de4082c27b84cae6bbd7944') { throw 'GeoIP checksum mismatch' }
- name: Test settings import
shell: pwsh
run: ./ci/test_portable_import.ps1
- name: Install locked frontend dependencies
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Check frontend
run: npx --no-install eslint src/
- name: Build executable
run: npm run tauri -- build --no-bundle --ci -- --locked
- name: Package portable ZIP
shell: pwsh
run: ./ci/package_windows_portable.ps1
- name: Test portable configuration and relocation
shell: pwsh
run: ./ci/smoke_windows_portable.ps1
- name: Preserve runtime diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: portable-smoke-diagnostics
path: portable-output/smoke-diagnostics/
if-no-files-found: ignore
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: TrguiNG-portable-windows-x64
path: |
portable-output/*.zip
portable-output/*.sha256
if-no-files-found: error
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ tests/manual.torrent
src/build
.DS_Store
*.mmdb
portable-output/
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ Weekly builds of current development version are available from github
[build workflows](https://github.com/openscopeproject/TrguiNG/actions/workflows/build.yml).
Pick the latest successful run and scroll down to the artifacts section.

## Windows portable mode

Place `portable.flag` next to the executable to keep configuration in
`Data/trguing.json` and the WebView2 profile in `Data/WebView2`. The WebView2
runtime remains a system dependency. Without the marker, the existing installed
configuration path is preserved. See [portable/README.txt](portable/README.txt)
for migration, packaging and update instructions.

## Compiling

Prerequisites:
Expand Down
48 changes: 48 additions & 0 deletions ci/package_windows_portable.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
param(
[string]$Binary = (Join-Path $PSScriptRoot '..\src-tauri\target\release\TrguiNG.exe'),
[string]$GeoIp = (Join-Path $PSScriptRoot '..\src-tauri\dbip.mmdb')
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$sha = (git -C $repo rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Cannot identify build commit.' }
foreach ($file in @($Binary, $GeoIp)) {
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Missing input: $file" }
}
$sourceUrl = if ($env:GITHUB_REPOSITORY) {
"https://github.com/$env:GITHUB_REPOSITORY"
} else {
(git -C $repo remote get-url origin).Trim() -replace '^git@github.com:', 'https://github.com/' -replace '\.git$', ''
}
$version = (Get-Content -LiteralPath (Join-Path $repo 'package.json') -Raw | ConvertFrom-Json).version
$name = "TrguiNG-$version-portable-windows-x64-$($sha.Substring(0, 8))"
$output = Join-Path $repo 'portable-output'
$package = Join-Path $output $name
# Do not overwrite an existing package or any user Data directory.
if (Test-Path -LiteralPath $package) { throw "Output already exists: $package" }
$null = New-Item -ItemType Directory -Path (Join-Path $package 'Data') -Force
Copy-Item -LiteralPath $Binary -Destination (Join-Path $package 'TrguiNG.exe')
Copy-Item -LiteralPath $GeoIp -Destination (Join-Path $package 'dbip.mmdb')
Copy-Item -Path (Join-Path $repo 'portable\*') -Destination $package
Copy-Item -LiteralPath (Join-Path $repo 'LICENSE.txt') -Destination $package
[IO.File]::WriteAllText((Join-Path $package 'portable.flag'), '')
$binaryHash = (Get-FileHash -LiteralPath (Join-Path $package 'TrguiNG.exe') -Algorithm SHA256).Hash
$geoHash = (Get-FileHash -LiteralPath (Join-Path $package 'dbip.mmdb') -Algorithm SHA256).Hash
@(
"Source: $sourceUrl",
"Commit: $sha",
"Version: $version (portable fork)",
"Built UTC: $([DateTime]::UtcNow.ToString('o'))",
"TrguiNG.exe SHA256: $binaryHash",
"dbip.mmdb SHA256: $geoHash",
'WebView2: system runtime; user data in Data\WebView2',
'GeoIP: DB-IP Country Lite (CC BY 4.0), https://db-ip.com'
) | Set-Content -LiteralPath (Join-Path $package 'BUILD-INFO.txt') -Encoding UTF8
$zip = Join-Path $output ($name + '.zip')
Compress-Archive -LiteralPath $package -DestinationPath $zip -CompressionLevel Optimal
$zipHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
[IO.File]::WriteAllText(($zip + '.sha256'), "$zipHash $name.zip`n")
Write-Host "Package: $zip"
Write-Host "SHA256: $zipHash"
73 changes: 73 additions & 0 deletions ci/smoke_windows_portable.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$packages = @(Get-ChildItem -LiteralPath (Join-Path $repo 'portable-output') -Directory)
if ($packages.Count -ne 1) { throw 'Expected one portable package.' }
$diagnostics = Join-Path $repo 'portable-output\smoke-diagnostics'
$null = New-Item -ItemType Directory -Path $diagnostics -Force
$fixture = Join-Path ([IO.Path]::GetTempPath()) ('TrguiNG portable smoke ' + [Guid]::NewGuid().ToString('N'))
$root = Join-Path $fixture 'First location'
$null = New-Item -ItemType Directory -Path $fixture
$roaming = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'trguing.json'
$originalHash = if (Test-Path -LiteralPath $roaming) { (Get-FileHash -LiteralPath $roaming).Hash } else { $null }
$process = $null
try {
Copy-Item -LiteralPath $packages[0].FullName -Destination $root -Recurse
$config = Join-Path $root 'Data\trguing.json'
# The argument listener is mounted only when a server tab is open. Use an
# unreachable loopback endpoint, never a real daemon or user credentials.
$settings = @{
servers = @(@{
name = 'Portable smoke'
connection = @{ url = 'http://127.0.0.1:9/transmission/rpc'; username = ''; password = '' }
pathMappings = @(); expandedDirFilters = @(); lastSaveDirs = @()
intervals = @{ session = 60; torrents = 60; torrentsMinimized = 60; details = 60 }
})
configVersion = 1
app = @{ onClose = 'quit'; showTrayIcon = $false; openTabs = @('Portable smoke'); lastTab = 0 }
interface = @{ theme = 'dark' }
}
[IO.File]::WriteAllText($config, ($settings | ConvertTo-Json -Depth 8))
foreach ($attempt in 1..2) {
Write-Host "Starting portable GUI, attempt $attempt"
$process = Start-Process -FilePath (Join-Path $root 'TrguiNG.exe') -WorkingDirectory $fixture -PassThru -RedirectStandardOutput (Join-Path $diagnostics "stdout-$attempt.txt") -RedirectStandardError (Join-Path $diagnostics "stderr-$attempt.txt")
$ready = $false
foreach ($retry in 1..20) {
if ($process.HasExited) { throw "TrguiNG exited before becoming ready: $($process.ExitCode)" }
try {
$response = Invoke-WebRequest 'http://127.0.0.1:44321/args' -Method Post -ContentType 'application/json' -Body '[]' -TimeoutSec 3 -NoProxy
if ($response.StatusCode -eq 200) { $ready = $true; break }
} catch {
Write-Host "Readiness attempt ${retry}: $($_.Exception.Message)"
Start-Sleep -Seconds 1
}
}
if (-not $ready) { throw 'The frontend did not signal readiness.' }
Write-Host 'Frontend argument listener is ready.'
$process.Refresh()
if (-not $process.CloseMainWindow()) { throw 'Unable to close the application window.' }
if (-not $process.WaitForExit(20000)) { throw 'Application did not finish saving and exit.' }
if ($process.ExitCode -ne 0) { throw "Application exit code: $($process.ExitCode)" }
$saved = Get-Content -LiteralPath (Join-Path $root 'Data\trguing.json') -Raw | ConvertFrom-Json
if ($saved.interface.theme -ne 'dark' -or $null -eq $saved.interface.filterSections) { throw 'Portable configuration was not read and saved.' }
if (@(Get-ChildItem -LiteralPath (Join-Path $root 'Data\WebView2') -Force).Count -eq 0) { throw 'WebView2 profile was not created inside Data.' }
$afterHash = if (Test-Path -LiteralPath $roaming) { (Get-FileHash -LiteralPath $roaming).Hash } else { $null }
if ($afterHash -ne $originalHash) { throw 'Application changed the installed configuration.' }
Write-Host "Attempt ${attempt}: portable configuration and WebView2 profile verified."
if ($attempt -eq 1) {
# WebView2 processes can take a moment to release the profile after exit.
$moved = Join-Path $fixture 'Moved location'
foreach ($retry in 1..30) {
try { [IO.Directory]::Move($root, $moved); break } catch {
if ($retry -eq 30) { throw }
Start-Sleep -Seconds 1
}
}
$root = $moved
}
}
Write-Host 'PASS: Windows GUI starts, reads/saves portable settings, uses portable WebView2 data, and survives relocation.'
} finally {
if ($null -ne $process -and -not $process.HasExited) { Stop-Process -Id $process.Id -Force }
Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue
}
25 changes: 25 additions & 0 deletions ci/test_portable_import.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$fixture = Join-Path ([IO.Path]::GetTempPath()) ('trguing-import-test-' + [Guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $fixture
try {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot '..\portable\Import-Settings.ps1') -Destination $fixture
[IO.File]::WriteAllText((Join-Path $fixture 'portable.flag'), '')
$source = Join-Path $fixture 'original.json'
[IO.File]::WriteAllText($source, '{"servers":[],"configVersion":1}')
$original = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash
& (Join-Path $fixture 'Import-Settings.ps1') -Source $source
$destination = Join-Path $fixture 'Data\trguing.json'
if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ne $original) { throw 'Original changed' }
if ((Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash -ne $original) { throw 'Destination differs' }
$backups = @(Get-ChildItem -LiteralPath (Join-Path $fixture 'Data\Backups') -File)
if ($backups.Count -ne 1) { throw 'Expected one backup' }
if ((Get-FileHash -LiteralPath $backups[0].FullName -Algorithm SHA256).Hash -ne $original) { throw 'Backup differs' }
$rejected = $false
try { & (Join-Path $fixture 'Import-Settings.ps1') -Source $source } catch { $rejected = $true }
if (-not $rejected) { throw 'Existing portable settings were not protected' }
if ((Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash -ne $original) { throw 'Existing settings changed' }
Write-Host 'PASS: migration preserves original, creates identical backup, and refuses overwrite.'
} finally {
Remove-Item -LiteralPath $fixture -Recurse -Force
}
3 changes: 3 additions & 0 deletions portable/Import-Settings.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Import-Settings.ps1" %*
pause
41 changes: 41 additions & 0 deletions portable/Import-Settings.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
param(
[string]$Source = (Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'trguing.json')
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

if (Get-Process -Name TrguiNG -ErrorAction SilentlyContinue) {
throw 'Close TrguiNG completely, including the tray icon, before importing settings.'
}
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'portable.flag') -PathType Leaf)) {
throw 'portable.flag is missing. Run this script from the extracted portable package.'
}
if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) {
throw "Configuration file not found: $Source"
}

$data = Join-Path $PSScriptRoot 'Data'
$destination = Join-Path $data 'trguing.json'
if (Test-Path -LiteralPath $destination) {
throw 'Data\trguing.json already exists. Import cancelled to preserve your portable settings.'
}

# Validate syntax without reserializing or printing credentials.
$contents = [IO.File]::ReadAllText((Resolve-Path -LiteralPath $Source).Path)
$null = ConvertFrom-Json -InputObject $contents
$backups = Join-Path $data 'Backups'
$null = New-Item -ItemType Directory -Path $backups -Force
$backup = Join-Path $backups ('trguing-original-' + [Guid]::NewGuid().ToString('N') + '.json')
Copy-Item -LiteralPath $Source -Destination $backup
Copy-Item -LiteralPath $backup -Destination $destination

$sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash
$backupHash = (Get-FileHash -LiteralPath $backup -Algorithm SHA256).Hash
$targetHash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
if ($sourceHash -ne $backupHash -or $sourceHash -ne $targetHash) {
throw 'Checksum mismatch. Keep the source and backup files and check the copies before starting TrguiNG.'
}
Write-Host 'Settings copied and SHA-256 verified. The original file was left unchanged.'
Write-Host "Backup: $backup"
Write-Host 'Start TrguiNG.exe from this folder.'
44 changes: 44 additions & 0 deletions portable/README.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
TrguiNG portable mode for Windows
================================

Extract the portable ZIP to a writable folder outside Program Files. Windows
must have the WebView2 Runtime installed; it is not included in the package.

Close TrguiNG completely before migrating settings or updating the application.
To import your installed settings, run Import-Settings.cmd before starting the
portable executable. It copies %APPDATA%\trguing.json, keeps an identical backup
in Data\Backups, verifies SHA-256 and leaves the original unchanged. Import is
refused when Data\trguing.json already exists. To use a different source:

.\Import-Settings.ps1 -Source 'D:\Backup\trguing.json'

Package layout:
TrguiNG.exe Application
dbip.mmdb Local GeoIP database
portable.flag Enables portable mode
Data\trguing.json Configuration and server profiles
Data\WebView2\ WebView2 profile/cache, created when starting the app
Data\Backups\ Original configuration copied by the import helper
BUILD-INFO.txt Source commit and executable/GeoIP SHA-256 hashes

Move the entire folder to use it on another drive or computer. Existing remote
and local path mappings may need adjustment. The old EBWebView cache does not
need to be copied; the application creates a new profile. Configuration files
and backups contain reversibly obfuscated passwords and should be protected.

Keep portable.flag and Data when updating. Close the app and back up the folder
before replacing program files. Without portable.flag, the executable uses the
existing installed configuration location. An unusable portable data directory
does not silently fall back to the installed profile.

Do not run installed and portable copies simultaneously: the application's
single-instance listener uses the same loopback port. Avoid enabling .torrent
or magnet associations if you do not want registry integration on that PC.

To build on Windows, put dbip.mmdb in src-tauri, install the project's build
prerequisites, run npm ci and npm run tauri -- build --no-bundle --ci -- --locked,
then run ./ci/package_windows_portable.ps1. Packages and SHA-256 files are written
to portable-output. The packaging script refuses to overwrite existing output.

License: GNU Affero General Public License v3 or later; see LICENSE.txt.
GeoIP: DB-IP Country Lite, CC BY 4.0, https://db-ip.com/db/download/ip-to-country-lite
12 changes: 12 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,15 @@ pub async fn save_text_file(contents: String, path: String) -> Result<(), String
pub async fn load_text_file(path: String) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("Unable to read file: {e}"))
}

#[tauri::command]
pub fn read_config(paths: State<'_, crate::storage::StoragePaths>) -> Result<String, String> {
paths.read().map_err(|e| format!("Unable to read configuration: {e}"))
}

#[tauri::command]
pub fn write_config(paths: State<'_, crate::storage::StoragePaths>, contents: String) -> Result<(), String> {
serde_json::from_str::<serde_json::Value>(&contents)
.map_err(|e| format!("Invalid configuration JSON: {e}"))?;
paths.write(&contents).map_err(|e| format!("Unable to write configuration: {e}"))
}
Loading