Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b124f57
Advance vcpkg baseline
phoerious Jan 17, 2026
be1efff
Fix Linux test failures (#13113)
phoerious Mar 6, 2026
4cb1d8d
Passkeys: Add publicKey to register response (#12757)
varjolintu Jan 17, 2026
d97e69b
fix(gui): enable Auto-Type help button when feature is active
abobov Jan 11, 2026
75ba31c
Fix showing URls in browser access dialog
Jan 6, 2026
34945d9
Fix minor font and theme issues (#12814)
xboxones1 Jan 17, 2026
407827e
Check version in vcpkg.json and appstream XML well-formedness, add tr…
phoerious Jan 18, 2026
6baaec2
Revert Auto-Type change that caused race condition (#12738)
droidmonkey Mar 8, 2026
8904293
Fix setting browser related values to customData (#13026)
varjolintu Mar 8, 2026
7fc0c45
Passkeys: Set BE and BS flags to true (#13042)
varjolintu Mar 8, 2026
0ce7ed3
Add support for nested folders with Bitwarden import (#13081)
varjolintu Mar 8, 2026
d54277a
Fix showing correct checkbox value in entry Browser Integration setti…
varjolintu Mar 8, 2026
63836c2
Sanitise attachment file names before saving (#13114)
phoerious Mar 8, 2026
bc5875f
Mock datetime to avoid test failures on second lapse (#13115)
phoerious Mar 8, 2026
552e9e6
Support TIMEOTP autotype and entry placeholder (#13117)
droidmonkey Mar 9, 2026
fa39adc
Passkeys: Fix default BE and BS flag value (#13122)
varjolintu Mar 9, 2026
d5fe2f0
Security: Prevent loading of openssl.cnf (#13118)
droidmonkey Mar 9, 2026
f70ca1a
Also override OPENSSL_ENGINES to prevent DLL injection (#13124)
phoerious Mar 9, 2026
33ef158
Bump version number and Changelog
droidmonkey Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 116 additions & 15 deletions release-tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@


import argparse
from collections import defaultdict
import ctypes
from datetime import datetime
import hashlib
import json
import logging
import lzma
import os
Expand All @@ -33,7 +35,8 @@
import sys
import tarfile
import tempfile
from urllib.request import urlretrieve
from urllib import request
from xml import sax


###########################################################################################
Expand Down Expand Up @@ -447,6 +450,7 @@ def perform_version_checks(cls, version, src_dir, git_ref=None, version_exists=F
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_vcpkg_manifest(version, src_dir)
cls.check_version_in_cmake(version, src_dir)
cls.check_changelog(version, src_dir)
cls.check_app_stream_info(version, src_dir)
Expand All @@ -460,32 +464,32 @@ def check_src_dir_exists(src_dir):
raise Error(f'Source directory "{src_dir}" does not exist!')

@staticmethod
def check_git_repository(cwd):
def check_git_repository(cwd=None):
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):
def check_release_exists(tag_name, cwd=None):
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):
def check_release_does_not_exist(tag_name, cwd=None):
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):
def check_working_tree_clean(cwd=None):
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):
def check_branch_exists(branch, cwd=None):
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):
def check_version_in_cmake(version, cwd=None):
cmakelists = Path('CMakeLists.txt')
if cwd:
cmakelists = Path(cwd) / cmakelists
Expand All @@ -500,7 +504,17 @@ def check_version_in_cmake(version, cwd):
raise Error(f'Version number in {cmakelists} not updated! Expected: %s, found: %s.', version, cmake_version)

@staticmethod
def check_changelog(version, cwd):
def check_version_in_vcpkg_manifest(version, cwd=None):
manifest = Path('vcpkg.json')
if cwd:
manifest = Path(cwd) / manifest
manifest_json = json.load(manifest.open('r'))
if version != manifest_json['version-string']:
raise Error(f'Version number in {manifest} not updated! Expected: %s, found: %s.',
version, manifest_json['version-string'])

@staticmethod
def check_changelog(version, cwd=None):
changelog = Path('CHANGELOG.md')
if cwd:
changelog = Path(cwd) / changelog
Expand All @@ -511,12 +525,21 @@ def check_changelog(version, cwd):
raise Error(f'{changelog} has not been updated to the "%s" release.', version)

@staticmethod
def check_app_stream_info(version, cwd):
def check_app_stream_info(version, cwd=None):
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)

try:
parser = sax.make_parser()
parser.setContentHandler(sax.handler.ContentHandler())
parser.parse(appstream)
except sax.SAXParseException as e:
raise Error(f'{appstream} is not well-formed. Error: %s at line %s, column %s',
e.getMessage(), e.getLineNumber(), e.getColumnNumber())

regex = re.compile(rf'^\s*<release version="{version}" date=".+?">')
with appstream.open('r', encoding='utf-8') as f:
for line in f:
Expand Down Expand Up @@ -572,8 +595,7 @@ def run(self, version, src_dir, release_branch, tag_name, no_latest, sign_key, n
# 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)
i18n.run_tx_pull(src_dir, tx_resource, 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("UTF-8"), re.MULTILINE | re.DOTALL)
Expand Down Expand Up @@ -808,7 +830,7 @@ 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)
request.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,
Expand Down Expand Up @@ -1048,7 +1070,7 @@ def run(self, file, gpg_key):
class I18N(Command):
"""Update translation files and pull from or push to Transifex."""

TRANSIFEX_RESOURCE = 'keepassxc.share-translations-keepassxc-en-ts--{}'
TRANSIFEX_RESOURCE = 'share-translations-keepassxc-en-ts--{}'
TRANSIFEX_PULL_PERC = 60

@classmethod
Expand All @@ -1070,6 +1092,15 @@ def setup_arg_parser(cls, parser: argparse.ArgumentParser):
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)

list_translators = subparsers.add_parser('tx-list-translators',
help='Print a HTML-formatted list of translation contributors.')
list_translators.add_argument('-o', '--org', help='Transifex org name.', default='keepassxc')
list_translators.add_argument('-p', '--project', help='Transifex project name.', default='keepassxc')
list_translators.add_argument('-r', '--resource', help='Transifex resource name.',
choices=['master', 'develop'])
list_translators.add_argument('-b', '--member-blacklist', nargs='+', help='Transifex users to ignore',
default=['phoerious', 'droidmonkey'])

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')
Expand Down Expand Up @@ -1112,12 +1143,15 @@ def run(self, subcmd, src_dir, branch, **kwargs):
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 'tx_args' in kwargs:
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 == 'tx-list-translators':
self.run_tx_list_translators(src_dir, kwargs['org'], kwargs['project'], kwargs['resource'],
kwargs['member_blacklist'])

elif subcmd == 'lupdate':
kwargs['lupdate_args'] = kwargs['lupdate_args'][1:]
Expand All @@ -1137,6 +1171,7 @@ def derive_resource_name(self, override_resource=None, *, cwd):

# noinspection PyMethodMayBeStatic
def run_tx_push(self, src_dir, resource, yes, tx_args):
resource = 'keepassxc.' + self.derive_resource_name(resource, cwd=src_dir)
sys.stderr.write('\nAbout to push the ' + fmt.bold('"en"') +
' source file from the current branch to Transifex:\n')
sys.stderr.write(f' {fmt.bold(_git_get_branch(cwd=src_dir))}'
Expand All @@ -1151,6 +1186,7 @@ def run_tx_push(self, src_dir, resource, yes, tx_args):

# noinspection PyMethodMayBeStatic
def run_tx_pull(self, src_dir, resource, min_perc, commit=False, yes=False, tx_args=None):
resource = 'keepassxc.' + self.derive_resource_name(resource, cwd=src_dir)
sys.stderr.write('\nAbout to pull translations for ' + fmt.bold(f'"{resource}"') + '.\n')
if not yes and not _yes_no_prompt('Continue?'):
logger.error('Pull aborted.')
Expand All @@ -1164,6 +1200,71 @@ def run_tx_pull(self, src_dir, resource, min_perc, commit=False, yes=False, tx_a
if commit:
_git_commit_files(files, 'Update translations.', cwd=src_dir)

# noinspection PyMethodMayBeStatic
def run_tx_list_translators(self, src_dir, org, project, resource, member_blacklist):
txrc = Path.home() / '.transifexrc'
if not txrc.exists():
raise Error('No Transifex config found. Run tx init first.')

org = f'o:{org}'
project = f'{org}:p:{project}'
resource = f'{project}:r:{self.derive_resource_name(resource, cwd=src_dir)}'

token = [l for l in open(txrc, 'r') if l.startswith('token')][0].split('=', 1)[1].strip()
member_blacklist = [f'u:{m}' for m in member_blacklist]

def get_url(url):
req = request.Request(url)
req.add_header('Content-Type', 'application/vnd.api+json')
req.add_header('Authorization', f'Bearer {token}')
with request.urlopen(req) as resp:
return json.load(resp)

logger.info('Fetching languages...',)
languages_json = get_url(f'https://rest.api.transifex.com/projects/{project}/languages')
languages = {}
for lang in languages_json['data']:
languages[lang['id']] = lang['attributes']['name']

logger.info('Fetching language stats...')
language_stats_json = get_url('https://rest.api.transifex.com/resource_language_stats?'
f'filter[project]={project}&filter[resource]={resource}')
for s in language_stats_json['data']:
completion = s['attributes']['translated_strings'] / s['attributes']['total_strings']
if completion < .6:
languages.pop(s['relationships']['language']['data']['id'])

logger.info('Fetching language members...')
members_json = get_url(f'https://rest.api.transifex.com/team_memberships?filter[organization]={org}')
members = defaultdict(set)
for member in members_json['data']:
print('.', end='', file=sys.stderr)
sys.stderr.flush()
if member['relationships']['user']['data']['id'] in member_blacklist:
continue
lid = member['relationships']['language']['data']['id']
if lid not in languages:
continue
user = get_url(member['relationships']['user']['links']['related'])['data']['attributes']['username']
members[lid].add(user)
print(file=sys.stderr, flush=True)

print('<ul>')
for lang in sorted(languages, key=lambda x: languages[x]):
if not members[lang]:
continue
lines = [f' <li><strong>{languages[lang]}:</strong> ']
for i, m in enumerate(sorted(members[lang], key=lambda x: x.lower())):
if len(lines[-1]) + len(m) >= 120:
lines.append(' ')
lines[-1] += m
if i < len(members[lang]) - 1:
lines[-1] += ', '
lines[-1] += '</li>'
print('\n'.join(lines))
print('</ul>')
logger.info('Done. Please add the list to the About dialog and commit the changes.')

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)
Expand Down
19 changes: 12 additions & 7 deletions src/autotype/AutoType.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ namespace
{"f14", Qt::Key_F14},
{"f15", Qt::Key_F15},
{"f16", Qt::Key_F16}};
constexpr int s_minWaitDelay = 100; // 100 ms
constexpr int s_maxWaitDelay = 10000; // 10 seconds
} // namespace

AutoType* AutoType::m_instance = nullptr;
Expand Down Expand Up @@ -312,6 +314,9 @@ void AutoType::executeAutoTypeActions(const Entry* entry,
// Restore executor mode
m_executor->mode = mode;

// Initial Auto-Type delay to allow window to come to foreground
Tools::wait(qBound(s_minWaitDelay, config()->get(Config::AutoTypeStartDelay).toInt(), s_maxWaitDelay));

// Grab the current active window after everything settles
if (window == 0) {
window = m_plugin->activeWindow();
Expand Down Expand Up @@ -543,16 +548,16 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin
}

const int maxTypeDelay = 500;
const int maxWaitDelay = 10000;
const int maxRepetition = 100;

int currentTypingDelay = qBound(0, config()->get(Config::AutoTypeDelay).toInt(), maxTypeDelay);
int cumulativeDelay = qBound(0, config()->get(Config::AutoTypeStartDelay).toInt(), maxWaitDelay);
// Take into account the initial delay which is added before any actions are performed
int cumulativeDelay = qBound(s_minWaitDelay, config()->get(Config::AutoTypeStartDelay).toInt(), s_maxWaitDelay);

// Initial actions include start delay and initial inter-key delay
QList<QSharedPointer<AutoTypeAction>> actions;
actions << QSharedPointer<AutoTypeBegin>::create();
actions << QSharedPointer<AutoTypeDelay>::create(currentTypingDelay, true);
actions << QSharedPointer<AutoTypeDelay>::create(cumulativeDelay);

// Replace escaped braces with a template for easier regex
QString sequence = entrySequence;
Expand Down Expand Up @@ -631,16 +636,16 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin
actions << QSharedPointer<AutoTypeDelay>::create(qBound(0, delay, maxTypeDelay), true);
} else if (placeholder == "delay") {
// Mid typing delay (wait), repeat represents the desired delay in milliseconds
if (repeat > maxWaitDelay) {
error = tr("Very long delay detected, max is %1: %2").arg(maxWaitDelay).arg(fullPlaceholder);
if (repeat > s_maxWaitDelay) {
error = tr("Very long delay detected, max is %1: %2").arg(s_maxWaitDelay).arg(fullPlaceholder);
return {};
}
cumulativeDelay += repeat;
actions << QSharedPointer<AutoTypeDelay>::create(qBound(0, repeat, maxWaitDelay));
actions << QSharedPointer<AutoTypeDelay>::create(qBound(0, repeat, s_maxWaitDelay));
} else if (placeholder == "clearfield") {
// Platform-specific field clearing
actions << QSharedPointer<AutoTypeClearField>::create();
} else if (placeholder == "totp") {
} else if (placeholder == "totp" || placeholder == "timeotp") {
if (entry->hasValidTotp()) {
// Calculate TOTP at the time of typing including delays
bool isValid = false;
Expand Down
Loading
Loading