-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathProgram.cs
468 lines (403 loc) · 20.9 KB
/
Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CommandLine;
using WixSharp;
using WixSharp.Bootstrapper;
using WixSharp.CommonTasks;
using File = WixSharp.File;
using SystemFile = System.IO.File;
namespace Coder.Desktop.Installer;
public class SharedOptions
{
[Option('a', "arch", Required = true, HelpText = "Platform to build for (x64, arm64)")]
public Platform Platform { get; set; }
[Option('v', "version", Required = true, HelpText = "Product version (e.g. 1.0.0.0)")]
public Version Version { get; set; }
[Option('l', "license-file", Default = "License.rtf",
HelpText = "Path to the License.rtf file to embed into the installer UI")]
public string LicenseFile { get; set; }
[Option("icon-file", Default = "coder.ico", HelpText = "Path to the icon file to embed into the installer")]
public string IconFile { get; set; }
[Option('o', "output-path", Required = true, HelpText = "Output path for the build MSI/exe")]
public string OutputPath { get; set; }
public void Validate()
{
if (Platform is not Platform.x64 and not Platform.arm64)
throw new ArgumentException($"Invalid platform '{Platform}' specified", nameof(Platform));
if (Version == null || Version.Major < 0 || Version.Minor < 0 || Version.Build < 0 || Version.Revision < 0)
throw new ArgumentException($"Invalid version '{Version}' specified, must have 4 components",
nameof(Version));
if (!SystemFile.Exists(LicenseFile))
throw new ArgumentException($"License file not found at '{LicenseFile}'", nameof(LicenseFile));
if (!SystemFile.Exists(IconFile))
throw new ArgumentException($"Icon file not found at '{IconFile}'", nameof(IconFile));
}
}
[Verb("build-msi", HelpText = "Build the MSI package")]
public class MsiOptions : SharedOptions
{
[Option('i', "input-dir", Required = true, HelpText = "Input directory for the files to be installed")]
public string InputDir { get; set; }
[Option("service-exe", Default = "CoderVpnService.exe",
HelpText = "Sub path within --input-dir of the service executable")]
public string ServiceExe { get; set; }
[Option("service-name", Default = "Coder Desktop",
HelpText =
"The service name for the installer to create during install. This should match what the service binary expects during startup")]
public string ServiceName { get; set; }
[Option("app-exe", Default = "Coder Desktop.exe",
HelpText = "Sub path within --input-dir of the desktop app executable")]
public string AppExe { get; set; }
[Option("vpn-dir", Default = "vpn",
HelpText = "Sub path within --input-dir for the Coder VPN directory. Must contain wintun.dll")]
public string VpnDir { get; set; }
[Option("banner-bmp", Default = "banner.bmp", HelpText = "Top banner.bmp of MSI installer UI (493x58)")]
public string BannerBmp { get; set; }
[Option("dialog-bmp", Default = "dialog.bmp", HelpText = "Background dialog.bmp of MSI installer UI (493x312)")]
public string DialogBmp { get; set; }
public new void Validate()
{
base.Validate();
InputDir = Path.GetFullPath(InputDir);
if (!Directory.Exists(InputDir))
throw new ArgumentException($"Input directory '{InputDir}' does not exist", nameof(InputDir));
var serviceExe = Path.Combine(InputDir, ServiceExe);
if (!SystemFile.Exists(serviceExe))
throw new ArgumentException($"Service executable not found at '{serviceExe}'", nameof(ServiceExe));
if (string.IsNullOrEmpty(ServiceName))
throw new ArgumentException("Service name is required", nameof(ServiceName));
var appExe = Path.Combine(InputDir, AppExe);
if (!SystemFile.Exists(appExe))
throw new ArgumentException($"App executable not found at '{appExe}'", nameof(AppExe));
var vpnDir = Path.Combine(InputDir, VpnDir);
if (!Directory.Exists(vpnDir))
throw new ArgumentException($"VPN directory '{vpnDir}' does not exist", nameof(VpnDir));
var wintunDll = Path.Combine(vpnDir, "wintun.dll");
if (!SystemFile.Exists(wintunDll))
throw new ArgumentException($"wintun.dll not found at '{wintunDll}'", nameof(VpnDir));
if (!SystemFile.Exists(BannerBmp))
throw new ArgumentException($"Banner BMP file not found at '{BannerBmp}'", nameof(BannerBmp));
if (!SystemFile.Exists(DialogBmp))
throw new ArgumentException($"Dialog BMP file not found at '{DialogBmp}'", nameof(DialogBmp));
}
}
[Verb("build-bootstrapper", HelpText = "Build the bootstrapper executable")]
public class BootstrapperOptions : SharedOptions
{
[Option("logo-png", Default = "logo.png", HelpText = "Logo.png for the bootstrapper UI (75x75)")]
public string LogoPng { get; set; }
[Option('m', "msi-path", Required = true, HelpText = "Path to the MSI package to embed")]
public string MsiPath { get; set; }
[Option('w', "windows-app-sdk-path", Required = true, HelpText = "Path to the Windows App Sdk package to embed")]
public string WindowsAppSdkPath { get; set; }
public new void Validate()
{
base.Validate();
if (!SystemFile.Exists(LogoPng))
throw new ArgumentException($"Logo PNG file not found at '{LogoPng}'", nameof(LogoPng));
if (!SystemFile.Exists(MsiPath))
throw new ArgumentException($"MSI package not found at '{MsiPath}'", nameof(MsiPath));
if (!SystemFile.Exists(WindowsAppSdkPath))
throw new ArgumentException($"Windows App Sdk package not found at '{WindowsAppSdkPath}'", nameof(WindowsAppSdkPath));
}
}
public class Program
{
private const string ProductName = "Coder Desktop";
private const string Manufacturer = "Coder Technologies Inc.";
private const string HelpUrl = "https://coder.com/docs";
private const string RegistryKey = @"SOFTWARE\Coder Desktop";
private const string DotNetCheckName = "DOTNET_RUNTIME_CHECK";
private const RollForward DotNetCheckRollForward = RollForward.minor;
private const RuntimeType DotNetCheckRuntimeType = RuntimeType.desktop;
private static readonly Guid MsiUpgradeCode = new("4d0a5478-9f5b-4c23-8fa1-c7d67b4242b1");
private static readonly Guid BundleUpgradeCode = new("8a773cf1-029c-4ba8-a3f8-dda90f9e641b");
private static readonly RegistryHive RegistryHive = RegistryHive.LocalMachine;
private static readonly Version DotNetCheckVersion = new(8, 0, 0);
private static readonly Dictionary<Platform, ExePackagePayload> DotNetRuntimePackagePayloads = new()
{
[Platform.x64] = new ExePackagePayload
{
Name = ".NET Desktop Runtime 8.0.13 (x64).exe",
DownloadUrl =
"https://download.visualstudio.microsoft.com/download/pr/fc8c9dea-8180-4dad-bf1b-5f229cf47477/c3f0536639ab40f1470b6bad5e1b95b8/windowsdesktop-runtime-8.0.13-win-x64.exe",
Hash =
"abeef95a520e5d22d4a8b0d369fe103c2552a5c337500582e850da3611135bb68bb479d123cee85a445310cf4db73037e6198eec40d66d4d746ef2e2e5f1450f",
Size = 58_433_888,
},
[Platform.arm64] = new ExePackagePayload
{
Name = ".NET Desktop Runtime 8.0.13 (arm64).exe",
DownloadUrl =
"https://download.visualstudio.microsoft.com/download/pr/7468483d-b69c-4ff8-b900-e046f3a73e8d/fce0ba9123be8a4cc10ed1c73af09ae6/windowsdesktop-runtime-8.0.13-win-arm64.exe",
Hash =
"45c1fc3f5adb8551fb0ee805ad6c5046a9447da38cbe6b7e9d04fdc995d21885b8b9415ba3bc9040644d82e04aab3a88c625854efc7870ac0236d0368de90c3c",
Size = 53_944_552,
},
};
private static int Main(string[] args)
{
try
{
WixExtension.Bal.PreferredVersion = "5.0.2";
WixExtension.NetFx.PreferredVersion = "5.0.2";
WixExtension.UI.PreferredVersion = "5.0.2";
WixExtension.Util.PreferredVersion = "5.0.2";
Compiler.VerboseOutput = true;
Compiler.OutputWriteLine = Console.Error.WriteLine;
Compiler.WixSourceSaved += PrintFile;
AutoElements.DisableAutoUserProfileRegistry = true;
return Parser.Default.ParseArguments<MsiOptions, BootstrapperOptions>(args)
.MapResult(
(MsiOptions opts) => BuildMsiPackage(opts),
(BootstrapperOptions opts) => BuildBundle(opts),
errs => 1);
}
catch (Exception ex)
{
Console.Error.WriteLine();
Console.Error.WriteLine($"Error: {ex}");
return 1;
}
}
private static int BuildMsiPackage(MsiOptions opts)
{
opts.Validate();
var project = new Project($"{ProductName} (Core)");
// Manually iterate over all the files in the input directory
// recursively and create Dir and File elements for each. Also adds a
// ServiceInstaller element for the service executable and a
// FileShortcut element for the desktop app executable.
var service = new ServiceInstaller
{
Name = opts.ServiceName,
StartOn = SvcEvent.Install,
StopOn = SvcEvent.InstallUninstall_Wait,
RemoveOn = SvcEvent.Uninstall_Wait,
DelayedAutoStart = false,
Start = SvcStartType.auto,
ServiceSid = ServiceSid.none,
FirstFailureActionType = FailureActionType.restart,
SecondFailureActionType = FailureActionType.restart,
ThirdFailureActionType = FailureActionType.none,
RestartServiceDelayInSeconds = 30,
ResetPeriodInDays = 1,
PreShutdownDelay = 1000 * 60 * 3, // default
// This matches Tailscale's service dependencies.
DependsOn =
[
new ServiceDependency("iphlpsvc"), // IP Helper
new ServiceDependency("netprofm"), // Network List Service
new ServiceDependency("WinHttpAutoProxySvc"), // WinHTTP Web Proxy Auto-Discovery Service
],
};
var shortcut = new FileShortcut("Coder Desktop", "%StartMenuFolder%")
{
IconFile = "", // not required since the app has its own icon
WorkingDirectory = "[INSTALLFOLDER]",
Description = "Launch Coder Desktop",
};
var programFiles64Folder = new Dir(new Id("ProgramFiles64Folder"));
var installDir = new Dir(new Id("INSTALLFOLDER"), ProductName);
RecursivelyAddDirectory(installDir, opts.InputDir, path =>
{
var normalizedPath = path.Replace('\\', '/');
var normalizedServiceExe = opts.ServiceExe.Replace('\\', '/');
var normalizedAppExe = opts.AppExe.Replace('\\', '/');
if (normalizedPath.EndsWith(normalizedServiceExe, StringComparison.OrdinalIgnoreCase)) return [service];
if (normalizedPath.EndsWith(normalizedAppExe, StringComparison.OrdinalIgnoreCase)) return [shortcut];
return [];
});
programFiles64Folder.AddDir(installDir);
project.AddDir(programFiles64Folder);
project.AddRegValues(
// Add registry values that are consumed by the manager. Note that these
// should not be changed. See Vpn.Service/Program.cs and
// Vpn.Service/ManagerConfig.cs for more details.
new RegValue(RegistryHive, RegistryKey, "Manager:ServiceRpcPipeName", "Coder.Desktop.Vpn"),
new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryPath",
$"[INSTALLFOLDER]{opts.VpnDir}\\coder-vpn.exe"),
new RegValue(RegistryHive, RegistryKey, "Manager:LogFileLocation",
@"[INSTALLFOLDER]coder-desktop-service.log"),
new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinarySignatureSigner", "Coder Technologies Inc."),
new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryAllowVersionMismatch", "false"),
// Add registry values that are consumed by the App MutagenController. See App/Services/MutagenController.cs
new RegValue(RegistryHive, RegistryKey, "AppMutagenController:MutagenExecutablePath",
@"[INSTALLFOLDER]mutagen.exe")
);
// Note: most of this control panel info will not be visible as this
// package is usually hidden in favor of the bootstrapper showing
// instead.
project.Description = $"Contains the required components of {ProductName} by {Manufacturer}.";
project.ControlPanelInfo.ProductIcon = opts.IconFile;
project.ControlPanelInfo.Comments = ProductName;
project.ControlPanelInfo.Manufacturer = Manufacturer;
project.ControlPanelInfo.HelpLink = HelpUrl;
project.ControlPanelInfo.InstallLocation = "[INSTALLFOLDER]";
project.Platform = opts.Platform;
project.Version = opts.Version;
project.GUID = MsiUpgradeCode;
// Prevent downgrades and set an error message if a newer version is
// installed.
project.MajorUpgrade = new MajorUpgrade();
project.MajorUpgrade.AllowDowngrades = false;
project.MajorUpgrade.DowngradeErrorMessage = "A newer version of the product is installed.";
// If a user launches the core installer manually, we want to show a UI
// with the license agreement and installation directory settings.
project.UI = WUI.WixUI_InstallDir;
project.LicenceFile = opts.LicenseFile;
project.WixVariables["WixUIBannerBmp"] = opts.BannerBmp;
project.WixVariables["WixUIDialogBmp"] = opts.DialogBmp;
// This is a property that gets injected by the bootstrapper below.
project.Properties =
[
new Property("INSTALLFOLDER", "")
{
Secure = true,
},
];
// Check for .NET Desktop Runtime 8.0. The bootstrapper will also check
// and install it before launching this MSI.
//
// For whatever reason, including the DotNetCompatibilityCheck element
// using `project.GenericItems` does not work (I'm guessing it's related
// to it being wrapped in a `<Fragment>` element).
//
// This manually adds it directly into the `<Package>` element without
// a wrapper, which seems to work great.
project.Include(WixExtension.NetFx);
project.WixSourceGenerated += doc =>
doc.FindFirst("Package").AddElement(
WixExtension.NetFx.ToXName("DotNetCompatibilityCheck"),
$"Property={DotNetCheckName}; RuntimeType={DotNetCheckRuntimeType}; Version={DotNetCheckVersion}; RollForward={DotNetCheckRollForward}; Platform={opts.Platform}");
project.LaunchConditions.Add(new LaunchCondition(
Condition.Create($"Installed OR {DotNetCheckName} = 0"),
$"Please install .NET Desktop Runtime 8.0 (check result: [{DotNetCheckName}])"));
// Build the MSI package.
var outputPath = Compiler.BuildMsi(project, opts.OutputPath);
if (string.IsNullOrEmpty(outputPath))
throw new InvalidOperationException("MSI could not be built, output path is empty");
if (!SystemFile.Exists(outputPath))
throw new InvalidOperationException($"MSI was not created at '{outputPath}'");
Console.Error.WriteLine();
Console.Error.WriteLine($"MSI built at {outputPath}");
return 0;
}
private static int BuildBundle(BootstrapperOptions opts)
{
opts.Validate();
if (!DotNetRuntimePackagePayloads.TryGetValue(opts.Platform, out var dotNetRuntimePayload))
throw new ArgumentException($"Invalid architecture '{opts.Platform}' specified", nameof(opts.Platform));
var bundle = new Bundle(ProductName,
new ExePackage // .NET Runtime
{
PerMachine = true,
// Don't uninstall the runtime when the bundle is uninstalled.
Permanent = true,
// Since it's a "permanent" package, once installed we don't
// need to keep the installer around.
Cache = PackageCacheAction.remove,
DetectCondition = DotNetCheckName,
// We must not use `/quiet` so the user can accept the license
// agreement.
InstallArguments = "/norestart",
// If it fails to install for whatever reason, continue
// anyway. The MSI will fatally exit if the runtime really isn't
// available, and the user can install it themselves.
Vital = false,
Payloads = [dotNetRuntimePayload],
},
// TODO: right now we are including the Windows App Sdk in the bundle
// and always install it
// Microsoft makes it difficult to check if it exists from a regular installer:
// https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/check-windows-app-sdk-versions
// https://github.com/microsoft/WindowsAppSDK/discussions/2437
new ExePackage // Windows App Sdk
{
PerMachine = true,
Permanent = true,
Cache = PackageCacheAction.remove,
// There is no license agreement for this SDK.
InstallArguments = "--quiet",
Vital = false,
Payloads =
[
new ExePackagePayload
{
SourceFile = opts.WindowsAppSdkPath
}
],
},
new MsiPackage(opts.MsiPath)
{
ForcePerMachine = true,
// Prevent the UI of the MSI installer from appearing.
DisplayInternalUI = false,
// Hide this subpackage from the apps list. Users can uninstall
// this subpackage by uninstalling the bundle.
Visible = false,
Compressed = true,
Vital = true,
MsiProperties = "INSTALLFOLDER=[InstallFolder]",
});
bundle.Manufacturer = Manufacturer;
bundle.HelpUrl = HelpUrl;
bundle.IconFile = opts.IconFile;
bundle.Platform = opts.Platform;
bundle.Version = opts.Version;
bundle.UpgradeCode = BundleUpgradeCode;
bundle.Application.LicensePath = opts.LicenseFile;
bundle.Application.LogoFile = opts.LogoPng;
// Set the default install folder, which will eventually be passed into
// the MSI.
bundle.Variables =
[
new Variable("InstallFolder", $"[ProgramFiles64Folder]{ProductName}", false, false, VariableType.formatted),
];
// Check for .NET Desktop Runtime 8.0. For similar reasons as the MSI,
// this is manually added to the `<Bundle>` element.
bundle.Include(WixExtension.NetFx);
bundle.WixSourceGenerated += doc =>
doc.FindFirst("Bundle").AddElement(
WixExtension.NetFx.ToXName("DotNetCoreSearch"),
$"Variable={DotNetCheckName}; RuntimeType={DotNetCheckRuntimeType}; MajorVersion={DotNetCheckVersion.Major}; Platform={opts.Platform}");
// Build the bootstrapper executable.
var outputPath = Compiler.Build(bundle, opts.OutputPath);
if (string.IsNullOrEmpty(outputPath))
throw new InvalidOperationException("Bundle could not be built, output path is empty");
if (!SystemFile.Exists(outputPath))
throw new InvalidOperationException($"Bundle was not created at '{outputPath}'");
Console.Error.WriteLine();
Console.Error.WriteLine($"Bundle built at {outputPath}");
return 0;
}
private static void PrintFile(string filePath)
{
Console.Error.WriteLine();
Console.Error.WriteLine($"Contents of {filePath}:");
Console.Error.WriteLine(SystemFile.ReadAllText(filePath));
Console.Error.WriteLine();
}
private static void RecursivelyAddDirectory(Dir parent, string directoryPath,
Func<string, WixEntity[]> fileItemsFunc)
{
// Iterate over files in the directory and add them to parent.
foreach (var file in Directory.EnumerateFiles(directoryPath))
{
var items = fileItemsFunc(file);
Console.Error.WriteLine(
$"Adding file {file} into {parent.Name} with items {string.Join(", ", items.Select(i => i.GetType().Name))}");
parent.AddFile(new File(file, items));
}
// Recurse into subdirectories and add them to parent.
foreach (var subdirectory in Directory.EnumerateDirectories(directoryPath))
{
var dir = new Dir(Path.GetFileName(subdirectory));
RecursivelyAddDirectory(dir, subdirectory, fileItemsFunc);
Console.Error.WriteLine($"Adding directory {dir.Name} into {parent.Name}");
parent.AddDir(dir);
}
}
}