Skip to content

Commit 598aa70

Browse files
feat: Allow @electron/windows-sign to take over Squirrel codesigning (#501)
* feat: Allow @electron/windows-sign to take over Squirrel codesigning * test: Remove test for Node 14 * fix: Tests, correct node version * fix: We actually need Node v20 * fix: Update @electron/windows-sign * build: Update node-orb * docs: Add documentation
1 parent b133e78 commit 598aa70

11 files changed

+559
-297
lines changed

.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@ lib/
55
.idea/
66
npm-debug.log
77
SquirrelSetup.log
8+
electron-windows-sign.log
9+
receiver.mjs
10+
signtool-original.exe
11+
Squirrel-Releasify.log
812
.node-version
913
.DS_Store
1014
spec/fixtures/app/Update.exe
1115
vendor/7z.dll
1216
vendor/7z.exe
17+
hook.log

README.md

+13
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ There are several configuration settings supported:
7070
| `remoteReleases` | No | A URL to your existing updates. If given, these will be downloaded to create delta updates |
7171
| `remoteToken` | No | Authentication token for remote updates |
7272
| `frameworkVersion` | No | Set the required .NET framework version, e.g. `net461` |
73+
| `windowsSign` | No | Use [@electron/windows-sign][@electron/windows-sign] for advanced codesigning. See [documentation](#advanced-codesigning-with-electronwindows-sign) for details. |
7374

7475
## Sign your installer or else bad things will happen
7576

@@ -169,10 +170,22 @@ function handleSquirrelEvent() {
169170

170171
Notice that the first time the installer launches your app, your app will see a `--squirrel-firstrun` flag. This allows you to do things like showing up a splash screen or presenting a settings UI. Another thing to be aware of is that, since the app is spawned by squirrel and squirrel acquires a file lock during installation, you won't be able to successfully check for app updates till a few seconds later when squirrel releases the lock.
171172

173+
## Advanced codesigning with [@electron/windows-sign][@electron/windows-sign]
174+
175+
This package supports two different ways to codesign your application and the installer:
176+
177+
1) Modern: By passing a `windowsSign` option, which will be passed to [@electron/windows-sign]. This method allows full customization of the code-signing process - and supports more complicated scenarios like cloud-hosted EV certificates, custom sign pipelines, and per-file overrides. It also supports all existing "simple" codesigning scenarios, including just passing a certificate file and password. Please see https://github.com/@electron/windows-sign for all possible configuration options.
178+
179+
When passing `windowsSign`, do not pass any other available parameters at the top level (like `certificateFile`, `certificatePassword`, or `signWithParams`).
180+
181+
2) Legacy: By passing the top-level settings (`certificateFile`, `certificatePassword`, and `signWithParams`). For simple codesigning scenarios, there's no reason not to use this method - it'll work just as fine as the modern method.
182+
172183
## Debugging this package
173184

174185
You can get debug messages from this package by running with the environment variable `DEBUG=electron-windows-installer:main` e.g.
175186

176187
```shell
177188
DEBUG=electron-windows-installer:main node tasks/electron-winstaller.js
178189
```
190+
191+
[@electron/windows-sign]: https://github.com/electron/windows-sign/

package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"build": "tsc",
2424
"prepublish": "npm run build",
2525
"lint": "eslint --ext .ts src spec",
26-
"test": "npm run lint && ava --timeout=30s",
26+
"ava": "ava --timeout=60s",
27+
"test": "npm run lint && npm run ava",
2728
"tdd": "ava --watch"
2829
},
2930
"dependencies": {
@@ -46,6 +47,9 @@
4647
"ts-node": "^10.9.1",
4748
"typescript": "^4.9.3"
4849
},
50+
"optionalDependencies": {
51+
"@electron/windows-sign": "^1.1.2"
52+
},
4953
"engines": {
5054
"node": ">=8.0.0"
5155
},

spec/helpers/helpers.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import path from 'path';
2+
import fs from 'fs-extra';
3+
4+
import { createTempDir } from '../../src/temp-utils';
5+
6+
export const FIXTURE_APP_DIR = path.join(__dirname, '../fixtures/app');
7+
8+
export async function createTempAppDirectory(): Promise<string> {
9+
const appDirectory = await createTempDir('electron-winstaller-ad-');
10+
await fs.copy(FIXTURE_APP_DIR, appDirectory);
11+
return appDirectory;
12+
}

