Compare commits

...

39 Commits

Author SHA1 Message Date
Tamino Bauknecht
c0ea6f65f9 Database merge confirmation dialog (#10173)
* Add Entry::calculateDifference()

This new function contains the logic that was previously in
EntryHistoryModel::calculateHistoryModifications().
It allows the re-use to display the differences in case of a merge.

* Introduce Database Merge Confirmation Dialog

Adds a dialog allowing a user to review the changes of a merge operation.
This dialog displays the changes and allows the user to abort the merge
without modifying the database.

Fixes #1152

* Added dry run option to Merger
* Changed behavior when actual merge differs from dry run to just output a warning to console
* Fixed KeeShare conflicting with merge operations in the middle of a merge

---------

Co-authored-by: Jonathan White <support@dmapps.us>
2025-09-14 12:02:22 -04:00
A2va
9a40182a62 Add an option to add KeePassXC to PATH during installation (#12171)
---------

Co-authored-by: Jonathan White <support@dmapps.us>
2025-09-09 20:27:09 -04:00
Copilot
05150e2483 Fix incorrect "Restore Entry" option shown for non-recycle bin items in search results (#12198)
---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan White <support@dmapps.us>
2025-09-09 20:26:45 -04:00
Copilot
dd023ca157 Fix pre-release issues with attachment viewer (#12244)
* Fix translation issues for "FIT" and "New Attachment" in attachment editor

* Fix markdown preview persistence and enable external links in attachment editor

* Update preview panel if manually moved from collapsed position

* Match edit view scroll position (by percentage) when changed. This ensures the preview remains in relative sync with the edited document, for example when a large amount of HTML reduces down to a short preview document.

* Fix default preview size to be half the width of the edit widget.

* Set tab stop to 10 and remove base ui file

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan White <support@dmapps.us>
2025-09-08 06:22:20 -04:00
Alexey Mostovoy
ad2f95117a Return on first disabled item in areAllDisabled 2025-09-07 20:18:53 -04:00
xboxones1
6e1694111f Several ui fixes (#11967)
* Fix background color error for invalid autotype shortcut

* Fix alignment in autotype settings

* Fix contrast for splitter handle

* Fix font size reset when changing theme

---------

Co-authored-by: Jonathan White <support@dmapps.us>
2025-09-07 20:17:32 -04:00
Jonathan White
7ea141652e Update base translations and improve consistency (#12432)
* Improve confirmation prompts and tooltips for delete actions in the GUI

* Fixes #10543
2025-09-07 20:16:36 -04:00
Copilot
b12f6f0786 Fix SearchWidget issues with saved searches and "Press Enter to search" option (#12314)
---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: droidmonkey <2809491+droidmonkey@users.noreply.github.com>
2025-09-07 17:15:41 -04:00
varjolintu
7e3e2c10d2 Fix Do not ask permission for HTTP Basic Auth option 2025-09-07 10:56:42 -04:00
varjolintu
e9cf38a6e3 Browser: Do not allow site automatically 2025-09-07 10:55:25 -04:00
WillyJL
142454d08e Fix keyboard shortcuts when menubar is hidden (#12431)
---------

Co-authored-by: Jonathan White <support@dmapps.us>
2025-09-07 10:55:06 -04:00
Copilot
83d1003ec2 Add keyboard shortcut to "Jump to Group" from search results (#12225)
* Add Ctrl+Shift+J keyboard shortcut for "Jump to Group" from search results

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan White <support@dmapps.us>
2025-09-07 10:36:20 -04:00
Luz Paz
41da5b2127 Fix various typos 2025-08-24 10:25:48 -04:00
Jonathan White
606cf37952 Prevent mouse wheel scroll on entry edit username field (#12398)
* Reported by shawnkhu via Matrix, thank you!
2025-08-24 09:59:09 -04:00
André Draszik
544f983bad csvImport: fix modified and creation time import
Creation and last modification time stamps are imported incorrectly
during CSV import:
    * the imported created time is set to the CSV's last modified time
    * the imported last modified time is set to the CSV's icon index
      (which isn't a valid time usually and gets set to the current
      date & time instead).

The reason is commit 33a3796074 ("Add ability to parse tags from CSV
files") which shifted indices but missed to update all relevant time
related code locations.

Update the missing indices for those two to fix the import.

* Closes #12378

Fixes: 33a3796074 ("Add ability to parse tags from CSV files")
Signed-off-by: André Draszik <andre.draszik@linaro.org>
2025-08-24 09:57:47 -04:00
Eva Zhang
8d59090243 Documentation Updates (#12373)
Added documentation updates for Browser Integration and Secret Service Integration 

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan White <support@dmapps.us>
2025-08-17 17:46:15 -04:00
Juzu-O
9e8a966c23 Add URL auto-type and copy options to auto-type selection popup and menus (#12341)
* Added "Type {URL}" option to the auto-type selection popup right-click context menu
* Added "Copy {URL}" option to the auto-type selection popup right-click context menu
* Added keyboard shortcuts: CTRL+4 for "Type {URL}" and CTRL+SHIFT+4 for "Copy {URL}"
* Updated "Use Virtual Keyboard" shortcut from CTRL+4 to CTRL+5 to avoid inconsistency with order of shortcuts
* Added URL auto-type options "{URL}" and "{URL}{ENTER}" to main window entry view right-click menu
* Added URL auto-type options "{URL}" and "{URL}{ENTER}" to toolbar auto-type button dropdown menu
* Added translation strings for "Type {URL}" and "Copy {URL}" to support internationalization

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: juzu-o <3142026+juzu-o@users.noreply.github.com>
Co-authored-by: Jonathan White <support@dmapps.us>
2025-08-09 23:10:04 -04:00
Juzu-O
448165613d Add more granular Auto-Type confirmation settings (#12370)
This new setting gives users more control and safety:

* When Auto-Type is invoked globally (e.g., via a system-wide hotkey), the confirmation popup will always appear, letting the user confirm which credentials will be auto-typed.
* When Auto-Type is invoked from within KeePassXC's main window, the confirmation step can be skipped, since the user already can visually confirm which entry is being auto-typed.

This balances usability and security, reducing friction for intended actions while providing an extra safeguard for potentially ambiguous global Auto-Type triggers.

---------

Co-authored-by: Jonathan White <support@dmapps.us>
2025-08-09 22:36:51 -04:00
varjolintu
93423eda30 Fix inheriting browser group settings 2025-08-09 21:48:51 -04:00
thal
56b63a9e0f Update CONTRIBUTING.md
Transifex URL got changed
2025-07-13 07:09:17 -04:00
Jonathan White
2c2b686593 Update to the latest vcpkg baseline 2025-07-12 10:43:01 -04:00
Jonathan White
7c2fd5e3e9 Fix spelling in README/CONTRIBUTING 2025-07-11 19:44:40 -04:00
Jessy LANGE
7ec0f1f5a8 Add "press enter to search" option (#12263)
* Also increase auto-search timeout to 500 ms to improve user experience, especially with large databases. The previous value of 100ms guaranteed a search was performed after every character entered, even when typing relatively fast. 

---------

Co-authored-by: Jonathan White <support@dmapps.us>
2025-07-06 14:16:25 -04:00
Jonathan White
76b2f377df Enforce new-line at end of code files 2025-07-06 11:50:24 -04:00
Jonathan White
20c65fbd1e Correct Argon2 settings when creating new database
* Argon2 default parallelism settings were set to the number of threads on the computer. That is excessive on high cpu count computers.
2025-07-06 10:52:11 -04:00
Jonathan White
74326616c5 Fix two problems with URL wildcard matching
* Fixes #12255
* Periods were not being escaped in the url string before being used in a regex resulting in matching 'any character' between domain parts
* Wildcards entered as `*.` were being replaced with simply `*` resulting in unexpected matches to occur. Fixing this has a side effect of `https://*.github.com` NOT matching `https://github.com` which should be the expected behavior. Users can enter both url's if they desire to match the primary and all sub domains or leave out the wildcard entirely to use normal matching behavior.
2025-07-04 09:06:27 -04:00
Jonathan White
634a5b34f1 Improve inactivity timer
* Fix #11957
* Prevent resetting the timer hundreds of times per second
* Improve code flow for inactivity timer in general
2025-07-04 09:05:39 -04:00
Jonathan White
8c7cc90363 Fix TOTP visibility on unlock and setting change
Also fix invalid key message being visible when adding new TOTP secret to an entry.
2025-06-22 11:12:20 -04:00
Samuel Rac
217ee01572 Fix Proton Pass importer not importing email when there is no username 2025-06-22 11:00:25 -04:00
shotor
7a5cd6105c Maintain selected sort option when toggling filters 2025-06-22 10:58:17 -04:00
Jonathan White
64078933ab Fix icon download dialog not appearing above windows
* Fixes #12070
2025-06-19 16:22:17 -04:00
Jonathan White
e5fbab38d8 Remove start menu shortcuts on uninstall 2025-06-19 16:22:17 -04:00
Jonathan White
e2cf37a91f Replace newlines with HTML line breaks in message dialogs
* Keeps readability of translation strings without losing line breaks due to forced rich text display
2025-06-19 16:22:17 -04:00
Jonathan White
f62ea95499 Don't add space to invalid TOTP strings
* Fixes #11357
* Introduces validity parameter to TOTP generator function for future use elsewhere in the code base
* Fixes this in preview panel and TOTP dialog
* Disable actions to copy/show TOTP if the settings are invalid
* Show an error message on the TOTP setup dialog if the settings are invalid
* Show a TOTP icon with an x if the settings are invalid
2025-06-19 16:22:17 -04:00
Jonathan White
b5f4e98925 Fix handling of small passphrase wordlists
* Fixes #11856
* Set the minimum recommended wordlist size to 1,296 - equal to the EFF Short List
* Issue a clear warning when using a smaller wordlist but do not prevent generation of passphrases
* Improve wording when removing custom wordlist
2025-06-19 16:22:17 -04:00
Jonathan White
20aefd0c7a Show main page when editing entry or database settings
* Fixes #11891
2025-06-19 16:22:17 -04:00
Jonathan White
9baf77cbc4 Disable save button when viewing non-database screens
* Fixes #11662 - disable the save button when viewing Password Generator and Application Settings to restore previous behavior of toolbar
2025-06-19 16:22:17 -04:00
Jonathan White
5dfcc72f98 Fix minor issues with tags context menu
* Fixes #11808
* Don't show tear off menu or option to tear off if there are no tags
* Fix "No Tags" not being shown on first hover
* Fix issues when using a tag named "No Tags"
* Fix #12153 - tags becoming unsorted in the context menu when switching between database tabs
2025-06-19 16:22:17 -04:00
Kuznetsov Oleg
f2a4cc7e66 Refactor attachment handling system with enhanced UI (#12085)
* Renamed NewEntryAttachmentsDialog to EditEntryAttachmentsDialog for clarity.
* Introduced EditEntryAttachmentsDialog class to manage editing of existing attachments.
* Added functionality to preview attachments while editing them.
* Enhanced EntryAttachmentsModel with rowByKey method for better key management.
* Add image attachment support with zoom functionality.
* Add html and markdown detection.
* Improve button layout on the attachment section when editing an entry
2025-06-19 13:27:23 -04:00
168 changed files with 5487 additions and 1277 deletions

View File

@@ -54,6 +54,7 @@ IncludeCategories:
IndentCaseLabels: false IndentCaseLabels: false
IndentWidth: 4 IndentWidth: 4
IndentWrappedFunctionNames: false IndentWrappedFunctionNames: false
InsertNewlineAtEOF: true
KeepEmptyLinesAtTheStartOfBlocks: true KeepEmptyLinesAtTheStartOfBlocks: true
MacroBlockBegin: '' MacroBlockBegin: ''
MacroBlockEnd: '' MacroBlockEnd: ''

View File

@@ -77,7 +77,7 @@ Both issue lists are sorted by total number of comments. While not perfect, look
### Using AI ### Using AI
Generative AI is fast becoming a first-party feature in most development environments, including GitHub itself. If you use Generative AI to write the vast majority of your submission (e.g., agent-based or vibe coding) then you **must document your use of AI** in your pull request. Please include the service you used and/or model that generated the code. All code submissions go through a rigourous review process regardless of the development workflow used. Generative AI is fast becoming a first-party feature in most development environments, including GitHub itself. If you use Generative AI to write the vast majority of your submission (e.g., agent-based or vibe coding) then you **must document your use of AI** in your pull request. Please include the service you used and/or model that generated the code. All code submissions go through a rigorous review process regardless of the development workflow used.
### Pull requests ### Pull requests
@@ -87,7 +87,7 @@ All pull requests must comply with the above requirements and with the [stylegui
### Translations ### Translations
Translations are managed on [Transifex](https://www.transifex.com/keepassxc/keepassxc/) which offers a web interface. Translations are managed on [Transifex](https://explore.transifex.com/keepassxc/keepassxc/) which offers a web interface.
Please join an existing language team or request a new one if there is none. Please join an existing language team or request a new one if there is none.
If you open a Pull Request with new strings that require translations, you will need to run the following: If you open a Pull Request with new strings that require translations, you will need to run the following:

View File

@@ -223,6 +223,7 @@ Files: share/icons/application/scalable/actions/application-exit.svg
share/icons/application/scalable/actions/totp-copy.svg share/icons/application/scalable/actions/totp-copy.svg
share/icons/application/scalable/actions/totp-copy-password.svg share/icons/application/scalable/actions/totp-copy-password.svg
share/icons/application/scalable/actions/totp-edit.svg share/icons/application/scalable/actions/totp-edit.svg
share/icons/application/scalable/actions/totp-invalid.svg
share/icons/application/scalable/actions/trash.svg share/icons/application/scalable/actions/trash.svg
share/icons/application/scalable/actions/url-copy.svg share/icons/application/scalable/actions/url-copy.svg
share/icons/application/scalable/actions/user-guide.svg share/icons/application/scalable/actions/user-guide.svg

View File

@@ -58,7 +58,7 @@ Contributors are required to adhere to the project's [Code of Conduct](CODE-OF-C
## Generative AI ## Generative AI
Generative AI is fast becoming a first-party feature in most development environments, including GitHub itself. If the majority of a code submission is made using Generative AI (e.g., agent-based or vibe coding) then **we will document that in the pull request.** All code submissions go through a rigourous review process regardless of the development workflow or submitter. Generative AI is fast becoming a first-party feature in most development environments, including GitHub itself. If the majority of a code submission is made using Generative AI (e.g., agent-based or vibe coding) then **we will document that in the pull request.** All code submissions go through a rigorous review process regardless of the development workflow or submitter.
## License ## License

View File

@@ -24,7 +24,7 @@ if(NOT PCSC_FOUND)
# Additional search paths for Windows if not running in Visual Studio environment # Additional search paths for Windows if not running in Visual Studio environment
if (WIN32) if (WIN32)
# Resolve the ambiguity of using two names for one architechture # Resolve the ambiguity of using two names for one architecture
if(CMAKE_SYSTEM_PROCESSOR STREQUAL "AMD64" OR CMAKE_SYSTEM_PROCESSOR STREQUAL "x64") if(CMAKE_SYSTEM_PROCESSOR STREQUAL "AMD64" OR CMAKE_SYSTEM_PROCESSOR STREQUAL "x64")
set(ARCH_DIR "x64") set(ARCH_DIR "x64")
else() else()

View File

@@ -37,6 +37,8 @@ include::topics/Passkeys.adoc[tags=*]
include::topics/AutoType.adoc[tags=*] include::topics/AutoType.adoc[tags=*]
include::topics/SecretService.adoc[tags=*]
include::topics/SSHAgent.adoc[tags=*] include::topics/SSHAgent.adoc[tags=*]
include::topics/Reference.adoc[tags=*] include::topics/Reference.adoc[tags=*]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -107,12 +107,34 @@ You can then choose to update/add the credentials to your KeePassXC database dir
4. When you have successfully submitted the password on the website, a popup will appear asking you to either update an existing entry or add a new one. 4. When you have successfully submitted the password on the website, a popup will appear asking you to either update an existing entry or add a new one.
// tag::advanced[] // tag::advanced[]
=== Browser statistics === Browser Integration Report
You can see a cross-section of all browser-related settings applied to entries within a database through the Browser Statistics report. To access these, use the _Database_ -> _Database reports..._ menu option then click on _Browser Statistics_ on the left-hand menu. From here you can see all entries with URLs applied to them, explicitly allowed and denied URLs, and any entries with custom browser settings. You can see a cross-section of all browser-related settings applied to entries within a database through the Browser Statistics report. To access, use the _Database_ -> _Database reports..._ menu option then click on _Browser Statistics_ on the left-hand menu. From here you can see all entries with URLs applied to them, explicitly allowed and denied URLs, and any entries with custom browser settings.
.Browser statistics TIP: You can delete remembered site settings from the report by right clicking the entry you want to reset and selecting "Delete plugin data from entry".
.Browser Integration Report
image::browser_statistics.png[] image::browser_statistics.png[]
=== Additional Fill-In Fields
Sometimes login pages have additional fields you would like to fill (e.g., account number). Use the following instructions to add them:
1. Edit the entry you want to add fields to. Go to the advanced tab and add the attributes you need. Each attribute *must start with* `KPH:`, but otherwise the name does not matter. If multiple KPH attributes are defined, they are used in alphabetical order (i.e., the order shown in KeePassXC).
2. Within the browser, navigate to the page you want to use the additional fields on. Select the "Choose Custom Login Fields" button from the extension popup window. Choose Username, Password and String Field(s). Confirm the selections.
3. Refresh the web page. The new KPH attribute(s) should be filled to the extra fields.
.String Fields Selection in Browser
image:browser_integration_additional_attribute.png[]
=== Clearing Remembered Sites
Entries that you have chosen to remember allow/deny rules are stored in their respect custom data fields. You can clear all of these remembered settings at once through the database settings. Follow these steps:
1. Go to *Database* → *Database Settings* or click the database settings icon in the toolbar.
2. Go to the *Browser Integration* tab, then click on the *Forget all site-specific settings on entries* button.
3. Confirm this action in the popup dialog. This cannot be undone once the database is saved.
+
.Clear Remembered Sites
image::browser_integration_clear_sites.png[,100%]
=== Advanced Usage === Advanced Usage
You can configure unique browser integration behavior for each entry. This allows you to add multiple URLs to an entry, hide an entry from the browser integration, and more. To access these settings, open an entry for editing then click on _Browser Integration_ option in the left-hand menu *(1)*. You can configure unique browser integration behavior for each entry. This allows you to add multiple URLs to an entry, hide an entry from the browser integration, and more. To access these settings, open an entry for editing then click on _Browser Integration_ option in the left-hand menu *(1)*.

View File

@@ -375,7 +375,7 @@ image::database_settings.png[]
* *Database name:* This is the default identifier for your database and is shown in the tab bar and title bar (when active). You can change this name as desired. * *Database name:* This is the default identifier for your database and is shown in the tab bar and title bar (when active). You can change this name as desired.
* *Database description:* Provide some meaningful description for your database. * *Database description:* Provide some meaningful description for your database.
* *Default username:* Provide a default username for all new entries that you create in this database. * *Default username:* Provide a default username for all new entries that you create in this database.
* *Public Databse Metadata:* Here you can set a public (unencrypted) name, icon, and color for your database. This is used on the database unlock screen to help distinguish multiple databases from each other. * *Public Database Metadata:* Here you can set a public (unencrypted) name, icon, and color for your database. This is used on the database unlock screen to help distinguish multiple databases from each other.
* *Max history items:* This is the maximum number of history items that are stored for each entry. When you set this to 0, no history will be saved. Set this value to a low value to prevent the database from getting too large (we recommend no more than 10). * *Max history items:* This is the maximum number of history items that are stored for each entry. When you set this to 0, no history will be saved. Set this value to a low value to prevent the database from getting too large (we recommend no more than 10).
* *Max. history size:* When the history of an entry gets above this size, it is truncated. For example, this happens when entries have large attachments. Set this value small to prevent the database from getting too large (we recommend 6 MiB). * *Max. history size:* When the history of an entry gets above this size, it is truncated. For example, this happens when entries have large attachments. Set this value small to prevent the database from getting too large (we recommend 6 MiB).
* *Use recycle bin:* Select this check-box if you want deleted entries to move to the recycle bin instead of being permanently removed. The recycle bin will be created if it does not already exist after your first deletion. To delete entries permanently, you must empty the recycle bin manually. * *Use recycle bin:* Select this check-box if you want deleted entries to move to the recycle bin instead of being permanently removed. The recycle bin will be created if it does not already exist after your first deletion. To delete entries permanently, you must empty the recycle bin manually.

View File

@@ -36,8 +36,9 @@ kbd:[Ctrl + E]
|Copy Password and TOTP | kbd:[Ctrl + Y] |Copy Password and TOTP | kbd:[Ctrl + Y]
|Show TOTP | kbd:[Ctrl + Shift + T] |Show TOTP | kbd:[Ctrl + Shift + T]
|Trigger AutoType | kbd:[Ctrl + Shift + V] |Trigger AutoType | kbd:[Ctrl + Shift + V]
|Add key to SSH Agent | kbd:[Ctrl + H] |Add key to SSH Agent | kbd:[Ctrl + H]
|Remove key from SSH Agent | kbd:[Ctrl + Shift + H] |Remove key from SSH Agent | kbd:[Ctrl + Shift + H]
|Jump to Group (from search) | kbd:[Ctrl + Shift + J]
|Move entry up (if unsorted) | kbd:[Ctrl + Alt + Up] |Move entry up (if unsorted) | kbd:[Ctrl + Alt + Up]
|Move entry down (if unsorted) | kbd:[Ctrl + Alt + Down] |Move entry down (if unsorted) | kbd:[Ctrl + Alt + Down]
|Sort Groups A-Z | kbd:[Ctrl + Down] |Sort Groups A-Z | kbd:[Ctrl + Down]

View File

@@ -0,0 +1,48 @@
= KeePassXC Secret Service Integration
include::.sharedheader[]
:imagesdir: ../images
// tag::content[]
== Secret Service Integration
This feature allows KeePassXC to act as a Secret Service provider over DBus. It enables applications to store and retrieve secrets securely via the https://www.freedesktop.org/wiki/Specifications/secret-storage-spec/[Secret Storage specification]. While running, KeePassXC acts as a Secret Service server registered on DBus so clients like seahorse, python-secretstorage, secret-tool, or other implementations can connect and access the exposed database in KeePassXC.
=== Enabling the Integration
Only one secret service provider can be enabled at a time. You may have to disable other providers, such as GNOME Keyring or KWallet, to use KeePassXC as a secret service provider. You will see a notice when attempting to enable KeePassXC as the secret service provider if another is already running.
To replace most third party secret service providers with KeePassXC, run the following shell snippet:
```bash
mkdir -p "${XDG_DATA_HOME:-${HOME}/.local/share}/dbus-1/services"
cat > "${XDG_DATA_HOME:-${HOME}/.local/share}/dbus-1/services/org.freedesktop.secrets.service" <<EOF
[D-BUS Service]
Name=org.freedesktop.secrets
Exec=/usr/bin/keepassxc
EOF
```
NOTE: You may need to restart your session or log out and back in for the changes to take effect.
1. Open KeePassXC → **Tools → Settings → Secret Service Integration** → check **Enable KeePassXC Freedesktop.org Secret Service Integration**. Then press OK to save this setting and enable the integration. Go back into this settings screen to see currently open databases that you can unlock and edit their exposure to secret service.
+
.Secret Service Settings
image::secretservice_enable_settings.png[]
2. Either click the pencil icon in the previous settings screen, or go to **Database → Database Settings → Secret Service Integration**. Enable **Expose entries under this group**, and select the desired group. All entries within this group and all subgroups will be exposed to the service.
+
.Secret Service Database Settings
image::secretservice_database_settings.png[]
3. Use apps that integrate with secret service to start saving and using credentials within KeePassXC. If you enabled confirmation prior to access, you will see the following dialog:
+
.Secret Service Access Confirmation Dialog
image::secretservice_access_dialog.png[]
TIP: When applications use `secret-tool` and you have access confirmation enabled, then you will be prompted each time credentials are requested. This is due to `secret-tool` obtaining a new process id each time it is run.
=== Implementation Details
* The user can specify the database and group that is exposed to the service.
* Desktop notifications when a secret is retrieved and access confirmation dialogs.
* `FdoSecrets::Service` is the top level DBus service. There is one `FdoSecrets::Collection` per opened database tab and each entry under the exposed database group has a corresponding `FdoSecrets::Item` DBus object.
* The following entry attributes are exposed to the secret service: Title, Username, Password, URL, Notes, TOTP, and non-protected Custom Attributes.
// end::content[]

View File

@@ -18,7 +18,16 @@ image::main_interface.png[]
*(D) Preview* Shows a preview of the selected group or entry. You can interact with most information stored in an entry from here without opening the entry for editing. You can temporarily hide this preview using the down-arrow button on the right hand side or completely disable it from the View menu. *(D) Preview* Shows a preview of the selected group or entry. You can interact with most information stored in an entry from here without opening the entry for editing. You can temporarily hide this preview using the down-arrow button on the right hand side or completely disable it from the View menu.
TIP: You can enable double-click copying of entry username and password in the Application Security Settings. This is turned off by default starting with version 2.7.0. [TIP]
====
Starting with version 2.7.0, double-click copying of entry usernames and passwords is disabled by default.
To enable it:
. Open *KeePassXC*, and navigate to *Tools* → *Settings*.
. In the left sidebar, select *General*.
. Under *Entry Management*, check the box for _"Copy data on double clicking field in entry view"_.
====
=== Toolbar === Toolbar
The toolbar provides a quick way to perform common tasks with your database. Some entries in the toolbar are dynamically disabled based on the information contained in the selected entry. Every common action in KeePassXC can be controlled with a keyboard shortcut as well. The toolbar provides a quick way to perform common tasks with your database. Some entries in the toolbar are dynamically disabled based on the information contained in the selected entry. Every common action in KeePassXC can be controlled with a keyboard shortcut as well.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.47 15.08L11 13V7H12.5V12.25L15.58 14.08C15.17 14.36 14.79 14.7 14.47 15.08M13.08 19.92C12.72 19.97 12.37 20 12 20C7.58 20 4 16.42 4 12S7.58 4 12 4 20 7.58 20 12C20 12.37 19.97 12.72 19.92 13.08C20.61 13.18 21.25 13.4 21.84 13.72C21.94 13.16 22 12.59 22 12C22 6.5 17.5 2 12 2S2 6.5 2 12C2 17.5 6.47 22 12 22C12.59 22 13.16 21.94 13.72 21.84C13.4 21.25 13.18 20.61 13.08 19.92M21.12 15.46L19 17.59L16.88 15.47L15.47 16.88L17.59 19L15.47 21.12L16.88 22.54L19 20.41L21.12 22.54L22.54 21.12L20.41 19L22.54 16.88L21.12 15.46Z" /></svg>

After

Width:  |  Height:  |  Size: 602 B

View File

@@ -92,6 +92,7 @@
<file>application/scalable/actions/totp-copy.svg</file> <file>application/scalable/actions/totp-copy.svg</file>
<file>application/scalable/actions/totp-copy-password.svg</file> <file>application/scalable/actions/totp-copy-password.svg</file>
<file>application/scalable/actions/totp-edit.svg</file> <file>application/scalable/actions/totp-edit.svg</file>
<file>application/scalable/actions/totp-invalid.svg</file>
<file>application/scalable/actions/trash.svg</file> <file>application/scalable/actions/trash.svg</file>
<file>application/scalable/actions/url-copy.svg</file> <file>application/scalable/actions/url-copy.svg</file>
<file>application/scalable/actions/user-guide.svg</file> <file>application/scalable/actions/user-guide.svg</file>

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@
<!-- Custom Controls for KPXC Installer --> <!-- Custom Controls for KPXC Installer -->
<Control Id="DesktopShortcutCheckBox" Type="CheckBox" X="20" Y="150" Width="290" Height="17" Property="INSTALLDESKTOPSHORTCUT" CheckBoxValue="1" Text="Create a shortcut on the desktop" /> <Control Id="DesktopShortcutCheckBox" Type="CheckBox" X="20" Y="150" Width="290" Height="17" Property="INSTALLDESKTOPSHORTCUT" CheckBoxValue="1" Text="Create a shortcut on the desktop" />
<Control Id="AutostartCheckBox" Type="CheckBox" X="20" Y="170" Width="290" Height="17" Property="AUTOSTARTPROGRAM" CheckBoxValue="1" Text="Autostart KeePassXC on login" /> <Control Id="AutostartCheckBox" Type="CheckBox" X="20" Y="170" Width="290" Height="17" Property="AUTOSTARTPROGRAM" CheckBoxValue="1" Text="Autostart KeePassXC on login" />
<Control Id="AddToPathCheckBox" Type="CheckBox" X="20" Y="190" Width="290" Height="17" Property="ADDTOPATH" CheckBoxValue="1" Text="Add KeePassXC to the PATH environment variable" />
</Dialog> </Dialog>
</UI> </UI>
</Fragment> </Fragment>

View File

@@ -56,6 +56,13 @@
<Condition>AUTOSTARTPROGRAM</Condition> <Condition>AUTOSTARTPROGRAM</Condition>
</Component> </Component>
<!-- Add KeePassXC to PATH -->
<Component Id="AddKeePassXCToPath" Guid="*" Directory="INSTALL_ROOT">
<Condition>ADDTOPATH</Condition>
<Environment Id="KeePassXCSystemPathEntryENV" Action="set" Part="last" Name="PATH" Value="[INSTALL_ROOT]" System="yes"/>
<RegistryValue Root="HKCU" Key="Software\KeePassXC" Name="AddedToPATH" Type="integer" Value="1" KeyPath="yes" />
</Component>
<DirectoryRef Id="TARGETDIR"> <DirectoryRef Id="TARGETDIR">
<!-- Startmenu shortcuts --> <!-- Startmenu shortcuts -->
<Directory Id="ProgramMenuFolder"> <Directory Id="ProgramMenuFolder">
@@ -73,6 +80,7 @@
Name="KeePassXC - User Guide" Name="KeePassXC - User Guide"
Target="[#CM_FP_share.docs.KeePassXC_UserGuide.html]" Target="[#CM_FP_share.docs.KeePassXC_UserGuide.html]"
WorkingDirectory="INSTALL_ROOT" /> WorkingDirectory="INSTALL_ROOT" />
<RemoveFile Id="RemoveShortcuts" Name="*.*" On="uninstall" />
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall" /> <RemoveFolder Id="ApplicationProgramsFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\KeePassXC" Name="StartMenuShortcut" Type="integer" Value="1" KeyPath="yes"/> <RegistryValue Root="HKCU" Key="Software\KeePassXC" Name="StartMenuShortcut" Type="integer" Value="1" KeyPath="yes"/>
</Component> </Component>
@@ -104,15 +112,21 @@
<RegistrySearch Id="AutoStartSearch" Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="$(var.CPACK_PACKAGE_NAME)" Type="raw" /> <RegistrySearch Id="AutoStartSearch" Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="$(var.CPACK_PACKAGE_NAME)" Type="raw" />
</Property> </Property>
<Property Id="INSTALLDESKTOPSHORTCUT" Secure="yes" /> <Property Id="INSTALLDESKTOPSHORTCUT" Secure="yes" />
<Property Id="ADDTOPATH" Value="1" Secure="yes" />
<Property Id="ADDTOPATH_REGISTRY">
<RegistrySearch Id="AddToPathSearch" Root="HKCU" Key="Software\KeePassXC" Name="AddedToPATH" Type="raw" />
</Property>
<!-- Set properties based on existing conditions, prevents changing state on upgrade --> <!-- Set properties based on existing conditions, prevents changing state on upgrade -->
<SetProperty Id="AUTOSTARTPROGRAM" After="AppSearch" Value="">AUTOSTARTPROGRAM="0" OR (WIX_UPGRADE_DETECTED AND NOT AUTOSTARTPROGRAM_REGISTRY)</SetProperty> <SetProperty Id="AUTOSTARTPROGRAM" After="AppSearch" Value="" Sequence="first">AUTOSTARTPROGRAM="0" OR (WIX_UPGRADE_DETECTED AND NOT AUTOSTARTPROGRAM_REGISTRY)</SetProperty>
<SetProperty Id="ADDTOPATH" After="AppSearch" Value="" Sequence="first">ADDTOPATH="0" OR (WIX_UPGRADE_DETECTED AND NOT ADDTOPATH_REGISTRY)</SetProperty>
<SetProperty Id="LicenseAccepted" After="AppSearch" Value="1">WIX_UPGRADE_DETECTED</SetProperty> <SetProperty Id="LicenseAccepted" After="AppSearch" Value="1">WIX_UPGRADE_DETECTED</SetProperty>
<FeatureRef Id="ProductFeature"> <FeatureRef Id="ProductFeature">
<ComponentRef Id="ApplicationShortcuts" /> <ComponentRef Id="ApplicationShortcuts" />
<ComponentRef Id="Autostart" /> <ComponentRef Id="Autostart" />
<ComponentRef Id="DesktopShortcut" /> <ComponentRef Id="DesktopShortcut" />
<ComponentRef Id="AddKeePassXCToPath"/>
</FeatureRef> </FeatureRef>
<!-- Action to launch application after installer exits --> <!-- Action to launch application after installer exits -->

View File

@@ -134,6 +134,7 @@ set(gui_SOURCES
gui/IconModels.cpp gui/IconModels.cpp
gui/KMessageWidget.cpp gui/KMessageWidget.cpp
gui/MainWindow.cpp gui/MainWindow.cpp
gui/MergeDialog.cpp
gui/MessageBox.cpp gui/MessageBox.cpp
gui/MessageWidget.cpp gui/MessageWidget.cpp
gui/PasswordWidget.cpp gui/PasswordWidget.cpp
@@ -159,8 +160,14 @@ set(gui_SOURCES
gui/entry/EntryAttachmentsModel.cpp gui/entry/EntryAttachmentsModel.cpp
gui/entry/EntryAttachmentsWidget.cpp gui/entry/EntryAttachmentsWidget.cpp
gui/entry/EntryAttributesModel.cpp gui/entry/EntryAttributesModel.cpp
gui/entry/NewEntryAttachmentsDialog.cpp gui/entry/EditEntryAttachmentsDialog.cpp
gui/entry/PreviewEntryAttachmentsDialog.cpp gui/entry/PreviewEntryAttachmentsDialog.cpp
gui/entry/attachments/TextAttachmentsWidget.cpp
gui/entry/attachments/ImageAttachmentsWidget.cpp
gui/entry/attachments/ImageAttachmentsView.cpp
gui/entry/attachments/TextAttachmentsPreviewWidget.cpp
gui/entry/attachments/TextAttachmentsEditWidget.cpp
gui/entry/attachments/AttachmentWidget.cpp
gui/entry/EntryHistoryModel.cpp gui/entry/EntryHistoryModel.cpp
gui/entry/EntryModel.cpp gui/entry/EntryModel.cpp
gui/entry/EntryView.cpp gui/entry/EntryView.cpp

View File

@@ -637,10 +637,16 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin
// Platform-specific field clearing // Platform-specific field clearing
actions << QSharedPointer<AutoTypeClearField>::create(); actions << QSharedPointer<AutoTypeClearField>::create();
} else if (placeholder == "totp") { } else if (placeholder == "totp") {
// Entry totp (requires special handling) if (entry->hasValidTotp()) {
QString totp = entry->totp(); // Entry totp (requires special handling)
for (const auto& ch : totp) { QString totp = entry->totp();
actions << QSharedPointer<AutoTypeKey>::create(ch); for (const auto& ch : totp) {
actions << QSharedPointer<AutoTypeKey>::create(ch);
}
} else if (entry->hasTotp()) {
// Entry has TOTP configured but invalid settings
error = tr("Entry has invalid TOTP settings");
return {};
} }
} else if (placeholder.startsWith("pickchars")) { } else if (placeholder.startsWith("pickchars")) {
// Reset to the original capture to preserve case // Reset to the original capture to preserve case

View File

@@ -37,6 +37,7 @@ enum MENU_FIELD
USERNAME = 1, USERNAME = 1,
PASSWORD, PASSWORD,
TOTP, TOTP,
URL,
}; };
AutoTypeSelectDialog::AutoTypeSelectDialog(QWidget* parent) AutoTypeSelectDialog::AutoTypeSelectDialog(QWidget* parent)
@@ -264,7 +265,8 @@ void AutoTypeSelectDialog::updateActionMenu(const AutoTypeMatch& match)
bool hasUsername = !match.first->username().isEmpty(); bool hasUsername = !match.first->username().isEmpty();
bool hasPassword = !match.first->password().isEmpty(); bool hasPassword = !match.first->password().isEmpty();
bool hasTotp = match.first->hasTotp(); bool hasTotp = match.first->hasValidTotp();
bool hasUrl = !match.first->url().isEmpty();
for (auto action : m_actionMenu->actions()) { for (auto action : m_actionMenu->actions()) {
auto prop = action->property(MENU_FIELD_PROP_NAME); auto prop = action->property(MENU_FIELD_PROP_NAME);
@@ -279,6 +281,9 @@ void AutoTypeSelectDialog::updateActionMenu(const AutoTypeMatch& match)
case MENU_FIELD::TOTP: case MENU_FIELD::TOTP:
action->setEnabled(hasTotp); action->setEnabled(hasTotp);
break; break;
case MENU_FIELD::URL:
action->setEnabled(hasUrl);
break;
} }
} }
} }
@@ -290,15 +295,19 @@ void AutoTypeSelectDialog::buildActionMenu()
auto typeUsernameAction = new QAction(icons()->icon("auto-type"), tr("Type {USERNAME}"), this); auto typeUsernameAction = new QAction(icons()->icon("auto-type"), tr("Type {USERNAME}"), this);
auto typePasswordAction = new QAction(icons()->icon("auto-type"), tr("Type {PASSWORD}"), this); auto typePasswordAction = new QAction(icons()->icon("auto-type"), tr("Type {PASSWORD}"), this);
auto typeTotpAction = new QAction(icons()->icon("auto-type"), tr("Type {TOTP}"), this); auto typeTotpAction = new QAction(icons()->icon("auto-type"), tr("Type {TOTP}"), this);
auto typeUrlAction = new QAction(icons()->icon("auto-type"), tr("Type {URL}"), this);
auto copyUsernameAction = new QAction(icons()->icon("username-copy"), tr("Copy Username"), this); auto copyUsernameAction = new QAction(icons()->icon("username-copy"), tr("Copy Username"), this);
auto copyPasswordAction = new QAction(icons()->icon("password-copy"), tr("Copy Password"), this); auto copyPasswordAction = new QAction(icons()->icon("password-copy"), tr("Copy Password"), this);
auto copyTotpAction = new QAction(icons()->icon("totp"), tr("Copy TOTP"), this); auto copyTotpAction = new QAction(icons()->icon("totp"), tr("Copy TOTP"), this);
auto copyUrlAction = new QAction(icons()->icon("url-copy"), tr("Copy URL"), this);
m_actionMenu->addAction(typeUsernameAction); m_actionMenu->addAction(typeUsernameAction);
m_actionMenu->addAction(typePasswordAction); m_actionMenu->addAction(typePasswordAction);
m_actionMenu->addAction(typeTotpAction); m_actionMenu->addAction(typeTotpAction);
m_actionMenu->addAction(typeUrlAction);
m_actionMenu->addAction(copyUsernameAction); m_actionMenu->addAction(copyUsernameAction);
m_actionMenu->addAction(copyPasswordAction); m_actionMenu->addAction(copyPasswordAction);
m_actionMenu->addAction(copyTotpAction); m_actionMenu->addAction(copyTotpAction);
m_actionMenu->addAction(copyUrlAction);
typeUsernameAction->setShortcut(Qt::CTRL + Qt::Key_1); typeUsernameAction->setShortcut(Qt::CTRL + Qt::Key_1);
typeUsernameAction->setProperty(MENU_FIELD_PROP_NAME, MENU_FIELD::USERNAME); typeUsernameAction->setProperty(MENU_FIELD_PROP_NAME, MENU_FIELD::USERNAME);
@@ -324,10 +333,18 @@ void AutoTypeSelectDialog::buildActionMenu()
submitAutoTypeMatch(match); submitAutoTypeMatch(match);
}); });
typeUrlAction->setShortcut(Qt::CTRL + Qt::Key_4);
typeUrlAction->setProperty(MENU_FIELD_PROP_NAME, MENU_FIELD::URL);
connect(typeUrlAction, &QAction::triggered, this, [&] {
auto match = m_ui->view->currentMatch();
match.second = "{URL}";
submitAutoTypeMatch(match);
});
#if defined(Q_OS_WIN) || defined(Q_OS_MAC) #if defined(Q_OS_WIN) || defined(Q_OS_MAC)
auto typeVirtualAction = new QAction(icons()->icon("auto-type"), tr("Use Virtual Keyboard"), nullptr); auto typeVirtualAction = new QAction(icons()->icon("auto-type"), tr("Use Virtual Keyboard"), nullptr);
m_actionMenu->insertAction(copyUsernameAction, typeVirtualAction); m_actionMenu->insertAction(copyUsernameAction, typeVirtualAction);
typeVirtualAction->setShortcut(Qt::CTRL + Qt::Key_4); typeVirtualAction->setShortcut(Qt::CTRL + Qt::Key_5);
connect(typeVirtualAction, &QAction::triggered, this, [&] { connect(typeVirtualAction, &QAction::triggered, this, [&] {
m_virtualMode = true; m_virtualMode = true;
activateCurrentMatch(); activateCurrentMatch();
@@ -364,17 +381,29 @@ void AutoTypeSelectDialog::buildActionMenu()
} }
}); });
copyUrlAction->setShortcut(Qt::CTRL + Qt::SHIFT + Qt::Key_4);
copyUrlAction->setProperty(MENU_FIELD_PROP_NAME, MENU_FIELD::URL);
connect(copyUrlAction, &QAction::triggered, this, [&] {
auto entry = m_ui->view->currentMatch().first;
if (entry) {
clipboard()->setText(entry->resolvePlaceholder(entry->url()));
reject();
}
});
// Qt 5.10 introduced a new "feature" to hide shortcuts in context menus // Qt 5.10 introduced a new "feature" to hide shortcuts in context menus
// Unfortunately, Qt::AA_DontShowShortcutsInContextMenus is broken, have to manually enable them // Unfortunately, Qt::AA_DontShowShortcutsInContextMenus is broken, have to manually enable them
typeUsernameAction->setShortcutVisibleInContextMenu(true); typeUsernameAction->setShortcutVisibleInContextMenu(true);
typePasswordAction->setShortcutVisibleInContextMenu(true); typePasswordAction->setShortcutVisibleInContextMenu(true);
typeTotpAction->setShortcutVisibleInContextMenu(true); typeTotpAction->setShortcutVisibleInContextMenu(true);
typeUrlAction->setShortcutVisibleInContextMenu(true);
#if defined(Q_OS_WIN) || defined(Q_OS_MAC) #if defined(Q_OS_WIN) || defined(Q_OS_MAC)
typeVirtualAction->setShortcutVisibleInContextMenu(true); typeVirtualAction->setShortcutVisibleInContextMenu(true);
#endif #endif
copyUsernameAction->setShortcutVisibleInContextMenu(true); copyUsernameAction->setShortcutVisibleInContextMenu(true);
copyPasswordAction->setShortcutVisibleInContextMenu(true); copyPasswordAction->setShortcutVisibleInContextMenu(true);
copyTotpAction->setShortcutVisibleInContextMenu(true); copyTotpAction->setShortcutVisibleInContextMenu(true);
copyUrlAction->setShortcutVisibleInContextMenu(true);
} }
void AutoTypeSelectDialog::showEvent(QShowEvent* event) void AutoTypeSelectDialog::showEvent(QShowEvent* event)

View File

@@ -51,12 +51,18 @@
<enum>Qt::NoFocus</enum> <enum>Qt::NoFocus</enum>
</property> </property>
<property name="toolTip"> <property name="toolTip">
<string>&lt;p&gt;You can use advanced search queries to find any entry in your open databases. The following shortcuts are useful:&lt;br/&gt; <string>&lt;p&gt;The following shortcuts are available:&lt;br/&gt;
Ctrl+F - Toggle database search&lt;br/&gt; Ctrl+F - Focus search&lt;br/&gt;
Ctrl+1 - Type username&lt;br/&gt; Ctrl+1 - Type username&lt;br/&gt;
Ctrl+2 - Type password&lt;br/&gt; Ctrl+2 - Type password&lt;br/&gt;
Ctrl+3 - Type TOTP&lt;br/&gt; Ctrl+3 - Type TOTP&lt;br/&gt;
Ctrl+4 - Use Virtual Keyboard (Windows Only)&lt;/p&gt;</string> Ctrl+4 - Type URL&lt;br/&gt;
Ctrl+5 - Use Virtual Keyboard (Windows Only)&lt;br/&gt;
Ctrl+Shift+1 - Copy username&lt;br/&gt;
Ctrl+Shift+2 - Copy password&lt;br/&gt;
Ctrl+Shift+3 - Copy TOTP&lt;br/&gt;
Ctrl+Shift+4 - Copy URL&lt;br/&gt;
&lt;/p&gt;</string>
</property> </property>
<property name="styleSheet"> <property name="styleSheet">
<string notr="true">QToolButton { <string notr="true">QToolButton {
@@ -172,6 +178,9 @@ Ctrl+4 - Use Virtual Keyboard (Windows Only)&lt;/p&gt;</string>
<height>0</height> <height>0</height>
</size> </size>
</property> </property>
<property name="toolTip">
<string>You can use advanced search queries to find any entry in your open databases.</string>
</property>
<property name="placeholderText"> <property name="placeholderText">
<string>Search…</string> <string>Search…</string>
</property> </property>

View File

@@ -150,14 +150,13 @@ void BrowserAccessControlDialog::selectionChanged()
bool BrowserAccessControlDialog::areAllDisabled() const bool BrowserAccessControlDialog::areAllDisabled() const
{ {
auto areAllDisabled = true;
for (const auto& item : getAllItems()) { for (const auto& item : getAllItems()) {
if (item->flags() != Qt::NoItemFlags) { if (item->flags() != Qt::NoItemFlags) {
areAllDisabled = false; return false;
} }
} }
return areAllDisabled; return true;
} }
QList<QTableWidgetItem*> BrowserAccessControlDialog::getAllItems() const QList<QTableWidgetItem*> BrowserAccessControlDialog::getAllItems() const

View File

@@ -155,4 +155,4 @@ void BrowserPasskeysConfirmationDialog::updateEntriesToTable(const QList<Entry*>
m_ui->credentialsTable->resizeColumnsToContents(); m_ui->credentialsTable->resizeColumnsToContents();
m_ui->credentialsTable->horizontalHeader()->setStretchLastSection(true); m_ui->credentialsTable->horizontalHeader()->setStretchLastSection(true);
} }

View File

@@ -413,7 +413,7 @@ BrowserService::findEntries(const EntryParameters& entryParameters, const String
continue; continue;
case Unknown: case Unknown:
if (alwaysAllowAccess) { if (alwaysAllowAccess || (entryParameters.httpAuth && ignoreHttpAuth)) {
allowedEntries.append(entry); allowedEntries.append(entry);
} else { } else {
entriesToConfirm.append(entry); entriesToConfirm.append(entry);
@@ -898,16 +898,6 @@ void BrowserService::addEntry(const EntryParameters& entryParameters,
const QString host = QUrl(entryParameters.siteUrl).host(); const QString host = QUrl(entryParameters.siteUrl).host();
const QString submitHost = QUrl(entryParameters.formUrl).host(); const QString submitHost = QUrl(entryParameters.formUrl).host();
BrowserEntryConfig config;
config.allow(host);
if (!submitHost.isEmpty()) {
config.allow(submitHost);
}
if (!entryParameters.realm.isEmpty()) {
config.setRealm(entryParameters.realm);
}
config.save(entry);
if (downloadFavicon && m_currentDatabaseWidget) { if (downloadFavicon && m_currentDatabaseWidget) {
m_currentDatabaseWidget->downloadFaviconInBackground(entry); m_currentDatabaseWidget->downloadFaviconInBackground(entry);
@@ -1192,7 +1182,7 @@ QJsonObject BrowserService::prepareEntry(const Entry* entry)
res["uuid"] = entry->resolveMultiplePlaceholders(entry->uuidToHex()); res["uuid"] = entry->resolveMultiplePlaceholders(entry->uuidToHex());
res["group"] = entry->resolveMultiplePlaceholders(entry->group()->name()); res["group"] = entry->resolveMultiplePlaceholders(entry->group()->name());
if (entry->hasTotp()) { if (entry->hasValidTotp()) {
res["totp"] = entry->totp(); res["totp"] = entry->totp();
} }
@@ -1572,11 +1562,11 @@ bool BrowserService::handleURLWithWildcards(const QUrl& entryQUrl, const QString
} }
// Escape illegal characters // Escape illegal characters
auto re = firstPart.replace(QRegularExpression(R"(([!\^\$\+\-\(\)@<>]))"), "\\\\1"); auto re = Tools::escapeRegex(firstPart);
if (hostnameUsed) { if (hostnameUsed) {
// Replace all host parts with wildcards // Replace all host parts with wildcards
re = re.replace(QString("%1.").arg(UrlTools::URL_WILDCARD), "(.*?)"); re = re.replace(QString("%1.").arg(UrlTools::URL_WILDCARD), "(.*?)\\.");
} }
// Append a + to the end of regex to match all paths after the last asterisk // Append a + to the end of regex to match all paths after the last asterisk

View File

@@ -67,8 +67,8 @@ int DatabaseInfo::executeWithDatabase(QSharedPointer<Database> database, QShared
out << QObject::tr("Number of short passwords") << ": " << QString::number(stats.shortPasswords) << Qt::endl; out << QObject::tr("Number of short passwords") << ": " << QString::number(stats.shortPasswords) << Qt::endl;
out << QObject::tr("Number of weak passwords") << ": " << QString::number(stats.weakPasswords) << Qt::endl; out << QObject::tr("Number of weak passwords") << ": " << QString::number(stats.weakPasswords) << Qt::endl;
out << QObject::tr("Entries excluded from reports") << ": " << QString::number(stats.excludedEntries) << Qt::endl; out << QObject::tr("Entries excluded from reports") << ": " << QString::number(stats.excludedEntries) << Qt::endl;
out << QObject::tr("Average password length") << ": " << QObject::tr("%1 characters").arg(stats.averagePwdLength()) out << QObject::tr("Average password length") << ": "
<< Qt::endl; << QObject::tr("%1 character(s)", "", stats.averagePwdLength()).arg(stats.averagePwdLength()) << Qt::endl;
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }

View File

@@ -68,11 +68,9 @@ int Diceware::execute(const QStringList& arguments)
dicewareGenerator.setWordList(wordListFile); dicewareGenerator.setWordList(wordListFile);
} }
if (!dicewareGenerator.isValid()) { // Show a warning if the wordlist is smaller than the recommended size
// We already validated the word count input so if the generator is invalid, it if (!dicewareGenerator.isWordListValid()) {
// must be because the word list is too small. err << QObject::tr("Warning: the chosen wordlist is smaller than the minimum recommended size!") << Qt::endl;
err << QObject::tr("Cannot generate valid passphrases because the wordlist is too short") << Qt::endl;
return EXIT_FAILURE;
} }
QString password = dicewareGenerator.generatePassphrase(); QString password = dicewareGenerator.generatePassphrase();

View File

@@ -87,10 +87,10 @@ int Merge::executeWithDatabase(QSharedPointer<Database> database, QSharedPointer
} }
Merger merger(db2.data(), database.data()); Merger merger(db2.data(), database.data());
QStringList changeList = merger.merge(); auto changeList = merger.merge();
for (auto& mergeChange : changeList) { for (const auto& mergeChange : changeList) {
out << "\t" << mergeChange << Qt::endl; out << "\t" << mergeChange.toString() << Qt::endl;
} }
if (!changeList.isEmpty() && !parser->isSet(Merge::DryRunOption)) { if (!changeList.isEmpty() && !parser->isSet(Merge::DryRunOption)) {

View File

@@ -147,6 +147,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> configStrings = {
{Config::Security_HidePasswordPreviewPanel, {QS("Security/HidePasswordPreviewPanel"), Roaming, true}}, {Config::Security_HidePasswordPreviewPanel, {QS("Security/HidePasswordPreviewPanel"), Roaming, true}},
{Config::Security_HideTotpPreviewPanel, {QS("Security/HideTotpPreviewPanel"), Roaming, false}}, {Config::Security_HideTotpPreviewPanel, {QS("Security/HideTotpPreviewPanel"), Roaming, false}},
{Config::Security_AutoTypeAsk, {QS("Security/AutotypeAsk"), Roaming, true}}, {Config::Security_AutoTypeAsk, {QS("Security/AutotypeAsk"), Roaming, true}},
{Config::Security_AutoTypeSkipMainWindowConfirmation, {QS("Security/AutoTypeSkipMainWindowConfirmation"), Roaming, false}},
{Config::Security_IconDownloadFallback, {QS("Security/IconDownloadFallback"), Roaming, false}}, {Config::Security_IconDownloadFallback, {QS("Security/IconDownloadFallback"), Roaming, false}},
{Config::Security_NoConfirmMoveEntryToRecycleBin,{QS("Security/NoConfirmMoveEntryToRecycleBin"), Roaming, true}}, {Config::Security_NoConfirmMoveEntryToRecycleBin,{QS("Security/NoConfirmMoveEntryToRecycleBin"), Roaming, true}},
{Config::Security_EnableCopyOnDoubleClick,{QS("Security/EnableCopyOnDoubleClick"), Roaming, false}}, {Config::Security_EnableCopyOnDoubleClick,{QS("Security/EnableCopyOnDoubleClick"), Roaming, false}},

View File

@@ -98,6 +98,7 @@ public:
GUI_CompactMode, GUI_CompactMode,
GUI_CheckForUpdates, GUI_CheckForUpdates,
GUI_CheckForUpdatesIncludeBetas, GUI_CheckForUpdatesIncludeBetas,
SearchWaitForEnter,
GUI_ShowExpiredEntriesOnDatabaseUnlock, GUI_ShowExpiredEntriesOnDatabaseUnlock,
GUI_ShowExpiredEntriesOnDatabaseUnlockOffsetDays, GUI_ShowExpiredEntriesOnDatabaseUnlockOffsetDays,
GUI_FontSizeOffset, GUI_FontSizeOffset,
@@ -128,6 +129,7 @@ public:
Security_HidePasswordPreviewPanel, Security_HidePasswordPreviewPanel,
Security_HideTotpPreviewPanel, Security_HideTotpPreviewPanel,
Security_AutoTypeAsk, Security_AutoTypeAsk,
Security_AutoTypeSkipMainWindowConfirmation,
Security_IconDownloadFallback, Security_IconDownloadFallback,
Security_NoConfirmMoveEntryToRecycleBin, Security_NoConfirmMoveEntryToRecycleBin,
Security_EnableCopyOnDoubleClick, Security_EnableCopyOnDoubleClick,

View File

@@ -826,7 +826,12 @@ void Database::updateTagList()
} }
m_tagList = tagSet.values(); m_tagList = tagSet.values();
m_tagList.sort();
QCollator collator;
collator.setNumericMode(true);
collator.setCaseSensitivity(Qt::CaseInsensitive);
std::sort(m_tagList.begin(), m_tagList.end(), collator);
emit tagListUpdated(); emit tagListUpdated();
} }

View File

@@ -570,6 +570,12 @@ bool Entry::hasTotp() const
return !m_data.totpSettings.isNull(); return !m_data.totpSettings.isNull();
} }
bool Entry::hasValidTotp() const
{
auto error = Totp::checkValidSettings(m_data.totpSettings);
return error.isEmpty();
}
bool Entry::hasPasskey() const bool Entry::hasPasskey() const
{ {
return m_attributes->hasPasskey(); return m_attributes->hasPasskey();
@@ -581,10 +587,13 @@ void Entry::removePasskey()
removeTag(tr("Passkey")); removeTag(tr("Passkey"));
} }
QString Entry::totp() const QString Entry::totp(bool* isValid) const
{ {
if (hasTotp()) { if (hasTotp()) {
return Totp::generateTotp(m_data.totpSettings); return Totp::generateTotp(m_data.totpSettings, isValid);
}
if (isValid) {
*isValid = false;
} }
return {}; return {};
} }
@@ -950,6 +959,68 @@ bool Entry::equals(const Entry* other, CompareItemOptions options) const
return true; return true;
} }
QStringList Entry::calculateDifference(const Entry* other)
{
QStringList modifiedFields;
if (*attributes() != *other->attributes()) {
bool foundAttribute = false;
if (title() != other->title()) {
modifiedFields << tr("Title");
foundAttribute = true;
}
if (username() != other->username()) {
modifiedFields << tr("Username");
foundAttribute = true;
}
if (password() != other->password()) {
modifiedFields << tr("Password");
foundAttribute = true;
}
if (url() != other->url()) {
modifiedFields << tr("URL");
foundAttribute = true;
}
if (notes() != other->notes()) {
modifiedFields << tr("Notes");
foundAttribute = true;
}
if (!foundAttribute) {
modifiedFields << tr("Custom Attributes");
}
}
if (iconNumber() != other->iconNumber() || iconUuid() != other->iconUuid()) {
modifiedFields << tr("Icon");
}
if (foregroundColor() != other->foregroundColor() || backgroundColor() != other->backgroundColor()) {
modifiedFields << tr("Color");
}
if (timeInfo().expires() != other->timeInfo().expires()
|| timeInfo().expiryTime() != other->timeInfo().expiryTime()) {
modifiedFields << tr("Expiration");
}
if (totp() != other->totp()) {
modifiedFields << tr("TOTP");
}
if (*customData() != *other->customData()) {
modifiedFields << tr("Custom Data");
}
if (*attachments() != *other->attachments()) {
modifiedFields << tr("Attachments");
}
if (*autoTypeAssociations() != *other->autoTypeAssociations() || autoTypeEnabled() != other->autoTypeEnabled()
|| defaultAutoTypeSequence() != other->defaultAutoTypeSequence()) {
modifiedFields << tr("Auto-Type");
}
if (tags() != other->tags()) {
modifiedFields << tr("Tags");
}
return modifiedFields;
}
Entry* Entry::clone(CloneFlags flags) const Entry* Entry::clone(CloneFlags flags) const
{ {
auto entry = new Entry(); auto entry = new Entry();

View File

@@ -109,7 +109,7 @@ public:
QString password() const; QString password() const;
QString notes() const; QString notes() const;
QString attribute(const QString& key) const; QString attribute(const QString& key) const;
QString totp() const; QString totp(bool* isValid = nullptr) const;
QString totpSettingsString() const; QString totpSettingsString() const;
QSharedPointer<Totp::Settings> totpSettings() const; QSharedPointer<Totp::Settings> totpSettings() const;
Group* previousParentGroup(); Group* previousParentGroup();
@@ -126,6 +126,7 @@ public:
void removePasskey(); void removePasskey();
bool hasTotp() const; bool hasTotp() const;
bool hasValidTotp() const;
bool isExpired() const; bool isExpired() const;
bool willExpireInDays(int days) const; bool willExpireInDays(int days) const;
void expireNow(); void expireNow();
@@ -178,6 +179,13 @@ public:
bool equals(const Entry* other, CompareItemOptions options = CompareItemDefault) const; bool equals(const Entry* other, CompareItemOptions options = CompareItemDefault) const;
/**
* Determine differences between attributes of this and another entry.
*
* @return The list of attribute names that are different between the two entries
*/
QStringList calculateDifference(const Entry* other);
enum CloneFlag enum CloneFlag
{ {
CloneNoFlags = 0, CloneNoFlags = 0,

View File

@@ -1,6 +1,6 @@
/* /*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2010 Felix Geyer <debfx@fobos.de> * Copyright (C) 2010 Felix Geyer <debfx@fobos.de>
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -457,6 +457,7 @@ const Group* Group::parentGroup() const
void Group::setParent(Group* parent, int index, bool trackPrevious) void Group::setParent(Group* parent, int index, bool trackPrevious)
{ {
Q_ASSERT(parent); Q_ASSERT(parent);
Q_ASSERT(this != parent);
Q_ASSERT(index >= -1 && index <= parent->children().size()); Q_ASSERT(index >= -1 && index <= parent->children().size());
// setting a new parent for root groups is not allowed // setting a new parent for root groups is not allowed
Q_ASSERT(!m_db || (m_db->rootGroup() != this)); Q_ASSERT(!m_db || (m_db->rootGroup() != this));
@@ -1142,6 +1143,24 @@ bool Group::resolveAutoTypeEnabled() const
} }
} }
bool Group::resolveBrowserOptionEnabled(const QString& option) const
{
switch (resolveCustomDataTriState(option, true)) {
case Inherit:
if (!m_parent) {
return false;
}
return m_parent->resolveBrowserOptionEnabled(option);
case Enable:
return true;
case Disable:
return false;
default:
Q_ASSERT(false);
return false;
}
}
Entry* Group::addEntryWithPath(const QString& entryPath) Entry* Group::addEntryWithPath(const QString& entryPath)
{ {
if (entryPath.isEmpty() || findEntryByPath(entryPath)) { if (entryPath.isEmpty() || findEntryByPath(entryPath)) {

View File

@@ -1,6 +1,6 @@
/* /*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2010 Felix Geyer <debfx@fobos.de> * Copyright (C) 2010 Felix Geyer <debfx@fobos.de>
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -94,6 +94,7 @@ public:
Group::MergeMode mergeMode() const; Group::MergeMode mergeMode() const;
bool resolveSearchingEnabled() const; bool resolveSearchingEnabled() const;
bool resolveAutoTypeEnabled() const; bool resolveAutoTypeEnabled() const;
bool resolveBrowserOptionEnabled(const QString& option) const;
Entry* lastTopVisibleEntry() const; Entry* lastTopVisibleEntry() const;
bool isExpired() const; bool isExpired() const;
bool isRecycled() const; bool isRecycled() const;

View File

@@ -20,28 +20,28 @@
#include <QCoreApplication> #include <QCoreApplication>
#include <QTimer> #include <QTimer>
namespace
{
// Minimum timeout is 10 seconds
constexpr int MIN_TIMEOUT = 10000;
} // namespace
InactivityTimer::InactivityTimer(QObject* parent) InactivityTimer::InactivityTimer(QObject* parent)
: QObject(parent) : QObject(parent)
, m_timer(new QTimer(this)) , m_timer(new QTimer(this))
, m_active(false)
{ {
m_timer->setSingleShot(true); m_timer->setSingleShot(false);
connect(m_timer, SIGNAL(timeout()), SLOT(timeout())); connect(m_timer, SIGNAL(timeout()), SLOT(timeout()));
} }
void InactivityTimer::setInactivityTimeout(int inactivityTimeout) void InactivityTimer::activate(int inactivityTimeout)
{
Q_ASSERT(inactivityTimeout > 0);
m_timer->setInterval(inactivityTimeout);
}
void InactivityTimer::activate()
{ {
if (!m_active) { if (!m_active) {
qApp->installEventFilter(this); qApp->installEventFilter(this);
} }
m_active = true; m_active = true;
m_resetBlocked = false;
m_timer->setInterval(qMax(MIN_TIMEOUT, inactivityTimeout));
m_timer->start(); m_timer->start();
} }
@@ -54,12 +54,15 @@ void InactivityTimer::deactivate()
bool InactivityTimer::eventFilter(QObject* watched, QEvent* event) bool InactivityTimer::eventFilter(QObject* watched, QEvent* event)
{ {
const QEvent::Type type = event->type(); const auto type = event->type();
// clang-format off // clang-format off
if ((type >= QEvent::MouseButtonPress && type <= QEvent::KeyRelease) if (!m_resetBlocked &&
|| (type >= QEvent::HoverEnter && type <= QEvent::HoverMove) ((type >= QEvent::MouseButtonPress && type <= QEvent::KeyRelease) ||
|| (type == QEvent::Wheel)) { (type >= QEvent::HoverEnter && type <= QEvent::HoverMove) ||
type == QEvent::Wheel)) {
m_timer->start(); m_timer->start();
m_resetBlocked = true;
QTimer::singleShot(500, this, [this]() { m_resetBlocked = false; });
} }
// clang-format on // clang-format on
@@ -73,7 +76,7 @@ void InactivityTimer::timeout()
return; return;
} }
if (m_active && !m_timer->isActive()) { if (m_active) {
emit inactivityDetected(); emit inactivityDetected();
} }

View File

@@ -29,8 +29,7 @@ class InactivityTimer : public QObject
public: public:
explicit InactivityTimer(QObject* parent = nullptr); explicit InactivityTimer(QObject* parent = nullptr);
void setInactivityTimeout(int inactivityTimeout); void activate(int inactivityTimeout);
void activate();
void deactivate(); void deactivate();
signals: signals:
@@ -44,7 +43,8 @@ private slots:
private: private:
QTimer* m_timer; QTimer* m_timer;
bool m_active; bool m_active = false;
bool m_resetBlocked = false;
QMutex m_emitMutx; QMutex m_emitMutx;
}; };

View File

@@ -21,6 +21,107 @@
#include "core/Metadata.h" #include "core/Metadata.h"
#include "core/Tools.h" #include "core/Tools.h"
Merger::Change::Change(Type type, QString details)
: m_type{type}
, m_details{std::move(details)}
{
}
Merger::Change::Change(Type type, const Group& group, QString details)
: m_type{type}
, m_group{group.fullPath()}
, m_uuid{group.uuid()}
, m_details{std::move(details)}
{
}
Merger::Change::Change(Type type, const Entry& entry, QString details)
: m_type{type}
, m_title{entry.title()}
, m_uuid{entry.uuid()}
, m_details{std::move(details)}
{
if (const auto* group = entry.group()) {
m_group = group->fullPath();
}
}
Merger::Change::Change(QString details)
: m_details{std::move(details)}
{
}
bool Merger::Change::operator==(const Merger::Change& other) const
{
return m_type == other.m_type && m_group == other.m_group && m_title == other.m_title && m_uuid == other.m_uuid
&& m_details == other.m_details;
}
bool Merger::Change::operator!=(const Merger::Change& other) const
{
return !(*this == other);
}
Merger::Change::Type Merger::Change::type() const
{
return m_type;
}
const QString& Merger::Change::title() const
{
return m_title;
}
const QString& Merger::Change::group() const
{
return m_group;
}
const QUuid& Merger::Change::uuid() const
{
return m_uuid;
}
const QString& Merger::Change::details() const
{
return m_details;
}
QString Merger::Change::typeString() const
{
switch (m_type) {
case Type::Added:
return tr("Added");
case Type::Modified:
return tr("Modified");
case Type::Moved:
return tr("Moved");
case Type::Deleted:
return tr("Deleted");
case Type::Metadata:
return "Metadata";
case Type::Unspecified:
return "";
default:
return "?";
}
}
QString Merger::Change::toString() const
{
QString result;
if (m_type != Type::Unspecified) {
result += QString("%1: ").arg(typeString());
}
if (!m_group.isEmpty()) {
result += QString("'%1'").arg(m_group);
}
if (!m_title.isEmpty()) {
result += QString("/'%1'").arg(m_title);
}
if (!m_uuid.isNull()) {
result += QString(" [%1]").arg(m_uuid.toString());
}
if (!m_details.isEmpty()) {
result += QString(" (%1)").arg(m_details);
}
return result;
}
Merger::Merger(const Database* sourceDb, Database* targetDb) Merger::Merger(const Database* sourceDb, Database* targetDb)
: m_mode(Group::Default) : m_mode(Group::Default)
{ {
@@ -64,8 +165,9 @@ void Merger::setSkipDatabaseCustomData(bool state)
m_skipCustomData = state; m_skipCustomData = state;
} }
QStringList Merger::merge() Merger::ChangeList Merger::merge(bool dryRun)
{ {
m_dryRun = dryRun;
// Order of merge steps is important - it is possible that we // Order of merge steps is important - it is possible that we
// create some items before deleting them afterwards // create some items before deleting them afterwards
ChangeList changes; ChangeList changes;
@@ -74,9 +176,10 @@ QStringList Merger::merge()
changes << mergeMetadata(m_context); changes << mergeMetadata(m_context);
// At this point we have a list of changes we may want to show the user // At this point we have a list of changes we may want to show the user
if (!changes.isEmpty()) { if (!changes.isEmpty() && !dryRun) {
m_context.m_targetDb->markAsModified(); m_context.m_targetDb->markAsModified();
} }
m_dryRun = false;
return changes; return changes;
} }
@@ -88,43 +191,59 @@ Merger::ChangeList Merger::mergeGroup(const MergeContext& context)
for (Entry* sourceEntry : sourceEntries) { for (Entry* sourceEntry : sourceEntries) {
Entry* targetEntry = context.m_targetRootGroup->findEntryByUuid(sourceEntry->uuid()); Entry* targetEntry = context.m_targetRootGroup->findEntryByUuid(sourceEntry->uuid());
if (!targetEntry) { if (!targetEntry) {
changes << tr("Creating missing %1 [%2]").arg(sourceEntry->title(), sourceEntry->uuidToHex());
// This entry does not exist at all. Create it. // This entry does not exist at all. Create it.
targetEntry = sourceEntry->clone(Entry::CloneIncludeHistory); changes << Change(Change::Type::Added, *sourceEntry);
moveEntry(targetEntry, context.m_targetGroup); if (!m_dryRun) {
targetEntry = sourceEntry->clone(Entry::CloneIncludeHistory);
moveEntry(targetEntry, context.m_targetGroup);
}
} else { } else {
// Entry is already present in the database. Update it. // Entry is already present in the database. Update it.
const bool locationChanged = const bool locationChanged =
targetEntry->timeInfo().locationChanged() < sourceEntry->timeInfo().locationChanged(); targetEntry->timeInfo().locationChanged() < sourceEntry->timeInfo().locationChanged();
if (locationChanged && targetEntry->group() != context.m_targetGroup) { if (locationChanged && targetEntry->group() != context.m_targetGroup) {
changes << tr("Relocating %1 [%2]").arg(sourceEntry->title(), sourceEntry->uuidToHex()); changes << Change(
moveEntry(targetEntry, context.m_targetGroup); Change::Type::Moved, *sourceEntry, tr("Previous location: %1").arg(targetEntry->group()->name()));
if (!m_dryRun) {
moveEntry(targetEntry, context.m_targetGroup);
}
} }
changes << resolveEntryConflict(context, sourceEntry, targetEntry); changes << resolveEntryConflict(context, sourceEntry, targetEntry);
} }
} }
// merge groups recursively // merge child groups recursively
const QList<Group*> sourceChildGroups = context.m_sourceGroup->children(); const QList<Group*> sourceChildGroups = context.m_sourceGroup->children();
for (Group* sourceChildGroup : sourceChildGroups) { for (Group* sourceChildGroup : sourceChildGroups) {
bool groupCreated = false;
Group* targetChildGroup = context.m_targetRootGroup->findGroupByUuid(sourceChildGroup->uuid()); Group* targetChildGroup = context.m_targetRootGroup->findGroupByUuid(sourceChildGroup->uuid());
if (!targetChildGroup) { if (!targetChildGroup) {
changes << tr("Creating missing %1 [%2]").arg(sourceChildGroup->name(), sourceChildGroup->uuidToHex()); changes << Change(
Change::Type::Added,
*sourceChildGroup,
tr("Number of entries in group: %1").arg(QString::number(sourceChildGroup->entries().size())));
// Create the target group, it will be cleaned up later if in dry run mode
targetChildGroup = sourceChildGroup->clone(Entry::CloneNoFlags, Group::CloneNoFlags); targetChildGroup = sourceChildGroup->clone(Entry::CloneNoFlags, Group::CloneNoFlags);
moveGroup(targetChildGroup, context.m_targetGroup); groupCreated = true;
TimeInfo timeinfo = targetChildGroup->timeInfo(); if (!m_dryRun) {
timeinfo.setLocationChanged(sourceChildGroup->timeInfo().locationChanged());
targetChildGroup->setTimeInfo(timeinfo);
} else {
bool locationChanged =
targetChildGroup->timeInfo().locationChanged() < sourceChildGroup->timeInfo().locationChanged();
if (locationChanged && targetChildGroup->parent() != context.m_targetGroup) {
changes << tr("Relocating %1 [%2]").arg(sourceChildGroup->name(), sourceChildGroup->uuidToHex());
moveGroup(targetChildGroup, context.m_targetGroup); moveGroup(targetChildGroup, context.m_targetGroup);
TimeInfo timeinfo = targetChildGroup->timeInfo(); TimeInfo timeinfo = targetChildGroup->timeInfo();
timeinfo.setLocationChanged(sourceChildGroup->timeInfo().locationChanged()); timeinfo.setLocationChanged(sourceChildGroup->timeInfo().locationChanged());
targetChildGroup->setTimeInfo(timeinfo); targetChildGroup->setTimeInfo(timeinfo);
} }
} else {
bool locationChanged =
targetChildGroup->timeInfo().locationChanged() < sourceChildGroup->timeInfo().locationChanged();
if (locationChanged && targetChildGroup->parent() != context.m_targetGroup) {
changes << Change(
Change::Type::Moved, *sourceChildGroup, tr("Previous location: %1").arg(targetChildGroup->name()));
if (!m_dryRun) {
moveGroup(targetChildGroup, context.m_targetGroup);
TimeInfo timeinfo = targetChildGroup->timeInfo();
timeinfo.setLocationChanged(sourceChildGroup->timeInfo().locationChanged());
targetChildGroup->setTimeInfo(timeinfo);
}
}
changes << resolveGroupConflict(context, sourceChildGroup, targetChildGroup); changes << resolveGroupConflict(context, sourceChildGroup, targetChildGroup);
} }
MergeContext subcontext{context.m_sourceDb, MergeContext subcontext{context.m_sourceDb,
@@ -134,6 +253,10 @@ Merger::ChangeList Merger::mergeGroup(const MergeContext& context)
sourceChildGroup, sourceChildGroup,
targetChildGroup}; targetChildGroup};
changes << mergeGroup(subcontext); changes << mergeGroup(subcontext);
// Cleanup the temporary target group structure
if (m_dryRun && groupCreated) {
delete subcontext.m_targetGroup;
}
} }
return changes; return changes;
} }
@@ -149,24 +272,68 @@ Merger::resolveGroupConflict(const MergeContext& context, const Group* sourceChi
// only if the other group is newer, update the existing one. // only if the other group is newer, update the existing one.
if (timeExisting < timeOther) { if (timeExisting < timeOther) {
changes << tr("Overwriting %1 [%2]").arg(sourceChildGroup->name(), sourceChildGroup->uuidToHex()); QStringList modifications;
targetChildGroup->setName(sourceChildGroup->name()); auto updateIfNecessary = [&modifications, this](const auto& targetValue,
targetChildGroup->setNotes(sourceChildGroup->notes()); const auto& sourceValue,
auto&& updateFunction,
const QString& modification) {
if (targetValue != sourceValue) {
modifications << modification;
if (!m_dryRun) {
updateFunction(sourceValue);
}
return true;
}
return false;
};
updateIfNecessary(
targetChildGroup->name(),
sourceChildGroup->name(),
[&](auto&& newValue) { targetChildGroup->setName(newValue); },
tr("Group name"));
updateIfNecessary(
targetChildGroup->notes(),
sourceChildGroup->notes(),
[&](auto&& newValue) { targetChildGroup->setNotes(newValue); },
tr("Notes"));
if (sourceChildGroup->iconNumber() == 0) { if (sourceChildGroup->iconNumber() == 0) {
targetChildGroup->setIcon(sourceChildGroup->iconUuid()); updateIfNecessary(
targetChildGroup->iconUuid(),
sourceChildGroup->iconUuid(),
[&](auto&& newValue) { targetChildGroup->setIcon(newValue); },
tr("Icon (UUID)"));
} else { } else {
targetChildGroup->setIcon(sourceChildGroup->iconNumber()); updateIfNecessary(
targetChildGroup->iconNumber(),
sourceChildGroup->iconNumber(),
[&](auto&& newValue) { targetChildGroup->setIcon(newValue); },
tr("Icon (Number)"));
} }
targetChildGroup->setExpiryTime(sourceChildGroup->timeInfo().expiryTime()); updateIfNecessary(
TimeInfo timeInfo = targetChildGroup->timeInfo(); targetChildGroup->timeInfo().expiryTime(),
timeInfo.setLastModificationTime(timeOther); sourceChildGroup->timeInfo().expiryTime(),
targetChildGroup->setTimeInfo(timeInfo); [&](auto&& newValue) { targetChildGroup->setExpiryTime(newValue); },
tr("Expiry time"));
updateIfNecessary(
timeExisting,
timeOther,
[&](auto&& newValue) {
TimeInfo timeInfo = targetChildGroup->timeInfo();
timeInfo.setLastModificationTime(newValue);
targetChildGroup->setTimeInfo(timeInfo);
},
tr("Modification time"));
changes << Change(Change::Type::Modified, *sourceChildGroup, modifications.join(", "));
} }
return changes; return changes;
} }
void Merger::moveEntry(Entry* entry, Group* targetGroup) void Merger::moveEntry(Entry* entry, Group* targetGroup)
{ {
if (m_dryRun) {
return;
}
Q_ASSERT(entry); Q_ASSERT(entry);
Group* sourceGroup = entry->group(); Group* sourceGroup = entry->group();
if (sourceGroup == targetGroup) { if (sourceGroup == targetGroup) {
@@ -196,6 +363,10 @@ void Merger::moveEntry(Entry* entry, Group* targetGroup)
void Merger::moveGroup(Group* group, Group* targetGroup) void Merger::moveGroup(Group* group, Group* targetGroup)
{ {
if (m_dryRun) {
return;
}
Q_ASSERT(group); Q_ASSERT(group);
Group* sourceGroup = group->parentGroup(); Group* sourceGroup = group->parentGroup();
if (sourceGroup == targetGroup) { if (sourceGroup == targetGroup) {
@@ -225,6 +396,10 @@ void Merger::moveGroup(Group* group, Group* targetGroup)
void Merger::eraseEntry(Entry* entry) void Merger::eraseEntry(Entry* entry)
{ {
if (m_dryRun) {
return;
}
Database* database = entry->database(); Database* database = entry->database();
// most simple method to remove an item from DeletedObjects :( // most simple method to remove an item from DeletedObjects :(
const QList<DeletedObject> deletions = database->deletedObjects(); const QList<DeletedObject> deletions = database->deletedObjects();
@@ -242,6 +417,10 @@ void Merger::eraseEntry(Entry* entry)
void Merger::eraseGroup(Group* group) void Merger::eraseGroup(Group* group)
{ {
if (m_dryRun) {
return;
}
Database* database = group->database(); Database* database = group->database();
// most simple method to remove an item from DeletedObjects :( // most simple method to remove an item from DeletedObjects :(
const QList<DeletedObject> deletions = database->deletedObjects(); const QList<DeletedObject> deletions = database->deletedObjects();
@@ -268,6 +447,8 @@ Merger::ChangeList Merger::resolveEntryConflict_MergeHistories(const MergeContex
const int comparison = compare(targetEntry->timeInfo().lastModificationTime(), const int comparison = compare(targetEntry->timeInfo().lastModificationTime(),
sourceEntry->timeInfo().lastModificationTime(), sourceEntry->timeInfo().lastModificationTime(),
CompareItemIgnoreMilliseconds); CompareItemIgnoreMilliseconds);
auto differences = targetEntry->calculateDifference(sourceEntry);
differences += "History";
const int maxItems = targetEntry->database()->metadata()->historyMaxItems(); const int maxItems = targetEntry->database()->metadata()->historyMaxItems();
if (comparison < 0) { if (comparison < 0) {
Group* currentGroup = targetEntry->group(); Group* currentGroup = targetEntry->group();
@@ -276,7 +457,9 @@ Merger::ChangeList Merger::resolveEntryConflict_MergeHistories(const MergeContex
qPrintable(targetEntry->title()), qPrintable(targetEntry->title()),
qPrintable(sourceEntry->title()), qPrintable(sourceEntry->title()),
qPrintable(currentGroup->name())); qPrintable(currentGroup->name()));
changes << tr("Synchronizing from newer source %1 [%2]").arg(targetEntry->title(), targetEntry->uuidToHex()); changes << Change(Change::Type::Modified,
*targetEntry,
tr("%1 (Add local modifications to new entry)").arg(differences.join(", ")));
mergeHistory(targetEntry, clonedEntry, mergeMethod, maxItems); mergeHistory(targetEntry, clonedEntry, mergeMethod, maxItems);
eraseEntry(targetEntry); eraseEntry(targetEntry);
moveEntry(clonedEntry, currentGroup); moveEntry(clonedEntry, currentGroup);
@@ -287,8 +470,9 @@ Merger::ChangeList Merger::resolveEntryConflict_MergeHistories(const MergeContex
qPrintable(targetEntry->group()->name())); qPrintable(targetEntry->group()->name()));
const bool changed = mergeHistory(sourceEntry, targetEntry, mergeMethod, maxItems); const bool changed = mergeHistory(sourceEntry, targetEntry, mergeMethod, maxItems);
if (changed) { if (changed) {
changes changes << Change(Change::Type::Modified,
<< tr("Synchronizing from older source %1 [%2]").arg(targetEntry->title(), targetEntry->uuidToHex()); *targetEntry,
tr("%1 (Add new modifications to existing entry)").arg(differences.join(", ")));
} }
} }
return changes; return changes;
@@ -300,8 +484,10 @@ Merger::resolveEntryConflict(const MergeContext& context, const Entry* sourceEnt
// We need to cut off the milliseconds since the persistent format only supports times down to seconds // We need to cut off the milliseconds since the persistent format only supports times down to seconds
// so when we import data from a remote source, it may represent the (or even some msec newer) data // so when we import data from a remote source, it may represent the (or even some msec newer) data
// which may be discarded due to higher runtime precision // which may be discarded due to higher runtime precision
Group::MergeMode mergeMode = m_mode;
Group::MergeMode mergeMode = m_mode == Group::Default ? context.m_targetGroup->mergeMode() : m_mode; if (mergeMode == Group::Default && context.m_targetGroup) {
mergeMode = context.m_targetGroup->mergeMode();
}
return resolveEntryConflict_MergeHistories(context, sourceEntry, targetEntry, mergeMode); return resolveEntryConflict_MergeHistories(context, sourceEntry, targetEntry, mergeMode);
} }
@@ -324,11 +510,11 @@ bool Merger::mergeHistory(const Entry* sourceEntry,
const QDateTime modificationTime = Clock::serialized(historyItem->timeInfo().lastModificationTime()); const QDateTime modificationTime = Clock::serialized(historyItem->timeInfo().lastModificationTime());
if (merged.contains(modificationTime) if (merged.contains(modificationTime)
&& !merged[modificationTime]->equals(historyItem, CompareItemIgnoreMilliseconds)) { && !merged[modificationTime]->equals(historyItem, CompareItemIgnoreMilliseconds)) {
::qWarning("Inconsistent history entry of %s[%s] at %s contains conflicting changes - conflict resolution " qWarning("Inconsistent history entry of %s[%s] at %s contains conflicting changes - conflict resolution "
"may lose data!", "may lose data!",
qPrintable(sourceEntry->title()), qPrintable(sourceEntry->title()),
qPrintable(sourceEntry->uuidToHex()), qPrintable(sourceEntry->uuidToHex()),
qPrintable(modificationTime.toString("yyyy-MM-dd HH-mm-ss-zzz"))); qPrintable(modificationTime.toString("yyyy-MM-dd HH-mm-ss-zzz")));
} }
merged[modificationTime] = historyItem->clone(Entry::CloneNoFlags); merged[modificationTime] = historyItem->clone(Entry::CloneNoFlags);
} }
@@ -337,11 +523,10 @@ bool Merger::mergeHistory(const Entry* sourceEntry,
const QDateTime modificationTime = Clock::serialized(historyItem->timeInfo().lastModificationTime()); const QDateTime modificationTime = Clock::serialized(historyItem->timeInfo().lastModificationTime());
if (merged.contains(modificationTime) if (merged.contains(modificationTime)
&& !merged[modificationTime]->equals(historyItem, CompareItemIgnoreMilliseconds)) { && !merged[modificationTime]->equals(historyItem, CompareItemIgnoreMilliseconds)) {
::qWarning( qWarning("History entry of %s[%s] at %s contains conflicting changes - conflict resolution may lose data!",
"History entry of %s[%s] at %s contains conflicting changes - conflict resolution may lose data!", qPrintable(sourceEntry->title()),
qPrintable(sourceEntry->title()), qPrintable(sourceEntry->uuidToHex()),
qPrintable(sourceEntry->uuidToHex()), qPrintable(modificationTime.toString("yyyy-MM-dd HH-mm-ss-zzz")));
qPrintable(modificationTime.toString("yyyy-MM-dd HH-mm-ss-zzz")));
} }
if (preferRemote && merged.contains(modificationTime)) { if (preferRemote && merged.contains(modificationTime)) {
// forcefully apply the remote history item // forcefully apply the remote history item
@@ -357,9 +542,9 @@ bool Merger::mergeHistory(const Entry* sourceEntry,
if (targetModificationTime == sourceModificationTime if (targetModificationTime == sourceModificationTime
&& !targetEntry->equals(sourceEntry, && !targetEntry->equals(sourceEntry,
CompareItemIgnoreMilliseconds | CompareItemIgnoreHistory | CompareItemIgnoreLocation)) { CompareItemIgnoreMilliseconds | CompareItemIgnoreHistory | CompareItemIgnoreLocation)) {
::qWarning("Entry of %s[%s] contains conflicting changes - conflict resolution may lose data!", qWarning("Entry of %s[%s] contains conflicting changes - conflict resolution may lose data!",
qPrintable(sourceEntry->title()), qPrintable(sourceEntry->title()),
qPrintable(sourceEntry->uuidToHex())); qPrintable(sourceEntry->uuidToHex()));
} }
if (targetModificationTime < sourceModificationTime) { if (targetModificationTime < sourceModificationTime) {
@@ -398,22 +583,24 @@ bool Merger::mergeHistory(const Entry* sourceEntry,
qDeleteAll(updatedHistoryItems); qDeleteAll(updatedHistoryItems);
return false; return false;
} }
// We need to prevent any modification to the database since every change should be tracked either if (!m_dryRun) {
// in a clone history item or in the Entry itself // We need to prevent any modification to the database since every change should be tracked either
const TimeInfo timeInfo = targetEntry->timeInfo(); // in a clone history item or in the Entry itself
const bool blockedSignals = targetEntry->blockSignals(true); const TimeInfo timeInfo = targetEntry->timeInfo();
bool updateTimeInfo = targetEntry->canUpdateTimeinfo(); const bool blockedSignals = targetEntry->blockSignals(true);
targetEntry->setUpdateTimeinfo(false); bool updateTimeInfo = targetEntry->canUpdateTimeinfo();
targetEntry->removeHistoryItems(targetHistoryItems); targetEntry->setUpdateTimeinfo(false);
for (Entry* historyItem : merged) { targetEntry->removeHistoryItems(targetHistoryItems);
Q_ASSERT(!historyItem->parent()); for (Entry* historyItem : merged) {
targetEntry->addHistoryItem(historyItem); Q_ASSERT(!historyItem->parent());
targetEntry->addHistoryItem(historyItem);
}
targetEntry->truncateHistory();
targetEntry->blockSignals(blockedSignals);
targetEntry->setUpdateTimeinfo(updateTimeInfo);
Q_ASSERT(timeInfo == targetEntry->timeInfo());
Q_UNUSED(timeInfo);
} }
targetEntry->truncateHistory();
targetEntry->blockSignals(blockedSignals);
targetEntry->setUpdateTimeinfo(updateTimeInfo);
Q_ASSERT(timeInfo == targetEntry->timeInfo());
Q_UNUSED(timeInfo);
return true; return true;
} }
@@ -437,7 +624,6 @@ Merger::ChangeList Merger::mergeDeletions(const MergeContext& context)
for (const auto& object : (targetDeletions + sourceDeletions)) { for (const auto& object : (targetDeletions + sourceDeletions)) {
if (!mergedDeletions.contains(object.uuid)) { if (!mergedDeletions.contains(object.uuid)) {
mergedDeletions[object.uuid] = object; mergedDeletions[object.uuid] = object;
auto* entry = context.m_targetRootGroup->findEntryByUuid(object.uuid); auto* entry = context.m_targetRootGroup->findEntryByUuid(object.uuid);
if (entry) { if (entry) {
entries << entry; entries << entry;
@@ -465,17 +651,18 @@ Merger::ChangeList Merger::mergeDeletions(const MergeContext& context)
} }
deletions << object; deletions << object;
if (entry->group()) { if (entry->group()) {
changes << tr("Deleting child %1 [%2]").arg(entry->title(), entry->uuidToHex()); changes << Change(Change::Type::Deleted, *entry, tr("Explicit deletion"));
} else { } else {
changes << tr("Deleting orphan %1 [%2]").arg(entry->title(), entry->uuidToHex()); changes << Change(Change::Type::Deleted, *entry, tr("Implicit deletion (e.g. removal of parent group)"));
}
if (!m_dryRun) {
eraseEntry(entry);
} }
// Entry is inserted into deletedObjects after deletions are processed
eraseEntry(entry);
} }
while (!groups.isEmpty()) { while (!groups.isEmpty()) {
auto* group = groups.takeFirst(); auto* group = groups.takeFirst();
if (Tools::asSet(group->children()).intersects(Tools::asSet(groups))) { if (!(group->children().toSet() & groups.toSet()).isEmpty()) {
// we need to finish all children before we are able to determine if the group can be removed // we need to finish all children before we are able to determine if the group can be removed
groups << group; groups << group;
continue; continue;
@@ -491,17 +678,22 @@ Merger::ChangeList Merger::mergeDeletions(const MergeContext& context)
} }
deletions << object; deletions << object;
if (group->parentGroup()) { if (group->parentGroup()) {
changes << tr("Deleting child %1 [%2]").arg(group->name(), group->uuidToHex()); changes << Change(Change::Type::Deleted, *group, tr("Explicit deletion"));
} else { } else {
changes << tr("Deleting orphan %1 [%2]").arg(group->name(), group->uuidToHex()); changes << Change(Change::Type::Deleted, *group, tr("Implicit deletion (e.g. removal of parent group)"));
}
if (!m_dryRun) {
eraseGroup(group);
} }
eraseGroup(group);
} }
// Put every deletion to the earliest date of deletion // Put every deletion to the earliest date of deletion
if (deletions != context.m_targetDb->deletedObjects()) { if (deletions != context.m_targetDb->deletedObjects()) {
changes << tr("Changed deleted objects"); changes << Change(Change::Type::Metadata, tr("Changed deleted objects"));
if (!m_dryRun) {
context.m_targetDb->setDeletedObjects(deletions);
}
} }
context.m_targetDb->setDeletedObjects(deletions);
return changes; return changes;
} }
@@ -516,8 +708,11 @@ Merger::ChangeList Merger::mergeMetadata(const MergeContext& context)
for (const auto& iconUuid : sourceMetadata->customIconsOrder()) { for (const auto& iconUuid : sourceMetadata->customIconsOrder()) {
if (!targetMetadata->hasCustomIcon(iconUuid)) { if (!targetMetadata->hasCustomIcon(iconUuid)) {
targetMetadata->addCustomIcon(iconUuid, sourceMetadata->customIcon(iconUuid)); changes << Change(Change::Type::Metadata,
changes << tr("Adding missing icon %1").arg(QString::fromLatin1(iconUuid.toRfc4122().toHex())); tr("Adding new icon %1").arg(QString::fromLatin1(iconUuid.toRfc4122().toHex())));
if (!m_dryRun) {
targetMetadata->addCustomIcon(iconUuid, sourceMetadata->customIcon(iconUuid));
}
} }
} }
@@ -540,8 +735,10 @@ Merger::ChangeList Merger::mergeMetadata(const MergeContext& context)
// Do not remove protected custom data // Do not remove protected custom data
if (!sourceMetadata->customData()->contains(key) && !sourceMetadata->customData()->isProtected(key)) { if (!sourceMetadata->customData()->contains(key) && !sourceMetadata->customData()->isProtected(key)) {
auto value = targetMetadata->customData()->value(key); auto value = targetMetadata->customData()->value(key);
targetMetadata->customData()->remove(key); changes << Change(Change::Type::Metadata, tr("Removed custom data %1 [%2]").arg(key, value));
changes << tr("Removed custom data %1 [%2]").arg(key, value); if (!m_dryRun) {
targetMetadata->customData()->remove(key);
}
} }
} }
@@ -556,8 +753,10 @@ Merger::ChangeList Merger::mergeMetadata(const MergeContext& context)
auto targetValue = targetMetadata->customData()->value(key); auto targetValue = targetMetadata->customData()->value(key);
// Merge only if the values are not the same. // Merge only if the values are not the same.
if (sourceValue != targetValue) { if (sourceValue != targetValue) {
targetMetadata->customData()->set(key, sourceValue); changes << Change(Change::Type::Metadata, tr("Adding custom data %1 [%2]").arg(key, sourceValue));
changes << tr("Adding custom data %1 [%2]").arg(key, sourceValue); if (!m_dryRun) {
targetMetadata->customData()->set(key, sourceValue);
}
} }
} }
} }

View File

@@ -32,12 +32,51 @@ public:
void setForcedMergeMode(Group::MergeMode mode); void setForcedMergeMode(Group::MergeMode mode);
void resetForcedMergeMode(); void resetForcedMergeMode();
void setSkipDatabaseCustomData(bool state); void setSkipDatabaseCustomData(bool state);
QStringList merge();
class Change
{
public:
enum class Type
{
Unspecified,
Added,
Modified,
Moved,
Deleted,
Metadata,
};
Change(Type type, QString details);
Change(Type type, const Group& group, QString details = "");
Change(Type type, const Entry& entry, QString details = "");
explicit Change(QString details = "");
[[nodiscard]] Type type() const;
[[nodiscard]] QString typeString() const;
[[nodiscard]] const QString& title() const;
[[nodiscard]] const QString& group() const;
[[nodiscard]] const QUuid& uuid() const;
[[nodiscard]] const QString& details() const;
[[nodiscard]] QString toString() const;
void merge();
bool operator==(const Change& other) const;
bool operator!=(const Change& other) const;
private:
Type m_type{Type::Unspecified};
QString m_title;
QString m_group;
QUuid m_uuid;
QString m_details;
};
using ChangeList = QList<Change>;
ChangeList merge(bool dryRun = false);
private: private:
typedef QString Change;
typedef QStringList ChangeList;
struct MergeContext struct MergeContext
{ {
QPointer<const Database> m_sourceDb; QPointer<const Database> m_sourceDb;
@@ -47,6 +86,7 @@ private:
QPointer<const Group> m_sourceGroup; QPointer<const Group> m_sourceGroup;
QPointer<Group> m_targetGroup; QPointer<Group> m_targetGroup;
}; };
ChangeList mergeGroup(const MergeContext& context); ChangeList mergeGroup(const MergeContext& context);
ChangeList mergeDeletions(const MergeContext& context); ChangeList mergeDeletions(const MergeContext& context);
ChangeList mergeMetadata(const MergeContext& context); ChangeList mergeMetadata(const MergeContext& context);
@@ -68,6 +108,7 @@ private:
MergeContext m_context; MergeContext m_context;
Group::MergeMode m_mode; Group::MergeMode m_mode;
bool m_skipCustomData = false; bool m_skipCustomData = false;
bool m_dryRun = false;
}; };
#endif // KEEPASSXC_MERGER_H #endif // KEEPASSXC_MERGER_H

View File

@@ -99,7 +99,7 @@ void PassphraseGenerator::setWordList(const QString& path)
m_wordlist = wordset.toList(); m_wordlist = wordset.toList();
if (m_wordlist.size() < m_minimum_wordlist_length) { if (!isWordListValid()) {
qWarning("Wordlist is less than minimum acceptable size: %s", qPrintable(path)); qWarning("Wordlist is less than minimum acceptable size: %s", qPrintable(path));
} }
} }
@@ -117,8 +117,7 @@ void PassphraseGenerator::setWordSeparator(const QString& separator)
QString PassphraseGenerator::generatePassphrase() const QString PassphraseGenerator::generatePassphrase() const
{ {
// In case there was an error loading the wordlist if (m_wordlist.isEmpty()) {
if (!isValid() || m_wordlist.empty()) {
return {}; return {};
} }
@@ -149,7 +148,7 @@ QString PassphraseGenerator::generatePassphrase() const
return words.join(m_separator); return words.join(m_separator);
} }
bool PassphraseGenerator::isValid() const bool PassphraseGenerator::isWordListValid() const
{ {
return m_wordCount > 0 && m_wordlist.size() >= m_minimum_wordlist_length; return m_wordlist.size() >= m_minWordListSize;
} }

View File

@@ -40,7 +40,7 @@ public:
void setWordCase(PassphraseWordCase wordCase); void setWordCase(PassphraseWordCase wordCase);
void setDefaultWordList(); void setDefaultWordList();
void setWordSeparator(const QString& separator); void setWordSeparator(const QString& separator);
bool isValid() const; bool isWordListValid() const;
QString generatePassphrase() const; QString generatePassphrase() const;
@@ -50,7 +50,7 @@ public:
private: private:
int m_wordCount; int m_wordCount;
int m_minimum_wordlist_length = 4000; int m_minWordListSize = 1296;
PassphraseWordCase m_wordCase; PassphraseWordCase m_wordCase;
QString m_separator; QString m_separator;
QList<QString> m_wordlist; QList<QString> m_wordlist;

View File

@@ -35,6 +35,7 @@
#include <QIODevice> #include <QIODevice>
#include <QLocale> #include <QLocale>
#include <QMetaProperty> #include <QMetaProperty>
#include <QMimeDatabase>
#include <QRegularExpression> #include <QRegularExpression>
#include <QStringList> #include <QStringList>
#include <QUrl> #include <QUrl>
@@ -150,23 +151,23 @@ namespace Tools
if (seconds >= secondsInYear) { if (seconds >= secondsInYear) {
auto years = std::floor(seconds / secondsInYear); auto years = std::floor(seconds / secondsInYear);
return QObject::tr("over %1 year(s)", nullptr, years).arg(years); return QObject::tr("over %1 year(s)", "", years).arg(years);
} else if (seconds >= secondsInMonth) { } else if (seconds >= secondsInMonth) {
auto months = std::round(seconds / secondsInMonth); auto months = std::round(seconds / secondsInMonth);
return QObject::tr("about %1 month(s)", nullptr, months).arg(months); return QObject::tr("about %1 month(s)", "", months).arg(months);
} else if (seconds >= secondsInWeek) { } else if (seconds >= secondsInWeek) {
auto weeks = std::round(seconds / secondsInWeek); auto weeks = std::round(seconds / secondsInWeek);
return QObject::tr("%1 week(s)", nullptr, weeks).arg(weeks); return QObject::tr("%1 week(s)", "", weeks).arg(weeks);
} else if (seconds >= secondsInDay) { } else if (seconds >= secondsInDay) {
auto days = std::floor(seconds / secondsInDay); auto days = std::floor(seconds / secondsInDay);
return QObject::tr("%1 day(s)", nullptr, days).arg(days); return QObject::tr("%1 day(s)", "", days).arg(days);
} else if (seconds >= secondsInHour) { } else if (seconds >= secondsInHour) {
auto hours = std::floor(seconds / secondsInHour); auto hours = std::floor(seconds / secondsInHour);
return QObject::tr("%1 hour(s)", nullptr, hours).arg(hours); return QObject::tr("%1 hour(s)", "", hours).arg(hours);
} }
auto minutes = std::floor(seconds / 60); auto minutes = std::floor(seconds / 60);
return QObject::tr("%1 minute(s)", nullptr, minutes).arg(minutes); return QObject::tr("%1 minute(s)", "", minutes).arg(minutes);
} }
bool readFromDevice(QIODevice* device, QByteArray& data, int size) bool readFromDevice(QIODevice* device, QByteArray& data, int size)
@@ -478,29 +479,57 @@ namespace Tools
MimeType toMimeType(const QString& mimeName) MimeType toMimeType(const QString& mimeName)
{ {
static QStringList textFormats = { const static QStringList TextFormats = {"text/",
"text/", "application/json",
"application/json", "application/xml",
"application/xml", "application/soap+xml",
"application/soap+xml", "application/x-yaml",
"application/x-yaml", "application/protobuf",
"application/protobuf", "application/x-zerosize"};
}; const static QStringList HtmlFormats = {"text/html"};
static QStringList imageFormats = {"image/"}; const static QStringList MarkdownFormats = {"text/markdown"};
const static QStringList ImageFormats = {"image/"};
static auto isCompatible = [](const QString& format, const QStringList& list) { static auto isCompatible = [](const QString& format, const QStringList& list) {
return std::any_of( return std::any_of(
list.cbegin(), list.cend(), [&format](const auto& item) { return format.startsWith(item); }); list.cbegin(), list.cend(), [&format](const auto& item) { return format.startsWith(item); });
}; };
if (isCompatible(mimeName, imageFormats)) { if (isCompatible(mimeName, ImageFormats)) {
return MimeType::Image; return MimeType::Image;
} }
if (isCompatible(mimeName, textFormats)) { if (isCompatible(mimeName, TextFormats)) {
if (isCompatible(mimeName, HtmlFormats)) {
return MimeType::Html;
} else if (isCompatible(mimeName, MarkdownFormats)) {
return MimeType::Markdown;
}
return MimeType::PlainText; return MimeType::PlainText;
} }
return MimeType::Unknown; return MimeType::Unknown;
} }
MimeType getMimeType(const QByteArray& data)
{
QMimeDatabase mimeDb;
const auto mime = mimeDb.mimeTypeForData(data);
return toMimeType(mime.name());
}
MimeType getMimeType(const QFileInfo& fileInfo)
{
QMimeDatabase mimeDb;
const auto mime = mimeDb.mimeTypeForFile(fileInfo);
return toMimeType(mime.name());
}
bool isTextMimeType(MimeType mimeType)
{
return mimeType == Tools::MimeType::PlainText || mimeType == Tools::MimeType::Html
|| mimeType == Tools::MimeType::Markdown;
}
} // namespace Tools } // namespace Tools

View File

@@ -22,6 +22,7 @@
#include "core/Global.h" #include "core/Global.h"
#include <QDateTime> #include <QDateTime>
#include <QFileInfo>
#include <QList> #include <QList>
#include <QProcessEnvironment> #include <QProcessEnvironment>
#include <QSet> #include <QSet>
@@ -119,10 +120,16 @@ namespace Tools
{ {
Image, Image,
PlainText, PlainText,
Html,
Markdown,
Unknown Unknown
}; };
MimeType toMimeType(const QString& mimeName); MimeType toMimeType(const QString& mimeName);
MimeType getMimeType(const QByteArray& data);
MimeType getMimeType(const QFileInfo& fileInfo);
bool isTextMimeType(MimeType mimeType);
} // namespace Tools } // namespace Tools
#endif // KEEPASSX_TOOLS_H #endif // KEEPASSX_TOOLS_H

View File

@@ -210,12 +210,33 @@ QString Totp::writeSettings(const QSharedPointer<Totp::Settings>& settings,
} }
} }
QString Totp::generateTotp(const QSharedPointer<Totp::Settings>& settings, const quint64 time) QString Totp::checkValidSettings(const QSharedPointer<Totp::Settings>& settings)
{ {
Q_ASSERT(!settings.isNull());
if (settings.isNull()) { if (settings.isNull()) {
return QObject::tr("Invalid Settings", "TOTP"); return QObject::tr("Invalid Settings", "TOTP");
} }
QVariant secret = Base32::decode(Base32::sanitizeInput(settings->key.toLatin1()));
if (secret.isNull()) {
return QObject::tr("Invalid Key", "TOTP");
}
if (settings->step == 0) {
return QObject::tr("Invalid Step", "TOTP");
}
if (settings->digits == 0) {
return QObject::tr("Invalid Digits", "TOTP");
}
return {};
}
QString Totp::generateTotp(const QSharedPointer<Totp::Settings>& settings, bool* isValid, const quint64 time)
{
auto error = checkValidSettings(settings);
if (!error.isEmpty()) {
if (isValid) {
*isValid = false;
}
return error;
}
const Encoder& encoder = settings->encoder; const Encoder& encoder = settings->encoder;
uint step = settings->step; uint step = settings->step;
@@ -229,9 +250,6 @@ QString Totp::generateTotp(const QSharedPointer<Totp::Settings>& settings, const
} }
QVariant secret = Base32::decode(Base32::sanitizeInput(settings->key.toLatin1())); QVariant secret = Base32::decode(Base32::sanitizeInput(settings->key.toLatin1()));
if (secret.isNull()) {
return QObject::tr("Invalid Key", "TOTP");
}
QCryptographicHash::Algorithm cryptoHash; QCryptographicHash::Algorithm cryptoHash;
switch (settings->algorithm) { switch (settings->algorithm) {
@@ -274,6 +292,9 @@ QString Totp::generateTotp(const QSharedPointer<Totp::Settings>& settings, const
retval[pos] = encoder.alphabet[int(password % encoder.alphabet.size())]; retval[pos] = encoder.alphabet[int(password % encoder.alphabet.size())];
password /= encoder.alphabet.size(); password /= encoder.alphabet.size();
} }
if (isValid) {
*isValid = true;
}
return retval; return retval;
} }

View File

@@ -91,8 +91,10 @@ namespace Totp
const QString& title = {}, const QString& title = {},
const QString& username = {}, const QString& username = {},
bool forceOtp = false); bool forceOtp = false);
// Returns an empty string if settings are valid, otherwise an error message is supplied
QString generateTotp(const QSharedPointer<Totp::Settings>& settings, const quint64 time = 0ull); QString checkValidSettings(const QSharedPointer<Totp::Settings>& settings);
QString
generateTotp(const QSharedPointer<Totp::Settings>& settings, bool* isValid = nullptr, const quint64 time = 0ull);
bool hasCustomSettings(const QSharedPointer<Totp::Settings>& settings); bool hasCustomSettings(const QSharedPointer<Totp::Settings>& settings);

View File

@@ -33,11 +33,11 @@
*/ */
Argon2Kdf::Argon2Kdf(Type type) Argon2Kdf::Argon2Kdf(Type type)
: Kdf::Kdf(type == Type::Argon2d ? KeePass2::KDF_ARGON2D : KeePass2::KDF_ARGON2ID) : Kdf::Kdf(type == Type::Argon2d ? KeePass2::KDF_ARGON2D : KeePass2::KDF_ARGON2ID)
, m_version(0x13) , m_version(ARGON2_DEFAULT_VERSION)
, m_memory(1 << 16) , m_memory(ARGON2_DEFAULT_MEMORY)
, m_parallelism(static_cast<quint32>(QThread::idealThreadCount())) , m_parallelism(qMin<quint32>(QThread::idealThreadCount(), ARGON2_DEFAULT_PARALLELISM))
{ {
m_rounds = 10; m_rounds = ARGON2_DEFAULT_ROUNDS;
} }
quint32 Argon2Kdf::version() const quint32 Argon2Kdf::version() const
@@ -52,7 +52,7 @@ bool Argon2Kdf::setVersion(quint32 version)
m_version = version; m_version = version;
return true; return true;
} }
m_version = 0x13; m_version = ARGON2_DEFAULT_VERSION;
return false; return false;
} }
@@ -73,7 +73,7 @@ bool Argon2Kdf::setMemory(quint64 kibibytes)
m_memory = kibibytes; m_memory = kibibytes;
return true; return true;
} }
m_memory = 16; m_memory = ARGON2_DEFAULT_MEMORY;
return false; return false;
} }
@@ -89,7 +89,7 @@ bool Argon2Kdf::setParallelism(quint32 threads)
m_parallelism = threads; m_parallelism = threads;
return true; return true;
} }
m_parallelism = 1; m_parallelism = ARGON2_DEFAULT_PARALLELISM;
return false; return false;
} }

