v2026.10.01_002: 배포 폴더 구조 (맨 위 3개 파일 + Lib + Logs)

- 시작 프로그램 (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 <noreply@anthropic.com>
main v2026.10.01_002
RyuJongJin 2026-10-01 13:49:50 +09:00
parent 08c5fcd2e9
commit ebea6ddbb6
10 changed files with 270 additions and 15 deletions

View File

@ -34,7 +34,7 @@
1. [Releases](../../releases) 에서 `JJ_MKVMaker_v*_win64.zip` 을 받아 원하는 폴더에 풉니다. 1. [Releases](../../releases) 에서 `JJ_MKVMaker_v*_win64.zip` 을 받아 원하는 폴더에 풉니다.
2. `jj_mkvmaker.exe` 를 실행합니다. (Windows 10/11 64비트) 2. `jj_mkvmaker.exe` 를 실행합니다. (Windows 10/11 64비트)
3. 폴더 전체가 프로그램입니다. exe 파일 하나만 옮기면 동작하지 않습니다. 3. 폴더 전체가 프로그램입니다. 맨 위의 `jj_mkvmaker.exe` 는 `Lib\` 안의 프로그램을 실행하는 시작 프로그램이고, 작업 기록은 `Logs\` 에 남습니다. exe 파일 하나만 옮기면 동작하지 않습니다.
처음 쓸 때 내려받는 것 (한 번만): 처음 쓸 때 내려받는 것 (한 번만):

View File

@ -13,6 +13,7 @@ import 'app/download_manager.dart';
import 'app/settings.dart'; import 'app/settings.dart';
import 'core/app_update.dart'; import 'core/app_update.dart';
import 'core/playlist.dart'; import 'core/playlist.dart';
import 'platform/windows/app_paths.dart';
import 'platform/windows/com_guard.dart'; import 'platform/windows/com_guard.dart';
import 'platform/windows/desktop_shell.dart'; import 'platform/windows/desktop_shell.dart';
import 'platform/windows/exit_trace.dart'; import 'platform/windows/exit_trace.dart';
@ -59,16 +60,22 @@ Future<void> main(List<String> args) async {
} }
final services = PlatformServices.create(); final services = PlatformServices.create();
// 작업 기록: 배포 폴더의 Logs (쓸 수 없으면 설정 폴더 아래 Logs)
final logsDir = AppPaths.logsDir(dataDir);
final controller = AppController(services, settingsStore: SettingsStore()) 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 session = SessionMarker(p.join(dataDir, 'session.json'));
final crashed = session.start(); 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) { if (crashed != null) {
String hm(DateTime t) => '${t.month}/${t.day} ${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; 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)}). ' controller.note('⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 ${hm(crashed.$1)}, 마지막 확인 ${hm(crashed.$2)}). '
'그 전의 기록은 설정 폴더의 app.log 에 있습니다.'); '그 전의 기록은 Logs 폴더의 app.log 에 있습니다.');
} }
await controller.init(); await controller.init();
@ -115,7 +122,6 @@ Future<void> main(List<String> args) async {
final ctx = navigatorKey.currentContext; final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return false; if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return false;
} }
ExitTrace.file = p.join(dataDir, 'exit.log');
ExitTrace.start(); ExitTrace.start();
final job = controller.currentJob; final job = controller.currentJob;
final wait = controller.pendingJobs.length; final wait = controller.pendingJobs.length;

View File

@ -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<String> cleanupOldLayout() {
if (!inLib) return const [];
final top = root;
final removed = <String>[];
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;
}
}

View File

@ -5,6 +5,8 @@ import 'package:crypto/crypto.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'app_paths.dart';
import '../../core/app_update.dart'; import '../../core/app_update.dart';
import '../../services/updater.dart'; import '../../services/updater.dart';
@ -24,7 +26,7 @@ class GitHubUpdater implements Updater {
this.apiBase = 'https://api.github.com', this.apiBase = 'https://api.github.com',
String? appDir, String? appDir,
this.versionOverride, this.versionOverride,
}) : appDir = appDir ?? p.dirname(Platform.resolvedExecutable); }) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
@override @override
Future<String> currentVersion() async { Future<String> currentVersion() async {
@ -98,13 +100,16 @@ class GitHubUpdater implements Updater {
if (t.exitCode != 0) throw UpdateException('압축을 풀 수 없습니다: ${t.stderr}'); if (t.exitCode != 0) throw UpdateException('압축을 풀 수 없습니다: ${t.stderr}');
await zip.delete(); await zip.delete();
final exeName = p.basename(Platform.resolvedExecutable).toLowerCase(); final exeName = p.basename(Platform.resolvedExecutable).toLowerCase();
final exe = await out // 배포 폴더 맨 위 = 가장 얕은 곳의 jj_mkvmaker.exe (Lib 구조면 시작 프로그램, 예전 구조면 프로그램 자신)
final exes = await out
.list(recursive: true) .list(recursive: true)
.firstWhere((e) => e is File && p.basename(e.path).toLowerCase() == 'jj_mkvmaker.exe' || .where((e) => e is File && {'jj_mkvmaker.exe', exeName}.contains(p.basename(e.path).toLowerCase()))
e is File && p.basename(e.path).toLowerCase() == exeName, .map((e) => e.path)
orElse: () => throw const UpdateException('압축 파일 안에 프로그램이 없습니다.')); .toList();
if (exes.isEmpty) throw const UpdateException('압축 파일 안에 프로그램이 없습니다.');
exes.sort((a, b) => p.split(a).length.compareTo(p.split(b).length));
onProgress(1); onProgress(1);
return p.dirname(exe.path); return p.dirname(exes.first);
} }
/// 파일 교체 스크립트 (영문만: Windows PowerShell 5.1 이 BOM 없는 UTF-8 을 잘못 읽는 문제 방지) /// 파일 교체 스크립트 (영문만: Windows PowerShell 5.1 이 BOM 없는 UTF-8 을 잘못 읽는 문제 방지)
@ -130,7 +135,7 @@ exit 0
[ [
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1.path, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1.path,
'-ParentPid', '$waitPid', '-Source', source, '-Target', appDir, '-ParentPid', '$waitPid', '-Source', source, '-Target', appDir,
'-Exe', p.join(appDir, p.basename(Platform.resolvedExecutable)), '-Exe', p.join(appDir, 'jj_mkvmaker.exe'),
if (!restart) '-NoRestart', if (!restart) '-NoRestart',
], ],
mode: restart ? ProcessStartMode.detached : ProcessStartMode.normal, mode: restart ? ProcessStartMode.detached : ProcessStartMode.normal,

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.13.4 sdk: ^3.13.4

View File

@ -77,7 +77,8 @@ void main() {
// 놓기 // 놓기
await tester.runAsync(() async { await tester.runAsync(() async {
await _native(tester, 'performOperation', [file]); 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<void>.delayed(const Duration(milliseconds: 20)); await Future<void>.delayed(const Duration(milliseconds: 20));
} }
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
@ -93,7 +94,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.runAsync(() async { await tester.runAsync(() async {
await _native(tester, 'performOperation', [file]); await _native(tester, 'performOperation', [file]);
await Future<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 1000));
}); });
// 앞의 안내가 사라지고 새 안내가 나올 때까지 // 앞의 안내가 사라지고 새 안내가 나올 때까지
for (var i = 0; i < 10; i++) { for (var i = 0; i < 10; i++) {

27
tool/build_launcher.ps1 Normal file
View File

@ -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 <Visual Studio Build Tools 폴더>]
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'

46
tool/make_layout.ps1 Normal file
View File

@ -0,0 +1,46 @@
# Build the distribution layout from a Flutter release build:
#
# <Target>\jj_mkvmaker.exe launcher (tool\build_launcher.ps1) -> runs Lib\jj_mkvmaker.exe
# <Target>\LICENSE.txt, README.md
# <Target>\Lib\... everything from build\windows\x64\runner\Release
# <Target>\Logs\ created by the app at run time
#
# -Clean : make <Target> 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

View File

@ -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 <windows.h>
#include <shellapi.h>
#include <string>
static std::wstring ExeDir() {
wchar_t buf[MAX_PATH * 4];
const DWORD n = GetModuleFileNameW(nullptr, buf, static_cast<DWORD>(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<WORD>(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;
}

View File

@ -0,0 +1,31 @@
// 시작 프로그램의 아이콘 · 버전 정보
#include <windows.h>
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