diff --git a/.gitattributes b/.gitattributes index 44196ec59f..08f7af9d05 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,14 @@ +# Github-linguist language hints +*.h linguist-language=C++ +*.cpp linguist-language=C++ + +# Line endings harmony +* text=auto eol=lf + +# binary files +*.ai binary + +# Export src/version.h.cmake export-subst .gitattributes export-ignore .gitignore export-ignore @@ -7,13 +18,3 @@ src/version.h.cmake export-subst snapcraft.yaml export-ignore make_release.sh export-ignore AppImage-Recipe.sh export-ignore - -# github-linguist language hints -*.h linguist-language=C++ -*.cpp linguist-language=C++ - -# binary files -*.ai binary - -# Line endings harmony -* text=auto diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0eb7156bf4..3ab9ed8684 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,38 +1,44 @@ -This is a C++ based repository that uses Qt5 as a primary support and GUI library. This repository is for a password manager application that stores passwords -and other highly sensitive information. The data format that passwords are stored is called KDBX which is a mixed binary and XML format that is fully encrypted -at rest. This format is unpacked into a series of data structures: Database, Groups, and Entries. Please follow these guidelines when contributing: +This repository is a C++ (C++20) Qt-based password manager. The important domain concepts are +Database, Group, and Entry (KDBX format). Key areas to know before making changes are below. -## Code Standards +Quick reference (common commands) +- Configure + build (preferred: CMake presets) + - Windows (PowerShell): `cmake --preset x64-debug` + - Build: `cmake --build --preset x64-debug` or `cmake --build . -j ` from the build dir +- Formatting (required before commits): + - `cmake --build . --target format` (runs clang-format) +- Tests: + - Run all tests: `ctest -j ` from build dir + - Run single test (verbose): `ctest -R -V` +- Translations & i18n (release tooling): + - Update translation sources: `python ./release-tool.py i18n lupdate` -### Required Before Each Commit -- Run `cmake --build . --target format` before committing any changes to ensure proper code formatting -- This will run clang-format to ensure all code conforms to the style guide -- From the checkout directory, also run `./release-tool i18n lupdate` to update translation files +Big-picture architecture (where to look) +- src/core: core data model (Database, Groups, Entries). Example: `src/core/Database.h` +- src/format: KDBX readers/writers and import/export logic. (sensitive - avoid casual edits) +- src/crypto: cryptographic primitives and key derivation. (sensitive - avoid casual edits) +- src/gui: Qt UI layers, widgets, main window and app lifecycle (entry: `src/main.cpp`, `src/gui/MainWindow.cpp`) +- src/sshagent, src/browser, src/fdosecrets, src/quickunlock: integration adapters for external systems +- tests/ and tests/gui/: QTest-based unit and GUI tests (follow existing test patterns) -### Development Flow -- Setup Build Folder: `mkdir build; cd build` -- Configure: `cmake -G Ninja -DWITH_XC_ALL=ON -DWITH_GUI_TESTS=ON ..` -- Build: `cmake --build . -- -j $(nproc)` -- Test: `ctest` +Project-specific conventions & patterns +- Language/features: C++20, heavy use of Qt signal/slot idioms and QObject-derived classes. +- Build: use provided CMake commands to configure and build the project successfully. +- Formatting: a CMake target (`format`) runs clang-format — run it before committing. +- Translations: translation files are generated/updated via the release tool — run it before committing. +- UI files: .ui changes are non-trivial; prefer proposing .ui edits rather than committing wholesale .ui changes unless very simple. +- Sensitive areas: `src/crypto` and `src/format` contain security-sensitive logic — avoid refactors that change algorithms without expert review. -## Repository Structure -- `docs/topics`: Documentation written in asciidoctor syntax -- `src/`: Main source code files are under this subdirectory -- `src/autotype`: Code that emulates a virtual keyboard to type into interfaces -- `src/browser`: Interface with the KeePassXC Browser Extension using a JSON-based protocol -- `src/cli`: Command Line Interface code -- `src/core`: Contains files that define the data model and other shared code structures -- `src/format`: Code for import/export and reading/writing of KDBX databases -- `src/fdosecrets`: freedesktop.org Secret Service interface code -- `src/quickunlock`: Quick unlock interfaces for various platforms -- `src/sshagent`: SSH Agent interface code to load private keys from the database into ssh-agent -- `tests/`: Test source code files -- `tests/gui`: GUI test source code files +Concrete examples (where to copy patterns) +- Signal connections: see `src/keeshare/ShareObserver.cpp` (connect to Database signals like `groupAdded` / `modified`). +- Opening/locking DBs: `src/gui/DatabaseTabWidget.*` and `src/gui/DatabaseWidget.*` show typical lifecycle and `emitActiveDatabaseChanged()`. +- Format/validation: use `src/format/KdbxReader.cpp` and `Kdbx4Reader.cpp` for error handling patterns when reading DBs. -## Key Guidelines -1. Follow C++20 and Qt5 best practices and idiomatic patterns -2. Maintain existing code structure and organization -3. Prefer not to edit cryptographic handling code or other sensitive parts of the code base -4. Write unit tests for new functionality using QTest scaffolding -5. Suggest changes to the `docs/topics` folder when appropriate -6. Unless the change is simple, don't actually make edits to .ui files, just suggest the changes needed +Rules for automated agents +- Do not change cryptographic or serialization logic unless the change is narrowly scoped and you run tests. +- When adding features, create relevant unit tests within existing files in `tests/`. +- Always run code formatting, translation update, and tests before submitting commits. +- All tests related to your change must pass before committing. +- Reference real files in PR descriptions (e.g., "changed src/core/Database.h and tests/TestDatabase.cpp"). + +If anything above is unclear or you want more detail about a specific area (build matrix, CI, or release-tool commands), tell me which part and I will expand. diff --git a/.tx/config b/.tx/config index 82aefce423..886755d679 100644 --- a/.tx/config +++ b/.tx/config @@ -1,19 +1,21 @@ [main] -host = https://www.transifex.com +host = https://app.transifex.com [o:keepassxc:p:keepassxc:r:share-translations-keepassxc-en-ts--develop] file_filter = share/translations/keepassxc_.ts source_file = share/translations/keepassxc_en.ts -source_lang = en type = QT +minimum_perc = 60 +resource_name = keepassxc_en.ts (develop) replace_edited_strings = false keep_translations = false [o:keepassxc:p:keepassxc:r:share-translations-keepassxc-en-ts--master] file_filter = share/translations/keepassxc_.ts source_file = share/translations/keepassxc_en.ts -source_lang = en type = QT +minimum_perc = 60 +resource_name = keepassxc_en.ts (2.7.x stable) replace_edited_strings = false keep_translations = false diff --git a/cmake/MakePortableZip.cmake b/cmake/MakePortableZip.cmake deleted file mode 100644 index 6f873c5d57..0000000000 --- a/cmake/MakePortableZip.cmake +++ /dev/null @@ -1,3 +0,0 @@ -if (CMAKE_INSTALL_PREFIX MATCHES "/ZIP/") - file(TOUCH "${CMAKE_INSTALL_PREFIX}/.portable") -endif() diff --git a/cmake/WindowsPostInstall.cmake.in b/cmake/WindowsPostInstall.cmake.in new file mode 100644 index 0000000000..6d70ec71d9 --- /dev/null +++ b/cmake/WindowsPostInstall.cmake.in @@ -0,0 +1,71 @@ +# Copyright (C) 2025 KeePassXC Team +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 or (at your option) +# version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +set(_installdir ${CPACK_TEMPORARY_INSTALL_DIRECTORY}) +set(_sign @WITH_XC_SIGNINSTALL@) +set(_cert_thumbprint @WITH_XC_SIGNINSTALL_CERT@) +set(_timestamp_url @WITH_XC_SIGNINSTALL_TIMESTAMP_URL@) + +# Setup portable zip file if building one +if(_installdir MATCHES "/ZIP/") + file(TOUCH "${_installdir}/.portable") + message(STATUS "Injected portable zip file.") +endif() + +# Find all dll and exe files in the install directory +file(GLOB_RECURSE _sign_files + RELATIVE "${_installdir}" + "${_installdir}/*.dll" + "${_installdir}/*.exe" +) + +# Sign relevant binaries if requested +if(_sign AND _sign_files) + # Find signtool in PATH or error out + find_program(_signtool signtool.exe QUIET) + if(NOT _signtool) + message(FATAL_ERROR "signtool.exe not found in PATH, correct or unset WITH_XC_SIGNINSTALL") + endif() + + # Set a default timestamp URL if none was provided + if (NOT _timestamp_url) + set(_timestamp_url "http://timestamp.sectigo.com") + endif() + + # Check that a certificate thumbprint was provided or error out + if (NOT _cert_thumbprint) + message(STATUS "Signing using best available certificate.") + set(_certopt /a) + else() + message(STATUS "Signing using certificate with thumbprint ${_cert_thumbprint}.") + set(_certopt /sha1 ${_cert_thumbprint}) + endif() + + message(STATUS "Signing binary files with signtool, this may take a while...") + # Use cmd /c to enable pop-up for pin entry if needed + execute_process( + COMMAND cmd /c ${_signtool} sign /fd SHA256 ${_certopt} /tr ${_timestamp_url} /td SHA256 ${_sign_files} + WORKING_DIRECTORY "${_installdir}" + RESULT_VARIABLE sign_result + OUTPUT_VARIABLE sign_output + ERROR_VARIABLE sign_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE + ECHO_OUTPUT_VARIABLE + ) + if (NOT sign_result EQUAL 0) + message(FATAL_ERROR "signtool failed: ${sign_error}") + endif() +endif() diff --git a/release-tool b/release-tool deleted file mode 100755 index 43f0580006..0000000000 --- a/release-tool +++ /dev/null @@ -1,1471 +0,0 @@ -#!/usr/bin/env bash -# -# KeePassXC Release Preparation Helper -# Copyright (C) 2021 KeePassXC team -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 2 or (at your option) -# version 3 of the License. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -printf "\e[1m\e[32mKeePassXC\e[0m Release Preparation Helper\n" -printf "Copyright (C) 2021 KeePassXC Team \n\n" - -set -eE -o pipefail - -if [ "$(uname -s)" == "Linux" ]; then - OS_LINUX="1" -elif [ "$(uname -s)" == "Darwin" ]; then - OS_MACOS="1" -elif [ "$(uname -o)" == "Msys" ]; then - OS_WINDOWS="1" -fi - -# ----------------------------------------------------------------------- -# global default values -# ----------------------------------------------------------------------- -RELEASE_NAME="" -APP_NAME="KeePassXC" -SRC_DIR="." -GPG_KEY="CFB4C2166397D0D2" -GPG_GIT_KEY="" -OUTPUT_DIR="release" -SOURCE_BRANCH="" -TAG_NAME="" -DOCKER_IMAGE="" -DOCKER_CONTAINER_NAME="keepassxc-build-container" -CMAKE_GENERATOR="Unix Makefiles" -CMAKE_OPTIONS="" -CPACK_GENERATORS="WIX;ZIP" -COMPILER="g++" -MAKE_OPTIONS="-j$(getconf _NPROCESSORS_ONLN)" -BUILD_PLUGINS="all" -INSTALL_PREFIX="/usr/local" -ORIG_BRANCH="" -ORIG_CWD="$(pwd)" -MACOSX_DEPLOYMENT_TARGET=10.15 -TIMESTAMP_SERVER="http://timestamp.sectigo.com" - -# ----------------------------------------------------------------------- -# helper functions -# ----------------------------------------------------------------------- -printUsage() { - local cmd - if [ -z "$1" ] || [ "help" == "$1" ]; then - cmd="COMMAND" - elif [ "check" == "$1" ] || [ "merge" == "$1" ] || [ "build" == "$1" ] || [ "gpgsign" == "$1" ] || \ - [ "appsign" == "$1" ] || [ "notarize" == "$1" ] || [ "appimage" == "$1" ] || [ "i18n" == "$1" ]; then - cmd="$1" - else - logError "Unknown command: '$1'\n" - cmd="COMMAND" - fi - - printf "\e[1mUsage:\e[0m $(basename "$0") $cmd [OPTIONS, ...]\n" - - if [ "COMMAND" == "$cmd" ]; then - cat << EOF - -Commands: - check Perform a dry-run check, nothing is changed - merge Merge release branch into main branch and create release tags - build Build and package binary release from sources - gpgsign Sign previously compiled release packages with GPG - appsign Sign binaries with code signing certificates on Windows and macOS - notarize Submit macOS application DMG for notarization - help Show help for the given command - i18n Update translation files and pull from or push to Transifex -EOF - elif [ "merge" == "$cmd" ]; then - cat << EOF - -Merge release branch into main branch and create release tags - -Options: - -v, --version Release version number or name (required) - -a, --app-name Application name (default: '${APP_NAME}') - -s, --source-dir Source directory (default: '${SRC_DIR}') - -k, --key GPG key used to sign the merge commit and release tag, - leave empty to let Git choose your default key - (default: '${GPG_GIT_KEY}') - -r, --release-branch Source release branch to merge from (default: 'release/VERSION') - -t, --tag-name Override release tag name (defaults to version number) - -h, --help Show this help -EOF - elif [ "build" == "$cmd" ]; then - cat << EOF - -Build and package binary release from sources - -Options: - -v, --version Release version number or name (required) - -a, --app-name Application name (default: '${APP_NAME}') - -s, --source-dir Source directory (default: '${SRC_DIR}') - -o, --output-dir Output directory where to build the release - (default: '${OUTPUT_DIR}') - -t, --tag-name Release tag to check out (defaults to version number) - -b, --build Build sources after exporting release - -d, --docker-image Use the specified Docker image to compile the application. - The image must have all required build dependencies installed. - This option has no effect if --build is not set. - --container-name Docker container name (default: '${DOCKER_CONTAINER_NAME}') - The container must not exist already - --snapcraft Create and use docker image to build snapcraft distribution. - This option has no effect if --docker-image is not set. - --appimage Build a Linux AppImage after compilation. - If this option is set, --install-prefix has no effect - --appsign Perform platform specific App Signing before packaging - --timestamp Explicitly set the timestamp server to use for appsign (default: '${TIMESTAMP_SERVER}') - --vcpkg Specify VCPKG toolchain file (example: ~/vcpkg/scripts/buildsystems/vcpkg.cmake) - -k, --key Specify the App Signing Key/Identity - --cmake-generator Override the default CMake generator (Default: Ninja) - -c, --cmake-options Additional CMake options for compiling the sources - --compiler Compiler to use (default: '${COMPILER}') - -m, --make-options Make options for compiling sources (default: '${MAKE_OPTIONS}') - -g, --generators Additional CPack generators (default: '${CPACK_GENERATORS}') - -i, --install-prefix Install prefix (default: '${INSTALL_PREFIX}') - -p, --plugins Space-separated list of plugins to build - (default: ${BUILD_PLUGINS}) - --snapshot Don't checkout the release tag - -n, --no-source-tarball Don't build source tarball - -h, --help Show this help -EOF - elif [ "gpgsign" == "$cmd" ]; then - cat << EOF - -Sign previously compiled release packages with GPG - -Options: - -f, --files Files to sign (required) - -k, --key GPG key used to sign the files (default: '${GPG_KEY}') - -h, --help Show this help -EOF - elif [ "appsign" == "$cmd" ]; then - cat << EOF - -Sign binaries with code signing certificates on Windows and macOS - -Options: - -f, --files Files to sign (required) - -k, --key, -i, --identity - Signing Key or Apple Developer ID (required) - --timestamp Explicitly set the timestamp server to use for appsign (default: '${TIMESTAMP_SERVER}') - -u, --username Apple username for notarization (required on macOS) - -h, --help Show this help -EOF - elif [ "notarize" == "$cmd" ]; then - cat << EOF - -Submit macOS application DMG for notarization - -Options: - -f, --files Files to notarize (required) - -u, --username Apple username for notarization (required) - -c, --keychain Apple keychain entry name storing the notarization - app password (default: 'AC_PASSWORD') - -h, --help Show this help -EOF - elif [ "appimage" == "$cmd" ]; then - cat << EOF - -Generate Linux AppImage from 'make install' AppDir - -Options: - -a, --appdir Input AppDir (required) - -v, --version KeePassXC version - -o, --output-dir Output directory where to build the AppImage - (default: '${OUTPUT_DIR}') - -d, --docker-image Use the specified Docker image to build the AppImage. - The image must have all required build dependencies installed. - --container-name Docker container name (default: '${DOCKER_CONTAINER_NAME}') - The container must not exist already - --appsign Embed a PGP signature into the AppImage - -k, --key The PGP Signing Key - --verbosity linuxdeploy verbosity (default: 3) - -h, --help Show this help -EOF - elif [ "i18n" == "$cmd" ]; then - cat << EOF - -Update translation files and pull from or push to Transifex - -Subcommands: - tx-push Push source translation file to Transifex - tx-pull Pull updated translations from Transifex - lupdate Update source translation file from C++ sources -EOF - fi -} - -logInfo() { - printf "\e[1m[ \e[34mINFO\e[39m ]\e[0m $1\n" -} - -logWarn() { - printf "\e[1m[ \e[33mWARNING\e[39m ]\e[0m $1\n" -} - -logError() { - printf "\e[1m[ \e[31mERROR\e[39m ]\e[0m $1\n" >&2 -} - -init() { - if [ -z "$RELEASE_NAME" ]; then - logError "Missing arguments, --version is required!\n" - printUsage "check" - exit 1 - fi - - if [ -z "$TAG_NAME" ]; then - TAG_NAME="$RELEASE_NAME" - fi - - if [ -z "$SOURCE_BRANCH" ]; then - SOURCE_BRANCH="release/${RELEASE_NAME}" - fi - - ORIG_CWD="$(pwd)" - SRC_DIR="$(realpath "$SRC_DIR")" - cd "$SRC_DIR" > /dev/null 2>&1 - ORIG_BRANCH="$(git rev-parse --abbrev-ref HEAD 2> /dev/null)" - cd "$ORIG_CWD" -} - -cleanup() { - logInfo "Checking out original branch..." - if [ "" != "$ORIG_BRANCH" ]; then - git checkout "$ORIG_BRANCH" > /dev/null 2>&1 - fi - logInfo "Leaving source directory..." - cd "$ORIG_CWD" -} - -exitError() { - cleanup - logError "$1" - exit 1 -} - -cmdExists() { - command -v "$1" &> /dev/null -} - -checkSourceDirExists() { - if [ ! -d "$SRC_DIR" ]; then - exitError "Source directory '${SRC_DIR}' does not exist!" - fi -} - -checkOutputDirDoesNotExist() { - if [ -e "$OUTPUT_DIR" ]; then - exitError "Output directory '$OUTPUT_DIR' already exists. Please choose a different location!" - fi -} - -checkGitRepository() { - if [ ! -d .git ] || [ ! -f CHANGELOG.md ]; then - exitError "Source directory is not a valid Git repository!" - fi -} - -checkReleaseDoesNotExist() { - if [ $(git tag -l $TAG_NAME) ]; then - exitError "Release '$RELEASE_NAME' (tag: '$TAG_NAME') already exists!" - fi -} - -checkWorkingTreeClean() { - if ! git diff-index --quiet HEAD --; then - exitError "Current working tree is not clean! Please commit or unstage any changes." - fi -} - -checkSourceBranchExists() { - if ! git rev-parse "$SOURCE_BRANCH" > /dev/null 2>&1; then - exitError "Source branch '$SOURCE_BRANCH' does not exist!" - fi -} - -checkVersionInCMake() { - local app_name_upper="$(echo "$APP_NAME" | tr '[:lower:]' '[:upper:]')" - local major_num="$(echo ${RELEASE_NAME} | cut -f1 -d.)" - local minor_num="$(echo ${RELEASE_NAME} | cut -f2 -d.)" - local patch_num="$(echo ${RELEASE_NAME} | cut -f3 -d. | cut -f1 -d-)" - - if ! grep -q "${app_name_upper}_VERSION_MAJOR \"${major_num}\"" CMakeLists.txt; then - exitError "${app_name_upper}_VERSION_MAJOR not updated to '${major_num}' in CMakeLists.txt!" - fi - - if ! grep -q "${app_name_upper}_VERSION_MINOR \"${minor_num}\"" CMakeLists.txt; then - exitError "${app_name_upper}_VERSION_MINOR not updated to '${minor_num}' in CMakeLists.txt!" - fi - - if ! grep -q "${app_name_upper}_VERSION_PATCH \"${patch_num}\"" CMakeLists.txt; then - exitError "${app_name_upper}_VERSION_PATCH not updated to '${patch_num}' in CMakeLists.txt!" - fi -} - -checkChangeLog() { - if [ ! -f CHANGELOG.md ]; then - exitError "No CHANGELOG file found!" - fi - - if ! grep -qEzo "## ${RELEASE_NAME} \([0-9]{4}-[0-9]{2}-[0-9]{2}\)" CHANGELOG.md; then - exitError "'CHANGELOG.md' has not been updated to the '${RELEASE_NAME}' release!" - fi -} - -checkAppStreamInfo() { - if [ ! -f share/linux/org.keepassxc.KeePassXC.appdata.xml ]; then - exitError "No AppStream info file found!" - fi - - if ! grep -qEzo "" share/linux/org.keepassxc.KeePassXC.appdata.xml; then - exitError "'share/linux/org.keepassxc.KeePassXC.appdata.xml' has not been updated to the '${RELEASE_NAME}' release!" - fi -} - -checkTransifexCommandExists() { - if ! cmdExists tx; then - exitError "Transifex tool 'tx' not installed! Please install it using 'pip install transifex-client'." - fi -} - -checkSigntoolCommandExists() { - if ! cmdExists signtool; then - exitError "signtool command not found on the PATH! Add the Windows SDK binary folder to your PATH." - fi -} - -checkXcodeSetup() { - if ! cmdExists xcrun; then - exitError "xcrun command not found on the PATH! Please check that you have correctly installed Xcode." - fi - if ! xcrun -f codesign > /dev/null 2>&1; then - exitError "codesign command not found. You may need to run 'sudo xcode-select -r' to set up Xcode." - fi - if ! xcrun -f altool > /dev/null 2>&1; then - exitError "altool command not found. You may need to run 'sudo xcode-select -r' to set up Xcode." - fi - if ! xcrun -f stapler > /dev/null 2>&1; then - exitError "stapler command not found. You may need to run 'sudo xcode-select -r' to set up Xcode." - fi -} - -checkQt5LUpdateExists() { - if cmdExists lupdate && ! $(lupdate -version | grep -q "lupdate version 5\."); then - if ! cmdExists lupdate-qt5; then - exitError "Qt Linguist tool (lupdate-qt5) is not installed! Please install using 'apt install qttools5-dev-tools'" - fi - fi -} - -performChecks() { - logInfo "Performing basic checks..." - - checkSourceDirExists - - logInfo "Changing to source directory..." - cd "${SRC_DIR}" - - logInfo "Validating toolset and repository..." - - checkTransifexCommandExists - checkQt5LUpdateExists - checkGitRepository - checkReleaseDoesNotExist - checkWorkingTreeClean - checkSourceBranchExists - - logInfo "Checking out '${SOURCE_BRANCH}'..." - git checkout "$SOURCE_BRANCH" > /dev/null 2>&1 - - logInfo "Attempting to find '${RELEASE_NAME}' in various files..." - - checkVersionInCMake - checkChangeLog - checkAppStreamInfo - - logInfo "\e[1m\e[32mAll checks passed!\e[0m" -} - -# re-implement realpath for OS X (thanks mschrag) -# https://superuser.com/questions/205127/ -if ! cmdExists realpath; then - realpath() { - pushd . > /dev/null - if [ -d "$1" ]; then - cd "$1" - dirs -l +0 - else - cd "$(dirname "$1")" - cur_dir=$(dirs -l +0) - - if [ "$cur_dir" == "/" ]; then - echo "$cur_dir$(basename "$1")" - else - echo "$cur_dir/$(basename "$1")" - fi - fi - popd > /dev/null - } -fi - - -trap 'exitError "Exited upon user request."' SIGINT SIGTERM -trap 'exitError "Error occurred!"' ERR - - -# ----------------------------------------------------------------------- -# check command -# ----------------------------------------------------------------------- -check() { - while [ $# -ge 1 ]; do - local arg="$1" - case "$arg" in - -v|--version) - RELEASE_NAME="$2" - shift ;; - esac - shift - done - - init - - performChecks - - cleanup - - logInfo "Congrats! You can successfully merge, build, and sign KeepassXC." -} - -# ----------------------------------------------------------------------- -# merge command -# ----------------------------------------------------------------------- -merge() { - while [ $# -ge 1 ]; do - local arg="$1" - case "$arg" in - -v|--version) - RELEASE_NAME="$2" - shift ;; - - -a|--app-name) - APP_NAME="$2" - shift ;; - - -s|--source-dir) - SRC_DIR="$2" - shift ;; - - -k|--key|-g|--gpg-key) - GPG_GIT_KEY="$2" - shift ;; - - --timestamp) - TIMESTAMP_SERVER="$2" - shift ;; - - -r|--release-branch) - SOURCE_BRANCH="$2" - shift ;; - - -t|--tag-name) - TAG_NAME="$2" - shift ;; - - -h|--help) - printUsage "merge" - exit ;; - - *) - logError "Unknown option '$arg'\n" - printUsage "merge" - exit 1 ;; - esac - shift - done - - init - - performChecks - - # Update translations - i18n lupdate - i18n tx-pull - - if [ 0 -ne $? ]; then - exitError "Updating translations failed!" - fi - if ! git diff-index --quiet HEAD --; then - git add -A ./share/translations/ - logInfo "Committing changes..." - if [ -z "$GPG_GIT_KEY" ]; then - git commit -m "Update translations" - else - git commit -m "Update translations" -S"$GPG_GIT_KEY" - fi - fi - - local flags="-Pzo" - if [ -n "$OS_MACOS" ]; then - flags="-Ezo" - fi - CHANGELOG=$(grep ${flags} "## ${RELEASE_NAME} \([0-9]{4}-[0-9]{2}-[0-9]{2}\)\n\n(.|\n)+?\n\n## " CHANGELOG.md \ - | tail -n+3 | sed '$d' | sed 's/^### //') - COMMIT_MSG="Release ${RELEASE_NAME}" - - logInfo "Creating tag '${TAG_NAME}'..." - if [ -z "$GPG_GIT_KEY" ]; then - git tag -a "$TAG_NAME" -m "$COMMIT_MSG" -m "${CHANGELOG}" -s - else - git tag -a "$TAG_NAME" -m "$COMMIT_MSG" -m "${CHANGELOG}" -s -u "$GPG_GIT_KEY" - fi - - logInfo "Advancing 'latest' tag..." - if [ -z "$GPG_GIT_KEY" ]; then - git tag -sf -a "latest" -m "Latest stable release" - else - git tag -sf -u "$GPG_GIT_KEY" -a "latest" -m "Latest stable release" - fi - - cleanup - - logInfo "All done!" - logInfo "Don't forget to push the tags using \e[1mgit push --tags\e[0m." -} - -# ----------------------------------------------------------------------- -# appimage command -# ----------------------------------------------------------------------- -appimage() { - local appdir - local build_appsign=false - local build_key - local verbosity="1" - - while [ $# -ge 1 ]; do - local arg="$1" - case "$arg" in - -v|--version) - RELEASE_NAME="$2" - shift ;; - - -a|--appdir) - appdir="$2" - shift ;; - - -o|--output-dir) - OUTPUT_DIR="$2" - shift ;; - - -d|--docker-image) - DOCKER_IMAGE="$2" - shift ;; - - --container-name) - DOCKER_CONTAINER_NAME="$2" - shift ;; - - --appsign) - build_appsign=true ;; - - --verbosity) - verbosity=$2 - shift ;; - - -k|--key) - build_key="$2" - shift ;; - - -h|--help) - printUsage "appimage" - exit ;; - - *) - logError "Unknown option '$arg'\n" - printUsage "appimage" - exit 1 ;; - esac - shift - done - - if [ -z "${appdir}" ]; then - logError "Missing arguments, --appdir is required!\n" - printUsage "appimage" - exit 1 - fi - - if [ ! -d "${appdir}" ]; then - exitError "AppDir does not exist, please create one with 'make install'!" - elif [ -e "${appdir}/AppRun" ]; then - exitError "AppDir has already been run through linuxdeploy, please create a fresh AppDir with 'make install'." - fi - - appdir="$(realpath "$appdir")" - - local out="${OUTPUT_DIR}" - if [ -z "$out" ]; then - out="." - fi - mkdir -p "$out" - local out_real="$(realpath "$out")" - cd "$out" - - local linuxdeploy="linuxdeploy" - local linuxdeploy_cleanup - local linuxdeploy_plugin_qt="linuxdeploy-plugin-qt" - local linuxdeploy_plugin_qt_cleanup - local appimagetool="appimagetool" - local appimagetool_cleanup - - logInfo "Testing for AppImage tools..." - local docker_test_cmd - if [ "" != "$DOCKER_IMAGE" ]; then - docker_test_cmd="docker run -it --user $(id -u):$(id -g) --rm ${DOCKER_IMAGE}" - fi - - # Test if linuxdeploy and linuxdeploy-plugin-qt are installed - # on the system or inside the Docker container - if ! ${docker_test_cmd} which ${linuxdeploy} > /dev/null; then - logInfo "Downloading linuxdeploy..." - linuxdeploy="./linuxdeploy" - linuxdeploy_cleanup="rm -f ${linuxdeploy}" - if ! curl -Lf "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" > "$linuxdeploy"; then - exitError "linuxdeploy download failed." - fi - chmod +x "$linuxdeploy" - fi - if ! ${docker_test_cmd} which ${linuxdeploy_plugin_qt} > /dev/null; then - logInfo "Downloading linuxdeploy-plugin-qt..." - linuxdeploy_plugin_qt="./linuxdeploy-plugin-qt" - linuxdeploy_plugin_qt_cleanup="rm -f ${linuxdeploy_plugin_qt}" - if ! curl -Lf "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage" > "$linuxdeploy_plugin_qt"; then - exitError "linuxdeploy-plugin-qt download failed." - fi - chmod +x "$linuxdeploy_plugin_qt" - fi - - # appimagetool is always run outside a Docker container, so we can access our GPG keys - if ! cmdExists ${appimagetool}; then - logInfo "Downloading appimagetool..." - appimagetool="./appimagetool" - appimagetool_cleanup="rm -f ${appimagetool}" - if ! curl -Lf "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" > "$appimagetool"; then - exitError "appimagetool download failed." - fi - chmod +x "$appimagetool" - fi - - # Create custom AppRun wrapper - cat << 'EOF' > "${out_real}/KeePassXC-AppRun" -#!/usr/bin/env bash - -export PATH="$(dirname $0)/usr/bin:${PATH}" -export LD_LIBRARY_PATH="$(dirname $0)/usr/lib:${LD_LIBRARY_PATH}" - -if [ "$1" == "cli" ]; then - shift - exec keepassxc-cli "$@" -elif [ "$1" == "proxy" ]; then - shift - exec keepassxc-proxy "$@" -elif [ -v CHROME_WRAPPER ] || [ -v MOZ_LAUNCHED_CHILD ]; then - exec keepassxc-proxy "$@" -else - exec keepassxc "$@" -fi -EOF - chmod +x "${out_real}/KeePassXC-AppRun" - - # Find .desktop files, icons, and binaries to deploy - local desktop_file="$(find "$appdir" -name "org.keepassxc.KeePassXC.desktop" | head -n1)" - local icon="$(find "$appdir" -path '*/application/256x256/apps/keepassxc.png' | head -n1)" - local executables="$(find "$appdir" -type f -executable -path '*/bin/keepassxc*' -print0 | xargs -0 -i printf " --executable={}")" - - logInfo "Collecting libs and patching binaries..." - if [ -z "$DOCKER_IMAGE" ]; then - "$linuxdeploy" --verbosity=${verbosity} --plugin=qt --appdir="$appdir" --desktop-file="$desktop_file" \ - --custom-apprun="${out_real}/KeePassXC-AppRun" --icon-file="$icon" ${executables} - else - docker run --name "$DOCKER_CONTAINER_NAME" --rm \ - --cap-add SYS_ADMIN --security-opt apparmor:unconfined --device /dev/fuse -it \ - -v "${out_real}:${out_real}:rw" \ - -v "${appdir}:${appdir}:rw" \ - -w "$out_real" \ - --user $(id -u):$(id -g) \ - "$DOCKER_IMAGE" \ - bash -c "${linuxdeploy} --verbosity=${verbosity} --plugin=qt \ - --appdir='${appdir}' --custom-apprun='${out_real}/KeePassXC-AppRun' \ - --desktop-file='${desktop_file}' --icon-file='${icon}' ${executables}" - fi - - if [ $? -ne 0 ]; then - exitError "AppDir deployment failed." - fi - - logInfo "Creating AppImage..." - local appsign_flag="" - local appsign_key_flag="" - if ${build_appsign}; then - appsign_flag="--sign" - appsign_key_flag="--sign-key ${build_key}" - fi - local appimage_name="KeePassXC-x86_64.AppImage" - if [ "" != "$RELEASE_NAME" ]; then - appimage_name="KeePassXC-${RELEASE_NAME}-x86_64.AppImage" - echo "X-AppImage-Version=${RELEASE_NAME}" >> "$desktop_file" - fi - - # Run appimagetool to package (and possibly sign) AppImage - # --no-appstream is required, since it may crash on newer systems - # see: https://github.com/AppImage/AppImageKit/issues/856 - if ! "$appimagetool" --updateinformation "gh-releases-zsync|keepassxreboot|keepassxc|latest|KeePassXC-*-x86_64.AppImage.zsync" \ - ${appsign_flag} ${appsign_key_flag} --no-appstream "$appdir" "${out_real}/${appimage_name}"; then - exitError "AppImage creation failed." - fi - - logInfo "Cleaning up temporary files..." - ${linuxdeploy_cleanup} - ${linuxdeploy_plugin_qt_cleanup} - ${appimagetool_cleanup} - rm -f "${out_real}/KeePassXC-AppRun" -} - -# ----------------------------------------------------------------------- -# build command -# ----------------------------------------------------------------------- -build() { - local build_source_tarball=true - local build_snapshot=false - local build_snapcraft=false - local build_appimage=false - local build_generators="" - local build_appsign=false - local build_key="" - local build_vcpkg="" - - while [ $# -ge 1 ]; do - local arg="$1" - case "$arg" in - -v|--version) - RELEASE_NAME="$2" - shift ;; - - -a|--app-name) - APP_NAME="$2" - shift ;; - - -s|--source-dir) - SRC_DIR="$2" - shift ;; - - -o|--output-dir) - OUTPUT_DIR="$2" - shift ;; - - -t|--tag-name) - TAG_NAME="$2" - shift ;; - - -d|--docker-image) - DOCKER_IMAGE="$2" - shift ;; - - --container-name) - DOCKER_CONTAINER_NAME="$2" - shift ;; - - --appsign) - build_appsign=true ;; - - --timestamp) - TIMESTAMP_SERVER="$2" - shift ;; - - -k|--key) - build_key="$2" - shift ;; - - --snapcraft) - build_snapcraft=true ;; - - --appimage) - build_appimage=true ;; - - --cmake-generator) - CMAKE_GENERATOR="$2" - shift ;; - - -c|--cmake-options) - CMAKE_OPTIONS="$2" - shift ;; - - --compiler) - COMPILER="$2" - shift ;; - - --vcpkg) - build_vcpkg="$2" - shift ;; - - -m|--make-options) - MAKE_OPTIONS="$2" - shift ;; - - -g|--generators) - build_generators="$2" - shift ;; - - -i|--install-prefix) - INSTALL_PREFIX="$2" - shift ;; - - -p|--plugins) - BUILD_PLUGINS="$2" - shift ;; - - -n|--no-source-tarball) - build_source_tarball=false ;; - - --snapshot) - build_snapshot=true ;; - - -h|--help) - printUsage "build" - exit ;; - - *) - logError "Unknown option '$arg'\n" - printUsage "build" - exit 1 ;; - esac - shift - done - - init - - # Resolve appsign key to absolute path if under Windows - if [[ "${build_key}" && -n "$OS_WINDOWS" ]]; then - build_key="$(realpath "${build_key}")" - fi - - if [[ -f ${build_vcpkg} ]]; then - CMAKE_OPTIONS="${CMAKE_OPTIONS} -DCMAKE_TOOLCHAIN_FILE=${build_vcpkg} -DX_VCPKG_APPLOCAL_DEPS_INSTALL=ON" - fi - - if ${build_snapshot}; then - TAG_NAME="HEAD" - local branch=`git rev-parse --abbrev-ref HEAD` - logInfo "Using current branch ${branch} to build..." - RELEASE_NAME="${RELEASE_NAME}-snapshot" - CMAKE_OPTIONS="${CMAKE_OPTIONS} -DKEEPASSXC_BUILD_TYPE=Snapshot -DOVERRIDE_VERSION=${RELEASE_NAME}" - else - checkWorkingTreeClean - if echo "$TAG_NAME" | grep -qE '\-(alpha|beta)[0-9]+$'; then - CMAKE_OPTIONS="${CMAKE_OPTIONS} -DKEEPASSXC_BUILD_TYPE=PreRelease" - logInfo "Checking out pre-release tag '${TAG_NAME}'..." - else - CMAKE_OPTIONS="${CMAKE_OPTIONS} -DKEEPASSXC_BUILD_TYPE=Release" - logInfo "Checking out release tag '${TAG_NAME}'..." - fi - - if ! git checkout "$TAG_NAME" > /dev/null 2>&1; then - exitError "Failed to check out target branch." - fi - fi - - if ! ${build_snapshot} && [ -d "$OUTPUT_DIR" ]; then - exitError "Output dir '${OUTPUT_DIR}' already exists." - fi - - logInfo "Creating output directory..." - if ! mkdir -p "$OUTPUT_DIR"; then - exitError "Failed to create output directory!" - fi - OUTPUT_DIR="$(realpath "$OUTPUT_DIR")" - - if ${build_source_tarball}; then - logInfo "Creating source tarball..." - local app_name_lower="$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]')" - local prefix="${app_name_lower}-${RELEASE_NAME}" - local tarball_name="${prefix}-src.tar" - - git archive --format=tar "$TAG_NAME" --prefix="${prefix}/" --output="${OUTPUT_DIR}/${tarball_name}" - - # add .version and .gitrev files to tarball - mkdir "${prefix}" - echo -n ${RELEASE_NAME} > "${prefix}/.version" - echo -n `git rev-parse --short=7 HEAD` > "${prefix}/.gitrev" - tar --append --file="${OUTPUT_DIR}/${tarball_name}" "${prefix}/.version" "${prefix}/.gitrev" - rm "${prefix}/.version" "${prefix}/.gitrev" - rmdir "${prefix}" 2> /dev/null - - local xz="xz" - if ! cmdExists xz; then - logWarn "xz not installed. Falling back to bz2..." - xz="bzip2" - fi - $xz -6 -f "${OUTPUT_DIR}/${tarball_name}" - fi - - logInfo "Creating build directory..." - mkdir -p "${OUTPUT_DIR}/build-release" - cd "${OUTPUT_DIR}/build-release" - - logInfo "Configuring sources..." - for p in ${BUILD_PLUGINS}; do - CMAKE_OPTIONS="${CMAKE_OPTIONS} -DWITH_XC_$(echo $p | tr '[:lower:]' '[:upper:]')=On" - done - if [ -n "$OS_LINUX" ] && ${build_appimage}; then - CMAKE_OPTIONS="${CMAKE_OPTIONS} -DKEEPASSXC_DIST_TYPE=AppImage" - # linuxdeploy requires /usr as install prefix - INSTALL_PREFIX="/usr" - fi - if [ -n "$OS_MACOS" ]; then - type brew &> /dev/null 2>&1 - if [ $? -eq 0 ]; then - INSTALL_PREFIX=$(brew --prefix) - fi - fi - - # Do not build tests cases - CMAKE_OPTIONS="${CMAKE_OPTIONS} -DWITH_TESTS=OFF" - - if [ "$COMPILER" == "g++" ]; then - export CC=gcc - elif [ "$COMPILER" == "clang++" ]; then - export CC=clang - else - export CC="$COMPILER" - fi - export CXX="$COMPILER" - - if [ -z "$DOCKER_IMAGE" ]; then - if [ -n "$OS_MACOS" ]; then - # Building on macOS - export MACOSX_DEPLOYMENT_TARGET - - logInfo "Configuring build..." - cmake -G "${CMAKE_GENERATOR}" -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES="$(uname -m)" \ - -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" ${CMAKE_OPTIONS} "$SRC_DIR" - - logInfo "Compiling and packaging sources..." - cmake --build . -- ${MAKE_OPTIONS} - cpack -G "DragNDrop" - - # Appsign the executables if desired - if ${build_appsign}; then - logInfo "Signing executable files" - appsign "-f" "./${APP_NAME}-${RELEASE_NAME}.dmg" "-k" "${build_key}" - fi - - mv "./${APP_NAME}-${RELEASE_NAME}.dmg" "../${APP_NAME}-${RELEASE_NAME}-$(uname -m).dmg" - elif [ -n "$OS_WINDOWS" ]; then - # Building on Windows with Msys2 - logInfo "Configuring build..." - cmake -DCMAKE_BUILD_TYPE=Release -G "${CMAKE_GENERATOR}" -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" \ - ${CMAKE_OPTIONS} "$SRC_DIR" - - logInfo "Compiling and packaging sources..." - cmake --build . --config "Release" -- ${MAKE_OPTIONS} - - # Appsign the executables if desired - if ${build_appsign} && [ -f "${build_key}" ]; then - logInfo "Signing executable files" - appsign "-f" $(find src | grep -Ei 'keepassxc.*(\.exe|\.dll)$') "-k" "${build_key}" - fi - - # Call cpack directly instead of calling make package. - # This is important because we want to build the MSI when making a - # release. - cpack -G "${CPACK_GENERATORS};${build_generators}" - - mv "${APP_NAME}-"*.* ../ - else - mkdir -p "${OUTPUT_DIR}/KeePassXC.AppDir" - - # Building on Linux without Docker container - logInfo "Configuring build..." - cmake -DCMAKE_BUILD_TYPE=Release ${CMAKE_OPTIONS} \ - -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" "$SRC_DIR" - - logInfo "Compiling sources..." - make ${MAKE_OPTIONS} - - logInfo "Installing to bin dir..." - make DESTDIR="${OUTPUT_DIR}/KeePassXC.AppDir" install/strip - fi - else - if ${build_snapcraft}; then - logInfo "Building snapcraft docker image..." - - sudo docker image build -t "$DOCKER_IMAGE" "$(realpath "$SRC_DIR")/ci/snapcraft" - - logInfo "Launching Docker contain to compile snapcraft..." - - sudo docker run --name "$DOCKER_CONTAINER_NAME" --rm -it --user $(id -u):$(id -g) \ - -v "$(realpath "$SRC_DIR"):/keepassxc" -w "/keepassxc" \ - "$DOCKER_IMAGE" snapcraft - else - mkdir -p "${OUTPUT_DIR}/KeePassXC.AppDir" - - logInfo "Launching Docker container to compile sources..." - - docker run --name "$DOCKER_CONTAINER_NAME" --rm \ - --cap-add SYS_ADMIN --security-opt apparmor:unconfined --device /dev/fuse \ - --user $(id -u):$(id -g) \ - -e "CC=${CC}" -e "CXX=${CXX}" -it \ - -v "$(realpath "$SRC_DIR"):/keepassxc/src:ro" \ - -v "$(realpath "$OUTPUT_DIR"):/keepassxc/out:rw" \ - "$DOCKER_IMAGE" \ - bash -c "cd /keepassxc/out/build-release && \ - cmake -DCMAKE_BUILD_TYPE=Release ${CMAKE_OPTIONS} \ - -DCMAKE_INSTALL_PREFIX=${INSTALL_PREFIX} /keepassxc/src && \ - make ${MAKE_OPTIONS} && make DESTDIR=/keepassxc/out/KeePassXC.AppDir install/strip" - fi - - if [ 0 -ne $? ]; then - exitError "Docker build failed!" - fi - - logInfo "Build finished, Docker container terminated." - fi - - if [ -n "$OS_LINUX" ] && ${build_appimage}; then - local appsign_flag="" - local appsign_key_flag="" - local docker_image_flag="" - local docker_container_name_flag="" - if ${build_appsign}; then - appsign_flag="--appsign" - appsign_key_flag="-k ${build_key}" - fi - if [ "" != "${DOCKER_IMAGE}" ]; then - docker_image_flag="-d ${DOCKER_IMAGE}" - docker_container_name_flag="--container-name ${DOCKER_CONTAINER_NAME}" - fi - appimage -a "${OUTPUT_DIR}/KeePassXC.AppDir" -o "${OUTPUT_DIR}" \ - ${appsign_flag} ${appsign_key_flag} ${docker_image_flag} ${docker_container_name_flag} - fi - - cleanup - - logInfo "All done!" -} - -# ----------------------------------------------------------------------- -# gpgsign command -# ----------------------------------------------------------------------- -gpgsign() { - local sign_files=() - - while [ $# -ge 1 ]; do - local arg="$1" - case "$arg" in - -f|--files) - while [ "${2:0:1}" != "-" ] && [ $# -ge 2 ]; do - sign_files+=("$2") - shift - done ;; - - -k|--key|-g|--gpg-key) - GPG_KEY="$2" - shift ;; - - -h|--help) - printUsage "gpgsign" - exit ;; - - *) - logError "Unknown option '$arg'\n" - printUsage "gpgsign" - exit 1 ;; - esac - shift - done - - if [ -z "${sign_files}" ]; then - logError "Missing arguments, --files is required!\n" - printUsage "gpgsign" - exit 1 - fi - - for f in "${sign_files[@]}"; do - if [ ! -f "$f" ]; then - exitError "File '${f}' does not exist or is not a file!" - fi - - logInfo "Signing file '${f}' using release key..." - gpg --output "${f}.sig" --armor --local-user "$GPG_KEY" --detach-sig "$f" - - if [ 0 -ne $? ]; then - exitError "Signing failed!" - fi - - logInfo "Creating digest for file '${f}'..." - local rp="$(realpath "$f")" - local bname="$(basename "$f")" - (cd "$(dirname "$rp")"; sha256sum "$bname" > "${bname}.DIGEST") - done - - logInfo "All done!" -} - -# ----------------------------------------------------------------------- -# appsign command -# ----------------------------------------------------------------------- -appsign() { - local sign_files=() - local key - - while [ $# -ge 1 ]; do - local arg="$1" - case "$arg" in - -f|--files) - while [ "${2:0:1}" != "-" ] && [ $# -ge 2 ]; do - sign_files+=("$2") - shift - done ;; - - -k|--key|-i|--identity) - key="$2" - shift ;; - - -h|--help) - printUsage "appsign" - exit ;; - - *) - logError "Unknown option '$arg'\n" - printUsage "appsign" - exit 1 ;; - esac - shift - done - - if [ -z "${key}" ]; then - logError "Missing arguments, --key is required!\n" - printUsage "appsign" - exit 1 - fi - - if [ -z "${sign_files}" ]; then - logError "Missing arguments, --files is required!\n" - printUsage "appsign" - exit 1 - fi - - for f in "${sign_files[@]}"; do - if [ ! -e "${f}" ]; then - exitError "File '${f}' does not exist!" - fi - done - - if [ -n "$OS_MACOS" ]; then - checkXcodeSetup - - local orig_dir="$(pwd)" - local real_src_dir="$(realpath "${SRC_DIR}")" - for f in "${sign_files[@]}"; do - if [[ ${f: -4} == '.dmg' ]]; then - logInfo "Unpacking disk image '${f}'..." - local tmp_dir="/tmp/KeePassXC_${RANDOM}" - mkdir -p ${tmp_dir}/mnt - if ! hdiutil attach -quiet -noautoopen -mountpoint ${tmp_dir}/mnt "${f}"; then - exitError "DMG mount failed!" - fi - cd ${tmp_dir} - cp -a ./mnt ./app - hdiutil detach -quiet ${tmp_dir}/mnt - local app_dir_tmp="./app/KeePassXC.app" - - if [ ! -d "$app_dir_tmp" ]; then - cd "${orig_dir}" - exitError "Unpacking failed!" - fi - elif [[ ${f: -4} == '.app' ]]; then - local app_dir_tmp="$f" - else - logWarn "Skipping non-app file '${f}'..." - continue - fi - - logInfo "Signing libraries and frameworks..." - if ! find "$app_dir_tmp" \( -name '*.dylib' -o -name '*.so' -o -name '*.framework' \) -print0 | xargs -0 \ - xcrun codesign --sign "${key}" --verbose --force --options runtime; then - cd "${orig_dir}" - exitError "Signing failed!" - fi - logInfo "Signing executables..." - if ! find "${app_dir_tmp}/Contents/MacOS" \( -type f -not -name KeePassXC \) -print0 | xargs -0 \ - xcrun codesign --sign "${key}" --verbose --force --options runtime; then - cd "${orig_dir}" - exitError "Signing failed!" - fi - # Sign main executable with additional entitlements - if ! xcrun codesign --sign "${key}" --verbose --force --options runtime --entitlements \ - "${real_src_dir}/share/macosx/keepassxc.entitlements" "${app_dir_tmp}/Contents/MacOS/KeePassXC"; then - cd "${orig_dir}" - exitError "Signing failed!" - fi - - if [[ ${f: -4} == '.dmg' ]]; then - logInfo "Repacking disk image..." - hdiutil create \ - -volname "KeePassXC" \ - -size $((1000 * ($(du -sk ./app | cut -f1) + 5000))) \ - -srcfolder ./app \ - -fs HFS+ \ - -fsargs "-c c=64,a=16,e=16" \ - -format UDBZ \ - "${tmp_dir}/$(basename "${f}")" - - cd "${orig_dir}" - cp -f "${tmp_dir}/$(basename "${f}")" "${f}" - rm -Rf ${tmp_dir} - fi - - logInfo "File '${f}' successfully signed." - done - - elif [ -n "$OS_WINDOWS" ]; then - if [[ ! -f "${key}" ]]; then - exitError "Appsign key file was not found! (${key})" - fi - - logInfo "Using appsign key ${key}." - IFS=$'\n' read -s -r -p "Key password: " password - echo - - for f in "${sign_files[@]}"; do - ext=${f: -4} - if [[ $ext == ".msi" || $ext == ".exe" || $ext == ".dll" ]]; then - # Make sure we can find the signtool - checkSigntoolCommandExists - - # osslsigncode does not succeed at signing MSI files at this time... - logInfo "Signing file '${f}' using Microsoft signtool..." - signtool sign -f "${key}" -p "${password}" -d "KeePassXC" -td sha256 \ - -fd sha256 -tr "${TIMESTAMP_SERVER}" "${f}" - - if [ 0 -ne $? ]; then - exitError "Signing failed!" - fi - else - logInfo "Skipping non-executable file '${f}'..." - fi - done - - else - exitError "Unsupported platform for code signing!\n" - fi - - logInfo "All done!" -} - - -# ----------------------------------------------------------------------- -# notarize command -# ----------------------------------------------------------------------- -notarize() { - local notarize_files=() - local ac_username - local ac_keychain="AC_PASSWORD" - - while [ $# -ge 1 ]; do - local arg="$1" - case "$arg" in - -f|--files) - while [ "${2:0:1}" != "-" ] && [ $# -ge 2 ]; do - notarize_files+=("$2") - shift - done ;; - - -u|--username) - ac_username="$2" - shift ;; - - -c|--keychain) - ac_keychain="$2" - shift ;; - - -h|--help) - printUsage "notarize" - exit ;; - - *) - logError "Unknown option '$arg'\n" - printUsage "notarize" - exit 1 ;; - esac - shift - done - - if [ -z "$OS_MACOS" ]; then - exitError "Notarization is only supported on macOS!" - fi - - if [ -z "${notarize_files}" ]; then - logError "Missing arguments, --files is required!\n" - printUsage "notarize" - exit 1 - fi - - if [ -z "$ac_username" ]; then - logError "Missing arguments, --username is required!" - printUsage "notarize" - exit 1 - fi - - for f in "${notarize_files[@]}"; do - if [[ ${f: -4} != '.dmg' ]]; then - logWarn "Skipping non-DMG file '${f}'..." - continue - fi - - logInfo "Submitting disk image '${f}' for notarization..." - local status - status="$(xcrun altool --notarize-app \ - --primary-bundle-id "org.keepassxc.keepassxc" \ - --username "${ac_username}" \ - --password "@keychain:${ac_keychain}" \ - --file "${f}")" - - if [ 0 -ne $? ]; then - logError "Submission failed!" - exitError "Error message:\n${status}" - fi - - local ticket="$(echo "${status}" | grep -oE '[a-f0-9-]+$')" - logInfo "Submission successful. Ticket ID: ${ticket}." - - logInfo "Waiting for notarization to finish (this may take a while)..." - while true; do - echo -n "." - - status="$(xcrun altool --notarization-info "${ticket}" \ - --username "${ac_username}" \ - --password "@keychain:${ac_keychain}" 2> /dev/null)" - - if echo "$status" | grep -q "Status Code: 0"; then - logInfo "\nNotarization successful." - break - elif echo "$status" | grep -q "Status Code"; then - logError "\nNotarization failed!" - exitError "Error message:\n${status}" - fi - - sleep 5 - done - - logInfo "Stapling ticket to disk image..." - xcrun stapler staple "${f}" - - if [ 0 -ne $? ]; then - exitError "Stapling failed!" - fi - - logInfo "Disk image successfully notarized." - done -} - - -# ----------------------------------------------------------------------- -# i18n command -# ----------------------------------------------------------------------- - -i18n() { - local cmd="$1" - if [ -z "$cmd" ]; then - logError "No subcommand specified.\n" - printUsage i18n - exit 1 - elif [ "$cmd" != "tx-push" ] && [ "$cmd" != "tx-pull" ] && [ "$cmd" != "lupdate" ]; then - logError "Unknown subcommand: '${cmd}'\n" - printUsage i18n - exit 1 - fi - shift - - checkGitRepository - - if [ "$cmd" == "lupdate" ]; then - if [ ! -d share/translations ]; then - logError "Command must be called from repository root directory." - exit 1 - fi - - checkQt5LUpdateExists - - logInfo "Updating source translation file..." - LUPDATE=lupdate-qt5 - if ! command -v $LUPDATE > /dev/null; then - LUPDATE=lupdate - fi - $LUPDATE -no-ui-lines -disable-heuristic similartext -locations none -extensions c,cpp,h,js,mm,qrc,ui \ - -no-obsolete src -ts share/translations/keepassxc_en.ts $@ - - return 0 - fi - - checkTransifexCommandExists - - local branch="$(git branch --show-current 2>&1)" - local real_branch="$branch" - if [[ "$branch" =~ ^release/ ]]; then - logInfo "Release branch, setting language resource to master branch." - branch="master" - elif [ "$branch" != "develop" ] && [ "$branch" != "master" ]; then - logError "Must be on master or develop branch!" - exit 1 - fi - local resource="keepassxc.share-translations-keepassxc-en-ts--${branch}" - - if [ "$cmd" == "tx-push" ]; then - echo -e "This will push the \e[1m'en'\e[0m source file from the current branch to Transifex:\n" >&2 - echo -e " \e[1m${real_branch}\e[0m -> \e[1m${resource}\e[0m\n" >&2 - echo -n "Continue? [y/N] " >&2 - read -r yesno - if [ "$yesno" != "y" ] && [ "$yesno" != "Y" ]; then - logError "Push aborted." - exit 1 - fi - - logInfo "Pushing source translation file to Transifex..." - tx push -s --use-git-timestamps -r "$resource" $@ - - elif [ "$cmd" == "tx-pull" ]; then - logInfo "Pulling updated translations from Transifex..." - tx pull -af --minimum-perc=60 -r "$resource" $@ - fi -} - - -# ----------------------------------------------------------------------- -# parse global command line -# ----------------------------------------------------------------------- -MODE="$1" -shift || true -if [ -z "$MODE" ]; then - logError "Missing arguments!\n" - printUsage - exit 1 -elif [ "help" == "$MODE" ]; then - printUsage "$1" - exit -elif [ "check" == "$MODE" ] || [ "merge" == "$MODE" ] || [ "build" == "$MODE" ] \ - || [ "gpgsign" == "$MODE" ] || [ "appsign" == "$MODE" ]|| [ "notarize" == "$MODE" ] \ - || [ "appimage" == "$MODE" ]|| [ "i18n" == "$MODE" ]; then - ${MODE} "$@" -else - printUsage "$MODE" -fi diff --git a/release-tool.ps1 b/release-tool.ps1 deleted file mode 100644 index c9db231f63..0000000000 --- a/release-tool.ps1 +++ /dev/null @@ -1,665 +0,0 @@ -<# -.SYNOPSIS -KeePassXC Release Tool - -.DESCRIPTION -Commands: - merge Merge release branch into main branch and create release tags - build Build and package binary release from sources - sign Sign previously compiled release packages - -.NOTES -The following are descriptions of certain parameters: - -Vcpkg Specify VCPKG toolchain location (example: C:\vcpkg) - -Tag Release tag to check out (defaults to version number) - -Snapshot Build current HEAD without checkout out Tag - -CMakeGenerator Override the default CMake generator - -CMakeOptions Additional CMake options for compiling the sources - -CPackGenerators Set CPack generators (default: WIX;ZIP) - -Compiler Compiler to use (example: g++, clang, msbuild) - -MakeOptions Options to pass to the make program - -SignBuild Perform platform specific App Signing before packaging - -SignCert Specify the App Signing Certificate - -TimeStamp Explicitly set the timestamp server to use for appsign - -SourceBranch Source branch to merge from (default: 'release/$Version') - -TargetBranch Target branch to merge to (default: master) - -VSToolChain Specify Visual Studio Toolchain by name if more than one is available -#> - -param( - [Parameter(ParameterSetName = "merge", Mandatory, Position = 0)] - [switch] $Merge, - [Parameter(ParameterSetName = "build", Mandatory, Position = 0)] - [switch] $Build, - [Parameter(ParameterSetName = "sign", Mandatory, Position = 0)] - [switch] $Sign, - - [Parameter(ParameterSetName = "merge", Mandatory, Position = 1)] - [Parameter(ParameterSetName = "build", Mandatory, Position = 1)] - [Parameter(ParameterSetName = "sign", Mandatory, Position = 1)] - [string] $Version, - - [Parameter(ParameterSetName = "build", Mandatory)] - [string] $Vcpkg, - - [Parameter(ParameterSetName = "sign", Mandatory)] - [SupportsWildcards()] - [string[]] $SignFiles, - - # [Parameter(ParameterSetName = "build")] - # [switch] $DryRun, - [Parameter(ParameterSetName = "build")] - [switch] $Snapshot, - [Parameter(ParameterSetName = "build")] - [switch] $SignBuild, - - [Parameter(ParameterSetName = "build")] - [string] $CMakeGenerator = "Ninja", - [Parameter(ParameterSetName = "build")] - [string] $CMakeOptions, - [Parameter(ParameterSetName = "build")] - [string] $CPackGenerators = "WIX;ZIP", - [Parameter(ParameterSetName = "build")] - [string] $Compiler, - [Parameter(ParameterSetName = "build")] - [string] $MakeOptions, - [Parameter(ParameterSetName = "build")] - [Parameter(ParameterSetName = "sign")] - [X509Certificate] $SignCert, - [Parameter(ParameterSetName = "build")] - [Parameter(ParameterSetName = "sign")] - [string] $Timestamp = "http://timestamp.sectigo.com", - [Parameter(ParameterSetName = "merge")] - [Parameter(ParameterSetName = "build")] - [Parameter(ParameterSetName = "sign")] - [string] $GpgKey = "CFB4C2166397D0D2", - [Parameter(ParameterSetName = "merge")] - [Parameter(ParameterSetName = "build")] - [string] $SourceDir = ".", - [Parameter(ParameterSetName = "build")] - [string] $OutDir = ".\release", - [Parameter(ParameterSetName = "merge")] - [Parameter(ParameterSetName = "build")] - [string] $Tag, - [Parameter(ParameterSetName = "merge")] - [string] $SourceBranch, - [Parameter(ParameterSetName = "build")] - [string] $VSToolChain, - [Parameter(ParameterSetName = "merge")] - [Parameter(ParameterSetName = "build")] - [Parameter(ParameterSetName = "sign")] - [string] $ExtraPath -) - -# Helper function definitions -function Test-RequiredPrograms { - # If any of these fail they will throw an exception terminating the script - if ($Build) { - Get-Command git | Out-Null - Get-Command cmake | Out-Null - } - if ($Merge) { - Get-Command git | Out-Null - Get-Command tx | Out-Null - Get-Command lupdate | Out-Null - } - if ($Sign) { - Get-Command gpg | Out-Null - } -} - -function Test-VersionInFiles { - # Check CMakeLists.txt - $Major, $Minor, $Patch = $Version.split(".", 3) - if (!(Select-String "$SourceDir\CMakeLists.txt" -pattern "KEEPASSXC_VERSION_MAJOR `"$Major`"" -Quiet) ` - -or !(Select-String "$SourceDir\CMakeLists.txt" -pattern "KEEPASSXC_VERSION_MINOR `"$Minor`"" -Quiet) ` - -or !(Select-String "$SourceDir\CMakeLists.txt" -pattern "KEEPASSXC_VERSION_PATCH `"$Patch`"" -Quiet)) { - throw "CMakeLists.txt has not been updated to $Version." - } - - # Check Changelog - if (!(Select-String "$SourceDir\CHANGELOG.md" -pattern "^## $Version \(\d{4}-\d{2}-\d{2}\)$" -Quiet)) { - throw "CHANGELOG.md does not contain a section for $Version." - } - - # Check AppStreamInfo - if (!(Select-String "$SourceDir\share\linux\org.keepassxc.KeePassXC.appdata.xml" ` - -pattern "" -Quiet)) { - throw "share/linux/org.keepassxc.KeePassXC.appdata.xml does not contain a section for $Version." - } -} - -function Test-WorkingTreeClean { - & git diff-index --quiet HEAD -- - if ($LASTEXITCODE) { - throw "Current working tree is not clean! Please commit or unstage any changes." - } -} - -function Invoke-VSToolchain([String] $Toolchain, [String] $Path, [String] $Arch) { - # Find Visual Studio installations - $vs = Get-CimInstance MSFT_VSInstance -Namespace root/cimv2/vs - - if ($vs.count -eq 0) { - $err = "No Visual Studio installations found, download one from https://visualstudio.com/downloads." - $err = "$err`nIf Visual Studio is installed, you may need to repair the install then restart." - throw $err - } - - $VSBaseDir = $vs[0].InstallLocation - if ($Toolchain) { - # Try to find the specified toolchain by name - foreach ($_ in $vs) { - if ($_.Name -eq $Toolchain) { - $VSBaseDir = $_.InstallLocation - break - } - } - } elseif ($vs.count -gt 1) { - # Ask the user which install to use - $i = 0 - foreach ($_ in $vs) { - $i = $i + 1 - $i.ToString() + ") " + $_.Name | Write-Host - } - $i = Read-Host -Prompt "Which Visual Studio installation do you want to use?" - $i = [Convert]::ToInt32($i, 10) - 1 - if ($i -lt 0 -or $i -ge $vs.count) { - throw "Invalid selection made" - } - $VSBaseDir = $vs[$i].InstallLocation - } - - # Bootstrap the specified VS Toolchain - Import-Module "$VSBaseDir\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" - Enter-VsDevShell -VsInstallPath $VSBaseDir -Arch $Arch -StartInPath $Path | Write-Host - Write-Host # Newline after command output -} - -function Invoke-Cmd([string] $command, [string[]] $options = @(), [switch] $maskargs, [switch] $quiet) { - $call = ('{0} {1}' -f $command, ($options -Join ' ')) - if ($maskargs) { - Write-Host "$command " -ForegroundColor DarkGray - } - else { - Write-Host $call -ForegroundColor DarkGray - } - if ($quiet) { - Invoke-Expression $call > $null - } else { - Invoke-Expression $call - } - if ($LASTEXITCODE -ne 0) { - throw "Failed to run command: {0}" -f $command - } - Write-Host #insert newline after command output -} - -function Find-SignCert() { - $certs = Get-ChildItem Cert:\CurrentUser\My -codesign - if ($certs.Count -eq 0) { - throw "No code signing certificate found in User certificate store" - } elseif ($certs.Count -gt 1) { - # Ask the user which to use - $i = 0 - foreach ($_ in $certs) { - $i = $i + 1 - $i.ToString() + ") $($_.Thumbprint) - $($_.NotAfter)" | Write-Host - } - $i = Read-Host -Prompt "Which certificate do you want to use?" - $i = [Convert]::ToInt32($i, 10) - 1 - if ($i -lt 0 -or $i -ge $certs.count) { - throw "Invalid selection made" - } - return $certs[$i] - } else { - Write-Host "Found signing certificate: $($certs[0].Subject) ($($certs[0].Thumbprint))" -ForegroundColor Cyan - Write-Host - return $certs[0] - } -} - -function Invoke-SignFiles([string[]] $files, [X509Certificate] $cert, [string] $time) { - if ($files.Length -eq 0) { - return - } - - Write-Host "Signing files using $($cert.Subject) ($($cert.Thumbprint))" -ForegroundColor Cyan - - foreach ($_ in $files) { - $sig = Get-AuthenticodeSignature -FilePath "$_" -ErrorAction SilentlyContinue - if ($sig.Status -ne "Valid") { - Write-Host "Signing file '$_'" - $tmp = Set-AuthenticodeSignature -Certificate $cert -FilePath "$_" -TimestampServer "$Timestamp" -HashAlgorithm "SHA256" - } - } -} - -function Invoke-GpgSignFiles([string[]] $files, [string] $key) { - if ($files.Length -eq 0) { - return - } - - Write-Host "Signing files using GPG key $key" -ForegroundColor Cyan - - foreach ($_ in $files) { - Write-Host "Signing file '$_' and creating DIGEST..." - if (Test-Path "$_.sig") { - Remove-Item "$_.sig" - } - Invoke-Cmd "gpg" "--output `"$_.sig`" --armor --local-user `"$key`" --detach-sig `"$_`"" - $FileName = (Get-Item $_).Name - (Get-FileHash "$_" SHA256).Hash + " *$FileName" | Out-File "$_.DIGEST" -NoNewline - } -} - - -# Handle errors and restore state -$OrigDir = (Get-Location).Path -$OrigBranch = & git rev-parse --abbrev-ref HEAD -$ErrorActionPreference = 'Stop' -trap { - Write-Host "Restoring state..." -ForegroundColor Yellow - & git checkout $OrigBranch - Set-Location "$OrigDir" -} - -Write-Host "KeePassXC Release Preparation Helper" -ForegroundColor Green -Write-Host "Copyright (C) 2022 KeePassXC Team `n" -ForegroundColor Green - -# Prepend extra PATH locations as specified -if ($ExtraPath) { - $env:Path = "$ExtraPath;$env:Path" -} - -# Resolve absolute directory for paths -$SourceDir = (Resolve-Path $SourceDir).Path - -# Check format of -Version -if ($Version -notmatch "^\d+\.\d+\.\d+(-Beta\d*)?$") { - throw "Invalid format for -Version input" -} - -# Check platform -if (!$IsWindows) { - throw "The PowerShell release tool is not available for Linux or macOS at this time." -} - -if ($Merge) { - Test-RequiredPrograms - - # Change to SourceDir - Set-Location "$SourceDir" - - Test-VersionInFiles - Test-WorkingTreeClean - - if (!$SourceBranch.Length) { - $SourceBranch = & git branch --show-current - } - - if ($SourceBranch -notmatch "^release/.*$") { - throw "Must be on a release/* branch to continue." - } - - # Update translation files - Write-Host "Updating source translation file..." - Invoke-Cmd "lupdate" "-no-ui-lines -disable-heuristic similartext -locations none", ` - "-extensions c,cpp,h,js,mm,qrc,ui -no-obsolete ./src -ts share/translations/keepassxc_en.ts" - - Write-Host "Pulling updated translations from Transifex..." - Invoke-Cmd "tx" "pull -af --minimum-perc=60 -r keepassxc.share-translations-keepassxc-en-ts--develop" - - # Only commit if there are changes - $changes = & git status --porcelain - if ($changes.Length -gt 0) { - Write-Host "Committing translation updates..." - Invoke-Cmd "git" "add -A ./share/translations/" -quiet - Invoke-Cmd "git" "commit -m `"Update translations`"" -quiet - } - - # Read the version release notes from CHANGELOG - $Changelog = "" - $ReadLine = $false - Get-Content "CHANGELOG.md" | ForEach-Object { - if ($ReadLine) { - if ($_ -match "^## ") { - $ReadLine = $false - } else { - $Changelog += $_ + "`n" - } - } elseif ($_ -match "$Version \(\d{4}-\d{2}-\d{2}\)") { - $ReadLine = $true - } - } - - Write-Host "Creating tag for '$Version'..." - $tmp = New-TemporaryFile - "Release $Version`n$Changelog" | Out-File $tmp.FullName - Invoke-Cmd "git" "tag -a `"$Version`" -F `"$tmp`" -s" -quiet - Remove-Item $tmp.FullName -Force - - Write-Host "Moving latest tag..." - Invoke-Cmd "git" "tag -f -a `"latest`" -m `"Latest stable release`" -s" -quiet - - Write-Host "All done!" - Write-Host "Please merge the release branch back into the develop branch now and then push your changes." - Write-Host "Don't forget to also push the tags using 'git push --tags'." -} elseif ($Build) { - $Vcpkg = (Resolve-Path "$Vcpkg/scripts/buildsystems/vcpkg.cmake").Path - - # Find Visual Studio and establish build environment - Invoke-VSToolchain $VSToolChain $SourceDir -Arch "amd64" - - if ($SignBuild -and !$SignCert) { - $SignCert = Find-SignCert - } - - Test-RequiredPrograms - - if ($Snapshot) { - $Tag = "HEAD" - $SourceBranch = & git rev-parse --abbrev-ref HEAD - $ReleaseName = "$Version-snapshot" - $CMakeOptions = "-DKEEPASSXC_BUILD_TYPE=Snapshot -DOVERRIDE_VERSION=`"$ReleaseName`" $CMakeOptions" - Write-Host "Using current branch '$SourceBranch' to build." -ForegroundColor Cyan - } else { - Test-WorkingTreeClean - - # Clear output directory - if (Test-Path $OutDir) { - Remove-Item $OutDir -Recurse - } - - if ($Version -match "-beta\d*$") { - $CMakeOptions = "-DKEEPASSXC_BUILD_TYPE=PreRelease $CMakeOptions" - } else { - $CMakeOptions = "-DKEEPASSXC_BUILD_TYPE=Release $CMakeOptions" - } - - # Setup Tag if not defined then checkout tag - if ($Tag -eq "" -or $Tag -eq $null) { - $Tag = $Version - } - Write-Host "Checking out tag 'tags/$Tag' to build." -ForegroundColor Cyan - Invoke-Cmd "git" "checkout `"tags/$Tag`"" - } - - # Create directories - New-Item "$OutDir" -ItemType Directory -Force | Out-Null - $OutDir = (Resolve-Path $OutDir).Path - - $BuildDir = "$OutDir\build-release" - New-Item "$BuildDir" -ItemType Directory -Force | Out-Null - - # Enter build directory - Set-Location "$BuildDir" - - # Setup CMake options - $CMakeOptions = "-DWITH_XC_ALL=ON -DWITH_TESTS=OFF -DCMAKE_BUILD_TYPE=Release $CMakeOptions" - $CMakeOptions = "-DCMAKE_TOOLCHAIN_FILE:FILEPATH=`"$Vcpkg`" -DX_VCPKG_APPLOCAL_DEPS_INSTALL=ON $CMakeOptions" - - Write-Host "Configuring build..." -ForegroundColor Cyan - Invoke-Cmd "cmake" "-G `"$CMakeGenerator`" $CMakeOptions `"$SourceDir`"" - - Write-Host "Compiling sources..." -ForegroundColor Cyan - Invoke-Cmd "cmake" "--build . --config Release -- $MakeOptions" - - if ($SignBuild) { - $VcpkgDir = $BuildDir + "\vcpkg_installed\" - if (Test-Path $VcpkgDir) { - $files = Get-ChildItem $VcpkgDir -Filter "*.dll" -Recurse -File | - Where-Object {$_.FullName -notlike "$VcpkgDir*debug\*" -and $_.FullName -notlike "$VcpkgDir*tools\*"} | - ForEach-Object {$_.FullName} - } - $files += Get-ChildItem "$BuildDir\src" -Include "*keepassxc*.exe", "*keepassxc*.dll" -Recurse -File | ForEach-Object { $_.FullName } - Invoke-SignFiles $files $SignCert $Timestamp - } - - Write-Host "Create deployment packages..." -ForegroundColor Cyan - Invoke-Cmd "cpack" "-G `"$CPackGenerators`"" - Move-Item "$BuildDir\keepassxc-*" -Destination "$OutDir" -Force - - if ($SignBuild) { - # Enter output directory - Set-Location -Path "$OutDir" - - # Sign MSI files using AppSign key - $files = Get-ChildItem $OutDir -Include "*.msi" -Name - Invoke-SignFiles $files $SignCert $Timestamp - - # Sign all output files using the GPG key then hash them - $files = Get-ChildItem $OutDir -Include "*.msi", "*.zip" -Name - Invoke-GpgSignFiles $files $GpgKey - } - - # Restore state - Invoke-Command {git checkout $OrigBranch} - Set-Location "$OrigDir" -} elseif ($Sign) { - Test-RequiredPrograms - - if (!$SignCert) { - $SignCert = Find-SignCert - } - - # Resolve wildcard paths - $ResolvedFiles = @() - foreach ($_ in $SignFiles) { - $ResolvedFiles += (Get-ChildItem $_ -File | ForEach-Object { $_.FullName }) - } - - $AppSignFiles = $ResolvedFiles.Where({ $_ -match "\.(msi|exe|dll)$" }) - Invoke-SignFiles $AppSignFiles $SignCert $Timestamp - - $GpgSignFiles = $ResolvedFiles.Where({ $_ -match "\.(msi|zip|gz|xz|dmg|appimage)$" }) - Invoke-GpgSignFiles $GpgSignFiles $GpgKey -} - -# SIG # Begin signature block -# MIImVAYJKoZIhvcNAQcCoIImRTCCJkECAQExDzANBglghkgBZQMEAgEFADB5Bgor -# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG -# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCRMgDV7DQ6PzRo -# 3ULpsxL1VU2JvIFnZPXlxq/hkfU2Y6CCH2owggYUMIID/KADAgECAhB6I67aU2mW -# D5HIPlz0x+M/MA0GCSqGSIb3DQEBDAUAMFcxCzAJBgNVBAYTAkdCMRgwFgYDVQQK -# Ew9TZWN0aWdvIExpbWl0ZWQxLjAsBgNVBAMTJVNlY3RpZ28gUHVibGljIFRpbWUg -# U3RhbXBpbmcgUm9vdCBSNDYwHhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5 -# WjBVMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMSwwKgYD -# VQQDEyNTZWN0aWdvIFB1YmxpYyBUaW1lIFN0YW1waW5nIENBIFIzNjCCAaIwDQYJ -# KoZIhvcNAQEBBQADggGPADCCAYoCggGBAM2Y2ENBq26CK+z2M34mNOSJjNPvIhKA -# VD7vJq+MDoGD46IiM+b83+3ecLvBhStSVjeYXIjfa3ajoW3cS3ElcJzkyZlBnwDE -# JuHlzpbN4kMH2qRBVrjrGJgSlzzUqcGQBaCxpectRGhhnOSwcjPMI3G0hedv2eNm -# GiUbD12OeORN0ADzdpsQ4dDi6M4YhoGE9cbY11XxM2AVZn0GiOUC9+XE0wI7CQKf -# OUfigLDn7i/WeyxZ43XLj5GVo7LDBExSLnh+va8WxTlA+uBvq1KO8RSHUQLgzb1g -# bL9Ihgzxmkdp2ZWNuLc+XyEmJNbD2OIIq/fWlwBp6KNL19zpHsODLIsgZ+WZ1AzC -# s1HEK6VWrxmnKyJJg2Lv23DlEdZlQSGdF+z+Gyn9/CRezKe7WNyxRf4e4bwUtrYE -# 2F5Q+05yDD68clwnweckKtxRaF0VzN/w76kOLIaFVhf5sMM/caEZLtOYqYadtn03 -# 4ykSFaZuIBU9uCSrKRKTPJhWvXk4CllgrwIDAQABo4IBXDCCAVgwHwYDVR0jBBgw -# FoAU9ndq3T/9ARP/FqFsggIv0Ao9FCUwHQYDVR0OBBYEFF9Y7UwxeqJhQo1SgLqz -# YZcZojKbMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMBMGA1Ud -# JQQMMAoGCCsGAQUFBwMIMBEGA1UdIAQKMAgwBgYEVR0gADBMBgNVHR8ERTBDMEGg -# P6A9hjtodHRwOi8vY3JsLnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNUaW1lU3Rh -# bXBpbmdSb290UjQ2LmNybDB8BggrBgEFBQcBAQRwMG4wRwYIKwYBBQUHMAKGO2h0 -# dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY1RpbWVTdGFtcGluZ1Jv -# b3RSNDYucDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTAN -# BgkqhkiG9w0BAQwFAAOCAgEAEtd7IK0ONVgMnoEdJVj9TC1ndK/HYiYh9lVUacah -# RoZ2W2hfiEOyQExnHk1jkvpIJzAMxmEc6ZvIyHI5UkPCbXKspioYMdbOnBWQUn73 -# 3qMooBfIghpR/klUqNxx6/fDXqY0hSU1OSkkSivt51UlmJElUICZYBodzD3M/SFj -# eCP59anwxs6hwj1mfvzG+b1coYGnqsSz2wSKr+nDO+Db8qNcTbJZRAiSazr7KyUJ -# Go1c+MScGfG5QHV+bps8BX5Oyv9Ct36Y4Il6ajTqV2ifikkVtB3RNBUgwu/mSiSU -# ice/Jp/q8BMk/gN8+0rNIE+QqU63JoVMCMPY2752LmESsRVVoypJVt8/N3qQ1c6F -# ibbcRabo3azZkcIdWGVSAdoLgAIxEKBeNh9AQO1gQrnh1TA8ldXuJzPSuALOz1Uj -# b0PCyNVkWk7hkhVHfcvBfI8NtgWQupiaAeNHe0pWSGH2opXZYKYG4Lbukg7HpNi/ -# KqJhue2Keak6qH9A8CeEOB7Eob0Zf+fU+CCQaL0cJqlmnx9HCDxF+3BLbUufrV64 -# EbTI40zqegPZdA+sXCmbcZy6okx/SjwsusWRItFA3DE8MORZeFb6BmzBtqKJ7l93 -# 9bbKBy2jvxcJI98Va95Q5JnlKor3m0E7xpMeYRriWklUPsetMSf2NvUQa/E5vVye -# fQIwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0GCSqGSIb3DQEBDAUA -# MFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxLTArBgNV -# BAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290IFI0NjAeFw0yMTAz -# MjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQK -# Ew9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUg -# U2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCb -# K51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEeAEZGbEN4QMgCsJLZ -# UKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGVoYW3haT29PSTahYk -# wmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk8XL/tfCK6cPuYHE2 -# 15wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzhP06ShdnRqtZlV59+ -# 8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41aHU5pledi9lCBbH9 -# JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+ac7IH60B+Ja7TVM+ -# EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/2yGgW+ufflcZ/ZE9 -# o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9gRvd+QOfdRrJZb1sC -# AwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhAQnAgNpFcF4XmMB0G -# A1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8BAf8EBAMCAYYwEgYD -# VR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcDAzAbBgNVHSAEFDAS -# MAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyGOmh0dHA6Ly9jcmwu -# c2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5nUm9vdFI0Ni5jcmww -# ewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8vY3J0LnNlY3RpZ28u -# Y29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYucDdjMCMGCCsGAQUF -# BzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEA -# Bv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW4HL9hcpkOTz5jUug -# 2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIhrCymlaS98+QpoBCy -# KppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYpmlG+vd0xqlqd099i -# ChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7DcML4iTAWS+MVXeNLj -# 1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yELg09Jlo8BMe80jO3 -# 7PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRzNyE6bxuSKcutisqm -# KL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw4rg3sTCggkHSRqTq -# lLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6E52z1NZJ6ctuMFBQ -# ZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6xWls/GDnVNueKjWU -# H3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZtg0y9ShQoPzmCcn63 -# Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggZJMIIEsaADAgECAhAG -# Qz/MzOQzqJLMF7dGpYxlMA0GCSqGSIb3DQEBDAUAMFQxCzAJBgNVBAYTAkdCMRgw -# FgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28gUHVibGlj -# IENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjQwMjIzMDAwMDAwWhcNMjcwMjIyMjM1 -# OTU5WjBgMQswCQYDVQQGEwJVUzERMA8GA1UECAwIVmlyZ2luaWExHjAcBgNVBAoM -# FURyb2lkTW9ua2V5IEFwcHMsIExMQzEeMBwGA1UEAwwVRHJvaWRNb25rZXkgQXBw -# cywgTExDMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuJtEjRyetghx -# 6Abi1cpMT88uT6nIcTe3AyUvdSkjCtUM8Gat0YJfqTxokb9dBzJa7j8YWOUU1Yc4 -# EDXoYYtVRE+1UkdPAcXNMf2hNXGI45iZVwhBPQZBU4QfKltzYqrjAZgDvxeYd68q -# ImjzUfrCY3uZHwEIuCewmNMPpEgbdjuSXDyBAKKBtaO2iqyaJpqcC39QnDKlXMic -# DPqqH5fI7wK7Lg9f4BwOsaO4P68I3pOv7L/6E5GR9+hTj6txhxFz/yCbDxN1PUvD -# sGaXjMmVeP2M95fkwOFwut5yBESDIwAGEWUFsTJ32hSmE74+xG6rVqtueayV7U9c -# GURznSk9ZlTUqQOW9Z4K+pu29gTZ9zVWlONIsQR7QXfGKZWF+Xik6rTujSRTTsK7 -# QNMYzBI6b9v0nD2pEWuGZDXIO5o5N2HzXEFlwxCFY483yWSObHNBp9PFtiDueqv+ -# 8vrN+lsirZlDFCxI6hW+F8oYp3XxHdSqxsMRTqbO6dUjH2Tyd0G5fbyT8Rid7DbP -# 6p/apzIrdFOM0kdcKLmppYBp7BInTdjbWJYhtuORIUZQbUOSM71vYCUHj7xkckiY -# YmkUf0XH8xx8jqgVWseBW63gCEowhCEYxaWt0QGyXJ6UrlV4WTUCWzxm45I5OQoo -# fymUvdutKgr9bR3nJ5yS/c+E3KnqJhkCAwEAAaOCAYkwggGFMB8GA1UdIwQYMBaA -# FA8qyyCHKLjsb0iuK1SmKaoXpM0MMB0GA1UdDgQWBBQta729krTac3CUndU0S0Dd -# DscjHTAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggr -# BgEFBQcDAzBKBgNVHSAEQzBBMDUGDCsGAQQBsjEBAgEDAjAlMCMGCCsGAQUFBwIB -# FhdodHRwczovL3NlY3RpZ28uY29tL0NQUzAIBgZngQwBBAEwSQYDVR0fBEIwQDA+ -# oDygOoY4aHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNp -# Z25pbmdDQVIzNi5jcmwweQYIKwYBBQUHAQEEbTBrMEQGCCsGAQUFBzAChjhodHRw -# Oi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ0NBUjM2 -# LmNydDAjBggrBgEFBQcwAYYXaHR0cDovL29jc3Auc2VjdGlnby5jb20wDQYJKoZI -# hvcNAQEMBQADggGBAJSy5YPCbh9ZsuDCKgDuzOWZzNza4/FrA+kT7EitDezYN3S/ -# P0EVc0tPbgYAKfNqY+ihAMyjZHdgybfBWhGzUTDo+HEipcnZ2pgwPadsw23jJ8MN -# 1tdms9iKDakIQ2MVsB7cGFRU8QjLovkPdZkyLcjuYbkiZRoNoKlhmrOOf6n1oCwX -# VJ9ONJijc+Lr3+4EIqZ39ET2+uI9Wg9Bfd9XrDZfYFEcRJjNzRpCtHb26aIzV/Xi -# MWasHRPaII34SzD0BmaPbsLeGW1UGvW3tQcgVNdT/uajegmShVb+c5J5ktRSJ0cq -# yxmTAYaeMuA6IxG1f6kui1SAFQs2lzlGyEgxgiNGo7cHHN2KidhrBL3U2bGr9Tkd -# p3gmV+Gj3esCdQzJE4aqmUZvIvHpkrair4qbLFZRNozAZJn2SIeQa5u2U0ZmvcAr -# 1C7S3JVLP3t9LKE0mlFkV9pbIU97ND3iH3tO0Zb3SvCK/XjO1PZVb8EXsi67wbfM -# SWAwi2CETDonb7+gBjCCBl0wggTFoAMCAQICEDpSaiyEzlXmHWX8zBLY6YkwDQYJ -# KoZIhvcNAQEMBQAwVTELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3RpZ28gTGlt -# aXRlZDEsMCoGA1UEAxMjU2VjdGlnbyBQdWJsaWMgVGltZSBTdGFtcGluZyBDQSBS -# MzYwHhcNMjQwMTE1MDAwMDAwWhcNMzUwNDE0MjM1OTU5WjBuMQswCQYDVQQGEwJH -# QjETMBEGA1UECBMKTWFuY2hlc3RlcjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVk -# MTAwLgYDVQQDEydTZWN0aWdvIFB1YmxpYyBUaW1lIFN0YW1waW5nIFNpZ25lciBS -# MzUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCN0Wf0wUibvf04STpN -# YYGbw9jcRaVhBDaNBp7jmJaA9dQZW5ighrXGNMYjK7Dey5RIHMqLIbT9z9if753m -# YbojJrKWO4ZP0N5dBT2TwZZaPb8E+hqaDZ8Vy2c+x1NiEwbEzTrPX4W3QFq/zJvD -# DbWKL99qLL42GJQzX3n5wWo60KklfFn+Wb22mOZWYSqkCVGl8aYuE12SqIS4MVO4 -# PUaxXeO+4+48YpQlNqbc/ndTgszRQLF4MjxDPjRDD1M9qvpLTZcTGVzxfViyIToR -# NxPP6DUiZDU6oXARrGwyP9aglPXwYbkqI2dLuf9fiIzBugCDciOly8TPDgBkJmjA -# fILNiGcVEzg+40xUdhxNcaC+6r0juPiR7bzXHh7v/3RnlZuT3ZGstxLfmE7fRMAF -# wbHdDz5gtHLqjSTXDiNF58IxPtvmZPG2rlc+Yq+2B8+5pY+QZn+1vEifI0MDtiA6 -# BxxQuOnj4PnqDaK7NEKwtD1pzoA3jJFuoJiwbatwhDkg1PIjYnMDbDW+wAc9FtRN -# 6pUsO405jaBgigoFZCw9hWjLNqgFVTo7lMb5rVjJ9aSBVVL2dcqzyFW2LdWk5Xdp -# 65oeeOALod7YIIMv1pbqC15R7QCYLxcK1bCl4/HpBbdE5mjy9JR70BHuYx27n4XN -# OZbwrXcG3wZf9gEUk7stbPAoBQIDAQABo4IBjjCCAYowHwYDVR0jBBgwFoAUX1jt -# TDF6omFCjVKAurNhlxmiMpswHQYDVR0OBBYEFGjvpDJJabZSOB3qQzks9BRqngyF -# MA4GA1UdDwEB/wQEAwIGwDAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsG -# AQUFBwMIMEoGA1UdIARDMEEwNQYMKwYBBAGyMQECAQMIMCUwIwYIKwYBBQUHAgEW -# F2h0dHBzOi8vc2VjdGlnby5jb20vQ1BTMAgGBmeBDAEEAjBKBgNVHR8EQzBBMD+g -# PaA7hjlodHRwOi8vY3JsLnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNUaW1lU3Rh -# bXBpbmdDQVIzNi5jcmwwegYIKwYBBQUHAQEEbjBsMEUGCCsGAQUFBzAChjlodHRw -# Oi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNUaW1lU3RhbXBpbmdDQVIz -# Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG -# SIb3DQEBDAUAA4IBgQCw3C7J+k82TIov9slP1e8YTx+fDsa//hJ62Y6SMr2E89rv -# 82y/n8we5W6z5pfBEWozlW7nWp+sdPCdUTFw/YQcqvshH6b9Rvs9qZp5Z+V7nHwP -# TH8yzKwgKzTTG1I1XEXLAK9fHnmXpaDeVeI8K6Lw3iznWZdLQe3zl+Rejdq5l2jU -# 7iUfMkthfhFmi+VVYPkR/BXpV7Ub1QyyWebqkjSHJHRmv3lBYbQyk08/S7TlIeOr -# 9iQ+UN57fJg4QI0yqdn6PyiehS1nSgLwKRs46T8A6hXiSn/pCXaASnds0LsM5OVo -# KYfbgOOlWCvKfwUySWoSgrhncihSBXxH2pAuDV2vr8GOCEaePZc0Dy6O1rYnKjGm -# qm/IRNkJghSMizr1iIOPN+23futBXAhmx8Ji/4NTmyH9K0UvXHiuA2Pa3wZxxR9r -# 9XeIUVb2V8glZay+2ULlc445CzCvVSZV01ZB6bgvCuUuBx079gCcepjnZDCcEuIC -# 5Se4F6yFaZ8RvmiJ4hgwggaCMIIEaqADAgECAhA2wrC9fBs656Oz3TbLyXVoMA0G -# CSqGSIb3DQEBDAUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMKTmV3IEplcnNl -# eTEUMBIGA1UEBxMLSmVyc2V5IENpdHkxHjAcBgNVBAoTFVRoZSBVU0VSVFJVU1Qg -# TmV0d29yazEuMCwGA1UEAxMlVVNFUlRydXN0IFJTQSBDZXJ0aWZpY2F0aW9uIEF1 -# dGhvcml0eTAeFw0yMTAzMjIwMDAwMDBaFw0zODAxMTgyMzU5NTlaMFcxCzAJBgNV -# BAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxLjAsBgNVBAMTJVNlY3Rp -# Z28gUHVibGljIFRpbWUgU3RhbXBpbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB -# AQUAA4ICDwAwggIKAoICAQCIndi5RWedHd3ouSaBmlRUwHxJBZvMWhUP2ZQQRLRB -# QIF3FJmp1OR2LMgIU14g0JIlL6VXWKmdbmKGRDILRxEtZdQnOh2qmcxGzjqemIk8 -# et8sE6J+N+Gl1cnZocew8eCAawKLu4TRrCoqCAT8uRjDeypoGJrruH/drCio28aq -# IVEn45NZiZQI7YYBex48eL78lQ0BrHeSmqy1uXe9xN04aG0pKG9ki+PC6VEfzutu -# 6Q3IcZZfm00r9YAEp/4aeiLhyaKxLuhKKaAdQjRaf/h6U13jQEV1JnUTCm511n5a -# vv4N+jSVwd+Wb8UMOs4netapq5Q/yGyiQOgjsP/JRUj0MAT9YrcmXcLgsrAimfWY -# 3MzKm1HCxcquinTqbs1Q0d2VMMQyi9cAgMYC9jKc+3mW62/yVl4jnDcw6ULJsBkO -# krcPLUwqj7poS0T2+2JMzPP+jZ1h90/QpZnBkhdtixMiWDVgh60KmLmzXiqJc6lG -# wqoUqpq/1HVHm+Pc2B6+wCy/GwCcjw5rmzajLbmqGygEgaj/OLoanEWP6Y52Hfle -# f3XLvYnhEY4kSirMQhtberRvaI+5YsD3XVxHGBjlIli5u+NrLedIxsE88WzKXqZj -# j9Zi5ybJL2WjeXuOTbswB7XjkZbErg7ebeAQUQiS/uRGZ58NHs57ZPUfECcgJC+v -# 2wIDAQABo4IBFjCCARIwHwYDVR0jBBgwFoAUU3m/WqorSs9UgOHYm8Cd8rIDZssw -# HQYDVR0OBBYEFPZ3at0//QET/xahbIICL9AKPRQlMA4GA1UdDwEB/wQEAwIBhjAP -# BgNVHRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMIMBEGA1UdIAQKMAgw -# BgYEVR0gADBQBgNVHR8ESTBHMEWgQ6BBhj9odHRwOi8vY3JsLnVzZXJ0cnVzdC5j -# b20vVVNFUlRydXN0UlNBQ2VydGlmaWNhdGlvbkF1dGhvcml0eS5jcmwwNQYIKwYB -# BQUHAQEEKTAnMCUGCCsGAQUFBzABhhlodHRwOi8vb2NzcC51c2VydHJ1c3QuY29t -# MA0GCSqGSIb3DQEBDAUAA4ICAQAOvmVB7WhEuOWhxdQRh+S3OyWM637ayBeR7djx -# Q8SihTnLf2sABFoB0DFR6JfWS0snf6WDG2gtCGflwVvcYXZJJlFfym1Doi+4PfDP -# 8s0cqlDmdfyGOwMtGGzJ4iImyaz3IBae91g50QyrVbrUoT0mUGQHbRcF57olpfHh -# QEStz5i6hJvVLFV/ueQ21SM99zG4W2tB1ExGL98idX8ChsTwbD/zIExAopoe3l6J -# rzJtPxj8V9rocAnLP2C8Q5wXVVZcbw4x4ztXLsGzqZIiRh5i111TW7HV1AtsQa6v -# Xy633vCAbAOIaKcLAo/IU7sClyZUk62XD0VUnHD+YvVNvIGezjM6CRpcWed/ODip -# tK+evDKPU2K6synimYBaNH49v9Ih24+eYXNtI38byt5kIvh+8aW88WThRpv8lUJK -# aPn37+YHYafob9Rg7LyTrSYpyZoBmwRWSE4W6iPjB7wJjJpH29308ZkpKKdpkiS9 -# WNsf/eeUtvRrtIEiSJHN899L1P4l6zKVsdrUu1FX1T/ubSrsxrYJD+3f3aKg6yxd -# bugot06YwGXXiy5UUGZvOu3lXlxA+fC13dQ5OlL2gIb5lmF6Ii8+CQOYDwXM+yd9 -# dbmocQsHjcRPsccUd5E9FiswEqORvz8g3s+jR3SFCgXhN4wz7NgAnOgpCdUo4uDy -# llU9PzGCBkAwggY8AgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3Rp -# Z28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWduaW5n -# IENBIFIzNgIQBkM/zMzkM6iSzBe3RqWMZTANBglghkgBZQMEAgEFAKCBhDAYBgor -# BgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEE -# MBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCCw -# CjBOFSrHIl5SZxVeFP1D+IfXa4B5pNieNHIkm0/SqTANBgkqhkiG9w0BAQEFAASC -# AgAgFK2xkUz0aie9HSo0e4qyDk83CNX9G/GR7+DObTay5l7OYVZIdB2kOZIS8UbH -# 4gMSsjplIVObVyf1DjGGCctq4bFDABL7wpwqm7P3tEjs2d/HK2Yxoe1c8YFTYMJJ -# Vc6Q9l/nZA7ZC/SCH1NyEgK+w3vQ6SARudN8/ZgFVa1P3DdwOADmLD774v3bOUKq -# XKDOySeYD7bkCekPv6yx6DnrWBBsYIKFRv2Yv4duThki4CC1FMgEVTmdBDJIP3R8 -# 1BgXjPvVxYX3aQ9emC3KluyNr/BEPZiVdwBjXCE60n7g/Y8qNgqY0ZaImSpl9MFx -# VkrxE7iNfBcBE8xVCghyDahs1BxyEeEdQk+QlLD1Cv3KGODlyWjgncDAX7fnkC6l -# M7KUttjXGi9uQG3g2dUCX+744wPhRg+DBfch2Em70I0kYsPY6ETyrQogZdi6QzKO -# Hlf/hUW0o9HCc6BrTSL4y8G0mlKVCgUpMOjlrip88bvW05ZUX20arGKxGg1uxFIA -# r7wvQyFn+RvNc0kqWt/xgwp3HAc80ABPCYumLqGwucBWisiMt4P2s+fkLpYJdC/n -# pS/3fRoepfGmv8J1WAIjGiO7e12aDrTQqNP+2RUzkNpy2eRQDL+3VUFQOQqEfkVL -# Y6wpN6nB7olNULhPUlwZChf49v/h+XUxhgHozWN576qoyqGCAyIwggMeBgkqhkiG -# 9w0BCQYxggMPMIIDCwIBATBpMFUxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0 -# aWdvIExpbWl0ZWQxLDAqBgNVBAMTI1NlY3RpZ28gUHVibGljIFRpbWUgU3RhbXBp -# bmcgQ0EgUjM2AhA6UmoshM5V5h1l/MwS2OmJMA0GCWCGSAFlAwQCAgUAoHkwGAYJ -# KoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjQwOTE0MDMy -# MTU1WjA/BgkqhkiG9w0BCQQxMgQwwrUMFcAva5866cdprEw/weWm4EfoAA4SCloN -# B50191F7ps9XQIxGfsz+g0vQxzxfMA0GCSqGSIb3DQEBAQUABIICAC3qVFmWQWkL -# kn/AYJPZ3B7Yvwq0P7SqcHO9w5FiV5wsznH6xfvkTzXssQLhKaZdqypnHCTNth8D -# 7mgr6zZYh5CgQQ3SSG2q0xVzs3wanJmZ4g6I7bVeGMLv47tFnCed9G3aP5cywDBn -# vMOiwZnQR1WwM8T6qE4sAb4lKXUYDbIVB1DMRAF3j2rQMAN9e9jF6Ok+ZyQqpBSl -# ve2vBR0TgFXeyidwiz6O2I1FWc1OzwMchbJTANbQqWRKuiQ6gm0Bj/S8dalBb77I -# jxS0Tn7kRH1Sr50ZfWRSxj7H7afsQOKbDHxhWFhctvQfbrmbNj+gHcm9j/rSPpU7 -# zj5OvgKyYQnjiLjCnGBTmSML2ZwvXhPv2XkFQ2yL2nYWTRqLjARdcP62kSrkQxEa -# DLAZ7mcndE+HZVMllBGVI9/H5hkE7jINBU4gNvyqQQqF3xTatJMldyrXCQ6R9wfN -# LsdyFB177vZXLrS1EymCzq1COpbrw3oa/LXP+1hZFhoaOYy00LUnCU5Zjd8UFWIh -# FDj3Z7O/Xz3P8BR4t7PGqUu3x8UbxcsGDH0w0e3pvPmxXiBZlspjNieg073YNKxU -# Yuj0b3cX/cpYH0M0Ne/tXuHwbZthwwll3vytT7Aa+oglejolDQjRc8Gv5KW0dUK3 -# LmVw9eforeFUrTExSEc/0jf29BmZz9do -# SIG # End signature block diff --git a/release-tool.py b/release-tool.py new file mode 100755 index 0000000000..90ade0a3f4 --- /dev/null +++ b/release-tool.py @@ -0,0 +1,1301 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 KeePassXC Team +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 or (at your option) +# version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import argparse +import ctypes +from datetime import datetime +import hashlib +import logging +import lzma +import os +from pathlib import Path +import platform +import random +import re +import signal +import shutil +import stat +import string +import subprocess +import sys +import tarfile +import tempfile +from urllib.request import urlretrieve + + +########################################################################################### +# Ctrl+F TOC +########################################################################################### + +# class Check(Command) +# class Merge(Command) +# class Build(Command) +# class BuildSrc(Command) +# class AppSign(Command) +# class GPGSign(Command) +# class I18N(Command) + + +########################################################################################### +# Errors and Logging +########################################################################################### + + +class Error(Exception): + def __init__(self, msg, *args, **kwargs): + self.msg = msg + self.args = args + self.kwargs = kwargs + self.__dict__.update(kwargs) + + def __str__(self): + return self.msg % self.args + + +class SubprocessError(Error): + pass + + +class LogFormatter(logging.Formatter): + @staticmethod + def _term_colors_on(): + return 'color' in os.getenv('TERM', '') or 'FORCE_COLOR' in os.environ or sys.platform == 'win32' + + _TERM_BOLD = '\x1b[1m' if _term_colors_on() else '' + _TERM_RES_BOLD = '\x1b[22m' if _term_colors_on() else '' + _TERM_RED = '\x1b[31m' if _term_colors_on() else '' + _TERM_BRIGHT_RED = '\x1b[91m' if _term_colors_on() else '' + _TERM_YELLOW = '\x1b[33m' if _term_colors_on() else '' + _TERM_BLUE = '\x1b[34m' if _term_colors_on() else '' + _TERM_GREEN = '\x1b[32m' if _term_colors_on() else '' + _TERM_RES_CLR = '\x1b[39m' if _term_colors_on() else '' + _TERM_RES = '\x1b[0m' if _term_colors_on() else '' + + _FMT = { + logging.DEBUG: f'{_TERM_BOLD}[%(levelname)s]{_TERM_RES} %(message)s', + logging.INFO: f'{_TERM_BOLD}[{_TERM_BLUE}%(levelname)s{_TERM_RES_CLR}]{_TERM_RES} %(message)s', + logging.WARNING: f'{_TERM_BOLD}[{_TERM_YELLOW}%(levelname)s{_TERM_RES_CLR}]{_TERM_RES}{_TERM_YELLOW} %(message)s{_TERM_RES}', + logging.ERROR: f'{_TERM_BOLD}[{_TERM_RED}%(levelname)s{_TERM_RES_CLR}]{_TERM_RES}{_TERM_RED} %(message)s{_TERM_RES}', + logging.CRITICAL: f'{_TERM_BOLD}[{_TERM_BRIGHT_RED}%(levelname)s{_TERM_RES_CLR}]{_TERM_RES}{_TERM_BRIGHT_RED} %(message)s{_TERM_RES}', + } + + def format(self, record): + return logging.Formatter(self._FMT.get(record.levelno, '%(message)s')).format(record) + + # Function to make text bold in the terminal + def bold(self, text): + return f'{self._TERM_BOLD}{text}{self._TERM_RES_BOLD}' + # Functions to color text in the terminal + def red(self, text): + return f'{self._TERM_RED}{text}{self._TERM_RES_CLR}' + def bright_red(self, text): + return f'{self._TERM_BRIGHT_RED}{text}{self._TERM_RES_CLR}' + def yellow(self, text): + return f'{self._TERM_YELLOW}{text}{self._TERM_RES_CLR}' + def blue(self, text): + return f'{self._TERM_BLUE}{text}{self._TERM_RES_CLR}' + def green(self, text): + return f'{self._TERM_GREEN}{text}{self._TERM_RES_CLR}' + # Force a reset of terminal formatting + def reset(self): + return f'{self._TERM_RES}' + + +fmt = LogFormatter() +console_handler = logging.StreamHandler() +console_handler.setFormatter(fmt) +logger = logging.getLogger(__file__) +logger.setLevel(os.getenv('LOGLEVEL') if 'LOGLEVEL' in os.environ else logging.INFO) +logger.addHandler(console_handler) + + +########################################################################################### +# Helper Functions +########################################################################################### + + +_GIT_ORIG_BRANCH_CWD = None + + +def _get_bin_path(build_dir=None): + if not build_dir: + return os.getenv('PATH') + build_dir = Path(build_dir).absolute() + path_sep = ';' if sys.platform == 'win32' else ':' + return path_sep.join(list(map(str, build_dir.rglob('vcpkg_installed/*/tools/**/bin'))) + [os.getenv('PATH')]) + + +def _yes_no_prompt(prompt, default_no=True): + sys.stderr.write(f'{prompt} {"[y/N]" if default_no else "[Y/n]"} ') + yes_no = input().strip().lower() + if default_no: + return yes_no == 'y' + return yes_no != 'n' + + +def _choice_prompt(prompt, choices): + while True: + sys.stderr.write(prompt + '\n') + for i, c in enumerate(choices): + sys.stderr.write(f' {i + 1}) {c}\n') + sys.stderr.write('\nYour choice: ') + choice = input().strip() + if not choice.isnumeric() or int(choice) < 1 or int(choice) > len(choices): + logger.error('Invalid choice: %s', choice) + continue + return int(choice) - 1 + + +def _run(cmd, *args, cwd, path=None, env=None, input=None, capture_output=True, timeout=None, check=True, + docker_image=None, docker_privileged=False, docker_mounts=None, docker_platform=None, **run_kwargs): + """ + Run a command and return its output. + Raises an error if ``check`` is ``True`` and the process exited with a non-zero code. + """ + if not cmd: + raise ValueError('Empty command given.') + + if not env: + env = os.environ.copy() + if path: + env['PATH'] = path + if LogFormatter._term_colors_on(): + env['FORCE_COLOR'] = '1' + + if docker_image: + docker_cmd = ['docker', 'run', '--rm', '--tty=true', f'--workdir={cwd}', f'--user={os.getuid()}:{os.getgid()}'] + docker_cmd.extend([f'--env={k}={v}' for k, v in env.items() if k in ['FORCE_COLOR', 'CC', 'CXX']]) + if path: + docker_cmd.append(f'--env=PATH={path}') + docker_cmd.append(f'--volume={Path(cwd).absolute()}:{Path(cwd).absolute()}:rw') + if docker_mounts: + docker_cmd.extend([f'--volume={Path(d).absolute()}:{Path(d).absolute()}:rw' for d in docker_mounts]) + if docker_privileged: + docker_cmd.extend(['--cap-add=SYS_ADMIN', '--security-opt=apparmor:unconfined', '--device=/dev/fuse']) + if docker_platform: + docker_cmd.append(f'--platform={docker_platform}') + docker_cmd.append(docker_image) + cmd = docker_cmd + cmd + + try: + logger.debug('Running command: %s', ' '.join(cmd)) + return subprocess.run( + cmd, *args, + input=input, + capture_output=capture_output, + cwd=cwd, + env=env, + timeout=timeout, + check=check, + **run_kwargs) + except FileNotFoundError: + raise Error('Command not found: %s', cmd[0] if type(cmd) in [list, tuple] else cmd) + except subprocess.CalledProcessError as e: + if e.stderr: + err_txt = e.stderr + if type(err_txt) is bytes: + err_txt = err_txt.decode() + raise SubprocessError('Command "%s" exited with non-zero code: %s', + cmd[0], err_txt, **e.__dict__) + else: + raise SubprocessError('Command "%s" exited with non-zero code.', cmd[0], **e.__dict__) + + +def _cmd_exists(cmd, path=None): + """Check if command exists.""" + return shutil.which(cmd, path=path) is not None + + +def _git_working_dir_clean(*, cwd): + """Check whether the Git working directory is clean.""" + return _run(['git', 'diff-index', '--quiet', 'HEAD', '--'], check=False, cwd=cwd).returncode == 0 + + +def _git_get_branch(*, cwd): + """Get current Git branch.""" + return _run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=cwd, text=True).stdout.strip() + + +def _git_branches_related(branch1, branch2, *, cwd): + """Check whether branch is ancestor or descendant of another.""" + return (_run(['git', 'merge-base', '--is-ancestor', branch1, branch2], cwd=cwd, check=False).returncode == 0 or + _run(['git', 'merge-base', '--is-ancestor', branch2, branch1], cwd=cwd, check=False).returncode == 0) + + +def _git_checkout(branch, *, cwd): + """Check out Git branch.""" + try: + global _GIT_ORIG_BRANCH_CWD + if not _GIT_ORIG_BRANCH_CWD: + _GIT_ORIG_BRANCH_CWD = (_git_get_branch(cwd=cwd), cwd) + logger.info('Checking out branch "%s"...', branch) + _run(['git', 'checkout', branch], cwd=cwd, text=True) + except SubprocessError as e: + raise Error('Failed to check out branch "%s". %s', branch, e) + + +def _git_commit_files(files, message, *, cwd, sign_key=None): + """Commit changes to files or directories.""" + _run(['git', 'reset'], cwd=cwd) + _run(['git', 'add', *files], cwd=cwd) + + if _git_working_dir_clean(cwd=cwd): + logger.info('No changes to commit.') + return + + logger.info('Committing changes...') + commit_args = ['git', 'commit', '--message', message] + if sign_key: + commit_args.extend(['--gpg-sign', sign_key]) + _run(commit_args, cwd=cwd, capture_output=False) + + +def _cleanup(): + """Post-execution cleanup.""" + try: + if _GIT_ORIG_BRANCH_CWD: + _git_checkout(_GIT_ORIG_BRANCH_CWD[0], cwd=_GIT_ORIG_BRANCH_CWD[1]) + return 0 + except Exception as e: + logger.critical('Exception occurred during cleanup:', exc_info=e) + return 1 + + +def _split_version(version): + if type(version) is not str or not re.match(r'^\d+\.\d+\.\d+$', version): + raise Error('Invalid version number: %s', version) + return version.split('.') + + +def _capture_vs_env(arch='amd64'): + """ + Finds a valid Visual Studio environment using vswhere or common install locations. + Run the VS developer batch script in a cmd shell and capture the environment it sets. + Returns a dict suitable for passing as subprocess env or for updating os.environ. + """ + vs_cmd = None + # Try vswhere first, fall back to common install locations + installer_path = Path(os.environ.get('ProgramFiles(x86)')) / 'Microsoft Visual Studio' / 'Installer' + search_path = os.environ.get('PATH') + ';' + str(installer_path) + vswhere = shutil.which('vswhere', path=search_path) + if vswhere: + vsdir = _run([vswhere, '-latest', '-products', '*', '-requires', 'Microsoft.Component.MSBuild', + '-property', 'installationPath'], cwd=None, text=True).stdout.strip() + if vsdir: + cand = Path(vsdir) / 'Common7' / 'Tools' / 'VsDevCmd.bat' + if cand.exists(): + vs_cmd = str(cand) + # Fallback to search common paths + if not vs_cmd: + program_files = [os.environ.get('ProgramFiles(x86)'), os.environ.get('ProgramFiles')] + for pfdir in program_files: + for vs in sorted(Path(pfdir).glob('Microsoft Visual Studio/*/*'), reverse=True): + cand = vs / 'Common7' / 'Tools' / 'VsDevCmd.bat' + if cand.exists(): + vs_cmd = str(cand) + # VS not found, raise error + if not vs_cmd: + raise Error('Visual Studio developer command script not found. Install VS or add vswhere to PATH.') + + logger.info('Using Visual Studio developer command script: %s', vs_cmd) + + # Use cmd.exe to run the batch file and then dump the environment with `set` + try: + out = _run(f'cmd /c "{vs_cmd}" -arch={arch} -no_logo && set', cwd=None, text=True).stdout + except subprocess.CalledProcessError as e: + raise Error('Failed to run Visual Studio dev script: %s', e.output or str(e)) + + env = {} + for line in out.splitlines(): + if len(kv := line.split('=', 1)) == 2: + env[kv[0]] = kv[1] + + # VS has plenty of environment variables, so this is a basic sanity check + if len(env) < 10: + raise Error('Failed to capture environment from Visual Studio dev script.') + + return env + + +########################################################################################### +# CLI Commands +########################################################################################### + + +class Command: + """Command base class.""" + + def __init__(self, arg_parser): + self._arg_parser = arg_parser + + @classmethod + def setup_arg_parser(cls, parser: argparse.ArgumentParser): + raise NotImplementedError('setup_arg_parser not implemented in subclass: %s' % cls.__name__) + + def run(self, **kwargs): + raise NotImplementedError('run not implemented in subclass: %s' % self.__class__.__name__) + + +class Check(Command): + """Perform a pre-merge dry-run check, nothing is changed.""" + + @classmethod + def setup_arg_parser(cls, parser: argparse.ArgumentParser): + parser.add_argument('version', help='Release version number or name.') + parser.add_argument('-s', '--src-dir', help='Source directory.', default='.') + parser.add_argument('-b', '--release-branch', help='Release source branch (default: inferred from --version).') + + def run(self, version, src_dir, release_branch): + if not version: + logger.warning('No version specified, performing only basic checks.') + self.perform_basic_checks(src_dir) + if version: + self.perform_version_checks(version, src_dir, release_branch) + logger.info('All checks passed.') + + @classmethod + def perform_basic_checks(cls, src_dir): + logger.info('Performing basic checks...') + cls.check_src_dir_exists(src_dir) + cls.check_git() + cls.check_git_repository(src_dir) + + logger.info('Checking for required build tools...') + cls.check_git() + cls.check_gnupg() + cls.check_xcode_setup() + + @classmethod + def perform_version_checks(cls, version, src_dir, git_ref=None, version_exists=False, checkout=True): + logger.info('Performing version checks...') + major, minor, patch = _split_version(version) + cls.check_working_tree_clean(src_dir) + if version_exists: + git_ref = git_ref or version + cls.check_release_exists(git_ref, src_dir) + else: + git_ref = git_ref or f'release/{major}.{minor}.x' + cls.check_release_does_not_exist(version, src_dir) + cls.check_branch_exists(git_ref, src_dir) + if checkout: + _git_checkout(git_ref, cwd=src_dir) + logger.debug('Attempting to find "%s" version string in source files...', version) + cls.check_version_in_cmake(version, src_dir) + cls.check_changelog(version, src_dir) + cls.check_app_stream_info(version, src_dir) + + @staticmethod + def check_src_dir_exists(src_dir): + if not src_dir: + raise Error('Empty source directory given.') + if not Path(src_dir).is_dir(): + raise Error(f'Source directory "{src_dir}" does not exist!') + + @staticmethod + def check_git_repository(cwd): + if _run(['git', 'rev-parse', '--is-inside-work-tree'], check=False, cwd=cwd).returncode != 0: + raise Error('Not a valid Git repository: %s', cwd) + + @staticmethod + def check_release_exists(tag_name, cwd): + if not _run(['git', 'tag', '--list', tag_name], check=False, cwd=cwd).stdout: + raise Error('Release tag does not exists: %s', tag_name) + + @staticmethod + def check_release_does_not_exist(tag_name, cwd): + if _run(['git', 'tag', '--list', tag_name], check=False, cwd=cwd).stdout: + raise Error('Release tag already exists: %s', tag_name) + + @staticmethod + def check_working_tree_clean(cwd): + if not _git_working_dir_clean(cwd=cwd): + raise Error('Current working tree is not clean! Please commit or unstage any changes.') + + @staticmethod + def check_branch_exists(branch, cwd): + if _run(['git', 'rev-parse', branch], check=False, cwd=cwd).returncode != 0: + raise Error(f'Branch or tag "{branch}" does not exist!') + + @staticmethod + def check_version_in_cmake(version, cwd): + cmakelists = Path('CMakeLists.txt') + if cwd: + cmakelists = Path(cwd) / cmakelists + if not cmakelists.is_file(): + raise Error('File not found: %s', cmakelists) + cmakelists_text = cmakelists.read_text() + major = re.search(r'^set\(KEEPASSXC_VERSION_MAJOR "(\d+)"\)$', cmakelists_text, re.MULTILINE).group(1) + minor = re.search(r'^set\(KEEPASSXC_VERSION_MINOR "(\d+)"\)$', cmakelists_text, re.MULTILINE).group(1) + patch = re.search(r'^set\(KEEPASSXC_VERSION_PATCH "(\d+)"\)$', cmakelists_text, re.MULTILINE).group(1) + cmake_version = '.'.join([major, minor, patch]) + if cmake_version != version: + raise Error(f'Version number in {cmakelists} not updated! Expected: %s, found: %s.', version, cmake_version) + + @staticmethod + def check_changelog(version, cwd): + changelog = Path('CHANGELOG.md') + if cwd: + changelog = Path(cwd) / changelog + if not changelog.is_file(): + raise Error('File not found: %s', changelog) + major, minor, patch = _split_version(version) + if not re.search(rf'^## {major}\.{minor}\.{patch} \(.+?\)\n+', changelog.read_text(), re.MULTILINE): + raise Error(f'{changelog} has not been updated to the "%s" release.', version) + + @staticmethod + def check_app_stream_info(version, cwd): + appstream = Path('share/linux/org.keepassxc.KeePassXC.appdata.xml') + if cwd: + appstream = Path(cwd) / appstream + if not appstream.is_file(): + raise Error('File not found: %s', appstream) + regex = re.compile(rf'^\s*') + with appstream.open('r', encoding='utf-8') as f: + for line in f: + if regex.search(line): + return + raise Error(f'{appstream} has not been updated to the "%s" release.', version) + + @staticmethod + def check_git(): + if not _cmd_exists('git'): + raise Error('Git not installed.') + + @staticmethod + def check_gnupg(): + if not _cmd_exists('gpg'): + raise Error('GnuPG not installed.') + + @staticmethod + def check_xcode_setup(): + if sys.platform != 'darwin': + return + if not _cmd_exists('xcrun'): + raise Error('xcrun command not found! Please check that you have correctly installed Xcode.') + + +class Merge(Command): + """Merge release branch into main branch and create release tags.""" + + @classmethod + def setup_arg_parser(cls, parser: argparse.ArgumentParser): + parser.add_argument('version', help='Release version number or name.') + parser.add_argument('-s', '--src-dir', help='Source directory.', default='.') + parser.add_argument('-b', '--release-branch', help='Release source branch (default: inferred from version).') + parser.add_argument('-t', '--tag-name', help='Name of tag to create (default: same as version).') + parser.add_argument('-l', '--no-latest', help='Don\'t advance "latest" tag.', action='store_true') + parser.add_argument('-k', '--sign-key', help='PGP key for signing release tags (default: ask).') + parser.add_argument('--no-sign', help='Don\'t sign release tags (for testing only!)', action='store_true') + parser.add_argument('-y', '--yes', help='Bypass confirmation prompts.', action='store_true') + parser.add_argument('--skip-translations', help='Skip pulling translations from Transifex', action='store_true') + parser.add_argument('--tx-resource', help='Transifex resource name.', choices=['master', 'develop']) + parser.add_argument('--tx-min-perc', choices=range(0, 101), metavar='[0-100]', + default=I18N.TRANSIFEX_PULL_PERC, + help='Minimum percent complete for Transifex pull (default: %(default)s).') + + def run(self, version, src_dir, release_branch, tag_name, no_latest, sign_key, no_sign, yes, + skip_translations, tx_resource, tx_min_perc): + major, minor, patch = _split_version(version) + Check.perform_basic_checks(src_dir) + Check.perform_version_checks(version, src_dir, release_branch) + Check.check_gnupg() + sign_key = GPGSign.get_secret_key(sign_key) + + # Update translations + if not skip_translations: + i18n = I18N(self._arg_parser) + i18n.run_tx_pull(src_dir, i18n.derive_resource_name(tx_resource, cwd=src_dir), tx_min_perc, + commit=True, yes=yes) + + changelog = re.search(rf'^## ({major}\.{minor}\.{patch} \(.*?\)\n\n+.+?)\n\n+## ', + (Path(src_dir) / 'CHANGELOG.md').read_text(), re.MULTILINE | re.DOTALL) + if not changelog: + raise Error(f'No changelog entry found for version {version}.') + changelog = 'Release ' + changelog.group(1) + + tag_name = tag_name or version + logger.info('Creating "%s" tag...', tag_name) + tag_cmd = ['git', 'tag', '--annotate', tag_name, '--message', changelog] + if not no_sign: + tag_cmd.extend(['--sign', '--local-user', sign_key]) + _run(tag_cmd, cwd=src_dir) + + if not no_latest: + logger.info('Advancing "latest" tag...') + tag_cmd = ['git', 'tag', '--annotate', 'latest', '--message', 'Latest stable release', '--force'] + if not no_sign: + tag_cmd.extend(['--sign', '--local-user', sign_key]) + _run(tag_cmd, cwd=src_dir) + + log_msg = ('All done! Don\'t forget to push the release branch and the new tags:\n' + f' {fmt.bold(f'git push origin {release_branch}')}\n' + f' {fmt.bold(f'git push origin tag {tag_name}')}') + if not no_latest: + log_msg += f'\n {fmt.bold('git push origin tag latest --force')}' + logger.info(log_msg) + + +class Build(Command): + """Build and package binary release from sources.""" + + @classmethod + def setup_arg_parser(cls, parser: argparse.ArgumentParser): + parser.add_argument('version', help='Release version number or name.') + parser.add_argument('-s', '--src-dir', help='Source directory.', default='.') + parser.add_argument('-t', '--tag-name', help='Name of the tag to check out (default: same as version).') + parser.add_argument('-o', '--output-dir', default='release', + help='Build output directory (default: %(default)s.') + parser.add_argument('-g', '--cmake-generator', help='Override default CMake generator.') + parser.add_argument('-i', '--install-prefix', default='/usr/local', + help='Build install prefix (default: %(default)s).') + parser.add_argument('--snapshot', help='Build snapshot from current HEAD.', action='store_true') + parser.add_argument('--use-system-deps', help='Use system dependencies instead of vcpkg.', action='store_true') + parser.add_argument('-j', '--parallelism', default=os.cpu_count(), type=int, + help='Build parallelism (default: %(default)s).') + parser.add_argument('-y', '--yes', help='Bypass confirmation prompts.', action='store_true') + parser.add_argument('--with-tests', help='Build and run tests.', action='store_true') + + if sys.platform == 'darwin': + parser.add_argument('--macos-target', default=12, metavar='MACOSX_DEPLOYMENT_TARGET', + help='macOS deployment target version (default: %(default)s).') + parser.add_argument('-p', '--platform-target', default=platform.uname().machine, + help='Build target platform (default: %(default)s).', choices=['x86_64', 'arm64']) + elif sys.platform == 'linux': + parser.add_argument('-d', '--docker-image', help='Run build in Docker image (overrides --use-system-deps).') + parser.add_argument('-p', '--platform-target', help='Build target platform (default: %(default)s).', + choices=['x86_64', 'aarch64'], default=platform.uname().machine) + parser.add_argument('-a', '--appimage', help='Build an AppImage.', action='store_true') + elif sys.platform == 'win32': + parser.add_argument('-p', '--platform-target', help='Build target platform (default: %(default)s).', + choices=['amd64', 'arm64'], default='amd64') + parser.add_argument('--sign', help='Sign binaries prior to packaging.', action='store_true') + parser.add_argument('--sign-cert', help='SHA1 fingerprint of the signing certificate (optional).') + parser.set_defaults(cmake_generator='Ninja', no_source_tarball=True) + + parser.add_argument('-c', '--cmake-opts', nargs=argparse.REMAINDER, + help='Additional CMake options (no other arguments can be specified after this).') + + def run(self, version, src_dir, output_dir, tag_name, snapshot, cmake_generator, yes, with_tests, **kwargs): + Check.perform_basic_checks(src_dir) + src_dir = Path(src_dir).resolve() + output_dir = Path(output_dir) + if output_dir.exists(): + logger.warning(f'Output directory "{output_dir}" already exists.') + if not yes and not _yes_no_prompt('Reuse existing output directory?'): + raise Error('Build aborted!') + else: + logger.debug('Creating output directory...') + output_dir.mkdir(parents=True) + + tag_name = tag_name or version + kwargs['with_tests'] = with_tests + with_tests = 'ON' if with_tests else 'OFF' + cmake_opts = [ + '-DWITH_XC_ALL=ON', + '-DCMAKE_BUILD_TYPE=Release', + '-DCMAKE_INSTALL_PREFIX=' + kwargs['install_prefix'], + '-DWITH_TESTS=' + with_tests, + '-DWITH_GUI_TESTS=' + with_tests, + ] + + if not kwargs['use_system_deps'] and not kwargs.get('docker_image'): + cmake_opts.append(f'-DCMAKE_TOOLCHAIN_FILE={self._get_vcpkg_toolchain_file()}') + + if snapshot: + logger.info('Building a snapshot from HEAD.') + try: + Check.check_version_in_cmake(version, src_dir) + except Error as e: + logger.warning(e.msg, *e.args) + cmake_opts.append(f'-DOVERRIDE_VERSION={version}-snapshot') + cmake_opts.append('-DKEEPASSXC_BUILD_TYPE=Snapshot') + version += '-snapshot' + else: + Check.perform_version_checks(version, src_dir, tag_name, version_exists=True, checkout=True) + cmake_opts.append('-DKEEPASSXC_BUILD_TYPE=Release') + + if cmake_generator: + cmake_opts.extend(['-G', cmake_generator]) + kwargs['cmake_opts'] = cmake_opts + (kwargs['cmake_opts'] or []) + + if sys.platform == 'win32': + return self.build_windows(version, src_dir, output_dir, **kwargs) + if sys.platform == 'darwin': + return self.build_macos(version, src_dir, output_dir, **kwargs) + if sys.platform == 'linux': + return self.build_linux(version, src_dir, output_dir, **kwargs) + raise Error('Unsupported build platform: %s', sys.platform) + + @staticmethod + def _get_vcpkg_toolchain_file(path=None): + vcpkg = shutil.which('vcpkg', path=path) + if not vcpkg: + # Check the VCPKG_ROOT environment variable + if 'VCPKG_ROOT' in os.environ: + vcpkg = Path(os.environ['VCPKG_ROOT']) / 'vcpkg' + else: + raise Error('vcpkg not found in PATH (use --use-system-deps to build with system dependencies instead).') + toolchain = Path(vcpkg).parent / 'scripts' / 'buildsystems' / 'vcpkg.cmake' + if not toolchain.is_file(): + raise Error('Toolchain file not found in vcpkg installation directory.') + return toolchain.resolve() + + @staticmethod + def _run_tests(cwd, ctest_cmd='ctest', parallelism=4): + logger.info('Running tests...') + _run([ctest_cmd, '-E', 'gui|cli', '--output-on-failure', '-j', str(parallelism)], cwd=cwd, capture_output=False) + _run([ctest_cmd, '-R', 'gui|cli', '--output-on-failure'], cwd=cwd, capture_output=False) + + # noinspection PyMethodMayBeStatic + def build_windows(self, version, src_dir, output_dir, *, parallelism, cmake_opts, platform_target, + sign, sign_cert, with_tests, **_): + # Check for required tools + if not _cmd_exists('candle.exe') or not _cmd_exists('light.exe') or not _cmd_exists('heat.exe'): + raise Error('WiX Toolset not found on the PATH (candle.exe, light.exe, heat.exe).') + + # Setup build signing if requested + if sign: + cmake_opts.append('-DWITH_XC_SIGNINSTALL=ON') + cmake_opts.append(f'-DWITH_XC_SIGNINSTALL_CERT={sign_cert}') + # Use vcpkg for dependency deployment + cmake_opts.append('-DX_VCPKG_APPLOCAL_DEPS_INSTALL=ON') + + # Find Visual Studio and capture build environment + vs_env = _capture_vs_env(arch=platform_target) + + # Use vs_env to resolve common tools + cmake_cmd = shutil.which('cmake', path=vs_env.get('PATH')) + cpack_cmd = shutil.which('cpack', path=vs_env.get('PATH')) + ctest_cmd = shutil.which('ctest', path=vs_env.get('PATH')) + + # Start the build + with tempfile.TemporaryDirectory() as build_dir: + logger.info('Configuring build...') + _run([cmake_cmd, *cmake_opts, str(src_dir)], cwd=build_dir, env=vs_env, capture_output=False) + + logger.info('Compiling sources...') + _run([cmake_cmd, '--build', '.', f'--parallel', str(parallelism)], cwd=build_dir, env=vs_env, capture_output=False) + + if with_tests: + self._run_tests(cwd=build_dir, ctest_cmd=ctest_cmd) + + logger.info('Packaging application...') + _run([cpack_cmd, '-G', 'ZIP;WIX'], cwd=build_dir, env=vs_env, capture_output=False) + + artifacts = list(Path(build_dir).glob("*.zip")) + list(Path(build_dir).glob("*.msi")) + for artifact in artifacts: + artifact.replace(output_dir / artifact.name) + logger.info(f'Created artifact: {output_dir / artifact.name}') + + # noinspection PyMethodMayBeStatic + def build_macos(self, version, src_dir, output_dir, *, use_system_deps, parallelism, cmake_opts, + macos_target, platform_target, with_tests, **_): + if not use_system_deps: + cmake_opts.append(f'-DVCPKG_TARGET_TRIPLET={platform_target.replace("86_", "")}-osx-dynamic-release') + cmake_opts.append(f'-DCMAKE_OSX_DEPLOYMENT_TARGET={macos_target}') + cmake_opts.append(f'-DCMAKE_OSX_ARCHITECTURES={platform_target}') + + with tempfile.TemporaryDirectory() as build_dir: + logger.info('Configuring build...') + _run(['cmake', *cmake_opts, str(src_dir)], cwd=build_dir, capture_output=False) + + logger.info('Compiling sources...') + _run(['cmake', '--build', '.', f'--parallel', str(parallelism)], cwd=build_dir, capture_output=False) + + if with_tests: + self._run_tests(cwd=build_dir, parallelism=parallelism) + + logger.info('Packaging application...') + _run(['cpack', '-G', 'DragNDrop'], cwd=build_dir, capture_output=False) + + output_file = Path(build_dir) / f'KeePassXC-{version}.dmg' + output_file.rename(output_dir / f'KeePassXC-{version}-{platform_target}-unsigned.dmg') + + logger.info('All done! Please don\'t forget to sign the binaries before distribution.') + + @staticmethod + def _download_tools_if_not_available(toolname, bin_dir, url, docker_args=None): + if _run(['which', toolname], cwd=None, check=False, **(docker_args or {})).returncode != 0: + logger.info(f'Downloading {toolname}...') + outfile = bin_dir / toolname + urlretrieve(url, outfile) + outfile.chmod(outfile.stat().st_mode | stat.S_IEXEC) + + def build_linux(self, version, src_dir, output_dir, *, install_prefix, parallelism, cmake_opts, use_system_deps, + platform_target, appimage, docker_image, with_tests, **_): + if use_system_deps and platform_target != platform.uname().machine and not docker_image: + raise Error('Need --docker-image for cross-platform compilation when not building with vcpkg!') + + docker_args = dict( + docker_image=docker_image, + docker_mounts=[src_dir], + docker_platform=f'linux/{platform_target}', + ) + if docker_image: + logger.info('Pulling Docker image...') + _run(['docker', 'pull', f'--platform=linux/{platform_target}', docker_image], + cwd=None, capture_output=False) + + if appimage: + cmake_opts.append('-DKEEPASSXC_DIST_TYPE=AppImage') + + with tempfile.TemporaryDirectory() as build_dir: + logger.info('Configuring build...') + _run(['cmake', *cmake_opts, str(src_dir)], cwd=build_dir, capture_output=False, **docker_args) + + logger.info('Compiling sources...') + _run(['cmake', '--build', '.', '--parallel', str(parallelism)], + cwd=build_dir, capture_output=False, **docker_args) + + if with_tests: + self._run_tests(cwd=build_dir, parallelism=parallelism) + + logger.info('Bundling AppDir...') + app_dir = Path(build_dir) / f'KeePassXC-{version}-{platform_target}.AppDir' + _run(['cmake', '--install', '.', '--strip', + '--prefix', (app_dir.absolute() / install_prefix.lstrip('/')).as_posix()], + cwd=build_dir, capture_output=False, **docker_args) + shutil.copytree(app_dir, output_dir / app_dir.name, symlinks=True) + + if appimage: + self._build_linux_appimage( + version, src_dir, output_dir, app_dir, build_dir, install_prefix, platform_target, docker_args) + + def _build_linux_appimage(self, version, src_dir, output_dir, app_dir, build_dir, install_prefix, + platform_target, docker_args): + if (app_dir / 'AppRun').exists(): + raise Error('AppDir has already been run through linuxdeploy! Please create a fresh AppDir and try again.') + + bin_dir = Path(build_dir) / 'bin' + bin_dir.mkdir() + self._download_tools_if_not_available( + 'linuxdeploy', bin_dir, + 'https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/' + + f'linuxdeploy-{platform_target}.AppImage', + docker_args) + self._download_tools_if_not_available( + 'linuxdeploy-plugin-qt', bin_dir, + 'https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/' + + f'linuxdeploy-plugin-qt-{platform_target}.AppImage', + docker_args) + self._download_tools_if_not_available( + 'appimagetool', bin_dir, + 'https://github.com/AppImage/AppImageKit/releases/download/continuous/' + + f'appimagetool-{platform_target}.AppImage', + docker_args) + + env_path = ':'.join([bin_dir.as_posix(), _get_bin_path()]) + install_prefix = app_dir / install_prefix.lstrip('/') + desktop_file = install_prefix / 'share/applications/org.keepassxc.KeePassXC.desktop' + icon_file = install_prefix / 'share/icons/hicolor/256x256/apps/keepassxc.png' + executables = (install_prefix / 'bin').glob('keepassxc*') + app_run = src_dir / 'share/linux/appimage-apprun.sh' + + logger.info('Building AppImage...') + logger.debug('Running linuxdeploy...') + _run(['linuxdeploy', '--plugin=qt', f'--appdir={app_dir}', f'--custom-apprun={app_run}', + f'--desktop-file={desktop_file}', f'--icon-file={icon_file}', + *[f'--executable={ex}' for ex in executables]], + cwd=build_dir, capture_output=False, path=env_path, **docker_args) + + logger.debug('Running appimagetool...') + appimage_name = f'KeePassXC-{version}-{platform_target}.AppImage' + desktop_file.write_text(desktop_file.read_text().strip() + f'\nX-AppImage-Version={version}\n') + _run(['appimagetool', '--updateinformation=gh-releases-zsync|keepassxreboot|keepassxc|latest|' + + f'KeePassXC-*-{platform_target}.AppImage.zsync', + app_dir.as_posix(), (output_dir.absolute() / appimage_name).as_posix()], + cwd=build_dir, capture_output=False, path=env_path, **docker_args, docker_privileged=True) + + +class BuildSrc(Command): + """Build and package source tarball.""" + + @classmethod + def setup_arg_parser(cls, parser: argparse.ArgumentParser): + parser.add_argument('version', help='Release version number or name.') + parser.add_argument('-s', '--src-dir', help='Source directory.', default='.') + parser.add_argument('-t', '--tag-name', help='Name of the tag to check out (default: same as version).') + parser.add_argument('-o', '--output-dir', default='release', + help='Build output directory (default: %(default)s.') + parser.add_argument('-y', '--yes', help='Bypass confirmation prompts.', action='store_true') + + def run(self, version, src_dir, output_dir, tag_name, yes, **kwargs): + Check.perform_basic_checks(src_dir) + src_dir = Path(src_dir).resolve() + output_dir = Path(output_dir) + if output_dir.exists(): + logger.warning(f'Output directory "{output_dir}" already exists.') + if not yes and not _yes_no_prompt('Reuse existing output directory?'): + raise Error('Build aborted!') + + logger.info('Exporting sources...') + prefix = f'keepassxc-{version}' + output_file = Path(output_dir) / f'{prefix}-src.tar.xz' + tag_name = tag_name or version + + with tempfile.TemporaryDirectory() as tmp: + # Export sources to temporary tarball + tmp = Path(tmp) + tmp_export = tmp / 'export.tar' + _run(['git', 'archive', '--format=tar', f'--prefix={prefix}/', f'--output={tmp_export}', tag_name], + cwd=src_dir) + + # Append .version and .gitrev files to tarball + fver = tmp / '.version' + fver.write_text(version) + frev = tmp / '.gitrev' + git_rev = _run(['git', 'rev-parse', '--short=7', tag_name], cwd=src_dir, text=True).stdout.strip() + frev.write_text(git_rev) + with tarfile.open(tmp_export, 'a') as tf: + tf.add(fver, Path(prefix) / fver.name) + tf.add(frev, Path(prefix) / frev.name) + + logger.info('Compressing source tarball...') + tmp_comp = tmp_export.with_suffix('.tar.xz') + with lzma.open(tmp_comp, 'wb', preset=6) as f: + f.write(tmp_export.read_bytes()) + tmp_comp.rename(output_file) + + +class AppSign(Command): + """Sign binaries with code signing certificates on Windows and macOS.""" + + @classmethod + def setup_arg_parser(cls, parser: argparse.ArgumentParser): + parser.add_argument('file', help='Input file(s) to sign.', nargs='+') + parser.add_argument('-i', '--identity', help='Key or identity used for the signature (default: ask).') + parser.add_argument('-s', '--src-dir', help='Source directory (default: %(default)s).', default='.') + + if sys.platform == 'darwin': + parser.add_argument('-n', '--notarize', help='Notarize signed file(s).', action='store_true') + parser.add_argument('-c', '--keychain-profile', default='notarization-creds', + help='Read Apple credentials for notarization from a keychain (default: %(default)s).') + + def run(self, file, identity, src_dir, **kwargs): + for i, f in enumerate(file): + f = Path(f) + if not f.exists(): + raise Error('Input file does not exist: %s', f) + file[i] = f + + if sys.platform == 'win32': + for f in file: + self.sign_windows(f, identity, Path(src_dir)) + + elif sys.platform == 'darwin': + Check.check_xcode_setup() + if kwargs['notarize']: + self._macos_validate_keychain_profile(kwargs['keychain_profile']) + identity = self._macos_get_codesigning_identity(identity) + for f in file: + out_file = self.sign_macos(f, identity, Path(src_dir)) + if out_file and kwargs['notarize'] and out_file.suffix == '.dmg': + self.notarize_macos(out_file, kwargs['keychain_profile']) + + else: + raise Error('Unsupported platform.') + + logger.info('All done.') + + # noinspection PyMethodMayBeStatic + def sign_windows(self, file, identity, src_dir): + # Check for signtool + if not _cmd_exists('signtool.exe'): + raise Error('signtool was not found on the PATH.') + + signtool_args = ['signtool', 'sign', '/fd', 'sha256', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256'] + if not identity: + logger.info('Using automatic selection of signing certificate.') + signtool_args += ['/a'] + else: + logger.info('Using specified signing certificate: %s', identity) + signtool_args += ['/sha1', identity] + signtool_args += ['/d', file.name, str(file.resolve())] + + _run(signtool_args, cwd=src_dir, capture_output=False) + + # noinspection PyMethodMayBeStatic + def _macos_validate_keychain_profile(self, keychain_profile): + if _run(['security', 'find-generic-password', '-a', + f'com.apple.gke.notary.tool.saved-creds.{keychain_profile}'], cwd=None, check=False).returncode != 0: + raise Error(f'Keychain profile "%s" not found! Run\n' + f' {fmt.bold('xcrun notarytool store-credentials %s [...]' % keychain_profile)}\n' + f'to store your Apple notary service credentials in a keychain as "%s".', + keychain_profile, keychain_profile) + + # noinspection PyMethodMayBeStatic + def _macos_get_codesigning_identity(self, user_choice=None): + result = _run(['security', 'find-identity', '-v', '-p', 'codesigning'], cwd=None, text=True) + identities = [l.strip() for l in result.stdout.strip().split('\n')[:-1]] + identities = [i.split(' ', 2)[1:] for i in identities] + if not identities: + raise Error('No codesigning identities found.') + + if not user_choice and len(identities) == 1: + logger.info('Using codesigning identity %s.', identities[0][1]) + return identities[0][0] + elif not user_choice: + return identities[_choice_prompt( + 'The following code signing identities were found. Which one do you want to use?', + [' '.join(i) for i in identities])][0] + else: + for i in identities: + # Exact match of ID or substring match of description + if user_choice == i[0] or user_choice in i[1]: + return i[0] + raise Error('Invalid identity: %s', user_choice) + + # noinspection PyMethodMayBeStatic + def sign_macos(self, file, identity, src_dir): + logger.info('Signing "%s"', file) + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp).absolute() + app_dir = tmp / 'app' + out_file = file.parent / file.name.replace('-unsigned', '') + + if file.is_file() and file.suffix == '.dmg': + logger.debug('Unpacking disk image...') + mnt = tmp / 'mnt' + mnt.mkdir() + try: + _run(['hdiutil', 'attach', '-noautoopen', '-mountpoint', mnt.as_posix(), file.as_posix()], cwd=None) + shutil.copytree(mnt, app_dir, symlinks=True) + finally: + _run(['hdiutil', 'detach', mnt.as_posix()], cwd=None) + elif file.is_dir() and file.suffix == '.app': + logger.debug('Copying .app directory...') + shutil.copytree(file, app_dir, symlinks=True) + else: + logger.warning('Skipping non-app file "%s"', file) + return None + + app_dir_app = list(app_dir.glob('*.app'))[0] + + logger.debug('Signing libraries and frameworks...') + _run(['xcrun', 'codesign', f'--sign={identity}', '--force', '--options=runtime', '--deep', + app_dir_app.as_posix()], cwd=None) + + # (Re-)Sign main executable with --entitlements + logger.debug('Signing main executable...') + _run(['xcrun', 'codesign', f'--sign={identity}', '--force', '--options=runtime', + '--entitlements', (src_dir / 'share/macosx/keepassxc.entitlements').as_posix(), + (app_dir_app / 'Contents/MacOS/KeePassXC').as_posix()], cwd=None) + + tmp_out = out_file.with_suffix(f'.{"".join(random.choices(string.ascii_letters, k=8))}{file.suffix}') + try: + if file.suffix == '.dmg': + logger.debug('Repackaging disk image...') + dmg_size = sum(f.stat().st_size for f in app_dir.rglob('*')) + _run(['hdiutil', 'create', '-volname', 'KeePassXC', '-srcfolder', app_dir.as_posix(), + '-fs', 'HFS+', '-fsargs', '-c c=64,a=16,e=16', '-format', 'UDBZ', + '-size', f'{dmg_size}k', tmp_out.as_posix()], + cwd=None) + elif file.suffix == '.app': + shutil.copytree(app_dir, tmp_out, symlinks=True) + except: + if tmp_out.is_file(): + tmp_out.unlink() + elif tmp_out.is_dir(): + shutil.rmtree(tmp_out, ignore_errors=True) + raise + finally: + # Replace original file if all went well + if tmp_out.exists(): + if tmp_out.is_dir(): + shutil.rmtree(file) + else: + file.unlink() + tmp_out.rename(out_file) + + logger.info('File signed successfully and written to: "%s".', out_file) + return out_file + + # noinspection PyMethodMayBeStatic + def notarize_macos(self, file, keychain_profile): + logger.info('Submitting "%s" for notarization...', file) + _run(['xcrun', 'notarytool', 'submit', f'--keychain-profile={keychain_profile}', '--wait', + file.as_posix()], cwd=None, capture_output=False) + + logger.debug('Stapling notarization ticket...') + _run(['xcrun', 'stapler', 'staple', file.as_posix()], cwd=None) + _run(['xcrun', 'stapler', 'validate', file.as_posix()], cwd=None) + + logger.info('Notarization successful.') + + +class GPGSign(Command): + """Sign previously compiled release packages with GPG.""" + + @classmethod + def setup_arg_parser(cls, parser: argparse.ArgumentParser): + parser.add_argument('file', help='Input file(s) to sign', nargs='+') + parser.add_argument('-k', '--gpg-key', help='GnuPG key for signing input files (default: ask).') + + @staticmethod + def get_secret_key(user_choice): + keys = _run(['gpg', '--list-secret-keys', '--keyid-format=long'], cwd=None, text=True) + keys = re.findall(r'^sec#?\s+(.+?/[A-F0-9]+) .+?\n\s+(.+?)\nuid .+?] (.+?)\n', keys.stdout, re.MULTILINE) + if not keys: + raise Error('No secret keys found!') + + if not user_choice and len(keys) == 1: + logger.info('Using secret key %s %s.', keys[0][0], keys[0][2]) + return keys[0][1] + elif not user_choice: + return keys[_choice_prompt( + 'The following secret keys were found. Which one do you want to use?', + [' '.join([k[0], k[2]]) for k in keys])][1] + else: + for i in keys: + if user_choice in i[1] or user_choice in i[2]: + return i[1] + raise Error('Invalid key ID: %s', user_choice) + + def run(self, file, gpg_key): + Check.check_gnupg() + + for i, f in enumerate(file): + f = Path(f) + if not f.is_file(): + raise Error('File "%s" does not exist or is not a file!', f) + file[i] = f + + key_id = self.get_secret_key(gpg_key) + for f in file: + logger.info('Signing "%s"...', f) + _run(['gpg', '--armor', f'--local-user={key_id}', '--detach-sig', + f'--output={f.with_suffix(f.suffix + ".sig")}', str(f)], cwd=None) + + logger.info('Creating digest file...') + h = hashlib.sha256(f.read_bytes()).hexdigest() + f.with_suffix(f.suffix + '.DIGEST').write_text(f'{h} {f.name}\n') + + logger.info('All done.') + + +class I18N(Command): + """Update translation files and pull from or push to Transifex.""" + + TRANSIFEX_RESOURCE = 'keepassxc.share-translations-keepassxc-en-ts--{}' + TRANSIFEX_PULL_PERC = 60 + + @classmethod + def setup_arg_parser(cls, parser: argparse.ArgumentParser): + parser.add_argument('-s', '--src-dir', help='Source directory.', default='.') + parser.add_argument('-b', '--branch', help='Branch to operate on.') + + subparsers = parser.add_subparsers(title='Subcommands', dest='subcmd') + push = subparsers.add_parser('tx-push', help='Push source translation file to Transifex.') + push.add_argument('-r', '--resource', help='Transifex resource name.', choices=['master', 'develop']) + push.add_argument('-y', '--yes', help='Don\'t ask before pushing source file.', action='store_true') + push.add_argument('tx_args', help='Additional arguments to pass to tx subcommand.', nargs=argparse.REMAINDER) + + pull = subparsers.add_parser('tx-pull', help='Pull updated translations from Transifex.') + pull.add_argument('-r', '--resource', help='Transifex resource name.', choices=['master', 'develop']) + pull.add_argument('-m', '--min-perc', help='Minimum percent complete for pull (default: %(default)s).', + choices=range(0, 101), metavar='[0-100]', default=cls.TRANSIFEX_PULL_PERC) + pull.add_argument('-c', '--commit', help='Commit changes.', action='store_true') + pull.add_argument('-y', '--yes', help='Don\'t ask before pulling translations.', action='store_true') + pull.add_argument('tx_args', help='Additional arguments to pass to tx subcommand.', nargs=argparse.REMAINDER) + + lupdate = subparsers.add_parser('lupdate', help='Update source translation file from C++ sources.') + lupdate.add_argument('-d', '--build-dir', help='Build directory for looking up lupdate binary.') + lupdate.add_argument('-c', '--commit', help='Commit changes.', action='store_true') + lupdate.add_argument('lupdate_args', help='Additional arguments to pass to lupdate subcommand.', + nargs=argparse.REMAINDER) + + @staticmethod + def check_transifex_cmd_exists(): + if not _cmd_exists('tx'): + raise Error(f'Transifex tool "tx" is not installed! Installation instructions: ' + f'{fmt.bold('https://developers.transifex.com/docs/cli')}.') + + @staticmethod + def check_transifex_config_exists(src_dir): + if not (Path(src_dir) / '.tx' / 'config').is_file(): + raise Error('No Transifex config found in source dir.') + if not (Path.home() / '.transifexrc').is_file(): + raise Error('Transifex API key not configured. Run "tx status" first.') + + @staticmethod + def check_lupdate_exists(path): + if _cmd_exists('lupdate', path=path): + result = _run(['lupdate', '-version'], path=path, check=False, cwd=None, text=True) + if result.returncode == 0 and result.stdout.startswith('lupdate version 5.'): + return + raise Error('lupdate command not found. Make sure it is installed and the correct version.') + + def run(self, subcmd, src_dir, branch, **kwargs): + if not subcmd: + logger.error('No subcommand specified.') + self._arg_parser.parse_args(['i18n', '--help']) + + Check.perform_basic_checks(src_dir) + if branch: + Check.check_working_tree_clean(src_dir) + Check.check_branch_exists(branch, src_dir) + _git_checkout(branch, cwd=src_dir) + + if subcmd.startswith('tx-'): + self.check_transifex_cmd_exists() + self.check_transifex_config_exists(src_dir) + + kwargs['resource'] = self.derive_resource_name(kwargs['resource'], cwd=src_dir) + kwargs['tx_args'] = kwargs['tx_args'][1:] + if subcmd == 'tx-push': + self.run_tx_push(src_dir, **kwargs) + elif subcmd == 'tx-pull': + self.run_tx_pull(src_dir, **kwargs) + + elif subcmd == 'lupdate': + kwargs['lupdate_args'] = kwargs['lupdate_args'][1:] + self.run_lupdate(src_dir, **kwargs) + + # noinspection PyMethodMayBeStatic + def derive_resource_name(self, override_resource=None, *, cwd): + if override_resource: + res = override_resource + elif _git_branches_related('develop', 'HEAD', cwd=cwd): + logger.info(f'Branch derives from develop, using {fmt.bold('"develop"')} resource.') + res = 'develop' + else: + logger.info(f'Release branch, using {fmt.bold('"master"')} resource.') + res = 'master' + return self.TRANSIFEX_RESOURCE.format(res) + + # noinspection PyMethodMayBeStatic + def run_tx_push(self, src_dir, resource, yes, tx_args): + sys.stderr.write(f'\nAbout to push the {fmt.bold('"en"')} source file from the ' + f'current branch to Transifex:\n') + sys.stderr.write(f' {fmt.bold(_git_get_branch(cwd=src_dir))}' + f' -> {fmt.bold(resource)}\n') + if not yes and not _yes_no_prompt('Continue?'): + logger.error('Push aborted.') + return + logger.info('Pushing source file to Transifex...') + _run(['tx', 'push', '--source', '--use-git-timestamps', *tx_args, resource], + cwd=src_dir, capture_output=False) + logger.info('Push successful.') + + # noinspection PyMethodMayBeStatic + def run_tx_pull(self, src_dir, resource, min_perc, commit=False, yes=False, tx_args=None): + sys.stderr.write(f'\nAbout to pull translations for {fmt.bold(f'"{resource}"')}.\n') + if not yes and not _yes_no_prompt('Continue?'): + logger.error('Pull aborted.') + return + logger.info('Pulling translations from Transifex...') + tx_args = tx_args or [] + _run(['tx', 'pull', '--all', '--use-git-timestamps', f'--minimum-perc={min_perc}', *tx_args, resource], + cwd=src_dir, capture_output=False) + logger.info('Pull successful.') + files = [f.relative_to(src_dir) for f in Path(src_dir).glob('share/translations/*.ts')] + if commit: + _git_commit_files(files, 'Update translations.', cwd=src_dir) + + def run_lupdate(self, src_dir, build_dir=None, commit=False, lupdate_args=None): + path = _get_bin_path(build_dir) + self.check_lupdate_exists(path) + logger.info('Updating translation source files from C++ sources...') + _run(['lupdate', '-no-ui-lines', '-disable-heuristic', 'similartext', '-locations', 'none', + '-extensions', 'c,cpp,h,js,mm,qrc,ui', '-no-obsolete', 'src', + '-ts', str(Path(f'share/translations/keepassxc_en.ts')), *(lupdate_args or [])], + cwd=src_dir, path=path, capture_output=False) + logger.info('Translation source files updated.') + if commit: + _git_commit_files([f'share/translations/keepassxc_en.ts'], + 'Update translation sources.', cwd=src_dir) + + +########################################################################################### +# CLI Main +########################################################################################### + + +def main(): + if sys.platform == 'win32': + # Enable terminal colours + ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7) + + sys.stderr.write(fmt.bold(f'{fmt.green('KeePassXC')} Release Preparation Tool\n')) + sys.stderr.write(f'Copyright (C) 2016-{datetime.now().year} KeePassXC Team \n\n') + + parser = argparse.ArgumentParser(add_help=True) + subparsers = parser.add_subparsers(title='Commands') + + check_parser = subparsers.add_parser('check', help=Check.__doc__) + Check.setup_arg_parser(check_parser) + check_parser.set_defaults(_cmd=Check) + + merge_parser = subparsers.add_parser('merge', help=Merge.__doc__) + Merge.setup_arg_parser(merge_parser) + merge_parser.set_defaults(_cmd=Merge) + + build_parser = subparsers.add_parser('build', help=Build.__doc__) + Build.setup_arg_parser(build_parser) + build_parser.set_defaults(_cmd=Build) + + build_src_parser = subparsers.add_parser('build-src', help=BuildSrc.__doc__) + BuildSrc.setup_arg_parser(build_src_parser) + build_src_parser.set_defaults(_cmd=BuildSrc) + + appsign_parser = subparsers.add_parser('appsign', help=AppSign.__doc__) + AppSign.setup_arg_parser(appsign_parser) + appsign_parser.set_defaults(_cmd=AppSign) + + gpgsign_parser = subparsers.add_parser('gpgsign', help=GPGSign.__doc__) + GPGSign.setup_arg_parser(gpgsign_parser) + gpgsign_parser.set_defaults(_cmd=GPGSign) + + i18n_parser = subparsers.add_parser('i18n', help=I18N.__doc__) + I18N.setup_arg_parser(i18n_parser) + i18n_parser.set_defaults(_cmd=I18N) + + args = parser.parse_args() + if '_cmd' not in args: + parser.print_help() + return 1 + return args._cmd(parser).run(**{k: v for k, v in vars(args).items() if k != '_cmd'}) or 0 + + +def _sig_handler(_, __): + logger.error('Process interrupted.') + sys.exit(3 | _cleanup()) + + +signal.signal(signal.SIGINT, _sig_handler) +signal.signal(signal.SIGTERM, _sig_handler) + +if __name__ == '__main__': + ret = 0 + try: + ret = main() + except Error as e: + logger.error(e.msg, *e.args, extra=e.kwargs) + ret = e.kwargs.get('returncode', 1) + except KeyboardInterrupt: + logger.error('Process interrupted.') + ret = 3 + except Exception as e: + logger.critical('Unhandled exception:', exc_info=e) + ret = 4 + except SystemExit as e: + ret = e.code + finally: + ret |= _cleanup() + sys.exit(ret) diff --git a/share/linux/appimage-apprun.sh b/share/linux/appimage-apprun.sh new file mode 100755 index 0000000000..82d8a56101 --- /dev/null +++ b/share/linux/appimage-apprun.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +if [ "$1" == "cli" ] || [ "$(basename "$ARGV0")" == "keepassxc-cli" ] || [ "$(basename "$ARGV0")" == "keepassxc-cli.AppImage" ]; then + [ "$1" == "cli" ] && shift + exec keepassxc-cli "$@" +elif [ "$1" == "proxy" ] || [ "$(basename "$ARGV0")" == "keepassxc-proxy" ] || [ "$(basename "$ARGV0")" == "keepassxc-proxy.AppImage" ] \\ + || [ -v CHROME_WRAPPER ] || [ -v MOZ_LAUNCHED_CHILD ]; then + [ "$1" == "proxy" ] && shift + exec keepassxc-proxy "$@" +else + exec keepassxc "$@" +fi diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 11de7e4ba1..fcc29784d7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -482,8 +482,9 @@ if(WIN32) "${CMAKE_SOURCE_DIR}/LICENSE.GPL-2" "${CMAKE_CURRENT_BINARY_DIR}/INSTALLER_LICENSE.txt") - # Prepare portal zip file - set(CPACK_INSTALL_SCRIPTS "${CMAKE_SOURCE_DIR}/cmake/MakePortableZip.cmake") + # Prepare post-install script and set to run prior to building cpack installers + configure_file("${CMAKE_SOURCE_DIR}/cmake/WindowsPostInstall.cmake.in" "${CMAKE_BINARY_DIR}/WindowsPostInstall.cmake" @ONLY) + set(CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/WindowsPostInstall.cmake") string(REGEX REPLACE "-.*$" "" KEEPASSXC_VERSION_CLEAN ${KEEPASSXC_VERSION}) diff --git a/vcpkg.json b/vcpkg.json index 8351862b4a..f055407a2d 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,7 +1,7 @@ { "name": "keepassxc", "version-string": "2.8.0", - "builtin-baseline": "f9c128a6f7a96f5fa2f6f089f37ed6788a90e4b0", + "builtin-baseline": "74e6536215718009aae747d86d84b78376bf9e09", "dependencies": [ { "name": "argon2", @@ -36,37 +36,22 @@ }, { "name": "qt5", - "version>=": "5.15.11" + "version>=": "5.15.17", + "features": [ + "declarative", + "extras", + "imageformats", + "svg", + "tools", + "translations" + ], + "default-features": false }, { - "name": "qt5-imageformats", - "version>=": "5.15.11" - }, - { - "name": "qt5-macextras", - "version>=": "5.15.11", - "platform": "osx" - }, - { - "name": "qt5-svg", - "version>=": "5.15.11" - }, - { - "name": "qt5-tools", - "version>=": "5.15.11" - }, - { - "name": "qt5-translations", - "version>=": "5.15.11" - }, - { - "name": "qt5-wayland", - "version>=": "5.15.11", - "platform": "linux | freebsd" - }, - { - "name": "qt5-x11extras", - "version>=": "5.15.11", + "name": "qt5", + "features": [ + "wayland" + ], "platform": "linux | freebsd" }, {