Skip to content

Commit 356a4b1

Browse files
committed
* Fixes #12536 - use friendly description when signing msi file
* Move pre-build installer signing to cmake to streamline the operation and avoid signing hundreds of unnecessary files. * Enable building both amd64 and arm64 builds with visual studio
1 parent 5b61e6c commit 356a4b1

4 files changed

Lines changed: 179 additions & 11 deletions

File tree

cmake/MakePortableZip.cmake

Lines changed: 0 additions & 3 deletions
This file was deleted.

cmake/WindowsPostInstall.cmake.in

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 2 or (at your option)
6+
# version 3 of the License.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU General Public License
14+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
15+
16+
set(_installdir ${CPACK_TEMPORARY_INSTALL_DIRECTORY})
17+
set(_sign @WITH_XC_SIGNINSTALL@)
18+
set(_cert_thumbprint @WITH_XC_SIGNINSTALL_CERT@)
19+
set(_timestamp_url @WITH_XC_SIGNINSTALL_TIMESTAMP_URL@)
20+
21+
# Setup portable zip file if building one
22+
if(_installdir MATCHES "/ZIP/")
23+
file(TOUCH "${_installdir}/.portable")
24+
message(STATUS "Injected portable zip file.")
25+
endif()
26+
27+
# Find all dll and exe files in the install directory
28+
file(GLOB_RECURSE _sign_files
29+
RELATIVE "${_installdir}"
30+
"${_installdir}/*.dll"
31+
"${_installdir}/*.exe"
32+
)
33+
34+
# Sign relevant binaries if requested
35+
if(_sign AND _sign_files)
36+
# Find signtool in PATH or error out
37+
find_program(_signtool signtool.exe QUIET)
38+
if(NOT _signtool)
39+
message(FATAL_ERROR "signtool.exe not found in PATH, correct or unset WITH_XC_SIGNINSTALL")
40+
endif()
41+
42+
# Set a default timestamp URL if none was provided
43+
if (NOT _timestamp_url)
44+
set(_timestamp_url "http://timestamp.sectigo.com")
45+
endif()
46+
47+
# Check that a certificate thumbprint was provided or error out
48+
if (NOT _cert_thumbprint)
49+
message(STATUS "Signing using best available certificate.")
50+
set(_certopt /a)
51+
else()
52+
message(STATUS "Signing using certificate with thumbprint ${_cert_thumbprint}.")
53+
set(_certopt /sha1 ${_cert_thumbprint})
54+
endif()
55+
56+
message(STATUS "Signing binary files with signtool, this may take a while...")
57+
# Use cmd /c to enable pop-up for pin entry if needed
58+
execute_process(
59+
COMMAND cmd /c ${_signtool} sign /fd SHA256 ${_certopt} /tr ${_timestamp_url} /td SHA256 ${_sign_files}
60+
WORKING_DIRECTORY "${_installdir}"
61+
RESULT_VARIABLE sign_result
62+
OUTPUT_VARIABLE sign_output
63+
ERROR_VARIABLE sign_error
64+
OUTPUT_STRIP_TRAILING_WHITESPACE
65+
ERROR_STRIP_TRAILING_WHITESPACE
66+
ECHO_OUTPUT_VARIABLE
67+
)
68+
if (NOT sign_result EQUAL 0)
69+
message(FATAL_ERROR "signtool failed: ${sign_error}")
70+
endif()
71+
endif()

release-tool.py

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,56 @@ def _split_version(version):
261261
return version.split('.')
262262

263263