View File

@@ -20,6 +20,11 @@
#include "Kdf.h" #include "Kdf.h"
constexpr auto ARGON2_DEFAULT_VERSION = 0x13;
constexpr auto ARGON2_DEFAULT_ROUNDS = 10;
constexpr auto ARGON2_DEFAULT_MEMORY = 1 << 16;
constexpr auto ARGON2_DEFAULT_PARALLELISM = 4;
class Argon2Kdf : public Kdf class Argon2Kdf : public Kdf
{ {
public: public:
@@ -47,6 +52,15 @@ public:
int benchmark(int msec) const override; int benchmark(int msec) const override;
static quint64 toMebibytes(quint64 kibibytes)
{
return kibibytes >> 10;
}
static quint64 toKibibytes(quint64 mebibits)
{
return mebibits << 10;
}
quint32 m_version; quint32 m_version;
quint64 m_memory; quint64 m_memory;
quint32 m_parallelism; quint32 m_parallelism;

View File

@@ -125,7 +125,7 @@ namespace FdoSecrets
// add some informative and readonly attributes // add some informative and readonly attributes
attrs[ItemAttributes::UuidKey] = m_backend->uuidToHex(); attrs[ItemAttributes::UuidKey] = m_backend->uuidToHex();
attrs[ItemAttributes::PathKey] = path(); attrs[ItemAttributes::PathKey] = path();
if (m_backend->hasTotp()) { if (m_backend->hasValidTotp()) {
attrs[ItemAttributes::TotpKey] = m_backend->totp(); attrs[ItemAttributes::TotpKey] = m_backend->totp();
} }
return {}; return {};

View File

@@ -73,7 +73,13 @@ namespace
} }
if (loginMap.contains("itemEmail")) { if (loginMap.contains("itemEmail")) {
entry->attributes()->set("login_email", loginMap.value("itemEmail").toString()); // Place the email value as the username if empty, otherwise set it as an attribute
const auto email = loginMap.value("itemEmail").toString();
if (entry->username().isEmpty()) {
entry->setUsername(email);
} else if (!email.isEmpty()) {
entry->attributes()->set("login_email", email);
}
} }
// Set the entry url(s) // Set the entry url(s)

