Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions native/launcher/launcher.manifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
22 changes: 22 additions & 0 deletions native/launcher/launcher.rc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#include <windows.h>
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
79 changes: 79 additions & 0 deletions native/launcher/main.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
#define COBJMACROS
#define _UNICODE
#include <windows.h>
#include <sddl.h>
#include <taskschd.h>
#include <stdio.h>

#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;
}
8 changes: 6 additions & 2 deletions scripts/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
15 changes: 10 additions & 5 deletions scripts/build_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand Down
46 changes: 46 additions & 0 deletions scripts/build_launcher.py
Original file line number Diff line number Diff line change
@@ -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("<I", executable, 0x3c)[0]
optional = pe + 24
assert executable[pe:pe + 4] == b"PE\0\0"
assert struct.unpack_from("<H", executable, optional)[0] == 0x20b, "Expected a 64-bit executable."
assert struct.unpack_from("<H", executable, optional + 68)[0] == 2, "Launcher must not open a console."
assert struct.unpack_from("<II", executable, optional + 112 + 14 * 8) == (0, 0), "Launcher must not require .NET."
1 change: 1 addition & 0 deletions scripts/installer_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ def check_user_install(setup, logs, global_data):
assert Path(helper).parent == protected
assert not (protected / "versions").exists()
assert (destination / "WindowsMegaProxy.Launcher.exe").is_file()
assert not list(destination.rglob("*.dll")), "The native launcher must not carry a .NET runtime."
assert not (destination / "WindowsMegaProxy.exe").exists(), "Only the unprivileged launcher belongs in the user-writable install directory."
assert (global_data / "preservation-check.txt").read_text() == "preserved"
assert service_state("WindowsMegaProxy") is None
Expand Down
28 changes: 0 additions & 28 deletions src/WindowsMegaProxy.Launcher/Program.cs

This file was deleted.

11 changes: 0 additions & 11 deletions src/WindowsMegaProxy.Launcher/WindowsMegaProxy.Launcher.csproj

This file was deleted.

36 changes: 23 additions & 13 deletions src/WindowsMegaProxy.Setup/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string>() :
Json.Deserialize<string[]>(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);

Expand Down
7 changes: 7 additions & 0 deletions tests/WindowsMegaProxy.SetupChecks/Checks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading