From ef021bbe81db85b6e8eeb4b349e68e52dd91ab04 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:21:23 +0900 Subject: [PATCH 01/24] fix: harden PowerShell native host registration --- scripts/diagnose.ps1 | 38 +++++++++++++++++++- scripts/native-host-registration.ps1 | 24 +++++++++++++ scripts/register-native-host.ps1 | 15 ++++---- scripts/test-native-host-registration.ps1 | 42 +++++++++++++++++++++++ scripts/test.ps1 | 2 ++ 5 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 scripts/native-host-registration.ps1 create mode 100644 scripts/test-native-host-registration.ps1 diff --git a/scripts/diagnose.ps1 b/scripts/diagnose.ps1 index b894676..a3488e5 100644 --- a/scripts/diagnose.ps1 +++ b/scripts/diagnose.ps1 @@ -14,7 +14,10 @@ Write-Host "Node.js: $(if ($node) { & $node.Source --version } else { 'not found Write-Host "npm: $(if ($npm) { & $npm.Source --version } else { 'not found' })" $browserCandidates = [ordered]@{ - Whale = @((Join-Path $env:LOCALAPPDATA 'Naver\Naver Whale\Application\whale.exe')) + Whale = @( + (Join-Path $env:LOCALAPPDATA 'Naver\Naver Whale\Application\whale.exe'), + (Join-Path $env:ProgramFiles 'Naver\Naver Whale\Application\whale.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Naver\Naver Whale\Application\whale.exe')) Edge = @((Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'), (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')) Chrome = @((Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'), (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe')) Brave = @((Join-Path $env:ProgramFiles 'BraveSoftware\Brave-Browser\Application\brave.exe')) @@ -27,5 +30,38 @@ foreach ($entry in $browserCandidates.GetEnumerator()) { } & (Join-Path $PSScriptRoot 'register-native-host.ps1') -Action Status -Browser All | Format-Table -AutoSize +$expectedManifestPath = Join-Path $env:LOCALAPPDATA 'eslee\DownloadRouter\native-host\com.eslee.download_router.json' +if (Test-Path -LiteralPath $expectedManifestPath -PathType Leaf) { + try { + $bytes = [IO.File]::ReadAllBytes($expectedManifestPath) + $hasUtf8Bom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + $offset = if ($hasUtf8Bom) { 3 } else { 0 } + $json = [Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset) + $manifest = $json | ConvertFrom-Json + $expectedOrigin = 'chrome-extension://gilicenlclaemgiijcjjejilikbooggj/' + $hostExists = Test-Path -LiteralPath $manifest.path -PathType Leaf + $originAllowed = @($manifest.allowed_origins) -contains $expectedOrigin + Write-Host "Native Host manifest: valid-json=yes, utf8-bom=$hasUtf8Bom, host-exists=$hostExists, development-origin=$originAllowed" + + if ($hostExists) { + $startInfo = New-Object Diagnostics.ProcessStartInfo + $startInfo.FileName = $manifest.path + $startInfo.Arguments = '--self-test' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardError = $true + $process = [Diagnostics.Process]::Start($startInfo) + $null = $process.StandardError.ReadToEnd() + $process.WaitForExit() + Write-Host "Native Host self-test: $(if ($process.ExitCode -eq 0) { 'passed' } else { "failed (exit $($process.ExitCode))" })" + } + } + catch { + Write-Warning "Native Host manifest validation failed: $($_.Exception.GetType().Name)" + } +} +else { + Write-Warning 'Native Host manifest was not found at the expected per-user location.' +} Write-Host "Extension load path after build: $(Join-Path $root 'src\DownloadRouter.Extension\dist')" Write-Host 'No browser profile, full URL, downloaded file, token, or raw log content was collected.' diff --git a/scripts/native-host-registration.ps1 b/scripts/native-host-registration.ps1 new file mode 100644 index 0000000..aa76927 --- /dev/null +++ b/scripts/native-host-registration.ps1 @@ -0,0 +1,24 @@ +function Test-WindowsAbsolutePath([string]$Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + + $isDriveAbsolute = $Path -match '^[A-Za-z]:[\\/]' + $isUnc = $Path -match '^\\\\[^\\]+\\[^\\]+' + return $isDriveAbsolute -or $isUnc +} + +function Get-NativeHostAllowedOrigins( + [string]$DevelopmentExtensionId, + [string[]]$AdditionalExtensionId = @() +) { + return @($DevelopmentExtensionId) + $AdditionalExtensionId | + Where-Object { $_ -match '^[a-p]{32}$' } | + Select-Object -Unique | + ForEach-Object { "chrome-extension://$_/" } +} + +function Write-Utf8WithoutBom([string]$Path, [string]$Content) { + $encoding = New-Object System.Text.UTF8Encoding -ArgumentList $false + [IO.File]::WriteAllText($Path, $Content, $encoding) +} diff --git a/scripts/register-native-host.ps1 b/scripts/register-native-host.ps1 index 6f7ea45..a4d7f2e 100644 --- a/scripts/register-native-host.ps1 +++ b/scripts/register-native-host.ps1 @@ -9,6 +9,7 @@ param( ) . (Join-Path $PSScriptRoot 'common.ps1') +. (Join-Path $PSScriptRoot 'native-host-registration.ps1') $root = Get-RepositoryRoot $hostName = 'com.eslee.download_router' $developmentExtensionId = 'gilicenlclaemgiijcjjejilikbooggj' @@ -50,7 +51,9 @@ if ($Action -eq 'Unregister') { return } -if (-not [IO.Path]::IsPathFullyQualified($HostPath)) { throw 'HostPath must be an absolute path.' } +if (-not (Test-WindowsAbsolutePath $HostPath)) { + throw 'HostPath must be an absolute path.' +} $HostPath = [IO.Path]::GetFullPath($HostPath) if (-not (Test-Path -LiteralPath $HostPath -PathType Leaf)) { throw "Native Host executable not found: $HostPath`nRun .\scripts\build.ps1 -Configuration Release -PublishNativeHost first." @@ -62,10 +65,7 @@ if ([IO.Path]::GetFileName($HostPath) -ne 'DownloadRouter.NativeHost.exe') { if (-not (Test-Path -LiteralPath $manifestDirectory)) { New-Item -ItemType Directory -Path $manifestDirectory | Out-Null } -$allowedOrigins = @($developmentExtensionId) + $AdditionalExtensionId | - Where-Object { $_ -match '^[a-p]{32}$' } | - Select-Object -Unique | - ForEach-Object { "chrome-extension://$_/" } +$allowedOrigins = Get-NativeHostAllowedOrigins $developmentExtensionId $AdditionalExtensionId $manifest = [ordered]@{ name = $hostName description = 'eslee Download Router Native Messaging Host' @@ -73,12 +73,13 @@ $manifest = [ordered]@{ type = 'stdio' allowed_origins = @($allowedOrigins) } -$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 +$manifestJson = $manifest | ConvertTo-Json -Depth 4 +Write-Utf8WithoutBom $manifestPath ($manifestJson + [Environment]::NewLine) foreach ($name in $selected) { $key = Get-RegistryKey $name if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null } - (Get-Item -Path $key).SetValue('', $manifestPath, [Microsoft.Win32.RegistryValueKind]::String) + Set-Item -LiteralPath $key -Value $manifestPath Write-Host "Registered $name -> $manifestPath" } diff --git a/scripts/test-native-host-registration.ps1 b/scripts/test-native-host-registration.ps1 new file mode 100644 index 0000000..d0db9e6 --- /dev/null +++ b/scripts/test-native-host-registration.ps1 @@ -0,0 +1,42 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'native-host-registration.ps1') + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw $Message + } +} + +Assert-True (Test-WindowsAbsolutePath 'C:\Program Files\eslee\DownloadRouter.NativeHost.exe') 'Drive-absolute paths must be accepted.' +Assert-True (Test-WindowsAbsolutePath '\\server\share\DownloadRouter.NativeHost.exe') 'UNC paths must be accepted.' +Assert-True (-not (Test-WindowsAbsolutePath 'C:relative\DownloadRouter.NativeHost.exe')) 'Drive-relative paths must be rejected.' +Assert-True (-not (Test-WindowsAbsolutePath '.\DownloadRouter.NativeHost.exe')) 'Relative paths must be rejected.' + +$developmentExtensionId = 'gilicenlclaemgiijcjjejilikbooggj' +$additionalExtensionId = 'abcdefghijklmnopabcdefghijklmnop' +$origins = @(Get-NativeHostAllowedOrigins $developmentExtensionId @($additionalExtensionId, 'invalid-id', $developmentExtensionId)) +Assert-True ($origins.Count -eq 2) 'Only unique extension IDs in the Chromium a-p alphabet must be retained.' +Assert-True ($origins -contains "chrome-extension://$developmentExtensionId/") 'The development extension origin is required.' +Assert-True ($origins -contains "chrome-extension://$additionalExtensionId/") 'A valid additional extension origin must be retained.' + +$temporaryFile = [IO.Path]::GetTempFileName() +try { + Write-Utf8WithoutBom $temporaryFile '{"name":"com.eslee.download_router"}' + $bytes = [IO.File]::ReadAllBytes($temporaryFile) + $hasUtf8Bom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + Assert-True (-not $hasUtf8Bom) 'Native Messaging manifests must be UTF-8 without BOM.' + $parsed = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json + Assert-True ($parsed.name -eq 'com.eslee.download_router') 'The BOM-free manifest must remain valid JSON.' +} +finally { + if (Test-Path -LiteralPath $temporaryFile) { + Remove-Item -LiteralPath $temporaryFile -Force + } +} + +Write-Host 'Native Host registration compatibility tests passed.' diff --git a/scripts/test.ps1 b/scripts/test.ps1 index 4361e70..342eb3c 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -9,6 +9,8 @@ $root = Get-RepositoryRoot $dotnet = Assert-DotNetVersion $nodeTools = Assert-NodeTools +& (Join-Path $PSScriptRoot 'test-native-host-registration.ps1') + & $dotnet test (Join-Path $root 'DownloadRouter.slnx') --configuration $Configuration --no-build --no-restore --nologo if ($LASTEXITCODE -ne 0) { throw '.NET tests failed.' } From 50983491aaea263dc6a8255cded91dcd11fc3c61 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:21:32 +0900 Subject: [PATCH 02/24] fix: make native messaging diagnostics fail open --- src/DownloadRouter.Extension/package.json | 2 +- src/DownloadRouter.Extension/src/native.ts | 42 +++++++++++- .../tests/native.test.ts | 16 +++++ .../Files/FileMoveService.cs | 5 +- .../Logging/JsonLineFileLogger.cs | 13 +++- src/DownloadRouter.NativeHost/Program.cs | 4 +- .../JsonLineFileLoggerTests.cs | 40 ++++++++++++ .../CommandValidationTests.cs | 65 +++++++++++++++++++ 8 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 src/DownloadRouter.Extension/tests/native.test.ts create mode 100644 tests/DownloadRouter.Infrastructure.Tests/JsonLineFileLoggerTests.cs diff --git a/src/DownloadRouter.Extension/package.json b/src/DownloadRouter.Extension/package.json index b096c65..8cd2538 100644 --- a/src/DownloadRouter.Extension/package.json +++ b/src/DownloadRouter.Extension/package.json @@ -11,7 +11,7 @@ "clean": "node scripts/clean.mjs", "build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-manifest.mjs", "lint": "eslint .", - "test": "tsc -p tsconfig.test.json && node --test build-tests/tests/protocol.test.js build-tests/tests/source-attribution.test.js", + "test": "tsc -p tsconfig.test.json && node --test build-tests/tests/native.test.js build-tests/tests/protocol.test.js build-tests/tests/source-attribution.test.js", "check": "npm run lint && npm run test && npm run build" }, "devDependencies": { diff --git a/src/DownloadRouter.Extension/src/native.ts b/src/DownloadRouter.Extension/src/native.ts index a2414c0..f822288 100644 --- a/src/DownloadRouter.Extension/src/native.ts +++ b/src/DownloadRouter.Extension/src/native.ts @@ -9,15 +9,55 @@ export function sendNative(request: AgentRequest): Promise { if (chrome.runtime.lastError) { // Fail open: browser downloads must never depend on the local host. + reportNativeFailure(classifyNativeRuntimeError(chrome.runtime.lastError.message)); resolve(null); return; } - resolve(isAgentResponse(response) ? response : null); + if (!isAgentResponse(response)) { + reportNativeFailure("invalid-response"); + resolve(null); + return; + } + + if (!response.success) { + reportNativeFailure(safeAgentErrorCode(response.errorCode)); + } + + resolve(response); }); }); } +export function classifyNativeRuntimeError(message: string | undefined): string { + const normalized = message?.toLowerCase() ?? ""; + if (normalized.includes("not found")) { + return "host-not-found"; + } + + if (normalized.includes("not permitted") || normalized.includes("forbidden") || normalized.includes("access")) { + return "host-access-denied"; + } + + if (normalized.includes("exited") || normalized.includes("closed")) { + return "host-exited"; + } + + return "native-message-failed"; +} + +export function safeAgentErrorCode(errorCode: string | undefined): string { + if (errorCode && errorCode.length <= 80 && /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u.test(errorCode)) { + return errorCode; + } + + return "agent-request-failed"; +} + +function reportNativeFailure(code: string): void { + console.warn(`[Download Router] Native host communication failed (${code}); the browser download remains unchanged.`); +} + function isAgentResponse(value: unknown): value is AgentResponse { if (typeof value !== "object" || value === null) { return false; diff --git a/src/DownloadRouter.Extension/tests/native.test.ts b/src/DownloadRouter.Extension/tests/native.test.ts new file mode 100644 index 0000000..ba6e19c --- /dev/null +++ b/src/DownloadRouter.Extension/tests/native.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classifyNativeRuntimeError, safeAgentErrorCode } from "../src/native.js"; + +void test("native runtime failures are classified without retaining raw messages", () => { + assert.equal(classifyNativeRuntimeError("Specified native messaging host not found."), "host-not-found"); + assert.equal(classifyNativeRuntimeError("Access to the native messaging host is not permitted."), "host-access-denied"); + assert.equal(classifyNativeRuntimeError("Native host has exited."), "host-exited"); + assert.equal(classifyNativeRuntimeError("C:\\Users\\person\\private.exe?token=secret"), "native-message-failed"); +}); + +void test("only bounded protocol-style agent error codes are logged", () => { + assert.equal(safeAgentErrorCode("agent.unavailable"), "agent.unavailable"); + assert.equal(safeAgentErrorCode("C:\\Users\\person\\private.exe"), "agent-request-failed"); + assert.equal(safeAgentErrorCode("https://example.test/?token=secret"), "agent-request-failed"); +}); diff --git a/src/DownloadRouter.Infrastructure/Files/FileMoveService.cs b/src/DownloadRouter.Infrastructure/Files/FileMoveService.cs index 3f37879..69f2a63 100644 --- a/src/DownloadRouter.Infrastructure/Files/FileMoveService.cs +++ b/src/DownloadRouter.Infrastructure/Files/FileMoveService.cs @@ -77,9 +77,8 @@ public async Task MoveAsync( } logger.LogInformation( - "File move completed from {SourceFileName} to {DestinationDirectory}", - Path.GetFileName(sourceFull), - destinationDirectory); + "File move completed for {SourceFileName}", + Path.GetFileName(sourceFull)); return new FileMoveResult(true, destination, null, null, false); } finally diff --git a/src/DownloadRouter.Infrastructure/Logging/JsonLineFileLogger.cs b/src/DownloadRouter.Infrastructure/Logging/JsonLineFileLogger.cs index 1c4425c..665c18c 100644 --- a/src/DownloadRouter.Infrastructure/Logging/JsonLineFileLogger.cs +++ b/src/DownloadRouter.Infrastructure/Logging/JsonLineFileLogger.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using DownloadRouter.Core.Models; using Microsoft.Extensions.Logging; @@ -44,6 +45,13 @@ private void RotateIfNeeded() private sealed class JsonLineFileLogger(string category, Action writer) : ILogger { + private static readonly Regex HttpUrlPattern = new( + "https?://[^\\s\\\"']+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + private static readonly Regex WindowsPathPattern = new( + "(?(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; @@ -68,14 +76,15 @@ public void Log( category, eventId = eventId.Id, message, - exception = exception is null ? null : Redact(exception.GetType().Name + ": " + exception.Message), + exception = exception?.GetType().Name, }; writer(JsonSerializer.Serialize(payload, ProtocolJson.Options)); } private static string Redact(string value) { - var result = value; + var result = HttpUrlPattern.Replace(value, "[URL_REDACTED]"); + result = WindowsPathPattern.Replace(result, "[PATH_REDACTED]"); foreach (var marker in new[] { "Authorization:", "Bearer ", "token=", "sig=", "signature=" }) { var index = result.IndexOf(marker, StringComparison.OrdinalIgnoreCase); diff --git a/src/DownloadRouter.NativeHost/Program.cs b/src/DownloadRouter.NativeHost/Program.cs index 2d471a5..816242f 100644 --- a/src/DownloadRouter.NativeHost/Program.cs +++ b/src/DownloadRouter.NativeHost/Program.cs @@ -113,8 +113,8 @@ static bool TryStartAgent() Process.Start(new ProcessStartInfo { FileName = Path.GetFullPath(candidate), - UseShellExecute = false, - CreateNoWindow = true, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(candidate))!, }); return true; diff --git a/tests/DownloadRouter.Infrastructure.Tests/JsonLineFileLoggerTests.cs b/tests/DownloadRouter.Infrastructure.Tests/JsonLineFileLoggerTests.cs new file mode 100644 index 0000000..395eca0 --- /dev/null +++ b/tests/DownloadRouter.Infrastructure.Tests/JsonLineFileLoggerTests.cs @@ -0,0 +1,40 @@ +using DownloadRouter.Infrastructure.Logging; +using Microsoft.Extensions.Logging; + +namespace DownloadRouter.Infrastructure.Tests; + +public sealed class JsonLineFileLoggerTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), "download-router-logger-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public void RedactsUrlsPathsTokensAndExceptionMessages() + { + using var provider = new JsonLineFileLoggerProvider(root); + var logger = provider.CreateLogger("privacy-test"); + logger.LogError( + new IOException("private exception C:\\Users\\person\\Downloads\\secret.txt"), + "Native failure at {Url} for {Path} with token={Token}", + "https://example.test/file?token=secret", + "C:\\Users\\person\\Downloads\\secret.txt", + "secret"); + + var line = File.ReadAllText(Path.Combine(root, "download-router.jsonl")); + + Assert.DoesNotContain("example.test", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("C:\\Users", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("private exception", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("token=secret", line, StringComparison.OrdinalIgnoreCase); + Assert.Contains("[URL_REDACTED]", line, StringComparison.Ordinal); + Assert.Contains("[PATH_REDACTED]", line, StringComparison.Ordinal); + Assert.Contains("IOException", line, StringComparison.Ordinal); + } + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs index e58efc3..a02e1d7 100644 --- a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs +++ b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs @@ -55,9 +55,74 @@ public async Task UnmatchedDownloadFailsOpenAndCreatesNoJob() var response = await handler.HandleAsync(request, CancellationToken.None); Assert.True(response.Success); + Assert.False(response.Data!.Value.GetProperty("tracked").GetBoolean()); + Assert.True(response.Data.Value.GetProperty("failOpen").GetBoolean()); Assert.Empty(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None)); } + [Fact] + public async Task MatchedDownloadCreatesHistoryAndRoutesCompletedFile() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + var source = Path.Combine(incoming, "example.txt"); + await File.WriteAllTextAsync(source, "public test fixture", CancellationToken.None); + var now = DateTimeOffset.UtcNow; + var rule = new DownloadRule( + Guid.NewGuid(), + "example.com smoke rule", + true, + RuleMatchType.DomainAndSubdomains, + "example.com", + RuleMatchTarget.InitiatingPage, + destination, + StorageMode.Automatic, + 0, + 0, + now, + now); + await repository.UpsertRuleAsync(rule, CancellationToken.None); + + var started = new AgentCommand( + ProtocolConstants.CurrentVersion, + Guid.NewGuid(), + "download.started", + JsonSerializer.SerializeToElement( + new DownloadStartedPayload( + "Whale", + "example-download-1", + "example.txt", + null, + "https://example.com/downloads", + "https://example.com/example.txt", + null, + null), + ProtocolJson.Options)); + var startedResponse = await handler.HandleAsync(started, CancellationToken.None); + + Assert.True(startedResponse.Success, startedResponse.Message); + Assert.True(startedResponse.Data!.Value.GetProperty("tracked").GetBoolean()); + + var changed = new AgentCommand( + ProtocolConstants.CurrentVersion, + Guid.NewGuid(), + "download.changed", + JsonSerializer.SerializeToElement( + new DownloadChangedPayload("Whale", "example-download-1", "complete", source, null), + ProtocolJson.Options)); + var changedResponse = await handler.HandleAsync(changed, CancellationToken.None); + + Assert.True(changedResponse.Success, changedResponse.Message); + Assert.Equal("Completed", changedResponse.Data!.Value.GetProperty("status").GetString()); + var job = Assert.Single(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None)); + Assert.Equal(DownloadJobStatus.Completed, job.Status); + Assert.False(File.Exists(source)); + Assert.NotNull(job.FinalPath); + Assert.True(File.Exists(job.FinalPath)); + Assert.Equal("public test fixture", await File.ReadAllTextAsync(job.FinalPath!, CancellationToken.None)); + } + private async Task<(AgentCommandHandler Handler, DownloadRouterRepository Repository)> CreateHandlerAsync() { var paths = AppPaths.CreateDefault(); From 19fba5a4780df3fd7b86f708e3df783f0fc0edbc Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:21:38 +0900 Subject: [PATCH 03/24] fix: render WinUI content per-monitor responsively --- src/DownloadRouter.App/MainWindow.xaml | 38 +++++++++- src/DownloadRouter.App/MainWindow.xaml.cs | 85 +++++++++++++++++++---- src/DownloadRouter.App/app.manifest | 6 ++ 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/DownloadRouter.App/MainWindow.xaml b/src/DownloadRouter.App/MainWindow.xaml index e4bc863..95daaa8 100644 --- a/src/DownloadRouter.App/MainWindow.xaml +++ b/src/DownloadRouter.App/MainWindow.xaml @@ -2,9 +2,30 @@ x:Class="DownloadRouter.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - + + + + + + + + + + + + + + + + + + + + + - - + + + + diff --git a/src/DownloadRouter.App/MainWindow.xaml.cs b/src/DownloadRouter.App/MainWindow.xaml.cs index 33e4d00..0c5f19d 100644 --- a/src/DownloadRouter.App/MainWindow.xaml.cs +++ b/src/DownloadRouter.App/MainWindow.xaml.cs @@ -11,12 +11,18 @@ public sealed partial class MainWindow : Window private readonly AgentClient agent = new(); private readonly PathTokenResolver pathResolver = new(new WindowsKnownPathProvider()); private readonly PathBoundaryValidator boundaryValidator = new(); + private readonly DispatcherTimer historyRefreshTimer = new() { Interval = TimeSpan.FromSeconds(3) }; + private string? historySnapshot; + private bool historyRefreshInProgress; public MainWindow() { InitializeComponent(); Title = "eslee Download Router"; Navigation.SelectedItem = Navigation.MenuItems[0]; + historyRefreshTimer.Tick += HistoryRefreshTimer_Tick; + historyRefreshTimer.Start(); + Closed += (_, _) => historyRefreshTimer.Stop(); _ = ShowDashboardAsync(); } @@ -67,12 +73,12 @@ private async Task ShowDashboardAsync() private async Task ShowRulesAsync() { Prepare("사이트 규칙", "도메인, 정확한 호스트 또는 URL 일부를 기준으로 저장 위치를 지정합니다."); - var name = new TextBox { Header = "규칙 이름", PlaceholderText = "예: 네이버 다운로드" }; - var matchValue = new TextBox { Header = "대상 사이트 또는 URL 일부", PlaceholderText = "naver.com" }; - var storageRoot = new TextBox { Header = "저장 루트", Text = "{Downloads}\\eslee\\Routed" }; - var matchType = new ComboBox { Header = "매칭 방식", ItemsSource = Enum.GetValues(), SelectedIndex = 0 }; - var matchTarget = new ComboBox { Header = "매칭 대상", ItemsSource = Enum.GetValues(), SelectedItem = RuleMatchTarget.InitiatingPage }; - var storageMode = new ComboBox { Header = "저장 방식", ItemsSource = Enum.GetValues(), SelectedIndex = 0 }; + var name = new TextBox { Header = "규칙 이름", PlaceholderText = "예: 네이버 다운로드", HorizontalAlignment = HorizontalAlignment.Stretch }; + var matchValue = new TextBox { Header = "대상 사이트 또는 URL 일부", PlaceholderText = "naver.com", HorizontalAlignment = HorizontalAlignment.Stretch }; + var storageRoot = new TextBox { Header = "저장 루트", Text = "{Downloads}\\eslee\\Routed", HorizontalAlignment = HorizontalAlignment.Stretch }; + var matchType = new ComboBox { Header = "매칭 방식", ItemsSource = Enum.GetValues(), SelectedIndex = 0, HorizontalAlignment = HorizontalAlignment.Stretch }; + var matchTarget = new ComboBox { Header = "매칭 대상", ItemsSource = Enum.GetValues(), SelectedItem = RuleMatchTarget.InitiatingPage, HorizontalAlignment = HorizontalAlignment.Stretch }; + var storageMode = new ComboBox { Header = "저장 방식", ItemsSource = Enum.GetValues(), SelectedIndex = 0, HorizontalAlignment = HorizontalAlignment.Stretch }; var preview = new TextBlock { Text = "naver.com은 naver.com과 모든 하위 도메인에 일치합니다.", TextWrapping = TextWrapping.Wrap }; matchValue.TextChanged += (_, _) => preview.Text = string.IsNullOrWhiteSpace(matchValue.Text) ? "매칭 예시가 여기에 표시됩니다." @@ -138,27 +144,69 @@ private async Task ShowRulesAsync() private async Task ShowHistoryAsync() { - Prepare("다운로드 작업 및 이력", "최근 작업의 성공·실패·대기 상태와 정제된 출처를 표시합니다."); try { var response = await agent.SendAsync("jobs.list"); var jobs = AgentClient.ReadData>(response) ?? []; - foreach (var job in jobs) - { - AddCard(job.CurrentFileName, $"{job.Browser} · {job.Status} · {job.SanitizedSource ?? "출처 확인 불가"}\n{job.ErrorMessage ?? job.FinalPath ?? string.Empty}"); - } + RenderHistory(jobs); + } + catch (Exception exception) + { + Prepare("다운로드 작업 및 이력", "사이트 규칙에 매칭되어 추적된 작업의 성공·실패·대기 상태와 정제된 출처를 표시합니다."); + AddError("다운로드 이력을 불러오지 못했습니다.", exception); + } + } - if (jobs.Count == 0) + private async void HistoryRefreshTimer_Tick(object? sender, object args) + { + if (historyRefreshInProgress + || !string.Equals( + (Navigation.SelectedItem as NavigationViewItem)?.Tag as string, + "history", + StringComparison.Ordinal)) + { + return; + } + + historyRefreshInProgress = true; + try + { + var response = await agent.SendAsync("jobs.list"); + var jobs = AgentClient.ReadData>(response) ?? []; + if (!string.Equals(historySnapshot, CreateHistorySnapshot(jobs), StringComparison.Ordinal)) { - AddMuted("아직 추적된 다운로드가 없습니다. 규칙 없는 다운로드는 브라우저 기본 동작을 유지합니다."); + RenderHistory(jobs); } } catch (Exception exception) { - AddError("다운로드 이력을 불러오지 못했습니다.", exception); + System.Diagnostics.Debug.WriteLine($"History refresh failed: {exception.GetType().Name}"); + } + finally + { + historyRefreshInProgress = false; } } + private void RenderHistory(IReadOnlyList jobs) + { + Prepare("다운로드 작업 및 이력", "사이트 규칙에 매칭되어 추적된 작업의 성공·실패·대기 상태와 정제된 출처를 표시합니다."); + foreach (var job in jobs) + { + AddCard(job.CurrentFileName, $"{job.Browser} · {job.Status} · {job.SanitizedSource ?? "출처 확인 불가"}\n{job.ErrorMessage ?? job.FinalPath ?? string.Empty}"); + } + + if (jobs.Count == 0) + { + AddMuted("아직 규칙에 매칭되어 추적된 다운로드가 없습니다. 규칙 없는 다운로드는 이력에 추가하지 않고 브라우저 기본 동작을 유지합니다."); + } + + historySnapshot = CreateHistorySnapshot(jobs); + } + + private static string CreateHistorySnapshot(IEnumerable jobs) + => string.Join('|', jobs.Select(static job => $"{job.Id:N}:{job.Status}:{job.CompletedAt:O}:{job.ErrorCode}")); + private async Task ShowPendingAsync() { Prepare("저장 위치 선택 대기", "각 규칙의 루트와 실제 하위 폴더만 선택할 수 있습니다."); @@ -186,7 +234,7 @@ private async Task ShowPendingAsync() Header = $"{rule.Name}: {group.Count()}개 파일", ItemsSource = folders, SelectedIndex = folders.Count > 0 ? 0 : -1, - MinWidth = 420, + HorizontalAlignment = HorizontalAlignment.Stretch, }; var apply = new Button { Content = "대기열 전체에 적용" }; apply.Click += async (_, _) => @@ -282,6 +330,12 @@ private async Task ShowDiagnosticsAsync() { var response = await agent.SendAsync("diagnostics.status"); AddCard("현재 진단 상태", response.Data?.GetRawText() ?? "응답 없음"); + var rasterizationScale = ContentPanel.XamlRoot?.RasterizationScale; + AddCard( + "UI 렌더링 배율", + rasterizationScale is null + ? "현재 창의 배율을 확인할 수 없습니다." + : $"{rasterizationScale.Value * 100:0}% (XamlRoot RasterizationScale {rasterizationScale.Value:0.##})"); } catch (Exception exception) { @@ -341,6 +395,7 @@ private void AddCard(string title, string value) panel.Children.Add(new TextBlock { Text = value, TextWrapping = TextWrapping.Wrap }); ContentPanel.Children.Add(new Border { + HorizontalAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(16), CornerRadius = new CornerRadius(8), Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Microsoft.UI.Xaml.Media.Brush, diff --git a/src/DownloadRouter.App/app.manifest b/src/DownloadRouter.App/app.manifest index 81f13ab..6b75f7e 100644 --- a/src/DownloadRouter.App/app.manifest +++ b/src/DownloadRouter.App/app.manifest @@ -1,6 +1,12 @@ + + + true/pm + PerMonitorV2, PerMonitor + + From 2c8f84a81bba89ad045f3730bcd946991e04473b Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:22:01 +0900 Subject: [PATCH 04/24] docs: record Whale and DPI verification --- ARCHITECTURE.md | 6 +++-- CHANGELOG.md | 21 +++++++++++++++- DEVELOPMENT.md | 8 ++++++ PROJECT_STATE.md | 40 +++++++++++++++++------------- TESTING.md | 42 +++++++++++++++++++++++--------- docs/BROWSER_COMPATIBILITY.md | 22 +++++++++++++---- docs/MANUAL_EXTENSION_INSTALL.md | 4 +++ 7 files changed, 106 insertions(+), 37 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 35309d9..4c39d80 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,13 +21,13 @@ chrome.downloads.onCreated/onChanged ### Extension -Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고 `onChanged`에서 완료 또는 중단 상태를 전송합니다. 연결 오류는 다운로드를 취소하거나 변경하지 않습니다. +Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고 `onChanged`에서 완료 또는 중단 상태를 전송합니다. 연결 오류는 다운로드를 취소하거나 변경하지 않습니다. 실패할 때만 원문 대신 `host-not-found`, `host-exited`, `agent.unavailable` 같은 제한된 분류 코드를 service worker 콘솔에 기록합니다. Chromium downloads API에는 신뢰할 수 있는 시작 탭 URL 필드가 없습니다. 따라서 활성 탭을 다운로드 출처로 추측하지 않고 `initiatingPageUrl`은 근거가 있을 때만 사용합니다. 현재 확장은 referrer를 우선 근거로 전달합니다. ### Native Host -브라우저가 실행하는 짧은 수명의 브리지입니다. 고정 개발 확장 origin, 프로토콜 버전, 비어 있지 않은 request ID, 명령 allowlist, 최대 1 MiB 메시지를 검사합니다. 셸 문자열을 실행하지 않으며 장기 상태나 UI가 없습니다. Agent가 없으면 같은 설치 디렉터리의 정확한 `DownloadRouter.Agent.exe`만 시작합니다. +브라우저가 실행하는 짧은 수명의 브리지입니다. 고정 개발 확장 origin, 프로토콜 버전, 비어 있지 않은 request ID, 명령 allowlist, 최대 1 MiB 메시지를 검사합니다. 임의 셸 문자열을 실행하지 않으며 장기 상태나 UI가 없습니다. Agent가 없으면 같은 설치 디렉터리의 정확한 `DownloadRouter.Agent.exe`만 숨김 프로세스로 분리 실행해 Native Messaging stdin/stdout/stderr를 상속하지 않게 합니다. ### Agent @@ -58,6 +58,8 @@ SQLite 마이그레이션은 `schema_migrations`, `rules`, `download_jobs`, `job Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. 현재 대시보드, 규칙, 이력, 규칙별 선택 대기 그룹, 브라우저 연결, 일반 설정, 진단, 정보 화면 골격이 있습니다. +앱은 unpackaged WinUI 3 프로세스를 manifest에서 Per-Monitor V2로 선언합니다. 크기와 여백은 장치 독립 픽셀(DIP)을 사용하고 루트에서 layout rounding을 적용합니다. 모든 화면은 하나의 `NavigationView -> vertical ScrollViewer -> stretch viewport -> MaxWidth 1100 form` 구조를 공유합니다. 1200 DIP 미만에서는 16 DIP, 넓은 창에서는 32 DIP 좌우 패딩을 사용하며 가로 스크롤은 만들지 않습니다. 이력 화면은 현재 작업의 ID/상태 스냅샷이 바뀔 때만 다시 렌더링합니다. + ## 작업 상태 ```text diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b06362..700bb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,14 +15,33 @@ - WinUI 대시보드, 규칙, 이력, 선택 대기 그룹, 브라우저, 진단 화면 - 27개 .NET 테스트, 4개 TypeScript 테스트, Agent/Native Host 스모크 테스트 - per-user Native Host 등록 및 Inno Setup 설치 골격 +- Native Host 등록의 Windows PowerShell 5.1 회귀 테스트와 개인정보 로그 회귀 테스트 +- 매칭 규칙의 작업 생성·실제 파일 이동을 검증하는 Agent 통합 테스트 + +### Changed + +- WinUI 앱을 Per-Monitor V2로 선언하고 공통 콘텐츠를 세로 ScrollViewer, stretch viewport, 1100 DIP 반응형 폼으로 재구성 +- 다운로드 이력이 규칙에 매칭된 작업만 표시함을 명시하고 데이터 변경 시 3초 간격으로 자동 갱신 +- Whale 설치 탐지에 Program Files와 Program Files (x86) 후보를 추가 + +### Fixed + +- DPI awareness 누락으로 QHD 125%에서 앱 전체가 96 DPI 비트맵으로 확대되던 흐릿한 렌더링 +- NavigationView 콘텐츠 폭/중복 패딩과 고정 MinWidth 때문에 좁은 창에서 오른쪽이 잘리던 레이아웃 +- PowerShell 5.1에 없는 경로 API와 RegistryKey 직접 쓰기로 실패하던 HKCU Native Host 등록 +- PowerShell 5.1이 Native Host manifest에 UTF-8 BOM을 붙이던 문제 +- Extension이 Native Messaging 실패를 아무 진단 없이 무시하던 문제 +- Native Host가 자동 시작한 Agent에 브라우저 표준 스트림 핸들을 상속하던 문제 ### Security - 명령 allowlist, 1 MiB 메시지 제한, current-user IPC, URL 민감정보 정제 - native SQLite bundle을 advisory 경고가 없는 고정 버전으로 명시 +- Extension 오류 로그를 bounded error code로 제한하고 파일 로그에서 URL, 로컬 경로, 토큰, 예외 메시지를 제거 ### Known limitations -- 실제 브라우저 검증 전 +- Whale의 매칭 규칙 자동 이동/직접 선택은 실제 브라우저 검증 전 +- Windows 100%/150% 및 FHD/4K 실제 디스플레이 시각 검증 전 - 선택 대기 자동 알림/팝업, 새 폴더 생성/새로 고침 미구현 - 시작 시 실행 토글과 미완료 작업 자동 복구 미연결 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 29e981e..90d99d3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -30,6 +30,14 @@ bootstrap은 버전 검사, NuGet restore, `npm ci`, 로컬 데이터 디렉터 - 새 패키지는 중앙 버전 파일 또는 잠금 파일에 정확한 버전을 기록합니다. - 사용자명, 회사 경로, 브라우저 프로필, PC별 Native Host manifest를 커밋하지 않습니다. +## WinUI와 DPI + +- unpackaged App manifest의 `PerMonitorV2, PerMonitor` 선언을 유지합니다. 제거하면 Windows가 앱을 96 DPI 비트맵으로 확대할 수 있습니다. +- XAML 크기와 간격은 DIP 기준이며 물리 픽셀이나 모니터 해상도별 고정 폭을 사용하지 않습니다. +- 새 화면은 공통 `ContentPanel` 안에서 가로 stretch하고, 폼 전체는 1100 DIP `MaxWidth`를 넘지 않습니다. +- 좁은 창은 가로 스크롤 대신 세로 스크롤을 사용하고, 최소 폭을 지정할 때는 실제 720 DIP 창에서 우측 경계를 확인합니다. +- 시각 검증 시 `GetProcessDpiAwareness`, `GetWindowDpiAwarenessContext`, `GetDpiForWindow`와 진단 화면의 `XamlRoot.RasterizationScale`을 함께 확인합니다. + ## 반복 작업 ```powershell diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 2cc3ef3..465edbd 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -7,8 +7,8 @@ - 현재 단계: Phase 0 완료, Phase 1 기술 스파이크 완료, 핵심 기능 개발 중 - 공식 저장소: https://github.com/esleeeeee/eslee-Download-Router - 게시 브랜치: `main`, `develop`, `feature/initial-spike` — 세 브랜치 모두 공식 원격에 생성 완료 -- 구현 기준 커밋: `9975c47bd0ac64e26fa651dcd8162aac068f73d8` (`feat: bootstrap download router spike`) -- 원격 검증: GitHub 플러그인에서 공개 저장소, 세 브랜치, 위 커밋을 직접 확인 +- 작업 브랜치/기준 HEAD: `feature/initial-spike` / `3a4e25e86f570d03a37f61f4754b3d11bd501717` +- 현재 문서는 위 HEAD에 적용한 로컬 진단·수정 결과를 포함하며 원격 push는 하지 않음 ## 구현 완료 @@ -27,6 +27,9 @@ - 규칙별 선택 대기 작업 묶음 처리 API와 App 화면 - WinUI 3 필수 내비게이션 화면 골격과 Agent 진단 - 브라우저 설치/관리 주소 adapter와 HKCU Native Host 등록 스크립트 +- Windows PowerShell 5.1 절대 경로/HKCU 등록 호환성과 BOM 없는 UTF-8 Native Host manifest +- 실패할 때만 분류 코드를 남기는 Extension Native Messaging 진단 로그 +- Per-Monitor V2 WinUI 렌더링, 반응형 공통 콘텐츠/세로 스크롤, 3초 이력 변경 감지 - self-contained win-x64 publish와 per-user Inno Setup 골격 ## 빌드와 테스트 @@ -34,12 +37,15 @@ | 항목 | 결과 | |---|---| | `.NET Debug build` | 성공, 경고 0, 오류 0 | -| `.NET tests` | 27/27 통과(Core 21, Infrastructure 4, Integration 2) | +| `.NET tests` | 29/29 통과(Core 21, Infrastructure 5, Integration 3) | | Extension ESLint/TypeScript build | 성공 | -| Extension Node tests | 4/4 통과 | +| Extension Node tests | 6/6 통과 | | Agent Named Pipe ping | 성공 | -| Native Host self-test | 성공 | +| Native Host self-test/길이 접두사 ping/Agent 자동 시작 | 성공 | +| Native Host Agent 장애 fail-open | `agent.unavailable`, Host 종료 코드 0, 브라우저 비차단 응답 확인 | | Release win-x64 self-contained publish | App/Agent/Native Host 성공 | +| WinUI QHD 125% | 수정 전 DPI Unaware/96 → 수정 후 Per-Monitor V2/120 확인 | +| WinUI 반응형 폭 | 900px 창에서 우측 넘침 0, 2400px 창에서 1100 DIP 폼 중앙 정렬 확인 | | clean clone | 공식 `feature/initial-spike@9975c47`에서 bootstrap/build/test 성공 | | Installer compile/install/uninstall | 미검증 | @@ -47,24 +53,24 @@ | 브라우저 | 설치 탐지 | 확장 로드 | 다운로드 이벤트 | Native Messaging | 자동 저장 | 직접 선택 | |---|---|---|---|---|---|---| -| Whale | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -| Edge | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -| Chrome | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | +| Whale | 성공 | 성공 | 성공 | 성공 | 수동 검증 필요 | 수동 검증 필요 | +| Edge | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | +| Chrome | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Brave | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Vivaldi | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Opera | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -실제 브라우저에서 수행하지 않은 결과를 성공으로 표시하지 않았습니다. +Whale은 설치된 4.38.386.14, 고정 ID 확장과 `dist` 로드, HKCU 등록, 실제 미매칭 다운로드 이벤트의 Agent 도달을 확인했습니다. 규칙이 없는 다운로드는 의도적으로 작업을 만들지 않으므로 이력이 비는 것이 정상입니다. 실제 Whale에서 매칭 규칙 파일 이동과 직접 선택은 아직 성공으로 표시하지 않았습니다. ## 알려진 문제와 제한 - downloads API만으로 다운로드 시작 탭 URL을 신뢰성 있게 얻을 수 없어 활성 탭을 추측하지 않습니다. 현재 referrer와 파일 URL을 분리해 사용합니다. -- 직접 선택은 App의 대기 화면에서 규칙별 묶음으로 처리합니다. Agent의 자동 창 활성화/트레이 알림, 새 폴더 생성, 새로 고침은 미구현입니다. +- 직접 선택은 App의 대기 화면에서 규칙별 묶음으로 처리합니다. Agent의 자동 창 활성화/트레이 알림, 새 폴더 생성, 폴더 새로 고침은 미구현입니다. - 시작 시 실행 UI는 실제 Windows 등록과 연결되지 않았습니다. - Agent 시작 시 미완료 이동 복구 워커는 미구현입니다. DB에는 작업 상태가 유지됩니다. -- 브라우저별 Native Messaging registry adapter, 특히 Whale/Opera는 실제 PC 검증이 필요합니다. +- Whale registry adapter는 이 PC에서 검증했습니다. Brave/Vivaldi/Opera adapter는 실제 PC 검증이 필요합니다. - 브라우저 자체 “다운로드 전에 저장 위치 확인” 설정 감지와 안내는 문서만 있고 UI 자동 감지는 미구현입니다. -- 앱 UI의 실제 실행/시각·접근성 검증과 installer 동작 검증이 필요합니다. +- QHD 125% 실제 실행과 좁은/넓은 창 시각·경계 검증은 완료했습니다. Windows 100%/150%, FHD/4K 실기기, 키보드/스크린리더 접근성 및 installer 동작 검증은 남아 있습니다. ## 보류된 결정 @@ -75,11 +81,11 @@ ## 다음 작업 -1. Edge에서 개발자 모드 확장 로드와 Native Messaging 왕복을 실제 검증 -2. Edge 자동 저장과 규칙 미매칭 fail-open 실다운로드 검증 -3. 규칙별 전용 폴더 트리 picker에 새 폴더/새로 고침 추가 및 Agent 알림 연결 -4. Whale registry adapter와 다운로드/Native Messaging 검증 -5. Chrome 동일 시나리오 검증 +1. Whale에서 `example.com` 샘플 규칙으로 공개 파일 자동 이동과 이력 자동 갱신을 수동 검증 +2. Whale 직접 선택 규칙과 Native Host 장애 중 실다운로드 유지 검증 +3. Windows 100%/150% 및 FHD/4K에서 UI 실배율 시각 검증 +4. Edge에서 개발자 모드 확장 로드와 Native Messaging 왕복을 실제 검증 +5. 규칙별 전용 폴더 트리 picker에 새 폴더/새로 고침 추가 및 Agent 알림 연결 6. Agent 시작 시 미완료 작업 복구와 시작 시 실행 옵션 구현 7. Inno Setup 설치/제거와 앱 UI 검증 diff --git a/TESTING.md b/TESTING.md index 08ff7a4..ef160b9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3,23 +3,42 @@ ## 자동 검증 ```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\bootstrap.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build.ps1 -Configuration Release -PublishNativeHost +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\register-native-host.ps1 -Action Repair -Browser Whale +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\register-native-host.ps1 -Action Status -Browser Whale powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\diagnose.ps1 ``` 2026-07-21 현재 로컬 결과: - .NET Debug build: 성공, 경고 0, 오류 0 -- .NET tests: 27/27 통과 +- .NET tests: 29/29 통과 - Core 21: 규칙, IDN/도메인 경계, 경로, 개인정보, 프로토콜, 상태 전이 - - Infrastructure 4: SQLite migration/round-trip, 동일·교차 볼륨 흐름, 중복 이름 - - Integration 2: 명령 거부, 미매칭 fail-open -- Extension: ESLint 성공, TypeScript compile 성공, Node tests 4/4 통과, dist build 성공 + - Infrastructure 5: SQLite migration/round-trip, 동일·교차 볼륨 흐름, 중복 이름, 로그 개인정보 제거 + - Integration 3: 명령 거부, 미매칭 fail-open, 매칭 규칙의 이력 생성과 실제 파일 이동 +- Extension: ESLint 성공, TypeScript compile 성공, Node tests 6/6 통과, dist build 성공 +- Windows PowerShell 5.1 등록 테스트: drive/UNC 절대 경로, 상대 경로 거부, origin 필터, BOM 없는 UTF-8 통과 - Agent Named Pipe ping: 성공 -- Native Host `--self-test`: 성공 +- Native Host `--self-test`, 길이 접두사 ping, 게시 Agent 자동 시작: 성공 +- Agent 미가용: `agent.unavailable`, Native Host 종료 코드 0, Agent 미기동, stderr 민감정보 없음 - App/Agent/Native Host Release `win-x64 --self-contained true` publish: 성공 +## UI/DPI 검증 + +QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습니다. + +- 수정 전: process DPI awareness `Unaware`, window DPI 96 +- 수정 후: `PerMonitorAware`, `PER_MONITOR_AWARE_V2=true`, window DPI 120 +- 900x850 물리 픽셀 창: 사이트 규칙의 보이는 입력/드롭다운/버튼 11개가 모두 우측 창 경계 안, 세로 스크롤바 생성 +- 2400x1250 물리 픽셀 창: 폼 폭 1375px = 1100 DIP, 콘텐츠 좌우 여백 309/312px +- 대시보드, 이력, 선택 대기, 브라우저 연결, 설정, 진단, 정보는 같은 공통 콘텐츠 루트를 사용하므로 동일 폭/스크롤 수정이 적용됨 + +100%/150% Windows 실배율과 FHD/4K 물리 디스플레이는 현재 단일 QHD 125% 환경에서 직접 전환하지 않았습니다. manifest/DIP/MaxWidth 구조와 좁은·넓은 창 경계는 검증했지만 이 실기기 항목은 수동 확인으로 남깁니다. + ## 새 테스트 작성 원칙 - 순수 규칙과 경로 정책은 Core 단위 테스트로 작성합니다. @@ -36,7 +55,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 2. 해당 브라우저만 `scripts\register-native-host.ps1 -Browser `으로 등록 3. `src\DownloadRouter.Extension\dist`를 개발자 모드에서 로드 4. Agent와 App 실행 후 진단 화면의 ping 확인 -5. `naver.com`과 하위 도메인용 테스트 규칙 생성 +5. `example.com`과 하위 도메인용 테스트 규칙을 만들고 전용 테스트 목적 폴더 지정 6. 규칙 없는 사이트 다운로드가 원래 위치에 남는지 확인 7. 자동 규칙으로 일반 HTTP/HTTPS 파일 이동 확인 8. 동일 이름 3개가 번호를 붙여 모두 보존되는지 확인 @@ -46,14 +65,13 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 12. Native Host 등록을 제거하거나 Agent를 종료한 상태에서도 다운로드가 계속되는지 확인 13. 브라우저의 “다운로드 전 저장 위치 확인” 설정과 충돌 안내 확인 -## 아직 수행하지 않은 검증 +## 브라우저 검증 상태 -- 실제 Whale, Edge, Chrome, Brave, Vivaldi, Opera 확장 로드 -- 실제 Native Messaging 왕복 -- 실제 브라우저 다운로드 자동 이동/직접 선택 -- 앱 화면 시각 QA와 접근성 +- Whale 4.38.386.14: 확장 `dist` 로드와 고정 ID, 실제 다운로드 이벤트, Native Messaging 왕복, 미매칭 fail-open 성공 +- Whale 매칭 규칙 자동 이동/직접 선택: 미검증 +- Edge/Chrome/Brave/Vivaldi/Opera 확장과 Native Messaging: 미검증 +- UI: QHD 125% 시각·경계 검증 완료, 100%/150%와 접근성 미검증 - 네트워크 드라이브 및 실제 다른 물리 드라이브 -- clean clone 전체 bootstrap/build/test(초기 push 후 수행 예정) - Inno Setup 설치/제거 및 코드 서명 미수행 항목을 성공으로 기록하지 않습니다. diff --git a/docs/BROWSER_COMPATIBILITY.md b/docs/BROWSER_COMPATIBILITY.md index cbdbed8..7268c4f 100644 --- a/docs/BROWSER_COMPATIBILITY.md +++ b/docs/BROWSER_COMPATIBILITY.md @@ -2,13 +2,13 @@ 마지막 갱신: 2026-07-21 -자동화된 Agent/Native Host 스모크 테스트와 실제 브라우저 수동 테스트를 구분합니다. 현재 실제 브라우저 검증은 전부 미수행입니다. +자동화된 Agent/Native Host 스모크 테스트와 실제 브라우저 수동 테스트를 구분합니다. Whale은 실제 미매칭 다운로드 경로까지 부분 성공이며 자동 이동과 직접 선택은 아직 미검증입니다. | 브라우저 | 지원 수준 | 설치 탐지 | 확장 로드 | 다운로드 이벤트 | Native Messaging | 자동 저장 | 직접 선택 | 상태 | |---|---|---|---|---|---|---|---|---| -| Naver Whale | 정식 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 수동 검증 필요 | -| Microsoft Edge | 정식 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 최우선 검증 대상 | -| Google Chrome | 정식 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 수동 검증 필요 | +| Naver Whale | 정식 목표 | 성공 | 성공 | 성공 | 성공 | 미검증 | 미검증 | 부분 성공 | +| Microsoft Edge | 정식 목표 | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 확장 수동 검증 필요 | +| Google Chrome | 정식 목표 | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 확장 수동 검증 필요 | | Brave | 호환 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | adapter 후보 | | Vivaldi | 호환 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | adapter 후보 | | Opera | 호환 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | registry 확인 필요 | @@ -48,9 +48,21 @@ Native Messaging: 증거/로그 위치(민감정보 제외): ``` +## 2026-07-21 — Whale 4.38.386.14 부분 검증 + +- Windows: Windows 11, 10.0.26200.0 +- 설치 탐지: 성공. Program Files 설치 경로 후보를 진단 스크립트에 반영 +- 확장 로드: 성공. 고정 ID `gilicenlclaemgiijcjjejilikbooggj`, Profile 1의 unpacked 경로가 현재 `dist`와 일치 +- manifest: HKCU Whale 등록, 이름/Host 경로/origin 일치, JSON 유효, UTF-8 BOM 없음 +- 다운로드 이벤트/Native Messaging: 성공. 실제 미매칭 다운로드 이벤트가 Agent 로그에 도달했고 Agent 오류 0건 +- 미매칭 fail-open: 성공. 사용자 다운로드는 완료되고 작업 DB에는 규칙 미매칭 항목을 만들지 않음 +- 자동 저장/직접 선택: 실제 Whale에서는 미검증. 격리된 통합 테스트에서는 샘플 규칙의 작업 생성과 파일 이동 성공 +- 종합 상태: 부분 성공 + ## 2026-07-21 — 로컬 프로토콜 검증 - 실제 브라우저: 사용하지 않음 - Native Host self-test: 성공 - Agent current-user Named Pipe ping: 성공 -- 해석: 프로세스와 IPC 골격은 작동하지만 어느 브라우저의 호환성도 증명하지 않음 +- Agent 장애 fail-open: `agent.unavailable`, Host 종료 코드 0, 브라우저 비차단 응답 확인 +- 해석: 로컬 프로토콜과 Whale 미매칭 경로는 확인했지만 다른 브라우저 호환성과 Whale 자동 이동은 증명하지 않음 diff --git a/docs/MANUAL_EXTENSION_INSTALL.md b/docs/MANUAL_EXTENSION_INSTALL.md index 92c1295..8991004 100644 --- a/docs/MANUAL_EXTENSION_INSTALL.md +++ b/docs/MANUAL_EXTENSION_INSTALL.md @@ -22,6 +22,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\register-native-ho ``` 등록은 HKCU와 `%LOCALAPPDATA%\eslee\DownloadRouter\native-host`만 사용하며 관리자 권한을 요구하지 않습니다. +manifest는 Windows PowerShell 5.1에서도 UTF-8 BOM 없이 기록됩니다. `scripts\diagnose.ps1`의 `valid-json=yes`, `utf8-bom=False`, `host-exists=True`, `development-origin=True`를 함께 확인하세요. ## 3. 확장 로드 @@ -40,6 +41,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 -SkipB 앱의 “진단 및 문제 해결”에서 Agent 연결을 테스트합니다. 이 성공은 앱과 Agent 사이만 확인합니다. 브라우저 Native Messaging 성공은 확장 service worker 오류와 Agent 작업 이력을 함께 확인해야 합니다. +“다운로드 이력”은 모든 브라우저 다운로드가 아니라 사이트 규칙에 매칭되어 추적된 작업만 표시합니다. 규칙이 없는 다운로드가 정상 완료되고 이력이 비어 있는 것은 fail-open 정책상 정상입니다. 이력 화면을 열어 둔 동안 추적 작업이 바뀌면 약 3초 안에 자동 갱신됩니다. + ## 5. 테스트 규칙 샘플은 `samples\rule.naver.example.json`에 있습니다. 앱에서 동일한 값으로 규칙을 만들고 개인 PC에 맞는 저장 루트를 지정합니다. 실제 개인 파일 대신 공개 테스트 파일을 사용하세요. @@ -60,5 +63,6 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\unregister-native- - 확장 ID와 `allowed_origins` 일치 확인 - 기업 정책의 Native Messaging/개발자 모드 차단 확인 - 브라우저를 완전히 종료 후 다시 시작 +- 확장 관리 화면의 service worker 검사에서 `[Download Router] Native host communication failed ()` 확인. 로그에는 원문 URL, 토큰, 전체 로컬 경로가 포함되지 않습니다. URL query, 인증 토큰, 실제 사용자 경로가 포함된 로그를 issue나 문서에 붙이지 마세요. From dea36fef49746bb7beda0b3b9a69327e9858b6b4 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:17:45 +0900 Subject: [PATCH 05/24] fix: track browser cancellation and routing states --- .gitignore | 1 + .../AgentCommandHandler.cs | 225 ++++++++++++++---- src/DownloadRouter.Agent/Program.cs | 1 + .../SelectionUiLauncher.cs | 95 ++++++++ .../Jobs/DownloadJobQueries.cs | 20 ++ .../Jobs/DownloadJobStateMachine.cs | 57 +++-- .../Jobs/SelectionPromptQueue.cs | 48 ++++ .../Models/DomainModels.cs | 75 +++++- src/DownloadRouter.Extension/package.json | 2 +- .../src/background.ts | 59 ++++- .../src/download-state.ts | 12 + src/DownloadRouter.Extension/src/native.ts | 6 +- src/DownloadRouter.Extension/src/protocol.ts | 3 +- .../tests/download-state.test.ts | 12 + .../Storage/DownloadRouterRepository.cs | 177 +++++++++++++- .../ProtocolAndStateTests.cs | 45 +++- .../RepositoryTests.cs | 58 +++++ .../CommandValidationTests.cs | 193 +++++++++++++++ 18 files changed, 995 insertions(+), 94 deletions(-) create mode 100644 src/DownloadRouter.Agent/SelectionUiLauncher.cs create mode 100644 src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs create mode 100644 src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs create mode 100644 src/DownloadRouter.Extension/src/download-state.ts create mode 100644 src/DownloadRouter.Extension/tests/download-state.test.ts diff --git a/.gitignore b/.gitignore index 586cd88..2e536d9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ TestResults/ # Node node_modules/ +build-tests/ *.tsbuildinfo # Local application data diff --git a/src/DownloadRouter.Agent/AgentCommandHandler.cs b/src/DownloadRouter.Agent/AgentCommandHandler.cs index 11b1483..4a0970e 100644 --- a/src/DownloadRouter.Agent/AgentCommandHandler.cs +++ b/src/DownloadRouter.Agent/AgentCommandHandler.cs @@ -19,6 +19,7 @@ public sealed class AgentCommandHandler( PathBoundaryValidator boundaryValidator, DownloadJobStateMachine stateMachine, FileMoveService fileMoveService, + ISelectionUiLauncher selectionUiLauncher, AppPaths paths, ILogger logger) { @@ -54,7 +55,10 @@ public async Task HandleAsync(AgentCommand request, CancellationT "rules.list" => AgentResponse.Ok(request.RequestId, await repository.GetRulesAsync(cancellationToken).ConfigureAwait(false)), "rules.upsert" => await HandleRuleUpsertAsync(request, cancellationToken).ConfigureAwait(false), "jobs.list" => AgentResponse.Ok(request.RequestId, await repository.GetRecentJobsAsync(cancellationToken: cancellationToken).ConfigureAwait(false)), + "jobs.delete" => await HandleJobsDeleteAsync(request, cancellationToken).ConfigureAwait(false), + "downloads.active" => await HandleActiveDownloadsAsync(request, cancellationToken).ConfigureAwait(false), "selection.complete" => await HandleSelectionCompletedAsync(request, cancellationToken).ConfigureAwait(false), + "selection.skip" => await HandleSelectionSkippedAsync(request, cancellationToken).ConfigureAwait(false), "job.retry" => await HandleRetryAsync(request, cancellationToken).ConfigureAwait(false), "diagnostics.status" => AgentResponse.Ok(request.RequestId, new { @@ -127,9 +131,9 @@ private async Task HandleDownloadStartedAsync( return AgentResponse.Ok(request.RequestId, new { tracked = false, failOpen = true }); } - var status = matched.Rule.StorageMode == StorageMode.SelectSubfolder - ? DownloadJobStatus.WaitingForSelection - : DownloadJobStatus.WaitingForDownload; + var routingState = matched.Rule.StorageMode == StorageMode.SelectSubfolder + ? RoutingState.WaitingForSelection + : RoutingState.NotRequired; var job = new DownloadJob( Guid.NewGuid(), browser, @@ -145,13 +149,16 @@ private async Task HandleDownloadStartedAsync( NormalizeOptionalSourcePath(payload.FilePath), null, null, - status, + BrowserTransferState.InProgress, + routingState, null, null, DateTimeOffset.UtcNow, null); await repository.CreateJobAsync(job, cancellationToken).ConfigureAwait(false); + var selectionUiRequested = matched.Rule.StorageMode == StorageMode.SelectSubfolder + && selectionUiLauncher.RequestSelectionUi(); logger.LogInformation( "Rule {RuleId} matched download {DownloadId} using {SourceField}", matched.Rule.Id, @@ -163,6 +170,7 @@ private async Task HandleDownloadStartedAsync( jobId = job.Id, storageMode = matched.Rule.StorageMode.ToString(), requiresSelection = matched.Rule.StorageMode == StorageMode.SelectSubfolder, + selectionUiRequested, }); } @@ -187,59 +195,74 @@ private async Task HandleDownloadChangedAsync( { var cancelled = string.Equals(payload.Error, "USER_CANCELED", StringComparison.OrdinalIgnoreCase) || state == "cancelled"; - var next = cancelled ? DownloadJobStatus.Cancelled : DownloadJobStatus.Interrupted; - stateMachine.EnsureCanTransition(job.Status, next); + var next = cancelled ? BrowserTransferState.Cancelled : BrowserTransferState.Interrupted; + if (job.BrowserState != BrowserTransferState.InProgress) + { + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + + stateMachine.EnsureCanTransition(job.BrowserState, next); + var routing = cancelled ? RoutingState.NotRequired : RoutingState.Failed; + stateMachine.EnsureCanTransition(job.RoutingState, routing); job = job with { - Status = next, - ErrorCode = cancelled ? "download.cancelled" : "download.interrupted", - ErrorMessage = payload.Error, + BrowserState = next, + RoutingState = routing, + ErrorCode = cancelled ? "download.cancelled" : "download.interrupted." + NormalizeBrowserError(payload.Error), + ErrorMessage = cancelled + ? "The user cancelled this download in the browser." + : "The browser interrupted this download before completion.", + CompletedAt = DateTimeOffset.UtcNow, }; - await repository.UpdateJobAsync(job, "download.interrupted", cancellationToken).ConfigureAwait(false); - return AgentResponse.Ok(request.RequestId, new { tracked = true, status = next.ToString() }); + await repository.UpdateJobAsync(job, cancelled ? "download.cancelled" : "download.interrupted", cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, JobState(job)); } if (state != "complete") { - return AgentResponse.Ok(request.RequestId, new { tracked = true, status = job.Status.ToString() }); + return AgentResponse.Ok(request.RequestId, JobState(job)); } + if (job.BrowserState != BrowserTransferState.InProgress) + { + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + + stateMachine.EnsureCanTransition(job.BrowserState, BrowserTransferState.Complete); var sourcePath = NormalizeOptionalSourcePath(payload.FilePath) ?? throw new ArgumentException("A completed download must include its final local path."); job = job with { OriginalPath = sourcePath, CurrentFileName = Path.GetFileName(sourcePath), + BrowserState = BrowserTransferState.Complete, ErrorCode = null, ErrorMessage = null, }; var rule = await repository.GetRuleAsync(job.RuleId, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The matched rule no longer exists."); - if (rule.StorageMode == StorageMode.SelectSubfolder && job.SelectedRelativeFolder is null) + if (job.RoutingState == RoutingState.WaitingForSelection) { - if (job.Status != DownloadJobStatus.WaitingForSelection) + await repository.UpdateJobAsync(job, "download.completed-selection-pending", cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, new { - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.WaitingForSelection); - } - - job = job with { Status = DownloadJobStatus.WaitingForSelection }; - await repository.UpdateJobAsync(job, "selection.waiting", cancellationToken).ConfigureAwait(false); - return AgentResponse.Ok(request.RequestId, new { tracked = true, status = job.Status.ToString(), requiresSelection = true }); + tracked = true, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + requiresSelection = true, + }); } - if (job.Status != DownloadJobStatus.ReadyToMove) - { - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.ReadyToMove); - } - - job = job with { Status = DownloadJobStatus.ReadyToMove }; await repository.UpdateJobAsync(job, "download.completed", cancellationToken).ConfigureAwait(false); var moved = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); return AgentResponse.Ok(request.RequestId, new { tracked = true, status = moved.Status.ToString(), + browserState = moved.BrowserState.ToString(), + routingState = moved.RoutingState.ToString(), destinationPath = moved.FinalPath, errorCode = moved.ErrorCode, }); @@ -269,7 +292,7 @@ private async Task HandleSelectionCompletedAsync( foreach (var jobId in payload.JobIds.Distinct()) { var job = await repository.GetJobAsync(jobId, cancellationToken).ConfigureAwait(false); - if (job is null || job.Status is DownloadJobStatus.Completed or DownloadJobStatus.Cancelled) + if (job is null || !job.IsSelectionPending) { continue; } @@ -279,30 +302,90 @@ private async Task HandleSelectionCompletedAsync( var root = pathTokenResolver.Resolve(rule.StorageRoot); _ = boundaryValidator.ValidateRelativeFolder(root, payload.RelativeFolder); - var next = job.OriginalPath is null - ? DownloadJobStatus.WaitingForDownload - : DownloadJobStatus.ReadyToMove; - stateMachine.EnsureCanTransition(job.Status, next); + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.SelectionReady); job = job with { SelectedRelativeFolder = payload.RelativeFolder, - Status = next, + RoutingState = RoutingState.SelectionReady, ErrorCode = null, ErrorMessage = null, }; await repository.UpdateJobAsync(job, "selection.completed", cancellationToken).ConfigureAwait(false); - if (job.OriginalPath is not null) + if (job.BrowserState == BrowserTransferState.Complete && job.OriginalPath is not null) { job = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); } - results.Add(new { jobId = job.Id, status = job.Status.ToString(), destinationPath = job.FinalPath }); + results.Add(new + { + jobId = job.Id, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + destinationPath = job.FinalPath, + }); } return AgentResponse.Ok(request.RequestId, results); } + private async Task HandleSelectionSkippedAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + var job = await repository.GetJobAsync(payload.JobId, cancellationToken).ConfigureAwait(false); + if (job is null) + { + return AgentResponse.Error(request.RequestId, "job.not-found", "The download job was not found."); + } + + if (!job.IsSelectionPending) + { + return AgentResponse.Error(request.RequestId, "selection.not-pending", "This download is no longer waiting for a folder selection."); + } + + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.Skipped); + job = job with + { + RoutingState = RoutingState.Skipped, + ErrorCode = null, + ErrorMessage = null, + CompletedAt = DateTimeOffset.UtcNow, + }; + await repository.UpdateJobAsync(job, "selection.skipped", cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + + private async Task HandleJobsDeleteAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + if (payload.JobIds.Count is < 1 or > 1000) + { + return AgentResponse.Error(request.RequestId, "jobs.invalid-count", "Delete between 1 and 1000 history items."); + } + + var deleted = await repository.DeleteJobsAsync(payload.JobIds, cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, new { deleted, filesDeleted = 0 }); + } + + private async Task HandleActiveDownloadsAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + if (!TryParseBrowser(payload.Browser, out var browser)) + { + return AgentResponse.Error(request.RequestId, "download.unknown-browser", "The browser is not supported."); + } + + var downloads = await repository.GetActiveBrowserDownloadsAsync(browser, cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, downloads); + } + private async Task HandleRetryAsync(AgentCommand request, CancellationToken cancellationToken) { var payload = request.Payload.Deserialize(ProtocolJson.Options) @@ -318,12 +401,14 @@ private async Task HandleRetryAsync(AgentCommand request, Cancell return AgentResponse.Error(request.RequestId, "file.source-missing", "The original download path is unknown."); } - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.RetryPending); - job = job with { Status = DownloadJobStatus.RetryPending, ErrorCode = null, ErrorMessage = null }; + if (job.BrowserState != BrowserTransferState.Complete) + { + return AgentResponse.Error(request.RequestId, "download.not-complete", "Only completed browser downloads can be retried."); + } + + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.RetryPending); + job = job with { RoutingState = RoutingState.RetryPending, ErrorCode = null, ErrorMessage = null }; await repository.UpdateJobAsync(job, "job.retry-requested", cancellationToken).ConfigureAwait(false); - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.ReadyToMove); - job = job with { Status = DownloadJobStatus.ReadyToMove }; - await repository.UpdateJobAsync(job, "job.retry-ready", cancellationToken).ConfigureAwait(false); var rule = await repository.GetRuleAsync(job.RuleId, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The matched rule no longer exists."); job = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); @@ -340,8 +425,13 @@ private async Task MoveJobAsync( throw new InvalidOperationException("Cannot move a job without a source path."); } - stateMachine.EnsureCanTransition(job.Status, DownloadJobStatus.Moving); - job = job with { Status = DownloadJobStatus.Moving }; + if (job.BrowserState != BrowserTransferState.Complete) + { + throw new InvalidOperationException("Cannot move a download before the browser reports completion."); + } + + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.Moving); + job = job with { RoutingState = RoutingState.Moving }; await repository.UpdateJobAsync(job, "file.move-started", cancellationToken).ConfigureAwait(false); var root = pathTokenResolver.Resolve(rule.StorageRoot); @@ -352,12 +442,12 @@ private async Task MoveJobAsync( browserReportedComplete: true, cancellationToken).ConfigureAwait(false); var next = result.Success - ? DownloadJobStatus.Completed - : result.CanRetry ? DownloadJobStatus.RetryPending : DownloadJobStatus.Failed; - stateMachine.EnsureCanTransition(job.Status, next); + ? RoutingState.Completed + : result.CanRetry ? RoutingState.RetryPending : RoutingState.Failed; + stateMachine.EnsureCanTransition(job.RoutingState, next); job = job with { - Status = next, + RoutingState = next, FinalPath = result.DestinationPath, ErrorCode = result.ErrorCode, ErrorMessage = result.ErrorMessage, @@ -389,5 +479,50 @@ private static bool TryParseBrowser(string value, out BrowserKind browser) return Path.GetFullPath(path); } + private static string NormalizeBrowserError(string? value) + { + var known = value?.Trim().ToUpperInvariant(); + return known switch + { + "FILE_FAILED" => "file-failed", + "FILE_ACCESS_DENIED" => "file-access-denied", + "FILE_NO_SPACE" => "file-no-space", + "FILE_NAME_TOO_LONG" => "file-name-too-long", + "FILE_TOO_LARGE" => "file-too-large", + "FILE_VIRUS_INFECTED" => "file-virus-infected", + "FILE_TRANSIENT_ERROR" => "file-transient-error", + "FILE_BLOCKED" => "file-blocked", + "FILE_SECURITY_CHECK_FAILED" => "file-security-check-failed", + "FILE_TOO_SHORT" => "file-too-short", + "FILE_HASH_MISMATCH" => "file-hash-mismatch", + "NETWORK_FAILED" => "network-failed", + "NETWORK_TIMEOUT" => "network-timeout", + "NETWORK_DISCONNECTED" => "network-disconnected", + "NETWORK_SERVER_DOWN" => "network-server-down", + "NETWORK_INVALID_REQUEST" => "network-invalid-request", + "SERVER_FAILED" => "server-failed", + "SERVER_NO_RANGE" => "server-no-range", + "SERVER_BAD_CONTENT" => "server-bad-content", + "SERVER_UNAUTHORIZED" => "server-unauthorized", + "SERVER_CERT_PROBLEM" => "server-cert-problem", + "SERVER_FORBIDDEN" => "server-forbidden", + "SERVER_UNREACHABLE" => "server-unreachable", + "SERVER_CONTENT_LENGTH_MISMATCH" => "server-content-length-mismatch", + "SERVER_CROSS_ORIGIN_REDIRECT" => "server-cross-origin-redirect", + "USER_SHUTDOWN" => "user-shutdown", + "CRASH" => "crash", + _ => "unknown", + }; + } + + private static object JobState(DownloadJob job) + => new + { + tracked = true, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + }; + private sealed record JobRetryPayload(Guid JobId); } diff --git a/src/DownloadRouter.Agent/Program.cs b/src/DownloadRouter.Agent/Program.cs index 82dc74c..f448aab 100644 --- a/src/DownloadRouter.Agent/Program.cs +++ b/src/DownloadRouter.Agent/Program.cs @@ -38,6 +38,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/DownloadRouter.Agent/SelectionUiLauncher.cs b/src/DownloadRouter.Agent/SelectionUiLauncher.cs new file mode 100644 index 0000000..71c0910 --- /dev/null +++ b/src/DownloadRouter.Agent/SelectionUiLauncher.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; +using DownloadRouter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace DownloadRouter.Agent; + +public interface ISelectionUiLauncher +{ + bool RequestSelectionUi(); +} + +public sealed class SelectionUiLauncher(ILogger logger) : ISelectionUiLauncher +{ + private readonly object gate = new(); + private string? cachedAppPath; + + public bool RequestSelectionUi() + { + lock (gate) + { + try + { + if (Mutex.TryOpenExisting(ProtocolConstants.AppMutexName, out var existing)) + { + existing.Dispose(); + return true; + } + + var appPath = cachedAppPath ??= FindAppPath(); + if (appPath is null) + { + logger.LogInformation("Selection UI executable was not found; the download remains pending and unchanged"); + return false; + } + + Process.Start(new ProcessStartInfo + { + FileName = appPath, + UseShellExecute = true, + WorkingDirectory = Path.GetDirectoryName(appPath)!, + }); + return true; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Selection UI could not be opened; the browser download remains unchanged"); + return false; + } + } + } + + private static string? FindAppPath() + { + var overridePath = Environment.GetEnvironmentVariable("DOWNLOAD_ROUTER_APP_PATH"); + if (!string.IsNullOrWhiteSpace(overridePath) + && Path.IsPathFullyQualified(overridePath) + && File.Exists(overridePath)) + { + return Path.GetFullPath(overridePath); + } + + var installed = Path.Combine(AppContext.BaseDirectory, "DownloadRouter.App.exe"); + if (File.Exists(installed)) + { + return installed; + } + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DownloadRouter.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + return null; + } + + var configuration = AppContext.BaseDirectory.Contains( + $"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase) + ? "Release" + : "Debug"; + var development = Path.Combine( + directory.FullName, + "src", + "DownloadRouter.App", + "bin", + configuration, + "net10.0-windows10.0.22621.0", + "win-x64", + "DownloadRouter.App.exe"); + return File.Exists(development) ? development : null; + } +} diff --git a/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs b/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs new file mode 100644 index 0000000..3b97e51 --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs @@ -0,0 +1,20 @@ +using DownloadRouter.Core.Models; + +namespace DownloadRouter.Core.Jobs; + +public static class DownloadJobQueries +{ + public static IReadOnlyList ActiveSelections(IEnumerable jobs) + => jobs.Where(static job => job.IsSelectionPending).ToList(); + + public static DashboardCounts CountDashboard(IEnumerable jobs) + { + var snapshot = jobs.ToList(); + return new DashboardCounts( + snapshot.Count(static job => job.BrowserState == BrowserTransferState.InProgress), + snapshot.Count(static job => job.IsSelectionPending), + snapshot.Count(static job => job.RoutingState == RoutingState.Completed), + snapshot.Count(static job => job.BrowserState is BrowserTransferState.Cancelled or BrowserTransferState.Interrupted), + snapshot.Count(static job => job.RoutingState is RoutingState.RetryPending or RoutingState.Failed)); + } +} diff --git a/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs b/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs index 07456ad..5d16e01 100644 --- a/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs +++ b/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs @@ -4,32 +4,53 @@ namespace DownloadRouter.Core.Jobs; public sealed class DownloadJobStateMachine { - private static readonly IReadOnlyDictionary> AllowedTransitions - = new Dictionary> + private static readonly IReadOnlyDictionary> AllowedBrowserTransitions + = new Dictionary> { - [DownloadJobStatus.Detected] = Set(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.WaitingForSelection, DownloadJobStatus.Cancelled, DownloadJobStatus.Failed), - [DownloadJobStatus.WaitingForDownload] = Set(DownloadJobStatus.ReadyToMove, DownloadJobStatus.WaitingForSelection, DownloadJobStatus.Cancelled, DownloadJobStatus.Interrupted, DownloadJobStatus.Failed), - [DownloadJobStatus.WaitingForSelection] = Set(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.ReadyToMove, DownloadJobStatus.Cancelled, DownloadJobStatus.Interrupted, DownloadJobStatus.Failed), - [DownloadJobStatus.ReadyToMove] = Set(DownloadJobStatus.Moving, DownloadJobStatus.Cancelled, DownloadJobStatus.Failed), - [DownloadJobStatus.Moving] = Set(DownloadJobStatus.Completed, DownloadJobStatus.RetryPending, DownloadJobStatus.Failed), - [DownloadJobStatus.RetryPending] = Set(DownloadJobStatus.ReadyToMove, DownloadJobStatus.Moving, DownloadJobStatus.Failed), - [DownloadJobStatus.Interrupted] = Set(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.WaitingForSelection, DownloadJobStatus.ReadyToMove, DownloadJobStatus.Failed), - [DownloadJobStatus.Failed] = Set(DownloadJobStatus.RetryPending), - [DownloadJobStatus.Completed] = Set(), - [DownloadJobStatus.Cancelled] = Set(), + [BrowserTransferState.InProgress] = BrowserSet(BrowserTransferState.Complete, BrowserTransferState.Cancelled, BrowserTransferState.Interrupted), + [BrowserTransferState.Complete] = BrowserSet(), + [BrowserTransferState.Cancelled] = BrowserSet(), + [BrowserTransferState.Interrupted] = BrowserSet(), }; - public bool CanTransition(DownloadJobStatus current, DownloadJobStatus next) - => current == next || (AllowedTransitions.TryGetValue(current, out var allowed) && allowed.Contains(next)); + private static readonly IReadOnlyDictionary> AllowedRoutingTransitions + = new Dictionary> + { + [RoutingState.WaitingForSelection] = RoutingSet(RoutingState.SelectionReady, RoutingState.Skipped, RoutingState.Failed, RoutingState.NotRequired), + [RoutingState.SelectionReady] = RoutingSet(RoutingState.Moving, RoutingState.Skipped, RoutingState.Failed, RoutingState.NotRequired), + [RoutingState.NotRequired] = RoutingSet(RoutingState.Moving, RoutingState.Failed, RoutingState.Skipped), + [RoutingState.Moving] = RoutingSet(RoutingState.Completed, RoutingState.RetryPending, RoutingState.Failed), + [RoutingState.RetryPending] = RoutingSet(RoutingState.Moving, RoutingState.Failed, RoutingState.Skipped), + [RoutingState.Failed] = RoutingSet(RoutingState.RetryPending), + [RoutingState.Completed] = RoutingSet(), + [RoutingState.Skipped] = RoutingSet(), + }; + + public bool CanTransition(BrowserTransferState current, BrowserTransferState next) + => current == next || (AllowedBrowserTransitions.TryGetValue(current, out var allowed) && allowed.Contains(next)); - public void EnsureCanTransition(DownloadJobStatus current, DownloadJobStatus next) + public bool CanTransition(RoutingState current, RoutingState next) + => current == next || (AllowedRoutingTransitions.TryGetValue(current, out var allowed) && allowed.Contains(next)); + + public void EnsureCanTransition(BrowserTransferState current, BrowserTransferState next) { if (!CanTransition(current, next)) { - throw new InvalidOperationException($"Invalid download job transition: {current} -> {next}."); + throw new InvalidOperationException($"Invalid browser transfer transition: {current} -> {next}."); } } - private static ISet Set(params DownloadJobStatus[] values) - => new HashSet(values); + public void EnsureCanTransition(RoutingState current, RoutingState next) + { + if (!CanTransition(current, next)) + { + throw new InvalidOperationException($"Invalid routing transition: {current} -> {next}."); + } + } + + private static ISet BrowserSet(params BrowserTransferState[] values) + => new HashSet(values); + + private static ISet RoutingSet(params RoutingState[] values) + => new HashSet(values); } diff --git a/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs b/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs new file mode 100644 index 0000000..72d9c64 --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs @@ -0,0 +1,48 @@ +namespace DownloadRouter.Core.Jobs; + +public sealed class SelectionPromptQueue +{ + private readonly Queue queue = new(); + private readonly HashSet queued = []; + + public int Count => queue.Count; + + public bool Enqueue(Guid jobId) + { + if (jobId == Guid.Empty || !queued.Add(jobId)) + { + return false; + } + + queue.Enqueue(jobId); + return true; + } + + public bool TryDequeue(out Guid jobId) + { + if (!queue.TryDequeue(out jobId)) + { + return false; + } + + queued.Remove(jobId); + return true; + } + + public bool Remove(Guid jobId) + { + if (!queued.Remove(jobId)) + { + return false; + } + + var remaining = queue.Where(id => id != jobId).ToArray(); + queue.Clear(); + foreach (var id in remaining) + { + queue.Enqueue(id); + } + + return true; + } +} diff --git a/src/DownloadRouter.Core/Models/DomainModels.cs b/src/DownloadRouter.Core/Models/DomainModels.cs index 361ad70..533e73a 100644 --- a/src/DownloadRouter.Core/Models/DomainModels.cs +++ b/src/DownloadRouter.Core/Models/DomainModels.cs @@ -45,6 +45,27 @@ public enum DownloadJobStatus Failed, Cancelled, Interrupted, + Skipped, +} + +public enum BrowserTransferState +{ + InProgress, + Complete, + Cancelled, + Interrupted, +} + +public enum RoutingState +{ + WaitingForSelection, + SelectionReady, + Moving, + RetryPending, + Completed, + Skipped, + Failed, + NotRequired, } public sealed record DownloadRule( @@ -92,11 +113,42 @@ public sealed record DownloadJob( string? OriginalPath, string? FinalPath, string? SelectedRelativeFolder, - DownloadJobStatus Status, + BrowserTransferState BrowserState, + RoutingState RoutingState, string? ErrorCode, string? ErrorMessage, DateTimeOffset CreatedAt, - DateTimeOffset? CompletedAt); + DateTimeOffset? CompletedAt) +{ + public DownloadJobStatus Status + => BrowserState switch + { + BrowserTransferState.Cancelled => DownloadJobStatus.Cancelled, + BrowserTransferState.Interrupted => DownloadJobStatus.Interrupted, + _ => RoutingState switch + { + RoutingState.WaitingForSelection => DownloadJobStatus.WaitingForSelection, + RoutingState.SelectionReady when BrowserState == BrowserTransferState.InProgress => DownloadJobStatus.WaitingForDownload, + RoutingState.SelectionReady => DownloadJobStatus.ReadyToMove, + RoutingState.Moving => DownloadJobStatus.Moving, + RoutingState.RetryPending => DownloadJobStatus.RetryPending, + RoutingState.Completed => DownloadJobStatus.Completed, + RoutingState.Skipped => DownloadJobStatus.Skipped, + RoutingState.Failed => DownloadJobStatus.Failed, + RoutingState.NotRequired when BrowserState == BrowserTransferState.InProgress => DownloadJobStatus.WaitingForDownload, + RoutingState.NotRequired => DownloadJobStatus.ReadyToMove, + _ => DownloadJobStatus.Detected, + }, + }; + + public bool IsSelectionPending + => BrowserState is BrowserTransferState.InProgress or BrowserTransferState.Complete + && RoutingState == RoutingState.WaitingForSelection; + + public bool IsTerminal + => BrowserState is BrowserTransferState.Cancelled or BrowserTransferState.Interrupted + || RoutingState is RoutingState.Completed or RoutingState.Skipped or RoutingState.Failed; +} public sealed record AgentCommand( int Version, @@ -140,11 +192,27 @@ public sealed record SelectionCompletedPayload( IReadOnlyList JobIds, string RelativeFolder); +public sealed record SelectionSkippedPayload(Guid JobId); + +public sealed record JobsDeletePayload(IReadOnlyList JobIds); + +public sealed record ActiveDownloadsPayload(string Browser); + +public sealed record ActiveBrowserDownload(Guid JobId, string DownloadId); + +public sealed record DashboardCounts( + int DownloadsInProgress, + int WaitingForSelection, + int RecentlyCompleted, + int CancelledOrInterrupted, + int RetryOrFailed); + public static class ProtocolConstants { public const int CurrentVersion = 1; public const int MaximumMessageBytes = 1024 * 1024; public const string AgentPipeName = "eslee.download-router.agent.v1"; + public const string AppMutexName = "Local\\eslee.DownloadRouter.App"; public static readonly ISet AllowedCommands = new HashSet(StringComparer.Ordinal) { @@ -154,7 +222,10 @@ public static class ProtocolConstants "rules.list", "rules.upsert", "jobs.list", + "jobs.delete", + "downloads.active", "selection.complete", + "selection.skip", "job.retry", "diagnostics.status", }; diff --git a/src/DownloadRouter.Extension/package.json b/src/DownloadRouter.Extension/package.json index 8cd2538..77273e3 100644 --- a/src/DownloadRouter.Extension/package.json +++ b/src/DownloadRouter.Extension/package.json @@ -11,7 +11,7 @@ "clean": "node scripts/clean.mjs", "build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-manifest.mjs", "lint": "eslint .", - "test": "tsc -p tsconfig.test.json && node --test build-tests/tests/native.test.js build-tests/tests/protocol.test.js build-tests/tests/source-attribution.test.js", + "test": "tsc -p tsconfig.test.json && node --test build-tests/tests/download-state.test.js build-tests/tests/native.test.js build-tests/tests/protocol.test.js build-tests/tests/source-attribution.test.js", "check": "npm run lint && npm run test && npm run build" }, "devDependencies": { diff --git a/src/DownloadRouter.Extension/src/background.ts b/src/DownloadRouter.Extension/src/background.ts index d95ca8c..80bb1cc 100644 --- a/src/DownloadRouter.Extension/src/background.ts +++ b/src/DownloadRouter.Extension/src/background.ts @@ -1,10 +1,16 @@ import { detectBrowser } from "./browser.js"; +import { reportedDownloadState } from "./download-state.js"; import { sendNative } from "./native.js"; import { createRequest } from "./protocol.js"; import { sourceMetadata } from "./source-attribution.js"; const browser = detectBrowser(navigator.userAgent); +interface ActiveBrowserDownload { + jobId: string; + downloadId: string; +} + chrome.downloads.onCreated.addListener((item) => { const metadata = sourceMetadata(item); void sendNative( @@ -28,14 +34,49 @@ chrome.downloads.onChanged.addListener((delta) => { return; } - void sendNative( - createRequest("download.changed", { - browser, - downloadId: delta.id.toString(), - state, - filePath: item.filename || null, - error: delta.error?.current ?? item.error ?? null, - }), - ); + reportChangedDownload(item, state, delta.error?.current ?? item.error ?? null); }); }); + +void reconcileActiveDownloads(); + +function reportChangedDownload( + item: chrome.downloads.DownloadItem, + state: "complete" | "interrupted", + error: string | null, +): void { + void sendNative( + createRequest("download.changed", { + browser, + downloadId: item.id.toString(), + state: reportedDownloadState(state, error), + filePath: item.filename || null, + error, + }), + ); +} + +async function reconcileActiveDownloads(): Promise { + const response = await sendNative<{ browser: string }, ActiveBrowserDownload[]>( + createRequest("downloads.active", { browser }), + ); + if (!response?.success || !Array.isArray(response.data)) { + return; + } + + for (const tracked of response.data) { + const id = Number.parseInt(tracked.downloadId, 10); + if (!Number.isSafeInteger(id) || id < 0) { + continue; + } + + chrome.downloads.search({ id }, (items) => { + const item = items[0]; + if (!item || (item.state !== "complete" && item.state !== "interrupted")) { + return; + } + + reportChangedDownload(item, item.state, item.error ?? null); + }); + } +} diff --git a/src/DownloadRouter.Extension/src/download-state.ts b/src/DownloadRouter.Extension/src/download-state.ts new file mode 100644 index 0000000..bfed99a --- /dev/null +++ b/src/DownloadRouter.Extension/src/download-state.ts @@ -0,0 +1,12 @@ +export type ReportedDownloadState = "complete" | "interrupted" | "cancelled"; + +export function reportedDownloadState( + browserState: "complete" | "interrupted", + error: string | null | undefined, +): ReportedDownloadState { + if (browserState === "interrupted" && error?.toUpperCase() === "USER_CANCELED") { + return "cancelled"; + } + + return browserState; +} diff --git a/src/DownloadRouter.Extension/src/native.ts b/src/DownloadRouter.Extension/src/native.ts index f822288..943422d 100644 --- a/src/DownloadRouter.Extension/src/native.ts +++ b/src/DownloadRouter.Extension/src/native.ts @@ -4,7 +4,9 @@ import { type AgentResponse, } from "./protocol.js"; -export function sendNative(request: AgentRequest): Promise { +export function sendNative( + request: AgentRequest, +): Promise | null> { return new Promise((resolve) => { chrome.runtime.sendNativeMessage(nativeHostName, request, (response: unknown) => { if (chrome.runtime.lastError) { @@ -24,7 +26,7 @@ export function sendNative(request: AgentRequest): Promise); }); }); } diff --git a/src/DownloadRouter.Extension/src/protocol.ts b/src/DownloadRouter.Extension/src/protocol.ts index 4b244fa..a8bbbd3 100644 --- a/src/DownloadRouter.Extension/src/protocol.ts +++ b/src/DownloadRouter.Extension/src/protocol.ts @@ -4,7 +4,8 @@ export const nativeHostName = "com.eslee.download_router"; export type AgentCommandName = | "ping" | "download.started" - | "download.changed"; + | "download.changed" + | "downloads.active"; export interface AgentRequest { version: number; diff --git a/src/DownloadRouter.Extension/tests/download-state.test.ts b/src/DownloadRouter.Extension/tests/download-state.test.ts new file mode 100644 index 0000000..591d43e --- /dev/null +++ b/src/DownloadRouter.Extension/tests/download-state.test.ts @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { reportedDownloadState } from "../src/download-state.js"; + +test("USER_CANCELED is reported as an explicit cancellation", () => { + assert.equal(reportedDownloadState("interrupted", "USER_CANCELED"), "cancelled"); +}); + +test("other browser errors remain interrupted", () => { + assert.equal(reportedDownloadState("interrupted", "NETWORK_FAILED"), "interrupted"); +}); diff --git a/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs b/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs index 7e6f87b..8f2ff7d 100644 --- a/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs +++ b/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs @@ -6,7 +6,7 @@ namespace DownloadRouter.Infrastructure.Storage; public sealed class DownloadRouterRepository(AppPaths paths) { - private const int CurrentSchemaVersion = 1; + private const int CurrentSchemaVersion = 2; public async Task InitializeAsync(CancellationToken cancellationToken = default) { @@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS DownloadJobs ( FinalPath TEXT NULL, SelectedRelativeFolder TEXT NULL, Status TEXT NOT NULL, + BrowserState TEXT NOT NULL DEFAULT 'InProgress', + RoutingState TEXT NOT NULL DEFAULT 'NotRequired', ErrorCode TEXT NULL, ErrorMessage TEXT NULL, CreatedAt TEXT NOT NULL, @@ -100,11 +102,11 @@ FOREIGN KEY(RuleId) REFERENCES Rules(Id) ); INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) - VALUES ($version, $appliedAt); + VALUES (1, $appliedAt); """; - command.Parameters.AddWithValue("$version", CurrentSchemaVersion); command.Parameters.AddWithValue("$appliedAt", Format(DateTimeOffset.UtcNow)); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + await MigrateToCurrentVersionAsync(connection, cancellationToken).ConfigureAwait(false); } public async Task> GetRulesAsync(CancellationToken cancellationToken = default) @@ -183,12 +185,12 @@ INSERT INTO DownloadJobs( Id, Browser, BrowserDownloadId, OriginalFileName, CurrentFileName, InitiatingPageUrl, InitialUrl, FinalUrl, ReferrerUrl, SanitizedSource, RuleId, OriginalPath, FinalPath, SelectedRelativeFolder, Status, - ErrorCode, ErrorMessage, CreatedAt, CompletedAt) + BrowserState, RoutingState, ErrorCode, ErrorMessage, CreatedAt, CompletedAt) VALUES( $id, $browser, $browserDownloadId, $originalFileName, $currentFileName, $initiatingPageUrl, $initialUrl, $finalUrl, $referrerUrl, $sanitizedSource, $ruleId, $originalPath, $finalPath, $selectedRelativeFolder, $status, - $errorCode, $errorMessage, $createdAt, $completedAt); + $browserState, $routingState, $errorCode, $errorMessage, $createdAt, $completedAt); """; AddJobParameters(command, job); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); @@ -263,6 +265,8 @@ UPDATE DownloadJobs SET FinalPath = $finalPath, SelectedRelativeFolder = $selectedRelativeFolder, Status = $status, + BrowserState = $browserState, + RoutingState = $routingState, ErrorCode = $errorCode, ErrorMessage = $errorMessage, CompletedAt = $completedAt @@ -279,6 +283,68 @@ UPDATE DownloadJobs SET await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } + public async Task DeleteJobsAsync( + IReadOnlyCollection jobIds, + CancellationToken cancellationToken = default) + { + var ids = jobIds.Where(static id => id != Guid.Empty).Distinct().ToArray(); + if (ids.Length is < 1 or > 1000) + { + throw new ArgumentOutOfRangeException(nameof(jobIds), "Delete between 1 and 1000 history items."); + } + + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + var placeholders = ids.Select((_, index) => $"$id{index}").ToArray(); + var inClause = string.Join(", ", placeholders); + + await using (var deleteEvents = connection.CreateCommand()) + { + deleteEvents.Transaction = (SqliteTransaction)transaction; + deleteEvents.CommandText = $"DELETE FROM DownloadEvents WHERE JobId IN ({inClause});"; + AddIdParameters(deleteEvents, ids); + await deleteEvents.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + int affected; + await using (var deleteJobs = connection.CreateCommand()) + { + deleteJobs.Transaction = (SqliteTransaction)transaction; + deleteJobs.CommandText = $"DELETE FROM DownloadJobs WHERE Id IN ({inClause});"; + AddIdParameters(deleteJobs, ids); + affected = await deleteJobs.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return affected; + } + + public async Task> GetActiveBrowserDownloadsAsync( + BrowserKind browser, + CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT Id, BrowserDownloadId + FROM DownloadJobs + WHERE Browser = $browser + AND BrowserState = 'InProgress' + AND RoutingState NOT IN ('Completed', 'Skipped'); + """; + command.Parameters.AddWithValue("$browser", browser.ToString()); + var result = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + result.Add(new ActiveBrowserDownload(Guid.Parse(reader.GetString(0)), reader.GetString(1))); + } + + return result; + } + public async Task RecoverInProgressJobsAsync(CancellationToken cancellationToken = default) { await using var connection = CreateConnection(); @@ -287,9 +353,10 @@ public async Task RecoverInProgressJobsAsync(CancellationToken cancellationToken command.CommandText = """ UPDATE DownloadJobs SET Status = 'RetryPending', + RoutingState = 'RetryPending', ErrorCode = 'agent.restarted', ErrorMessage = 'The agent restarted while this file was being moved.' - WHERE Status IN ('ReadyToMove', 'Moving'); + WHERE RoutingState = 'Moving'; """; await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } @@ -298,10 +365,85 @@ UPDATE DownloadJobs SELECT Id, Browser, BrowserDownloadId, OriginalFileName, CurrentFileName, InitiatingPageUrl, InitialUrl, FinalUrl, ReferrerUrl, SanitizedSource, RuleId, OriginalPath, FinalPath, SelectedRelativeFolder, Status, - ErrorCode, ErrorMessage, CreatedAt, CompletedAt + BrowserState, RoutingState, ErrorCode, ErrorMessage, CreatedAt, CompletedAt FROM DownloadJobs """; + private static async Task MigrateToCurrentVersionAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + var columns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var columnCommand = connection.CreateCommand()) + { + columnCommand.CommandText = "PRAGMA table_info(DownloadJobs);"; + await using var reader = await columnCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + columns.Add(reader.GetString(1)); + } + } + + if (!columns.Contains("BrowserState")) + { + await ExecuteAsync( + connection, + "ALTER TABLE DownloadJobs ADD COLUMN BrowserState TEXT NOT NULL DEFAULT 'InProgress';", + cancellationToken).ConfigureAwait(false); + } + + if (!columns.Contains("RoutingState")) + { + await ExecuteAsync( + connection, + "ALTER TABLE DownloadJobs ADD COLUMN RoutingState TEXT NOT NULL DEFAULT 'NotRequired';", + cancellationToken).ConfigureAwait(false); + } + + await ExecuteAsync( + connection, + """ + UPDATE DownloadJobs + SET BrowserState = CASE + WHEN Status = 'Cancelled' THEN 'Cancelled' + WHEN Status = 'Interrupted' THEN 'Interrupted' + WHEN OriginalPath IS NOT NULL OR Status IN ('ReadyToMove', 'Moving', 'RetryPending', 'Completed', 'Failed') THEN 'Complete' + ELSE 'InProgress' + END, + RoutingState = CASE + WHEN Status = 'WaitingForSelection' THEN 'WaitingForSelection' + WHEN Status = 'WaitingForDownload' AND SelectedRelativeFolder IS NOT NULL THEN 'SelectionReady' + WHEN Status = 'ReadyToMove' AND SelectedRelativeFolder IS NOT NULL THEN 'SelectionReady' + WHEN Status = 'Moving' THEN 'Moving' + WHEN Status = 'RetryPending' THEN 'RetryPending' + WHEN Status = 'Completed' THEN 'Completed' + WHEN Status = 'Failed' OR Status = 'Interrupted' THEN 'Failed' + WHEN Status = 'Cancelled' THEN 'NotRequired' + ELSE 'NotRequired' + END + WHERE NOT EXISTS (SELECT 1 FROM MigrationHistory WHERE Version = 2); + + INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) + VALUES (2, CURRENT_TIMESTAMP); + """, + cancellationToken).ConfigureAwait(false); + + if (CurrentSchemaVersion != 2) + { + throw new InvalidOperationException("Repository migration version is inconsistent."); + } + } + + private static async Task ExecuteAsync( + SqliteConnection connection, + string sql, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + private SqliteConnection CreateConnection() => new(new SqliteConnectionStringBuilder { @@ -354,11 +496,12 @@ private static DownloadJob ReadJob(SqliteDataReader reader) GetNullableString(reader, 11), GetNullableString(reader, 12), GetNullableString(reader, 13), - Enum.Parse(reader.GetString(14)), - GetNullableString(reader, 15), - GetNullableString(reader, 16), - Parse(reader.GetString(17)), - reader.IsDBNull(18) ? null : Parse(reader.GetString(18))); + Enum.Parse(reader.GetString(15)), + Enum.Parse(reader.GetString(16)), + GetNullableString(reader, 17), + GetNullableString(reader, 18), + Parse(reader.GetString(19)), + reader.IsDBNull(20) ? null : Parse(reader.GetString(20))); private static string? GetNullableString(SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); @@ -396,6 +539,8 @@ private static void AddJobParameters(SqliteCommand command, DownloadJob job) command.Parameters.AddWithValue("$finalPath", Db(job.FinalPath)); command.Parameters.AddWithValue("$selectedRelativeFolder", Db(job.SelectedRelativeFolder)); command.Parameters.AddWithValue("$status", job.Status.ToString()); + command.Parameters.AddWithValue("$browserState", job.BrowserState.ToString()); + command.Parameters.AddWithValue("$routingState", job.RoutingState.ToString()); command.Parameters.AddWithValue("$errorCode", Db(job.ErrorCode)); command.Parameters.AddWithValue("$errorMessage", Db(job.ErrorMessage)); command.Parameters.AddWithValue("$createdAt", Format(job.CreatedAt)); @@ -425,6 +570,14 @@ INSERT INTO DownloadEvents(JobId, EventType, Detail, CreatedAt) private static object Db(string? value) => value is null ? DBNull.Value : value; + private static void AddIdParameters(SqliteCommand command, IReadOnlyList ids) + { + for (var index = 0; index < ids.Count; index++) + { + command.Parameters.AddWithValue($"$id{index}", ids[index].ToString("D")); + } + } + private static string Format(DateTimeOffset value) => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); private static DateTimeOffset Parse(string value) => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); diff --git a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs index 2b85f9e..3a8d12a 100644 --- a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs +++ b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs @@ -38,16 +38,53 @@ await Assert.ThrowsAsync(() => public void StateMachineRejectsMovingBeforeDownloadCompletion() { var stateMachine = new DownloadJobStateMachine(); - Assert.False(stateMachine.CanTransition(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.Moving)); + Assert.False(stateMachine.CanTransition(RoutingState.WaitingForSelection, RoutingState.Moving)); Assert.Throws(() => - stateMachine.EnsureCanTransition(DownloadJobStatus.WaitingForDownload, DownloadJobStatus.Moving)); + stateMachine.EnsureCanTransition(RoutingState.WaitingForSelection, RoutingState.Moving)); } [Fact] public void CompletedAndCancelledJobsAreTerminal() { var stateMachine = new DownloadJobStateMachine(); - Assert.False(stateMachine.CanTransition(DownloadJobStatus.Completed, DownloadJobStatus.RetryPending)); - Assert.False(stateMachine.CanTransition(DownloadJobStatus.Cancelled, DownloadJobStatus.WaitingForDownload)); + Assert.False(stateMachine.CanTransition(RoutingState.Completed, RoutingState.RetryPending)); + Assert.False(stateMachine.CanTransition(BrowserTransferState.Cancelled, BrowserTransferState.InProgress)); } + + [Fact] + public void CancelledJobsAreExcludedFromPendingAndDashboardCounts() + { + var cancelled = CreateJob(BrowserTransferState.Cancelled, RoutingState.NotRequired); + var pending = CreateJob(BrowserTransferState.InProgress, RoutingState.WaitingForSelection); + + var active = DownloadJobQueries.ActiveSelections([cancelled, pending]); + var counts = DownloadJobQueries.CountDashboard([cancelled, pending]); + + Assert.Equal(pending.Id, Assert.Single(active).Id); + Assert.Equal(1, counts.WaitingForSelection); + Assert.Equal(1, counts.CancelledOrInterrupted); + } + + [Fact] + public void SelectionPromptQueueIsFifoAndRejectsDuplicateJobs() + { + var queue = new SelectionPromptQueue(); + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + + Assert.True(queue.Enqueue(first)); + Assert.False(queue.Enqueue(first)); + Assert.True(queue.Enqueue(second)); + Assert.True(queue.TryDequeue(out var dequeuedFirst)); + Assert.True(queue.TryDequeue(out var dequeuedSecond)); + Assert.Equal(first, dequeuedFirst); + Assert.Equal(second, dequeuedSecond); + Assert.False(queue.TryDequeue(out _)); + } + + private static DownloadJob CreateJob(BrowserTransferState browserState, RoutingState routingState) + => new( + Guid.NewGuid(), BrowserKind.Whale, Guid.NewGuid().ToString("N"), "sample.bin", "sample.bin", + null, null, null, null, "https://example.com", Guid.NewGuid(), null, null, null, + browserState, routingState, null, null, DateTimeOffset.UtcNow, null); } diff --git a/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs b/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs index 2e8ef8d..c1a33ef 100644 --- a/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs +++ b/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs @@ -35,6 +35,64 @@ public async Task MigrationCreatesDatabaseAndRuleRoundTrips() Assert.True(File.Exists(paths.DatabasePath)); } + [Fact] + public async Task VersionOneJobsMigrateToSeparateBrowserAndRoutingStates() + { + var paths = AppPaths.CreateDefault(); + paths.EnsureCreated(); + var ruleId = Guid.NewGuid(); + var jobId = Guid.NewGuid(); + await using (var connection = new SqliteConnection($"Data Source={paths.DatabasePath}")) + { + await connection.OpenAsync(CancellationToken.None); + await using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TABLE MigrationHistory (Version INTEGER PRIMARY KEY, AppliedAt TEXT NOT NULL); + INSERT INTO MigrationHistory(Version, AppliedAt) VALUES (1, '2026-01-01T00:00:00.0000000+00:00'); + CREATE TABLE Rules ( + Id TEXT PRIMARY KEY, Name TEXT NOT NULL, IsEnabled INTEGER NOT NULL, + MatchType TEXT NOT NULL, MatchValue TEXT NOT NULL, MatchTarget TEXT NOT NULL, + StorageRoot TEXT NOT NULL, StorageMode TEXT NOT NULL, Priority INTEGER NOT NULL, + ListOrder INTEGER NOT NULL, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL); + CREATE TABLE DownloadJobs ( + Id TEXT PRIMARY KEY, Browser TEXT NOT NULL, BrowserDownloadId TEXT NOT NULL, + OriginalFileName TEXT NOT NULL, CurrentFileName TEXT NOT NULL, + InitiatingPageUrl TEXT NULL, InitialUrl TEXT NULL, FinalUrl TEXT NULL, + ReferrerUrl TEXT NULL, SanitizedSource TEXT NULL, RuleId TEXT NOT NULL, + OriginalPath TEXT NULL, FinalPath TEXT NULL, SelectedRelativeFolder TEXT NULL, + Status TEXT NOT NULL, ErrorCode TEXT NULL, ErrorMessage TEXT NULL, + CreatedAt TEXT NOT NULL, CompletedAt TEXT NULL, + FOREIGN KEY(RuleId) REFERENCES Rules(Id), UNIQUE(Browser, BrowserDownloadId)); + INSERT INTO Rules VALUES( + $ruleId, 'legacy rule', 1, 'DomainAndSubdomains', 'example.com', 'InitiatingPage', + $root, 'SelectSubfolder', 0, 0, $created, $created); + INSERT INTO DownloadJobs VALUES( + $jobId, 'Whale', 'legacy-1', 'legacy.txt', 'legacy.txt', + NULL, NULL, NULL, NULL, 'https://example.com', $ruleId, + NULL, NULL, NULL, 'WaitingForSelection', NULL, NULL, $created, NULL); + """; + command.Parameters.AddWithValue("$ruleId", ruleId.ToString("D")); + command.Parameters.AddWithValue("$jobId", jobId.ToString("D")); + command.Parameters.AddWithValue("$root", root); + command.Parameters.AddWithValue("$created", DateTimeOffset.UtcNow.ToString("O")); + await command.ExecuteNonQueryAsync(CancellationToken.None); + } + + var repository = new DownloadRouterRepository(paths); + await repository.InitializeAsync(CancellationToken.None); + + var migrated = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.NotNull(migrated); + Assert.Equal(BrowserTransferState.InProgress, migrated.BrowserState); + Assert.Equal(RoutingState.WaitingForSelection, migrated.RoutingState); + + await using var verification = new SqliteConnection($"Data Source={paths.DatabasePath}"); + await verification.OpenAsync(CancellationToken.None); + await using var versionCommand = verification.CreateCommand(); + versionCommand.CommandText = "SELECT COUNT(*) FROM MigrationHistory WHERE Version = 2;"; + Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); + } + public void Dispose() { Environment.SetEnvironmentVariable("DOWNLOAD_ROUTER_DATA_DIR", previousOverride); diff --git a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs index a02e1d7..8514d70 100644 --- a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs +++ b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs @@ -123,6 +123,193 @@ public async Task MatchedDownloadCreatesHistoryAndRoutesCompletedFile() Assert.Equal("public test fixture", await File.ReadAllTextAsync(job.FinalPath!, CancellationToken.None)); } + [Fact] + public async Task SameRuleDownloadsCanSelectDifferentFoldersBeforeAndAfterCompletion() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "A")); + Directory.CreateDirectory(Path.Combine(destination, "B")); + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + + var firstId = await StartAsync(handler, "select-1", "first.txt"); + var secondId = await StartAsync(handler, "select-2", "second.txt"); + var firstSource = Path.Combine(incoming, "first.txt"); + var secondSource = Path.Combine(incoming, "second.txt"); + await File.WriteAllTextAsync(firstSource, "first", CancellationToken.None); + await File.WriteAllTextAsync(secondSource, "second", CancellationToken.None); + + var selectFirst = await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([firstId], "A")); + Assert.True(selectFirst.Success, selectFirst.Message); + var firstBeforeCompletion = await repository.GetJobAsync(firstId, CancellationToken.None); + Assert.Equal(BrowserTransferState.InProgress, firstBeforeCompletion!.BrowserState); + Assert.Equal(RoutingState.SelectionReady, firstBeforeCompletion.RoutingState); + + var completeFirst = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "select-1", "complete", firstSource, null)); + Assert.True(completeFirst.Success, completeFirst.Message); + Assert.True(File.Exists(Path.Combine(destination, "A", "first.txt"))); + + var completeSecond = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "select-2", "complete", secondSource, null)); + Assert.True(completeSecond.Success, completeSecond.Message); + Assert.True(File.Exists(secondSource)); + var secondBeforeSelection = await repository.GetJobAsync(secondId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Complete, secondBeforeSelection!.BrowserState); + Assert.Equal(RoutingState.WaitingForSelection, secondBeforeSelection.RoutingState); + + var selectSecond = await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([secondId], "B")); + Assert.True(selectSecond.Success, selectSecond.Message); + Assert.True(File.Exists(Path.Combine(destination, "B", "second.txt"))); + Assert.False(File.Exists(secondSource)); + } + + [Fact] + public async Task BatchSelectionChangesOnlyExplicitlySelectedJobs() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var firstId = await StartAsync(handler, "batch-1", "first.txt"); + var secondId = await StartAsync(handler, "batch-2", "second.txt"); + var thirdId = await StartAsync(handler, "batch-3", "third.txt"); + + var response = await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([firstId, thirdId], string.Empty)); + + Assert.True(response.Success, response.Message); + Assert.Equal(RoutingState.SelectionReady, (await repository.GetJobAsync(firstId, CancellationToken.None))!.RoutingState); + Assert.Equal(RoutingState.WaitingForSelection, (await repository.GetJobAsync(secondId, CancellationToken.None))!.RoutingState); + Assert.Equal(RoutingState.SelectionReady, (await repository.GetJobAsync(thirdId, CancellationToken.None))!.RoutingState); + } + + [Fact] + public async Task UserCancellationIsTerminalPendingIsExcludedAndNoFileIsMoved() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "cancel-1", "cancelled.txt"); + var source = Path.Combine(incoming, "cancelled.txt"); + await File.WriteAllTextAsync(source, "partial content", CancellationToken.None); + + var response = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "cancel-1", "cancelled", source, "USER_CANCELED")); + + Assert.True(response.Success, response.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Cancelled, job!.BrowserState); + Assert.Equal(RoutingState.NotRequired, job.RoutingState); + Assert.Equal("download.cancelled", job.ErrorCode); + Assert.Empty(DownloadJobQueries.ActiveSelections(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None))); + Assert.Equal(0, DownloadJobQueries.CountDashboard([job]).WaitingForSelection); + Assert.True(File.Exists(source)); + Assert.Empty(Directory.EnumerateFiles(destination)); + } + + [Fact] + public async Task BrowserInterruptionStoresOnlyARecognizedErrorCode() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "interrupt-1", "interrupted.txt"); + + var response = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload( + "Whale", + "interrupt-1", + "interrupted", + null, + "https://example.com/file?token=secret")); + + Assert.True(response.Success, response.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Interrupted, job!.BrowserState); + Assert.Equal(RoutingState.Failed, job.RoutingState); + Assert.Equal("download.interrupted.unknown", job.ErrorCode); + Assert.DoesNotContain("secret", job.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DeletingHistoryDoesNotDeleteTheDownloadedFile() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "delete-1", "keep.txt"); + var source = Path.Combine(incoming, "keep.txt"); + await File.WriteAllTextAsync(source, "must remain", CancellationToken.None); + + var response = await SendAsync(handler, "jobs.delete", new JobsDeletePayload([jobId])); + + Assert.True(response.Success, response.Message); + Assert.Equal(1, response.Data!.Value.GetProperty("deleted").GetInt32()); + Assert.Equal(0, response.Data.Value.GetProperty("filesDeleted").GetInt32()); + Assert.Null(await repository.GetJobAsync(jobId, CancellationToken.None)); + Assert.True(File.Exists(source)); + Assert.Equal("must remain", await File.ReadAllTextAsync(source, CancellationToken.None)); + } + + private static async Task CreateRuleAsync( + DownloadRouterRepository repository, + string destination, + StorageMode storageMode) + { + var now = DateTimeOffset.UtcNow; + await repository.UpsertRuleAsync( + new DownloadRule( + Guid.NewGuid(), "selection rule", true, RuleMatchType.DomainAndSubdomains, "example.com", + RuleMatchTarget.InitiatingPage, destination, storageMode, 0, 0, now, now), + CancellationToken.None); + } + + private static async Task StartAsync( + AgentCommandHandler handler, + string downloadId, + string fileName) + { + var response = await SendAsync( + handler, + "download.started", + new DownloadStartedPayload( + "Whale", downloadId, fileName, null, "https://example.com/downloads", + $"https://example.com/{fileName}", null, null)); + Assert.True(response.Success, response.Message); + return response.Data!.Value.GetProperty("jobId").GetGuid(); + } + + private static Task SendAsync( + AgentCommandHandler handler, + string command, + object payload) + => handler.HandleAsync( + new AgentCommand( + ProtocolConstants.CurrentVersion, + Guid.NewGuid(), + command, + JsonSerializer.SerializeToElement(payload, ProtocolJson.Options)), + CancellationToken.None); + private async Task<(AgentCommandHandler Handler, DownloadRouterRepository Repository)> CreateHandlerAsync() { var paths = AppPaths.CreateDefault(); @@ -137,11 +324,17 @@ public async Task MatchedDownloadCreatesHistoryAndRoutesCompletedFile() boundary, new DownloadJobStateMachine(), new FileMoveService(boundary, NullLogger.Instance), + new NoOpSelectionUiLauncher(), paths, NullLogger.Instance); return (handler, repository); } + private sealed class NoOpSelectionUiLauncher : ISelectionUiLauncher + { + public bool RequestSelectionUi() => true; + } + public void Dispose() { Environment.SetEnvironmentVariable("DOWNLOAD_ROUTER_DATA_DIR", previousOverride); From edb06376747ab1563c1fa130b8b790d1f98da441 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:31:47 +0900 Subject: [PATCH 06/24] feat: add per-download folder selection and history controls --- src/DownloadRouter.App/App.xaml.cs | 11 + src/DownloadRouter.App/MainWindow.History.cs | 266 +++++++++++++++++ src/DownloadRouter.App/MainWindow.Live.cs | 278 ++++++++++++++++++ src/DownloadRouter.App/MainWindow.Pending.cs | 216 ++++++++++++++ src/DownloadRouter.App/MainWindow.xaml.cs | 177 ++--------- .../tests/download-state.test.ts | 4 +- 6 files changed, 795 insertions(+), 157 deletions(-) create mode 100644 src/DownloadRouter.App/MainWindow.History.cs create mode 100644 src/DownloadRouter.App/MainWindow.Live.cs create mode 100644 src/DownloadRouter.App/MainWindow.Pending.cs diff --git a/src/DownloadRouter.App/App.xaml.cs b/src/DownloadRouter.App/App.xaml.cs index c1bdb56..c80aa83 100644 --- a/src/DownloadRouter.App/App.xaml.cs +++ b/src/DownloadRouter.App/App.xaml.cs @@ -1,10 +1,12 @@ using Microsoft.UI.Xaml; +using DownloadRouter.Core.Models; namespace DownloadRouter.App; public partial class App : Application { private Window? window; + private Mutex? singleInstance; public App() { @@ -13,6 +15,15 @@ public App() protected override void OnLaunched(LaunchActivatedEventArgs args) { + singleInstance = new Mutex(initiallyOwned: true, ProtocolConstants.AppMutexName, out var createdNew); + if (!createdNew) + { + singleInstance.Dispose(); + singleInstance = null; + Exit(); + return; + } + window = new MainWindow(); window.Activate(); } diff --git a/src/DownloadRouter.App/MainWindow.History.cs b/src/DownloadRouter.App/MainWindow.History.cs new file mode 100644 index 0000000..b601e30 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.History.cs @@ -0,0 +1,266 @@ +using DownloadRouter.Core.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.UI.Text; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private IReadOnlyList historyJobs = []; + private readonly HashSet selectedHistoryJobs = []; + private HistoryFilter historyFilter = HistoryFilter.All; + private string? historySnapshot; + private bool blockingDialogOpen; + + private async Task ShowHistoryAsync() + { + try + { + var response = await agent.SendAsync("jobs.list"); + historyJobs = AgentClient.ReadData>(response) ?? []; + RenderHistory(); + } + catch (Exception exception) + { + Prepare("다운로드 작업 및 이력", "사이트 규칙에 매칭된 작업의 전송 상태와 라우팅 상태를 각각 표시합니다."); + AddError("다운로드 이력을 불러오지 못했습니다.", exception); + } + } + + private void RenderHistory() + { + Prepare("다운로드 작업 및 이력", "취소된 작업도 자동 삭제하지 않으며, 이력 삭제는 실제 파일에 영향을 주지 않습니다."); + + var filter = new ComboBox + { + Header = "상태 필터", + ItemsSource = new[] { "전체", "완료", "취소", "중단", "실패", "진행 중" }, + SelectedIndex = (int)historyFilter, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + filter.SelectionChanged += (_, _) => + { + historyFilter = (HistoryFilter)Math.Max(filter.SelectedIndex, 0); + RenderHistory(); + }; + ContentPanel.Children.Add(filter); + + var deleteSelected = new Button + { + Content = "선택 항목 삭제", + HorizontalAlignment = HorizontalAlignment.Stretch, + IsEnabled = selectedHistoryJobs.Count > 0, + }; + deleteSelected.Click += async (_, _) => await DeleteHistoryAsync(selectedHistoryJobs.ToArray()); + ContentPanel.Children.Add(deleteSelected); + + var clearCancelled = new Button + { + Content = "취소된 이력 정리", + HorizontalAlignment = HorizontalAlignment.Stretch, + IsEnabled = historyJobs.Any(static job => job.BrowserState == BrowserTransferState.Cancelled), + }; + clearCancelled.Click += async (_, _) => await DeleteHistoryAsync( + historyJobs.Where(static job => job.BrowserState == BrowserTransferState.Cancelled) + .Select(static job => job.Id) + .ToArray()); + ContentPanel.Children.Add(clearCancelled); + + var visible = historyJobs.Where(MatchesHistoryFilter).ToList(); + foreach (var job in visible) + { + ContentPanel.Children.Add(CreateHistoryCard(job)); + } + + if (visible.Count == 0) + { + AddMuted(historyJobs.Count == 0 + ? "아직 규칙에 매칭되어 추적된 다운로드가 없습니다." + : "선택한 상태에 해당하는 이력이 없습니다."); + } + + historySnapshot = CreateHistorySnapshot(historyJobs); + } + + private Border CreateHistoryCard(DownloadJob job) + { + var panel = new StackPanel { Spacing = 8 }; + var checkbox = new CheckBox + { + Content = "이 항목 선택", + IsChecked = selectedHistoryJobs.Contains(job.Id), + }; + checkbox.Checked += (_, _) => + { + selectedHistoryJobs.Add(job.Id); + RenderHistory(); + }; + checkbox.Unchecked += (_, _) => + { + selectedHistoryJobs.Remove(job.Id); + RenderHistory(); + }; + panel.Children.Add(checkbox); + + var title = new TextBlock + { + Text = job.CurrentFileName, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + TextDecorations = job.BrowserState == BrowserTransferState.Cancelled + ? TextDecorations.Strikethrough + : TextDecorations.None, + }; + panel.Children.Add(title); + panel.Children.Add(CreateStatusBadge(job)); + panel.Children.Add(new TextBlock + { + Text = $"{job.Browser} · {GetSourceHost(job)} · 시작 {job.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}", + TextWrapping = TextWrapping.Wrap, + }); + + if (!string.IsNullOrWhiteSpace(job.FinalPath)) + { + panel.Children.Add(new TextBlock { Text = $"이동 위치: {job.FinalPath}", TextWrapping = TextWrapping.Wrap }); + } + + if (!string.IsNullOrWhiteSpace(job.ErrorCode)) + { + panel.Children.Add(new TextBlock { Text = $"오류 코드: {job.ErrorCode}", TextWrapping = TextWrapping.Wrap }); + } + + var delete = new Button { Content = "이력에서 삭제", HorizontalAlignment = HorizontalAlignment.Stretch }; + delete.Click += async (_, _) => await DeleteHistoryAsync([job.Id]); + panel.Children.Add(delete); + + return new Border + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(16), + CornerRadius = new CornerRadius(8), + Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + Opacity = job.BrowserState == BrowserTransferState.Cancelled ? 0.55 : 1, + Child = panel, + }; + } + + private static Border CreateStatusBadge(DownloadJob job) + => new() + { + HorizontalAlignment = HorizontalAlignment.Left, + Padding = new Thickness(8, 4, 8, 4), + CornerRadius = new CornerRadius(12), + Background = Application.Current.Resources[ + job.BrowserState == BrowserTransferState.Interrupted || job.RoutingState == RoutingState.Failed + ? "SystemFillColorCautionBackgroundBrush" + : "SubtleFillColorSecondaryBrush"] as Brush, + Child = new TextBlock + { + Text = DescribeStatus(job), + TextWrapping = TextWrapping.Wrap, + }, + }; + + private async Task DeleteHistoryAsync(IReadOnlyList ids) + { + if (ids.Count == 0) + { + await ShowMessageAsync("삭제할 이력을 선택하세요."); + return; + } + + var confirmed = await ShowConfirmationAsync( + "다운로드 이력 삭제", + $"{ids.Count}개 항목을 이력에서 삭제하시겠습니까?\n\n이 작업은 프로그램 이력에서만 삭제되며 실제 다운로드 파일은 삭제하지 않습니다."); + if (!confirmed) + { + return; + } + + var response = await agent.SendAsync("jobs.delete", new JobsDeletePayload(ids)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "이력 삭제에 실패했습니다."); + return; + } + + selectedHistoryJobs.ExceptWith(ids); + await ShowHistoryAsync(); + } + + private async Task ShowConfirmationAsync(string title, string message) + { + blockingDialogOpen = true; + try + { + var dialog = new ContentDialog + { + Title = title, + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + PrimaryButtonText = "확인", + CloseButtonText = "취소", + DefaultButton = ContentDialogButton.Close, + XamlRoot = ContentPanel.XamlRoot, + }; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } + finally + { + blockingDialogOpen = false; + } + } + + private bool MatchesHistoryFilter(DownloadJob job) + => historyFilter switch + { + HistoryFilter.All => true, + HistoryFilter.Completed => job.RoutingState == RoutingState.Completed, + HistoryFilter.Cancelled => job.BrowserState == BrowserTransferState.Cancelled, + HistoryFilter.Interrupted => job.BrowserState == BrowserTransferState.Interrupted, + HistoryFilter.Failed => job.RoutingState is RoutingState.Failed or RoutingState.RetryPending, + HistoryFilter.InProgress => job.BrowserState == BrowserTransferState.InProgress, + _ => true, + }; + + private static string CreateHistorySnapshot(IEnumerable jobs) + => string.Join('|', jobs.Select(static job => + $"{job.Id:N}:{job.BrowserState}:{job.RoutingState}:{job.CompletedAt:O}:{job.ErrorCode}")); + + private static string DescribeStatus(DownloadJob job) + => job.BrowserState switch + { + BrowserTransferState.Cancelled => "사용자가 다운로드를 취소했습니다", + BrowserTransferState.Interrupted => "브라우저 또는 네트워크 오류로 중단", + BrowserTransferState.InProgress when job.RoutingState == RoutingState.WaitingForSelection => "다운로드 중 · 위치 선택 대기", + BrowserTransferState.Complete when job.RoutingState == RoutingState.WaitingForSelection => "다운로드 완료 · 위치 선택 대기", + BrowserTransferState.InProgress when job.RoutingState == RoutingState.SelectionReady => "위치 선택 완료 · 다운로드 진행 중", + _ => job.RoutingState switch + { + RoutingState.Moving => "파일 이동 중", + RoutingState.Completed => "이동 완료", + RoutingState.Skipped => "이번 파일 이동 안 함", + RoutingState.RetryPending => "파일 이동 재시도 대기", + RoutingState.Failed => "파일 이동 실패", + RoutingState.NotRequired when job.BrowserState == BrowserTransferState.InProgress => "다운로드 중", + RoutingState.NotRequired => "다운로드 완료", + _ => $"{job.BrowserState} · {job.RoutingState}", + }, + }; + + private static string GetSourceHost(DownloadJob job) + => Uri.TryCreate(job.SanitizedSource, UriKind.Absolute, out var source) + ? source.Host + : "출처 확인 불가"; + + private enum HistoryFilter + { + All, + Completed, + Cancelled, + Interrupted, + Failed, + InProgress, + } +} diff --git a/src/DownloadRouter.App/MainWindow.Live.cs b/src/DownloadRouter.App/MainWindow.Live.cs new file mode 100644 index 0000000..d654c06 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Live.cs @@ -0,0 +1,278 @@ +using System.Runtime.InteropServices; +using DownloadRouter.Core.Jobs; +using DownloadRouter.Core.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private readonly DispatcherTimer liveUpdateTimer = new() { Interval = TimeSpan.FromSeconds(1) }; + private readonly SelectionPromptQueue selectionPromptQueue = new(); + private readonly HashSet deferredSelectionPrompts = []; + private ContentDialog? currentSelectionDialog; + private Guid? currentSelectionJobId; + private bool currentSelectionInvalidated; + private bool liveUpdateInProgress; + private bool selectionPromptInProgress; + private string? livePageSnapshot; + + private void InitializeLiveUpdates() + { + liveUpdateTimer.Tick += LiveUpdateTimer_Tick; + liveUpdateTimer.Start(); + } + + private void StopLiveUpdates() + { + liveUpdateTimer.Stop(); + currentSelectionDialog?.Hide(); + } + + private async Task ShowDashboardAsync() + { + Prepare("대시보드", "브라우저 전송 상태와 파일 라우팅 상태를 구분해 표시합니다."); + try + { + var jobsResponse = await agent.SendAsync("jobs.list"); + var rulesResponse = await agent.SendAsync("rules.list"); + var jobs = AgentClient.ReadData>(jobsResponse) ?? []; + var rules = AgentClient.ReadData>(rulesResponse) ?? []; + RenderDashboard(jobs, rules); + } + catch (Exception exception) + { + AddError("Agent에 연결할 수 없습니다. scripts/run-dev.ps1을 실행한 뒤 다시 시도하세요.", exception); + } + } + + private void RenderDashboard(IReadOnlyList jobs, IReadOnlyList rules) + { + Prepare("대시보드", "브라우저 전송 상태와 파일 라우팅 상태를 구분해 표시합니다."); + var counts = DownloadJobQueries.CountDashboard(jobs); + AddCard("활성 규칙", rules.Count(static rule => rule.IsEnabled).ToString()); + AddCard("다운로드 중", counts.DownloadsInProgress.ToString()); + AddCard("저장 위치 선택 대기", counts.WaitingForSelection.ToString()); + AddCard("최근 완료", counts.RecentlyCompleted.ToString()); + AddCard("취소/중단", counts.CancelledOrInterrupted.ToString()); + AddCard("재시도/실패", counts.RetryOrFailed.ToString()); + } + + private async void LiveUpdateTimer_Tick(object? sender, object args) + { + if (liveUpdateInProgress) + { + return; + } + + liveUpdateInProgress = true; + try + { + var jobs = AgentClient.ReadData>(await agent.SendAsync("jobs.list")) ?? []; + var rules = AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []; + var active = DownloadJobQueries.ActiveSelections(jobs); + SynchronizeSelectionPrompt(active); + + if (!selectionPromptInProgress && !blockingDialogOpen) + { + _ = ProcessNextSelectionPromptAsync(active, rules); + } + + await RefreshVisibleLivePageAsync(jobs, rules); + } + catch (Exception exception) + { + System.Diagnostics.Debug.WriteLine($"Live UI refresh failed: {exception.GetType().Name}"); + } + finally + { + liveUpdateInProgress = false; + } + } + + private void SynchronizeSelectionPrompt(IReadOnlyList active) + { + var activeIds = active.Select(static job => job.Id).ToHashSet(); + if (currentSelectionJobId is Guid current && !activeIds.Contains(current)) + { + currentSelectionInvalidated = true; + currentSelectionDialog?.Hide(); + } + + foreach (var job in active) + { + if (job.Id != currentSelectionJobId && !deferredSelectionPrompts.Contains(job.Id)) + { + selectionPromptQueue.Enqueue(job.Id); + } + } + } + + private async Task ProcessNextSelectionPromptAsync( + IReadOnlyList active, + IReadOnlyList rules) + { + while (selectionPromptQueue.TryDequeue(out var jobId)) + { + var job = active.FirstOrDefault(candidate => candidate.Id == jobId); + var rule = job is null ? null : rules.FirstOrDefault(candidate => candidate.Id == job.RuleId); + if (job is null || rule is null || deferredSelectionPrompts.Contains(jobId)) + { + continue; + } + + await ShowSelectionPromptAsync(job, rule, Math.Max(0, active.Count - 1)); + return; + } + } + + private async Task ShowSelectionPromptAsync(DownloadJob job, DownloadRule rule, int otherPendingCount) + { + selectionPromptInProgress = true; + currentSelectionJobId = job.Id; + currentSelectionInvalidated = false; + string? action = null; + try + { + var root = pathResolver.Resolve(rule.StorageRoot); + var folders = EnumerateSafeFolders(root); + var picker = CreateFolderPicker("선택 가능한 하위 폴더", folders, job.SelectedRelativeFolder); + var content = new StackPanel { Spacing = 10, MinWidth = 360 }; + content.Children.Add(new TextBlock + { + Text = $"파일: {job.CurrentFileName}\n출처 사이트: {GetSourceHost(job)}\n상태: {DescribeStatus(job)}\n규칙: {rule.Name}\n저장 루트: {root}\n동시에 대기 중인 다른 파일: {otherPendingCount}개", + TextWrapping = TextWrapping.Wrap, + }); + content.Children.Add(picker); + + var send = new Button { Content = "이 위치로 보내기", HorizontalAlignment = HorizontalAlignment.Stretch }; + send.Click += (_, _) => + { + if (picker.SelectedItem is not string) + { + return; + } + + action = "apply"; + currentSelectionDialog?.Hide(); + }; + content.Children.Add(send); + + var later = new Button { Content = "나중에 선택", HorizontalAlignment = HorizontalAlignment.Stretch }; + later.Click += (_, _) => + { + action = "later"; + currentSelectionDialog?.Hide(); + }; + content.Children.Add(later); + + var skip = new Button { Content = "이번 파일은 이동하지 않기", HorizontalAlignment = HorizontalAlignment.Stretch }; + skip.Click += (_, _) => + { + action = "skip"; + currentSelectionDialog?.Hide(); + }; + content.Children.Add(skip); + + currentSelectionDialog = new ContentDialog + { + Title = "다운로드 저장 위치 선택", + Content = content, + XamlRoot = ContentPanel.XamlRoot, + }; + BringSelectionWindowToFront(); + await currentSelectionDialog.ShowAsync(); + + if (currentSelectionInvalidated) + { + return; + } + + if (action == "apply" && picker.SelectedItem is string selectedFolder) + { + var relativeFolder = selectedFolder == "." ? string.Empty : selectedFolder; + var response = await agent.SendAsync( + "selection.complete", + new SelectionCompletedPayload([job.Id], relativeFolder)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "선택 적용에 실패했습니다."); + } + } + else if (action == "skip") + { + var response = await agent.SendAsync("selection.skip", new SelectionSkippedPayload(job.Id)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "이동 건너뛰기 적용에 실패했습니다."); + } + } + else + { + deferredSelectionPrompts.Add(job.Id); + } + } + catch (Exception exception) + { + deferredSelectionPrompts.Add(job.Id); + System.Diagnostics.Debug.WriteLine($"Selection prompt failed open: {exception.GetType().Name}"); + } + finally + { + currentSelectionDialog = null; + currentSelectionJobId = null; + currentSelectionInvalidated = false; + selectionPromptInProgress = false; + } + } + + private async Task RefreshVisibleLivePageAsync( + IReadOnlyList jobs, + IReadOnlyList rules) + { + if (blockingDialogOpen || currentSelectionDialog is not null) + { + return; + } + + var snapshot = CreateHistorySnapshot(jobs); + if (string.Equals(livePageSnapshot, snapshot, StringComparison.Ordinal)) + { + return; + } + + livePageSnapshot = snapshot; + var tag = (Navigation.SelectedItem as NavigationViewItem)?.Tag as string; + switch (tag) + { + case "dashboard": + RenderDashboard(jobs, rules); + break; + case "history": + historyJobs = jobs; + selectedHistoryJobs.IntersectWith(jobs.Select(static job => job.Id)); + RenderHistory(); + break; + case "pending": + await ShowPendingAsync(); + break; + } + } + + private void BringSelectionWindowToFront() + { + Activate(); + var windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(this); + _ = ShowWindow(windowHandle, 9); + _ = SetForegroundWindow(windowHandle); + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint windowHandle, int command); +} diff --git a/src/DownloadRouter.App/MainWindow.Pending.cs b/src/DownloadRouter.App/MainWindow.Pending.cs new file mode 100644 index 0000000..fd735d6 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Pending.cs @@ -0,0 +1,216 @@ +using DownloadRouter.Core.Jobs; +using DownloadRouter.Core.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private async Task ShowPendingAsync() + { + Prepare("저장 위치 선택 대기", "파일마다 서로 다른 하위 폴더를 선택할 수 있습니다. 완료 전 선택은 저장만 하고, 실제 이동은 다운로드 완료 후 시작합니다."); + try + { + var jobs = AgentClient.ReadData>(await agent.SendAsync("jobs.list")) ?? []; + var rules = (AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []) + .ToDictionary(static rule => rule.Id); + var active = DownloadJobQueries.ActiveSelections(jobs); + var groups = active.GroupBy(static job => job.RuleId).ToList(); + + foreach (var group in groups) + { + if (!rules.TryGetValue(group.Key, out var rule)) + { + continue; + } + + AddPendingRuleGroup(rule, group.ToList()); + } + + if (groups.Count == 0) + { + AddMuted("현재 선택을 기다리는 활성 파일이 없습니다. 취소·중단·완료·건너뜀 작업은 이 목록에서 제외됩니다."); + } + } + catch (Exception exception) + { + AddError("선택 대기 목록을 불러오지 못했습니다.", exception); + } + } + + private void AddPendingRuleGroup(DownloadRule rule, IReadOnlyList jobs) + { + var root = pathResolver.Resolve(rule.StorageRoot); + var folders = EnumerateSafeFolders(root); + var selectedJobs = new HashSet(); + + ContentPanel.Children.Add(new TextBlock + { + Text = $"{rule.Name} · {jobs.Count}개 파일", + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + TextWrapping = TextWrapping.Wrap, + }); + ContentPanel.Children.Add(new TextBlock + { + Text = $"저장 루트: {root}", + Opacity = 0.75, + TextWrapping = TextWrapping.Wrap, + }); + + foreach (var job in jobs) + { + ContentPanel.Children.Add(CreatePendingCard(job, rule, root, folders, selectedJobs)); + } + + var batchPicker = CreateFolderPicker("선택한 파일에 적용할 하위 폴더", folders, null); + ContentPanel.Children.Add(batchPicker); + var applySelected = new Button + { + Content = "체크한 파일에만 일괄 적용", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + applySelected.Click += async (_, _) => + { + if (selectedJobs.Count == 0) + { + await ShowMessageAsync("먼저 적용할 파일의 체크박스를 선택하세요."); + return; + } + + if (batchPicker.SelectedItem is not string folder) + { + await ShowMessageAsync("하위 폴더를 선택하세요."); + return; + } + + await ApplySelectionAsync(selectedJobs.ToArray(), folder); + }; + ContentPanel.Children.Add(applySelected); + ContentPanel.Children.Add(new Border { Height = 1, Opacity = 0.35, Margin = new Thickness(0, 8, 0, 8) }); + } + + private Border CreatePendingCard( + DownloadJob job, + DownloadRule rule, + string root, + IReadOnlyList folders, + ISet selectedJobs) + { + var panel = new StackPanel { Spacing = 8 }; + var selected = new CheckBox { Content = "일괄 적용 대상으로 선택" }; + selected.Checked += (_, _) => selectedJobs.Add(job.Id); + selected.Unchecked += (_, _) => selectedJobs.Remove(job.Id); + panel.Children.Add(selected); + panel.Children.Add(new TextBlock + { + Text = job.CurrentFileName, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + }); + panel.Children.Add(CreateStatusBadge(job)); + panel.Children.Add(new TextBlock + { + Text = $"출처 호스트: {GetSourceHost(job)}\n브라우저: {job.Browser}\n다운로드 시작: {job.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}\n규칙: {rule.Name}\n저장 루트: {root}\n현재 선택: {DisplayFolder(job.SelectedRelativeFolder)}", + TextWrapping = TextWrapping.Wrap, + }); + + var picker = CreateFolderPicker("이 파일의 하위 폴더", folders, job.SelectedRelativeFolder); + panel.Children.Add(picker); + + var apply = new Button { Content = "이 파일에 적용", HorizontalAlignment = HorizontalAlignment.Stretch }; + apply.Click += async (_, _) => + { + if (picker.SelectedItem is not string folder) + { + await ShowMessageAsync("하위 폴더를 선택하세요."); + return; + } + + await ApplySelectionAsync([job.Id], folder); + }; + panel.Children.Add(apply); + + var later = new Button { Content = "나중에 선택", HorizontalAlignment = HorizontalAlignment.Stretch }; + later.Click += async (_, _) => + { + deferredSelectionPrompts.Add(job.Id); + await ShowMessageAsync("선택 대기 상태를 유지합니다. 이 파일은 이동되지 않았습니다."); + }; + panel.Children.Add(later); + + var skip = new Button { Content = "이번 파일은 이동하지 않기", HorizontalAlignment = HorizontalAlignment.Stretch }; + skip.Click += async (_, _) => await SkipSelectionAsync(job.Id); + panel.Children.Add(skip); + + return new Border + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(16), + CornerRadius = new CornerRadius(8), + Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + Child = panel, + }; + } + + private static ComboBox CreateFolderPicker( + string header, + IReadOnlyList folders, + string? selectedRelativeFolder) + { + var selected = string.IsNullOrEmpty(selectedRelativeFolder) ? "." : selectedRelativeFolder; + var selectedIndex = folders.ToList().FindIndex(folder => string.Equals(folder, selected, StringComparison.OrdinalIgnoreCase)); + return new ComboBox + { + Header = header, + ItemsSource = folders, + SelectedIndex = selectedIndex >= 0 ? selectedIndex : folders.Count > 0 ? 0 : -1, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + } + + private async Task ApplySelectionAsync(IReadOnlyList jobIds, string displayedFolder) + { + var relativeFolder = displayedFolder == "." ? string.Empty : displayedFolder; + var response = await agent.SendAsync( + "selection.complete", + new SelectionCompletedPayload(jobIds, relativeFolder)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "선택 적용에 실패했습니다."); + return; + } + + foreach (var jobId in jobIds) + { + deferredSelectionPrompts.Remove(jobId); + } + + await ShowPendingAsync(); + } + + private async Task SkipSelectionAsync(Guid jobId) + { + var confirmed = await ShowConfirmationAsync( + "이번 파일은 이동하지 않기", + "이 작업은 Download Router의 이동만 건너뜁니다. 브라우저가 받은 실제 파일은 삭제하지 않습니다."); + if (!confirmed) + { + return; + } + + var response = await agent.SendAsync("selection.skip", new SelectionSkippedPayload(jobId)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "이동 건너뛰기 적용에 실패했습니다."); + return; + } + + deferredSelectionPrompts.Remove(jobId); + await ShowPendingAsync(); + } + + private static string DisplayFolder(string? relativeFolder) + => string.IsNullOrEmpty(relativeFolder) ? "저장 루트" : relativeFolder; +} diff --git a/src/DownloadRouter.App/MainWindow.xaml.cs b/src/DownloadRouter.App/MainWindow.xaml.cs index 0c5f19d..cfcd6ef 100644 --- a/src/DownloadRouter.App/MainWindow.xaml.cs +++ b/src/DownloadRouter.App/MainWindow.xaml.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using DownloadRouter.Core.Models; using DownloadRouter.Core.Paths; using Microsoft.UI.Xaml; @@ -11,18 +10,14 @@ public sealed partial class MainWindow : Window private readonly AgentClient agent = new(); private readonly PathTokenResolver pathResolver = new(new WindowsKnownPathProvider()); private readonly PathBoundaryValidator boundaryValidator = new(); - private readonly DispatcherTimer historyRefreshTimer = new() { Interval = TimeSpan.FromSeconds(3) }; - private string? historySnapshot; - private bool historyRefreshInProgress; public MainWindow() { InitializeComponent(); Title = "eslee Download Router"; Navigation.SelectedItem = Navigation.MenuItems[0]; - historyRefreshTimer.Tick += HistoryRefreshTimer_Tick; - historyRefreshTimer.Start(); - Closed += (_, _) => historyRefreshTimer.Stop(); + InitializeLiveUpdates(); + Closed += (_, _) => StopLiveUpdates(); _ = ShowDashboardAsync(); } @@ -50,24 +45,12 @@ private Task ShowPageAsync(string tag) _ => Task.CompletedTask, }; - private async Task ShowDashboardAsync() + private void ContentScrollViewer_SizeChanged(object sender, SizeChangedEventArgs args) { - Prepare("대시보드", "규칙이 적용된 다운로드와 연결 상태를 한눈에 확인합니다."); - try - { - var jobsResponse = await agent.SendAsync("jobs.list"); - var rulesResponse = await agent.SendAsync("rules.list"); - var jobs = AgentClient.ReadData>(jobsResponse) ?? []; - var rules = AgentClient.ReadData>(rulesResponse) ?? []; - AddCard("활성 규칙", rules.Count(static rule => rule.IsEnabled).ToString()); - AddCard("저장 위치 선택 대기", jobs.Count(static job => job.Status == DownloadJobStatus.WaitingForSelection).ToString()); - AddCard("최근 완료", jobs.Count(static job => job.Status == DownloadJobStatus.Completed).ToString()); - AddCard("재시도/실패", jobs.Count(static job => job.Status is DownloadJobStatus.RetryPending or DownloadJobStatus.Failed).ToString()); - } - catch (Exception exception) - { - AddError("Agent에 연결할 수 없습니다. scripts/run-dev.ps1을 실행한 뒤 다시 시도하세요.", exception); - } + var available = Math.Max( + 0, + args.NewSize.Width - ContentViewport.Padding.Left - ContentViewport.Padding.Right); + ContentPanel.Width = Math.Min(1100, available); } private async Task ShowRulesAsync() @@ -142,130 +125,6 @@ private async Task ShowRulesAsync() } } - private async Task ShowHistoryAsync() - { - try - { - var response = await agent.SendAsync("jobs.list"); - var jobs = AgentClient.ReadData>(response) ?? []; - RenderHistory(jobs); - } - catch (Exception exception) - { - Prepare("다운로드 작업 및 이력", "사이트 규칙에 매칭되어 추적된 작업의 성공·실패·대기 상태와 정제된 출처를 표시합니다."); - AddError("다운로드 이력을 불러오지 못했습니다.", exception); - } - } - - private async void HistoryRefreshTimer_Tick(object? sender, object args) - { - if (historyRefreshInProgress - || !string.Equals( - (Navigation.SelectedItem as NavigationViewItem)?.Tag as string, - "history", - StringComparison.Ordinal)) - { - return; - } - - historyRefreshInProgress = true; - try - { - var response = await agent.SendAsync("jobs.list"); - var jobs = AgentClient.ReadData>(response) ?? []; - if (!string.Equals(historySnapshot, CreateHistorySnapshot(jobs), StringComparison.Ordinal)) - { - RenderHistory(jobs); - } - } - catch (Exception exception) - { - System.Diagnostics.Debug.WriteLine($"History refresh failed: {exception.GetType().Name}"); - } - finally - { - historyRefreshInProgress = false; - } - } - - private void RenderHistory(IReadOnlyList jobs) - { - Prepare("다운로드 작업 및 이력", "사이트 규칙에 매칭되어 추적된 작업의 성공·실패·대기 상태와 정제된 출처를 표시합니다."); - foreach (var job in jobs) - { - AddCard(job.CurrentFileName, $"{job.Browser} · {job.Status} · {job.SanitizedSource ?? "출처 확인 불가"}\n{job.ErrorMessage ?? job.FinalPath ?? string.Empty}"); - } - - if (jobs.Count == 0) - { - AddMuted("아직 규칙에 매칭되어 추적된 다운로드가 없습니다. 규칙 없는 다운로드는 이력에 추가하지 않고 브라우저 기본 동작을 유지합니다."); - } - - historySnapshot = CreateHistorySnapshot(jobs); - } - - private static string CreateHistorySnapshot(IEnumerable jobs) - => string.Join('|', jobs.Select(static job => $"{job.Id:N}:{job.Status}:{job.CompletedAt:O}:{job.ErrorCode}")); - - private async Task ShowPendingAsync() - { - Prepare("저장 위치 선택 대기", "각 규칙의 루트와 실제 하위 폴더만 선택할 수 있습니다."); - try - { - var jobs = AgentClient.ReadData>(await agent.SendAsync("jobs.list")) ?? []; - var rules = (AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []) - .ToDictionary(static rule => rule.Id); - var groups = jobs - .Where(static job => job.Status == DownloadJobStatus.WaitingForSelection) - .GroupBy(static job => job.RuleId) - .ToList(); - - foreach (var group in groups) - { - if (!rules.TryGetValue(group.Key, out var rule)) - { - continue; - } - - var root = pathResolver.Resolve(rule.StorageRoot); - var folders = EnumerateSafeFolders(root); - var picker = new ComboBox - { - Header = $"{rule.Name}: {group.Count()}개 파일", - ItemsSource = folders, - SelectedIndex = folders.Count > 0 ? 0 : -1, - HorizontalAlignment = HorizontalAlignment.Stretch, - }; - var apply = new Button { Content = "대기열 전체에 적용" }; - apply.Click += async (_, _) => - { - if (picker.SelectedItem is not string selected) - { - await ShowMessageAsync("하위 폴더를 선택하세요."); - return; - } - - var response = await agent.SendAsync("selection.complete", new SelectionCompletedPayload( - group.Select(static job => job.Id).ToArray(), - selected == "." ? string.Empty : selected)); - await ShowMessageAsync(response.Success ? "선택을 적용했습니다." : response.Message ?? "선택 적용에 실패했습니다."); - await ShowPendingAsync(); - }; - ContentPanel.Children.Add(picker); - ContentPanel.Children.Add(apply); - } - - if (groups.Count == 0) - { - AddMuted("현재 선택을 기다리는 파일이 없습니다."); - } - } - catch (Exception exception) - { - AddError("선택 대기 목록을 불러오지 못했습니다.", exception); - } - } - private Task ShowBrowsersAsync() { Prepare("브라우저 연결", "설치 상태와 개발자 모드 확장 설치 경로를 안내합니다."); @@ -417,13 +276,21 @@ private void AddError(string prefix, Exception exception) private async Task ShowMessageAsync(string message) { - var dialog = new ContentDialog + blockingDialogOpen = true; + try { - Title = "eslee Download Router", - Content = message, - CloseButtonText = "확인", - XamlRoot = ContentPanel.XamlRoot, - }; - await dialog.ShowAsync(); + var dialog = new ContentDialog + { + Title = "eslee Download Router", + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + CloseButtonText = "확인", + XamlRoot = ContentPanel.XamlRoot, + }; + await dialog.ShowAsync(); + } + finally + { + blockingDialogOpen = false; + } } } diff --git a/src/DownloadRouter.Extension/tests/download-state.test.ts b/src/DownloadRouter.Extension/tests/download-state.test.ts index 591d43e..131cfc9 100644 --- a/src/DownloadRouter.Extension/tests/download-state.test.ts +++ b/src/DownloadRouter.Extension/tests/download-state.test.ts @@ -3,10 +3,10 @@ import assert from "node:assert/strict"; import { reportedDownloadState } from "../src/download-state.js"; -test("USER_CANCELED is reported as an explicit cancellation", () => { +void test("USER_CANCELED is reported as an explicit cancellation", () => { assert.equal(reportedDownloadState("interrupted", "USER_CANCELED"), "cancelled"); }); -test("other browser errors remain interrupted", () => { +void test("other browser errors remain interrupted", () => { assert.equal(reportedDownloadState("interrupted", "NETWORK_FAILED"), "interrupted"); }); From 63b0ab5eb933fd08b763bf550d2b23a41d9caca6 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:31:47 +0900 Subject: [PATCH 07/24] fix: balance shared page spacing and viewport width --- src/DownloadRouter.App/MainWindow.xaml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/DownloadRouter.App/MainWindow.xaml b/src/DownloadRouter.App/MainWindow.xaml index 95daaa8..17c04d9 100644 --- a/src/DownloadRouter.App/MainWindow.xaml +++ b/src/DownloadRouter.App/MainWindow.xaml @@ -10,7 +10,7 @@ - + @@ -18,7 +18,7 @@ - + @@ -42,16 +42,21 @@ - + From facabef25ee9f3c2391daa68d101b2bfc8115061 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:50:02 +0900 Subject: [PATCH 08/24] fix: refresh active selection prompts safely --- .../AgentCommandHandler.cs | 4 ++++ src/DownloadRouter.App/MainWindow.Live.cs | 17 +++++++++++++++++ .../Jobs/SelectionPromptQueue.cs | 18 ++++++++++++++++++ .../ProtocolAndStateTests.cs | 15 +++++++++++++++ .../CommandValidationTests.cs | 1 + 5 files changed, 55 insertions(+) diff --git a/src/DownloadRouter.Agent/AgentCommandHandler.cs b/src/DownloadRouter.Agent/AgentCommandHandler.cs index 4a0970e..c4293b6 100644 --- a/src/DownloadRouter.Agent/AgentCommandHandler.cs +++ b/src/DownloadRouter.Agent/AgentCommandHandler.cs @@ -204,10 +204,14 @@ private async Task HandleDownloadChangedAsync( stateMachine.EnsureCanTransition(job.BrowserState, next); var routing = cancelled ? RoutingState.NotRequired : RoutingState.Failed; stateMachine.EnsureCanTransition(job.RoutingState, routing); + var reportedFileName = string.IsNullOrWhiteSpace(payload.FilePath) + ? job.CurrentFileName + : Path.GetFileName(NormalizeOptionalSourcePath(payload.FilePath)); job = job with { BrowserState = next, RoutingState = routing, + CurrentFileName = reportedFileName, ErrorCode = cancelled ? "download.cancelled" : "download.interrupted." + NormalizeBrowserError(payload.Error), ErrorMessage = cancelled ? "The user cancelled this download in the browser." diff --git a/src/DownloadRouter.App/MainWindow.Live.cs b/src/DownloadRouter.App/MainWindow.Live.cs index d654c06..943bdb3 100644 --- a/src/DownloadRouter.App/MainWindow.Live.cs +++ b/src/DownloadRouter.App/MainWindow.Live.cs @@ -13,6 +13,7 @@ public sealed partial class MainWindow private readonly HashSet deferredSelectionPrompts = []; private ContentDialog? currentSelectionDialog; private Guid? currentSelectionJobId; + private string? currentSelectionSnapshot; private bool currentSelectionInvalidated; private bool liveUpdateInProgress; private bool selectionPromptInProgress; @@ -99,6 +100,17 @@ private void SynchronizeSelectionPrompt(IReadOnlyList active) currentSelectionInvalidated = true; currentSelectionDialog?.Hide(); } + else if (currentSelectionJobId is Guid activeCurrent) + { + var currentJob = active.First(candidate => candidate.Id == activeCurrent); + var updatedSnapshot = CreateSelectionPromptSnapshot(currentJob, active.Count); + if (!string.Equals(currentSelectionSnapshot, updatedSnapshot, StringComparison.Ordinal)) + { + currentSelectionInvalidated = true; + selectionPromptQueue.EnqueueFirst(activeCurrent); + currentSelectionDialog?.Hide(); + } + } foreach (var job in active) { @@ -131,6 +143,7 @@ private async Task ShowSelectionPromptAsync(DownloadJob job, DownloadRule rule, { selectionPromptInProgress = true; currentSelectionJobId = job.Id; + currentSelectionSnapshot = CreateSelectionPromptSnapshot(job, otherPendingCount + 1); currentSelectionInvalidated = false; string? action = null; try @@ -222,6 +235,7 @@ private async Task ShowSelectionPromptAsync(DownloadJob job, DownloadRule rule, { currentSelectionDialog = null; currentSelectionJobId = null; + currentSelectionSnapshot = null; currentSelectionInvalidated = false; selectionPromptInProgress = false; } @@ -268,6 +282,9 @@ private void BringSelectionWindowToFront() _ = SetForegroundWindow(windowHandle); } + private static string CreateSelectionPromptSnapshot(DownloadJob job, int activeCount) + => $"{job.Id:N}:{job.CurrentFileName}:{job.BrowserState}:{job.RoutingState}:{activeCount}"; + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetForegroundWindow(nint windowHandle); diff --git a/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs b/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs index 72d9c64..f020d72 100644 --- a/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs +++ b/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs @@ -18,6 +18,24 @@ public bool Enqueue(Guid jobId) return true; } + public bool EnqueueFirst(Guid jobId) + { + if (jobId == Guid.Empty || !queued.Add(jobId)) + { + return false; + } + + var remaining = queue.ToArray(); + queue.Clear(); + queue.Enqueue(jobId); + foreach (var id in remaining) + { + queue.Enqueue(id); + } + + return true; + } + public bool TryDequeue(out Guid jobId) { if (!queue.TryDequeue(out jobId)) diff --git a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs index 3a8d12a..be25e02 100644 --- a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs +++ b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs @@ -82,6 +82,21 @@ public void SelectionPromptQueueIsFifoAndRejectsDuplicateJobs() Assert.False(queue.TryDequeue(out _)); } + [Fact] + public void SelectionPromptQueueCanRefreshTheCurrentJobWithoutBreakingFifo() + { + var queue = new SelectionPromptQueue(); + var current = Guid.NewGuid(); + var next = Guid.NewGuid(); + queue.Enqueue(next); + queue.EnqueueFirst(current); + + Assert.True(queue.TryDequeue(out var refreshed)); + Assert.True(queue.TryDequeue(out var queuedNext)); + Assert.Equal(current, refreshed); + Assert.Equal(next, queuedNext); + } + private static DownloadJob CreateJob(BrowserTransferState browserState, RoutingState routingState) => new( Guid.NewGuid(), BrowserKind.Whale, Guid.NewGuid().ToString("N"), "sample.bin", "sample.bin", diff --git a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs index 8514d70..f642738 100644 --- a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs +++ b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs @@ -217,6 +217,7 @@ public async Task UserCancellationIsTerminalPendingIsExcludedAndNoFileIsMoved() Assert.Equal(BrowserTransferState.Cancelled, job!.BrowserState); Assert.Equal(RoutingState.NotRequired, job.RoutingState); Assert.Equal("download.cancelled", job.ErrorCode); + Assert.Equal("cancelled.txt", job.CurrentFileName); Assert.Empty(DownloadJobQueries.ActiveSelections(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None))); Assert.Equal(0, DownloadJobQueries.CountDashboard([job]).WaitingForSelection); Assert.True(File.Exists(source)); From 8e83b74dc049a7a23dcef80ecfb896f30439da4f Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:57:56 +0900 Subject: [PATCH 09/24] fix: keep cancellation file names stable --- src/DownloadRouter.Agent/AgentCommandHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DownloadRouter.Agent/AgentCommandHandler.cs b/src/DownloadRouter.Agent/AgentCommandHandler.cs index c4293b6..16cc669 100644 --- a/src/DownloadRouter.Agent/AgentCommandHandler.cs +++ b/src/DownloadRouter.Agent/AgentCommandHandler.cs @@ -206,7 +206,7 @@ private async Task HandleDownloadChangedAsync( stateMachine.EnsureCanTransition(job.RoutingState, routing); var reportedFileName = string.IsNullOrWhiteSpace(payload.FilePath) ? job.CurrentFileName - : Path.GetFileName(NormalizeOptionalSourcePath(payload.FilePath)); + : Path.GetFileName(NormalizeOptionalSourcePath(payload.FilePath)) ?? job.CurrentFileName; job = job with { BrowserState = next, From dd459f1679e5ee79a4d14ae94379c2a53e9b1ca6 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:58:04 +0900 Subject: [PATCH 10/24] docs: record SelectSubfolder Whale verification --- ARCHITECTURE.md | 32 ++++++------ CHANGELOG.md | 17 +++++-- DEVELOPMENT.md | 7 ++- HANDOFF.md | 12 ++--- PROJECT_STATE.md | 37 +++++++------- TESTING.md | 42 ++++++++++++---- docs/BROWSER_COMPATIBILITY.md | 18 +++++-- scripts/manual-download-server.ps1 | 81 ++++++++++++++++++++++++++++++ 8 files changed, 190 insertions(+), 56 deletions(-) create mode 100644 scripts/manual-download-server.ps1 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4c39d80..7eacc94 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,7 +21,7 @@ chrome.downloads.onCreated/onChanged ### Extension -Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고 `onChanged`에서 완료 또는 중단 상태를 전송합니다. 연결 오류는 다운로드를 취소하거나 변경하지 않습니다. 실패할 때만 원문 대신 `host-not-found`, `host-exited`, `agent.unavailable` 같은 제한된 분류 코드를 service worker 콘솔에 기록합니다. +Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고 `onChanged`에서 완료, 사용자 취소 또는 다른 중단 상태를 전송합니다. 시작 시 Agent가 가진 진행 중 ID만 `downloads.search`로 재확인해 놓친 종료 이벤트를 재전송합니다. 연결 오류는 다운로드를 취소하거나 변경하지 않습니다. 실패할 때만 원문 대신 `host-not-found`, `host-exited`, `agent.unavailable` 같은 제한된 분류 코드를 service worker 콘솔에 기록합니다. Chromium downloads API에는 신뢰할 수 있는 시작 탭 URL 필드가 없습니다. 따라서 활성 탭을 다운로드 출처로 추측하지 않고 `initiatingPageUrl`은 근거가 있을 때만 사용합니다. 현재 확장은 referrer를 우선 근거로 전달합니다. @@ -31,7 +31,7 @@ Chromium downloads API에는 신뢰할 수 있는 시작 탭 URL 필드가 없 ### Agent -현재 사용자 범위 mutex로 단일 인스턴스를 보장합니다. 여러 Native Host 연결을 비동기 Named Pipe 서버로 병합합니다. 명령 처리기는 DB 오류와 경계 오류를 안전한 오류 응답으로 바꾸며, 규칙이 없을 때 작업을 만들지 않습니다. +현재 사용자 범위 mutex로 단일 인스턴스를 보장합니다. 여러 Native Host 연결을 비동기 Named Pipe 서버로 병합합니다. 명령 처리기는 DB 오류와 경계 오류를 안전한 오류 응답으로 바꾸며, 규칙이 없을 때 작업을 만들지 않습니다. SelectSubfolder 작업 생성 시 설치 폴더 또는 개발 출력의 App만 실행 요청하며 실패해도 브라우저 다운로드와 원본 위치를 바꾸지 않습니다. ### Core @@ -40,7 +40,7 @@ Chromium downloads API에는 신뢰할 수 있는 시작 탭 URL 필드가 없 - IDN 호스트 정규화와 도메인 경계 비교 - 경로 토큰 해석과 루트 내부 검증 - URL 민감정보 제거 -- 명시적 작업 상태 머신 +- 브라우저 전송과 라우팅 결정을 분리한 명시적 상태 머신 ### Infrastructure @@ -56,25 +56,25 @@ SQLite 마이그레이션은 `schema_migrations`, `rules`, `download_jobs`, `job ### WinUI 3 App -Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. 현재 대시보드, 규칙, 이력, 규칙별 선택 대기 그룹, 브라우저 연결, 일반 설정, 진단, 정보 화면 골격이 있습니다. +Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. 현재 대시보드, 규칙, 파일별 선택 대기, 상태 필터·삭제 이력, 브라우저 연결, 일반 설정, 진단, 정보 화면이 있습니다. App은 사용자 범위 mutex로 단일 인스턴스를 유지하고 1초 간격으로 Pending을 읽어 중복 없는 FIFO ContentDialog를 하나씩 표시합니다. -앱은 unpackaged WinUI 3 프로세스를 manifest에서 Per-Monitor V2로 선언합니다. 크기와 여백은 장치 독립 픽셀(DIP)을 사용하고 루트에서 layout rounding을 적용합니다. 모든 화면은 하나의 `NavigationView -> vertical ScrollViewer -> stretch viewport -> MaxWidth 1100 form` 구조를 공유합니다. 1200 DIP 미만에서는 16 DIP, 넓은 창에서는 32 DIP 좌우 패딩을 사용하며 가로 스크롤은 만들지 않습니다. 이력 화면은 현재 작업의 ID/상태 스냅샷이 바뀔 때만 다시 렌더링합니다. +앱은 unpackaged WinUI 3 프로세스를 manifest에서 Per-Monitor V2로 선언합니다. 크기와 여백은 장치 독립 픽셀(DIP)을 사용하고 루트에서 layout rounding을 적용합니다. 모든 화면은 하나의 `NavigationView -> vertical ScrollViewer -> stretch viewport -> MaxWidth 1100 form` 구조를 공유합니다. 실제 폼 폭은 `ViewportWidth - 좌우 Padding`과 1100 DIP 중 작은 값이며 가로 스크롤을 만들지 않습니다. Compact는 `16,32,16,24`, Wide는 `32,48,32,32` DIP 패딩을 사용합니다. ## 작업 상태 ```text -WaitingForDownload -WaitingForSelection -ReadyToMove -Moving -Completed -RetryPending -Interrupted -Cancelled -Failed +BrowserTransferState RoutingState +InProgress WaitingForSelection +Complete SelectionReady +Cancelled Moving +Interrupted RetryPending + Completed + Skipped + Failed + NotRequired ``` -상태 전이는 `DownloadJobStateMachine`이 검사합니다. 완료 전에 사용자가 선택하면 `WaitingForDownload`, 완료 후 선택하면 `ReadyToMove`를 거쳐 동일한 이동 경로를 사용합니다. +두 축의 상태 전이는 `DownloadJobStateMachine`이 각각 검사합니다. 완료 전에 선택하면 `InProgress / SelectionReady`만 저장하고 파일을 이동하지 않습니다. `Complete / SelectionReady`가 된 뒤에만 Moving으로 전이합니다. 취소는 `Cancelled / NotRequired`, 다른 중단은 `Interrupted / Failed`이며 둘 다 Pending과 이동 대상에서 제외됩니다. 기존 단일 `Status` 열은 마이그레이션과 호환 표시용 파생 값으로 유지합니다. ## 저장 위치 선택 경계 @@ -91,7 +91,7 @@ Failed ## 후속 설계 항목 -- Agent가 선택 대기 알림/창을 활성화하는 방식 +- 트레이/Windows 알림과 세션을 넘는 “나중에 선택” 정책 - 새 폴더 만들기와 새로 고침을 포함한 전용 트리 선택기 - Windows 시작 시 실행 설정 저장 및 등록 - 중단된 작업의 시작 시 복구 워커 diff --git a/CHANGELOG.md b/CHANGELOG.md index 700bb53..cd9f970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,17 +12,22 @@ - 도메인·정확한 호스트·URL 포함 규칙과 출처 대상 선택 - 경로 토큰, 루트 경계와 reparse point 방어 - 동일/교차 볼륨 안전 이동, 안정화 확인, SHA-256 검증, 중복 이름 보존 -- WinUI 대시보드, 규칙, 이력, 선택 대기 그룹, 브라우저, 진단 화면 -- 27개 .NET 테스트, 4개 TypeScript 테스트, Agent/Native Host 스모크 테스트 +- WinUI 대시보드, 규칙, 파일별 선택 대기, 상태 필터·삭제 이력, 브라우저, 진단 화면 +- 38개 .NET 테스트, 8개 TypeScript 테스트, Agent/Native Host 스모크 테스트 - per-user Native Host 등록 및 Inno Setup 설치 골격 - Native Host 등록의 Windows PowerShell 5.1 회귀 테스트와 개인정보 로그 회귀 테스트 - 매칭 규칙의 작업 생성·실제 파일 이동을 검증하는 Agent 통합 테스트 +- BrowserTransferState/RoutingState 분리와 기존 DB v2 마이그레이션 +- 단일 FIFO 선택 팝업, App 자동 시작 요청, 파일별/선택 항목 폴더 적용과 이동 건너뛰기 +- Extension 시작 시 진행 중 브라우저 다운로드 상태 재조정 ### Changed - WinUI 앱을 Per-Monitor V2로 선언하고 공통 콘텐츠를 세로 ScrollViewer, stretch viewport, 1100 DIP 반응형 폼으로 재구성 - 다운로드 이력이 규칙에 매칭된 작업만 표시함을 명시하고 데이터 변경 시 3초 간격으로 자동 갱신 - Whale 설치 탐지에 Program Files와 Program Files (x86) 후보를 추가 +- 대시보드가 다운로드 중, 선택 대기, 완료, 취소/중단, 재시도/실패를 별도로 집계 +- 취소 이력을 자동 삭제하지 않고 취소선과 낮은 opacity로 유지하며 DB 이력만 사용자 삭제 ### Fixed @@ -32,6 +37,10 @@ - PowerShell 5.1이 Native Host manifest에 UTF-8 BOM을 붙이던 문제 - Extension이 Native Messaging 실패를 아무 진단 없이 무시하던 문제 - Native Host가 자동 시작한 Agent에 브라우저 표준 스트림 핸들을 상속하던 문제 +- `WaitingForSelection` 하나로 전송 중/완료/취소를 동시에 표현해 취소 항목이 Pending에 남던 문제 +- ScrollViewer가 좁은 창에서도 1100 DIP 폼을 측정해 우측 콘텐츠가 창 밖으로 나가던 문제 +- 모든 페이지 제목이 TitleBar 바로 아래에 붙어 보이던 공통 상단 여백 +- 다운로드 완료로 파일명이 바뀐 동안 열린 선택 팝업이 이전 이름과 상태를 유지하던 문제 ### Security @@ -41,7 +50,7 @@ ### Known limitations -- Whale의 매칭 규칙 자동 이동/직접 선택은 실제 브라우저 검증 전 +- Whale Automatic 규칙과 다른 Chromium 브라우저의 실제 이동은 검증 전 - Windows 100%/150% 및 FHD/4K 실제 디스플레이 시각 검증 전 -- 선택 대기 자동 알림/팝업, 새 폴더 생성/새로 고침 미구현 +- 트레이/Windows 알림, 새 폴더 생성/새로 고침 미구현 - 시작 시 실행 토글과 미완료 작업 자동 복구 미연결 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 90d99d3..bc10289 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -34,7 +34,8 @@ bootstrap은 버전 검사, NuGet restore, `npm ci`, 로컬 데이터 디렉터 - unpackaged App manifest의 `PerMonitorV2, PerMonitor` 선언을 유지합니다. 제거하면 Windows가 앱을 96 DPI 비트맵으로 확대할 수 있습니다. - XAML 크기와 간격은 DIP 기준이며 물리 픽셀이나 모니터 해상도별 고정 폭을 사용하지 않습니다. -- 새 화면은 공통 `ContentPanel` 안에서 가로 stretch하고, 폼 전체는 1100 DIP `MaxWidth`를 넘지 않습니다. +- 새 화면은 공통 `ContentPanel` 안에서 가로 stretch하고, 폼 전체는 `min(1100 DIP, ScrollViewer viewport - padding)`을 넘지 않습니다. +- Compact/Wide의 공통 상단 여백 32/48 DIP를 화면별 Spacer로 중복 구현하지 않습니다. - 좁은 창은 가로 스크롤 대신 세로 스크롤을 사용하고, 최소 폭을 지정할 때는 실제 720 DIP 창에서 우측 경계를 확인합니다. - 시각 검증 시 `GetProcessDpiAwareness`, `GetWindowDpiAwarenessContext`, `GetDpiForWindow`와 진단 화면의 `XamlRoot.RasterizationScale`을 함께 확인합니다. @@ -50,6 +51,10 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build.ps1 -Configu # Agent 파이프 왕복 확인 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 -Configuration Debug +# SelectSubfolder 완료 전 선택/취소용 로컬 지연 다운로드 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\manual-download-server.ps1 ` + -Root .\artifacts\manual-whale\server -Port 8765 + # 설치 페이로드 준비 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\publish.ps1 -Configuration Release ``` diff --git a/HANDOFF.md b/HANDOFF.md index 69706a4..f2002ea 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6,7 +6,7 @@ - 저장소: `https://github.com/esleeeeee/eslee-Download-Router.git` - 이어서 사용할 브랜치: `feature/initial-spike`(기준 통합 브랜치는 `develop`) -- 구현 기준 커밋: `9975c47bd0ac64e26fa651dcd8162aac068f73d8` (`feat: bootstrap download router spike`) +- 구현 기준: Draft PR #1의 최신 `feature/initial-spike` - 원격 상태: `main`, `develop`, `feature/initial-spike` 게시 및 GitHub 플러그인 검증 완료 - .NET SDK: `10.0.302` - Node.js: 24 이상 @@ -32,20 +32,20 @@ Edge에서 `edge://extensions`를 열고 개발자 모드를 켠 뒤 “압축 ## 현재 정상 기준 - Debug solution build 성공, 경고 0/오류 0 -- .NET tests 27/27 통과 -- Extension lint/build와 Node tests 4/4 통과 +- .NET tests 38/38 통과 +- Extension lint/build와 Node tests 8/8 통과 - Agent Named Pipe ping 성공 - Native Host self-test 성공 - App/Agent/Native Host self-contained Release publish 성공 -- 공식 `feature/initial-spike@9975c47` 클린 클론 bootstrap/build/test 성공 +- QHD 125% Per-Monitor V2, 900×850 수평 넘침 0, 파일별 SelectSubfolder Whale 실검증 성공 -브라우저 수동 검증, installer, UI 시각 검증은 아직 정상 기준에 포함되지 않습니다. +Whale Automatic 규칙, 다른 Chromium 브라우저, installer, 100%/150% 및 FHD/4K 시각 검증은 아직 정상 기준에 포함되지 않습니다. ## 가장 먼저 할 작업 Edge에서 실제 확장 -> Native Host -> Agent ping과 다운로드 이벤트를 확인하세요. 성공/실패 로그를 `docs/BROWSER_COMPATIBILITY.md`에 기록하고, 실패하면 `scripts\diagnose.ps1`과 Native Host `-Action Status` 결과부터 확인합니다. -그 다음 자동 규칙, 미매칭 fail-open, 직접 선택 묶음, 중복 이름, 취소/중단 순서로 검증합니다. +그 다음 자동 규칙, 미매칭 fail-open, 파일별 직접 선택, 중복 이름, 취소/중단 순서로 검증합니다. ## PC별로 다시 지정할 항목 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 465edbd..80d09e2 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,14 +1,13 @@ # 프로젝트 상태 -기준일: 2026-07-21 (Asia/Seoul) +기준일: 2026-07-22 (Asia/Seoul) ## 요약 - 현재 단계: Phase 0 완료, Phase 1 기술 스파이크 완료, 핵심 기능 개발 중 - 공식 저장소: https://github.com/esleeeeee/eslee-Download-Router - 게시 브랜치: `main`, `develop`, `feature/initial-spike` — 세 브랜치 모두 공식 원격에 생성 완료 -- 작업 브랜치/기준 HEAD: `feature/initial-spike` / `3a4e25e86f570d03a37f61f4754b3d11bd501717` -- 현재 문서는 위 HEAD에 적용한 로컬 진단·수정 결과를 포함하며 원격 push는 하지 않음 +- 작업 브랜치: `feature/initial-spike`, 기존 Draft PR #1에서 후속 변경 추적 ## 구현 완료 @@ -24,12 +23,15 @@ - 다운로드 완료 파일 안정화와 동일 볼륨 move - 교차 볼륨 copy -> 크기/SHA-256 검증 -> rename -> 원본 삭제 - 중복 파일 번호 보존과 재시도 상태 -- 규칙별 선택 대기 작업 묶음 처리 API와 App 화면 +- 브라우저 전송 상태와 라우팅 상태를 분리한 작업 모델과 v1 → v2 SQLite 마이그레이션 +- 파일별 SelectSubfolder 카드, 명시적으로 체크한 항목만 일괄 적용, 건너뛰기와 세션 단위 나중에 선택 +- 단일 FIFO 선택 팝업, 실행 중 App 활성화, 미실행 App 시작 요청, 취소 시 자동 닫기 +- 취소선·상태 필터·개별/선택/취소 이력 삭제 UI와 실제 파일 비삭제 보장 - WinUI 3 필수 내비게이션 화면 골격과 Agent 진단 - 브라우저 설치/관리 주소 adapter와 HKCU Native Host 등록 스크립트 - Windows PowerShell 5.1 절대 경로/HKCU 등록 호환성과 BOM 없는 UTF-8 Native Host manifest - 실패할 때만 분류 코드를 남기는 Extension Native Messaging 진단 로그 -- Per-Monitor V2 WinUI 렌더링, 반응형 공통 콘텐츠/세로 스크롤, 3초 이력 변경 감지 +- Per-Monitor V2 WinUI 렌더링, 뷰포트 실폭 제한, 32/48 DIP 공통 상단 여백, 1초 상태 변경 감지 - self-contained win-x64 publish와 per-user Inno Setup 골격 ## 빌드와 테스트 @@ -37,9 +39,9 @@ | 항목 | 결과 | |---|---| | `.NET Debug build` | 성공, 경고 0, 오류 0 | -| `.NET tests` | 29/29 통과(Core 21, Infrastructure 5, Integration 3) | +| `.NET tests` | 38/38 통과(Core 24, Infrastructure 6, Integration 8) | | Extension ESLint/TypeScript build | 성공 | -| Extension Node tests | 6/6 통과 | +| Extension Node tests | 8/8 통과 | | Agent Named Pipe ping | 성공 | | Native Host self-test/길이 접두사 ping/Agent 자동 시작 | 성공 | | Native Host Agent 장애 fail-open | `agent.unavailable`, Host 종료 코드 0, 브라우저 비차단 응답 확인 | @@ -53,21 +55,22 @@ | 브라우저 | 설치 탐지 | 확장 로드 | 다운로드 이벤트 | Native Messaging | 자동 저장 | 직접 선택 | |---|---|---|---|---|---|---| -| Whale | 성공 | 성공 | 성공 | 성공 | 수동 검증 필요 | 수동 검증 필요 | +| Whale | 성공 | 성공 | 성공 | 성공 | 수동 검증 필요 | 성공(SelectSubfolder) | | Edge | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Chrome | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Brave | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Vivaldi | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Opera | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -Whale은 설치된 4.38.386.14, 고정 ID 확장과 `dist` 로드, HKCU 등록, 실제 미매칭 다운로드 이벤트의 Agent 도달을 확인했습니다. 규칙이 없는 다운로드는 의도적으로 작업을 만들지 않으므로 이력이 비는 것이 정상입니다. 실제 Whale에서 매칭 규칙 파일 이동과 직접 선택은 아직 성공으로 표시하지 않았습니다. +Whale 4.38.386.14에서 로컬 HTTP 테스트 파일과 격리 DB/저장 루트를 사용했습니다. 완료 후 `quick-a.bin → A`, `quick-b.bin → B`, 다운로드 중 선택 후 완료된 `select-slow.bin → D` 이동을 확인했습니다. 4개 동시 시작은 팝업 중첩 없이 FIFO로 하나씩 표시됐고 각 파일을 A/B/C/D에 개별 적용했습니다. 브라우저 취소는 팝업 자동 닫기, `Cancelled / NotRequired`, Pending 제외, 이동 0건으로 확인했습니다. 취소 이력 정리 후 이미 이동된 실제 파일은 모두 유지됐습니다. Automatic 규칙의 실브라우저 검증은 별도 항목으로 남습니다. ## 알려진 문제와 제한 - downloads API만으로 다운로드 시작 탭 URL을 신뢰성 있게 얻을 수 없어 활성 탭을 추측하지 않습니다. 현재 referrer와 파일 URL을 분리해 사용합니다. -- 직접 선택은 App의 대기 화면에서 규칙별 묶음으로 처리합니다. Agent의 자동 창 활성화/트레이 알림, 새 폴더 생성, 폴더 새로 고침은 미구현입니다. +- App 실행 파일을 찾을 수 있으면 Agent가 선택 UI를 시작하고, 실행 중이면 1초 폴링과 창 활성화로 FIFO 팝업을 표시합니다. 설치 손상으로 App 실행 파일을 찾지 못해도 다운로드는 원래 위치에서 완료되고 Pending에 남습니다. +- “나중에 선택” 팝업 억제는 현재 App 세션 동안만 유지됩니다. 트레이/Windows 알림, 새 폴더 생성, 폴더 새로 고침은 미구현입니다. - 시작 시 실행 UI는 실제 Windows 등록과 연결되지 않았습니다. -- Agent 시작 시 미완료 이동 복구 워커는 미구현입니다. DB에는 작업 상태가 유지됩니다. +- Extension 시작 시 Agent의 진행 중 ID를 브라우저 다운로드 기록과 대조해 완료·취소·중단을 재전송합니다. 브라우저 기록에서 사라진 오래된 작업은 근거 없이 완료·삭제하지 않습니다. - Whale registry adapter는 이 PC에서 검증했습니다. Brave/Vivaldi/Opera adapter는 실제 PC 검증이 필요합니다. - 브라우저 자체 “다운로드 전에 저장 위치 확인” 설정 감지와 안내는 문서만 있고 UI 자동 감지는 미구현입니다. - QHD 125% 실제 실행과 좁은/넓은 창 시각·경계 검증은 완료했습니다. Windows 100%/150%, FHD/4K 실기기, 키보드/스크린리더 접근성 및 installer 동작 검증은 남아 있습니다. @@ -81,12 +84,12 @@ Whale은 설치된 4.38.386.14, 고정 ID 확장과 `dist` 로드, HKCU 등록, ## 다음 작업 -1. Whale에서 `example.com` 샘플 규칙으로 공개 파일 자동 이동과 이력 자동 갱신을 수동 검증 -2. Whale 직접 선택 규칙과 Native Host 장애 중 실다운로드 유지 검증 -3. Windows 100%/150% 및 FHD/4K에서 UI 실배율 시각 검증 -4. Edge에서 개발자 모드 확장 로드와 Native Messaging 왕복을 실제 검증 -5. 규칙별 전용 폴더 트리 picker에 새 폴더/새로 고침 추가 및 Agent 알림 연결 -6. Agent 시작 시 미완료 작업 복구와 시작 시 실행 옵션 구현 +1. Whale Automatic 규칙과 Native Host 장애 중 실다운로드 유지 검증 +2. Windows 100%/150% 및 FHD/4K에서 UI 실배율 시각 검증 +3. Edge에서 개발자 모드 확장 로드와 Native Messaging 왕복을 실제 검증 +4. 파일별 picker에 새 폴더/새로 고침 및 영구 알림 설정 추가 +5. 브라우저 기록에서 사라진 오래된 비종료 작업의 사용자 확인 복구 흐름 설계 +6. 시작 시 실행 옵션 구현 7. Inno Setup 설치/제거와 앱 UI 검증 ## 실행 명령 diff --git a/TESTING.md b/TESTING.md index ef160b9..4dc3b4e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -13,14 +13,14 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\diagnose.ps1 ``` -2026-07-21 현재 로컬 결과: +2026-07-22 현재 로컬 결과: - .NET Debug build: 성공, 경고 0, 오류 0 -- .NET tests: 29/29 통과 - - Core 21: 규칙, IDN/도메인 경계, 경로, 개인정보, 프로토콜, 상태 전이 - - Infrastructure 5: SQLite migration/round-trip, 동일·교차 볼륨 흐름, 중복 이름, 로그 개인정보 제거 - - Integration 3: 명령 거부, 미매칭 fail-open, 매칭 규칙의 이력 생성과 실제 파일 이동 -- Extension: ESLint 성공, TypeScript compile 성공, Node tests 6/6 통과, dist build 성공 +- .NET tests: 38/38 통과 + - Core 24: 규칙, IDN/도메인 경계, 경로, 개인정보, 분리 상태 전이, 대시보드 집계, FIFO 중복 방지 + - Infrastructure 6: v1 → v2 SQLite 마이그레이션, round-trip, 동일·교차 볼륨, 중복 이름, 로그 개인정보 제거 + - Integration 8: 명령 거부, 미매칭 fail-open, 완료 전/후 파일별 선택, 선택 항목 일괄 적용, 취소/중단, 실제 파일 비삭제 이력 삭제 +- Extension: ESLint 성공, TypeScript compile 성공, Node tests 8/8 통과, dist build 성공 - Windows PowerShell 5.1 등록 테스트: drive/UNC 절대 경로, 상대 경로 거부, origin 필터, BOM 없는 UTF-8 통과 - Agent Named Pipe ping: 성공 - Native Host `--self-test`, 길이 접두사 ping, 게시 Agent 자동 시작: 성공 @@ -35,6 +35,8 @@ QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습 - 수정 후: `PerMonitorAware`, `PER_MONITOR_AWARE_V2=true`, window DPI 120 - 900x850 물리 픽셀 창: 사이트 규칙의 보이는 입력/드롭다운/버튼 11개가 모두 우측 창 경계 안, 세로 스크롤바 생성 - 2400x1250 물리 픽셀 창: 폼 폭 1375px = 1100 DIP, 콘텐츠 좌우 여백 309/312px +- 900x850 UI Automation 재검증: 최대 하위 요소 우측 경계가 창보다 9px 안쪽, 수평 넘침 0 +- Wide 공통 상단 여백: 48 DIP, 실제 QHD 125% 창에서 제목 위치가 창 상단 기준 39px에서 96px로 이동 - 대시보드, 이력, 선택 대기, 브라우저 연결, 설정, 진단, 정보는 같은 공통 콘텐츠 루트를 사용하므로 동일 폭/스크롤 수정이 적용됨 100%/150% Windows 실배율과 FHD/4K 물리 디스플레이는 현재 단일 QHD 125% 환경에서 직접 전환하지 않았습니다. manifest/DIP/MaxWidth 구조와 좁은·넓은 창 경계는 검증했지만 이 실기기 항목은 수동 확인으로 남깁니다. @@ -55,20 +57,42 @@ QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습 2. 해당 브라우저만 `scripts\register-native-host.ps1 -Browser `으로 등록 3. `src\DownloadRouter.Extension\dist`를 개발자 모드에서 로드 4. Agent와 App 실행 후 진단 화면의 ping 확인 -5. `example.com`과 하위 도메인용 테스트 규칙을 만들고 전용 테스트 목적 폴더 지정 +5. `127.0.0.1` 또는 전용 테스트 호스트 규칙을 만들고 전용 테스트 목적 폴더 지정 6. 규칙 없는 사이트 다운로드가 원래 위치에 남는지 확인 7. 자동 규칙으로 일반 HTTP/HTTPS 파일 이동 확인 8. 동일 이름 3개가 번호를 붙여 모두 보존되는지 확인 -9. 직접 선택 규칙에서 여러 파일이 규칙별 한 그룹으로 표시되는지 확인 +9. 직접 선택 규칙에서 각 파일이 별도 카드와 FIFO 팝업으로 표시되고 서로 다른 폴더를 선택할 수 있는지 확인 10. 루트 외부, `..`, junction/reparse point 경로가 거부되는지 확인 11. 다운로드 취소·중단이 이력에 남고 파일을 삭제하지 않는지 확인 12. Native Host 등록을 제거하거나 Agent를 종료한 상태에서도 다운로드가 계속되는지 확인 13. 브라우저의 “다운로드 전 저장 위치 확인” 설정과 충돌 안내 확인 +### SelectSubfolder 반복 가능한 Whale 시나리오 + +테스트 전용 격리 DB와 폴더를 사용하고 실제 사용자 다운로드를 삭제하지 않습니다. `scripts/manual-download-server.ps1`은 브라우저 완료 전 선택과 취소를 재현할 수 있도록 로컬 파일을 지연 전송합니다. + +1. A/B/C/D 하위 폴더가 있는 테스트 저장 루트와 바이너리 fixture를 준비합니다. +2. 별도 `DOWNLOAD_ROUTER_DATA_DIR`로 Agent를 시작하고 `127.0.0.1` SelectSubfolder 규칙을 등록합니다. +3. 서버를 시작합니다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\manual-download-server.ps1 ` + -Root .\artifacts\manual-whale\server -Port 8765 -DelayMilliseconds 75 +``` + +4. 완료 후 두 파일을 A/B에 각각 선택하고 둘 다 이동되는지 확인합니다. +5. 큰 파일 다운로드 중 D를 선택해 `InProgress / SelectionReady`와 목적지 파일 없음, 완료 후 자동 이동을 확인합니다. +6. 큰 파일을 Whale에서 취소해 팝업 닫힘, `Cancelled / NotRequired`, Pending 제외, 이동 0건을 확인합니다. +7. 4개를 동시에 시작해 팝업이 하나만 열리고 파일별 선택으로 A/B/C/D 이동이 가능한지 확인합니다. +8. 취소된 이력 정리 후 이미 이동된 실제 파일이 그대로 있는지 확인합니다. + +2026-07-22 실제 Whale 4.38.386.14 결과: 위 4~8번 성공. 테스트 파일은 격리 루트에 보존했고 취소 작업은 DB 이력에서만 삭제했습니다. + ## 브라우저 검증 상태 - Whale 4.38.386.14: 확장 `dist` 로드와 고정 ID, 실제 다운로드 이벤트, Native Messaging 왕복, 미매칭 fail-open 성공 -- Whale 매칭 규칙 자동 이동/직접 선택: 미검증 +- Whale SelectSubfolder: 완료 전/후 선택, A/B 개별 폴더, 취소, 4개 FIFO, 이력 삭제 성공 +- Whale Automatic 규칙: 미검증 - Edge/Chrome/Brave/Vivaldi/Opera 확장과 Native Messaging: 미검증 - UI: QHD 125% 시각·경계 검증 완료, 100%/150%와 접근성 미검증 - 네트워크 드라이브 및 실제 다른 물리 드라이브 diff --git a/docs/BROWSER_COMPATIBILITY.md b/docs/BROWSER_COMPATIBILITY.md index 7268c4f..7a5d341 100644 --- a/docs/BROWSER_COMPATIBILITY.md +++ b/docs/BROWSER_COMPATIBILITY.md @@ -1,12 +1,12 @@ # 브라우저 호환성 및 검증 기록 -마지막 갱신: 2026-07-21 +마지막 갱신: 2026-07-22 -자동화된 Agent/Native Host 스모크 테스트와 실제 브라우저 수동 테스트를 구분합니다. Whale은 실제 미매칭 다운로드 경로까지 부분 성공이며 자동 이동과 직접 선택은 아직 미검증입니다. +자동화된 Agent/Native Host 스모크 테스트와 실제 브라우저 수동 테스트를 구분합니다. Whale의 SelectSubfolder 파일별 선택은 실제 브라우저 성공이며 Automatic 규칙은 아직 미검증입니다. | 브라우저 | 지원 수준 | 설치 탐지 | 확장 로드 | 다운로드 이벤트 | Native Messaging | 자동 저장 | 직접 선택 | 상태 | |---|---|---|---|---|---|---|---|---| -| Naver Whale | 정식 목표 | 성공 | 성공 | 성공 | 성공 | 미검증 | 미검증 | 부분 성공 | +| Naver Whale | 정식 목표 | 성공 | 성공 | 성공 | 성공 | 미검증 | 성공 | 부분 성공 | | Microsoft Edge | 정식 목표 | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 확장 수동 검증 필요 | | Google Chrome | 정식 목표 | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 확장 수동 검증 필요 | | Brave | 호환 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | adapter 후보 | @@ -66,3 +66,15 @@ Native Messaging: - Agent current-user Named Pipe ping: 성공 - Agent 장애 fail-open: `agent.unavailable`, Host 종료 코드 0, 브라우저 비차단 응답 확인 - 해석: 로컬 프로토콜과 Whale 미매칭 경로는 확인했지만 다른 브라우저 호환성과 Whale 자동 이동은 증명하지 않음 + +## 2026-07-22 — Whale 4.38.386.14 SelectSubfolder 검증 + +- Windows: Windows 11, QHD 2560×1440, 125% +- 데이터: 별도 `DOWNLOAD_ROUTER_DATA_DIR`, 로컬 HTTP 지연 서버, 전용 A/B/C/D 저장 폴더 +- 완료 후 선택: `quick-a.bin → A`, `quick-b.bin → B`, 원본 다운로드 위치에서 안전 이동 성공 +- 완료 전 선택: 진행 중 D 선택 시 `InProgress / SelectionReady`, 목적지 파일 없음. 완료 이벤트 후 24 MiB 파일 D 이동 성공 +- 사용자 취소: Whale 취소 버튼 후 팝업 자동 닫힘, `Cancelled / NotRequired`, `download.cancelled`, Pending 0, 이동 0 +- 4개 동시 시작: 단일 FIFO 팝업에서 네 작업을 순서대로 처리하고 A/B/C/D에 각각 이동 +- 이력 삭제: 취소 이력 정리 확인창을 거쳐 DB 항목만 제거. 이미 이동된 테스트 파일 4개 유지 +- UI: 900×850 창에서 UI Automation 기준 수평 넘침 0, 공통 Wide 상단 패딩 48 DIP 확인 +- Automatic 규칙, 다른 Chromium 브라우저, Native Host 장애 중 실다운로드: 미검증 diff --git a/scripts/manual-download-server.ps1 b/scripts/manual-download-server.ps1 new file mode 100644 index 0000000..6bd7940 --- /dev/null +++ b/scripts/manual-download-server.ps1 @@ -0,0 +1,81 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Root, + [ValidateRange(1024, 65535)] + [int]$Port = 8765, + [ValidateRange(0, 5000)] + [int]$DelayMilliseconds = 75, + [ValidateRange(4096, 1048576)] + [int]$ChunkSize = 65536 +) + +$rootFull = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar) +if (-not (Test-Path -LiteralPath $rootFull -PathType Container)) { + throw "Download fixture root does not exist: $rootFull" +} + +$listener = New-Object Net.HttpListener +$listener.Prefixes.Add("http://127.0.0.1:$Port/") +$listener.Start() +Write-Host "Manual download server listening on http://127.0.0.1:$Port/" + +try { + while ($listener.IsListening) { + $context = $listener.GetContext() + try { + $relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) + if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) { + $context.Response.StatusCode = 404 + $context.Response.Close() + continue + } + + $filePath = [IO.Path]::GetFullPath((Join-Path $rootFull $relative)) + if ((-not $filePath.StartsWith( + $rootFull + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) -or + (-not (Test-Path -LiteralPath $filePath -PathType Leaf))) { + $context.Response.StatusCode = 404 + $context.Response.Close() + continue + } + + $file = Get-Item -LiteralPath $filePath + $context.Response.StatusCode = 200 + $context.Response.ContentType = 'application/octet-stream' + $context.Response.ContentLength64 = $file.Length + $context.Response.AddHeader('Content-Disposition', "attachment; filename=`"$($file.Name)`"") + $buffer = New-Object byte[] $ChunkSize + $input = [IO.File]::OpenRead($file.FullName) + try { + while (($read = $input.Read($buffer, 0, $buffer.Length)) -gt 0) { + $context.Response.OutputStream.Write($buffer, 0, $read) + $context.Response.OutputStream.Flush() + if ($DelayMilliseconds -gt 0) { + Start-Sleep -Milliseconds $DelayMilliseconds + } + } + } + finally { + $input.Dispose() + $context.Response.OutputStream.Dispose() + } + } + catch [Net.HttpListenerException] { + if ($listener.IsListening) { + Write-Warning $_.Exception.GetType().Name + } + } + catch [IO.IOException] { + # Browser cancellation closes the response stream. This is expected in manual tests. + } + finally { + try { $context.Response.Close() } catch { } + } + } +} +finally { + $listener.Stop() + $listener.Close() +} From 5d4a074e4f01b106d975b13948b5f326dea95e3f Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:28:10 +0900 Subject: [PATCH 11/24] feat: extend routing jobs and live metadata --- .../AgentCommandHandler.cs | 202 ++++++++++++++++-- .../Jobs/DownloadJobStateMachine.cs | 4 +- .../Jobs/DownloadPresentation.cs | 41 ++++ .../Models/DomainModels.cs | 28 ++- .../Paths/SafeFolderTreeProvider.cs | 75 +++++++ src/DownloadRouter.Extension/package.json | 2 +- .../src/background.ts | 23 +- src/DownloadRouter.Extension/src/file-name.ts | 18 ++ src/DownloadRouter.Extension/src/protocol.ts | 1 + .../src/source-attribution.ts | 12 +- .../tests/file-name.test.ts | 14 ++ .../Storage/DownloadRouterRepository.cs | 84 +++++++- .../ProtocolAndStateTests.cs | 58 +++++ .../RepositoryTests.cs | 27 +++ .../CommandValidationTests.cs | 193 ++++++++++++++++- 15 files changed, 736 insertions(+), 46 deletions(-) create mode 100644 src/DownloadRouter.Core/Jobs/DownloadPresentation.cs create mode 100644 src/DownloadRouter.Core/Paths/SafeFolderTreeProvider.cs create mode 100644 src/DownloadRouter.Extension/src/file-name.ts create mode 100644 src/DownloadRouter.Extension/tests/file-name.test.ts diff --git a/src/DownloadRouter.Agent/AgentCommandHandler.cs b/src/DownloadRouter.Agent/AgentCommandHandler.cs index 16cc669..9da1254 100644 --- a/src/DownloadRouter.Agent/AgentCommandHandler.cs +++ b/src/DownloadRouter.Agent/AgentCommandHandler.cs @@ -51,14 +51,17 @@ public async Task HandleAsync(AgentCommand request, CancellationT timestamp = DateTimeOffset.UtcNow, }), "download.started" => await HandleDownloadStartedAsync(request, cancellationToken).ConfigureAwait(false), + "download.metadata" => await HandleDownloadMetadataAsync(request, cancellationToken).ConfigureAwait(false), "download.changed" => await HandleDownloadChangedAsync(request, cancellationToken).ConfigureAwait(false), "rules.list" => AgentResponse.Ok(request.RequestId, await repository.GetRulesAsync(cancellationToken).ConfigureAwait(false)), "rules.upsert" => await HandleRuleUpsertAsync(request, cancellationToken).ConfigureAwait(false), + "rules.delete" => await HandleRuleDeleteAsync(request, cancellationToken).ConfigureAwait(false), "jobs.list" => AgentResponse.Ok(request.RequestId, await repository.GetRecentJobsAsync(cancellationToken: cancellationToken).ConfigureAwait(false)), "jobs.delete" => await HandleJobsDeleteAsync(request, cancellationToken).ConfigureAwait(false), "downloads.active" => await HandleActiveDownloadsAsync(request, cancellationToken).ConfigureAwait(false), "selection.complete" => await HandleSelectionCompletedAsync(request, cancellationToken).ConfigureAwait(false), "selection.skip" => await HandleSelectionSkippedAsync(request, cancellationToken).ConfigureAwait(false), + "route.change" => await HandleRouteChangeAsync(request, cancellationToken).ConfigureAwait(false), "job.retry" => await HandleRetryAsync(request, cancellationToken).ConfigureAwait(false), "diagnostics.status" => AgentResponse.Ok(request.RequestId, new { @@ -101,22 +104,26 @@ private async Task HandleDownloadStartedAsync( { var payload = Deserialize(request.Payload); if (!TryParseBrowser(payload.Browser, out var browser) - || string.IsNullOrWhiteSpace(payload.DownloadId) - || string.IsNullOrWhiteSpace(payload.FileName)) + || string.IsNullOrWhiteSpace(payload.DownloadId)) { - return AgentResponse.Error(request.RequestId, "download.invalid-metadata", "Browser, download ID, and file name are required."); + return AgentResponse.Error(request.RequestId, "download.invalid-metadata", "Browser and download ID are required."); } var existing = await repository.GetJobAsync(browser, payload.DownloadId, cancellationToken).ConfigureAwait(false); if (existing is not null) { + existing = await UpdateFileNameAsync(existing, payload.FileName, payload.FilePath, cancellationToken).ConfigureAwait(false); return AgentResponse.Ok(request.RequestId, new { tracked = true, jobId = existing.Id, existing = true }); } + var trustedFileName = DownloadPresentation.TrustedFileName(payload.FileName) + ?? DownloadPresentation.TrustedFileName(payload.FilePath) + ?? string.Empty; + var metadata = new DownloadMetadata( browser, payload.DownloadId, - Path.GetFileName(payload.FileName), + trustedFileName, payload.FilePath, payload.InitiatingPageUrl, payload.InitialUrl, @@ -138,8 +145,8 @@ private async Task HandleDownloadStartedAsync( Guid.NewGuid(), browser, payload.DownloadId, - Path.GetFileName(payload.FileName), - Path.GetFileName(payload.FileName), + trustedFileName, + trustedFileName, sanitizer.Sanitize(payload.InitiatingPageUrl), sanitizer.Sanitize(payload.InitialUrl), sanitizer.Sanitize(payload.FinalUrl), @@ -174,6 +181,32 @@ private async Task HandleDownloadStartedAsync( }); } + private async Task HandleDownloadMetadataAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + if (!TryParseBrowser(payload.Browser, out var browser)) + { + return AgentResponse.Error(request.RequestId, "download.unknown-browser", "The browser is not supported."); + } + + var job = await repository.GetJobAsync(browser, payload.DownloadId, cancellationToken).ConfigureAwait(false); + if (job is null) + { + return AgentResponse.Ok(request.RequestId, new { tracked = false, failOpen = true }); + } + + var updated = await UpdateFileNameAsync(job, payload.FileName, payload.FilePath, cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, new + { + tracked = true, + jobId = updated.Id, + fileName = DownloadPresentation.DisplayFileName(updated), + changed = !string.Equals(job.CurrentFileName, updated.CurrentFileName, StringComparison.Ordinal), + }); + } + private async Task HandleDownloadChangedAsync( AgentCommand request, CancellationToken cancellationToken) @@ -202,11 +235,16 @@ private async Task HandleDownloadChangedAsync( } stateMachine.EnsureCanTransition(job.BrowserState, next); - var routing = cancelled ? RoutingState.NotRequired : RoutingState.Failed; - stateMachine.EnsureCanTransition(job.RoutingState, routing); + var routing = cancelled ? job.RoutingState : RoutingState.Failed; + if (!cancelled) + { + stateMachine.EnsureCanTransition(job.RoutingState, routing); + } var reportedFileName = string.IsNullOrWhiteSpace(payload.FilePath) ? job.CurrentFileName - : Path.GetFileName(NormalizeOptionalSourcePath(payload.FilePath)) ?? job.CurrentFileName; + : DownloadPresentation.TrustedFileName(payload.FileName) + ?? DownloadPresentation.TrustedFileName(NormalizeOptionalSourcePath(payload.FilePath)) + ?? job.CurrentFileName; job = job with { BrowserState = next, @@ -238,7 +276,9 @@ private async Task HandleDownloadChangedAsync( job = job with { OriginalPath = sourcePath, - CurrentFileName = Path.GetFileName(sourcePath), + CurrentFileName = DownloadPresentation.TrustedFileName(payload.FileName) + ?? DownloadPresentation.TrustedFileName(sourcePath) + ?? job.CurrentFileName, BrowserState = BrowserTransferState.Complete, ErrorCode = null, ErrorMessage = null, @@ -259,6 +299,20 @@ private async Task HandleDownloadChangedAsync( }); } + if (job.RoutingState == RoutingState.Skipped) + { + job = job with { CompletedAt = DateTimeOffset.UtcNow }; + await repository.UpdateJobAsync(job, "download.completed-routing-skipped", cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, new + { + tracked = true, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + destinationPath = (string?)null, + }); + } + await repository.UpdateJobAsync(job, "download.completed", cancellationToken).ConfigureAwait(false); var moved = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); return AgentResponse.Ok(request.RequestId, new @@ -278,10 +332,27 @@ private async Task HandleRuleUpsertAsync( { var rule = Deserialize(request.Payload); _ = pathTokenResolver.Resolve(rule.StorageRoot); - await repository.UpsertRuleAsync(rule with { UpdatedAt = DateTimeOffset.UtcNow }, cancellationToken).ConfigureAwait(false); + var existing = await repository.GetRuleAsync(rule.Id, cancellationToken).ConfigureAwait(false); + var persisted = rule with + { + CreatedAt = existing?.CreatedAt ?? rule.CreatedAt, + UpdatedAt = DateTimeOffset.UtcNow, + }; + await repository.UpsertRuleAsync(persisted, cancellationToken).ConfigureAwait(false); return AgentResponse.Ok(request.RequestId, new { ruleId = rule.Id }); } + private async Task HandleRuleDeleteAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + var deleted = await repository.DeleteRuleAsync(payload.RuleId, cancellationToken).ConfigureAwait(false); + return deleted + ? AgentResponse.Ok(request.RequestId, new { ruleId = payload.RuleId, jobsDeleted = 0, filesDeleted = 0 }) + : AgentResponse.Error(request.RequestId, "rule.not-found", "The rule was not found or was already deleted."); + } + private async Task HandleSelectionCompletedAsync( AgentCommand request, CancellationToken cancellationToken) @@ -345,9 +416,10 @@ private async Task HandleSelectionSkippedAsync( return AgentResponse.Error(request.RequestId, "job.not-found", "The download job was not found."); } - if (!job.IsSelectionPending) + if (job.BrowserState is not (BrowserTransferState.InProgress or BrowserTransferState.Complete) + || job.RoutingState is not (RoutingState.WaitingForSelection or RoutingState.SelectionReady)) { - return AgentResponse.Error(request.RequestId, "selection.not-pending", "This download is no longer waiting for a folder selection."); + return AgentResponse.Error(request.RequestId, "selection.not-pending", "This download can no longer skip folder routing."); } stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.Skipped); @@ -376,6 +448,78 @@ private async Task HandleJobsDeleteAsync( return AgentResponse.Ok(request.RequestId, new { deleted, filesDeleted = 0 }); } + private async Task HandleRouteChangeAsync( + AgentCommand request, + CancellationToken cancellationToken) + { + var payload = Deserialize(request.Payload); + var job = await repository.GetJobAsync(payload.JobId, cancellationToken).ConfigureAwait(false); + if (job is null) + { + return AgentResponse.Error(request.RequestId, "job.not-found", "The download job was not found."); + } + + if (!DownloadPresentation.CanChangeRoute(job)) + { + return AgentResponse.Error( + request.RequestId, + "route.change-not-allowed", + job.BrowserState is BrowserTransferState.Cancelled or BrowserTransferState.Interrupted + ? "Cancelled or interrupted downloads cannot change destination." + : "This download state cannot change destination."); + } + + var rule = await repository.GetRuleAsync(job.RuleId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The matched rule no longer exists."); + var root = pathTokenResolver.Resolve(rule.StorageRoot); + _ = boundaryValidator.ValidateRelativeFolder(root, payload.RelativeFolder); + + if (job.RoutingState == RoutingState.Completed) + { + if (!payload.ConfirmCompletedMove) + { + return AgentResponse.Error(request.RequestId, "route.confirmation-required", "Moving an already routed file requires explicit confirmation."); + } + + if (string.IsNullOrWhiteSpace(job.FinalPath) || !File.Exists(job.FinalPath)) + { + return AgentResponse.Error(request.RequestId, "file.source-missing", "The routed file no longer exists at its recorded location."); + } + + job = job with + { + OriginalPath = job.FinalPath, + SelectedRelativeFolder = payload.RelativeFolder, + ErrorCode = null, + ErrorMessage = null, + }; + var movedAgain = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); + return AgentResponse.Ok(request.RequestId, JobRouteState(movedAgain)); + } + + if (job.RoutingState is RoutingState.WaitingForSelection or RoutingState.Skipped) + { + stateMachine.EnsureCanTransition(job.RoutingState, RoutingState.SelectionReady); + job = job with { RoutingState = RoutingState.SelectionReady }; + } + + job = job with + { + SelectedRelativeFolder = payload.RelativeFolder, + ErrorCode = null, + ErrorMessage = null, + CompletedAt = null, + }; + await repository.UpdateJobAsync(job, "route.changed", cancellationToken).ConfigureAwait(false); + + if (job.BrowserState == BrowserTransferState.Complete && job.OriginalPath is not null) + { + job = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); + } + + return AgentResponse.Ok(request.RequestId, JobRouteState(job)); + } + private async Task HandleActiveDownloadsAsync( AgentCommand request, CancellationToken cancellationToken) @@ -528,5 +672,37 @@ private static object JobState(DownloadJob job) routingState = job.RoutingState.ToString(), }; + private async Task UpdateFileNameAsync( + DownloadJob job, + string? reportedFileName, + string? reportedFilePath, + CancellationToken cancellationToken) + { + var trusted = DownloadPresentation.TrustedFileName(reportedFileName) + ?? DownloadPresentation.TrustedFileName(reportedFilePath); + if (trusted is null || string.Equals(trusted, job.CurrentFileName, StringComparison.Ordinal)) + { + return job; + } + + var updated = job with + { + CurrentFileName = trusted, + OriginalFileName = string.IsNullOrWhiteSpace(job.OriginalFileName) ? trusted : job.OriginalFileName, + }; + await repository.UpdateJobAsync(updated, "download.filename-updated", cancellationToken).ConfigureAwait(false); + return updated; + } + + private static object JobRouteState(DownloadJob job) + => new + { + jobId = job.Id, + status = job.Status.ToString(), + browserState = job.BrowserState.ToString(), + routingState = job.RoutingState.ToString(), + destinationPath = job.FinalPath, + }; + private sealed record JobRetryPayload(Guid JobId); } diff --git a/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs b/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs index 5d16e01..b8ca8c7 100644 --- a/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs +++ b/src/DownloadRouter.Core/Jobs/DownloadJobStateMachine.cs @@ -22,8 +22,8 @@ private static readonly IReadOnlyDictionary> Al [RoutingState.Moving] = RoutingSet(RoutingState.Completed, RoutingState.RetryPending, RoutingState.Failed), [RoutingState.RetryPending] = RoutingSet(RoutingState.Moving, RoutingState.Failed, RoutingState.Skipped), [RoutingState.Failed] = RoutingSet(RoutingState.RetryPending), - [RoutingState.Completed] = RoutingSet(), - [RoutingState.Skipped] = RoutingSet(), + [RoutingState.Completed] = RoutingSet(RoutingState.Moving), + [RoutingState.Skipped] = RoutingSet(RoutingState.SelectionReady, RoutingState.Moving), }; public bool CanTransition(BrowserTransferState current, BrowserTransferState next) diff --git a/src/DownloadRouter.Core/Jobs/DownloadPresentation.cs b/src/DownloadRouter.Core/Jobs/DownloadPresentation.cs new file mode 100644 index 0000000..7c0814a --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/DownloadPresentation.cs @@ -0,0 +1,41 @@ +using DownloadRouter.Core.Models; + +namespace DownloadRouter.Core.Jobs; + +public static class DownloadPresentation +{ + public const string PendingFileName = "파일 이름 확인 중…"; + + public static string DisplayFileName(DownloadJob job) + => TrustedFileName(job.CurrentFileName) ?? PendingFileName; + + public static string? TrustedFileName(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var fileName = Path.GetFileName(value.Trim()); + if (string.IsNullOrWhiteSpace(fileName) + || fileName.Equals("download", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".crdownload", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".partial", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return fileName; + } + + public static bool IsCancelled(DownloadJob job) + => job.BrowserState == BrowserTransferState.Cancelled; + + public static bool CanChangeRoute(DownloadJob job) + => job.BrowserState is BrowserTransferState.InProgress or BrowserTransferState.Complete + && job.RoutingState is RoutingState.WaitingForSelection + or RoutingState.SelectionReady + or RoutingState.Skipped + or RoutingState.Completed; +} diff --git a/src/DownloadRouter.Core/Models/DomainModels.cs b/src/DownloadRouter.Core/Models/DomainModels.cs index 533e73a..50f05dd 100644 --- a/src/DownloadRouter.Core/Models/DomainModels.cs +++ b/src/DownloadRouter.Core/Models/DomainModels.cs @@ -33,6 +33,12 @@ public enum StorageMode SelectSubfolder, } +public enum WindowCloseBehavior +{ + MinimizeToTray, + ExitApplication, +} + public enum DownloadJobStatus { Detected, @@ -186,7 +192,14 @@ public sealed record DownloadChangedPayload( string DownloadId, string State, string? FilePath, - string? Error); + string? Error, + string? FileName = null); + +public sealed record DownloadMetadataChangedPayload( + string Browser, + string DownloadId, + string? FilePath, + string? FileName); public sealed record SelectionCompletedPayload( IReadOnlyList JobIds, @@ -194,8 +207,15 @@ public sealed record SelectionCompletedPayload( public sealed record SelectionSkippedPayload(Guid JobId); +public sealed record JobRouteChangePayload( + Guid JobId, + string RelativeFolder, + bool ConfirmCompletedMove = false); + public sealed record JobsDeletePayload(IReadOnlyList JobIds); +public sealed record RuleDeletePayload(Guid RuleId); + public sealed record ActiveDownloadsPayload(string Browser); public sealed record ActiveBrowserDownload(Guid JobId, string DownloadId); @@ -213,19 +233,25 @@ public static class ProtocolConstants public const int MaximumMessageBytes = 1024 * 1024; public const string AgentPipeName = "eslee.download-router.agent.v1"; public const string AppMutexName = "Local\\eslee.DownloadRouter.App"; + public const string AppActivationEventName = "Local\\eslee.DownloadRouter.App.Activate"; + public const string AppShutdownEventName = "Local\\eslee.DownloadRouter.App.Shutdown"; + public const string AgentShutdownEventName = "Local\\eslee.DownloadRouter.Agent.Shutdown"; public static readonly ISet AllowedCommands = new HashSet(StringComparer.Ordinal) { "ping", "download.started", + "download.metadata", "download.changed", "rules.list", "rules.upsert", + "rules.delete", "jobs.list", "jobs.delete", "downloads.active", "selection.complete", "selection.skip", + "route.change", "job.retry", "diagnostics.status", }; diff --git a/src/DownloadRouter.Core/Paths/SafeFolderTreeProvider.cs b/src/DownloadRouter.Core/Paths/SafeFolderTreeProvider.cs new file mode 100644 index 0000000..37b8c00 --- /dev/null +++ b/src/DownloadRouter.Core/Paths/SafeFolderTreeProvider.cs @@ -0,0 +1,75 @@ +namespace DownloadRouter.Core.Paths; + +public sealed record FolderTreeEntry( + string Name, + string FullPath, + string RelativePath, + bool IsAccessible, + string? ErrorMessage = null); + +public sealed record FolderTreeChildren( + IReadOnlyList Entries, + string? ErrorMessage = null); + +public sealed class SafeFolderTreeProvider(PathBoundaryValidator boundaryValidator) +{ + public Task GetChildrenAsync( + string storageRoot, + string parentPath, + CancellationToken cancellationToken = default) + => Task.Run(() => GetChildren(storageRoot, parentPath, cancellationToken), cancellationToken); + + private FolderTreeChildren GetChildren( + string storageRoot, + string parentPath, + CancellationToken cancellationToken) + { + var root = Path.GetFullPath(storageRoot); + var parent = Path.GetFullPath(parentPath); + boundaryValidator.EnsureWithin(root, parent); + if (!Directory.Exists(parent)) + { + return new FolderTreeChildren([], "폴더가 존재하지 않습니다."); + } + + try + { + var result = new List(); + foreach (var child in Directory.EnumerateDirectories(parent) + .OrderBy(static path => Path.GetFileName(path), StringComparer.CurrentCultureIgnoreCase)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var fullPath = Path.GetFullPath(child); + boundaryValidator.EnsureWithin(root, fullPath); + if ((File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + result.Add(new FolderTreeEntry( + Path.GetFileName(fullPath), + fullPath, + Path.GetRelativePath(root, fullPath), + true)); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + result.Add(new FolderTreeEntry( + Path.GetFileName(child), + child, + string.Empty, + false, + "이 폴더에 접근할 수 없습니다.")); + } + } + + return new FolderTreeChildren(result); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + return new FolderTreeChildren([], "이 폴더의 하위 항목을 읽을 수 없습니다."); + } + } +} diff --git a/src/DownloadRouter.Extension/package.json b/src/DownloadRouter.Extension/package.json index 77273e3..47bab94 100644 --- a/src/DownloadRouter.Extension/package.json +++ b/src/DownloadRouter.Extension/package.json @@ -11,7 +11,7 @@ "clean": "node scripts/clean.mjs", "build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-manifest.mjs", "lint": "eslint .", - "test": "tsc -p tsconfig.test.json && node --test build-tests/tests/download-state.test.js build-tests/tests/native.test.js build-tests/tests/protocol.test.js build-tests/tests/source-attribution.test.js", + "test": "tsc -p tsconfig.test.json && node --test build-tests/tests/download-state.test.js build-tests/tests/file-name.test.js build-tests/tests/native.test.js build-tests/tests/protocol.test.js build-tests/tests/source-attribution.test.js", "check": "npm run lint && npm run test && npm run build" }, "devDependencies": { diff --git a/src/DownloadRouter.Extension/src/background.ts b/src/DownloadRouter.Extension/src/background.ts index 80bb1cc..0e9fbca 100644 --- a/src/DownloadRouter.Extension/src/background.ts +++ b/src/DownloadRouter.Extension/src/background.ts @@ -1,5 +1,6 @@ import { detectBrowser } from "./browser.js"; import { reportedDownloadState } from "./download-state.js"; +import { trustedDownloadFileName } from "./file-name.js"; import { sendNative } from "./native.js"; import { createRequest } from "./protocol.js"; import { sourceMetadata } from "./source-attribution.js"; @@ -24,7 +25,7 @@ chrome.downloads.onCreated.addListener((item) => { chrome.downloads.onChanged.addListener((delta) => { const state = delta.state?.current; - if (state !== "complete" && state !== "interrupted") { + if (!delta.filename && state !== "complete" && state !== "interrupted") { return; } @@ -34,7 +35,13 @@ chrome.downloads.onChanged.addListener((delta) => { return; } - reportChangedDownload(item, state, delta.error?.current ?? item.error ?? null); + if (delta.filename) { + reportDownloadMetadata(item); + } + + if (state === "complete" || state === "interrupted") { + reportChangedDownload(item, state, delta.error?.current ?? item.error ?? null); + } }); }); @@ -51,11 +58,23 @@ function reportChangedDownload( downloadId: item.id.toString(), state: reportedDownloadState(state, error), filePath: item.filename || null, + fileName: trustedDownloadFileName(item.filename), error, }), ); } +function reportDownloadMetadata(item: chrome.downloads.DownloadItem): void { + void sendNative( + createRequest("download.metadata", { + browser, + downloadId: item.id.toString(), + filePath: item.filename || null, + fileName: trustedDownloadFileName(item.filename), + }), + ); +} + async function reconcileActiveDownloads(): Promise { const response = await sendNative<{ browser: string }, ActiveBrowserDownload[]>( createRequest("downloads.active", { browser }), diff --git a/src/DownloadRouter.Extension/src/file-name.ts b/src/DownloadRouter.Extension/src/file-name.ts new file mode 100644 index 0000000..6e0967f --- /dev/null +++ b/src/DownloadRouter.Extension/src/file-name.ts @@ -0,0 +1,18 @@ +export function trustedDownloadFileName(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + const parts = trimmed.split(/[\\/]/u); + const fileName = parts.at(-1)?.trim(); + if ( + !fileName || + fileName.toLowerCase() === "download" || + /\.(?:crdownload|partial|tmp)$/iu.test(fileName) + ) { + return null; + } + + return fileName; +} diff --git a/src/DownloadRouter.Extension/src/protocol.ts b/src/DownloadRouter.Extension/src/protocol.ts index a8bbbd3..6437640 100644 --- a/src/DownloadRouter.Extension/src/protocol.ts +++ b/src/DownloadRouter.Extension/src/protocol.ts @@ -4,6 +4,7 @@ export const nativeHostName = "com.eslee.download_router"; export type AgentCommandName = | "ping" | "download.started" + | "download.metadata" | "download.changed" | "downloads.active"; diff --git a/src/DownloadRouter.Extension/src/source-attribution.ts b/src/DownloadRouter.Extension/src/source-attribution.ts index 8ec3c54..97dd49a 100644 --- a/src/DownloadRouter.Extension/src/source-attribution.ts +++ b/src/DownloadRouter.Extension/src/source-attribution.ts @@ -17,7 +17,7 @@ export interface DownloadLike { export function sourceMetadata(item: DownloadLike): DownloadSourceMetadata { const filePath = nonEmpty(item.filename); return { - fileName: baseName(filePath) ?? "download", + fileName: trustedDownloadFileName(filePath) ?? "", filePath, // Chromium's downloads API does not expose the initiating tab ID. Never guess // from the active tab; referrer and file URLs remain separate fallback fields. @@ -42,16 +42,8 @@ export function httpUrlOrNull(value: string | undefined): string | null { } } -function baseName(path: string | null): string | null { - if (path === null) { - return null; - } - - const parts = path.split(/[\\/]/u); - return parts.at(-1) ?? null; -} - function nonEmpty(value: string | undefined): string | null { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : null; } +import { trustedDownloadFileName } from "./file-name.js"; diff --git a/src/DownloadRouter.Extension/tests/file-name.test.ts b/src/DownloadRouter.Extension/tests/file-name.test.ts new file mode 100644 index 0000000..ae8f894 --- /dev/null +++ b/src/DownloadRouter.Extension/tests/file-name.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { trustedDownloadFileName } from "../src/file-name.js"; + +void test("temporary Chromium names are never presented as final names", () => { + assert.equal(trustedDownloadFileName("download"), null); + assert.equal(trustedDownloadFileName("C:\\Downloads\\미확인 197533.crdownload"), null); + assert.equal(trustedDownloadFileName("file.partial"), null); + assert.equal(trustedDownloadFileName("file.tmp"), null); +}); + +void test("a downloads API final path yields only its base file name", () => { + assert.equal(trustedDownloadFileName("C:\\Downloads\\실제 파일 이름.zip"), "실제 파일 이름.zip"); +}); diff --git a/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs b/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs index 8f2ff7d..4c1c380 100644 --- a/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs +++ b/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs @@ -6,7 +6,7 @@ namespace DownloadRouter.Infrastructure.Storage; public sealed class DownloadRouterRepository(AppPaths paths) { - private const int CurrentSchemaVersion = 2; + private const int CurrentSchemaVersion = 3; public async Task InitializeAsync(CancellationToken cancellationToken = default) { @@ -37,7 +37,8 @@ CREATE TABLE IF NOT EXISTS Rules ( Priority INTEGER NOT NULL, ListOrder INTEGER NOT NULL, CreatedAt TEXT NOT NULL, - UpdatedAt TEXT NOT NULL + UpdatedAt TEXT NOT NULL, + IsDeleted INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS IX_Rules_EnabledOrder @@ -118,6 +119,7 @@ public async Task> GetRulesAsync(CancellationToken c SELECT Id, Name, IsEnabled, MatchType, MatchValue, MatchTarget, StorageRoot, StorageMode, Priority, ListOrder, CreatedAt, UpdatedAt FROM Rules + WHERE IsDeleted = 0 ORDER BY ListOrder, CreatedAt, Id; """; @@ -155,10 +157,10 @@ public async Task UpsertRuleAsync(DownloadRule rule, CancellationToken cancellat command.CommandText = """ INSERT INTO Rules( Id, Name, IsEnabled, MatchType, MatchValue, MatchTarget, StorageRoot, - StorageMode, Priority, ListOrder, CreatedAt, UpdatedAt) + StorageMode, Priority, ListOrder, CreatedAt, UpdatedAt, IsDeleted) VALUES( $id, $name, $enabled, $matchType, $matchValue, $matchTarget, $storageRoot, - $storageMode, $priority, $listOrder, $createdAt, $updatedAt) + $storageMode, $priority, $listOrder, $createdAt, $updatedAt, 0) ON CONFLICT(Id) DO UPDATE SET Name = excluded.Name, IsEnabled = excluded.IsEnabled, @@ -169,12 +171,35 @@ ON CONFLICT(Id) DO UPDATE SET StorageMode = excluded.StorageMode, Priority = excluded.Priority, ListOrder = excluded.ListOrder, - UpdatedAt = excluded.UpdatedAt; + UpdatedAt = excluded.UpdatedAt, + IsDeleted = 0; """; AddRuleParameters(command, rule); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + public async Task DeleteRuleAsync(Guid ruleId, CancellationToken cancellationToken = default) + { + if (ruleId == Guid.Empty) + { + throw new ArgumentException("A non-empty rule ID is required.", nameof(ruleId)); + } + + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE Rules + SET IsDeleted = 1, + IsEnabled = 0, + UpdatedAt = $updatedAt + WHERE Id = $id AND IsDeleted = 0; + """; + command.Parameters.AddWithValue("$id", ruleId.ToString("D")); + command.Parameters.AddWithValue("$updatedAt", Format(DateTimeOffset.UtcNow)); + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false) == 1; + } + public async Task CreateJobAsync(DownloadJob job, CancellationToken cancellationToken = default) { await using var connection = CreateConnection(); @@ -373,9 +398,11 @@ private static async Task MigrateToCurrentVersionAsync( SqliteConnection connection, CancellationToken cancellationToken) { + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); var columns = new HashSet(StringComparer.OrdinalIgnoreCase); await using (var columnCommand = connection.CreateCommand()) { + columnCommand.Transaction = transaction; columnCommand.CommandText = "PRAGMA table_info(DownloadJobs);"; await using var reader = await columnCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) @@ -389,7 +416,8 @@ private static async Task MigrateToCurrentVersionAsync( await ExecuteAsync( connection, "ALTER TABLE DownloadJobs ADD COLUMN BrowserState TEXT NOT NULL DEFAULT 'InProgress';", - cancellationToken).ConfigureAwait(false); + cancellationToken, + transaction).ConfigureAwait(false); } if (!columns.Contains("RoutingState")) @@ -397,7 +425,8 @@ await ExecuteAsync( await ExecuteAsync( connection, "ALTER TABLE DownloadJobs ADD COLUMN RoutingState TEXT NOT NULL DEFAULT 'NotRequired';", - cancellationToken).ConfigureAwait(false); + cancellationToken, + transaction).ConfigureAwait(false); } await ExecuteAsync( @@ -426,20 +455,55 @@ WHEN OriginalPath IS NOT NULL OR Status IN ('ReadyToMove', 'Moving', 'RetryPendi INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) VALUES (2, CURRENT_TIMESTAMP); """, - cancellationToken).ConfigureAwait(false); + cancellationToken, + transaction).ConfigureAwait(false); + + var ruleColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var ruleColumnCommand = connection.CreateCommand()) + { + ruleColumnCommand.Transaction = transaction; + ruleColumnCommand.CommandText = "PRAGMA table_info(Rules);"; + await using var reader = await ruleColumnCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + ruleColumns.Add(reader.GetString(1)); + } + } - if (CurrentSchemaVersion != 2) + if (!ruleColumns.Contains("IsDeleted")) + { + await ExecuteAsync( + connection, + "ALTER TABLE Rules ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;", + cancellationToken, + transaction).ConfigureAwait(false); + } + + await ExecuteAsync( + connection, + """ + INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) + VALUES (3, CURRENT_TIMESTAMP); + """, + cancellationToken, + transaction).ConfigureAwait(false); + + if (CurrentSchemaVersion != 3) { throw new InvalidOperationException("Repository migration version is inconsistent."); } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } private static async Task ExecuteAsync( SqliteConnection connection, string sql, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + SqliteTransaction? transaction = null) { await using var command = connection.CreateCommand(); + command.Transaction = transaction; command.CommandText = sql; await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } diff --git a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs index be25e02..a71fb6c 100644 --- a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs +++ b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs @@ -2,6 +2,7 @@ using DownloadRouter.Core.Ipc; using DownloadRouter.Core.Jobs; using DownloadRouter.Core.Models; +using DownloadRouter.Core.Paths; namespace DownloadRouter.Core.Tests; @@ -97,6 +98,63 @@ public void SelectionPromptQueueCanRefreshTheCurrentJobWithoutBreakingFifo() Assert.Equal(next, queuedNext); } + [Fact] + public void TemporaryDownloadNamesUseThePendingLabelUntilTrustedMetadataArrives() + { + Assert.Null(DownloadPresentation.TrustedFileName("download")); + Assert.Null(DownloadPresentation.TrustedFileName("미확인 197533.crdownload")); + Assert.Null(DownloadPresentation.TrustedFileName("C:\\Downloads\\sample.partial")); + Assert.Equal("최종 이름.zip", DownloadPresentation.TrustedFileName("C:\\Downloads\\최종 이름.zip")); + + var pending = CreateJob(BrowserTransferState.InProgress, RoutingState.WaitingForSelection) with + { + CurrentFileName = "download", + }; + Assert.Equal(DownloadPresentation.PendingFileName, DownloadPresentation.DisplayFileName(pending)); + } + + [Theory] + [InlineData(RoutingState.WaitingForSelection)] + [InlineData(RoutingState.SelectionReady)] + [InlineData(RoutingState.Skipped)] + [InlineData(RoutingState.NotRequired)] + [InlineData(RoutingState.Failed)] + public void CancellationPresentationDependsOnlyOnBrowserState(RoutingState routingState) + { + var job = CreateJob(BrowserTransferState.Cancelled, routingState); + + Assert.True(DownloadPresentation.IsCancelled(job)); + Assert.False(DownloadPresentation.CanChangeRoute(job)); + Assert.Empty(DownloadJobQueries.ActiveSelections([job])); + } + + [Fact] + public async Task FolderTreeLoadsOnlyTheExpandedLevelAndRejectsRootEscape() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "download-router-tree-tests", Guid.NewGuid().ToString("N"))).FullName; + try + { + var first = Directory.CreateDirectory(Path.Combine(root, "kr")).FullName; + Directory.CreateDirectory(Path.Combine(first, "모야지")); + Directory.CreateDirectory(Path.Combine(root, new string('긴', 110))); + var provider = new SafeFolderTreeProvider(new DownloadRouter.Core.Paths.PathBoundaryValidator()); + + var rootChildren = await provider.GetChildrenAsync(root, root, CancellationToken.None); + Assert.Contains(rootChildren.Entries, entry => entry.Name == "kr"); + Assert.DoesNotContain(rootChildren.Entries, entry => entry.Name == "모야지"); + Assert.Contains(rootChildren.Entries, entry => entry.Name.Length >= 100); + + var nested = await provider.GetChildrenAsync(root, first, CancellationToken.None); + Assert.Equal("모야지", Assert.Single(nested.Entries).Name); + await Assert.ThrowsAsync(() => + provider.GetChildrenAsync(root, Path.GetDirectoryName(root)!, CancellationToken.None)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + private static DownloadJob CreateJob(BrowserTransferState browserState, RoutingState routingState) => new( Guid.NewGuid(), BrowserKind.Whale, Guid.NewGuid().ToString("N"), "sample.bin", "sample.bin", diff --git a/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs b/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs index c1a33ef..06a5f3a 100644 --- a/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs +++ b/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs @@ -91,6 +91,33 @@ INSERT INTO DownloadJobs VALUES( await using var versionCommand = verification.CreateCommand(); versionCommand.CommandText = "SELECT COUNT(*) FROM MigrationHistory WHERE Version = 2;"; Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); + versionCommand.CommandText = "SELECT COUNT(*) FROM MigrationHistory WHERE Version = 3;"; + Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); + } + + [Fact] + public async Task SoftDeletingRulePreservesItsJobsAndAllowsNoFurtherMatching() + { + var paths = AppPaths.CreateDefault(); + var repository = new DownloadRouterRepository(paths); + await repository.InitializeAsync(CancellationToken.None); + var now = DateTimeOffset.UtcNow; + var rule = new DownloadRule( + Guid.NewGuid(), "preserved history rule", true, RuleMatchType.ExactHost, "example.com", + RuleMatchTarget.FileUrl, root, StorageMode.SelectSubfolder, 0, 0, now, now); + await repository.UpsertRuleAsync(rule, CancellationToken.None); + var job = new DownloadJob( + Guid.NewGuid(), BrowserKind.Whale, "soft-delete-1", "sample.bin", "sample.bin", + null, null, null, null, "https://example.com", rule.Id, null, null, null, + BrowserTransferState.InProgress, RoutingState.WaitingForSelection, + null, null, now, null); + await repository.CreateJobAsync(job, CancellationToken.None); + + Assert.True(await repository.DeleteRuleAsync(rule.Id, CancellationToken.None)); + + Assert.Empty(await repository.GetRulesAsync(CancellationToken.None)); + Assert.NotNull(await repository.GetRuleAsync(rule.Id, CancellationToken.None)); + Assert.NotNull(await repository.GetJobAsync(job.Id, CancellationToken.None)); } public void Dispose() diff --git a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs index f642738..603012e 100644 --- a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs +++ b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs @@ -215,7 +215,7 @@ public async Task UserCancellationIsTerminalPendingIsExcludedAndNoFileIsMoved() Assert.True(response.Success, response.Message); var job = await repository.GetJobAsync(jobId, CancellationToken.None); Assert.Equal(BrowserTransferState.Cancelled, job!.BrowserState); - Assert.Equal(RoutingState.NotRequired, job.RoutingState); + Assert.Equal(RoutingState.WaitingForSelection, job.RoutingState); Assert.Equal("download.cancelled", job.ErrorCode); Assert.Equal("cancelled.txt", job.CurrentFileName); Assert.Empty(DownloadJobQueries.ActiveSelections(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None))); @@ -271,17 +271,196 @@ public async Task DeletingHistoryDoesNotDeleteTheDownloadedFile() Assert.Equal("must remain", await File.ReadAllTextAsync(source, CancellationToken.None)); } - private static async Task CreateRuleAsync( + [Fact] + public async Task TemporaryNameUpdatesTheSameJobWhenDownloadsApiReportsFinalMetadata() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "metadata-1", "download"); + var initial = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(DownloadPresentation.PendingFileName, DownloadPresentation.DisplayFileName(initial!)); + + var temporary = await SendAsync( + handler, + "download.metadata", + new DownloadMetadataChangedPayload("Whale", "metadata-1", "C:\\Downloads\\미확인 197533.crdownload", null)); + Assert.True(temporary.Success, temporary.Message); + Assert.Equal(DownloadPresentation.PendingFileName, DownloadPresentation.DisplayFileName((await repository.GetJobAsync(jobId, CancellationToken.None))!)); + + var finalMetadata = await SendAsync( + handler, + "download.metadata", + new DownloadMetadataChangedPayload("Whale", "metadata-1", "C:\\Downloads\\실제 이름.zip", "실제 이름.zip")); + Assert.True(finalMetadata.Success, finalMetadata.Message); + Assert.Equal("실제 이름.zip", (await repository.GetJobAsync(jobId, CancellationToken.None))!.CurrentFileName); + + var duplicateStart = await SendAsync( + handler, + "download.started", + new DownloadStartedPayload( + "Whale", "metadata-1", "실제 이름.zip", null, "https://example.com/downloads", + "https://example.com/file", null, null)); + Assert.True(duplicateStart.Success, duplicateStart.Message); + Assert.True(duplicateStart.Data!.Value.GetProperty("existing").GetBoolean()); + Assert.Single(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task RouteCanChangeBeforeCompletionAndMovesImmediatelyAfterCompletion() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "kr", "모야지")); + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "route-before-complete", "tree.bin"); + + var changed = await SendAsync( + handler, + "route.change", + new JobRouteChangePayload(jobId, Path.Combine("kr", "모야지"))); + Assert.True(changed.Success, changed.Message); + var selected = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.InProgress, selected!.BrowserState); + Assert.Equal(RoutingState.SelectionReady, selected.RoutingState); + + var source = Path.Combine(incoming, "tree.bin"); + await File.WriteAllTextAsync(source, "tree route", CancellationToken.None); + var completed = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "route-before-complete", "complete", source, null, "tree.bin")); + Assert.True(completed.Success, completed.Message); + Assert.True(File.Exists(Path.Combine(destination, "kr", "모야지", "tree.bin"))); + } + + [Fact] + public async Task CompletedFileRequiresConfirmationAndCanMoveAgainWithoutOverwrite() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "other")); + await CreateRuleAsync(repository, destination, StorageMode.Automatic); + var jobId = await StartAsync(handler, "reroute-complete", "completed.txt"); + var source = Path.Combine(incoming, "completed.txt"); + await File.WriteAllTextAsync(source, "completed route", CancellationToken.None); + Assert.True((await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "reroute-complete", "complete", source, null))).Success); + + var confirmationRequired = await SendAsync( + handler, + "route.change", + new JobRouteChangePayload(jobId, "other")); + Assert.False(confirmationRequired.Success); + Assert.Equal("route.confirmation-required", confirmationRequired.ErrorCode); + + var movedAgain = await SendAsync( + handler, + "route.change", + new JobRouteChangePayload(jobId, "other", ConfirmCompletedMove: true)); + Assert.True(movedAgain.Success, movedAgain.Message); + Assert.True(File.Exists(Path.Combine(destination, "other", "completed.txt"))); + Assert.Equal("completed route", await File.ReadAllTextAsync(Path.Combine(destination, "other", "completed.txt"), CancellationToken.None)); + } + + [Fact] + public async Task RuleEditAndSoftDeletePreserveIdentityCreationTimeAndHistory() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + var rule = await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "rule-history", "history.bin"); + var attemptedCreatedAt = rule.CreatedAt.AddDays(10); + + var edited = await SendAsync( + handler, + "rules.upsert", + rule with { Name = "edited rule", IsEnabled = false, CreatedAt = attemptedCreatedAt }); + Assert.True(edited.Success, edited.Message); + var restored = await repository.GetRuleAsync(rule.Id, CancellationToken.None); + Assert.Equal(rule.Id, restored!.Id); + Assert.Equal(rule.CreatedAt, restored.CreatedAt); + Assert.Equal("edited rule", restored.Name); + Assert.False(restored.IsEnabled); + + var deleted = await SendAsync(handler, "rules.delete", new RuleDeletePayload(rule.Id)); + Assert.True(deleted.Success, deleted.Message); + Assert.Empty(await repository.GetRulesAsync(CancellationToken.None)); + Assert.NotNull(await repository.GetJobAsync(jobId, CancellationToken.None)); + } + + [Fact] + public async Task CancelledJobCannotChangeRouteRegardlessOfPreviousSelection() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "A")); + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "cancel-route", "cancel.bin"); + Assert.True((await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([jobId], "A"))).Success); + Assert.True((await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "cancel-route", "cancelled", null, "USER_CANCELED"))).Success); + + var response = await SendAsync( + handler, + "route.change", + new JobRouteChangePayload(jobId, string.Empty)); + + Assert.False(response.Success); + Assert.Equal("route.change-not-allowed", response.ErrorCode); + var cancelled = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Cancelled, cancelled!.BrowserState); + Assert.Equal(RoutingState.SelectionReady, cancelled.RoutingState); + } + + [Fact] + public async Task SkippingSelectionKeepsTheCompletedFileInItsBrowserLocation() + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "skip-route", "keep-here.txt"); + + var skipped = await SendAsync(handler, "selection.skip", new SelectionSkippedPayload(jobId)); + Assert.True(skipped.Success, skipped.Message); + + var source = Path.Combine(incoming, "keep-here.txt"); + await File.WriteAllTextAsync(source, "do not move", CancellationToken.None); + var completed = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "skip-route", "complete", source, null, "keep-here.txt")); + + Assert.True(completed.Success, completed.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Complete, job!.BrowserState); + Assert.Equal(RoutingState.Skipped, job.RoutingState); + Assert.Null(job.FinalPath); + Assert.True(File.Exists(source)); + Assert.Empty(Directory.EnumerateFiles(destination)); + } + + private static async Task CreateRuleAsync( DownloadRouterRepository repository, string destination, StorageMode storageMode) { var now = DateTimeOffset.UtcNow; - await repository.UpsertRuleAsync( - new DownloadRule( - Guid.NewGuid(), "selection rule", true, RuleMatchType.DomainAndSubdomains, "example.com", - RuleMatchTarget.InitiatingPage, destination, storageMode, 0, 0, now, now), - CancellationToken.None); + var rule = new DownloadRule( + Guid.NewGuid(), "selection rule", true, RuleMatchType.DomainAndSubdomains, "example.com", + RuleMatchTarget.InitiatingPage, destination, storageMode, 0, 0, now, now); + await repository.UpsertRuleAsync(rule, CancellationToken.None); + return rule; } private static async Task StartAsync( From 1998cd40dedc4ab03063542428dcdf226f20d993 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:28:26 +0900 Subject: [PATCH 12/24] feat: add hierarchical picker and tray workflows --- src/DownloadRouter.Agent/Program.cs | 20 +- src/DownloadRouter.App/AgentProcessManager.cs | 99 ++++++ src/DownloadRouter.App/App.xaml.cs | 92 ++++- src/DownloadRouter.App/AppPreferencesStore.cs | 40 +++ .../FolderSelectionWindow.cs | 224 ++++++++++++ src/DownloadRouter.App/FolderTreePicker.cs | 238 +++++++++++++ src/DownloadRouter.App/MainWindow.History.cs | 132 ++++++- src/DownloadRouter.App/MainWindow.Lifetime.cs | 130 +++++++ src/DownloadRouter.App/MainWindow.Live.cs | 120 +++---- src/DownloadRouter.App/MainWindow.Pending.cs | 63 ++-- src/DownloadRouter.App/MainWindow.Rules.cs | 305 ++++++++++++++++ src/DownloadRouter.App/MainWindow.Settings.cs | 67 ++++ src/DownloadRouter.App/MainWindow.xaml | 6 +- src/DownloadRouter.App/MainWindow.xaml.cs | 168 +++------ src/DownloadRouter.App/TrayIconHost.cs | 331 ++++++++++++++++++ .../Startup/AutoStartRegistration.cs | 40 +++ .../AutoStartRegistrationTests.cs | 37 ++ 17 files changed, 1868 insertions(+), 244 deletions(-) create mode 100644 src/DownloadRouter.App/AgentProcessManager.cs create mode 100644 src/DownloadRouter.App/AppPreferencesStore.cs create mode 100644 src/DownloadRouter.App/FolderSelectionWindow.cs create mode 100644 src/DownloadRouter.App/FolderTreePicker.cs create mode 100644 src/DownloadRouter.App/MainWindow.Lifetime.cs create mode 100644 src/DownloadRouter.App/MainWindow.Rules.cs create mode 100644 src/DownloadRouter.App/MainWindow.Settings.cs create mode 100644 src/DownloadRouter.App/TrayIconHost.cs create mode 100644 src/DownloadRouter.Core/Startup/AutoStartRegistration.cs create mode 100644 tests/DownloadRouter.Core.Tests/AutoStartRegistrationTests.cs diff --git a/src/DownloadRouter.Agent/Program.cs b/src/DownloadRouter.Agent/Program.cs index f448aab..f6e2362 100644 --- a/src/DownloadRouter.Agent/Program.cs +++ b/src/DownloadRouter.Agent/Program.cs @@ -1,5 +1,6 @@ using DownloadRouter.Agent; using DownloadRouter.Core.Jobs; +using DownloadRouter.Core.Models; using DownloadRouter.Core.Paths; using DownloadRouter.Core.Privacy; using DownloadRouter.Core.Rules; @@ -46,5 +47,22 @@ var repository = host.Services.GetRequiredService(); await repository.InitializeAsync().ConfigureAwait(false); await repository.RecoverInProgressJobsAsync().ConfigureAwait(false); -await host.RunAsync().ConfigureAwait(false); +using var shutdownEvent = new EventWaitHandle( + initialState: false, + EventResetMode.AutoReset, + ProtocolConstants.AgentShutdownEventName); +var shutdownRegistration = ThreadPool.RegisterWaitForSingleObject( + shutdownEvent, + (_, _) => _ = host.StopAsync(), + null, + Timeout.Infinite, + executeOnlyOnce: true); +try +{ + await host.RunAsync().ConfigureAwait(false); +} +finally +{ + shutdownRegistration.Unregister(null); +} return 0; diff --git a/src/DownloadRouter.App/AgentProcessManager.cs b/src/DownloadRouter.App/AgentProcessManager.cs new file mode 100644 index 0000000..1d38dbf --- /dev/null +++ b/src/DownloadRouter.App/AgentProcessManager.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using DownloadRouter.Core.Models; + +namespace DownloadRouter.App; + +public sealed class AgentProcessManager(AgentClient client) +{ + public static void RequestShutdown() + { + try + { + using var shutdownEvent = EventWaitHandle.OpenExisting(ProtocolConstants.AgentShutdownEventName); + shutdownEvent.Set(); + } + catch (WaitHandleCannotBeOpenedException) + { + } + } + + public async Task EnsureRunningAsync(CancellationToken cancellationToken = default) + { + if (await CanPingAsync(cancellationToken)) + { + return true; + } + + var agentPath = FindAgentPath(); + if (agentPath is null) + { + return false; + } + + Process.Start(new ProcessStartInfo + { + FileName = agentPath, + WorkingDirectory = Path.GetDirectoryName(agentPath)!, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + }); + for (var attempt = 0; attempt < 20; attempt++) + { + await Task.Delay(150, cancellationToken); + if (await CanPingAsync(cancellationToken)) + { + return true; + } + } + + return false; + } + + private async Task CanPingAsync(CancellationToken cancellationToken) + { + try + { + return (await client.SendAsync("ping", cancellationToken: cancellationToken)).Success; + } + catch (Exception exception) when (exception is IOException or TimeoutException or OperationCanceledException) + { + return false; + } + } + + private static string? FindAgentPath() + { + var installed = Path.Combine(AppContext.BaseDirectory, "DownloadRouter.Agent.exe"); + if (File.Exists(installed)) + { + return installed; + } + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DownloadRouter.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + return null; + } + + var configuration = AppContext.BaseDirectory.Contains( + $"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase) + ? "Release" + : "Debug"; + var candidate = Path.Combine( + directory.FullName, + "src", + "DownloadRouter.Agent", + "bin", + configuration, + "net10.0", + "DownloadRouter.Agent.exe"); + return File.Exists(candidate) ? candidate : null; + } +} diff --git a/src/DownloadRouter.App/App.xaml.cs b/src/DownloadRouter.App/App.xaml.cs index c80aa83..9967d56 100644 --- a/src/DownloadRouter.App/App.xaml.cs +++ b/src/DownloadRouter.App/App.xaml.cs @@ -7,24 +7,112 @@ public partial class App : Application { private Window? window; private Mutex? singleInstance; + private EventWaitHandle? activationEvent; + private RegisteredWaitHandle? activationRegistration; + private EventWaitHandle? shutdownEvent; + private RegisteredWaitHandle? shutdownRegistration; public App() { InitializeComponent(); + UnhandledException += (_, eventArgs) => + { + try + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "eslee", + "DownloadRouter", + "logs"); + Directory.CreateDirectory(directory); + File.AppendAllText( + Path.Combine(directory, "app-unhandled.log"), + $"{DateTimeOffset.UtcNow:O} {eventArgs.Exception}{Environment.NewLine}"); + } + catch + { + } + }; } protected override void OnLaunched(LaunchActivatedEventArgs args) { + var commandLine = Environment.GetCommandLineArgs(); + var background = commandLine + .Any(static argument => argument.Equals("--background", StringComparison.OrdinalIgnoreCase)); + var shutdown = commandLine + .Any(static argument => argument.Equals("--shutdown", StringComparison.OrdinalIgnoreCase)); singleInstance = new Mutex(initiallyOwned: true, ProtocolConstants.AppMutexName, out var createdNew); if (!createdNew) { + if (shutdown) + { + SignalExistingInstance(ProtocolConstants.AppShutdownEventName); + AgentProcessManager.RequestShutdown(); + } + else if (!background) + { + SignalExistingInstance(ProtocolConstants.AppActivationEventName); + } + + singleInstance.Dispose(); + singleInstance = null; + Exit(); + return; + } + + if (shutdown) + { + AgentProcessManager.RequestShutdown(); singleInstance.Dispose(); singleInstance = null; Exit(); return; } - window = new MainWindow(); - window.Activate(); + var mainWindow = new MainWindow(); + window = mainWindow; + activationEvent = new EventWaitHandle( + initialState: false, + EventResetMode.AutoReset, + ProtocolConstants.AppActivationEventName); + activationRegistration = ThreadPool.RegisterWaitForSingleObject( + activationEvent, + (_, _) => mainWindow.DispatcherQueue.TryEnqueue(mainWindow.ShowFromTray), + null, + Timeout.Infinite, + executeOnlyOnce: false); + shutdownEvent = new EventWaitHandle( + initialState: false, + EventResetMode.AutoReset, + ProtocolConstants.AppShutdownEventName); + shutdownRegistration = ThreadPool.RegisterWaitForSingleObject( + shutdownEvent, + (_, _) => mainWindow.DispatcherQueue.TryEnqueue(mainWindow.ExitFromExternalRequest), + null, + Timeout.Infinite, + executeOnlyOnce: true); + mainWindow.InitializeHost(background, () => + { + activationRegistration?.Unregister(null); + shutdownRegistration?.Unregister(null); + activationEvent?.Dispose(); + shutdownEvent?.Dispose(); + singleInstance?.Dispose(); + AgentProcessManager.RequestShutdown(); + Exit(); + }); + } + + private static void SignalExistingInstance(string eventName) + { + try + { + using var existingEvent = EventWaitHandle.OpenExisting(eventName); + existingEvent.Set(); + } + catch (WaitHandleCannotBeOpenedException) + { + } } } diff --git a/src/DownloadRouter.App/AppPreferencesStore.cs b/src/DownloadRouter.App/AppPreferencesStore.cs new file mode 100644 index 0000000..d51d337 --- /dev/null +++ b/src/DownloadRouter.App/AppPreferencesStore.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using DownloadRouter.Core.Models; + +namespace DownloadRouter.App; + +public sealed record AppPreferences(WindowCloseBehavior CloseBehavior = WindowCloseBehavior.MinimizeToTray); + +public sealed class AppPreferencesStore +{ + private readonly string configPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "eslee", + "DownloadRouter", + "config.local.json"); + + public AppPreferences Load() + { + try + { + return File.Exists(configPath) + ? JsonSerializer.Deserialize(File.ReadAllText(configPath), ProtocolJson.Options) + ?? new AppPreferences() + : new AppPreferences(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return new AppPreferences(); + } + } + + public void Save(AppPreferences preferences) + { + var directory = Path.GetDirectoryName(configPath) + ?? throw new InvalidOperationException("The settings directory could not be resolved."); + Directory.CreateDirectory(directory); + var temporary = configPath + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(preferences, ProtocolJson.Options)); + File.Move(temporary, configPath, overwrite: true); + } +} diff --git a/src/DownloadRouter.App/FolderSelectionWindow.cs b/src/DownloadRouter.App/FolderSelectionWindow.cs new file mode 100644 index 0000000..73144a5 --- /dev/null +++ b/src/DownloadRouter.App/FolderSelectionWindow.cs @@ -0,0 +1,224 @@ +using System.Runtime.InteropServices; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Graphics; + +namespace DownloadRouter.App; + +public enum FolderSelectionAction +{ + Apply, + Later, + Skip, + Closed, +} + +public sealed record FolderSelectionResult( + FolderSelectionAction Action, + string RelativeFolder); + +public sealed class FolderSelectionWindow +{ + private const int DefaultWidthDip = 560; + private const int DefaultHeightDip = 760; + private readonly Window window = new(); + private readonly FolderTreePicker picker = new(); + private readonly TextBlock details = new() + { + TextWrapping = TextWrapping.Wrap, + HorizontalAlignment = HorizontalAlignment.Stretch, + MaxWidth = DefaultWidthDip - 40, + }; + private readonly TaskCompletionSource completion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private bool completed; + + public void UpdateDescription(string description) + => details.Text = description; + + public async Task ShowAsync( + string storageRoot, + string? selectedRelativeFolder, + string description, + bool allowLater, + bool allowSkip, + CancellationToken cancellationToken = default) + { + window.Title = "다운로드 저장 위치 선택"; + window.Content = CreateContent(description, allowLater, allowSkip); + window.Closed += (_, _) => CompleteWithoutClosing(FolderSelectionAction.Closed); + await picker.InitializeAsync(storageRoot, selectedRelativeFolder, cancellationToken); + window.Activate(); + SizeAndCenterWindow(); + BringToForeground(); + + using var registration = cancellationToken.Register(() => + window.DispatcherQueue.TryEnqueue(() => Complete(FolderSelectionAction.Closed))); + return await completion.Task; + } + + private UIElement CreateContent(string description, bool allowLater, bool allowSkip) + { + var root = new Grid + { + Padding = new Thickness(20), + RowSpacing = 12, + MinWidth = 420, + MaxWidth = DefaultWidthDip, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + details.Text = description; + var detailsScroll = new ScrollViewer + { + Content = details, + MaxHeight = 150, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + }; + root.Children.Add(detailsScroll); + + picker.HorizontalAlignment = HorizontalAlignment.Stretch; + picker.VerticalAlignment = VerticalAlignment.Stretch; + picker.MinHeight = 240; + Grid.SetRow(picker, 1); + root.Children.Add(picker); + + var buttons = new StackPanel + { + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var apply = new Button + { + Content = "이 위치로 보내기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + apply.Click += (_, _) => Complete(FolderSelectionAction.Apply); + picker.SelectionChanged += (_, _) => apply.IsEnabled = picker.IsSelectionAccessible; + buttons.Children.Add(apply); + + if (allowLater) + { + var later = new Button + { + Content = "나중에 선택", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + later.Click += (_, _) => Complete(FolderSelectionAction.Later); + buttons.Children.Add(later); + } + + if (allowSkip) + { + var skip = new Button + { + Content = "이번 파일은 이동하지 않기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + skip.Click += (_, _) => Complete(FolderSelectionAction.Skip); + buttons.Children.Add(skip); + } + + var close = new Button + { + Content = "창 닫기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + close.Click += (_, _) => Complete(FolderSelectionAction.Closed); + buttons.Children.Add(close); + Grid.SetRow(buttons, 2); + root.Children.Add(buttons); + return root; + } + + private void Complete(FolderSelectionAction action) + { + if (CompleteWithoutClosing(action)) + { + window.Close(); + } + } + + private bool CompleteWithoutClosing(FolderSelectionAction action) + { + if (completed) + { + return false; + } + + completed = true; + completion.TrySetResult(new FolderSelectionResult(action, picker.SelectedRelativeFolder)); + return true; + } + + private void SizeAndCenterWindow() + { + var handle = WinRT.Interop.WindowNative.GetWindowHandle(window); + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(handle); + var appWindow = AppWindow.GetFromWindowId(windowId); + var dpi = Math.Max(96u, GetDpiForWindow(handle)); + var scale = dpi / 96d; + var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary); + var workArea = displayArea.WorkArea; + var width = Math.Max(420, Math.Min((int)Math.Round(DefaultWidthDip * scale), workArea.Width - 64)); + var height = Math.Max(520, Math.Min((int)Math.Round(DefaultHeightDip * scale), workArea.Height - 64)); + appWindow.Resize(new SizeInt32(width, height)); + appWindow.Move(new PointInt32( + workArea.X + Math.Max(0, (workArea.Width - width) / 2), + workArea.Y + Math.Max(0, (workArea.Height - height) / 2))); + if (appWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsResizable = false; + presenter.IsMaximizable = false; + } + } + + private void BringToForeground() + { + var handle = WinRT.Interop.WindowNative.GetWindowHandle(window); + _ = ShowWindow(handle, 5); + if (!SetForegroundWindow(handle)) + { + var flash = new FlashWindowInfo + { + Size = (uint)Marshal.SizeOf(), + Window = handle, + Flags = 3, + Count = 3, + Timeout = 0, + }; + _ = FlashWindowEx(ref flash); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct FlashWindowInfo + { + public uint Size; + public nint Window; + public uint Flags; + public uint Count; + public uint Timeout; + } + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint windowHandle, int command); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FlashWindowEx(ref FlashWindowInfo info); +} diff --git a/src/DownloadRouter.App/FolderTreePicker.cs b/src/DownloadRouter.App/FolderTreePicker.cs new file mode 100644 index 0000000..82ae014 --- /dev/null +++ b/src/DownloadRouter.App/FolderTreePicker.cs @@ -0,0 +1,238 @@ +using DownloadRouter.Core.Paths; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; + +namespace DownloadRouter.App; + +public sealed class FolderTreePicker : UserControl +{ + private readonly SafeFolderTreeProvider provider = new(new PathBoundaryValidator()); + private readonly TreeView tree = new() + { + HorizontalAlignment = HorizontalAlignment.Stretch, + SelectionMode = TreeViewSelectionMode.Single, + }; + private readonly TextBlock selectedPath = new() + { + TextWrapping = TextWrapping.Wrap, + Opacity = 0.75, + }; + private readonly TextBlock nodeError = new() + { + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed, + }; + private readonly ProgressRing loading = new() + { + Width = 20, + Height = 20, + Visibility = Visibility.Collapsed, + }; + private readonly Dictionary entries = []; + private readonly HashSet loadedNodes = []; + private string storageRoot = string.Empty; + + public event EventHandler? SelectionChanged; + + public FolderTreePicker() + { + tree.ItemTemplate = (DataTemplate)XamlReader.Load( + """ + + + + """); + var refresh = new Button + { + Content = "선택한 폴더 새로 고침", + HorizontalAlignment = HorizontalAlignment.Left, + }; + refresh.Click += async (_, _) => await RefreshSelectedAsync(); + tree.Expanding += Tree_Expanding; + tree.SelectionChanged += Tree_SelectionChanged; + + var panel = new Grid + { + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + RowSpacing = 8, + }; + panel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + panel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + panel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + panel.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + panel.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + panel.Children.Add(selectedPath); + Grid.SetRow(loading, 1); + panel.Children.Add(loading); + Grid.SetRow(nodeError, 2); + panel.Children.Add(nodeError); + Grid.SetRow(tree, 3); + panel.Children.Add(tree); + Grid.SetRow(refresh, 4); + panel.Children.Add(refresh); + Content = panel; + } + + public string SelectedRelativeFolder + => tree.SelectedNode is not null && entries.TryGetValue(tree.SelectedNode, out var entry) + ? entry.RelativePath == "." ? string.Empty : entry.RelativePath + : string.Empty; + + public string SelectedFullPath + => tree.SelectedNode is not null && entries.TryGetValue(tree.SelectedNode, out var entry) + ? entry.FullPath + : storageRoot; + + public bool IsSelectionAccessible + => tree.SelectedNode is not null + && entries.TryGetValue(tree.SelectedNode, out var entry) + && entry.IsAccessible; + + public async Task InitializeAsync( + string root, + string? selectedRelativeFolder = null, + CancellationToken cancellationToken = default) + { + storageRoot = Path.GetFullPath(root); + entries.Clear(); + loadedNodes.Clear(); + tree.RootNodes.Clear(); + var rootEntry = new FolderTreeEntry( + storageRoot, + storageRoot, + ".", + true); + var rootNode = CreateNode(rootEntry); + rootNode.HasUnrealizedChildren = true; + tree.RootNodes.Add(rootNode); + tree.SelectedNode = rootNode; + selectedPath.Text = $"선택 위치: {storageRoot}"; + + if (!string.IsNullOrWhiteSpace(selectedRelativeFolder)) + { + await SelectRelativePathAsync(selectedRelativeFolder, cancellationToken); + } + } + + public async Task SelectRelativePathAsync( + string relativeFolder, + CancellationToken cancellationToken = default) + { + var rootNode = tree.RootNodes.FirstOrDefault(); + if (rootNode is null) + { + return; + } + + var current = rootNode; + foreach (var part in relativeFolder.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + await EnsureChildrenLoadedAsync(current, cancellationToken); + var next = current.Children.FirstOrDefault(candidate => + entries.TryGetValue(candidate, out var entry) + && string.Equals(entry.Name, part, StringComparison.OrdinalIgnoreCase)); + if (next is null) + { + return; + } + + current.IsExpanded = true; + current = next; + } + + tree.SelectedNode = current; + } + + private TreeViewNode CreateNode(FolderTreeEntry entry) + { + var node = new TreeViewNode + { + Content = entry.Name, + HasUnrealizedChildren = entry.IsAccessible, + }; + entries[node] = entry; + return node; + } + + private async void Tree_Expanding(TreeView sender, TreeViewExpandingEventArgs args) + { + await EnsureChildrenLoadedAsync(args.Node, CancellationToken.None); + args.Node.IsExpanded = true; + } + + private void Tree_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args) + { + if (tree.SelectedNode is null || !entries.TryGetValue(tree.SelectedNode, out var entry)) + { + return; + } + + selectedPath.Text = entry.IsAccessible + ? $"선택 위치: {entry.FullPath}" + : entry.ErrorMessage ?? "이 폴더에 접근할 수 없습니다."; + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + + private async Task EnsureChildrenLoadedAsync(TreeViewNode node, CancellationToken cancellationToken) + { + if (loadedNodes.Contains(node) || !entries.TryGetValue(node, out var entry) || !entry.IsAccessible) + { + return; + } + + loadedNodes.Add(node); + loading.Visibility = Visibility.Visible; + loading.IsActive = true; + nodeError.Visibility = Visibility.Collapsed; + try + { + var children = await provider.GetChildrenAsync(storageRoot, entry.FullPath, cancellationToken); + node.Children.Clear(); + foreach (var child in children.Entries) + { + node.Children.Add(CreateNode(child)); + } + + node.HasUnrealizedChildren = false; + if (!string.IsNullOrWhiteSpace(children.ErrorMessage)) + { + nodeError.Text = children.ErrorMessage; + nodeError.Visibility = Visibility.Visible; + } + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + node.HasUnrealizedChildren = false; + nodeError.Text = "이 폴더의 하위 항목을 읽을 수 없습니다."; + nodeError.Visibility = Visibility.Visible; + } + finally + { + loading.IsActive = false; + loading.Visibility = Visibility.Collapsed; + } + } + + private async Task RefreshSelectedAsync() + { + var node = tree.SelectedNode ?? tree.RootNodes.FirstOrDefault(); + if (node is null || !entries.TryGetValue(node, out var entry) || !entry.IsAccessible) + { + return; + } + + loadedNodes.Remove(node); + node.Children.Clear(); + node.HasUnrealizedChildren = true; + await EnsureChildrenLoadedAsync(node, CancellationToken.None); + node.IsExpanded = true; + } +} diff --git a/src/DownloadRouter.App/MainWindow.History.cs b/src/DownloadRouter.App/MainWindow.History.cs index b601e30..96a903e 100644 --- a/src/DownloadRouter.App/MainWindow.History.cs +++ b/src/DownloadRouter.App/MainWindow.History.cs @@ -1,4 +1,5 @@ using DownloadRouter.Core.Models; +using DownloadRouter.Core.Jobs; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; @@ -9,6 +10,7 @@ namespace DownloadRouter.App; public sealed partial class MainWindow { private IReadOnlyList historyJobs = []; + private IReadOnlyDictionary historyRules = new Dictionary(); private readonly HashSet selectedHistoryJobs = []; private HistoryFilter historyFilter = HistoryFilter.All; private string? historySnapshot; @@ -19,7 +21,10 @@ private async Task ShowHistoryAsync() try { var response = await agent.SendAsync("jobs.list"); + var rulesResponse = await agent.SendAsync("rules.list"); historyJobs = AgentClient.ReadData>(response) ?? []; + historyRules = (AgentClient.ReadData>(rulesResponse) ?? []) + .ToDictionary(static rule => rule.Id); RenderHistory(); } catch (Exception exception) @@ -43,10 +48,45 @@ private void RenderHistory() filter.SelectionChanged += (_, _) => { historyFilter = (HistoryFilter)Math.Max(filter.SelectedIndex, 0); + selectedHistoryJobs.Clear(); RenderHistory(); }; ContentPanel.Children.Add(filter); + var visible = historyJobs.Where(MatchesHistoryFilter).ToList(); + var visibleIds = visible.Select(static job => job.Id).ToHashSet(); + var selectedVisibleCount = visibleIds.Count(selectedHistoryJobs.Contains); + var selectAll = new CheckBox + { + Content = "현재 필터의 항목 전체 선택", + IsThreeState = true, + IsChecked = visible.Count == 0 || selectedVisibleCount == 0 + ? false + : selectedVisibleCount == visible.Count ? true : null, + }; + selectAll.Checked += (_, _) => + { + selectedHistoryJobs.UnionWith(visibleIds); + RenderHistory(); + }; + selectAll.Unchecked += (_, _) => + { + selectedHistoryJobs.ExceptWith(visibleIds); + RenderHistory(); + }; + selectAll.Indeterminate += (_, _) => + { + selectedHistoryJobs.ExceptWith(visibleIds); + RenderHistory(); + }; + ContentPanel.Children.Add(selectAll); + ContentPanel.Children.Add(new TextBlock + { + Text = $"선택됨: {selectedHistoryJobs.Count}개 · 필터 변경 시 선택은 초기화됩니다.", + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap, + }); + var deleteSelected = new Button { Content = "선택 항목 삭제", @@ -68,7 +108,6 @@ private void RenderHistory() .ToArray()); ContentPanel.Children.Add(clearCancelled); - var visible = historyJobs.Where(MatchesHistoryFilter).ToList(); foreach (var job in visible) { ContentPanel.Children.Add(CreateHistoryCard(job)); @@ -106,7 +145,7 @@ private Border CreateHistoryCard(DownloadJob job) var title = new TextBlock { - Text = job.CurrentFileName, + Text = DownloadPresentation.DisplayFileName(job), FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap, TextDecorations = job.BrowserState == BrowserTransferState.Cancelled @@ -115,11 +154,26 @@ private Border CreateHistoryCard(DownloadJob job) }; panel.Children.Add(title); panel.Children.Add(CreateStatusBadge(job)); - panel.Children.Add(new TextBlock + var auxiliary = new TextBlock { Text = $"{job.Browser} · {GetSourceHost(job)} · 시작 {job.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}", TextWrapping = TextWrapping.Wrap, - }); + TextDecorations = DownloadPresentation.IsCancelled(job) + ? TextDecorations.Strikethrough + : TextDecorations.None, + Opacity = DownloadPresentation.IsCancelled(job) ? 0.55 : 1, + }; + panel.Children.Add(auxiliary); + + if (DownloadPresentation.IsCancelled(job)) + { + panel.Children.Add(new TextBlock + { + Text = "사용자가 다운로드를 취소했습니다.", + TextWrapping = TextWrapping.Wrap, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + } if (!string.IsNullOrWhiteSpace(job.FinalPath)) { @@ -131,6 +185,31 @@ private Border CreateHistoryCard(DownloadJob job) panel.Children.Add(new TextBlock { Text = $"오류 코드: {job.ErrorCode}", TextWrapping = TextWrapping.Wrap }); } + if (DownloadPresentation.CanChangeRoute(job) && historyRules.TryGetValue(job.RuleId, out var rule)) + { + var route = new Button + { + Content = job.RoutingState == RoutingState.Completed ? "다른 위치로 이동" : "저장 위치 선택/변경", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + route.Click += async (_, _) => await ChangeHistoryRouteAsync(job, rule); + panel.Children.Add(route); + } + else if (job.BrowserState is BrowserTransferState.Cancelled or BrowserTransferState.Interrupted + || job.RoutingState is RoutingState.Failed or RoutingState.RetryPending) + { + panel.Children.Add(new TextBlock + { + Text = job.BrowserState == BrowserTransferState.Cancelled + ? "취소된 다운로드는 저장 위치를 변경할 수 없습니다." + : job.BrowserState == BrowserTransferState.Interrupted + ? "중단된 다운로드는 저장 위치를 변경할 수 없습니다." + : "실패 또는 재시도 상태에서는 먼저 원본 파일 상태를 확인하세요.", + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + }); + } + var delete = new Button { Content = "이력에서 삭제", HorizontalAlignment = HorizontalAlignment.Stretch }; delete.Click += async (_, _) => await DeleteHistoryAsync([job.Id]); panel.Children.Add(delete); @@ -146,6 +225,49 @@ private Border CreateHistoryCard(DownloadJob job) }; } + private async Task ChangeHistoryRouteAsync(DownloadJob job, DownloadRule rule) + { + var root = pathResolver.Resolve(rule.StorageRoot); + var completedMove = job.RoutingState == RoutingState.Completed; + if (completedMove && !await ShowConfirmationAsync( + "이미 이동된 파일을 다시 이동", + "기존 안전 이동 절차를 사용해 이 파일만 다른 위치로 이동합니다. 같은 이름의 파일은 덮어쓰지 않습니다.")) + { + return; + } + + var result = await new FolderSelectionWindow().ShowAsync( + root, + job.SelectedRelativeFolder, + $"파일: {DownloadPresentation.DisplayFileName(job)}\n현재 상태: {DescribeStatus(job)}\n저장 루트: {root}", + allowLater: !completedMove, + allowSkip: !completedMove); + if (result.Action == FolderSelectionAction.Skip) + { + var skipped = await agent.SendAsync("selection.skip", new SelectionSkippedPayload(job.Id)); + if (!skipped.Success) + { + await ShowMessageAsync(skipped.Message ?? "이동 건너뛰기에 실패했습니다."); + } + } + else if (result.Action == FolderSelectionAction.Later) + { + deferredSelectionPrompts.Add(job.Id); + } + else if (result.Action == FolderSelectionAction.Apply) + { + var response = await agent.SendAsync( + "route.change", + new JobRouteChangePayload(job.Id, result.RelativeFolder, completedMove)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "저장 위치 변경에 실패했습니다."); + } + } + + await ShowHistoryAsync(); + } + private static Border CreateStatusBadge(DownloadJob job) => new() { @@ -226,7 +348,7 @@ private bool MatchesHistoryFilter(DownloadJob job) private static string CreateHistorySnapshot(IEnumerable jobs) => string.Join('|', jobs.Select(static job => - $"{job.Id:N}:{job.BrowserState}:{job.RoutingState}:{job.CompletedAt:O}:{job.ErrorCode}")); + $"{job.Id:N}:{job.CurrentFileName}:{job.BrowserState}:{job.RoutingState}:{job.SelectedRelativeFolder}:{job.FinalPath}:{job.CompletedAt:O}:{job.ErrorCode}")); private static string DescribeStatus(DownloadJob job) => job.BrowserState switch diff --git a/src/DownloadRouter.App/MainWindow.Lifetime.cs b/src/DownloadRouter.App/MainWindow.Lifetime.cs new file mode 100644 index 0000000..20ff1ba --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Lifetime.cs @@ -0,0 +1,130 @@ +using System.Runtime.InteropServices; +using DownloadRouter.Core.Models; +using Microsoft.UI.Windowing; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private readonly AppPreferencesStore preferencesStore = new(); + private AppPreferences preferences = new(); + private AppWindow? appWindow; + private TrayIconHost? trayIcon; + private bool exitRequested; + private Action? exitCallback; + + public void InitializeHost(bool background, Action onExit) + { + exitCallback = onExit; + preferences = preferencesStore.Load(); + var handle = WinRT.Interop.WindowNative.GetWindowHandle(this); + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(handle); + appWindow = AppWindow.GetFromWindowId(windowId); + appWindow.Closing += AppWindow_Closing; + trayIcon = new TrayIconHost( + DispatcherQueue, + ShowFromTray, + () => NavigateFromTray("pending"), + () => NavigateFromTray("settings"), + ExitFromTray); + Closed += (_, _) => + { + trayIcon?.Dispose(); + exitCallback?.Invoke(); + }; + _ = EnsureAgentForHostAsync(); + if (!background) + { + ShowFromTray(); + } + } + + public void ShowFromTray() + { + appWindow?.Show(); + Activate(); + var handle = WinRT.Interop.WindowNative.GetWindowHandle(this); + _ = ShowWindow(handle, 9); + _ = SetForegroundWindow(handle); + } + + private void NavigateFromTray(string tag) + { + ShowFromTray(); + var item = Navigation.MenuItems + .OfType() + .FirstOrDefault(candidate => string.Equals(candidate.Tag as string, tag, StringComparison.Ordinal)); + if (item is not null) + { + Navigation.SelectedItem = item; + } + } + + private void ExitFromTray() + => ExitFromExternalRequest(); + + public void ExitFromExternalRequest() + { + exitRequested = true; + StopLiveUpdates(); + trayIcon?.Dispose(); + trayIcon = null; + Close(); + } + + private void AppWindow_Closing(AppWindow sender, AppWindowClosingEventArgs args) + { + if (exitRequested) + { + return; + } + + if (preferences.CloseBehavior == WindowCloseBehavior.MinimizeToTray) + { + args.Cancel = true; + sender.Hide(); + return; + } + + exitRequested = true; + StopLiveUpdates(); + trayIcon?.Dispose(); + trayIcon = null; + } + + private async Task EnsureAgentForHostAsync() + { + var running = await new AgentProcessManager(agent).EnsureRunningAsync(); + if (!running) + { + WriteAppDiagnostic("Agent start failed; Native Host fallback remains enabled."); + } + } + + private static void WriteAppDiagnostic(string message) + { + try + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "eslee", + "DownloadRouter", + "logs"); + Directory.CreateDirectory(directory); + File.AppendAllText( + Path.Combine(directory, "app-startup.log"), + $"{DateTimeOffset.UtcNow:O} {message}{Environment.NewLine}"); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint windowHandle, int command); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint windowHandle); +} diff --git a/src/DownloadRouter.App/MainWindow.Live.cs b/src/DownloadRouter.App/MainWindow.Live.cs index 943bdb3..fdb2f68 100644 --- a/src/DownloadRouter.App/MainWindow.Live.cs +++ b/src/DownloadRouter.App/MainWindow.Live.cs @@ -1,4 +1,3 @@ -using System.Runtime.InteropServices; using DownloadRouter.Core.Jobs; using DownloadRouter.Core.Models; using Microsoft.UI.Xaml; @@ -11,13 +10,16 @@ public sealed partial class MainWindow private readonly DispatcherTimer liveUpdateTimer = new() { Interval = TimeSpan.FromSeconds(1) }; private readonly SelectionPromptQueue selectionPromptQueue = new(); private readonly HashSet deferredSelectionPrompts = []; - private ContentDialog? currentSelectionDialog; + private CancellationTokenSource? currentSelectionCancellation; private Guid? currentSelectionJobId; + private DownloadRule? currentSelectionRule; + private FolderSelectionWindow? currentSelectionWindow; private string? currentSelectionSnapshot; private bool currentSelectionInvalidated; private bool liveUpdateInProgress; private bool selectionPromptInProgress; private string? livePageSnapshot; + private int previousPendingCount; private void InitializeLiveUpdates() { @@ -28,7 +30,7 @@ private void InitializeLiveUpdates() private void StopLiveUpdates() { liveUpdateTimer.Stop(); - currentSelectionDialog?.Hide(); + currentSelectionCancellation?.Cancel(); } private async Task ShowDashboardAsync() @@ -73,6 +75,7 @@ private async void LiveUpdateTimer_Tick(object? sender, object args) var jobs = AgentClient.ReadData>(await agent.SendAsync("jobs.list")) ?? []; var rules = AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []; var active = DownloadJobQueries.ActiveSelections(jobs); + UpdatePendingBadge(active.Count); SynchronizeSelectionPrompt(active); if (!selectionPromptInProgress && !blockingDialogOpen) @@ -98,7 +101,7 @@ private void SynchronizeSelectionPrompt(IReadOnlyList active) if (currentSelectionJobId is Guid current && !activeIds.Contains(current)) { currentSelectionInvalidated = true; - currentSelectionDialog?.Hide(); + currentSelectionCancellation?.Cancel(); } else if (currentSelectionJobId is Guid activeCurrent) { @@ -106,9 +109,12 @@ private void SynchronizeSelectionPrompt(IReadOnlyList active) var updatedSnapshot = CreateSelectionPromptSnapshot(currentJob, active.Count); if (!string.Equals(currentSelectionSnapshot, updatedSnapshot, StringComparison.Ordinal)) { - currentSelectionInvalidated = true; - selectionPromptQueue.EnqueueFirst(activeCurrent); - currentSelectionDialog?.Hide(); + currentSelectionSnapshot = updatedSnapshot; + if (currentSelectionRule is not null) + { + currentSelectionWindow?.UpdateDescription( + CreateSelectionDescription(currentJob, currentSelectionRule, Math.Max(0, active.Count - 1))); + } } } @@ -143,77 +149,38 @@ private async Task ShowSelectionPromptAsync(DownloadJob job, DownloadRule rule, { selectionPromptInProgress = true; currentSelectionJobId = job.Id; + currentSelectionRule = rule; currentSelectionSnapshot = CreateSelectionPromptSnapshot(job, otherPendingCount + 1); currentSelectionInvalidated = false; - string? action = null; try { var root = pathResolver.Resolve(rule.StorageRoot); - var folders = EnumerateSafeFolders(root); - var picker = CreateFolderPicker("선택 가능한 하위 폴더", folders, job.SelectedRelativeFolder); - var content = new StackPanel { Spacing = 10, MinWidth = 360 }; - content.Children.Add(new TextBlock - { - Text = $"파일: {job.CurrentFileName}\n출처 사이트: {GetSourceHost(job)}\n상태: {DescribeStatus(job)}\n규칙: {rule.Name}\n저장 루트: {root}\n동시에 대기 중인 다른 파일: {otherPendingCount}개", - TextWrapping = TextWrapping.Wrap, - }); - content.Children.Add(picker); - - var send = new Button { Content = "이 위치로 보내기", HorizontalAlignment = HorizontalAlignment.Stretch }; - send.Click += (_, _) => - { - if (picker.SelectedItem is not string) - { - return; - } - - action = "apply"; - currentSelectionDialog?.Hide(); - }; - content.Children.Add(send); - - var later = new Button { Content = "나중에 선택", HorizontalAlignment = HorizontalAlignment.Stretch }; - later.Click += (_, _) => - { - action = "later"; - currentSelectionDialog?.Hide(); - }; - content.Children.Add(later); - - var skip = new Button { Content = "이번 파일은 이동하지 않기", HorizontalAlignment = HorizontalAlignment.Stretch }; - skip.Click += (_, _) => - { - action = "skip"; - currentSelectionDialog?.Hide(); - }; - content.Children.Add(skip); - - currentSelectionDialog = new ContentDialog - { - Title = "다운로드 저장 위치 선택", - Content = content, - XamlRoot = ContentPanel.XamlRoot, - }; - BringSelectionWindowToFront(); - await currentSelectionDialog.ShowAsync(); + currentSelectionCancellation = new CancellationTokenSource(); + currentSelectionWindow = new FolderSelectionWindow(); + var result = await currentSelectionWindow.ShowAsync( + root, + job.SelectedRelativeFolder, + CreateSelectionDescription(job, rule, otherPendingCount), + allowLater: true, + allowSkip: true, + currentSelectionCancellation.Token); if (currentSelectionInvalidated) { return; } - if (action == "apply" && picker.SelectedItem is string selectedFolder) + if (result.Action == FolderSelectionAction.Apply) { - var relativeFolder = selectedFolder == "." ? string.Empty : selectedFolder; var response = await agent.SendAsync( "selection.complete", - new SelectionCompletedPayload([job.Id], relativeFolder)); + new SelectionCompletedPayload([job.Id], result.RelativeFolder)); if (!response.Success) { await ShowMessageAsync(response.Message ?? "선택 적용에 실패했습니다."); } } - else if (action == "skip") + else if (result.Action == FolderSelectionAction.Skip) { var response = await agent.SendAsync("selection.skip", new SelectionSkippedPayload(job.Id)); if (!response.Success) @@ -233,10 +200,13 @@ private async Task ShowSelectionPromptAsync(DownloadJob job, DownloadRule rule, } finally { - currentSelectionDialog = null; currentSelectionJobId = null; + currentSelectionRule = null; + currentSelectionWindow = null; currentSelectionSnapshot = null; currentSelectionInvalidated = false; + currentSelectionCancellation?.Dispose(); + currentSelectionCancellation = null; selectionPromptInProgress = false; } } @@ -245,7 +215,7 @@ private async Task RefreshVisibleLivePageAsync( IReadOnlyList jobs, IReadOnlyList rules) { - if (blockingDialogOpen || currentSelectionDialog is not null) + if (blockingDialogOpen || selectionPromptInProgress) { return; } @@ -265,6 +235,7 @@ private async Task RefreshVisibleLivePageAsync( break; case "history": historyJobs = jobs; + historyRules = rules.ToDictionary(static rule => rule.Id); selectedHistoryJobs.IntersectWith(jobs.Select(static job => job.Id)); RenderHistory(); break; @@ -274,22 +245,21 @@ private async Task RefreshVisibleLivePageAsync( } } - private void BringSelectionWindowToFront() - { - Activate(); - var windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(this); - _ = ShowWindow(windowHandle, 9); - _ = SetForegroundWindow(windowHandle); - } - private static string CreateSelectionPromptSnapshot(DownloadJob job, int activeCount) => $"{job.Id:N}:{job.CurrentFileName}:{job.BrowserState}:{job.RoutingState}:{activeCount}"; - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetForegroundWindow(nint windowHandle); + private string CreateSelectionDescription(DownloadJob job, DownloadRule rule, int otherPendingCount) + => $"파일: {DownloadPresentation.DisplayFileName(job)}\n출처 사이트: {GetSourceHost(job)}\n상태: {DescribeStatus(job)}\n규칙: {rule.Name}\n저장 루트: {pathResolver.Resolve(rule.StorageRoot)}\n동시에 대기 중인 다른 파일: {otherPendingCount}개"; + + private void UpdatePendingBadge(int count) + { + PendingInfoBadge.Value = count; + PendingInfoBadge.Visibility = count > 0 ? Visibility.Visible : Visibility.Collapsed; + if (count > previousPendingCount) + { + trayIcon?.ShowSelectionNotification(count); + } + previousPendingCount = count; + } - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool ShowWindow(nint windowHandle, int command); } diff --git a/src/DownloadRouter.App/MainWindow.Pending.cs b/src/DownloadRouter.App/MainWindow.Pending.cs index fd735d6..f1ce7c2 100644 --- a/src/DownloadRouter.App/MainWindow.Pending.cs +++ b/src/DownloadRouter.App/MainWindow.Pending.cs @@ -43,7 +43,6 @@ private async Task ShowPendingAsync() private void AddPendingRuleGroup(DownloadRule rule, IReadOnlyList jobs) { var root = pathResolver.Resolve(rule.StorageRoot); - var folders = EnumerateSafeFolders(root); var selectedJobs = new HashSet(); ContentPanel.Children.Add(new TextBlock @@ -61,14 +60,12 @@ private void AddPendingRuleGroup(DownloadRule rule, IReadOnlyList j foreach (var job in jobs) { - ContentPanel.Children.Add(CreatePendingCard(job, rule, root, folders, selectedJobs)); + ContentPanel.Children.Add(CreatePendingCard(job, rule, root, selectedJobs)); } - var batchPicker = CreateFolderPicker("선택한 파일에 적용할 하위 폴더", folders, null); - ContentPanel.Children.Add(batchPicker); var applySelected = new Button { - Content = "체크한 파일에만 일괄 적용", + Content = "체크한 파일의 저장 위치 선택", HorizontalAlignment = HorizontalAlignment.Stretch, }; applySelected.Click += async (_, _) => @@ -79,13 +76,18 @@ private void AddPendingRuleGroup(DownloadRule rule, IReadOnlyList j return; } - if (batchPicker.SelectedItem is not string folder) + var result = await new FolderSelectionWindow().ShowAsync( + root, + selectedRelativeFolder: null, + $"{selectedJobs.Count}개 파일에 같은 하위 폴더를 적용합니다. 체크하지 않은 파일은 변경하지 않습니다.", + allowLater: false, + allowSkip: false); + if (result.Action != FolderSelectionAction.Apply) { - await ShowMessageAsync("하위 폴더를 선택하세요."); return; } - await ApplySelectionAsync(selectedJobs.ToArray(), folder); + await ApplySelectionAsync(selectedJobs.ToArray(), result.RelativeFolder); }; ContentPanel.Children.Add(applySelected); ContentPanel.Children.Add(new Border { Height = 1, Opacity = 0.35, Margin = new Thickness(0, 8, 0, 8) }); @@ -95,7 +97,6 @@ private Border CreatePendingCard( DownloadJob job, DownloadRule rule, string root, - IReadOnlyList folders, ISet selectedJobs) { var panel = new StackPanel { Spacing = 8 }; @@ -105,7 +106,7 @@ private Border CreatePendingCard( panel.Children.Add(selected); panel.Children.Add(new TextBlock { - Text = job.CurrentFileName, + Text = DownloadPresentation.DisplayFileName(job), FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap, }); @@ -116,19 +117,27 @@ private Border CreatePendingCard( TextWrapping = TextWrapping.Wrap, }); - var picker = CreateFolderPicker("이 파일의 하위 폴더", folders, job.SelectedRelativeFolder); - panel.Children.Add(picker); - - var apply = new Button { Content = "이 파일에 적용", HorizontalAlignment = HorizontalAlignment.Stretch }; + var apply = new Button { Content = "이 파일의 저장 위치 선택/변경", HorizontalAlignment = HorizontalAlignment.Stretch }; apply.Click += async (_, _) => { - if (picker.SelectedItem is not string folder) + var result = await new FolderSelectionWindow().ShowAsync( + root, + job.SelectedRelativeFolder, + $"파일: {DownloadPresentation.DisplayFileName(job)}\n현재 선택: {DisplayFolder(job.SelectedRelativeFolder)}", + allowLater: true, + allowSkip: true); + if (result.Action == FolderSelectionAction.Apply) { - await ShowMessageAsync("하위 폴더를 선택하세요."); - return; + await ApplySelectionAsync([job.Id], result.RelativeFolder); + } + else if (result.Action == FolderSelectionAction.Skip) + { + await SkipSelectionAsync(job.Id); + } + else if (result.Action == FolderSelectionAction.Later) + { + deferredSelectionPrompts.Add(job.Id); } - - await ApplySelectionAsync([job.Id], folder); }; panel.Children.Add(apply); @@ -154,22 +163,6 @@ private Border CreatePendingCard( }; } - private static ComboBox CreateFolderPicker( - string header, - IReadOnlyList folders, - string? selectedRelativeFolder) - { - var selected = string.IsNullOrEmpty(selectedRelativeFolder) ? "." : selectedRelativeFolder; - var selectedIndex = folders.ToList().FindIndex(folder => string.Equals(folder, selected, StringComparison.OrdinalIgnoreCase)); - return new ComboBox - { - Header = header, - ItemsSource = folders, - SelectedIndex = selectedIndex >= 0 ? selectedIndex : folders.Count > 0 ? 0 : -1, - HorizontalAlignment = HorizontalAlignment.Stretch, - }; - } - private async Task ApplySelectionAsync(IReadOnlyList jobIds, string displayedFolder) { var relativeFolder = displayedFolder == "." ? string.Empty : displayedFolder; diff --git a/src/DownloadRouter.App/MainWindow.Rules.cs b/src/DownloadRouter.App/MainWindow.Rules.cs new file mode 100644 index 0000000..a068df4 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Rules.cs @@ -0,0 +1,305 @@ +using DownloadRouter.Core.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.Storage.Pickers; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private Guid? editingRuleId; + + private async Task ShowRulesManagementAsync() + { + Prepare("사이트 규칙", "새 규칙 만들기와 저장된 규칙을 한 페이지의 구분된 섹션에서 관리합니다."); + try + { + var response = await agent.SendAsync("rules.list"); + var rules = AgentClient.ReadData>(response) ?? []; + var editing = editingRuleId is Guid id ? rules.FirstOrDefault(rule => rule.Id == id) : null; + if (editingRuleId is not null && editing is null) + { + editingRuleId = null; + } + + AddRuleEditor(editing); + ContentPanel.Children.Add(new Border { Height = 1, Opacity = 0.35, Margin = new Thickness(0, 12, 0, 12) }); + ContentPanel.Children.Add(new TextBlock + { + Text = $"저장된 규칙 · {rules.Count}개", + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + }); + foreach (var rule in rules) + { + ContentPanel.Children.Add(CreateRuleCard(rule)); + } + + if (rules.Count == 0) + { + AddMuted("아직 저장된 규칙이 없습니다."); + } + } + catch (Exception exception) + { + AddError("규칙 목록을 불러오지 못했습니다.", exception); + } + } + + private void AddRuleEditor(DownloadRule? editing) + { + var panel = new StackPanel { Spacing = 12 }; + panel.Children.Add(new TextBlock + { + Text = editing is null ? "새 규칙 만들기" : $"규칙 편집 · {editing.Name}", + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + TextWrapping = TextWrapping.Wrap, + }); + var name = new TextBox + { + Header = "규칙 이름", + PlaceholderText = "예: 네이버 다운로드", + Text = editing?.Name ?? string.Empty, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var matchValue = new TextBox + { + Header = "대상 사이트 또는 URL 일부", + PlaceholderText = "naver.com", + Text = editing?.MatchValue ?? string.Empty, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var matchType = new ComboBox + { + Header = "매칭 방식", + ItemsSource = Enum.GetValues(), + SelectedItem = editing?.MatchType ?? RuleMatchType.DomainAndSubdomains, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var matchTarget = new ComboBox + { + Header = "매칭 대상", + ItemsSource = Enum.GetValues(), + SelectedItem = editing?.MatchTarget ?? RuleMatchTarget.InitiatingPage, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var storageRoot = new TextBox + { + Header = "저장 루트", + Text = editing?.StorageRoot ?? "{Downloads}\\eslee\\Routed", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var chooseRoot = new Button + { + Content = "폴더 선택…", + HorizontalAlignment = HorizontalAlignment.Left, + }; + chooseRoot.Click += async (_, _) => await PickStorageRootAsync(storageRoot); + var storageMode = new ComboBox + { + Header = "저장 방식", + ItemsSource = Enum.GetValues(), + SelectedItem = editing?.StorageMode ?? StorageMode.Automatic, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var pathStatus = new TextBlock { TextWrapping = TextWrapping.Wrap, Opacity = 0.75 }; + var checkPath = new Button { Content = "경로 읽기/쓰기 진단", HorizontalAlignment = HorizontalAlignment.Left }; + checkPath.Click += (_, _) => pathStatus.Text = DiagnoseStorageRoot(storageRoot.Text); + var preview = new TextBlock + { + Text = "매칭 예시가 여기에 표시됩니다.", + TextWrapping = TextWrapping.Wrap, + }; + matchValue.TextChanged += (_, _) => preview.Text = string.IsNullOrWhiteSpace(matchValue.Text) + ? "매칭 예시가 여기에 표시됩니다." + : $"{matchValue.Text.Trim()} 및 선택한 방식에 맞는 주소에 적용됩니다."; + + var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + var save = new Button { Content = editing is null ? "규칙 저장" : "규칙 수정" }; + save.Click += async (_, _) => + { + try + { + var diagnosis = DiagnoseStorageRoot(storageRoot.Text); + pathStatus.Text = diagnosis; + if (!diagnosis.StartsWith("사용 가능", StringComparison.Ordinal)) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var rule = new DownloadRule( + editing?.Id ?? Guid.NewGuid(), + name.Text.Trim(), + editing?.IsEnabled ?? true, + (RuleMatchType)matchType.SelectedItem, + matchValue.Text.Trim(), + (RuleMatchTarget)matchTarget.SelectedItem, + storageRoot.Text.Trim(), + (StorageMode)storageMode.SelectedItem, + editing?.Priority ?? 0, + editing?.ListOrder ?? 0, + editing?.CreatedAt ?? now, + now); + var response = await agent.SendAsync("rules.upsert", rule); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "규칙 저장에 실패했습니다."); + return; + } + + editingRuleId = null; + await ShowRulesManagementAsync(); + } + catch (Exception exception) + { + await ShowMessageAsync("규칙 저장 실패: " + exception.Message); + } + }; + actions.Children.Add(save); + if (editing is not null) + { + var cancel = new Button { Content = "편집 취소" }; + cancel.Click += async (_, _) => + { + editingRuleId = null; + await ShowRulesManagementAsync(); + }; + actions.Children.Add(cancel); + } + + panel.Children.Add(name); + panel.Children.Add(matchValue); + panel.Children.Add(matchType); + panel.Children.Add(matchTarget); + panel.Children.Add(storageRoot); + panel.Children.Add(chooseRoot); + panel.Children.Add(checkPath); + panel.Children.Add(pathStatus); + panel.Children.Add(storageMode); + panel.Children.Add(preview); + panel.Children.Add(actions); + ContentPanel.Children.Add(new Border + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(20), + CornerRadius = new CornerRadius(10), + Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + Child = panel, + }); + } + + private Border CreateRuleCard(DownloadRule rule) + { + var panel = new StackPanel { Spacing = 8 }; + panel.Children.Add(new TextBlock + { + Text = rule.Name, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + }); + panel.Children.Add(new TextBlock + { + Text = $"매칭: {rule.MatchType} · 대상: {rule.MatchTarget}\n값: {rule.MatchValue}\n저장 루트: {rule.StorageRoot}\n저장 방식: {rule.StorageMode}", + TextWrapping = TextWrapping.Wrap, + }); + var enabled = new ToggleSwitch { Header = "규칙 활성화", IsOn = rule.IsEnabled }; + enabled.Toggled += async (_, _) => + { + var response = await agent.SendAsync( + "rules.upsert", + rule with { IsEnabled = enabled.IsOn, UpdatedAt = DateTimeOffset.UtcNow }); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "규칙 상태 변경에 실패했습니다."); + } + }; + panel.Children.Add(enabled); + + var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + var edit = new Button { Content = "편집" }; + edit.Click += async (_, _) => + { + editingRuleId = rule.Id; + await ShowRulesManagementAsync(); + ContentScrollViewer.ChangeView(null, 0, null); + }; + actions.Children.Add(edit); + var delete = new Button { Content = "삭제" }; + delete.Click += async (_, _) => + { + if (!await ShowConfirmationAsync( + "사이트 규칙 삭제", + "이 규칙을 삭제해도 이미 이동된 파일과 다운로드 이력은 삭제되지 않습니다.")) + { + return; + } + + var response = await agent.SendAsync("rules.delete", new RuleDeletePayload(rule.Id)); + if (!response.Success) + { + await ShowMessageAsync(response.Message ?? "규칙 삭제에 실패했습니다."); + return; + } + + if (editingRuleId == rule.Id) + { + editingRuleId = null; + } + await ShowRulesManagementAsync(); + }; + actions.Children.Add(delete); + panel.Children.Add(actions); + return new Border + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(16), + CornerRadius = new CornerRadius(8), + Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + Child = panel, + }; + } + + private async Task PickStorageRootAsync(TextBox target) + { + var picker = new FolderPicker(); + picker.FileTypeFilter.Add("*"); + var handle = WinRT.Interop.WindowNative.GetWindowHandle(this); + WinRT.Interop.InitializeWithWindow.Initialize(picker, handle); + var selected = await picker.PickSingleFolderAsync(); + if (selected is not null) + { + target.Text = selected.Path; + } + } + + private string DiagnoseStorageRoot(string configuredPath) + { + try + { + var fullPath = pathResolver.Resolve(configuredPath.Trim()); + if (!Directory.Exists(fullPath)) + { + return "사용 불가: 폴더가 존재하지 않습니다."; + } + + _ = Directory.EnumerateFileSystemEntries(fullPath).Take(1).ToArray(); + var probe = Path.Combine(fullPath, $".eslee-write-test-{Guid.NewGuid():N}.tmp"); + using (new FileStream( + probe, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 1, + FileOptions.DeleteOnClose)) + { + } + + return $"사용 가능: 읽기/쓰기 확인 · {fullPath}"; + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + return $"사용 불가: {exception.Message}"; + } + } +} diff --git a/src/DownloadRouter.App/MainWindow.Settings.cs b/src/DownloadRouter.App/MainWindow.Settings.cs new file mode 100644 index 0000000..19a9172 --- /dev/null +++ b/src/DownloadRouter.App/MainWindow.Settings.cs @@ -0,0 +1,67 @@ +using DownloadRouter.Core.Models; +using DownloadRouter.Core.Startup; +using Microsoft.UI.Xaml.Controls; + +namespace DownloadRouter.App; + +public sealed partial class MainWindow +{ + private readonly AutoStartRegistration autoStartRegistration = new(); + + private Task ShowSettingsManagementAsync() + { + Prepare("일반 설정", "로그인 자동 시작과 창 닫기 동작은 현재 사용자 설정으로 즉시 저장됩니다."); + var executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("현재 App 실행 파일 경로를 확인할 수 없습니다."); + var autoStart = new ToggleSwitch + { + Header = "Windows 로그인 시 백그라운드로 실행", + OffContent = "사용 안 함", + OnContent = "사용 중", + IsOn = autoStartRegistration.IsEnabled(executablePath), + }; + autoStart.Toggled += async (_, _) => + { + try + { + autoStartRegistration.SetEnabled(autoStart.IsOn, executablePath); + } + catch (Exception exception) + { + await ShowMessageAsync("자동 시작 설정 변경 실패: " + exception.Message); + autoStart.IsOn = autoStartRegistration.IsEnabled(executablePath); + } + }; + ContentPanel.Children.Add(autoStart); + AddMuted("로그인 시 DownloadRouter.App --background가 실행되어 Agent와 트레이를 준비합니다. Native Host의 Agent 자동 시작 fallback도 유지됩니다."); + + var closeBehavior = new ComboBox + { + Header = "창 닫기 버튼 동작", + ItemsSource = new[] { "트레이로 최소화", "프로그램 UI 완전 종료" }, + SelectedIndex = preferences.CloseBehavior == WindowCloseBehavior.MinimizeToTray ? 0 : 1, + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Stretch, + }; + closeBehavior.SelectionChanged += (_, _) => + { + preferences = preferences with + { + CloseBehavior = closeBehavior.SelectedIndex == 1 + ? WindowCloseBehavior.ExitApplication + : WindowCloseBehavior.MinimizeToTray, + }; + preferencesStore.Save(preferences); + }; + ContentPanel.Children.Add(closeBehavior); + AddMuted("기본값은 트레이로 최소화입니다. 트레이 메뉴의 ‘종료’는 설정과 관계없이 UI와 트레이를 종료합니다."); + + ContentPanel.Children.Add(new ComboBox + { + Header = "테마", + ItemsSource = new[] { "시스템 설정", "라이트", "다크" }, + SelectedIndex = 0, + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Stretch, + }); + return Task.CompletedTask; + } +} diff --git a/src/DownloadRouter.App/MainWindow.xaml b/src/DownloadRouter.App/MainWindow.xaml index 17c04d9..ebb03bc 100644 --- a/src/DownloadRouter.App/MainWindow.xaml +++ b/src/DownloadRouter.App/MainWindow.xaml @@ -35,7 +35,11 @@ - + + + + + diff --git a/src/DownloadRouter.App/MainWindow.xaml.cs b/src/DownloadRouter.App/MainWindow.xaml.cs index cfcd6ef..6a9ae0b 100644 --- a/src/DownloadRouter.App/MainWindow.xaml.cs +++ b/src/DownloadRouter.App/MainWindow.xaml.cs @@ -21,7 +21,9 @@ public MainWindow() _ = ShowDashboardAsync(); } - private async void Navigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + private async void Navigation_SelectionChanged( + NavigationView sender, + NavigationViewSelectionChangedEventArgs args) { if (args.SelectedItemContainer?.Tag is not string tag) { @@ -35,11 +37,11 @@ private Task ShowPageAsync(string tag) => tag switch { "dashboard" => ShowDashboardAsync(), - "rules" => ShowRulesAsync(), + "rules" => ShowRulesManagementAsync(), "history" => ShowHistoryAsync(), "pending" => ShowPendingAsync(), "browsers" => ShowBrowsersAsync(), - "settings" => ShowSettingsAsync(), + "settings" => ShowSettingsManagementAsync(), "diagnostics" => ShowDiagnosticsAsync(), "about" => ShowAboutAsync(), _ => Task.CompletedTask, @@ -53,93 +55,29 @@ private void ContentScrollViewer_SizeChanged(object sender, SizeChangedEventArgs ContentPanel.Width = Math.Min(1100, available); } - private async Task ShowRulesAsync() - { - Prepare("사이트 규칙", "도메인, 정확한 호스트 또는 URL 일부를 기준으로 저장 위치를 지정합니다."); - var name = new TextBox { Header = "규칙 이름", PlaceholderText = "예: 네이버 다운로드", HorizontalAlignment = HorizontalAlignment.Stretch }; - var matchValue = new TextBox { Header = "대상 사이트 또는 URL 일부", PlaceholderText = "naver.com", HorizontalAlignment = HorizontalAlignment.Stretch }; - var storageRoot = new TextBox { Header = "저장 루트", Text = "{Downloads}\\eslee\\Routed", HorizontalAlignment = HorizontalAlignment.Stretch }; - var matchType = new ComboBox { Header = "매칭 방식", ItemsSource = Enum.GetValues(), SelectedIndex = 0, HorizontalAlignment = HorizontalAlignment.Stretch }; - var matchTarget = new ComboBox { Header = "매칭 대상", ItemsSource = Enum.GetValues(), SelectedItem = RuleMatchTarget.InitiatingPage, HorizontalAlignment = HorizontalAlignment.Stretch }; - var storageMode = new ComboBox { Header = "저장 방식", ItemsSource = Enum.GetValues(), SelectedIndex = 0, HorizontalAlignment = HorizontalAlignment.Stretch }; - var preview = new TextBlock { Text = "naver.com은 naver.com과 모든 하위 도메인에 일치합니다.", TextWrapping = TextWrapping.Wrap }; - matchValue.TextChanged += (_, _) => preview.Text = string.IsNullOrWhiteSpace(matchValue.Text) - ? "매칭 예시가 여기에 표시됩니다." - : $"{matchValue.Text.Trim()} 및 선택한 방식에 맞는 주소에 적용됩니다."; - var save = new Button { Content = "규칙 저장", HorizontalAlignment = HorizontalAlignment.Left }; - save.Click += async (_, _) => - { - try - { - var now = DateTimeOffset.UtcNow; - var rule = new DownloadRule( - Guid.NewGuid(), - name.Text.Trim(), - true, - (RuleMatchType)matchType.SelectedItem, - matchValue.Text.Trim(), - (RuleMatchTarget)matchTarget.SelectedItem, - storageRoot.Text.Trim(), - (StorageMode)storageMode.SelectedItem, - 0, - 0, - now, - now); - var response = await agent.SendAsync("rules.upsert", rule); - await ShowMessageAsync(response.Success ? "규칙을 저장했습니다." : response.Message ?? "규칙 저장에 실패했습니다."); - await ShowRulesAsync(); - } - catch (Exception exception) - { - await ShowMessageAsync("규칙 저장 실패: " + exception.Message); - } - }; - - ContentPanel.Children.Add(name); - ContentPanel.Children.Add(matchValue); - ContentPanel.Children.Add(matchType); - ContentPanel.Children.Add(matchTarget); - ContentPanel.Children.Add(storageRoot); - ContentPanel.Children.Add(storageMode); - ContentPanel.Children.Add(preview); - ContentPanel.Children.Add(save); - ContentPanel.Children.Add(new Border { Height = 1, Opacity = 0.4 }); - - try - { - var response = await agent.SendAsync("rules.list"); - var rules = AgentClient.ReadData>(response) ?? []; - foreach (var rule in rules) - { - AddCard(rule.Name, $"{rule.MatchType}: {rule.MatchValue} → {rule.StorageRoot} ({rule.StorageMode})"); - } - - if (rules.Count == 0) - { - AddMuted("아직 저장된 규칙이 없습니다."); - } - } - catch (Exception exception) - { - AddError("규칙 목록을 불러오지 못했습니다.", exception); - } - } - private Task ShowBrowsersAsync() { Prepare("브라우저 연결", "설치 상태와 개발자 모드 확장 설치 경로를 안내합니다."); foreach (var browser in BrowserCatalog.Detect()) { var panel = new StackPanel { Spacing = 8 }; - panel.Children.Add(new TextBlock { Text = browser.DisplayName, Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style }); + panel.Children.Add(new TextBlock + { + Text = browser.DisplayName, + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + }); panel.Children.Add(new TextBlock { Text = browser.IsInstalled - ? $"설치됨 · {(browser.IsOfficial ? "정식 지원" : "호환 지원")} · 확장 연결은 브라우저에서 확인 필요" + ? $"설치됨 · {(browser.IsOfficial ? "공식 지원" : "호환 지원")} · 확장 연결은 브라우저에서 확인 필요" : "설치되지 않음", TextWrapping = TextWrapping.Wrap, }); - var open = new Button { Content = "확장 관리 페이지 열기", IsEnabled = browser.IsInstalled }; + var open = new Button + { + Content = "확장 관리 페이지 열기", + IsEnabled = browser.IsInstalled, + }; open.Click += async (_, _) => { try @@ -152,25 +90,21 @@ private Task ShowBrowsersAsync() } }; panel.Children.Add(open); - ContentPanel.Children.Add(new Border { Padding = new Thickness(16), CornerRadius = new CornerRadius(8), Child = panel }); + ContentPanel.Children.Add(new Border + { + Padding = new Thickness(16), + CornerRadius = new CornerRadius(8), + Child = panel, + }); } - AddMuted("설치 순서: 개발자 모드 켜기 → 압축 해제된 확장 로드 → src/DownloadRouter.Extension/dist 선택 → Native Host 등록 → 연결 테스트."); - return Task.CompletedTask; - } - - private Task ShowSettingsAsync() - { - Prepare("일반 설정", "모든 설정과 데이터는 현재 사용자 LocalAppData 아래에만 저장됩니다."); - ContentPanel.Children.Add(new ToggleSwitch { Header = "Windows 로그인 시 Agent 실행", IsOn = false }); - ContentPanel.Children.Add(new ComboBox { Header = "테마", ItemsSource = new[] { "시스템 설정", "라이트", "다크" }, SelectedIndex = 0 }); - AddMuted("시작 프로그램 등록은 기본값이 아니며, 현재 화면의 토글은 다음 단계에서 Agent 설정 저장과 연결됩니다."); + AddMuted("설치 순서: 개발자 모드 켜기 → 압축 해제된 확장 로드 → src/DownloadRouter.Extension/dist 선택 → Native Host 등록 → 연결 테스트"); return Task.CompletedTask; } private async Task ShowDiagnosticsAsync() { - Prepare("진단 및 문제 해결", "민감 URL과 다운로드 파일을 포함하지 않는 로컬 진단 정보를 표시합니다."); + Prepare("진단 및 문제 해결", "민감 URL과 다운로드 파일을 포함하지 않는 로컬 진단 정보만 표시합니다."); var test = new Button { Content = "Agent 연결 테스트" }; test.Click += async (_, _) => { @@ -205,52 +139,31 @@ rasterizationScale is null private Task ShowAboutAsync() { Prepare("eslee Download Router", "Chromium 다운로드를 사이트 규칙에 따라 안전하게 정리하는 로컬 Windows 프로그램입니다."); - AddCard("개인정보", "서버 전송, 텔레메트리, 광고 SDK가 없습니다. 다운로드 처리에 필요한 최소 정보만 로컬에 정제해 저장합니다."); - AddCard("지원 범위", "Windows 11 x64 · Whale / Edge / Chrome 정식 지원 · Brave / Vivaldi / Opera 호환 지원 · Firefox 제외"); - AddCard("프로토콜", $"Native Messaging → 현재 사용자 Named Pipe v{ProtocolConstants.CurrentVersion} → 단일 Agent"); + AddCard("개인정보", "서버 전송, 텔레메트리, 광고 SDK가 없습니다. 다운로드 처리에 필요한 최소 정보만 로컬에 저장합니다."); + AddCard("지원 범위", "Windows 11 x64 · Whale / Edge / Chrome 공식 지원 · Brave / Vivaldi / Opera 호환 지원 · Firefox 제외"); + AddCard("프로토콜", $"Native Messaging ↔ 현재 사용자 Named Pipe v{ProtocolConstants.CurrentVersion} ↔ 단일 Agent"); return Task.CompletedTask; } - private List EnumerateSafeFolders(string root) - { - var result = new List { "." }; - if (!Directory.Exists(root)) - { - return result; - } - - var queue = new Queue(); - queue.Enqueue(root); - while (queue.Count > 0 && result.Count < 1000) - { - var current = queue.Dequeue(); - foreach (var child in Directory.EnumerateDirectories(current).OrderBy(static path => path, StringComparer.CurrentCultureIgnoreCase)) - { - if ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0) - { - continue; - } - - boundaryValidator.EnsureWithin(root, child); - result.Add(Path.GetRelativePath(root, child)); - queue.Enqueue(child); - } - } - - return result; - } - private void Prepare(string title, string description) { ContentPanel.Children.Clear(); - ContentPanel.Children.Add(new TextBlock { Text = title, Style = Application.Current.Resources["TitleTextBlockStyle"] as Style }); + ContentPanel.Children.Add(new TextBlock + { + Text = title, + Style = Application.Current.Resources["TitleTextBlockStyle"] as Style, + }); ContentPanel.Children.Add(new TextBlock { Text = description, TextWrapping = TextWrapping.Wrap }); } private void AddCard(string title, string value) { var panel = new StackPanel { Spacing = 4 }; - panel.Children.Add(new TextBlock { Text = title, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); + panel.Children.Add(new TextBlock + { + Text = title, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); panel.Children.Add(new TextBlock { Text = value, TextWrapping = TextWrapping.Wrap }); ContentPanel.Children.Add(new Border { @@ -263,7 +176,12 @@ private void AddCard(string title, string value) } private void AddMuted(string text) - => ContentPanel.Children.Add(new TextBlock { Text = text, Opacity = 0.7, TextWrapping = TextWrapping.Wrap }); + => ContentPanel.Children.Add(new TextBlock + { + Text = text, + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap, + }); private void AddError(string prefix, Exception exception) => ContentPanel.Children.Add(new InfoBar diff --git a/src/DownloadRouter.App/TrayIconHost.cs b/src/DownloadRouter.App/TrayIconHost.cs new file mode 100644 index 0000000..f9ab2f5 --- /dev/null +++ b/src/DownloadRouter.App/TrayIconHost.cs @@ -0,0 +1,331 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using Microsoft.UI.Dispatching; + +namespace DownloadRouter.App; + +public sealed class TrayIconHost : IDisposable +{ + private const uint TrayMessage = 0x8001; + private const uint IconId = 1; + private const uint NimAdd = 0x00000000; + private const uint NimModify = 0x00000001; + private const uint NimDelete = 0x00000002; + private const uint NimSetVersion = 0x00000004; + private const uint NifMessage = 0x00000001; + private const uint NifIcon = 0x00000002; + private const uint NifTip = 0x00000004; + private const uint NifInfo = 0x00000010; + private const uint NotifyIconVersion4 = 4; + private const uint WmLeftButtonDoubleClick = 0x0203; + private const uint WmRightButtonUp = 0x0205; + private const uint WmContextMenu = 0x007B; + private const uint WmCommand = 0x0111; + private const uint NinSelect = 0x0400; + private const uint MfString = 0x00000000; + private const uint MfSeparator = 0x00000800; + private const uint TpmRightButton = 0x0002; + private const uint TpmReturnCommand = 0x0100; + private const uint OpenCommand = 1001; + private const uint PendingCommand = 1002; + private const uint SettingsCommand = 1003; + private const uint ExitCommand = 1004; + private static readonly ConcurrentDictionary Instances = new(); + private static readonly WindowProcedureDelegate WindowProcedureInstance = WindowProcedure; + + private readonly DispatcherQueue dispatcher; + private readonly Action open; + private readonly Action openPending; + private readonly Action openSettings; + private readonly Action exit; + private readonly string windowClassName = $"eslee.DownloadRouter.Tray.{Environment.ProcessId}"; + private nint windowHandle; + private nint menuHandle; + private bool disposed; + + public TrayIconHost( + DispatcherQueue dispatcher, + Action open, + Action openPending, + Action openSettings, + Action exit) + { + this.dispatcher = dispatcher; + this.open = open; + this.openPending = openPending; + this.openSettings = openSettings; + this.exit = exit; + CreateMessageWindow(); + CreateMenu(); + AddIcon(); + } + + public void ShowSelectionNotification(int pendingCount) + { + var data = CreateIconData(NifInfo); + data.InfoTitle = "저장 위치 선택 대기"; + data.Info = $"선택을 기다리는 다운로드가 {pendingCount}개 있습니다."; + data.InfoFlags = 0x00000001; + _ = ShellNotifyIcon(NimModify, ref data); + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + var data = CreateIconData(0); + _ = ShellNotifyIcon(NimDelete, ref data); + Instances.TryRemove(windowHandle, out _); + if (menuHandle != 0) + { + _ = DestroyMenu(menuHandle); + } + if (windowHandle != 0) + { + _ = DestroyWindow(windowHandle); + } + } + + private void CreateMessageWindow() + { + var windowClass = new WindowClass + { + WindowProcedure = Marshal.GetFunctionPointerForDelegate(WindowProcedureInstance), + Instance = GetModuleHandle(null), + ClassName = windowClassName, + }; + if (RegisterClass(ref windowClass) == 0) + { + throw new InvalidOperationException("The tray message window class could not be registered."); + } + + windowHandle = CreateWindowEx( + 0, + windowClassName, + "eslee Download Router tray", + 0, + 0, + 0, + 0, + 0, + new nint(-3), + 0, + windowClass.Instance, + 0); + if (windowHandle == 0) + { + throw new InvalidOperationException("The tray message window could not be created."); + } + + Instances[windowHandle] = this; + } + + private void CreateMenu() + { + menuHandle = CreatePopupMenu(); + _ = AppendMenu(menuHandle, MfString, OpenCommand, "eslee Download Router 열기"); + _ = AppendMenu(menuHandle, MfString, PendingCommand, "저장 위치 선택 대기 열기"); + _ = AppendMenu(menuHandle, MfString, SettingsCommand, "일반 설정"); + _ = AppendMenu(menuHandle, MfSeparator, 0, string.Empty); + _ = AppendMenu(menuHandle, MfString, ExitCommand, "종료"); + } + + private void AddIcon() + { + var data = CreateIconData(NifMessage | NifIcon | NifTip); + data.CallbackMessage = TrayMessage; + data.Icon = LoadIcon(0, new nint(32512)); + data.Tip = "eslee Download Router"; + if (!ShellNotifyIcon(NimAdd, ref data)) + { + throw new InvalidOperationException("The system tray icon could not be created."); + } + + data.TimeoutOrVersion = NotifyIconVersion4; + _ = ShellNotifyIcon(NimSetVersion, ref data); + } + + private NotifyIconData CreateIconData(uint flags) + => new() + { + Size = (uint)Marshal.SizeOf(), + Window = windowHandle, + Id = IconId, + Flags = flags, + Tip = string.Empty, + Info = string.Empty, + InfoTitle = string.Empty, + }; + + private void HandleNotification(uint notification) + { + if (notification is WmLeftButtonDoubleClick or NinSelect) + { + dispatcher.TryEnqueue(() => open()); + return; + } + + if (notification is not WmRightButtonUp and not WmContextMenu) + { + return; + } + + _ = GetCursorPos(out var point); + _ = SetForegroundWindow(windowHandle); + var command = TrackPopupMenu( + menuHandle, + TpmRightButton | TpmReturnCommand, + point.X, + point.Y, + 0, + windowHandle, + 0); + HandleCommand(command); + } + + private void HandleCommand(uint command) + { + var action = command switch + { + OpenCommand => open, + PendingCommand => openPending, + SettingsCommand => openSettings, + ExitCommand => exit, + _ => null, + }; + if (action is not null) + { + dispatcher.TryEnqueue(() => action()); + } + } + + private static nint WindowProcedure(nint window, uint message, nint wParam, nint lParam) + { + if (message == TrayMessage && Instances.TryGetValue(window, out var instance)) + { + instance.HandleNotification((uint)(lParam.ToInt64() & 0xffff)); + return 0; + } + + if (message == WmCommand && Instances.TryGetValue(window, out instance)) + { + instance.HandleCommand((uint)(wParam.ToInt64() & 0xffff)); + return 0; + } + + return DefWindowProc(window, message, wParam, lParam); + } + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private delegate nint WindowProcedureDelegate(nint window, uint message, nint wParam, nint lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WindowClass + { + public uint Style; + public nint WindowProcedure; + public int ClassExtra; + public int WindowExtra; + public nint Instance; + public nint Icon; + public nint Cursor; + public nint Background; + public string? MenuName; + public string ClassName; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NotifyIconData + { + public uint Size; + public nint Window; + public uint Id; + public uint Flags; + public uint CallbackMessage; + public nint Icon; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string Tip; + public uint State; + public uint StateMask; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string Info; + public uint TimeoutOrVersion; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string InfoTitle; + public uint InfoFlags; + public Guid ItemGuid; + public nint BalloonIcon; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Point + { + public int X; + public int Y; + } + + [DllImport("shell32.dll", EntryPoint = "Shell_NotifyIconW", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShellNotifyIcon(uint message, ref NotifyIconData data); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern ushort RegisterClass(ref WindowClass windowClass); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern nint CreateWindowEx( + uint extendedStyle, + string className, + string windowName, + uint style, + int x, + int y, + int width, + int height, + nint parent, + nint menu, + nint instance, + nint parameter); + + [DllImport("user32.dll")] + private static extern nint DefWindowProc(nint window, uint message, nint wParam, nint lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyWindow(nint window); + + [DllImport("user32.dll")] + private static extern nint CreatePopupMenu(); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AppendMenu(nint menu, uint flags, uint id, string text); + + [DllImport("user32.dll")] + private static extern uint TrackPopupMenu( + nint menu, + uint flags, + int x, + int y, + int reserved, + nint window, + nint rectangle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyMenu(nint menu); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetCursorPos(out Point point); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint window); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern nint LoadIcon(nint instance, nint iconName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern nint GetModuleHandle(string? moduleName); +} diff --git a/src/DownloadRouter.Core/Startup/AutoStartRegistration.cs b/src/DownloadRouter.Core/Startup/AutoStartRegistration.cs new file mode 100644 index 0000000..29f6fad --- /dev/null +++ b/src/DownloadRouter.Core/Startup/AutoStartRegistration.cs @@ -0,0 +1,40 @@ +using Microsoft.Win32; +using System.Runtime.Versioning; + +namespace DownloadRouter.Core.Startup; + +[SupportedOSPlatform("windows")] +public sealed class AutoStartRegistration( + string subKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run", + string valueName = "eslee Download Router") +{ + public bool IsEnabled(string executablePath) + { + using var key = Registry.CurrentUser.OpenSubKey(subKeyPath, writable: false); + var current = key?.GetValue(valueName) as string; + return string.Equals(current, BuildCommand(executablePath), StringComparison.OrdinalIgnoreCase); + } + + public void SetEnabled(bool enabled, string executablePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + if (!Path.IsPathFullyQualified(executablePath)) + { + throw new ArgumentException("The startup executable path must be absolute.", nameof(executablePath)); + } + + using var key = Registry.CurrentUser.CreateSubKey(subKeyPath, writable: true) + ?? throw new InvalidOperationException("The current-user startup registry key could not be opened."); + if (enabled) + { + key.SetValue(valueName, BuildCommand(executablePath), RegistryValueKind.String); + } + else + { + key.DeleteValue(valueName, throwOnMissingValue: false); + } + } + + public static string BuildCommand(string executablePath) + => $"\"{Path.GetFullPath(executablePath)}\" --background"; +} diff --git a/tests/DownloadRouter.Core.Tests/AutoStartRegistrationTests.cs b/tests/DownloadRouter.Core.Tests/AutoStartRegistrationTests.cs new file mode 100644 index 0000000..d242c01 --- /dev/null +++ b/tests/DownloadRouter.Core.Tests/AutoStartRegistrationTests.cs @@ -0,0 +1,37 @@ +using DownloadRouter.Core.Startup; +using Microsoft.Win32; + +namespace DownloadRouter.Core.Tests; + +public sealed class AutoStartRegistrationTests +{ + [Fact] + public void RegistrationIsIdempotentAndUsesBackgroundArgument() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var keyPath = $@"Software\eslee\DownloadRouter.Tests\{Guid.NewGuid():N}"; + const string valueName = "AutoStartTest"; + var registration = new AutoStartRegistration(keyPath, valueName); + var executable = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Download Router App.exe")); + try + { + registration.SetEnabled(true, executable); + registration.SetEnabled(true, executable); + + Assert.True(registration.IsEnabled(executable)); + using var key = Registry.CurrentUser.OpenSubKey(keyPath); + Assert.Equal($"\"{executable}\" --background", key!.GetValue(valueName)); + + registration.SetEnabled(false, executable); + Assert.False(registration.IsEnabled(executable)); + } + finally + { + Registry.CurrentUser.DeleteSubKeyTree(keyPath, throwOnMissingSubKey: false); + } + } +} From 2a4e558c042f32c99a2f72881070f66216c4f841 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:28:37 +0900 Subject: [PATCH 13/24] feat: package per-user installer --- installer/DownloadRouter.iss | 35 +++++++++++++++++-- installer/README.md | 32 +++++++++++++++++ scripts/build-installer.ps1 | 32 +++++++++++++++++ scripts/publish.ps1 | 7 ++++ scripts/unregister-native-host.ps1 | 5 +++ scripts/unregister-startup.ps1 | 9 +++++ .../DownloadRouter.App.csproj | 10 ++++++ 7 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 installer/README.md create mode 100644 scripts/build-installer.ps1 create mode 100644 scripts/unregister-startup.ps1 diff --git a/installer/DownloadRouter.iss b/installer/DownloadRouter.iss index a29dc83..abb47b2 100644 --- a/installer/DownloadRouter.iss +++ b/installer/DownloadRouter.iss @@ -1,5 +1,5 @@ #define AppName "eslee Download Router" -#define AppVersion "0.1.0" +#define AppVersion "0.2.0" #define Publisher "eslee" [Setup] @@ -16,13 +16,44 @@ OutputBaseFilename=eslee-download-router-setup Compression=lzma2 SolidCompression=yes UninstallDisplayIcon={app}\DownloadRouter.App.exe +DisableProgramGroupPage=yes +WizardStyle=modern +CloseApplications=yes +RestartApplications=no + +[Tasks] +Name: "desktopicon"; Description: "바탕 화면 바로가기 만들기"; GroupDescription: "추가 바로가기:"; Flags: unchecked +Name: "autostart"; Description: "Windows 로그인 시 백그라운드로 자동 시작"; GroupDescription: "백그라운드 실행:"; Flags: checkedonce [Files] Source: "..\artifacts\publish\app\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +[Icons] +Name: "{autoprograms}\eslee Download Router"; Filename: "{app}\DownloadRouter.App.exe" +Name: "{autodesktop}\eslee Download Router"; Filename: "{app}\DownloadRouter.App.exe"; Tasks: desktopicon + +[Registry] +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "eslee Download Router"; ValueData: """{app}\DownloadRouter.App.exe"" --background"; Tasks: autostart; Flags: uninsdeletevalue + [Run] -Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\register-native-host.ps1"" -Action Register -HostPath ""{app}\DownloadRouter.NativeHost.exe"""; Flags: runhidden +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\register-native-host.ps1"" -Action Register -Browser All -HostPath ""{app}\DownloadRouter.NativeHost.exe"""; Flags: runhidden Filename: "{app}\DownloadRouter.App.exe"; Description: "{cm:LaunchProgram,{#AppName}}"; Flags: nowait postinstall skipifsilent [UninstallRun] Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\unregister-native-host.ps1"""; Flags: runhidden; RunOnceId: "UnregisterNativeHost" +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\unregister-startup.ps1"""; Flags: runhidden; RunOnceId: "UnregisterStartup" + +[Code] +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; + AppExe: String; +begin + Result := ''; + AppExe := ExpandConstant('{app}\DownloadRouter.App.exe'); + if FileExists(AppExe) then + begin + if not Exec(AppExe, '--shutdown', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Result := '기존 eslee Download Router를 정상 종료하지 못했습니다.'; + end; +end; diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..5524547 --- /dev/null +++ b/installer/README.md @@ -0,0 +1,32 @@ +# 사용자 단위 설치 파일 + +`DownloadRouter.iss`는 관리자 권한 없이 현재 사용자에게 설치하는 Inno Setup 6 정의입니다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps1 +``` + +산출물은 `artifacts\installer\eslee-download-router-setup.exe`입니다. Release self-contained App, Agent, Native Host, 확장 dist와 등록/해제 스크립트를 포함합니다. + +## 설치 정책 + +- 기본 경로: `%LOCALAPPDATA%\Programs\eslee\DownloadRouter` +- 시작 메뉴 바로가기, 선택적 바탕화면 바로가기 +- 선택한 경우 HKCU Run에 `DownloadRouter.App.exe --background` +- Whale, Edge, Chrome, Brave, Vivaldi, Opera의 HKCU Native Host 등록 +- 업그레이드 전 설치 App에 `--shutdown`을 전달해 App과 Agent를 정상 종료 +- unpackaged WinUI 필수 리소스 `App.xbf`, `MainWindow.xbf`, `DownloadRouter.App.pri` 포함 + +## 제거 정책 + +제거는 자동 시작, Native Host 등록, 바로가기와 설치 파일만 삭제합니다. 사용자 규칙과 이력이 들어 있는 `%LOCALAPPDATA%\eslee\DownloadRouter`는 기본 보존합니다. 사용자 데이터 삭제 옵션은 현재 제공하지 않으므로 제거기에서 이 경로를 추가로 지우지 마세요. + +## 배포 전 확인 + +- 설치/업그레이드/제거 종료 코드 +- 설치 App 10초 이상 안정 실행과 X 후 트레이 상주 +- 시작 메뉴/자동 시작/Native Host 등록 및 제거 +- 제거 전후 SQLite 행 수와 파일 SHA-256 동일 +- 서명된 배포라면 `SignTool` 설정과 인증서 체인을 별도 검토 + +현재 로컬 검증 산출물은 코드 서명되지 않았습니다. diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 new file mode 100644 index 0000000..e2a1e72 --- /dev/null +++ b/scripts/build-installer.ps1 @@ -0,0 +1,32 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release' +) + +. (Join-Path $PSScriptRoot 'common.ps1') +$root = Get-RepositoryRoot + +& (Join-Path $PSScriptRoot 'publish.ps1') -Configuration $Configuration +if ($LASTEXITCODE -ne 0) { throw 'Installer payload publish failed.' } + +$isccCandidates = @( + (Get-Command ISCC.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue), + (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe') +) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -Unique + +$iscc = $isccCandidates | Select-Object -First 1 +if (-not $iscc) { + throw 'Inno Setup 6 was not found. Install the trusted JRSoftware.InnoSetup winget package.' +} + +& $iscc (Join-Path $root 'installer\DownloadRouter.iss') +if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compilation failed.' } + +$setup = Join-Path $root 'artifacts\installer\eslee-download-router-setup.exe' +if (-not (Test-Path -LiteralPath $setup -PathType Leaf)) { + throw 'The expected installer output was not created.' +} + +Write-Host "Installer built: $setup" diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 index ba37595..f0f2a76 100644 --- a/scripts/publish.ps1 +++ b/scripts/publish.ps1 @@ -18,6 +18,11 @@ New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null & $dotnet publish (Join-Path $root 'src\DownloadRouter.App\DownloadRouter.App.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $publishRoot --nologo if ($LASTEXITCODE -ne 0) { throw 'Settings app publish failed.' } +foreach ($resource in @('App.xbf', 'MainWindow.xbf', 'DownloadRouter.App.pri')) { + if (-not (Test-Path -LiteralPath (Join-Path $publishRoot $resource) -PathType Leaf)) { + throw "Required unpackaged WinUI resource was not published: $resource" + } +} foreach ($project in @('DownloadRouter.Agent', 'DownloadRouter.NativeHost')) { $projectOutput = Join-Path $stagingRoot $project @@ -44,7 +49,9 @@ New-Item -ItemType Directory -Path $scriptOutput -Force | Out-Null Copy-Item -Path (Join-Path $root 'src\DownloadRouter.Extension\dist\*') -Destination $extensionOutput -Recurse -Force Copy-Item -LiteralPath (Join-Path $root 'scripts\common.ps1') -Destination $scriptOutput Copy-Item -LiteralPath (Join-Path $root 'scripts\register-native-host.ps1') -Destination $scriptOutput +Copy-Item -LiteralPath (Join-Path $root 'scripts\native-host-registration.ps1') -Destination $scriptOutput Copy-Item -LiteralPath (Join-Path $root 'scripts\unregister-native-host.ps1') -Destination $scriptOutput +Copy-Item -LiteralPath (Join-Path $root 'scripts\unregister-startup.ps1') -Destination $scriptOutput Write-Host "Self-contained installer payload: $publishRoot" Write-Host 'Build installer\DownloadRouter.iss with Inno Setup after reviewing signing settings.' diff --git a/scripts/unregister-native-host.ps1 b/scripts/unregister-native-host.ps1 index bd24b91..c761097 100644 --- a/scripts/unregister-native-host.ps1 +++ b/scripts/unregister-native-host.ps1 @@ -5,3 +5,8 @@ param( ) & (Join-Path $PSScriptRoot 'register-native-host.ps1') -Action Unregister -Browser $Browser + +$manifestPath = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'eslee\DownloadRouter\native-host\com.eslee.download_router.json' +if (Test-Path -LiteralPath $manifestPath -PathType Leaf) { + Remove-Item -LiteralPath $manifestPath -Force +} diff --git a/scripts/unregister-startup.ps1 b/scripts/unregister-startup.ps1 new file mode 100644 index 0000000..8c0adcc --- /dev/null +++ b/scripts/unregister-startup.ps1 @@ -0,0 +1,9 @@ +[CmdletBinding()] +param() + +$runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +if (Test-Path -LiteralPath $runKey) { + Remove-ItemProperty -LiteralPath $runKey -Name 'eslee Download Router' -ErrorAction SilentlyContinue +} + +Write-Host 'Removed current-user login startup registration.' diff --git a/src/DownloadRouter.App/DownloadRouter.App.csproj b/src/DownloadRouter.App/DownloadRouter.App.csproj index 8888fca..6ff21a7 100644 --- a/src/DownloadRouter.App/DownloadRouter.App.csproj +++ b/src/DownloadRouter.App/DownloadRouter.App.csproj @@ -18,4 +18,14 @@ + + + + + + + From a36326baf6eb74ed2dd0f76f5d569bc43986786e Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:28:45 +0900 Subject: [PATCH 14/24] test: add installed Whale validation tools --- scripts/send-agent-command.ps1 | 68 +++++++++++++++++++++++++++ scripts/whale-test-server.mjs | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 scripts/send-agent-command.ps1 create mode 100644 scripts/whale-test-server.mjs diff --git a/scripts/send-agent-command.ps1 b/scripts/send-agent-command.ps1 new file mode 100644 index 0000000..4bce9f9 --- /dev/null +++ b/scripts/send-agent-command.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('ping', 'download.started', 'download.metadata', 'download.changed', 'downloads.active', 'rules.list', 'rules.upsert', 'rules.delete', 'jobs.list', 'jobs.delete', 'selection.complete', 'selection.skip', 'route.change', 'job.retry', 'diagnostics.status')] + [string]$Command, + [Parameter(Mandatory = $true)] + [string]$PayloadJson, + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Debug' +) + +. (Join-Path $PSScriptRoot 'common.ps1') +$root = Get-RepositoryRoot +$hostPath = Join-Path $root "src\DownloadRouter.NativeHost\bin\$Configuration\net10.0\DownloadRouter.NativeHost.exe" +if (-not (Test-Path -LiteralPath $hostPath -PathType Leaf)) { + throw "Native Host development output was not found: $hostPath" +} + +$payload = $PayloadJson | ConvertFrom-Json +$request = @{ + version = 1 + requestId = [guid]::NewGuid() + command = $Command + payload = $payload +} | ConvertTo-Json -Depth 20 -Compress +$requestBytes = [Text.Encoding]::UTF8.GetBytes($request) +$prefix = [BitConverter]::GetBytes([int]$requestBytes.Length) +$startInfo = New-Object Diagnostics.ProcessStartInfo +$startInfo.FileName = $hostPath +$startInfo.Arguments = 'chrome-extension://gilicenlclaemgiijcjjejilikbooggj/' +$startInfo.UseShellExecute = $false +$startInfo.CreateNoWindow = $true +$startInfo.RedirectStandardInput = $true +$startInfo.RedirectStandardOutput = $true +$startInfo.RedirectStandardError = $true +$process = [Diagnostics.Process]::Start($startInfo) +try { + $process.StandardInput.BaseStream.Write($prefix, 0, $prefix.Length) + $process.StandardInput.BaseStream.Write($requestBytes, 0, $requestBytes.Length) + $process.StandardInput.BaseStream.Flush() + $process.StandardInput.Close() + + $responsePrefix = New-Object byte[] 4 + if ($process.StandardOutput.BaseStream.Read($responsePrefix, 0, 4) -ne 4) { + throw "Native Host returned no framed response: $($process.StandardError.ReadToEnd())" + } + + $responseLength = [BitConverter]::ToInt32($responsePrefix, 0) + if ($responseLength -lt 1 -or $responseLength -gt 1048576) { + throw "Native Host returned an invalid response length: $responseLength" + } + + $responseBytes = New-Object byte[] $responseLength + $offset = 0 + while ($offset -lt $responseLength) { + $read = $process.StandardOutput.BaseStream.Read($responseBytes, $offset, $responseLength - $offset) + if ($read -le 0) { throw 'Native Host response ended before the declared length.' } + $offset += $read + } + + [Text.Encoding]::UTF8.GetString($responseBytes) +} +finally { + $process.StandardInput.Dispose() + $process.StandardOutput.Dispose() + $process.StandardError.Dispose() + $process.Dispose() +} diff --git a/scripts/whale-test-server.mjs b/scripts/whale-test-server.mjs new file mode 100644 index 0000000..431acde --- /dev/null +++ b/scripts/whale-test-server.mjs @@ -0,0 +1,84 @@ +import http from "node:http"; + +const port = Number.parseInt(process.argv[2] ?? "8765", 10); +const host = "127.0.0.1"; + +const page = ` + +eslee Download Router Whale validation +

Whale validation downloads

+
`; + +const fixtures = new Map([ + ["/automatic", ["whale-automatic-test.txt", "automatic routing fixture\n"]], + ["/select", ["whale-tree-test.txt", "tree selection fixture\n"]], +]); + +const server = http.createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + if (url.pathname === "/") { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(page); + return; + } + + const fixture = fixtures.get(url.pathname); + if (fixture) { + const [fileName, body] = fixture; + response.writeHead(200, { + "content-type": "text/plain; charset=utf-8", + "content-disposition": `attachment; filename="${fileName}"`, + "content-length": Buffer.byteLength(body), + }); + response.end(body); + return; + } + + if (url.pathname === "/pending") { + const body = "pending filename fixture\n"; + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-length": Buffer.byteLength(body), + }); + response.end(body); + return; + } + + if (url.pathname === "/slow") { + const chunk = Buffer.alloc(64 * 1024, 0x61); + const chunks = 200; + let sent = 0; + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=whale-cancel-test.bin", + "content-length": chunk.length * chunks, + }); + const timer = setInterval(() => { + if (response.destroyed || sent >= chunks) { + clearInterval(timer); + if (!response.destroyed) response.end(); + return; + } + response.write(chunk); + sent += 1; + }, 100); + response.on("close", () => clearInterval(timer)); + return; + } + + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("not found\n"); +}); + +server.listen(port, host, () => { + process.stdout.write(`Whale validation server listening on http://${host}:${port}/\n`); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); +} From 85fc571030cc37e38ed5bca8b21262ee79801fff Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:28:58 +0900 Subject: [PATCH 15/24] docs: record installed background workflow --- ARCHITECTURE.md | 19 ++++++++------- CHANGELOG.md | 24 +++++++++++++++---- DEVELOPMENT.md | 18 +++++++++++++- PROJECT_STATE.md | 39 +++++++++++++++++------------- README.md | 17 +++++++++---- TESTING.md | 41 +++++++++++++++++++++++++------- docs/BROWSER_COMPATIBILITY.md | 17 ++++++++++--- docs/MANUAL_EXTENSION_INSTALL.md | 10 ++++++++ 8 files changed, 140 insertions(+), 45 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7eacc94..6976bbe 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,7 +21,7 @@ chrome.downloads.onCreated/onChanged ### Extension -Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고 `onChanged`에서 완료, 사용자 취소 또는 다른 중단 상태를 전송합니다. 시작 시 Agent가 가진 진행 중 ID만 `downloads.search`로 재확인해 놓친 종료 이벤트를 재전송합니다. 연결 오류는 다운로드를 취소하거나 변경하지 않습니다. 실패할 때만 원문 대신 `host-not-found`, `host-exited`, `agent.unavailable` 같은 제한된 분류 코드를 service worker 콘솔에 기록합니다. +Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고, `onChanged`의 `filename` delta를 같은 Job의 메타데이터 갱신으로 전송합니다. 완료 시 `downloads.search({ id })`로 최종 파일명을 다시 확인합니다. `download`, `*.crdownload` 같은 임시 이름은 확정 이름으로 취급하지 않습니다. 시작 시 Agent가 가진 진행 중 ID만 재확인해 놓친 종료 이벤트를 재전송합니다. 연결 오류는 다운로드를 취소하거나 변경하지 않습니다. 실패할 때만 원문 대신 제한된 분류 코드를 기록합니다. Chromium downloads API에는 신뢰할 수 있는 시작 탭 URL 필드가 없습니다. 따라서 활성 탭을 다운로드 출처로 추측하지 않고 `initiatingPageUrl`은 근거가 있을 때만 사용합니다. 현재 확장은 referrer를 우선 근거로 전달합니다. @@ -56,7 +56,9 @@ SQLite 마이그레이션은 `schema_migrations`, `rules`, `download_jobs`, `job ### WinUI 3 App -Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. 현재 대시보드, 규칙, 파일별 선택 대기, 상태 필터·삭제 이력, 브라우저 연결, 일반 설정, 진단, 정보 화면이 있습니다. App은 사용자 범위 mutex로 단일 인스턴스를 유지하고 1초 간격으로 Pending을 읽어 중복 없는 FIFO ContentDialog를 하나씩 표시합니다. +Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. App은 사용자 범위 mutex로 단일 인스턴스를 유지하는 트레이 호스트이며 `--background`에서는 메인 창을 표시하지 않습니다. 1초 간격으로 Pending을 읽고 중복 없는 FIFO `FolderSelectionWindow`를 하나씩 표시합니다. 선택 창은 메인 창과 독립적이어서 메인 창이 숨김·최소화 상태여도 활성화되며, 포커스 확보 실패 시 작업표시줄 점멸을 사용합니다. + +공통 `FolderTreePicker`는 루트 하나만 먼저 만들고 노드 확장 시 해당 단계의 자식만 비동기로 읽습니다. 로드된 노드를 중복 조회하지 않고, 접근 불가 항목은 노드 단위 오류로 제한하며 reparse point는 선택 경계에서 제외합니다. 팝업, 선택 대기, 이력 경로 변경이 같은 컴포넌트와 Agent 명령을 사용합니다. 규칙의 저장 루트는 경계가 아직 정해지지 않은 선택이므로 HWND로 초기화한 Windows `FolderPicker`를 사용합니다. 앱은 unpackaged WinUI 3 프로세스를 manifest에서 Per-Monitor V2로 선언합니다. 크기와 여백은 장치 독립 픽셀(DIP)을 사용하고 루트에서 layout rounding을 적용합니다. 모든 화면은 하나의 `NavigationView -> vertical ScrollViewer -> stretch viewport -> MaxWidth 1100 form` 구조를 공유합니다. 실제 폼 폭은 `ViewportWidth - 좌우 Padding`과 1100 DIP 중 작은 값이며 가로 스크롤을 만들지 않습니다. Compact는 `16,32,16,24`, Wide는 `32,48,32,32` DIP 패딩을 사용합니다. @@ -74,7 +76,7 @@ Interrupted RetryPending NotRequired ``` -두 축의 상태 전이는 `DownloadJobStateMachine`이 각각 검사합니다. 완료 전에 선택하면 `InProgress / SelectionReady`만 저장하고 파일을 이동하지 않습니다. `Complete / SelectionReady`가 된 뒤에만 Moving으로 전이합니다. 취소는 `Cancelled / NotRequired`, 다른 중단은 `Interrupted / Failed`이며 둘 다 Pending과 이동 대상에서 제외됩니다. 기존 단일 `Status` 열은 마이그레이션과 호환 표시용 파생 값으로 유지합니다. +두 축의 상태 전이는 `DownloadJobStateMachine`이 각각 검사합니다. 완료 전에 선택하면 `InProgress / SelectionReady`만 저장하고 파일을 이동하지 않습니다. `Complete / SelectionReady`가 된 뒤에만 Moving으로 전이합니다. 사용자 취소는 BrowserTransferState만 `Cancelled`로 바꾸고 당시 RoutingState를 보존하되, 표시·Pending·이동 가능 여부는 항상 BrowserTransferState를 우선합니다. 다른 중단은 `Interrupted / Failed`입니다. 기존 단일 `Status` 열은 마이그레이션과 호환 표시용 파생 값으로 유지합니다. ## 저장 위치 선택 경계 @@ -86,14 +88,15 @@ Interrupted RetryPending - 확장: `npm ci` 후 TypeScript compile, manifest 복사, ZIP 생성 - Native Host: HKCU 브라우저별 registry adapter와 `%LOCALAPPDATA%` manifest - Installer: per-user, `PrivilegesRequired=lowest` +- 자동 시작: `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`에 설치 App의 정확한 따옴표 경로와 `--background` +- X 버튼: 기본은 AppWindow 숨김, 트레이 `종료` 또는 `--shutdown`만 App/Agent 정상 종료 +- 제거: 설치 파일·자동 시작·Native Host만 제거하고 사용자 DB와 규칙은 보존 설치 페이로드와 PC별 manifest는 커밋하지 않습니다. ## 후속 설계 항목 -- 트레이/Windows 알림과 세션을 넘는 “나중에 선택” 정책 -- 새 폴더 만들기와 새로 고침을 포함한 전용 트리 선택기 -- Windows 시작 시 실행 설정 저장 및 등록 +- 세션을 넘는 “나중에 선택” 알림 정책과 새 폴더 만들기 - 중단된 작업의 시작 시 복구 워커 -- 브라우저 adapter의 실제 레지스트리/실행 경로 검증 -- 설치 서명과 업데이트 전략 +- Whale 이외 브라우저 adapter의 실제 레지스트리/실행 경로 검증 +- 설치 코드 서명과 업데이트 채널 diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9f970..505a738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,13 +13,19 @@ - 경로 토큰, 루트 경계와 reparse point 방어 - 동일/교차 볼륨 안전 이동, 안정화 확인, SHA-256 검증, 중복 이름 보존 - WinUI 대시보드, 규칙, 파일별 선택 대기, 상태 필터·삭제 이력, 브라우저, 진단 화면 -- 38개 .NET 테스트, 8개 TypeScript 테스트, Agent/Native Host 스모크 테스트 +- 53개 .NET 테스트, 10개 TypeScript 테스트, Agent/Native Host 스모크 테스트 - per-user Native Host 등록 및 Inno Setup 설치 골격 - Native Host 등록의 Windows PowerShell 5.1 회귀 테스트와 개인정보 로그 회귀 테스트 - 매칭 규칙의 작업 생성·실제 파일 이동을 검증하는 Agent 통합 테스트 - BrowserTransferState/RoutingState 분리와 기존 DB v2 마이그레이션 - 단일 FIFO 선택 팝업, App 자동 시작 요청, 파일별/선택 항목 폴더 적용과 이동 건너뛰기 - Extension 시작 시 진행 중 브라우저 다운로드 상태 재조정 +- 계층형 lazy `FolderTreePicker`와 고정 크기 독립 `FolderSelectionWindow` +- 이력의 Job별 경로 선택·변경, 완료 파일 확인 후 재이동, 현재 필터 전체 선택 +- NavigationView Pending `InfoBadge`와 공통 대시보드 집계 +- 규칙 편집·활성 토글·soft delete·저장 루트 Windows FolderPicker +- 단일 인스턴스 트레이 호스트, `--background`/`--shutdown`, HKCU 로그인 자동 시작 +- per-user Inno Setup 설치/업그레이드/제거 및 설치 파일 빌드 스크립트 ### Changed @@ -28,6 +34,8 @@ - Whale 설치 탐지에 Program Files와 Program Files (x86) 후보를 추가 - 대시보드가 다운로드 중, 선택 대기, 완료, 취소/중단, 재시도/실패를 별도로 집계 - 취소 이력을 자동 삭제하지 않고 취소선과 낮은 opacity로 유지하며 DB 이력만 사용자 삭제 +- 취소 시 당시 RoutingState를 보존하되 표시, Pending과 이동 가능 여부는 BrowserTransferState를 우선 +- 사이트 규칙 화면을 규칙 편집 Card와 저장된 규칙 Card 섹션으로 구분 ### Fixed @@ -41,6 +49,13 @@ - ScrollViewer가 좁은 창에서도 1100 DIP 폼을 측정해 우측 콘텐츠가 창 밖으로 나가던 문제 - 모든 페이지 제목이 TitleBar 바로 아래에 붙어 보이던 공통 상단 여백 - 다운로드 완료로 파일명이 바뀐 동안 열린 선택 팝업이 이전 이름과 상태를 유지하던 문제 +- `download`, `*.crdownload` 등 임시 이름을 확정 이름처럼 표시하던 문제와 같은 다운로드의 중복 Job 가능성 +- 긴 트리 노드가 선택 창 DesiredSize와 폭을 계속 키우던 문제 +- 최소화·트레이 상태에서 ContentDialog가 사용자 화면에 직접 나타나지 않던 문제 +- RoutingState에 따라 취소선이 누락되던 문제 +- 선택 대기 수가 내비게이션에서 보이지 않던 문제 +- 이력의 3상태 전체 선택이 한 번의 클릭으로 해제되지 않던 문제 +- `dotnet publish` 결과에서 unpackaged WinUI XBF/PRI가 빠져 설치 App이 시작하지 못하던 문제 ### Security @@ -50,7 +65,8 @@ ### Known limitations -- Whale Automatic 규칙과 다른 Chromium 브라우저의 실제 이동은 검증 전 +- Whale Automatic/SelectSubfolder는 실제 이동 검증 완료, 다른 Chromium 브라우저는 검증 전 - Windows 100%/150% 및 FHD/4K 실제 디스플레이 시각 검증 전 -- 트레이/Windows 알림, 새 폴더 생성/새로 고침 미구현 -- 시작 시 실행 토글과 미완료 작업 자동 복구 미연결 +- 새 폴더 생성과 세션 간 “나중에 선택” 알림 미구현 +- 실제 로그아웃/로그인 자동 시작과 미완료 작업 자동 복구 미검증 +- 설치 파일 코드 서명 미구현 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bc10289..a434585 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -57,9 +57,25 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\manual-download-se # 설치 페이로드 준비 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\publish.ps1 -Configuration Release + +# 사용자 단위 Inno Setup 설치 파일 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps1 ``` -Inno Setup으로 `installer\DownloadRouter.iss`를 컴파일하기 전에 `artifacts\publish\app`을 검토하고 서명 설정을 결정합니다. +`build-installer.ps1`은 publish 결과에 `App.xbf`, `MainWindow.xbf`, `DownloadRouter.App.pri`가 있는지 먼저 검사합니다. unpackaged WinUI 3 게시에서 이 세 파일이 빠지면 설치본이 `XamlParseException`으로 시작하지 못하므로 해당 검사를 제거하지 않습니다. 설치 파일은 아직 코드 서명되지 않았습니다. + +## 트레이, 자동 시작, 선택 창 + +- App은 단일 인스턴스이며 `--background`에서 메인 창을 숨긴 채 트레이와 Agent만 준비합니다. +- 로그인 자동 시작은 관리자 권한이 필요 없는 HKCU Run을 사용합니다. 값은 반드시 따옴표로 감싼 절대 App 경로와 `--background`여야 합니다. +- 기본 X 동작은 메인 AppWindow 숨김입니다. `종료` 명령과 설치 업그레이드의 `--shutdown`만 완전 종료를 요청합니다. +- 폴더 선택은 메인 창의 ContentDialog가 아니라 별도 Window입니다. 창 크기는 DIP를 실제 모니터 DPI로 변환한 뒤 작업 영역 안으로 제한합니다. +- 트리는 전체 재귀 열거를 금지하고 확장한 노드의 직계 자식만 비동기로 읽습니다. UI와 Agent 양쪽에서 루트 경계를 검사합니다. +- 이력의 경로 변경은 규칙을 수정하지 않고 Job의 상대 경로만 변경합니다. 이미 이동된 파일은 사용자 확인 뒤 기존 안전 이동 서비스를 다시 사용합니다. + +## 설치/제거 검증 + +업그레이드 전 App의 `--shutdown` 완료를 기다리고 설치합니다. 설치 뒤에는 시작 메뉴, HKCU Run, 6개 브라우저 Native Host, 백그라운드 단일 인스턴스, X 후 프로세스 유지와 트레이 메뉴를 확인합니다. 제거 전후에는 사용자 DB의 행 수와 SHA-256을 비교합니다. 제거 스크립트는 `%LOCALAPPDATA%\eslee\DownloadRouter`를 삭제하면 안 됩니다. ## 기능 추가 순서 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 80d09e2..d8663b1 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -25,23 +25,29 @@ - 중복 파일 번호 보존과 재시도 상태 - 브라우저 전송 상태와 라우팅 상태를 분리한 작업 모델과 v1 → v2 SQLite 마이그레이션 - 파일별 SelectSubfolder 카드, 명시적으로 체크한 항목만 일괄 적용, 건너뛰기와 세션 단위 나중에 선택 -- 단일 FIFO 선택 팝업, 실행 중 App 활성화, 미실행 App 시작 요청, 취소 시 자동 닫기 +- 고정 DIP 전용 선택 창, lazy 계층형 FolderTreePicker, 단일 FIFO, 숨김/최소화 상태 직접 표시, 취소 시 자동 닫기 +- `onCreated`/`filename` delta/완료 검색을 통한 같은 Job 파일명 갱신과 임시 이름 차단 +- 이력의 Job별 저장 위치 선택·변경, 완료 파일 명시적 재이동, 이동 안 함/나중에 선택 - 취소선·상태 필터·개별/선택/취소 이력 삭제 UI와 실제 파일 비삭제 보장 +- 현재 필터 전체 선택/해제와 선택 수 표시 +- NavigationView Pending InfoBadge와 대시보드 공통 집계 +- 한 페이지의 규칙 생성/편집 섹션과 저장된 규칙 카드, 활성 토글, soft delete, HWND FolderPicker +- App 단일 인스턴스 트레이 호스트, `--background`, HKCU 로그인 자동 시작, X 숨김과 명시적 종료 - WinUI 3 필수 내비게이션 화면 골격과 Agent 진단 - 브라우저 설치/관리 주소 adapter와 HKCU Native Host 등록 스크립트 - Windows PowerShell 5.1 절대 경로/HKCU 등록 호환성과 BOM 없는 UTF-8 Native Host manifest - 실패할 때만 분류 코드를 남기는 Extension Native Messaging 진단 로그 - Per-Monitor V2 WinUI 렌더링, 뷰포트 실폭 제한, 32/48 DIP 공통 상단 여백, 1초 상태 변경 감지 -- self-contained win-x64 publish와 per-user Inno Setup 골격 +- self-contained win-x64 publish와 설치/업그레이드/제거가 검증된 per-user Inno Setup 0.2.0 ## 빌드와 테스트 | 항목 | 결과 | |---|---| | `.NET Debug build` | 성공, 경고 0, 오류 0 | -| `.NET tests` | 38/38 통과(Core 24, Infrastructure 6, Integration 8) | +| `.NET tests` | 53/53 통과(Core 32, Infrastructure 7, Integration 14) | | Extension ESLint/TypeScript build | 성공 | -| Extension Node tests | 8/8 통과 | +| Extension Node tests | 10/10 통과 | | Agent Named Pipe ping | 성공 | | Native Host self-test/길이 접두사 ping/Agent 자동 시작 | 성공 | | Native Host Agent 장애 fail-open | `agent.unavailable`, Host 종료 코드 0, 브라우저 비차단 응답 확인 | @@ -49,48 +55,48 @@ | WinUI QHD 125% | 수정 전 DPI Unaware/96 → 수정 후 Per-Monitor V2/120 확인 | | WinUI 반응형 폭 | 900px 창에서 우측 넘침 0, 2400px 창에서 1100 DIP 폼 중앙 정렬 확인 | | clean clone | 공식 `feature/initial-spike@9975c47`에서 bootstrap/build/test 성공 | -| Installer compile/install/uninstall | 미검증 | +| Installer compile/install/upgrade/uninstall | 성공, 사용자 DB 해시·행 수 보존, 자동 시작/6개 Native Host/바로가기 정리 확인 | ## 브라우저 검증 | 브라우저 | 설치 탐지 | 확장 로드 | 다운로드 이벤트 | Native Messaging | 자동 저장 | 직접 선택 | |---|---|---|---|---|---|---| -| Whale | 성공 | 성공 | 성공 | 성공 | 수동 검증 필요 | 성공(SelectSubfolder) | +| Whale | 성공 | 성공 | 성공 | 성공 | 성공 | 성공(계층형 SelectSubfolder) | | Edge | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Chrome | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Brave | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Vivaldi | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Opera | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -Whale 4.38.386.14에서 로컬 HTTP 테스트 파일과 격리 DB/저장 루트를 사용했습니다. 완료 후 `quick-a.bin → A`, `quick-b.bin → B`, 다운로드 중 선택 후 완료된 `select-slow.bin → D` 이동을 확인했습니다. 4개 동시 시작은 팝업 중첩 없이 FIFO로 하나씩 표시됐고 각 파일을 A/B/C/D에 개별 적용했습니다. 브라우저 취소는 팝업 자동 닫기, `Cancelled / NotRequired`, Pending 제외, 이동 0건으로 확인했습니다. 취소 이력 정리 후 이미 이동된 실제 파일은 모두 유지됐습니다. Automatic 규칙의 실브라우저 검증은 별도 항목으로 남습니다. +Whale 4.38.386.14에서 로컬 HTTP fixture로 Automatic과 SelectSubfolder를 실제 검증했습니다. Automatic은 C: 기본 다운로드 위치에서 D: 테스트 규칙 루트로 교차 볼륨 이동했고, SelectSubfolder는 테스트 루트에서 `kr → 모야지`만 확장·선택해 이동했습니다. 100자 이상 노드를 표시하고 복귀해도 선택 창 폭은 700px(125%의 560 DIP)로 동일했습니다. 메인 창 숨김/최소화 중에도 독립 선택 창이 직접 나타났습니다. 기존 A/B/C/D FIFO와 브라우저 취소 검증도 유지됩니다. ## 알려진 문제와 제한 - downloads API만으로 다운로드 시작 탭 URL을 신뢰성 있게 얻을 수 없어 활성 탭을 추측하지 않습니다. 현재 referrer와 파일 URL을 분리해 사용합니다. -- App 실행 파일을 찾을 수 있으면 Agent가 선택 UI를 시작하고, 실행 중이면 1초 폴링과 창 활성화로 FIFO 팝업을 표시합니다. 설치 손상으로 App 실행 파일을 찾지 못해도 다운로드는 원래 위치에서 완료되고 Pending에 남습니다. -- “나중에 선택” 팝업 억제는 현재 App 세션 동안만 유지됩니다. 트레이/Windows 알림, 새 폴더 생성, 폴더 새로 고침은 미구현입니다. -- 시작 시 실행 UI는 실제 Windows 등록과 연결되지 않았습니다. +- App 실행 파일을 찾을 수 있으면 Agent가 선택 UI를 시작하고, 실행 중이면 1초 폴링과 독립 창으로 FIFO를 표시합니다. 설치 손상으로 App을 찾지 못해도 다운로드는 원래 위치에서 완료되고 Pending에 남습니다. +- “나중에 선택” 팝업 억제는 현재 App 세션 동안만 유지됩니다. 트리는 선택 노드 새로 고침을 지원하지만 새 폴더 생성은 미구현입니다. - Extension 시작 시 Agent의 진행 중 ID를 브라우저 다운로드 기록과 대조해 완료·취소·중단을 재전송합니다. 브라우저 기록에서 사라진 오래된 작업은 근거 없이 완료·삭제하지 않습니다. - Whale registry adapter는 이 PC에서 검증했습니다. Brave/Vivaldi/Opera adapter는 실제 PC 검증이 필요합니다. - 브라우저 자체 “다운로드 전에 저장 위치 확인” 설정 감지와 안내는 문서만 있고 UI 자동 감지는 미구현입니다. -- QHD 125% 실제 실행과 좁은/넓은 창 시각·경계 검증은 완료했습니다. Windows 100%/150%, FHD/4K 실기기, 키보드/스크린리더 접근성 및 installer 동작 검증은 남아 있습니다. +- QHD 125% 실제 실행과 좁은/넓은 창 시각·경계 검증은 완료했습니다. Windows 100%/150%, FHD/4K 실기기와 키보드/스크린리더 접근성 검증은 남아 있습니다. +- HKCU 자동 시작 명령과 백그라운드 실행은 확인했지만 실제 로그아웃/로그인 또는 재부팅은 수행하지 않았습니다. +- 설치 파일은 코드 서명되지 않았습니다. ## 보류된 결정 - 배포 코드 서명 인증서와 업데이트 채널 - 안정 배포 확장 ID/스토어 배포 방식 - 로그 보존 기간과 사용자 삭제 UI -- Agent 선택 알림을 App activation, tray, 별도 picker 중 어떤 방식으로 구현할지 +- 로그아웃을 포함한 장기 “나중에 선택” 알림 정책 ## 다음 작업 -1. Whale Automatic 규칙과 Native Host 장애 중 실다운로드 유지 검증 +1. Native Host 장애 중 Whale 실다운로드 유지 검증 2. Windows 100%/150% 및 FHD/4K에서 UI 실배율 시각 검증 3. Edge에서 개발자 모드 확장 로드와 Native Messaging 왕복을 실제 검증 -4. 파일별 picker에 새 폴더/새로 고침 및 영구 알림 설정 추가 +4. FolderTreePicker 새 폴더 만들기와 영구 알림 설정 추가 5. 브라우저 기록에서 사라진 오래된 비종료 작업의 사용자 확인 복구 흐름 설계 -6. 시작 시 실행 옵션 구현 -7. Inno Setup 설치/제거와 앱 UI 검증 +6. 설치 코드 서명과 업데이트 채널 결정 ## 실행 명령 @@ -100,6 +106,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 -SkipBuild +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps1 ``` ## 로컬 전용 설정 diff --git a/README.md b/README.md index 556df50..ad00522 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Chromium 기반 브라우저에서 완료된 다운로드를 사이트 규칙에 따라 Windows 폴더로 분류하는 로컬 전용 애플리케이션입니다. 규칙이 없거나 로컬 구성 요소가 응답하지 않으면 브라우저의 원래 다운로드를 그대로 유지하는 fail-open 방식을 사용합니다. -> 현재 상태: Phase 0과 Phase 1 기술 스파이크가 구현되었습니다. 자동 빌드·테스트와 로컬 IPC는 검증했지만 실제 브라우저의 확장 로드, Native Messaging, 다운로드 라우팅은 아직 수동 검증 전입니다. 자세한 내용은 [PROJECT_STATE.md](PROJECT_STATE.md)를 확인하세요. +> 현재 상태: 핵심 라우팅, 계층형 폴더 선택, 트레이 상주, 로그인 자동 시작, 규칙/이력 관리와 사용자 단위 설치 흐름을 구현했습니다. Whale 4.38.386.14에서 Automatic과 SelectSubfolder의 실제 C: → D: 이동을 검증했습니다. 자세한 결과와 남은 제약은 [PROJECT_STATE.md](PROJECT_STATE.md)를 확인하세요. ## 구성 요소 @@ -63,6 +63,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\package-extension. | Agent IPC 스모크 테스트 | `scripts\smoke-agent.ps1` | | 확장 패키지 생성 | `scripts\package-extension.ps1` | | self-contained 설치 페이로드 | `scripts\publish.ps1` | +| 사용자 단위 설치 파일 | `scripts\build-installer.ps1` | | Native Host 상태 확인 | `scripts\register-native-host.ps1 -Action Status` | | 진단 | `scripts\diagnose.ps1` | @@ -89,14 +90,22 @@ PowerShell 실행 정책이 로컬 스크립트를 막는 경우 예시처럼 `p - 직접 선택 경로는 지정 루트 내부로 제한하고 `..`, 심볼릭 링크, junction, reparse point 탈출을 거부합니다. - URL 자격 증명, query, fragment는 저장 전에 제거합니다. +## 설치형 실행 + +`scripts\build-installer.ps1`은 Release self-contained 페이로드와 Inno Setup 사용자 단위 설치 파일을 `artifacts\installer`에 만듭니다. 설치본은 App을 `--background`로 로그인 자동 시작하고, App이 단일 인스턴스 트레이 호스트로서 Agent 실행을 보장합니다. 기본 X 버튼 동작은 메인 창 숨기기이며 완전 종료는 트레이의 `종료` 또는 일반 설정의 명시적 종료 동작으로 수행합니다. + +설치/업그레이드 시 기존 프로세스는 정상 종료 신호를 받고, Whale·Edge·Chrome·Brave·Vivaldi·Opera의 HKCU Native Host가 설치 경로로 등록됩니다. 제거 시 자동 시작, 바로가기와 Native Host 등록만 제거하며 `%LOCALAPPDATA%\eslee\DownloadRouter`의 규칙·이력 DB는 보존합니다. + 설계와 위협 모델은 [ARCHITECTURE.md](ARCHITECTURE.md), [SECURITY.md](SECURITY.md)를 참고하세요. ## 현재 제한 - Chromium downloads API가 다운로드 시작 탭 URL을 직접 제공하지 않으므로, 신뢰할 수 없는 활성 탭 추측은 하지 않습니다. 제공되는 referrer, 최초 URL, 최종 URL을 분리해 사용합니다. -- 폴더 선택 대기 작업은 앱의 전용 화면에서 규칙별로 묶어 처리하지만, 자동 팝업·새 폴더 만들기·새로 고침은 후속 구현입니다. -- 시작 시 실행 토글은 UI 골격만 있으며 실제 Windows 등록은 아직 연결하지 않았습니다. -- 모든 브라우저 수동 검증 결과는 현재 `미검증`입니다. +- 폴더 트리는 lazy loading과 선택 노드 새로 고침을 지원하지만 새 폴더 만들기는 제공하지 않습니다. +- Windows 로그아웃/로그인 또는 재부팅 자체는 작업 환경을 중단하므로 수행하지 않았습니다. HKCU Run 등록과 `--background` 실행은 각각 확인했습니다. +- UI는 QHD 125%에서 실제 검증했습니다. FHD/4K와 Windows 100%/150%는 Per-Monitor V2/DIP 구조 및 좁은·넓은 창 경계 테스트만 완료했고 물리 디스플레이 전환 검증은 남아 있습니다. +- Edge·Chrome·Brave·Vivaldi·Opera의 실제 다운로드는 아직 수동 검증하지 않았습니다. +- 설치 파일은 현재 코드 서명되지 않았습니다. ## 문서 diff --git a/TESTING.md b/TESTING.md index 4dc3b4e..d85f2cd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,16 +16,17 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\diagnose.ps1 2026-07-22 현재 로컬 결과: - .NET Debug build: 성공, 경고 0, 오류 0 -- .NET tests: 38/38 통과 - - Core 24: 규칙, IDN/도메인 경계, 경로, 개인정보, 분리 상태 전이, 대시보드 집계, FIFO 중복 방지 - - Infrastructure 6: v1 → v2 SQLite 마이그레이션, round-trip, 동일·교차 볼륨, 중복 이름, 로그 개인정보 제거 - - Integration 8: 명령 거부, 미매칭 fail-open, 완료 전/후 파일별 선택, 선택 항목 일괄 적용, 취소/중단, 실제 파일 비삭제 이력 삭제 -- Extension: ESLint 성공, TypeScript compile 성공, Node tests 8/8 통과, dist build 성공 +- .NET tests: 53/53 통과 + - Core 32: 규칙, IDN/도메인 경계, 경로, 개인정보, 상태 전이, Pending 집계, 취소 표시 조합, 임시 파일명, lazy 폴더 트리, 자동 시작 등록/해제 + - Infrastructure 7: v1 → v2 → v3 SQLite 마이그레이션, soft-delete 규칙, round-trip, 동일·교차 볼륨, 중복 이름, 로그 개인정보 제거 + - Integration 14: 명령 거부, 미매칭 fail-open, 파일명 동일 Job 갱신, 완료 전/후 선택, 경로 재지정/재이동 확인, 취소/중단, 규칙 편집·삭제, skip 완료 시 원본 보존, 실제 파일 비삭제 이력 삭제 +- Extension: ESLint 성공, TypeScript compile 성공, Node tests 10/10 통과, dist build 성공 - Windows PowerShell 5.1 등록 테스트: drive/UNC 절대 경로, 상대 경로 거부, origin 필터, BOM 없는 UTF-8 통과 - Agent Named Pipe ping: 성공 - Native Host `--self-test`, 길이 접두사 ping, 게시 Agent 자동 시작: 성공 - Agent 미가용: `agent.unavailable`, Native Host 종료 코드 0, Agent 미기동, stderr 민감정보 없음 - App/Agent/Native Host Release `win-x64 --self-contained true` publish: 성공 +- Inno Setup 6.7.3 설치 파일 compile: 성공, unpackaged WinUI XBF/PRI 포함 검사 통과 ## UI/DPI 검증 @@ -41,6 +42,28 @@ QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습 100%/150% Windows 실배율과 FHD/4K 물리 디스플레이는 현재 단일 QHD 125% 환경에서 직접 전환하지 않았습니다. manifest/DIP/MaxWidth 구조와 좁은·넓은 창 경계는 검증했지만 이 실기기 항목은 수동 확인으로 남깁니다. +### 선택 창과 설치본 UI Automation + +- QHD 125%에서 전용 선택 창: 700×950px = 560×760 DIP, 작업 영역 중앙, 크기 조절/최대화 비활성 +- 100자 이상 폴더 노드를 `ScrollIntoView`한 전/후와 루트 복귀 후 창 폭: 모두 700px +- 트리 루트 첫 확장 한 번으로 직계 자식이 실체화되고 `kr → 모야지` 선택 가능 +- 메인 창 최소화 상태에서 선택 창은 별도 최상위 창으로 표시되고 메인 창은 `Minimized` 유지 +- 메인 창 X 후 App/Agent 프로세스 유지, 보이는 메인 최상위 창 없음 +- 이력 현재 필터 전체 선택: `Off → On`, 다시 한 번 클릭해 `Off`; 필터 변경 시 선택 초기화 정책 문구 표시 + +### 설치/업그레이드/제거 + +- silent 초기 설치와 업그레이드 종료 코드 0 +- 설치 App 10초 이상 안정 실행, 백그라운드 실행 시 보이는 메인 창 0, 두 번째 실행 후 App 인스턴스 1 +- 실제 창 DPI 120, `XamlRoot.RasterizationScale=1.25` +- 설치 전후 DB v3 마이그레이션에서 기존 Rule/Job 수와 ID 보존 +- 자동 시작 값: 설치 App의 따옴표 절대 경로 + `--background` +- 트레이 메뉴: `열기`, `저장 위치 선택 대기 열기`, `일반 설정`, `종료` +- 제거 전: Native Host 6개, 자동 시작, 시작 메뉴 존재 +- 제거 후: Native Host 0개, 자동 시작/시작 메뉴/설치 디렉터리 없음 +- 제거 전후 사용자 SQLite: 규칙 4행, 작업 29행, DB SHA-256 동일 +- 로그아웃/로그인·재부팅은 실제 수행하지 않음; 등록된 자동 시작 명령의 수동 백그라운드 실행만 확인 + ## 새 테스트 작성 원칙 - 순수 규칙과 경로 정책은 Core 단위 테스트로 작성합니다. @@ -86,16 +109,16 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\manual-download-se 7. 4개를 동시에 시작해 팝업이 하나만 열리고 파일별 선택으로 A/B/C/D 이동이 가능한지 확인합니다. 8. 취소된 이력 정리 후 이미 이동된 실제 파일이 그대로 있는지 확인합니다. -2026-07-22 실제 Whale 4.38.386.14 결과: 위 4~8번 성공. 테스트 파일은 격리 루트에 보존했고 취소 작업은 DB 이력에서만 삭제했습니다. +2026-07-22 실제 Whale 4.38.386.14 결과: 위 4~8번과 설치본 Automatic/Tree 흐름 성공. 이번 테스트가 만든 파일은 휴지통으로 이동해 복구 가능하며 테스트 DB 이력과 규칙만 정확한 ID로 정리했습니다. ## 브라우저 검증 상태 - Whale 4.38.386.14: 확장 `dist` 로드와 고정 ID, 실제 다운로드 이벤트, Native Messaging 왕복, 미매칭 fail-open 성공 - Whale SelectSubfolder: 완료 전/후 선택, A/B 개별 폴더, 취소, 4개 FIFO, 이력 삭제 성공 -- Whale Automatic 규칙: 미검증 +- Whale Automatic 규칙: 실제 C: → D: 교차 볼륨 이동 성공 - Edge/Chrome/Brave/Vivaldi/Opera 확장과 Native Messaging: 미검증 -- UI: QHD 125% 시각·경계 검증 완료, 100%/150%와 접근성 미검증 +- UI: QHD 125% 시각·경계·독립 팝업 검증 완료, 100%/150%와 접근성 미검증 - 네트워크 드라이브 및 실제 다른 물리 드라이브 -- Inno Setup 설치/제거 및 코드 서명 +- Inno Setup 설치/업그레이드/제거 성공, 코드 서명 미구현 미수행 항목을 성공으로 기록하지 않습니다. diff --git a/docs/BROWSER_COMPATIBILITY.md b/docs/BROWSER_COMPATIBILITY.md index 7a5d341..82ab83c 100644 --- a/docs/BROWSER_COMPATIBILITY.md +++ b/docs/BROWSER_COMPATIBILITY.md @@ -2,11 +2,11 @@ 마지막 갱신: 2026-07-22 -자동화된 Agent/Native Host 스모크 테스트와 실제 브라우저 수동 테스트를 구분합니다. Whale의 SelectSubfolder 파일별 선택은 실제 브라우저 성공이며 Automatic 규칙은 아직 미검증입니다. +자동화된 Agent/Native Host 스모크 테스트와 실제 브라우저 수동 테스트를 구분합니다. Whale의 SelectSubfolder와 Automatic은 실제 브라우저에서 성공했습니다. | 브라우저 | 지원 수준 | 설치 탐지 | 확장 로드 | 다운로드 이벤트 | Native Messaging | 자동 저장 | 직접 선택 | 상태 | |---|---|---|---|---|---|---|---|---| -| Naver Whale | 정식 목표 | 성공 | 성공 | 성공 | 성공 | 미검증 | 성공 | 부분 성공 | +| Naver Whale | 정식 목표 | 성공 | 성공 | 성공 | 성공 | 성공 | 성공 | 성공 | | Microsoft Edge | 정식 목표 | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 확장 수동 검증 필요 | | Google Chrome | 정식 목표 | 성공 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 확장 수동 검증 필요 | | Brave | 호환 목표 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | adapter 후보 | @@ -77,4 +77,15 @@ Native Messaging: - 4개 동시 시작: 단일 FIFO 팝업에서 네 작업을 순서대로 처리하고 A/B/C/D에 각각 이동 - 이력 삭제: 취소 이력 정리 확인창을 거쳐 DB 항목만 제거. 이미 이동된 테스트 파일 4개 유지 - UI: 900×850 창에서 UI Automation 기준 수평 넘침 0, 공통 Wide 상단 패딩 48 DIP 확인 -- Automatic 규칙, 다른 Chromium 브라우저, Native Host 장애 중 실다운로드: 미검증 +- Automatic 규칙은 같은 날짜의 설치본 검증에서 성공. 다른 Chromium 브라우저와 Native Host 장애 중 실다운로드는 미검증 + +## 2026-07-22 — 설치본 Whale Automatic/Tree 검증 + +- 환경: Windows 11, QHD 2560×1440, 125%, Whale 4.38.386.14 +- 설치본: per-user Inno Setup 0.2.0 업그레이드 설치, App/Agent/Native Host self-contained +- Automatic: 실제 Whale 다운로드가 C: 기본 다운로드 위치에서 D: 규칙 루트로 이동. 브라우저 완료 전 이동 없음, 작업은 `Complete / Completed` +- SelectSubfolder: D: 테스트 루트에서 `kr` 확장 후 `모야지` 선택, C: 원본 소멸과 D: 대상 생성 확인 +- 긴 노드: 100자 이상 폴더를 표시하고 위아래로 이동해도 선택 창 폭은 125%에서 700px(560 DIP)로 동일 +- 숨김/최소화: 메인 창을 숨기거나 최소화한 상태에서 독립 선택 창 표시, 메인 창은 복원되지 않음 +- 파일명: 임시 이름 필터와 같은 Job 메타데이터 갱신은 자동 테스트 및 Agent 실연동으로 확인. Whale가 이 fixture에서 지연 filename delta를 내지 않아 “확인 중 → 최종 이름” 시각 전환 자체는 미재현 +- Automatic/Tree 테스트 파일과 테스트 규칙·작업만 정리했으며 사용자 작업은 보존 diff --git a/docs/MANUAL_EXTENSION_INSTALL.md b/docs/MANUAL_EXTENSION_INSTALL.md index 8991004..6c16fb1 100644 --- a/docs/MANUAL_EXTENSION_INSTALL.md +++ b/docs/MANUAL_EXTENSION_INSTALL.md @@ -12,6 +12,14 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\package-extension. 압축 해제 로드 경로는 `src\DownloadRouter.Extension\dist`입니다. ZIP은 배포 검토용이며 개발자 모드 “압축 풀린 확장 로드”에는 폴더를 사용합니다. +일반 사용자 설치 흐름을 검증할 때는 Inno Setup 6이 설치된 환경에서 다음을 실행합니다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps1 +``` + +생성된 `artifacts\installer\eslee-download-router-setup.exe`는 사용자 단위로 App/Agent/Native Host/확장 dist를 설치하고 HKCU Native Host 및 선택한 자동 시작을 등록합니다. 설치본을 사용하는 경우 아래 개발 출력 등록 명령은 실행하지 않습니다. + ## 2. Native Host 등록 브라우저별로 하나씩 등록하고 상태를 확인합니다. @@ -55,6 +63,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\unregister-native- 브라우저 확장 관리 화면에서 개발자 확장을 제거합니다. 사용자 DB와 규칙은 자동 삭제하지 않습니다. +Inno Setup 설치본 제거도 자동 시작, 바로가기와 설치 경로의 Native Host 등록만 해제합니다. `%LOCALAPPDATA%\eslee\DownloadRouter`의 SQLite DB와 규칙은 재설치를 위해 기본 보존됩니다. + ## 문제 해결 - `scripts\diagnose.ps1` 실행 From e664803dc321c9bb00554ea686bf178a62853b32 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:39:12 +0900 Subject: [PATCH 16/24] Fix pending prompts, cancellation, version, and themes --- ARCHITECTURE.md | 13 +- CHANGELOG.md | 18 ++- DEVELOPMENT.md | 6 +- Directory.Build.props | 6 + HANDOFF.md | 6 +- PROJECT_STATE.md | 19 ++- README.md | 7 +- TESTING.md | 33 +++- installer/DownloadRouter.iss | 10 +- installer/README.md | 5 + scripts/build-installer.ps1 | 17 ++- scripts/build.ps1 | 9 +- scripts/publish.ps1 | 7 +- scripts/send-agent-command.ps1 | 2 +- .../AgentCommandHandler.cs | 126 +++++++++++++-- src/DownloadRouter.App/App.xaml.cs | 6 +- src/DownloadRouter.App/AppPreferencesStore.cs | 40 ----- .../FolderSelectionWindow.cs | 22 ++- src/DownloadRouter.App/MainWindow.History.cs | 2 +- src/DownloadRouter.App/MainWindow.Lifetime.cs | 7 +- src/DownloadRouter.App/MainWindow.Live.cs | 81 +++++++--- src/DownloadRouter.App/MainWindow.Pending.cs | 27 +++- src/DownloadRouter.App/MainWindow.Settings.cs | 26 +++- src/DownloadRouter.App/MainWindow.xaml.cs | 53 ++++++- src/DownloadRouter.App/ThemeManager.cs | 55 +++++++ .../Jobs/DownloadJobQueries.cs | 9 ++ .../Jobs/SelectionPromptPolicy.cs | 22 +++ .../Jobs/SelectionPromptQueue.cs | 8 + .../Models/DomainModels.cs | 13 +- .../Models/ProductVersionInfo.cs | 52 +++++++ .../Settings/AppPreferencesStore.cs | 77 ++++++++++ .../src/background.ts | 143 ++++++++++++++++-- src/DownloadRouter.Extension/src/chrome.d.ts | 4 + .../src/download-state.ts | 19 +++ src/DownloadRouter.Extension/src/native.ts | 20 ++- src/DownloadRouter.Extension/src/protocol.ts | 2 + .../tests/download-state.test.ts | 21 ++- .../tests/native.test.ts | 20 ++- .../Storage/DownloadRouterRepository.cs | 61 ++++++-- .../ProtocolAndStateTests.cs | 139 +++++++++++++++++ .../RepositoryTests.cs | 4 + .../CommandValidationTests.cs | 82 +++++++++- 42 files changed, 1147 insertions(+), 152 deletions(-) delete mode 100644 src/DownloadRouter.App/AppPreferencesStore.cs create mode 100644 src/DownloadRouter.App/ThemeManager.cs create mode 100644 src/DownloadRouter.Core/Jobs/SelectionPromptPolicy.cs create mode 100644 src/DownloadRouter.Core/Models/ProductVersionInfo.cs create mode 100644 src/DownloadRouter.Core/Settings/AppPreferencesStore.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6976bbe..b82a3c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,7 +21,7 @@ chrome.downloads.onCreated/onChanged ### Extension -Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고, `onChanged`의 `filename` delta를 같은 Job의 메타데이터 갱신으로 전송합니다. 완료 시 `downloads.search({ id })`로 최종 파일명을 다시 확인합니다. `download`, `*.crdownload` 같은 임시 이름은 확정 이름으로 취급하지 않습니다. 시작 시 Agent가 가진 진행 중 ID만 재확인해 놓친 종료 이벤트를 재전송합니다. 연결 오류는 다운로드를 취소하거나 변경하지 않습니다. 실패할 때만 원문 대신 제한된 분류 코드를 기록합니다. +Manifest V3 service worker이며 권한은 `downloads`, `nativeMessaging`뿐입니다. `onCreated`에서 referrer, 최초/최종 파일 URL을 별도 필드로 전달하고, `onChanged`의 `filename` delta를 같은 Job의 메타데이터 갱신으로 전송합니다. `USER_CANCELED` error delta는 state delta를 기다리지 않고 즉시 idempotent `download.cancelled`로 전송합니다. interrupted는 `downloads.search({ id })` 후 delta error → item error → 마지막 error 순서로 원인을 결정합니다. `onErased`는 기록 삭제 진단일 뿐 취소가 아닙니다. 시작 재조정은 complete/cancelled/interrupted/in_progress/stale을 구분하고 item 누락을 취소로 추측하지 않습니다. 연결 로그에는 정제된 ID/state/error/전송 결과만 기록합니다. Chromium downloads API에는 신뢰할 수 있는 시작 탭 URL 필드가 없습니다. 따라서 활성 탭을 다운로드 출처로 추측하지 않고 `initiatingPageUrl`은 근거가 있을 때만 사용합니다. 현재 확장은 referrer를 우선 근거로 전달합니다. @@ -56,7 +56,7 @@ SQLite 마이그레이션은 `schema_migrations`, `rules`, `download_jobs`, `job ### WinUI 3 App -Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. App은 사용자 범위 mutex로 단일 인스턴스를 유지하는 트레이 호스트이며 `--background`에서는 메인 창을 표시하지 않습니다. 1초 간격으로 Pending을 읽고 중복 없는 FIFO `FolderSelectionWindow`를 하나씩 표시합니다. 선택 창은 메인 창과 독립적이어서 메인 창이 숨김·최소화 상태여도 활성화되며, 포커스 확보 실패 시 작업표시줄 점멸을 사용합니다. +Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. App은 사용자 범위 mutex로 단일 인스턴스를 유지하는 트레이 호스트이며 `--background`에서는 메인 창을 표시하지 않습니다. 500ms 간격으로 Pending을 읽되 자동 팝업 정책을 통과한 항목만 중복 없는 FIFO `FolderSelectionWindow`로 표시합니다. 선택 창은 메인 창과 독립적이어서 메인 창이 숨김·최소화 상태여도 활성화되며, 포커스 확보 실패 시 작업표시줄 점멸을 사용합니다. 공통 `FolderTreePicker`는 루트 하나만 먼저 만들고 노드 확장 시 해당 단계의 자식만 비동기로 읽습니다. 로드된 노드를 중복 조회하지 않고, 접근 불가 항목은 노드 단위 오류로 제한하며 reparse point는 선택 경계에서 제외합니다. 팝업, 선택 대기, 이력 경로 변경이 같은 컴포넌트와 Agent 명령을 사용합니다. 규칙의 저장 루트는 경계가 아직 정해지지 않은 선택이므로 HWND로 초기화한 Windows `FolderPicker`를 사용합니다. @@ -76,18 +76,25 @@ Interrupted RetryPending NotRequired ``` -두 축의 상태 전이는 `DownloadJobStateMachine`이 각각 검사합니다. 완료 전에 선택하면 `InProgress / SelectionReady`만 저장하고 파일을 이동하지 않습니다. `Complete / SelectionReady`가 된 뒤에만 Moving으로 전이합니다. 사용자 취소는 BrowserTransferState만 `Cancelled`로 바꾸고 당시 RoutingState를 보존하되, 표시·Pending·이동 가능 여부는 항상 BrowserTransferState를 우선합니다. 다른 중단은 `Interrupted / Failed`입니다. 기존 단일 `Status` 열은 마이그레이션과 호환 표시용 파생 값으로 유지합니다. +두 축의 상태 전이는 `DownloadJobStateMachine`이 각각 검사합니다. 완료 전에 선택하면 `InProgress / SelectionReady`만 저장하고 파일을 이동하지 않습니다. `Complete / SelectionReady`가 된 뒤에만 Moving으로 전이합니다. 사용자 취소는 BrowserTransferState를 `Cancelled`로 바꾸고 WaitingForSelection/SelectionReady는 `NotRequired`로 종료합니다. Skipped/Failed 같은 이미 확정된 진단 라우팅은 보존할 수 있으며, 표시·Pending·이동 가능 여부는 항상 BrowserTransferState를 우선합니다. 다른 중단은 일반적으로 `Interrupted / Failed`입니다. 기존 단일 `Status` 열은 마이그레이션과 호환 표시용 파생 값으로 유지합니다. ## 저장 위치 선택 경계 저장 루트를 정규화한 뒤 상대 경로를 결합하고 다시 루트 내부인지 확인합니다. `..`, 루트 경로 자체 변경, reparse point 통과를 거부합니다. App은 reparse point 디렉터리를 열거하지 않습니다. 보안 결정은 UI가 아니라 Agent에서도 다시 검증합니다. +## 팝업 수명과 테마 + +자동 선택 팝업은 `SelectionPromptPolicy.AutoPromptWindow`의 30분 안에 생성되었거나 브라우저 이벤트가 갱신된 SelectSubfolder 대기 Job만 대상으로 합니다. 오래되거나 브라우저 record가 stale인 Job은 DB/대기 탭/InfoBadge에 남고 자동 큐에는 들어가지 않습니다. `모두 나중에 선택`은 메모리의 현재 큐 ID만 세션 숨김 집합으로 옮기므로 RoutingState와 파일은 바뀌지 않고 이후 새 ID는 다시 큐에 들어옵니다. + +`ThemeManager`는 App 시작 때 `config.local.json`의 System/Light/Dark를 정규화하고 모든 Window 루트 FrameworkElement를 등록합니다. 설정 변경은 등록된 열린 Window 전체에 적용되고 새 FolderSelectionWindow는 Content 지정 직후 등록됩니다. System은 `ElementTheme.Default`입니다. UI 설정 저장은 SQLite 이력 스키마와 분리합니다. + ## 배포 - 앱, Agent, Native Host: Windows x64 self-contained publish - 확장: `npm ci` 후 TypeScript compile, manifest 복사, ZIP 생성 - Native Host: HKCU 브라우저별 registry adapter와 `%LOCALAPPDATA%` manifest - Installer: per-user, `PrivilegesRequired=lowest` +- 버전: `Directory.Build.props` VersionPrefix를 App/Agent/Native Host assembly와 Installer AppVersion의 단일 원본으로 사용 - 자동 시작: `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`에 설치 App의 정확한 따옴표 경로와 `--background` - X 버튼: 기본은 AppWindow 숨김, 트레이 `종료` 또는 `--shutdown`만 App/Agent 정상 종료 - 제거: 설치 파일·자동 시작·Native Host만 제거하고 사용자 DB와 규칙은 보존 diff --git a/CHANGELOG.md b/CHANGELOG.md index 505a738..2931a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ ### Added +- 30분 자동 팝업 정책, 이전 세션 대기 작업 안내와 선택 창의 `모두 나중에 선택` 큐 일괄 숨김 +- Extension `download.cancelled`/`download.interrupted`, 시작 시 complete/interrupted/in_progress/stale 재조정과 정제된 연결 진단 +- 전역 `ThemeManager`, System/Light/Dark 사용자 설정 저장과 열린/새 Window 즉시 적용 +- 정보 화면 제품명·0.3.0 버전·commit·설치형/개발 빌드 표시, 데이터 폴더/GitHub 열기 +- `Directory.Build.props` 단일 버전 원본과 App/Agent/Native Host/Installer 일치 검증 + - .NET 10/WinUI 3 모노레포와 재현 가능한 bootstrap/build/test/publish 스크립트 - Manifest V3 TypeScript 확장, Native Messaging 브리지, Named Pipe Agent - SQLite 스키마와 마이그레이션, 규칙/작업/이벤트 저장 @@ -13,7 +19,7 @@ - 경로 토큰, 루트 경계와 reparse point 방어 - 동일/교차 볼륨 안전 이동, 안정화 확인, SHA-256 검증, 중복 이름 보존 - WinUI 대시보드, 규칙, 파일별 선택 대기, 상태 필터·삭제 이력, 브라우저, 진단 화면 -- 53개 .NET 테스트, 10개 TypeScript 테스트, Agent/Native Host 스모크 테스트 +- 67개 .NET 테스트, 13개 TypeScript 테스트, Agent/Native Host 스모크 테스트 - per-user Native Host 등록 및 Inno Setup 설치 골격 - Native Host 등록의 Windows PowerShell 5.1 회귀 테스트와 개인정보 로그 회귀 테스트 - 매칭 규칙의 작업 생성·실제 파일 이동을 검증하는 Agent 통합 테스트 @@ -29,6 +35,10 @@ ### Changed +- 취소 상태는 선택 여부와 관계없이 `Cancelled`가 되고 Waiting/SelectionReady 라우팅은 `NotRequired`로 종료 +- UI 상태 갱신 주기를 500ms로 줄여 취소 후 팝업·Pending·이력을 1초 이내 반영 +- 브라우저 기록에서 찾지 못한 진행 작업은 취소로 추측하지 않고 stale 진단 상태로 보존하며 자동 팝업에서 제외 + - WinUI 앱을 Per-Monitor V2로 선언하고 공통 콘텐츠를 세로 ScrollViewer, stretch viewport, 1100 DIP 반응형 폼으로 재구성 - 다운로드 이력이 규칙에 매칭된 작업만 표시함을 명시하고 데이터 변경 시 3초 간격으로 자동 갱신 - Whale 설치 탐지에 Program Files와 Program Files (x86) 후보를 추가 @@ -39,6 +49,12 @@ ### Fixed +- 앱 시작 때 오래된 `WaitingForSelection` 전체를 FIFO에 재삽입해 20개 이상 팝업이 연속 표시되던 문제 +- Whale가 `state` delta 없이 `error.current=USER_CANCELED`만 보낼 때 Extension이 조기 반환해 취소가 누락되던 문제 +- `downloads.onErased`와 실제 사용자 취소를 혼동할 수 있는 시작 재조정 공백 +- 일반 설정의 테마 ComboBox가 저장·적용 로직에 연결되지 않아 화면이 바뀌지 않던 문제 +- 정보 화면에 실행 버전이 없고 설치 프로그램 버전만 별도 하드코딩되어 구성 요소가 불일치할 수 있던 문제 + - DPI awareness 누락으로 QHD 125%에서 앱 전체가 96 DPI 비트맵으로 확대되던 흐릿한 렌더링 - NavigationView 콘텐츠 폭/중복 패딩과 고정 MinWidth 때문에 좁은 창에서 오른쪽이 잘리던 레이아웃 - PowerShell 5.1에 없는 경로 API와 RegistryKey 직접 쓰기로 실패하던 HKCU Native Host 등록 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a434585..4069d68 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -64,6 +64,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps `build-installer.ps1`은 publish 결과에 `App.xbf`, `MainWindow.xbf`, `DownloadRouter.App.pri`가 있는지 먼저 검사합니다. unpackaged WinUI 3 게시에서 이 세 파일이 빠지면 설치본이 `XamlParseException`으로 시작하지 못하므로 해당 검사를 제거하지 않습니다. 설치 파일은 아직 코드 서명되지 않았습니다. +제품 버전은 `Directory.Build.props`의 `VersionPrefix` 한 곳에서만 변경합니다. build/publish는 가능한 경우 Git HEAD를 `SourceRevisionId`로 전달하고 정보 화면은 informational version을 읽습니다. `build-installer.ps1`은 App/Agent/Native Host ProductVersion이 VersionPrefix와 일치하지 않으면 Inno Setup 실행 전에 실패합니다. `DownloadRouter.iss`에 버전을 직접 하드코딩하지 마세요. + ## 트레이, 자동 시작, 선택 창 - App은 단일 인스턴스이며 `--background`에서 메인 창을 숨긴 채 트레이와 Agent만 준비합니다. @@ -72,10 +74,12 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps - 폴더 선택은 메인 창의 ContentDialog가 아니라 별도 Window입니다. 창 크기는 DIP를 실제 모니터 DPI로 변환한 뒤 작업 영역 안으로 제한합니다. - 트리는 전체 재귀 열거를 금지하고 확장한 노드의 직계 자식만 비동기로 읽습니다. UI와 Agent 양쪽에서 루트 경계를 검사합니다. - 이력의 경로 변경은 규칙을 수정하지 않고 Job의 상대 경로만 변경합니다. 이미 이동된 파일은 사용자 확인 뒤 기존 안전 이동 서비스를 다시 사용합니다. +- 자동 팝업은 `SelectionPromptPolicy.AutoPromptWindow`(30분)를 통과한 Job만 사용합니다. 대기 탭과 InfoBadge는 모든 실제 Pending을 사용하므로 두 목록을 다시 합치지 마세요. +- 테마는 Window별 임시 코드 대신 App의 단일 ThemeManager에 등록합니다. 새 Window를 추가하면 Content 설정 직후 등록하고 UI 설정은 기존 `config.local.json`에 보존합니다. ## 설치/제거 검증 -업그레이드 전 App의 `--shutdown` 완료를 기다리고 설치합니다. 설치 뒤에는 시작 메뉴, HKCU Run, 6개 브라우저 Native Host, 백그라운드 단일 인스턴스, X 후 프로세스 유지와 트레이 메뉴를 확인합니다. 제거 전후에는 사용자 DB의 행 수와 SHA-256을 비교합니다. 제거 스크립트는 `%LOCALAPPDATA%\eslee\DownloadRouter`를 삭제하면 안 됩니다. +업그레이드 전 App의 `--shutdown` 완료를 기다리고 설치합니다. 설치 뒤에는 시작 메뉴, HKCU Run, 6개 브라우저 Native Host, 백그라운드 단일 인스턴스, X 후 프로세스 유지와 트레이 메뉴를 확인합니다. 업그레이드 전후에는 사용자 DB의 행 수·SHA-256과 `config.local.json`의 테마 값을 비교합니다. 제거 스크립트는 `%LOCALAPPDATA%\eslee\DownloadRouter`를 삭제하면 안 됩니다. ## 기능 추가 순서 diff --git a/Directory.Build.props b/Directory.Build.props index e1b0192..2c84f00 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,6 +5,12 @@ enable enable true + 0.3.0 + $(VersionPrefix) + $(VersionPrefix).0 + $(VersionPrefix).0 + $(VersionPrefix) + $(VersionPrefix)+$(SourceRevisionId) true true portable diff --git a/HANDOFF.md b/HANDOFF.md index f2002ea..635b88b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -32,12 +32,12 @@ Edge에서 `edge://extensions`를 열고 개발자 모드를 켠 뒤 “압축 ## 현재 정상 기준 - Debug solution build 성공, 경고 0/오류 0 -- .NET tests 38/38 통과 -- Extension lint/build와 Node tests 8/8 통과 +- .NET tests 67/67 통과 +- Extension lint/build와 Node tests 13/13 통과 - Agent Named Pipe ping 성공 - Native Host self-test 성공 - App/Agent/Native Host self-contained Release publish 성공 -- QHD 125% Per-Monitor V2, 900×850 수평 넘침 0, 파일별 SelectSubfolder Whale 실검증 성공 +- QHD 125% Per-Monitor V2, 900×850 수평 넘침 0, 파일별 SelectSubfolder, 30분 팝업 정책, 전역 테마와 0.3.0 버전 체계 검증 Whale Automatic 규칙, 다른 Chromium 브라우저, installer, 100%/150% 및 FHD/4K 시각 검증은 아직 정상 기준에 포함되지 않습니다. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d8663b1..2e81924 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # 프로젝트 상태 -기준일: 2026-07-22 (Asia/Seoul) +기준일: 2026-07-23 (Asia/Seoul) ## 요약 @@ -25,8 +25,10 @@ - 중복 파일 번호 보존과 재시도 상태 - 브라우저 전송 상태와 라우팅 상태를 분리한 작업 모델과 v1 → v2 SQLite 마이그레이션 - 파일별 SelectSubfolder 카드, 명시적으로 체크한 항목만 일괄 적용, 건너뛰기와 세션 단위 나중에 선택 +- 생성/마지막 브라우저 이벤트 기준 30분 자동 팝업 정책, 이전 세션 대기 표시, 현재 큐 `모두 나중에 선택` - 고정 DIP 전용 선택 창, lazy 계층형 FolderTreePicker, 단일 FIFO, 숨김/최소화 상태 직접 표시, 취소 시 자동 닫기 - `onCreated`/`filename` delta/완료 검색을 통한 같은 Job 파일명 갱신과 임시 이름 차단 +- `USER_CANCELED` 즉시 전송, interrupted 오류 우선순위, onErased 비취소 처리와 service worker 시작 재조정 - 이력의 Job별 저장 위치 선택·변경, 완료 파일 명시적 재이동, 이동 안 함/나중에 선택 - 취소선·상태 필터·개별/선택/취소 이력 삭제 UI와 실제 파일 비삭제 보장 - 현재 필터 전체 선택/해제와 선택 수 표시 @@ -38,16 +40,18 @@ - Windows PowerShell 5.1 절대 경로/HKCU 등록 호환성과 BOM 없는 UTF-8 Native Host manifest - 실패할 때만 분류 코드를 남기는 Extension Native Messaging 진단 로그 - Per-Monitor V2 WinUI 렌더링, 뷰포트 실폭 제한, 32/48 DIP 공통 상단 여백, 1초 상태 변경 감지 -- self-contained win-x64 publish와 설치/업그레이드/제거가 검증된 per-user Inno Setup 0.2.0 +- 저장된 System/Light/Dark를 열린 모든 Window와 이후 생성 Window에 적용하는 공통 ThemeManager +- assembly informational version을 표시하는 정보 화면과 `Directory.Build.props` 기반 App/Agent/Native Host/Installer 단일 0.3.0 버전 +- self-contained win-x64 publish와 설치/업그레이드/제거가 검증된 per-user Inno Setup 0.3.0 ## 빌드와 테스트 | 항목 | 결과 | |---|---| | `.NET Debug build` | 성공, 경고 0, 오류 0 | -| `.NET tests` | 53/53 통과(Core 32, Infrastructure 7, Integration 14) | +| `.NET tests` | 67/67 통과(Core 41, Infrastructure 7, Integration 19) | | Extension ESLint/TypeScript build | 성공 | -| Extension Node tests | 10/10 통과 | +| Extension Node tests | 13/13 통과 | | Agent Named Pipe ping | 성공 | | Native Host self-test/길이 접두사 ping/Agent 자동 시작 | 성공 | | Native Host Agent 장애 fail-open | `agent.unavailable`, Host 종료 코드 0, 브라우저 비차단 응답 확인 | @@ -74,11 +78,12 @@ Whale 4.38.386.14에서 로컬 HTTP fixture로 Automatic과 SelectSubfolder를 - downloads API만으로 다운로드 시작 탭 URL을 신뢰성 있게 얻을 수 없어 활성 탭을 추측하지 않습니다. 현재 referrer와 파일 URL을 분리해 사용합니다. - App 실행 파일을 찾을 수 있으면 Agent가 선택 UI를 시작하고, 실행 중이면 1초 폴링과 독립 창으로 FIFO를 표시합니다. 설치 손상으로 App을 찾지 못해도 다운로드는 원래 위치에서 완료되고 Pending에 남습니다. -- “나중에 선택” 팝업 억제는 현재 App 세션 동안만 유지됩니다. 트리는 선택 노드 새로 고침을 지원하지만 새 폴더 생성은 미구현입니다. +- “나중에 선택”과 “모두 나중에 선택” 팝업 억제는 현재 App 세션 동안만 유지됩니다. 30분이 지난 작업은 대기 탭에서 수동 처리하며 트리의 새 폴더 생성은 미구현입니다. - Extension 시작 시 Agent의 진행 중 ID를 브라우저 다운로드 기록과 대조해 완료·취소·중단을 재전송합니다. 브라우저 기록에서 사라진 오래된 작업은 근거 없이 완료·삭제하지 않습니다. - Whale registry adapter는 이 PC에서 검증했습니다. Brave/Vivaldi/Opera adapter는 실제 PC 검증이 필요합니다. - 브라우저 자체 “다운로드 전에 저장 위치 확인” 설정 감지와 안내는 문서만 있고 UI 자동 감지는 미구현입니다. - QHD 125% 실제 실행과 좁은/넓은 창 시각·경계 검증은 완료했습니다. Windows 100%/150%, FHD/4K 실기기와 키보드/스크린리더 접근성 검증은 남아 있습니다. +- 전역 테마의 매핑·저장·fallback은 자동 검증했습니다. 실제 QHD 125%의 다크/라이트 전환, 선택 창, 트레이 복원, 재실행 검증 결과는 아래 최신 설치본 검증 기록을 기준으로 하며 100%/150% 실기기 전환은 남아 있습니다. - HKCU 자동 시작 명령과 백그라운드 실행은 확인했지만 실제 로그아웃/로그인 또는 재부팅은 수행하지 않았습니다. - 설치 파일은 코드 서명되지 않았습니다. @@ -91,8 +96,8 @@ Whale 4.38.386.14에서 로컬 HTTP fixture로 Automatic과 SelectSubfolder를 ## 다음 작업 -1. Native Host 장애 중 Whale 실다운로드 유지 검증 -2. Windows 100%/150% 및 FHD/4K에서 UI 실배율 시각 검증 +1. Windows 100%/150% 및 FHD/4K에서 UI 실배율·테마 시각 검증 +2. Native Host 장애 중 Whale 실다운로드 유지 검증 3. Edge에서 개발자 모드 확장 로드와 Native Messaging 왕복을 실제 검증 4. FolderTreePicker 새 폴더 만들기와 영구 알림 설정 추가 5. 브라우저 기록에서 사라진 오래된 비종료 작업의 사용자 확인 복구 흐름 설계 diff --git a/README.md b/README.md index ad00522..6616676 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Chromium 기반 브라우저에서 완료된 다운로드를 사이트 규칙에 따라 Windows 폴더로 분류하는 로컬 전용 애플리케이션입니다. 규칙이 없거나 로컬 구성 요소가 응답하지 않으면 브라우저의 원래 다운로드를 그대로 유지하는 fail-open 방식을 사용합니다. -> 현재 상태: 핵심 라우팅, 계층형 폴더 선택, 트레이 상주, 로그인 자동 시작, 규칙/이력 관리와 사용자 단위 설치 흐름을 구현했습니다. Whale 4.38.386.14에서 Automatic과 SelectSubfolder의 실제 C: → D: 이동을 검증했습니다. 자세한 결과와 남은 제약은 [PROJECT_STATE.md](PROJECT_STATE.md)를 확인하세요. +> 현재 상태: 핵심 라우팅, 계층형 폴더 선택, 트레이 상주, 로그인 자동 시작, 규칙/이력 관리와 사용자 단위 설치 흐름을 구현했습니다. 30분 자동 팝업 정책, Whale 취소 재조정, 전역 System/Light/Dark 테마와 단일 0.3.0 제품 버전 체계를 포함합니다. 자세한 결과와 남은 제약은 [PROJECT_STATE.md](PROJECT_STATE.md)를 확인하세요. ## 구성 요소 @@ -96,6 +96,10 @@ PowerShell 실행 정책이 로컬 스크립트를 막는 경우 예시처럼 `p 설치/업그레이드 시 기존 프로세스는 정상 종료 신호를 받고, Whale·Edge·Chrome·Brave·Vivaldi·Opera의 HKCU Native Host가 설치 경로로 등록됩니다. 제거 시 자동 시작, 바로가기와 Native Host 등록만 제거하며 `%LOCALAPPDATA%\eslee\DownloadRouter`의 규칙·이력 DB는 보존합니다. +`Directory.Build.props`의 `VersionPrefix`가 App, Agent, Native Host, 설치 프로그램의 단일 버전 원본입니다. 정보 화면은 실행 assembly의 informational version과 짧은 commit, 설치형/개발 빌드 구분을 표시합니다. 일반 설정의 시스템/라이트/다크 테마는 `%LOCALAPPDATA%\eslee\DownloadRouter\config.local.json`에 저장되어 열린 창, 새 선택 창, 트레이 복원과 백그라운드 시작에 동일하게 적용됩니다. + +SelectSubfolder 자동 팝업은 생성 또는 마지막 브라우저 이벤트가 30분 이내인 작업만 대상으로 합니다. 오래된 작업과 브라우저 기록을 찾을 수 없는 작업은 삭제하지 않고 대기 탭과 배지에 유지합니다. 선택 창의 `모두 나중에 선택`은 현재 App 세션의 기존 큐만 숨기며 이후 새 다운로드는 정상 표시합니다. + 설계와 위협 모델은 [ARCHITECTURE.md](ARCHITECTURE.md), [SECURITY.md](SECURITY.md)를 참고하세요. ## 현재 제한 @@ -103,6 +107,7 @@ PowerShell 실행 정책이 로컬 스크립트를 막는 경우 예시처럼 `p - Chromium downloads API가 다운로드 시작 탭 URL을 직접 제공하지 않으므로, 신뢰할 수 없는 활성 탭 추측은 하지 않습니다. 제공되는 referrer, 최초 URL, 최종 URL을 분리해 사용합니다. - 폴더 트리는 lazy loading과 선택 노드 새로 고침을 지원하지만 새 폴더 만들기는 제공하지 않습니다. - Windows 로그아웃/로그인 또는 재부팅 자체는 작업 환경을 중단하므로 수행하지 않았습니다. HKCU Run 등록과 `--background` 실행은 각각 확인했습니다. +- `System` 테마는 WinUI의 `ElementTheme.Default`를 사용합니다. Windows 앱 테마 실시간 변경은 WinUI 알림에 따르며, 최소한 다음 창 생성과 App 재시작 시에는 현재 시스템 값을 반영합니다. - UI는 QHD 125%에서 실제 검증했습니다. FHD/4K와 Windows 100%/150%는 Per-Monitor V2/DIP 구조 및 좁은·넓은 창 경계 테스트만 완료했고 물리 디스플레이 전환 검증은 남아 있습니다. - Edge·Chrome·Brave·Vivaldi·Opera의 실제 다운로드는 아직 수동 검증하지 않았습니다. - 설치 파일은 현재 코드 서명되지 않았습니다. diff --git a/TESTING.md b/TESTING.md index d85f2cd..111b1bb 100644 --- a/TESTING.md +++ b/TESTING.md @@ -13,14 +13,14 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-agent.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\diagnose.ps1 ``` -2026-07-22 현재 로컬 결과: +2026-07-23 현재 로컬 결과: - .NET Debug build: 성공, 경고 0, 오류 0 -- .NET tests: 53/53 통과 - - Core 32: 규칙, IDN/도메인 경계, 경로, 개인정보, 상태 전이, Pending 집계, 취소 표시 조합, 임시 파일명, lazy 폴더 트리, 자동 시작 등록/해제 - - Infrastructure 7: v1 → v2 → v3 SQLite 마이그레이션, soft-delete 규칙, round-trip, 동일·교차 볼륨, 중복 이름, 로그 개인정보 제거 - - Integration 14: 명령 거부, 미매칭 fail-open, 파일명 동일 Job 갱신, 완료 전/후 선택, 경로 재지정/재이동 확인, 취소/중단, 규칙 편집·삭제, skip 완료 시 원본 보존, 실제 파일 비삭제 이력 삭제 -- Extension: ESLint 성공, TypeScript compile 성공, Node tests 10/10 통과, dist build 성공 +- .NET tests: 67/67 통과 + - Core 41: 규칙, 경로, 개인정보, 상태 전이, 30분 팝업 정책/23개 큐 일괄 숨김, 테마 저장/fallback, 제품 버전, 임시 파일명, lazy 폴더 트리, 자동 시작 등록/해제 + - Infrastructure 7: v1 → v2 → v3 → v4 SQLite 마이그레이션, soft-delete 규칙, round-trip, 동일·교차 볼륨, 중복 이름, 로그 개인정보 제거 + - Integration 19: 명령 거부, 미매칭 fail-open, 완료 전/후 선택, 네 선택 흐름의 idempotent 취소, stale/in_progress 재조정, 중단, 규칙 편집·삭제, skip 완료 시 원본 보존, 실제 파일 비삭제 이력 삭제 +- Extension: ESLint 성공, TypeScript compile 성공, Node tests 13/13 통과, dist build 성공 - Windows PowerShell 5.1 등록 테스트: drive/UNC 절대 경로, 상대 경로 거부, origin 필터, BOM 없는 UTF-8 통과 - Agent Named Pipe ping: 성공 - Native Host `--self-test`, 길이 접두사 ping, 게시 Agent 자동 시작: 성공 @@ -40,6 +40,14 @@ QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습 - Wide 공통 상단 여백: 48 DIP, 실제 QHD 125% 창에서 제목 위치가 창 상단 기준 39px에서 96px로 이동 - 대시보드, 이력, 선택 대기, 브라우저 연결, 설정, 진단, 정보는 같은 공통 콘텐츠 루트를 사용하므로 동일 폭/스크롤 수정이 적용됨 +### 테마와 정보 화면 + +- System/Light/Dark 문자열 매핑, 잘못된 값의 System fallback, `config.local.json` 저장/재로드 자동 테스트 +- MainWindow와 FolderSelectionWindow를 같은 ThemeManager에 등록해 설정 변경 시 열린 Window 전체, 생성 시 새 Window에 `RequestedTheme` 적용 +- 정보 화면은 App assembly informational version을 읽으며 commit이 없어도 SemVer fallback +- `Directory.Build.props` VersionPrefix와 App ProductVersion, Inno Setup 전달 경로 자동 비교 +- QHD 125% 설치본에서 시스템→다크, 선택 창 다크, X 트레이 복원, 완전 재실행, 라이트, 시스템 복귀를 순서대로 확인 + 100%/150% Windows 실배율과 FHD/4K 물리 디스플레이는 현재 단일 QHD 125% 환경에서 직접 전환하지 않았습니다. manifest/DIP/MaxWidth 구조와 좁은·넓은 창 경계는 검증했지만 이 실기기 항목은 수동 확인으로 남깁니다. ### 선택 창과 설치본 UI Automation @@ -56,7 +64,8 @@ QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습 - silent 초기 설치와 업그레이드 종료 코드 0 - 설치 App 10초 이상 안정 실행, 백그라운드 실행 시 보이는 메인 창 0, 두 번째 실행 후 App 인스턴스 1 - 실제 창 DPI 120, `XamlRoot.RasterizationScale=1.25` -- 설치 전후 DB v3 마이그레이션에서 기존 Rule/Job 수와 ID 보존 +- 설치 전후 DB v4 마이그레이션에서 기존 Rule/Job 수와 ID, `config.local.json` 테마 보존 +- Installer/App/Agent/Native Host의 제품 버전 0.3.0 일치 - 자동 시작 값: 설치 App의 따옴표 절대 경로 + `--background` - 트레이 메뉴: `열기`, `저장 위치 선택 대기 열기`, `일반 설정`, `종료` - 제거 전: Native Host 6개, 자동 시작, 시작 메뉴 존재 @@ -109,6 +118,16 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\manual-download-se 7. 4개를 동시에 시작해 팝업이 하나만 열리고 파일별 선택으로 A/B/C/D 이동이 가능한지 확인합니다. 8. 취소된 이력 정리 후 이미 이동된 실제 파일이 그대로 있는지 확인합니다. +### 취소와 오래된 큐 회귀 시나리오 + +1. 아무 선택 없음, 위치 선택 완료, 나중에 선택, 이번 파일 이동 안 함의 네 Job을 각각 실제 Whale에서 시작합니다. +2. 브라우저 UI에서 취소하고 1초 이내 `Cancelled`, 팝업 닫힘, Pending 감소, 이동 0, 이력 취소선/설명 반영을 확인합니다. +3. App을 완전 종료 후 다시 실행해 네 취소 상태가 DB에서 유지되는지 확인합니다. +4. 격리 DB에 마지막 브라우저 이벤트가 31분 이상 지난 SelectSubfolder 대기 Job 23개를 만들고 시작 팝업 0, 대기 탭 23개와 이전 세션 안내를 확인합니다. +5. 최근 Job을 추가해 `1 / 23` 형식과 동시에 대기 중인 다른 파일 수를 확인합니다. +6. `모두 나중에 선택` 뒤 RoutingState와 파일이 바뀌지 않고 기존 큐 전체가 닫히는지 확인합니다. +7. 그 뒤 새 다운로드를 시작해 새 팝업이 정상 표시되는지 확인합니다. + 2026-07-22 실제 Whale 4.38.386.14 결과: 위 4~8번과 설치본 Automatic/Tree 흐름 성공. 이번 테스트가 만든 파일은 휴지통으로 이동해 복구 가능하며 테스트 DB 이력과 규칙만 정확한 ID로 정리했습니다. ## 브라우저 검증 상태 diff --git a/installer/DownloadRouter.iss b/installer/DownloadRouter.iss index abb47b2..317e869 100644 --- a/installer/DownloadRouter.iss +++ b/installer/DownloadRouter.iss @@ -1,5 +1,10 @@ #define AppName "eslee Download Router" -#define AppVersion "0.2.0" +#ifndef AppVersion + #error AppVersion must be supplied from Directory.Build.props by scripts/build-installer.ps1 +#endif +#ifndef AppCommit + #define AppCommit "unknown" +#endif #define Publisher "eslee" [Setup] @@ -20,6 +25,9 @@ DisableProgramGroupPage=yes WizardStyle=modern CloseApplications=yes RestartApplications=no +VersionInfoVersion={#AppVersion}.0 +VersionInfoProductVersion={#AppVersion} +VersionInfoDescription={#AppName} installer ({#AppCommit}) [Tasks] Name: "desktopicon"; Description: "바탕 화면 바로가기 만들기"; GroupDescription: "추가 바로가기:"; Flags: unchecked diff --git a/installer/README.md b/installer/README.md index 5524547..2bf541c 100644 --- a/installer/README.md +++ b/installer/README.md @@ -8,6 +8,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps 산출물은 `artifacts\installer\eslee-download-router-setup.exe`입니다. Release self-contained App, Agent, Native Host, 확장 dist와 등록/해제 스크립트를 포함합니다. +설치 프로그램의 `AppVersion`은 `DownloadRouter.iss`에 하드코딩하지 않습니다. `build-installer.ps1`이 `Directory.Build.props`의 `VersionPrefix`를 읽어 Inno Setup에 전달하고 App/Agent/Native Host ProductVersion의 일치를 먼저 검사합니다. + ## 설치 정책 - 기본 경로: `%LOCALAPPDATA%\Programs\eslee\DownloadRouter` @@ -16,6 +18,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps - Whale, Edge, Chrome, Brave, Vivaldi, Opera의 HKCU Native Host 등록 - 업그레이드 전 설치 App에 `--shutdown`을 전달해 App과 Agent를 정상 종료 - unpackaged WinUI 필수 리소스 `App.xbf`, `MainWindow.xbf`, `DownloadRouter.App.pri` 포함 +- `%LOCALAPPDATA%\eslee\DownloadRouter`의 DB, 규칙, 이력과 `config.local.json` 테마는 업그레이드에서 보존 ## 제거 정책 @@ -27,6 +30,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps - 설치 App 10초 이상 안정 실행과 X 후 트레이 상주 - 시작 메뉴/자동 시작/Native Host 등록 및 제거 - 제거 전후 SQLite 행 수와 파일 SHA-256 동일 +- App/Agent/Native Host/Installer 제품 버전 일치와 정보 화면 표시 +- System/Light/Dark 즉시 전환, 트레이 복원·완전 재실행·백그라운드 시작 후 유지 - 서명된 배포라면 `SignTool` 설정과 인증서 체인을 별도 검토 현재 로컬 검증 산출물은 코드 서명되지 않았습니다. diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index e2a1e72..4320d0b 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -6,6 +6,13 @@ param( . (Join-Path $PSScriptRoot 'common.ps1') $root = Get-RepositoryRoot +$versionDocument = [xml](Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props') -Raw) +$appVersion = [string]($versionDocument.Project.PropertyGroup.VersionPrefix | Select-Object -First 1) +if ($appVersion -notmatch '^\d+\.\d+\.\d+$') { + throw 'Directory.Build.props VersionPrefix must be a three-part SemVer.' +} +$gitRevision = & git -C $root rev-parse HEAD 2>$null +$sourceRevisionId = if ($LASTEXITCODE -eq 0) { ([string]$gitRevision).Trim() } else { 'unknown' } & (Join-Path $PSScriptRoot 'publish.ps1') -Configuration $Configuration if ($LASTEXITCODE -ne 0) { throw 'Installer payload publish failed.' } @@ -21,7 +28,14 @@ if (-not $iscc) { throw 'Inno Setup 6 was not found. Install the trusted JRSoftware.InnoSetup winget package.' } -& $iscc (Join-Path $root 'installer\DownloadRouter.iss') +foreach ($binary in @('DownloadRouter.App.exe', 'DownloadRouter.Agent.exe', 'DownloadRouter.NativeHost.exe')) { + $productVersion = (Get-Item -LiteralPath (Join-Path $root "artifacts\publish\app\$binary")).VersionInfo.ProductVersion + if (-not $productVersion.StartsWith("$appVersion+", [StringComparison]::Ordinal) -and $productVersion -ne $appVersion) { + throw "$binary product version '$productVersion' does not match $appVersion." + } +} + +& $iscc "/DAppVersion=$appVersion" "/DAppCommit=$sourceRevisionId" (Join-Path $root 'installer\DownloadRouter.iss') if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compilation failed.' } $setup = Join-Path $root 'artifacts\installer\eslee-download-router-setup.exe' @@ -30,3 +44,4 @@ if (-not (Test-Path -LiteralPath $setup -PathType Leaf)) { } Write-Host "Installer built: $setup" +Write-Host "Product version: $appVersion ($sourceRevisionId)" diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 180de45..d4ff438 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -9,8 +9,11 @@ param( $root = Get-RepositoryRoot $dotnet = Assert-DotNetVersion $nodeTools = Assert-NodeTools +$gitRevision = & git -C $root rev-parse HEAD 2>$null +$sourceRevisionId = if ($LASTEXITCODE -eq 0) { ([string]$gitRevision).Trim() } else { '' } +$versionProperty = if ($sourceRevisionId) { "-p:SourceRevisionId=$sourceRevisionId" } else { $null } -& $dotnet build (Join-Path $root 'DownloadRouter.slnx') --configuration $Configuration --no-restore --nologo +& $dotnet build (Join-Path $root 'DownloadRouter.slnx') --configuration $Configuration --no-restore --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw 'dotnet build failed. Run scripts/bootstrap.ps1 first.' } Push-Location (Join-Path $root 'src\DownloadRouter.Extension') @@ -24,9 +27,9 @@ finally { if ($PublishNativeHost) { $output = Join-Path $root 'artifacts\native-host' - & $dotnet publish (Join-Path $root 'src\DownloadRouter.Agent\DownloadRouter.Agent.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $output --nologo + & $dotnet publish (Join-Path $root 'src\DownloadRouter.Agent\DownloadRouter.Agent.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $output --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw 'Agent publish failed.' } - & $dotnet publish (Join-Path $root 'src\DownloadRouter.NativeHost\DownloadRouter.NativeHost.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $output --nologo + & $dotnet publish (Join-Path $root 'src\DownloadRouter.NativeHost\DownloadRouter.NativeHost.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $output --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw 'Native Host publish failed.' } Write-Host "Published Native Host bundle: $output" } diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 index f0f2a76..e473eaf 100644 --- a/scripts/publish.ps1 +++ b/scripts/publish.ps1 @@ -8,6 +8,9 @@ param( $root = Get-RepositoryRoot $dotnet = Assert-DotNetVersion $nodeTools = Assert-NodeTools +$gitRevision = & git -C $root rev-parse HEAD 2>$null +$sourceRevisionId = if ($LASTEXITCODE -eq 0) { ([string]$gitRevision).Trim() } else { '' } +$versionProperty = if ($sourceRevisionId) { "-p:SourceRevisionId=$sourceRevisionId" } else { $null } $publishRoot = Join-Path $root 'artifacts\publish\app' $stagingRoot = Join-Path $root 'artifacts\publish\staging' @@ -16,7 +19,7 @@ if (Test-Path -LiteralPath $stagingRoot) { Remove-Item -LiteralPath $stagingRoot New-Item -ItemType Directory -Path $publishRoot -Force | Out-Null New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null -& $dotnet publish (Join-Path $root 'src\DownloadRouter.App\DownloadRouter.App.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $publishRoot --nologo +& $dotnet publish (Join-Path $root 'src\DownloadRouter.App\DownloadRouter.App.csproj') --configuration $Configuration --runtime win-x64 --self-contained true --output $publishRoot --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw 'Settings app publish failed.' } foreach ($resource in @('App.xbf', 'MainWindow.xbf', 'DownloadRouter.App.pri')) { if (-not (Test-Path -LiteralPath (Join-Path $publishRoot $resource) -PathType Leaf)) { @@ -26,7 +29,7 @@ foreach ($resource in @('App.xbf', 'MainWindow.xbf', 'DownloadRouter.App.pri')) foreach ($project in @('DownloadRouter.Agent', 'DownloadRouter.NativeHost')) { $projectOutput = Join-Path $stagingRoot $project - & $dotnet publish (Join-Path $root "src\$project\$project.csproj") --configuration $Configuration --runtime win-x64 --self-contained true --output $projectOutput --nologo + & $dotnet publish (Join-Path $root "src\$project\$project.csproj") --configuration $Configuration --runtime win-x64 --self-contained true --output $projectOutput --nologo $versionProperty if ($LASTEXITCODE -ne 0) { throw "$project publish failed." } Copy-Item -Path (Join-Path $projectOutput '*') -Destination $publishRoot -Recurse -Force } diff --git a/scripts/send-agent-command.ps1 b/scripts/send-agent-command.ps1 index 4bce9f9..9f7c741 100644 --- a/scripts/send-agent-command.ps1 +++ b/scripts/send-agent-command.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('ping', 'download.started', 'download.metadata', 'download.changed', 'downloads.active', 'rules.list', 'rules.upsert', 'rules.delete', 'jobs.list', 'jobs.delete', 'selection.complete', 'selection.skip', 'route.change', 'job.retry', 'diagnostics.status')] + [ValidateSet('ping', 'download.started', 'download.metadata', 'download.changed', 'download.cancelled', 'download.interrupted', 'downloads.active', 'rules.list', 'rules.upsert', 'rules.delete', 'jobs.list', 'jobs.delete', 'selection.complete', 'selection.skip', 'route.change', 'job.retry', 'diagnostics.status')] [string]$Command, [Parameter(Mandatory = $true)] [string]$PayloadJson, diff --git a/src/DownloadRouter.Agent/AgentCommandHandler.cs b/src/DownloadRouter.Agent/AgentCommandHandler.cs index 9da1254..b1e33ba 100644 --- a/src/DownloadRouter.Agent/AgentCommandHandler.cs +++ b/src/DownloadRouter.Agent/AgentCommandHandler.cs @@ -53,6 +53,8 @@ public async Task HandleAsync(AgentCommand request, CancellationT "download.started" => await HandleDownloadStartedAsync(request, cancellationToken).ConfigureAwait(false), "download.metadata" => await HandleDownloadMetadataAsync(request, cancellationToken).ConfigureAwait(false), "download.changed" => await HandleDownloadChangedAsync(request, cancellationToken).ConfigureAwait(false), + "download.cancelled" => await HandleDownloadChangedAsync(request, cancellationToken, "cancelled").ConfigureAwait(false), + "download.interrupted" => await HandleDownloadChangedAsync(request, cancellationToken, "interrupted").ConfigureAwait(false), "rules.list" => AgentResponse.Ok(request.RequestId, await repository.GetRulesAsync(cancellationToken).ConfigureAwait(false)), "rules.upsert" => await HandleRuleUpsertAsync(request, cancellationToken).ConfigureAwait(false), "rules.delete" => await HandleRuleDeleteAsync(request, cancellationToken).ConfigureAwait(false), @@ -134,7 +136,9 @@ private async Task HandleDownloadStartedAsync( var matched = matcher.Match(rules, metadata); if (matched is null) { - logger.LogInformation("No enabled rule matched download {BrowserDownloadId}; browser behavior remains unchanged", payload.DownloadId); + logger.LogInformation( + "No enabled rule matched download {BrowserDownloadId}; browser behavior remains unchanged", + SanitizeDownloadId(payload.DownloadId)); return AgentResponse.Ok(request.RequestId, new { tracked = false, failOpen = true }); } @@ -161,7 +165,8 @@ private async Task HandleDownloadStartedAsync( null, null, DateTimeOffset.UtcNow, - null); + null, + DateTimeOffset.UtcNow); await repository.CreateJobAsync(job, cancellationToken).ConfigureAwait(false); var selectionUiRequested = matched.Rule.StorageMode == StorageMode.SelectSubfolder @@ -169,7 +174,7 @@ private async Task HandleDownloadStartedAsync( logger.LogInformation( "Rule {RuleId} matched download {DownloadId} using {SourceField}", matched.Rule.Id, - payload.DownloadId, + SanitizeDownloadId(payload.DownloadId), matched.SourceField); return AgentResponse.Ok(request.RequestId, new { @@ -209,7 +214,8 @@ private async Task HandleDownloadMetadataAsync( private async Task HandleDownloadChangedAsync( AgentCommand request, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? forcedState = null) { var payload = Deserialize(request.Payload); if (!TryParseBrowser(payload.Browser, out var browser)) @@ -217,13 +223,52 @@ private async Task HandleDownloadChangedAsync( return AgentResponse.Error(request.RequestId, "download.unknown-browser", "The browser is not supported."); } + var sanitizedDownloadId = SanitizeDownloadId(payload.DownloadId); + var state = (forcedState ?? payload.State).Trim().ToLowerInvariant(); + logger.LogInformation( + "Browser command received: command={Command}, browser={Browser}, downloadId={DownloadId}, state={State}, error={Error}", + request.Command, + browser, + sanitizedDownloadId, + SanitizeState(state), + NormalizeBrowserError(payload.Error)); + var job = await repository.GetJobAsync(browser, payload.DownloadId, cancellationToken).ConfigureAwait(false); if (job is null) { return AgentResponse.Ok(request.RequestId, new { tracked = false, failOpen = true }); } - var state = payload.State.Trim().ToLowerInvariant(); + var beforeBrowser = job.BrowserState; + var beforeRouting = job.RoutingState; + if (state == "stale") + { + if (job.BrowserState == BrowserTransferState.InProgress && !job.IsBrowserRecordStale) + { + job = job with { IsBrowserRecordStale = true }; + await repository.UpdateJobAsync(job, "download.browser-record-stale", cancellationToken).ConfigureAwait(false); + } + + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + + if (state == "in_progress") + { + if (job.BrowserState == BrowserTransferState.InProgress) + { + job = job with + { + LastBrowserEventAt = DateTimeOffset.UtcNow, + IsBrowserRecordStale = false, + }; + await repository.UpdateJobAsync(job, "download.reconciled-in-progress", cancellationToken).ConfigureAwait(false); + } + + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); + return AgentResponse.Ok(request.RequestId, JobState(job)); + } + if (state is "interrupted" or "cancelled") { var cancelled = string.Equals(payload.Error, "USER_CANCELED", StringComparison.OrdinalIgnoreCase) @@ -231,11 +276,18 @@ private async Task HandleDownloadChangedAsync( var next = cancelled ? BrowserTransferState.Cancelled : BrowserTransferState.Interrupted; if (job.BrowserState != BrowserTransferState.InProgress) { + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); return AgentResponse.Ok(request.RequestId, JobState(job)); } stateMachine.EnsureCanTransition(job.BrowserState, next); - var routing = cancelled ? job.RoutingState : RoutingState.Failed; + var routing = cancelled + ? job.RoutingState is RoutingState.WaitingForSelection or RoutingState.SelectionReady + ? RoutingState.NotRequired + : job.RoutingState + : job.RoutingState is RoutingState.Completed or RoutingState.Skipped + ? job.RoutingState + : RoutingState.Failed; if (!cancelled) { stateMachine.EnsureCanTransition(job.RoutingState, routing); @@ -255,18 +307,23 @@ private async Task HandleDownloadChangedAsync( ? "The user cancelled this download in the browser." : "The browser interrupted this download before completion.", CompletedAt = DateTimeOffset.UtcNow, + LastBrowserEventAt = DateTimeOffset.UtcNow, + IsBrowserRecordStale = false, }; await repository.UpdateJobAsync(job, cancelled ? "download.cancelled" : "download.interrupted", cancellationToken).ConfigureAwait(false); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); return AgentResponse.Ok(request.RequestId, JobState(job)); } if (state != "complete") { + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); return AgentResponse.Ok(request.RequestId, JobState(job)); } if (job.BrowserState != BrowserTransferState.InProgress) { + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); return AgentResponse.Ok(request.RequestId, JobState(job)); } @@ -282,6 +339,8 @@ private async Task HandleDownloadChangedAsync( BrowserState = BrowserTransferState.Complete, ErrorCode = null, ErrorMessage = null, + LastBrowserEventAt = DateTimeOffset.UtcNow, + IsBrowserRecordStale = false, }; var rule = await repository.GetRuleAsync(job.RuleId, cancellationToken).ConfigureAwait(false) @@ -289,6 +348,7 @@ private async Task HandleDownloadChangedAsync( if (job.RoutingState == RoutingState.WaitingForSelection) { await repository.UpdateJobAsync(job, "download.completed-selection-pending", cancellationToken).ConfigureAwait(false); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); return AgentResponse.Ok(request.RequestId, new { tracked = true, @@ -303,6 +363,7 @@ private async Task HandleDownloadChangedAsync( { job = job with { CompletedAt = DateTimeOffset.UtcNow }; await repository.UpdateJobAsync(job, "download.completed-routing-skipped", cancellationToken).ConfigureAwait(false); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, job); return AgentResponse.Ok(request.RequestId, new { tracked = true, @@ -315,6 +376,7 @@ private async Task HandleDownloadChangedAsync( await repository.UpdateJobAsync(job, "download.completed", cancellationToken).ConfigureAwait(false); var moved = await MoveJobAsync(job, rule, cancellationToken).ConfigureAwait(false); + LogTransition(sanitizedDownloadId, request.Command, beforeBrowser, beforeRouting, moved); return AgentResponse.Ok(request.RequestId, new { tracked = true, @@ -680,20 +742,56 @@ private async Task UpdateFileNameAsync( { var trusted = DownloadPresentation.TrustedFileName(reportedFileName) ?? DownloadPresentation.TrustedFileName(reportedFilePath); - if (trusted is null || string.Equals(trusted, job.CurrentFileName, StringComparison.Ordinal)) - { - return job; - } - var updated = job with { - CurrentFileName = trusted, - OriginalFileName = string.IsNullOrWhiteSpace(job.OriginalFileName) ? trusted : job.OriginalFileName, + CurrentFileName = trusted ?? job.CurrentFileName, + OriginalFileName = trusted is not null && string.IsNullOrWhiteSpace(job.OriginalFileName) + ? trusted + : job.OriginalFileName, + LastBrowserEventAt = DateTimeOffset.UtcNow, + IsBrowserRecordStale = false, }; - await repository.UpdateJobAsync(updated, "download.filename-updated", cancellationToken).ConfigureAwait(false); + await repository.UpdateJobAsync( + updated, + trusted is not null && !string.Equals(trusted, job.CurrentFileName, StringComparison.Ordinal) + ? "download.filename-updated" + : "download.browser-event", + cancellationToken).ConfigureAwait(false); return updated; } + private void LogTransition( + string downloadId, + string command, + BrowserTransferState beforeBrowser, + RoutingState beforeRouting, + DownloadJob after) + => logger.LogInformation( + "Browser command applied: command={Command}, downloadId={DownloadId}, browserState={BeforeBrowser}->{AfterBrowser}, routingState={BeforeRouting}->{AfterRouting}", + command, + downloadId, + beforeBrowser, + after.BrowserState, + beforeRouting, + after.RoutingState); + + private static string SanitizeDownloadId(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "invalid"; + } + + var sanitized = new string(value.Where(static character => + char.IsAsciiLetterOrDigit(character) || character is '-' or '_' or '.').Take(64).ToArray()); + return sanitized.Length == 0 ? "invalid" : sanitized; + } + + private static string SanitizeState(string? value) + => value is "complete" or "interrupted" or "cancelled" or "in_progress" or "stale" + ? value + : "unknown"; + private static object JobRouteState(DownloadJob job) => new { diff --git a/src/DownloadRouter.App/App.xaml.cs b/src/DownloadRouter.App/App.xaml.cs index 9967d56..477122a 100644 --- a/src/DownloadRouter.App/App.xaml.cs +++ b/src/DownloadRouter.App/App.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml; using DownloadRouter.Core.Models; +using DownloadRouter.Core.Settings; namespace DownloadRouter.App; @@ -70,7 +71,10 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) return; } - var mainWindow = new MainWindow(); + var preferencesStore = new AppPreferencesStore(); + var preferences = preferencesStore.Load(); + var themeManager = new ThemeManager(preferences.ThemePreference); + var mainWindow = new MainWindow(preferencesStore, themeManager, preferences); window = mainWindow; activationEvent = new EventWaitHandle( initialState: false, diff --git a/src/DownloadRouter.App/AppPreferencesStore.cs b/src/DownloadRouter.App/AppPreferencesStore.cs deleted file mode 100644 index d51d337..0000000 --- a/src/DownloadRouter.App/AppPreferencesStore.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json; -using DownloadRouter.Core.Models; - -namespace DownloadRouter.App; - -public sealed record AppPreferences(WindowCloseBehavior CloseBehavior = WindowCloseBehavior.MinimizeToTray); - -public sealed class AppPreferencesStore -{ - private readonly string configPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "eslee", - "DownloadRouter", - "config.local.json"); - - public AppPreferences Load() - { - try - { - return File.Exists(configPath) - ? JsonSerializer.Deserialize(File.ReadAllText(configPath), ProtocolJson.Options) - ?? new AppPreferences() - : new AppPreferences(); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) - { - return new AppPreferences(); - } - } - - public void Save(AppPreferences preferences) - { - var directory = Path.GetDirectoryName(configPath) - ?? throw new InvalidOperationException("The settings directory could not be resolved."); - Directory.CreateDirectory(directory); - var temporary = configPath + ".tmp"; - File.WriteAllText(temporary, JsonSerializer.Serialize(preferences, ProtocolJson.Options)); - File.Move(temporary, configPath, overwrite: true); - } -} diff --git a/src/DownloadRouter.App/FolderSelectionWindow.cs b/src/DownloadRouter.App/FolderSelectionWindow.cs index 73144a5..3e0e614 100644 --- a/src/DownloadRouter.App/FolderSelectionWindow.cs +++ b/src/DownloadRouter.App/FolderSelectionWindow.cs @@ -10,6 +10,7 @@ public enum FolderSelectionAction { Apply, Later, + LaterAll, Skip, Closed, } @@ -18,7 +19,7 @@ public sealed record FolderSelectionResult( FolderSelectionAction Action, string RelativeFolder); -public sealed class FolderSelectionWindow +public sealed class FolderSelectionWindow(ThemeManager themeManager) { private const int DefaultWidthDip = 560; private const int DefaultHeightDip = 760; @@ -43,10 +44,12 @@ public async Task ShowAsync( string description, bool allowLater, bool allowSkip, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool allowLaterAll = false) { window.Title = "다운로드 저장 위치 선택"; - window.Content = CreateContent(description, allowLater, allowSkip); + window.Content = CreateContent(description, allowLater, allowSkip, allowLaterAll); + themeManager.RegisterWindow(window); window.Closed += (_, _) => CompleteWithoutClosing(FolderSelectionAction.Closed); await picker.InitializeAsync(storageRoot, selectedRelativeFolder, cancellationToken); window.Activate(); @@ -58,7 +61,7 @@ public async Task ShowAsync( return await completion.Task; } - private UIElement CreateContent(string description, bool allowLater, bool allowSkip) + private UIElement CreateContent(string description, bool allowLater, bool allowSkip, bool allowLaterAll) { var root = new Grid { @@ -114,6 +117,17 @@ private UIElement CreateContent(string description, bool allowLater, bool allowS buttons.Children.Add(later); } + if (allowLaterAll) + { + var laterAll = new Button + { + Content = "모두 나중에 선택", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + laterAll.Click += (_, _) => Complete(FolderSelectionAction.LaterAll); + buttons.Children.Add(laterAll); + } + if (allowSkip) { var skip = new Button diff --git a/src/DownloadRouter.App/MainWindow.History.cs b/src/DownloadRouter.App/MainWindow.History.cs index 96a903e..0ff4838 100644 --- a/src/DownloadRouter.App/MainWindow.History.cs +++ b/src/DownloadRouter.App/MainWindow.History.cs @@ -236,7 +236,7 @@ private async Task ChangeHistoryRouteAsync(DownloadJob job, DownloadRule rule) return; } - var result = await new FolderSelectionWindow().ShowAsync( + var result = await new FolderSelectionWindow(themeManager).ShowAsync( root, job.SelectedRelativeFolder, $"파일: {DownloadPresentation.DisplayFileName(job)}\n현재 상태: {DescribeStatus(job)}\n저장 루트: {root}", diff --git a/src/DownloadRouter.App/MainWindow.Lifetime.cs b/src/DownloadRouter.App/MainWindow.Lifetime.cs index 20ff1ba..faebd6a 100644 --- a/src/DownloadRouter.App/MainWindow.Lifetime.cs +++ b/src/DownloadRouter.App/MainWindow.Lifetime.cs @@ -1,13 +1,15 @@ using System.Runtime.InteropServices; using DownloadRouter.Core.Models; +using DownloadRouter.Core.Settings; using Microsoft.UI.Windowing; namespace DownloadRouter.App; public sealed partial class MainWindow { - private readonly AppPreferencesStore preferencesStore = new(); - private AppPreferences preferences = new(); + private readonly AppPreferencesStore preferencesStore; + private readonly ThemeManager themeManager; + private AppPreferences preferences; private AppWindow? appWindow; private TrayIconHost? trayIcon; private bool exitRequested; @@ -16,7 +18,6 @@ public sealed partial class MainWindow public void InitializeHost(bool background, Action onExit) { exitCallback = onExit; - preferences = preferencesStore.Load(); var handle = WinRT.Interop.WindowNative.GetWindowHandle(this); var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(handle); appWindow = AppWindow.GetFromWindowId(windowId); diff --git a/src/DownloadRouter.App/MainWindow.Live.cs b/src/DownloadRouter.App/MainWindow.Live.cs index fdb2f68..710baf1 100644 --- a/src/DownloadRouter.App/MainWindow.Live.cs +++ b/src/DownloadRouter.App/MainWindow.Live.cs @@ -7,9 +7,10 @@ namespace DownloadRouter.App; public sealed partial class MainWindow { - private readonly DispatcherTimer liveUpdateTimer = new() { Interval = TimeSpan.FromSeconds(1) }; + private readonly DispatcherTimer liveUpdateTimer = new() { Interval = TimeSpan.FromMilliseconds(500) }; private readonly SelectionPromptQueue selectionPromptQueue = new(); private readonly HashSet deferredSelectionPrompts = []; + private readonly HashSet sessionAutoPromptJobs = []; private CancellationTokenSource? currentSelectionCancellation; private Guid? currentSelectionJobId; private DownloadRule? currentSelectionRule; @@ -19,7 +20,8 @@ public sealed partial class MainWindow private bool liveUpdateInProgress; private bool selectionPromptInProgress; private string? livePageSnapshot; - private int previousPendingCount; + private int previousAutomaticCount; + private int shownAutoPromptCount; private void InitializeLiveUpdates() { @@ -75,12 +77,13 @@ private async void LiveUpdateTimer_Tick(object? sender, object args) var jobs = AgentClient.ReadData>(await agent.SendAsync("jobs.list")) ?? []; var rules = AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []; var active = DownloadJobQueries.ActiveSelections(jobs); - UpdatePendingBadge(active.Count); - SynchronizeSelectionPrompt(active); + var automatic = DownloadJobQueries.AutomaticSelections(jobs, DateTimeOffset.UtcNow); + UpdatePendingBadge(active.Count, automatic.Count); + SynchronizeSelectionPrompt(active, automatic); if (!selectionPromptInProgress && !blockingDialogOpen) { - _ = ProcessNextSelectionPromptAsync(active, rules); + _ = ProcessNextSelectionPromptAsync(automatic, rules); } await RefreshVisibleLivePageAsync(jobs, rules); @@ -95,7 +98,9 @@ private async void LiveUpdateTimer_Tick(object? sender, object args) } } - private void SynchronizeSelectionPrompt(IReadOnlyList active) + private void SynchronizeSelectionPrompt( + IReadOnlyList active, + IReadOnlyList automatic) { var activeIds = active.Select(static job => job.Id).ToHashSet(); if (currentSelectionJobId is Guid current && !activeIds.Contains(current)) @@ -113,16 +118,24 @@ private void SynchronizeSelectionPrompt(IReadOnlyList active) if (currentSelectionRule is not null) { currentSelectionWindow?.UpdateDescription( - CreateSelectionDescription(currentJob, currentSelectionRule, Math.Max(0, active.Count - 1))); + CreateSelectionDescription( + currentJob, + currentSelectionRule, + Math.Max(0, active.Count - 1), + Math.Max(1, shownAutoPromptCount), + Math.Max(shownAutoPromptCount, sessionAutoPromptJobs.Count))); } } } - foreach (var job in active) + foreach (var job in automatic) { if (job.Id != currentSelectionJobId && !deferredSelectionPrompts.Contains(job.Id)) { - selectionPromptQueue.Enqueue(job.Id); + if (selectionPromptQueue.Enqueue(job.Id)) + { + sessionAutoPromptJobs.Add(job.Id); + } } } } @@ -140,12 +153,25 @@ private async Task ProcessNextSelectionPromptAsync( continue; } - await ShowSelectionPromptAsync(job, rule, Math.Max(0, active.Count - 1)); + shownAutoPromptCount++; + await ShowSelectionPromptAsync( + job, + rule, + Math.Max(0, active.Count - 1), + shownAutoPromptCount, + Math.Max(shownAutoPromptCount, sessionAutoPromptJobs.Count), + active.Select(static candidate => candidate.Id).ToArray()); return; } } - private async Task ShowSelectionPromptAsync(DownloadJob job, DownloadRule rule, int otherPendingCount) + private async Task ShowSelectionPromptAsync( + DownloadJob job, + DownloadRule rule, + int otherPendingCount, + int queuePosition, + int queueTotal, + IReadOnlyList currentQueueJobIds) { selectionPromptInProgress = true; currentSelectionJobId = job.Id; @@ -156,14 +182,15 @@ private async Task ShowSelectionPromptAsync(DownloadJob job, DownloadRule rule, { var root = pathResolver.Resolve(rule.StorageRoot); currentSelectionCancellation = new CancellationTokenSource(); - currentSelectionWindow = new FolderSelectionWindow(); + currentSelectionWindow = new FolderSelectionWindow(themeManager); var result = await currentSelectionWindow.ShowAsync( root, job.SelectedRelativeFolder, - CreateSelectionDescription(job, rule, otherPendingCount), + CreateSelectionDescription(job, rule, otherPendingCount, queuePosition, queueTotal), allowLater: true, allowSkip: true, - currentSelectionCancellation.Token); + cancellationToken: currentSelectionCancellation.Token, + allowLaterAll: true); if (currentSelectionInvalidated) { @@ -188,6 +215,17 @@ private async Task ShowSelectionPromptAsync(DownloadJob job, DownloadRule rule, await ShowMessageAsync(response.Message ?? "이동 건너뛰기 적용에 실패했습니다."); } } + else if (result.Action == FolderSelectionAction.LaterAll) + { + foreach (var queuedJobId in currentQueueJobIds) + { + deferredSelectionPrompts.Add(queuedJobId); + } + + selectionPromptQueue.Drain(); + sessionAutoPromptJobs.Clear(); + shownAutoPromptCount = 0; + } else { deferredSelectionPrompts.Add(job.Id); @@ -248,18 +286,23 @@ private async Task RefreshVisibleLivePageAsync( private static string CreateSelectionPromptSnapshot(DownloadJob job, int activeCount) => $"{job.Id:N}:{job.CurrentFileName}:{job.BrowserState}:{job.RoutingState}:{activeCount}"; - private string CreateSelectionDescription(DownloadJob job, DownloadRule rule, int otherPendingCount) - => $"파일: {DownloadPresentation.DisplayFileName(job)}\n출처 사이트: {GetSourceHost(job)}\n상태: {DescribeStatus(job)}\n규칙: {rule.Name}\n저장 루트: {pathResolver.Resolve(rule.StorageRoot)}\n동시에 대기 중인 다른 파일: {otherPendingCount}개"; + private string CreateSelectionDescription( + DownloadJob job, + DownloadRule rule, + int otherPendingCount, + int queuePosition = 1, + int queueTotal = 1) + => $"{queuePosition} / {queueTotal}\n파일: {DownloadPresentation.DisplayFileName(job)}\n출처 사이트: {GetSourceHost(job)}\n상태: {DescribeStatus(job)}\n규칙: {rule.Name}\n저장 루트: {pathResolver.Resolve(rule.StorageRoot)}\n동시에 대기 중인 다른 파일 {otherPendingCount}개"; - private void UpdatePendingBadge(int count) + private void UpdatePendingBadge(int count, int automaticCount) { PendingInfoBadge.Value = count; PendingInfoBadge.Visibility = count > 0 ? Visibility.Visible : Visibility.Collapsed; - if (count > previousPendingCount) + if (automaticCount > previousAutomaticCount) { trayIcon?.ShowSelectionNotification(count); } - previousPendingCount = count; + previousAutomaticCount = automaticCount; } } diff --git a/src/DownloadRouter.App/MainWindow.Pending.cs b/src/DownloadRouter.App/MainWindow.Pending.cs index f1ce7c2..677c826 100644 --- a/src/DownloadRouter.App/MainWindow.Pending.cs +++ b/src/DownloadRouter.App/MainWindow.Pending.cs @@ -17,6 +17,18 @@ private async Task ShowPendingAsync() var rules = (AgentClient.ReadData>(await agent.SendAsync("rules.list")) ?? []) .ToDictionary(static rule => rule.Id); var active = DownloadJobQueries.ActiveSelections(jobs); + var previousSessionCount = active.Count(job => + SelectionPromptPolicy.IsPreviousSessionPending(job, DateTimeOffset.UtcNow)); + if (previousSessionCount > 0) + { + ContentPanel.Children.Add(new InfoBar + { + IsOpen = true, + Severity = InfoBarSeverity.Informational, + Title = "이전 세션의 대기 작업", + Message = $"30분 이상 지났거나 브라우저 기록을 확인할 수 없는 {previousSessionCount}개 작업은 자동 팝업 없이 이 탭에서만 유지됩니다.", + }); + } var groups = active.GroupBy(static job => job.RuleId).ToList(); foreach (var group in groups) @@ -76,7 +88,7 @@ private void AddPendingRuleGroup(DownloadRule rule, IReadOnlyList j return; } - var result = await new FolderSelectionWindow().ShowAsync( + var result = await new FolderSelectionWindow(themeManager).ShowAsync( root, selectedRelativeFolder: null, $"{selectedJobs.Count}개 파일에 같은 하위 폴더를 적용합니다. 체크하지 않은 파일은 변경하지 않습니다.", @@ -111,6 +123,17 @@ private Border CreatePendingCard( TextWrapping = TextWrapping.Wrap, }); panel.Children.Add(CreateStatusBadge(job)); + if (SelectionPromptPolicy.IsPreviousSessionPending(job, DateTimeOffset.UtcNow)) + { + panel.Children.Add(new TextBlock + { + Text = job.IsBrowserRecordStale + ? "이전 세션의 대기 작업 · 브라우저 기록을 찾을 수 없어 자동 표시하지 않음" + : "이전 세션의 대기 작업 · 30분 자동 팝업 기한 경과", + Opacity = 0.75, + TextWrapping = TextWrapping.Wrap, + }); + } panel.Children.Add(new TextBlock { Text = $"출처 호스트: {GetSourceHost(job)}\n브라우저: {job.Browser}\n다운로드 시작: {job.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}\n규칙: {rule.Name}\n저장 루트: {root}\n현재 선택: {DisplayFolder(job.SelectedRelativeFolder)}", @@ -120,7 +143,7 @@ private Border CreatePendingCard( var apply = new Button { Content = "이 파일의 저장 위치 선택/변경", HorizontalAlignment = HorizontalAlignment.Stretch }; apply.Click += async (_, _) => { - var result = await new FolderSelectionWindow().ShowAsync( + var result = await new FolderSelectionWindow(themeManager).ShowAsync( root, job.SelectedRelativeFolder, $"파일: {DownloadPresentation.DisplayFileName(job)}\n현재 선택: {DisplayFolder(job.SelectedRelativeFolder)}", diff --git a/src/DownloadRouter.App/MainWindow.Settings.cs b/src/DownloadRouter.App/MainWindow.Settings.cs index 19a9172..78af969 100644 --- a/src/DownloadRouter.App/MainWindow.Settings.cs +++ b/src/DownloadRouter.App/MainWindow.Settings.cs @@ -1,5 +1,6 @@ using DownloadRouter.Core.Models; using DownloadRouter.Core.Startup; +using DownloadRouter.Core.Settings; using Microsoft.UI.Xaml.Controls; namespace DownloadRouter.App; @@ -55,13 +56,32 @@ private Task ShowSettingsManagementAsync() ContentPanel.Children.Add(closeBehavior); AddMuted("기본값은 트레이로 최소화입니다. 트레이 메뉴의 ‘종료’는 설정과 관계없이 UI와 트레이를 종료합니다."); - ContentPanel.Children.Add(new ComboBox + var theme = new ComboBox { Header = "테마", ItemsSource = new[] { "시스템 설정", "라이트", "다크" }, - SelectedIndex = 0, + SelectedIndex = preferences.ThemePreference switch + { + AppThemePreference.Light => 1, + AppThemePreference.Dark => 2, + _ => 0, + }, HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Stretch, - }); + }; + theme.SelectionChanged += (_, _) => + { + var selected = theme.SelectedIndex switch + { + 1 => AppThemePreference.Light, + 2 => AppThemePreference.Dark, + _ => AppThemePreference.System, + }; + preferences = preferences with { Theme = AppThemePolicy.ToStorageValue(selected) }; + preferencesStore.Save(preferences); + themeManager.SetPreference(selected); + }; + ContentPanel.Children.Add(theme); + AddMuted("시스템 설정은 ElementTheme.Default를 사용하므로 Windows 앱 테마 변경을 따릅니다. 라이트/다크 선택은 열린 창과 이후 생성되는 저장 위치 선택 창에 즉시 적용됩니다."); return Task.CompletedTask; } } diff --git a/src/DownloadRouter.App/MainWindow.xaml.cs b/src/DownloadRouter.App/MainWindow.xaml.cs index 6a9ae0b..0a5ae88 100644 --- a/src/DownloadRouter.App/MainWindow.xaml.cs +++ b/src/DownloadRouter.App/MainWindow.xaml.cs @@ -1,7 +1,10 @@ using DownloadRouter.Core.Models; using DownloadRouter.Core.Paths; +using DownloadRouter.Core.Settings; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Windows.Storage; +using Windows.System; namespace DownloadRouter.App; @@ -11,9 +14,16 @@ public sealed partial class MainWindow : Window private readonly PathTokenResolver pathResolver = new(new WindowsKnownPathProvider()); private readonly PathBoundaryValidator boundaryValidator = new(); - public MainWindow() + public MainWindow( + AppPreferencesStore preferencesStore, + ThemeManager themeManager, + AppPreferences preferences) { + this.preferencesStore = preferencesStore; + this.themeManager = themeManager; + this.preferences = preferences; InitializeComponent(); + themeManager.RegisterWindow(this); Title = "eslee Download Router"; Navigation.SelectedItem = Navigation.MenuItems[0]; InitializeLiveUpdates(); @@ -136,13 +146,48 @@ rasterizationScale is null } } - private Task ShowAboutAsync() + private async Task ShowAboutAsync() { - Prepare("eslee Download Router", "Chromium 다운로드를 사이트 규칙에 따라 안전하게 정리하는 로컬 Windows 프로그램입니다."); + var version = ProductVersionInfo.Read(typeof(App).Assembly, AppContext.BaseDirectory); + Prepare(version.ProductName, "Chromium 다운로드를 사이트 규칙에 따라 안전하게 정리하는 로컬 Windows 프로그램입니다."); + AddCard("현재 버전", version.DisplayVersion); + AddCard("빌드", $"{version.BuildDescription} · {version.InformationalVersion}"); + AddCard("배포 형태", version.DistributionDescription); AddCard("개인정보", "서버 전송, 텔레메트리, 광고 SDK가 없습니다. 다운로드 처리에 필요한 최소 정보만 로컬에 저장합니다."); AddCard("지원 범위", "Windows 11 x64 · Whale / Edge / Chrome 공식 지원 · Brave / Vivaldi / Opera 호환 지원 · Firefox 제외"); AddCard("프로토콜", $"Native Messaging ↔ 현재 사용자 Named Pipe v{ProtocolConstants.CurrentVersion} ↔ 단일 Agent"); - return Task.CompletedTask; + + var openData = new Button + { + Content = "데이터 저장 위치 열기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + openData.Click += async (_, _) => + { + try + { + var dataDirectory = Path.GetDirectoryName(preferencesStore.ConfigPath) + ?? throw new InvalidOperationException("데이터 저장 위치를 확인할 수 없습니다."); + Directory.CreateDirectory(dataDirectory); + var folder = await StorageFolder.GetFolderFromPathAsync(dataDirectory); + _ = await Launcher.LaunchFolderAsync(folder); + } + catch (Exception exception) + { + await ShowMessageAsync("데이터 저장 위치 열기 실패: " + exception.Message); + } + }; + ContentPanel.Children.Add(openData); + + var openGitHub = new Button + { + Content = "GitHub 저장소 열기", + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + openGitHub.Click += async (_, _) => + _ = await Launcher.LaunchUriAsync(new Uri("https://github.com/esleeeeee/eslee-Download-Router")); + ContentPanel.Children.Add(openGitHub); + await Task.CompletedTask; } private void Prepare(string title, string description) diff --git a/src/DownloadRouter.App/ThemeManager.cs b/src/DownloadRouter.App/ThemeManager.cs new file mode 100644 index 0000000..e39138e --- /dev/null +++ b/src/DownloadRouter.App/ThemeManager.cs @@ -0,0 +1,55 @@ +using DownloadRouter.Core.Models; +using Microsoft.UI.Xaml; + +namespace DownloadRouter.App; + +public sealed class ThemeManager(AppThemePreference initialPreference) +{ + private readonly HashSet windows = []; + + public AppThemePreference CurrentPreference { get; private set; } = initialPreference; + + public static ElementTheme ToElementTheme(AppThemePreference preference) + => preference switch + { + AppThemePreference.Light => ElementTheme.Light, + AppThemePreference.Dark => ElementTheme.Dark, + _ => ElementTheme.Default, + }; + + public void RegisterWindow(Window window) + { + ArgumentNullException.ThrowIfNull(window); + if (windows.Add(window)) + { + window.Closed += Window_Closed; + } + + Apply(window); + } + + public void SetPreference(AppThemePreference preference) + { + CurrentPreference = Enum.IsDefined(preference) ? preference : AppThemePreference.System; + foreach (var window in windows.ToArray()) + { + Apply(window); + } + } + + private void Apply(Window window) + { + if (window.Content is FrameworkElement root) + { + root.RequestedTheme = ToElementTheme(CurrentPreference); + } + } + + private void Window_Closed(object sender, WindowEventArgs args) + { + if (sender is Window window) + { + windows.Remove(window); + } + } +} diff --git a/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs b/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs index 3b97e51..474151d 100644 --- a/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs +++ b/src/DownloadRouter.Core/Jobs/DownloadJobQueries.cs @@ -7,6 +7,15 @@ public static class DownloadJobQueries public static IReadOnlyList ActiveSelections(IEnumerable jobs) => jobs.Where(static job => job.IsSelectionPending).ToList(); + public static IReadOnlyList AutomaticSelections( + IEnumerable jobs, + DateTimeOffset now) + => jobs + .Where(job => SelectionPromptPolicy.IsAutoPromptEligible(job, now)) + .OrderBy(static job => job.LastBrowserEventAt ?? job.CreatedAt) + .ThenBy(static job => job.CreatedAt) + .ToList(); + public static DashboardCounts CountDashboard(IEnumerable jobs) { var snapshot = jobs.ToList(); diff --git a/src/DownloadRouter.Core/Jobs/SelectionPromptPolicy.cs b/src/DownloadRouter.Core/Jobs/SelectionPromptPolicy.cs new file mode 100644 index 0000000..0349193 --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/SelectionPromptPolicy.cs @@ -0,0 +1,22 @@ +using DownloadRouter.Core.Models; + +namespace DownloadRouter.Core.Jobs; + +public static class SelectionPromptPolicy +{ + public static readonly TimeSpan AutoPromptWindow = TimeSpan.FromMinutes(30); + + public static bool IsAutoPromptEligible(DownloadJob job, DateTimeOffset now) + { + if (!job.IsSelectionPending || job.IsBrowserRecordStale) + { + return false; + } + + var lastActivity = job.LastBrowserEventAt ?? job.CreatedAt; + return lastActivity <= now && now - lastActivity <= AutoPromptWindow; + } + + public static bool IsPreviousSessionPending(DownloadJob job, DateTimeOffset now) + => job.IsSelectionPending && !IsAutoPromptEligible(job, now); +} diff --git a/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs b/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs index f020d72..9504dff 100644 --- a/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs +++ b/src/DownloadRouter.Core/Jobs/SelectionPromptQueue.cs @@ -63,4 +63,12 @@ public bool Remove(Guid jobId) return true; } + + public IReadOnlyList Drain() + { + var result = queue.ToArray(); + queue.Clear(); + queued.Clear(); + return result; + } } diff --git a/src/DownloadRouter.Core/Models/DomainModels.cs b/src/DownloadRouter.Core/Models/DomainModels.cs index 50f05dd..000b5b8 100644 --- a/src/DownloadRouter.Core/Models/DomainModels.cs +++ b/src/DownloadRouter.Core/Models/DomainModels.cs @@ -39,6 +39,13 @@ public enum WindowCloseBehavior ExitApplication, } +public enum AppThemePreference +{ + System, + Light, + Dark, +} + public enum DownloadJobStatus { Detected, @@ -124,7 +131,9 @@ public sealed record DownloadJob( string? ErrorCode, string? ErrorMessage, DateTimeOffset CreatedAt, - DateTimeOffset? CompletedAt) + DateTimeOffset? CompletedAt, + DateTimeOffset? LastBrowserEventAt = null, + bool IsBrowserRecordStale = false) { public DownloadJobStatus Status => BrowserState switch @@ -243,6 +252,8 @@ public static class ProtocolConstants "download.started", "download.metadata", "download.changed", + "download.cancelled", + "download.interrupted", "rules.list", "rules.upsert", "rules.delete", diff --git a/src/DownloadRouter.Core/Models/ProductVersionInfo.cs b/src/DownloadRouter.Core/Models/ProductVersionInfo.cs new file mode 100644 index 0000000..c88b735 --- /dev/null +++ b/src/DownloadRouter.Core/Models/ProductVersionInfo.cs @@ -0,0 +1,52 @@ +using System.Reflection; + +namespace DownloadRouter.Core.Models; + +public sealed record ProductVersionInfo( + string ProductName, + string InformationalVersion, + string SemanticVersion, + string? Commit, + bool IsInstalledBuild) +{ + public const string Name = "eslee Download Router"; + + public string DisplayVersion => $"버전 {SemanticVersion}"; + + public string BuildDescription + => Commit is null ? "빌드 정보 없음" : $"커밋 {Commit}"; + + public string DistributionDescription + => IsInstalledBuild ? "설치형 빌드" : "개발 빌드"; + + public static ProductVersionInfo Read(Assembly assembly, string baseDirectory) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + var informational = assembly + .GetCustomAttribute()? + .InformationalVersion; + if (string.IsNullOrWhiteSpace(informational)) + { + informational = assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + } + + var separator = informational.IndexOf('+', StringComparison.Ordinal); + var semantic = separator >= 0 ? informational[..separator] : informational; + var revision = separator >= 0 ? informational[(separator + 1)..] : null; + var commit = string.IsNullOrWhiteSpace(revision) + ? null + : new string(revision.Where(char.IsAsciiLetterOrDigit).Take(7).ToArray()); + if (commit is { Length: 0 }) + { + commit = null; + } + + return new ProductVersionInfo( + Name, + informational, + semantic, + commit, + File.Exists(Path.Combine(baseDirectory, "unins000.exe"))); + } +} diff --git a/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs b/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs new file mode 100644 index 0000000..4844906 --- /dev/null +++ b/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using DownloadRouter.Core.Models; + +namespace DownloadRouter.Core.Settings; + +public sealed record AppPreferences( + WindowCloseBehavior CloseBehavior = WindowCloseBehavior.MinimizeToTray, + string Theme = "System") +{ + public AppThemePreference ThemePreference => AppThemePolicy.Parse(Theme); +} + +public static class AppThemePolicy +{ + public static AppThemePreference Parse(string? value) + => Enum.TryParse(value, ignoreCase: true, out var result) + && Enum.IsDefined(result) + ? result + : AppThemePreference.System; + + public static string ToStorageValue(AppThemePreference preference) + => Enum.IsDefined(preference) ? preference.ToString() : AppThemePreference.System.ToString(); + + public static string ToElementThemeName(AppThemePreference preference) + => preference switch + { + AppThemePreference.Light => "Light", + AppThemePreference.Dark => "Dark", + _ => "Default", + }; +} + +public sealed class AppPreferencesStore +{ + private readonly string configPath; + + public AppPreferencesStore(string? configPath = null) + { + this.configPath = configPath ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "eslee", + "DownloadRouter", + "config.local.json"); + } + + public string ConfigPath => configPath; + + public AppPreferences Load() + { + try + { + var loaded = File.Exists(configPath) + ? JsonSerializer.Deserialize(File.ReadAllText(configPath), ProtocolJson.Options) + ?? new AppPreferences() + : new AppPreferences(); + return loaded with { Theme = AppThemePolicy.ToStorageValue(loaded.ThemePreference) }; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return new AppPreferences(); + } + } + + public void Save(AppPreferences preferences) + { + var directory = Path.GetDirectoryName(configPath) + ?? throw new InvalidOperationException("The settings directory could not be resolved."); + Directory.CreateDirectory(directory); + var normalized = preferences with + { + Theme = AppThemePolicy.ToStorageValue(preferences.ThemePreference), + }; + var temporary = configPath + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(normalized, ProtocolJson.Options)); + File.Move(temporary, configPath, overwrite: true); + } +} diff --git a/src/DownloadRouter.Extension/src/background.ts b/src/DownloadRouter.Extension/src/background.ts index 0e9fbca..4ede836 100644 --- a/src/DownloadRouter.Extension/src/background.ts +++ b/src/DownloadRouter.Extension/src/background.ts @@ -1,11 +1,17 @@ import { detectBrowser } from "./browser.js"; -import { reportedDownloadState } from "./download-state.js"; +import { + isUserCancelled, + preferredDownloadError, + safeDownloadError, +} from "./download-state.js"; import { trustedDownloadFileName } from "./file-name.js"; import { sendNative } from "./native.js"; -import { createRequest } from "./protocol.js"; +import { createRequest, type AgentCommandName } from "./protocol.js"; import { sourceMetadata } from "./source-attribution.js"; const browser = detectBrowser(navigator.userAgent); +const lastErrors = new Map(); +const reportedTerminalStates = new Map(); interface ActiveBrowserDownload { jobId: string; @@ -13,6 +19,8 @@ interface ActiveBrowserDownload { } chrome.downloads.onCreated.addListener((item) => { + lastErrors.delete(item.id); + reportedTerminalStates.delete(item.id); const metadata = sourceMetadata(item); void sendNative( createRequest("download.started", { @@ -25,6 +33,20 @@ chrome.downloads.onCreated.addListener((item) => { chrome.downloads.onChanged.addListener((delta) => { const state = delta.state?.current; + const deltaError = delta.error?.current ?? null; + if (deltaError) { + lastErrors.set(delta.id, deltaError); + } + + console.debug( + `[Download Router] onChanged downloadId=${safeDownloadId(delta.id)} state=${safeDownloadState(state)} error=${safeDownloadError(deltaError)}`, + ); + + if (isUserCancelled(deltaError)) { + reportTerminalWithoutItem(delta.id, "download.cancelled", "cancelled", deltaError); + return; + } + if (!delta.filename && state !== "complete" && state !== "interrupted") { return; } @@ -32,6 +54,9 @@ chrome.downloads.onChanged.addListener((delta) => { chrome.downloads.search({ id: delta.id }, (items) => { const item = items[0]; if (!item) { + if (state === "interrupted") { + reportStale(delta.id); + } return; } @@ -39,24 +64,46 @@ chrome.downloads.onChanged.addListener((delta) => { reportDownloadMetadata(item); } - if (state === "complete" || state === "interrupted") { - reportChangedDownload(item, state, delta.error?.current ?? item.error ?? null); + if (state === "complete") { + reportTerminal(item, "download.changed", "complete", null); + return; + } + + if (state === "interrupted") { + const error = preferredDownloadError(deltaError, item.error, lastErrors.get(delta.id)); + reportTerminal( + item, + isUserCancelled(error) ? "download.cancelled" : "download.interrupted", + isUserCancelled(error) ? "cancelled" : "interrupted", + error, + ); } }); }); +chrome.downloads.onErased.addListener((downloadId) => { + console.debug(`[Download Router] onErased downloadId=${safeDownloadId(downloadId)} event=history-erased`); + lastErrors.delete(downloadId); + reportedTerminalStates.delete(downloadId); +}); + void reconcileActiveDownloads(); -function reportChangedDownload( +function reportTerminal( item: chrome.downloads.DownloadItem, - state: "complete" | "interrupted", + command: AgentCommandName, + state: "complete" | "interrupted" | "cancelled", error: string | null, ): void { + if (!markTerminalReported(item.id, state)) { + return; + } + void sendNative( - createRequest("download.changed", { + createRequest(command, { browser, downloadId: item.id.toString(), - state: reportedDownloadState(state, error), + state, filePath: item.filename || null, fileName: trustedDownloadFileName(item.filename), error, @@ -64,6 +111,26 @@ function reportChangedDownload( ); } +function reportTerminalWithoutItem( + downloadId: number, + command: AgentCommandName, + state: "cancelled" | "interrupted", + error: string | null, +): void { + if (!markTerminalReported(downloadId, state)) { + return; + } + + void sendNative(createRequest(command, { + browser, + downloadId: downloadId.toString(), + state, + filePath: null, + fileName: null, + error, + })); +} + function reportDownloadMetadata(item: chrome.downloads.DownloadItem): void { void sendNative( createRequest("download.metadata", { @@ -75,6 +142,44 @@ function reportDownloadMetadata(item: chrome.downloads.DownloadItem): void { ); } +function reportReconciledState(item: chrome.downloads.DownloadItem): void { + if (item.state === "complete") { + reportTerminal(item, "download.changed", "complete", null); + return; + } + + if (item.state === "interrupted") { + const error = preferredDownloadError(null, item.error, lastErrors.get(item.id)); + reportTerminal( + item, + isUserCancelled(error) ? "download.cancelled" : "download.interrupted", + isUserCancelled(error) ? "cancelled" : "interrupted", + error, + ); + return; + } + + void sendNative(createRequest("download.changed", { + browser, + downloadId: item.id.toString(), + state: "in_progress", + filePath: null, + fileName: null, + error: null, + })); +} + +function reportStale(downloadId: number): void { + void sendNative(createRequest("download.changed", { + browser, + downloadId: downloadId.toString(), + state: "stale", + filePath: null, + fileName: null, + error: null, + })); +} + async function reconcileActiveDownloads(): Promise { const response = await sendNative<{ browser: string }, ActiveBrowserDownload[]>( createRequest("downloads.active", { browser }), @@ -91,11 +196,29 @@ async function reconcileActiveDownloads(): Promise { chrome.downloads.search({ id }, (items) => { const item = items[0]; - if (!item || (item.state !== "complete" && item.state !== "interrupted")) { + if (!item) { + reportStale(id); return; } - reportChangedDownload(item, item.state, item.error ?? null); + reportReconciledState(item); }); } } + +function markTerminalReported(id: number, state: "cancelled" | "interrupted" | "complete"): boolean { + if (reportedTerminalStates.get(id) === state) { + return false; + } + + reportedTerminalStates.set(id, state); + return true; +} + +function safeDownloadId(id: number): string { + return Number.isSafeInteger(id) && id >= 0 ? id.toString() : "invalid"; +} + +function safeDownloadState(state: string | undefined): string { + return state === "in_progress" || state === "complete" || state === "interrupted" ? state : "none"; +} diff --git a/src/DownloadRouter.Extension/src/chrome.d.ts b/src/DownloadRouter.Extension/src/chrome.d.ts index 20f4bec..6dc5641 100644 --- a/src/DownloadRouter.Extension/src/chrome.d.ts +++ b/src/DownloadRouter.Extension/src/chrome.d.ts @@ -48,6 +48,10 @@ declare namespace chrome { addListener(callback: (downloadDelta: DownloadDelta) => void): void; }; + const onErased: { + addListener(callback: (downloadId: number) => void): void; + }; + function search(query: { id?: number }, callback: (results: DownloadItem[]) => void): void; } } diff --git a/src/DownloadRouter.Extension/src/download-state.ts b/src/DownloadRouter.Extension/src/download-state.ts index bfed99a..692d829 100644 --- a/src/DownloadRouter.Extension/src/download-state.ts +++ b/src/DownloadRouter.Extension/src/download-state.ts @@ -10,3 +10,22 @@ export function reportedDownloadState( return browserState; } + +export function preferredDownloadError( + deltaError: string | null | undefined, + itemError: string | null | undefined, + previousError: string | null | undefined, +): string | null { + return deltaError ?? itemError ?? previousError ?? null; +} + +export function isUserCancelled(error: string | null | undefined): boolean { + return error?.toUpperCase() === "USER_CANCELED"; +} + +export function safeDownloadError(error: string | null | undefined): string { + const normalized = error?.toUpperCase() ?? "NONE"; + return /^(USER_(?:CANCELED|SHUTDOWN)|CRASH|NETWORK_[A-Z_]+|FILE_[A-Z_]+|SERVER_[A-Z_]+)$/u.test(normalized) + ? normalized + : "UNKNOWN"; +} diff --git a/src/DownloadRouter.Extension/src/native.ts b/src/DownloadRouter.Extension/src/native.ts index 943422d..6bec3f2 100644 --- a/src/DownloadRouter.Extension/src/native.ts +++ b/src/DownloadRouter.Extension/src/native.ts @@ -7,23 +7,26 @@ import { export function sendNative( request: AgentRequest, ): Promise | null> { + const context = nativeDiagnosticContext(request); return new Promise((resolve) => { chrome.runtime.sendNativeMessage(nativeHostName, request, (response: unknown) => { if (chrome.runtime.lastError) { // Fail open: browser downloads must never depend on the local host. - reportNativeFailure(classifyNativeRuntimeError(chrome.runtime.lastError.message)); + reportNativeFailure(context, classifyNativeRuntimeError(chrome.runtime.lastError.message)); resolve(null); return; } if (!isAgentResponse(response)) { - reportNativeFailure("invalid-response"); + reportNativeFailure(context, "invalid-response"); resolve(null); return; } if (!response.success) { - reportNativeFailure(safeAgentErrorCode(response.errorCode)); + reportNativeFailure(context, safeAgentErrorCode(response.errorCode)); + } else { + console.debug(`[Download Router] native-send ${context} result=success`); } resolve(response as AgentResponse); @@ -56,8 +59,15 @@ export function safeAgentErrorCode(errorCode: string | undefined): string { return "agent-request-failed"; } -function reportNativeFailure(code: string): void { - console.warn(`[Download Router] Native host communication failed (${code}); the browser download remains unchanged.`); +function reportNativeFailure(context: string, code: string): void { + console.warn(`[Download Router] native-send ${context} result=failure code=${code}; browser download unchanged.`); +} + +export function nativeDiagnosticContext(request: AgentRequest): string { + const payload = request.payload as { downloadId?: unknown }; + const rawId = typeof payload.downloadId === "string" ? payload.downloadId : "none"; + const downloadId = /^[0-9]{1,20}$/u.test(rawId) ? rawId : "invalid"; + return `command=${request.command} downloadId=${downloadId}`; } function isAgentResponse(value: unknown): value is AgentResponse { diff --git a/src/DownloadRouter.Extension/src/protocol.ts b/src/DownloadRouter.Extension/src/protocol.ts index 6437640..086204c 100644 --- a/src/DownloadRouter.Extension/src/protocol.ts +++ b/src/DownloadRouter.Extension/src/protocol.ts @@ -6,6 +6,8 @@ export type AgentCommandName = | "download.started" | "download.metadata" | "download.changed" + | "download.cancelled" + | "download.interrupted" | "downloads.active"; export interface AgentRequest { diff --git a/src/DownloadRouter.Extension/tests/download-state.test.ts b/src/DownloadRouter.Extension/tests/download-state.test.ts index 131cfc9..856019c 100644 --- a/src/DownloadRouter.Extension/tests/download-state.test.ts +++ b/src/DownloadRouter.Extension/tests/download-state.test.ts @@ -1,7 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { reportedDownloadState } from "../src/download-state.js"; +import { + isUserCancelled, + preferredDownloadError, + reportedDownloadState, + safeDownloadError, +} from "../src/download-state.js"; void test("USER_CANCELED is reported as an explicit cancellation", () => { assert.equal(reportedDownloadState("interrupted", "USER_CANCELED"), "cancelled"); @@ -10,3 +15,17 @@ void test("USER_CANCELED is reported as an explicit cancellation", () => { void test("other browser errors remain interrupted", () => { assert.equal(reportedDownloadState("interrupted", "NETWORK_FAILED"), "interrupted"); }); + +void test("browser error priority is delta then search result then last observed error", () => { + assert.equal(preferredDownloadError("USER_CANCELED", "NETWORK_FAILED", "FILE_FAILED"), "USER_CANCELED"); + assert.equal(preferredDownloadError(null, "NETWORK_FAILED", "FILE_FAILED"), "NETWORK_FAILED"); + assert.equal(preferredDownloadError(undefined, undefined, "FILE_FAILED"), "FILE_FAILED"); + assert.equal(preferredDownloadError(null, null, null), null); +}); + +void test("USER_CANCELED matching and diagnostics are normalized without raw values", () => { + assert.equal(isUserCancelled("user_canceled"), true); + assert.equal(isUserCancelled("USER_SHUTDOWN"), false); + assert.equal(safeDownloadError("NETWORK_FAILED"), "NETWORK_FAILED"); + assert.equal(safeDownloadError("https://example.test/?token=secret"), "UNKNOWN"); +}); diff --git a/src/DownloadRouter.Extension/tests/native.test.ts b/src/DownloadRouter.Extension/tests/native.test.ts index ba6e19c..85dfd46 100644 --- a/src/DownloadRouter.Extension/tests/native.test.ts +++ b/src/DownloadRouter.Extension/tests/native.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { classifyNativeRuntimeError, safeAgentErrorCode } from "../src/native.js"; +import { classifyNativeRuntimeError, nativeDiagnosticContext, safeAgentErrorCode } from "../src/native.js"; +import { createRequest } from "../src/protocol.js"; void test("native runtime failures are classified without retaining raw messages", () => { assert.equal(classifyNativeRuntimeError("Specified native messaging host not found."), "host-not-found"); @@ -14,3 +15,20 @@ void test("only bounded protocol-style agent error codes are logged", () => { assert.equal(safeAgentErrorCode("C:\\Users\\person\\private.exe"), "agent-request-failed"); assert.equal(safeAgentErrorCode("https://example.test/?token=secret"), "agent-request-failed"); }); + +void test("native diagnostics contain only command and numeric download ID", () => { + const safe = nativeDiagnosticContext(createRequest( + "download.cancelled", + { downloadId: "123", filePath: "C:\\Users\\person\\private.bin", token: "secret" }, + "request-1", + )); + const rejected = nativeDiagnosticContext(createRequest( + "download.changed", + { downloadId: "https://example.test/?token=secret" }, + "request-2", + )); + + assert.equal(safe, "command=download.cancelled downloadId=123"); + assert.equal(rejected, "command=download.changed downloadId=invalid"); + assert.doesNotMatch(safe, /private|secret|Users/u); +}); diff --git a/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs b/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs index 4c1c380..b211296 100644 --- a/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs +++ b/src/DownloadRouter.Infrastructure/Storage/DownloadRouterRepository.cs @@ -6,7 +6,7 @@ namespace DownloadRouter.Infrastructure.Storage; public sealed class DownloadRouterRepository(AppPaths paths) { - private const int CurrentSchemaVersion = 3; + private const int CurrentSchemaVersion = 4; public async Task InitializeAsync(CancellationToken cancellationToken = default) { @@ -66,6 +66,8 @@ CREATE TABLE IF NOT EXISTS DownloadJobs ( ErrorMessage TEXT NULL, CreatedAt TEXT NOT NULL, CompletedAt TEXT NULL, + LastBrowserEventAt TEXT NULL, + IsBrowserRecordStale INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(RuleId) REFERENCES Rules(Id), UNIQUE(Browser, BrowserDownloadId) ); @@ -210,12 +212,14 @@ INSERT INTO DownloadJobs( Id, Browser, BrowserDownloadId, OriginalFileName, CurrentFileName, InitiatingPageUrl, InitialUrl, FinalUrl, ReferrerUrl, SanitizedSource, RuleId, OriginalPath, FinalPath, SelectedRelativeFolder, Status, - BrowserState, RoutingState, ErrorCode, ErrorMessage, CreatedAt, CompletedAt) + BrowserState, RoutingState, ErrorCode, ErrorMessage, CreatedAt, CompletedAt, + LastBrowserEventAt, IsBrowserRecordStale) VALUES( $id, $browser, $browserDownloadId, $originalFileName, $currentFileName, $initiatingPageUrl, $initialUrl, $finalUrl, $referrerUrl, $sanitizedSource, $ruleId, $originalPath, $finalPath, $selectedRelativeFolder, $status, - $browserState, $routingState, $errorCode, $errorMessage, $createdAt, $completedAt); + $browserState, $routingState, $errorCode, $errorMessage, $createdAt, $completedAt, + $lastBrowserEventAt, $isBrowserRecordStale); """; AddJobParameters(command, job); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); @@ -294,7 +298,9 @@ UPDATE DownloadJobs SET RoutingState = $routingState, ErrorCode = $errorCode, ErrorMessage = $errorMessage, - CompletedAt = $completedAt + CompletedAt = $completedAt, + LastBrowserEventAt = $lastBrowserEventAt, + IsBrowserRecordStale = $isBrowserRecordStale WHERE Id = $id; """; AddJobParameters(command, job); @@ -356,8 +362,7 @@ public async Task> GetActiveBrowserDownload SELECT Id, BrowserDownloadId FROM DownloadJobs WHERE Browser = $browser - AND BrowserState = 'InProgress' - AND RoutingState NOT IN ('Completed', 'Skipped'); + AND BrowserState = 'InProgress'; """; command.Parameters.AddWithValue("$browser", browser.ToString()); var result = new List(); @@ -390,7 +395,8 @@ UPDATE DownloadJobs SELECT Id, Browser, BrowserDownloadId, OriginalFileName, CurrentFileName, InitiatingPageUrl, InitialUrl, FinalUrl, ReferrerUrl, SanitizedSource, RuleId, OriginalPath, FinalPath, SelectedRelativeFolder, Status, - BrowserState, RoutingState, ErrorCode, ErrorMessage, CreatedAt, CompletedAt + BrowserState, RoutingState, ErrorCode, ErrorMessage, CreatedAt, CompletedAt, + LastBrowserEventAt, IsBrowserRecordStale FROM DownloadJobs """; @@ -488,7 +494,38 @@ INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) cancellationToken, transaction).ConfigureAwait(false); - if (CurrentSchemaVersion != 3) + if (!columns.Contains("LastBrowserEventAt")) + { + await ExecuteAsync( + connection, + "ALTER TABLE DownloadJobs ADD COLUMN LastBrowserEventAt TEXT NULL;", + cancellationToken, + transaction).ConfigureAwait(false); + } + + if (!columns.Contains("IsBrowserRecordStale")) + { + await ExecuteAsync( + connection, + "ALTER TABLE DownloadJobs ADD COLUMN IsBrowserRecordStale INTEGER NOT NULL DEFAULT 0;", + cancellationToken, + transaction).ConfigureAwait(false); + } + + await ExecuteAsync( + connection, + """ + UPDATE DownloadJobs + SET LastBrowserEventAt = CreatedAt + WHERE LastBrowserEventAt IS NULL; + + INSERT OR IGNORE INTO MigrationHistory(Version, AppliedAt) + VALUES (4, CURRENT_TIMESTAMP); + """, + cancellationToken, + transaction).ConfigureAwait(false); + + if (CurrentSchemaVersion != 4) { throw new InvalidOperationException("Repository migration version is inconsistent."); } @@ -565,7 +602,9 @@ private static DownloadJob ReadJob(SqliteDataReader reader) GetNullableString(reader, 17), GetNullableString(reader, 18), Parse(reader.GetString(19)), - reader.IsDBNull(20) ? null : Parse(reader.GetString(20))); + reader.IsDBNull(20) ? null : Parse(reader.GetString(20)), + reader.IsDBNull(21) ? null : Parse(reader.GetString(21)), + reader.GetBoolean(22)); private static string? GetNullableString(SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); @@ -609,6 +648,10 @@ private static void AddJobParameters(SqliteCommand command, DownloadJob job) command.Parameters.AddWithValue("$errorMessage", Db(job.ErrorMessage)); command.Parameters.AddWithValue("$createdAt", Format(job.CreatedAt)); command.Parameters.AddWithValue("$completedAt", job.CompletedAt is null ? DBNull.Value : Format(job.CompletedAt.Value)); + command.Parameters.AddWithValue( + "$lastBrowserEventAt", + job.LastBrowserEventAt is null ? DBNull.Value : Format(job.LastBrowserEventAt.Value)); + command.Parameters.AddWithValue("$isBrowserRecordStale", job.IsBrowserRecordStale); } private static async Task AppendEventAsync( diff --git a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs index a71fb6c..ad321b4 100644 --- a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs +++ b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs @@ -3,6 +3,11 @@ using DownloadRouter.Core.Jobs; using DownloadRouter.Core.Models; using DownloadRouter.Core.Paths; +using DownloadRouter.Core.Settings; +using System.Reflection; +using System.Reflection.Emit; +using System.Diagnostics; +using System.Xml.Linq; namespace DownloadRouter.Core.Tests; @@ -98,6 +103,140 @@ public void SelectionPromptQueueCanRefreshTheCurrentJobWithoutBreakingFifo() Assert.Equal(next, queuedNext); } + [Fact] + public void SelectionPromptQueueCanBeHiddenAsOneSessionBatch() + { + var queue = new SelectionPromptQueue(); + var ids = Enumerable.Range(0, 23).Select(_ => Guid.NewGuid()).ToArray(); + foreach (var id in ids) + { + queue.Enqueue(id); + } + + Assert.Equal(ids, queue.Drain()); + Assert.Equal(0, queue.Count); + Assert.False(queue.TryDequeue(out _)); + } + + [Fact] + public void AutomaticSelectionPolicyKeepsOldAndStaleJobsInPendingWithoutPopup() + { + var now = DateTimeOffset.UtcNow; + var recent = CreateJob(BrowserTransferState.InProgress, RoutingState.WaitingForSelection) with + { + CreatedAt = now.AddMinutes(-5), + LastBrowserEventAt = now.AddMinutes(-1), + }; + var old = recent with + { + Id = Guid.NewGuid(), + CreatedAt = now.AddHours(-1), + LastBrowserEventAt = now.AddMinutes(-31), + }; + var stale = recent with { Id = Guid.NewGuid(), IsBrowserRecordStale = true }; + var cancelled = recent with { Id = Guid.NewGuid(), BrowserState = BrowserTransferState.Cancelled }; + + var pending = DownloadJobQueries.ActiveSelections([recent, old, stale, cancelled]); + var automatic = DownloadJobQueries.AutomaticSelections([old, stale, recent, cancelled], now); + + Assert.Equal(3, pending.Count); + Assert.Equal(recent.Id, Assert.Single(automatic).Id); + Assert.True(SelectionPromptPolicy.IsPreviousSessionPending(old, now)); + Assert.True(SelectionPromptPolicy.IsPreviousSessionPending(stale, now)); + } + + [Theory] + [InlineData(null, AppThemePreference.System, "Default")] + [InlineData("invalid-old-value", AppThemePreference.System, "Default")] + [InlineData("Light", AppThemePreference.Light, "Light")] + [InlineData("dark", AppThemePreference.Dark, "Dark")] + public void ThemeSettingsMapSafely(string? value, AppThemePreference expected, string elementTheme) + { + var preference = AppThemePolicy.Parse(value); + Assert.Equal(expected, preference); + Assert.Equal(elementTheme, AppThemePolicy.ToElementThemeName(preference)); + } + + [Fact] + public void PreferencesPersistAndInvalidThemeFallsBackWithoutLosingOtherSettings() + { + var directory = Path.Combine(Path.GetTempPath(), "download-router-preferences-tests", Guid.NewGuid().ToString("N")); + var path = Path.Combine(directory, "config.local.json"); + try + { + var store = new AppPreferencesStore(path); + store.Save(new AppPreferences(WindowCloseBehavior.ExitApplication, "Dark")); + var restored = store.Load(); + Assert.Equal(WindowCloseBehavior.ExitApplication, restored.CloseBehavior); + Assert.Equal(AppThemePreference.Dark, restored.ThemePreference); + + File.WriteAllText(path, "{\"closeBehavior\":1,\"theme\":\"retired-theme\"}"); + restored = store.Load(); + Assert.Equal(WindowCloseBehavior.ExitApplication, restored.CloseBehavior); + Assert.Equal(AppThemePreference.System, restored.ThemePreference); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + [Fact] + public void ProductVersionUsesAssemblyInformationalVersionAndSurvivesMissingCommit() + { + var current = ProductVersionInfo.Read(typeof(ProductVersionInfo).Assembly, AppContext.BaseDirectory); + var assemblyName = new AssemblyName("NoInformationalVersion") { Version = new Version(7, 2, 1, 0) }; + var dynamicAssembly = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run); + var fallback = ProductVersionInfo.Read(dynamicAssembly, AppContext.BaseDirectory); + + Assert.False(string.IsNullOrWhiteSpace(current.InformationalVersion)); + Assert.StartsWith(current.SemanticVersion, current.InformationalVersion, StringComparison.Ordinal); + Assert.Equal("7.2.1", fallback.SemanticVersion); + Assert.Null(fallback.Commit); + } + + [Fact] + public void AppAndInstallerDeriveTheirVersionFromDirectoryBuildProps() + { + var repositoryRoot = FindRepositoryRoot(); + var document = XDocument.Load(Path.Combine(repositoryRoot, "Directory.Build.props")); + var version = document.Descendants("VersionPrefix").Single().Value; + var appExecutable = Directory + .EnumerateFiles( + Path.Combine(repositoryRoot, "src", "DownloadRouter.App", "bin"), + "DownloadRouter.App.exe", + SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .First(); + var appProductVersion = FileVersionInfo.GetVersionInfo(appExecutable).ProductVersion; + var installerSource = File.ReadAllText(Path.Combine(repositoryRoot, "installer", "DownloadRouter.iss")); + var installerBuild = File.ReadAllText(Path.Combine(repositoryRoot, "scripts", "build-installer.ps1")); + + Assert.Matches("^\\d+\\.\\d+\\.\\d+$", version); + Assert.True( + string.Equals(appProductVersion, version, StringComparison.Ordinal) + || appProductVersion?.StartsWith(version + "+", StringComparison.Ordinal) == true); + Assert.Contains("#ifndef AppVersion", installerSource, StringComparison.Ordinal); + Assert.DoesNotContain("#define AppVersion \"", installerSource, StringComparison.Ordinal); + Assert.Contains("Directory.Build.props", installerBuild, StringComparison.Ordinal); + Assert.Contains("/DAppVersion=", installerBuild, StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null && !File.Exists(Path.Combine(current.FullName, "Directory.Build.props"))) + { + current = current.Parent; + } + + return current?.FullName + ?? throw new DirectoryNotFoundException("Repository root was not found from the test output path."); + } + [Fact] public void TemporaryDownloadNamesUseThePendingLabelUntilTrustedMetadataArrives() { diff --git a/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs b/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs index 06a5f3a..4e099f9 100644 --- a/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs +++ b/tests/DownloadRouter.Infrastructure.Tests/RepositoryTests.cs @@ -93,6 +93,10 @@ INSERT INTO DownloadJobs VALUES( Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); versionCommand.CommandText = "SELECT COUNT(*) FROM MigrationHistory WHERE Version = 3;"; Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); + versionCommand.CommandText = "SELECT COUNT(*) FROM MigrationHistory WHERE Version = 4;"; + Assert.Equal(1L, (long)(await versionCommand.ExecuteScalarAsync(CancellationToken.None))!); + Assert.NotNull(migrated.LastBrowserEventAt); + Assert.False(migrated.IsBrowserRecordStale); } [Fact] diff --git a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs index 603012e..4316bf0 100644 --- a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs +++ b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs @@ -209,13 +209,13 @@ public async Task UserCancellationIsTerminalPendingIsExcludedAndNoFileIsMoved() var response = await SendAsync( handler, - "download.changed", + "download.cancelled", new DownloadChangedPayload("Whale", "cancel-1", "cancelled", source, "USER_CANCELED")); Assert.True(response.Success, response.Message); var job = await repository.GetJobAsync(jobId, CancellationToken.None); Assert.Equal(BrowserTransferState.Cancelled, job!.BrowserState); - Assert.Equal(RoutingState.WaitingForSelection, job.RoutingState); + Assert.Equal(RoutingState.NotRequired, job.RoutingState); Assert.Equal("download.cancelled", job.ErrorCode); Assert.Equal("cancelled.txt", job.CurrentFileName); Assert.Empty(DownloadJobQueries.ActiveSelections(await repository.GetRecentJobsAsync(cancellationToken: CancellationToken.None))); @@ -250,6 +250,82 @@ public async Task BrowserInterruptionStoresOnlyARecognizedErrorCode() Assert.DoesNotContain("secret", job.ErrorMessage, StringComparison.OrdinalIgnoreCase); } + [Theory] + [InlineData("no-selection")] + [InlineData("selection-ready")] + [InlineData("later")] + [InlineData("skipped")] + public async Task ExplicitUserCancellationIsIdempotentForEverySelectionFlow(string scenario) + { + var (handler, repository) = await CreateHandlerAsync(); + var incoming = Directory.CreateDirectory(Path.Combine(root, "incoming")).FullName; + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + Directory.CreateDirectory(Path.Combine(destination, "selected")); + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var downloadId = "cancel-" + scenario; + var jobId = await StartAsync(handler, downloadId, scenario + ".bin"); + if (scenario == "selection-ready") + { + Assert.True((await SendAsync( + handler, + "selection.complete", + new SelectionCompletedPayload([jobId], "selected"))).Success); + } + else if (scenario == "skipped") + { + Assert.True((await SendAsync( + handler, + "selection.skip", + new SelectionSkippedPayload(jobId))).Success); + } + + var source = Path.Combine(incoming, scenario + ".bin"); + await File.WriteAllTextAsync(source, "partial", CancellationToken.None); + var payload = new DownloadChangedPayload("Whale", downloadId, "cancelled", source, "USER_CANCELED"); + var first = await SendAsync(handler, "download.cancelled", payload); + var duplicate = await SendAsync(handler, "download.cancelled", payload); + + Assert.True(first.Success, first.Message); + Assert.True(duplicate.Success, duplicate.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.Cancelled, job!.BrowserState); + Assert.Equal( + scenario == "skipped" ? RoutingState.Skipped : RoutingState.NotRequired, + job.RoutingState); + Assert.Equal("download.cancelled", job.ErrorCode); + Assert.Empty(DownloadJobQueries.ActiveSelections([job])); + Assert.True(File.Exists(source)); + Assert.Empty(Directory.EnumerateFiles(destination, "*", SearchOption.AllDirectories)); + } + + [Fact] + public async Task MissingBrowserRecordBecomesStaleWithoutBeingGuessedCancelled() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "stale-1", "stale.bin"); + + Assert.True((await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "stale-1", "stale", null, null))).Success); + var stale = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(BrowserTransferState.InProgress, stale!.BrowserState); + Assert.Equal(RoutingState.WaitingForSelection, stale.RoutingState); + Assert.True(stale.IsBrowserRecordStale); + Assert.Single(DownloadJobQueries.ActiveSelections([stale])); + Assert.Empty(DownloadJobQueries.AutomaticSelections([stale], DateTimeOffset.UtcNow)); + + Assert.True((await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload("Whale", "stale-1", "in_progress", null, null))).Success); + var restored = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.False(restored!.IsBrowserRecordStale); + Assert.Single(DownloadJobQueries.AutomaticSelections([restored], DateTimeOffset.UtcNow)); + } + [Fact] public async Task DeletingHistoryDoesNotDeleteTheDownloadedFile() { @@ -419,7 +495,7 @@ public async Task CancelledJobCannotChangeRouteRegardlessOfPreviousSelection() Assert.Equal("route.change-not-allowed", response.ErrorCode); var cancelled = await repository.GetJobAsync(jobId, CancellationToken.None); Assert.Equal(BrowserTransferState.Cancelled, cancelled!.BrowserState); - Assert.Equal(RoutingState.SelectionReady, cancelled.RoutingState); + Assert.Equal(RoutingState.NotRequired, cancelled.RoutingState); } [Fact] From 49c87eb9a17a63ba9838aa87a3a666c4260e288f Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:41:30 +0900 Subject: [PATCH 17/24] Preserve existing settings when saving theme --- .../Settings/AppPreferencesStore.cs | 17 ++++++++++++++++- .../ProtocolAndStateTests.cs | 5 +++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs b/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs index 4844906..be7433a 100644 --- a/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs +++ b/src/DownloadRouter.Core/Settings/AppPreferencesStore.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using DownloadRouter.Core.Models; namespace DownloadRouter.Core.Settings; @@ -70,8 +71,22 @@ public void Save(AppPreferences preferences) { Theme = AppThemePolicy.ToStorageValue(preferences.ThemePreference), }; + JsonObject document; + try + { + document = File.Exists(configPath) + ? JsonNode.Parse(File.ReadAllText(configPath)) as JsonObject ?? [] + : []; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + document = []; + } + + document["closeBehavior"] = (int)normalized.CloseBehavior; + document["theme"] = normalized.Theme; var temporary = configPath + ".tmp"; - File.WriteAllText(temporary, JsonSerializer.Serialize(normalized, ProtocolJson.Options)); + File.WriteAllText(temporary, document.ToJsonString(ProtocolJson.Options)); File.Move(temporary, configPath, overwrite: true); } } diff --git a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs index ad321b4..fc28020 100644 --- a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs +++ b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs @@ -164,11 +164,16 @@ public void PreferencesPersistAndInvalidThemeFallsBackWithoutLosingOtherSettings var path = Path.Combine(directory, "config.local.json"); try { + Directory.CreateDirectory(directory); + File.WriteAllText(path, "{\"dataDirectory\":\"preserve-me\",\"fileStability\":{\"maxWaitSeconds\":30}}"); var store = new AppPreferencesStore(path); store.Save(new AppPreferences(WindowCloseBehavior.ExitApplication, "Dark")); var restored = store.Load(); Assert.Equal(WindowCloseBehavior.ExitApplication, restored.CloseBehavior); Assert.Equal(AppThemePreference.Dark, restored.ThemePreference); + var savedJson = File.ReadAllText(path); + Assert.Contains("preserve-me", savedJson, StringComparison.Ordinal); + Assert.Contains("maxWaitSeconds", savedJson, StringComparison.Ordinal); File.WriteAllText(path, "{\"closeBehavior\":1,\"theme\":\"retired-theme\"}"); restored = store.Load(); From 59adffc3d3dff8ef35a338cce3e50e1e12a4e3ef Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:46:06 +0900 Subject: [PATCH 18/24] Avoid duplicate source revision in product version --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 2c84f00..aceb37d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,8 +9,8 @@ $(VersionPrefix) $(VersionPrefix).0 $(VersionPrefix).0 - $(VersionPrefix) - $(VersionPrefix)+$(SourceRevisionId) + $(VersionPrefix) + true true true portable From a60ea32de76254f46a393dc24367058b347aa902 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:52:29 +0900 Subject: [PATCH 19/24] Apply theme-aware window backgrounds --- src/DownloadRouter.App/App.xaml | 11 +++++++++ src/DownloadRouter.App/MainWindow.xaml | 2 +- src/DownloadRouter.App/ThemeManager.cs | 31 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/DownloadRouter.App/App.xaml b/src/DownloadRouter.App/App.xaml index fd7e321..8233cd2 100644 --- a/src/DownloadRouter.App/App.xaml +++ b/src/DownloadRouter.App/App.xaml @@ -4,6 +4,17 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + + + + + + + + + + diff --git a/src/DownloadRouter.App/MainWindow.xaml b/src/DownloadRouter.App/MainWindow.xaml index ebb03bc..5b900db 100644 --- a/src/DownloadRouter.App/MainWindow.xaml +++ b/src/DownloadRouter.App/MainWindow.xaml @@ -2,7 +2,7 @@ x:Class="DownloadRouter.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - + diff --git a/src/DownloadRouter.App/ThemeManager.cs b/src/DownloadRouter.App/ThemeManager.cs index e39138e..512443f 100644 --- a/src/DownloadRouter.App/ThemeManager.cs +++ b/src/DownloadRouter.App/ThemeManager.cs @@ -1,11 +1,14 @@ using DownloadRouter.Core.Models; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; namespace DownloadRouter.App; public sealed class ThemeManager(AppThemePreference initialPreference) { private readonly HashSet windows = []; + private readonly Dictionary roots = []; public AppThemePreference CurrentPreference { get; private set; } = initialPreference; @@ -23,6 +26,11 @@ public void RegisterWindow(Window window) if (windows.Add(window)) { window.Closed += Window_Closed; + if (window.Content is FrameworkElement registeredRoot) + { + roots[window] = registeredRoot; + registeredRoot.ActualThemeChanged += Root_ActualThemeChanged; + } } Apply(window); @@ -42,14 +50,37 @@ private void Apply(Window window) if (window.Content is FrameworkElement root) { root.RequestedTheme = ToElementTheme(CurrentPreference); + ApplyBackground(root); } } + private static void ApplyBackground(FrameworkElement root) + { + if (root is not Panel panel) + { + return; + } + + var themeKey = root.ActualTheme == ElementTheme.Dark ? "Dark" : "Light"; + if (Application.Current.Resources.ThemeDictionaries[themeKey] is ResourceDictionary dictionary + && dictionary["WindowBackgroundBrush"] is Brush brush) + { + panel.Background = brush; + } + } + + private static void Root_ActualThemeChanged(FrameworkElement sender, object args) + => ApplyBackground(sender); + private void Window_Closed(object sender, WindowEventArgs args) { if (sender is Window window) { windows.Remove(window); + if (roots.Remove(window, out var root)) + { + root.ActualThemeChanged -= Root_ActualThemeChanged; + } } } } From f4a2fee2035856ec3e29b477ce0bf4260ef0723f Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:57:07 +0900 Subject: [PATCH 20/24] Use theme-aware card surfaces --- src/DownloadRouter.App/App.xaml | 9 +++++++++ src/DownloadRouter.App/MainWindow.History.cs | 10 +++++----- src/DownloadRouter.App/MainWindow.Pending.cs | 2 +- src/DownloadRouter.App/MainWindow.Rules.cs | 4 ++-- src/DownloadRouter.App/MainWindow.xaml.cs | 2 +- src/DownloadRouter.App/ThemeManager.cs | 10 ++++++++++ 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/DownloadRouter.App/App.xaml b/src/DownloadRouter.App/App.xaml index 8233cd2..ca04195 100644 --- a/src/DownloadRouter.App/App.xaml +++ b/src/DownloadRouter.App/App.xaml @@ -7,12 +7,21 @@ + + + + + + + + + diff --git a/src/DownloadRouter.App/MainWindow.History.cs b/src/DownloadRouter.App/MainWindow.History.cs index 0ff4838..5f65b74 100644 --- a/src/DownloadRouter.App/MainWindow.History.cs +++ b/src/DownloadRouter.App/MainWindow.History.cs @@ -219,7 +219,7 @@ private Border CreateHistoryCard(DownloadJob job) HorizontalAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(16), CornerRadius = new CornerRadius(8), - Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), Opacity = job.BrowserState == BrowserTransferState.Cancelled ? 0.55 : 1, Child = panel, }; @@ -268,16 +268,16 @@ private async Task ChangeHistoryRouteAsync(DownloadJob job, DownloadRule rule) await ShowHistoryAsync(); } - private static Border CreateStatusBadge(DownloadJob job) + private Border CreateStatusBadge(DownloadJob job) => new() { HorizontalAlignment = HorizontalAlignment.Left, Padding = new Thickness(8, 4, 8, 4), CornerRadius = new CornerRadius(12), - Background = Application.Current.Resources[ + Background = themeManager.GetThemeBrush( job.BrowserState == BrowserTransferState.Interrupted || job.RoutingState == RoutingState.Failed - ? "SystemFillColorCautionBackgroundBrush" - : "SubtleFillColorSecondaryBrush"] as Brush, + ? "WarningStatusSurfaceBrush" + : "StatusSurfaceBrush"), Child = new TextBlock { Text = DescribeStatus(job), diff --git a/src/DownloadRouter.App/MainWindow.Pending.cs b/src/DownloadRouter.App/MainWindow.Pending.cs index 677c826..8fc791c 100644 --- a/src/DownloadRouter.App/MainWindow.Pending.cs +++ b/src/DownloadRouter.App/MainWindow.Pending.cs @@ -181,7 +181,7 @@ private Border CreatePendingCard( HorizontalAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(16), CornerRadius = new CornerRadius(8), - Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), Child = panel, }; } diff --git a/src/DownloadRouter.App/MainWindow.Rules.cs b/src/DownloadRouter.App/MainWindow.Rules.cs index a068df4..d13e67c 100644 --- a/src/DownloadRouter.App/MainWindow.Rules.cs +++ b/src/DownloadRouter.App/MainWindow.Rules.cs @@ -184,7 +184,7 @@ private void AddRuleEditor(DownloadRule? editing) HorizontalAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(20), CornerRadius = new CornerRadius(10), - Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), Child = panel, }); } @@ -255,7 +255,7 @@ private Border CreateRuleCard(DownloadRule rule) HorizontalAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(16), CornerRadius = new CornerRadius(8), - Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), Child = panel, }; } diff --git a/src/DownloadRouter.App/MainWindow.xaml.cs b/src/DownloadRouter.App/MainWindow.xaml.cs index 0a5ae88..6048d94 100644 --- a/src/DownloadRouter.App/MainWindow.xaml.cs +++ b/src/DownloadRouter.App/MainWindow.xaml.cs @@ -215,7 +215,7 @@ private void AddCard(string title, string value) HorizontalAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(16), CornerRadius = new CornerRadius(8), - Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Microsoft.UI.Xaml.Media.Brush, + Background = themeManager.GetThemeBrush("CardSurfaceBrush"), Child = panel, }); } diff --git a/src/DownloadRouter.App/ThemeManager.cs b/src/DownloadRouter.App/ThemeManager.cs index 512443f..502c851 100644 --- a/src/DownloadRouter.App/ThemeManager.cs +++ b/src/DownloadRouter.App/ThemeManager.cs @@ -45,6 +45,16 @@ public void SetPreference(AppThemePreference preference) } } + public Brush? GetThemeBrush(string resourceKey) + { + var activeTheme = roots.Values.FirstOrDefault()?.ActualTheme == ElementTheme.Dark + ? "Dark" + : "Light"; + return Application.Current.Resources.ThemeDictionaries[activeTheme] is ResourceDictionary dictionary + ? dictionary[resourceKey] as Brush + : null; + } + private void Apply(Window window) { if (window.Content is FrameworkElement root) From 2e2f5282b3e54e56100c025babda5cb2083ff703 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:01:23 +0900 Subject: [PATCH 21/24] Match window chrome to the selected theme --- src/DownloadRouter.App/ThemeManager.cs | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/DownloadRouter.App/ThemeManager.cs b/src/DownloadRouter.App/ThemeManager.cs index 502c851..f63a7ef 100644 --- a/src/DownloadRouter.App/ThemeManager.cs +++ b/src/DownloadRouter.App/ThemeManager.cs @@ -1,7 +1,9 @@ using DownloadRouter.Core.Models; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using Windows.UI; namespace DownloadRouter.App; @@ -61,6 +63,7 @@ private void Apply(Window window) { root.RequestedTheme = ToElementTheme(CurrentPreference); ApplyBackground(root); + ApplyTitleBar(window, root.ActualTheme); } } @@ -79,8 +82,31 @@ private static void ApplyBackground(FrameworkElement root) } } - private static void Root_ActualThemeChanged(FrameworkElement sender, object args) - => ApplyBackground(sender); + private void Root_ActualThemeChanged(FrameworkElement sender, object args) + { + ApplyBackground(sender); + var window = roots.FirstOrDefault(pair => ReferenceEquals(pair.Value, sender)).Key; + if (window is not null) + { + ApplyTitleBar(window, sender.ActualTheme); + } + } + + private static void ApplyTitleBar(Window window, ElementTheme theme) + { + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow( + WinRT.Interop.WindowNative.GetWindowHandle(window)); + var titleBar = AppWindow.GetFromWindowId(windowId).TitleBar; + var dark = theme == ElementTheme.Dark; + titleBar.BackgroundColor = dark ? Color.FromArgb(255, 32, 32, 32) : Color.FromArgb(255, 243, 243, 243); + titleBar.ForegroundColor = dark ? Color.FromArgb(255, 255, 255, 255) : Color.FromArgb(255, 0, 0, 0); + titleBar.ButtonBackgroundColor = titleBar.BackgroundColor; + titleBar.ButtonForegroundColor = titleBar.ForegroundColor; + titleBar.ButtonHoverBackgroundColor = dark ? Color.FromArgb(255, 56, 56, 56) : Color.FromArgb(255, 229, 229, 229); + titleBar.ButtonHoverForegroundColor = titleBar.ForegroundColor; + titleBar.ButtonPressedBackgroundColor = dark ? Color.FromArgb(255, 74, 74, 74) : Color.FromArgb(255, 210, 210, 210); + titleBar.ButtonPressedForegroundColor = titleBar.ForegroundColor; + } private void Window_Closed(object sender, WindowEventArgs args) { From 40e7644577abacfdf0937d81799266301f227a06 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:15:49 +0900 Subject: [PATCH 22/24] Keep startup reconciliation from refreshing prompt age --- .../AgentCommandHandler.cs | 12 ++++++--- .../Models/DomainModels.cs | 3 ++- .../src/background.ts | 7 ++++- .../CommandValidationTests.cs | 27 +++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/DownloadRouter.Agent/AgentCommandHandler.cs b/src/DownloadRouter.Agent/AgentCommandHandler.cs index b1e33ba..082dcc2 100644 --- a/src/DownloadRouter.Agent/AgentCommandHandler.cs +++ b/src/DownloadRouter.Agent/AgentCommandHandler.cs @@ -259,7 +259,9 @@ private async Task HandleDownloadChangedAsync( { job = job with { - LastBrowserEventAt = DateTimeOffset.UtcNow, + LastBrowserEventAt = payload.IsReconciliation + ? job.LastBrowserEventAt + : DateTimeOffset.UtcNow, IsBrowserRecordStale = false, }; await repository.UpdateJobAsync(job, "download.reconciled-in-progress", cancellationToken).ConfigureAwait(false); @@ -307,7 +309,9 @@ private async Task HandleDownloadChangedAsync( ? "The user cancelled this download in the browser." : "The browser interrupted this download before completion.", CompletedAt = DateTimeOffset.UtcNow, - LastBrowserEventAt = DateTimeOffset.UtcNow, + LastBrowserEventAt = payload.IsReconciliation + ? job.LastBrowserEventAt + : DateTimeOffset.UtcNow, IsBrowserRecordStale = false, }; await repository.UpdateJobAsync(job, cancelled ? "download.cancelled" : "download.interrupted", cancellationToken).ConfigureAwait(false); @@ -339,7 +343,9 @@ private async Task HandleDownloadChangedAsync( BrowserState = BrowserTransferState.Complete, ErrorCode = null, ErrorMessage = null, - LastBrowserEventAt = DateTimeOffset.UtcNow, + LastBrowserEventAt = payload.IsReconciliation + ? job.LastBrowserEventAt + : DateTimeOffset.UtcNow, IsBrowserRecordStale = false, }; diff --git a/src/DownloadRouter.Core/Models/DomainModels.cs b/src/DownloadRouter.Core/Models/DomainModels.cs index 000b5b8..62c9192 100644 --- a/src/DownloadRouter.Core/Models/DomainModels.cs +++ b/src/DownloadRouter.Core/Models/DomainModels.cs @@ -202,7 +202,8 @@ public sealed record DownloadChangedPayload( string State, string? FilePath, string? Error, - string? FileName = null); + string? FileName = null, + bool IsReconciliation = false); public sealed record DownloadMetadataChangedPayload( string Browser, diff --git a/src/DownloadRouter.Extension/src/background.ts b/src/DownloadRouter.Extension/src/background.ts index 4ede836..53cf1a4 100644 --- a/src/DownloadRouter.Extension/src/background.ts +++ b/src/DownloadRouter.Extension/src/background.ts @@ -94,6 +94,7 @@ function reportTerminal( command: AgentCommandName, state: "complete" | "interrupted" | "cancelled", error: string | null, + isReconciliation = false, ): void { if (!markTerminalReported(item.id, state)) { return; @@ -107,6 +108,7 @@ function reportTerminal( filePath: item.filename || null, fileName: trustedDownloadFileName(item.filename), error, + isReconciliation, }), ); } @@ -144,7 +146,7 @@ function reportDownloadMetadata(item: chrome.downloads.DownloadItem): void { function reportReconciledState(item: chrome.downloads.DownloadItem): void { if (item.state === "complete") { - reportTerminal(item, "download.changed", "complete", null); + reportTerminal(item, "download.changed", "complete", null, true); return; } @@ -155,6 +157,7 @@ function reportReconciledState(item: chrome.downloads.DownloadItem): void { isUserCancelled(error) ? "download.cancelled" : "download.interrupted", isUserCancelled(error) ? "cancelled" : "interrupted", error, + true, ); return; } @@ -166,6 +169,7 @@ function reportReconciledState(item: chrome.downloads.DownloadItem): void { filePath: null, fileName: null, error: null, + isReconciliation: true, })); } @@ -177,6 +181,7 @@ function reportStale(downloadId: number): void { filePath: null, fileName: null, error: null, + isReconciliation: true, })); } diff --git a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs index 4316bf0..d6adc11 100644 --- a/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs +++ b/tests/DownloadRouter.IntegrationTests/CommandValidationTests.cs @@ -326,6 +326,33 @@ public async Task MissingBrowserRecordBecomesStaleWithoutBeingGuessedCancelled() Assert.Single(DownloadJobQueries.AutomaticSelections([restored], DateTimeOffset.UtcNow)); } + [Fact] + public async Task StartupReconciliationDoesNotMakeAnOldPendingJobAutoPromptEligibleAgain() + { + var (handler, repository) = await CreateHandlerAsync(); + var destination = Directory.CreateDirectory(Path.Combine(root, "routed")).FullName; + await CreateRuleAsync(repository, destination, StorageMode.SelectSubfolder); + var jobId = await StartAsync(handler, "old-reconciled", "old.bin"); + var oldTime = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromMinutes(31)); + var original = await repository.GetJobAsync(jobId, CancellationToken.None); + await repository.UpdateJobAsync( + original! with { CreatedAt = oldTime, LastBrowserEventAt = oldTime }, + "test-aged-pending", + CancellationToken.None); + + var reconciled = await SendAsync( + handler, + "download.changed", + new DownloadChangedPayload( + "Whale", "old-reconciled", "in_progress", null, null, IsReconciliation: true)); + + Assert.True(reconciled.Success, reconciled.Message); + var job = await repository.GetJobAsync(jobId, CancellationToken.None); + Assert.Equal(oldTime, job!.LastBrowserEventAt); + Assert.Single(DownloadJobQueries.ActiveSelections([job])); + Assert.Empty(DownloadJobQueries.AutomaticSelections([job], DateTimeOffset.UtcNow)); + } + [Fact] public async Task DeletingHistoryDoesNotDeleteTheDownloadedFile() { From 55bce83c56a34bd018a31a3e51b3b38d39c10cbf Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:26:27 +0900 Subject: [PATCH 23/24] Document cancellation and reconciliation verification --- CHANGELOG.md | 5 ++++- PROJECT_STATE.md | 8 ++++---- README.md | 2 +- TESTING.md | 10 ++++++---- docs/BROWSER_COMPATIBILITY.md | 10 ++++++++++ 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2931a12..733c514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ - 경로 토큰, 루트 경계와 reparse point 방어 - 동일/교차 볼륨 안전 이동, 안정화 확인, SHA-256 검증, 중복 이름 보존 - WinUI 대시보드, 규칙, 파일별 선택 대기, 상태 필터·삭제 이력, 브라우저, 진단 화면 -- 67개 .NET 테스트, 13개 TypeScript 테스트, Agent/Native Host 스모크 테스트 +- 68개 .NET 테스트, 13개 TypeScript 테스트, Agent/Native Host 스모크 테스트 - per-user Native Host 등록 및 Inno Setup 설치 골격 - Native Host 등록의 Windows PowerShell 5.1 회귀 테스트와 개인정보 로그 회귀 테스트 - 매칭 규칙의 작업 생성·실제 파일 이동을 검증하는 Agent 통합 테스트 @@ -38,6 +38,7 @@ - 취소 상태는 선택 여부와 관계없이 `Cancelled`가 되고 Waiting/SelectionReady 라우팅은 `NotRequired`로 종료 - UI 상태 갱신 주기를 500ms로 줄여 취소 후 팝업·Pending·이력을 1초 이내 반영 - 브라우저 기록에서 찾지 못한 진행 작업은 취소로 추측하지 않고 stale 진단 상태로 보존하며 자동 팝업에서 제외 +- service worker 시작 재조정은 Job 상태만 맞추고 생성/마지막 실시간 브라우저 이벤트 기반의 30분 팝업 연령은 유지 - WinUI 앱을 Per-Monitor V2로 선언하고 공통 콘텐츠를 세로 ScrollViewer, stretch viewport, 1100 DIP 반응형 폼으로 재구성 - 다운로드 이력이 규칙에 매칭된 작업만 표시함을 명시하고 데이터 변경 시 3초 간격으로 자동 갱신 @@ -50,9 +51,11 @@ ### Fixed - 앱 시작 때 오래된 `WaitingForSelection` 전체를 FIFO에 재삽입해 20개 이상 팝업이 연속 표시되던 문제 +- service worker 시작 검색 결과가 오래된 Job의 마지막 브라우저 이벤트 시각을 현재로 덮어 다시 자동 팝업 대상으로 만들던 문제 - Whale가 `state` delta 없이 `error.current=USER_CANCELED`만 보낼 때 Extension이 조기 반환해 취소가 누락되던 문제 - `downloads.onErased`와 실제 사용자 취소를 혼동할 수 있는 시작 재조정 공백 - 일반 설정의 테마 ComboBox가 저장·적용 로직에 연결되지 않아 화면이 바뀌지 않던 문제 +- 다크 테마에서 코드로 만든 카드와 기본 창 제목 표시줄이 라이트 리소스를 사용하던 대비 문제 - 정보 화면에 실행 버전이 없고 설치 프로그램 버전만 별도 하드코딩되어 구성 요소가 불일치할 수 있던 문제 - DPI awareness 누락으로 QHD 125%에서 앱 전체가 96 DPI 비트맵으로 확대되던 흐릿한 렌더링 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 2e81924..b0b4fa3 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -28,7 +28,7 @@ - 생성/마지막 브라우저 이벤트 기준 30분 자동 팝업 정책, 이전 세션 대기 표시, 현재 큐 `모두 나중에 선택` - 고정 DIP 전용 선택 창, lazy 계층형 FolderTreePicker, 단일 FIFO, 숨김/최소화 상태 직접 표시, 취소 시 자동 닫기 - `onCreated`/`filename` delta/완료 검색을 통한 같은 Job 파일명 갱신과 임시 이름 차단 -- `USER_CANCELED` 즉시 전송, interrupted 오류 우선순위, onErased 비취소 처리와 service worker 시작 재조정 +- `USER_CANCELED` 즉시 전송, interrupted 오류 우선순위, onErased 비취소 처리와 팝업 연령을 갱신하지 않는 service worker 시작 재조정 - 이력의 Job별 저장 위치 선택·변경, 완료 파일 명시적 재이동, 이동 안 함/나중에 선택 - 취소선·상태 필터·개별/선택/취소 이력 삭제 UI와 실제 파일 비삭제 보장 - 현재 필터 전체 선택/해제와 선택 수 표시 @@ -49,7 +49,7 @@ | 항목 | 결과 | |---|---| | `.NET Debug build` | 성공, 경고 0, 오류 0 | -| `.NET tests` | 67/67 통과(Core 41, Infrastructure 7, Integration 19) | +| `.NET tests` | 68/68 통과(Core 41, Infrastructure 7, Integration 20) | | Extension ESLint/TypeScript build | 성공 | | Extension Node tests | 13/13 통과 | | Agent Named Pipe ping | 성공 | @@ -72,14 +72,14 @@ | Vivaldi | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Opera | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -Whale 4.38.386.14에서 로컬 HTTP fixture로 Automatic과 SelectSubfolder를 실제 검증했습니다. Automatic은 C: 기본 다운로드 위치에서 D: 테스트 규칙 루트로 교차 볼륨 이동했고, SelectSubfolder는 테스트 루트에서 `kr → 모야지`만 확장·선택해 이동했습니다. 100자 이상 노드를 표시하고 복귀해도 선택 창 폭은 700px(125%의 560 DIP)로 동일했습니다. 메인 창 숨김/최소화 중에도 독립 선택 창이 직접 나타났습니다. 기존 A/B/C/D FIFO와 브라우저 취소 검증도 유지됩니다. +Whale 4.38.386.14에서 로컬 HTTP fixture로 Automatic과 SelectSubfolder를 실제 검증했습니다. Automatic은 C: 기본 다운로드 위치에서 D: 테스트 규칙 루트로 교차 볼륨 이동했고, SelectSubfolder는 테스트 루트에서 `kr → 모야지`만 확장·선택해 이동했습니다. 100자 이상 노드를 표시하고 복귀해도 선택 창 폭은 700px(125%의 560 DIP)로 동일했습니다. 메인 창 숨김/최소화 중에도 독립 선택 창이 직접 나타났습니다. 2026-07-23에는 아무 선택 없음/선택 완료/나중에 선택/이동 안 함 네 실제 Whale 취소가 모두 약 0.24초 안에 `Cancelled`로 반영되고 팝업·Pending·이동 0건과 이력 취소선을 확인했습니다. ## 알려진 문제와 제한 - downloads API만으로 다운로드 시작 탭 URL을 신뢰성 있게 얻을 수 없어 활성 탭을 추측하지 않습니다. 현재 referrer와 파일 URL을 분리해 사용합니다. - App 실행 파일을 찾을 수 있으면 Agent가 선택 UI를 시작하고, 실행 중이면 1초 폴링과 독립 창으로 FIFO를 표시합니다. 설치 손상으로 App을 찾지 못해도 다운로드는 원래 위치에서 완료되고 Pending에 남습니다. - “나중에 선택”과 “모두 나중에 선택” 팝업 억제는 현재 App 세션 동안만 유지됩니다. 30분이 지난 작업은 대기 탭에서 수동 처리하며 트리의 새 폴더 생성은 미구현입니다. -- Extension 시작 시 Agent의 진행 중 ID를 브라우저 다운로드 기록과 대조해 완료·취소·중단을 재전송합니다. 브라우저 기록에서 사라진 오래된 작업은 근거 없이 완료·삭제하지 않습니다. +- Extension 시작 시 Agent의 진행 중 ID를 브라우저 다운로드 기록과 대조해 완료·취소·중단을 재전송합니다. 이 재조정은 실제 브라우저 이벤트 시각을 갱신하지 않으므로 오래된 Pending을 자동 팝업 대상으로 되살리지 않으며, 브라우저 기록에서 사라진 작업도 근거 없이 완료·삭제하지 않습니다. - Whale registry adapter는 이 PC에서 검증했습니다. Brave/Vivaldi/Opera adapter는 실제 PC 검증이 필요합니다. - 브라우저 자체 “다운로드 전에 저장 위치 확인” 설정 감지와 안내는 문서만 있고 UI 자동 감지는 미구현입니다. - QHD 125% 실제 실행과 좁은/넓은 창 시각·경계 검증은 완료했습니다. Windows 100%/150%, FHD/4K 실기기와 키보드/스크린리더 접근성 검증은 남아 있습니다. diff --git a/README.md b/README.md index 6616676..e185fbe 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ PowerShell 실행 정책이 로컬 스크립트를 막는 경우 예시처럼 `p `Directory.Build.props`의 `VersionPrefix`가 App, Agent, Native Host, 설치 프로그램의 단일 버전 원본입니다. 정보 화면은 실행 assembly의 informational version과 짧은 commit, 설치형/개발 빌드 구분을 표시합니다. 일반 설정의 시스템/라이트/다크 테마는 `%LOCALAPPDATA%\eslee\DownloadRouter\config.local.json`에 저장되어 열린 창, 새 선택 창, 트레이 복원과 백그라운드 시작에 동일하게 적용됩니다. -SelectSubfolder 자동 팝업은 생성 또는 마지막 브라우저 이벤트가 30분 이내인 작업만 대상으로 합니다. 오래된 작업과 브라우저 기록을 찾을 수 없는 작업은 삭제하지 않고 대기 탭과 배지에 유지합니다. 선택 창의 `모두 나중에 선택`은 현재 App 세션의 기존 큐만 숨기며 이후 새 다운로드는 정상 표시합니다. +SelectSubfolder 자동 팝업은 생성 또는 마지막 실시간 브라우저 이벤트가 30분 이내인 작업만 대상으로 합니다. Extension 시작 시 검색 기반 재조정은 이 연령을 갱신하지 않습니다. 오래된 작업과 브라우저 기록을 찾을 수 없는 작업은 삭제하지 않고 대기 탭과 배지에 유지합니다. 선택 창의 `모두 나중에 선택`은 현재 App 세션의 기존 큐만 숨기며 이후 새 다운로드는 정상 표시합니다. 설계와 위협 모델은 [ARCHITECTURE.md](ARCHITECTURE.md), [SECURITY.md](SECURITY.md)를 참고하세요. diff --git a/TESTING.md b/TESTING.md index 111b1bb..7a735c9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,10 +16,10 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\diagnose.ps1 2026-07-23 현재 로컬 결과: - .NET Debug build: 성공, 경고 0, 오류 0 -- .NET tests: 67/67 통과 +- .NET tests: 68/68 통과 - Core 41: 규칙, 경로, 개인정보, 상태 전이, 30분 팝업 정책/23개 큐 일괄 숨김, 테마 저장/fallback, 제품 버전, 임시 파일명, lazy 폴더 트리, 자동 시작 등록/해제 - Infrastructure 7: v1 → v2 → v3 → v4 SQLite 마이그레이션, soft-delete 규칙, round-trip, 동일·교차 볼륨, 중복 이름, 로그 개인정보 제거 - - Integration 19: 명령 거부, 미매칭 fail-open, 완료 전/후 선택, 네 선택 흐름의 idempotent 취소, stale/in_progress 재조정, 중단, 규칙 편집·삭제, skip 완료 시 원본 보존, 실제 파일 비삭제 이력 삭제 + - Integration 20: 명령 거부, 미매칭 fail-open, 완료 전/후 선택, 네 선택 흐름의 idempotent 취소, stale/in_progress 재조정, 시작 재조정의 30분 연령 보존, 중단, 규칙 편집·삭제, skip 완료 시 원본 보존, 실제 파일 비삭제 이력 삭제 - Extension: ESLint 성공, TypeScript compile 성공, Node tests 13/13 통과, dist build 성공 - Windows PowerShell 5.1 등록 테스트: drive/UNC 절대 경로, 상대 경로 거부, origin 필터, BOM 없는 UTF-8 통과 - Agent Named Pipe ping: 성공 @@ -43,7 +43,7 @@ QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습 ### 테마와 정보 화면 - System/Light/Dark 문자열 매핑, 잘못된 값의 System fallback, `config.local.json` 저장/재로드 자동 테스트 -- MainWindow와 FolderSelectionWindow를 같은 ThemeManager에 등록해 설정 변경 시 열린 Window 전체, 생성 시 새 Window에 `RequestedTheme` 적용 +- MainWindow와 FolderSelectionWindow를 같은 ThemeManager에 등록해 설정 변경 시 열린 Window 전체, 생성 시 새 Window에 `RequestedTheme` 적용. 코드 생성 카드·상태 배지와 AppWindow 제목 표시줄도 현재 테마 리소스로 동기화 - 정보 화면은 App assembly informational version을 읽으며 commit이 없어도 SemVer fallback - `Directory.Build.props` VersionPrefix와 App ProductVersion, Inno Setup 전달 경로 자동 비교 - QHD 125% 설치본에서 시스템→다크, 선택 창 다크, X 트레이 복원, 완전 재실행, 라이트, 시스템 복귀를 순서대로 확인 @@ -130,10 +130,12 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\manual-download-se 2026-07-22 실제 Whale 4.38.386.14 결과: 위 4~8번과 설치본 Automatic/Tree 흐름 성공. 이번 테스트가 만든 파일은 휴지통으로 이동해 복구 가능하며 테스트 DB 이력과 규칙만 정확한 ID로 정리했습니다. +2026-07-23 실제 Whale 4.38.386.14 결과: A(선택 없음) 237ms, C(나중에 선택) 236ms, D(이동 안 함) 227ms에 `Cancelled`가 반영됐고 B(선택 완료)도 Agent 진단에서 즉시 `SelectionReady → NotRequired`를 확인했습니다. 네 경우 모두 팝업 0, Pending 원복, 이동 0이며 다크 테마 이력에서 파일명·보조 설명 취소선과 낮은 opacity를 확인했습니다. service worker 재로드 중 시작 재조정이 오래된 Job의 팝업 연령을 갱신하는 추가 원인을 재현해 프로토콜의 `isReconciliation`으로 분리했고, 31분 fixture가 재조정 뒤에도 자동 팝업 제외되는 통합 테스트를 추가했습니다. + ## 브라우저 검증 상태 - Whale 4.38.386.14: 확장 `dist` 로드와 고정 ID, 실제 다운로드 이벤트, Native Messaging 왕복, 미매칭 fail-open 성공 -- Whale SelectSubfolder: 완료 전/후 선택, A/B 개별 폴더, 취소, 4개 FIFO, 이력 삭제 성공 +- Whale SelectSubfolder: 완료 전/후 선택, A/B 개별 폴더, 네 선택 상태의 취소, 4개 FIFO, 이력 취소선·삭제 성공 - Whale Automatic 규칙: 실제 C: → D: 교차 볼륨 이동 성공 - Edge/Chrome/Brave/Vivaldi/Opera 확장과 Native Messaging: 미검증 - UI: QHD 125% 시각·경계·독립 팝업 검증 완료, 100%/150%와 접근성 미검증 diff --git a/docs/BROWSER_COMPATIBILITY.md b/docs/BROWSER_COMPATIBILITY.md index 82ab83c..6cb71bd 100644 --- a/docs/BROWSER_COMPATIBILITY.md +++ b/docs/BROWSER_COMPATIBILITY.md @@ -89,3 +89,13 @@ Native Messaging: - 숨김/최소화: 메인 창을 숨기거나 최소화한 상태에서 독립 선택 창 표시, 메인 창은 복원되지 않음 - 파일명: 임시 이름 필터와 같은 Job 메타데이터 갱신은 자동 테스트 및 Agent 실연동으로 확인. Whale가 이 fixture에서 지연 filename delta를 내지 않아 “확인 중 → 최종 이름” 시각 전환 자체는 미재현 - Automatic/Tree 테스트 파일과 테스트 규칙·작업만 정리했으며 사용자 작업은 보존 + +## 2026-07-23 — Whale 취소/재조정/설치본 테마 검증 + +- 환경: Windows 11, QHD 2560×1440, 125%, Whale 4.38.386.14, per-user 설치본 0.3.0 +- 실제 브라우저 취소: 선택 없음 237ms, 나중에 선택 236ms, 이동 안 함 227ms에 Agent `Cancelled`; 선택 완료도 진단 로그에서 `InProgress → Cancelled`, `SelectionReady → NotRequired` 즉시 적용 +- 공통 결과: 선택 팝업 닫힘, Pending 수 원복, 이동 파일 0, 이력 유지, 파일명과 보조 설명 취소선, opacity 감소 +- 진단: `onChanged`의 정제된 download ID/state/error, Native Messaging 결과, Agent 명령과 전후 BrowserTransferState/RoutingState를 기록하며 URL/query/token/전체 경로는 기록하지 않음 +- 시작 재조정: `chrome.downloads.search` 결과를 실시간 이벤트와 구분해 기존 Job의 30분 팝업 연령을 갱신하지 않음. 브라우저 기록 없음은 stale로 보존 +- 설치/테마: 기존 `config.local.json` 해시와 Dark 설정을 보존한 업그레이드 종료 코드 0; MainWindow, 카드, NavigationView, 이력, 상태 배지와 제목 표시줄의 다크 대비 확인 +- 다른 Chromium 브라우저와 Windows 100%/150%, FHD/4K 실기기 시각 검증은 남아 있음 From ed0aacce970c34ac2e4674e3a62ac41e7eb34210 Mon Sep 17 00:00:00 2001 From: esleeeeee <41484071+esleeeeee@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:47:09 +0900 Subject: [PATCH 24/24] Fix minimized selection window foreground activation --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 8 +- DEVELOPMENT.md | 2 + Directory.Build.props | 2 +- HANDOFF.md | 2 +- PROJECT_STATE.md | 10 +- README.md | 2 +- TESTING.md | 14 +- docs/BROWSER_COMPATIBILITY.md | 2 +- .../FolderSelectionWindow.cs | 228 +++++++++++++++++- src/DownloadRouter.App/MainWindow.History.cs | 2 +- src/DownloadRouter.App/MainWindow.Lifetime.cs | 14 +- src/DownloadRouter.App/MainWindow.Live.cs | 4 +- src/DownloadRouter.App/MainWindow.Pending.cs | 4 +- .../SelectionWindowActivationCoordinator.cs | 99 ++++++++ .../ProtocolAndStateTests.cs | 214 ++++++++++++++++ 16 files changed, 582 insertions(+), 29 deletions(-) create mode 100644 src/DownloadRouter.Core/Jobs/SelectionWindowActivationCoordinator.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b82a3c9..2c5e071 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -56,7 +56,9 @@ SQLite 마이그레이션은 `schema_migrations`, `rules`, `download_jobs`, `job ### WinUI 3 App -Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. App은 사용자 범위 mutex로 단일 인스턴스를 유지하는 트레이 호스트이며 `--background`에서는 메인 창을 표시하지 않습니다. 500ms 간격으로 Pending을 읽되 자동 팝업 정책을 통과한 항목만 중복 없는 FIFO `FolderSelectionWindow`로 표시합니다. 선택 창은 메인 창과 독립적이어서 메인 창이 숨김·최소화 상태여도 활성화되며, 포커스 확보 실패 시 작업표시줄 점멸을 사용합니다. +Agent와 동일한 Core 라이브러리를 참조하지만 DB나 파일 이동 구현을 직접 호출하지 않고 Named Pipe 명령으로 통신합니다. App은 사용자 범위 mutex로 단일 인스턴스를 유지하는 트레이 호스트이며 `--background`에서는 메인 창을 표시하지 않습니다. 500ms 간격으로 Pending을 읽되 자동 팝업 정책을 통과한 항목만 중복 없는 FIFO `FolderSelectionWindow`로 표시합니다. 선택창은 owner 없는 독립 top-level HWND입니다. 표시 때 선택창 HWND만 `SW_SHOWNORMAL`, XAML activate, topmost/bring-to-top, foreground 순으로 처리하고, Windows foreground 제한이 직접 호출을 거부하면 현재 foreground thread와 입력 큐를 잠시 연결해 선택창을 다시 활성화합니다. MainWindow에는 restore를 호출하지 않으며 FlashWindowEx는 모든 활성화 시도가 실패했을 때만 사용합니다. + +선택창 전면 진단은 MainWindow와 선택창의 HWND, visible, iconic, owner, foreground 및 Show/Bring/SetForeground 결과만 기록합니다. 다운로드 URL, query, token, 전체 로컬 경로는 기록하지 않습니다. 공통 `FolderTreePicker`는 루트 하나만 먼저 만들고 노드 확장 시 해당 단계의 자식만 비동기로 읽습니다. 로드된 노드를 중복 조회하지 않고, 접근 불가 항목은 노드 단위 오류로 제한하며 reparse point는 선택 경계에서 제외합니다. 팝업, 선택 대기, 이력 경로 변경이 같은 컴포넌트와 Agent 명령을 사용합니다. 규칙의 저장 루트는 경계가 아직 정해지지 않은 선택이므로 HWND로 초기화한 Windows `FolderPicker`를 사용합니다. diff --git a/CHANGELOG.md b/CHANGELOG.md index 733c514..c1fbd9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,12 @@ ### Added +- 선택창 전면 표시 전후의 MainWindow/FolderSelectionWindow HWND, visible, iconic, owner, foreground와 native 호출 결과를 남기는 경로·URL 비포함 진단 +- 숨김/최소화 MainWindow, iconic 선택창 복원, foreground fallback, FIFO 취소 제거를 검증하는 Core 회귀 테스트 - 30분 자동 팝업 정책, 이전 세션 대기 작업 안내와 선택 창의 `모두 나중에 선택` 큐 일괄 숨김 - Extension `download.cancelled`/`download.interrupted`, 시작 시 complete/interrupted/in_progress/stale 재조정과 정제된 연결 진단 - 전역 `ThemeManager`, System/Light/Dark 사용자 설정 저장과 열린/새 Window 즉시 적용 -- 정보 화면 제품명·0.3.0 버전·commit·설치형/개발 빌드 표시, 데이터 폴더/GitHub 열기 +- 정보 화면 제품명·0.3.1 버전·commit·설치형/개발 빌드 표시, 데이터 폴더/GitHub 열기 - `Directory.Build.props` 단일 버전 원본과 App/Agent/Native Host/Installer 일치 검증 - .NET 10/WinUI 3 모노레포와 재현 가능한 bootstrap/build/test/publish 스크립트 @@ -19,7 +21,7 @@ - 경로 토큰, 루트 경계와 reparse point 방어 - 동일/교차 볼륨 안전 이동, 안정화 확인, SHA-256 검증, 중복 이름 보존 - WinUI 대시보드, 규칙, 파일별 선택 대기, 상태 필터·삭제 이력, 브라우저, 진단 화면 -- 68개 .NET 테스트, 13개 TypeScript 테스트, Agent/Native Host 스모크 테스트 +- 73개 .NET 테스트, 13개 TypeScript 테스트, Agent/Native Host 스모크 테스트 - per-user Native Host 등록 및 Inno Setup 설치 골격 - Native Host 등록의 Windows PowerShell 5.1 회귀 테스트와 개인정보 로그 회귀 테스트 - 매칭 규칙의 작업 생성·실제 파일 이동을 검증하는 Agent 통합 테스트 @@ -50,6 +52,8 @@ ### Fixed +- MainWindow가 최소화된 상태에서 선택창이 foreground를 얻지 못하고 작업표시줄 강조로만 끝나던 문제 +- foreground 제한 시 바로 Flash fallback으로 종료하던 순서를 선택창 HWND의 normal 표시, 활성화, topmost, 입력 스레드 연결 재시도로 보강 - 앱 시작 때 오래된 `WaitingForSelection` 전체를 FIFO에 재삽입해 20개 이상 팝업이 연속 표시되던 문제 - service worker 시작 검색 결과가 오래된 Job의 마지막 브라우저 이벤트 시각을 현재로 덮어 다시 자동 팝업 대상으로 만들던 문제 - Whale가 `state` delta 없이 `error.current=USER_CANCELED`만 보낼 때 Extension이 조기 반환해 취소가 누락되던 문제 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4069d68..5996eae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -72,6 +72,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-installer.ps - 로그인 자동 시작은 관리자 권한이 필요 없는 HKCU Run을 사용합니다. 값은 반드시 따옴표로 감싼 절대 App 경로와 `--background`여야 합니다. - 기본 X 동작은 메인 AppWindow 숨김입니다. `종료` 명령과 설치 업그레이드의 `--shutdown`만 완전 종료를 요청합니다. - 폴더 선택은 메인 창의 ContentDialog가 아니라 별도 Window입니다. 창 크기는 DIP를 실제 모니터 DPI로 변환한 뒤 작업 영역 안으로 제한합니다. +- 선택창은 MainWindow owner를 설정하지 않는 독립 HWND입니다. 최소화 회귀를 막기 위해 MainWindow가 아니라 선택창 HWND에만 normal 표시/activate/foreground를 적용하고, foreground 제한 시 입력 스레드 연결 재시도 뒤에만 Flash fallback을 사용합니다. +- 선택창 전면 표시 코드를 바꿀 때는 MainWindow hidden/minimized, selection iconic, 팝업 종료 뒤 MainWindow 상태, FIFO 중복과 취소 제거 회귀 테스트를 함께 실행합니다. - 트리는 전체 재귀 열거를 금지하고 확장한 노드의 직계 자식만 비동기로 읽습니다. UI와 Agent 양쪽에서 루트 경계를 검사합니다. - 이력의 경로 변경은 규칙을 수정하지 않고 Job의 상대 경로만 변경합니다. 이미 이동된 파일은 사용자 확인 뒤 기존 안전 이동 서비스를 다시 사용합니다. - 자동 팝업은 `SelectionPromptPolicy.AutoPromptWindow`(30분)를 통과한 Job만 사용합니다. 대기 탭과 InfoBadge는 모든 실제 Pending을 사용하므로 두 목록을 다시 합치지 마세요. diff --git a/Directory.Build.props b/Directory.Build.props index aceb37d..a7f5762 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ enable enable true - 0.3.0 + 0.3.1 $(VersionPrefix) $(VersionPrefix).0 $(VersionPrefix).0 diff --git a/HANDOFF.md b/HANDOFF.md index 635b88b..0b9a596 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -37,7 +37,7 @@ Edge에서 `edge://extensions`를 열고 개발자 모드를 켠 뒤 “압축 - Agent Named Pipe ping 성공 - Native Host self-test 성공 - App/Agent/Native Host self-contained Release publish 성공 -- QHD 125% Per-Monitor V2, 900×850 수평 넘침 0, 파일별 SelectSubfolder, 30분 팝업 정책, 전역 테마와 0.3.0 버전 체계 검증 +- QHD 125% Per-Monitor V2, 900×850 수평 넘침 0, 파일별 SelectSubfolder, 최소화 상태 전면 활성화, 30분 팝업 정책, 전역 테마와 0.3.1 버전 체계 검증 Whale Automatic 규칙, 다른 Chromium 브라우저, installer, 100%/150% 및 FHD/4K 시각 검증은 아직 정상 기준에 포함되지 않습니다. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index b0b4fa3..8408ce3 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -26,7 +26,7 @@ - 브라우저 전송 상태와 라우팅 상태를 분리한 작업 모델과 v1 → v2 SQLite 마이그레이션 - 파일별 SelectSubfolder 카드, 명시적으로 체크한 항목만 일괄 적용, 건너뛰기와 세션 단위 나중에 선택 - 생성/마지막 브라우저 이벤트 기준 30분 자동 팝업 정책, 이전 세션 대기 표시, 현재 큐 `모두 나중에 선택` -- 고정 DIP 전용 선택 창, lazy 계층형 FolderTreePicker, 단일 FIFO, 숨김/최소화 상태 직접 표시, 취소 시 자동 닫기 +- 고정 DIP 전용 선택 창, lazy 계층형 FolderTreePicker, 단일 FIFO, 숨김/최소화 상태의 선택창 HWND 직접 활성화, 취소 시 자동 닫기 - `onCreated`/`filename` delta/완료 검색을 통한 같은 Job 파일명 갱신과 임시 이름 차단 - `USER_CANCELED` 즉시 전송, interrupted 오류 우선순위, onErased 비취소 처리와 팝업 연령을 갱신하지 않는 service worker 시작 재조정 - 이력의 Job별 저장 위치 선택·변경, 완료 파일 명시적 재이동, 이동 안 함/나중에 선택 @@ -41,15 +41,15 @@ - 실패할 때만 분류 코드를 남기는 Extension Native Messaging 진단 로그 - Per-Monitor V2 WinUI 렌더링, 뷰포트 실폭 제한, 32/48 DIP 공통 상단 여백, 1초 상태 변경 감지 - 저장된 System/Light/Dark를 열린 모든 Window와 이후 생성 Window에 적용하는 공통 ThemeManager -- assembly informational version을 표시하는 정보 화면과 `Directory.Build.props` 기반 App/Agent/Native Host/Installer 단일 0.3.0 버전 -- self-contained win-x64 publish와 설치/업그레이드/제거가 검증된 per-user Inno Setup 0.3.0 +- assembly informational version을 표시하는 정보 화면과 `Directory.Build.props` 기반 App/Agent/Native Host/Installer 단일 0.3.1 버전 +- self-contained win-x64 publish와 설치/업그레이드/제거가 검증된 per-user Inno Setup 0.3.1 ## 빌드와 테스트 | 항목 | 결과 | |---|---| | `.NET Debug build` | 성공, 경고 0, 오류 0 | -| `.NET tests` | 68/68 통과(Core 41, Infrastructure 7, Integration 20) | +| `.NET tests` | 73/73 통과(Core 46, Infrastructure 7, Integration 20) | | Extension ESLint/TypeScript build | 성공 | | Extension Node tests | 13/13 통과 | | Agent Named Pipe ping | 성공 | @@ -72,7 +72,7 @@ | Vivaldi | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | | Opera | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | 미검증 | -Whale 4.38.386.14에서 로컬 HTTP fixture로 Automatic과 SelectSubfolder를 실제 검증했습니다. Automatic은 C: 기본 다운로드 위치에서 D: 테스트 규칙 루트로 교차 볼륨 이동했고, SelectSubfolder는 테스트 루트에서 `kr → 모야지`만 확장·선택해 이동했습니다. 100자 이상 노드를 표시하고 복귀해도 선택 창 폭은 700px(125%의 560 DIP)로 동일했습니다. 메인 창 숨김/최소화 중에도 독립 선택 창이 직접 나타났습니다. 2026-07-23에는 아무 선택 없음/선택 완료/나중에 선택/이동 안 함 네 실제 Whale 취소가 모두 약 0.24초 안에 `Cancelled`로 반영되고 팝업·Pending·이동 0건과 이력 취소선을 확인했습니다. +Whale 4.38.386.14에서 로컬 HTTP fixture로 Automatic과 SelectSubfolder를 실제 검증했습니다. Automatic은 C: 기본 다운로드 위치에서 D: 테스트 규칙 루트로 교차 볼륨 이동했고, SelectSubfolder는 테스트 루트에서 `kr → 모야지`만 확장·선택해 이동했습니다. 100자 이상 노드를 표시하고 복귀해도 선택 창 폭은 700px(125%의 560 DIP)로 동일했습니다. 0.3.0 사용자 검증에서 트레이 숨김은 통과했지만 최소화 상태는 선택창이 Whale 뒤에 남는 회귀가 확인됐고, 0.3.1에서 선택창 HWND 전용 foreground/입력 스레드 재시도와 topmost 정책으로 수정했습니다. 2026-07-23에는 아무 선택 없음/선택 완료/나중에 선택/이동 안 함 네 실제 Whale 취소가 모두 약 0.24초 안에 `Cancelled`로 반영되고 팝업·Pending·이동 0건과 이력 취소선을 확인했습니다. ## 알려진 문제와 제한 diff --git a/README.md b/README.md index e185fbe..24ebc60 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Chromium 기반 브라우저에서 완료된 다운로드를 사이트 규칙에 따라 Windows 폴더로 분류하는 로컬 전용 애플리케이션입니다. 규칙이 없거나 로컬 구성 요소가 응답하지 않으면 브라우저의 원래 다운로드를 그대로 유지하는 fail-open 방식을 사용합니다. -> 현재 상태: 핵심 라우팅, 계층형 폴더 선택, 트레이 상주, 로그인 자동 시작, 규칙/이력 관리와 사용자 단위 설치 흐름을 구현했습니다. 30분 자동 팝업 정책, Whale 취소 재조정, 전역 System/Light/Dark 테마와 단일 0.3.0 제품 버전 체계를 포함합니다. 자세한 결과와 남은 제약은 [PROJECT_STATE.md](PROJECT_STATE.md)를 확인하세요. +> 현재 상태: 핵심 라우팅, 계층형 폴더 선택, 트레이 상주, 로그인 자동 시작, 규칙/이력 관리와 사용자 단위 설치 흐름을 구현했습니다. 30분 자동 팝업 정책, Whale 취소 재조정, 최소화 상태 선택창 전면 활성화, 전역 System/Light/Dark 테마와 단일 0.3.1 제품 버전 체계를 포함합니다. 자세한 결과와 남은 제약은 [PROJECT_STATE.md](PROJECT_STATE.md)를 확인하세요. ## 구성 요소 diff --git a/TESTING.md b/TESTING.md index 7a735c9..cc640b5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,8 +16,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\diagnose.ps1 2026-07-23 현재 로컬 결과: - .NET Debug build: 성공, 경고 0, 오류 0 -- .NET tests: 68/68 통과 - - Core 41: 규칙, 경로, 개인정보, 상태 전이, 30분 팝업 정책/23개 큐 일괄 숨김, 테마 저장/fallback, 제품 버전, 임시 파일명, lazy 폴더 트리, 자동 시작 등록/해제 +- .NET tests: 73/73 통과 + - Core 46: 규칙, 경로, 개인정보, 상태 전이, 30분 팝업 정책/23개 큐 일괄 숨김, 선택창 foreground/최소화/취소 FIFO, 테마 저장/fallback, 제품 버전, 임시 파일명, lazy 폴더 트리, 자동 시작 등록/해제 - Infrastructure 7: v1 → v2 → v3 → v4 SQLite 마이그레이션, soft-delete 규칙, round-trip, 동일·교차 볼륨, 중복 이름, 로그 개인정보 제거 - Integration 20: 명령 거부, 미매칭 fail-open, 완료 전/후 선택, 네 선택 흐름의 idempotent 취소, stale/in_progress 재조정, 시작 재조정의 30분 연령 보존, 중단, 규칙 편집·삭제, skip 완료 시 원본 보존, 실제 파일 비삭제 이력 삭제 - Extension: ESLint 성공, TypeScript compile 성공, Node tests 13/13 통과, dist build 성공 @@ -65,7 +65,15 @@ QHD 2560x1440, Windows 배율 125%에서 실제 App 프로세스를 측정했습 - 설치 App 10초 이상 안정 실행, 백그라운드 실행 시 보이는 메인 창 0, 두 번째 실행 후 App 인스턴스 1 - 실제 창 DPI 120, `XamlRoot.RasterizationScale=1.25` - 설치 전후 DB v4 마이그레이션에서 기존 Rule/Job 수와 ID, `config.local.json` 테마 보존 -- Installer/App/Agent/Native Host의 제품 버전 0.3.0 일치 +- Installer/App/Agent/Native Host의 제품 버전 0.3.1 일치 + +### 최소화 상태 선택창 전면 활성화 + +- MainWindow와 FolderSelectionWindow의 HWND, visible, `IsIconic`, owner, foreground를 activation 전후로 기록 +- 선택창에는 owner를 지정하지 않으며 모든 restore/foreground 호출은 선택창 HWND만 대상으로 함 +- 순서: `SW_SHOWNORMAL` → `Window.Activate` → `BringWindowToTop` → `SetForegroundWindow` → foreground thread attach 재시도 → topmost/Flash fallback +- direct foreground가 거부된 실제 최소화 재현에서 attach 재시도로 선택창 foreground 획득, MainWindow는 `Minimized` 유지 +- 자동 테스트에서 숨김/최소화 MainWindow, iconic 선택창 normal 복원, MainWindow 비복원, FIFO 중복 방지와 취소 제거를 검증 - 자동 시작 값: 설치 App의 따옴표 절대 경로 + `--background` - 트레이 메뉴: `열기`, `저장 위치 선택 대기 열기`, `일반 설정`, `종료` - 제거 전: Native Host 6개, 자동 시작, 시작 메뉴 존재 diff --git a/docs/BROWSER_COMPATIBILITY.md b/docs/BROWSER_COMPATIBILITY.md index 6cb71bd..a198cc5 100644 --- a/docs/BROWSER_COMPATIBILITY.md +++ b/docs/BROWSER_COMPATIBILITY.md @@ -92,7 +92,7 @@ Native Messaging: ## 2026-07-23 — Whale 취소/재조정/설치본 테마 검증 -- 환경: Windows 11, QHD 2560×1440, 125%, Whale 4.38.386.14, per-user 설치본 0.3.0 +- 환경: Windows 11, QHD 2560×1440, 125%, Whale 4.38.386.14, per-user 설치본 0.3.1 - 실제 브라우저 취소: 선택 없음 237ms, 나중에 선택 236ms, 이동 안 함 227ms에 Agent `Cancelled`; 선택 완료도 진단 로그에서 `InProgress → Cancelled`, `SelectionReady → NotRequired` 즉시 적용 - 공통 결과: 선택 팝업 닫힘, Pending 수 원복, 이동 파일 0, 이력 유지, 파일명과 보조 설명 취소선, opacity 감소 - 진단: `onChanged`의 정제된 download ID/state/error, Native Messaging 결과, Agent 명령과 전후 BrowserTransferState/RoutingState를 기록하며 URL/query/token/전체 경로는 기록하지 않음 diff --git a/src/DownloadRouter.App/FolderSelectionWindow.cs b/src/DownloadRouter.App/FolderSelectionWindow.cs index 3e0e614..95f09ed 100644 --- a/src/DownloadRouter.App/FolderSelectionWindow.cs +++ b/src/DownloadRouter.App/FolderSelectionWindow.cs @@ -1,4 +1,6 @@ using System.Runtime.InteropServices; +using DownloadRouter.Core.Jobs; +using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -19,7 +21,10 @@ public sealed record FolderSelectionResult( FolderSelectionAction Action, string RelativeFolder); -public sealed class FolderSelectionWindow(ThemeManager themeManager) +public sealed class FolderSelectionWindow( + ThemeManager themeManager, + nint mainWindowHandle, + Action? diagnosticWriter = null) { private const int DefaultWidthDip = 560; private const int DefaultHeightDip = 760; @@ -33,6 +38,8 @@ public sealed class FolderSelectionWindow(ThemeManager themeManager) }; private readonly TaskCompletionSource completion = new( TaskCreationOptions.RunContinuationsAsynchronously); + private DispatcherQueueTimer? foregroundRetryTimer; + private int foregroundRetryCount; private bool completed; public void UpdateDescription(string description) @@ -50,14 +57,22 @@ public async Task ShowAsync( window.Title = "다운로드 저장 위치 선택"; window.Content = CreateContent(description, allowLater, allowSkip, allowLaterAll); themeManager.RegisterWindow(window); - window.Closed += (_, _) => CompleteWithoutClosing(FolderSelectionAction.Closed); + window.Closed += (_, _) => + { + diagnosticWriter?.Invoke("selection-window closed-event"); + CompleteWithoutClosing(FolderSelectionAction.Closed); + }; await picker.InitializeAsync(storageRoot, selectedRelativeFolder, cancellationToken); window.Activate(); SizeAndCenterWindow(); - BringToForeground(); + ShowAndActivateSelectionWindow("initial"); + StartForegroundRetries(); using var registration = cancellationToken.Register(() => - window.DispatcherQueue.TryEnqueue(() => Complete(FolderSelectionAction.Closed))); + { + diagnosticWriter?.Invoke("selection-window cancellation-requested"); + window.DispatcherQueue.TryEnqueue(() => Complete(FolderSelectionAction.Closed)); + }); return await completion.Task; } @@ -167,6 +182,9 @@ private bool CompleteWithoutClosing(FolderSelectionAction action) } completed = true; + foregroundRetryTimer?.Stop(); + foregroundRetryTimer = null; + diagnosticWriter?.Invoke($"selection-window completion action={action}"); completion.TrySetResult(new FolderSelectionResult(action, picker.SelectedRelativeFolder)); return true; } @@ -190,19 +208,169 @@ private void SizeAndCenterWindow() { presenter.IsResizable = false; presenter.IsMaximizable = false; + presenter.IsAlwaysOnTop = true; } } - private void BringToForeground() + private void StartForegroundRetries() + { + foregroundRetryTimer = window.DispatcherQueue.CreateTimer(); + foregroundRetryTimer.Interval = TimeSpan.FromMilliseconds(250); + foregroundRetryTimer.IsRepeating = true; + foregroundRetryTimer.Tick += (_, _) => + { + if (completed || foregroundRetryCount >= 4) + { + foregroundRetryTimer?.Stop(); + foregroundRetryTimer = null; + return; + } + + foregroundRetryCount++; + var handle = WinRT.Interop.WindowNative.GetWindowHandle(window); + if (!IsWindowVisible(handle) + || IsIconic(handle) + || GetForegroundWindow() != handle) + { + ShowAndActivateSelectionWindow($"retry-{foregroundRetryCount}"); + } + }; + foregroundRetryTimer.Start(); + } + + private void ShowAndActivateSelectionWindow(string phase) { var handle = WinRT.Interop.WindowNative.GetWindowHandle(window); - _ = ShowWindow(handle, 5); - if (!SetForegroundWindow(handle)) + var operations = new NativeSelectionWindowActivationOperations(window); + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(mainWindowHandle, handle); + diagnosticWriter?.Invoke(FormatActivationDiagnostic(phase, result)); + } + + private static string FormatActivationDiagnostic( + string phase, + SelectionWindowActivationResult result) + => $"selection-window activation phase={phase} " + + $"main-before={FormatState(result.MainBefore)} " + + $"selection-before={FormatState(result.SelectionBefore)} " + + $"selection-after-show={FormatState(result.SelectionAfterShow)} " + + $"show-normal={result.ShowNormalSucceeded} " + + $"bring-to-top={result.BringToTopSucceeded} " + + $"set-foreground={result.DirectForegroundSucceeded} " + + $"attached-input={result.AttachedForegroundSucceeded} " + + $"raised={result.RaisedForegroundSucceeded} " + + $"flash-fallback={result.FlashFallbackUsed} " + + $"main-after={FormatState(result.MainAfter)} " + + $"selection-after={FormatState(result.SelectionAfter)}"; + + private static string FormatState(NativeWindowState state) + => $"[hwnd=0x{state.Handle:X},visible={state.IsVisible},iconic={state.IsIconic}," + + $"owner=0x{state.OwnerHandle:X},foreground=0x{state.ForegroundHandle:X}]"; + + private sealed class NativeSelectionWindowActivationOperations( + Window xamlWindow) : ISelectionWindowActivationOperations + { + private const int ShowNormal = 1; + private const uint GetWindowOwner = 4; + private const uint SwpNoSize = 0x0001; + private const uint SwpNoMove = 0x0002; + private const uint SwpNoActivate = 0x0010; + private const uint SwpShowWindow = 0x0040; + private static readonly nint HwndTopmost = new(-1); + + public NativeWindowState Capture(nint windowHandle) + { + var foreground = GetForegroundWindow(); + return windowHandle == 0 + ? new NativeWindowState(0, false, false, 0, foreground) + : new NativeWindowState( + windowHandle, + IsWindowVisible(windowHandle), + IsIconic(windowHandle), + GetWindow(windowHandle, GetWindowOwner), + foreground); + } + + public bool ShowSelectionNormal(nint selectionWindowHandle) + { + _ = ShowWindow(selectionWindowHandle, ShowNormal); + return IsWindowVisible(selectionWindowHandle) && !IsIconic(selectionWindowHandle); + } + + public void ActivateSelection() + => xamlWindow.Activate(); + + public bool BringSelectionToTop(nint selectionWindowHandle) + => BringWindowToTop(selectionWindowHandle); + + public bool TrySetSelectionForeground(nint selectionWindowHandle) + { + _ = SetForegroundWindow(selectionWindowHandle); + return GetForegroundWindow() == selectionWindowHandle; + } + + public bool TryAttachInputAndActivate(nint selectionWindowHandle) + { + var foregroundWindowHandle = GetForegroundWindow(); + if (foregroundWindowHandle == selectionWindowHandle) + { + return true; + } + + if (foregroundWindowHandle == 0) + { + return false; + } + + var selectionThread = GetWindowThreadProcessId(selectionWindowHandle, out _); + var foregroundThread = GetWindowThreadProcessId(foregroundWindowHandle, out _); + if (selectionThread == 0 || foregroundThread == 0) + { + return false; + } + + var attached = selectionThread == foregroundThread + || AttachThreadInput(selectionThread, foregroundThread, true); + if (!attached) + { + return false; + } + + try + { + _ = ShowWindow(selectionWindowHandle, ShowNormal); + _ = BringWindowToTop(selectionWindowHandle); + _ = SetActiveWindow(selectionWindowHandle); + _ = SetFocus(selectionWindowHandle); + _ = SetForegroundWindow(selectionWindowHandle); + xamlWindow.Activate(); + return GetForegroundWindow() == selectionWindowHandle; + } + finally + { + if (selectionThread != foregroundThread) + { + _ = AttachThreadInput(selectionThread, foregroundThread, false); + } + } + } + + public bool RaiseSelectionAboveOtherWindows(nint selectionWindowHandle) + { + var flags = SwpNoMove | SwpNoSize | SwpNoActivate | SwpShowWindow; + var raised = SetWindowPos(selectionWindowHandle, HwndTopmost, 0, 0, 0, 0, flags); + xamlWindow.Activate(); + _ = BringWindowToTop(selectionWindowHandle); + _ = SetForegroundWindow(selectionWindowHandle); + return raised && GetForegroundWindow() == selectionWindowHandle; + } + + public void FlashSelection(nint selectionWindowHandle) { var flash = new FlashWindowInfo { Size = (uint)Marshal.SizeOf(), - Window = handle, + Window = selectionWindowHandle, Flags = 3, Count = 3, Timeout = 0, @@ -226,12 +394,54 @@ private struct FlashWindowInfo [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetForegroundWindow(nint windowHandle); + private static extern bool IsWindowVisible(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsIconic(nint windowHandle); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool ShowWindow(nint windowHandle, int command); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool BringWindowToTop(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint windowHandle); + + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern nint GetWindow(nint windowHandle, uint command); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(nint windowHandle, out uint processId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); + + [DllImport("user32.dll")] + private static extern nint SetActiveWindow(nint windowHandle); + + [DllImport("user32.dll")] + private static extern nint SetFocus(nint windowHandle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPos( + nint windowHandle, + nint insertAfter, + int x, + int y, + int width, + int height, + uint flags); + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool FlashWindowEx(ref FlashWindowInfo info); diff --git a/src/DownloadRouter.App/MainWindow.History.cs b/src/DownloadRouter.App/MainWindow.History.cs index 5f65b74..fbf0637 100644 --- a/src/DownloadRouter.App/MainWindow.History.cs +++ b/src/DownloadRouter.App/MainWindow.History.cs @@ -236,7 +236,7 @@ private async Task ChangeHistoryRouteAsync(DownloadJob job, DownloadRule rule) return; } - var result = await new FolderSelectionWindow(themeManager).ShowAsync( + var result = await CreateFolderSelectionWindow().ShowAsync( root, job.SelectedRelativeFolder, $"파일: {DownloadPresentation.DisplayFileName(job)}\n현재 상태: {DescribeStatus(job)}\n저장 루트: {root}", diff --git a/src/DownloadRouter.App/MainWindow.Lifetime.cs b/src/DownloadRouter.App/MainWindow.Lifetime.cs index faebd6a..b76b33d 100644 --- a/src/DownloadRouter.App/MainWindow.Lifetime.cs +++ b/src/DownloadRouter.App/MainWindow.Lifetime.cs @@ -103,6 +103,18 @@ private async Task EnsureAgentForHostAsync() } private static void WriteAppDiagnostic(string message) + => WriteDiagnostic("app-startup.log", message); + + private static void WriteWindowActivationDiagnostic(string message) + => WriteDiagnostic("window-activation.log", message); + + private FolderSelectionWindow CreateFolderSelectionWindow() + => new( + themeManager, + WinRT.Interop.WindowNative.GetWindowHandle(this), + WriteWindowActivationDiagnostic); + + private static void WriteDiagnostic(string fileName, string message) { try { @@ -113,7 +125,7 @@ private static void WriteAppDiagnostic(string message) "logs"); Directory.CreateDirectory(directory); File.AppendAllText( - Path.Combine(directory, "app-startup.log"), + Path.Combine(directory, fileName), $"{DateTimeOffset.UtcNow:O} {message}{Environment.NewLine}"); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) diff --git a/src/DownloadRouter.App/MainWindow.Live.cs b/src/DownloadRouter.App/MainWindow.Live.cs index 710baf1..32a5c47 100644 --- a/src/DownloadRouter.App/MainWindow.Live.cs +++ b/src/DownloadRouter.App/MainWindow.Live.cs @@ -105,6 +105,8 @@ private void SynchronizeSelectionPrompt( var activeIds = active.Select(static job => job.Id).ToHashSet(); if (currentSelectionJobId is Guid current && !activeIds.Contains(current)) { + WriteWindowActivationDiagnostic( + $"selection-window invalidated current-job={current:N} active-count={active.Count}"); currentSelectionInvalidated = true; currentSelectionCancellation?.Cancel(); } @@ -182,7 +184,7 @@ private async Task ShowSelectionPromptAsync( { var root = pathResolver.Resolve(rule.StorageRoot); currentSelectionCancellation = new CancellationTokenSource(); - currentSelectionWindow = new FolderSelectionWindow(themeManager); + currentSelectionWindow = CreateFolderSelectionWindow(); var result = await currentSelectionWindow.ShowAsync( root, job.SelectedRelativeFolder, diff --git a/src/DownloadRouter.App/MainWindow.Pending.cs b/src/DownloadRouter.App/MainWindow.Pending.cs index 8fc791c..c4baad4 100644 --- a/src/DownloadRouter.App/MainWindow.Pending.cs +++ b/src/DownloadRouter.App/MainWindow.Pending.cs @@ -88,7 +88,7 @@ private void AddPendingRuleGroup(DownloadRule rule, IReadOnlyList j return; } - var result = await new FolderSelectionWindow(themeManager).ShowAsync( + var result = await CreateFolderSelectionWindow().ShowAsync( root, selectedRelativeFolder: null, $"{selectedJobs.Count}개 파일에 같은 하위 폴더를 적용합니다. 체크하지 않은 파일은 변경하지 않습니다.", @@ -143,7 +143,7 @@ private Border CreatePendingCard( var apply = new Button { Content = "이 파일의 저장 위치 선택/변경", HorizontalAlignment = HorizontalAlignment.Stretch }; apply.Click += async (_, _) => { - var result = await new FolderSelectionWindow(themeManager).ShowAsync( + var result = await CreateFolderSelectionWindow().ShowAsync( root, job.SelectedRelativeFolder, $"파일: {DownloadPresentation.DisplayFileName(job)}\n현재 선택: {DisplayFolder(job.SelectedRelativeFolder)}", diff --git a/src/DownloadRouter.Core/Jobs/SelectionWindowActivationCoordinator.cs b/src/DownloadRouter.Core/Jobs/SelectionWindowActivationCoordinator.cs new file mode 100644 index 0000000..5209b3a --- /dev/null +++ b/src/DownloadRouter.Core/Jobs/SelectionWindowActivationCoordinator.cs @@ -0,0 +1,99 @@ +namespace DownloadRouter.Core.Jobs; + +public readonly record struct NativeWindowState( + nint Handle, + bool IsVisible, + bool IsIconic, + nint OwnerHandle, + nint ForegroundHandle) +{ + public bool IsForeground => Handle != 0 && ForegroundHandle == Handle; +} + +public interface ISelectionWindowActivationOperations +{ + NativeWindowState Capture(nint windowHandle); + + bool ShowSelectionNormal(nint selectionWindowHandle); + + void ActivateSelection(); + + bool BringSelectionToTop(nint selectionWindowHandle); + + bool TrySetSelectionForeground(nint selectionWindowHandle); + + bool TryAttachInputAndActivate(nint selectionWindowHandle); + + bool RaiseSelectionAboveOtherWindows(nint selectionWindowHandle); + + void FlashSelection(nint selectionWindowHandle); +} + +public sealed record SelectionWindowActivationResult( + NativeWindowState MainBefore, + NativeWindowState SelectionBefore, + NativeWindowState SelectionAfterShow, + NativeWindowState MainAfter, + NativeWindowState SelectionAfter, + bool ShowNormalSucceeded, + bool BringToTopSucceeded, + bool DirectForegroundSucceeded, + bool AttachedForegroundSucceeded, + bool RaisedForegroundSucceeded, + bool FlashFallbackUsed); + +public sealed class SelectionWindowActivationCoordinator( + ISelectionWindowActivationOperations operations) +{ + public SelectionWindowActivationResult Activate( + nint mainWindowHandle, + nint selectionWindowHandle) + { + ArgumentNullException.ThrowIfNull(operations); + if (selectionWindowHandle == 0) + { + throw new ArgumentException("The selection window handle must be valid.", nameof(selectionWindowHandle)); + } + + var mainBefore = operations.Capture(mainWindowHandle); + var selectionBefore = operations.Capture(selectionWindowHandle); + var showNormalSucceeded = operations.ShowSelectionNormal(selectionWindowHandle); + operations.ActivateSelection(); + var bringToTopSucceeded = operations.BringSelectionToTop(selectionWindowHandle); + var selectionAfterShow = operations.Capture(selectionWindowHandle); + + var directForegroundSucceeded = operations.TrySetSelectionForeground(selectionWindowHandle); + var attachedForegroundSucceeded = false; + var raisedForegroundSucceeded = false; + + if (!operations.Capture(selectionWindowHandle).IsForeground) + { + attachedForegroundSucceeded = operations.TryAttachInputAndActivate(selectionWindowHandle); + } + + if (!operations.Capture(selectionWindowHandle).IsForeground) + { + raisedForegroundSucceeded = operations.RaiseSelectionAboveOtherWindows(selectionWindowHandle); + } + + var selectionAfter = operations.Capture(selectionWindowHandle); + var flashFallbackUsed = !selectionAfter.IsForeground; + if (flashFallbackUsed) + { + operations.FlashSelection(selectionWindowHandle); + } + + return new SelectionWindowActivationResult( + mainBefore, + selectionBefore, + selectionAfterShow, + operations.Capture(mainWindowHandle), + selectionAfter, + showNormalSucceeded, + bringToTopSucceeded, + directForegroundSucceeded, + attachedForegroundSucceeded, + raisedForegroundSucceeded, + flashFallbackUsed); + } +} diff --git a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs index fc28020..4f0a198 100644 --- a/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs +++ b/tests/DownloadRouter.Core.Tests/ProtocolAndStateTests.cs @@ -118,6 +118,23 @@ public void SelectionPromptQueueCanBeHiddenAsOneSessionBatch() Assert.False(queue.TryDequeue(out _)); } + [Fact] + public void CancelledSelectionIsRemovedWithoutBreakingFifoOrCreatingDuplicates() + { + var queue = new SelectionPromptQueue(); + var cancelled = Guid.NewGuid(); + var next = Guid.NewGuid(); + Assert.True(queue.Enqueue(cancelled)); + Assert.True(queue.Enqueue(next)); + Assert.False(queue.Enqueue(next)); + + Assert.True(queue.Remove(cancelled)); + Assert.False(queue.Remove(cancelled)); + Assert.True(queue.TryDequeue(out var remaining)); + Assert.Equal(next, remaining); + Assert.False(queue.TryDequeue(out _)); + } + [Fact] public void AutomaticSelectionPolicyKeepsOldAndStaleJobsInPendingWithoutPopup() { @@ -272,6 +289,94 @@ public void CancellationPresentationDependsOnlyOnBrowserState(RoutingState routi Assert.Empty(DownloadJobQueries.ActiveSelections([job])); } + [Fact] + public void HiddenMainWindowShowsAndActivatesOnlyTheIndependentSelectionWindow() + { + var operations = new FakeSelectionWindowActivationOperations( + mainVisible: false, + mainIconic: false, + selectionVisible: false, + selectionIconic: false) + { + DirectForegroundSucceeds = true, + }; + + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(operations.MainWindowHandle, operations.SelectionWindowHandle); + + Assert.True(result.SelectionAfter.IsVisible); + Assert.False(result.SelectionAfter.IsIconic); + Assert.True(result.SelectionAfter.IsForeground); + Assert.False(result.MainAfter.IsVisible); + Assert.False(result.MainAfter.IsIconic); + Assert.All(operations.MutatedWindowHandles, handle => + Assert.Equal(operations.SelectionWindowHandle, handle)); + } + + [Fact] + public void MinimizedMainWindowRemainsMinimizedWhenAttachedInputActivatesSelection() + { + var operations = new FakeSelectionWindowActivationOperations( + mainVisible: true, + mainIconic: true, + selectionVisible: false, + selectionIconic: false) + { + AttachedForegroundSucceeds = true, + }; + + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(operations.MainWindowHandle, operations.SelectionWindowHandle); + + Assert.False(result.DirectForegroundSucceeded); + Assert.True(result.AttachedForegroundSucceeded); + Assert.True(result.SelectionAfter.IsForeground); + Assert.True(result.MainAfter.IsVisible); + Assert.True(result.MainAfter.IsIconic); + Assert.All(operations.MutatedWindowHandles, handle => + Assert.Equal(operations.SelectionWindowHandle, handle)); + } + + [Fact] + public void IconicSelectionWindowIsShownNormalBeforeForegroundActivation() + { + var operations = new FakeSelectionWindowActivationOperations( + mainVisible: true, + mainIconic: true, + selectionVisible: true, + selectionIconic: true) + { + DirectForegroundSucceeds = true, + }; + + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(operations.MainWindowHandle, operations.SelectionWindowHandle); + + Assert.True(result.SelectionBefore.IsIconic); + Assert.True(result.ShowNormalSucceeded); + Assert.True(result.SelectionAfterShow.IsVisible); + Assert.False(result.SelectionAfterShow.IsIconic); + } + + [Fact] + public void TaskbarFlashIsUsedOnlyAfterAllForegroundAttemptsFail() + { + var operations = new FakeSelectionWindowActivationOperations( + mainVisible: true, + mainIconic: true, + selectionVisible: false, + selectionIconic: false); + + var result = new SelectionWindowActivationCoordinator(operations) + .Activate(operations.MainWindowHandle, operations.SelectionWindowHandle); + + Assert.False(result.DirectForegroundSucceeded); + Assert.False(result.AttachedForegroundSucceeded); + Assert.False(result.RaisedForegroundSucceeded); + Assert.True(result.FlashFallbackUsed); + Assert.True(operations.FlashUsed); + } + [Fact] public async Task FolderTreeLoadsOnlyTheExpandedLevelAndRejectsRootEscape() { @@ -304,4 +409,113 @@ private static DownloadJob CreateJob(BrowserTransferState browserState, RoutingS Guid.NewGuid(), BrowserKind.Whale, Guid.NewGuid().ToString("N"), "sample.bin", "sample.bin", null, null, null, null, "https://example.com", Guid.NewGuid(), null, null, null, browserState, routingState, null, null, DateTimeOffset.UtcNow, null); + + private sealed class FakeSelectionWindowActivationOperations : + ISelectionWindowActivationOperations + { + public nint MainWindowHandle { get; } = new(101); + public nint SelectionWindowHandle { get; } = new(202); + public bool DirectForegroundSucceeds { get; init; } + public bool AttachedForegroundSucceeds { get; init; } + public bool RaisedForegroundSucceeds { get; init; } + public bool FlashUsed { get; private set; } + public List MutatedWindowHandles { get; } = []; + + private bool mainVisible; + private bool mainIconic; + private bool selectionVisible; + private bool selectionIconic; + private nint foregroundHandle = new(303); + + public FakeSelectionWindowActivationOperations( + bool mainVisible, + bool mainIconic, + bool selectionVisible, + bool selectionIconic) + { + this.mainVisible = mainVisible; + this.mainIconic = mainIconic; + this.selectionVisible = selectionVisible; + this.selectionIconic = selectionIconic; + } + + public NativeWindowState Capture(nint windowHandle) + => windowHandle == MainWindowHandle + ? new NativeWindowState( + MainWindowHandle, + mainVisible, + mainIconic, + 0, + foregroundHandle) + : new NativeWindowState( + SelectionWindowHandle, + selectionVisible, + selectionIconic, + 0, + foregroundHandle); + + public bool ShowSelectionNormal(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + selectionVisible = true; + selectionIconic = false; + return true; + } + + public void ActivateSelection() + { + MutatedWindowHandles.Add(SelectionWindowHandle); + } + + public bool BringSelectionToTop(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + return true; + } + + public bool TrySetSelectionForeground(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + if (DirectForegroundSucceeds) + { + foregroundHandle = selectionWindowHandle; + } + + return DirectForegroundSucceeds; + } + + public bool TryAttachInputAndActivate(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + if (AttachedForegroundSucceeds) + { + foregroundHandle = selectionWindowHandle; + } + + return AttachedForegroundSucceeds; + } + + public bool RaiseSelectionAboveOtherWindows(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + if (RaisedForegroundSucceeds) + { + foregroundHandle = selectionWindowHandle; + } + + return RaisedForegroundSucceeds; + } + + public void FlashSelection(nint selectionWindowHandle) + { + TrackSelectionMutation(selectionWindowHandle); + FlashUsed = true; + } + + private void TrackSelectionMutation(nint windowHandle) + { + Assert.Equal(SelectionWindowHandle, windowHandle); + MutatedWindowHandles.Add(windowHandle); + } + } }