View File

@@ -113,7 +113,7 @@ Application::Application(int& argc, char** argv)
.toUtf8() .toUtf8()
.constData(); .constData();
// forceably reset the lock file // forcibly reset the lock file
m_lockFile->removeStaleLockFile(); m_lockFile->removeStaleLockFile();
m_lockFile->tryLock(); m_lockFile->tryLock();
// start the listen server // start the listen server

View File

@@ -27,9 +27,11 @@
#include "autotype/AutoType.h" #include "autotype/AutoType.h"
#include "core/Translator.h" #include "core/Translator.h"
#include "gui/GuiTools.h"
#include "gui/Icons.h" #include "gui/Icons.h"
#include "gui/MainWindow.h" #include "gui/MainWindow.h"
#include "gui/osutils/OSUtils.h" #include "gui/osutils/OSUtils.h"
#include "gui/styles/StateColorPalette.h"
#include "quickunlock/QuickUnlockInterface.h" #include "quickunlock/QuickUnlockInterface.h"
#include "FileDialog.h" #include "FileDialog.h"
@@ -62,28 +64,6 @@ private:
QWidget* widget; QWidget* widget;
}; };
/**
* Helper class to ignore mouse wheel events on non-focused widgets
* NOTE: The widget must NOT have a focus policy of "WHEEL"
*/
class MouseWheelEventFilter : public QObject
{
public:
explicit MouseWheelEventFilter(QObject* parent)
: QObject(parent){};
protected:
bool eventFilter(QObject* obj, QEvent* event) override
{
const auto* widget = qobject_cast<QWidget*>(obj);
if (event->type() == QEvent::Wheel && widget && !widget->hasFocus()) {
event->ignore();
return true;
}
return QObject::eventFilter(obj, event);
}
};
ApplicationSettingsWidget::ApplicationSettingsWidget(QWidget* parent) ApplicationSettingsWidget::ApplicationSettingsWidget(QWidget* parent)
: EditWidget(parent) : EditWidget(parent)
, m_secWidget(new QWidget()) , m_secWidget(new QWidget())
@@ -129,6 +109,8 @@ ApplicationSettingsWidget::ApplicationSettingsWidget(QWidget* parent)
connect(m_generalUi->backupFilePathPicker, SIGNAL(pressed()), SLOT(selectBackupDirectory())); connect(m_generalUi->backupFilePathPicker, SIGNAL(pressed()), SLOT(selectBackupDirectory()));
connect(m_generalUi->showExpiredEntriesOnDatabaseUnlockCheckBox, SIGNAL(toggled(bool)), connect(m_generalUi->showExpiredEntriesOnDatabaseUnlockCheckBox, SIGNAL(toggled(bool)),
SLOT(showExpiredEntriesOnDatabaseUnlockToggled(bool))); SLOT(showExpiredEntriesOnDatabaseUnlockToggled(bool)));
connect(m_generalUi->autoTypeAskCheckBox, SIGNAL(toggled(bool)),
SLOT(autoTypeAskToggled(bool)));
connect(m_secUi->clearClipboardCheckBox, SIGNAL(toggled(bool)), connect(m_secUi->clearClipboardCheckBox, SIGNAL(toggled(bool)),
m_secUi->clearClipboardSpinBox, SLOT(setEnabled(bool))); m_secUi->clearClipboardSpinBox, SLOT(setEnabled(bool)));
@@ -155,7 +137,10 @@ ApplicationSettingsWidget::ApplicationSettingsWidget(QWidget* parent)
m_generalUi->autoTypeShortcutWidget->setStyleSheet(""); m_generalUi->autoTypeShortcutWidget->setStyleSheet("");
} else { } else {
QToolTip::showText(mapToGlobal(rect().bottomLeft()), error); QToolTip::showText(mapToGlobal(rect().bottomLeft()), error);
m_generalUi->autoTypeShortcutWidget->setStyleSheet("background-color: #FF9696;"); StateColorPalette statePalette;
auto color = statePalette.color(StateColorPalette::ColorRole::Error);
m_generalUi->autoTypeShortcutWidget->setStyleSheet(
QString("QLineEdit { background: %1; }").arg(color.name()));
} }
}); });
connect(m_generalUi->autoTypeShortcutWidget, &ShortcutWidget::shortcutReset, this, [this] { connect(m_generalUi->autoTypeShortcutWidget, &ShortcutWidget::shortcutReset, this, [this] {
@@ -302,6 +287,9 @@ void ApplicationSettingsWidget::loadSettings()
showExpiredEntriesOnDatabaseUnlockToggled(m_generalUi->showExpiredEntriesOnDatabaseUnlockCheckBox->isChecked()); showExpiredEntriesOnDatabaseUnlockToggled(m_generalUi->showExpiredEntriesOnDatabaseUnlockCheckBox->isChecked());
m_generalUi->autoTypeAskCheckBox->setChecked(config()->get(Config::Security_AutoTypeAsk).toBool()); m_generalUi->autoTypeAskCheckBox->setChecked(config()->get(Config::Security_AutoTypeAsk).toBool());
m_generalUi->autoTypeSkipMainWindowConfirmationCheckBox->setChecked(
config()->get(Config::Security_AutoTypeSkipMainWindowConfirmation).toBool());
autoTypeAskToggled(m_generalUi->autoTypeAskCheckBox->isChecked());
m_generalUi->autoTypeRelockDatabaseCheckBox->setChecked(config()->get(Config::Security_RelockAutoType).toBool()); m_generalUi->autoTypeRelockDatabaseCheckBox->setChecked(config()->get(Config::Security_RelockAutoType).toBool());
if (autoType()->isAvailable()) { if (autoType()->isAvailable()) {
@@ -445,6 +433,8 @@ void ApplicationSettingsWidget::saveSettings()
m_generalUi->showExpiredEntriesOnDatabaseUnlockOffsetSpinBox->value()); m_generalUi->showExpiredEntriesOnDatabaseUnlockOffsetSpinBox->value());
config()->set(Config::Security_AutoTypeAsk, m_generalUi->autoTypeAskCheckBox->isChecked()); config()->set(Config::Security_AutoTypeAsk, m_generalUi->autoTypeAskCheckBox->isChecked());
config()->set(Config::Security_AutoTypeSkipMainWindowConfirmation,
m_generalUi->autoTypeSkipMainWindowConfirmationCheckBox->isChecked());
config()->set(Config::Security_RelockAutoType, m_generalUi->autoTypeRelockDatabaseCheckBox->isChecked()); config()->set(Config::Security_RelockAutoType, m_generalUi->autoTypeRelockDatabaseCheckBox->isChecked());
if (autoType()->isAvailable()) { if (autoType()->isAvailable()) {
@@ -618,6 +608,11 @@ void ApplicationSettingsWidget::showExpiredEntriesOnDatabaseUnlockToggled(bool c
m_generalUi->showExpiredEntriesOnDatabaseUnlockOffsetSpinBox->setEnabled(checked); m_generalUi->showExpiredEntriesOnDatabaseUnlockOffsetSpinBox->setEnabled(checked);
} }
void ApplicationSettingsWidget::autoTypeAskToggled(bool checked)
{
m_generalUi->autoTypeSkipMainWindowConfirmationCheckBox->setEnabled(checked);
}
void ApplicationSettingsWidget::selectBackupDirectory() void ApplicationSettingsWidget::selectBackupDirectory()
{ {
auto backupDirectory = auto backupDirectory =
@@ -626,4 +621,4 @@ void ApplicationSettingsWidget::selectBackupDirectory()
m_generalUi->backupFilePath->setText( m_generalUi->backupFilePath->setText(
QDir(backupDirectory).filePath(config()->getDefault(Config::BackupFilePathPattern).toString())); QDir(backupDirectory).filePath(config()->getDefault(Config::BackupFilePathPattern).toString()));
} }
} }

View File

@@ -63,6 +63,7 @@ private slots:
void rememberDatabasesToggled(bool checked); void rememberDatabasesToggled(bool checked);
void checkUpdatesToggled(bool checked); void checkUpdatesToggled(bool checked);
void showExpiredEntriesOnDatabaseUnlockToggled(bool checked); void showExpiredEntriesOnDatabaseUnlockToggled(bool checked);
void autoTypeAskToggled(bool checked);
void selectBackupDirectory(); void selectBackupDirectory();
private: private:

View File

@@ -1157,6 +1157,42 @@
</property> </property>
</widget> </widget>
</item> </item>
<item>
<layout class="QHBoxLayout" name="autoTypeSkipMainWindowLayout">
<property name="spacing">
<number>0</number>
</property>
<item>
<spacer name="autoTypeSkipMainWindowSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>30</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QCheckBox" name="autoTypeSkipMainWindowConfirmationCheckBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Skip confirmation for main window Auto-Type actions</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
<item> <item>
<widget class="QCheckBox" name="autoTypeHideExpiredEntryCheckBox"> <widget class="QCheckBox" name="autoTypeHideExpiredEntryCheckBox">
<property name="text"> <property name="text">
@@ -1321,6 +1357,9 @@
<property name="text"> <property name="text">
<string>Remember last typed entry for:</string> <string>Remember last typed entry for:</string>
</property> </property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget> </widget>
</item> </item>
<item row="1" column="1"> <item row="1" column="1">

View File

@@ -44,6 +44,7 @@
#include "gui/FileDialog.h" #include "gui/FileDialog.h"
#include "gui/GuiTools.h" #include "gui/GuiTools.h"
#include "gui/MainWindow.h" #include "gui/MainWindow.h"
#include "gui/MergeDialog.h"
#include "gui/MessageBox.h" #include "gui/MessageBox.h"
#include "gui/TotpDialog.h" #include "gui/TotpDialog.h"
#include "gui/TotpExportSettingsDialog.h" #include "gui/TotpExportSettingsDialog.h"
@@ -877,12 +878,18 @@ void DatabaseWidget::performAutoType(const QString& sequence)
{ {
auto currentEntry = currentSelectedEntry(); auto currentEntry = currentSelectedEntry();
if (currentEntry) { if (currentEntry) {
// TODO: Include name of previously active window in confirmation question // Check if we need to ask for confirmation
if (config()->get(Config::Security_AutoTypeAsk).toBool() bool shouldAsk = config()->get(Config::Security_AutoTypeAsk).toBool();
&& MessageBox::question( bool skipMainWindowConfirmation = config()->get(Config::Security_AutoTypeSkipMainWindowConfirmation).toBool();
this, tr("Confirm Auto-Type"), tr("Perform Auto-Type into the previously active window?"))
!= MessageBox::Yes) { // Show confirmation if Security_AutoTypeAsk is true AND Security_AutoTypeSkipMainWindowConfirmation is false
return; if (shouldAsk && !skipMainWindowConfirmation) {
// TODO: Include name of previously active window in confirmation question
if (MessageBox::question(
this, tr("Confirm Auto-Type"), tr("Perform Auto-Type into the previously active window?"))
!= MessageBox::Yes) {
return;
}
} }
if (sequence.isEmpty()) { if (sequence.isEmpty()) {
@@ -918,6 +925,16 @@ void DatabaseWidget::performAutoTypeTOTP()
performAutoType(QStringLiteral("{TOTP}")); performAutoType(QStringLiteral("{TOTP}"));
} }
void DatabaseWidget::performAutoTypeURL()
{
performAutoType(QStringLiteral("{URL}"));
}
void DatabaseWidget::performAutoTypeURLEnter()
{
performAutoType(QStringLiteral("{URL}{ENTER}"));
}
void DatabaseWidget::openUrl() void DatabaseWidget::openUrl()
{ {
auto currentEntry = currentSelectedEntry(); auto currentEntry = currentSelectedEntry();
@@ -1104,8 +1121,8 @@ void DatabaseWidget::deleteGroup()
if (inRecycleBin || isRecycleBin || isRecycleBinSubgroup || !m_db->metadata()->recycleBinEnabled()) { if (inRecycleBin || isRecycleBin || isRecycleBinSubgroup || !m_db->metadata()->recycleBinEnabled()) {
auto result = MessageBox::question( auto result = MessageBox::question(
this, this,
tr("Delete group"), tr("Confirm Delete Group"),
tr("Do you really want to delete the group \"%1\" for good?").arg(currentGroup->name().toHtmlEscaped()), tr("Do you really want to permanently delete the group \"%1\"?").arg(currentGroup->name().toHtmlEscaped()),
MessageBox::Delete | MessageBox::Cancel, MessageBox::Delete | MessageBox::Cancel,
MessageBox::Cancel); MessageBox::Cancel);
@@ -1114,7 +1131,7 @@ void DatabaseWidget::deleteGroup()
} }
} else { } else {
auto result = MessageBox::question(this, auto result = MessageBox::question(this,
tr("Move group to recycle bin?"), tr("Confirm Recycle Group"),
tr("Do you really want to move the group " tr("Do you really want to move the group "
"\"%1\" to the recycle bin?") "\"%1\" to the recycle bin?")
.arg(currentGroup->name().toHtmlEscaped()), .arg(currentGroup->name().toHtmlEscaped()),
@@ -1371,18 +1388,30 @@ void DatabaseWidget::mergeDatabase(bool accepted)
return; return;
} }
Merger merger(srcDb.data(), m_db.data()); #ifdef WITH_XC_KEESHARE
QStringList changeList = merger.merge(); // Disable KeeShare while merging to avoid conflicts with incoming changes
KeeShare::instance()->setSharingEnabled(m_db, false);
#endif
if (!changeList.isEmpty()) { auto* mergeDialog = new MergeDialog(srcDb, m_db, this);
showMessage(tr("Successfully merged the database files."), MessageWidget::Information); connect(mergeDialog, &MergeDialog::databaseMerged, [this](bool changed) {
} else { if (changed) {
showMessage(tr("Database was not modified by merge operation."), MessageWidget::Information); showMessage(tr("Successfully merged the selected database."), MessageWidget::Positive);
} emit databaseMerged(m_db);
} else {
showMessage(tr("No changes were made by the merge operation."), MessageWidget::Information);
}
});
connect(mergeDialog, &MergeDialog::finished, [this](int result) {
if (result == QDialog::Rejected) {
showMessage(tr("Merge canceled, no changes were made."), MessageWidget::Information);
}
#ifdef WITH_XC_KEESHARE
KeeShare::instance()->setSharingEnabled(m_db, true);
#endif
});
mergeDialog->open();
} }
switchToMainView();
emit databaseMerged(m_db);
} }
void DatabaseWidget::syncUnlockedDatabase(bool accepted) void DatabaseWidget::syncUnlockedDatabase(bool accepted)
@@ -1422,7 +1451,7 @@ bool DatabaseWidget::syncWithDatabase(const QSharedPointer<Database>& otherDb, Q
emit updateSyncProgress(50, tr("Syncing...")); emit updateSyncProgress(50, tr("Syncing..."));
Merger firstMerge(m_db.data(), otherDb.data()); Merger firstMerge(m_db.data(), otherDb.data());
Merger secondMerge(otherDb.data(), m_db.data()); Merger secondMerge(otherDb.data(), m_db.data());
QStringList changeList = firstMerge.merge() + secondMerge.merge(); auto changeList = firstMerge.merge() + secondMerge.merge();
if (!changeList.isEmpty()) { if (!changeList.isEmpty()) {
// Save synced databases // Save synced databases
@@ -1527,7 +1556,7 @@ void DatabaseWidget::entryActivationSignalReceived(Entry* entry, EntryModel::Mod
} }
break; break;
case EntryModel::Totp: case EntryModel::Totp:
if (entry->hasTotp()) { if (entry->hasValidTotp()) {
setClipboardTextAndMinimize(entry->totp()); setClipboardTextAndMinimize(entry->totp());
} else { } else {
setupTotp(); setupTotp();
@@ -2386,7 +2415,7 @@ bool DatabaseWidget::currentEntryHasTotp()
if (!currentEntry) { if (!currentEntry) {
return false; return false;
} }
return currentEntry->hasTotp(); return currentEntry->hasValidTotp();
} }
#ifdef WITH_XC_SSHAGENT #ifdef WITH_XC_SSHAGENT
@@ -2624,7 +2653,7 @@ bool DatabaseWidget::saveBackup()
} }
const QString newFilePath = fileDialog()->getSaveFileName(this, const QString newFilePath = fileDialog()->getSaveFileName(this,
tr("Save database backup"), tr("Save Database Backup"),
FileDialog::getLastDir("backup", oldFilePath), FileDialog::getLastDir("backup", oldFilePath),
tr("KeePass 2 Database").append(" (*.kdbx)")); tr("KeePass 2 Database").append(" (*.kdbx)"));
@@ -2684,6 +2713,22 @@ bool DatabaseWidget::isRecycleBinSelected() const
return (group && group->isRecycled()) || (entry && entry->isRecycled()); return (group && group->isRecycled()) || (entry && entry->isRecycled());
} }
bool DatabaseWidget::hasRecycledSelectedEntries() const
{
if (!m_entryView) {
return false;
}
// Check if any of the selected entries are actually recycled
for (auto* entry : m_entryView->selectedEntries()) {
if (entry && entry->isRecycled()) {
return true;
}
}
return false;
}
void DatabaseWidget::emptyRecycleBin() void DatabaseWidget::emptyRecycleBin()
{ {
if (!isRecycleBinSelected()) { if (!isRecycleBinSelected()) {

View File

@@ -99,6 +99,7 @@ public:
bool canDeleteCurrentGroup() const; bool canDeleteCurrentGroup() const;
bool isGroupSelected() const; bool isGroupSelected() const;
bool isRecycleBinSelected() const; bool isRecycleBinSelected() const;
bool hasRecycledSelectedEntries() const;
int numberOfSelectedEntries() const; int numberOfSelectedEntries() const;
int currentEntryIndex() const; int currentEntryIndex() const;
@@ -214,6 +215,8 @@ public slots:
void performAutoTypePassword(); void performAutoTypePassword();
void performAutoTypePasswordEnter(); void performAutoTypePasswordEnter();
void performAutoTypeTOTP(); void performAutoTypeTOTP();
void performAutoTypeURL();
void performAutoTypeURLEnter();
void setClipboardTextAndMinimize(const QString& text); void setClipboardTextAndMinimize(const QString& text);
void openUrl(); void openUrl();
void downloadSelectedFavicons(); void downloadSelectedFavicons();

View File

@@ -84,7 +84,7 @@ int EditWidget::pageIndex(const QWidget* widget) const
for (int i = 0; i < m_ui->stackedWidget->count(); i++) { for (int i = 0; i < m_ui->stackedWidget->count(); i++) {
auto* scrollArea = qobject_cast<QScrollArea*>(m_ui->stackedWidget->widget(i)); auto* scrollArea = qobject_cast<QScrollArea*>(m_ui->stackedWidget->widget(i));
if (scrollArea && scrollArea->widget() == widget) { if (scrollArea && (scrollArea == widget || scrollArea->widget() == widget)) {
return i; return i;
} }
} }

View File

@@ -70,8 +70,7 @@ EntryPreviewWidget::EntryPreviewWidget(QWidget* parent)
m_ui->entryTotpLabel->installEventFilter(this); m_ui->entryTotpLabel->installEventFilter(this);
connect(m_ui->entryTotpButton, SIGNAL(toggled(bool)), m_ui->entryTotpLabel, SLOT(setVisible(bool))); connect(m_ui->entryTotpButton, SIGNAL(toggled(bool)), m_ui->entryTotp, SLOT(setVisible(bool)));
connect(m_ui->entryTotpButton, SIGNAL(toggled(bool)), m_ui->entryTotpProgress, SLOT(setVisible(bool)));
connect(m_ui->entryCloseButton, SIGNAL(clicked()), SLOT(hide())); connect(m_ui->entryCloseButton, SIGNAL(clicked()), SLOT(hide()));
connect(m_ui->toggleUsernameButton, SIGNAL(clicked(bool)), SLOT(setUsernameVisible(bool))); connect(m_ui->toggleUsernameButton, SIGNAL(clicked(bool)), SLOT(setUsernameVisible(bool)));
connect(m_ui->togglePasswordButton, SIGNAL(clicked(bool)), SLOT(setPasswordVisible(bool))); connect(m_ui->togglePasswordButton, SIGNAL(clicked(bool)), SLOT(setPasswordVisible(bool)));
@@ -95,6 +94,8 @@ EntryPreviewWidget::EntryPreviewWidget(QWidget* parent)
connect(config(), &Config::changed, this, [this](Config::ConfigKey key) { connect(config(), &Config::changed, this, [this](Config::ConfigKey key) {
if (key == Config::GUI_HidePreviewPanel) { if (key == Config::GUI_HidePreviewPanel) {
setVisible(!config()->get(Config::GUI_HidePreviewPanel).toBool()); setVisible(!config()->get(Config::GUI_HidePreviewPanel).toBool());
} else if (key == Config::Security_HideTotpPreviewPanel) {
m_ui->entryTotpButton->setChecked(!config()->get(Config::Security_HideTotpPreviewPanel).toBool());
} }
refresh(); refresh();
}); });
@@ -259,9 +260,9 @@ void EntryPreviewWidget::updateEntryTotp()
m_totpTimer.start(1000); m_totpTimer.start(1000);
m_ui->entryTotpProgress->setMaximum(m_currentEntry->totpSettings()->step); m_ui->entryTotpProgress->setMaximum(m_currentEntry->totpSettings()->step);
updateTotpLabel(); updateTotpLabel();
m_ui->entryTotp->setVisible(m_ui->entryTotpButton->isChecked());
} else { } else {
m_ui->entryTotpLabel->hide(); m_ui->entryTotp->hide();
m_ui->entryTotpProgress->hide();
m_ui->entryTotpButton->setChecked(false); m_ui->entryTotpButton->setChecked(false);
m_ui->entryTotpLabel->clear(); m_ui->entryTotpLabel->clear();
m_totpTimer.stop(); m_totpTimer.stop();
@@ -546,16 +547,23 @@ void EntryPreviewWidget::updateGroupSharingTab()
void EntryPreviewWidget::updateTotpLabel() void EntryPreviewWidget::updateTotpLabel()
{ {
if (!m_locked && m_currentEntry && m_currentEntry->hasTotp()) { if (!m_locked && m_currentEntry && m_currentEntry->hasTotp()) {
auto totpCode = m_currentEntry->totp(); bool isValid = false;
totpCode.insert(totpCode.size() / 2, " "); auto totpCode = m_currentEntry->totp(&isValid);
m_ui->entryTotpLabel->setText(totpCode); if (isValid) {
totpCode.insert(totpCode.size() / 2, " ");
auto step = m_currentEntry->totpSettings()->step; auto step = m_currentEntry->totpSettings()->step;
auto timeleft = step - (Clock::currentSecondsSinceEpoch() % step); auto timeleft = step - (Clock::currentSecondsSinceEpoch() % step);
m_ui->entryTotpProgress->setValue(timeleft); m_ui->entryTotpProgress->setValue(timeleft);
m_ui->entryTotpProgress->update(); m_ui->entryTotpProgress->update();
} else {
m_totpTimer.stop();
}
m_ui->entryTotpProgress->setVisible(isValid);
m_ui->entryTotpLabel->setText(totpCode);
} else { } else {
m_ui->entryTotpLabel->clear(); m_ui->entryTotp->setVisible(false);
m_totpTimer.stop(); m_totpTimer.stop();
} }
} }

View File

@@ -110,47 +110,66 @@
</layout> </layout>
</item> </item>
<item> <item>
<layout class="QVBoxLayout" name="verticalLayout"> <widget class="QWidget" name="entryTotp" native="true">
<property name="spacing"> <property name="minimumSize">
<number>0</number> <size>
<width>0</width>
<height>0</height>
</size>
</property> </property>
<item> <layout class="QVBoxLayout" name="verticalLayout_2">
<widget class="QLabel" name="entryTotpLabel"> <property name="spacing">
<property name="font"> <number>0</number>
<font> </property>
<pointsize>10</pointsize> <property name="leftMargin">
<weight>75</weight> <number>0</number>
<bold>true</bold> </property>
</font> <property name="topMargin">
</property> <number>0</number>
<property name="toolTip"> </property>
<string>Double click to copy to clipboard</string> <property name="rightMargin">
</property> <number>0</number>
<property name="text"> </property>
<string notr="true">1234567</string> <property name="bottomMargin">
</property> <number>0</number>
<property name="textInteractionFlags"> </property>
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set> <item>
</property> <widget class="QLabel" name="entryTotpLabel">
</widget> <property name="font">
</item> <font>
<item> <pointsize>10</pointsize>
<widget class="QProgressBar" name="entryTotpProgress"> <bold>true</bold>
<property name="maximumSize"> </font>
<size> </property>
<width>57</width> <property name="toolTip">
<height>4</height> <string>Double click to copy to clipboard</string>
</size> </property>
</property> <property name="text">
<property name="value"> <string notr="true">1234567</string>
<number>50</number> </property>
</property> <property name="textInteractionFlags">
<property name="textVisible"> <set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByKeyboard|Qt::TextInteractionFlag::TextSelectableByMouse</set>
<bool>false</bool> </property>
</property> </widget>
</widget> </item>
</item> <item>
</layout> <widget class="QProgressBar" name="entryTotpProgress">
<property name="maximumSize">
<size>
<width>57</width>
<height>4</height>
</size>
</property>
<property name="value">
<number>50</number>
</property>
<property name="textVisible">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item> </item>
<item> <item>
<widget class="QToolButton" name="entryTotpButton"> <widget class="QToolButton" name="entryTotpButton">

View File

@@ -32,14 +32,15 @@ namespace GuiTools
if (permanent) { if (permanent) {
QString prompt; QString prompt;
if (entries.size() == 1) { if (entries.size() == 1) {
prompt = QObject::tr("Do you really want to delete the entry \"%1\" for good?") auto entry = entries.first();
.arg(entries.first()->title().toHtmlEscaped()); prompt = QObject::tr("Do you really want to permanently delete the entry \"%1\"?")
.arg(entry->resolvePlaceholder(entry->title()).toHtmlEscaped());
} else { } else {
prompt = QObject::tr("Do you really want to delete %n entry(s) for good?", "", entries.size()); prompt = QObject::tr("Do you really want to permanently delete %n entry(s)?", "", entries.size());
} }
auto answer = MessageBox::question(parent, auto answer = MessageBox::question(parent,
QObject::tr("Delete entry(s)?", "", entries.size()), QObject::tr("Confirm Delete Entry(s)", "", entries.size()),
prompt, prompt,
MessageBox::Delete | MessageBox::Cancel, MessageBox::Delete | MessageBox::Cancel,
MessageBox::Cancel); MessageBox::Cancel);
@@ -50,14 +51,15 @@ namespace GuiTools
} else { } else {
QString prompt; QString prompt;
if (entries.size() == 1) { if (entries.size() == 1) {
auto entry = entries.first();
prompt = QObject::tr("Do you really want to move entry \"%1\" to the recycle bin?") prompt = QObject::tr("Do you really want to move entry \"%1\" to the recycle bin?")
.arg(entries.first()->title().toHtmlEscaped()); .arg(entry->resolvePlaceholder(entry->title()).toHtmlEscaped());
} else { } else {
prompt = QObject::tr("Do you really want to move %n entry(s) to the recycle bin?", "", entries.size()); prompt = QObject::tr("Do you really want to move %n entry(s) to the recycle bin?", "", entries.size());
} }
auto answer = MessageBox::question(parent, auto answer = MessageBox::question(parent,
QObject::tr("Move entry(s) to recycle bin?", "", entries.size()), QObject::tr("Confirm Recycle Entry(s)", "", entries.size()),
prompt, prompt,
MessageBox::Move | MessageBox::Cancel, MessageBox::Move | MessageBox::Cancel,
MessageBox::Cancel); MessageBox::Cancel);
@@ -72,11 +74,12 @@ namespace GuiTools
return false; return false;
} }
auto answer = MessageBox::question(parent, auto answer =
QObject::tr("Delete plugin data?"), MessageBox::question(parent,
QObject::tr("Delete plugin data from Entry(s)?", "", entries.size()), QObject::tr("Confirm Delete Plugin Data"),
MessageBox::Delete | MessageBox::Cancel, QObject::tr("Delete plugin data from the selected entry(s)?", "", entries.size()),
MessageBox::Cancel); MessageBox::Delete | MessageBox::Cancel,
MessageBox::Cancel);
return answer == MessageBox::Delete; return answer == MessageBox::Delete;
} }
@@ -100,7 +103,7 @@ namespace GuiTools
// Prompt the user on what to do with the reference (Overwrite, Delete, Skip) // Prompt the user on what to do with the reference (Overwrite, Delete, Skip)
auto result = MessageBox::question( auto result = MessageBox::question(
parent, parent,
QObject::tr("Replace references to entry?"), QObject::tr("Confirm Replace Entry References"),
QObject::tr( QObject::tr(
"Entry \"%1\" has %2 reference(s). " "Entry \"%1\" has %2 reference(s). "
"Do you want to overwrite references with values, skip this entry, or delete anyway?", "Do you want to overwrite references with values, skip this entry, or delete anyway?",

View File

@@ -18,6 +18,7 @@
#ifndef KEEPASSXC_GUITOOLS_H #ifndef KEEPASSXC_GUITOOLS_H
#define KEEPASSXC_GUITOOLS_H #define KEEPASSXC_GUITOOLS_H
#include <QEvent>
#include <QList> #include <QList>
#include <QWidget> #include <QWidget>
@@ -29,4 +30,27 @@ namespace GuiTools
bool confirmDeletePluginData(QWidget* parent, const QList<Entry*>& entries); bool confirmDeletePluginData(QWidget* parent, const QList<Entry*>& entries);
size_t deleteEntriesResolveReferences(QWidget* parent, const QList<Entry*>& entries, bool permanent); size_t deleteEntriesResolveReferences(QWidget* parent, const QList<Entry*>& entries, bool permanent);
} // namespace GuiTools } // namespace GuiTools
/**
* Helper class to ignore mouse wheel events on non-focused widgets
* NOTE: The widget must NOT have a focus policy of "WHEEL"
*/
class MouseWheelEventFilter : public QObject
{
public:
explicit MouseWheelEventFilter(QObject* parent)
: QObject(parent){};
protected:
bool eventFilter(QObject* obj, QEvent* event) override
{
const auto* widget = qobject_cast<QWidget*>(obj);
if (event->type() == QEvent::Wheel && widget && !widget->hasFocus()) {
event->ignore();
return true;
}
return QObject::eventFilter(obj, event);
}
};
#endif // KEEPASSXC_GUITOOLS_H #endif // KEEPASSXC_GUITOOLS_H

View File

@@ -60,4 +60,4 @@ private:
friend class TestIconDownloader; friend class TestIconDownloader;
}; };
#endif // KEEPASSXC_ICONDOWNLOADER_H #endif // KEEPASSXC_ICONDOWNLOADER_H

View File

@@ -38,7 +38,6 @@ IconDownloaderDialog::IconDownloaderDialog(QWidget* parent)
, m_ui(new Ui::IconDownloaderDialog()) , m_ui(new Ui::IconDownloaderDialog())
, m_dataModel(new QStandardItemModel(this)) , m_dataModel(new QStandardItemModel(this))
{ {
setWindowFlags(Qt::Window);
setAttribute(Qt::WA_DeleteOnClose); setAttribute(Qt::WA_DeleteOnClose);
m_ui->setupUi(this); m_ui->setupUi(this);

View File

@@ -172,6 +172,8 @@ MainWindow::MainWindow()
autotypeMenu->addAction(m_ui->actionEntryAutoTypePassword); autotypeMenu->addAction(m_ui->actionEntryAutoTypePassword);
autotypeMenu->addAction(m_ui->actionEntryAutoTypePasswordEnter); autotypeMenu->addAction(m_ui->actionEntryAutoTypePasswordEnter);
autotypeMenu->addAction(m_ui->actionEntryAutoTypeTOTP); autotypeMenu->addAction(m_ui->actionEntryAutoTypeTOTP);
autotypeMenu->addAction(m_ui->actionEntryAutoTypeURL);
autotypeMenu->addAction(m_ui->actionEntryAutoTypeURLEnter);
m_ui->actionEntryAutoType->setMenu(autotypeMenu); m_ui->actionEntryAutoType->setMenu(autotypeMenu);
auto autoTypeButton = qobject_cast<QToolButton*>(m_ui->toolBar->widgetForAction(m_ui->actionEntryAutoType)); auto autoTypeButton = qobject_cast<QToolButton*>(m_ui->toolBar->widgetForAction(m_ui->actionEntryAutoType));
if (autoTypeButton) { if (autoTypeButton) {
@@ -273,7 +275,7 @@ MainWindow::MainWindow()
m_ui->actionAllowScreenCapture->setVisible(osUtils->canPreventScreenCapture()); m_ui->actionAllowScreenCapture->setVisible(osUtils->canPreventScreenCapture());
m_inactivityTimer = new InactivityTimer(this); m_inactivityTimer = new InactivityTimer(this);
connect(m_inactivityTimer, SIGNAL(inactivityDetected()), this, SLOT(lockDatabasesAfterInactivity())); connect(m_inactivityTimer, SIGNAL(inactivityDetected()), this, SLOT(lockAllDatabases()));
applySettingsChanges(); applySettingsChanges();
// Qt 5.10 introduced a new "feature" to hide shortcuts in context menus // Qt 5.10 introduced a new "feature" to hide shortcuts in context menus
@@ -387,6 +389,8 @@ MainWindow::MainWindow()
m_ui->actionEntryAutoTypePassword->setIcon(icons()->icon("auto-type")); m_ui->actionEntryAutoTypePassword->setIcon(icons()->icon("auto-type"));
m_ui->actionEntryAutoTypePasswordEnter->setIcon(icons()->icon("auto-type")); m_ui->actionEntryAutoTypePasswordEnter->setIcon(icons()->icon("auto-type"));
m_ui->actionEntryAutoTypeTOTP->setIcon(icons()->icon("auto-type")); m_ui->actionEntryAutoTypeTOTP->setIcon(icons()->icon("auto-type"));
m_ui->actionEntryAutoTypeURL->setIcon(icons()->icon("auto-type"));
m_ui->actionEntryAutoTypeURLEnter->setIcon(icons()->icon("auto-type"));
m_ui->actionEntryMoveUp->setIcon(icons()->icon("move-up")); m_ui->actionEntryMoveUp->setIcon(icons()->icon("move-up"));
m_ui->actionEntryMoveDown->setIcon(icons()->icon("move-down")); m_ui->actionEntryMoveDown->setIcon(icons()->icon("move-down"));
m_ui->actionEntryCopyUsername->setIcon(icons()->icon("username-copy")); m_ui->actionEntryCopyUsername->setIcon(icons()->icon("username-copy"));
@@ -526,6 +530,9 @@ MainWindow::MainWindow()
m_actionMultiplexer.connect( m_actionMultiplexer.connect(
m_ui->actionEntryAutoTypePasswordEnter, SIGNAL(triggered()), SLOT(performAutoTypePasswordEnter())); m_ui->actionEntryAutoTypePasswordEnter, SIGNAL(triggered()), SLOT(performAutoTypePasswordEnter()));
m_actionMultiplexer.connect(m_ui->actionEntryAutoTypeTOTP, SIGNAL(triggered()), SLOT(performAutoTypeTOTP())); m_actionMultiplexer.connect(m_ui->actionEntryAutoTypeTOTP, SIGNAL(triggered()), SLOT(performAutoTypeTOTP()));
m_actionMultiplexer.connect(m_ui->actionEntryAutoTypeURL, SIGNAL(triggered()), SLOT(performAutoTypeURL()));
m_actionMultiplexer.connect(
m_ui->actionEntryAutoTypeURLEnter, SIGNAL(triggered()), SLOT(performAutoTypeURLEnter()));
m_actionMultiplexer.connect(m_ui->actionEntryOpenUrl, SIGNAL(triggered()), SLOT(openUrl())); m_actionMultiplexer.connect(m_ui->actionEntryOpenUrl, SIGNAL(triggered()), SLOT(openUrl()));
m_actionMultiplexer.connect(m_ui->actionEntryDownloadIcon, SIGNAL(triggered()), SLOT(downloadSelectedFavicons())); m_actionMultiplexer.connect(m_ui->actionEntryDownloadIcon, SIGNAL(triggered()), SLOT(downloadSelectedFavicons()));
#ifdef WITH_XC_SSHAGENT #ifdef WITH_XC_SSHAGENT
@@ -821,6 +828,8 @@ void MainWindow::updateSetTagsMenu()
return nullptr; return nullptr;
}; };
m_ui->menuTags->setTearOffEnabled(true);
auto dbWidget = m_ui->tabWidget->currentDatabaseWidget(); auto dbWidget = m_ui->tabWidget->currentDatabaseWidget();
if (dbWidget) { if (dbWidget) {
// Enumerate tags applied to the selected entries // Enumerate tags applied to the selected entries
@@ -831,31 +840,30 @@ void MainWindow::updateSetTagsMenu()
} }
} }
// Add known database tags as actions and set checked if // Remove missing tags
// a selected entry has that tag
const auto tagList = dbWidget->database()->tagList(); const auto tagList = dbWidget->database()->tagList();
for (const auto& tag : tagList) { for (const auto action : m_ui->menuTags->actions()) {
auto action = actionForTag(m_ui->menuTags, tag); if (!tagList.contains(action->text()) || !action->isEnabled()) {
if (action) { delete action;
action->setChecked(selectedTags.contains(tag));
} else {
action = m_ui->menuTags->addAction(icons()->icon("tag"), tag);
action->setCheckable(true);
action->setChecked(selectedTags.contains(tag));
m_setTagsMenuActions->addAction(action);
} }
} }
// Remove missing tags // Add known database tags as actions and set checked if
for (const auto action : m_ui->menuTags->actions()) { // a selected entry has that tag
if (!tagList.contains(action->text())) { for (const auto& tag : tagList) {
action->deleteLater(); auto action = actionForTag(m_ui->menuTags, tag);
if (!action) {
action = m_ui->menuTags->addAction(icons()->icon("tag"), tag);
action->setCheckable(true);
m_setTagsMenuActions->addAction(action);
} }
action->setChecked(selectedTags.contains(tag));
} }
} }
// If no tags exist in the database then show a tip to the user // If no tags exist in the database then show a tip to the user
if (m_ui->menuTags->isEmpty()) { if (m_ui->menuTags->isEmpty()) {
m_ui->menuTags->setTearOffEnabled(false);
auto action = m_ui->menuTags->addAction(tr("No Tags")); auto action = m_ui->menuTags->addAction(tr("No Tags"));
action->setEnabled(false); action->setEnabled(false);
} }
@@ -936,8 +944,20 @@ void MainWindow::updateMenuActionState()
m_ui->actionEntryEdit->setEnabled(singleEntrySelected); m_ui->actionEntryEdit->setEnabled(singleEntrySelected);
m_ui->actionEntryExpire->setEnabled(multiEntrySelected); m_ui->actionEntryExpire->setEnabled(multiEntrySelected);
m_ui->actionEntryDelete->setEnabled(multiEntrySelected); m_ui->actionEntryDelete->setEnabled(multiEntrySelected);
m_ui->actionEntryRestore->setVisible(multiEntrySelected && inRecycleBin); if (dbWidget) {
m_ui->actionEntryRestore->setEnabled(multiEntrySelected && inRecycleBin); if (dbWidget->database()->metadata()->recycleBinEnabled() && !inRecycleBin) {
m_ui->actionEntryDelete->setToolTip(
tr("Move selected entry(s) to the recycle bin", "", dbWidget->numberOfSelectedEntries()));
} else {
m_ui->actionEntryDelete->setToolTip(
tr("Permanently delete the selected entry(s)", "", dbWidget->numberOfSelectedEntries()));
}
} else {
m_ui->actionEntryDelete->setToolTip(tr("Delete Entry"));
}
bool hasRecycledEntries = (inDatabase && dbWidget && dbWidget->hasRecycledSelectedEntries());
m_ui->actionEntryRestore->setVisible(multiEntrySelected && hasRecycledEntries);
m_ui->actionEntryRestore->setEnabled(multiEntrySelected && hasRecycledEntries);
if (dbWidget) { if (dbWidget) {
m_ui->actionEntryRestore->setText(tr("Restore Entry(s)", "", dbWidget->numberOfSelectedEntries())); m_ui->actionEntryRestore->setText(tr("Restore Entry(s)", "", dbWidget->numberOfSelectedEntries()));
m_ui->actionEntryRestore->setToolTip(tr("Restore Entry(s)", "", dbWidget->numberOfSelectedEntries())); m_ui->actionEntryRestore->setToolTip(tr("Restore Entry(s)", "", dbWidget->numberOfSelectedEntries()));
@@ -975,6 +995,8 @@ void MainWindow::updateMenuActionState()
m_ui->actionEntryAutoTypePassword->setEnabled(singleEntrySelected && dbWidget->currentEntryHasPassword()); m_ui->actionEntryAutoTypePassword->setEnabled(singleEntrySelected && dbWidget->currentEntryHasPassword());
m_ui->actionEntryAutoTypePasswordEnter->setEnabled(singleEntrySelected && dbWidget->currentEntryHasPassword()); m_ui->actionEntryAutoTypePasswordEnter->setEnabled(singleEntrySelected && dbWidget->currentEntryHasPassword());
m_ui->actionEntryAutoTypeTOTP->setEnabled(singleEntrySelected && dbWidget->currentEntryHasTotp()); m_ui->actionEntryAutoTypeTOTP->setEnabled(singleEntrySelected && dbWidget->currentEntryHasTotp());
m_ui->actionEntryAutoTypeURL->setEnabled(singleEntrySelected && dbWidget->currentEntryHasUrl());
m_ui->actionEntryAutoTypeURLEnter->setEnabled(singleEntrySelected && dbWidget->currentEntryHasUrl());
m_ui->actionEntryAutoTypeTOTP->setVisible(singleEntrySelected && dbWidget->currentEntryHasTotp()); m_ui->actionEntryAutoTypeTOTP->setVisible(singleEntrySelected && dbWidget->currentEntryHasTotp());
m_ui->actionEntryOpenUrl->setEnabled(singleEntryOrEditing && dbWidget->currentEntryHasUrl()); m_ui->actionEntryOpenUrl->setEnabled(singleEntryOrEditing && dbWidget->currentEntryHasUrl());
m_ui->actionEntryTotp->setEnabled(singleEntrySelected && dbWidget->currentEntryHasTotp()); m_ui->actionEntryTotp->setEnabled(singleEntrySelected && dbWidget->currentEntryHasTotp());
@@ -1016,7 +1038,7 @@ void MainWindow::updateMenuActionState()
m_ui->actionGroupDownloadFavicons->setEnabled(groupSelected && groupHasEntries && !inRecycleBin); m_ui->actionGroupDownloadFavicons->setEnabled(groupSelected && groupHasEntries && !inRecycleBin);
// Database Menu // Database Menu
m_ui->actionDatabaseSave->setEnabled(m_ui->tabWidget->canSave()); m_ui->actionDatabaseSave->setEnabled(databaseUnlocked && m_ui->tabWidget->canSave());
m_ui->actionDatabaseSaveAs->setEnabled(databaseUnlocked); m_ui->actionDatabaseSaveAs->setEnabled(databaseUnlocked);
m_ui->actionDatabaseSaveBackup->setEnabled(databaseUnlocked); m_ui->actionDatabaseSaveBackup->setEnabled(databaseUnlocked);
m_ui->actionDatabaseClose->setEnabled(dbWidget); m_ui->actionDatabaseClose->setEnabled(dbWidget);
@@ -1314,6 +1336,11 @@ void MainWindow::databaseTabChanged(int tabIndex)
m_actionMultiplexer.setCurrentObject(m_ui->tabWidget->currentDatabaseWidget()); m_actionMultiplexer.setCurrentObject(m_ui->tabWidget->currentDatabaseWidget());
updateEntryCountLabel(); updateEntryCountLabel();
// Clear the tags menu to prevent re-use between databases
for (const auto action : m_ui->menuTags->actions()) {
delete action;
}
} }
bool MainWindow::event(QEvent* event) bool MainWindow::event(QEvent* event)
@@ -1650,21 +1677,23 @@ void MainWindow::showGroupContextMenu(const QPoint& globalPos)
void MainWindow::applySettingsChanges() void MainWindow::applySettingsChanges()
{ {
int timeout = config()->get(Config::Security_LockDatabaseIdleSeconds).toInt() * 1000;
if (timeout <= 0) {
timeout = 60;
}
m_inactivityTimer->setInactivityTimeout(timeout);
if (config()->get(Config::Security_LockDatabaseIdle).toBool()) { if (config()->get(Config::Security_LockDatabaseIdle).toBool()) {
m_inactivityTimer->activate(); auto timeout = config()->get(Config::Security_LockDatabaseIdleSeconds).toInt() * 1000;
m_inactivityTimer->activate(timeout);
} else { } else {
m_inactivityTimer->deactivate(); m_inactivityTimer->deactivate();
} }
m_ui->actionShowToolbar->setChecked(!config()->get(Config::GUI_HideToolbar).toBool()); auto hideToolbar = config()->get(Config::GUI_HideToolbar).toBool();
m_ui->actionShowMenubar->setChecked(!config()->get(Config::GUI_HideMenubar).toBool()); auto hideMenubar = config()->get(Config::GUI_HideMenubar).toBool();
m_ui->menubar->setHidden(config()->get(Config::GUI_HideMenubar).toBool());
m_ui->actionShowToolbar->setChecked(!hideToolbar);
m_ui->actionShowMenubar->setChecked(!hideMenubar);
// When menubar is hidden with setHidden() the menu keyboard shortcuts are disabled on Wayland,
// so force height of 0 instead and use maximumHeight() > 0 instead of isVisible() elsewhere
m_ui->menubar->setMaximumHeight(hideMenubar ? 0 : QWIDGETSIZE_MAX);
m_ui->toolBar->setHidden(config()->get(Config::GUI_HideToolbar).toBool()); m_ui->toolBar->setHidden(config()->get(Config::GUI_HideToolbar).toBool());
auto movable = config()->get(Config::GUI_MovableToolbar).toBool(); auto movable = config()->get(Config::GUI_MovableToolbar).toBool();
m_ui->toolBar->setMovable(movable); m_ui->toolBar->setMovable(movable);
@@ -1827,13 +1856,6 @@ void MainWindow::closeModalWindow()
} }
} }
void MainWindow::lockDatabasesAfterInactivity()
{
if (!m_ui->tabWidget->lockDatabases()) {
m_inactivityTimer->activate();
}
}
bool MainWindow::isTrayIconEnabled() const bool MainWindow::isTrayIconEnabled() const
{ {
return m_trayIcon && m_trayIcon->isVisible(); return m_trayIcon && m_trayIcon->isVisible();
@@ -1888,7 +1910,7 @@ void MainWindow::bringToFront()
void MainWindow::handleScreenLock() void MainWindow::handleScreenLock()
{ {
if (config()->get(Config::Security_LockDatabaseScreenLock).toBool()) { if (config()->get(Config::Security_LockDatabaseScreenLock).toBool()) {
lockDatabasesAfterInactivity(); lockAllDatabases();
} }
} }
@@ -1938,7 +1960,7 @@ void MainWindow::closeAllDatabases()
void MainWindow::lockAllDatabases() void MainWindow::lockAllDatabases()
{ {
lockDatabasesAfterInactivity(); m_ui->tabWidget->lockDatabases();
} }
void MainWindow::displayDesktopNotification(const QString& msg, QString title, int msTimeoutHint) void MainWindow::displayDesktopNotification(const QString& msg, QString title, int msTimeoutHint)
@@ -1994,6 +2016,7 @@ void MainWindow::initViewMenu()
restartApp(tr("You must restart the application to apply this setting. Would you like to restart now?")); restartApp(tr("You must restart the application to apply this setting. Would you like to restart now?"));
} else { } else {
kpxcApp->applyTheme(); kpxcApp->applyTheme();
kpxcApp->applyFontSize();
} }
}); });
@@ -2184,10 +2207,11 @@ MainWindowEventFilter::MainWindowEventFilter(QObject* parent)
m_menubarTimer.setSingleShot(false); m_menubarTimer.setSingleShot(false);
connect(&m_menubarTimer, &QTimer::timeout, this, [this] { connect(&m_menubarTimer, &QTimer::timeout, this, [this] {
auto mainwindow = getMainWindow(); auto mainwindow = getMainWindow();
if (mainwindow && mainwindow->m_ui->menubar->isVisible() && config()->get(Config::GUI_HideMenubar).toBool()) { if (mainwindow && mainwindow->m_ui->menubar->maximumHeight() > 0
&& config()->get(Config::GUI_HideMenubar).toBool()) {
// If the menu bar is visible with no active menu, hide it // If the menu bar is visible with no active menu, hide it
if (!mainwindow->m_ui->menubar->activeAction()) { if (!mainwindow->m_ui->menubar->activeAction()) {
mainwindow->m_ui->menubar->setVisible(false); mainwindow->m_ui->menubar->setMaximumHeight(0);
m_altCoolDown.start(); m_altCoolDown.start();
m_menubarTimer.stop(); m_menubarTimer.stop();
} }
@@ -2246,9 +2270,13 @@ bool MainWindowEventFilter::eventFilter(QObject* watched, QEvent* event)
if (keyEvent->key() == Qt::Key_Alt && !keyEvent->modifiers() && config()->get(Config::GUI_HideMenubar).toBool() if (keyEvent->key() == Qt::Key_Alt && !keyEvent->modifiers() && config()->get(Config::GUI_HideMenubar).toBool()
&& !m_altCoolDown.isActive()) { && !m_altCoolDown.isActive()) {
auto menubar = mainWindow->m_ui->menubar; auto menubar = mainWindow->m_ui->menubar;
menubar->setVisible(!menubar->isVisible()); menubar->setMaximumHeight(menubar->maximumHeight() > 0 ? 0 : QWIDGETSIZE_MAX);
if (menubar->isVisible()) { if (menubar->maximumHeight() > 0) {
menubar->setActiveAction(mainWindow->m_ui->menuFile->menuAction()); QTimer::singleShot(0, [menubar, mainWindow] {
// Run this with a singleshot timer so it's after menubar->setMaximumHeight() has taken effect,
// otherwise it won't be selected and menubarTimer will hide the menubar instantly
menubar->setActiveAction(mainWindow->m_ui->menuFile->menuAction());
});
m_menubarTimer.start(); m_menubarTimer.start();
} else { } else {
m_menubarTimer.stop(); m_menubarTimer.stop();

View File

@@ -140,7 +140,6 @@ private slots:
void applySettingsChanges(); void applySettingsChanges();
void trayIconTriggered(QSystemTrayIcon::ActivationReason reason); void trayIconTriggered(QSystemTrayIcon::ActivationReason reason);
void processTrayIconTrigger(); void processTrayIconTrigger();
void lockDatabasesAfterInactivity();
void handleScreenLock(); void handleScreenLock();
void showErrorMessage(const QString& message); void showErrorMessage(const QString& message);
void selectNextDatabaseTab(); void selectNextDatabaseTab();

View File

@@ -532,7 +532,7 @@
<string>&amp;New Entry…</string> <string>&amp;New Entry…</string>
</property> </property>
<property name="toolTip"> <property name="toolTip">
<string>Create Entry</string> <string>New Entry</string>
</property> </property>
<property name="shortcut"> <property name="shortcut">
<string notr="true">Ctrl+N</string> <string notr="true">Ctrl+N</string>
@@ -542,6 +542,9 @@
<property name="text"> <property name="text">
<string>&amp;Edit Entry…</string> <string>&amp;Edit Entry…</string>
</property> </property>
<property name="iconText">
<string>Edit Entry…</string>
</property>
<property name="toolTip"> <property name="toolTip">
<string>Edit Entry</string> <string>Edit Entry</string>
</property> </property>
@@ -554,12 +557,21 @@
<bool>false</bool> <bool>false</bool>
</property> </property>
<property name="text"> <property name="text">
<string>E&amp;xpire Entry</string> <string>E&amp;xpire Entry</string>
</property>
<property name="iconText">
<string>Expire Entry</string>
</property>
<property name="toolTip">
<string>Expire Entry</string>
</property> </property>
</action> </action>
<action name="actionEntryDelete"> <action name="actionEntryDelete">
<property name="text"> <property name="text">
<string>&amp;Delete Entry</string> <string>&amp;Delete Entry</string>
</property>
<property name="iconText">
<string>Delete Entry</string>
</property> </property>
<property name="toolTip"> <property name="toolTip">
<string>Delete Entry</string> <string>Delete Entry</string>
@@ -859,6 +871,34 @@
<string>Perform Auto-Type: {TOTP}</string> <string>Perform Auto-Type: {TOTP}</string>
</property> </property>
</action> </action>
<action name="actionEntryAutoTypeURL">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string notr="true">{URL}</string>
</property>
<property name="iconText">
<string notr="true">{URL}</string>
</property>
<property name="toolTip">
<string notr="true">{URL}</string>
</property>
</action>
<action name="actionEntryAutoTypeURLEnter">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string notr="true">{URL}{ENTER}</string>
</property>
<property name="iconText">
<string notr="true">{URL}{ENTER}</string>
</property>
<property name="toolTip">
<string notr="true">{URL}{ENTER}</string>
</property>
</action>
<action name="actionEntryDownloadIcon"> <action name="actionEntryDownloadIcon">
<property name="text"> <property name="text">
<string>Download &amp;Favicon</string> <string>Download &amp;Favicon</string>

199
src/gui/MergeDialog.cpp Normal file
View File

@@ -0,0 +1,199 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "MergeDialog.h"
#include "ui_MergeDialog.h"
#include "core/Database.h"
#include <QPushButton>
#include <QShortcut>
MergeDialog::MergeDialog(QSharedPointer<Database> source, QSharedPointer<Database> target, QWidget* parent)
: QDialog(parent)
, m_ui(new Ui::MergeDialog())
, m_headerContextMenu(new QMenu())
, m_sourceDatabase(std::move(source))
, m_targetDatabase(std::move(target))
{
setAttribute(Qt::WA_DeleteOnClose);
// block input to other windows since other interactions can lead to unexpected merge results
setWindowModality(Qt::WindowModality::ApplicationModal);
m_ui->setupUi(this);
m_ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Merge"));
m_ui->buttonBox->button(QDialogButtonBox::Ok)->setFocus();
connect(m_ui->buttonBox, &QDialogButtonBox::rejected, this, &MergeDialog::cancelMerge);
connect(m_ui->buttonBox, &QDialogButtonBox::accepted, this, &MergeDialog::performMerge);
setupChangeTable();
updateChangeTable();
}
MergeDialog::MergeDialog(const Merger::ChangeList& changes, QWidget* parent)
: QDialog(parent)
, m_ui(new Ui::MergeDialog())
, m_headerContextMenu(new QMenu())
, m_changes(changes)
{
setAttribute(Qt::WA_DeleteOnClose);
m_ui->setupUi(this);
m_ui->buttonBox->button(QDialogButtonBox::Ok)->setFocus();
m_ui->buttonBox->button(QDialogButtonBox::Abort)->hide();
connect(m_ui->buttonBox, &QDialogButtonBox::accepted, this, &MergeDialog::close);
setupChangeTable();
}
MergeDialog::~MergeDialog() = default;
QVector<MergeDialog::MergeDialogColumns> MergeDialog::columns()
{
return {MergeDialogColumns::Group,
MergeDialogColumns::Title,
MergeDialogColumns::Uuid,
MergeDialogColumns::Type,
MergeDialogColumns::Details};
}
int MergeDialog::columnIndex(MergeDialogColumns column)
{
return columns().indexOf(column);
}
QString MergeDialog::columnName(MergeDialogColumns column)
{
switch (column) {
case MergeDialogColumns::Group:
return tr("Group");
case MergeDialogColumns::Title:
return tr("Title");
case MergeDialogColumns::Uuid:
return tr("UUID");
case MergeDialogColumns::Type:
return tr("Change");
case MergeDialogColumns::Details:
return tr("Details");
}
return {};
}
QString MergeDialog::cellValue(const Merger::Change& change, MergeDialogColumns column)
{
switch (column) {
case MergeDialogColumns::Group:
return change.group();
case MergeDialogColumns::Title:
return change.title();
case MergeDialogColumns::Uuid:
if (!change.uuid().isNull()) {
return change.uuid().toString();
}
break;
case MergeDialogColumns::Type:
return change.typeString();
case MergeDialogColumns::Details:
return change.details();
}
return {};
}
bool MergeDialog::isColumnHiddenByDefault(MergeDialogColumns column)
{
return column == MergeDialogColumns::Uuid;
}
void MergeDialog::setupChangeTable()
{
Q_ASSERT(m_ui);
Q_ASSERT(m_ui->changeTable);
m_ui->changeTable->verticalHeader()->setVisible(false);
m_ui->changeTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Interactive);
m_ui->changeTable->horizontalHeader()->setContextMenuPolicy(Qt::ActionsContextMenu);
m_ui->changeTable->setShowGrid(false);
m_ui->changeTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_ui->changeTable->setSelectionBehavior(QAbstractItemView::SelectRows);
m_ui->changeTable->setSelectionMode(QAbstractItemView::SingleSelection);
// Create the header context menu
for (auto column : columns()) {
auto* action = new QAction(columnName(column), this);
action->setCheckable(true);
action->setChecked(!isColumnHiddenByDefault(column));
connect(action, &QAction::toggled, [this, column](bool checked) {
m_ui->changeTable->setColumnHidden(columnIndex(column), !checked);
m_ui->changeTable->horizontalHeader()->resizeSections(QHeaderView::ResizeMode::ResizeToContents);
});
m_ui->changeTable->horizontalHeader()->addAction(action);
}
}
void MergeDialog::updateChangeTable()
{
Q_ASSERT(m_ui);
Q_ASSERT(m_ui->changeTable);
Q_ASSERT(m_sourceDatabase.get());
Q_ASSERT(m_targetDatabase.get());
m_changes = Merger(m_sourceDatabase.data(), m_targetDatabase.get()).merge(true);
m_ui->changeTable->clear();
auto allColumns = columns();
m_ui->changeTable->setColumnCount(allColumns.size());
m_ui->changeTable->setRowCount(m_changes.size());
for (auto column : allColumns) {
auto name = columnName(column);
auto index = columnIndex(column);
m_ui->changeTable->setHorizontalHeaderItem(index, new QTableWidgetItem(name));
m_ui->changeTable->setColumnHidden(index, isColumnHiddenByDefault(column));
}
for (int row = 0; row < m_changes.size(); ++row) {
const auto& change = m_changes[row];
for (auto column : allColumns) {
m_ui->changeTable->setItem(row, columnIndex(column), new QTableWidgetItem(cellValue(change, column)));
}
}
m_ui->changeTable->horizontalHeader()->resizeSections(QHeaderView::ResizeMode::ResizeToContents);
}
void MergeDialog::performMerge()
{
auto changes = Merger(m_sourceDatabase.data(), m_targetDatabase.data()).merge();
if (changes != m_changes) {
qWarning("Merge results differed from the expected changes. Expected: %d, Actual: %d",
m_changes.size(),
changes.size());
}
emit databaseMerged(!changes.isEmpty());
done(QDialog::Accepted);
}
void MergeDialog::cancelMerge()
{
done(QDialog::Rejected);
}

83
src/gui/MergeDialog.h Normal file
View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSX_MERGEDIALOG_H
#define KEEPASSX_MERGEDIALOG_H
#include "core/Merger.h"
#include <QDialog>
#include <QMenu>
namespace Ui
{
class MergeDialog;
}
class Database;
class MergeDialog : public QDialog
{
Q_OBJECT
public:
/**
* Merge source into copy of target and display changes.
* On user confirmation, merge source into target.
*/
explicit MergeDialog(QSharedPointer<Database> source, QSharedPointer<Database> target, QWidget* parent = nullptr);
/**
* Display given changes.
*/
explicit MergeDialog(const Merger::ChangeList& changes, QWidget* parent = nullptr);
~MergeDialog() override;
signals:
// Signal will be emitted when a normal merge operation has been performed.
void databaseMerged(bool databaseChanged);
private slots:
void performMerge();
void cancelMerge();
private:
enum class MergeDialogColumns
{
Group,
Title,
Uuid,
Type,
Details
};
static QVector<MergeDialogColumns> columns();
static int columnIndex(MergeDialogColumns column);
static QString columnName(MergeDialogColumns column);
static QString cellValue(const Merger::Change& change, MergeDialogColumns column);
static bool isColumnHiddenByDefault(MergeDialogColumns column);
void setupChangeTable();
void updateChangeTable();
QScopedPointer<Ui::MergeDialog> m_ui;
QScopedPointer<QMenu> m_headerContextMenu;
Merger::ChangeList m_changes;
QSharedPointer<Database> m_sourceDatabase;
QSharedPointer<Database> m_targetDatabase;
};
#endif // KEEPASSX_MERGEDIALOG_H

31
src/gui/MergeDialog.ui Normal file
View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MergeDialog</class>
<widget class="QWidget" name="MergeDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>450</height>
</rect>
</property>
<property name="windowTitle">
<string>Database Merge Confirmation</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTableWidget" name="changeTable"/>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Abort|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -91,7 +91,9 @@ MessageBox::Button MessageBox::messageBox(QWidget* parent,
msgBox.setTextFormat(Qt::RichText); msgBox.setTextFormat(Qt::RichText);
msgBox.setIcon(icon); msgBox.setIcon(icon);
msgBox.setWindowTitle(title); msgBox.setWindowTitle(title);
msgBox.setText(text); // Replace newlines with HTML line breaks
auto fixedText = text;
msgBox.setText(fixedText.replace("\n", "<br>"));
if (m_overrideParent) { if (m_overrideParent) {
// Force the creation of the QWindow, without this windowHandle() will return nullptr // Force the creation of the QWindow, without this windowHandle() will return nullptr

View File

@@ -65,7 +65,7 @@ PasswordGeneratorWidget::PasswordGeneratorWidget(QWidget* parent)
connect(m_ui->buttonApply, SIGNAL(clicked()), SLOT(applyPassword())); connect(m_ui->buttonApply, SIGNAL(clicked()), SLOT(applyPassword()));
connect(m_ui->buttonCopy, SIGNAL(clicked()), SLOT(copyPassword())); connect(m_ui->buttonCopy, SIGNAL(clicked()), SLOT(copyPassword()));
connect(m_ui->buttonGenerate, SIGNAL(clicked()), SLOT(regeneratePassword())); connect(m_ui->buttonGenerate, SIGNAL(clicked()), SLOT(regeneratePassword()));
connect(m_ui->buttonDeleteWordList, SIGNAL(clicked()), SLOT(deleteWordList())); connect(m_ui->buttonDeleteWordList, SIGNAL(clicked()), SLOT(removeCustomWordList()));
connect(m_ui->buttonAddWordList, SIGNAL(clicked()), SLOT(addWordList())); connect(m_ui->buttonAddWordList, SIGNAL(clicked()), SLOT(addWordList()));
connect(m_ui->buttonClose, SIGNAL(clicked()), SIGNAL(closed())); connect(m_ui->buttonClose, SIGNAL(clicked()), SIGNAL(closed()));
@@ -115,6 +115,11 @@ PasswordGeneratorWidget::PasswordGeneratorWidget(QWidget* parent)
m_ui->comboBoxWordList->addItem(fileName, path.absolutePath() + QDir::separator() + fileName); m_ui->comboBoxWordList->addItem(fileName, path.absolutePath() + QDir::separator() + fileName);
} }
// Set color of wordlist warning
StateColorPalette statePalette;
auto color = statePalette.color(StateColorPalette::ColorRole::False);
m_ui->labelWordListWarning->setStyleSheet(QString("QLabel { color: %1; }").arg(color.name()));
loadSettings(); loadSettings();
} }
@@ -257,9 +262,7 @@ void PasswordGeneratorWidget::regeneratePassword()
m_ui->editNewPassword->setText(m_passwordGenerator->generatePassword()); m_ui->editNewPassword->setText(m_passwordGenerator->generatePassword());
} }
} else { } else {
if (m_dicewareGenerator->isValid()) { m_ui->editNewPassword->setText(m_dicewareGenerator->generatePassphrase());
m_ui->editNewPassword->setText(m_dicewareGenerator->generatePassphrase());
}
} }
} }
@@ -379,33 +382,28 @@ bool PasswordGeneratorWidget::isPasswordGenerated() const
return m_passwordGenerated; return m_passwordGenerated;
} }
void PasswordGeneratorWidget::deleteWordList() void PasswordGeneratorWidget::removeCustomWordList()
{ {
if (m_ui->comboBoxWordList->currentIndex() < m_firstCustomWordlistIndex) { if (m_ui->comboBoxWordList->currentIndex() < m_firstCustomWordlistIndex) {
return; return;
} }
QFile file(m_ui->comboBoxWordList->currentData().toString()); auto wordlist = m_ui->comboBoxWordList->currentText();
if (!file.exists()) {
return;
}
auto result = MessageBox::question(this, auto result = MessageBox::question(this,
tr("Confirm Delete Wordlist"), tr("Confirm Remove Wordlist"),
tr("Do you really want to delete the wordlist \"%1\"?").arg(file.fileName()), tr("Do you really want to remove the wordlist \"%1\"?").arg(wordlist),
MessageBox::Delete | MessageBox::Cancel, MessageBox::Remove | MessageBox::Cancel,
MessageBox::Cancel); MessageBox::Cancel);
if (result != MessageBox::Delete) {
return;
}
if (!file.remove()) { if (result == MessageBox::Remove) {
MessageBox::critical(this, tr("Failed to delete wordlist"), file.errorString()); QFile file(m_ui->comboBoxWordList->currentData().toString());
return; if (file.exists() && !file.remove()) {
} MessageBox::critical(this, tr("Failed to delete wordlist"), file.errorString());
}
m_ui->comboBoxWordList->removeItem(m_ui->comboBoxWordList->currentIndex()); m_ui->comboBoxWordList->removeItem(m_ui->comboBoxWordList->currentIndex());
updateGenerator(); updateGenerator();
}
} }
void PasswordGeneratorWidget::addWordList() void PasswordGeneratorWidget::addWordList()
@@ -589,11 +587,7 @@ void PasswordGeneratorWidget::updateGenerator()
} }
m_passwordGenerator->setFlags(flags); m_passwordGenerator->setFlags(flags);
if (m_passwordGenerator->isValid()) { m_ui->buttonGenerate->setEnabled(m_passwordGenerator->isValid());
m_ui->buttonGenerate->setEnabled(true);
} else {
m_ui->buttonGenerate->setEnabled(false);
}
} else { } else {
m_dicewareGenerator->setWordCase( m_dicewareGenerator->setWordCase(
static_cast<PassphraseGenerator::PassphraseWordCase>(m_ui->wordCaseComboBox->currentData().toInt())); static_cast<PassphraseGenerator::PassphraseWordCase>(m_ui->wordCaseComboBox->currentData().toInt()));
@@ -610,11 +604,8 @@ void PasswordGeneratorWidget::updateGenerator()
m_dicewareGenerator->setWordSeparator(m_ui->editWordSeparator->text()); m_dicewareGenerator->setWordSeparator(m_ui->editWordSeparator->text());
if (m_dicewareGenerator->isValid()) { m_ui->labelWordListWarning->setVisible(!m_dicewareGenerator->isWordListValid());
m_ui->buttonGenerate->setEnabled(true); m_ui->buttonGenerate->setEnabled(true);
} else {
m_ui->buttonGenerate->setEnabled(false);
}
} }
regeneratePassword(); regeneratePassword();

