From 024d6e04b064c699508029cce86ef2f68b2a0038 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:32:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=9C=ED=97=98:=20=EC=A0=95=EC=8B=9D=20?= =?UTF-8?q?=ED=8C=90=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=84=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=86=EC=9D=B4=20=EB=81=9D=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?(tool/update=5Fpath=5Fcheck.ps1=20=C2=B7=20update=5Fpath=5Freal?= =?UTF-8?q?=5Ftest,=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=201?= =?UTF-8?q?0/20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - %TEMP%\jj_updtest_<표> 에 예전 판 (기본 release\stage_v2026.10.18_001) 배포 폴더와 그 판이 남겼을 데이터 (settings.json · 이 판이 모르는 키 · WebDAV 가짜 서버 jj_updtest_dav · videos.json · DPAPI secrets.dat 의 가짜 비밀번호) 를 만든다 - 통합 시험이 앱 안 업데이트 길 (시험 길 JJ_UPDATE_LOCAL_ZIP) 로 최신 확인 → [지금 설치] (가짜 누르기) → 받기 · SHA256 · 풀기 → 설정 보관 → 교체 예약 - flutter test 는 끝날 때 시험 앱이 띄운 교체 스크립트까지 끝낸다 (기록 없음 · 도는 것 0 으로 확인 - 사용자 판은 앱이 스스로 끝나 그런 일이 없음) - 그래서 앱이 써 둔 교체 스크립트를 앱이 넘긴 것과 같은 인수 · 환경으로 바깥에서 띄움 - 확인: 프로그램 교체 · 새 판이 저절로 켜짐 (따로 데이터라 시작 메뉴를 건드리지 않음) · lastRunVersion · 모르는 키 · WebDAV 서버 · 동영상 목록 · 비밀번호 (안전 저장소) · 평문 비밀번호 없음 (설정 · .bak · 보관본 · 기록) · 예전 판 보관본 · 사용자 바로 가기 그대로 · 시험 폴더 · 가짜 자격 증명 0개 - 결과 (10/21, 2026.10.18_001 → 2026.10.20_990 시험 빌드 = after_1020 5057267): 모두 통과 - 실제 GitHub · 사용자 %APPDATA% · 시작 메뉴 · 실행 중인 앱은 건드리지 않음. 환경 변수가 없으면 통합 시험은 건너뜀 Co-Authored-By: Claude Opus 5.5 --- integration_test/update_path_real_test.dart | 70 +++++++++ tool/update_path_check.ps1 | 157 ++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 integration_test/update_path_real_test.dart create mode 100644 tool/update_path_check.ps1 diff --git a/integration_test/update_path_real_test.dart b/integration_test/update_path_real_test.dart new file mode 100644 index 0000000..6b47b5e --- /dev/null +++ b/integration_test/update_path_real_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/app/version_snapshot.dart'; +import 'package:jj_mkvmaker/platform/windows/data_dir_override.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/github_updater.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:jj_mkvmaker/ui/update_dialog.dart'; +import 'package:path/path.dart' as p; + +/// 관리자 지시 (10/20): 정식 판 업데이트 길을 사용자 데이터 없이 끝까지 - 앱 안의 [지금 설치] 를 가짜 누르기로. +/// 바깥 스크립트 (tool/update_path_check.ps1) 가 준비 · 확인 · 정리를 하고, 이 시험은 그 사이의 앱 쪽만 한다: +/// 예전 판 배포 폴더 (JJ_UPD_APP) · 예전 판 데이터 (JJ_MKVMAKER_DATA) 에서 시험 길 (JJ_UPDATE_LOCAL_ZIP) 로 최신 확인 → 받기 · +/// SHA256 · 풀기 → 설정 보관 → 교체 예약 (이 시험 프로세스가 끝나면 교체하고 새 판을 띄움). +/// 실제 GitHub · 사용자 %APPDATA% · 시작 메뉴는 건드리지 않는다. 그 환경 변수가 없으면 건너뜀. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final env = Platform.environment; + final app = env['JJ_UPD_APP'] ?? ''; + final from = env['JJ_UPD_FROM'] ?? ''; + final skip = !Platform.isWindows || app.isEmpty || from.isEmpty || dataDirOverride == null || (env['JJ_UPDATE_LOCAL_ZIP'] ?? '').isEmpty + ? 'JJ_UPD_APP · JJ_UPD_FROM · JJ_MKVMAKER_DATA · JJ_UPDATE_LOCAL_ZIP 이 없음 (tool/update_path_check.ps1 이 준다)' + : null; + + testWidgets('업데이트 길: 시험 길로 [지금 설치] → 설정 보관 → 교체 예약', (t) async { + applyDataDirOverride(); + final data = dataDirOverride!; + VersionSnapshot.instance = VersionSnapshot(data); + final work = Directory(p.join(p.dirname(app), 'upd_tmp'))..createSync(recursive: true); + final up = GitHubUpdater(appDir: app, versionOverride: from, tempRoot: work.path); + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), updater: up), + settingsStore: SettingsStore()); + await t.runAsync(c.init); + expect(c.settings.lastRunVersion, from, reason: '예전 판이 쓴 데이터를 읽음'); + final r = (await t.runAsync(up.latest))!; + expect(r.name, contains('시험 길')); + t.view.physicalSize = const Size(1200, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(), + home: Builder(builder: (ctx) => Scaffold(body: Center(child: FilledButton(onPressed: () => installRelease(ctx, c, r, from), child: const Text('go'))))), + )); + await t.tap(find.text('go')); + Future until(bool Function() done, {int ms = 60000}) async { + for (var i = 0; i < ms ~/ 100 && !done(); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(); + } + } + + // 받기 · SHA256 · 풀기 (실제 시간) → "설치 준비 완료" + await until(() => find.text('지금 설치').evaluate().isNotEmpty); + expect(find.text('지금 설치'), findsOneWidget, reason: '받기 · 검증 끝'); + await t.tap(find.text('지금 설치')); + await until(() => File(p.join(work.path, 'jj_mkvmaker_update.ps1')).existsSync(), ms: 20000); + expect(File(p.join(work.path, 'jj_mkvmaker_update.ps1')).existsSync(), isTrue, reason: '교체 스크립트 예약'); + expect(VersionSnapshot.instance!.has(from), isTrue, reason: '지금 판 ($from) 설정 보관'); + // ignore: avoid_print + print('UPD_SCHEDULED pid=$pid'); + }, skip: skip != null, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/tool/update_path_check.ps1 b/tool/update_path_check.ps1 new file mode 100644 index 0000000..e09ba15 --- /dev/null +++ b/tool/update_path_check.ps1 @@ -0,0 +1,157 @@ +# 정식 판 업데이트 길 시험 (관리자 지시 10/20): 사용자 데이터 · 시작 메뉴 · 실제 GitHub 를 건드리지 않고 끝까지. +# 1. %TEMP%\jj_updtest_<표> 에 예전 판 배포 폴더 (-OldStage) 와 그 판이 남겼을 데이터 (설정 · 동영상 목록 · 비밀번호 (DPAPI secrets.dat, +# 가짜 서버 jj_updtest_dav) · 이 판이 모르는 설정 키) 를 만든다. +# 2. 통합 시험 integration_test\update_path_real_test.dart 가 앱 안 업데이트 길로 [지금 설치] (시험 길 JJ_UPDATE_LOCAL_ZIP = -NewZip). +# 3. 시험이 끝나면 앱의 교체 스크립트가 배포 폴더를 바꾸고 새 판을 띄운다 (따로 데이터라 시작 메뉴를 건드리지 않음 - 121dd43). +# 4. 새 판이 설정을 쓴 뒤 끄고 확인: 판 · 설정 · 모르는 키 · 동영상 목록 · 비밀번호 · 보관본 (비밀번호 없음) · 시작 메뉴 바로 가기 그대로. +# 5. 시험 폴더를 지우고 jj_updtest_* 가 0개인지 본다. +# -NewZip 은 시작 메뉴 막기 (121dd43) 가 든 판이어야 한다 (아니면 새 판이 켜질 때 사용자 바로 가기를 바꾼다 - 그래서 먼저 판을 본다). +# 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\update_path_check.ps1 -NewZip [-OldStage <폴더>] [-From 2026.10.18_001] +param( + [Parameter(Mandatory = $true)][string]$NewZip, + [string]$OldStage = '', + [string]$From = '2026.10.18_001', + [int]$WaitSeconds = 60 +) +$ErrorActionPreference = 'Stop' +$appSrc = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$root = Split-Path $appSrc # 앱 폴더 (app · wt_*) 의 위 = 루트 +if (-not $OldStage) { $OldStage = Join-Path $root "release\stage_v$From\JJ_MKVMaker" } +$NewZip = (Resolve-Path $NewZip).Path +if ((Split-Path $NewZip -Leaf) -notmatch '_v(\d{4}\.\d{2}\.\d{2}_\d{3})_win64\.zip$') { throw "zip 이름에서 판을 읽지 못함: $NewZip" } +$newVer = $Matches[1] +$sha = (Get-FileHash $NewZip -Algorithm SHA256).Hash.ToLower() +$fails = New-Object System.Collections.Generic.List[string] +function Check([string]$what, [bool]$ok, [string]$detail = '') { + if ($ok) { Write-Host "통과: $what $detail" } else { Write-Host "실패: $what $detail"; $fails.Add($what) } +} + +# 사용자 쪽 (읽기만): 시작 메뉴 바로 가기 · 주소 연결 · %TEMP% 의 jj 이름 +$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\JJ_MKVMaker.lnk' +function UserArea { + $t = if (Test-Path $lnk) { (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath + ' @' + (Get-Item $lnk).LastWriteTime.Ticks } else { '(없음)' } + $k = 'HKCU:\Software\Classes\jjmkvmaker\shell\open\command' + $r = if (Test-Path $k) { (Get-ItemProperty $k).'(default)' } else { '(없음)' } + "$t | $r" +} +$userBefore = UserArea +$sysTemp = [IO.Path]::GetTempPath() +$tempBefore = @(Get-ChildItem $sysTemp -Force -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) + +$tag = -join ((1..6) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) +$T = Join-Path $sysTemp "jj_updtest_$tag" +$app = "$T\app"; $data = "$T\data"; $media = "$T\media"; $tmp = "$T\temp" +New-Item -ItemType Directory $T, $data, $media, $tmp | Out-Null +Write-Host "시험 폴더: $T ($From → $newVer)" +$appProcs = { @(Get-Process jj_mkvmaker -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($T, [StringComparison]::OrdinalIgnoreCase) }) } +try { + # 1. 예전 판 배포 폴더 · 데이터 + robocopy $OldStage $app /E /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { throw "예전 판 복사 실패 $LASTEXITCODE" } + $oldVer = (Get-Item "$app\Lib\jj_mkvmaker.exe").VersionInfo.ProductVersion + $videos = @("$media\a.mp4", "$media\b.mkv") + foreach ($v in $videos) { [IO.File]::WriteAllBytes($v, [byte[]](1..64)) } + $pw = "jj_updtest-Pw-$tag" + $utf8 = New-Object Text.UTF8Encoding $false + [IO.File]::WriteAllText("$data\videos.json", (ConvertTo-Json -InputObject $videos -Compress), $utf8) + $settings = [ordered]@{ + lastRunVersion = $From + autoCheckUpdates = $false + startMenuShortcut = $true + webdavServers = @(@{ id = 'jj_updtest_dav'; name = 'jj_updtest_dav'; url = 'https://127.0.0.1:9/dav'; user = 'jj_updtest_user' }) + zz_jj_updtest_future = @{ keep = $true; n = 7 } + } + [IO.File]::WriteAllText("$data\settings.json", (ConvertTo-Json -InputObject $settings -Depth 5), $utf8) + Add-Type -AssemblyName System.Security + $plain = $utf8.GetBytes((ConvertTo-Json -InputObject @{ 'dav.pw.jj_updtest_dav' = $pw } -Compress)) + [IO.File]::WriteAllBytes("$data\secrets.dat", [Security.Cryptography.ProtectedData]::Protect($plain, $null, 'CurrentUser')) + + # 2. 앱 안 업데이트 길 (통합 시험) - 환경 변수는 이 시험 · 새 판에만 + $saved = @{} + $vars = @{ JJ_MKVMAKER_DATA = $data; JJ_UPDATE_LOCAL_ZIP = "$NewZip;$sha"; JJ_UPD_APP = $app; JJ_UPD_FROM = $From; TEMP = $tmp; TMP = $tmp } + foreach ($k in $vars.Keys) { $saved[$k] = [Environment]::GetEnvironmentVariable($k); [Environment]::SetEnvironmentVariable($k, $vars[$k]) } + Push-Location $appSrc + try { + # flutter 의 경고 (stderr) 를 오류로 보지 않게 (PowerShell 5.1 의 2>&1 + Stop) + $ErrorActionPreference = 'Continue' + $out = & flutter test integration_test\update_path_real_test.dart -d windows 2>&1 | ForEach-Object { "$_" } + $code = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + } finally { + Pop-Location + foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } + } + $out | Select-String 'UPD_SCHEDULED|All tests|Some tests|\[E\]|Expected|Actual' | ForEach-Object { Write-Host " $_" } + Check '앱 안 업데이트 길 ([지금 설치] · 설정 보관 · 교체 예약)' ($code -eq 0 -and ($out -match 'UPD_SCHEDULED')) "(exit $code)" + + # 3. 교체 → 새 판이 켜짐 → 설정을 씀 + # flutter test 는 시험이 끝나면 시험 앱과 그 앱이 띄운 프로세스 (교체 스크립트 포함) 를 함께 끝낸다 (15 확인 10/21: 기록 없음 · 도는 것 0). + # 사용자 판은 앱이 스스로 끝나 그런 일이 없다. 그래서 앱이 써 둔 교체 스크립트를 앱이 넘긴 것과 같은 인수 · 환경으로 여기서 띄운다 + $exe = "$app\Lib\jj_mkvmaker.exe" + $work = "$T\upd_tmp" + $ps1 = "$work\jj_mkvmaker_update.ps1" + $src = Get-ChildItem "$work\jj_mkvmaker_update_$newVer\extract" -Recurse -Filter jj_mkvmaker.exe -ErrorAction SilentlyContinue | + Sort-Object { $_.FullName.Split('\').Count } | Select-Object -First 1 + $testPid = if (($out -join "`n") -match 'UPD_SCHEDULED pid=(\d+)') { [int]$Matches[1] } else { 0 } + Check '앱이 교체 스크립트 · 풀어 둔 새 판을 남김' ((Test-Path $ps1) -and $src) "($ps1)" + if ((Test-Path $ps1) -and $src) { + foreach ($k in $vars.Keys) { [Environment]::SetEnvironmentVariable($k, $vars[$k]) } + try { + Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $ps1, + '-ParentPid', $testPid, '-Source', $src.DirectoryName, '-Target', $app, '-Exe', "$app\jj_mkvmaker.exe" | Out-Null + } finally { + foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } + } + } + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt 120 -and -not ((Get-Item $exe).VersionInfo.ProductVersion -ne $oldVer -and (& $appProcs).Count -gt 0)) { Start-Sleep -Milliseconds 500 } + $newPv = (Get-Item $exe).VersionInfo.ProductVersion + # 교체 스크립트의 흔적 (안 됐을 때 까닭을 보려고): 기록 · 아직 도는 스크립트 + $ulog = "$app\..\upd_tmp\jj_mkvmaker_update.log" + Write-Host (" 교체 기록: " + $(if (Test-Path $ulog) { ((Get-Content $ulog | Select-String 'Copied|Extras|Failed|ERROR' | Select-Object -First 4) -join ' | ') } else { '(없음 - 스크립트가 robocopy 까지 가지 않음)' })) + $ps = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -like '*jj_mkvmaker_update.ps1*' -and $_.CommandLine -like "*$T*" }) + Write-Host " 아직 도는 교체 스크립트: $($ps.Count)개" + Check '프로그램 교체' ($newPv -ne $oldVer) "($oldVer → $newPv)" + Check '새 판이 저절로 켜짐 (교체 스크립트)' ((& $appProcs).Count -gt 0) + $sw.Restart() + $s = $null + while ($sw.Elapsed.TotalSeconds -lt $WaitSeconds) { + try { $s = Get-Content -Raw -Encoding UTF8 "$data\settings.json" | ConvertFrom-Json } catch { $s = $null } + if ($s -and $s.lastRunVersion -eq $newVer) { break } + Start-Sleep -Milliseconds 500 + } + Start-Sleep -Seconds 3 + & $appProcs | ForEach-Object { $_.CloseMainWindow() | Out-Null } + Start-Sleep -Seconds 5 + & $appProcs | Stop-Process -Force -ErrorAction SilentlyContinue + + # 4. 확인 + $raw = Get-Content -Raw -Encoding UTF8 "$data\settings.json" + $s = $raw | ConvertFrom-Json + Check '판 기록 (lastRunVersion)' ($s.lastRunVersion -eq $newVer) "($($s.lastRunVersion))" + Check '이 판이 모르는 설정 키 그대로' ($s.zz_jj_updtest_future.keep -eq $true -and $s.zz_jj_updtest_future.n -eq 7) + Check 'WebDAV 서버 그대로' (@($s.webdavServers | Where-Object { $_.id -eq 'jj_updtest_dav' -and $_.user -eq 'jj_updtest_user' }).Count -eq 1) + $v2 = Get-Content -Raw -Encoding UTF8 "$data\videos.json" | ConvertFrom-Json + Check '동영상 목록 그대로' ((@($v2) -join '|') -eq ($videos -join '|')) "($(@($v2).Count)개)" + $dec = $utf8.GetString([Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes("$data\secrets.dat"), $null, 'CurrentUser')) | ConvertFrom-Json + Check '비밀번호 (안전 저장소) 그대로' ($dec.'dav.pw.jj_updtest_dav' -eq $pw) + $plainHits = @(Get-ChildItem $data -Recurse -File | Where-Object { $_.Name -ne 'secrets.dat' -and (Select-String -LiteralPath $_.FullName -SimpleMatch $pw -Quiet) } | ForEach-Object { $_.FullName.Substring($data.Length + 1) }) + Check '평문 비밀번호 없음 (설정 · .bak · 보관본 · 기록)' ($plainHits.Count -eq 0) ($plainHits -join ', ') + Check "예전 판 ($From) 설정 보관본" (Test-Path "$data\version_snapshots\$From\settings.json") + $userAfter = UserArea + Check '사용자 시작 메뉴 바로 가기 · jjmkvmaker:// 그대로' ($userAfter -eq $userBefore) "($userAfter)" +} finally { + & $appProcs | Stop-Process -Force -ErrorAction SilentlyContinue + for ($i = 0; $i -lt 30 -and (Test-Path -LiteralPath $T); $i++) { + if ($i -gt 0) { Start-Sleep -Seconds 2 } + cmd /c "rd /s /q `"\\?\$T`" 2>nul" + } + $left = @(Get-ChildItem $sysTemp -Force -Filter 'jj_updtest_*' -ErrorAction SilentlyContinue) + Check '시험 폴더 · 가짜 자격 증명 (jj_updtest_*) 0개' ($left.Count -eq 0) "($($left.Count)개)" + $tempNew = @(Get-ChildItem $sysTemp -Force -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name } | Where-Object { $tempBefore -notcontains $_ -and $_ -notlike 'jj_updtest_*' }) + # 참고만: 시험 · 새 판의 TEMP 는 시험 폴더로 돌렸으므로, 여기 새로 생긴 jj 이름은 다른 세션의 시험일 수 있다 + if ($tempNew.Count -gt 0) { Write-Host "참고 - %TEMP% 에 새로 생긴 jj 이름 (다른 세션일 수 있음): $($tempNew -join ', ')" } +} +if ($fails.Count -gt 0) { Write-Host "실패 $($fails.Count)개"; exit 1 } +Write-Host '업데이트 길: 모두 통과' +exit 0