JJ_MKVMaker/lib/platform/windows/start_menu.dart

192 lines
9.8 KiB
Dart

import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
import 'package:path/path.dart' as p;
import 'data_dir_override.dart';
/// 99 (사용자 결정): 지금 사용자의 시작 메뉴에 JJ_MKVMaker 바로 가기를 앱 ID (AUMID) 와 함께 만들고,
/// jjmkvmaker:// 주소를 이 앱으로 연결한다 (관리자 권한 없이 - 사용자 영역만).
/// 그래야 Windows 알림의 출처가 "JJ_MKVMaker" 로 보이고, 알림을 누르면 앱이 열린다 (jjmkvmaker://lsync).
class StartMenu {
static const aumid = 'JJ.MKVMaker';
static const protocol = 'jjmkvmaker';
/// 관리자 지시 (10/20, 15 찾음): 시험판 · 시험 (따로 데이터 JJ_MKVMAKER_DATA) 이면 사용자의 시작 메뉴 바로 가기 ·
/// jjmkvmaker:// 를 자기 쪽으로 바꾸거나 지우지 않는다. 예전에는 시험 폴더의 판을 켜면 사용자 바로 가기가 그 판을 가리키고,
/// 시험 폴더를 지우면 시작 메뉴 · 알림 누르기가 깨졌다. 시험이 바꿀 수 있게 함수로 둔다
static bool Function() userAreaLocked = () => dataDirOverride != null;
/// 바로 가기 · 레지스트리를 바꾸는 명령 실행 (시험이 가짜로 바꿔 무엇을 부르는지 본다 - 사용자 영역을 건드리지 않고)
static Future<ProcessResult> Function(String exe, List<String> args) run = Process.run;
/// 시험용: 바로 가기 파일 자리 (null = 사용자의 시작 메뉴). 시험이 잘못돼도 사용자 바로 가기를 지우지 않게
static String? shortcutPathOverride;
/// 바로 가기 파일 (사용자의 시작 메뉴)
static String get shortcutPath => shortcutPathOverride ?? p.join(Platform.environment['APPDATA'] ?? '', 'Microsoft', 'Windows', 'Start Menu',
'Programs', 'JJ_MKVMaker.lnk');
/// 이 프로세스의 앱 ID (작업 표시줄 · 알림이 바로 가기와 같은 앱으로 묶이게)
static void setProcessAppId() {
if (!Platform.isWindows) return;
final id = aumid.toNativeUtf16();
try {
final shell32 = DynamicLibrary.open('shell32.dll');
final f = shell32.lookupFunction<Int32 Function(Pointer<Utf16>), int Function(Pointer<Utf16>)>(
'SetCurrentProcessExplicitAppUserModelID');
f(id);
} catch (_) {
} finally {
calloc.free(id);
}
}
/// 바로 가기 · 주소 연결을 만든다 (있고 이 실행 파일을 가리키면 그대로, 앱 폴더를 옮겼으면 고친다). 됐으면 true
/// [lnk] · [registerProtocol]: 시험용 (사용자의 시작 메뉴 · 레지스트리를 건드리지 않고)
static Future<bool> ensure({String? exe, String? lnk, bool registerProtocol = true}) async {
if (!Platform.isWindows) return false;
// 사용자 영역 (시작 메뉴 바로 가기 · HKCU 주소 연결) 을 건드리는 부름만 막는다 - 시험이 따로 준 바로 가기 파일만 쓰면 그대로
if ((lnk == null || registerProtocol) && userAreaLocked()) return false;
final target = exe ?? appLauncher();
final script = _script(lnk ?? shortcutPath, target, registerProtocol: registerProtocol);
try {
final r = await run(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', _encode(script)],
).timeout(const Duration(seconds: 60));
return r.exitCode == 0 && '${r.stdout}'.contains('JJ_OK');
} catch (_) {
return false;
}
}
/// 사용자가 켜는 실행 파일: 배포판은 폴더 맨 위의 jj_mkvmaker.exe (Lib\jj_mkvmaker.exe 를 띄우는 실행기), 아니면 지금 실행 파일
static String appLauncher([String? running]) {
final exe = running ?? Platform.resolvedExecutable;
final dir = p.dirname(exe);
if (p.basename(dir).toLowerCase() == 'lib') {
final launcher = p.join(p.dirname(dir), p.basename(exe));
if (File(launcher).existsSync()) return launcher;
}
return exe;
}
/// 설정에서 끄면: 바로 가기 · 주소 연결을 지운다 (이 앱이 만든 것만)
static Future<void> remove() async {
if (!Platform.isWindows || userAreaLocked()) return;
try {
final f = File(shortcutPath);
if (await f.exists()) await f.delete();
await run('reg.exe', ['delete', r'HKCU\Software\Classes\' + protocol, '/f']);
} catch (_) {}
}
static String _encode(String script) {
final bytes = <int>[for (final u in script.codeUnits) ...[u & 0xff, u >> 8]];
return base64Encode(bytes);
}
static String _q(String s) => s.replaceAll("'", "''");
// l10n-skip: PowerShell 스크립트 (안의 한글은 주석)
static String _script(String lnk, String exe, {bool registerProtocol = true}) => '''
\$ErrorActionPreference = 'Stop'
\$lnk = '${_q(lnk)}'
\$exe = '${_q(exe)}'
if (\$${registerProtocol ? 'true' : 'false'}) {
# 주소 연결 (사용자 영역): jjmkvmaker://... → 이 실행 파일
\$k = 'HKCU:\\Software\\Classes\\$protocol'
New-Item -Path \$k -Force | Out-Null
Set-ItemProperty -Path \$k -Name '(default)' -Value 'URL:JJ_MKVMaker'
Set-ItemProperty -Path \$k -Name 'URL Protocol' -Value ''
New-Item -Path "\$k\\shell\\open\\command" -Force | Out-Null
Set-ItemProperty -Path "\$k\\shell\\open\\command" -Name '(default)' -Value ('"' + \$exe + '" "%1"')
}
# 바로 가기가 이미 이 실행 파일을 가리키면 그대로
if (Test-Path -LiteralPath \$lnk) {
\$cur = (New-Object -ComObject WScript.Shell).CreateShortcut(\$lnk).TargetPath
if (\$cur -eq \$exe) { 'JJ_OK'; exit 0 }
}
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
[ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
interface IShellLinkW {
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder f, int c, IntPtr d, uint fl);
void GetIDList(out IntPtr p); void SetIDList(IntPtr p);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder n, int c);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string n);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder d, int c);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string d);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder a, int c);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string a);
void GetHotkey(out short h); void SetHotkey(short h);
void GetShowCmd(out int s); void SetShowCmd(int s);
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder p, int c, out int i);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string p, int i);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string p, uint r);
void Resolve(IntPtr h, uint f);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string f);
}
[StructLayout(LayoutKind.Sequential, Pack = 4)] public struct JJPropKey { public Guid fmtid; public uint pid; }
[StructLayout(LayoutKind.Explicit)] public struct JJPropVariant { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p; [FieldOffset(8)] public long pad; }
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
interface IPropertyStore {
void GetCount(out uint c); void GetAt(uint i, out JJPropKey k);
void GetValue(ref JJPropKey k, out JJPropVariant v); void SetValue(ref JJPropKey k, ref JJPropVariant v); void Commit();
}
public static class JJLink {
public static void Make(string lnk, string exe, string aumid) {
var link = (IShellLinkW)new CShellLink();
link.SetPath(exe);
link.SetWorkingDirectory(System.IO.Path.GetDirectoryName(exe));
link.SetIconLocation(exe, 0);
link.SetDescription("JJ_MKVMaker");
var store = (IPropertyStore)link;
var key = new JJPropKey { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };
var pv = new JJPropVariant { vt = 31, p = Marshal.StringToCoTaskMemUni(aumid) };
store.SetValue(ref key, ref pv);
store.Commit();
Marshal.FreeCoTaskMem(pv.p);
((IPersistFile)link).Save(lnk, true);
}
}
"@
New-Item -ItemType Directory -Force -Path (Split-Path -LiteralPath \$lnk) | Out-Null
[JJLink]::Make(\$lnk, \$exe, '$aumid')
'JJ_OK'
''';
/// 이 앱 이름으로 알림 (바로 가기가 있어야 한다). 누르면 jjmkvmaker://[launch] 로 앱이 열린다. 띄웠으면 true
static Future<bool> toast(String title, String body, {String launch = 'lsync'}) async {
String esc(String s) =>
s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
final lines = body.split('\n');
final xml = '<toast activationType="protocol" launch="$protocol://$launch">'
'<visual><binding template="ToastGeneric"><text>${esc(title)}</text>'
'<text>${esc(lines.first)}</text><text>${esc(lines.skip(1).join(' · '))}</text></binding></visual></toast>';
final script = '''
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > \$null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > \$null
\$x = New-Object Windows.Data.Xml.Dom.XmlDocument
\$x.LoadXml('${_q(xml)}')
\$t = New-Object Windows.UI.Notifications.ToastNotification \$x
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('$aumid').Show(\$t)
''';
try {
final r = await Process.run(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', _encode(script)],
).timeout(const Duration(seconds: 20));
return r.exitCode == 0 && !'${r.stderr}'.contains('Exception') && !'${r.stderr}'.contains('<S S="Error">');
} catch (_) {
return false;
}
}
}