diff --git a/docs/development.md b/docs/development.md index 45f2593..6b01e88 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,7 +6,9 @@ Develop on Windows 10/11 x64 with VS Code and [uv](https://docs.astral.sh/uv/). Visual Studio is not required. Dependencies are downloaded into the checkout; the pinned Go SDK builds the HTTP/3 diagnostic helper and the two network core DLLs. The DLL build also downloads a checksum-pinned Zig C compiler; Go is not needed on -users' computers. See [native core architecture and trust checks](../native/core/README.md). +users' computers. The same compiler builds the small C launcher for personal +installations; this launcher needs no .NET runtime and starts only the current +user's MegaProxy scheduled task. See [native core architecture and trust checks](../native/core/README.md). ```cmd setup.cmd diff --git a/native/launcher/launcher.manifest b/native/launcher/launcher.manifest new file mode 100644 index 0000000..b79832a --- /dev/null +++ b/native/launcher/launcher.manifest @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/native/launcher/launcher.rc b/native/launcher/launcher.rc new file mode 100644 index 0000000..6ab0131 --- /dev/null +++ b/native/launcher/launcher.rc @@ -0,0 +1,22 @@ +#include +1 ICON APP_ICON +1 RT_MANIFEST "launcher.manifest" +1 VERSIONINFO +FILEVERSION VERSION_NUMBER +PRODUCTVERSION VERSION_NUMBER +FILETYPE VFT_APP +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "FileDescription", APP_TITLE + VALUE "ProductName", APP_TITLE + VALUE "ProductVersion", PRODUCT_VERSION + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/native/launcher/main.c b/native/launcher/main.c new file mode 100644 index 0000000..ade83a0 --- /dev/null +++ b/native/launcher/main.c @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +#define COBJMACROS +#define _UNICODE +#include +#include +#include +#include + +#ifdef MEGAPROXY_DEV +#define APP_NAME L"WindowsMegaProxy.Dev" +#define DISPLAY_NAME L"MegaProxy DEV" +#else +#define APP_NAME L"WindowsMegaProxy" +#define DISPLAY_NAME L"MegaProxy" +#endif + +int WINAPI wWinMain(HINSTANCE instance, HINSTANCE previous, PWSTR command, int show) +{ + HANDLE token = NULL; + TOKEN_USER *user = NULL; + LPWSTR sid = NULL; + ITaskService *service = NULL; + ITaskFolder *folder = NULL; + IRegisteredTask *task = NULL; + IRunningTask *running = NULL; + BSTR root = NULL, name = NULL; + VARIANT empty; + VariantInit(&empty); + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + BOOL initialized = SUCCEEDED(hr); + DWORD size = 0; + wchar_t task_name[256]; + if (FAILED(hr)) goto done; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + hr = HRESULT_FROM_WIN32(GetLastError()); goto done; + } + GetTokenInformation(token, TokenUser, NULL, 0, &size); + user = LocalAlloc(LPTR, size); + if (!user) { hr = E_OUTOFMEMORY; goto done; } + if (!GetTokenInformation(token, TokenUser, user, size, &size) || + !ConvertSidToStringSidW(user->User.Sid, &sid)) { + hr = HRESULT_FROM_WIN32(GetLastError()); goto done; + } + swprintf(task_name, 256, APP_NAME L".User.%ls", sid); + root = SysAllocString(L"\\"); + name = SysAllocString(task_name); + if (!root || !name) { hr = E_OUTOFMEMORY; goto done; } + hr = CoCreateInstance(&CLSID_TaskScheduler, NULL, CLSCTX_INPROC_SERVER, + &IID_ITaskService, (void **)&service); + if (FAILED(hr)) goto done; + hr = ITaskService_Connect(service, empty, empty, empty, empty); + if (FAILED(hr)) goto done; + hr = ITaskService_GetFolder(service, root, &folder); + if (FAILED(hr)) goto done; + hr = ITaskFolder_GetTask(folder, name, &task); + if (FAILED(hr)) goto done; + hr = IRegisteredTask_Run(task, empty, &running); +done: + if (running) IRunningTask_Release(running); + if (task) IRegisteredTask_Release(task); + if (folder) ITaskFolder_Release(folder); + if (service) ITaskService_Release(service); + SysFreeString(name); + SysFreeString(root); + LocalFree(sid); + LocalFree(user); + if (token) CloseHandle(token); + if (initialized) CoUninitialize(); + if (FAILED(hr)) { + wchar_t message[1024], detail[512] = L""; + FormatMessageW(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, (DWORD)hr, 0, detail, 512, NULL); + swprintf(message, 1024, L"Could not start " DISPLAY_NAME + L". Repair the current-user installation.\n\nError 0x%08lX\n%ls", (unsigned long)hr, detail); + MessageBoxW(NULL, message, DISPLAY_NAME, MB_OK | MB_ICONERROR); + return 1; + } + return 0; +} diff --git a/scripts/build.py b/scripts/build.py index d0c6aef..6a0d10c 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -4,6 +4,7 @@ import tempfile from ctypes import wintypes as wt +from build_launcher import build_launcher from common import ROOT, app_name, artifact_root, cli, dotnet @@ -49,8 +50,11 @@ def main(dev=False, app_only=False): raise RuntimeError("Publish destination escapes artifacts.") destination.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="publish-", dir=destination.parent) as staging: - dotnet("publish", str(ROOT / f"src/{project}/{project}.csproj"), "-c", "Release", "-r", "win-x64", - *publish_options(dev=dev and output == "app"), "-o", staging, "--nologo", f"-p:MegaProxyDev={str(dev).lower()}") + if output == "launcher": + build_launcher(staging, dev=dev) + else: + dotnet("publish", str(ROOT / f"src/{project}/{project}.csproj"), "-c", "Release", "-r", "win-x64", + *publish_options(dev=dev and output == "app"), "-o", staging, "--nologo", f"-p:MegaProxyDev={str(dev).lower()}") # Replace only after a successful publish; discard obsolete bundled runtimes. if destination.exists(): shutil.rmtree(destination) diff --git a/scripts/build_core.py b/scripts/build_core.py index 666605c..d53a944 100644 --- a/scripts/build_core.py +++ b/scripts/build_core.py @@ -16,6 +16,15 @@ ZIG_SHA256 = "554f5378228923ffd558eac35e21af020c73789d87afeabf4bfd16f2e6feed2c" +def ensure_zig(): + zig = ROOT / f".tools/zig-x86_64-windows-{ZIG_VERSION}/zig.exe" + if not zig.exists(): + archive = download(f"https://ziglang.org/download/{ZIG_VERSION}/zig-x86_64-windows-{ZIG_VERSION}.zip", + ROOT / f".tools/downloads/zig-{ZIG_VERSION}.zip", ZIG_SHA256) + extract(archive, ROOT / ".tools") + return zig + + def main(dev=False, tidy=False, publisher=None): output = ROOT / "artifacts" / ("dev/cores" if dev else "cores") source = ROOT / "native/core" @@ -28,11 +37,7 @@ def main(dev=False, tidy=False, publisher=None): expected += ["WindowsMegaProxy.Dev.Core.exe" if dev else "WindowsMegaProxy.Core.exe", "Core-NOTICES.txt", "LICENSE.txt"] if not tidy and stamp.exists() and stamp.read_text() == fingerprint and all((output / name).is_file() for name in expected): return output - zig = ROOT / f".tools/zig-x86_64-windows-{ZIG_VERSION}/zig.exe" - if not zig.exists(): - archive = download(f"https://ziglang.org/download/{ZIG_VERSION}/zig-x86_64-windows-{ZIG_VERSION}.zip", - ROOT / f".tools/downloads/zig-{ZIG_VERSION}.zip", ZIG_SHA256) - extract(archive, ROOT / ".tools") + zig = ensure_zig() go = ensure_go() output.mkdir(parents=True, exist_ok=True) certificate = publisher.read_text(encoding="ascii") diff --git a/scripts/build_launcher.py b/scripts/build_launcher.py new file mode 100644 index 0000000..1f99eb0 --- /dev/null +++ b/scripts/build_launcher.py @@ -0,0 +1,46 @@ +"""Build the unprivileged Task Scheduler launcher without a bundled runtime.""" + +import os +import struct +import subprocess as sp +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +from build_core import ensure_zig +from common import ROOT, app_name + + +def build_launcher(output, dev=False): + zig = ensure_zig() + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=True) + source = ROOT / "native/launcher" + version = ET.parse(ROOT / "Directory.Build.props").findtext("PropertyGroup/Version") + commit = sp.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + title = "MegaProxy DEV" if dev else "MegaProxy" + icon = ROOT / "src/WindowsMegaProxy/Assets" / ("dev-app.ico" if dev else "app.ico") + env = os.environ.copy() + env["ZIG_GLOBAL_CACHE_DIR"] = str(ROOT / ".tools/zig-cache") + with tempfile.TemporaryDirectory(dir=output) as temporary: + resource = Path(temporary) / "launcher.rc" + resource.write_text( + f'#define APP_ICON "{icon.as_posix()}"\n' + f'#define APP_TITLE "{title}"\n' + f'#define VERSION_NUMBER {version.replace(".", ",")},0\n' + f'#define PRODUCT_VERSION "{version}{"-dev" if dev else ""}+{commit}"\n' + + (source / "launcher.rc").read_text(encoding="utf-8"), encoding="utf-8") + compiled = resource.with_suffix(".res") + sp.run([str(zig), "rc", "/I", str(source), "/fo", str(compiled), str(resource)], + cwd=source, env=env, check=True) + sp.run([str(zig), "cc", "-target", "x86_64-windows-gnu", "-Os", "-s", "-municode", "-Wl,--subsystem,windows", + *(["-DMEGAPROXY_DEV"] if dev else []), str(source / "main.c"), str(compiled), + "-lole32", "-loleaut32", "-ladvapi32", "-luser32", "-luuid", + "-o", str(output / (app_name(dev) + ".Launcher.exe"))], env=env, check=True) + executable = (output / (app_name(dev) + ".Launcher.exe")).read_bytes() + pe = struct.unpack_from(" - - WinExe - ../WindowsMegaProxy/Assets/app.ico - ../WindowsMegaProxy/Assets/dev-app.ico - WindowsMegaProxy.Dev.Launcher - - - - - diff --git a/src/WindowsMegaProxy.Setup/Program.cs b/src/WindowsMegaProxy.Setup/Program.cs index bbd645a..2e44d00 100644 --- a/src/WindowsMegaProxy.Setup/Program.cs +++ b/src/WindowsMegaProxy.Setup/Program.cs @@ -154,17 +154,20 @@ private static async Task Install(SetupManifest manifest, string mode, string up .Select(path => path.Substring(directory.Length + 1).Replace('\\', '/')) .Where(path => Personal || !path.StartsWith("launcher/", StringComparison.OrdinalIgnoreCase)).ToArray(); string backup = Path.Combine(State, "backup"); + string? inventory = File.Exists(Path.Combine(State, "files.json")) ? File.ReadAllText(Path.Combine(State, "files.json")) : null; + string[] obsoleteLauncher = ObsoleteLauncherFiles(inventory, files, Personal); + string[] affectedFiles = files.Concat(obsoleteLauncher).ToArray(); DeleteProtectedTree(backup); Protect(backup, readable: false); var snapshot = new Snapshot { Mode = previousMode, Existing = previousKey?.GetValue("HelperPath") is string || serviceKey is not null, Helper = previousKey?.GetValue("HelperPath") as string, - Inventory = File.Exists(Path.Combine(State, "files.json")) ? File.ReadAllText(Path.Combine(State, "files.json")) : null, + Inventory = inventory, Desktop = File.Exists(Path.Combine(Environment.GetFolderPath(Personal ? Environment.SpecialFolder.DesktopDirectory : Environment.SpecialFolder.CommonDesktopDirectory), "MegaProxy.lnk")), Settings = ReadSettings(), BuildId = previousKey?.GetValue("SetupBuildId") as string, - Files = files, PreviousFiles = files.Where(file => File.Exists(InstalledPath(file))).ToArray(), - ChangedFiles = files.Where(file => !SameInstalledFile(directory, file)).ToArray() + Files = affectedFiles, PreviousFiles = affectedFiles.Where(file => File.Exists(InstalledPath(file))).ToArray(), + ChangedFiles = affectedFiles.Where(file => obsoleteLauncher.Contains(file) || !SameInstalledFile(directory, file)).ToArray() }; if (snapshot.Helper is not null && (!Path.GetFullPath(snapshot.Helper).StartsWith(Root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || Path.GetFileName(snapshot.Helper) != "WindowsMegaProxy.Service.exe")) throw new IOException("Previous helper is outside the protected installation."); @@ -177,11 +180,13 @@ private static async Task Install(SetupManifest manifest, string mode, string up File.WriteAllText(Path.Combine(State, "rollback.json"), Json.Serialize(snapshot)); backedUp = true; Console.WriteLine("Installing application and launcher files."); - foreach (string file in snapshot.ChangedFiles) + foreach (string file in snapshot.ChangedFiles.Except(obsoleteLauncher)) { try { WriteInstalled(directory, file); } catch (Exception error) { throw new IOException("Could not install file: " + file, error); } } + foreach (string file in obsoleteLauncher) DeleteInstalled(file); + if (Personal) Unelevated(() => DeleteEmptyDirectories(LauncherDirectory)); Console.WriteLine("Checking installed dependencies."); await Require(Helper, "--check-dependencies"); configuring = true; @@ -396,15 +401,16 @@ private static void CleanupEmptyInstallation() File.Delete(Path.Combine(State, "install.lock")); File.Delete(Path.Combine(State, "rollback.json")); File.Delete(Path.Combine(State, "files.json")); - void Empty(string path) - { - RejectReparseChain(path); - if (!Directory.Exists(path)) return; - foreach (string directory in Directory.GetDirectories(path)) Empty(directory); - if (!Directory.EnumerateFileSystemEntries(path).Any()) Directory.Delete(path); - } - Empty(Root); - if (Personal) Unelevated(() => Empty(LauncherDirectory)); + DeleteEmptyDirectories(Root); + if (Personal) Unelevated(() => DeleteEmptyDirectories(LauncherDirectory)); + } + + private static void DeleteEmptyDirectories(string path) + { + RejectReparseChain(path); + if (!Directory.Exists(path)) return; + foreach (string directory in Directory.GetDirectories(path)) DeleteEmptyDirectories(directory); + if (!Directory.EnumerateFileSystemEntries(path).Any()) Directory.Delete(path); } private static void DeleteOwnedLauncher(string source) @@ -456,6 +462,10 @@ private static void StartHelper(string argument) catch (Exception error) { Console.Error.WriteLine("Installation succeeded; cleanup could not start: " + error.Message); } } + internal static string[] ObsoleteLauncherFiles(string? inventory, string[] files, bool personal) => + inventory is null || !personal ? Array.Empty() : + Json.Deserialize(inventory).Where(IsLauncher).Except(files, StringComparer.OrdinalIgnoreCase).ToArray(); + private static bool IsLauncher(string relative) => relative.StartsWith("launcher/", StringComparison.OrdinalIgnoreCase); private static string InstalledPath(string relative) => IsLauncher(relative) ? EntryPath(LauncherDirectory, relative.Substring(9)) : EntryPath(Root, relative); diff --git a/tests/WindowsMegaProxy.SetupChecks/Checks.cs b/tests/WindowsMegaProxy.SetupChecks/Checks.cs index 4d8701c..50bdbc6 100644 --- a/tests/WindowsMegaProxy.SetupChecks/Checks.cs +++ b/tests/WindowsMegaProxy.SetupChecks/Checks.cs @@ -26,6 +26,13 @@ private static int Main(string[] args) Console.WriteLine("Checking setup commands, permissions, archives and runtime caching."); Arguments(); LaunchDefaults(); + string oldInventory = "[\"launcher/WindowsMegaProxy.Launcher.exe\",\"launcher/coreclr.dll\",\"WindowsMegaProxy.dll\"]"; + string[] newFiles = { "launcher/windowsmegaproxy.launcher.exe" }; + Require(Program.ObsoleteLauncherFiles(oldInventory, newFiles, true).SequenceEqual(new[] { "launcher/coreclr.dll" }), + "Launcher upgrades must remove only obsolete inventoried launcher files, ignoring filename case."); + Require(Program.ObsoleteLauncherFiles(null, newFiles, true).Length == 0 && + Program.ObsoleteLauncherFiles(oldInventory, newFiles, false).Length == 0, + "Fresh and system installs must not remove personal launcher files."); Permissions(); UserFiles(root); Archives(root);