View File

@@ -67,7 +67,7 @@ public slots:
void applyPassword(); void applyPassword();
void copyPassword(); void copyPassword();
void setPasswordVisible(bool visible); void setPasswordVisible(bool visible);
void deleteWordList(); void removeCustomWordList();
void addWordList(); void addWordList();
protected: protected:

View File

@@ -769,7 +769,113 @@ QProgressBar::chunk {
<layout class="QGridLayout" name="gridLayout_2"> <layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0"> <item row="0" column="0">
<layout class="QGridLayout" name="gridLayout_3"> <layout class="QGridLayout" name="gridLayout_3">
<item row="1" column="1" alignment="Qt::AlignRight">
<widget class="QLabel" name="labelWordList">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Wordlist:</string>
</property>
</widget>
</item>
<item row="3" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_9">
<item>
<widget class="QLineEdit" name="editWordSeparator">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>20</width>
<height>0</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item row="2" column="1" alignment="Qt::AlignRight">
<widget class="QLabel" name="labelWordCount">
<property name="text">
<string>Word Count:</string>
</property>
<property name="buddy">
<cstring>spinBoxLength</cstring>
</property>
</widget>
</item>
<item row="4" column="1" alignment="Qt::AlignRight">
<widget class="QLabel" name="wordCaseLabel">
<property name="text">
<string>Word Case:</string>
</property>
</widget>
</item>
<item row="1" column="2"> <item row="1" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_10">
<item>
<widget class="QComboBox" name="comboBoxWordList">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonDeleteWordList">
<property name="focusPolicy">
<enum>Qt::TabFocus</enum>
</property>
<property name="toolTip">
<string>Delete selected wordlist</string>
</property>
<property name="accessibleDescription">
<string>Delete selected wordlist</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonAddWordList">
<property name="focusPolicy">
<enum>Qt::TabFocus</enum>
</property>
<property name="toolTip">
<string>Add custom wordlist</string>
</property>
<property name="accessibleDescription">
<string>Add custom wordlist</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_3"> <layout class="QHBoxLayout" name="horizontalLayout_3">
<property name="sizeConstraint"> <property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum> <enum>QLayout::SetMinimumSize</enum>
@@ -814,61 +920,7 @@ QProgressBar::chunk {
</item> </item>
</layout> </layout>
</item> </item>
<item row="3" column="1" alignment="Qt::AlignRight"> <item row="4" column="2">
<widget class="QLabel" name="wordCaseLabel">
<property name="text">
<string>Word Case:</string>
</property>
</widget>
</item>
<item row="2" column="1" alignment="Qt::AlignRight">
<widget class="QLabel" name="labelWordSeparator">
<property name="text">
<string>Word Separator:</string>
</property>
</widget>
</item>
<item row="0" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_10">
<item>
<widget class="QComboBox" name="comboBoxWordList">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonDeleteWordList">
<property name="focusPolicy">
<enum>Qt::TabFocus</enum>
</property>
<property name="toolTip">
<string>Delete selected wordlist</string>
</property>
<property name="accessibleDescription">
<string>Delete selected wordlist</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonAddWordList">
<property name="focusPolicy">
<enum>Qt::TabFocus</enum>
</property>
<property name="toolTip">
<string>Add custom wordlist</string>
</property>
<property name="accessibleDescription">
<string>Add custom wordlist</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_6"> <layout class="QHBoxLayout" name="horizontalLayout_6">
<item> <item>
<widget class="QComboBox" name="wordCaseComboBox"/> <widget class="QComboBox" name="wordCaseComboBox"/>
@@ -888,62 +940,23 @@ QProgressBar::chunk {
</item> </item>
</layout> </layout>
</item> </item>
<item row="1" column="1" alignment="Qt::AlignRight"> <item row="3" column="1" alignment="Qt::AlignRight">
<widget class="QLabel" name="labelWordCount"> <widget class="QLabel" name="labelWordSeparator">
<property name="text"> <property name="text">
<string>Word Count:</string> <string>Word Separator:</string>
</property>
<property name="buddy">
<cstring>spinBoxLength</cstring>
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="2"> <item row="0" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_9"> <widget class="QLabel" name="labelWordListWarning">
<item> <property name="font">
<widget class="QLineEdit" name="editWordSeparator"> <font>
<property name="sizePolicy"> <weight>75</weight>
<sizepolicy hsizetype="Minimum" vsizetype="Fixed"> <bold>true</bold>
<horstretch>0</horstretch> </font>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>20</width>
<height>0</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item row="0" column="1" alignment="Qt::AlignRight">
<widget class="QLabel" name="labelWordList">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property> </property>
<property name="text"> <property name="text">
<string>Wordlist:</string> <string>Warning: the chosen wordlist is smaller than the minimum recommended size!</string>
</property> </property>
</widget> </widget>
</item> </item>

View File

@@ -48,12 +48,14 @@ SearchWidget::SearchWidget(QWidget* parent)
new QShortcut(Qt::CTRL + Qt::Key_J, this, SLOT(toggleHelp()), nullptr, Qt::WidgetWithChildrenShortcut); new QShortcut(Qt::CTRL + Qt::Key_J, this, SLOT(toggleHelp()), nullptr, Qt::WidgetWithChildrenShortcut);
connect(m_ui->searchEdit, SIGNAL(textChanged(QString)), SLOT(startSearchTimer())); connect(m_ui->searchEdit, SIGNAL(textChanged(QString)), SLOT(startSearchTimer()));
connect(m_ui->searchEdit, SIGNAL(textChanged(QString)), SLOT(updateSaveButtonVisibility()));
connect(m_ui->helpIcon, SIGNAL(triggered()), SLOT(toggleHelp())); connect(m_ui->helpIcon, SIGNAL(triggered()), SLOT(toggleHelp()));
connect(m_ui->searchIcon, SIGNAL(triggered()), SLOT(showSearchMenu())); connect(m_ui->searchIcon, SIGNAL(triggered()), SLOT(showSearchMenu()));
connect(m_ui->saveIcon, &QAction::triggered, this, [this] { emit saveSearch(m_ui->searchEdit->text()); }); connect(m_ui->saveIcon, &QAction::triggered, this, [this] { emit saveSearch(m_ui->searchEdit->text()); });
connect(m_searchTimer, SIGNAL(timeout()), SLOT(startSearch())); connect(m_searchTimer, SIGNAL(timeout()), SLOT(startSearch()));
connect(m_clearSearchTimer, SIGNAL(timeout()), SLOT(clearSearch())); connect(m_clearSearchTimer, SIGNAL(timeout()), SLOT(clearSearch()));
connect(this, SIGNAL(escapePressed()), SLOT(clearSearch())); connect(this, SIGNAL(escapePressed()), SLOT(clearSearch()));
connect(m_ui->searchEdit, &QLineEdit::returnPressed, this, &SearchWidget::onReturnPressed);
m_ui->searchEdit->setPlaceholderText(tr("Search (%1)…", "Search placeholder text, %1 is the keyboard shortcut") m_ui->searchEdit->setPlaceholderText(tr("Search (%1)…", "Search placeholder text, %1 is the keyboard shortcut")
.arg(QKeySequence(QKeySequence::Find).toString(QKeySequence::NativeText))); .arg(QKeySequence(QKeySequence::Find).toString(QKeySequence::NativeText)));
@@ -69,6 +71,12 @@ SearchWidget::SearchWidget(QWidget* parent)
m_actionLimitGroup->setCheckable(true); m_actionLimitGroup->setCheckable(true);
m_actionLimitGroup->setChecked(config()->get(Config::SearchLimitGroup).toBool()); m_actionLimitGroup->setChecked(config()->get(Config::SearchLimitGroup).toBool());
m_actionWaitForEnter = m_searchMenu->addAction(
tr("Press Enter to search"), this, [](bool state) { config()->set(Config::SearchWaitForEnter, state); });
m_actionWaitForEnter->setObjectName("actionSearchWaitForEnter");
m_actionWaitForEnter->setCheckable(true);
m_actionWaitForEnter->setChecked(config()->get(Config::SearchWaitForEnter).toBool());
m_ui->searchIcon->setIcon(icons()->icon("system-search")); m_ui->searchIcon->setIcon(icons()->icon("system-search"));
m_ui->searchEdit->addAction(m_ui->searchIcon, QLineEdit::LeadingPosition); m_ui->searchEdit->addAction(m_ui->searchIcon, QLineEdit::LeadingPosition);
@@ -148,17 +156,17 @@ void SearchWidget::connectSignals(SignalMultiplexer& mx)
mx.connect(this, SIGNAL(caseSensitiveChanged(bool)), SLOT(setSearchCaseSensitive(bool))); mx.connect(this, SIGNAL(caseSensitiveChanged(bool)), SLOT(setSearchCaseSensitive(bool)));
mx.connect(this, SIGNAL(limitGroupChanged(bool)), SLOT(setSearchLimitGroup(bool))); mx.connect(this, SIGNAL(limitGroupChanged(bool)), SLOT(setSearchLimitGroup(bool)));
mx.connect(this, SIGNAL(downPressed()), SLOT(focusOnEntries())); mx.connect(this, SIGNAL(downPressed()), SLOT(focusOnEntries()));
mx.connect(SIGNAL(requestSearch(QString)), m_ui->searchEdit, SLOT(setText(QString))); mx.connect(SIGNAL(requestSearch(QString)), this, SLOT(performRequestedSearch(QString)));
mx.connect(SIGNAL(clearSearch()), this, SLOT(clearSearch())); mx.connect(SIGNAL(clearSearch()), this, SLOT(clearSearch()));
mx.connect(SIGNAL(entrySelectionChanged()), this, SLOT(resetSearchClearTimer())); mx.connect(SIGNAL(entrySelectionChanged()), this, SLOT(resetSearchClearTimer()));
mx.connect(SIGNAL(currentModeChanged(DatabaseWidget::Mode)), this, SLOT(resetSearchClearTimer())); mx.connect(SIGNAL(currentModeChanged(DatabaseWidget::Mode)), this, SLOT(resetSearchClearTimer()));
mx.connect(SIGNAL(databaseUnlocked()), this, SLOT(focusSearch())); mx.connect(SIGNAL(databaseUnlocked()), this, SLOT(focusSearch()));
mx.connect(m_ui->searchEdit, SIGNAL(returnPressed()), SLOT(switchToEntryEdit())); mx.connect(this, SIGNAL(enterPressed()), SLOT(switchToEntryEdit()));
} }
void SearchWidget::databaseChanged(DatabaseWidget* dbWidget) void SearchWidget::databaseChanged(DatabaseWidget* dbWidget)
{ {
if (dbWidget != nullptr) { if (dbWidget) {
// Set current search text from this database // Set current search text from this database
m_ui->searchEdit->setText(dbWidget->getCurrentSearch()); m_ui->searchEdit->setText(dbWidget->getCurrentSearch());
// Enforce search policy // Enforce search policy
@@ -171,18 +179,15 @@ void SearchWidget::databaseChanged(DatabaseWidget* dbWidget)
void SearchWidget::startSearchTimer() void SearchWidget::startSearchTimer()
{ {
if (!m_searchTimer->isActive()) { if (m_actionWaitForEnter->isChecked()) {
m_searchTimer->stop(); m_searchTimer->stop();
} else {
m_searchTimer->start(500);
} }
m_searchTimer->start(100);
} }
void SearchWidget::startSearch() void SearchWidget::startSearch()
{ {
if (!m_searchTimer->isActive()) {
m_searchTimer->stop();
}
m_ui->saveIcon->setVisible(true); m_ui->saveIcon->setVisible(true);
search(m_ui->searchEdit->text()); search(m_ui->searchEdit->text());
} }
@@ -200,18 +205,18 @@ void SearchWidget::updateCaseSensitive()
emit caseSensitiveChanged(m_actionCaseSensitive->isChecked()); emit caseSensitiveChanged(m_actionCaseSensitive->isChecked());
} }
void SearchWidget::setCaseSensitive(bool state)
{
m_actionCaseSensitive->setChecked(state);
updateCaseSensitive();
}
void SearchWidget::updateLimitGroup() void SearchWidget::updateLimitGroup()
{ {
config()->set(Config::SearchLimitGroup, m_actionLimitGroup->isChecked()); config()->set(Config::SearchLimitGroup, m_actionLimitGroup->isChecked());
emit limitGroupChanged(m_actionLimitGroup->isChecked()); emit limitGroupChanged(m_actionLimitGroup->isChecked());
} }
void SearchWidget::setCaseSensitive(bool state)
{
m_actionCaseSensitive->setChecked(state);
updateCaseSensitive();
}
void SearchWidget::setLimitGroup(bool state) void SearchWidget::setLimitGroup(bool state)
{ {
m_actionLimitGroup->setChecked(state); m_actionLimitGroup->setChecked(state);
@@ -244,3 +249,28 @@ void SearchWidget::showSearchMenu()
{ {
m_searchMenu->exec(m_ui->searchEdit->mapToGlobal(m_ui->searchEdit->rect().bottomLeft())); m_searchMenu->exec(m_ui->searchEdit->mapToGlobal(m_ui->searchEdit->rect().bottomLeft()));
} }
void SearchWidget::onReturnPressed()
{
if (m_actionWaitForEnter->isChecked()) {
m_ui->saveIcon->setVisible(true);
emit search(m_ui->searchEdit->text());
} else {
emit enterPressed();
}
}
void SearchWidget::performRequestedSearch(const QString& text)
{
// This method handles saved searches - it should set the text and immediately trigger search
// without any delay, regardless of the "Press Enter to search" setting
m_ui->searchEdit->setText(text);
m_ui->saveIcon->setVisible(!text.isEmpty());
emit search(text);
}
void SearchWidget::updateSaveButtonVisibility()
{
// Show save button whenever there's non-empty text in the search field
m_ui->saveIcon->setVisible(!m_ui->searchEdit->text().isEmpty());
}

View File

@@ -68,6 +68,7 @@ public slots:
void clearSearch(); void clearSearch();
private slots: private slots:
void onReturnPressed();
void startSearchTimer(); void startSearchTimer();
void startSearch(); void startSearch();
void updateCaseSensitive(); void updateCaseSensitive();
@@ -75,6 +76,8 @@ private slots:
void toggleHelp(); void toggleHelp();
void showSearchMenu(); void showSearchMenu();
void resetSearchClearTimer(); void resetSearchClearTimer();
void performRequestedSearch(const QString& text);
void updateSaveButtonVisibility();
private: private:
const QScopedPointer<Ui::SearchWidget> m_ui; const QScopedPointer<Ui::SearchWidget> m_ui;
@@ -83,6 +86,7 @@ private:
QTimer* m_clearSearchTimer; QTimer* m_clearSearchTimer;
QAction* m_actionCaseSensitive; QAction* m_actionCaseSensitive;
QAction* m_actionLimitGroup; QAction* m_actionLimitGroup;
QAction* m_actionWaitForEnter;
QMenu* m_searchMenu; QMenu* m_searchMenu;
}; };

View File

@@ -39,6 +39,7 @@ TotpDialog::TotpDialog(QWidget* parent, Entry* entry)
m_step = m_entry->totpSettings()->step; m_step = m_entry->totpSettings()->step;
resetCounter(); resetCounter();
updateProgressBar(); updateProgressBar();
updateSeconds();
connect(&m_totpUpdateTimer, SIGNAL(timeout()), this, SLOT(updateProgressBar())); connect(&m_totpUpdateTimer, SIGNAL(timeout()), this, SLOT(updateProgressBar()));
connect(&m_totpUpdateTimer, SIGNAL(timeout()), this, SLOT(updateSeconds())); connect(&m_totpUpdateTimer, SIGNAL(timeout()), this, SLOT(updateSeconds()));
@@ -88,10 +89,15 @@ void TotpDialog::updateSeconds()
void TotpDialog::updateTotp() void TotpDialog::updateTotp()
{ {
QString totpCode = m_entry->totp(); bool isValid = false;
QString firstHalf = totpCode.left(totpCode.size() / 2); QString totpCode = m_entry->totp(&isValid);
QString secondHalf = totpCode.mid(totpCode.size() / 2); if (isValid) {
m_ui->totpLabel->setText(firstHalf + " " + secondHalf); totpCode.insert(totpCode.size() / 2, " ");
}
m_ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(isValid);
m_ui->progressBar->setVisible(isValid);
m_ui->timerLabel->setVisible(isValid);
m_ui->totpLabel->setText(totpCode);
} }
void TotpDialog::resetCounter() void TotpDialog::resetCounter()

View File

@@ -108,6 +108,7 @@ void TotpSetupDialog::init()
m_ui->algorithmComboBox->addItem(item.first, item.second); m_ui->algorithmComboBox->addItem(item.first, item.second);
} }
m_ui->algorithmComboBox->setCurrentIndex(0); m_ui->algorithmComboBox->setCurrentIndex(0);
m_ui->invalidKeyLabel->setVisible(false);
// Read entry totp settings // Read entry totp settings
auto settings = m_entry->totpSettings(); auto settings = m_entry->totpSettings();
@@ -127,5 +128,8 @@ void TotpSetupDialog::init()
m_ui->algorithmComboBox->setCurrentIndex(index); m_ui->algorithmComboBox->setCurrentIndex(index);
} }
} }
auto error = Totp::checkValidSettings(settings);
m_ui->invalidKeyLabel->setVisible(!error.isEmpty());
} }
} }