spec/helpers/windowsSignHook.js

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const fs = require('fs-extra');
2+
const path = require('path');
3+
4+
module.exports = function(args) {
5+
console.log(...args);
6+
7+
fs.appendFileSync(path.join(__dirname, 'hook.log'), `${JSON.stringify(args)}\n`);
8+
};

spec/installer-spec.ts

+1-7
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import { createTempDir } from '../src/temp-utils';
44
import fs from 'fs-extra';
55
import { createWindowsInstaller } from '../src';
66
import spawn from '../src/spawn-promise';
7+
import { createTempAppDirectory } from './helpers/helpers';
78

89
const log = require('debug')('electron-windows-installer:spec');
910

10-
const fixtureAppDirectory = path.join(__dirname, 'fixtures/app');
11-
1211
function spawn7z(args: string[]): Promise<string> {
1312
const sevenZipPath = path.join(__dirname, '..', 'vendor', '7z.exe');
1413
const wineExe = ['arm64', 'x64'].includes(process.arch) ? 'wine64' : 'wine';
@@ -17,11 +16,6 @@ function spawn7z(args: string[]): Promise<string> {
1716
: spawn(sevenZipPath, args);
1817
}
1918

20-
async function createTempAppDirectory(): Promise<string> {
21-
const appDirectory = await createTempDir('electron-winstaller-ad-');
22-
await fs.copy(fixtureAppDirectory, appDirectory);
23-
return appDirectory;
24-
}
2519

2620
test.serial('creates a nuget package and installer', async (t): Promise<void> => {
2721
const outputDirectory = await createTempDir('ei-');

spec/sign-spec.ts

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import test from 'ava';
2+
import path from 'path';
3+
import { createTempDir } from '../src/temp-utils';
4+
import fs from 'fs-extra';
5+
import { createWindowsInstaller } from '../src';
6+
import { createTempAppDirectory } from './helpers/helpers';
7+
import { SignToolOptions } from '@electron/windows-sign';
8+
import semver from 'semver';
9+
10+
const log = require('debug')('electron-windows-installer:spec');
11+
12+
if (process.platform === 'win32' && semver.gte(process.version, '20.0.0')) {
13+
test.serial('creates a signtool.exe and uses it to sign', async (t): Promise<void> => {
14+
15+
const outputDirectory = await createTempDir('ei-');
16+
const appDirectory = await createTempAppDirectory();
17+
const hookLogPath = path.join(__dirname, './helpers/hook.log');
18+
const hookModulePath = path.join(__dirname, './helpers/windowsSignHook.js');
19+
const windowsSign: SignToolOptions = { hookModulePath };
20+
const options = { appDirectory, outputDirectory, windowsSign };
21+
22+
// Reset
23+
await fs.remove(hookLogPath);
24+
25+
// Test
26+
await createWindowsInstaller(options);
27+
28+
log(`Verifying assertions on ${outputDirectory}`);
29+
log(JSON.stringify(await fs.readdir(outputDirectory)));
30+
31+
const nupkgPath = path.join(outputDirectory, 'myapp-1.0.0-full.nupkg');
32+
33+
t.true(await fs.pathExists(nupkgPath));
34+
t.true(await fs.pathExists(path.join(outputDirectory, 'MyAppSetup.exe')));
35+
36+
if (process.platform === 'win32') {
37+
t.true(await fs.pathExists(path.join(outputDirectory, 'MyAppSetup.msi')));
38+
}
39+
40+
log('Verifying Update.exe');
41+
t.true(await fs.pathExists(path.join(appDirectory, 'Squirrel.exe')));
42+
43+
log('Verifying that our hook got to "sign" all files');
44+
const hookLog = await fs.readFile(hookLogPath, { encoding: 'utf8' });
45+
const filesLogged = hookLog.split('\n').filter(v => !!v.trim()).length;
46+
t.is(filesLogged, 8);
47+
});
48+
}

src/index.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as os from 'os';
77
import { exec } from 'child_process';
88
import spawn from './spawn-promise';
99
import template from 'lodash.template';
10+
import { createSignTool, resetSignTool } from './sign';
1011

1112
export { SquirrelWindowsOptions } from './options';
1213
export { SquirrelWindowsOptions as Options} from './options';
@@ -82,7 +83,7 @@ export async function createWindowsInstaller(options: SquirrelWindowsOptions): P
8283
const defaultLoadingGif = path.join(__dirname, '..', 'resources', 'install-spinner.gif');
8384
loadingGif = loadingGif ? path.resolve(loadingGif) : defaultLoadingGif;
8485

85-
const { certificateFile, certificatePassword, remoteReleases, signWithParams, remoteToken } = options;
86+
const { certificateFile, certificatePassword, remoteReleases, signWithParams, remoteToken, windowsSign } = options;
8687

8788
const metadata: Metadata = {
8889
description: '',
@@ -193,6 +194,8 @@ export async function createWindowsInstaller(options: SquirrelWindowsOptions): P
193194
cmd = monoExe;
194195
}
195196

197+
// Legacy codesign options
198+
await resetSignTool();
196199
if (signWithParams) {
197200
args.push('--signWithParams');
198201
if (!signWithParams.includes('/f') && !signWithParams.includes('/p') && certificateFile && certificatePassword) {
@@ -203,6 +206,11 @@ export async function createWindowsInstaller(options: SquirrelWindowsOptions): P
203206
} else if (certificateFile && certificatePassword) {
204207
args.push('--signWithParams');
205208
args.push(`/a /f "${path.resolve(certificateFile)}" /p "${certificatePassword}"`);
209+
// @electron/windows-sign options
210+
} else if (windowsSign) {
211+
args.push('--signWithParams');
212+
args.push('windows-sign');
213+
await createSignTool(options);
206214
}
207215

208216
if (options.setupIcon) {
@@ -244,4 +252,6 @@ export async function createWindowsInstaller(options: SquirrelWindowsOptions): P
244252
}
245253
}
246254
}
255+
256+
await resetSignTool();
247257
}

src/options.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Original definitions by: Brendan Forster <https://github.com/shiftkey>, Daniel Perez Alvarez <https://github.com/unindented>
33
// Original definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
44

5+
import { SignToolOptions } from '@electron/windows-sign';
6+
57
export interface SquirrelWindowsOptions {
68
/**
79
* The folder path of your Electron app
@@ -72,17 +74,29 @@ export interface SquirrelWindowsOptions {
7274
*/
7375
name?: string;
7476
/**
75-
* The path to an Authenticode Code Signing Certificate
77+
* The path to an Authenticode Code Signing Certificate.
78+
*
79+
* This is a legacy parameter provided for backwards compatibility.
80+
* For more comprehensive support of various codesigning scenarios
81+
* like EV certificates, see the "windowsSign" parameter.
7682
*/
7783
certificateFile?: string;
7884
/**
7985
* The password to decrypt the certificate given in `certificateFile`
86+
*
87+
* This is a legacy parameter provided for backwards compatibility.
88+
* For more comprehensive support of various codesigning scenarios
89+
* like EV certificates, see the "windowsSign" parameter.
8090
*/
8191
certificatePassword?: string;
8292
/**
8393
* Params to pass to signtool.
8494
*
8595
* Overrides `certificateFile` and `certificatePassword`.
96+
*
97+
* This is a legacy parameter provided for backwards compatibility.
98+
* For more comprehensive support of various codesigning scenarios
99+
* like EV certificates, see the "windowsSign" parameter.
86100
*/
87101
signWithParams?: string;
88102
/**
@@ -131,6 +145,20 @@ export interface SquirrelWindowsOptions {
131145
fixUpPaths?: boolean;
132146

133147
skipUpdateIcon?: boolean;
148+
149+
/**
150+
* Requires Node.js 18 or newer.
151+
*
152+
* Sign your app with @electron/windows-sign, allowing for full customization
153+
* of the code-signing process - and supports more complicated scenarios like
154+
* cloud-hosted EV certificates, custom sign pipelines, and per-file overrides.
155+
* It also supports all existing "simple" codesigning scenarios, including
156+
* just passing a certificate file and password.
157+
*
158+
* Please see https://github.com/@electron/windows-sign for all possible
159+
* configuration options.
160+
*/
161+
windowsSign?: SignToolOptions;
134162
}
135163

136164
export interface PersonMetadata {

src/sign.ts

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { createSeaSignTool as createSeaSignToolType } from '@electron/windows-sign';
2+
import path from 'path';
3+
import semver from 'semver';
4+
import fs from 'fs-extra';
5+
6+
import { SquirrelWindowsOptions } from './options';
7+
8+
const VENDOR_PATH = path.join(__dirname, '..', 'vendor');
9+
const ORIGINAL_SIGN_TOOL_PATH = path.join(VENDOR_PATH, 'signtool.exe');
10+
const BACKUP_SIGN_TOOL_PATH = path.join(VENDOR_PATH, 'signtool-original.exe');
11+
const SIGN_LOG_PATH = path.join(VENDOR_PATH, 'electron-windows-sign.log');
12+
13+
/**
14+
* This method uses @electron/windows-sign to create a fake signtool.exe
15+
* that can be called by Squirrel - but then just calls @electron/windows-sign
16+
* to actually perform the signing.
17+
*
18+
* That's useful for users who need a high degree of customization of the signing
19+
* process but still want to use @electron/windows-installer.
20+
*/
21+
export async function createSignTool(options: SquirrelWindowsOptions): Promise<void> {
22+
if (!options.windowsSign) {
23+
throw new Error('Signtool should only be created if windowsSign options are set');
24+
}
25+
26+
const createSeaSignTool = await getCreateSeaSignTool();
27+
28+
await resetSignTool();
29+
await fs.remove(SIGN_LOG_PATH);
30+
31+
// Make a backup of signtool.exe
32+
await fs.copy(ORIGINAL_SIGN_TOOL_PATH, BACKUP_SIGN_TOOL_PATH, { overwrite: true });
33+
34+
// Create a new signtool.exe using @electron/windows-sign
35+
await createSeaSignTool({
36+
path: ORIGINAL_SIGN_TOOL_PATH,
37+
windowsSign: options.windowsSign
38+
});
39+
}
40+
41+
/**
42+
* Ensure that signtool.exe is actually the "real" signtool.exe, not our
43+
* fake substitute.
44+
*/
45+
export async function resetSignTool() {
46+
if (fs.existsSync(BACKUP_SIGN_TOOL_PATH)) {
47+
// Reset the backup of signtool.exe
48+
await fs.copy(BACKUP_SIGN_TOOL_PATH, ORIGINAL_SIGN_TOOL_PATH, { overwrite: true });
49+
await fs.remove(BACKUP_SIGN_TOOL_PATH);
50+
}
51+
}
52+
53+
/**
54+
* @electron/windows-installer only requires Node.js >= 8.0.0.
55+
* @electron/windows-sign requires Node.js >= 16.0.0.
56+
* @electron/windows-sign's "fake signtool.exe" feature requires
57+
* Node.js >= 20.0.0, the first version to contain the "single
58+
* executable" feature with proper support.
59+
*
60+
* Since this is overall a very niche feature and only benefits
61+
* consumers with rather advanced codesigning needs, we did not
62+
* want to make Node.js v18 a hard requirement for @electron/windows-installer.
63+
*
64+
* Instead, @electron/windows-sign is an optional dependency - and
65+
* if it didn't install, we'll throw a useful error here.
66+
*
67+
* @returns
68+
*/
69+
async function getCreateSeaSignTool(): Promise<typeof createSeaSignToolType> {
70+
try {
71+
const { createSeaSignTool } = await import('@electron/windows-sign');
72+
return createSeaSignTool;
73+
} catch(error) {
74+
let message = 'In order to use windowsSign options, @electron/windows-sign must be installed as a dependency.';
75+
76+
if (semver.lte(process.version, '20.0.0')) {
77+
message += ` You are currently using Node.js ${process.version}. Please upgrade to Node.js 19 or later and reinstall all dependencies to ensure that @electron/windows-sign is available.`;
78+
} else {
79+
message += ` ${error}`;
80+
}
81+
82+
throw new Error(message);
83+
}
84+
}

0 commit comments

Comments
 (0)