diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3100a7b..831823b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,14 +90,14 @@ jobs: run: >- ./build/Publish-ReleaseCandidate.ps1 -OutputDirectory artifacts/rc - -Version 0.1.0-ci.${{ github.run_number }} + -Version 0.1.2-ci.${{ github.run_number }} - name: Publish per-user MSI if: ${{ github.event_name != 'pull_request' }} run: >- ./build/Publish-Installer.ps1 -OutputDirectory artifacts/installer - -Version 0.1.0-ci.${{ github.run_number }} + -Version 0.1.2-ci.${{ github.run_number }} -PayloadDirectory artifacts/rc/QuickPods -SkipPayloadPublish -SkipRestore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13b4471..4251fee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: version: - description: Semantic release version (for example 0.1.1) + description: Semantic release version (for example 0.1.2) required: true - default: 0.1.1 + default: 0.1.2 type: string permissions: @@ -89,6 +89,10 @@ jobs: throw 'Portable manifest version or signing state is invalid.' } + ./build/Test-PortablePayload.ps1 ` + -PayloadDirectory artifacts/release/QuickPods ` + -ExpectedVersion $env:QUICKPODS_RELEASE_VERSION | Out-Null + $expectedHash = ((Get-Content -LiteralPath $checksumFile -Raw).Trim() -split '\s+')[0] $actualHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne $expectedHash) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 203dea3..736c478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to QuickPods will be documented in this file. The format fol ## [Unreleased] +### Changed + +- Reorganized the portable package around an obvious root `QuickPods.exe` launcher. +- Moved the application runtime and helper executables into an internal `app` directory. +- Collected license and third-party notice files in a dedicated `licenses` directory. +- Added a bilingual package README with startup, security, update, and internal-file guidance. + ## [0.1.1] - 2026-08-08 ### Changed diff --git a/Directory.Build.props b/Directory.Build.props index 367d124..74a252d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,9 +13,9 @@ true true true - 0.1.1 - 0.1.1.0 - 0.1.1.0 + 0.1.2 + 0.1.2.0 + 0.1.2.0 QuickPods QuickPods contributors QuickPods contributors diff --git a/QuickPods.sln b/QuickPods.sln index 3df2121..524901a 100644 --- a/QuickPods.sln +++ b/QuickPods.sln @@ -47,6 +47,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickPods.BluetoothWorker", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickPods.Presentation", "src\QuickPods.Presentation\QuickPods.Presentation.csproj", "{FCDDB738-2751-4E22-861F-D2B40F208C8A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickPods.Launcher", "src\QuickPods.Launcher\QuickPods.Launcher.csproj", "{C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -57,6 +59,18 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Debug|x64.ActiveCfg = Debug|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Debug|x64.Build.0 = Debug|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Debug|x86.ActiveCfg = Debug|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Debug|x86.Build.0 = Debug|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Release|Any CPU.Build.0 = Release|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Release|x64.ActiveCfg = Release|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Release|x64.Build.0 = Release|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Release|x86.ActiveCfg = Release|Any CPU + {C81E23D4-44F8-4C63-9D8D-A6C6D1B5E521}.Release|x86.Build.0 = Release|Any CPU {61B767B0-94E2-449F-AE59-335FEDD7A800}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {61B767B0-94E2-449F-AE59-335FEDD7A800}.Debug|Any CPU.Build.0 = Debug|Any CPU {61B767B0-94E2-449F-AE59-335FEDD7A800}.Debug|x64.ActiveCfg = Debug|Any CPU diff --git a/README.ja.md b/README.ja.md index 5ed1b58..f354a1e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -42,9 +42,9 @@ 1. [最新のRelease](https://github.com/rimtty/QuickPods/releases/latest)から`QuickPods--win-x64.zip`と`SHA256SUMS.txt`をダウンロードします。 2. ZIPのSHA-256チェックサムが`SHA256SUMS.txt`と一致することを確認します。 3. ZIP内の`QuickPods`フォルダー全体を、自分のWindowsユーザーが所有する固定の場所へ展開します。 -4. 展開したフォルダーの`QuickPods.exe`を起動します。 +4. そのフォルダーの直下にある`QuickPods.exe`を起動します。 -パッケージは自己完結型で、.NETを別途インストールする必要はありません。未署名のため、初回起動時にWindowsがSmartScreen警告を表示する場合があります。実行を判断する前に、ダウンロード元とチェックサムを確認してください。すべてのファイルを同じフォルダーに保ち、Windowsログイン時の自動起動を有効にした後はフォルダーを移動しないでください。 +パッケージは自己完結型で、.NETを別途インストールする必要はありません。ルートの`QuickPods.exe`は軽量ランチャーです。ランタイムと補助プロセスは内部の`app`フォルダー、ライセンス関連ファイルは`licenses`フォルダーにまとめられています。`app`内のファイルを直接起動したり移動したりしないでください。未署名のため、初回起動時にWindowsがSmartScreen警告を表示する場合があります。実行を判断する前に、ダウンロード元とチェックサムを確認してください。すべてのファイルを同じフォルダーに保ち、Windowsログイン時の自動起動を有効にした後はフォルダーを移動しないでください。 ポータブル版の更新・削除、ソースからのビルド、パッケージ作成については[ユーザーガイド](docs/user-guide.md)、[更新ポリシー](docs/release/update-policy.md)、[リリースガイド](docs/release/README.md)を参照してください。 diff --git a/README.md b/README.md index f3c67b6..1172f01 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ QuickPods continues to work from the notification area when the taskbar is left- 1. Download `QuickPods--win-x64.zip` and `SHA256SUMS.txt` from the [latest release](https://github.com/rimtty/QuickPods/releases/latest). 2. Verify the ZIP's SHA-256 checksum against `SHA256SUMS.txt`. 3. Extract the complete `QuickPods` folder to a permanent location owned by your Windows user. -4. Run `QuickPods.exe` from that folder. +4. Run the `QuickPods.exe` located directly in that folder. -The package is self-contained and does not require a separate .NET installation. Because it is unsigned, Windows may display a SmartScreen warning on first launch. Confirm the download source and checksum before deciding whether to run it. Keep the extracted files together and do not move the folder after enabling start at sign-in. +The package is self-contained and does not require a separate .NET installation. The root `QuickPods.exe` is a lightweight launcher; runtime files and helper processes are kept in the internal `app` folder, while license files are collected under `licenses`. Do not start or move files from `app` manually. Because the package is unsigned, Windows may display a SmartScreen warning on first launch. Confirm the download source and checksum before deciding whether to run it. Keep the extracted files together and do not move the folder after enabling start at sign-in. For portable updates, removal, source builds, and packaging details, see the [user guide](docs/user-guide.md), [update policy](docs/release/update-policy.md), and [release guide](docs/release/README.md). diff --git a/build/New-InstallerPayloadFragment.ps1 b/build/New-InstallerPayloadFragment.ps1 index fbb50d9..dd5f9b3 100644 --- a/build/New-InstallerPayloadFragment.ps1 +++ b/build/New-InstallerPayloadFragment.ps1 @@ -15,12 +15,13 @@ if (-not (Test-Path -LiteralPath $payloadRoot -PathType Container)) { throw "Payload directory does not exist: $payloadRoot" } -$selfContainedProof = & (Join-Path $PSScriptRoot "Test-SelfContainedPayload.ps1") ` +$portableProof = & (Join-Path $PSScriptRoot "Test-PortablePayload.ps1") ` -PayloadDirectory $payloadRoot -$requiredFiles = @($selfContainedProof.Executables) + - @($selfContainedProof.RuntimeFiles) + - @($selfContainedProof.RuntimeConfigs) + - @("artifact-manifest.json") +$requiredFiles = @($portableProof.RootFiles) + + @($portableProof.LicenseFiles) + + @($portableProof.Executables) + + @($portableProof.RuntimeFiles) + + @($portableProof.RuntimeConfigs) $componentIds = [System.Collections.Generic.List[string]]::new() foreach ($requiredFile in $requiredFiles) { if (-not (Test-Path -LiteralPath (Join-Path $payloadRoot $requiredFile) -PathType Leaf)) { diff --git a/build/Publish-ReleaseCandidate.ps1 b/build/Publish-ReleaseCandidate.ps1 index 4ca12e8..9747104 100644 --- a/build/Publish-ReleaseCandidate.ps1 +++ b/build/Publish-ReleaseCandidate.ps1 @@ -23,19 +23,24 @@ if ($outputRoot -eq $repositoryRoot) { } $payloadDirectory = Join-Path $outputRoot "QuickPods" +$applicationDirectory = Join-Path $payloadDirectory "app" +$licenseDirectory = Join-Path $payloadDirectory "licenses" $buildArtifactsDirectory = Join-Path $outputRoot ".build-artifacts" +$launcherPublishDirectory = Join-Path $buildArtifactsDirectory "launcher-publish" if (Test-Path -LiteralPath $outputRoot) { Remove-Item -LiteralPath $outputRoot -Recurse -Force } -New-Item -ItemType Directory -Path $payloadDirectory -Force | Out-Null +New-Item -ItemType Directory -Path $applicationDirectory -Force | Out-Null +New-Item -ItemType Directory -Path $licenseDirectory -Force | Out-Null -$projects = @( +$applicationProjects = @( "src/QuickPods.App/QuickPods.App.csproj", "src/QuickPods.TaskbarObserver/QuickPods.TaskbarObserver.csproj", "src/QuickPods.TaskbarHost/QuickPods.TaskbarHost.csproj", "src/QuickPods.BluetoothWorker/QuickPods.BluetoothWorker.csproj" ) +$launcherProject = "src/QuickPods.Launcher/QuickPods.Launcher.csproj" Push-Location $repositoryRoot try { @@ -43,7 +48,7 @@ try { # The app project graph includes every shipped executable. The normal solution lock # files intentionally describe the framework-dependent build, so use RID-specific # locks under ignored obj directories and never rewrite the committed locks. - & dotnet restore $projects[0] ` + & dotnet restore $applicationProjects[0] ` --runtime win-x64 ` --artifacts-path $buildArtifactsDirectory ` -p:NuGetLockFilePath=obj/project.rc.packages.lock.json ` @@ -52,16 +57,26 @@ try { if ($LASTEXITCODE -ne 0) { throw "Release-candidate restore failed." } + + & dotnet restore $launcherProject ` + --runtime win-x64 ` + --artifacts-path $buildArtifactsDirectory ` + -p:NuGetLockFilePath=obj/project.rc.packages.lock.json ` + -p:RestoreLockedMode=false ` + -p:RestoreForceEvaluate=true + if ($LASTEXITCODE -ne 0) { + throw "Portable launcher restore failed." + } } - foreach ($project in $projects) { + foreach ($project in $applicationProjects) { & dotnet publish $project ` --configuration Release ` --runtime win-x64 ` --self-contained true ` --no-restore ` --artifacts-path $buildArtifactsDirectory ` - --output $payloadDirectory ` + --output $applicationDirectory ` -p:Version=$Version ` -p:ContinuousIntegrationBuild=true ` -p:DebugSymbols=false ` @@ -72,6 +87,28 @@ try { throw "Publish failed for $project." } } + + & dotnet publish $launcherProject ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + --no-restore ` + --artifacts-path $buildArtifactsDirectory ` + --output $launcherPublishDirectory ` + -p:Version=$Version ` + -p:ContinuousIntegrationBuild=true ` + -p:DebugSymbols=false ` + -p:DebugType=None ` + -p:PublishAot=true + if ($LASTEXITCODE -ne 0) { + throw "Publish failed for $launcherProject." + } + + $launcherExecutable = Join-Path $launcherPublishDirectory "QuickPods.exe" + if (-not (Test-Path -LiteralPath $launcherExecutable -PathType Leaf)) { + throw "Native portable launcher was not produced." + } + Copy-Item -LiteralPath $launcherExecutable -Destination $payloadDirectory } finally { Pop-Location if (Test-Path -LiteralPath $buildArtifactsDirectory -PathType Container) { @@ -91,12 +128,19 @@ foreach ($legalFile in $legalFiles.GetEnumerator()) { throw "Required legal file is missing: $($legalFile.Key)" } - Copy-Item -LiteralPath $legalFile.Key -Destination (Join-Path $payloadDirectory $legalFile.Value) + Copy-Item -LiteralPath $legalFile.Key -Destination (Join-Path $licenseDirectory $legalFile.Value) } +Copy-Item ` + -LiteralPath (Join-Path $repositoryRoot "packaging\PORTABLE-README.txt") ` + -Destination (Join-Path $payloadDirectory "README.txt") + $selfContainedProof = & (Join-Path $PSScriptRoot "Test-SelfContainedPayload.ps1") ` - -PayloadDirectory $payloadDirectory -$requiredExecutables = @($selfContainedProof.Executables) + -PayloadDirectory $applicationDirectory +$requiredExecutables = @("QuickPods.exe") + @( + $selfContainedProof.Executables | + ForEach-Object { "app/$($_)" } +) $executables = foreach ($requiredFile in $requiredExecutables) { $versionInfo = (Get-Item -LiteralPath (Join-Path $payloadDirectory $requiredFile)).VersionInfo @@ -126,6 +170,10 @@ $files = Get-ChildItem -LiteralPath $payloadDirectory -File -Recurse | $manifest = [ordered]@{ product = "QuickPods" version = $Version + layoutVersion = 2 + entryPoint = "QuickPods.exe" + applicationDirectory = "app" + licenseDirectory = "licenses" runtimeIdentifier = "win-x64" selfContained = $true includedFrameworks = $selfContainedProof.IncludedFrameworks @@ -135,6 +183,10 @@ $manifest = [ordered]@{ } $manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM +& (Join-Path $PSScriptRoot "Test-PortablePayload.ps1") ` + -PayloadDirectory $payloadDirectory ` + -ExpectedVersion $Version | Out-Null + $archivePath = Join-Path $outputRoot "QuickPods-$Version-win-x64.zip" Add-Type -AssemblyName System.IO.Compression $archiveStream = [System.IO.File]::Open( @@ -188,6 +240,7 @@ $checksumPath = Join-Path $outputRoot "SHA256SUMS.txt" [pscustomobject]@{ Version = $Version PayloadDirectory = $payloadDirectory + EntryPoint = (Join-Path $payloadDirectory "QuickPods.exe") ArchivePath = $archivePath ArchiveSha256 = $archiveHash FileCount = @($files).Count + 1 diff --git a/build/Test-InstallerLifecycle.ps1 b/build/Test-InstallerLifecycle.ps1 index fac60d4..6f3ddbe 100644 --- a/build/Test-InstallerLifecycle.ps1 +++ b/build/Test-InstallerLifecycle.ps1 @@ -240,9 +240,10 @@ try { -Force | Out-Null $stage = "launch-previous" - $previousProcess = Start-Process -FilePath $installedExecutable -ArgumentList "--background" -PassThru + $previousLauncher = Start-Process -FilePath $installedExecutable -ArgumentList "--background" -PassThru + $previousLauncher.WaitForExit(5000) Start-Sleep -Seconds 3 - Assert-Condition -Condition (-not $previousProcess.HasExited) -Message "Previous QuickPods process did not remain resident." + Assert-Condition -Condition (@(Get-QuickPodsProcesses).Count -gt 0) -Message "Previous QuickPods process did not remain resident." $stage = "upgrade-running-product" Invoke-MsiExecution ` @@ -260,9 +261,10 @@ try { $checks.runningUpgrade = $true $stage = "launch-current" - $currentProcess = Start-Process -FilePath $installedExecutable -ArgumentList "--background" -PassThru + $currentLauncher = Start-Process -FilePath $installedExecutable -ArgumentList "--background" -PassThru + $currentLauncher.WaitForExit(5000) Start-Sleep -Seconds 3 - Assert-Condition -Condition (-not $currentProcess.HasExited) -Message "Current QuickPods process did not remain resident." + Assert-Condition -Condition (@(Get-QuickPodsProcesses).Count -gt 0) -Message "Current QuickPods process did not remain resident." $stage = "uninstall-running-product" Invoke-MsiExecution ` diff --git a/build/Test-InstallerPackage.ps1 b/build/Test-InstallerPackage.ps1 index 79b5ae6..f9835b7 100644 --- a/build/Test-InstallerPackage.ps1 +++ b/build/Test-InstallerPackage.ps1 @@ -173,6 +173,7 @@ try { "ThirdPartyNotices.txt", "DOTNET-LICENSE.txt", "DOTNET-THIRD-PARTY-NOTICES.txt", + "README.txt", "artifact-manifest.json" )) { if (-not ($files | Where-Object { ($_[1] -split '\|')[-1] -eq $requiredFile })) { diff --git a/build/Test-PortablePayload.ps1 b/build/Test-PortablePayload.ps1 new file mode 100644 index 0000000..4edfd0f --- /dev/null +++ b/build/Test-PortablePayload.ps1 @@ -0,0 +1,103 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PayloadDirectory, + + [string]$ExpectedVersion +) + +$ErrorActionPreference = "Stop" +$payloadRoot = [System.IO.Path]::GetFullPath($PayloadDirectory) +if (-not (Test-Path -LiteralPath $payloadRoot -PathType Container)) { + throw "Portable payload directory does not exist: $payloadRoot" +} + +$applicationRoot = Join-Path $payloadRoot "app" +$selfContainedProof = & (Join-Path $PSScriptRoot "Test-SelfContainedPayload.ps1") ` + -PayloadDirectory $applicationRoot +$requiredRootFiles = @( + "QuickPods.exe", + "README.txt", + "artifact-manifest.json" +) +$requiredLicenseFiles = @( + "licenses/LICENSE", + "licenses/ThirdPartyNotices.txt", + "licenses/DOTNET-LICENSE.txt", + "licenses/DOTNET-THIRD-PARTY-NOTICES.txt" +) +foreach ($requiredFile in $requiredRootFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $payloadRoot $requiredFile) -PathType Leaf)) { + throw "Portable payload is missing root file $requiredFile." + } +} +foreach ($requiredFile in $requiredLicenseFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $payloadRoot $requiredFile) -PathType Leaf)) { + throw "Portable payload is missing license file $requiredFile." + } +} + +$unexpectedRootFiles = @( + Get-ChildItem -LiteralPath $payloadRoot -File | + Where-Object { $_.Name -notin $requiredRootFiles } +) +if ($unexpectedRootFiles.Count -gt 0) { + throw "Portable payload has unexpected root files: $($unexpectedRootFiles.Name -join ', ')" +} + +$rootDirectories = @(Get-ChildItem -LiteralPath $payloadRoot -Directory | ForEach-Object Name) +$expectedRootDirectories = @("app", "licenses") +if ($rootDirectories.Count -ne $expectedRootDirectories.Count -or + @($rootDirectories | Where-Object { $_ -notin $expectedRootDirectories }).Count -gt 0) { + throw "Portable payload root must contain only the app and licenses directories." +} + +$unexpectedLicenseFiles = @( + Get-ChildItem -LiteralPath (Join-Path $payloadRoot "licenses") -File | + Where-Object { "licenses/$($_.Name)" -notin $requiredLicenseFiles } +) +if ($unexpectedLicenseFiles.Count -gt 0) { + throw "Portable payload has unexpected license files: $($unexpectedLicenseFiles.Name -join ', ')" +} + +$manifestPath = Join-Path $payloadRoot "artifact-manifest.json" +try { + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +} catch { + throw "Portable artifact manifest is invalid JSON." +} +if ($manifest.layoutVersion -ne 2 -or + $manifest.entryPoint -ne "QuickPods.exe" -or + $manifest.applicationDirectory -ne "app" -or + $manifest.licenseDirectory -ne "licenses" -or + $manifest.runtimeIdentifier -ne "win-x64" -or + $manifest.selfContained -ne $true) { + throw "Portable artifact manifest has invalid layout metadata." +} +if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and + $manifest.version -ne $ExpectedVersion) { + throw "Portable artifact manifest version '$($manifest.version)' does not match '$ExpectedVersion'." +} + +$expectedExecutables = @("QuickPods.exe") + @( + $selfContainedProof.Executables | + ForEach-Object { "app/$($_)" } +) +$manifestExecutables = @($manifest.executables | ForEach-Object path) +foreach ($expectedExecutable in $expectedExecutables) { + if ($manifestExecutables -notcontains $expectedExecutable) { + throw "Portable manifest is missing executable $expectedExecutable." + } +} + +[pscustomobject]@{ + PayloadDirectory = $payloadRoot + ApplicationDirectory = $applicationRoot + EntryPoint = "QuickPods.exe" + Executables = $expectedExecutables + RuntimeFiles = @($selfContainedProof.RuntimeFiles | ForEach-Object { "app/$($_)" }) + RuntimeConfigs = @($selfContainedProof.RuntimeConfigs | ForEach-Object { "app/$($_)" }) + IncludedFrameworks = $selfContainedProof.IncludedFrameworks + RootFiles = $requiredRootFiles + LicenseFiles = $requiredLicenseFiles +} diff --git a/build/Test-SelfContainedPayload.ps1 b/build/Test-SelfContainedPayload.ps1 index 356b8ea..7f5bb09 100644 --- a/build/Test-SelfContainedPayload.ps1 +++ b/build/Test-SelfContainedPayload.ps1 @@ -34,12 +34,6 @@ $requiredRuntimeFiles = @( "coreclr.dll", "PresentationFramework.dll" ) -$requiredLegalFiles = @( - "LICENSE", - "ThirdPartyNotices.txt", - "DOTNET-LICENSE.txt", - "DOTNET-THIRD-PARTY-NOTICES.txt" -) $runtimeConfigExpectations = [ordered]@{ "QuickPods.runtimeconfig.json" = @( "Microsoft.NETCore.App", @@ -63,7 +57,6 @@ $requiredFiles = @( $requiredManagedAssemblies $requiredDependencyManifests $requiredRuntimeFiles - $requiredLegalFiles $runtimeConfigExpectations.Keys ) foreach ($requiredFile in $requiredFiles) { @@ -130,7 +123,6 @@ foreach ($expectation in $runtimeConfigExpectations.GetEnumerator()) { ManagedAssemblies = $requiredManagedAssemblies DependencyManifests = $requiredDependencyManifests RuntimeFiles = $requiredRuntimeFiles - LegalFiles = $requiredLegalFiles RuntimeConfigs = @($runtimeConfigExpectations.Keys) IncludedFrameworks = [pscustomobject]$includedFrameworkVersions } diff --git a/docs/architecture/README.md b/docs/architecture/README.md index acb9563..15b0ae0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -5,15 +5,16 @@ QuickPods separates product logic, Windows adapters, and failure-prone OS integr ## Process model ```text -QuickPods.exe -├─ WPF flyout, Settings, notification-area icon, and application lifetime -├─ Core Audio and Bluetooth product coordination -├─ QuickPods.TaskbarHost.exe -│ └─ taskbar discovery, native surface, rendering, input, and versioned IPC -├─ QuickPods.TaskbarObserver.exe -│ └─ short-lived UI Automation subscription for one Explorer generation -└─ QuickPods.BluetoothWorker.exe - └─ bounded Bluetooth capability or mutation call +QuickPods.exe (portable launcher) +└─ app\QuickPods.exe + ├─ WPF flyout, Settings, notification-area icon, and application lifetime + ├─ Core Audio and Bluetooth product coordination + ├─ QuickPods.TaskbarHost.exe + │ └─ taskbar discovery, native surface, rendering, input, and versioned IPC + ├─ QuickPods.TaskbarObserver.exe + │ └─ short-lived UI Automation subscription for one Explorer generation + └─ QuickPods.BluetoothWorker.exe + └─ bounded Bluetooth capability or mutation call ``` The app owns settings, logs, startup registration, and child-process lifetime. Helper executables never register themselves at sign-in and are placed in kill-on-close job objects where appropriate. @@ -23,6 +24,7 @@ The app owns settings, logs, startup registration, and child-process lifetime. H | Project | Responsibility | |---|---| | `QuickPods.App` | WPF UI, tray integration, settings UI, composition root | +| `QuickPods.Launcher` | dependency-free native launcher for the portable package root | | `QuickPods.Core` | product state and use-case coordination | | `QuickPods.Presentation` | language-independent presentation and localization | | `QuickPods.Contracts` | versioned process contracts and shared glyph definitions | @@ -54,10 +56,10 @@ Incomplete evidence hides the embedded surface and leaves notification-area acce ## Settings and diagnostics -Settings use atomic JSON replacement under `%LocalAppData%\QuickPods`. A malformed file is quarantined before safe defaults are written. Startup registration uses one current-user Run value pointing to `QuickPods.exe --background`. +Settings use atomic JSON replacement under `%LocalAppData%\QuickPods`. A malformed file is quarantined before safe defaults are written. Startup registration uses one current-user Run value pointing to the internal application executable with `--background`; the root launcher is needed only for direct user startup and installer cleanup forwarding. Structured diagnostics contain state classifications and counts, not raw device or account identifiers. See [Privacy](../privacy.md). ## Distribution -Release payloads are x64 and self-contained. The MSI installs per user without elevation, preserves settings during upgrade and uninstall, and removes the startup value owned by QuickPods on complete uninstall. Release signing is fail-closed and handled only through protected secrets or an explicitly supplied local certificate. +Release payloads are x64 and self-contained. The portable root contains one obvious launcher, keeps runtime files under `app`, and collects legal notices under `licenses`. The MSI installs the same layout per user without elevation, preserves settings during upgrade and uninstall, and removes the startup value owned by QuickPods on complete uninstall. Release signing remains fail-closed when MSI distribution is enabled. diff --git a/docs/release/README.md b/docs/release/README.md index 8410c68..65fe2c2 100644 --- a/docs/release/README.md +++ b/docs/release/README.md @@ -12,6 +12,8 @@ QuickPods can produce: End users do not need a separate .NET Desktop Runtime. +The portable ZIP presents a lightweight native `QuickPods.exe` launcher at its root. The launcher starts `app\QuickPods.exe`, while the self-contained runtime and isolated helper processes remain grouped under `app`. Legal notices, a bilingual `README.txt`, and the artifact manifest stay beside the launcher. + ## Versioning Use a three-part semantic version, optionally followed by a prerelease suffix: @@ -65,4 +67,4 @@ Before changing repository visibility, complete the separate [public repository ## Dependency and legal review -Review NuGet vulnerability output and every third-party license before release. QuickPods includes its own `LICENSE`, `ThirdPartyNotices.txt`, and the .NET license/notices in self-contained payloads. WiX is a build-time dependency; maintainers must review the toolchain's current terms independently. +Review NuGet vulnerability output and every third-party license before release. QuickPods collects its own `LICENSE`, `ThirdPartyNotices.txt`, and the .NET license/notices under the portable package's `licenses` directory. WiX is a build-time dependency; maintainers must review the toolchain's current terms independently. diff --git a/docs/user-guide.md b/docs/user-guide.md index f0db67f..36ca6dd 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -2,7 +2,7 @@ ## Start QuickPods -Extract the official portable ZIP to a permanent folder and launch `QuickPods.exe` from the extracted `QuickPods` folder. QuickPods stays in the notification area after its flyout closes. +Extract the official portable ZIP to a permanent folder and launch the root `QuickPods.exe` from the extracted `QuickPods` folder. The internal `app` directory contains runtime files and helper processes; do not launch or move them manually. License and third-party notice files are collected under `licenses`. QuickPods stays in the notification area after its flyout closes. The taskbar surface appears only when QuickPods can verify a safe empty region on a center-aligned Windows 11 taskbar. If it cannot, use the QuickPods notification-area icon instead. diff --git a/packaging/PORTABLE-README.txt b/packaging/PORTABLE-README.txt new file mode 100644 index 0000000..d1f65b4 --- /dev/null +++ b/packaging/PORTABLE-README.txt @@ -0,0 +1,36 @@ +QuickPods portable package / ポータブルパッケージ +=================================================== + +START / 起動方法 +---------------- +Double-click QuickPods.exe in this folder. +このフォルダーにある QuickPods.exe をダブルクリックしてください。 + +The app folder contains internal runtime files and helper processes. Do not move, +rename, or start files inside that folder directly. +app フォルダーには内部ランタイムと補助プロセスが入っています。フォルダー内の +ファイルを移動、改名、または直接起動しないでください。 + +License and third-party notice files are collected in the licenses folder. +ライセンスと第三者通知のファイルは licenses フォルダーにまとめられています。 + +SECURITY / セキュリティ +----------------------- +This package is currently unsigned. Windows may display a Microsoft Defender +SmartScreen warning. Download QuickPods only from the official GitHub Releases +page and verify the published SHA-256 checksum before deciding whether to run it. + +このパッケージは現在未署名です。Microsoft Defender SmartScreen の警告が表示 +される場合があります。公式GitHub Releasesページからのみダウンロードし、実行を +判断する前に公開されたSHA-256チェックサムを確認してください。 + +UPDATE / 更新 +------------- +Exit QuickPods, then replace this entire QuickPods folder with the folder from the +new release. Keep the same location if start at sign-in is enabled. + +QuickPodsを終了し、このQuickPodsフォルダー全体を新しいリリースのものに置き換えて +ください。ログイン時の自動起動を有効にしている場合は、同じ場所を使用してください。 + +Project: https://github.com/rimtty/QuickPods +License: MIT (see licenses/LICENSE) diff --git a/src/QuickPods.Launcher/LauncherRuntime.cs b/src/QuickPods.Launcher/LauncherRuntime.cs new file mode 100644 index 0000000..1ebcd4a --- /dev/null +++ b/src/QuickPods.Launcher/LauncherRuntime.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; + +namespace QuickPods.Launcher; + +internal static class LauncherRuntime +{ + internal const string ApplicationDirectoryName = "app"; + internal const string ApplicationExecutableName = "QuickPods.exe"; + private const string UnregisterStartupArgument = "--unregister-startup"; + + internal static int Run( + IReadOnlyList args, + string launcherDirectory, + Func startApplication, + Action showError) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentException.ThrowIfNullOrWhiteSpace(launcherDirectory); + ArgumentNullException.ThrowIfNull(startApplication); + ArgumentNullException.ThrowIfNull(showError); + + string applicationDirectory = Path.GetFullPath(Path.Combine( + launcherDirectory, + ApplicationDirectoryName)); + string applicationExecutable = Path.Combine( + applicationDirectory, + ApplicationExecutableName); + if (!File.Exists(applicationExecutable)) + { + showError(CreateMissingApplicationMessage(applicationExecutable)); + return 2; + } + + var startInfo = new ProcessStartInfo + { + FileName = applicationExecutable, + WorkingDirectory = applicationDirectory, + UseShellExecute = false, + }; + foreach (string argument in args) + { + startInfo.ArgumentList.Add(argument); + } + + bool waitForExit = args.Any(argument => string.Equals( + argument, + UnregisterStartupArgument, + StringComparison.OrdinalIgnoreCase)); + try + { + return startApplication(startInfo, waitForExit); + } + catch (Exception exception) + { + showError(CreateLaunchFailureMessage(exception.Message)); + return 3; + } + } + + private static string CreateMissingApplicationMessage(string applicationExecutable) => + $"QuickPods could not find its application files. Re-extract the complete ZIP package.\n\n" + + $"QuickPodsのアプリケーションファイルが見つかりません。ZIP全体をもう一度展開してください。\n\n" + + applicationExecutable; + + private static string CreateLaunchFailureMessage(string detail) => + $"QuickPods could not start.\n\nQuickPodsを起動できませんでした。\n\n{detail}"; +} diff --git a/src/QuickPods.Launcher/Program.cs b/src/QuickPods.Launcher/Program.cs new file mode 100644 index 0000000..8fc2213 --- /dev/null +++ b/src/QuickPods.Launcher/Program.cs @@ -0,0 +1,43 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace QuickPods.Launcher; + +internal static partial class Program +{ + private const uint MessageBoxErrorIcon = 0x00000010; + private const uint MessageBoxOk = 0x00000000; + + [STAThread] + private static int Main(string[] args) => LauncherRuntime.Run( + args, + AppContext.BaseDirectory, + StartApplication, + ShowError); + + private static int StartApplication(ProcessStartInfo startInfo, bool waitForExit) + { + using Process process = Process.Start(startInfo) ?? + throw new InvalidOperationException("Windows did not start the QuickPods application process."); + if (!waitForExit) + { + return 0; + } + + process.WaitForExit(); + return process.ExitCode; + } + + private static void ShowError(string message) => _ = MessageBox( + 0, + message, + "QuickPods", + MessageBoxOk | MessageBoxErrorIcon); + + [LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)] + private static partial int MessageBox( + nint windowHandle, + string text, + string caption, + uint type); +} diff --git a/src/QuickPods.Launcher/Properties/AssemblyInfo.cs b/src/QuickPods.Launcher/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..01e9ef8 --- /dev/null +++ b/src/QuickPods.Launcher/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("QuickPods.Foundation.Tests")] diff --git a/src/QuickPods.Launcher/QuickPods.Launcher.csproj b/src/QuickPods.Launcher/QuickPods.Launcher.csproj new file mode 100644 index 0000000..b5c075f --- /dev/null +++ b/src/QuickPods.Launcher/QuickPods.Launcher.csproj @@ -0,0 +1,18 @@ + + + + WinExe + net10.0-windows10.0.26100.0 + 10.0.22000.0 + win-x64 + QuickPods.Launcher + QuickPods + true + true + true + true + true + ..\..\docs\assets\branding\quickpods-icon-v2.ico + + + diff --git a/src/QuickPods.Launcher/packages.lock.json b/src/QuickPods.Launcher/packages.lock.json new file mode 100644 index 0000000..eb0191c --- /dev/null +++ b/src/QuickPods.Launcher/packages.lock.json @@ -0,0 +1,35 @@ +{ + "version": 2, + "dependencies": { + "net10.0-windows10.0.26100": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "tnG8ntt/Bk6odvHREnGLMo3PEiihy5iSlIFVp0JbIo00GKtNRt2k73eKZbPqR5yaJNIa3z8R86YLwbxfqpb17g==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + } + }, + "net10.0-windows10.0.26100/win-x64": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "tnG8ntt/Bk6odvHREnGLMo3PEiihy5iSlIFVp0JbIo00GKtNRt2k73eKZbPqR5yaJNIa3z8R86YLwbxfqpb17g==", + "dependencies": { + "runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.10" + } + }, + "runtime.win-x64.Microsoft.DotNet.ILCompiler": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "e5P00ObvdmAl49KkvGvqDLE5+EXu/IdXCDsLghgKFXKrNybyxMmH12HuGrsO7LTIGjXI58tyN3Hwxwr/MR0d8A==" + } + } + } +} \ No newline at end of file diff --git a/tests/QuickPods.Foundation.Tests/LauncherRuntimeTests.cs b/tests/QuickPods.Foundation.Tests/LauncherRuntimeTests.cs new file mode 100644 index 0000000..412c98c --- /dev/null +++ b/tests/QuickPods.Foundation.Tests/LauncherRuntimeTests.cs @@ -0,0 +1,116 @@ +using System.Diagnostics; +using System.IO; +using QuickPods.Launcher; +using Xunit; + +namespace QuickPods.Foundation.Tests; + +public sealed class LauncherRuntimeTests : IDisposable +{ + private readonly string launcherDirectory = Path.Combine( + Path.GetTempPath(), + "QuickPods.Launcher.Tests", + Guid.NewGuid().ToString("N")); + + [Fact] + public void MissingApplicationReportsAnActionableError() + { + string? error = null; + bool started = false; + + int exitCode = LauncherRuntime.Run( + [], + launcherDirectory, + (_, _) => + { + started = true; + return 0; + }, + message => error = message); + + Assert.Equal(2, exitCode); + Assert.False(started); + Assert.Contains("Re-extract", error, StringComparison.Ordinal); + Assert.Contains(Path.Combine("app", "QuickPods.exe"), error, StringComparison.Ordinal); + } + + [Fact] + public void LaunchesTheInternalApplicationWithArgumentsAndWorkingDirectory() + { + string applicationDirectory = CreateApplicationPlaceholder(); + ProcessStartInfo? captured = null; + bool? capturedWait = null; + + int exitCode = LauncherRuntime.Run( + ["--background", "value with spaces"], + launcherDirectory, + (startInfo, waitForExit) => + { + captured = startInfo; + capturedWait = waitForExit; + return 0; + }, + _ => Assert.Fail("The launcher unexpectedly reported an error.")); + + Assert.Equal(0, exitCode); + Assert.NotNull(captured); + Assert.Equal(Path.Combine(applicationDirectory, "QuickPods.exe"), captured.FileName); + Assert.Equal(applicationDirectory, captured.WorkingDirectory); + Assert.False(captured.UseShellExecute); + Assert.Equal(["--background", "value with spaces"], captured.ArgumentList); + Assert.False(capturedWait); + } + + [Fact] + public void WaitsForStartupCleanupAndReturnsTheApplicationExitCode() + { + _ = CreateApplicationPlaceholder(); + bool? capturedWait = null; + + int exitCode = LauncherRuntime.Run( + ["--unregister-startup"], + launcherDirectory, + (_, waitForExit) => + { + capturedWait = waitForExit; + return 17; + }, + _ => Assert.Fail("The launcher unexpectedly reported an error.")); + + Assert.Equal(17, exitCode); + Assert.True(capturedWait); + } + + [Fact] + public void LaunchFailureReturnsAnErrorWithoutLeakingImplementationDetails() + { + _ = CreateApplicationPlaceholder(); + string? error = null; + + int exitCode = LauncherRuntime.Run( + [], + launcherDirectory, + (_, _) => throw new InvalidOperationException("test failure"), + message => error = message); + + Assert.Equal(3, exitCode); + Assert.Contains("could not start", error, StringComparison.OrdinalIgnoreCase); + Assert.Contains("test failure", error, StringComparison.Ordinal); + } + + public void Dispose() + { + if (Directory.Exists(launcherDirectory)) + { + Directory.Delete(launcherDirectory, recursive: true); + } + } + + private string CreateApplicationPlaceholder() + { + string applicationDirectory = Path.Combine(launcherDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + File.WriteAllBytes(Path.Combine(applicationDirectory, "QuickPods.exe"), []); + return applicationDirectory; + } +} diff --git a/tests/QuickPods.Foundation.Tests/QuickPods.Foundation.Tests.csproj b/tests/QuickPods.Foundation.Tests/QuickPods.Foundation.Tests.csproj index ae9b495..4fe3fb8 100644 --- a/tests/QuickPods.Foundation.Tests/QuickPods.Foundation.Tests.csproj +++ b/tests/QuickPods.Foundation.Tests/QuickPods.Foundation.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/QuickPods.Foundation.Tests/packages.lock.json b/tests/QuickPods.Foundation.Tests/packages.lock.json index 39b71bb..b487716 100644 --- a/tests/QuickPods.Foundation.Tests/packages.lock.json +++ b/tests/QuickPods.Foundation.Tests/packages.lock.json @@ -99,6 +99,9 @@ "xunit.extensibility.core": "[2.9.3]" } }, + "QuickPods": { + "type": "Project" + }, "quickpods.contracts": { "type": "Project" }, @@ -108,27 +111,27 @@ "quickpods.infrastructure": { "type": "Project", "dependencies": { - "QuickPods.Contracts": "[0.1.0, )", - "QuickPods.Core": "[0.1.0, )" + "QuickPods.Contracts": "[0.1.1, )", + "QuickPods.Core": "[0.1.1, )" } }, "quickpods.presentation": { "type": "Project", "dependencies": { - "QuickPods.Contracts": "[0.1.0, )", - "QuickPods.Core": "[0.1.0, )" + "QuickPods.Contracts": "[0.1.1, )", + "QuickPods.Core": "[0.1.1, )" } }, "quickpods.taskbarhost": { "type": "Project", "dependencies": { - "QuickPods.Contracts": "[0.1.0, )" + "QuickPods.Contracts": "[0.1.1, )" } }, "quickpods.windows": { "type": "Project", "dependencies": { - "QuickPods.Core": "[0.1.0, )" + "QuickPods.Core": "[0.1.1, )" } } }