View File

@@ -14,6 +14,22 @@
<string>Setup TOTP</string> <string>Setup TOTP</string>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="invalidKeyLabel">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Error: secret key is invalid</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item> <item>
<layout class="QHBoxLayout" name="horizontalLayout_2"> <layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="leftMargin"> <property name="leftMargin">
@@ -210,6 +226,7 @@
<zorder>customSettingsGroup</zorder> <zorder>customSettingsGroup</zorder>
<zorder>buttonBox</zorder> <zorder>buttonBox</zorder>
<zorder>groupBox</zorder> <zorder>groupBox</zorder>
<zorder>invalidKeyLabel</zorder>
</widget> </widget>
<tabstops> <tabstops>
<tabstop>seedEdit</tabstop> <tabstop>seedEdit</tabstop>

View File

@@ -279,7 +279,7 @@ QSharedPointer<Database> CsvImportWidget::buildDatabase()
// Modified Time // Modified Time
TimeInfo timeInfo; TimeInfo timeInfo;
if (m_parserModel->data(m_parserModel->index(r, 9)).isValid()) { if (m_parserModel->data(m_parserModel->index(r, 9)).isValid()) {
auto datetime = m_parserModel->data(m_parserModel->index(r, 8)).toString(); auto datetime = m_parserModel->data(m_parserModel->index(r, 9)).toString();
if (datetime.contains(QRegularExpression("^\\d+$"))) { if (datetime.contains(QRegularExpression("^\\d+$"))) {
auto t = datetime.toLongLong(); auto t = datetime.toLongLong();
if (t <= INT32_MAX) { if (t <= INT32_MAX) {
@@ -298,7 +298,7 @@ QSharedPointer<Database> CsvImportWidget::buildDatabase()
} }
// Creation Time // Creation Time
if (m_parserModel->data(m_parserModel->index(r, 10)).isValid()) { if (m_parserModel->data(m_parserModel->index(r, 10)).isValid()) {
auto datetime = m_parserModel->data(m_parserModel->index(r, 9)).toString(); auto datetime = m_parserModel->data(m_parserModel->index(r, 10)).toString();
if (datetime.contains(QRegularExpression("^\\d+$"))) { if (datetime.contains(QRegularExpression("^\\d+$"))) {
auto t = datetime.toLongLong(); auto t = datetime.toLongLong();
if (t <= INT32_MAX) { if (t <= INT32_MAX) {

View File

@@ -99,6 +99,8 @@ DatabaseSettingsDialog::~DatabaseSettingsDialog() = default;
void DatabaseSettingsDialog::load(const QSharedPointer<Database>& db) void DatabaseSettingsDialog::load(const QSharedPointer<Database>& db)
{ {
// Default to the main page on load
setCurrentPage(0);
setHeadline(tr("Database Settings: %1").arg(db->canonicalFilePath())); setHeadline(tr("Database Settings: %1").arg(db->canonicalFilePath()));
m_generalWidget->loadSettings(db); m_generalWidget->loadSettings(db);

View File

@@ -159,12 +159,7 @@ void DatabaseSettingsWidgetEncryption::initialize()
// Set up KDF algorithms // Set up KDF algorithms
loadKdfAlgorithms(); loadKdfAlgorithms();
// Perform Benchmark if requested
if (isNewDatabase) { if (isNewDatabase) {
if (IS_ARGON2(m_ui->kdfComboBox->currentData())) {
m_ui->memorySpinBox->setValue(16);
m_ui->parallelismSpinBox->setValue(2);
}
benchmarkTransformRounds(); benchmarkTransformRounds();
} }
@@ -225,7 +220,7 @@ void DatabaseSettingsWidgetEncryption::loadKdfParameters()
// Set Argon2 parameters // Set Argon2 parameters
auto argon2Kdf = kdf.staticCast<Argon2Kdf>(); auto argon2Kdf = kdf.staticCast<Argon2Kdf>();
m_ui->transformRoundsSpinBox->setValue(argon2Kdf->rounds()); m_ui->transformRoundsSpinBox->setValue(argon2Kdf->rounds());
m_ui->memorySpinBox->setValue(static_cast<int>(argon2Kdf->memory()) / (1 << 10)); m_ui->memorySpinBox->setValue(Argon2Kdf::toMebibytes(argon2Kdf->memory()));
m_ui->parallelismSpinBox->setValue(argon2Kdf->parallelism()); m_ui->parallelismSpinBox->setValue(argon2Kdf->parallelism());
} else if (!dbIsArgon2 && !kdfIsArgon2) { } else if (!dbIsArgon2 && !kdfIsArgon2) {
// Set AES KDF parameters // Set AES KDF parameters
@@ -233,8 +228,8 @@ void DatabaseSettingsWidgetEncryption::loadKdfParameters()
} else { } else {
// Set reasonable defaults and then benchmark // Set reasonable defaults and then benchmark
if (kdfIsArgon2) { if (kdfIsArgon2) {
m_ui->memorySpinBox->setValue(16); m_ui->memorySpinBox->setValue(Argon2Kdf::toMebibytes(ARGON2_DEFAULT_MEMORY));
m_ui->parallelismSpinBox->setValue(2); m_ui->parallelismSpinBox->setValue(ARGON2_DEFAULT_PARALLELISM);
} }
benchmarkTransformRounds(); benchmarkTransformRounds();
} }
@@ -343,7 +338,7 @@ bool DatabaseSettingsWidgetEncryption::saveSettings()
kdf->setRounds(m_ui->transformRoundsSpinBox->value()); kdf->setRounds(m_ui->transformRoundsSpinBox->value());
if (IS_ARGON2(kdf->uuid())) { if (IS_ARGON2(kdf->uuid())) {
auto argon2Kdf = kdf.staticCast<Argon2Kdf>(); auto argon2Kdf = kdf.staticCast<Argon2Kdf>();
argon2Kdf->setMemory(static_cast<quint64>(m_ui->memorySpinBox->value()) * (1 << 10)); argon2Kdf->setMemory(Argon2Kdf::toKibibytes(m_ui->memorySpinBox->value()));
argon2Kdf->setParallelism(static_cast<quint32>(m_ui->parallelismSpinBox->value())); argon2Kdf->setParallelism(static_cast<quint32>(m_ui->parallelismSpinBox->value()));
} }
@@ -377,8 +372,8 @@ void DatabaseSettingsWidgetEncryption::benchmarkTransformRounds(int millisecs)
auto argon2Kdf = kdf.staticCast<Argon2Kdf>(); auto argon2Kdf = kdf.staticCast<Argon2Kdf>();
// Set a small static number of rounds for the benchmark // Set a small static number of rounds for the benchmark
argon2Kdf->setRounds(4); argon2Kdf->setRounds(4);
if (!argon2Kdf->setMemory(static_cast<quint64>(m_ui->memorySpinBox->value()) * (1 << 10))) { if (!argon2Kdf->setMemory(Argon2Kdf::toKibibytes(m_ui->memorySpinBox->value()))) {
m_ui->memorySpinBox->setValue(static_cast<int>(argon2Kdf->memory() / (1 << 10))); m_ui->memorySpinBox->setValue(Argon2Kdf::toMebibytes(argon2Kdf->memory()));
} }
if (!argon2Kdf->setParallelism(static_cast<quint32>(m_ui->parallelismSpinBox->value()))) { if (!argon2Kdf->setParallelism(static_cast<quint32>(m_ui->parallelismSpinBox->value()))) {
m_ui->parallelismSpinBox->setValue(argon2Kdf->parallelism()); m_ui->parallelismSpinBox->setValue(argon2Kdf->parallelism());

View File

@@ -537,7 +537,6 @@
<tabstop>transformBenchmarkButton</tabstop> <tabstop>transformBenchmarkButton</tabstop>
<tabstop>memorySpinBox</tabstop> <tabstop>memorySpinBox</tabstop>
<tabstop>parallelismSpinBox</tabstop> <tabstop>parallelismSpinBox</tabstop>
<tabstop>advancedSettingsButton</tabstop>
</tabstops> </tabstops>
<resources/> <resources/>
<connections/> <connections/>

View File

@@ -125,7 +125,7 @@
<item row="0" column="1"> <item row="0" column="1">
<widget class="QLineEdit" name="dbPublicName"> <widget class="QLineEdit" name="dbPublicName">
<property name="toolTip"> <property name="toolTip">
<string>Publically visible display name used on the unlock dialog</string> <string>Publicly visible display name used on the unlock dialog</string>
</property> </property>
<property name="accessibleName"> <property name="accessibleName">
<string>Database public display name</string> <string>Database public display name</string>
@@ -150,7 +150,7 @@
</size> </size>
</property> </property>
<property name="toolTip"> <property name="toolTip">
<string>Publically visible color used on the unlock dialog</string> <string>Publicly visible color used on the unlock dialog</string>
</property> </property>
<property name="accessibleName"> <property name="accessibleName">
<string>Database public display color chooser</string> <string>Database public display color chooser</string>

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "EditEntryAttachmentsDialog.h"
#include "ui_EditEntryAttachmentsDialog.h"
#include <core/EntryAttachments.h>
#include <QDebug>
#include <QMessageBox>
#include <QMimeDatabase>
#include <QPushButton>
EditEntryAttachmentsDialog::EditEntryAttachmentsDialog(QWidget* parent)
: QDialog(parent)
, m_ui(new Ui::EditEntryAttachmentsDialog)
{
m_ui->setupUi(this);
m_ui->dialogButtons->setStandardButtons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(m_ui->dialogButtons, &QDialogButtonBox::accepted, this, &EditEntryAttachmentsDialog::accept);
connect(m_ui->dialogButtons, &QDialogButtonBox::rejected, this, &EditEntryAttachmentsDialog::reject);
}
EditEntryAttachmentsDialog::~EditEntryAttachmentsDialog() = default;
void EditEntryAttachmentsDialog::setAttachment(attachments::Attachment attachment)
{
setWindowTitle(tr("Edit: %1").arg(attachment.name));
m_ui->attachmentWidget->openAttachment(std::move(attachment), attachments::OpenMode::ReadWrite);
}
attachments::Attachment EditEntryAttachmentsDialog::getAttachment() const
{
return m_ui->attachmentWidget->getAttachment();
}

View File

@@ -17,32 +17,29 @@
#pragma once #pragma once
#include "attachments/AttachmentTypes.h"
#include <QDialog> #include <QDialog>
#include <QPointer> #include <QPointer>
namespace Ui namespace Ui
{ {
class EntryAttachmentsDialog; class EditEntryAttachmentsDialog;
} }
class QByteArray;
class EntryAttachments; class EntryAttachments;
class NewEntryAttachmentsDialog : public QDialog class EditEntryAttachmentsDialog : public QDialog
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit NewEntryAttachmentsDialog(QPointer<EntryAttachments> attachments, QWidget* parent = nullptr); explicit EditEntryAttachmentsDialog(QWidget* parent = nullptr);
~NewEntryAttachmentsDialog() override; ~EditEntryAttachmentsDialog() override;
private slots: void setAttachment(attachments::Attachment attachment);
void saveAttachment(); attachments::Attachment getAttachment() const;
void fileNameTextChanged(const QString& fileName);
private: private:
bool validateFileName(const QString& fileName, QString& error) const; QScopedPointer<Ui::EditEntryAttachmentsDialog> m_ui;
QPointer<EntryAttachments> m_attachments;
QScopedPointer<Ui::EntryAttachmentsDialog> m_ui;
}; };

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>EditEntryAttachmentsDialog</class>
<widget class="QDialog" name="EditEntryAttachmentsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>447</width>
<height>424</height>
</rect>
</property>
<property name="windowTitle">
<string notr="true"/>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="AttachmentWidget" name="attachmentWidget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="dialogButtons">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>AttachmentWidget</class>
<extends>QWidget</extends>
<header location="global">gui/entry/attachments/AttachmentWidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -53,6 +53,7 @@
#include "gui/EditWidgetProperties.h" #include "gui/EditWidgetProperties.h"
#include "gui/FileDialog.h" #include "gui/FileDialog.h"
#include "gui/Font.h" #include "gui/Font.h"
#include "gui/GuiTools.h"
#include "gui/Icons.h" #include "gui/Icons.h"
#include "gui/MessageBox.h" #include "gui/MessageBox.h"
#include "gui/entry/AutoTypeAssociationsModel.h" #include "gui/entry/AutoTypeAssociationsModel.h"
@@ -185,7 +186,10 @@ void EditEntryWidget::setupMain()
m_mainUi->setupUi(m_mainWidget); m_mainUi->setupUi(m_mainWidget);
addPage(tr("Entry"), icons()->icon("document-edit"), m_mainWidget); addPage(tr("Entry"), icons()->icon("document-edit"), m_mainWidget);
// Disable mouse wheel grab when scrolling
m_mainUi->usernameComboBox->installEventFilter(new MouseWheelEventFilter(this));
m_mainUi->usernameComboBox->setEditable(true); m_mainUi->usernameComboBox->setEditable(true);
m_mainUi->usernameComboBox->lineEdit()->setFocusPolicy(Qt::StrongFocus);
m_usernameCompleter->setCompletionMode(QCompleter::InlineCompletion); m_usernameCompleter->setCompletionMode(QCompleter::InlineCompletion);
m_usernameCompleter->setCaseSensitivity(Qt::CaseSensitive); m_usernameCompleter->setCaseSensitivity(Qt::CaseSensitive);
m_usernameCompleter->setModel(m_usernameCompleterModel); m_usernameCompleter->setModel(m_usernameCompleterModel);
@@ -1686,21 +1690,21 @@ void EditEntryWidget::deleteAllHistoryEntries()
QMenu* EditEntryWidget::createPresetsMenu() QMenu* EditEntryWidget::createPresetsMenu()
{ {
auto* expirePresetsMenu = new QMenu(this); auto* expirePresetsMenu = new QMenu(this);
expirePresetsMenu->addAction(tr("%n hour(s)", nullptr, 12))->setData(QVariant::fromValue(TimeDelta::fromHours(12))); expirePresetsMenu->addAction(tr("%n hour(s)", "", 12))->setData(QVariant::fromValue(TimeDelta::fromHours(12)));
expirePresetsMenu->addAction(tr("%n hour(s)", nullptr, 24))->setData(QVariant::fromValue(TimeDelta::fromHours(24))); expirePresetsMenu->addAction(tr("%n hour(s)", "", 24))->setData(QVariant::fromValue(TimeDelta::fromHours(24)));
expirePresetsMenu->addSeparator(); expirePresetsMenu->addSeparator();
expirePresetsMenu->addAction(tr("%n week(s)", nullptr, 1))->setData(QVariant::fromValue(TimeDelta::fromDays(7))); expirePresetsMenu->addAction(tr("%n week(s)", "", 1))->setData(QVariant::fromValue(TimeDelta::fromDays(7)));
expirePresetsMenu->addAction(tr("%n week(s)", nullptr, 2))->setData(QVariant::fromValue(TimeDelta::fromDays(14))); expirePresetsMenu->addAction(tr("%n week(s)", "", 2))->setData(QVariant::fromValue(TimeDelta::fromDays(14)));
expirePresetsMenu->addAction(tr("%n week(s)", nullptr, 3))->setData(QVariant::fromValue(TimeDelta::fromDays(21))); expirePresetsMenu->addAction(tr("%n week(s)", "", 3))->setData(QVariant::fromValue(TimeDelta::fromDays(21)));
expirePresetsMenu->addSeparator(); expirePresetsMenu->addSeparator();
expirePresetsMenu->addAction(tr("%n month(s)", nullptr, 1))->setData(QVariant::fromValue(TimeDelta::fromMonths(1))); expirePresetsMenu->addAction(tr("%n month(s)", "", 1))->setData(QVariant::fromValue(TimeDelta::fromMonths(1)));
expirePresetsMenu->addAction(tr("%n month(s)", nullptr, 2))->setData(QVariant::fromValue(TimeDelta::fromMonths(2))); expirePresetsMenu->addAction(tr("%n month(s)", "", 2))->setData(QVariant::fromValue(TimeDelta::fromMonths(2)));
expirePresetsMenu->addAction(tr("%n month(s)", nullptr, 3))->setData(QVariant::fromValue(TimeDelta::fromMonths(3))); expirePresetsMenu->addAction(tr("%n month(s)", "", 3))->setData(QVariant::fromValue(TimeDelta::fromMonths(3)));
expirePresetsMenu->addAction(tr("%n month(s)", nullptr, 6))->setData(QVariant::fromValue(TimeDelta::fromMonths(6))); expirePresetsMenu->addAction(tr("%n month(s)", "", 6))->setData(QVariant::fromValue(TimeDelta::fromMonths(6)));
expirePresetsMenu->addSeparator(); expirePresetsMenu->addSeparator();
expirePresetsMenu->addAction(tr("%n year(s)", nullptr, 1))->setData(QVariant::fromValue(TimeDelta::fromYears(1))); expirePresetsMenu->addAction(tr("%n year(s)", "", 1))->setData(QVariant::fromValue(TimeDelta::fromYears(1)));
expirePresetsMenu->addAction(tr("%n year(s)", nullptr, 2))->setData(QVariant::fromValue(TimeDelta::fromYears(2))); expirePresetsMenu->addAction(tr("%n year(s)", "", 2))->setData(QVariant::fromValue(TimeDelta::fromYears(2)));
expirePresetsMenu->addAction(tr("%n year(s)", nullptr, 3))->setData(QVariant::fromValue(TimeDelta::fromYears(3))); expirePresetsMenu->addAction(tr("%n year(s)", "", 3))->setData(QVariant::fromValue(TimeDelta::fromYears(3)));
return expirePresetsMenu; return expirePresetsMenu;
} }

View File

@@ -140,6 +140,9 @@
</item> </item>
<item row="1" column="1"> <item row="1" column="1">
<widget class="QComboBox" name="usernameComboBox"> <widget class="QComboBox" name="usernameComboBox">
<property name="focusPolicy">
<enum>Qt::StrongFocus</enum>
</property>
<property name="accessibleName"> <property name="accessibleName">
<string>Username field</string> <string>Username field</string>
</property> </property>

View File

@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>EntryAttachmentsDialog</class>
<widget class="QDialog" name="EntryAttachmentsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>402</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLineEdit" name="titleEdit">
<property name="placeholderText">
<string>File name</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="errorLabel">
<property name="enabled">
<bool>true</bool>
</property>
<property name="styleSheet">
<string notr="true">color: #FF9696</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="attachmentTextEdit">
<property name="placeholderText">
<string>File contents...</string>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="dialogButtons">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -130,6 +130,15 @@ QString EntryAttachmentsModel::keyByIndex(const QModelIndex& index) const
return m_entryAttachments->keys().at(index.row()); return m_entryAttachments->keys().at(index.row());
} }
int EntryAttachmentsModel::rowByKey(const QString& key) const
{
if (!m_entryAttachments) {
return -1;
}
return m_entryAttachments->keys().indexOf(key);
}
void EntryAttachmentsModel::attachmentChange(const QString& key) void EntryAttachmentsModel::attachmentChange(const QString& key)
{ {
int row = m_entryAttachments->keys().indexOf(key); int row = m_entryAttachments->keys().indexOf(key);

View File

@@ -44,6 +44,7 @@ public:
bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override;
Qt::ItemFlags flags(const QModelIndex& index) const override; Qt::ItemFlags flags(const QModelIndex& index) const override;
QString keyByIndex(const QModelIndex& index) const; QString keyByIndex(const QModelIndex& index) const;
int rowByKey(const QString& key) const;
private slots: private slots:
void attachmentChange(const QString& key); void attachmentChange(const QString& key);

View File

@@ -17,23 +17,40 @@
#include "EntryAttachmentsWidget.h" #include "EntryAttachmentsWidget.h"
#include "EditEntryAttachmentsDialog.h"
#include "EntryAttachmentsModel.h" #include "EntryAttachmentsModel.h"
#include "NewEntryAttachmentsDialog.h"
#include "PreviewEntryAttachmentsDialog.h" #include "PreviewEntryAttachmentsDialog.h"
#include "ui_EntryAttachmentsWidget.h" #include "ui_EntryAttachmentsWidget.h"
#include <QDebug> #include <QDebug>
#include <QDropEvent> #include <QDropEvent>
#include <QLineEdit>
#include <QMenu>
#include <QMimeData> #include <QMimeData>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTemporaryFile> #include <QTemporaryFile>
#include "EntryAttachmentsModel.h"
#include "core/EntryAttachments.h" #include "core/EntryAttachments.h"
#include "core/Tools.h" #include "core/Tools.h"
#include "gui/FileDialog.h" #include "gui/FileDialog.h"
#include "gui/MessageBox.h" #include "gui/MessageBox.h"
namespace
{
constexpr const char* Suffix = ".txt";
QString generateUniqueName(const QString& name, const QStringList& existingNames)
{
uint64_t i = 0;
QString newName = QStringLiteral("%1%2").arg(name).arg(Suffix);
while (existingNames.contains(newName)) {
newName = QStringLiteral("%1_%2%3").arg(name).arg(++i).arg(Suffix);
}
return newName;
}
} // namespace
EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent) EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent)
: QWidget(parent) : QWidget(parent)
, m_ui(new Ui::EntryAttachmentsWidget) , m_ui(new Ui::EntryAttachmentsWidget)
@@ -67,14 +84,30 @@ EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent)
// clang-format on // clang-format on
connect(this, SIGNAL(readOnlyChanged(bool)), m_attachmentsModel, SLOT(setReadOnly(bool))); connect(this, SIGNAL(readOnlyChanged(bool)), m_attachmentsModel, SLOT(setReadOnly(bool)));
connect(m_ui->attachmentsView, SIGNAL(doubleClicked(QModelIndex)), SLOT(previewSelectedAttachment())); connect(m_ui->attachmentsView, &QAbstractItemView::doubleClicked, [this](const QModelIndex&) {
m_readOnly ? previewSelectedAttachment() : editSelectedAttachment();
});
connect(m_ui->attachmentsView->itemDelegate(), &QAbstractItemDelegate::commitData, [this](QWidget* editor) {
if (auto lineEdit = qobject_cast<QLineEdit*>(editor)) {
auto index = m_attachmentsModel->rowByKey(lineEdit->text());
m_ui->attachmentsView->setCurrentIndex(m_attachmentsModel->index(index, 0));
}
});
connect(m_ui->saveAttachmentButton, SIGNAL(clicked()), SLOT(saveSelectedAttachments())); connect(m_ui->saveAttachmentButton, SIGNAL(clicked()), SLOT(saveSelectedAttachments()));
connect(m_ui->openAttachmentButton, SIGNAL(clicked()), SLOT(openSelectedAttachments())); connect(m_ui->openAttachmentButton, SIGNAL(clicked()), SLOT(openSelectedAttachments()));
connect(m_ui->addAttachmentButton, SIGNAL(clicked()), SLOT(insertAttachments())); connect(m_ui->addAttachmentButton, SIGNAL(clicked()), SLOT(insertAttachments()));
connect(m_ui->newAttachmentButton, SIGNAL(clicked()), SLOT(newAttachments())); connect(m_ui->editAttachmentButton, SIGNAL(clicked()), SLOT(editSelectedAttachment()));
connect(m_ui->previewAttachmentButton, SIGNAL(clicked()), SLOT(previewSelectedAttachment())); connect(m_ui->previewAttachmentButton, SIGNAL(clicked()), SLOT(previewSelectedAttachment()));
connect(m_ui->removeAttachmentButton, SIGNAL(clicked()), SLOT(removeSelectedAttachments())); connect(m_ui->removeAttachmentButton, SIGNAL(clicked()), SLOT(removeSelectedAttachments()));
auto addButtonMenu = new QMenu(this);
addButtonMenu->addAction(tr("New Text Document"), this, &EntryAttachmentsWidget::newAttachments);
addButtonMenu->addAction(tr("Load from Disk…"), this, QOverload<>::of(&EntryAttachmentsWidget::insertAttachments));
m_ui->addAttachmentButton->setMenu(addButtonMenu);
updateButtonsVisible(); updateButtonsVisible();
updateButtonsEnabled(); updateButtonsEnabled();
} }
@@ -175,19 +208,34 @@ void EntryAttachmentsWidget::newAttachments()
return; return;
} }
NewEntryAttachmentsDialog newEntryDialog(m_entryAttachments, this); // Create a temporary file to allow the user to edit the attachment
if (newEntryDialog.exec() == QDialog::Accepted) { auto newFileName = generateUniqueName(tr("New Attachment"), m_entryAttachments->keys());
emit widgetUpdated(); m_entryAttachments->set(newFileName, QByteArray());
}
auto currentIndex = m_attachmentsModel->index(m_attachmentsModel->rowByKey(newFileName), 0);
m_ui->attachmentsView->setCurrentIndex(currentIndex);
m_ui->attachmentsView->edit(currentIndex);
} }
void EntryAttachmentsWidget::previewSelectedAttachment() void EntryAttachmentsWidget::previewSelectedAttachment()
{ {
Q_ASSERT(m_entryAttachments); Q_ASSERT(m_entryAttachments);
const auto index = m_ui->attachmentsView->selectionModel()->selectedIndexes().first(); const auto selectionModel = m_ui->attachmentsView->selectionModel();
if (!selectionModel) {
qWarning() << "Failed to preview an attachment: No selection model";
return;
}
auto indexes = selectionModel->selectedIndexes();
if (indexes.empty()) {
qWarning() << "Failed to edit an attachment: No attachment selected";
return;
}
const auto index = indexes.first();
if (!index.isValid()) { if (!index.isValid()) {
qWarning() << tr("Failed to preview an attachment: Attachment not found"); qWarning() << "Failed to preview an attachment: Attachment not found";
return; return;
} }
@@ -198,7 +246,7 @@ void EntryAttachmentsWidget::previewSelectedAttachment()
auto data = m_entryAttachments->value(name); auto data = m_entryAttachments->value(name);
PreviewEntryAttachmentsDialog previewDialog(this); PreviewEntryAttachmentsDialog previewDialog(this);
previewDialog.setAttachment(name, data); previewDialog.setAttachment({name, data});
connect(&previewDialog, SIGNAL(openAttachment(QString)), SLOT(openSelectedAttachments())); connect(&previewDialog, SIGNAL(openAttachment(QString)), SLOT(openSelectedAttachments()));
connect(&previewDialog, SIGNAL(saveAttachment(QString)), SLOT(saveSelectedAttachments())); connect(&previewDialog, SIGNAL(saveAttachment(QString)), SLOT(saveSelectedAttachments()));
@@ -208,7 +256,7 @@ void EntryAttachmentsWidget::previewSelectedAttachment()
&previewDialog, &previewDialog,
[&previewDialog, &name, this](const QString& key) { [&previewDialog, &name, this](const QString& key) {
if (key == name) { if (key == name) {
previewDialog.setAttachment(name, m_entryAttachments->value(name)); previewDialog.setAttachment({name, m_entryAttachments->value(name)});
} }
}); });
@@ -218,6 +266,51 @@ void EntryAttachmentsWidget::previewSelectedAttachment()
setFocus(); setFocus();
} }
void EntryAttachmentsWidget::editSelectedAttachment()
{
Q_ASSERT(m_entryAttachments);
const auto selectionModel = m_ui->attachmentsView->selectionModel();
if (!selectionModel) {
qWarning() << "Failed to edit an attachment: No selection model";
return;
}
const auto selectedIndexes = selectionModel->selectedIndexes();
if (selectedIndexes.isEmpty()) {
qWarning() << "Failed to edit an attachment: No attachment selected";
return;
}
const auto index = selectedIndexes.first();
if (!index.isValid()) {
qWarning() << "Failed to edit an attachment: Attachment not found";
return;
}
// Set selection to the first
m_ui->attachmentsView->setCurrentIndex(index);
auto name = m_attachmentsModel->keyByIndex(index);
auto data = m_entryAttachments->value(name);
EditEntryAttachmentsDialog editDialog(this);
editDialog.setAttachment({name, data});
if (editDialog.exec() == QDialog::Accepted) {
auto attachment = editDialog.getAttachment();
// Edit dialog cannot change the name of the attachment
if (attachment.name == name) {
m_entryAttachments->set(attachment.name, attachment.data);
}
}
// Set focus back to the widget to allow keyboard navigation
setFocus();
}
void EntryAttachmentsWidget::removeSelectedAttachments() void EntryAttachmentsWidget::removeSelectedAttachments()
{ {
Q_ASSERT(m_entryAttachments); Q_ASSERT(m_entryAttachments);
@@ -346,35 +439,38 @@ void EntryAttachmentsWidget::openSelectedAttachments()
void EntryAttachmentsWidget::updateButtonsEnabled() void EntryAttachmentsWidget::updateButtonsEnabled()
{ {
const bool hasSelection = m_ui->attachmentsView->selectionModel()->hasSelection(); const auto selectionModel = m_ui->attachmentsView->selectionModel();
const bool hasSelection = selectionModel && selectionModel->hasSelection();
m_ui->addAttachmentButton->setEnabled(!m_readOnly); m_ui->addAttachmentButton->setEnabled(!m_readOnly);
m_ui->newAttachmentButton->setEnabled(!m_readOnly);
m_ui->removeAttachmentButton->setEnabled(hasSelection && !m_readOnly); m_ui->removeAttachmentButton->setEnabled(hasSelection && !m_readOnly);
m_ui->editAttachmentButton->setEnabled(hasSelection && !m_readOnly);
if (const auto indexes = selectionModel ? selectionModel->selectedIndexes() : QModelIndexList{}; !indexes.empty()) {
auto mimeType = Tools::getMimeType(m_entryAttachments->value(m_attachmentsModel->keyByIndex(indexes.first())));
m_ui->editAttachmentButton->setEnabled(hasSelection && !m_readOnly && Tools::isTextMimeType(mimeType));
}
m_ui->saveAttachmentButton->setEnabled(hasSelection); m_ui->saveAttachmentButton->setEnabled(hasSelection);
m_ui->previewAttachmentButton->setEnabled(hasSelection); m_ui->previewAttachmentButton->setEnabled(hasSelection);
m_ui->openAttachmentButton->setEnabled(hasSelection); m_ui->openAttachmentButton->setEnabled(hasSelection);
updateSpacers(); updateLinesVisibility();
} }
void EntryAttachmentsWidget::updateSpacers() void EntryAttachmentsWidget::updateLinesVisibility()
{ {
if (m_buttonsVisible && !m_readOnly) { m_ui->editPreviewLine->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->previewVSpacer->changeSize(20, 40, QSizePolicy::Fixed, QSizePolicy::Expanding); m_ui->previewRemoveLine->setVisible(m_buttonsVisible && !m_readOnly);
} else {
m_ui->previewVSpacer->changeSize(0, 0, QSizePolicy::Fixed, QSizePolicy::Fixed);
}
} }
void EntryAttachmentsWidget::updateButtonsVisible() void EntryAttachmentsWidget::updateButtonsVisible()
{ {
m_ui->addAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly); m_ui->addAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->newAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly); m_ui->editAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->removeAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly); m_ui->removeAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
updateSpacers(); updateLinesVisibility();
} }
bool EntryAttachmentsWidget::insertAttachments(const QStringList& filenames, QString& errorMessage) bool EntryAttachmentsWidget::insertAttachments(const QStringList& filenames, QString& errorMessage)

