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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

This repository builds a Windows MSIX package containing:

- one .NET 10 NativeAOT launcher exposed through the `openclaw` and `clawctl`
app execution aliases;
- one .NET 10 NativeAOT launcher exposed through separate packaged
`openclaw` and `clawctl` application identities and app execution aliases;
- a pinned, verified build of
[`openclaw/openclaw`](https://github.com/openclaw/openclaw);
- the official Node.js archive matching the upstream build's runtime version
Expand All @@ -17,9 +17,10 @@ packages.

## Command model

Both aliases activate the same packaged `openclaw.exe`. The launcher recovers
the alias used to start it from the native process command line and selects one
of two deliberately separate surfaces.
Both application identities activate the same packaged `openclaw.exe`. The
launcher recovers either the package-qualified application identity or the alias
used to start it from the native process command line and selects one of two
deliberately separate surfaces.

### `openclaw`

Expand Down
15 changes: 8 additions & 7 deletions scripts/Build-MSIX.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -560,17 +560,18 @@ try {
"//*[local-name()='Extension' and @Category='windows.appExecutionAlias']"
)
)
if ($aliasExtension.Count -ne 1) {
throw 'The MSIX must contain one app execution alias extension.'
if ($aliasExtension.Count -ne 2) {
throw 'The MSIX must contain public and control app execution alias extensions.'
}
if ($aliasExtension[0].Executable -ne 'openclaw.exe') {
throw 'Both command aliases must target openclaw.exe.'
foreach ($extension in $aliasExtension) {
if ($extension.Executable -ne 'openclaw.exe') {
throw 'Both command aliases must target openclaw.exe.'
}
}

$registeredAliases = @(
$aliasExtension[0].SelectNodes(
".//*[local-name()='ExecutionAlias']"
) |
$aliasExtension |
ForEach-Object { $_.SelectNodes(".//*[local-name()='ExecutionAlias']") } |
ForEach-Object { $_.Alias }
)
foreach ($requiredAlias in @('openclaw.exe', 'clawctl.exe')) {
Expand Down
189 changes: 189 additions & 0 deletions src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
namespace OpenClaw.Launcher.Gateway;

/// <summary>
/// Generates the launcher files both persistence lanes invoke.
/// </summary>
internal static class GatewayLauncherScript
{
/// <summary>
/// Identifies a file this installation generated, so a file that happens to
/// share the name is reported rather than silently overwritten.
/// </summary>
public const string Marker = "@rem openclaw-gateway-launcher v1";

private const string PowerShellMarker = "# openclaw-gateway-launcher v1";

public const string ControlApplicationId = "Control";

public const string ControlArguments = "gateway-service start";

public static string Create(string workingDirectory, string activationScriptPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(activationScriptPath);

// At logon a task's working directory is the system directory. The
// gateway is given an explicit, controlled one instead, so it never
// writes relative paths into a system location.
return string.Join(
"\r\n",
"@echo off",
Marker,
"@rem Generated by OpenClaw. Edits are overwritten on the next install.",
"chcp 65001 >nul",
"setlocal",
$"cd /d \"{workingDirectory}\" || exit /b 1",
$"\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File \"{activationScriptPath}\"",
"exit /b %ERRORLEVEL%",
string.Empty);
}

public static string CreateActivationScript(string packageFamilyName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageFamilyName);

string applicationUserModelId =
$"{packageFamilyName}!{ControlApplicationId}";
return string.Join(
"\r\n",
PowerShellMarker,
"$ErrorActionPreference = 'Stop'",
$"$packageFamilyName = '{QuotePowerShell(packageFamilyName)}'",
$"$applicationId = '{ControlApplicationId}'",
$"$arguments = '{ControlArguments}'",
$"$applicationUserModelId = '{QuotePowerShell(applicationUserModelId)}'",
"$package = Get-AppxPackage -Name 'OpenClaw.Gateway' | " +
"Where-Object { $_.PackageFamilyName -eq $packageFamilyName } | " +
"Select-Object -First 1",
"if ($null -eq $package) { throw \"Package '$packageFamilyName' is not registered for the current user.\" }",
"if ($package.Status -inotmatch '^(Ok|Ready)$') { throw \"Package '$packageFamilyName' is not ready (status $($package.Status)).\" }",
"$manifestPath = Join-Path $package.InstallLocation 'AppxManifest.xml'",
"if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw \"Package '$packageFamilyName' has no AppxManifest.xml at $manifestPath.\" }",
"[xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw",
"$application = @($manifest.Package.Applications.Application | Where-Object { $_.Id -eq $applicationId }) | Select-Object -First 1",
"if ($null -eq $application) { throw \"Package '$packageFamilyName' does not declare application '$applicationId'.\" }",
"if ([string]$application.Executable -ne 'openclaw.exe') { throw \"Package '$packageFamilyName' application '$applicationId' does not target openclaw.exe.\" }",
"$source = @'",
"using System;",
"using System.ComponentModel;",
"using System.Runtime.InteropServices;",
"namespace OpenClaw.PackageActivation",
"{",
" [ComImport]",
" [Guid(\"2e941141-7f97-4756-ba1d-9decde894a3d\")]",
" [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
" public interface IApplicationActivationManager",
" {",
" [PreserveSig]",
" int ActivateApplication(",
" [MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,",
" [MarshalAs(UnmanagedType.LPWStr)] string arguments,",
" uint options,",
" out uint processId);",
" }",
"",
" [ComImport]",
" [Guid(\"45ba127d-10a8-46ea-8ab7-56ea9078943c\")]",
" public class ApplicationActivationManager",
" {",
" }",
"",
" public static class ApplicationActivator",
" {",
" private const uint Synchronize = 0x00100000;",
" private const uint QueryLimitedInformation = 0x1000;",
" private const uint Infinite = 0xffffffff;",
" private const uint WaitObject0 = 0;",
"",
" [DllImport(\"kernel32.dll\", SetLastError = true)]",
" private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId);",
"",
" [DllImport(\"kernel32.dll\", SetLastError = true)]",
" private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);",
"",
" [DllImport(\"kernel32.dll\", SetLastError = true)]",
" [return: MarshalAs(UnmanagedType.Bool)]",
" private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);",
"",
" [DllImport(\"kernel32.dll\")]",
" [return: MarshalAs(UnmanagedType.Bool)]",
" private static extern bool CloseHandle(IntPtr handle);",
"",
" public static int ActivateAndWait(string appUserModelId, string arguments)",
" {",
" var manager =",
" (IApplicationActivationManager)new ApplicationActivationManager();",
" uint processId;",
" int result = manager.ActivateApplication(",
" appUserModelId,",
" arguments,",
" 0,",
" out processId);",
" if (result != 0)",
" {",
" Marshal.ThrowExceptionForHR(result);",
" }",
"",
" IntPtr process = OpenProcess(",
" Synchronize | QueryLimitedInformation,",
" false,",
" processId);",
" if (process == IntPtr.Zero)",
" {",
" throw new Win32Exception(Marshal.GetLastWin32Error());",
" }",
"",
" try",
" {",
" uint exitCode;",
" if (WaitForSingleObject(process, Infinite) != WaitObject0 ||",
" !GetExitCodeProcess(process, out exitCode))",
" {",
" throw new Win32Exception(Marshal.GetLastWin32Error());",
" }",
"",
" return checked((int)exitCode);",
" }",
" finally",
" {",
" CloseHandle(process);",
" }",
" }",
" }",
"}",
"'@",
"Add-Type -TypeDefinition $source -Language CSharp",
"$exitCode = [OpenClaw.PackageActivation.ApplicationActivator]::ActivateAndWait($applicationUserModelId, $arguments)",
"exit $exitCode",
string.Empty);
}

/// <summary>
/// The Startup-folder fallback calls the same launcher rather than
/// repeating its contents, so the two lanes cannot drift apart.
/// </summary>
public static string CreateFallback(string launcherPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(launcherPath);

return string.Join(
"\r\n",
"@echo off",
Marker,
"@rem Generated by OpenClaw. Edits are overwritten on the next install.",
"chcp 65001 >nul",
$"call \"{launcherPath}\"",
"exit /b %ERRORLEVEL%",
string.Empty);
}

public static bool LooksGenerated(string content)
{
ArgumentNullException.ThrowIfNull(content);
return content.Contains(Marker, StringComparison.Ordinal) ||
content.Contains(PowerShellMarker, StringComparison.Ordinal);
}

private static string QuotePowerShell(string value) =>
value.Replace("'", "''", StringComparison.Ordinal);
}
5 changes: 0 additions & 5 deletions src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,6 @@ internal sealed record GatewayPersistenceRemovalResult(
/// The directory the gateway is given, rather than the system directory a
/// logon task would otherwise inherit.
/// </param>
/// <param name="AliasCommand">
/// The control command the launcher invokes. Held as configuration so the
/// launcher file can be regenerated without re-registering the task.
/// </param>
/// <param name="CommandProcessorPath">
/// The absolute path to the inbox command processor the task runs.
/// </param>
Expand All @@ -91,5 +87,4 @@ internal sealed record GatewayPersistenceOptions(
string LauncherPath,
string StartupFolderPath,
string WorkingDirectory,
string AliasCommand,
string CommandProcessorPath);
Loading
Loading