Skip to content

Commit 6357a03

Browse files
Fix update installer handoff (#268)
Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 11edebe commit 6357a03

27 files changed

Lines changed: 1020 additions & 121 deletions

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,15 @@ jobs:
8888
- name: Smoke native cursor overlay helper
8989
run: npm run native:smoke-overlay
9090

91+
- name: Smoke native update launcher helper
92+
run: npm run native:smoke-update-launcher
93+
9194
- name: Package Windows installer
9295
run: npm run package:win
9396

97+
- name: Verify updater metadata
98+
run: npm run package:win:verify-updater-metadata
99+
94100
- name: Verify release tag matches package version
95101
shell: powershell
96102
run: |

electron-builder.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ extraResources:
3232
to: native/SwitchifyBluetoothTransport.exe
3333
- from: build/native/text-input-helper/win-x64/SwitchifyTextInput.exe
3434
to: native/SwitchifyTextInput.exe
35+
- from: build/native/update-launcher-helper/win-x64/SwitchifyUpdateLauncher.exe
36+
to: native/SwitchifyUpdateLauncher.exe
3537

3638
win:
3739
icon: build/icon.ico
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.ComponentModel;
2+
using System.Runtime.InteropServices;
3+
using System.Text;
4+
5+
namespace Switchify.UpdateLauncher;
6+
7+
internal static class NativeMethods
8+
{
9+
internal const uint SeeMaskNoCloseProcess = 0x00000040;
10+
internal const int SwShownormal = 1;
11+
12+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
13+
internal sealed class ShellExecuteInfo
14+
{
15+
public int cbSize = Marshal.SizeOf<ShellExecuteInfo>();
16+
public uint fMask;
17+
public IntPtr hwnd;
18+
public string? lpVerb;
19+
public string? lpFile;
20+
public string? lpParameters;
21+
public string? lpDirectory;
22+
public int nShow;
23+
public IntPtr hInstApp;
24+
public IntPtr lpIDList;
25+
public string? lpClass;
26+
public IntPtr hkeyClass;
27+
public uint dwHotKey;
28+
public IntPtr hIcon;
29+
public IntPtr hProcess;
30+
}
31+
32+
[DllImport("shell32.dll", EntryPoint = "ShellExecuteExW", SetLastError = true, CharSet = CharSet.Unicode)]
33+
[return: MarshalAs(UnmanagedType.Bool)]
34+
internal static extern bool ShellExecuteEx(ShellExecuteInfo info);
35+
36+
[DllImport("kernel32.dll", SetLastError = true)]
37+
[return: MarshalAs(UnmanagedType.Bool)]
38+
internal static extern bool CloseHandle(IntPtr handle);
39+
40+
[DllImport("kernel32.dll", SetLastError = true)]
41+
internal static extern uint GetProcessId(IntPtr process);
42+
43+
internal static string JoinCommandLineArguments(IEnumerable<string> args)
44+
{
45+
return string.Join(" ", args.Select(QuoteCommandLineArgument));
46+
}
47+
48+
private static string QuoteCommandLineArgument(string value)
49+
{
50+
if (value.Length == 0)
51+
{
52+
return "\"\"";
53+
}
54+
55+
if (!value.Any(char.IsWhiteSpace) && !value.Contains('"') && !value.Contains('\\'))
56+
{
57+
return value;
58+
}
59+
60+
var builder = new StringBuilder();
61+
builder.Append('"');
62+
var backslashCount = 0;
63+
64+
foreach (var character in value)
65+
{
66+
if (character == '\\')
67+
{
68+
backslashCount++;
69+
continue;
70+
}
71+
72+
if (character == '"')
73+
{
74+
builder.Append('\\', backslashCount * 2 + 1);
75+
builder.Append('"');
76+
backslashCount = 0;
77+
continue;
78+
}
79+
80+
builder.Append('\\', backslashCount);
81+
builder.Append(character);
82+
backslashCount = 0;
83+
}
84+
85+
builder.Append('\\', backslashCount * 2);
86+
builder.Append('"');
87+
return builder.ToString();
88+
}
89+
90+
internal static int GetLastWin32Error()
91+
{
92+
return Marshal.GetLastWin32Error();
93+
}
94+
95+
internal static string Win32Message(int error)
96+
{
97+
return new Win32Exception(error).Message;
98+
}
99+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
using System.Text.Json;
2+
3+
namespace Switchify.UpdateLauncher;
4+
5+
internal static class Program
6+
{
7+
private const int ErrorCancelled = 1223;
8+
9+
private static int Main(string[] args)
10+
{
11+
try
12+
{
13+
if (args.Length == 1 && args[0] == "--self-test-quote")
14+
{
15+
Console.Out.WriteLine(NativeMethods.JoinCommandLineArguments(new[] { "--updated", "--force-run", "value with spaces" }));
16+
return 0;
17+
}
18+
19+
var parsed = ParseArgs(args);
20+
if (parsed is null)
21+
{
22+
WriteResult(false, "invalid_arguments");
23+
return 2;
24+
}
25+
26+
if (!File.Exists(parsed.InstallerPath))
27+
{
28+
WriteResult(false, "installer_missing");
29+
return 3;
30+
}
31+
32+
var parameters = NativeMethods.JoinCommandLineArguments(parsed.InstallerArgs);
33+
var info = new NativeMethods.ShellExecuteInfo
34+
{
35+
fMask = NativeMethods.SeeMaskNoCloseProcess,
36+
lpVerb = "runas",
37+
lpFile = parsed.InstallerPath,
38+
lpParameters = parameters,
39+
nShow = NativeMethods.SwShownormal
40+
};
41+
42+
if (!NativeMethods.ShellExecuteEx(info))
43+
{
44+
var error = NativeMethods.GetLastWin32Error();
45+
WriteResult(false, error == ErrorCancelled ? "uac_cancelled" : "launch_failed", null, error);
46+
return error == ErrorCancelled ? 4 : 5;
47+
}
48+
49+
if (info.hProcess == IntPtr.Zero)
50+
{
51+
WriteResult(false, "installer_process_unavailable");
52+
return 6;
53+
}
54+
55+
try
56+
{
57+
var pid = NativeMethods.GetProcessId(info.hProcess);
58+
WriteResult(true, "installer_started", pid == 0 ? null : checked((int)pid));
59+
return 0;
60+
}
61+
finally
62+
{
63+
NativeMethods.CloseHandle(info.hProcess);
64+
}
65+
}
66+
catch
67+
{
68+
WriteResult(false, "unexpected_error");
69+
return 10;
70+
}
71+
}
72+
73+
private static ParsedArgs? ParseArgs(string[] args)
74+
{
75+
string? installerPath = null;
76+
string? argsJson = null;
77+
78+
for (var index = 0; index < args.Length; index++)
79+
{
80+
var arg = args[index];
81+
if (arg == "--installer" && index + 1 < args.Length)
82+
{
83+
installerPath = args[++index];
84+
continue;
85+
}
86+
87+
if (arg == "--args-json" && index + 1 < args.Length)
88+
{
89+
argsJson = args[++index];
90+
continue;
91+
}
92+
93+
return null;
94+
}
95+
96+
if (string.IsNullOrWhiteSpace(installerPath) || string.IsNullOrWhiteSpace(argsJson))
97+
{
98+
return null;
99+
}
100+
101+
string[]? installerArgs;
102+
try
103+
{
104+
installerArgs = JsonSerializer.Deserialize<string[]>(argsJson);
105+
}
106+
catch (JsonException)
107+
{
108+
return null;
109+
}
110+
111+
if (installerArgs is null || installerArgs.Any((value) => value is null))
112+
{
113+
return null;
114+
}
115+
116+
return new ParsedArgs(installerPath, installerArgs);
117+
}
118+
119+
private static void WriteResult(bool ok, string status, int? pid = null, int? win32Error = null)
120+
{
121+
Console.Out.WriteLine(JsonSerializer.Serialize(new UpdateLauncherResult
122+
{
123+
Ok = ok,
124+
Status = status,
125+
Pid = pid,
126+
Win32Error = win32Error
127+
}));
128+
}
129+
130+
private sealed record ParsedArgs(string InstallerPath, string[] InstallerArgs);
131+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net8.0-windows</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<AssemblyName>SwitchifyUpdateLauncher</AssemblyName>
8+
<RootNamespace>Switchify.UpdateLauncher</RootNamespace>
9+
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
10+
<SelfContained>true</SelfContained>
11+
<PublishSingleFile>true</PublishSingleFile>
12+
<PublishTrimmed>false</PublishTrimmed>
13+
</PropertyGroup>
14+
</Project>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Switchify.UpdateLauncher;
4+
5+
internal sealed class UpdateLauncherResult
6+
{
7+
[JsonPropertyName("ok")]
8+
public required bool Ok { get; init; }
9+
10+
[JsonPropertyName("status")]
11+
public required string Status { get; init; }
12+
13+
[JsonPropertyName("pid")]
14+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
15+
public int? Pid { get; init; }
16+
17+
[JsonPropertyName("win32Error")]
18+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
19+
public int? Win32Error { get; init; }
20+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
"native:build-overlay": "npm run native:build",
1111
"native:smoke-overlay": "node scripts/smoke-cursor-overlay-helper.cjs",
1212
"native:smoke-text": "node scripts/smoke-text-input-helper.cjs",
13+
"native:smoke-update-launcher": "node scripts/smoke-update-launcher-helper.cjs",
1314
"package:win": "npm run build && npm run native:build && electron-builder --win --x64 --publish never && node scripts/sign-win-artifacts.cjs --update-latest-yml",
1415
"package:win:verify-uiaccess": "node scripts/verify-win-uiaccess-package.cjs",
16+
"package:win:verify-updater-metadata": "node scripts/verify-latest-yml-admin-rights.cjs",
1517
"signing:create-dev-cert": "node scripts/create-dev-signing-cert.cjs",
1618
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
1719
"test": "vitest run"

scripts/build-cursor-overlay-helper.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ const helpers = [
2121
projectPath: resolveProjectPath('native', 'text-input-helper', 'TextInputHelper.csproj'),
2222
outputDir: resolveProjectPath('build', 'native', 'text-input-helper', 'win-x64'),
2323
outputExeName: 'SwitchifyTextInput.exe'
24+
},
25+
{
26+
name: 'update launcher helper',
27+
projectPath: resolveProjectPath('native', 'update-launcher-helper', 'SwitchifyUpdateLauncher.csproj'),
28+
outputDir: resolveProjectPath('build', 'native', 'update-launcher-helper', 'win-x64'),
29+
outputExeName: 'SwitchifyUpdateLauncher.exe'
2430
}
2531
];
2632

scripts/package-win-after-pack.cjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,15 @@ function signWindowsExecutable(filePath) {
9494
runTool(signtoolExe, signingArgs);
9595
}
9696

97+
const nativeHelperNames = [
98+
'SwitchifyCursorOverlay.exe',
99+
'SwitchifyBluetoothTransport.exe',
100+
'SwitchifyTextInput.exe',
101+
'SwitchifyUpdateLauncher.exe'
102+
];
103+
97104
function signNativeHelpers(appOutDir) {
98-
for (const helperName of ['SwitchifyCursorOverlay.exe', 'SwitchifyBluetoothTransport.exe', 'SwitchifyTextInput.exe']) {
105+
for (const helperName of nativeHelperNames) {
99106
const helperPath = path.join(appOutDir, 'resources', 'native', helperName);
100107
if (!fs.existsSync(helperPath)) {
101108
throw new Error(`Native helper is missing from packaged resources: ${helperPath}`);
@@ -238,3 +245,4 @@ function createAzureSigningArgs(filePath, requireSigning) {
238245
}
239246

240247
module.exports.createSigningArgs = createSigningArgs;
248+
module.exports.nativeHelperNames = nativeHelperNames;

0 commit comments

Comments
 (0)