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);