diff --git a/.github/workflows/windows-portable.yml b/.github/workflows/windows-portable.yml new file mode 100644 index 00000000..1d71bba4 --- /dev/null +++ b/.github/workflows/windows-portable.yml @@ -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 diff --git a/.gitignore b/.gitignore index ea5d1c43..2fde4a1a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ tests/manual.torrent src/build .DS_Store *.mmdb +portable-output/ diff --git a/README.md b/README.md index 3ab31bd6..07bfeafd 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/ci/package_windows_portable.ps1 b/ci/package_windows_portable.ps1 new file mode 100644 index 00000000..83e2fcf4 --- /dev/null +++ b/ci/package_windows_portable.ps1 @@ -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" diff --git a/ci/smoke_windows_portable.ps1 b/ci/smoke_windows_portable.ps1 new file mode 100644 index 00000000..3a1d8785 --- /dev/null +++ b/ci/smoke_windows_portable.ps1 @@ -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 +} diff --git a/ci/test_portable_import.ps1 b/ci/test_portable_import.ps1 new file mode 100644 index 00000000..cb9f15c1 --- /dev/null +++ b/ci/test_portable_import.ps1 @@ -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 +} diff --git a/portable/Import-Settings.cmd b/portable/Import-Settings.cmd new file mode 100644 index 00000000..b1da09f4 --- /dev/null +++ b/portable/Import-Settings.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Import-Settings.ps1" %* +pause diff --git a/portable/Import-Settings.ps1 b/portable/Import-Settings.ps1 new file mode 100644 index 00000000..b3c60e3c --- /dev/null +++ b/portable/Import-Settings.ps1 @@ -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.' diff --git a/portable/README.txt b/portable/README.txt new file mode 100644 index 00000000..6b1ad14f --- /dev/null +++ b/portable/README.txt @@ -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 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 74b1ebb5..91fa7779 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { 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 { + 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::(&contents) + .map_err(|e| format!("Invalid configuration JSON: {e}"))?; + paths.write(&contents).map_err(|e| format!("Unable to write configuration: {e}")) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b4ce60ff..7f8d414e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -38,6 +38,7 @@ mod ipc; mod macos; mod poller; mod sound; +mod storage; mod torrentcache; mod tray; @@ -185,6 +186,19 @@ fn http_clients() -> HttpClients { } fn main() { + // Resolve portable paths before Tauri/WebView2 can start background threads. + let storage = (|| -> std::io::Result { + let paths = storage::StoragePaths::discover( + &std::env::current_exe()?, dirs::config_dir().as_deref(), + )?; + paths.prepare()?; + Ok(paths) + })().expect("Unable to initialize TrguiNG data directory"); + #[cfg(target_os = "windows")] + if let Some(path) = &storage.webview { + std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", path); + } + let context = tauri::generate_context!(); let ipc = ipc::Ipc::new(); @@ -211,6 +225,8 @@ fn main() { commands::create_tray, commands::save_text_file, commands::load_text_file, + commands::read_config, + commands::write_config, ]) .manage(ListenerHandle(Arc::new(RwLock::new(ipc)))) .manage(TorrentCacheHandle::default()) @@ -218,6 +234,7 @@ fn main() { .manage(MmdbReaderHandle::default()) .manage(CreationRequestsHandle::default()) .manage(http_clients()) + .manage(storage) .setup(setup); #[cfg(target_os = "macos")] diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 00000000..f9d9c3c3 --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,211 @@ +//! Configuration paths and persistence shared by every application window. + +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub struct StoragePaths { + config: PathBuf, + pub webview: Option, + write_lock: Mutex<()>, +} + +impl StoragePaths { + pub fn discover(executable: &Path, config_dir: Option<&Path>) -> io::Result { + let root = executable.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Executable has no parent directory", + ) + })?; + let marker = root.join("portable.flag"); + let portable = match fs::metadata(&marker) { + Ok(metadata) if metadata.is_file() => true, + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "portable.flag must be a file", + )) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => false, + Err(e) => return Err(e), + }; + let (config, webview) = if portable { + let data = root.join("Data"); + (data.join("trguing.json"), Some(data.join("WebView2"))) + } else { + let dir = config_dir.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Unable to resolve configuration directory", + ) + })?; + (dir.join("trguing.json"), None) + }; + Ok(Self { + config, + webview, + write_lock: Mutex::new(()), + }) + } + + pub fn prepare(&self) -> io::Result<()> { + if let Some(webview) = &self.webview { + fs::create_dir_all(webview)?; + } + Ok(()) + } + + pub fn read(&self) -> io::Result { + fs::read_to_string(&self.config) + } + + pub fn write(&self, contents: &str) -> io::Result<()> { + let _guard = self + .write_lock + .lock() + .map_err(|_| io::Error::other("Configuration lock poisoned"))?; + let parent = self + .config + .parent() + .ok_or_else(|| io::Error::other("Invalid configuration path"))?; + fs::create_dir_all(parent)?; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(io::Error::other)? + .as_nanos(); + let temporary = parent.join(format!("trguing.json.{}.{stamp}.tmp", std::process::id())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, &self.config) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0); + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "trguing-portable-{}-{}-{stamp}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(path.join("Aplicação portátil")).unwrap(); + fs::create_dir_all(path.join("Roaming")).unwrap(); + Self(path) + } + fn exe(&self) -> PathBuf { + self.0.join("Aplicação portátil/TrguiNG.exe") + } + fn roaming(&self) -> PathBuf { + self.0.join("Roaming") + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn installed_mode_keeps_existing_configuration() { + let f = Fixture::new(); + fs::write(f.roaming().join("trguing.json"), "installed").unwrap(); + let paths = StoragePaths::discover(&f.exe(), Some(&f.roaming())).unwrap(); + assert!(paths.webview.is_none()); + assert_eq!(paths.read().unwrap(), "installed"); + paths.write("updated").unwrap(); + assert_eq!( + fs::read_to_string(f.roaming().join("trguing.json")).unwrap(), + "updated" + ); + assert!(!f.exe().parent().unwrap().join("Data").exists()); + } + + #[test] + fn portable_mode_is_independent_of_roaming_and_survives_relocation() { + let f = Fixture::new(); + let root = f.exe().parent().unwrap().to_path_buf(); + fs::write(root.join("portable.flag"), "").unwrap(); + fs::write(f.roaming().join("trguing.json"), "original").unwrap(); + let paths = StoragePaths::discover(&f.exe(), None).unwrap(); + paths.prepare().unwrap(); + assert!(paths.webview.as_ref().unwrap().is_dir()); + assert!(paths.read().is_err()); + paths.write("{\"portable\":true}").unwrap(); + paths.write("{\"portable\":\"updated\"}").unwrap(); + assert_eq!( + fs::read_to_string(f.roaming().join("trguing.json")).unwrap(), + "original" + ); + let moved = f.0.join("Outra pasta"); + fs::rename(&root, &moved).unwrap(); + let relocated = + StoragePaths::discover(&moved.join("TrguiNG.exe"), Some(&f.roaming())).unwrap(); + assert_eq!(relocated.read().unwrap(), "{\"portable\":\"updated\"}"); + } + + #[test] + fn concurrent_saves_leave_complete_configuration_and_no_temporary_files() { + let f = Fixture::new(); + let paths = + std::sync::Arc::new(StoragePaths::discover(&f.exe(), Some(&f.roaming())).unwrap()); + paths.write("initial").unwrap(); + let workers: Vec<_> = (0..4) + .map(|i| { + let paths = paths.clone(); + std::thread::spawn(move || { + let value = format!("writer-{i}:{}", "x".repeat(8192)); + for _ in 0..10 { + paths.write(&value).unwrap(); + } + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + let value = paths.read().unwrap(); + assert!(value.starts_with("writer-")); + assert_eq!(value.len(), 9 + 8192); + assert_eq!(fs::read_dir(f.roaming()).unwrap().count(), 1); + } + + #[test] + fn invalid_marker_and_blocked_data_never_fall_back_to_roaming() { + let f = Fixture::new(); + let root = f.exe().parent().unwrap().to_path_buf(); + fs::create_dir(root.join("portable.flag")).unwrap(); + assert!(StoragePaths::discover(&f.exe(), Some(&f.roaming())).is_err()); + fs::remove_dir(root.join("portable.flag")).unwrap(); + fs::write(root.join("portable.flag"), "").unwrap(); + fs::write(root.join("Data"), "blocked").unwrap(); + let paths = StoragePaths::discover(&f.exe(), Some(&f.roaming())).unwrap(); + assert!(paths.prepare().is_err()); + assert!(paths.write("must not reach Roaming").is_err()); + assert!(!f.roaming().join("trguing.json").exists()); + } +} diff --git a/src/taurishim.ts b/src/taurishim.ts index 45fbcd7a..4f3ab1a6 100644 --- a/src/taurishim.ts +++ b/src/taurishim.ts @@ -106,19 +106,17 @@ export async function makeCreateTorrentView() { } } -const configFile = "trguing.json"; - export async function readConfigText() { - if (fs !== undefined) { - return await fs.readTextFile(configFile, { baseDir: fs.BaseDirectory.Config }); + if (TAURI) { + return await invoke("read_config"); } else { return localStorage.getItem("trguing-config") ?? "{}"; } } export async function writeConfigText(contents: string) { - if (fs !== undefined) { - await fs.writeTextFile(configFile, contents, { baseDir: fs.BaseDirectory.Config }); + if (TAURI) { + await invoke("write_config", { contents }); } else { localStorage.setItem("trguing-config", contents); }