diff --git a/README.md b/README.md index c371daf..ec70420 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,12 @@ ProxyWarden управляет службой только после точно .\release.cmd -PlanOnly # только JSON-план: без записи, сборки и сети .\release.cmd -Version 1.2.1 # версия без вопроса .\release.cmd -Version 1.2.1 -Resume # повторить только неудачный push +.\release.cmd -Version 2.0.0 -Replace # пересобрать ещё не выпущенную версию с заменой тега ``` -Не меняйте исходники во время сборки. Существующие теги не перезаписываются; при расхождении с удалённой веткой сценарий останавливается до изменения версий. При ошибке сборки изменения версии остаются локально для исправления, commit/tag/push не выполняются. При неудачном push готовая папка и локальный commit/tag сохраняются; `-Resume` проверяет исходники и SHA-256 перед повторной отправкой. +Не меняйте исходники во время сборки. По умолчанию существующие теги не перезаписываются; при расхождении с удалённой веткой сценарий останавливается до изменения версий. При ошибке сборки изменения версии остаются локально для исправления, commit/tag/push не выполняются. При неудачном push готовая папка и локальный commit/tag сохраняются; `-Resume` проверяет исходники и SHA-256 перед повторной отправкой. + +Если версия ещё не выложена пользователям, `-Version X.Y.Z -Replace` заново выполняет проверки и сборку с текущими изменениями. После сборки предыдущая папка сохраняется рядом как `proxywarden-vX.Y.Z-replaced-...`, а выбранный тег обновляется локально и в origin. История ветки сохраняется. Отправка использует `--force-with-lease` только для этого тега: если он изменился на сервере с начала операции, замена отклоняется. При сбое отправки используется обычный `-Version X.Y.Z -Resume`, который сохраняет первоначальное условие замены. `-Replace` требует явного номера версии и не совмещается с `-Resume`. Для локальной подготовки без commit/tag/push остаётся `scripts/prepare-release.ps1 -Version X.Y.Z`. Автоматические проверки не заменяют Windows VM/UAC/driver/routing acceptance: в manifest это отмечается отдельно. diff --git a/scripts/prepare-release.check.mjs b/scripts/prepare-release.check.mjs index 9b75c8a..8cd724a 100644 --- a/scripts/prepare-release.check.mjs +++ b/scripts/prepare-release.check.mjs @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, + readdirSync, writeFileSync, rmSync, } from "node:fs"; @@ -134,6 +135,17 @@ test("PlanOnly is offline and leaves versions/index/refs unchanged", (t) => { assert.equal(plan.details.targetVersion, "1.2.1"); assert.equal(f.git("status", "--porcelain"), before); assert.equal(f.git("rev-parse", "HEAD"), head); + const replacement = f.release("-Version", "1.2.0", "-Replace", "-PlanOnly"); + assert.equal(replacement.status, 0, replacement.stderr); + const replacementPlan = JSON.parse(replacement.stdout); + assert.equal(replacementPlan.changed, false); + assert.equal(replacementPlan.details.replace, true); + assert.equal( + replacementPlan.details.git.replaceOnlyVersionTagWithLease, + true, + ); + assert.equal(f.git("status", "--porcelain"), before); + assert.equal(f.git("rev-parse", "HEAD"), head); }); test("release commits exact dirty source, versions both locks, tags and atomically pushes", (t) => { @@ -172,6 +184,169 @@ test("failed build creates no commit/tag/push and preserves existing staging", ( assert.equal(f.git("tag", "--list"), ""); }); +for (const remoteOnly of [false, true]) { + test(`replacement rebuilds the same version and preserves the old folder (remote-only tag: ${remoteOnly})`, (t) => { + const f = fixture(t); + assert.equal(f.release("-Version", "1.2.1").status, 0); + const oldTag = f.git("rev-parse", "refs/tags/v1.2.1"); + const oldCommit = f.git("rev-parse", "HEAD"); + const oldManifest = f.manifest(); + if (remoteOnly) f.git("tag", "-d", "v1.2.1"); + f.write("feature.txt", "updated before publishing"); + + const result = f.release("-Version", "1.2.1", "-Replace"); + assert.equal(result.status, 0, result.stdout + result.stderr); + const newTag = f.git("rev-parse", "refs/tags/v1.2.1"); + assert.notEqual(newTag, oldTag); + assert.equal( + f.git("rev-parse", "v1.2.1^{commit}"), + f.git("rev-parse", "HEAD"), + ); + assert.equal( + f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0], + newTag, + ); + assert.equal(f.git("rev-parse", "HEAD~1"), oldCommit); + assert.equal(f.manifest().gitRelease.previousRemoteTag, oldTag); + assert.equal(f.manifest().gitRelease.status, "pushed"); + const backups = readdirSync(join(f.repo, "releases")).filter((name) => + name.startsWith("proxywarden-v1.2.1-replaced-"), + ); + assert.equal(backups.length, 1); + assert.deepEqual( + JSON.parse( + readFileSync( + join(f.repo, "releases", backups[0], "release-manifest.json"), + "utf8", + ), + ), + oldManifest, + ); + assert.equal( + readFileSync( + join( + f.repo, + "releases", + backups[0], + "artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe", + ), + "utf8", + ), + "test artifact", + ); + assert.equal(f.git("status", "--porcelain"), ""); + }); +} + +test("failed replacement build preserves the previous release and refs", (t) => { + const f = fixture( + t, + "if ($Replace) { throw 'Synthetic replacement build failure' }", + ); + assert.equal(f.release("-Version", "1.2.1").status, 0); + const oldManifest = f.manifest(); + const oldRefs = f.git("show-ref"); + f.write("feature.txt", "work in progress"); + const result = f.release("-Version", "1.2.1", "-Replace"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Synthetic replacement build failure/); + assert.equal(f.git("show-ref"), oldRefs); + assert.deepEqual(f.manifest(), oldManifest); + assert.deepEqual(readdirSync(join(f.repo, "releases")), [ + "proxywarden-v1.2.1", + ]); +}); + +test("a failed replacement push resumes using the original tag lease", (t) => { + const f = fixture(t); + assert.equal(f.release("-Version", "1.2.1").status, 0); + const oldRefs = f.git("ls-remote", "origin"); + f.write("feature.txt", "replacement"); + const hook = join(f.remote, "hooks/pre-receive"); + writeFileSync(hook, "#!/bin/sh\nexit 1\n"); + const result = f.release("-Version", "1.2.1", "-Replace"); + assert.notEqual(result.status, 0); + assert.equal(f.manifest().gitRelease.status, "pending-push"); + assert.equal(f.git("ls-remote", "origin"), oldRefs); + const replacementTag = f.git("rev-parse", "refs/tags/v1.2.1"); + rmSync(hook); + const resumed = f.release("-Version", "1.2.1", "-Resume"); + assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr); + assert.equal(f.manifest().gitRelease.status, "pushed"); + assert.equal( + f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0], + replacementTag, + ); + const repeat = f.release("-Version", "1.2.1", "-Resume"); + assert.equal(repeat.status, 0, repeat.stdout + repeat.stderr); +}); + +test("replacement never forces the branch when it advances during the build", (t) => { + const f = fixture( + t, + `if ($Replace) { + $otherCommit = 'Concurrent remote commit' | & git commit-tree 'HEAD^{tree}' -p HEAD + Invoke-Git @('push', 'origin', "${"$"}{otherCommit}:refs/heads/master") | Out-Null + }`, + ); + assert.equal(f.release("-Version", "1.2.1").status, 0); + const oldTag = f.git("ls-remote", "origin", "refs/tags/v1.2.1"); + f.write("feature.txt", "replacement"); + const result = f.release("-Version", "1.2.1", "-Replace"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /non-fast-forward|fetch first/); + const remoteBranch = f + .git("ls-remote", "origin", "refs/heads/master") + .split(/\s/)[0]; + assert.notEqual(remoteBranch, f.git("rev-parse", "HEAD")); + assert.equal( + f.git("show", "-s", "--format=%s", remoteBranch), + "Concurrent remote commit", + ); + assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), oldTag); +}); + +test("a concurrent remote tag change is preserved, including on Resume", (t) => { + const f = fixture( + t, + `if ($Replace) { + $otherCommit = Invoke-Git @('rev-parse', 'HEAD~1') + Invoke-Git @('--git-dir', (Join-Path $RepoRoot '../origin.git'), 'update-ref', 'refs/tags/v1.2.1', $otherCommit) | Out-Null + }`, + ); + assert.equal(f.release("-Version", "1.2.1").status, 0); + const oldBranch = f.git("ls-remote", "origin", "refs/heads/master"); + const concurrentTag = f.git("rev-parse", "HEAD~1"); + f.write("feature.txt", "replacement"); + const result = f.release("-Version", "1.2.1", "-Replace"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /stale info/); + const resumed = f.release("-Version", "1.2.1", "-Resume"); + assert.notEqual(resumed.status, 0); + assert.match(resumed.stderr, /stale info/); + assert.equal( + f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0], + concurrentTag, + ); + assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), oldBranch); +}); + +test("a concurrent local tag change is not overwritten by replacement", (t) => { + const f = fixture( + t, + "if ($Replace) { Invoke-Git @('tag', '-f', 'v1.2.1', 'HEAD~1') | Out-Null }", + ); + assert.equal(f.release("-Version", "1.2.1").status, 0); + const oldRemote = f.git("ls-remote", "origin"); + const concurrentTag = f.git("rev-parse", "HEAD~1"); + f.write("feature.txt", "replacement"); + const result = f.release("-Version", "1.2.1", "-Replace"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Local version tag changed/); + assert.equal(f.git("rev-parse", "refs/tags/v1.2.1"), concurrentTag); + assert.equal(f.git("ls-remote", "origin"), oldRemote); +}); + test("source edit during build refuses to tag an artifact from another tree", (t) => { const f = fixture( t, diff --git a/scripts/prepare-release.ps1 b/scripts/prepare-release.ps1 index 7971f13..f33c0eb 100644 --- a/scripts/prepare-release.ps1 +++ b/scripts/prepare-release.ps1 @@ -8,6 +8,7 @@ [switch]$PlanOnly, [switch]$Publish, [switch]$Resume, + [switch]$Replace, [switch]$Force ) @@ -373,12 +374,19 @@ function New-ReleaseDirectory { $releaseDir = Join-Path $root "proxywarden-v$TargetVersion" if (Test-Path -LiteralPath $releaseDir) { - if ($Publish -or -not $Force) { throw "Release directory already exists: $releaseDir. Use -Resume for a failed push, or choose another version." } + if (-not $Replace -and ($Publish -or -not $Force)) { throw "Release directory already exists: $releaseDir. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push." } if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) { - throw "Refusing to remove release directory outside OutputRoot: $releaseDir" + throw "Refusing to replace release directory outside OutputRoot: $releaseDir" + } + if ($Replace) { + $backupDir = "$releaseDir-replaced-$(Get-Date -Format 'yyyyMMdd-HHmmss')-$([guid]::NewGuid().ToString('N').Substring(0, 8))" + if (-not (Test-IsSubPath -Parent $root -Child $backupDir)) { throw 'Release backup must stay inside OutputRoot.' } + Move-Item -LiteralPath $releaseDir -Destination $backupDir + Write-Host "Предыдущая сборка сохранена: $backupDir" + } else { + Write-Host "Replacing existing release directory: $releaseDir" + Remove-Item -LiteralPath $releaseDir -Recurse -Force } - Write-Host "Replacing existing release directory: $releaseDir" - Remove-Item -LiteralPath $releaseDir -Recurse -Force } New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null @@ -654,16 +662,18 @@ function Get-ReleaseGitContext { Invoke-Git @('var', 'GIT_COMMITTER_IDENT') | Out-Null $remote = Invoke-Git @('remote', 'get-url', '--push', 'origin') $tag = "v$TargetVersion" - if (-not $Resume -and (Test-GitTag $tag)) { throw "Tag $tag already exists. Use -Version $TargetVersion -Resume for a failed push, or choose another version." } - $remoteTag = Invoke-Git @('ls-remote', '--tags', 'origin', "refs/tags/$tag", "refs/tags/$tag^{}") - if (-not $Resume -and $remoteTag) { throw "Remote tag $tag already exists. Choose another version." } + $localTag = if (Test-GitTag $tag) { Invoke-Git @('rev-parse', "refs/tags/$tag") } else { '' } + if (-not $Resume -and -not $Replace -and $localTag) { throw "Tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, -Resume to retry its push, or choose another version." } + $remoteTag = Invoke-Git @('ls-remote', '--refs', '--tags', 'origin', "refs/tags/$tag") + if (-not $Resume -and -not $Replace -and $remoteTag) { throw "Remote tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or choose another version." } + $remoteTagId = if ($remoteTag) { ($remoteTag -split '\s+')[0] } else { '' } $remoteBranch = Invoke-Git @('ls-remote', '--heads', 'origin', "refs/heads/$branch") if ($remoteBranch) { Invoke-Git @('fetch', '--no-tags', 'origin', "refs/heads/$branch") | Out-Null & git merge-base --is-ancestor FETCH_HEAD HEAD if ($LASTEXITCODE -ne 0) { throw "The origin/$branch branch has changes not in HEAD. Integrate them before releasing; automatic merge is not performed." } } - return @{ branch = $branch; head = $headCommit; remote = $remote; tag = $tag } + return @{ branch = $branch; head = $headCommit; remote = $remote; tag = $tag; replace = [bool]$Replace; previousLocalTag = $localTag; previousRemoteTag = $remoteTagId } } function Complete-ReleaseGit { @@ -692,13 +702,27 @@ function Push-Release { (Invoke-Git @('remote', 'get-url', '--push', 'origin')) -ne $Context.remote) { throw 'HEAD, branch or origin changed before push.' } - if (Test-GitTag $Context.tag) { - if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) { throw 'Existing tag points to another commit.' } + $localTag = if (Test-GitTag $Context.tag) { Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)") } else { '' } + if ($Context.replace -and $localTag -ne $Context.previousLocalTag -and + (-not $Resume -or -not $localTag -or (Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit)) { + throw 'Local version tag changed during the release. Replacement refused.' + } + if ($localTag) { + if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) { + if (-not $Context.replace) { throw 'Existing tag points to another commit.' } + Invoke-Git @('tag', '-a', '-f', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null + } } else { Invoke-Git @('tag', '-a', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null } - # One atomic push; never force or push unrelated tags. A failure leaves a resumable local release. - Invoke-Git @('push', '--atomic', 'origin', "${Commit}:refs/heads/$($Context.branch)", "refs/tags/$($Context.tag):refs/tags/$($Context.tag)") | Write-Host + $tagObject = Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)") + $pushArgs = @('push', '--atomic') + if ($Context.replace) { + # Lease only this tag, never the branch. Keep the original expectation across Resume. + $pushArgs += "--force-with-lease=refs/tags/$($Context.tag):$($Context.previousRemoteTag)" + } + $pushArgs += @('origin', "${Commit}:refs/heads/$($Context.branch)", "${tagObject}:refs/tags/$($Context.tag)") + Invoke-Git $pushArgs | Write-Host } function Resume-Release { @@ -723,6 +747,11 @@ function Resume-Release { if (-not (Test-IsSubPath $releaseDir $path) -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $artifact.sha256) { throw 'Release artifact checksum mismatch.' } } + if ($manifest.gitRelease.PSObject.Properties['replace'] -and $manifest.gitRelease.replace) { + $Context.replace = $true + $Context.previousLocalTag = $manifest.gitRelease.previousLocalTag + $Context.previousRemoteTag = $manifest.gitRelease.previousRemoteTag + } Push-Release -Context $Context -Commit $manifest.gitCommit $manifest.gitRelease.status = 'pushed' Write-JsonFile -Path $manifestPath -Value $manifest @@ -813,6 +842,7 @@ function New-PlanResult { skipBuild = [bool]$SkipBuild publish = [bool]$Publish resume = [bool]$Resume + replace = [bool]$Replace git = [ordered]@{ branch = Get-GitValue @('symbolic-ref', '--quiet', '--short', 'HEAD') remote = 'origin' @@ -820,6 +850,8 @@ function New-PlanResult { includedChanges = Get-GitValue @('status', '--short') commitAfterSuccessfulBuild = [bool]$Publish atomicPush = [bool]$Publish + replaceOnlyVersionTagWithLease = [bool]$Replace + preservePreviousReleaseDirectory = [bool]$Replace } manifests = @( $PackageJsonPath, @@ -845,6 +877,7 @@ try { Push-Location $RepoRoot if ($Resume -and (-not $Publish -or -not $Version -or $Bump)) { throw '-Resume requires -Publish -Version X.Y.Z.' } + if ($Replace -and (-not $Publish -or -not $Version -or $Bump -or $Resume)) { throw '-Replace requires -Publish -Version X.Y.Z and cannot be combined with -Resume or -Bump.' } if ($Version -and $Bump) { throw 'Use either -Version or -Bump.' } if ($Publish -and -not $PlanOnly -and ($SkipTests -or $SkipBuild -or $Force)) { throw 'A published release requires checks and a fresh build; SkipTests, SkipBuild and Force are not allowed.' } if ($Publish -and -not $PlanOnly -and -not $Resume) { @@ -869,6 +902,7 @@ try { Write-Host "" Write-Host "Preparing ProxyWarden release $targetVersion..." Write-Host "Repository: $RepoRoot" + if ($Replace) { Write-Host "Пересборка невыпущенного релиза v$targetVersion с заменой тега. Предыдущая папка будет сохранена рядом." } $gitContext = $null if ($Publish) { @@ -876,8 +910,8 @@ try { if ($Resume) { Resume-Release -TargetVersion $targetVersion -Context $gitContext; return } } $releasePath = Get-ReleasePath $targetVersion - if ((Test-Path -LiteralPath $releasePath) -and ($Publish -or -not $Force)) { - throw "Release directory already exists: $releasePath. Use -Resume for a failed push, or choose another version." + if ((Test-Path -LiteralPath $releasePath) -and -not $Replace -and ($Publish -or -not $Force)) { + throw "Release directory already exists: $releasePath. Use -Version $targetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push." } if ($Publish -and (Test-IsSubPath $RepoRoot $releasePath)) { & git check-ignore --quiet -- (Join-Path $releasePath 'release-manifest.json') @@ -907,7 +941,7 @@ try { if ($Publish) { $commit = Complete-ReleaseGit -Context $gitContext -SourceTree $sourceTree -TargetVersion $targetVersion - $gitRelease = [ordered]@{ branch = $gitContext.branch; remote = $gitContext.remote; tag = $gitContext.tag; sourceTree = $sourceTree; status = 'pending-push' } + $gitRelease = [ordered]@{ branch = $gitContext.branch; remote = $gitContext.remote; tag = $gitContext.tag; sourceTree = $sourceTree; status = 'pending-push'; replace = $gitContext.replace; previousLocalTag = $gitContext.previousLocalTag; previousRemoteTag = $gitContext.previousRemoteTag } Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease try { Push-Release -Context $gitContext -Commit $commit } catch { throw "Push failed; local release is preserved. Retry: .\release.cmd -Version $targetVersion -Resume. $($_.Exception.Message)" } diff --git a/src-tauri/src/singbox_runtime/native_tests.rs b/src-tauri/src/singbox_runtime/native_tests.rs index 9a0c445..9f19849 100644 --- a/src-tauri/src/singbox_runtime/native_tests.rs +++ b/src-tauri/src/singbox_runtime/native_tests.rs @@ -359,6 +359,35 @@ fn collision_and_unsafe_zip_reach_no_mutating_host_or_service_runner() { assert!(unsafe_host.calls.is_empty()); } +#[test] +fn fresh_install_rejects_invalid_service_xml_before_committing_ownership() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (runtime_path, runtime_proof) = runtime_package(&temp, "1.13.19", PackageSource::Bundled); + let (wrapper_path, wrapper_proof) = wrapper_package(&temp); + let mut promoted = promoted_snapshot( + &root, + "1.13.19", + "2.12.0", + SingBoxNativeServiceState::Stopped, + ); + assert!(!promoted.receipt_valid && !promoted.marker_valid); + promoted.service_xml_matches = false; + let mut host = FakeHost::new(root.clone(), vec![missing_snapshot(&root), promoted]); + + let error = install_singbox_native_core( + &mut host, + package_view(&runtime_path, &runtime_proof), + package_view(&wrapper_path, &wrapper_proof), + ) + .expect_err("invalid XML must fail before committing ownership"); + + assert_eq!(error, SingBoxNativeError::OwnershipMismatch); + assert!(host.metadata.is_none()); + assert_eq!(host.rollback, Some((SingBoxNativeMode::Install, true))); + assert!(!host.calls.contains(&CallKind::StartService)); +} + #[test] fn invalid_receipt_and_config_mismatch_run_no_service_mutation() { let temp = TestDir::new(); diff --git a/src-tauri/src/singbox_runtime/system.rs b/src-tauri/src/singbox_runtime/system.rs index 9e4df38..df4400e 100644 --- a/src-tauri/src/singbox_runtime/system.rs +++ b/src-tauri/src/singbox_runtime/system.rs @@ -1257,7 +1257,10 @@ impl SingBoxNativeHost for SystemSingBoxNativeHost { let mut receipt_files_match = false; let mut marker_valid = false; let mut marker_files_match = false; - let mut service_xml_matches = false; + // Freshly promoted files are checked before the receipt/marker is committed. + // XML validity must not depend on that later ownership metadata. + let service_xml_matches = + root_exists && install_root_trusted && verify_service_xml(self.root.path()).is_ok(); let mut marker = None; if root_exists && install_root_trusted { if let Ok((verified_receipt, verified_marker, receipt_match, marker_match)) = @@ -1267,7 +1270,6 @@ impl SingBoxNativeHost for SystemSingBoxNativeHost { marker_valid = true; receipt_files_match = receipt_match; marker_files_match = marker_match; - service_xml_matches = verify_service_xml(self.root.path()).is_ok(); self.last_receipt = Some(verified_receipt); self.last_marker = Some(verified_marker.clone()); marker = Some(verified_marker); @@ -1284,7 +1286,7 @@ impl SingBoxNativeHost for SystemSingBoxNativeHost { self.promoted_has_runtime_config, ) .is_ok() - && verify_service_xml(self.root.path()).is_ok() + && service_xml_matches } else { receipt_files_match && marker_files_match && service_xml_matches }; diff --git a/src/app/App.tsx b/src/app/App.tsx index cd1d7e9..ebe2174 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -37,7 +37,6 @@ import { type CommandError, type LocalSingBoxStatusResponse, type PingServerResponse, - type ProxyTargetCheckResponse, type ProxiFyreSetupStatus, type SingBoxSetupStatus, } from "../api/tauriCommands"; @@ -105,9 +104,7 @@ import { routeChainSegments, safeProxyError, pingTone, - proxyCheckNoticeKind, - proxyCheckNoticeTitle, - proxyCheckText, + type ProxyCheckResult, changesApplyButtonLabel, routeProxyCheckTarget, profileItemInput, @@ -230,9 +227,7 @@ export function App() { const [serverPings, setServerPings] = useState< Record >({}); - const [proxyCheck, setProxyCheck] = useState( - null, - ); + const [proxyCheck, setProxyCheck] = useState(null); const [adminStatus, setAdminStatus] = useState( null, ); @@ -374,7 +369,6 @@ export function App() { singbox, routeMode: savedSnapshot?.routeMode ?? routeMode, singBoxStatus, - proxyCheck, artifacts, }); @@ -1202,11 +1196,7 @@ export function App() { try { target = routeProxyCheckTarget(routeMode, proxyInput, singBoxStatus); } catch (error) { - showNotice({ - kind: "error", - title: "Маршрут не проверен", - text: errorMessage(error), - }); + setProxyCheck({ failure: errorMessage(error) }); return; } @@ -1223,18 +1213,9 @@ export function App() { const result = await pingProxyTarget(target.host, target.port); if (!current()) return; setProxyCheck(result); - showNotice({ - kind: proxyCheckNoticeKind(result), - title: proxyCheckNoticeTitle(result), - text: proxyCheckText(result), - }); } catch (error) { if (!current()) return; - showNotice({ - kind: "error", - title: "Маршрут не проверен", - text: errorMessage(error), - }); + setProxyCheck({ failure: errorMessage(error) }); } finally { if (request === probeRequest.current) setIsProxyChecking(false); } diff --git a/src/app/App.view.test.ts b/src/app/App.view.test.ts index 6219bc4..8d5cef3 100644 --- a/src/app/App.view.test.ts +++ b/src/app/App.view.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { ApplyConfigurationResult, PingServerResponse, + ProxyTargetCheckResponse, } from "../api/tauriCommands"; import type { ComponentStatus } from "../domain/types"; import { @@ -10,6 +11,8 @@ import { pingSummary, routeChainSegments, summaryRouteChainSegments, + systemSummaryState, + type SummaryStateInput, } from "./viewModel"; const runningProxiFyre: ComponentStatus = { @@ -129,9 +132,9 @@ describe("App view helpers", () => { ip: "203.0.113.10", }, { - id: "ipify", - name: "ipify", - url: "https://api.ipify.org", + id: "cloudflare-speed", + name: "Cloudflare Speed", + url: "https://speed.cloudflare.com/meta", ok: false, error: "timeout", }, @@ -147,12 +150,13 @@ describe("App view helpers", () => { expect(view).toMatchObject({ checked: true, endpoint: "192.168.50.111:8080", - tone: "warning", + tone: "muted", + title: "Ответили 1 из 2 контрольных точек", }); expect(view.probes.map((probe) => probe.id)).toEqual([ "tcp", "cloudflare-trace", - "ipify", + "cloudflare-speed", ]); expect(view.details).toContainEqual({ label: "Cloudflare Trace", @@ -163,6 +167,120 @@ describe("App view helpers", () => { latency: "21 ms", status: "Доступна", }); + expect(view.probes[2]).toMatchObject({ + label: "CF speed", + tone: "error", + status: "Ошибка", + detail: "https://speed.cloudflare.com/meta · timeout", + }); + }); + + it("keeps diagnostic failures separate from service and configuration status", () => { + const ready: SummaryStateInput = { + isLoading: false, + isDetectingComponents: false, + proxyfier: runningProxiFyre, + singbox: undefined, + routeMode: "external", + singBoxStatus: null, + artifacts: [ + { + component: "proxyfier", + sourceMatchesPrepared: true, + generatedExists: true, + activation: "confirmed", + }, + ], + }; + const failed: ProxyTargetCheckResponse = { + tag: "external", + server: "proxy.example.test", + serverPort: 1080, + ok: false, + error: "timeout", + probes: [], + }; + + for (const proxyCheck of [ + null, + failed, + { failure: "Command failed" }, + { ...failed, ok: true }, + ]) { + const input = { ...ready, proxyCheck }; + expect(systemSummaryState(input)).toMatchObject({ tone: "ok" }); + expect( + systemSummaryState({ + ...input, + proxyfier: { ...runningProxiFyre, state: "stopped", running: false }, + }), + ).toMatchObject({ tone: "warning", title: "Требует внимания" }); + } + }); + + it("keeps the TCP success when all HTTPS probes fail", () => { + const view = connectionCheckView({ + routeMode: "external", + proxyInput: "proxy.example.test:1080", + proxyCheck: { + tag: "external", + server: "proxy.example.test", + serverPort: 1080, + ok: false, + latency: 14, + error: "HTTPS probes failed", + probes: [ + { + id: "cloudflare-speed", + name: "Cloudflare Speed", + url: "https://speed.cloudflare.com/meta", + ok: false, + error: "timeout", + }, + ], + }, + singbox: undefined, + singBoxStatus: null, + selectedServer: null, + isDetectingComponents: false, + isProxyChecking: false, + }); + + expect(view).toMatchObject({ + tone: "muted", + title: "Ответили 0 из 1 контрольных точек", + }); + expect(view.probes[0]).toMatchObject({ + id: "tcp", + tone: "ok", + status: "Доступен", + latency: "14 ms", + detail: "TCP-соединение с SOCKS5 endpoint", + }); + expect(view.probes[1]).toMatchObject({ tone: "error", status: "Ошибка" }); + }); + + it("reports a diagnostic command failure without inventing failed probes", () => { + const view = connectionCheckView({ + routeMode: "external", + proxyInput: "proxy.example.test:1080", + proxyCheck: { failure: "Проверка прервана: timeout" }, + singbox: undefined, + singBoxStatus: null, + selectedServer: null, + isDetectingComponents: false, + isProxyChecking: false, + }); + + expect(view).toMatchObject({ + tone: "muted", + title: "Проверка не выполнена", + text: "Проверка прервана: timeout", + checked: true, + loading: false, + probes: [], + }); + expect(view.disabledReason).toBeUndefined(); }); it("shows the result surface while the route check is running", () => { diff --git a/src/app/components/ConnectionCheckPanel.test.tsx b/src/app/components/ConnectionCheckPanel.test.tsx index 535bb42..676b1a4 100644 --- a/src/app/components/ConnectionCheckPanel.test.tsx +++ b/src/app/components/ConnectionCheckPanel.test.tsx @@ -1,13 +1,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { ConnectionCheckView } from "../viewModel"; +import { connectionCheckView, type ConnectionCheckView } from "../viewModel"; import { ConnectionCheckPanel } from "./ConnectionCheckPanel"; describe("ConnectionCheckPanel", () => { it("renders the result summary, every probe, and hover details", () => { const check: ConnectionCheckView = { - tone: "warning", - title: "Частичный ответ", + tone: "muted", + title: "Ответили 2 из 3 контрольных точек", text: "Доступны 2 из 3 контрольных точек.", endpoint: "192.168.50.111:8080", endpointLabel: "Внешний SOCKS5", @@ -64,4 +64,27 @@ describe("ConnectionCheckPanel", () => { expect(markup).toContain("Технические детали"); expect(markup).toContain('aria-live="polite"'); }); + + it("shows a command failure inline and allows retry without an empty tooltip", () => { + const check = connectionCheckView({ + routeMode: "external", + proxyInput: "proxy.example.test:1080", + proxyCheck: { failure: "Проверка прервана: timeout" }, + singbox: undefined, + singBoxStatus: null, + selectedServer: null, + isDetectingComponents: false, + isProxyChecking: false, + }); + const markup = renderToStaticMarkup( + undefined} />, + ); + + expect(markup).toContain("Проверка не выполнена"); + expect(markup).toContain("Проверка прервана: timeout"); + expect(markup).toContain("Проверить"); + expect(markup).not.toContain("disabled="); + expect(markup).not.toContain('role="tooltip"'); + expect(markup).not.toContain("Требует внимания"); + }); }); diff --git a/src/app/components/ConnectionCheckPanel.tsx b/src/app/components/ConnectionCheckPanel.tsx index c0d0a1f..7b73132 100644 --- a/src/app/components/ConnectionCheckPanel.tsx +++ b/src/app/components/ConnectionCheckPanel.tsx @@ -14,6 +14,7 @@ export function ConnectionCheckPanel({ const detailsId = useId(); const buttonDisabled = Boolean(check.disabledReason); const showResult = check.loading || check.checked; + const hasDetails = check.details.length > 0 || check.probes.length > 0; return (
@@ -61,7 +62,9 @@ export function ConnectionCheckPanel({
- {!check.loading ? ( + {!check.loading && hasDetails ? ( Подробнее при наведении @@ -125,7 +128,7 @@ export function ConnectionCheckPanel({
) : null} - {!check.loading ? ( + {!check.loading && hasDetails ? (
!probe.ok)) return "warning"; + if (!check.ok || check.probes.some((probe) => !probe.ok)) return "muted"; return "ok"; } -export function proxyCheckNoticeKind( - check: ProxyTargetCheckResponse, -): Notice["kind"] { - if (!check.ok) return "error"; - return check.probes.some((probe) => !probe.ok) ? "info" : "success"; -} - export function proxyCheckTitle(check: ProxyTargetCheckResponse) { - if (!check.ok) return "Маршрут не прошел"; + if (!check.probes.length) + return check.ok ? "Прокси ответил" : "Не удалось подключиться к прокси"; const okCount = check.probes.filter((probe) => probe.ok).length; - if (check.probes.length && okCount < check.probes.length) - return "Маршрут частично отвечает"; - return "Маршрут отвечает"; -} - -export function proxyCheckNoticeTitle(check: ProxyTargetCheckResponse) { - if (!check.ok) return "Проверка маршрута не прошла"; - const okCount = check.probes.filter((probe) => probe.ok).length; - if (check.probes.length && okCount < check.probes.length) - return "Проверка частично прошла"; - return "Проверка маршрута прошла"; + return `Ответили ${okCount} из ${check.probes.length} контрольных точек`; } export function proxyCheckText(check: ProxyTargetCheckResponse) { @@ -635,11 +631,14 @@ export function proxyCheckProbes( { id: "tcp", label: "SOCKS5", - tone: check.error && !check.ok ? "error" : "ok", - status: check.ok ? "Доступен" : "Ошибка", + tone: check.ok || check.probes.length > 0 ? "ok" : "error", + status: check.ok || check.probes.length > 0 ? "Доступен" : "Нет ответа", ip: null, latency: check.latency != null ? `${check.latency} ms` : null, - detail: check.error ?? "TCP-соединение с SOCKS5 endpoint", + detail: + check.probes.length > 0 + ? "TCP-соединение с SOCKS5 endpoint" + : (check.error ?? "TCP-соединение с SOCKS5 endpoint"), }, ...check.probes.map((probe) => ({ id: probe.id,