264+
def _find_vs_dev_cmd():
265+
# Try vswhere first, fall back to common install locations
266+
try:
267+
vswhere = shutil.which('vswhere')
268+
if vswhere:
269+
inst = subprocess.check_output([vswhere, '-latest', '-products', '*',
270+
'-requires', 'Microsoft.Component.MSBuild',
271+
'-property', 'installationPath'], text=True).strip()
272+
if inst:
273+
cand = Path(inst) / 'Common7' / 'Tools' / 'VsDevCmd.bat'
274+
if cand.exists():
275+
return str(cand)
276+
except subprocess.CalledProcessError:
277+
pass
278+
279+
# Fallback common paths
280+
program_files = [os.environ.get('ProgramFiles(x86)'), os.environ.get('ProgramFiles')]
281+
for pfdir in program_files:
282+
for vs in sorted(Path(pfdir).glob('Microsoft Visual Studio/*/*'), reverse=True):
283+
cand = vs / 'Common7' / 'Tools' / 'VsDevCmd.bat'
284+
if cand.exists():
285+
return str(cand)
286+
287+
raise Error('Visual Studio developer command script not found. Install VS or add vswhere to PATH.')
288+
289+
290+
def _capture_vs_env(vs_cmd, arch='amd64'):
291+
"""
292+
Run the VS developer batch script in a cmd shell and capture the environment it sets.
293+
Returns a dict suitable for passing as subprocess env or for updating os.environ.
294+
"""
295+
# Use cmd.exe to run the batch file and then dump the environment with `set`
296+
try:
297+
out = subprocess.run(f'cmd /c "{vs_cmd}" -arch={arch} -no_logo && set', capture_output=True, text=True)
298+
except subprocess.CalledProcessError as e:
299+
raise Error('Failed to run Visual Studio dev script: %s', e.output or str(e))
300+
301+
env = {}
302+
for line in out.stdout.splitlines():
303+
k, v = line.split('=', 1)
304+
if v is not None:
305+
env[k] = v
306+
307+
# VS has plenty of environment variables, so this is a basic sanity check
308+
if len(env) < 10:
309+
raise Error('Failed to capture environment from Visual Studio dev script.')
310+
311+
return env
312+
313+
264314
###########################################################################################
265315
# CLI Commands
266316
###########################################################################################
@@ -285,7 +335,7 @@ class Check(Command):
285335

286336
@classmethod
287337
def setup_arg_parser(cls, parser: argparse.ArgumentParser):
288-
parser.add_argument('-v', '--version', help='Release version number or name.')
338+
parser.add_argument('version', help='Release version number or name.')
289339
parser.add_argument('-s', '--src-dir', help='Source directory.', default='.')
290340
parser.add_argument('-b', '--release-branch', help='Release source branch (default: inferred from --version).')
291341

@@ -507,6 +557,12 @@ def setup_arg_parser(cls, parser: argparse.ArgumentParser):
507557
parser.add_argument('-p', '--platform-target', help='Build target platform (default: %(default)s).',
508558
choices=['x86_64', 'aarch64'], default=platform.uname().machine)
509559
parser.add_argument('-a', '--appimage', help='Build an AppImage.', action='store_true')
560+
elif sys.platform == 'win32':
561+
parser.add_argument('-p', '--platform-target', help='Build target platform (default: %(default)s).',
562+
choices=['amd64', 'arm64'], default='amd64')
563+
parser.add_argument('--sign', help='Sign binaries prior to packaging.', action='store_true')
564+
parser.add_argument('--sign-cert', help='SHA1 fingerprint of the signing certificate (optional).')
565+
parser.set_defaults(cmake_generator='Ninja', no_source_tarball=True)
510566