View File

@@ -58,6 +58,7 @@ signals:
private slots: private slots:
void insertAttachments(); void insertAttachments();
void newAttachments(); void newAttachments();
void editSelectedAttachment();
void previewSelectedAttachment(); void previewSelectedAttachment();
void removeSelectedAttachments(); void removeSelectedAttachments();
void saveSelectedAttachments(); void saveSelectedAttachments();
@@ -68,7 +69,7 @@ private slots:
void attachmentModifiedExternally(const QString& key, const QString& filePath); void attachmentModifiedExternally(const QString& key, const QString& filePath);
private: private:
void updateSpacers(); void updateLinesVisibility();
bool insertAttachments(const QStringList& fileNames, QString& errorMessage); bool insertAttachments(const QStringList& fileNames, QString& errorMessage);

View File

@@ -6,8 +6,8 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>337</width> <width>332</width>
<height>258</height> <height>312</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
@@ -47,7 +47,7 @@
</item> </item>
<item> <item>
<widget class="QWidget" name="actionsWidget" native="true"> <widget class="QWidget" name="actionsWidget" native="true">
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,1,0,0,0,1,0,0"> <layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,0,0,0,0,0,0,0">
<property name="leftMargin"> <property name="leftMargin">
<number>0</number> <number>0</number>
</property> </property>
@@ -60,16 +60,6 @@
<property name="bottomMargin"> <property name="bottomMargin">
<number>0</number> <number>0</number>
</property> </property>
<item>
<widget class="QPushButton" name="newAttachmentButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>New</string>
</property>
</widget>
</item>
<item> <item>
<widget class="QPushButton" name="addAttachmentButton"> <widget class="QPushButton" name="addAttachmentButton">
<property name="enabled"> <property name="enabled">
@@ -79,22 +69,26 @@
<string>Add new attachment</string> <string>Add new attachment</string>
</property> </property>
<property name="text"> <property name="text">
<string>Add</string> <string>Add file…</string>
</property> </property>
</widget> </widget>
</item> </item>
<item> <item>
<spacer name="previewVSpacer"> <widget class="QPushButton" name="editAttachmentButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Edit</string>
</property>
</widget>
</item>
<item>
<widget class="Line" name="editPreviewLine">
<property name="orientation"> <property name="orientation">
<enum>Qt::Vertical</enum> <enum>Qt::Horizontal</enum>
</property> </property>
<property name="sizeHint" stdset="0"> </widget>
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item> </item>
<item> <item>
<widget class="QPushButton" name="previewAttachmentButton"> <widget class="QPushButton" name="previewAttachmentButton">
@@ -128,22 +122,19 @@
<string>Save selected attachment to disk</string> <string>Save selected attachment to disk</string>
</property> </property>
<property name="text"> <property name="text">
<string>Save</string> <string>Save</string>
</property> </property>
</widget> </widget>
</item> </item>
<item> <item>
<spacer name="verticalSpacer_2"> <widget class="Line" name="previewRemoveLine">
<property name="enabled">
<bool>true</bool>
</property>
<property name="orientation"> <property name="orientation">
<enum>Qt::Vertical</enum> <enum>Qt::Horizontal</enum>
</property> </property>
<property name="sizeHint" stdset="0"> </widget>
<size>
<width>20</width>
<height>173</height>
</size>
</property>
</spacer>
</item> </item>
<item> <item>
<widget class="QPushButton" name="removeAttachmentButton"> <widget class="QPushButton" name="removeAttachmentButton">
@@ -164,7 +155,7 @@
<enum>Qt::Vertical</enum> <enum>Qt::Vertical</enum>
</property> </property>
<property name="sizeType"> <property name="sizeType">
<enum>QSizePolicy::Minimum</enum> <enum>QSizePolicy::Expanding</enum>
</property> </property>
<property name="sizeHint" stdset="0"> <property name="sizeHint" stdset="0">
<size> <size>

