From ebea6ddbb6915a7120477a2a1ade6aed5ec538a3 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:49:50 +0900 Subject: [PATCH] =?UTF-8?q?v2026.10.01=5F002:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EA=B5=AC=EC=A1=B0=20(=EB=A7=A8=20?= =?UTF-8?q?=EC=9C=84=203=EA=B0=9C=20=ED=8C=8C=EC=9D=BC=20+=20Lib=20+=20Log?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시작 프로그램 (windows/launcher, C 런타임 정적 연결): 맨 위 jj_mkvmaker.exe 가 Lib\jj_mkvmaker.exe 를 같은 인수로 실행 - tool/build_launcher.ps1, tool/make_layout.ps1 (배포 폴더 · 릴리스 zip 배치) - 작업 기록 · 종료 기록을 배포 폴더의 Logs 에 저장 (AppPaths) - 업데이트: 배포 폴더 맨 위에 설치, 압축 파일에서 가장 위의 jj_mkvmaker.exe 기준 - 예전 한 폴더 구조에서 넘어오면 맨 위에 남은 예전 프로그램 파일 정리 - 끌어다 놓기 테스트: PC 가 바쁠 때도 통과하도록 대기 시간 늘림 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lib/main.dart | 14 +++-- lib/platform/windows/app_paths.dart | 74 ++++++++++++++++++++++++ lib/platform/windows/github_updater.dart | 19 +++--- pubspec.yaml | 2 +- test/drop_test.dart | 5 +- tool/build_launcher.ps1 | 27 +++++++++ tool/make_layout.ps1 | 46 +++++++++++++++ windows/launcher/launcher.cpp | 65 +++++++++++++++++++++ windows/launcher/launcher.rc | 31 ++++++++++ 10 files changed, 270 insertions(+), 15 deletions(-) create mode 100644 lib/platform/windows/app_paths.dart create mode 100644 tool/build_launcher.ps1 create mode 100644 tool/make_layout.ps1 create mode 100644 windows/launcher/launcher.cpp create mode 100644 windows/launcher/launcher.rc diff --git a/README.md b/README.md index dbb255a..5d28c6b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ 1. [Releases](../../releases) 에서 `JJ_MKVMaker_v*_win64.zip` 을 받아 원하는 폴더에 풉니다. 2. `jj_mkvmaker.exe` 를 실행합니다. (Windows 10/11 64비트) -3. 폴더 전체가 프로그램입니다. exe 파일 하나만 옮기면 동작하지 않습니다. +3. 폴더 전체가 프로그램입니다. 맨 위의 `jj_mkvmaker.exe` 는 `Lib\` 안의 프로그램을 실행하는 시작 프로그램이고, 작업 기록은 `Logs\` 에 남습니다. exe 파일 하나만 옮기면 동작하지 않습니다. 처음 쓸 때 내려받는 것 (한 번만): diff --git a/lib/main.dart b/lib/main.dart index aa27043..6772fd2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'app/download_manager.dart'; import 'app/settings.dart'; import 'core/app_update.dart'; import 'core/playlist.dart'; +import 'platform/windows/app_paths.dart'; import 'platform/windows/com_guard.dart'; import 'platform/windows/desktop_shell.dart'; import 'platform/windows/exit_trace.dart'; @@ -59,16 +60,22 @@ Future main(List args) async { } final services = PlatformServices.create(); + // 작업 기록: 배포 폴더의 Logs (쓸 수 없으면 설정 폴더 아래 Logs) + final logsDir = AppPaths.logsDir(dataDir); final controller = AppController(services, settingsStore: SettingsStore()) - ..logFile = p.join(dataDir, 'app.log'); + ..logFile = p.join(logsDir, 'app.log'); + ExitTrace.file = p.join(logsDir, 'exit.log'); // 지난 실행이 정상으로 끝났는지 (아니면 알려 주고 기록에 남긴다) final session = SessionMarker(p.join(dataDir, 'session.json')); final crashed = session.start(); - controller.note('── 시작 $appTitle ──'); + controller.note('── 시작 $appTitle (${AppPaths.exeDir}) ──'); + // 예전 구조 (한 폴더) 에서 Lib 구조로 바뀐 뒤 맨 위에 남은 예전 프로그램 파일 정리 + final cleaned = AppPaths.cleanupOldLayout(); + if (cleaned.isNotEmpty) controller.note('예전 구조의 프로그램 파일 정리 (${cleaned.length}개): ${cleaned.join(', ')}'); if (crashed != null) { String hm(DateTime t) => '${t.month}/${t.day} ${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; controller.note('⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 ${hm(crashed.$1)}, 마지막 확인 ${hm(crashed.$2)}). ' - '그 전의 기록은 설정 폴더의 app.log 에 있습니다.'); + '그 전의 기록은 Logs 폴더의 app.log 에 있습니다.'); } await controller.init(); @@ -115,7 +122,6 @@ Future main(List args) async { final ctx = navigatorKey.currentContext; if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return false; } - ExitTrace.file = p.join(dataDir, 'exit.log'); ExitTrace.start(); final job = controller.currentJob; final wait = controller.pendingJobs.length; diff --git a/lib/platform/windows/app_paths.dart b/lib/platform/windows/app_paths.dart new file mode 100644 index 0000000..638eb94 --- /dev/null +++ b/lib/platform/windows/app_paths.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// 배포 폴더 구조 +/// +/// JJ_MKVMaker\jj_mkvmaker.exe 시작 프로그램 (Lib\jj_mkvmaker.exe 를 실행) +/// JJ_MKVMaker\LICENSE.txt · README.md +/// JJ_MKVMaker\Lib\ 실제 프로그램 · DLL · data · ffmpeg · tools (← 지금 실행 중인 exe 가 있는 곳) +/// JJ_MKVMaker\Logs\ 작업 기록 (app.log · exit.log) +/// +/// 예전처럼 한 폴더에 모두 있는 구조 (exe 옆에 Lib 가 없음) 에서도 그대로 동작한다. +class AppPaths { + /// 지금 실행 중인 exe 가 있는 폴더 (프로그램 파일) + static String get exeDir => p.dirname(Platform.resolvedExecutable); + + /// 배포 폴더 (맨 위). exe 가 Lib 안에 있으면 그 위 폴더. + static String get root { + final dir = exeDir; + return p.basename(dir).toLowerCase() == 'lib' ? p.dirname(dir) : dir; + } + + /// Lib 구조로 실행 중인지 + static bool get inLib => root != exeDir; + + /// 작업 기록 폴더: 배포 폴더의 Logs (쓸 수 없으면 [fallback] 아래 Logs) + static String logsDir(String fallback) { + for (final dir in [p.join(root, 'Logs'), p.join(fallback, 'Logs')]) { + try { + Directory(dir).createSync(recursive: true); + final probe = File(p.join(dir, '.jj_write_test'))..writeAsStringSync('x'); + probe.deleteSync(); + return dir; + } catch (_) {} + } + return fallback; + } + + /// 예전 구조 (한 폴더) 에서 Lib 구조로 업데이트한 뒤 배포 폴더 맨 위에 남은 예전 프로그램 파일을 지운다. + /// 지우는 것: 프로그램이 넣었던 것으로 확인되는 것만 (DLL · data · ffmpeg · tools · 고지 파일). + /// 받은 동영상 · 모델 · 사용자가 둔 파일은 건드리지 않는다. 지운 이름 목록을 돌려준다. + static List cleanupOldLayout() { + if (!inLib) return const []; + final top = root; + final removed = []; + void del(FileSystemEntity e) { + try { + e.deleteSync(recursive: true); + removed.add(p.basename(e.path)); + } catch (_) {} + } + + // 맨 위의 DLL: Lib 에도 같은 이름이 있는 것만 (= 프로그램 부속품) + try { + for (final e in Directory(top).listSync()) { + final name = p.basename(e.path); + final lower = name.toLowerCase(); + if (e is File && + (lower.endsWith('.dll') || lower == 'third_party_notices.txt' || lower == 'native_assets.json') && + File(p.join(exeDir, name)).existsSync()) { + del(e); + } + } + } catch (_) {} + // 폴더: 프로그램 것인지 안의 대표 파일로 확인 + for (final (dir, marker) in [('data', 'app.so'), ('ffmpeg', 'ffmpeg.exe'), ('tools', 'yt-dlp.exe')]) { + final d = Directory(p.join(top, dir)); + if (d.existsSync() && File(p.join(d.path, marker)).existsSync() && Directory(p.join(exeDir, dir)).existsSync()) { + del(d); + } + } + return removed; + } +} diff --git a/lib/platform/windows/github_updater.dart b/lib/platform/windows/github_updater.dart index c06b098..43aa3c1 100644 --- a/lib/platform/windows/github_updater.dart +++ b/lib/platform/windows/github_updater.dart @@ -5,6 +5,8 @@ import 'package:crypto/crypto.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; +import 'app_paths.dart'; + import '../../core/app_update.dart'; import '../../services/updater.dart'; @@ -24,7 +26,7 @@ class GitHubUpdater implements Updater { this.apiBase = 'https://api.github.com', String? appDir, this.versionOverride, - }) : appDir = appDir ?? p.dirname(Platform.resolvedExecutable); + }) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위 @override Future currentVersion() async { @@ -98,13 +100,16 @@ class GitHubUpdater implements Updater { if (t.exitCode != 0) throw UpdateException('압축을 풀 수 없습니다: ${t.stderr}'); await zip.delete(); final exeName = p.basename(Platform.resolvedExecutable).toLowerCase(); - final exe = await out + // 배포 폴더 맨 위 = 가장 얕은 곳의 jj_mkvmaker.exe (Lib 구조면 시작 프로그램, 예전 구조면 프로그램 자신) + final exes = await out .list(recursive: true) - .firstWhere((e) => e is File && p.basename(e.path).toLowerCase() == 'jj_mkvmaker.exe' || - e is File && p.basename(e.path).toLowerCase() == exeName, - orElse: () => throw const UpdateException('압축 파일 안에 프로그램이 없습니다.')); + .where((e) => e is File && {'jj_mkvmaker.exe', exeName}.contains(p.basename(e.path).toLowerCase())) + .map((e) => e.path) + .toList(); + if (exes.isEmpty) throw const UpdateException('압축 파일 안에 프로그램이 없습니다.'); + exes.sort((a, b) => p.split(a).length.compareTo(p.split(b).length)); onProgress(1); - return p.dirname(exe.path); + return p.dirname(exes.first); } /// 파일 교체 스크립트 (영문만: Windows PowerShell 5.1 이 BOM 없는 UTF-8 을 잘못 읽는 문제 방지) @@ -130,7 +135,7 @@ exit 0 [ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1.path, '-ParentPid', '$waitPid', '-Source', source, '-Target', appDir, - '-Exe', p.join(appDir, p.basename(Platform.resolvedExecutable)), + '-Exe', p.join(appDir, 'jj_mkvmaker.exe'), if (!restart) '-NoRestart', ], mode: restart ? ProcessStartMode.detached : ProcessStartMode.normal, diff --git a/pubspec.yaml b/pubspec.yaml index 0b4401d..4c3c30d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.10.1+1 +version: 2026.10.1+2 environment: sdk: ^3.13.4 diff --git a/test/drop_test.dart b/test/drop_test.dart index 8a48438..9f5b2ac 100644 --- a/test/drop_test.dart +++ b/test/drop_test.dart @@ -77,7 +77,8 @@ void main() { // 놓기 await tester.runAsync(() async { await _native(tester, 'performOperation', [file]); - for (var i = 0; i < 50 && c.videos.isEmpty; i++) { + // PC 가 바쁠 때 (전체 테스트 중) 를 생각해 넉넉히 최대 5초 + for (var i = 0; i < 250 && c.videos.isEmpty; i++) { await Future.delayed(const Duration(milliseconds: 20)); } await Future.delayed(const Duration(milliseconds: 100)); @@ -93,7 +94,7 @@ void main() { await tester.pump(); await tester.runAsync(() async { await _native(tester, 'performOperation', [file]); - await Future.delayed(const Duration(milliseconds: 200)); + await Future.delayed(const Duration(milliseconds: 1000)); }); // 앞의 안내가 사라지고 새 안내가 나올 때까지 for (var i = 0; i < 10; i++) { diff --git a/tool/build_launcher.ps1 b/tool/build_launcher.ps1 new file mode 100644 index 0000000..3db7533 --- /dev/null +++ b/tool/build_launcher.ps1 @@ -0,0 +1,27 @@ +# 배포 폴더 맨 위의 시작 프로그램 (jj_mkvmaker.exe → Lib\jj_mkvmaker.exe) 을 빌드한다. +# 결과: build\launcher\jj_mkvmaker.exe (C 런타임 정적 연결 - 다른 DLL 없이 실행) +# +# 사용: powershell -File tool\build_launcher.ps1 [-VsDir ] +param([string]$VsDir = '') + +$ErrorActionPreference = 'Stop' +$app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $VsDir) { $VsDir = Join-Path $app '..\tools\VSBuildTools' } # PS 5.1: $PSScriptRoot is empty in param defaults +$src = Join-Path $app 'windows\launcher' +$out = Join-Path $app 'build\launcher' +New-Item -ItemType Directory -Force $out | Out-Null + +$vcvars = Get-ChildItem $VsDir -Recurse -Filter vcvars64.bat -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $vcvars) { throw "vcvars64.bat not found: $VsDir" } + +$cmd = @" +call "$($vcvars.FullName)" >nul +cd /d "$out" +rc /nologo /fo launcher.res "$src\launcher.rc" || exit /b 1 +cl /nologo /O2 /MT /EHsc /std:c++17 /utf-8 /DUNICODE /D_UNICODE "$src\launcher.cpp" launcher.res /Fe:jj_mkvmaker.exe /link /SUBSYSTEM:WINDOWS user32.lib shell32.lib || exit /b 1 +"@ +$bat = Join-Path $out 'build.cmd' +[IO.File]::WriteAllText($bat, $cmd, [Text.Encoding]::Default) +cmd /c $bat +if ($LASTEXITCODE -ne 0) { throw "launcher build failed" } +Join-Path $out 'jj_mkvmaker.exe' diff --git a/tool/make_layout.ps1 b/tool/make_layout.ps1 new file mode 100644 index 0000000..eb0a9e8 --- /dev/null +++ b/tool/make_layout.ps1 @@ -0,0 +1,46 @@ +# Build the distribution layout from a Flutter release build: +# +# \jj_mkvmaker.exe launcher (tool\build_launcher.ps1) -> runs Lib\jj_mkvmaker.exe +# \LICENSE.txt, README.md +# \Lib\... everything from build\windows\x64\runner\Release +# \Logs\ created by the app at run time +# +# -Clean : make exactly this layout (for the release zip stage folder) +# default: update in place (deploy folder) - keeps downloads, models, Logs and other user files, +# and removes program files left at the top by the old single-folder layout. +param( + [Parameter(Mandatory = $true)][string]$Target, + [string]$Release = '', + [switch]$Clean +) +$ErrorActionPreference = 'Stop' +$app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $Release) { $Release = Join-Path $app 'build\windows\x64\runner\Release' } # PS 5.1: $PSScriptRoot is empty in param defaults +$Release = (Resolve-Path $Release).Path +$launcher = Join-Path $app 'build\launcher\jj_mkvmaker.exe' +if (-not (Test-Path $launcher)) { $launcher = & (Join-Path $PSScriptRoot 'build_launcher.ps1') | Select-Object -Last 1 } +New-Item -ItemType Directory -Force $Target | Out-Null +$lib = Join-Path $Target 'Lib' + +if ($Clean) { + robocopy $Release $lib /E /PURGE /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NJH /NJS /NP | Out-Null + # nothing else at the top + Get-ChildItem $Target | Where-Object { $_.Name -notin 'Lib' } | Remove-Item -Recurse -Force +} else { + robocopy $Release $lib /E /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NJH /NJS /NP | Out-Null + # old single-folder layout: program files at the top that now live in Lib + Get-ChildItem $Target -File | Where-Object { + ($_.Extension -eq '.dll' -or $_.Name -in 'THIRD_PARTY_NOTICES.txt', 'native_assets.json') -and (Test-Path (Join-Path $lib $_.Name)) + } | Remove-Item -Force + foreach ($pair in @(@('data', 'app.so'), @('ffmpeg', 'ffmpeg.exe'), @('tools', 'yt-dlp.exe'))) { + $d = Join-Path $Target $pair[0] + if ((Test-Path (Join-Path $d $pair[1])) -and (Test-Path (Join-Path $lib $pair[0]))) { Remove-Item $d -Recurse -Force } + } +} +# debug build leftover (integration tests) must never ship +Remove-Item (Join-Path $lib 'data\flutter_assets\kernel_blob.bin') -Force -ErrorAction SilentlyContinue + +Copy-Item $launcher (Join-Path $Target 'jj_mkvmaker.exe') -Force +Copy-Item (Join-Path $lib 'LICENSE.txt') (Join-Path $Target 'LICENSE.txt') -Force +Copy-Item (Join-Path $app 'README.md') (Join-Path $Target 'README.md') -Force +Get-ChildItem $Target | Select-Object -ExpandProperty Name diff --git a/windows/launcher/launcher.cpp b/windows/launcher/launcher.cpp new file mode 100644 index 0000000..f6e0abc --- /dev/null +++ b/windows/launcher/launcher.cpp @@ -0,0 +1,65 @@ +// JJ_MKVMaker 시작 프로그램 (배포 폴더 맨 위의 jj_mkvmaker.exe) +// +// 배포 폴더를 깔끔하게 두려고 실제 프로그램과 부속 파일은 모두 Lib\ 아래에 있다. +// JJ_MKVMaker\jj_mkvmaker.exe ← 이 파일 (Lib\jj_mkvmaker.exe 를 같은 인수로 실행하고 끝남) +// JJ_MKVMaker\LICENSE.txt, README.md +// JJ_MKVMaker\Lib\... ← 프로그램 · DLL · ffmpeg · 도구 +// JJ_MKVMaker\Logs\... ← 작업 기록 +// +// 빌드: tool\build_launcher.ps1 (Visual Studio Build Tools 의 cl · rc, C 런타임 정적 연결 → 다른 DLL 없이 실행) +#include +#include + +#include + +static std::wstring ExeDir() { + wchar_t buf[MAX_PATH * 4]; + const DWORD n = GetModuleFileNameW(nullptr, buf, static_cast(sizeof(buf) / sizeof(buf[0]))); + std::wstring path(buf, n); + const size_t slash = path.find_last_of(L"\\/"); + return slash == std::wstring::npos ? L"." : path.substr(0, slash); +} + +// 이 프로그램을 실행할 때 받은 인수 (프로그램 이름 뒤 전부, 따옴표 포함 그대로) +static std::wstring ArgsAfterProgramName() { + const wchar_t* cmd = GetCommandLineW(); + bool quoted = false; + while (*cmd && (quoted || (*cmd != L' ' && *cmd != L'\t'))) { + if (*cmd == L'"') quoted = !quoted; + ++cmd; + } + while (*cmd == L' ' || *cmd == L'\t') ++cmd; + return cmd; +} + +int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int show) { + const std::wstring lib = ExeDir() + L"\\Lib"; + const std::wstring app = lib + L"\\jj_mkvmaker.exe"; + if (GetFileAttributesW(app.c_str()) == INVALID_FILE_ATTRIBUTES) { + const std::wstring msg = L"프로그램 파일을 찾을 수 없습니다:\n" + app + + L"\n\n배포 zip 을 다시 풀어 Lib 폴더가 jj_mkvmaker.exe 옆에 있는지 확인하세요."; + MessageBoxW(nullptr, msg.c_str(), L"JJ_MKVMaker", MB_ICONERROR | MB_OK); + return 1; + } + + std::wstring cmdline = L"\"" + app + L"\""; + const std::wstring args = ArgsAfterProgramName(); + if (!args.empty()) cmdline += L" " + args; + + // 새로 뜨는 창이 앞으로 올 수 있게 + AllowSetForegroundWindow(ASFW_ANY); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = static_cast(show); + PROCESS_INFORMATION pi{}; + if (!CreateProcessW(app.c_str(), cmdline.data(), nullptr, nullptr, FALSE, 0, nullptr, lib.c_str(), &si, &pi)) { + const std::wstring msg = L"프로그램을 시작할 수 없습니다 (오류 " + std::to_wstring(GetLastError()) + L"):\n" + app; + MessageBoxW(nullptr, msg.c_str(), L"JJ_MKVMaker", MB_ICONERROR | MB_OK); + return 1; + } + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return 0; +} diff --git a/windows/launcher/launcher.rc b/windows/launcher/launcher.rc new file mode 100644 index 0000000..ccc91f6 --- /dev/null +++ b/windows/launcher/launcher.rc @@ -0,0 +1,31 @@ +// 시작 프로그램의 아이콘 · 버전 정보 +#include + +1 ICON "..\\runner\\resources\\app_icon.ico" + +VS_VERSION_INFO VERSIONINFO + FILEVERSION 1,0,0,0 + PRODUCTVERSION 1,0,0,0 + FILEFLAGSMASK 0x3fL + FILEFLAGS 0x0L + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "041204b0" + BEGIN + VALUE "CompanyName", "RyuJongJin" + VALUE "FileDescription", "JJ_MKVMaker" + VALUE "InternalName", "jj_mkvmaker" + VALUE "OriginalFilename", "jj_mkvmaker.exe" + VALUE "ProductName", "JJ_MKVMaker" + VALUE "LegalCopyright", "MIT License" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x412, 1200 + END +END