511567
parser.add_argument('-c', '--cmake-opts', nargs=argparse.REMAINDER,
512568
help='Additional CMake options (no other arguments can be specified after this).')
@@ -530,6 +586,7 @@ def run(self, version, src_dir, output_dir, tag_name, snapshot, no_source_tarbal
530586
'-DCMAKE_INSTALL_PREFIX=' + kwargs['install_prefix'],
531587
'-DWITH_TESTS=OFF',
532588
'-DWITH_GUI_TESTS=OFF',
589+
'-DX_VCPKG_APPLOCAL_DEPS_INSTALL=ON',
533590
]
534591
if not kwargs['use_system_deps'] and not kwargs.get('docker_image'):
535592
cmake_opts.append(f'-DCMAKE_TOOLCHAIN_FILE={self._get_vcpkg_toolchain_file()}')
@@ -567,7 +624,11 @@ def run(self, version, src_dir, output_dir, tag_name, snapshot, no_source_tarbal
567624
def _get_vcpkg_toolchain_file(path=None):
568625
vcpkg = shutil.which('vcpkg', path=path)
569626
if not vcpkg:
570-
raise Error('vcpkg not found in PATH (use --use-system-deps to build with system dependencies instead).')
627+
# Check the VCPKG_ROOT environment variable
628+
if 'VCPKG_ROOT' in os.environ:
629+
vcpkg = Path(os.environ['VCPKG_ROOT']) / 'vcpkg'
630+
else:
631+
raise Error('vcpkg not found in PATH (use --use-system-deps to build with system dependencies instead).')
571632
toolchain = Path(vcpkg).parent / 'scripts' / 'buildsystems' / 'vcpkg.cmake'
572633
if not toolchain.is_file():
573634
raise Error('Toolchain file not found in vcpkg installation directory.')
@@ -605,11 +666,38 @@ def build_source_tarball(self, version, tag_name, src_dir, output_dir):
605666
_run([comp, '-6', '--force', str(output_file.absolute())], cwd=src_dir)
606667

607668
# noinspection PyMethodMayBeStatic
608-
def build_windows(self, version, src_dir, output_dir, *, parallelism, cmake_opts, **_):
609-
pass
669+
def build_windows(self, version, src_dir, output_dir, *, parallelism, cmake_opts, platform_target, sign, sign_cert, **_):
670+
# Check for required tools
671+
if not _cmd_exists('candle.exe') or not _cmd_exists('light.exe') or not _cmd_exists('heat.exe'):
672+
raise Error('WiX Toolset not found on the PATH (candle.exe, light.exe, heat.exe).')
673+
674+
# Setup build signing if requested
675+
if sign:
676+
cmake_opts.append('-DWITH_XC_SIGNINSTALL=ON')
677+
cmake_opts.append(f'-DWITH_XC_SIGNINSTALL_CERT={sign_cert}')
678+
679+
# Find Visual Studio and capture build environment
680+
vs_cmd = _find_vs_dev_cmd()
681+
vs_env = _capture_vs_env(vs_cmd, arch=platform_target)
682+
683+
# Start the build
684+
with tempfile.TemporaryDirectory() as build_dir:
685+
# NOTE: Shell must be True on Windows to run the command in the provided vs_env
686+
logger.info('Configuring build...')
687+
_run(['cmake', *cmake_opts, str(src_dir)], cwd=build_dir, env=vs_env, shell=True, capture_output=False)
688+
689+
logger.info('Compiling sources...')
690+
_run(['cmake', '--build', '.', f'--parallel', str(parallelism)], cwd=build_dir, env=vs_env, shell=True, capture_output=False)
691+
692+
logger.info('Packaging application...')
693+
_run(['cpack', '-G', 'ZIP;WIX'], cwd=build_dir, env=vs_env, shell=True, capture_output=False)
694+
695+
output_files = list(Path(build_dir).glob("*.zip")) + list(Path(build_dir).glob("*.msi"))
696+
for output_file in output_files:
697+
output_file.rename(output_dir / output_file.name)
610698

611699
# noinspection PyMethodMayBeStatic
612-
def build_macos(self, version, src_dir, output_dir, *, use_system_deps, parallelism, cmake_opts,
700+
def build_macos(self, version, src_dir, output_dir, *, use_system_deps, parallelism, cmake_opts,
613701
macos_target, platform_target, **_):
614702
if not use_system_deps:
615703
cmake_opts.append(f'-DVCPKG_TARGET_TRIPLET={platform_target.replace("86_", "")}-osx-dynamic-release')
@@ -763,7 +851,18 @@ def run(self, file, identity, src_dir, **kwargs):
763851
logger.info('All done.')
764852

765853
def sign_windows(self, file, identity, src_dir):
766-
pass
854+
# Check for signtool
855+
if not _cmd_exists('signtool.exe'):
856+
raise Error('signtool was not found on the PATH.')
857+
858+
signtool_args = ['signtool', 'sign', '/fd', 'sha256', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256']
859+
if not identity:
860+
signtool_args += ['/a']
861+
else:
862+
signtool_args += ['/sha1', identity]
863+
signtool_args += ['/d', file.name, str(file.resolve())]
864+
865+
_run(signtool_args, cwd=src_dir, capture_output=False, shell=True)
767866

768867
# noinspection PyMethodMayBeStatic
769868
def _macos_validate_keychain_profile(self, keychain_profile):

src/CMakeLists.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,9 @@ if(WIN32)
482482
"${CMAKE_SOURCE_DIR}/LICENSE.GPL-2"
483483
"${CMAKE_CURRENT_BINARY_DIR}/INSTALLER_LICENSE.txt")
484484

485-
# Prepare portal zip file
486-
set(CPACK_INSTALL_SCRIPTS "${CMAKE_SOURCE_DIR}/cmake/MakePortableZip.cmake")
485+
# Prepare post-install script and set to run prior to building cpack installers
486+
configure_file("${CMAKE_SOURCE_DIR}/cmake/WindowsPostInstall.cmake.in" "${CMAKE_BINARY_DIR}/WindowsPostInstall.cmake" @ONLY)
487+
set(CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/WindowsPostInstall.cmake")
487488

488489
string(REGEX REPLACE "-.*$" "" KEEPASSXC_VERSION_CLEAN ${KEEPASSXC_VERSION})
489490

0 commit comments

Comments
 (0)