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
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,15 @@ jobs:
- name: Smoke native cursor overlay helper
run: npm run native:smoke-overlay

- name: Smoke native update launcher helper
run: npm run native:smoke-update-launcher

- name: Package Windows installer
run: npm run package:win

- name: Verify updater metadata
run: npm run package:win:verify-updater-metadata

- name: Verify release tag matches package version
shell: powershell
run: |
Expand Down
2 changes: 2 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ extraResources:
to: native/SwitchifyBluetoothTransport.exe
- from: build/native/text-input-helper/win-x64/SwitchifyTextInput.exe
to: native/SwitchifyTextInput.exe
- from: build/native/update-launcher-helper/win-x64/SwitchifyUpdateLauncher.exe
to: native/SwitchifyUpdateLauncher.exe

win:
icon: build/icon.ico
Expand Down
99 changes: 99 additions & 0 deletions native/update-launcher-helper/NativeMethods.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;

namespace Switchify.UpdateLauncher;

internal static class NativeMethods
{
internal const uint SeeMaskNoCloseProcess = 0x00000040;
internal const int SwShownormal = 1;

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal sealed class ShellExecuteInfo
{
public int cbSize = Marshal.SizeOf<ShellExecuteInfo>();
public uint fMask;
public IntPtr hwnd;
public string? lpVerb;
public string? lpFile;
public string? lpParameters;
public string? lpDirectory;
public int nShow;
public IntPtr hInstApp;
public IntPtr lpIDList;
public string? lpClass;
public IntPtr hkeyClass;
public uint dwHotKey;
public IntPtr hIcon;
public IntPtr hProcess;
}

[DllImport("shell32.dll", EntryPoint = "ShellExecuteExW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool ShellExecuteEx(ShellExecuteInfo info);

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool CloseHandle(IntPtr handle);

[DllImport("kernel32.dll", SetLastError = true)]
internal static extern uint GetProcessId(IntPtr process);

internal static string JoinCommandLineArguments(IEnumerable<string> args)
{
return string.Join(" ", args.Select(QuoteCommandLineArgument));
}

private static string QuoteCommandLineArgument(string value)
{
if (value.Length == 0)
{
return "\"\"";
}

if (!value.Any(char.IsWhiteSpace) && !value.Contains('"') && !value.Contains('\\'))
{
return value;
}

var builder = new StringBuilder();
builder.Append('"');
var backslashCount = 0;

foreach (var character in value)
{
if (character == '\\')
{
backslashCount++;
continue;
}

if (character == '"')
{
builder.Append('\\', backslashCount * 2 + 1);
builder.Append('"');
backslashCount = 0;
continue;
}

builder.Append('\\', backslashCount);
builder.Append(character);
backslashCount = 0;
}

builder.Append('\\', backslashCount * 2);
builder.Append('"');
return builder.ToString();
}

internal static int GetLastWin32Error()
{
return Marshal.GetLastWin32Error();
}

internal static string Win32Message(int error)
{
return new Win32Exception(error).Message;
}
}
131 changes: 131 additions & 0 deletions native/update-launcher-helper/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.Text.Json;

namespace Switchify.UpdateLauncher;

internal static class Program
{
private const int ErrorCancelled = 1223;

private static int Main(string[] args)
{
try
{
if (args.Length == 1 && args[0] == "--self-test-quote")
{
Console.Out.WriteLine(NativeMethods.JoinCommandLineArguments(new[] { "--updated", "--force-run", "value with spaces" }));
return 0;
}

var parsed = ParseArgs(args);
if (parsed is null)
{
WriteResult(false, "invalid_arguments");
return 2;
}

if (!File.Exists(parsed.InstallerPath))
{
WriteResult(false, "installer_missing");
return 3;
}

var parameters = NativeMethods.JoinCommandLineArguments(parsed.InstallerArgs);
var info = new NativeMethods.ShellExecuteInfo
{
fMask = NativeMethods.SeeMaskNoCloseProcess,
lpVerb = "runas",
lpFile = parsed.InstallerPath,
lpParameters = parameters,
nShow = NativeMethods.SwShownormal
};

if (!NativeMethods.ShellExecuteEx(info))
{
var error = NativeMethods.GetLastWin32Error();
WriteResult(false, error == ErrorCancelled ? "uac_cancelled" : "launch_failed", null, error);
return error == ErrorCancelled ? 4 : 5;
}

if (info.hProcess == IntPtr.Zero)
{
WriteResult(false, "installer_process_unavailable");
return 6;
}

try
{
var pid = NativeMethods.GetProcessId(info.hProcess);
WriteResult(true, "installer_started", pid == 0 ? null : checked((int)pid));
return 0;
}
finally
{
NativeMethods.CloseHandle(info.hProcess);
}
}
catch
{
WriteResult(false, "unexpected_error");
return 10;
}
}

private static ParsedArgs? ParseArgs(string[] args)
{
string? installerPath = null;
string? argsJson = null;

for (var index = 0; index < args.Length; index++)
{
var arg = args[index];
if (arg == "--installer" && index + 1 < args.Length)
{
installerPath = args[++index];
continue;
}

if (arg == "--args-json" && index + 1 < args.Length)
{
argsJson = args[++index];
continue;
}

return null;
}

if (string.IsNullOrWhiteSpace(installerPath) || string.IsNullOrWhiteSpace(argsJson))
{
return null;
}

string[]? installerArgs;
try
{
installerArgs = JsonSerializer.Deserialize<string[]>(argsJson);
}
catch (JsonException)
{
return null;
}

if (installerArgs is null || installerArgs.Any((value) => value is null))
{
return null;
}

return new ParsedArgs(installerPath, installerArgs);
}

private static void WriteResult(bool ok, string status, int? pid = null, int? win32Error = null)
{
Console.Out.WriteLine(JsonSerializer.Serialize(new UpdateLauncherResult
{
Ok = ok,
Status = status,
Pid = pid,
Win32Error = win32Error
}));
}

private sealed record ParsedArgs(string InstallerPath, string[] InstallerArgs);
}
14 changes: 14 additions & 0 deletions native/update-launcher-helper/SwitchifyUpdateLauncher.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>SwitchifyUpdateLauncher</AssemblyName>
<RootNamespace>Switchify.UpdateLauncher</RootNamespace>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>
20 changes: 20 additions & 0 deletions native/update-launcher-helper/UpdateLauncherResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;

namespace Switchify.UpdateLauncher;

internal sealed class UpdateLauncherResult
{
[JsonPropertyName("ok")]
public required bool Ok { get; init; }

[JsonPropertyName("status")]
public required string Status { get; init; }

[JsonPropertyName("pid")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Pid { get; init; }

[JsonPropertyName("win32Error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Win32Error { get; init; }
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
"native:build-overlay": "npm run native:build",
"native:smoke-overlay": "node scripts/smoke-cursor-overlay-helper.cjs",
"native:smoke-text": "node scripts/smoke-text-input-helper.cjs",
"native:smoke-update-launcher": "node scripts/smoke-update-launcher-helper.cjs",
"package:win": "npm run build && npm run native:build && electron-builder --win --x64 --publish never && node scripts/sign-win-artifacts.cjs --update-latest-yml",
"package:win:verify-uiaccess": "node scripts/verify-win-uiaccess-package.cjs",
"package:win:verify-updater-metadata": "node scripts/verify-latest-yml-admin-rights.cjs",
"signing:create-dev-cert": "node scripts/create-dev-signing-cert.cjs",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"test": "vitest run"
Expand Down
6 changes: 6 additions & 0 deletions scripts/build-cursor-overlay-helper.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ const helpers = [
projectPath: resolveProjectPath('native', 'text-input-helper', 'TextInputHelper.csproj'),
outputDir: resolveProjectPath('build', 'native', 'text-input-helper', 'win-x64'),
outputExeName: 'SwitchifyTextInput.exe'
},
{
name: 'update launcher helper',
projectPath: resolveProjectPath('native', 'update-launcher-helper', 'SwitchifyUpdateLauncher.csproj'),
outputDir: resolveProjectPath('build', 'native', 'update-launcher-helper', 'win-x64'),
outputExeName: 'SwitchifyUpdateLauncher.exe'
}
];

Expand Down
10 changes: 9 additions & 1 deletion scripts/package-win-after-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,15 @@ function signWindowsExecutable(filePath) {
runTool(signtoolExe, signingArgs);
}

const nativeHelperNames = [
'SwitchifyCursorOverlay.exe',
'SwitchifyBluetoothTransport.exe',
'SwitchifyTextInput.exe',
'SwitchifyUpdateLauncher.exe'
];

function signNativeHelpers(appOutDir) {
for (const helperName of ['SwitchifyCursorOverlay.exe', 'SwitchifyBluetoothTransport.exe', 'SwitchifyTextInput.exe']) {
for (const helperName of nativeHelperNames) {
const helperPath = path.join(appOutDir, 'resources', 'native', helperName);
if (!fs.existsSync(helperPath)) {
throw new Error(`Native helper is missing from packaged resources: ${helperPath}`);
Expand Down Expand Up @@ -238,3 +245,4 @@ function createAzureSigningArgs(filePath, requireSigning) {
}

module.exports.createSigningArgs = createSigningArgs;
module.exports.nativeHelperNames = nativeHelperNames;
Loading