View File

@@ -193,64 +193,7 @@ void EntryHistoryModel::calculateHistoryModifications()
continue; continue;
} }
QStringList modifiedFields; auto modifiedFields = curr->calculateDifference(compare);
if (*curr->attributes() != *compare->attributes()) {
bool foundAttribute = false;
if (curr->title() != compare->title()) {
modifiedFields << tr("Title");
foundAttribute = true;
}
if (curr->username() != compare->username()) {
modifiedFields << tr("Username");
foundAttribute = true;
}
if (curr->password() != compare->password()) {
modifiedFields << tr("Password");
foundAttribute = true;
}
if (curr->url() != compare->url()) {
modifiedFields << tr("URL");
foundAttribute = true;
}
if (curr->notes() != compare->notes()) {
modifiedFields << tr("Notes");
foundAttribute = true;
}
if (!foundAttribute) {
modifiedFields << tr("Custom Attributes");
}
}
if (curr->iconNumber() != compare->iconNumber() || curr->iconUuid() != compare->iconUuid()) {
modifiedFields << tr("Icon");
}
if (curr->foregroundColor() != compare->foregroundColor()
|| curr->backgroundColor() != compare->backgroundColor()) {
modifiedFields << tr("Color");
}
if (curr->timeInfo().expires() != compare->timeInfo().expires()
|| curr->timeInfo().expiryTime() != compare->timeInfo().expiryTime()) {
modifiedFields << tr("Expiration");
}
if (curr->totp() != compare->totp()) {
modifiedFields << tr("TOTP");
}
if (*curr->customData() != *compare->customData()) {
modifiedFields << tr("Custom Data");
}
if (*curr->attachments() != *compare->attachments()) {
modifiedFields << tr("Attachments");
}
if (*curr->autoTypeAssociations() != *compare->autoTypeAssociations()
|| curr->autoTypeEnabled() != compare->autoTypeEnabled()
|| curr->defaultAutoTypeSequence() != compare->defaultAutoTypeSequence()) {
modifiedFields << tr("Auto-Type");
}
if (curr->tags() != compare->tags()) {
modifiedFields << tr("Tags");
}
m_historyModifications << modifiedFields.join(", "); m_historyModifications << modifiedFields.join(", ");

View File

@@ -297,7 +297,7 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const
break; break;
case Totp: case Totp:
if (entry->hasTotp()) { if (entry->hasTotp()) {
return icons()->icon("totp"); return entry->hasValidTotp() ? icons()->icon("totp") : icons()->icon("totp-invalid");
} }
break; break;
case PasswordStrength: case PasswordStrength:

Some files were not shown because too many files have changed in this diff Show More