Compare commits

..

2 Commits

257 changed files with 13079 additions and 24280 deletions

View File

@ -47,19 +47,6 @@ stages:
allow_failure: true
- when: never
.rules-branch-manual-scheduled-and-test-branch-always:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: always
allow_failure: false
- if: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME=~ /^test/
when: always
allow_failure: false
- if: $CI_COMMIT_BRANCH
when: manual
allow_failure: true
- when: never
# ENV
.env-windows:
before_script:
@ -131,15 +118,6 @@ lint:
tags:
- medium
bug-report-preview:
stage: test
extends:
- .rules-branch-and-MR-manual
script:
- make lint-bug-report-preview
tags:
- medium
.script-test:
stage: test
extends:
@ -156,15 +134,6 @@ test-linux:
tags:
- large
fuzz-linux:
stage: test
extends:
- .rules-branch-manual-MR-and-devel-always
script:
- make fuzz
tags:
- large
test-linux-race:
extends:
- test-linux
@ -185,19 +154,11 @@ test-integration-race:
script:
- make test-integration-race
test-integration-nightly:
extends:
- test-integration
- .rules-branch-manual-scheduled-and-test-branch-always
needs:
- test-integration
script:
- make test-integration-nightly
test-windows:
extends:
- .env-windows
- .script-test
- .rules-branch-and-MR-manual
test-darwin:
extends:
@ -207,16 +168,15 @@ test-darwin:
test-coverage:
stage: test
extends:
- .rules-branch-manual-scheduled-and-test-branch-always
- .rules-branch-manual-MR-and-devel-always
script:
- ./utils/coverage.sh
coverage: '/total:.*\(statements\).*\d+\.\d+%/'
needs:
- test-linux
- test-windows
#- test-windows
- test-darwin
- test-integration
- test-integration-nightly
tags:
- small
artifacts:

View File

@ -3,89 +3,10 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Umshiang Bridge 3.5.1
## Trift Bridge 3.4.2
### Fixed
* GODT-2963: Use multi error to report file removal errors.
* GODT-2956: Restore old deletion rules.
* GODT-2951: Negative WaitGroup Counter.
* GODT-2590: Fix send on closed channel.
* GODT-2949: Fix close of close channel in event service.
## Umshiang Bridge 3.5.0
### Added
* GODT-2734: Add testing steps to modify account settings.
* GODT-2746: Integration tests for reporting a problem.
* GODT-2891: Allow message create & delete during sync.
* GODT-2848: Decouple IMAP service from Event Loop.
* Add trace profiling option.
* GODT-2829: New Sync Service.
* Test: oss-fuzz support for fuzzing.
* GODT-2799: SMTP Service.
* GODT-2800: User Event Service.
* GODT-2801: Identity Service.
* GODT-2802: IMAP Serivce.
* GODT-2788: Add preview to bug report validation and JSON file validator.
* GODT-2803: Bridge Database access.
### Changed
* GODT-2909: Remove Timeout on event publish.
* GODT-2913: Reduce the number of configuration failure detected.
* GODT-2828: Increase sync progress report frequency.
* Test: Fix TestBridge_SyncWithOnGoingEvents.
* GODT-2871: Is telemetry enabled as service.
* Test(GODT-2873): Wait for Gluon Watcher to finish.
* Test(GODT-2744): Add integration tests for moving messages (with MOVE support).
* Test(GODT-2872): Fix nightly job.
* Test(GODT-2742): Add more integration tests regarding drafts.
* GODT-2787: Force Scrollview to top when re-opening questions set.
* GODT-2787: Tweaking Bug Report form with last Review.
* Ci(GODT-2717): Create a job that will run on schedule.
* GODT-2787: Fix vertical alignement on CategoryItem.
* GODT-2842: Implement Bug Report Fallback notification.
* Chore(GODT-2848): Simplify User Event Service.
* GODT-2808: Apply comment from Bug Report content review.
* Test(GODT-2743): Sync high number of messages.
* GODT-2814: Standalone Server Manager.
* GODT-2808: Initial list of categories and questions.
* GODT-2787: Replace the PathTracker by a more visual NavigationIndicator.
* GODT-2816: Wait until mandatory fields are filled then fill body and title.
* GODT-2794: Clear cached answers when report is sent.
* GODT-2793: Feed the bug report body with the answered questions.
* GODT-2791: Parse the Bug Report Flow description file and ensure forward compatibility (GODT-2789).
* GODT-2821: Display questions in one page.
* GODT-2786: Init bug report flow description file.
* GODT-2792: Implement display of question set for bug report.
* Use qmlformat on qml files, and removed deprecated tests.
### Fixed
* GODT-2828: Fix negative report time.
* GODT-2828: Fix sync progress report after restart.
* GODT-2867: Do not crash on timeout or context cancel.
* GODT-2693: Duplicate messages in sent folder.
* GODT-2867: Get attachment returns API error on network problem.
* GODT-2805: Ignore Contact Group Labels.
* GODT-2866: Add 429/5xx Retry to Event Service.
* GODT-2855: Fix for text overlapping in settings view.
* Test: Verify leaks at end of WithEnv.
* Test: Fix event registration in TestBridge_SyncWithOngoingEvents.
* Test: Fix deadlock in chToType.
* GODT-2865: Add error on failed unlock.
* GODT-2857: Do not check changed values in clear recent flag.
* GODT-2827: Restore ticker to event poller.
* Test: TestBridge_SendAddTextBodyPartIfNotExists eventually fix.
* GODT-2813: Write new vault to temporary file first.
* GODT-2807: Fix issue where sessionID would not be removed from command-line on restart by bridge-gui.
* GODT-2687: Tabs after header field colon.
* GODT-2764: Allow perma-delete for messages which still have labels.
* GODT-2693: Fix message appearing twice after sent.
* GODT-2781: Try to remove stale lock file before failing in checkSingleInstance.
* GODT-2780: Fix 'QSystemTrayIcon::setVisible: No Icon set' warning in bridge-gui log on startup.
* GODT-2778: Fix login screen being disabled after an 'already logged in' error.
* Fix typos found by codespell.
* GODT-2577: Answered flag should only be applied to replied messages.
* GODT-2902: Do not check for changed values. Related to GODT-2857.
## Trift Bridge 3.4.1
@ -134,7 +55,7 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/)
* GODT-2578: Refresh literals appended to Sent folder.
* GODT-2753: Vault test now check that value auto-assigned is first available port.
* GODT-2522: Handle migration with unreferenced db values.
* GODT-2670: Allow missing whitespace after header field colon.
* GODT-2693: Allow missing whitespace after header field colon.
* GODT-2653: Only log when err is not nil.
* GODT-2680: Fix for C++ debugger not working on ARM64 because of OpenSSL 3.1.
* GODT-2675: Update GPA to applye togin-gonic/gin patch + update COPYING_NOTES.
@ -219,7 +140,7 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/)
* GODT-2437: Silence harmless report to sentry.
* GODT-2649: Clean up cache files after failed connector create (Gluon).
* GODT-2638: Validate messages before import.
* GODT-2646: Bump GPA and Gluon dependency after CIRCL upgrade.
* GODT-2646: Bump GPA and Gluon dependecy after CIRCL upgrade.
* GODT-2454: Only Send status update if transaction succeeded.
* Test: fix flaky tests.
* GODT-2628: Attempt to fix closed channel panic on logout.
@ -279,7 +200,7 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/)
* GODT-2574: Fix label/unlabel of large amounts of messages.
* GODT-2573: Handle invalid header fields in message.
* GODT-2573: Crash on null update.
* GODT-2407: Replace invalid email addresses with empty for new Drafts.
* GODT-2407: Replace invalid email addresses with emtpy for new Drafts.
## [Bridge 3.1.3] Quebec
@ -420,7 +341,7 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/)
* GODT-2429: Do not report context cancel to sentry.
### Fixed
* GODT-2467: elide long email addresses in 'bad event' QML notification dialog.
* GODT-2467: elide long email adresses in 'bad event' QML notification dialog.
* GODT-2449: fix bug in Bridge-GUI's Exception::what().
* GODT-2427: Parsing header issues.
* GODT-2426: Fix crash on user delete.
@ -437,7 +358,7 @@ Changelog [format](http://keepachangelog.com/en/1.0.0/)
* GODT-2404: Handle unexpected EOF.
* GODT-2400: Allow state updates to be applied if command fails.
* GODT-2399: Fix immediate message deletion during updates.
* GODT-2390: Missing changes from previous commit.
* GODT-2390: Missing changes from pervious commit.
* GODT-2390: Add reports for uncaught json and net.opErr.
* GODT-2414: Multiple deletion bug in WriteControlledStore.
@ -502,7 +423,7 @@ GODT-1804: Preserve MIME parameters when uploading attachments.
* GODT-2223: Improve event handling.
* GODT-2305: Detect missing gluon DB.
* GODT-2291: Change gluon store default location from Cache to Data.
* Other: Disable dialer test until badssl cert is bumped.
* Other: Disable dialer test until badssl cert is bumbed.
* GODT-2292: Updated BUILDS.md doc.
* GODT-2258: suggest email as login when signing in via status window.
* Other: Report corrupt and/or insecure vaults to sentry.
@ -782,7 +703,7 @@ GODT-1804: Preserve MIME parameters when uploading attachments.
## [Bridge 2.4.6] Osney
### Changed
* GODT-2019: When signing out and a single user is connected we do not go back to the welcome screen.
* GODT-2019: When signing out and a single user is connecte* we do not go back to the welcome screen.
* GODT-2071: Bridge-gui report error if an orphan bridge is detected.
* GODT-2046: Bridge-gui log is included in optional archive sent with bug reports.
* GODT-2039: Bridge monitors bridge-gui via its PID.
@ -936,7 +857,7 @@ GODT-1804: Preserve MIME parameters when uploading attachments.
* GODT-1260: Renaming.
* GODT-1502: Rebranding: color and radius.
* GODT-1549: Add notification when address list changes.
* GODT-1560: Dependency licenses update and link.
* GODT-1560: Dependecy licenses update and link.
### Changed
* GODT-1543: Using one buffered event for off and on connection.
@ -1033,7 +954,7 @@ GODT-1537: Manual in-app update mechanism.
* GODT-1338: GODT-1343 Help view buttons.
* GODT-1340: Not crashing, user list updating in main thread.
* GODT-1345: Adding panic handlers.
* GODT-1271: Fix Status margins.
* GODT-1271: Fix Status margings.
* GODT-1320: Add loading property to each action within a notification.
* GODT-1210: Add "free user" banner.
* GODT-1314: Limit description field length within 150/800 bounds.
@ -1075,7 +996,7 @@ GODT-1537: Manual in-app update mechanism.
* GODT-1381 Treat readonly folder as failure for cache on disk.
* GODT-1431 Prevent watcher when not using disk on cache.
* GODT-1381: Use in-memory cache in case local cache is unavailable.
* GODT-1356 GODT-1302: Cache on disk concurrency and API retries.
* GODT-1356 GODT-1302: Cache on disk concurency and API retries.
* GODT-1332 Added tests for cache move functions.
* GODT-1332: moved cache related functions to separate file.
* GODT-1332 moving cache does not work on Windows.
@ -1326,7 +1247,7 @@ GODT-1537: Manual in-app update mechanism.
### Fixed
* GODT-1029 Fix tray icon not updating under certain conditions.
* GODT-1062 Fix lost notification bar when window is closed.
* GODT-1058 Install version after changing channel right away only in case of downgrade.
* GODT-1058 Install version after chaning channel right away only in case of downgrade.
* GODT-1073 Re-write autostart link on every start if turned on in preferences.
* GODT-1055 Fix flaky empty trash test.
@ -1416,7 +1337,7 @@ GODT-1537: Manual in-app update mechanism.
* GODT-820 Added GUI notification on impossibility of update installation (both silent and manual).
* GODT-870 Added GUI notification on error during silent update.
* GODT-805 Added GUI notification on update available.
* GODT-804 Added GUI notification on silent update installed (prompt to restart).
* GODT-804 Added GUI notification on silent update installed (promt to restart).
* GODT-275 Added option to disable autoupdates in settings (default autoupdate is enabled).
* GODT-874 Added manual triggers to Updater module.
* GODT-851 Added support of UID EXPUNGE.
@ -1740,7 +1661,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
### Changed
* GODT-360 Detect charset embedded in html/xml.
* GODT-354 Do not label/unlabel messages from `All Mail` folder.
* GODT-354 Do not label/unlabel messsages from `All Mail` folder.
* GODT-388 Support for both bridge and import/export credentials by package users.
* GODT-387 Store factory to make store optional.
* GODT-386 Renamed bridge to general users and keep bridge only for bridge stuff.
@ -1905,13 +1826,13 @@ CSB-331 Fix sending error due to mixed case in sender address.
* GODT-88 Run mbox sync in parallel when switch password mode (re-init not user).
* GODT-95 Do not throw error when trying to create new mailbox in IMAP root.
* GODT-75 Do not fail on unlabel inside delete.
* #1095 always delete IMAP USER including wrong password.
* #1095 always delete IMAP USER including wrong pasword.
* Unique pmapi client userID (including #1098).
* Using go.enmime@v0.6.1 snapshot.
* Better detection of non-auth-error.
* Reset `hasAuthChannel` during logout for proper login functionality (set up auth channel and unlock keys).
* Allow `APPEND` messages without parsable email address in sender field.
* #1060 avoid `Append` after internal message ID was found and message was copied to mailbox using `MessageLabel`.
* #1060 avoid `Append` after internal message ID was found and message was copyed to mailbox using `MessageLabel`.
* #1049 Basic usage of store in SMTP package to poll event loop during sending message.
* #1050 pollNow waits for events to be processed.
* #1047 Fix fetch of empty mailbox.
@ -2037,7 +1958,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* #903 added http.Client timeout to not hang out forever.
* Closing body after checking internet connection.
* Pedantic lint for bridgeUtils.
* Selected events are buffered and emitted again when frontend loop is ready.
* Selected events are buffered and emited again when frontend loop is ready.
* #890 implemented 2FA endpoint (auth split).
* #888 TLS Cert.
* Error bar and modal with explanation in GUI.
@ -2045,7 +1966,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* Add pinning to bridge (only for live API builds).
* #887 #883:
* Wait before clearing data.
* Configure which provides pmapi.ClientConfig and app directories.
* Configer which provides pmapi.ClientConfig and app directories.
* #861 restart after clear data.
* Panic handler for all goroutines.
* CD for linux.
@ -2093,7 +2014,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* #882 unassign PMAPI client after logout and force to run garbage collector.
* #880, #884, #885, #886 fix of informing user about outgoing non-encrypted e-mail.
* #838 `Sirupsen` -> `sirupsen`.
* #893 save panic report file every time.
* #893 save panic report file everytime.
* #880 fix of informing user about outgoing non-encrypted e-mail.
* Fix aliases in split mode.
* Fix decrypted data in log notification.
@ -2167,7 +2088,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
### Changed
* Fix custom message format.
* #802 accumulated long lines while parsing body structure.
* #802 acumulated long lines while parsing body structure.
* Process `AddressEvent` before `MessageEvent`.
* #791 updated crypto: fix wrong signature format.
* #793 fix returning size.
@ -2189,7 +2110,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
### Changed
* #748 when charset missing assume utf8 and check the validity.
* #750 before sync check that events are up-to-date, if not poll events instead of sync.
* #750 before sync check that events are uptodate, if not poll events instead of sync.
* Use pmapi with support of decrypted access token.
* #750 Status is using DB status instead of API.
* Format panic error as string instead of struct dump.
@ -2206,7 +2127,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* Full version of program visible on release notes.
### Changed
* #720 only one concurrent DB sync.
* #720 only one concurent DB sync.
* #720 sync every 3 pages.
* #512 extending list of charsets go-pm-mime!4.
@ -2230,7 +2151,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* Fix srp modulus issue with new `ProtonMail/crypto`.
* Generate version files from main file.
* Be able to set update set on build.
* #597 check on start that certificate will be still valid after one month and generate new cert if not.
* #597 check on start that certificat will be still valid after one month and generate new cert if not.
* #597 extended certificate validity to 2 years.
* Copyright 2019.
* Exclude `protontech` repos from credits.
@ -2249,7 +2170,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* #592 internal references are added only when not present already.
* #592 field `Date` changed to m.Time only when wrong format or missing `Date`.
* #645 pmapi#26 `Message.Flags` instead of `IsEncrypted`, `Type`, `IsReplied`, `IsRepliedAll`, `IsForwarded`.
* DB: do not allow to put Body or Attachments to db.
* DB: do not allow to put Body or Attachements to db.
* #574 SMTP: can now send more than one email.
* #671 Verbosity levels: `debug` (only bridge), `debug-client` (bridge and client communication), `debug-server` (bridge, whole SMTP/IMAP communication).
* #644 Return rfc.size 0 or correct size of fetched body (stored in DB).
@ -2321,7 +2242,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* Start with new versioning.
1.1.0
| | `--- bug fix number (internal, irregular, beta releases)
| | `--- bug fix number (internal, irregular, beta relases)
| `----- minor version (features, release once per month, live release, milestones)
`------- major version (big changes, once per year, breaking changes, api force upgrade)
@ -2387,7 +2308,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* All `client.Do` errors are interpreted as connection issue.
* Moved to internal gitlab.
* Typo `frontend-qml`.
* Better message for case when server is not reachable.
* Better message for case when server is not reacheable.
* Setting 1min timeout to IMAP connection.
### Changed
@ -2419,12 +2340,12 @@ CSB-331 Fix sending error due to mixed case in sender address.
* Keychain format and function refactor.
* Create crash file on panic with full trace.
* Clear old data only in main process (no double keychain typing).
* Create label update API route.
* Create label udpate API route.
* Selectable text in release notes.
### Added
* Support sending to external PGP recipients.
* Return error codes: `0: Ok`, `2: Frontend crashed`, `3: Bridge already running`, `4: Unknown argument`, `42: Restart application`.
* Return error codes: `0: Ok`, `2: Frontend crashed`, `3: Bridge already running`, `4: Uknown argument`, `42: Restart application`.
### Release notes
* Support of encryption to external PGP recipients using contacts created on beta.protonmail.com (see https://protonmail.com/blog/pgp-vulnerability-efail/ to understand the vulnerabilities that may be associated with sending to other PGP clients).
@ -2449,7 +2370,7 @@ CSB-331 Fix sending error due to mixed case in sender address.
* Bug report window.
* Checkbox and with label (only I/E).
* Error dialog and Info tooltip (only I/E).
* Add user modal formatting (colors, text).
* Add user modal formating (colors, text).
* Account view style.
* Input box style (used in bug report).
* Input field style (used in add account and change port).

View File

@ -11,7 +11,7 @@ ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
.PHONY: build build-gui build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository.
BRIDGE_APP_VERSION?=3.5.1+git
BRIDGE_APP_VERSION?=3.4.2+git
APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG
@ -246,7 +246,7 @@ test-race: gofiles
test-integration: gofiles
mkdir -p coverage/integration
go test \
-v -timeout=60m -p=1 -count=1 -tags=test_integration \
-v -timeout=60m -p=1 -count=1 \
${GOCOVERAGE} \
github.com/ProtonMail/proton-bridge/v3/tests \
${GOCOVERDIR}/integration
@ -258,22 +258,6 @@ test-integration-debug: gofiles
test-integration-race: gofiles
go test -v -timeout=60m -p=1 -count=1 -race -failfast github.com/ProtonMail/proton-bridge/v3/tests
test-integration-nightly: gofiles
mkdir -p coverage/integration
go test \
-v -timeout=90m -p=1 -count=1 -tags=test_integration \
${GOCOVERAGE} \
github.com/ProtonMail/proton-bridge/v3/tests \
${GOCOVERDIR}/integration \
nightly
fuzz: gofiles
go test -fuzz=FuzzUnmarshal -parallel=4 -fuzztime=60s $(PWD)/internal/legacy/credentials
go test -fuzz=FuzzNewParser -parallel=4 -fuzztime=60s $(PWD)/pkg/message/parser
go test -fuzz=FuzzReadHeaderBody -parallel=4 -fuzztime=60s $(PWD)/pkg/message
go test -fuzz=FuzzDecodeHeader -parallel=4 -fuzztime=60s $(PWD)/pkg/mime
go test -fuzz=FuzzDecodeCharset -parallel=4 -fuzztime=60s $(PWD)/pkg/mime
bench:
go test -run '^$$' -bench=. -memprofile bench_mem.pprof -cpuprofile bench_cpu.pprof ./internal/store
go tool pprof -png -output bench_mem.png bench_mem.pprof
@ -290,22 +274,9 @@ mocks:
mockgen --package mocks github.com/ProtonMail/proton-bridge/v3/internal/updater Downloader,Installer > internal/updater/mocks/mocks.go
mockgen --package mocks github.com/ProtonMail/proton-bridge/v3/internal/telemetry HeartbeatManager > internal/telemetry/mocks/mocks.go
cp internal/telemetry/mocks/mocks.go internal/bridge/mocks/telemetry_mocks.go
mockgen --package mocks github.com/ProtonMail/proton-bridge/v3/internal/services/userevents \
EventSource,EventIDStore > internal/services/userevents/mocks/mocks.go
mockgen --package userevents github.com/ProtonMail/proton-bridge/v3/internal/services/userevents \
EventSubscriber,MessageEventHandler,LabelEventHandler,AddressEventHandler,RefreshEventHandler,UserEventHandler,UserUsedSpaceEventHandler > tmp
mv tmp internal/services/userevents/mocks_test.go
mockgen --package mocks github.com/ProtonMail/proton-bridge/v3/internal/events EventPublisher \
> internal/events/mocks/mocks.go
mockgen --package mocks github.com/ProtonMail/proton-bridge/v3/internal/services/useridentity IdentityProvider,Telemetry \
> internal/services/useridentity/mocks/mocks.go
mockgen --self_package "github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice" -package syncservice github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice \
ApplyStageInput,BuildStageInput,BuildStageOutput,DownloadStageInput,DownloadStageOutput,MetadataStageInput,MetadataStageOutput,\
StateProvider,Regulator,UpdateApplier,MessageBuilder,APIClient,Reporter,DownloadRateModifier \
> tmp
mv tmp internal/services/syncservice/mocks_test.go
mockgen --package mocks github.com/ProtonMail/proton-bridge/v3/internal/user MessageDownloader > internal/user/mocks/mocks.go
lint: gofiles lint-golang lint-license lint-dependencies lint-changelog lint-bug-report
lint: gofiles lint-golang lint-license lint-dependencies lint-changelog
lint-license:
./utils/missing_license.sh check
@ -321,12 +292,6 @@ lint-golang:
$(info linting with GOMAXPROCS=${GOMAXPROCS})
golangci-lint run ./...
lint-bug-report:
python3 utils/validate_bug_report_file.py --file "internal/frontend/bridge-gui/bridge-gui/qml/Resources/bug_report_flow.json"
lint-bug-report-preview:
python3 utils/validate_bug_report_file.py --file "internal/frontend/bridge-gui/bridge-gui/qml/Resources/bug_report_flow.json" --preview
gobinsec: gobinsec-cache.yml build
gobinsec -wait -cache -config utils/gobinsec_conf.yml ${EXE_TARGET} ${DEPLOY_DIR}/${TARGET_OS}/${LAUNCHER_EXE}

View File

@ -2,13 +2,13 @@
## First login and sync
When user logs in to the bridge for the first time, immediately starts the first sync.
When user logs in to the bridge for the first time, immediatelly starts the first sync.
First sync downloads all headers of all e-mails and creates database to have proper UIDs
and indexes for IMAP. See [database](database.md) for more information.
By default, whenever it's possible, sync downloads only all e-mails maiblox which already
have list of labels so we can construct all mailboxes (inbox, sent, trash, custom folders
and labels) without need to download each e-mail headers many times.
and lables) without need to download each e-mail headers many times.
Note that we need to download also bodies to calculate size of the e-mail and set proper
content type (clients uses content type for guess if e-mail contains attachment)--but only
@ -22,7 +22,7 @@ client right after adding account.
When account is added to client, client start the sync. This sync will ask Bridge app
for all headers (done quickly) and then starts to download all bodies and attachment.
Unfortunately for some e-mail more than once if the same e-mail is in more mailboxes
Unfortunatelly for some e-mail more than once if the same e-mail is in more mailboxes
(e.g. inbox and all mail)--there is no way to tell over IMAP it's the same message.
After successful login of client to IMAP, Bridge starts event loop. That periodicly ask
@ -37,7 +37,7 @@ sequenceDiagram
Note right of B: Set up PM account<br/>by user
loop First sync
B ->> S: Fetch body and attachments
B ->> S: Fetch body and attachements
Note right of B: Build local database<br/>(e-mail UIDs)
end
@ -58,8 +58,8 @@ sequenceDiagram
C ->> B: IMAP SELECT directory
C ->> B: IMAP SEARCH e-mails UIDs
C ->> B: IMAP FETCH of e-mail UID
B ->> S: Fetch body and attachments
Note right of B: Decrypt message<br/>and attachment
B ->> S: Fetch body and attachements
Note right of B: Decrypt message<br/>and attachement
B ->> C: IMAP response
end
```

View File

@ -1,12 +1,12 @@
# Update mechanism of Bridge
There are multiple options how to change version of application:
There are mulitple options how to change version of application:
* Automatic in-app update
* Manual in-app update
* Manual install
In-app update ends with restarting bridge into new version. Automatic in-app
update is downloading, verifying and installing the new version immediately
update is downloading, verifying and installing the new version immediatelly
without user confirmation. For manual in-app update user needs to confirm first.
Update is done from special update file published on website.
@ -25,7 +25,7 @@ The bridge is installed and executed differently for given OS:
* macOS app does not use launcher
* No launcher, only one executable
* In-App update replaces the bridge files in installation path directly
* In-App udpate replaces the bridge files in installation path directly
```mermaid

4
go.mod
View File

@ -5,9 +5,9 @@ go 1.20
require (
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20230829112217-5d5c25c504b5
github.com/ProtonMail/gluon v0.16.1-0.20230901124123-075229a92cc4
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20230831064234-0e3a549b3f36
github.com/ProtonMail/go-proton-api v0.4.1-0.20230727082922-9115b4750ec7
github.com/ProtonMail/gopenpgp/v2 v2.7.1-proton
github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible

8
go.sum
View File

@ -23,8 +23,8 @@ github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/docker-credential-helpers v1.1.0 h1:+kvUIpwWcbtP3WFv5sSvkFn/XLzSqPOB5AAthuk9xPk=
github.com/ProtonMail/docker-credential-helpers v1.1.0/go.mod h1:mK0aBveCxhnQ756AmaTfXMZDeULvheYVhF/MWMErN5g=
github.com/ProtonMail/gluon v0.17.1-0.20230829112217-5d5c25c504b5 h1:C/8P5NHAKi2yCKez+OZ5rSR8SsL7k8si4pK4SE2QtV8=
github.com/ProtonMail/gluon v0.17.1-0.20230829112217-5d5c25c504b5/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/gluon v0.16.1-0.20230901124123-075229a92cc4 h1:Uq2v2NYEtlTaK2WTh9BMph2Kv51JxMgvTkd7CjGPYc8=
github.com/ProtonMail/gluon v0.16.1-0.20230901124123-075229a92cc4/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:D+aZah+k14Gn6kmL7eKxoo/4Dr/lK3ChBcwce2+SQP4=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:oTGdE7/DlWIr23G0IKW3OXK9wZ5Hw1GGiaJFccTvZi4=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
@ -35,8 +35,8 @@ github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7 h1:+j+Kd/
github.com/ProtonMail/go-message v0.13.1-0.20230526094639-b62c999c85b7/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20230831064234-0e3a549b3f36 h1:JVMK2w90bCWayUCXJIb3wkQ5+j2P/NbnrX3BrDoLzsc=
github.com/ProtonMail/go-proton-api v0.4.1-0.20230831064234-0e3a549b3f36/go.mod h1:nS8hMGjJLgC0Iej0JMYbsI388LesEkM1Hj/jCCxQeaQ=
github.com/ProtonMail/go-proton-api v0.4.1-0.20230727082922-9115b4750ec7 h1:Rmg3TPK6vFGNWR4hxmPoBhV75Sl716iB46wEi2U4Q+c=
github.com/ProtonMail/go-proton-api v0.4.1-0.20230727082922-9115b4750ec7/go.mod h1:+aTJoYu8bqzGECXL2DOdiZTZ64bGn3w0NC8VcFpJrFM=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v2 v2.7.1-proton h1:YS6M20yvjCJPR1r4ADW5TPn6rahs4iAyZaACei86bEc=

View File

@ -52,9 +52,6 @@ const (
flagCPUProfile = "cpu-prof"
flagCPUProfileShort = "p"
flagTraceProfile = "trace-prof"
flagTraceProfileShort = "t"
flagMemProfile = "mem-prof"
flagMemProfileShort = "m"
@ -99,11 +96,6 @@ func New() *cli.App {
Aliases: []string{flagCPUProfileShort},
Usage: "Generate CPU profile",
},
&cli.BoolFlag{
Name: flagTraceProfile,
Aliases: []string{flagTraceProfileShort},
Usage: "Generate Trace profile",
},
&cli.BoolFlag{
Name: flagMemProfile,
Aliases: []string{flagMemProfileShort},
@ -387,11 +379,6 @@ func withProfiler(c *cli.Context, fn func() error) error {
defer profile.Start(profile.CPUProfile, profile.ProfilePath(".")).Stop()
}
if c.Bool(flagTraceProfile) {
logrus.Debug("Running with Trace profiling")
defer profile.Start(profile.TraceProfile, profile.ProfilePath(".")).Stop()
}
if c.Bool(flagMemProfile) {
logrus.Debug("Running with memory profiling")
defer profile.Start(profile.MemProfile, profile.MemProfileAllocs, profile.ProfilePath(".")).Stop()

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
//go:build !build_qa && !test_integration
//go:build !build_qa
package bridge

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
//go:build build_qa || test_integration
//go:build build_qa
package bridge

View File

@ -23,6 +23,7 @@ import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
@ -37,11 +38,8 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/focus"
"github.com/ProtonMail/proton-bridge/v3/internal/identifier"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver"
"github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice"
"github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
@ -61,7 +59,7 @@ type Bridge struct {
// api manages user API clients.
api *proton.Manager
proxyCtl ProxyController
identifier identifier.Identifier
identifier Identifier
// tlsConfig holds the bridge TLS config used by the IMAP and SMTP servers.
tlsConfig *tls.Config
@ -127,8 +125,9 @@ type Bridge struct {
// goHeartbeat triggers a check/sending if heartbeat is needed.
goHeartbeat func()
serverManager *imapsmtpserver.Service
syncService *syncservice.Service
uidValidityGenerator imap.UIDValidityGenerator
serverManager *ServerManager
}
// New creates a new bridge.
@ -141,7 +140,7 @@ func New(
apiURL string, // the URL of the API to use
cookieJar http.CookieJar, // the cookie jar to use
identifier identifier.Identifier, // the identifier to keep track of the user agent
identifier Identifier, // the identifier to keep track of the user agent
tlsReporter TLSReporter, // the TLS reporter to report TLS errors
roundTripper http.RoundTripper, // the round tripper to use for API requests
proxyCtl ProxyController, // the DoH controller
@ -208,7 +207,7 @@ func newBridge(
reporter reporter.Reporter,
api *proton.Manager,
identifier identifier.Identifier,
identifier Identifier,
proxyCtl ProxyController,
uidValidityGenerator imap.UIDValidityGenerator,
@ -270,26 +269,17 @@ func newBridge(
firstStart: firstStart,
lastVersion: lastVersion,
tasks: tasks,
syncService: syncservice.NewService(reporter, panicHandler),
tasks: tasks,
uidValidityGenerator: uidValidityGenerator,
serverManager: newServerManager(),
}
bridge.serverManager = imapsmtpserver.NewService(context.Background(),
&bridgeSMTPSettings{b: bridge},
&bridgeIMAPSettings{b: bridge},
&bridgeEventPublisher{b: bridge},
panicHandler,
reporter,
uidValidityGenerator,
&bridgeIMAPSMTPTelemetry{b: bridge},
)
if err := bridge.serverManager.Init(context.Background(), bridge.tasks, &bridgeEventSubscription{b: bridge}); err != nil {
if err := bridge.serverManager.Init(bridge); err != nil {
return nil, err
}
bridge.syncService.Run(bridge.tasks)
return bridge, nil
}
@ -417,6 +407,11 @@ func (bridge *Bridge) GetErrors() []error {
func (bridge *Bridge) Close(ctx context.Context) {
logrus.Info("Closing bridge")
// Close the servers
if err := bridge.serverManager.CloseServers(ctx); err != nil {
logrus.WithError(err).Error("Failed to close servers")
}
// Close all users.
safe.Lock(func() {
for _, user := range bridge.users {
@ -424,11 +419,6 @@ func (bridge *Bridge) Close(ctx context.Context) {
}
}, bridge.usersLock)
// Close the servers
if err := bridge.serverManager.CloseServers(ctx); err != nil {
logrus.WithError(err).Error("Failed to close servers")
}
// Stop all ongoing tasks.
bridge.tasks.CancelAndWait()
@ -537,6 +527,24 @@ func loadTLSConfig(vault *vault.Vault) (*tls.Config, error) {
}, nil
}
func newListener(port int, useTLS bool, tlsConfig *tls.Config) (net.Listener, error) {
if useTLS {
tlsListener, err := tls.Listen("tcp", fmt.Sprintf("%v:%v", constants.Host, port), tlsConfig)
if err != nil {
return nil, err
}
return tlsListener, nil
}
netListener, err := net.Listen("tcp", fmt.Sprintf("%v:%v", constants.Host, port))
if err != nil {
return nil, err
}
return netListener, nil
}
func min(a, b time.Duration) time.Duration {
if a < b {
return a

View File

@ -44,7 +44,6 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/focus"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
@ -55,7 +54,6 @@ import (
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
var (
@ -623,10 +621,6 @@ func TestBridge_AddressWithoutKeys(t *testing.T) {
defer m.Close()
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
// Watch for sync finished event.
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done()
// Create a user which will have an address without keys.
userID, _, err := s.CreateUser("nokeys", []byte("password"))
require.NoError(t, err)
@ -647,6 +641,10 @@ func TestBridge_AddressWithoutKeys(t *testing.T) {
// Remove the address keys.
require.NoError(t, s.RemoveAddressKey(userID, aliasAddrID, aliasAddr.Keys[0].ID))
// Watch for sync finished event.
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done()
// We should be able to log the user in.
require.NoError(t, getErr(bridge.LoginFull(context.Background(), "nokeys", []byte("password"), nil, nil)))
require.NoError(t, err)
@ -702,10 +700,10 @@ func TestBridge_InitGluonDirectory(t *testing.T) {
configDir, err := b.GetGluonDataDir()
require.NoError(t, err)
_, err = os.ReadDir(imapsmtpserver.ApplyGluonCachePathSuffix(b.GetGluonCacheDir()))
_, err = os.ReadDir(bridge.ApplyGluonCachePathSuffix(b.GetGluonCacheDir()))
require.False(t, os.IsNotExist(err))
_, err = os.ReadDir(imapsmtpserver.ApplyGluonConfigPathSuffix(configDir))
_, err = os.ReadDir(bridge.ApplyGluonConfigPathSuffix(configDir))
require.False(t, os.IsNotExist(err))
})
})
@ -778,16 +776,16 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
require.NoError(t, err)
// Old store should no more exists.
_, err = os.ReadDir(imapsmtpserver.ApplyGluonCachePathSuffix(currentCacheDir))
_, err = os.ReadDir(bridge.ApplyGluonCachePathSuffix(currentCacheDir))
require.True(t, os.IsNotExist(err))
// Database should not have changed.
_, err = os.ReadDir(imapsmtpserver.ApplyGluonConfigPathSuffix(configDir))
_, err = os.ReadDir(bridge.ApplyGluonConfigPathSuffix(configDir))
require.False(t, os.IsNotExist(err))
// New path should have Gluon sub-folder.
require.Equal(t, filepath.Join(newCacheDir, "gluon"), b.GetGluonCacheDir())
// And store should be inside it.
_, err = os.ReadDir(imapsmtpserver.ApplyGluonCachePathSuffix(b.GetGluonCacheDir()))
_, err = os.ReadDir(bridge.ApplyGluonCachePathSuffix(b.GetGluonCacheDir()))
require.False(t, os.IsNotExist(err))
// We should be able to fetch.
@ -875,9 +873,6 @@ func TestBridge_ChangeAddressOrder(t *testing.T) {
// withEnv creates the full test environment and runs the tests.
func withEnv(t *testing.T, tests func(context.Context, *server.Server, *proton.NetCtl, bridge.Locator, []byte), opts ...server.Option) {
opt := goleak.IgnoreCurrent()
defer goleak.VerifyNone(t, opt)
server := server.New(opts...)
defer server.Close()
@ -1062,7 +1057,6 @@ func getConnectedUserIDs(t *testing.T, b *bridge.Bridge) []string {
func chToType[In, Out any](inCh <-chan In, done func()) (<-chan Out, func()) {
outCh := make(chan Out)
ctx, cancel := context.WithCancel(context.Background())
go func() {
defer close(outCh)
@ -1072,19 +1066,11 @@ func chToType[In, Out any](inCh <-chan In, done func()) (<-chan Out, func()) {
panic(fmt.Sprintf("unexpected type %T", in))
}
select {
case <-ctx.Done():
return
case outCh <- out:
}
outCh <- out
}
}()
return outCh, func() {
cancel()
done()
}
return outCh, done
}
type eventWaiter struct {

View File

@ -33,7 +33,7 @@ const (
DefaultMaxSessionCountForBugReport = 10
)
func (bridge *Bridge) ReportBug(ctx context.Context, osType, osVersion, title, description, username, email, client string, attachLogs bool) error {
func (bridge *Bridge) ReportBug(ctx context.Context, osType, osVersion, description, username, email, client string, attachLogs bool) error {
var account string
if info, err := bridge.QueryUserInfo(username); err == nil {
@ -82,7 +82,7 @@ func (bridge *Bridge) ReportBug(ctx context.Context, osType, osVersion, title, d
OS: osType,
OSVersion: osVersion,
Title: "[Bridge] Bug - " + title,
Title: "[Bridge] Bug",
Description: description,
Client: client,

View File

@ -79,7 +79,7 @@ func (bridge *Bridge) CheckClientState(ctx context.Context, checkFlags bool, pro
}
log.Debug("Building state")
state, err := meta.BuildMailboxToMessageMap(ctx, usr)
state, err := meta.BuildMailboxToMessageMap(usr)
if err != nil {
log.WithError(err).Error("Failed to build state")
return result, err

View File

@ -1,45 +0,0 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package bridge
import (
"context"
"github.com/ProtonMail/gluon/watcher"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
)
type bridgeEventSubscription struct {
b *Bridge
}
func (b bridgeEventSubscription) Add(ofType ...events.Event) *watcher.Watcher[events.Event] {
return b.b.addWatcher(ofType...)
}
func (b bridgeEventSubscription) Remove(watcher *watcher.Watcher[events.Event]) {
b.b.remWatcher(watcher)
}
type bridgeEventPublisher struct {
b *Bridge
}
func (b bridgeEventPublisher) PublishEvent(_ context.Context, event events.Event) {
b.b.publish(event)
}

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package files
package bridge
import (
"fmt"
@ -24,7 +24,7 @@ import (
"path/filepath"
)
func MoveDir(from, to string) error {
func moveDir(from, to string) error {
entries, err := os.ReadDir(from)
if err != nil {
return err
@ -36,7 +36,7 @@ func MoveDir(from, to string) error {
return err
}
if err := MoveDir(filepath.Join(from, entry.Name()), filepath.Join(to, entry.Name())); err != nil {
if err := moveDir(filepath.Join(from, entry.Name()), filepath.Join(to, entry.Name())); err != nil {
return err
}
@ -61,12 +61,12 @@ func moveFile(from, to string) error {
return os.Rename(from, to)
}
func CopyDir(from, to string) error {
func copyDir(from, to string) error {
entries, err := os.ReadDir(from)
if err != nil {
return err
}
if err := CreateIfNotExists(to, 0o700); err != nil {
if err := createIfNotExists(to, 0o700); err != nil {
return err
}
for _, entry := range entries {
@ -74,11 +74,11 @@ func CopyDir(from, to string) error {
destPath := filepath.Join(to, entry.Name())
if entry.IsDir() {
if err := CopyDir(sourcePath, destPath); err != nil {
if err := copyDir(sourcePath, destPath); err != nil {
return err
}
} else {
if err := CopyFile(sourcePath, destPath); err != nil {
if err := copyFile(sourcePath, destPath); err != nil {
return err
}
}
@ -86,7 +86,7 @@ func CopyDir(from, to string) error {
return nil
}
func CopyFile(srcFile, dstFile string) error {
func copyFile(srcFile, dstFile string) error {
out, err := os.Create(filepath.Clean(dstFile))
defer func(out *os.File) {
_ = out.Close()
@ -113,7 +113,7 @@ func CopyFile(srcFile, dstFile string) error {
return nil
}
func Exists(filePath string) bool {
func exists(filePath string) bool {
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return false
}
@ -121,8 +121,8 @@ func Exists(filePath string) bool {
return true
}
func CreateIfNotExists(dir string, perm os.FileMode) error {
if Exists(dir) {
func createIfNotExists(dir string, perm os.FileMode) error {
if exists(dir) {
return nil
}

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package files
package bridge
import (
"os"
@ -41,7 +41,7 @@ func TestMoveDir(t *testing.T) {
}
// Move the files.
if err := MoveDir(from, to); err != nil {
if err := moveDir(from, to); err != nil {
t.Fatal(err)
}

View File

@ -40,35 +40,3 @@ func (bridge *Bridge) setUserAgent(name, version string) {
}
}
}
type bridgeUserAgentUpdater struct {
*Bridge
}
func (b *bridgeUserAgentUpdater) GetUserAgent() string {
return b.identifier.GetUserAgent()
}
func (b *bridgeUserAgentUpdater) HasClient() bool {
return b.identifier.HasClient()
}
func (b *bridgeUserAgentUpdater) SetClient(name, version string) {
b.identifier.SetClient(name, version)
}
func (b *bridgeUserAgentUpdater) SetPlatform(platform string) {
b.identifier.SetPlatform(platform)
}
func (b *bridgeUserAgentUpdater) SetClientString(client string) {
b.identifier.SetClientString(client)
}
func (b *bridgeUserAgentUpdater) GetClientString() string {
return b.identifier.GetClientString()
}
func (b *bridgeUserAgentUpdater) SetUserAgent(name, version string) {
b.setUserAgent(name, version)
}

View File

@ -20,12 +20,23 @@ package bridge
import (
"context"
"crypto/tls"
"io"
"os"
"path/filepath"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon"
"github.com/ProtonMail/gluon/async"
imapEvents "github.com/ProtonMail/gluon/events"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/store"
"github.com/ProtonMail/gluon/store/fallback_v0"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/sirupsen/logrus"
)
@ -34,6 +45,16 @@ func (bridge *Bridge) restartIMAP(ctx context.Context) error {
return bridge.serverManager.RestartIMAP(ctx)
}
// addIMAPUser connects the given user to gluon.
func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
return bridge.serverManager.AddIMAPUser(ctx, user)
}
// removeIMAPUser disconnects the given user from gluon, optionally also removing its files.
func (bridge *Bridge) removeIMAPUser(ctx context.Context, user *user.User, withData bool) error {
return bridge.serverManager.RemoveIMAPUser(ctx, user, withData)
}
func (bridge *Bridge) handleIMAPEvent(event imapEvents.Event) {
switch event := event.(type) {
case imapEvents.UserAdded:
@ -71,59 +92,108 @@ func (bridge *Bridge) handleIMAPEvent(event imapEvents.Event) {
}
}
type bridgeIMAPSettings struct {
b *Bridge
func ApplyGluonCachePathSuffix(basePath string) string {
return filepath.Join(basePath, "backend", "store")
}
func (b *bridgeIMAPSettings) EventPublisher() imapsmtpserver.IMAPEventPublisher {
return b
func ApplyGluonConfigPathSuffix(basePath string) string {
return filepath.Join(basePath, "backend", "db")
}
func (b *bridgeIMAPSettings) TLSConfig() *tls.Config {
return b.b.tlsConfig
}
func newIMAPServer(
gluonCacheDir, gluonConfigDir string,
version *semver.Version,
tlsConfig *tls.Config,
reporter reporter.Reporter,
logClient, logServer bool,
eventCh chan<- imapEvents.Event,
tasks *async.Group,
uidValidityGenerator imap.UIDValidityGenerator,
panicHandler async.PanicHandler,
) (*gluon.Server, error) {
gluonCacheDir = ApplyGluonCachePathSuffix(gluonCacheDir)
gluonConfigDir = ApplyGluonConfigPathSuffix(gluonConfigDir)
func (b *bridgeIMAPSettings) LogClient() bool {
return b.b.logIMAPClient
}
logrus.WithFields(logrus.Fields{
"gluonStore": gluonCacheDir,
"gluonDB": gluonConfigDir,
"version": version,
"logClient": logClient,
"logServer": logServer,
}).Info("Creating IMAP server")
func (b *bridgeIMAPSettings) LogServer() bool {
return b.b.logIMAPServer
}
func (b *bridgeIMAPSettings) Port() int {
return b.b.vault.GetIMAPPort()
}
func (b *bridgeIMAPSettings) SetPort(i int) error {
return b.b.vault.SetIMAPPort(i)
}
func (b *bridgeIMAPSettings) UseSSL() bool {
return b.b.vault.GetIMAPSSL()
}
func (b *bridgeIMAPSettings) CacheDirectory() string {
return b.b.GetGluonCacheDir()
}
func (b *bridgeIMAPSettings) DataDirectory() (string, error) {
return b.b.GetGluonDataDir()
}
func (b *bridgeIMAPSettings) SetCacheDirectory(s string) error {
return b.b.vault.SetGluonDir(s)
}
func (b *bridgeIMAPSettings) Version() *semver.Version {
return b.b.curVersion
}
func (b *bridgeIMAPSettings) PublishIMAPEvent(ctx context.Context, event imapEvents.Event) {
select {
case <-ctx.Done():
return
case b.b.imapEventCh <- event:
// do nothing
if logClient || logServer {
log := logrus.WithField("protocol", "IMAP")
log.Warning("================================================")
log.Warning("THIS LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA")
log.Warning("================================================")
}
var imapClientLog io.Writer
if logClient {
imapClientLog = logging.NewIMAPLogger()
} else {
imapClientLog = io.Discard
}
var imapServerLog io.Writer
if logServer {
imapServerLog = logging.NewIMAPLogger()
} else {
imapServerLog = io.Discard
}
imapServer, err := gluon.New(
gluon.WithTLS(tlsConfig),
gluon.WithDataDir(gluonCacheDir),
gluon.WithDatabaseDir(gluonConfigDir),
gluon.WithStoreBuilder(new(storeBuilder)),
gluon.WithLogger(imapClientLog, imapServerLog),
getGluonVersionInfo(version),
gluon.WithReporter(reporter),
gluon.WithUIDValidityGenerator(uidValidityGenerator),
gluon.WithPanicHandler(panicHandler),
)
if err != nil {
return nil, err
}
tasks.Once(func(ctx context.Context) {
async.ForwardContext(ctx, eventCh, imapServer.AddWatcher())
})
tasks.Once(func(ctx context.Context) {
async.RangeContext(ctx, imapServer.GetErrorCh(), func(err error) {
logrus.WithError(err).Error("IMAP server error")
})
})
return imapServer, nil
}
func getGluonVersionInfo(version *semver.Version) gluon.Option {
return gluon.WithVersionInfo(
int(version.Major()),
int(version.Minor()),
int(version.Patch()),
constants.FullAppName,
"TODO",
"TODO",
)
}
type storeBuilder struct{}
func (*storeBuilder) New(path, userID string, passphrase []byte) (store.Store, error) {
return store.NewOnDiskStore(
filepath.Join(path, userID),
passphrase,
store.WithFallback(fallback_v0.NewOnDiskStoreV0WithCompressor(&fallback_v0.GZipCompressor{})),
)
}
func (*storeBuilder) Delete(path, userID string) error {
return os.RemoveAll(filepath.Join(path, userID))
}

View File

@ -1,26 +0,0 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package bridge
type bridgeIMAPSMTPTelemetry struct {
b *Bridge
}
func (b bridgeIMAPSMTPTelemetry) SetCacheLocation(s string) {
b.b.heartbeat.SetCacheLocation(s)
}

View File

@ -0,0 +1,55 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package bridge
import (
"strings"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/sirupsen/logrus"
)
func (bridge *Bridge) databaseResyncNeeded() bool {
if strings.HasPrefix(bridge.lastVersion.String(), "3.4.0") &&
strings.HasPrefix(bridge.curVersion.String(), "3.4.1") {
logrus.WithFields(logrus.Fields{
"lastVersion": bridge.lastVersion.String(),
"currVersion": bridge.curVersion.String(),
}).Warning("Database re-synchronisation needed")
return true
}
return false
}
func (bridge *Bridge) migrateUser(vault *vault.User) {
if bridge.databaseResyncNeeded() {
if err := bridge.reporter.ReportMessage("Database need to be re-sync for migration."); err != nil {
logrus.WithError(err).Error("Failed to report database re-sync for migration.")
}
if err := vault.ClearSyncStatus(); err != nil {
logrus.WithError(err).Error("Failed reset to SyncStatus.")
if err2 := bridge.reporter.ReportMessageWithContext("Failed to reset SyncStatus for Database migration.",
reporter.Context{
"error": err,
}); err2 != nil {
logrus.WithError(err2).Error("Failed to report reset SyncStatus error.")
}
}
}
}

View File

@ -84,11 +84,6 @@ func TestBridge_Refresh(t *testing.T) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
mocks.Reporter.EXPECT().ReportMessageWithContext(gomock.Any(), gomock.Any()).AnyTimes()
// Wait for refresh event first
refreshCh, refreshChDone := chToType[events.Event, events.UserRefreshed](b.GetEvents(events.UserRefreshed{}))
defer refreshChDone()
require.Equal(t, userID, (<-refreshCh).UserID)
// Then sync event
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()

View File

@ -467,9 +467,7 @@ SGVsbG8gd29ybGQK
require.Eventually(t, func() bool {
messages, err := clientFetch(senderIMAPClient, `Sent`, imap.FetchBodyStructure)
require.NoError(t, err)
if len(messages) != 4 {
return false
}
require.Equal(t, 4, len(messages))
// messages may not be in order
for _, message := range messages {

View File

@ -0,0 +1,696 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package bridge
import (
"context"
"fmt"
"net"
"path/filepath"
"github.com/ProtonMail/gluon"
"github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/gluon/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/pkg/cpc"
"github.com/emersion/go-smtp"
"github.com/sirupsen/logrus"
)
// ServerManager manages the IMAP & SMTP servers and their listeners.
type ServerManager struct {
requests *cpc.CPC
imapServer *gluon.Server
imapListener net.Listener
smtpServer *smtp.Server
smtpListener net.Listener
loadedUserCount int
}
func newServerManager() *ServerManager {
return &ServerManager{
requests: cpc.NewCPC(),
}
}
func (sm *ServerManager) Init(bridge *Bridge) error {
imapServer, err := createIMAPServer(bridge)
if err != nil {
return err
}
smtpServer := createSMTPServer(bridge)
sm.imapServer = imapServer
sm.smtpServer = smtpServer
bridge.tasks.Once(func(ctx context.Context) {
logging.DoAnnotated(ctx, func(ctx context.Context) {
sm.run(ctx, bridge)
}, logging.Labels{
"service": "server-manager",
})
})
return nil
}
func (sm *ServerManager) CloseServers(ctx context.Context) error {
defer sm.requests.Close()
_, err := sm.requests.Send(ctx, &smRequestClose{})
return err
}
func (sm *ServerManager) RestartIMAP(ctx context.Context) error {
_, err := sm.requests.Send(ctx, &smRequestRestartIMAP{})
return err
}
func (sm *ServerManager) RestartSMTP(ctx context.Context) error {
_, err := sm.requests.Send(ctx, &smRequestRestartSMTP{})
return err
}
func (sm *ServerManager) AddIMAPUser(ctx context.Context, user *user.User) error {
_, err := sm.requests.Send(ctx, &smRequestAddIMAPUser{user: user})
return err
}
func (sm *ServerManager) RemoveIMAPUser(ctx context.Context, user *user.User, withData bool) error {
_, err := sm.requests.Send(ctx, &smRequestRemoveIMAPUser{
user: user,
withData: withData,
})
return err
}
func (sm *ServerManager) SetGluonDir(ctx context.Context, gluonDir string) error {
_, err := sm.requests.Send(ctx, &smRequestSetGluonDir{
dir: gluonDir,
})
return err
}
func (sm *ServerManager) AddGluonUser(ctx context.Context, conn connector.Connector, passphrase []byte) (string, error) {
reply, err := cpc.SendTyped[string](ctx, sm.requests, &smRequestAddGluonUser{
conn: conn,
passphrase: passphrase,
})
return reply, err
}
func (sm *ServerManager) RemoveGluonUser(ctx context.Context, gluonID string) error {
_, err := sm.requests.Send(ctx, &smRequestRemoveGluonUser{
userID: gluonID,
})
return err
}
func (sm *ServerManager) run(ctx context.Context, bridge *Bridge) {
eventCh, cancel := bridge.GetEvents()
defer cancel()
for {
select {
case <-ctx.Done():
sm.handleClose(ctx, bridge)
return
case evt := <-eventCh:
switch evt.(type) {
case events.ConnStatusDown:
logrus.Info("Server Manager, network down stopping listeners")
if err := sm.closeSMTPServer(bridge); err != nil {
logrus.WithError(err).Error("Failed to close SMTP server")
}
if err := sm.stopIMAPListener(bridge); err != nil {
logrus.WithError(err)
}
case events.ConnStatusUp:
logrus.Info("Server Manager, network up starting listeners")
sm.handleLoadedUserCountChange(ctx, bridge)
}
case request, ok := <-sm.requests.ReceiveCh():
if !ok {
return
}
switch r := request.Value().(type) {
case *smRequestClose:
sm.handleClose(ctx, bridge)
request.Reply(ctx, nil, nil)
return
case *smRequestRestartSMTP:
err := sm.restartSMTP(bridge)
request.Reply(ctx, nil, err)
case *smRequestRestartIMAP:
err := sm.restartIMAP(ctx, bridge)
request.Reply(ctx, nil, err)
case *smRequestAddIMAPUser:
err := sm.handleAddIMAPUser(ctx, r.user)
request.Reply(ctx, nil, err)
if err == nil {
sm.loadedUserCount++
sm.handleLoadedUserCountChange(ctx, bridge)
}
case *smRequestRemoveIMAPUser:
err := sm.handleRemoveIMAPUser(ctx, r.user, r.withData)
request.Reply(ctx, nil, err)
if err == nil {
sm.loadedUserCount--
sm.handleLoadedUserCountChange(ctx, bridge)
}
case *smRequestSetGluonDir:
err := sm.handleSetGluonDir(ctx, bridge, r.dir)
request.Reply(ctx, nil, err)
case *smRequestAddGluonUser:
id, err := sm.handleAddGluonUser(ctx, r.conn, r.passphrase)
request.Reply(ctx, id, err)
case *smRequestRemoveGluonUser:
err := sm.handleRemoveGluonUser(ctx, r.userID)
request.Reply(ctx, nil, err)
}
}
}
}
func (sm *ServerManager) handleLoadedUserCountChange(ctx context.Context, bridge *Bridge) {
logrus.Infof("Validating Listener State %v", sm.loadedUserCount)
if sm.shouldStartServers() {
if sm.imapListener == nil {
if err := sm.serveIMAP(ctx, bridge); err != nil {
logrus.WithError(err).Error("Failed to start IMAP server")
}
}
if sm.smtpListener == nil {
if err := sm.restartSMTP(bridge); err != nil {
logrus.WithError(err).Error("Failed to start SMTP server")
}
}
} else {
if sm.imapListener != nil {
if err := sm.stopIMAPListener(bridge); err != nil {
logrus.WithError(err).Error("Failed to stop IMAP server")
}
}
if sm.smtpListener != nil {
if err := sm.closeSMTPServer(bridge); err != nil {
logrus.WithError(err).Error("Failed to stop SMTP server")
}
}
}
}
func (sm *ServerManager) handleClose(ctx context.Context, bridge *Bridge) {
// Close the IMAP server.
if err := sm.closeIMAPServer(ctx, bridge); err != nil {
logrus.WithError(err).Error("Failed to close IMAP server")
}
// Close the SMTP server.
if err := sm.closeSMTPServer(bridge); err != nil {
logrus.WithError(err).Error("Failed to close SMTP server")
}
}
func (sm *ServerManager) handleAddIMAPUser(ctx context.Context, user *user.User) error {
if sm.imapServer == nil {
return fmt.Errorf("no imap server instance running")
}
imapConn, err := user.NewIMAPConnectors()
if err != nil {
return fmt.Errorf("failed to create IMAP connectors: %w", err)
}
for addrID, imapConn := range imapConn {
log := logrus.WithFields(logrus.Fields{
"userID": user.ID(),
"addrID": addrID,
})
if gluonID, ok := user.GetGluonID(addrID); ok {
log.WithField("gluonID", gluonID).Info("Loading existing IMAP user")
// Load the user, checking whether the DB was newly created.
isNew, err := sm.imapServer.LoadUser(ctx, imapConn, gluonID, user.GluonKey())
if err != nil {
return fmt.Errorf("failed to load IMAP user: %w", err)
}
if isNew {
// If the DB was newly created, clear the sync status; gluon's DB was not found.
logrus.Warn("IMAP user DB was newly created, clearing sync status")
// Remove the user from IMAP so we can clear the sync status.
if err := sm.imapServer.RemoveUser(ctx, gluonID, false); err != nil {
return fmt.Errorf("failed to remove IMAP user: %w", err)
}
// Clear the sync status -- we need to resync all messages.
if err := user.ClearSyncStatus(); err != nil {
return fmt.Errorf("failed to clear sync status: %w", err)
}
// Add the user back to the IMAP server.
if isNew, err := sm.imapServer.LoadUser(ctx, imapConn, gluonID, user.GluonKey()); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
} else if isNew {
panic("IMAP user should already have a database")
}
} else if status := user.GetSyncStatus(); !status.HasLabels {
// Otherwise, the DB already exists -- if the labels are not yet synced, we need to re-create the DB.
if err := sm.imapServer.RemoveUser(ctx, gluonID, true); err != nil {
return fmt.Errorf("failed to remove old IMAP user: %w", err)
}
if err := user.RemoveGluonID(addrID, gluonID); err != nil {
return fmt.Errorf("failed to remove old IMAP user ID: %w", err)
}
gluonID, err := sm.imapServer.AddUser(ctx, imapConn, user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
if err := user.SetGluonID(addrID, gluonID); err != nil {
return fmt.Errorf("failed to set IMAP user ID: %w", err)
}
log.WithField("gluonID", gluonID).Info("Re-created IMAP user")
}
} else {
log.Info("Creating new IMAP user")
gluonID, err := sm.imapServer.AddUser(ctx, imapConn, user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
if err := user.SetGluonID(addrID, gluonID); err != nil {
return fmt.Errorf("failed to set IMAP user ID: %w", err)
}
log.WithField("gluonID", gluonID).Info("Created new IMAP user")
}
}
// Trigger a sync for the user, if needed.
user.TriggerSync()
return nil
}
func (sm *ServerManager) handleRemoveIMAPUser(ctx context.Context, user *user.User, withData bool) error {
if sm.imapServer == nil {
return fmt.Errorf("no imap server instance running")
}
logrus.WithFields(logrus.Fields{
"userID": user.ID(),
"withData": withData,
}).Debug("Removing IMAP user")
for addrID, gluonID := range user.GetGluonIDs() {
if err := sm.imapServer.RemoveUser(ctx, gluonID, withData); err != nil {
return fmt.Errorf("failed to remove IMAP user: %w", err)
}
if withData {
if err := user.RemoveGluonID(addrID, gluonID); err != nil {
return fmt.Errorf("failed to remove IMAP user ID: %w", err)
}
}
}
return nil
}
func createIMAPServer(bridge *Bridge) (*gluon.Server, error) {
gluonDataDir, err := bridge.GetGluonDataDir()
if err != nil {
return nil, fmt.Errorf("failed to get Gluon Database directory: %w", err)
}
return newIMAPServer(
bridge.vault.GetGluonCacheDir(),
gluonDataDir,
bridge.curVersion,
bridge.tlsConfig,
bridge.reporter,
bridge.logIMAPClient,
bridge.logIMAPServer,
bridge.imapEventCh,
bridge.tasks,
bridge.uidValidityGenerator,
bridge.panicHandler,
)
}
func createSMTPServer(bridge *Bridge) *smtp.Server {
return newSMTPServer(bridge, bridge.tlsConfig, bridge.logSMTP)
}
func (sm *ServerManager) closeSMTPServer(bridge *Bridge) error {
// We close the listener ourselves even though it's also closed by smtpServer.Close().
// This is because smtpServer.Serve() is called in a separate goroutine and might be executed
// after we've already closed the server. However, go-smtp has a bug; it blocks on the listener
// even after the server has been closed. So we close the listener ourselves to unblock it.
if sm.smtpListener != nil {
logrus.Info("Closing SMTP Listener")
if err := sm.smtpListener.Close(); err != nil {
return fmt.Errorf("failed to close SMTP listener: %w", err)
}
sm.smtpListener = nil
}
if sm.smtpServer != nil {
logrus.Info("Closing SMTP server")
if err := sm.smtpServer.Close(); err != nil {
logrus.WithError(err).Debug("Failed to close SMTP server (expected -- we close the listener ourselves)")
}
sm.smtpServer = nil
bridge.publish(events.SMTPServerStopped{})
}
return nil
}
func (sm *ServerManager) closeIMAPServer(ctx context.Context, bridge *Bridge) error {
if sm.imapListener != nil {
logrus.Info("Closing IMAP Listener")
if err := sm.imapListener.Close(); err != nil {
return fmt.Errorf("failed to close IMAP listener: %w", err)
}
sm.imapListener = nil
bridge.publish(events.IMAPServerStopped{})
}
if sm.imapServer != nil {
logrus.Info("Closing IMAP server")
if err := sm.imapServer.Close(ctx); err != nil {
return fmt.Errorf("failed to close IMAP server: %w", err)
}
sm.imapServer = nil
}
return nil
}
func (sm *ServerManager) restartIMAP(ctx context.Context, bridge *Bridge) error {
logrus.Info("Restarting IMAP server")
if sm.imapListener != nil {
if err := sm.imapListener.Close(); err != nil {
return fmt.Errorf("failed to close IMAP listener: %w", err)
}
sm.imapListener = nil
bridge.publish(events.IMAPServerStopped{})
}
if sm.shouldStartServers() {
return sm.serveIMAP(ctx, bridge)
}
return nil
}
func (sm *ServerManager) restartSMTP(bridge *Bridge) error {
logrus.Info("Restarting SMTP server")
if err := sm.closeSMTPServer(bridge); err != nil {
return fmt.Errorf("failed to close SMTP: %w", err)
}
bridge.publish(events.SMTPServerStopped{})
sm.smtpServer = newSMTPServer(bridge, bridge.tlsConfig, bridge.logSMTP)
if sm.shouldStartServers() {
return sm.serveSMTP(bridge)
}
return nil
}
func (sm *ServerManager) serveSMTP(bridge *Bridge) error {
port, err := func() (int, error) {
logrus.WithFields(logrus.Fields{
"port": bridge.vault.GetSMTPPort(),
"ssl": bridge.vault.GetSMTPSSL(),
}).Info("Starting SMTP server")
smtpListener, err := newListener(bridge.vault.GetSMTPPort(), bridge.vault.GetSMTPSSL(), bridge.tlsConfig)
if err != nil {
return 0, fmt.Errorf("failed to create SMTP listener: %w", err)
}
sm.smtpListener = smtpListener
bridge.tasks.Once(func(context.Context) {
if err := sm.smtpServer.Serve(smtpListener); err != nil {
logrus.WithError(err).Info("SMTP server stopped")
}
})
if err := bridge.vault.SetSMTPPort(getPort(smtpListener.Addr())); err != nil {
return 0, fmt.Errorf("failed to store SMTP port in vault: %w", err)
}
return getPort(smtpListener.Addr()), nil
}()
if err != nil {
bridge.publish(events.SMTPServerError{
Error: err,
})
return err
}
bridge.publish(events.SMTPServerReady{
Port: port,
})
return nil
}
func (sm *ServerManager) serveIMAP(ctx context.Context, bridge *Bridge) error {
port, err := func() (int, error) {
if sm.imapServer == nil {
return 0, fmt.Errorf("no IMAP server instance running")
}
logrus.WithFields(logrus.Fields{
"port": bridge.vault.GetIMAPPort(),
"ssl": bridge.vault.GetIMAPSSL(),
}).Info("Starting IMAP server")
imapListener, err := newListener(bridge.vault.GetIMAPPort(), bridge.vault.GetIMAPSSL(), bridge.tlsConfig)
if err != nil {
return 0, fmt.Errorf("failed to create IMAP listener: %w", err)
}
sm.imapListener = imapListener
if err := sm.imapServer.Serve(ctx, sm.imapListener); err != nil {
return 0, fmt.Errorf("failed to serve IMAP: %w", err)
}
if err := bridge.vault.SetIMAPPort(getPort(imapListener.Addr())); err != nil {
return 0, fmt.Errorf("failed to store IMAP port in vault: %w", err)
}
return getPort(imapListener.Addr()), nil
}()
if err != nil {
bridge.publish(events.IMAPServerError{
Error: err,
})
return err
}
bridge.publish(events.IMAPServerReady{
Port: port,
})
return nil
}
func (sm *ServerManager) stopIMAPListener(bridge *Bridge) error {
logrus.Info("Stopping IMAP listener")
if sm.imapListener != nil {
if err := sm.imapListener.Close(); err != nil {
return err
}
sm.imapListener = nil
bridge.publish(events.IMAPServerStopped{})
}
return nil
}
func (sm *ServerManager) handleSetGluonDir(ctx context.Context, bridge *Bridge, newGluonDir string) error {
return safe.RLockRet(func() error {
currentGluonDir := bridge.GetGluonCacheDir()
newGluonDir = filepath.Join(newGluonDir, "gluon")
if newGluonDir == currentGluonDir {
return fmt.Errorf("new gluon dir is the same as the old one")
}
if err := sm.closeIMAPServer(context.Background(), bridge); err != nil {
return fmt.Errorf("failed to close IMAP: %w", err)
}
sm.loadedUserCount = 0
if err := bridge.moveGluonCacheDir(currentGluonDir, newGluonDir); err != nil {
logrus.WithError(err).Error("failed to move GluonCacheDir")
if err := bridge.vault.SetGluonDir(currentGluonDir); err != nil {
return fmt.Errorf("failed to revert GluonCacheDir: %w", err)
}
}
bridge.heartbeat.SetCacheLocation(newGluonDir)
gluonDataDir, err := bridge.GetGluonDataDir()
if err != nil {
return fmt.Errorf("failed to get Gluon Database directory: %w", err)
}
imapServer, err := newIMAPServer(
bridge.vault.GetGluonCacheDir(),
gluonDataDir,
bridge.curVersion,
bridge.tlsConfig,
bridge.reporter,
bridge.logIMAPClient,
bridge.logIMAPServer,
bridge.imapEventCh,
bridge.tasks,
bridge.uidValidityGenerator,
bridge.panicHandler,
)
if err != nil {
return fmt.Errorf("failed to create new IMAP server: %w", err)
}
sm.imapServer = imapServer
for _, bridgeUser := range bridge.users {
if err := sm.handleAddIMAPUser(ctx, bridgeUser); err != nil {
return fmt.Errorf("failed to add users to new IMAP server: %w", err)
}
sm.loadedUserCount++
}
if sm.shouldStartServers() {
if err := sm.serveIMAP(ctx, bridge); err != nil {
return fmt.Errorf("failed to serve IMAP: %w", err)
}
}
return nil
}, bridge.usersLock)
}
func (sm *ServerManager) handleAddGluonUser(ctx context.Context, conn connector.Connector, passphrase []byte) (string, error) {
if sm.imapServer == nil {
return "", fmt.Errorf("no imap server instance running")
}
return sm.imapServer.AddUser(ctx, conn, passphrase)
}
func (sm *ServerManager) handleRemoveGluonUser(ctx context.Context, userID string) error {
if sm.imapServer == nil {
return fmt.Errorf("no imap server instance running")
}
return sm.imapServer.RemoveUser(ctx, userID, true)
}
func (sm *ServerManager) shouldStartServers() bool {
return sm.loadedUserCount >= 1
}
type smRequestClose struct{}
type smRequestRestartIMAP struct{}
type smRequestRestartSMTP struct{}
type smRequestAddIMAPUser struct {
user *user.User
}
type smRequestRemoveIMAPUser struct {
user *user.User
withData bool
}
type smRequestSetGluonDir struct {
dir string
}
type smRequestAddGluonUser struct {
conn connector.Connector
passphrase []byte
}
type smRequestRemoveGluonUser struct {
userID string
}

View File

@ -20,10 +20,11 @@ package bridge
import (
"context"
"fmt"
"net"
"os"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/services/userevents"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/sirupsen/logrus"
@ -130,41 +131,26 @@ func (bridge *Bridge) GetGluonDataDir() (string, error) {
}
func (bridge *Bridge) SetGluonDir(ctx context.Context, newGluonDir string) error {
bridge.usersLock.RLock()
defer func() {
logrus.Info("Restarting user event loops")
for _, u := range bridge.users {
u.ResumeEventLoop()
}
bridge.usersLock.RUnlock()
}()
type waiter struct {
w *userevents.EventPollWaiter
id string
}
waiters := make([]waiter, 0, len(bridge.users))
logrus.Info("Pausing user event loops for gluon dir change")
for id, u := range bridge.users {
waiters = append(waiters, waiter{w: u.PauseEventLoopWithWaiter(), id: id})
}
logrus.Info("Waiting on user event loop completion")
for _, waiter := range waiters {
if err := waiter.w.WaitPollFinished(ctx); err != nil {
logrus.WithError(err).Errorf("Failed to wait on event loop pause for user %v", waiter.id)
return fmt.Errorf("failed on event loop pause: %w", err)
}
}
logrus.Info("Changing gluon directory")
return bridge.serverManager.SetGluonDir(ctx, newGluonDir)
}
func (bridge *Bridge) moveGluonCacheDir(oldGluonDir, newGluonDir string) error {
logrus.Infof("gluon cache moving from %s to %s", oldGluonDir, newGluonDir)
oldCacheDir := ApplyGluonCachePathSuffix(oldGluonDir)
if err := copyDir(oldCacheDir, ApplyGluonCachePathSuffix(newGluonDir)); err != nil {
return fmt.Errorf("failed to copy gluon dir: %w", err)
}
if err := bridge.vault.SetGluonDir(newGluonDir); err != nil {
return fmt.Errorf("failed to set new gluon cache dir: %w", err)
}
if err := os.RemoveAll(oldCacheDir); err != nil {
logrus.WithError(err).Error("failed to remove old gluon cache dir")
}
return nil
}
func (bridge *Bridge) GetProxyAllowed() bool {
return bridge.vault.GetProxyAllowed()
}
@ -332,3 +318,16 @@ func (bridge *Bridge) FactoryReset(ctx context.Context) {
logrus.WithError(err).Error("Failed to clear data paths")
}
}
func getPort(addr net.Addr) int {
switch addr := addr.(type) {
case *net.TCPAddr:
return addr.Port
case *net.UDPAddr:
return addr.Port
default:
return 0
}
}

View File

@ -25,7 +25,6 @@ import (
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/stretchr/testify/require"
)
@ -52,45 +51,6 @@ func TestBridge_Settings_GluonDir(t *testing.T) {
})
}
func TestBridge_Settings_GluonDirWithOnGoingEvents(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
userID, addrID, err := s.CreateUser("imap", password)
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done()
_, err := bridge.LoginFull(context.Background(), "imap", password, nil, nil)
require.NoError(t, err)
<-syncCh
})
labelID, err := s.CreateLabel(userID, "folder", "", proton.LabelTypeFolder)
require.NoError(t, err)
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, labelID, 200)
})
withBridgeWaitForServers(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
// Create a new location for the Gluon data.
newGluonDir := t.TempDir()
// Move the gluon dir; it should also move the user's data.
require.NoError(t, bridge.SetGluonDir(context.Background(), newGluonDir))
// Check that the new directory is not empty.
entries, err := os.ReadDir(newGluonDir)
require.NoError(t, err)
// There should be at least one entry.
require.NotEmpty(t, entries)
})
})
}
func TestBridge_Settings_IMAPPort(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {

View File

@ -21,37 +21,43 @@ import (
"context"
"crypto/tls"
"github.com/ProtonMail/proton-bridge/v3/internal/identifier"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/sirupsen/logrus"
)
func (bridge *Bridge) restartSMTP(ctx context.Context) error {
return bridge.serverManager.RestartSMTP(ctx)
}
type bridgeSMTPSettings struct {
b *Bridge
}
func newSMTPServer(bridge *Bridge, tlsConfig *tls.Config, logSMTP bool) *smtp.Server {
logrus.WithField("logSMTP", logSMTP).Info("Creating SMTP server")
func (b *bridgeSMTPSettings) TLSConfig() *tls.Config {
return b.b.tlsConfig
}
smtpServer := smtp.NewServer(&smtpBackend{Bridge: bridge})
func (b *bridgeSMTPSettings) Log() bool {
return b.b.logSMTP
}
smtpServer.TLSConfig = tlsConfig
smtpServer.Domain = constants.Host
smtpServer.AllowInsecureAuth = true
smtpServer.MaxLineLength = 1 << 16
smtpServer.ErrorLog = logging.NewSMTPLogger()
func (b *bridgeSMTPSettings) Port() int {
return b.b.vault.GetSMTPPort()
}
// go-smtp suppors SASL PLAIN but not LOGIN. We need to add LOGIN support ourselves.
smtpServer.EnableAuth(sasl.Login, func(conn *smtp.Conn) sasl.Server {
return sasl.NewLoginServer(func(username, password string) error {
return conn.Session().AuthPlain(username, password)
})
})
func (b *bridgeSMTPSettings) SetPort(i int) error {
return b.b.vault.SetSMTPPort(i)
}
if logSMTP {
log := logrus.WithField("protocol", "SMTP")
log.Warning("================================================")
log.Warning("THIS LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA")
log.Warning("================================================")
func (b *bridgeSMTPSettings) UseSSL() bool {
return b.b.vault.GetSMTPSSL()
}
smtpServer.Debug = logging.NewSMTPDebugLogger()
}
func (b *bridgeSMTPSettings) Identifier() identifier.UserAgentUpdater {
return &bridgeUserAgentUpdater{Bridge: b.b}
return smtpServer
}

View File

@ -15,36 +15,26 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package smtp
package bridge
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/ProtonMail/proton-bridge/v3/internal/identifier"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/emersion/go-smtp"
"github.com/sirupsen/logrus"
)
type Backend struct {
accounts *Accounts
userAgent identifier.UserAgentUpdater
}
func NewBackend(accounts *Accounts, userAgent identifier.UserAgentUpdater) *Backend {
return &Backend{
accounts: accounts,
userAgent: userAgent,
}
type smtpBackend struct {
*Bridge
}
type smtpSession struct {
accounts *Accounts
userAgent identifier.UserAgentUpdater
*Bridge
userID string
authID string
@ -53,32 +43,45 @@ type smtpSession struct {
to []string
}
func (be *Backend) NewSession(*smtp.Conn) (smtp.Session, error) {
return &smtpSession{accounts: be.accounts, userAgent: be.userAgent}, nil
func (be *smtpBackend) NewSession(*smtp.Conn) (smtp.Session, error) {
return &smtpSession{Bridge: be.Bridge}, nil
}
func (s *smtpSession) AuthPlain(username, password string) error {
userID, authID, err := s.accounts.CheckAuth(username, []byte(password))
if err != nil {
if !errors.Is(err, ErrNoSuchUser) {
return fmt.Errorf("unknown error")
return safe.RLockRet(func() error {
for _, user := range s.users {
addrID, err := user.CheckAuth(username, []byte(password))
if err != nil {
continue
}
s.userID = user.ID()
s.authID = addrID
if strings.Contains(s.Bridge.GetCurrentUserAgent(), useragent.DefaultUserAgent) {
s.Bridge.setUserAgent(useragent.UnknownClient, useragent.DefaultVersion)
}
user.SendConfigStatusSuccess(context.Background())
return nil
}
logrus.WithFields(logrus.Fields{
"username": username,
"pkg": "smtp",
}).Error("Incorrect login credentials.")
return fmt.Errorf("invalid username or password")
}
s.userID = userID
s.authID = authID
if strings.Contains(s.userAgent.GetUserAgent(), useragent.DefaultUserAgent) {
s.userAgent.SetUserAgent(useragent.UnknownClient, useragent.DefaultVersion)
}
return nil
err := fmt.Errorf("invalid username or password")
for _, user := range s.users {
for _, mail := range user.Emails() {
if mail == username {
user.ReportConfigStatusFailure(err.Error())
return err
}
}
}
return err
}, s.usersLock)
}
func (s *smtpSession) Reset() {
@ -105,7 +108,14 @@ func (s *smtpSession) Rcpt(to string) error {
}
func (s *smtpSession) Data(r io.Reader) error {
err := s.accounts.SendMail(context.Background(), s.userID, s.authID, s.from, s.to, r)
err := safe.RLockRet(func() error {
user, ok := s.users[s.userID]
if !ok {
return ErrNoSuchUser
}
return user.SendMail(s.authID, s.from, s.to, r)
}, s.usersLock)
if err != nil {
logrus.WithField("pkg", "smtp").WithError(err).Error("Send mail failed.")

View File

@ -21,11 +21,9 @@ import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
@ -254,17 +252,14 @@ func TestBridge_SyncWithOngoingEvents(t *testing.T) {
// Login the user; its sync should fail.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()
{
syncFailedCh, syncFailedDone := chToType[events.Event, events.SyncFailed](b.GetEvents(events.SyncFailed{}))
defer syncFailedDone()
syncCh, done := chToType[events.Event, events.SyncFailed](b.GetEvents(events.SyncFailed{}))
defer done()
userID, err := b.LoginFull(ctx, "imap", password, nil, nil)
require.NoError(t, err)
require.Equal(t, userID, (<-syncFailedCh).UserID)
require.Equal(t, userID, (<-syncCh).UserID)
info, err := b.GetUserInfo(userID)
require.NoError(t, err)
@ -287,7 +282,11 @@ func TestBridge_SyncWithOngoingEvents(t *testing.T) {
// Remove the network limit, allowing the sync to finish.
netCtl.SetReadLimit(0)
{
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()
require.Equal(t, userID, (<-syncCh).UserID)
info, err := b.GetUserInfo(userID)
@ -299,6 +298,12 @@ func TestBridge_SyncWithOngoingEvents(t *testing.T) {
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
status, err := client.Select(`Folders/folder`, false)
require.NoError(t, err)
// Original folder should have more than 0 messages and less than the total.
require.Greater(t, status.Messages, uint32(0))
require.Less(t, status.Messages, uint32(numMsg))
// Check that the new messages arrive in the right location.
require.Eventually(t, func() bool {
status, err := client.Select(`Folders/folder2`, true)
@ -316,269 +321,6 @@ func TestBridge_SyncWithOngoingEvents(t *testing.T) {
}, server.WithTLS(false))
}
func TestBridge_CanProcessEventsDuringSync(t *testing.T) {
numMsg := 1 << 8
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
userID, addrID, err := s.CreateUser("imap", password)
require.NoError(t, err)
labelID, err := s.CreateLabel(userID, "folder", "", proton.LabelTypeFolder)
require.NoError(t, err)
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, labelID, numMsg)
})
// Simulate 429 to prevent sync from progressing.
s.AddStatusHook(func(request *http.Request) (int, bool) {
if strings.Contains(request.URL.Path, "/mail/v4/messages/") {
return http.StatusTooManyRequests, true
}
return 0, false
})
// The initial user should be fully synced.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncStartedCh, syncStartedDone := chToType[events.Event, events.SyncStarted](bridge.GetEvents(events.SyncStarted{}))
defer syncStartedDone()
addressCreatedCh, addressCreatedDone := chToType[events.Event, events.UserAddressCreated](bridge.GetEvents(events.UserAddressCreated{}))
defer addressCreatedDone()
userID, err := bridge.LoginFull(ctx, "imap", password, nil, nil)
require.NoError(t, err)
require.Equal(t, userID, (<-syncStartedCh).UserID)
// Create a new address
newAddress := "foo@proton.ch"
addrID, err := s.CreateAddress(userID, newAddress, password)
require.NoError(t, err)
event := <-addressCreatedCh
require.Equal(t, userID, event.UserID)
require.Equal(t, newAddress, event.Email)
require.Equal(t, addrID, event.AddressID)
})
}, server.WithTLS(false))
}
func TestBridge_RefreshDuringSyncRestartSync(t *testing.T) {
numMsg := 1 << 8
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
userID, addrID, err := s.CreateUser("imap", password)
require.NoError(t, err)
labelID, err := s.CreateLabel(userID, "folder", "", proton.LabelTypeFolder)
require.NoError(t, err)
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, labelID, numMsg)
})
var refreshPerformed atomic.Bool
refreshPerformed.Store(false)
// Simulate 429 to prevent sync from progressing.
s.AddStatusHook(func(request *http.Request) (int, bool) {
if strings.Contains(request.URL.Path, "/mail/v4/messages/") {
if !refreshPerformed.Load() {
return http.StatusTooManyRequests, true
}
}
return 0, false
})
// The initial user should be fully synced.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done()
userID, err := bridge.LoginFull(ctx, "imap", password, nil, nil)
require.NoError(t, err)
syncStartedCh, syncStartedDone := chToType[events.Event, events.SyncStarted](bridge.GetEvents(events.SyncStarted{}))
defer syncStartedDone()
require.Equal(t, userID, (<-syncStartedCh).UserID)
require.NoError(t, err, s.RefreshUser(userID, proton.RefreshMail))
require.Equal(t, userID, (<-syncStartedCh).UserID)
refreshPerformed.Store(true)
require.Equal(t, userID, (<-syncCh).UserID)
})
}, server.WithTLS(false))
}
func TestBridge_EventReplayAfterSyncHasFinished(t *testing.T) {
numMsg := 1 << 8
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
userID, addrID, err := s.CreateUser("imap", password)
require.NoError(t, err)
labelID, err := s.CreateLabel(userID, "folder", "", proton.LabelTypeFolder)
require.NoError(t, err)
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, labelID, numMsg)
})
addrID1, err := s.CreateAddress(userID, "foo@proton.ch", password)
require.NoError(t, err)
var allowSyncToProgress atomic.Bool
allowSyncToProgress.Store(false)
// Simulate 429 to prevent sync from progressing.
s.AddStatusHook(func(request *http.Request) (int, bool) {
if request.Method == "GET" && strings.Contains(request.URL.Path, "/mail/v4/messages/") {
if !allowSyncToProgress.Load() {
return http.StatusTooManyRequests, true
}
}
return 0, false
})
// The initial user should be fully synced.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, done := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
defer done()
syncStartedCh, syncStartedDone := chToType[events.Event, events.SyncStarted](bridge.GetEvents(events.SyncStarted{}))
defer syncStartedDone()
addressCreatedCh, addressCreatedDone := chToType[events.Event, events.UserAddressCreated](bridge.GetEvents(events.UserAddressCreated{}))
defer addressCreatedDone()
userID, err := bridge.LoginFull(ctx, "imap", password, nil, nil)
require.NoError(t, err)
require.Equal(t, userID, (<-syncStartedCh).UserID)
// create 20 more messages and move them to inbox
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 20)
})
// User AddrID2 event as a check point to see when the new address was created.
addrID2, err := s.CreateAddress(userID, "bar@proton.ch", password)
require.NoError(t, err)
allowSyncToProgress.Store(true)
require.Equal(t, userID, (<-syncCh).UserID)
// At most two events can be published, one for the first address, then for the second.
// if the second event is not `addrID2` then something went wrong.
event := <-addressCreatedCh
if event.AddressID == addrID1 {
event = <-addressCreatedCh
}
require.Equal(t, addrID2, event.AddressID)
info, err := bridge.GetUserInfo(userID)
require.NoError(t, err)
client, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
// Finally check if the 20 messages are in INBOX.
status, err := client.Status("INBOX", []imap.StatusItem{imap.StatusMessages})
require.NoError(t, err)
require.Equal(t, uint32(20), status.Messages)
// Finally check if the numMsg are in the folder.
status, err = client.Status("Folders/folder", []imap.StatusItem{imap.StatusMessages})
require.NoError(t, err)
require.Equal(t, uint32(numMsg), status.Messages)
})
}, server.WithTLS(false))
}
func TestBridge_MessageCreateDuringSync(t *testing.T) {
numMsg := 1 << 8
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
userID, addrID, err := s.CreateUser("imap", password)
require.NoError(t, err)
labelID, err := s.CreateLabel(userID, "folder", "", proton.LabelTypeFolder)
require.NoError(t, err)
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, labelID, numMsg)
})
var allowSyncToProgress atomic.Bool
allowSyncToProgress.Store(false)
// Simulate 429 to prevent sync from progressing.
s.AddStatusHook(func(request *http.Request) (int, bool) {
if request.Method == "GET" && strings.Contains(request.URL.Path, "/mail/v4/messages/") {
if !allowSyncToProgress.Load() {
return http.StatusTooManyRequests, true
}
}
return 0, false
})
// The initial user should be fully synced.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncStartedCh, syncStartedDone := chToType[events.Event, events.SyncStarted](bridge.GetEvents(events.SyncStarted{}))
defer syncStartedDone()
addressCreatedCh, addressCreatedDone := chToType[events.Event, events.UserAddressCreated](bridge.GetEvents(events.UserAddressCreated{}))
defer addressCreatedDone()
userID, err := bridge.LoginFull(ctx, "imap", password, nil, nil)
require.NoError(t, err)
require.Equal(t, userID, (<-syncStartedCh).UserID)
// create 20 more messages and move them to inbox
withClient(ctx, t, s, "imap", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 20)
})
// User AddrID2 event as a check point to see when the new address was created.
addrID, err := s.CreateAddress(userID, "bar@proton.ch", password)
require.NoError(t, err)
// At most two events can be published, one for the first address, then for the second.
// if the second event is not `addrID` then something went wrong.
event := <-addressCreatedCh
require.Equal(t, addrID, event.AddressID)
allowSyncToProgress.Store(true)
info, err := bridge.GetUserInfo(userID)
require.NoError(t, err)
client, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
require.Eventually(t, func() bool {
// Finally check if the 20 messages are in INBOX.
status, err := client.Status("INBOX", []imap.StatusItem{imap.StatusMessages})
require.NoError(t, err)
return uint32(20) == status.Messages
}, 10*time.Second, time.Second)
})
}, server.WithTLS(false))
}
func withClient(ctx context.Context, t *testing.T, s *server.Server, username string, password []byte, fn func(context.Context, *proton.Client)) { //nolint:unparam
m := proton.New(
proton.WithHostURL(s.GetHostURL()),

View File

@ -31,7 +31,7 @@ import (
"github.com/stretchr/testify/require"
)
// Disabled due to flakiness.
// Disabled due to flakyness.
func _TestBridge_SyncExistsWithErrorWhenTooManyFilesAreOpen(t *testing.T) { //nolint:unused
var rlimitCurrent syscall.Rlimit

View File

@ -32,7 +32,15 @@ type Locator interface {
GetLicenseFilePath() string
GetDependencyLicensesLink() string
Clear(...string) error
ProvideIMAPSyncConfigPath() (string, error)
}
type Identifier interface {
GetUserAgent() string
HasClient() bool
SetClient(name, version string)
SetPlatform(platform string)
SetClientString(client string)
GetClientString() string
}
type ProxyController interface {

View File

@ -30,7 +30,6 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
"github.com/ProtonMail/proton-bridge/v3/internal/try"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
@ -244,11 +243,6 @@ func (bridge *Bridge) LogoutUser(ctx context.Context, userID string) error {
func (bridge *Bridge) DeleteUser(ctx context.Context, userID string) error {
logrus.WithField("userID", userID).Info("Deleting user")
syncConfigDir, err := bridge.locator.ProvideIMAPSyncConfigPath()
if err != nil {
return fmt.Errorf("failed to get sync config path")
}
return safe.LockRet(func() error {
if !bridge.vault.HasUser(userID) {
return ErrNoSuchUser
@ -258,10 +252,6 @@ func (bridge *Bridge) DeleteUser(ctx context.Context, userID string) error {
bridge.logoutUser(ctx, user, true, true, !bridge.GetTelemetryDisabled())
}
if err := imapservice.DeleteSyncState(syncConfigDir, userID); err != nil {
return fmt.Errorf("failed to delete use sync config")
}
if err := bridge.vault.DeleteUser(userID); err != nil {
logrus.WithError(err).Error("Failed to delete vault user")
}
@ -288,10 +278,18 @@ func (bridge *Bridge) SetAddressMode(ctx context.Context, userID string, mode va
return fmt.Errorf("address mode is already %q", mode)
}
if err := bridge.removeIMAPUser(ctx, user, true); err != nil {
return fmt.Errorf("failed to remove IMAP user: %w", err)
}
if err := user.SetAddressMode(ctx, mode); err != nil {
return fmt.Errorf("failed to set address mode: %w", err)
}
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
bridge.publish(events.AddressModeChanged{
UserID: userID,
AddressMode: mode,
@ -337,7 +335,13 @@ func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string,
logrus.WithError(rerr).Error("Failed to report feedback failure")
}
return user.BadEventFeedbackResync(ctx)
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
user.BadEventFeedbackResync(ctx)
return nil
}
if rerr := bridge.reporter.ReportMessageWithContext(
@ -520,10 +524,8 @@ func (bridge *Bridge) addUserWithVault(
return fmt.Errorf("failed to get Statistics directory: %w", err)
}
syncSettingsPath, err := bridge.locator.ProvideIMAPSyncConfigPath()
if err != nil {
return fmt.Errorf("failed to get IMAP sync config path: %w", err)
}
// re-set SyncStatus if database need to be re-synced for migration.
bridge.migrateUser(vault)
user, err := user.New(
ctx,
@ -536,16 +538,16 @@ func (bridge *Bridge) addUserWithVault(
bridge.vault.GetMaxSyncMemory(),
statsPath,
bridge,
bridge.serverManager,
bridge.serverManager,
&bridgeEventSubscription{b: bridge},
bridge.syncService,
syncSettingsPath,
)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
// Connect the user's address(es) to gluon.
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
// Handle events coming from the user before forwarding them to the bridge.
// For example, if the user's addresses change, we need to update them in gluon.
bridge.tasks.Once(func(ctx context.Context) {
@ -555,8 +557,11 @@ func (bridge *Bridge) addUserWithVault(
"event": event,
}).Debug("Received user event")
bridge.handleUserEvent(ctx, user, event)
bridge.publish(event)
if err := bridge.handleUserEvent(ctx, user, event); err != nil {
logrus.WithError(err).Error("Failed to handle user event")
} else {
bridge.publish(event)
}
})
})
@ -607,6 +612,10 @@ func (bridge *Bridge) logoutUser(ctx context.Context, user *user.User, withAPI,
"withData": withData,
}).Debug("Logging out user")
if err := bridge.removeIMAPUser(ctx, user, withData); err != nil {
logrus.WithError(err).Error("Failed to remove IMAP user")
}
if err := user.Logout(ctx, withAPI); err != nil {
logrus.WithError(err).Error("Failed to logout user")
}

View File

@ -19,17 +19,44 @@ package bridge
import (
"context"
"fmt"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/proton-bridge/v3/internal"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/sirupsen/logrus"
)
func (bridge *Bridge) handleUserEvent(ctx context.Context, user *user.User, event events.Event) {
func (bridge *Bridge) handleUserEvent(ctx context.Context, user *user.User, event events.Event) error {
switch event := event.(type) {
case events.UserAddressCreated:
if err := bridge.handleUserAddressCreated(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address created event: %w", err)
}
case events.UserAddressEnabled:
if err := bridge.handleUserAddressEnabled(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address enabled event: %w", err)
}
case events.UserAddressDisabled:
if err := bridge.handleUserAddressDisabled(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address disabled event: %w", err)
}
case events.UserAddressDeleted:
if err := bridge.handleUserAddressDeleted(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address deleted event: %w", err)
}
case events.UserRefreshed:
if err := bridge.handleUserRefreshed(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user refreshed event: %w", err)
}
case events.UserDeauth:
bridge.handleUserDeauth(ctx, user)
@ -39,6 +66,102 @@ func (bridge *Bridge) handleUserEvent(ctx context.Context, user *user.User, even
case events.UncategorizedEventError:
bridge.handleUncategorizedErrorEvent(event)
}
return nil
}
func (bridge *Bridge) handleUserAddressCreated(ctx context.Context, user *user.User, event events.UserAddressCreated) error {
if user.GetAddressMode() == vault.CombinedMode {
return nil
}
gluonID, err := bridge.serverManager.AddGluonUser(ctx, user.NewIMAPConnector(event.AddressID), user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add user to IMAP server: %w", err)
}
if err := user.SetGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to set gluon ID: %w", err)
}
return nil
}
func (bridge *Bridge) handleUserAddressEnabled(ctx context.Context, user *user.User, event events.UserAddressEnabled) error {
if user.GetAddressMode() == vault.CombinedMode {
return nil
}
gluonID, err := bridge.serverManager.AddGluonUser(ctx, user.NewIMAPConnector(event.AddressID), user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add user to IMAP server: %w", err)
}
if err := user.SetGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to set gluon ID: %w", err)
}
return nil
}
func (bridge *Bridge) handleUserAddressDisabled(ctx context.Context, user *user.User, event events.UserAddressDisabled) error {
if user.GetAddressMode() == vault.CombinedMode {
return nil
}
gluonID, ok := user.GetGluonID(event.AddressID)
if !ok {
return fmt.Errorf("gluon ID not found for address %s", event.AddressID)
}
if err := bridge.serverManager.RemoveGluonUser(ctx, gluonID); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
if err := user.RemoveGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to remove gluon ID for address: %w", err)
}
return nil
}
func (bridge *Bridge) handleUserAddressDeleted(ctx context.Context, user *user.User, event events.UserAddressDeleted) error {
if user.GetAddressMode() == vault.CombinedMode {
return nil
}
gluonID, ok := user.GetGluonID(event.AddressID)
if !ok {
return fmt.Errorf("gluon ID not found for address %s", event.AddressID)
}
if err := bridge.serverManager.handleRemoveGluonUser(ctx, gluonID); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
if err := user.RemoveGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to remove gluon ID for address: %w", err)
}
return nil
}
func (bridge *Bridge) handleUserRefreshed(ctx context.Context, user *user.User, event events.UserRefreshed) error {
return safe.RLockRet(func() error {
if event.CancelEventPool {
user.CancelSyncAndEventPoll()
}
if err := bridge.removeIMAPUser(ctx, user, true); err != nil {
return fmt.Errorf("failed to remove IMAP user: %w", err)
}
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
return nil
}, bridge.usersLock)
}
func (bridge *Bridge) handleUserDeauth(ctx context.Context, user *user.User) {
@ -48,7 +171,7 @@ func (bridge *Bridge) handleUserDeauth(ctx context.Context, user *user.User) {
}, bridge.usersLock)
}
func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, event events.UserBadEvent) {
func (bridge *Bridge) handleUserBadEvent(_ context.Context, user *user.User, event events.UserBadEvent) {
safe.Lock(func() {
if rerr := bridge.reporter.ReportMessageWithContext("Failed to handle event", reporter.Context{
"user_id": user.ID(),
@ -61,7 +184,12 @@ func (bridge *Bridge) handleUserBadEvent(ctx context.Context, user *user.User, e
logrus.WithError(rerr).Error("Failed to report failed event handling")
}
user.OnBadEvent(ctx)
user.CancelSyncAndEventPoll()
// Disable IMAP user
if err := bridge.removeIMAPUser(context.Background(), user, false); err != nil {
logrus.WithError(err).Error("Failed to remove IMAP user")
}
}, bridge.usersLock)
}

View File

@ -17,13 +17,7 @@
package events
import (
"context"
"fmt"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/watcher"
)
import "fmt"
type Event interface {
fmt.Stringer
@ -34,30 +28,3 @@ type Event interface {
type eventBase struct{}
func (eventBase) _isEvent() {}
type EventPublisher interface {
PublishEvent(ctx context.Context, event Event)
}
type NullEventPublisher struct{}
func (NullEventPublisher) PublishEvent(_ context.Context, _ Event) {}
type Subscription interface {
Add(ofType ...Event) *watcher.Watcher[Event]
Remove(watcher *watcher.Watcher[Event])
}
type NullSubscription struct{}
func (n NullSubscription) Add(ofType ...Event) *watcher.Watcher[Event] {
return watcher.New[Event](&async.NoopPanicHandler{}, ofType...)
}
func (n NullSubscription) Remove(watcher *watcher.Watcher[Event]) {
watcher.Close()
}
func NewNullSubscription() *NullSubscription {
return &NullSubscription{}
}

View File

@ -1,48 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/ProtonMail/proton-bridge/v3/internal/events (interfaces: EventPublisher)
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
reflect "reflect"
events "github.com/ProtonMail/proton-bridge/v3/internal/events"
gomock "github.com/golang/mock/gomock"
)
// MockEventPublisher is a mock of EventPublisher interface.
type MockEventPublisher struct {
ctrl *gomock.Controller
recorder *MockEventPublisherMockRecorder
}
// MockEventPublisherMockRecorder is the mock recorder for MockEventPublisher.
type MockEventPublisherMockRecorder struct {
mock *MockEventPublisher
}
// NewMockEventPublisher creates a new mock instance.
func NewMockEventPublisher(ctrl *gomock.Controller) *MockEventPublisher {
mock := &MockEventPublisher{ctrl: ctrl}
mock.recorder = &MockEventPublisherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockEventPublisher) EXPECT() *MockEventPublisherMockRecorder {
return m.recorder
}
// PublishEvent mocks base method.
func (m *MockEventPublisher) PublishEvent(arg0 context.Context, arg1 events.Event) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "PublishEvent", arg0, arg1)
}
// PublishEvent indicates an expected call of PublishEvent.
func (mr *MockEventPublisherMockRecorder) PublishEvent(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishEvent", reflect.TypeOf((*MockEventPublisher)(nil).PublishEvent), arg0, arg1)
}

View File

@ -37,22 +37,6 @@ func (event IMAPServerStopped) String() string {
return "IMAPServerStopped"
}
type IMAPServerClosed struct {
eventBase
}
func (event IMAPServerClosed) String() string {
return "IMAPServerClosed"
}
type IMAPServerCreated struct {
eventBase
}
func (event IMAPServerCreated) String() string {
return "IMAPServerCreated"
}
type IMAPServerError struct {
eventBase

View File

@ -385,14 +385,6 @@ Status GRPCService::Login(ServerContext *, LoginRequest const *request, Empty *)
app().log().debug(__FUNCTION__);
UsersTab &usersTab = app().mainWindow().usersTab();
loginUsername_ = QString::fromStdString(request->username());
SPUser const& user = usersTab.userTable().userWithUsernameOrEmail(QString::fromStdString(request->username()));
if (user) {
qtProxy_.sendDelayedEvent(newLoginAlreadyLoggedInEvent(user->id()));
return Status::OK;
}
if (usersTab.nextUserUsernamePasswordError()) {
qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::USERNAME_PASSWORD_ERROR, usersTab.usernamePasswordErrorMessage()));
return Status::OK;
@ -834,7 +826,7 @@ bool GRPCService::sendEvent(SPStreamEvent const &event) {
//****************************************************************************************************************************************************
void GRPCService::finishLogin() {
UsersTab &usersTab = app().mainWindow().usersTab();
SPUser user = usersTab.userWithUsernameOrEmail(loginUsername_);
SPUser user = usersTab.userWithUsername(loginUsername_);
bool const alreadyExist = user.get();
if (!user) {
user = randomUser();

View File

@ -272,8 +272,8 @@ bridgepp::SPUser UsersTab::userWithID(QString const &userID) {
/// \return The user with the given username.
/// \return A null pointer if the user is not in the list.
//****************************************************************************************************************************************************
bridgepp::SPUser UsersTab::userWithUsernameOrEmail(QString const &username) {
return users_.userWithUsernameOrEmail(username);
bridgepp::SPUser UsersTab::userWithUsername(QString const &username) {
return users_.userWithUsername(username);
}

View File

@ -38,7 +38,7 @@ public: // member functions.
UsersTab &operator=(UsersTab &&) = delete; ///< Disabled move assignment operator.
UserTable &userTable(); ///< Returns a reference to the user table.
bridgepp::SPUser userWithID(QString const &userID); ///< Get the user with the given ID.
bridgepp::SPUser userWithUsernameOrEmail(QString const &username); ///< Get the user with the given username.
bridgepp::SPUser userWithUsername(QString const &username); ///< Get the user with the given username.
bool nextUserUsernamePasswordError() const; ///< Check if next user login should trigger a username/password error.
bool nextUserFreeUserError() const; ///< Check if next user login should trigger a Free user error.
bool nextUserTFARequired() const; ///< Check if next user login should requires 2FA.

View File

@ -150,16 +150,13 @@ bridgepp::SPUser UserTable::userWithID(QString const &userID) {
//****************************************************************************************************************************************************
/// \param[in] username The username, or any email address attached to the account.
/// \param[in] username The username.
/// \return The user with the given username.
/// \return A null pointer if the user is not in the list.
//****************************************************************************************************************************************************
bridgepp::SPUser UserTable::userWithUsernameOrEmail(QString const &username) {
bridgepp::SPUser UserTable::userWithUsername(QString const &username) {
QList<SPUser>::const_iterator it = std::find_if(users_.constBegin(), users_.constEnd(), [&username](SPUser const &user) -> bool {
if (user->username().compare(username, Qt::CaseInsensitive) == 0) {
return true;
}
return user->addresses().contains(username, Qt::CaseInsensitive);
return user->username() == username;
});
return it == users_.end() ? nullptr : *it;

View File

@ -40,7 +40,7 @@ public: // member functions.
void append(bridgepp::SPUser const &user); ///< Append a user.
bridgepp::SPUser userAtIndex(qint32 index); ///< Return the user at the given index.
bridgepp::SPUser userWithID(QString const &userID); ///< Return the user with a given id.
bridgepp::SPUser userWithUsernameOrEmail(QString const &username); ///< Return the user with a given username.
bridgepp::SPUser userWithUsername(QString const &username); ///< Return the user with a given username.
qint32 indexOfUser(QString const &userID); ///< Return the index of a given User.
void touch(qint32 index); ///< touch the user at a given index (indicates it has been modified).
void touch(QString const& userID); ///< touch the user with the given userID (indicates it has been modified).

View File

@ -20,7 +20,6 @@
#include "QMLBackend.h"
#include "SentryUtils.h"
#include "Settings.h"
#include <bridgepp/CLI/CLIUtils.h>
#include <bridgepp/GRPC/GRPCClient.h>
#include <bridgepp/Exception/Exception.h>
#include <bridgepp/ProcessMonitor.h>
@ -102,23 +101,19 @@ void AppController::onFatalError(Exception const &exception) {
qApp->exit(EXIT_FAILURE);
}
//****************************************************************************************************************************************************
/// \param[in] isCrashing Is the restart triggered by a crash.
//****************************************************************************************************************************************************
void AppController::restart(bool isCrashing) {
if (launcher_.isEmpty()) {
return;
}
if (!launcher_.isEmpty()) {
QProcess p;
log_->info(QString("Restarting - App : %1 - Args : %2").arg(launcher_, launcherArgs_.join(" ")));
QStringList args = launcherArgs_;
if (isCrashing) {
args.append(noWindowFlag);
}
QProcess p;
QStringList args = stripStringParameterFromCommandLine("--session-id", launcherArgs_);
if (isCrashing) {
args.append(noWindowFlag);
p.startDetached(launcher_, args);
p.waitForStarted();
}
log_->info(QString("Restarting - App : %1 - Args : %2").arg(launcher_, args.join(" ")));
p.startDetached(launcher_, args);
p.waitForStarted();
}

View File

@ -37,15 +37,6 @@
using namespace bridgepp;
namespace {
QString const bugReportFile = ":qml/Resources/bug_report_flow.json";
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
@ -98,8 +89,6 @@ void QMLBackend::init(GRPCConfig const &serviceConfig) {
this->setUseSSLForIMAP(sslForIMAP);
this->setUseSSLForSMTP(sslForSMTP);
this->retrieveUserList();
if (!reportFlow_.parse(bugReportFile))
app().log().error(QString("Cannot parse BugReportFlow description file: %1").arg(bugReportFile));
}
@ -221,63 +210,6 @@ bool QMLBackend::areSameFileOrFolder(QUrl const &lhs, QUrl const &rhs) const {
}
//****************************************************************************************************************************************************
/// \param[in] categoryId The id of the bug category.
/// \return Set of question for this category.
//****************************************************************************************************************************************************
QString QMLBackend::getBugCategory(quint8 categoryId) const {
return reportFlow_.getCategory(categoryId);
}
//****************************************************************************************************************************************************
/// \param[in] categoryId The id of the bug category.
/// \return Set of question for this category.
//****************************************************************************************************************************************************
QVariantList QMLBackend::getQuestionSet(quint8 categoryId) const {
QVariantList list = reportFlow_.questionSet(categoryId);
if (list.count() == 0)
app().log().error(QString("Bug category not found (id: %1)").arg(categoryId));
return list;
};
//****************************************************************************************************************************************************
/// \param[in] questionId The id of the question.
/// \param[in] answer The answer to that question.
//****************************************************************************************************************************************************
void QMLBackend::setQuestionAnswer(quint8 questionId, QString const &answer) {
if (!reportFlow_.setAnswer(questionId, answer))
app().log().error(QString("Bug Report Question not found (id: %1)").arg(questionId));
}
//****************************************************************************************************************************************************
/// \param[in] questionId The id of the question.
/// \return answer for the given question.
//****************************************************************************************************************************************************
QString QMLBackend::getQuestionAnswer(quint8 questionId) const {
return reportFlow_.getAnswer(questionId);
}
//****************************************************************************************************************************************************
/// \param[in] categoryId The id of the question set.
/// \return concatenate answers for set of questions.
//****************************************************************************************************************************************************
QString QMLBackend::collectAnswers(quint8 categoryId) const {
return reportFlow_.collectAnswers(categoryId);
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void QMLBackend::clearAnswers() {
reportFlow_.clearAnswers();
}
//****************************************************************************************************************************************************
/// \return The value for the 'showOnStartup' property.
//****************************************************************************************************************************************************
@ -649,21 +581,6 @@ QStringList QMLBackend::availableKeychain() const {
}
//****************************************************************************************************************************************************
/// \return The value for the 'bugCategories' property.
//****************************************************************************************************************************************************
QVariantList QMLBackend::bugCategories() const {
return reportFlow_.categories();
}
//****************************************************************************************************************************************************
/// \return The value for the 'bugQuestions' property.
//****************************************************************************************************************************************************
QVariantList QMLBackend::bugQuestions() const {
return reportFlow_.questions();
}
//****************************************************************************************************************************************************
/// \return The value for the 'currentKeychain' property.
//****************************************************************************************************************************************************
@ -928,15 +845,14 @@ void QMLBackend::triggerReset() const {
//****************************************************************************************************************************************************
/// \param[in] category The category of the bug.
/// \param[in] description The description of the bug.
/// \param[in] address The email address.
/// \param[in] emailClient The email client.
/// \param[in] includeLogs Should the logs be included in the report.
//****************************************************************************************************************************************************
void QMLBackend::reportBug(QString const &category, QString const &description, QString const &address, QString const &emailClient, bool includeLogs) const {
void QMLBackend::reportBug(QString const &description, QString const &address, QString const &emailClient, bool includeLogs) const {
HANDLE_EXCEPTION(
app().grpc().reportBug(category, description, address, emailClient, includeLogs);
app().grpc().reportBug(description, address, emailClient, includeLogs);
)
}
@ -1265,7 +1181,6 @@ void QMLBackend::connectGrpcEvents() {
connect(client, &GRPCClient::resetFinished, this, &QMLBackend::onResetFinished);
connect(client, &GRPCClient::reportBugFinished, this, &QMLBackend::reportBugFinished);
connect(client, &GRPCClient::reportBugSuccess, this, &QMLBackend::bugReportSendSuccess);
connect(client, &GRPCClient::reportBugFallback, this, &QMLBackend::bugReportSendFallback);
connect(client, &GRPCClient::reportBugError, this, &QMLBackend::bugReportSendError);
connect(client, &GRPCClient::showMainWindow, [&]() { this->showMainWindow("gRPC showMainWindow event"); });

View File

@ -24,7 +24,6 @@
#include "BuildConfig.h"
#include "TrayIcon.h"
#include "UserList.h"
#include <bridgepp/BugReportFlow/BugReportFlow.h>
#include <bridgepp/GRPC/GRPCClient.h>
#include <bridgepp/GRPC/GRPCUtils.h>
#include <bridgepp/Worker/Overseer.h>
@ -52,18 +51,12 @@ public: // member functions.
void showSettings(QString const &reason); ///< Show the settings page.
void selectUser(QString const &userID, bool forceShowWindow, QString const &reason); ///< Select the user and display its account details (or login screen).
// invocable methods can be called from QML. They generally return a value, which slots cannot do.
// invokable methods can be called from QML. They generally return a value, which slots cannot do.
Q_INVOKABLE static QString buildYear(); ///< Return the application build year.
Q_INVOKABLE QPoint getCursorPos() const; ///< Retrieve the cursor position.
Q_INVOKABLE bool isPortFree(int port) const; ///< Check if a given network port is available.
Q_INVOKABLE QString nativePath(QUrl const &url) const; ///< Retrieve the native path of a local URL.
Q_INVOKABLE bool areSameFileOrFolder(QUrl const &lhs, QUrl const &rhs) const; ///< Check if two local URL point to the same file.
Q_INVOKABLE QString getBugCategory(quint8 categoryId) const; ///< Get a Category name.
Q_INVOKABLE QVariantList getQuestionSet(quint8 categoryId) const; ///< Retrieve the set of question for a given bug category.
Q_INVOKABLE void setQuestionAnswer(quint8 questionId, QString const &answer); ///< Feed an answer for a given question.
Q_INVOKABLE QString getQuestionAnswer(quint8 questionId) const; ///< Get the answer for a given question.
Q_INVOKABLE QString collectAnswers(quint8 categoryId) const; ///< Collect answer for a given set of questions.
Q_INVOKABLE void clearAnswers(); ///< Clear all collected answers.
public: // Qt/QML properties. Note that the NOTIFY-er signal is required even for read-only properties (QML warning otherwise)
Q_PROPERTY(bool showOnStartup READ showOnStartup NOTIFY showOnStartupChanged)
@ -94,8 +87,6 @@ public: // Qt/QML properties. Note that the NOTIFY-er signal is required even fo
Q_PROPERTY(QString currentEmailClient READ currentEmailClient NOTIFY currentEmailClientChanged)
Q_PROPERTY(QStringList availableKeychain READ availableKeychain NOTIFY availableKeychainChanged)
Q_PROPERTY(QString currentKeychain READ currentKeychain NOTIFY currentKeychainChanged)
Q_PROPERTY(QVariantList bugCategories READ bugCategories NOTIFY bugCategoriesChanged)
Q_PROPERTY(QVariantList bugQuestions READ bugQuestions NOTIFY bugQuestionsChanged)
Q_PROPERTY(UserList *users MEMBER users_ NOTIFY usersChanged)
Q_PROPERTY(bool dockIconVisible READ dockIconVisible WRITE setDockIconVisible NOTIFY dockIconVisibleChanged)
@ -133,8 +124,6 @@ public: // Qt/QML properties. Note that the NOTIFY-er signal is required even fo
QString currentEmailClient() const; ///< Getter for the 'currentEmail' property.
QStringList availableKeychain() const; ///< Getter for the 'availableKeychain' property.
QString currentKeychain() const; ///< Getter for the 'currentKeychain' property.
QVariantList bugCategories() const; ///< Getter for the 'bugCategories' property.
QVariantList bugQuestions() const; ///< Getter for the 'bugQuestions' property.
void setDockIconVisible(bool visible); ///< Setter for the 'dockIconVisible' property.
bool dockIconVisible() const;; ///< Getter for the 'dockIconVisible' property.
@ -164,8 +153,6 @@ signals: // Signal used by the Qt property system. Many of them are unused but r
void tagChanged(QString const &tag); ///<Signal for the change of the 'tag' property.
void currentEmailClientChanged(QString const &email); ///<Signal for the change of the 'currentEmailClient' property.
void currentKeychainChanged(QString const &keychain); ///<Signal for the change of the 'currentKeychain' property.
void bugCategoriesChanged(QVariantList const &bugCategories); ///<Signal for the change of the 'bugCategories' property.
void bugQuestionsChanged(QVariantList const &bugQuestions); ///<Signal for the change of the 'bugQuestions' property.
void availableKeychainChanged(QStringList const &keychains); ///<Signal for the change of the 'availableKeychain' property.
void hostnameChanged(QString const &hostname); ///<Signal for the change of the 'hostname' property.
void isAutostartOnChanged(bool value); ///<Signal for the change of the 'isAutostartOn' property.
@ -194,7 +181,7 @@ public slots: // slot for signals received from QML -> To be forwarded to Bridge
void checkUpdates() const; ///< Slot for the update check.
void installUpdate() const; ///< Slot for the update install.
void triggerReset() const; ///< Slot for the triggering of reset.
void reportBug(QString const &category, QString const &description, QString const &address, QString const &emailClient, bool includeLogs) const; ///< Slot for the bug report.
void reportBug(QString const &description, QString const &address, QString const &emailClient, bool includeLogs) const; ///< Slot for the bug report.
void exportTLSCertificates() const; ///< Slot for the export of the TLS certificates.
void onResetFinished(); ///< Slot for the reset finish signal.
void onVersionChanged(); ///< Slot for the version change signal.
@ -266,7 +253,6 @@ signals: // Signals received from the Go backend, to be forwarded to QML
void resetFinished(); ///< Signal for the 'resetFinished' gRPC stream event.
void reportBugFinished(); ///< Signal for the 'reportBugFinished' gRPC stream event.
void bugReportSendSuccess(); ///< Signal for the 'bugReportSendSuccess' gRPC stream event.
void bugReportSendFallback(); ///< Signal for the 'bugReportSendFallback' gRPC stream event.
void bugReportSendError(); ///< Signal for the 'bugReportSendError' gRPC stream event.
void showMainWindow(); ///< Signal for the 'showMainWindow' gRPC stream event.
void hideMainWindow(); ///< Signal for the 'hideMainWindow' gRPC stream event.
@ -298,7 +284,6 @@ private: // data members
bool isInternetOn_ { true }; ///< Does bridge consider internet as on?
QList<QString> badEventDisplayQueue_; ///< THe queue for displaying 'bad event feedback request dialog'.
std::unique_ptr<TrayIcon> trayIcon_; ///< The tray icon for the application.
bridgepp::BugReportFlow reportFlow_; ///< The bug report flow.
friend class AppController;
};

View File

@ -6,11 +6,7 @@
<file>qml/Banner.qml</file>
<file>qml/Bridge.qml</file>
<file>qml/bridgeqml.qmlproject</file>
<file>qml/BugCategoryView.qml</file>
<file>qml/BugQuestionView.qml</file>
<file>qml/BugReportFlow.qml</file>
<file>qml/BugReportView.qml</file>
<file>qml/CategoryItem.qml</file>
<file>qml/Configuration.qml</file>
<file>qml/ConfigurationItem.qml</file>
<file>qml/ContentWrapper.qml</file>
@ -36,7 +32,6 @@
<file>qml/icons/ic-eye-slash.svg</file>
<file>qml/icons/ic-eye.svg</file>
<file>qml/icons/ic-illustrative-view-html-code.svg</file>
<file>qml/icons/ic-info-circle.svg</file>
<file>qml/icons/ic-info-circle-filled.svg</file>
<file>qml/icons/ic-info.svg</file>
<file>qml/icons/ic-microsoft-outlook.svg</file>
@ -100,8 +95,6 @@
<file>qml/Proton/TextArea.qml</file>
<file>qml/Proton/TextField.qml</file>
<file>qml/Proton/Toggle.qml</file>
<file>qml/QuestionItem.qml</file>
<file>qml/Resources/bug_report_flow.json</file>
<file>qml/SettingsItem.qml</file>
<file>qml/SettingsView.qml</file>
<file>qml/SetupGuide.qml</file>

View File

@ -192,8 +192,6 @@ TrayIcon::TrayIcon()
if (!onLinux()) { // we disable this on linux because of a Qt bug that causes the signal to be emitted for other apps (GODT-2750)
connect(this, &TrayIcon::messageClicked, []() { app().backend().showMainWindow("tray icon popup notification clicked"); });
}
this->setIcon();
this->show();
// TrayIcon does not expose its screen, so we connect relevant screen events to our DPI change handler.

View File

@ -63,7 +63,7 @@ BRIDGE_BUILD_ENV= ${BRIDGE_BUILD_ENV:-"dev"}
git submodule update --init --recursive ${VCPKG_ROOT}
check_exit "Failed to initialize vcpkg as a submodule."
echo submodule updated
echo submodule udpated
VCPKG_EXE="${VCPKG_ROOT}/vcpkg"
VCPKG_BOOTSTRAP="${VCPKG_ROOT}/bootstrap-vcpkg.sh"

View File

@ -137,21 +137,12 @@ bool checkSingleInstance(QLockFile &lock) {
if (lock.getLockInfo(&pid, &hostname, &appName)) {
details = QString("(PID : %1 - Host : %2 - App : %3)").arg(pid).arg(hostname, appName);
}
if (lock.error() == QLockFile::LockFailedError) {
// This happens if a stale lock file exists and another process uses that PID.
// Try removing the stale file, which will fail if a real process is holding a
// file-level lock. A false error is more problematic than not locking properly
// on corner-case systems.
if (lock.removeStaleLockFile() && lock.tryLock()) {
app().log().info("Removed stale lock file");
app().log().info(QString("lock file created %1").arg(lock.fileName()));
return true;
}
}
app().log().error(QString("Instance already exists %1 %2").arg(lock.fileName(), details));
return false;
} else {
app().log().info(QString("lock file created %1").arg(lock.fileName()));
}
app().log().info(QString("lock file created %1").arg(lock.fileName()));
return true;
}

View File

@ -1,252 +1,251 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
Item {
id: root
enum ViewType {
SmallView,
LargeView
}
property ColorScheme colorScheme
property var user
property var _spacing: 12 * ProtonStyle.px
property ColorScheme colorScheme
property color progressColor: {
if (!root.enabled)
return root.colorScheme.text_weak;
if (root.type === AccountDelegate.SmallView)
return root.colorScheme.text_weak;
if (root.user && root.user.isSyncing)
return root.colorScheme.text_weak;
if (root.progressRatio < .50)
return root.colorScheme.signal_success;
if (root.progressRatio < .75)
return root.colorScheme.signal_warning;
return root.colorScheme.signal_danger;
property color progressColor : {
if (!root.enabled) return root.colorScheme.text_weak
if (root.type == AccountDelegate.SmallView) return root.colorScheme.text_weak
if (root.user && root.user.isSyncing) return root.colorScheme.text_weak
if (root.progressRatio < .50) return root.colorScheme.signal_success
if (root.progressRatio < .75) return root.colorScheme.signal_warning
return root.colorScheme.signal_danger
}
property real progressRatio: {
if (!root.user)
return 0;
return root.user.isSyncing ? root.user.syncProgress : reasonableFraction(root.user.usedBytes, root.user.totalBytes);
return 0
return root.user.isSyncing ? root.user.syncProgress : reasonableFraction(root.user.usedBytes, root.user.totalBytes)
}
property string totalSpace: root.spaceWithUnits(root.user ? root.reasonableBytes(root.user.totalBytes) : 0)
property var type: AccountDelegate.SmallView
property string usedSpace: root.spaceWithUnits(root.user ? root.reasonableBytes(root.user.usedBytes) : 0)
property var user
function reasonableFraction(used, total){
var usedSafe = root.reasonableBytes(used)
var totalSafe = root.reasonableBytes(total)
if (totalSafe == 0 || usedSafe == 0) return 0
if (totalSafe <= usedSafe) return 1
return usedSafe / totalSafe
}
function reasonableBytes(bytes){
var safeBytes = bytes+0
if (safeBytes != bytes) return 0
if (safeBytes < 0) return 0
return Math.ceil(safeBytes)
}
function spaceWithUnits(bytes){
if (bytes*1 !== bytes || bytes == 0 ) return "0 kB"
var units = ['B',"kB", "MB", "GB", "TB"];
var i = parseInt(Math.floor(Math.log(bytes)/Math.log(1024)));
return Math.round(bytes*10 / Math.pow(1024, i))/10 + " " + units[i]
}
function primaryEmail() {
return root.user ? root.user.primaryEmailOrUsername() : "";
}
function reasonableBytes(bytes) {
const safeBytes = bytes + 0;
if (safeBytes !== bytes)
return 0;
if (safeBytes < 0)
return 0;
return Math.ceil(safeBytes);
}
function reasonableFraction(used, total) {
const usedSafe = root.reasonableBytes(used);
const totalSafe = root.reasonableBytes(total);
if (totalSafe === 0 || usedSafe === 0)
return 0;
if (totalSafe <= usedSafe)
return 1;
return usedSafe / totalSafe;
}
function spaceWithUnits(bytes) {
if (bytes * 1 !== bytes || bytes === 0)
return "0 kB";
const units = ['B', "kB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes * 10 / Math.pow(1024, i)) / 10 + " " + units[i];
return root.user ? root.user.primaryEmailOrUsername() : ""
}
// width expected to be set by parent object
implicitHeight: children[0].implicitHeight
implicitHeight : children[0].implicitHeight
enum ViewType{
SmallView, LargeView
}
property var type : AccountDelegate.SmallView
RowLayout {
spacing: root._spacing
anchors {
top: root.top
left: root.left
right: root.right
top: root.top
}
Rectangle {
id: avatar
Layout.fillHeight: true
Layout.preferredWidth: height
color: root.colorScheme.background_avatar
radius: ProtonStyle.avatar_radius
color: root.colorScheme.background_avatar
Label {
anchors.fill: parent
color: "#FFFFFF"
colorScheme: root.colorScheme
font.weight: Font.Normal
horizontalAlignment: Qt.AlignHCenter
text: root.user ? root.user.avatarText.toUpperCase() : ""
anchors.fill: parent
text: root.user ? root.user.avatarText.toUpperCase(): ""
type: {
switch (root.type) {
case AccountDelegate.SmallView:
return Label.Body;
case AccountDelegate.LargeView:
return Label.Title;
case AccountDelegate.SmallView: return Label.Body
case AccountDelegate.LargeView: return Label.Title
}
}
font.weight: Font.Normal
color: "#FFFFFF"
horizontalAlignment: Qt.AlignHCenter
verticalAlignment: Qt.AlignVCenter
}
}
ColumnLayout {
id: account
Layout.fillHeight: true
Layout.fillWidth: true
spacing: 0
Label {
id: labelEmail
Layout.maximumWidth: root.width - (root._spacing + avatar.width)
colorScheme: root.colorScheme
elide: Text.ElideMiddle
text: primaryEmail()
type: {
switch (root.type) {
case AccountDelegate.SmallView:
return Label.Body;
case AccountDelegate.LargeView:
return Label.Title;
case AccountDelegate.SmallView: return Label.Body
case AccountDelegate.LargeView: return Label.Title
}
}
elide: Text.ElideMiddle
MouseArea {
id: labelArea
anchors.fill: parent
anchors.fill:parent
hoverEnabled: true
}
ToolTip {
id: toolTipEmail
delay: 1000
text: primaryEmail()
visible: labelArea.containsMouse && labelEmail.truncated
text: primaryEmail()
delay: 1000
background: Rectangle {
border.color: root.colorScheme.background_strong
color: root.colorScheme.background_norm
}
contentItem: Text {
color: root.colorScheme.text_norm
text: toolTipEmail.text
}
}
}
Item {
implicitHeight: root.type === AccountDelegate.LargeView ? 6 * ProtonStyle.px : 0
}
Item { implicitHeight: root.type == AccountDelegate.LargeView ? 6 * ProtonStyle.px : 0 }
RowLayout {
spacing: 0
Label {
color: root.progressColor
colorScheme: root.colorScheme
text: {
if (!root.user)
return qsTr("Signed out");
return qsTr("Signed out")
switch (root.user.state) {
case EUserState.SignedOut:
default:
return qsTr("Signed out");
return qsTr("Signed out")
case EUserState.Locked:
return qsTr("Connecting") + dotsTimer.dots;
return qsTr("Connecting") + dotsTimer.dots
case EUserState.Connected:
if (root.user.isSyncing)
return qsTr("Synchronizing (%1%)").arg(Math.floor(root.user.syncProgress * 100)) + dotsTimer.dots;
return qsTr("Synchronizing (%1%)").arg(Math.floor(root.user.syncProgress * 100)) + dotsTimer.dots
else
return root.usedSpace;
}
}
type: {
switch (root.type) {
case AccountDelegate.SmallView:
return Label.Caption;
case AccountDelegate.LargeView:
return Label.Body;
return root.usedSpace
}
}
Timer {
// dots animation while connecting & syncing.
id: dotsTimer
Timer { // dots animation while connecting & syncing.
id:dotsTimer
property string dots: ""
interval: 500
repeat: true
interval: 500;
repeat: true;
running: (root.user != null) && ((root.user.state === EUserState.Locked) || (root.user.isSyncing))
onRunningChanged: {
dots = "";
}
onTriggered: {
dots += ".";
dots += "."
if (dots.length > 3)
dots = "";
dots = ""
}
onRunningChanged: {
dots = ""
}
}
}
Label {
color: root.colorScheme.text_weak
colorScheme: root.colorScheme
text: root.user && root.user.state === EUserState.Connected && !root.user.isSyncing ? " / " + root.totalSpace : ""
color: root.progressColor
type: {
switch (root.type) {
case AccountDelegate.SmallView:
return Label.Caption;
case AccountDelegate.LargeView:
return Label.Body;
case AccountDelegate.SmallView: return Label.Caption
case AccountDelegate.LargeView: return Label.Body
}
}
}
Label {
colorScheme: root.colorScheme
text: root.user && root.user.state == EUserState.Connected && !root.user.isSyncing ? " / " + root.totalSpace : ""
color: root.colorScheme.text_weak
type: {
switch (root.type) {
case AccountDelegate.SmallView: return Label.Caption
case AccountDelegate.LargeView: return Label.Body
}
}
}
}
Item {
implicitHeight: root.type === AccountDelegate.LargeView ? 3 * ProtonStyle.px : 0
}
Item { implicitHeight: root.type == AccountDelegate.LargeView ? 3 * ProtonStyle.px : 0 }
Rectangle {
id: progress_bar
color: root.colorScheme.border_weak
visible: root.user ? root.type == AccountDelegate.LargeView : false
width: 140 * ProtonStyle.px
height: 4 * ProtonStyle.px
radius: ProtonStyle.progress_bar_radius
visible: root.user ? root.type === AccountDelegate.LargeView : false
width: 140 * ProtonStyle.px
color: root.colorScheme.border_weak
Rectangle {
id: progress_bar_filled
color: root.progressColor
radius: ProtonStyle.progress_bar_radius
visible: root.user ? parent.visible && (root.user.state === EUserState.Connected) : false
width: Math.min(1, Math.max(0.02, root.progressRatio)) * parent.width
color: root.progressColor
visible: root.user ? parent.visible && (root.user.state == EUserState.Connected): false
anchors {
bottom: parent.bottom
left: parent.left
top: parent.top
top : parent.top
bottom : parent.bottom
left : parent.left
}
width: Math.min(1,Math.max(0.02,root.progressRatio)) * parent.width
}
}
}
Item {
Layout.fillWidth: true
}

View File

@ -1,36 +1,43 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
Item {
id: root
property bool _connected: root.user ? root.user.state === EUserState.Connected : false
property int _contentWidth: 640
property int _detailsMargin: 25
property int _lineThickness: 1
property int _spacing: 20
property int _topMargin: 32
property ColorScheme colorScheme
property var notifications
property var user
signal showSetupGuide(var user, string address)
signal showSignIn
signal showSetupGuide(var user, string address)
property int _contentWidth: 640
property int _topMargin: 32
property int _detailsMargin: 25
property int _spacing: 20
property int _lineThickness: 1
property bool _connected: root.user ? root.user.state === EUserState.Connected : false
Rectangle {
anchors.fill: parent
color: root.colorScheme.background_weak
@ -38,7 +45,6 @@ Item {
ScrollView {
id: scrollView
anchors.fill: parent
Component.onCompleted: contentItem.boundsBehavior = Flickable.StopAtBounds
ColumnLayout {
@ -48,16 +54,16 @@ Item {
Rectangle {
id: topArea
Layout.fillWidth: true
clip: true
color: root.colorScheme.background_norm
clip: true
Layout.fillWidth: true
implicitHeight: childrenRect.height
ColumnLayout {
id: topLayout
width: _contentWidth
anchors.horizontalCenter: parent.horizontalCenter
spacing: _spacing
width: _contentWidth
RowLayout {
// account delegate with action buttons
@ -67,82 +73,83 @@ Item {
AccountDelegate {
Layout.fillWidth: true
colorScheme: root.colorScheme
enabled: _connected
type: AccountDelegate.LargeView
user: root.user
type: AccountDelegate.LargeView
enabled: _connected
}
Button {
Layout.alignment: Qt.AlignTop
colorScheme: root.colorScheme
secondary: true
text: qsTr("Sign out")
secondary: true
visible: _connected
onClicked: {
if (!root.user)
return;
root.user.logout();
}
}
Button {
Layout.alignment: Qt.AlignTop
colorScheme: root.colorScheme
secondary: true
text: qsTr("Sign in")
secondary: true
visible: root.user ? (root.user.state === EUserState.SignedOut) : false
onClicked: {
if (!root.user)
return;
root.showSignIn();
}
}
Button {
Layout.alignment: Qt.AlignTop
colorScheme: root.colorScheme
icon.source: "/qml/icons/ic-trash.svg"
secondary: true
visible: root.user ? root.user.state !== EUserState.Locked : false
onClicked: {
if (!root.user)
return;
root.notifications.askDeleteAccount(root.user);
}
visible: root.user ? root.user.state !== EUserState.Locked : false
}
}
Rectangle {
Layout.fillWidth: true
color: root.colorScheme.border_weak
height: root._lineThickness
color: root.colorScheme.border_weak
}
SettingsItem {
Layout.fillWidth: true
actionText: qsTr("Configure")
colorScheme: root.colorScheme
description: qsTr("Using the mailbox details below (re)configure your client.")
showSeparator: splitMode.visible
text: qsTr("Email clients")
actionText: qsTr("Configure")
description: qsTr("Using the mailbox details below (re)configure your client.")
type: SettingsItem.Button
visible: _connected && (!root.user.splitMode) || (root.user.addresses.length === 1)
showSeparator: splitMode.visible
onClicked: {
if (!root.user)
return;
root.showSetupGuide(root.user, user.addresses[0]);
}
Layout.fillWidth: true
}
SettingsItem {
id: splitMode
Layout.fillWidth: true
checked: root.user ? root.user.splitMode : false
colorScheme: root.colorScheme
description: qsTr("Setup multiple email addresses individually.")
showSeparator: addressSelector.visible
text: qsTr("Split addresses")
description: qsTr("Setup multiple email addresses individually.")
type: SettingsItem.Toggle
checked: root.user ? root.user.splitMode : false
visible: _connected && root.user.addresses.length > 1
showSeparator: addressSelector.visible
onClicked: {
if (!splitMode.checked) {
root.notifications.askEnableSplitMode(user);
@ -151,23 +158,26 @@ Item {
root.user.toggleSplitMode(!splitMode.checked);
}
}
}
RowLayout {
Layout.bottomMargin: _spacing
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
Layout.bottomMargin: _spacing
visible: _connected && root.user.splitMode
ComboBox {
id: addressSelector
Layout.fillWidth: true
colorScheme: root.colorScheme
Layout.fillWidth: true
model: root.user ? root.user.addresses : null
}
Button {
colorScheme: root.colorScheme
secondary: true
text: qsTr("Configure")
secondary: true
onClicked: {
if (!root.user)
return;
@ -175,23 +185,25 @@ Item {
}
}
}
Rectangle {
height: 0
} // just for some extra space before separator
}
}
Rectangle {
id: bottomArea
Layout.fillWidth: true
color: root.colorScheme.background_weak
implicitHeight: bottomLayout.implicitHeight
color: root.colorScheme.background_weak
ColumnLayout {
id: bottomLayout
width: _contentWidth
anchors.horizontalCenter: parent.horizontalCenter
spacing: _spacing
visible: _connected
width: _contentWidth
Label {
Layout.topMargin: _detailsMargin
@ -199,34 +211,35 @@ Item {
text: qsTr("Mailbox details")
type: Label.Body_semibold
}
RowLayout {
id: configuration
spacing: _spacing
Layout.fillWidth: true
Layout.fillHeight: true
property string currentAddress: addressSelector.displayText
Layout.fillHeight: true
Layout.fillWidth: true
spacing: _spacing
Configuration {
Layout.fillWidth: true
colorScheme: root.colorScheme
title: qsTr("IMAP")
hostname: Backend.hostname
port: Backend.imapPort.toString()
username: configuration.currentAddress
password: root.user ? root.user.password : ""
security: Backend.useSSLForIMAP ? "SSL" : "STARTTLS"
}
Configuration {
Layout.fillWidth: true
colorScheme: root.colorScheme
hostname: Backend.hostname
password: root.user ? root.user.password : ""
port: Backend.imapPort.toString()
security: Backend.useSSLForIMAP ? "SSL" : "STARTTLS"
title: qsTr("IMAP")
username: configuration.currentAddress
}
Configuration {
Layout.fillWidth: true
colorScheme: root.colorScheme
hostname: Backend.hostname
password: root.user ? root.user.password : ""
port: Backend.smtpPort.toString()
security: Backend.useSSLForSMTP ? "SSL" : "STARTTLS"
title: qsTr("SMTP")
hostname: Backend.hostname
port: Backend.smtpPort.toString()
username: configuration.currentAddress
password: root.user ? root.user.password : ""
security: Backend.useSSLForSMTP ? "SSL" : "STARTTLS"
}
}
}

View File

@ -1,19 +1,25 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
import Notifications
@ -21,28 +27,34 @@ Popup {
id: root
property ColorScheme colorScheme
property var mainWindow
property Notification notification
property var mainWindow
topMargin: 37
leftMargin: (mainWindow.width - root.implicitWidth)/2
implicitHeight: contentLayout.implicitHeight + contentLayout.anchors.topMargin + contentLayout.anchors.bottomMargin
implicitWidth: 600 // contentLayout.implicitWidth + contentLayout.anchors.leftMargin + contentLayout.anchors.rightMargin
leftMargin: (mainWindow.width - root.implicitWidth) / 2
modal: false
popupType: ApplicationWindow.PopupType.Banner
shouldShow: notification ? (notification.active && !notification.dismissed) : false
topMargin: 37
modal: false
Action {
id: defaultDismissAction
text: qsTr("OK")
text: qsTr("OK")
onTriggered: {
if (!root.notification) {
return;
return
}
root.notification.dismissed = true;
root.notification.dismissed = true
}
}
RowLayout {
id: contentLayout
anchors.fill: parent
@ -51,148 +63,170 @@ Popup {
Item {
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
implicitHeight: children[1].implicitHeight + children[1].anchors.topMargin + children[1].anchors.bottomMargin
implicitWidth: children[1].implicitWidth + children[1].anchors.leftMargin + children[1].anchors.rightMargin
Rectangle {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.top: parent.top
width: parent.width + 10
radius: ProtonStyle.banner_radius
color: {
if (!root.notification) {
return "transparent";
return "transparent"
}
switch (root.notification.type) {
case Notification.NotificationType.Info:
return root.colorScheme.signal_info;
case Notification.NotificationType.Success:
return root.colorScheme.signal_success;
case Notification.NotificationType.Warning:
return root.colorScheme.signal_warning;
case Notification.NotificationType.Danger:
return root.colorScheme.signal_danger;
case Notification.NotificationType.Info:
return root.colorScheme.signal_info
case Notification.NotificationType.Success:
return root.colorScheme.signal_success
case Notification.NotificationType.Warning:
return root.colorScheme.signal_warning
case Notification.NotificationType.Danger:
return root.colorScheme.signal_danger
}
}
radius: ProtonStyle.banner_radius
width: parent.width + 10
}
RowLayout {
anchors.bottomMargin: 14
anchors.fill: parent
anchors.leftMargin: 16
anchors.topMargin: 14
anchors.bottomMargin: 14
anchors.leftMargin: 16
spacing: 8
ColorImage {
color: root.colorScheme.text_invert
width: 24
height: 24
sourceSize.width: 24
sourceSize.height: 24
Layout.preferredHeight: 24
Layout.preferredWidth: 24
color: root.colorScheme.text_invert
height: 24
source: {
if (!root.notification) {
return "";
return ""
}
switch (root.notification.type) {
case Notification.NotificationType.Info:
return "/qml/icons/ic-info-circle-filled.svg";
case Notification.NotificationType.Success:
return "/qml/icons/ic-info-circle-filled.svg";
case Notification.NotificationType.Warning:
return "/qml/icons/ic-exclamation-circle-filled.svg";
case Notification.NotificationType.Danger:
return "/qml/icons/ic-exclamation-circle-filled.svg";
case Notification.NotificationType.Info:
return "/qml/icons/ic-info-circle-filled.svg"
case Notification.NotificationType.Success:
return "/qml/icons/ic-info-circle-filled.svg"
case Notification.NotificationType.Warning:
return "/qml/icons/ic-exclamation-circle-filled.svg"
case Notification.NotificationType.Danger:
return "/qml/icons/ic-exclamation-circle-filled.svg"
}
}
sourceSize.height: 24
sourceSize.width: 24
width: 24
}
Label {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.leftMargin: 16
color: root.colorScheme.text_invert
colorScheme: root.colorScheme
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: 16
color: root.colorScheme.text_invert
text: root.notification ? root.notification.description : ""
wrapMode: Text.WordWrap
}
}
}
Rectangle {
Layout.fillHeight: true
width: 1
color: {
if (!root.notification) {
return "transparent";
return "transparent"
}
switch (root.notification.type) {
case Notification.NotificationType.Info:
return root.colorScheme.signal_info_active;
case Notification.NotificationType.Success:
return root.colorScheme.signal_success_active;
case Notification.NotificationType.Warning:
return root.colorScheme.signal_warning_active;
case Notification.NotificationType.Danger:
return root.colorScheme.signal_danger_active;
case Notification.NotificationType.Info:
return root.colorScheme.signal_info_active
case Notification.NotificationType.Success:
return root.colorScheme.signal_success_active
case Notification.NotificationType.Warning:
return root.colorScheme.signal_warning_active
case Notification.NotificationType.Danger:
return root.colorScheme.signal_danger_active
}
}
width: 1
}
Button {
id: actionButton
Layout.fillHeight: true
action: (root.notification && root.notification.action.length > 0) ? root.notification.action[0] : defaultDismissAction
colorScheme: root.colorScheme
Layout.fillHeight: true
id: actionButton
action: (root.notification && root.notification.action.length > 0) ? root.notification.action[0] : defaultDismissAction
background: Item {
clip: true
Rectangle {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.top: parent.top
width: parent.width + 10
radius: ProtonStyle.banner_radius
color: {
if (!root.notification) {
return "transparent";
return "transparent"
}
let norm;
let hover;
let active;
var norm
var hover
var active
switch (root.notification.type) {
case Notification.NotificationType.Info:
norm = root.colorScheme.signal_info;
hover = root.colorScheme.signal_info_hover;
active = root.colorScheme.signal_info_active;
case Notification.NotificationType.Info:
norm = root.colorScheme.signal_info
hover = root.colorScheme.signal_info_hover
active = root.colorScheme.signal_info_active
break;
case Notification.NotificationType.Success:
norm = root.colorScheme.signal_success;
hover = root.colorScheme.signal_success_hover;
active = root.colorScheme.signal_success_active;
case Notification.NotificationType.Success:
norm = root.colorScheme.signal_success
hover = root.colorScheme.signal_success_hover
active = root.colorScheme.signal_success_active
break;
case Notification.NotificationType.Warning:
norm = root.colorScheme.signal_warning;
hover = root.colorScheme.signal_warning_hover;
active = root.colorScheme.signal_warning_active;
case Notification.NotificationType.Warning:
norm = root.colorScheme.signal_warning
hover = root.colorScheme.signal_warning_hover
active = root.colorScheme.signal_warning_active
break;
case Notification.NotificationType.Danger:
norm = root.colorScheme.signal_danger;
hover = root.colorScheme.signal_danger_hover;
active = root.colorScheme.signal_danger_active;
case Notification.NotificationType.Danger:
norm = root.colorScheme.signal_danger
hover = root.colorScheme.signal_danger_hover
active = root.colorScheme.signal_danger_active
break;
}
if (actionButton.down) {
return active;
return active
}
if (actionButton.enabled && (actionButton.highlighted || actionButton.hovered || actionButton.checked)) {
return hover;
return hover
}
if (actionButton.loading) {
return hover;
return hover
}
return norm;
return norm
}
radius: ProtonStyle.banner_radius
width: parent.width + 10
}
}
}

View File

@ -1,112 +1,129 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Window
import Qt.labs.platform
import Proton
import Notifications
QtObject {
id: root
property MainWindow _mainWindow: MainWindow {
id: mainWindow
notifications: root._notifications
title: root.title
visible: false
onVisibleChanged: {
Backend.dockIconVisible = visible;
}
Connections {
function onColorSchemeNameChanged(scheme) {
root.setColorScheme();
}
function onDiskCacheUnavailable() {
mainWindow.showAndRise();
}
function onHideMainWindow() {
mainWindow.hide();
}
target: Backend
}
function bound(num, lowerLimit, upperLimit) {
return Math.max(lowerLimit, Math.min(upperLimit, num))
}
property var title: Backend.appname
property Notifications _notifications: Notifications {
id: notifications
frontendMain: mainWindow
}
property NotificationFilter _trayNotificationFilter: NotificationFilter {
id: trayNotificationFilter
source: root._notifications ? root._notifications.all : undefined
onTopmostChanged: {
if (topmost) {
switch (topmost.type) {
case Notification.NotificationType.Danger:
Backend.setErrorTrayIcon(topmost.brief, topmost.icon);
return;
case Notification.NotificationType.Warning:
Backend.setWarnTrayIcon(topmost.brief, topmost.icon);
return;
case Notification.NotificationType.Info:
Backend.setUpdateTrayIcon(topmost.brief, topmost.icon);
return;
property NotificationFilter _trayNotificationFilter: NotificationFilter {
id: trayNotificationFilter
source: root._notifications ? root._notifications.all : undefined
onTopmostChanged: {
if (topmost) {
switch (topmost.type) {
case Notification.NotificationType.Danger:
Backend.setErrorTrayIcon(topmost.brief, topmost.icon)
return
case Notification.NotificationType.Warning:
Backend.setWarnTrayIcon(topmost.brief, topmost.icon)
return
case Notification.NotificationType.Info:
Backend.setUpdateTrayIcon(topmost.brief, topmost.icon)
return
}
}
Backend.setNormalTrayIcon();
Backend.setNormalTrayIcon()
}
}
property var title: Backend.appname
function bound(num, lowerLimit, upperLimit) {
return Math.max(lowerLimit, Math.min(upperLimit, num));
}
function setColorScheme() {
if (Backend.colorSchemeName === "light")
ProtonStyle.currentStyle = ProtonStyle.lightStyle;
if (Backend.colorSchemeName === "dark")
ProtonStyle.currentStyle = ProtonStyle.darkStyle;
property MainWindow _mainWindow: MainWindow {
id: mainWindow
visible: false
title: root.title
notifications: root._notifications
onVisibleChanged: {
Backend.dockIconVisible = visible
}
Connections {
target: Backend
function onDiskCacheUnavailable() {
mainWindow.showAndRise()
}
function onColorSchemeNameChanged(scheme) { root.setColorScheme() }
function onHideMainWindow() {
mainWindow.hide();
}
}
}
Component.onCompleted: {
if (!Backend) {
console.log("Backend not loaded");
console.log("Backend not loaded")
}
root.setColorScheme();
root.setColorScheme()
if (!Backend.users) {
console.log("users not loaded");
console.log("users not loaded")
}
const c = Backend.users.count;
const u = Backend.users.get(0);
var c = Backend.users.count
var u = Backend.users.get(0)
// DEBUG
if (c !== 0) {
console.log("users non zero", c);
console.log("first user", u);
console.log("users non zero", c)
console.log("first user", u )
}
if (c === 0) {
mainWindow.showAndRise();
mainWindow.showAndRise()
}
if (u) {
if (c === 1 && (u.state === EUserState.SignedOut)) {
mainWindow.showAndRise();
mainWindow.showAndRise()
}
}
Backend.guiReady();
if (Backend.showOnStartup || Backend.showSplashScreen) {
mainWindow.showAndRise();
Backend.guiReady()
if (Backend.showOnStartup || Backend.showSplashScreen) {
mainWindow.showAndRise()
}
}
function setColorScheme() {
if (Backend.colorSchemeName === "light") ProtonStyle.currentStyle = ProtonStyle.lightStyle
if (Backend.colorSchemeName === "dark") ProtonStyle.currentStyle = ProtonStyle.darkStyle
}
}

View File

@ -1,52 +0,0 @@
// Copyright (c) 2023 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
SettingsView {
id: root
signal categorySelected(int categoryId)
fillHeight: true
property var categories: Backend.bugCategories
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr("What do you want to report?")
type: Label.Heading
}
Repeater {
model: root.categories
CategoryItem {
Layout.fillWidth: true
actionIcon: "/qml/icons/ic-chevron-right.svg"
colorScheme: root.colorScheme
text: modelData.name
hint: modelData.hint ? modelData.hint: ""
onClicked: root.categorySelected(index)
}
}
// fill height so the footer label will always be attached to the bottom
Item {
Layout.fillHeight: true
}
}

View File

@ -1,130 +0,0 @@
// Copyright (c) 2023 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
SettingsView {
id: root
property var questions:Backend.bugQuestions
property var categoryId:0
property var questionSet:ListModel{}
property bool error: questionRepeater.error
signal questionAnswered
function setCategoryId(catId) {
root.categoryId = catId;
}
function submit() {
root.questionAnswered();
}
fillHeight: true
onCategoryIdChanged: {
root.questionSet = Backend.getQuestionSet(root.categoryId)
}
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr("Provide more details")
type: Label.Heading
}
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr(Backend.getBugCategory(root.categoryId))
type: Label.Title
}
TextEdit {
Layout.fillWidth: true
color: root.colorScheme.text_weak
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.caption_letter_spacing
font.pixelSize: ProtonStyle.caption_font_size
font.weight: ProtonStyle.fontWeight_400
textFormat: Text.MarkdownText
readOnly: true
selectByMouse: true
selectedTextColor: root.colorScheme.text_invert
// No way to set lineHeight: ProtonStyle.caption_line_height
selectionColor: root.colorScheme.interaction_norm
text: qsTr("* Mandatory questions")
wrapMode: Text.WordWrap
}
Repeater {
id: questionRepeater
model: root.questionSet
property bool error :{
for (var i = 0; i < questionRepeater.count; i++) {
if (questionRepeater.itemAt(i).error)
return true;
}
return false;
}
function validate(){
for (var i = 0; i < questionRepeater.count; i++) {
questionRepeater.itemAt(i).validate()
}
}
QuestionItem {
Layout.fillWidth: true
colorScheme: root.colorScheme
showSeparator: index < (root.questionSet.length - 1)
text: root.questions[modelData].text
tips: root.questions[modelData].tips ? root.questions[modelData].tips : ""
label: root.questions[modelData].label ? root.questions[modelData].label : ""
type: root.questions[modelData].type
mandatory: root.questions[modelData].mandatory ? root.questions[modelData].mandatory : false
answerList: root.questions[modelData].answerList ? root.questions[modelData].answerList : []
maxChar: root.questions[modelData].maxChar ? root.questions[modelData].maxChar : 150
onAnswerChanged: {
Backend.setQuestionAnswer(modelData, answer);
}
Connections {
function onVisibleChanged() {
setDefaultValue(Backend.getQuestionAnswer(modelData))
}
target: root
}
}
}
// fill height so the footer label will always be attached to the bottom
Item {
Layout.fillHeight: true
}
Button {
id: continueButton
colorScheme: root.colorScheme
enabled: !loading && !root.error
text: qsTr("Continue")
onClicked: {
questionRepeater.validate()
if (!root.error)
submit();
}
}
}

View File

@ -1,99 +0,0 @@
// Copyright (c) 2023 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
import Notifications
Item {
id: root
property ColorScheme colorScheme
property string selectedAddress
property int categoryId: -1
signal back
signal bugReportWasSent
onVisibleChanged: {
root.showBugCategory();
}
function showBugCategory() {
bugReportFlow.currentIndex = 0;
}
function showBugQuestion() {
bugQuestion.setCategoryId(root.categoryId);
bugQuestion.positionViewAtBegining();
bugReportFlow.currentIndex = 1;
}
function showBugReport() {
bugReport.setCategoryId(root.categoryId);
bugReportFlow.currentIndex = 2;
}
Rectangle {
anchors.fill: parent
Layout.fillHeight: true // right content background
Layout.fillWidth: true
color: colorScheme.background_norm
StackLayout {
id: bugReportFlow
anchors.fill: parent
BugCategoryView {
// 0
id: bugCategory
colorScheme: root.colorScheme
onBack: {
root.back()
}
onCategorySelected: function(categoryId){
root.categoryId = categoryId
root.showBugQuestion();
}
}
BugQuestionView {
// 1
id: bugQuestion
colorScheme: root.colorScheme
onBack: {
root.showBugCategory();
}
onQuestionAnswered: {
root.showBugReport();
}
}
BugReportView {
// 2
id: bugReport
colorScheme: root.colorScheme
selectedAddress: root.selectedAddress
onBack: {
root.showBugQuestion();
}
onBugReportWasSent: {
Backend.clearAnswers();
root.bugReportWasSent();
}
}
}
}
}

View File

@ -1,161 +1,202 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
SettingsView {
id: root
property var selectedAddress
property var categoryId:-1
property string category: Backend.getBugCategory(root.categoryId)
signal bugReportWasSent
function isValidEmail(text) {
const reEmail = /^[^@]+@[^@]+\.[A-Za-z]+\s*$/;
return reEmail.test(text);
}
function setCategoryId(catId) {
root.categoryId = catId;
}
function setDefaultValue() {
description.text = Backend.collectAnswers(root.categoryId);
address.text = root.selectedAddress;
emailClient.text = Backend.currentEmailClient;
includeLogs.checked = true;
}
function submit() {
sendButton.loading = true;
Backend.reportBug(root.category, description.text, address.text, emailClient.text, includeLogs.checked);
}
fillHeight: true
onVisibleChanged: {
root.setDefaultValue();
}
property var selectedAddress
signal bugReportWasSent()
Label {
text: qsTr("Report a problem")
colorScheme: root.colorScheme
text: qsTr("Send report")
type: Label.Heading
}
TextArea {
id: description
property int _minLength: 150
property int _maxLength: 800
label: qsTr("Description")
colorScheme: root.colorScheme
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: heightForLinesVisible(4)
hint: description.text.length + "/" + _maxLength
placeholderText: qsTr("Tell us what went wrong or isn't working (min. %1 characters).").arg(_minLength)
validator: function(text) {
if (description.text.length < description._minLength) {
return qsTr("Enter a problem description (min. %1 characters).").arg(_minLength)
}
if (description.text.length > description._maxLength) {
return qsTr("Enter a problem description (max. %1 characters).").arg(_maxLength)
}
return
}
onTextChanged: {
// Rise max length error immediately while typing
if (description.text.length > description._maxLength) {
validate()
}
}
KeyNavigation.priority: KeyNavigation.BeforeItem
KeyNavigation.tab: address
Layout.fillHeight: true
Layout.fillWidth: true
Layout.minimumHeight: heightForLinesVisible(4)
colorScheme: root.colorScheme
textFormat: Text.MarkdownText
// set implicitHeight to explicit height because se don't
// want TextArea implicitHeight (which is height of all text)
// to be considered in SettingsView internal scroll view
implicitHeight: height
label: "Your answers to: " + qsTr(root.category);
readOnly : true
}
TextField {
id: address
Layout.fillWidth: true
colorScheme: root.colorScheme
label: qsTr("Your contact email")
colorScheme: root.colorScheme
Layout.fillWidth: true
placeholderText: qsTr("e.g. jane.doe@protonmail.com")
validator: function (str) {
validator: function(str) {
if (!isValidEmail(str)) {
return qsTr("Enter valid email address");
return qsTr("Enter valid email address")
}
return;
return
}
}
TextField {
id: emailClient
Layout.fillWidth: true
colorScheme: root.colorScheme
label: qsTr("Your email client (including version)")
colorScheme: root.colorScheme
Layout.fillWidth: true
placeholderText: qsTr("e.g. Apple Mail 14.0")
validator: function (str) {
validator: function(str) {
if (str.length === 0) {
return qsTr("Enter an email client name and version");
return qsTr("Enter an email client name and version")
}
return;
return
}
}
RowLayout {
CheckBox {
id: includeLogs
checked: true
colorScheme: root.colorScheme
text: qsTr("Include my recent logs")
colorScheme: root.colorScheme
checked: true
}
Button {
Layout.leftMargin: 12
colorScheme: root.colorScheme
secondary: true
text: qsTr("View logs")
secondary: true
colorScheme: root.colorScheme
onClicked: Qt.openUrlExternally(Backend.logsPath)
}
}
TextEdit {
text: qsTr("Reports are not end-to-end encrypted, please do not send any sensitive information.")
readOnly: true
Layout.fillWidth: true
color: root.colorScheme.text_weak
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.caption_letter_spacing
font.pixelSize: ProtonStyle.caption_font_size
font.weight: ProtonStyle.fontWeight_400
readOnly: true
selectByMouse: true
selectedTextColor: root.colorScheme.text_invert
font.pixelSize: ProtonStyle.caption_font_size
font.letterSpacing: ProtonStyle.caption_letter_spacing
// No way to set lineHeight: ProtonStyle.caption_line_height
selectionColor: root.colorScheme.interaction_norm
text: qsTr("Reports are not end-to-end encrypted, please do not send any sensitive information.")
selectedTextColor: root.colorScheme.text_invert
wrapMode: Text.WordWrap
selectByMouse: true
}
Button {
id: sendButton
text: qsTr("Send")
colorScheme: root.colorScheme
enabled: !loading
text: qsTr("Send")
onClicked: {
description.validate();
address.validate();
emailClient.validate();
description.validate()
address.validate()
emailClient.validate()
if (description.error || address.error || emailClient.error) {
return;
return
}
submit();
submit()
}
Connections {
function onBugReportSendSuccess() {
root.bugReportWasSent();
}
function onReportBugFinished() {
sendButton.loading = false;
}
target: Backend
function onReportBugFinished() { sendButton.loading = false }
function onBugReportSendSuccess() { root.bugReportWasSent() }
}
}
}
function setDescription(message) {
description.text = message
}
function setDefaultValue() {
description.text = ""
address.text = root.selectedAddress
emailClient.text = Backend.currentEmailClient
includeLogs.checked = true
}
function isValidEmail(text){
var reEmail = /^[^@]+@[^@]+\.[A-Za-z]+\s*$/
return reEmail.test(text)
}
function submit() {
sendButton.loading = true
Backend.reportBug(
description.text,
address.text,
emailClient.text,
includeLogs.checked
)
}
onVisibleChanged: {
root.setDefaultValue()
}
}

View File

@ -1,113 +0,0 @@
// Copyright (c) 2023 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
Item {
id: root
property var _bottomMargin: 20
property var _lineHeight: 1
property string actionIcon: ""
property var colorScheme
property bool showSeparator: true
property string text: "Text"
property string hint: ""
signal clicked
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
RowLayout {
anchors.fill: parent
spacing: 16
Label {
id: mainLabel
colorScheme: root.colorScheme
text: root.text
type: Label.Body
Layout.alignment: Qt.AlignVCenter
Layout.bottomMargin: root._bottomMargin
wrapMode: Text.WordWrap
}
ColorImage {
id: infoImage
Layout.alignment: Qt.AlignVCenter
Layout.bottomMargin: root._bottomMargin
color: root.colorScheme.interaction_norm
height: 21
width: 21
source: "/qml/icons/ic-info-circle.svg"
sourceSize.height: 21
sourceSize.width: 21
visible: root.hint !== ""
MouseArea {
id: imageArea
anchors.fill: infoImage
hoverEnabled: true
}
ToolTip {
id: toolTipinfo
text: root.hint
visible: imageArea.containsMouse
implicitWidth: Math.min(400, tooltipText.implicitWidth)
background: Rectangle {
radius: 4
border.color: root.colorScheme.border_weak
color: root.colorScheme.background_weak
}
contentItem: Text {
id: tooltipText
color: root.colorScheme.text_hint
text: toolTipinfo.text
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// fill height so the footer label will always be attached to the bottom
Item {
Layout.fillWidth: true
}
Button {
id: button
Layout.alignment: Qt.AlignVCenter
Layout.bottomMargin: root._bottomMargin
colorScheme: root.colorScheme
icon.source: root.actionIcon
text: ""
secondary: true
visible: root.actionIcon !== ""
onClicked: {
if (!root.loading)
root.clicked();
}
}
}
Rectangle {
anchors.bottom: root.bottom
anchors.left: root.left
anchors.right: root.right
color: colorScheme.border_weak
height: root._lineHeight
visible: root.showSeparator
}
}

View File

@ -1,80 +1,71 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
Rectangle {
id: root
property int _margin: 24
property ColorScheme colorScheme
property string hostname
property string password
property string port
property string security
property string title
property string hostname
property string port
property string username
property string password
property string security
implicitWidth: 304
implicitHeight: content.height + 2*root._margin
color: root.colorScheme.background_norm
implicitHeight: content.height + 2 * root._margin
implicitWidth: 304
radius: ProtonStyle.card_radius
property int _margin: 24
ColumnLayout {
id: content
spacing: 12
width: root.width - 2 * root._margin
anchors {
bottomMargin: root._margin
left: root.left
leftMargin: root._margin
rightMargin: root._margin
width: root.width - 2*root._margin
anchors{
top: root.top
topMargin: root._margin
left: root.left
leftMargin : root._margin
rightMargin : root._margin
topMargin : root._margin
bottomMargin : root._margin
}
spacing: 12
Label {
colorScheme: root.colorScheme
text: root.title
type: Label.Body_semibold
}
ConfigurationItem {
colorScheme: root.colorScheme
label: qsTr("Hostname")
value: root.hostname
}
ConfigurationItem {
colorScheme: root.colorScheme
label: qsTr("Port")
value: root.port
}
ConfigurationItem {
colorScheme: root.colorScheme
label: qsTr("Username")
value: root.username
}
ConfigurationItem {
colorScheme: root.colorScheme
label: qsTr("Password")
value: root.password
}
ConfigurationItem {
colorScheme: root.colorScheme
label: qsTr("Security")
value: root.security
}
ConfigurationItem{ colorScheme: root.colorScheme; label: qsTr("Hostname") ; value: root.hostname }
ConfigurationItem{ colorScheme: root.colorScheme; label: qsTr("Port") ; value: root.port }
ConfigurationItem{ colorScheme: root.colorScheme; label: qsTr("Username") ; value: root.username }
ConfigurationItem{ colorScheme: root.colorScheme; label: qsTr("Password") ; value: root.password }
ConfigurationItem{ colorScheme: root.colorScheme; label: qsTr("Security") ; value: root.security }
}
}

View File

@ -1,29 +1,35 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
Item {
id: root
Layout.fillWidth: true
property var colorScheme
property string label
property string value
Layout.fillWidth: true
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
@ -41,42 +47,45 @@ Item {
}
TextEdit {
id: valueText
Layout.fillWidth: true
text: root.value
color: root.colorScheme.text_weak
readOnly: true
selectByKeyboard: true
selectByMouse: true
selectByKeyboard: true
selectionColor: root.colorScheme.text_weak
text: root.value
wrapMode: Text.WrapAnywhere
Layout.fillWidth: true
}
}
Item {
Layout.fillWidth: true
}
ColorImage {
source: "/qml/icons/ic-copy.svg"
color: root.colorScheme.text_norm
height: root.colorScheme.body_font_size
source: "/qml/icons/ic-copy.svg"
sourceSize.height: root.colorScheme.body_font_size
MouseArea {
anchors.fill: parent
onClicked: {
valueText.select(0, valueText.length);
valueText.copy();
valueText.deselect();
onClicked : {
valueText.select(0, valueText.length)
valueText.copy()
valueText.deselect()
}
onPressed: parent.scale = 0.90
onReleased: parent.scale = 1
}
}
}
Rectangle {
Layout.fillWidth: true
color: root.colorScheme.border_norm
height: 1
color: root.colorScheme.border_norm
}
}
}

View File

@ -1,138 +1,155 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
SettingsView {
id: root
function setDefaultValues() {
imapSSLButton.checked = Backend.useSSLForIMAP;
imapSTARTTLSButton.checked = !Backend.useSSLForIMAP;
smtpSSLButton.checked = Backend.useSSLForSMTP;
smtpSTARTTLSButton.checked = !Backend.useSSLForSMTP;
}
function submit() {
submitButton.loading = true;
Backend.setMailServerSettings(Backend.imapPort, Backend.smtpPort, imapSSLButton.checked, smtpSSLButton.checked);
}
fillHeight: false
onVisibleChanged: {
root.setDefaultValues();
}
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr("Connection mode")
type: Label.Heading
}
Label {
Layout.fillWidth: true
color: root.colorScheme.text_weak
}
Label {
colorScheme: root.colorScheme
text: qsTr("Change the protocol Bridge and the email client use to connect for IMAP and SMTP.")
type: Label.Body
color: root.colorScheme.text_weak
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
ColumnLayout {
spacing: 16
ButtonGroup {
id: imapProtocolSelection
}
ButtonGroup{ id: imapProtocolSelection }
Label {
colorScheme: root.colorScheme
text: qsTr("IMAP connection")
}
RadioButton {
id: imapSSLButton
ButtonGroup.group: imapProtocolSelection
colorScheme: root.colorScheme
ButtonGroup.group: imapProtocolSelection
text: qsTr("SSL")
}
RadioButton {
id: imapSTARTTLSButton
ButtonGroup.group: imapProtocolSelection
colorScheme: root.colorScheme
ButtonGroup.group: imapProtocolSelection
text: qsTr("STARTTLS")
}
}
Rectangle {
Layout.fillWidth: true
color: root.colorScheme.border_weak
height: 1
color: root.colorScheme.border_weak
}
ColumnLayout {
spacing: 16
ButtonGroup {
id: smtpProtocolSelection
}
ButtonGroup{ id: smtpProtocolSelection }
Label {
colorScheme: root.colorScheme
text: qsTr("SMTP connection")
}
RadioButton {
id: smtpSSLButton
ButtonGroup.group: smtpProtocolSelection
colorScheme: root.colorScheme
ButtonGroup.group: smtpProtocolSelection
text: qsTr("SSL")
}
RadioButton {
id: smtpSTARTTLSButton
ButtonGroup.group: smtpProtocolSelection
colorScheme: root.colorScheme
ButtonGroup.group: smtpProtocolSelection
text: qsTr("STARTTLS")
}
}
Rectangle {
Layout.fillWidth: true
color: root.colorScheme.border_weak
height: 1
color: root.colorScheme.border_weak
}
RowLayout {
spacing: 12
Button {
id: submitButton
colorScheme: root.colorScheme
enabled: (!loading) && ((imapSSLButton.checked !== Backend.useSSLForIMAP) || (smtpSSLButton.checked !== Backend.useSSLForSMTP))
text: qsTr("Save")
onClicked: {
submitButton.loading = true;
root.submit();
submitButton.loading = true
root.submit()
}
enabled: (!loading) && ((imapSSLButton.checked !== Backend.useSSLForIMAP) || (smtpSSLButton.checked !== Backend.useSSLForSMTP))
}
Button {
colorScheme: root.colorScheme
secondary: true
text: qsTr("Cancel")
onClicked: root.back()
secondary: true
}
Connections {
function onChangeMailServerSettingsFinished() {
submitButton.loading = false;
root.back();
}
Connections {
target: Backend
function onChangeMailServerSettingsFinished() {
submitButton.loading = false
root.back()
}
}
}
function submit(){
submitButton.loading = true
Backend.setMailServerSettings(Backend.imapPort, Backend.smtpPort, imapSSLButton.checked, smtpSSLButton.checked)
}
function setDefaultValues(){
imapSSLButton.checked = Backend.useSSLForIMAP
imapSTARTTLSButton.checked = !Backend.useSSLForIMAP
smtpSSLButton.checked = Backend.useSSLForSMTP
smtpSTARTTLSButton.checked = !Backend.useSSLForSMTP
}
onVisibleChanged: {
root.setDefaultValues()
}
}

View File

@ -1,58 +1,36 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
import Notifications
Item {
id: root
property ColorScheme colorScheme
property var notifications
signal closeWindow
signal quitBridge
signal showSetupGuide(var user, string address)
function selectUser(userID) {
const users = Backend.users;
for (let i = 0; i < users.count; i++) {
const user = users.get(i);
if (user.id !== userID) {
continue;
}
accounts.currentIndex = i;
if (user.state === EUserState.SignedOut)
showSignIn(user.primaryEmailOrUsername());
return;
}
console.error("User with ID ", userID, " was not found in the account list");
}
function showHelp() {
rightContent.showHelpView();
}
function showLocalCacheSettings() {
rightContent.showLocalCacheSettings();
}
function showSettings() {
rightContent.showGeneralSettings();
}
function showSignIn(username) {
signIn.username = username;
rightContent.showSignIn();
}
signal closeWindow()
signal quitBridge()
RowLayout {
anchors.fill: parent
@ -60,13 +38,13 @@ Item {
Rectangle {
id: leftBar
property ColorScheme colorScheme: root.colorScheme.prominent
Layout.fillHeight: true
Layout.maximumWidth: 320
Layout.minimumWidth: 264
Layout.maximumWidth: 320
Layout.preferredWidth: 320
Layout.fillHeight: true
color: colorScheme.background_norm
ColumnLayout {
@ -74,21 +52,24 @@ Item {
spacing: 0
RowLayout {
id: topLeftBar
id:topLeftBar
Layout.fillWidth: true
Layout.maximumHeight: 60
Layout.minimumHeight: 60
Layout.maximumHeight: 60
Layout.preferredHeight: 60
spacing: 0
Status {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 17
Layout.leftMargin: 16
Layout.topMargin: 24
Layout.bottomMargin: 17
Layout.alignment: Qt.AlignHCenter
colorScheme: leftBar.colorScheme
notificationWhitelist: Notifications.Group.Connection | Notifications.Group.ForceUpdate
notifications: root.notifications
notificationWhitelist: Notifications.Group.Connection | Notifications.Group.ForceUpdate
}
// just a placeholder
@ -96,38 +77,47 @@ Item {
Layout.fillHeight: true
Layout.fillWidth: true
}
Button {
Layout.bottomMargin: 9
Layout.maximumHeight: 36
Layout.maximumWidth: 36
Layout.minimumHeight: 36
Layout.minimumWidth: 36
Layout.preferredHeight: 36
Layout.preferredWidth: 36
Layout.rightMargin: 4
Layout.topMargin: 16
colorScheme: leftBar.colorScheme
Layout.minimumHeight: 36
Layout.maximumHeight: 36
Layout.preferredHeight: 36
Layout.minimumWidth: 36
Layout.maximumWidth: 36
Layout.preferredWidth: 36
Layout.topMargin: 16
Layout.bottomMargin: 9
Layout.rightMargin: 4
horizontalPadding: 0
icon.source: "/qml/icons/ic-question-circle.svg"
onClicked: rightContent.showHelpView()
}
Button {
Layout.bottomMargin: 9
Layout.maximumHeight: 36
Layout.maximumWidth: 36
Layout.minimumHeight: 36
Layout.minimumWidth: 36
Layout.preferredHeight: 36
Layout.preferredWidth: 36
Layout.rightMargin: 4
Layout.topMargin: 16
colorScheme: leftBar.colorScheme
Layout.minimumHeight: 36
Layout.maximumHeight: 36
Layout.preferredHeight: 36
Layout.minimumWidth: 36
Layout.maximumWidth: 36
Layout.preferredWidth: 36
Layout.topMargin: 16
Layout.bottomMargin: 9
Layout.rightMargin: 4
horizontalPadding: 0
icon.source: "/qml/icons/ic-cog-wheel.svg"
onClicked: rightContent.showGeneralSettings()
}
Button {
id: dotMenuButton
Layout.bottomMargin: 9
@ -144,7 +134,7 @@ Item {
icon.source: "/qml/icons/ic-three-dots-vertical.svg"
onClicked: {
dotMenu.open();
dotMenu.open()
}
Menu {
@ -153,319 +143,332 @@ Item {
modal: true
y: dotMenuButton.Layout.preferredHeight + dotMenuButton.Layout.bottomMargin
onClosed: {
parent.checked = false;
}
onOpened: {
parent.checked = true;
}
MenuItem {
colorScheme: root.colorScheme
text: qsTr("Close window")
onClicked: {
root.closeWindow();
root.closeWindow()
}
}
MenuItem {
colorScheme: root.colorScheme
text: qsTr("Quit Bridge")
onClicked: {
root.quitBridge();
root.quitBridge()
}
}
onClosed: {
parent.checked = false
}
onOpened: {
parent.checked = true
}
}
}
}
Item {
implicitHeight: 10
}
Item {implicitHeight:10}
// Separator line
Rectangle {
Layout.fillWidth: true
Layout.maximumHeight: 1
Layout.minimumHeight: 1
Layout.maximumHeight: 1
color: leftBar.colorScheme.border_weak
}
ListView {
id: accounts
property var _leftRightMargins: 16
property var _topBottomMargins: 24
property var _leftRightMargins: 16
Layout.bottomMargin: accounts._topBottomMargins
Layout.fillHeight: true
Layout.fillWidth: true
Layout.fillHeight: true
Layout.leftMargin: accounts._leftRightMargins
Layout.rightMargin: accounts._leftRightMargins
Layout.topMargin: accounts._topBottomMargins
boundsBehavior: Flickable.StopAtBounds
clip: true
model: Backend.users
Layout.bottomMargin: accounts._topBottomMargins
spacing: 12
clip: true
boundsBehavior: Flickable.StopAtBounds
delegate: Item {
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
implicitWidth: children[0].implicitWidth + children[0].anchors.leftMargin + children[0].anchors.rightMargin
width: leftBar.width - 2 * accounts._leftRightMargins
AccountDelegate {
id: accountDelegate
anchors.bottomMargin: 8
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.topMargin: 8
colorScheme: leftBar.colorScheme
user: Backend.users.get(index)
}
MouseArea {
anchors.fill: parent
onClicked: {
const user = Backend.users.get(index);
accounts.currentIndex = index;
if (!user)
return;
if (user.state !== EUserState.SignedOut) {
rightContent.showAccount();
} else {
signIn.username = user.primaryEmailOrUsername();
rightContent.showSignIn();
}
}
}
}
header: Rectangle {
height: headerLabel.height + 16
height: headerLabel.height+16
// color: ProtonStyle.transparent
Label {
id: headerLabel
Label{
colorScheme: leftBar.colorScheme
id: headerLabel
text: qsTr("Accounts")
type: Label.LabelType.Body
}
}
highlight: Rectangle {
color: leftBar.colorScheme.interaction_default_active
radius: ProtonStyle.account_row_radius
}
model: Backend.users
delegate: Item {
width: leftBar.width - 2*accounts._leftRightMargins
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
implicitWidth: children[0].implicitWidth + children[0].anchors.leftMargin + children[0].anchors.rightMargin
AccountDelegate {
id: accountDelegate
anchors.fill: parent
anchors.topMargin: 8
anchors.bottomMargin: 8
anchors.leftMargin: 12
anchors.rightMargin: 12
colorScheme: leftBar.colorScheme
user: Backend.users.get(index)
}
MouseArea {
anchors.fill: parent
onClicked: {
var user = Backend.users.get(index)
accounts.currentIndex = index
if (!user) return
if (user.state !== EUserState.SignedOut) {
rightContent.showAccount()
} else {
signIn.username = user.primaryEmailOrUsername()
rightContent.showSignIn()
}
}
}
}
}
// Separator
Rectangle {
Layout.fillWidth: true
Layout.maximumHeight: 1
Layout.minimumHeight: 1
Layout.maximumHeight: 1
color: leftBar.colorScheme.border_weak
}
Item {
id: bottomLeftBar
Layout.fillWidth: true
Layout.maximumHeight: 52
Layout.minimumHeight: 52
Layout.maximumHeight: 52
Layout.preferredHeight: 52
Button {
anchors.left: parent.left
anchors.leftMargin: 16
anchors.top: parent.top
anchors.topMargin: 7
colorScheme: leftBar.colorScheme
height: 36
horizontalPadding: 0
icon.source: "/qml/icons/ic-plus.svg"
width: 36
height: 36
anchors.left: parent.left
anchors.top: parent.top
anchors.leftMargin: 16
anchors.topMargin: 7
horizontalPadding: 0
icon.source: "/qml/icons/ic-plus.svg"
onClicked: {
signIn.username = "";
rightContent.showSignIn();
signIn.username = ""
rightContent.showSignIn()
}
}
}
}
}
Rectangle {
Layout.fillHeight: true // right content background
Rectangle { // right content background
Layout.fillWidth: true
Layout.fillHeight: true
color: colorScheme.background_norm
StackLayout {
id: rightContent
function showAccount(index) {
if (index !== undefined && index >= 0) {
accounts.currentIndex = index;
}
rightContent.currentIndex = 0;
}
function showBugReport() {
rightContent.currentIndex = 8;
}
function showConnectionModeSettings() {
rightContent.currentIndex = 5;
}
function showGeneralSettings() {
rightContent.currentIndex = 2;
}
function showHelpView() {
rightContent.currentIndex = 7;
}
function showKeychainSettings() {
rightContent.currentIndex = 3;
}
function showLocalCacheSettings() {
rightContent.currentIndex = 6;
}
function showPortSettings() {
rightContent.currentIndex = 4;
}
function showSignIn() {
rightContent.currentIndex = 1;
signIn.focus = true;
}
anchors.fill: parent
AccountView {
// 0
AccountView { // 0
colorScheme: root.colorScheme
notifications: root.notifications
user: {
if (accounts.currentIndex < 0)
return undefined;
if (Backend.users.count === 0)
return undefined;
return Backend.users.get(accounts.currentIndex);
}
onShowSetupGuide: function (user, address) {
root.showSetupGuide(user, address);
if (accounts.currentIndex < 0) return undefined
if (Backend.users.count == 0) return undefined
return Backend.users.get(accounts.currentIndex)
}
onShowSignIn: {
const user = this.user;
signIn.username = user ? user.primaryEmailOrUsername() : "";
rightContent.showSignIn();
var user = this.user
signIn.username = user ? user.primaryEmailOrUsername() : ""
rightContent.showSignIn()
}
onShowSetupGuide: function(user, address) {
root.showSetupGuide(user,address)
}
}
GridLayout {
// 1 Sign In
GridLayout { // 1 Sign In
columns: 2
Button {
id: backButton
Layout.alignment: Qt.AlignTop
Layout.leftMargin: 18
Layout.topMargin: 10
Layout.alignment: Qt.AlignTop
colorScheme: root.colorScheme
horizontalPadding: 8
onClicked: {
signIn.abort()
rightContent.showAccount()
}
icon.source: "/qml/icons/ic-arrow-left.svg"
secondary: true
onClicked: {
signIn.abort();
rightContent.showAccount();
}
horizontalPadding: 8
}
SignIn {
id: signIn
Layout.bottomMargin: 68
Layout.fillHeight: true
Layout.fillWidth: true
Layout.leftMargin: 80 - backButton.width - 18
Layout.preferredWidth: 320
Layout.rightMargin: 80
Layout.topMargin: 68
Layout.leftMargin: 80 - backButton.width - 18
Layout.rightMargin: 80
Layout.bottomMargin: 68
Layout.preferredWidth: 320
Layout.fillWidth: true
Layout.fillHeight: true
colorScheme: root.colorScheme
}
}
GeneralSettings {
// 2
GeneralSettings { // 2
colorScheme: root.colorScheme
notifications: root.notifications
onBack: {
rightContent.showAccount();
rightContent.showAccount()
}
}
KeychainSettings {
// 3
KeychainSettings { // 3
colorScheme: root.colorScheme
onBack: {
rightContent.showGeneralSettings();
rightContent.showGeneralSettings()
}
}
PortSettings {
// 4
PortSettings { // 4
colorScheme: root.colorScheme
notifications: root.notifications
onBack: {
rightContent.showGeneralSettings()
}
}
ConnectionModeSettings { // 5
colorScheme: root.colorScheme
onBack: {
rightContent.showGeneralSettings()
}
}
LocalCacheSettings { // 6
colorScheme: root.colorScheme
notifications: root.notifications
onBack: {
rightContent.showGeneralSettings();
rightContent.showGeneralSettings()
}
}
ConnectionModeSettings {
// 5
HelpView { // 7
colorScheme: root.colorScheme
onBack: {
rightContent.showGeneralSettings();
rightContent.showAccount()
}
}
LocalCacheSettings {
// 6
colorScheme: root.colorScheme
notifications: root.notifications
onBack: {
rightContent.showGeneralSettings();
}
}
HelpView {
// 7
colorScheme: root.colorScheme
onBack: {
rightContent.showAccount();
}
}
BugReportFlow {
// 8
BugReportView { // 8
id: bugReport
colorScheme: root.colorScheme
selectedAddress: {
if (accounts.currentIndex < 0)
return "";
if (Backend.users.count === 0)
return "";
const user = Backend.users.get(accounts.currentIndex);
if (!user)
return "";
return user.addresses[0];
if (accounts.currentIndex < 0) return ""
if (Backend.users.count == 0) return ""
var user = Backend.users.get(accounts.currentIndex)
if (!user) return ""
return user.addresses[0]
}
onBack: {
rightContent.showHelpView();
}
onBugReportWasSent: {
rightContent.showAccount();
}
}
Connections {
function onLoginAlreadyLoggedIn(index) {
rightContent.showAccount(index);
}
function onLoginFinished(index) {
rightContent.showAccount(index);
rightContent.showHelpView()
}
onBugReportWasSent: {
rightContent.showAccount()
}
}
function showAccount(index) {
if (index !== undefined && index >= 0){
accounts.currentIndex = index
}
rightContent.currentIndex = 0
}
function showSignIn () { rightContent.currentIndex = 1; signIn.focus = true }
function showGeneralSettings () { rightContent.currentIndex = 2 }
function showKeychainSettings () { rightContent.currentIndex = 3 }
function showPortSettings () { rightContent.currentIndex = 4 }
function showConnectionModeSettings() { rightContent.currentIndex = 5 }
function showLocalCacheSettings () { rightContent.currentIndex = 6 }
function showHelpView () { rightContent.currentIndex = 7 }
function showBugReport () { rightContent.currentIndex = 8 }
Connections {
target: Backend
function onLoginFinished(index) { rightContent.showAccount(index) }
function onLoginAlreadyLoggedIn(index) { rightContent.showAccount(index) }
}
}
}
}
}
function showLocalCacheSettings(){rightContent.showLocalCacheSettings() }
function showSettings(){rightContent.showGeneralSettings() }
function showHelp(){rightContent.showHelpView() }
function showSignIn(username){
signIn.username = username
rightContent.showSignIn()
}
function selectUser(userID) {
var users = Backend.users;
for (var i = 0; i < users.count; i++) {
var user = users.get(i)
if (user.id !== userID) {
continue;
}
accounts.currentIndex = i;
if (user.state === EUserState.SignedOut)
showSignIn(user.primaryEmailOrUsername())
return;
}
console.error("User with ID ", userID, " was not found in the account list")
}
function showBugReportAndPrefill(description) {
rightContent.showBugReport()
bugReport.setDescription(description)
}
}

View File

@ -1,45 +1,54 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Controls
import "."
import "Proton"
import "./Proton"
Rectangle {
property var target: parent
border.color: "red"
border.width: 1
color: "transparent"
height: target.height
width: target.width
x: target.x
y: target.y
width: target.width
height: target.height
color: "transparent"
border.color: "red"
border.width: 1
//z: parent.z - 1
z: 10000000
Label {
text: parent.width + "x" + parent.height
anchors.centerIn: parent
color: "black"
colorScheme: ProtonStyle.currentStyle
text: parent.width + "x" + parent.height
}
Rectangle {
width: target.implicitWidth
height: target.implicitHeight
color: "transparent"
border.color: "green"
border.width: 1
color: "transparent"
height: target.implicitHeight
width: target.implicitWidth
//z: parent.z - 1
z: 10000000
}

View File

@ -1,19 +1,25 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
SettingsView {
@ -25,138 +31,144 @@ SettingsView {
fillHeight: false
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr("Settings")
type: Label.Heading
Layout.fillWidth: true
}
SettingsItem {
id: autoUpdate
Layout.fillWidth: true
checked: Backend.isAutomaticUpdateOn
colorScheme: root.colorScheme
description: qsTr("Bridge will automatically update in the background.")
text: qsTr("Automatic updates")
description: qsTr("Bridge will automatically update in the background.")
type: SettingsItem.Toggle
checked: Backend.isAutomaticUpdateOn
onClicked: Backend.toggleAutomaticUpdate(!autoUpdate.checked)
Layout.fillWidth: true
}
SettingsItem {
id: autostart
Layout.fillWidth: true
checked: Backend.isAutostartOn
colorScheme: root.colorScheme
description: qsTr("Bridge will open upon startup.")
text: qsTr("Open on startup")
description: qsTr("Bridge will open upon startup.")
type: SettingsItem.Toggle
checked: Backend.isAutostartOn
onClicked: {
autostart.loading = true;
Backend.toggleAutostart(!autostart.checked);
autostart.loading = true
Backend.toggleAutostart(!autostart.checked)
}
Connections {
function onToggleAutostartFinished() {
autostart.loading = false;
}
Connections{
target: Backend
function onToggleAutostartFinished() {
autostart.loading = false
}
}
Layout.fillWidth: true
}
SettingsItem {
id: beta
Layout.fillWidth: true
checked: Backend.isBetaEnabled
colorScheme: root.colorScheme
description: qsTr("Be among the first to try new features.")
text: qsTr("Beta access")
description: qsTr("Be among the first to try new features.")
type: SettingsItem.Toggle
checked: Backend.isBetaEnabled
onClicked: {
if (!beta.checked) {
root.notifications.askEnableBeta();
root.notifications.askEnableBeta()
} else {
Backend.toggleBeta(false);
Backend.toggleBeta(false)
}
}
Layout.fillWidth: true
}
RowLayout {
ColorImage {
Layout.alignment: Qt.AlignCenter
source: root._isAdvancedShown ? "/qml/icons/ic-chevron-down.svg" : "/qml/icons/ic-chevron-right.svg"
color: root.colorScheme.interaction_norm
height: root.colorScheme.body_font_size
source: root._isAdvancedShown ? "/qml/icons/ic-chevron-down.svg" : "/qml/icons/ic-chevron-right.svg"
sourceSize.height: root.colorScheme.body_font_size
MouseArea {
anchors.fill: parent
onClicked: root._isAdvancedShown = !root._isAdvancedShown
}
}
Label {
id: advSettLabel
color: root.colorScheme.interaction_norm
colorScheme: root.colorScheme
text: qsTr("Advanced settings")
color: root.colorScheme.interaction_norm
type: Label.Body
MouseArea {
anchors.fill: parent
onClicked: root._isAdvancedShown = !root._isAdvancedShown
}
}
}
SettingsItem {
id: keychains
Layout.fillWidth: true
actionText: qsTr("Change")
checked: Backend.isDoHEnabled
colorScheme: root.colorScheme
description: qsTr("Change which keychain Bridge uses as default")
text: qsTr("Change keychain")
type: SettingsItem.Button
visible: root._isAdvancedShown && Backend.availableKeychain.length > 1
colorScheme: root.colorScheme
text: qsTr("Change keychain")
description: qsTr("Change which keychain Bridge uses as default")
actionText: qsTr("Change")
type: SettingsItem.Button
checked: Backend.isDoHEnabled
onClicked: root.parent.showKeychainSettings()
Layout.fillWidth: true
}
SettingsItem {
id: doh
Layout.fillWidth: true
checked: Backend.isDoHEnabled
colorScheme: root.colorScheme
description: qsTr("If Protons servers are blocked in your location, alternative network routing will be used to reach Proton.")
text: qsTr("Alternative routing")
type: SettingsItem.Toggle
visible: root._isAdvancedShown
colorScheme: root.colorScheme
text: qsTr("Alternative routing")
description: qsTr("If Protons servers are blocked in your location, alternative network routing will be used to reach Proton.")
type: SettingsItem.Toggle
checked: Backend.isDoHEnabled
onClicked: Backend.toggleDoH(!doh.checked)
Layout.fillWidth: true
}
SettingsItem {
id: darkMode
Layout.fillWidth: true
checked: Backend.colorSchemeName === "dark"
colorScheme: root.colorScheme
description: qsTr("Choose dark color theme.")
text: qsTr("Dark mode")
type: SettingsItem.Toggle
visible: root._isAdvancedShown
colorScheme: root.colorScheme
text: qsTr("Dark mode")
description: qsTr("Choose dark color theme.")
type: SettingsItem.Toggle
checked: Backend.colorSchemeName == "dark"
onClicked: Backend.changeColorScheme( darkMode.checked ? "light" : "dark")
onClicked: Backend.changeColorScheme(darkMode.checked ? "light" : "dark")
Layout.fillWidth: true
}
SettingsItem {
id: allMail
Layout.fillWidth: true
checked: Backend.isAllMailVisible
colorScheme: root.colorScheme
description: qsTr("Choose to list the All Mail folder in your local client.")
text: qsTr("Show All Mail")
type: SettingsItem.Toggle
visible: root._isAdvancedShown
colorScheme: root.colorScheme
text: qsTr("Show All Mail")
description: qsTr("Choose to list the All Mail folder in your local client.")
type: SettingsItem.Toggle
checked: Backend.isAllMailVisible
onClicked: root.notifications.askChangeAllMailVisibility(Backend.isAllMailVisible)
Layout.fillWidth: true
}
SettingsItem {
id: telemetry
Layout.fillWidth: true
@ -169,68 +181,73 @@ SettingsView {
onClicked: Backend.toggleIsTelemetryDisabled(telemetry.checked)
}
SettingsItem {
id: ports
Layout.fillWidth: true
actionText: qsTr("Change")
colorScheme: root.colorScheme
description: qsTr("Choose which ports are used by default.")
text: qsTr("Default ports")
type: SettingsItem.Button
visible: root._isAdvancedShown
colorScheme: root.colorScheme
text: qsTr("Default ports")
actionText: qsTr("Change")
description: qsTr("Choose which ports are used by default.")
type: SettingsItem.Button
onClicked: root.parent.showPortSettings()
Layout.fillWidth: true
}
SettingsItem {
id: imap
Layout.fillWidth: true
actionText: qsTr("Change")
colorScheme: root.colorScheme
description: qsTr("Change the protocol Bridge and the email client use to connect for IMAP and SMTP.")
text: qsTr("Connection mode")
type: SettingsItem.Button
visible: root._isAdvancedShown
colorScheme: root.colorScheme
text: qsTr("Connection mode")
actionText: qsTr("Change")
description: qsTr("Change the protocol Bridge and the email client use to connect for IMAP and SMTP.")
type: SettingsItem.Button
onClicked: root.parent.showConnectionModeSettings()
Layout.fillWidth: true
}
SettingsItem {
id: cache
Layout.fillWidth: true
actionText: qsTr("Configure")
colorScheme: root.colorScheme
description: qsTr("Configure Bridge's local cache.")
text: qsTr("Local cache")
type: SettingsItem.Button
visible: root._isAdvancedShown
colorScheme: root.colorScheme
text: qsTr("Local cache")
actionText: qsTr("Configure")
description: qsTr("Configure Bridge's local cache.")
type: SettingsItem.Button
onClicked: root.parent.showLocalCacheSettings()
Layout.fillWidth: true
}
SettingsItem {
id: exportTLSCertificates
Layout.fillWidth: true
actionText: qsTr("Export")
colorScheme: root.colorScheme
description: qsTr("Export the TLS private key and certificate used by the IMAP and SMTP servers.")
text: qsTr("Export TLS certificates")
type: SettingsItem.Button
visible: root._isAdvancedShown
colorScheme: root.colorScheme
text: qsTr("Export TLS certificates")
actionText: qsTr("Export")
description: qsTr("Export the TLS private key and certificate used by the IMAP and SMTP servers.")
type: SettingsItem.Button
onClicked: {
Backend.exportTLSCertificates();
Backend.exportTLSCertificates()
}
Layout.fillWidth: true
}
SettingsItem {
id: reset
Layout.fillWidth: true
actionText: qsTr("Reset")
colorScheme: root.colorScheme
description: qsTr("Remove all accounts, clear cached data, and restore the original settings.")
text: qsTr("Reset Bridge")
type: SettingsItem.Button
visible: root._isAdvancedShown
colorScheme: root.colorScheme
text: qsTr("Reset Bridge")
actionText: qsTr("Reset")
description: qsTr("Remove all accounts, clear cached data, and restore the original settings.")
type: SettingsItem.Button
onClicked: {
root.notifications.askResetBridge();
root.notifications.askResetBridge()
}
Layout.fillWidth: true
}
}

View File

@ -1,110 +1,126 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
SettingsView {
id: root
fillHeight: true
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr("Help")
type: Label.Heading
Layout.fillWidth: true
}
SettingsItem {
id: setupPage
Layout.fillWidth: true
actionIcon: "/qml/icons/ic-external-link.svg"
actionText: qsTr("Go to help topics")
colorScheme: root.colorScheme
description: qsTr("Get help setting up your client with our instructions and FAQs.")
text: qsTr("Installation and setup")
actionText: qsTr("Go to help topics")
actionIcon: "/qml/icons/ic-external-link.svg"
description: qsTr("Get help setting up your client with our instructions and FAQs.")
type: SettingsItem.PrimaryButton
onClicked: {
Backend.notifyKBArticleClicked("https://proton.me/support/bridge");
Qt.openUrlExternally("https://proton.me/support/bridge");
}
Qt.openUrlExternally("https://proton.me/support/bridge")}
Layout.fillWidth: true
}
SettingsItem {
id: checkUpdates
Layout.fillWidth: true
actionText: qsTr("Check now")
colorScheme: root.colorScheme
description: qsTr("Check that you're using the latest version of Bridge.\nTo stay up to date, enable auto-updates in settings.")
text: qsTr("Updates")
actionText: qsTr("Check now")
description: qsTr("Check that you're using the latest version of Bridge. To stay up to date, enable auto-updates in settings.")
type: SettingsItem.Button
onClicked: {
checkUpdates.loading = true;
Backend.checkUpdates();
checkUpdates.loading = true
Backend.checkUpdates()
}
Connections {
function onCheckUpdatesFinished() {
checkUpdates.loading = false;
}
target: Backend
function onCheckUpdatesFinished() { checkUpdates.loading = false }
}
Layout.fillWidth: true
}
SettingsItem {
id: logs
Layout.fillWidth: true
actionText: qsTr("View logs")
colorScheme: root.colorScheme
description: qsTr("Open and review logs to troubleshoot.")
text: qsTr("Logs")
actionText: qsTr("View logs")
description: qsTr("Open and review logs to troubleshoot.")
type: SettingsItem.Button
onClicked: Qt.openUrlExternally(Backend.logsPath)
Layout.fillWidth: true
}
SettingsItem {
id: reportBug
Layout.fillWidth: true
actionText: qsTr("Report problem")
colorScheme: root.colorScheme
description: qsTr("Something not working as expected? Let us know.")
text: qsTr("Report a problem")
actionText: qsTr("Report a problem")
description: qsTr("Something not working as expected? Let us know.")
type: SettingsItem.Button
onClicked: {
Backend.updateCurrentMailClient();
Backend.notifyReportBugClicked();
root.parent.showBugReport();
Backend.updateCurrentMailClient()
Backend.notifyReportBugClicked()
root.parent.showBugReport()
}
Layout.fillWidth: true
}
// fill height so the footer label will always be attached to the bottom
// fill height so the footer label will be always attached to the bottom
Item {
Layout.fillHeight: true
Layout.fillWidth: true
}
Label {
Layout.alignment: Qt.AlignHCenter
color: root.colorScheme.text_weak
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("%1 v%2 (%3)<br>© 2017-%4 %5<br>%6 %7<br>%8").arg(Backend.appname).arg(Backend.version).arg(Backend.tag).arg(Backend.buildYear()).arg(Backend.vendor).arg(link(Backend.licensePath, qsTr("License"))).arg(link(Backend.dependencyLicensesLink, qsTr("Dependencies"))).arg(link(Backend.releaseNotesLink, qsTr("Release notes")))
textFormat: Text.StyledText
type: Label.Caption
color: root.colorScheme.text_weak
textFormat: Text.StyledText
onLinkActivated: function (link) {
Qt.openUrlExternally(link);
}
horizontalAlignment: Text.AlignHCenter
text: qsTr("%1 v%2 (%3)<br>© 2017-%4 %5<br>%6 %7<br>%8").
arg(Backend.appname).
arg(Backend.version).
arg(Backend.tag).
arg(Backend.buildYear()).
arg(Backend.vendor).
arg(link(Backend.licensePath, qsTr("License"))).
arg(link(Backend.dependencyLicensesLink, qsTr("Dependencies"))).
arg(link(Backend.releaseNotesLink, qsTr("Release notes")))
onLinkActivated: function(link) { Qt.openUrlExternally(link) }
}
}

View File

@ -1,105 +1,116 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
SettingsView {
id: root
property bool _valuesChanged: keychainSelection.checkedButton && keychainSelection.checkedButton.text !== Backend.currentKeychain
function setDefaultValues() {
for (const bi in keychainSelection.buttons) {
const button = keychainSelection.buttons[bi];
if (button.text === Backend.currentKeychain) {
button.checked = true;
break;
}
}
}
fillHeight: false
Component.onCompleted: root.setDefaultValues()
onBack: {
root.setDefaultValues();
}
property bool _valuesChanged: keychainSelection.checkedButton && keychainSelection.checkedButton.text != Backend.currentKeychain
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr("Default keychain")
type: Label.Heading
}
Label {
Layout.fillWidth: true
color: root.colorScheme.text_weak
}
Label {
colorScheme: root.colorScheme
text: qsTr("Change which keychain Bridge uses as default")
type: Label.Body
color: root.colorScheme.text_weak
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
ColumnLayout {
spacing: 16
ButtonGroup {
id: keychainSelection
}
ButtonGroup{ id: keychainSelection }
Repeater {
model: Backend.availableKeychain
RadioButton {
ButtonGroup.group: keychainSelection
colorScheme: root.colorScheme
ButtonGroup.group: keychainSelection
text: modelData
}
}
}
Rectangle {
Layout.fillWidth: true
color: root.colorScheme.border_weak
height: 1
color: root.colorScheme.border_weak
}
RowLayout {
spacing: 12
Button {
id: submitButton
colorScheme: root.colorScheme
enabled: root._valuesChanged
text: qsTr("Save and restart")
enabled: root._valuesChanged
onClicked: {
Backend.changeKeychain(keychainSelection.checkedButton.text);
Backend.changeKeychain(keychainSelection.checkedButton.text)
}
}
Button {
colorScheme: root.colorScheme
secondary: true
text: qsTr("Cancel")
onClicked: root.back()
secondary: true
}
Connections {
function onChangeKeychainFinished() {
submitButton.loading = false;
root.back();
}
Connections {
target: Backend
function onChangeKeychainFinished() {
submitButton.loading = false
root.back()
}
}
}
onBack: {
root.setDefaultValues()
}
function setDefaultValues(){
for (var bi in keychainSelection.buttons){
var button = keychainSelection.buttons[bi]
if (button.text == Backend.currentKeychain) {
button.checked = true
break;
}
}
}
Component.onCompleted: root.setDefaultValues()
}

View File

@ -1,88 +1,81 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs
import Proton
SettingsView {
id: root
property url diskCachePath: pathDialog.shortcuts.home
property var notifications
function refresh() {
diskCacheSetting.description = Backend.nativePath(root.diskCachePath);
submitButton.enabled = (!submitButton.loading) && !Backend.areSameFileOrFolder(Backend.diskCachePath, root.diskCachePath);
}
function setDefaultValues() {
root.diskCachePath = Backend.diskCachePath;
root.refresh();
}
function submit() {
submitButton.loading = true;
Backend.setDiskCachePath(root.diskCachePath);
}
fillHeight: false
onBack: {
root.setDefaultValues();
}
onVisibleChanged: {
root.setDefaultValues();
property var notifications
property url diskCachePath: pathDialog.shortcuts.home
function refresh() {
diskCacheSetting.description = Backend.nativePath(root.diskCachePath)
submitButton.enabled = (!submitButton.loading) && !Backend.areSameFileOrFolder(Backend.diskCachePath, root.diskCachePath)
}
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr("Local cache")
type: Label.Heading
}
Label {
Layout.fillWidth: true
color: root.colorScheme.text_weak
}
Label {
colorScheme: root.colorScheme
text: qsTr("Bridge stores your encrypted messages locally to optimize communication with your client.")
type: Label.Body
color: root.colorScheme.text_weak
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
SettingsItem {
id: diskCacheSetting
Layout.fillWidth: true
actionText: qsTr("Change location")
colorScheme: root.colorScheme
descriptionWrap: Text.WrapAnywhere
text: qsTr("Current cache location")
actionText: qsTr("Change location")
descriptionWrap: Text.WrapAnywhere
type: SettingsItem.Button
onClicked: {
pathDialog.open();
pathDialog.open()
}
Layout.fillWidth: true
FolderDialog {
id: pathDialog
currentFolder: root.diskCachePath
title: qsTr("Select cache location")
currentFolder: root.diskCachePath
onAccepted: {
root.diskCachePath = pathDialog.selectedFolder;
root.refresh();
root.diskCachePath = pathDialog.selectedFolder
root.refresh()
}
}
}
}
RowLayout {
spacing: 12
@ -90,25 +83,43 @@ SettingsView {
id: submitButton
colorScheme: root.colorScheme
text: qsTr("Save")
onClicked: {
root.submit();
root.submit()
}
}
Button {
colorScheme: root.colorScheme
secondary: true
text: qsTr("Cancel")
onClicked: root.back()
secondary: true
}
Connections {
function onDiskCachePathChangeFinished() {
submitButton.loading = false;
root.setDefaultValues();
}
Connections {
target: Backend
function onDiskCachePathChangeFinished() {
submitButton.loading = false
root.setDefaultValues()
}
}
}
onBack: {
root.setDefaultValues()
}
function submit() {
submitButton.loading = true
Backend.setDiskCachePath(root.diskCachePath)
}
function setDefaultValues(){
root.diskCachePath = Backend.diskCachePath
root.refresh();
}
onVisibleChanged: {
root.setDefaultValues()
}
}

View File

@ -1,199 +1,232 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Window
import QtQuick.Layouts
import QtQuick.Controls
import Proton
import Notifications
ApplicationWindow {
id: root
property int _defaultHeight: 780
property int _defaultWidth: 1080
property var notifications
function selectUser(userID) {
contentWrapper.selectUser(userID);
}
function showAndRise() {
root.show();
root.raise();
if (!root.active) {
root.requestActivate();
}
}
function showHelp() {
contentWrapper.showHelp();
}
function showLocalCacheSettings() {
contentWrapper.showLocalCacheSettings();
}
function showSettings() {
contentWrapper.showSettings();
}
function showSetup(user, address) {
setupGuide.user = user;
setupGuide.address = address;
setupGuide.reset();
contentLayout._showSetup = !!setupGuide.user;
}
function showSignIn(username) {
if (contentLayout.currentIndex === 1)
return;
contentWrapper.showSignIn(username);
}
colorScheme: ProtonStyle.currentStyle
visible: true
property int _defaultWidth: 1080
property int _defaultHeight: 780
width: _defaultWidth
height: _defaultHeight
minimumWidth: _defaultWidth
visible: true
width: _defaultWidth
property var notifications
// show Setup Guide on every new user
Connections {
target: Backend.users
function onRowsInserted(parent, first, last) {
// considering that users are added one-by-one
var user = Backend.users.get(first)
if (user.state === EUserState.SignedOut) {
return
}
if (user.setupGuideSeen) {
return
}
root.showSetup(user,user.addresses[0])
}
function onRowsAboutToBeRemoved(parent, first, last) {
for (let i = first; i <= last; i++) {
const user = Backend.users.get(i);
for (var i = first; i <= last; i++ ) {
var user = Backend.users.get(i)
if (setupGuide.user === user) {
setupGuide.user = null;
contentLayout._showSetup = false;
return;
setupGuide.user = null
contentLayout._showSetup = false
return
}
}
}
function onRowsInserted(parent, first, _) {
// considering that users are added one-by-one
const user = Backend.users.get(first);
if (user.state === EUserState.SignedOut) {
return;
}
if (user.setupGuideSeen) {
return;
}
root.showSetup(user, user.addresses[0]);
}
target: Backend.users
}
Connections {
function onLoginFinished(index, wasSignedOut) {
const user = Backend.users.get(index);
if (user && !wasSignedOut) {
root.showSetup(user, user.addresses[0]);
}
console.debug("Login finished", index);
}
function onSelectUser(userID, forceShowWindow) {
contentWrapper.selectUser(userID);
if (forceShowWindow) {
root.showAndRise();
}
}
function onShowHelp() {
root.showHelp();
root.showAndRise();
}
target: Backend
function onShowMainWindow() {
root.showAndRise();
}
function onShowSettings() {
root.showSettings();
root.showAndRise();
root.showAndRise()
}
target: Backend
function onLoginFinished(index, wasSignedOut) {
var user = Backend.users.get(index)
if (user && !wasSignedOut) {
root.showSetup(user, user.addresses[0])
}
console.debug("Login finished", index)
}
function onShowHelp() {
root.showHelp()
root.showAndRise()
}
function onShowSettings() {
root.showSettings()
root.showAndRise()
}
function onSelectUser(userID, forceShowWindow) {
contentWrapper.selectUser(userID)
if (forceShowWindow) {
root.showAndRise()
}
}
}
StackLayout {
id: contentLayout
property bool _showSetup: false
anchors.fill: parent
property bool _showSetup: false
currentIndex: {
// show welcome when there are no users
if (Backend.users.count === 0) {
return 1;
return 1
}
const u = Backend.users.get(0);
var u = Backend.users.get(0)
if (!u) {
console.trace();
console.log("empty user");
return 1;
console.trace()
console.log("empty user")
return 1
}
if ((Backend.users.count === 1) && (u.state === EUserState.SignedOut)) {
showSignIn(u.primaryEmailOrUsername());
return 0;
showSignIn(u.primaryEmailOrUsername())
return 0
}
if (contentLayout._showSetup) {
return 2;
return 2
}
return 0;
return 0
}
ContentWrapper {
// 0
ContentWrapper { // 0
id: contentWrapper
Layout.fillHeight: true
Layout.fillWidth: true
colorScheme: root.colorScheme
notifications: root.notifications
onCloseWindow: {
root.close();
Layout.fillHeight: true
Layout.fillWidth: true
onShowSetupGuide: function(user, address) {
root.showSetup(user,address)
}
onCloseWindow: {
root.close()
}
onQuitBridge: {
// If we ever want to add a confirmation dialog before quitting:
//root.notifications.askQuestion("Quit Bridge", "Insert warning message here.", "Quit", "Cancel", Backend.quit, null)
root.close();
Backend.quit();
}
onShowSetupGuide: function (user, address) {
root.showSetup(user, address);
root.close()
Backend.quit()
}
}
WelcomeGuide {
Layout.fillHeight: true
Layout.fillWidth: true // 1
colorScheme: root.colorScheme
}
SetupGuide {
// 2
id: setupGuide
Layout.fillHeight: true
Layout.fillWidth: true
WelcomeGuide { // 1
colorScheme: root.colorScheme
Layout.fillHeight: true
Layout.fillWidth: true
}
SetupGuide { // 2
id: setupGuide
colorScheme: root.colorScheme
Layout.fillHeight: true
Layout.fillWidth: true
onDismissed: {
root.showSetup(null, "");
root.showSetup(null,"")
}
onFinished: {
// TODO: Do not close window. Trigger Backend to check that
// there is a successfully connected client. Then Backend
// should send another signal to close the setup guide.
root.showSetup(null, "");
root.showSetup(null,"")
}
}
}
NotificationPopups {
colorScheme: root.colorScheme
mainWindow: root
notifications: root.notifications
mainWindow: root
}
SplashScreen {
id: splashScreen
colorScheme: root.colorScheme
}
function showLocalCacheSettings() { contentWrapper.showLocalCacheSettings() }
function showSettings() { contentWrapper.showSettings() }
function showHelp() { contentWrapper.showHelp() }
function selectUser(userID) { contentWrapper.selectUser(userID) }
function showBugReportAndPrefill(message) {
contentWrapper.showBugReportAndPrefill(message)
}
function showSignIn(username) {
if (contentLayout.currentIndex == 1) return
contentWrapper.showSignIn(username)
}
function showSetup(user, address) {
setupGuide.user = user
setupGuide.address = address
setupGuide.reset()
if (setupGuide.user) {
contentLayout._showSetup = true
} else {
contentLayout._showSetup = false
}
}
function showAndRise() {
root.show()
root.raise()
if (!root.active) {
root.requestActivate()
}
}
}

View File

@ -1,99 +1,118 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
import Notifications
Dialog {
id: root
default property alias data: additionalChildrenContainer.children
property var notification
modal: true
shouldShow: notification && notification.active && !notification.dismissed
modal: true
default property alias data: additionalChildrenContainer.children
ColumnLayout {
spacing: 0
Image {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 16
sourceSize.width: 64
sourceSize.height: 64
Layout.preferredHeight: 64
Layout.preferredWidth: 64
Layout.bottomMargin: 16
visible: source != ""
source: {
if (!root.notification) {
return "";
return ""
}
switch (root.notification.type) {
case Notification.NotificationType.Info:
return "/qml/icons/ic-info.svg";
case Notification.NotificationType.Success:
return "/qml/icons/ic-success.svg";
case Notification.NotificationType.Warning:
case Notification.NotificationType.Danger:
return "/qml/icons/ic-alert.svg";
case Notification.NotificationType.Info:
return "/qml/icons/ic-info.svg"
case Notification.NotificationType.Success:
return "/qml/icons/ic-success.svg"
case Notification.NotificationType.Warning:
case Notification.NotificationType.Danger:
return "/qml/icons/ic-alert.svg"
}
}
sourceSize.height: 64
sourceSize.width: 64
visible: source != ""
}
Label {
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
Layout.bottomMargin: 8
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.title
type: Label.LabelType.Title
}
Label {
Layout.bottomMargin: 16
Layout.fillWidth: true
Layout.preferredWidth: 240
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.description
type: Label.LabelType.Body
wrapMode: Text.WordWrap
Layout.bottomMargin: 16
onLinkActivated: function (link) {
Qt.openUrlExternally(link);
}
colorScheme: root.colorScheme
text: root.notification.description
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
type: Label.LabelType.Body
onLinkActivated: function(link) { Qt.openUrlExternally(link) }
}
Item {
id: additionalChildrenContainer
Layout.bottomMargin: 16
Layout.fillWidth: true
Layout.bottomMargin: 16
visible: children.length > 0
implicitHeight: additionalChildrenContainer.childrenRect.height
implicitWidth: additionalChildrenContainer.childrenRect.width
visible: children.length > 0
}
ColumnLayout {
spacing: 8
Repeater {
model: root.notification.action
delegate: Button {
Layout.fillWidth: true
action: modelData
colorScheme: root.colorScheme
loading: modelData.loading
action: modelData
secondary: index > 0
loading: modelData.loading
}
}
}

View File

@ -1,19 +1,25 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
import Notifications
@ -21,98 +27,118 @@ Item {
id: root
property ColorScheme colorScheme
property var mainWindow
property int notificationBlacklist: NotificationFilter.FilterConsts.None
property int notificationWhitelist: NotificationFilter.FilterConsts.All
property var notifications
property var mainWindow
property int notificationWhitelist: NotificationFilter.FilterConsts.All
property int notificationBlacklist: NotificationFilter.FilterConsts.None
NotificationFilter {
id: bannerNotificationFilter
blacklist: Notifications.Group.Dialogs
source: root.notifications.all
blacklist: Notifications.Group.Dialogs
}
Banner {
colorScheme: root.colorScheme
mainWindow: root.mainWindow
notification: bannerNotificationFilter.topmost
mainWindow: root.mainWindow
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.updateManualReady
Switch {
id: autoUpdate
checked: Backend.isAutomaticUpdateOn
id:autoUpdate
colorScheme: root.colorScheme
text: qsTr("Update automatically in the future")
checked: Backend.isAutomaticUpdateOn
onClicked: Backend.toggleAutomaticUpdate(autoUpdate.checked)
}
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.updateForce
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.updateForceError
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.enableBeta
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.cacheUnavailable
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.cacheCantMove
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.diskFull
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.enableSplitMode
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.resetBridge
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.changeAllMailVisibility
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.deleteAccount
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.noKeychain
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.rebuildKeychain
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.apiCertIssue
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.noActiveKeyForRecipient
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.userBadEvent
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.genericError
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.genericQuestion

View File

@ -1,45 +1,54 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick.Controls
QtObject {
id: root
enum NotificationType {
Info,
Success,
Warning,
Danger
}
property list<Action> action
property bool active: false
// brief is used in status view only
property string brief
default property var children
property var data
// description is used in banners and in dialogs as description
property string description
property bool dismissed: false
property int group
property string icon
readonly property var occurred: active ? new Date() : undefined
enum NotificationType {
Info = 0,
Success = 1,
Warning = 2,
Danger = 3
}
// title is used in dialogs only
property string title
// description is used in banners and in dialogs as description
property string description
// brief is used in status view only
property string brief
property string icon
property list<Action> action
property int type
property int group
property bool dismissed: false
property bool active: false
readonly property var occurred: active ? new Date() : undefined
property var data
onActiveChanged: {
dismissed = false;
dismissed = false
}
}

View File

@ -1,95 +1,114 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQml.Models
// contains notifications that satisfy black- and whitelist and are sorted in time-occurred order
ListModel {
id: root
enum FilterConsts {
None,
None = 0,
All = 255
}
property int blacklist: NotificationFilter.FilterConsts.None
property bool componentCompleted: false
property var source
property Notification topmost
property int whitelist: NotificationFilter.FilterConsts.All
property int blacklist: NotificationFilter.FilterConsts.None
property Notification topmost
property var source
property bool componentCompleted: false
Component.onCompleted: {
root.componentCompleted = true
root.rebuildList()
}
// overriding get method to ignore any role and return directly object itself
function get(row) {
if (row < 0 || row >= count) {
return undefined;
return undefined
}
return data(index(row, 0), Qt.DisplayRole);
}
function rebuildList() {
let i;
// avoid evaluation of the list before Component.onCompleted
if (!root.componentCompleted) {
return;
}
for (i = 0; i < root.count; i++) {
root.get(i).onActiveChanged.disconnect(root.updateList);
}
root.clear();
if (!root.source) {
return;
}
for (i = 0; i < root.source.length; i++) {
const obj = root.source[i];
if (obj.group & root.blacklist) {
continue;
}
if (!(obj.group & root.whitelist)) {
continue;
}
root.append({
"obj": obj
});
obj.onActiveChanged.connect(root.updateList);
}
}
function updateList() {
let topmost = null;
for (let i = 0; i < root.count; i++) {
const obj = root.get(i);
if (!obj.active) {
continue;
}
if (topmost && (topmost.type > obj.type)) {
continue;
}
if (topmost && (topmost.type === obj.type) && (topmost.occurred > obj.occurred)) {
continue;
}
topmost = obj;
}
root.topmost = topmost;
return data(index(row, 0), Qt.DisplayRole)
}
Component.onCompleted: {
root.componentCompleted = true;
root.rebuildList();
function rebuildList() {
// avoid evaluation of the list before Component.onCompleted
if (!root.componentCompleted) {
return
}
for (var i = 0; i < root.count; i++) {
root.get(i).onActiveChanged.disconnect( root.updateList )
}
root.clear()
if (!root.source) {
return
}
for (i = 0; i < root.source.length; i++) {
var obj = root.source[i]
if (obj.group & root.blacklist) {
continue
}
if (!(obj.group & root.whitelist)) {
continue
}
root.append({obj})
obj.onActiveChanged.connect( root.updateList )
}
}
function updateList() {
var topmost = null
for (var i = 0; i < root.count; i++) {
var obj = root.get(i)
if (!obj.active) {
continue
}
if (topmost && (topmost.type > obj.type)) {
continue
}
if (topmost && (topmost.type === obj.type) && (topmost.occurred > obj.occurred)) {
continue
}
topmost = obj
}
root.topmost = topmost
}
onWhitelistChanged: {
root.rebuildList()
}
onBlacklistChanged: {
root.rebuildList();
root.rebuildList()
}
onSourceChanged: {
root.rebuildList();
}
onWhitelistChanged: {
root.rebuildList();
root.rebuildList()
}
}

View File

@ -1,157 +1,178 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
SettingsView {
id: root
property bool _valuesChanged: (imapField.text * 1 !== Backend.imapPort || smtpField.text * 1 !== Backend.smtpPort)
property var notifications
function isPortFree(field) {
const num = field.text * 1;
if (num === Backend.imapPort)
return true;
if (num === Backend.smtpPort)
return true;
if (!Backend.isPortFree(num)) {
field.error = true;
field.errorString = qsTr("Port occupied");
return false;
}
return true;
}
function setDefaultValues() {
imapField.text = Backend.imapPort;
smtpField.text = Backend.smtpPort;
imapField.error = false;
smtpField.error = false;
}
function validate(port) {
const num = port * 1;
if (!(num > 1 && num < 65536)) {
return qsTr("Invalid port number");
}
if (imapField.text === smtpField.text) {
return qsTr("Port numbers must be different");
}
}
fillHeight: false
Component.onCompleted: root.setDefaultValues()
onBack: {
root.setDefaultValues();
}
property var notifications
property bool _valuesChanged: (
imapField.text*1 !== Backend.imapPort ||
smtpField.text*1 !== Backend.smtpPort
)
Label {
Layout.fillWidth: true
colorScheme: root.colorScheme
text: qsTr("Default ports")
type: Label.Heading
}
Label {
Layout.fillWidth: true
color: root.colorScheme.text_weak
}
Label {
colorScheme: root.colorScheme
text: qsTr("Changes require reconfiguration of your email client.")
type: Label.Body
color: root.colorScheme.text_weak
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
RowLayout {
spacing: 16
TextField {
id: imapField
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
Layout.preferredWidth: 160
colorScheme: root.colorScheme
label: qsTr("IMAP port")
Layout.preferredWidth: 160
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
validator: root.validate
}
TextField {
id: smtpField
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
Layout.preferredWidth: 160
colorScheme: root.colorScheme
label: qsTr("SMTP port")
Layout.preferredWidth: 160
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
validator: root.validate
}
}
Rectangle {
Layout.fillWidth: true
color: root.colorScheme.border_weak
height: 1
color: root.colorScheme.border_weak
}
RowLayout {
spacing: 12
Button {
id: submitButton
colorScheme: root.colorScheme
enabled: (!loading) && root._valuesChanged
text: qsTr("Save")
enabled: (!loading) && root._valuesChanged
onClicked: {
// removing error here because we may have set it manually (port occupied)
imapField.error = false;
smtpField.error = false;
imapField.error = false
smtpField.error = false
// checking errors separately because we want to display "same port" error only once
imapField.validate();
imapField.validate()
if (imapField.error) {
return;
return
}
smtpField.validate();
smtpField.validate()
if (smtpField.error) {
return;
return
}
submitButton.loading = true;
submitButton.loading = true
// check both ports before returning an error
let err = false;
err |= !isPortFree(imapField);
err |= !isPortFree(smtpField);
var err = false
err |= !isPortFree(imapField)
err |= !isPortFree(smtpField)
if (err) {
submitButton.loading = false;
return;
submitButton.loading = false
return
}
// We turn off all port error notification. They will be restored if problems persist
root.notifications.imapPortStartupError.active = false;
root.notifications.smtpPortStartupError.active = false;
root.notifications.imapPortChangeError.active = false;
root.notifications.smtpPortChangeError.active = false;
Backend.setMailServerSettings(imapField.text, smtpField.text, Backend.useSSLForIMAP, Backend.useSSLForSMTP);
// We turn off all port error notification. They well be restored if problems persist
root.notifications.imapPortStartupError.active = false
root.notifications.smtpPortStartupError.active = false
root.notifications.imapPortChangeError.active = false
root.notifications.smtpPortChangeError.active = false
Backend.setMailServerSettings(imapField.text, smtpField.text, Backend.useSSLForIMAP, Backend.useSSLForSMTP)
}
}
Button {
colorScheme: root.colorScheme
secondary: true
text: qsTr("Cancel")
onClicked: root.back()
secondary: true
}
Connections {
function onChangeMailServerSettingsFinished() {
submitButton.loading = false;
}
Connections {
target: Backend
function onChangeMailServerSettingsFinished() {
submitButton.loading = false
}
}
}
onBack: {
root.setDefaultValues()
}
function validate(port) {
var num = port*1
if (! (num > 1 && num < 65536) ) {
return qsTr("Invalid port number")
}
if (imapField.text == smtpField.text) {
return qsTr("Port numbers must be different")
}
return
}
function isPortFree(field) {
var num = field.text*1
if (num === Backend.imapPort) return true
if (num === Backend.smtpPort) return true
if (!Backend.isPortFree(num)) {
field.error = true
field.errorString = qsTr("Port occupied")
return false
}
return true
}
function setDefaultValues(){
imapField.text = Backend.imapPort
smtpField.text = Backend.smtpPort
imapField.error = false
smtpField.error = false
}
Component.onCompleted: root.setDefaultValues()
}

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Templates as T

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Window
@ -20,14 +25,14 @@ import QtQuick.Templates as T
T.ApplicationWindow {
id: root
property ColorScheme colorScheme
// popup priority based on types
enum PopupType {
Banner,
Dialog
Banner = 0,
Dialog = 1
}
property ColorScheme colorScheme
// contains currently visible popup
property var popupVisible: null
@ -36,61 +41,85 @@ T.ApplicationWindow {
// overriding get method to ignore any role and return directly object itself
function get(row) {
if (row < 0 || row >= count) {
return undefined;
return undefined
}
return data(index(row, 0), Qt.DisplayRole);
return data(index(row, 0), Qt.DisplayRole)
}
onRowsInserted: function(parent, first, last) {
for (var i = first; i <= last; i++) {
var obj = popups.get(i)
obj.onShouldShowChanged.connect( root.processPopups )
}
processPopups()
}
onRowsAboutToBeRemoved: function (parent, first, last) {
for (let i = first; i <= last; i++) {
const obj = popups.get(i);
obj.onShouldShowChanged.disconnect(root.processPopups);
for (var i = first; i <= last; i++ ) {
var obj = popups.get(i)
obj.onShouldShowChanged.disconnect( root.processPopups )
// if currently visible popup was removed
if (root.popupVisible === obj) {
root.popupVisible.visible = false;
root.popupVisible = null;
root.popupVisible.visible = false
root.popupVisible = null
}
}
processPopups();
}
onRowsInserted: function (parent, first, last) {
for (let i = first; i <= last; i++) {
const obj = popups.get(i);
obj.onShouldShowChanged.connect(root.processPopups);
}
processPopups();
processPopups()
}
}
function processPopups() {
if ((root.popupVisible) && (!root.popupVisible.shouldShow)) {
root.popupVisible.visible = false;
root.popupVisible.visible = false
}
let topmost = null;
for (let i = 0; i < popups.count; i++) {
const obj = popups.get(i);
var topmost = null
for (var i = 0; i < popups.count; i++) {
var obj = popups.get(i)
if (obj.shouldShow === false) {
continue;
continue
}
if (topmost && (topmost.popupType > obj.popupType)) {
continue;
continue
}
if (topmost && (topmost.popupType === obj.popupType) && (topmost.occurred > obj.occurred)) {
continue;
continue
}
topmost = obj;
topmost = obj
}
if (root.popupVisible !== topmost) {
if (root.popupVisible) {
root.popupVisible.visible = false;
root.popupVisible.visible = false
}
root.popupVisible = topmost;
root.popupVisible = topmost
}
if (!root.popupVisible) {
return;
return
}
root.popupVisible.visible = true
}
Connections {
target: root.popupVisible
function onVisibleChanged() {
if (root.popupVisible.visible) {
return
}
root.popupVisible = null
root.processPopups()
}
root.popupVisible.visible = true;
}
color: root.colorScheme.background_norm
@ -98,19 +127,8 @@ T.ApplicationWindow {
Overlay.modal: Rectangle {
color: root.colorScheme.backdrop_norm
}
Overlay.modeless: Rectangle {
color: "transparent"
}
Connections {
function onVisibleChanged() {
if (root.popupVisible.visible) {
return;
}
root.popupVisible = null;
root.processPopups();
}
target: root.popupVisible
}
}

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
@ -18,169 +23,212 @@ import QtQuick.Layouts
import "." as Proton
T.Button {
id: control
property ColorScheme colorScheme
property alias secondary: control.flat
readonly property bool primary: !secondary
readonly property bool isIcon: control.text === ""
readonly property bool hasTextAndIcon: (control.text !== "") && (iconImage.source.toString().length > 0)
property bool loading: false
property bool borderless: false
property ColorScheme colorScheme
readonly property bool hasTextAndIcon: (control.text !== "") && (iconImage.source.toString().length > 0)
readonly property bool isIcon: control.text === ""
property int labelType: Proton.Label.LabelType.Body
property bool loading: false
readonly property bool primary: !secondary
property alias secondary: control.flat
property alias textHorizontalAlignment: label.horizontalAlignment
property alias textVerticalAlignment: label.verticalAlignment
font: label.font
horizontalPadding: 16
icon.color: {
if (primary && !isIcon) {
return "#FFFFFF";
} else {
return control.colorScheme.text_norm;
}
}
icon.height: 16
icon.width: 16
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, implicitContentHeight + topPadding + bottomPadding)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, implicitContentWidth + leftPadding + rightPadding)
property int labelType: Proton.Label.LabelType.Body
property alias textVerticalAlignment: label.verticalAlignment
property alias textHorizontalAlignment: label.horizontalAlignment
id: control
implicitWidth: Math.max(
implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding
)
implicitHeight: Math.max(
implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding
)
padding: 8
horizontalPadding: 16
spacing: 10
background: Rectangle {
border.color: {
return control.colorScheme.border_norm;
font: label.font
icon.width: 16
icon.height: 16
icon.color: {
if (primary && !isIcon) {
return "#FFFFFF"
} else {
return control.colorScheme.text_norm
}
border.width: secondary && !borderless ? 1 : 0
color: {
if (!isIcon) {
if (primary) {
// Primary colors
if (control.down) {
return control.colorScheme.interaction_norm_active;
}
if (control.enabled && (control.highlighted || control.hovered || control.checked || control.activeFocus)) {
return control.colorScheme.interaction_norm_hover;
}
if (control.loading) {
return control.colorScheme.interaction_norm_hover;
}
return control.colorScheme.interaction_norm;
} else {
// Secondary colors
if (control.down) {
return control.colorScheme.interaction_default_active;
}
if (control.enabled && (control.highlighted || control.hovered || control.checked || control.activeFocus)) {
return control.colorScheme.interaction_default_hover;
}
if (control.loading) {
return control.colorScheme.interaction_default_hover;
}
return control.colorScheme.interaction_default;
}
} else {
if (primary) {
// Primary icon colors
if (control.down) {
return control.colorScheme.interaction_default_active;
}
if (control.enabled && (control.highlighted || control.hovered || control.checked || control.activeFocus)) {
return control.colorScheme.interaction_default_hover;
}
if (control.loading) {
return control.colorScheme.interaction_default_hover;
}
return control.colorScheme.interaction_default;
} else {
// Secondary icon colors
if (control.down) {
return control.colorScheme.interaction_default_active;
}
if (control.enabled && (control.highlighted || control.hovered || control.checked || control.activeFocus)) {
return control.colorScheme.interaction_default_hover;
}
if (control.loading) {
return control.colorScheme.interaction_default_hover;
}
return control.colorScheme.interaction_default;
}
}
}
implicitHeight: 36
implicitWidth: 36
opacity: control.enabled || control.loading ? 1.0 : 0.5
radius: ProtonStyle.button_radius
visible: true
}
contentItem: RowLayout {
id: _contentItem
spacing: control.hasTextAndIcon ? control.spacing : 0
Proton.Label {
id: label
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
color: {
if (primary && !isIcon) {
return "#FFFFFF";
} else {
return control.colorScheme.text_norm;
}
}
colorScheme: root.colorScheme
id: label
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
elide: Text.ElideRight
horizontalAlignment: Qt.AlignHCenter
opacity: control.enabled || control.loading ? 1.0 : 0.5
text: control.text
type: labelType
visible: !control.isIcon
text: control.text
color: {
if (primary && !isIcon) {
return "#FFFFFF"
} else {
return control.colorScheme.text_norm
}
}
opacity: control.enabled || control.loading ? 1.0 : 0.5
type: labelType
}
ColorImage {
id: iconImage
Layout.alignment: Qt.AlignCenter
color: control.icon.color
height: {
if (control.loading) {
return width;
}
Math.min(control.icon.height, availableHeight);
}
source: control.loading ? "/qml/icons/Loader_16.svg" : control.icon.source
sourceSize.height: control.icon.height
sourceSize.width: control.icon.width
visible: control.loading || control.icon.source
width: {
// special case for loading since we want icon to be square for rotation animation
if (control.loading) {
return Math.min(control.icon.width, availableWidth, control.icon.height, availableHeight);
return Math.min(control.icon.width, availableWidth, control.icon.height, availableHeight)
}
return Math.min(control.icon.width, availableWidth);
return Math.min(control.icon.width, availableWidth)
}
height: {
if (control.loading) {
return width
}
Math.min(control.icon.height, availableHeight)
}
sourceSize.width: control.icon.width
sourceSize.height: control.icon.height
color: control.icon.color
source: control.loading ? "/qml/icons/Loader_16.svg" : control.icon.source
visible: control.loading || control.icon.source
RotationAnimation {
direction: RotationAnimation.Clockwise
target: iconImage
loops: Animation.Infinite
duration: 1000
from: 0
loops: Animation.Infinite
running: control.loading
target: iconImage
to: 360
direction: RotationAnimation.Clockwise
running: control.loading
}
}
}
background: Rectangle {
implicitWidth: 36
implicitHeight: 36
radius: ProtonStyle.button_radius
visible: true
color: {
if (!isIcon) {
if (primary) {
// Primary colors
if (control.down) {
return control.colorScheme.interaction_norm_active
}
if (control.enabled && (control.highlighted || control.hovered || control.checked || control.activeFocus)) {
return control.colorScheme.interaction_norm_hover
}
if (control.loading) {
return control.colorScheme.interaction_norm_hover
}
return control.colorScheme.interaction_norm
} else {
// Secondary colors
if (control.down) {
return control.colorScheme.interaction_default_active
}
if (control.enabled && (control.highlighted || control.hovered || control.checked || control.activeFocus)) {
return control.colorScheme.interaction_default_hover
}
if (control.loading) {
return control.colorScheme.interaction_default_hover
}
return control.colorScheme.interaction_default
}
} else {
if (primary) {
// Primary icon colors
if (control.down) {
return control.colorScheme.interaction_default_active
}
if (control.enabled && (control.highlighted || control.hovered || control.checked || control.activeFocus)) {
return control.colorScheme.interaction_default_hover
}
if (control.loading) {
return control.colorScheme.interaction_default_hover
}
return control.colorScheme.interaction_default
} else {
// Secondary icon colors
if (control.down) {
return control.colorScheme.interaction_default_active
}
if (control.enabled && (control.highlighted || control.hovered || control.checked || control.activeFocus)) {
return control.colorScheme.interaction_default_hover
}
if (control.loading) {
return control.colorScheme.interaction_default_hover
}
return control.colorScheme.interaction_default
}
}
}
border.color: {
return control.colorScheme.border_norm
}
border.width: secondary && !borderless ? 1 : 0
opacity: control.enabled || control.loading ? 1.0 : 0.5
}
Component.onCompleted: {
if (!control.colorScheme) {
console.trace();
let next = root;
for (let i = 0; i < 1000; i++) {
console.log(i, next, "colorscheme", next.colorScheme);
next = next.parent;
if (!next)
break;
console.trace()
var next = root
for (var i = 0; i<1000; i++) {
console.log(i, next, "colorscheme", next.colorScheme)
next = next.parent
if (!next) break
}
console.error("ColorScheme not defined");
console.error("ColorScheme not defined")
}
}
}

View File

@ -1,96 +1,97 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Templates as T
T.CheckBox {
id: control
property ColorScheme colorScheme
property bool error: false
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, implicitContentHeight + topPadding + bottomPadding, implicitIndicatorHeight + topPadding + bottomPadding)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, implicitContentWidth + leftPadding + rightPadding)
id: control
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding,
implicitIndicatorHeight + topPadding + bottomPadding)
padding: 0
spacing: 8
contentItem: CheckLabel {
color: {
if (!enabled) {
return control.colorScheme.text_disabled;
}
if (error) {
return control.colorScheme.signal_danger;
}
return control.colorScheme.text_norm;
}
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.body_letter_spacing
font.pixelSize: ProtonStyle.body_font_size
font.weight: ProtonStyle.fontWeight_400
leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0
lineHeight: ProtonStyle.body_line_height
lineHeightMode: Text.FixedHeight
rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0
text: control.text
}
indicator: Rectangle {
border.color: {
if (!control.enabled) {
return control.colorScheme.field_disabled;
}
if (control.error) {
return control.colorScheme.signal_danger;
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover;
}
return control.colorScheme.field_norm;
}
border.width: control.checked ? 0 : 1
color: {
if (!checked) {
return control.colorScheme.background_norm;
}
if (!control.enabled) {
return control.colorScheme.field_disabled;
}
if (control.error) {
return control.colorScheme.signal_danger;
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover;
}
return control.colorScheme.interaction_norm;
}
implicitHeight: 20
implicitWidth: 20
implicitHeight: 20
radius: ProtonStyle.checkbox_radius
x: text ? (control.mirrored ? control.width - width - control.rightPadding : control.leftPadding) : control.leftPadding + (control.availableWidth - width) / 2
y: control.topPadding + (control.availableHeight - height) / 2
color: {
if (!checked) {
return control.colorScheme.background_norm
}
if (!control.enabled) {
return control.colorScheme.field_disabled
}
if (control.error) {
return control.colorScheme.signal_danger
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover
}
return control.colorScheme.interaction_norm
}
border.width: control.checked ? 0 : 1
border.color: {
if (!control.enabled) {
return control.colorScheme.field_disabled
}
if (control.error) {
return control.colorScheme.signal_danger
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover
}
return control.colorScheme.field_norm
}
ColorImage {
color: "#FFFFFF"
height: parent.height - 4
source: "/qml/icons/ic-check.svg"
sourceSize.height: parent.height - 4
sourceSize.width: parent.width - 4
visible: control.checkState === Qt.Checked
width: parent.width - 4
x: (parent.width - width) / 2
y: (parent.height - height) / 2
width: parent.width - 4
height: parent.height - 4
sourceSize.width: parent.width - 4
sourceSize.height: parent.height - 4
color: "#FFFFFF"
source: "/qml/icons/ic-check.svg"
visible: control.checkState === Qt.Checked
}
// TODO: do we need PartiallyChecked state?
@ -104,4 +105,30 @@ T.CheckBox {
// visible: control.checkState === Qt.PartiallyChecked
//}
}
contentItem: CheckLabel {
leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0
rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0
text: control.text
color: {
if (!enabled) {
return control.colorScheme.text_disabled
}
if (error) {
return control.colorScheme.signal_danger
}
return control.colorScheme.text_norm
}
font.family: ProtonStyle.font_family
font.weight: ProtonStyle.fontWeight_400
font.pixelSize: ProtonStyle.body_font_size
lineHeight: ProtonStyle.body_line_height
lineHeightMode: Text.FixedHeight
font.letterSpacing: ProtonStyle.body_letter_spacing
}
}

View File

@ -1,88 +1,93 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQml
QtObject {
// Backdrop
property color backdrop_norm
property color background_avatar
// Background
property color background_norm
property color background_strong
property color background_weak
// Border
property color border_norm
property color border_weak
property color field_disabled
property color field_hover
// Field
property color field_norm
// Interaction-default
property color interaction_default
property color interaction_default_active
property color interaction_default_hover
// Interaction-norm
property color interaction_norm
property color interaction_norm_active
property color interaction_norm_hover
// Interaction-weak
property color interaction_weak
property color interaction_weak_active
property color interaction_weak_hover
property string logo_img
// should be a pointer to ColorScheme object
property var prominent
// Primary
property color primary_norm
// should be a pointer to ColorScheme object
property var prominent
property color scrollbar_hover
// Scrollbar
property color scrollbar_norm
property color shadow_lifted
// Shadows
property color shadow_norm
// Signal
property color signal_danger
property color signal_danger_active
property color signal_danger_hover
property color signal_info
property color signal_info_active
property color signal_info_hover
property color signal_success
property color signal_success_active
property color signal_success_hover
property color signal_warning
property color signal_warning_active
property color signal_warning_hover
property color text_disabled
property color text_hint
property color text_invert
// Interaction-norm
property color interaction_norm
property color interaction_norm_hover
property color interaction_norm_active
// Text
property color text_norm
property color text_weak
property color text_hint
property color text_disabled
property color text_invert
// Field
property color field_norm
property color field_hover
property color field_disabled
// Border
property color border_norm
property color border_weak
// Background
property color background_norm
property color background_weak
property color background_strong
property color background_avatar
// Interaction-weak
property color interaction_weak
property color interaction_weak_hover
property color interaction_weak_active
// Interaction-default
property color interaction_default
property color interaction_default_hover
property color interaction_default_active
// Scrollbar
property color scrollbar_norm
property color scrollbar_hover
// Signal
property color signal_danger
property color signal_danger_hover
property color signal_danger_active
property color signal_warning
property color signal_warning_hover
property color signal_warning_active
property color signal_success
property color signal_success_hover
property color signal_success_active
property color signal_info
property color signal_info_hover
property color signal_info_active
// Shadows
property color shadow_norm
property color shadow_lifted
// Backdrop
property color backdrop_norm
// Images
property string welcome_img
property string logo_img
}

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Window
import QtQuick.Controls
@ -21,124 +26,148 @@ T.ComboBox {
property ColorScheme colorScheme
bottomPadding: 5
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.body_letter_spacing
font.pixelSize: ProtonStyle.body_font_size
font.weight: ProtonStyle.fontWeight_400
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, implicitContentHeight + topPadding + bottomPadding, implicitIndicatorHeight + topPadding + bottomPadding)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, implicitContentWidth + leftPadding + rightPadding)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding,
implicitIndicatorHeight + topPadding + bottomPadding)
leftPadding: 12 + (!root.mirrored || !indicator || !indicator.visible ? 0 : indicator.width + spacing)
rightPadding: 12 + (root.mirrored || !indicator || !indicator.visible ? 0 : indicator.width + spacing)
spacing: 8
topPadding: 5
background: Rectangle {
border.color: root.colorScheme.border_norm
border.width: 1
color: {
if (root.down) {
return root.colorScheme.interaction_default_active;
}
if (root.enabled && root.hovered || root.activeFocus) {
return root.colorScheme.interaction_default_hover;
}
if (!root.enabled) {
return root.colorScheme.interaction_default;
}
return root.colorScheme.background_norm;
}
implicitHeight: 36
implicitWidth: 140
radius: ProtonStyle.context_item_radius
}
topPadding: 5
bottomPadding: 5
spacing: 8
font.family: ProtonStyle.font_family
font.weight: ProtonStyle.fontWeight_400
font.pixelSize: ProtonStyle.body_font_size
font.letterSpacing: ProtonStyle.body_letter_spacing
contentItem: T.TextField {
autoScroll: root.editable
color: root.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
enabled: root.editable
font: root.font
inputMethodHints: root.inputMethodHints
padding: 5
placeholderTextColor: root.enabled ? root.colorScheme.text_hint : root.colorScheme.text_disabled
readOnly: root.down
selectedTextColor: root.colorScheme.text_invert
selectionColor: root.colorScheme.interaction_norm
text: root.editable ? root.editText : root.displayText
font: root.font
enabled: root.editable
autoScroll: root.editable
readOnly: root.down
inputMethodHints: root.inputMethodHints
validator: root.validator
verticalAlignment: TextInput.AlignVCenter
color: root.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
selectionColor: root.colorScheme.interaction_norm
selectedTextColor: root.colorScheme.text_invert
placeholderTextColor: root.enabled ? root.colorScheme.text_hint : root.colorScheme.text_disabled
background: Rectangle {
border.color: {
if (root.activeFocus) {
return root.colorScheme.interaction_norm;
}
if (root.hovered || root.activeFocus) {
return root.colorScheme.field_hover;
}
return root.colorScheme.field_norm;
}
border.width: 1
color: root.colorScheme.background_norm
radius: ProtonStyle.context_item_radius
visible: root.enabled && root.editable && !root.flat
}
}
delegate: ItemDelegate {
property bool selected: root.currentIndex === index
font: root.font
highlighted: root.highlightedIndex === index
hoverEnabled: root.hoverEnabled
palette.highlightedText: selected ? root.colorScheme.text_invert : root.colorScheme.text_norm
palette.text: {
if (!root.enabled) {
return root.colorScheme.text_disabled;
}
if (selected) {
return root.colorScheme.text_invert;
}
return root.colorScheme.text_norm;
}
text: root.textRole ? (Array.isArray(root.model) ? modelData[root.textRole] : model[root.textRole]) : modelData
width: parent.width
background: PaddedRectangle {
color: {
if (parent.down) {
return root.colorScheme.interaction_default_active;
border.color: {
if (root.activeFocus) {
return root.colorScheme.interaction_norm
}
if (parent.selected) {
return root.colorScheme.interaction_norm;
}
if (parent.hovered || parent.highlighted) {
return root.colorScheme.interaction_default_hover;
}
return root.colorScheme.interaction_default;
}
radius: ProtonStyle.context_item_radius
}
}
indicator: ColorImage {
color: root.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
source: popup.visible ? "/qml/icons/ic-chevron-up.svg" : "/qml/icons/ic-chevron-down.svg"
sourceSize.height: 16
sourceSize.width: 16
x: root.mirrored ? 12 : root.width - width - 12
y: root.topPadding + (root.availableHeight - height) / 2
}
popup: T.Popup {
bottomMargin: 8
height: Math.min(contentItem.implicitHeight, root.Window.height - topMargin - bottomMargin)
topMargin: 8
width: root.width
y: root.height
background: Rectangle {
border.color: root.colorScheme.border_weak
if (root.hovered || root.activeFocus) {
return root.colorScheme.field_hover
}
return root.colorScheme.field_norm
}
border.width: 1
color: root.colorScheme.background_norm
radius: ProtonStyle.dialog_radius
}
}
background: Rectangle {
implicitWidth: 140
implicitHeight: 36
radius: ProtonStyle.context_item_radius
color: {
if (root.down) {
return root.colorScheme.interaction_default_active
}
if (root.enabled && root.hovered || root.activeFocus) {
return root.colorScheme.interaction_default_hover
}
if (!root.enabled) {
return root.colorScheme.interaction_default
}
return root.colorScheme.background_norm
}
border.color: root.colorScheme.border_norm
border.width: 1
}
indicator: ColorImage {
x: root.mirrored ? 12 : root.width - width - 12
y: root.topPadding + (root.availableHeight - height) / 2
color: root.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
source: popup.visible ? "/qml/icons/ic-chevron-up.svg" : "/qml/icons/ic-chevron-down.svg"
sourceSize.width: 16
sourceSize.height: 16
}
delegate: ItemDelegate {
width: parent.width
text: root.textRole ? (Array.isArray(root.model) ? modelData[root.textRole] : model[root.textRole]) : modelData
palette.text: {
if (!root.enabled) {
return root.colorScheme.text_disabled
}
if (selected) {
return root.colorScheme.text_invert
}
return root.colorScheme.text_norm
}
font: root.font
hoverEnabled: root.hoverEnabled
property bool selected: root.currentIndex === index
highlighted: root.highlightedIndex === index
palette.highlightedText: selected ? root.colorScheme.text_invert : root.colorScheme.text_norm
background: PaddedRectangle {
radius: ProtonStyle.context_item_radius
color: {
if (parent.down) {
return root.colorScheme.interaction_default_active
}
if (parent.selected) {
return root.colorScheme.interaction_norm
}
if (parent.hovered || parent.highlighted) {
return root.colorScheme.interaction_default_hover
}
return root.colorScheme.interaction_default
}
}
}
popup: T.Popup {
y: root.height
width: root.width
height: Math.min(contentItem.implicitHeight, root.Window.height - topMargin - bottomMargin)
topMargin: 8
bottomMargin: 8
contentItem: Item {
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
implicitWidth: children[0].implicitWidth + children[0].anchors.leftMargin + children[0].anchors.rightMargin
@ -146,14 +175,21 @@ T.ComboBox {
ListView {
anchors.fill: parent
anchors.margins: 8
currentIndex: root.highlightedIndex
implicitHeight: contentHeight
model: root.delegateModel
currentIndex: root.highlightedIndex
spacing: 4
T.ScrollIndicator.vertical: ScrollIndicator {
}
T.ScrollIndicator.vertical: ScrollIndicator { }
}
}
background: Rectangle {
color: root.colorScheme.background_norm
radius: ProtonStyle.dialog_radius
border.color: root.colorScheme.border_weak
border.width: 1
}
}
}

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Templates as T
@ -18,46 +23,58 @@ import QtQuick.Controls.impl
T.Dialog {
id: root
property ColorScheme colorScheme
readonly property var occurred: shouldShow ? new Date() : undefined
Component.onCompleted: {
if (!ApplicationWindow.window) {
return
}
if (ApplicationWindow.window.popups === undefined) {
return
}
var obj = this
ApplicationWindow.window.popups.append( { obj } )
}
readonly property int popupType: ApplicationWindow.PopupType.Dialog
property bool shouldShow: false
readonly property var occurred: shouldShow ? new Date() : undefined
function open() {
root.shouldShow = true
}
function close() {
root.shouldShow = false;
}
function open() {
root.shouldShow = true;
root.shouldShow = false
}
anchors.centerIn: Overlay.overlay
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding + (implicitHeaderHeight > 0 ? implicitHeaderHeight + spacing : 0) + (implicitFooterHeight > 0 ? implicitFooterHeight + spacing : 0))
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, contentWidth + leftPadding + rightPadding, implicitHeaderWidth, implicitFooterWidth)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
contentWidth + leftPadding + rightPadding,
implicitHeaderWidth,
implicitFooterWidth)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
contentHeight + topPadding + bottomPadding
+ (implicitHeaderHeight > 0 ? implicitHeaderHeight + spacing : 0)
+ (implicitFooterHeight > 0 ? implicitFooterHeight + spacing : 0))
padding: 24
// TODO: Add DropShadow here
T.Overlay.modal: Rectangle {
color: root.colorScheme.backdrop_norm
}
T.Overlay.modeless: Rectangle {
color: "transparent"
}
background: Rectangle {
color: root.colorScheme.background_norm
radius: ProtonStyle.dialog_radius
}
Component.onCompleted: {
if (!ApplicationWindow.window) {
return;
}
if (ApplicationWindow.window.popups === undefined) {
return;
}
const obj = this;
ApplicationWindow.window.popups.append({
"obj": obj
});
// TODO: Add DropShadow here
T.Overlay.modal: Rectangle {
color: root.colorScheme.backdrop_norm
}
T.Overlay.modeless: Rectangle {
color: "transparent"
}
}

View File

@ -1,23 +1,32 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Templates as T
import "." as Proton
T.Label {
id: root
property ColorScheme colorScheme
enum LabelType {
// weight 700, size 28, height 36
Heading,
@ -38,92 +47,96 @@ T.Label {
// weight 700, size 12, height 16, spacing 0.4
Caption_bold
}
property ColorScheme colorScheme
property int type: Proton.Label.LabelType.Body
function link(url, text) {
return `<a href="${url}">${text}</a>`;
color: root.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
linkColor: root.colorScheme.interaction_norm
palette.link: linkColor
font.family: ProtonStyle.font_family
lineHeightMode: Text.FixedHeight
font.weight: {
switch (root.type) {
case Proton.Label.LabelType.Heading:
return ProtonStyle.fontWeight_700
case Proton.Label.LabelType.Title:
return ProtonStyle.fontWeight_700
case Proton.Label.LabelType.Lead:
return ProtonStyle.fontWeight_400
case Proton.Label.LabelType.Body:
return ProtonStyle.fontWeight_400
case Proton.Label.LabelType.Body_semibold:
return ProtonStyle.fontWeight_600
case Proton.Label.LabelType.Body_bold:
return ProtonStyle.fontWeight_700
case Proton.Label.LabelType.Caption:
return ProtonStyle.fontWeight_400
case Proton.Label.LabelType.Caption_semibold:
return ProtonStyle.fontWeight_600
case Proton.Label.LabelType.Caption_bold:
return ProtonStyle.fontWeight_700
}
}
font.pixelSize: {
switch (root.type) {
case Proton.Label.LabelType.Heading:
return ProtonStyle.heading_font_size
case Proton.Label.LabelType.Title:
return ProtonStyle.title_font_size
case Proton.Label.LabelType.Lead:
return ProtonStyle.lead_font_size
case Proton.Label.LabelType.Body:
case Proton.Label.LabelType.Body_semibold:
case Proton.Label.LabelType.Body_bold:
return ProtonStyle.body_font_size
case Proton.Label.LabelType.Caption:
case Proton.Label.LabelType.Caption_semibold:
case Proton.Label.LabelType.Caption_bold:
return ProtonStyle.caption_font_size
}
}
lineHeight: {
switch (root.type) {
case Proton.Label.LabelType.Heading:
return ProtonStyle.heading_line_height
case Proton.Label.LabelType.Title:
return ProtonStyle.title_line_height
case Proton.Label.LabelType.Lead:
return ProtonStyle.lead_line_height
case Proton.Label.LabelType.Body:
case Proton.Label.LabelType.Body_semibold:
case Proton.Label.LabelType.Body_bold:
return ProtonStyle.body_line_height
case Proton.Label.LabelType.Caption:
case Proton.Label.LabelType.Caption_semibold:
case Proton.Label.LabelType.Caption_bold:
return ProtonStyle.caption_line_height
}
}
color: root.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
font.family: ProtonStyle.font_family
font.letterSpacing: {
switch (root.type) {
case Proton.Label.LabelType.Heading:
case Proton.Label.LabelType.Title:
case Proton.Label.LabelType.Lead:
return 0;
return 0
case Proton.Label.LabelType.Body:
case Proton.Label.LabelType.Body_semibold:
case Proton.Label.LabelType.Body_bold:
return ProtonStyle.body_letter_spacing;
return ProtonStyle.body_letter_spacing
case Proton.Label.LabelType.Caption:
case Proton.Label.LabelType.Caption_semibold:
case Proton.Label.LabelType.Caption_bold:
return ProtonStyle.caption_letter_spacing;
return ProtonStyle.caption_letter_spacing
}
}
font.pixelSize: {
switch (root.type) {
case Proton.Label.LabelType.Heading:
return ProtonStyle.heading_font_size;
case Proton.Label.LabelType.Title:
return ProtonStyle.title_font_size;
case Proton.Label.LabelType.Lead:
return ProtonStyle.lead_font_size;
case Proton.Label.LabelType.Body:
case Proton.Label.LabelType.Body_semibold:
case Proton.Label.LabelType.Body_bold:
return ProtonStyle.body_font_size;
case Proton.Label.LabelType.Caption:
case Proton.Label.LabelType.Caption_semibold:
case Proton.Label.LabelType.Caption_bold:
return ProtonStyle.caption_font_size;
}
}
font.weight: {
switch (root.type) {
case Proton.Label.LabelType.Heading:
return ProtonStyle.fontWeight_700;
case Proton.Label.LabelType.Title:
return ProtonStyle.fontWeight_700;
case Proton.Label.LabelType.Lead:
return ProtonStyle.fontWeight_400;
case Proton.Label.LabelType.Body:
return ProtonStyle.fontWeight_400;
case Proton.Label.LabelType.Body_semibold:
return ProtonStyle.fontWeight_600;
case Proton.Label.LabelType.Body_bold:
return ProtonStyle.fontWeight_700;
case Proton.Label.LabelType.Caption:
return ProtonStyle.fontWeight_400;
case Proton.Label.LabelType.Caption_semibold:
return ProtonStyle.fontWeight_600;
case Proton.Label.LabelType.Caption_bold:
return ProtonStyle.fontWeight_700;
}
}
lineHeight: {
switch (root.type) {
case Proton.Label.LabelType.Heading:
return ProtonStyle.heading_line_height;
case Proton.Label.LabelType.Title:
return ProtonStyle.title_line_height;
case Proton.Label.LabelType.Lead:
return ProtonStyle.lead_line_height;
case Proton.Label.LabelType.Body:
case Proton.Label.LabelType.Body_semibold:
case Proton.Label.LabelType.Body_bold:
return ProtonStyle.body_line_height;
case Proton.Label.LabelType.Caption:
case Proton.Label.LabelType.Caption_semibold:
case Proton.Label.LabelType.Caption_bold:
return ProtonStyle.caption_line_height;
}
}
lineHeightMode: Text.FixedHeight
linkColor: root.colorScheme.interaction_norm
palette.link: linkColor
verticalAlignment: Text.AlignBottom
function link(url, text) {
return `<a href="${url}">${text}</a>`
}
}

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
@ -22,19 +27,22 @@ T.Menu {
property ColorScheme colorScheme
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, contentWidth + leftPadding + rightPadding)
implicitWidth: Math.max(
implicitBackgroundWidth + leftInset + rightInset,
contentWidth + leftPadding + rightPadding
)
implicitHeight: Math.max(
implicitBackgroundHeight + topInset + bottomInset,
contentHeight + topPadding + bottomPadding
)
margins: 0
overlap: 1
background: Rectangle {
border.color: colorScheme.border_weak
border.width: 1
color: colorScheme.background_norm
implicitHeight: 40
implicitWidth: 200
radius: ProtonStyle.account_row_radius
delegate: MenuItem {
colorScheme: control.colorScheme
}
contentItem: Item {
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
implicitWidth: children[0].implicitWidth + children[0].anchors.leftMargin + children[0].anchors.rightMargin
@ -42,17 +50,23 @@ T.Menu {
ListView {
anchors.fill: parent
anchors.margins: 8
implicitHeight: contentHeight
model: control.contentModel
interactive: Window.window ? contentHeight > Window.window.height : false
clip: true
currentIndex: control.currentIndex
implicitHeight: contentHeight
interactive: Window.window ? contentHeight > Window.window.height : false
model: control.contentModel
ScrollIndicator.vertical: ScrollIndicator {
}
ScrollIndicator.vertical: ScrollIndicator {}
}
}
delegate: MenuItem {
colorScheme: control.colorScheme
background: Rectangle {
implicitWidth: 200
implicitHeight: 40
color: colorScheme.background_norm
border.width: 1
border.color: colorScheme.border_weak
radius: ProtonStyle.account_row_radius
}
}

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
@ -21,39 +26,46 @@ T.MenuItem {
property ColorScheme colorScheme
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.body_letter_spacing
font.pixelSize: ProtonStyle.body_font_size
font.weight: ProtonStyle.fontWeight_400
icon.color: control.enabled ? control.colorScheme.text_norm : control.colorScheme.text_disabled
icon.height: 24
icon.width: 24
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, implicitContentHeight + topPadding + bottomPadding, implicitIndicatorHeight + topPadding + bottomPadding)
width: parent.width // required. Other item overflows to the right of the menu and get clipped.
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding,
implicitIndicatorHeight + topPadding + bottomPadding)
padding: 12
spacing: 6
width: parent.width // required. Other item overflows to the right of the menu and get clipped.
background: Rectangle {
color: control.down ? control.colorScheme.interaction_default_active : control.highlighted ? control.colorScheme.interaction_default_hover : control.colorScheme.interaction_default
implicitHeight: 36
implicitWidth: 164
radius: ProtonStyle.button_radius
}
icon.width: 24
icon.height: 24
icon.color: control.enabled ? control.colorScheme.text_norm : control.colorScheme.text_disabled
font.family: ProtonStyle.font_family
font.weight: ProtonStyle.fontWeight_400
font.pixelSize: ProtonStyle.body_font_size
font.letterSpacing: ProtonStyle.body_letter_spacing
contentItem: IconLabel {
id: iconLabel
readonly property real arrowPadding: control.subMenu && control.arrow ? control.arrow.width + control.spacing : 0
readonly property real indicatorPadding: control.checkable && control.indicator ? control.indicator.width + control.spacing : 0
alignment: Qt.AlignLeft
color: control.enabled ? control.colorScheme.text_norm : control.colorScheme.text_disabled
display: control.display
font: control.font
icon: control.icon
leftPadding: !control.mirrored ? indicatorPadding : arrowPadding
mirrored: control.mirrored
rightPadding: control.mirrored ? indicatorPadding : arrowPadding
spacing: control.spacing
mirrored: control.mirrored
display: control.display
alignment: Qt.AlignLeft
icon: control.icon
text: control.text
font: control.font
color: control.enabled ? control.colorScheme.text_norm : control.colorScheme.text_disabled
}
background: Rectangle {
implicitWidth: 164
implicitHeight: 36
radius: ProtonStyle.button_radius
color: control.down ? control.colorScheme.interaction_default_active : control.highlighted ? control.colorScheme.interaction_default_hover : control.colorScheme.interaction_default
}
}

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Controls
@ -18,40 +23,45 @@ import QtQuick.Templates as T
T.Popup {
id: root
property ColorScheme colorScheme
readonly property var occurred: shouldShow ? new Date() : undefined
property int popupType: ApplicationWindow.PopupType.Banner
property bool shouldShow: false
function close() {
root.shouldShow = false;
}
function open() {
root.shouldShow = true;
}
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, contentWidth + leftPadding + rightPadding)
// TODO: Add DropShadow here
T.Overlay.modal: Rectangle {
color: root.colorScheme.backdrop_norm
}
T.Overlay.modeless: Rectangle {
color: "transparent"
}
Component.onCompleted: {
if (!ApplicationWindow.window) {
return;
return
}
if (ApplicationWindow.window.popups === undefined) {
return;
return
}
const obj = this;
ApplicationWindow.window.popups.append({
"obj": obj
});
var obj = this
ApplicationWindow.window.popups.append( { obj } )
}
property int popupType: ApplicationWindow.PopupType.Banner
property bool shouldShow: false
readonly property var occurred: shouldShow ? new Date() : undefined
function open() {
root.shouldShow = true
}
function close() {
root.shouldShow = false
}
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
contentWidth + leftPadding + rightPadding)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
contentHeight + topPadding + bottomPadding)
// TODO: Add DropShadow here
T.Overlay.modal: Rectangle {
color: root.colorScheme.backdrop_norm
}
T.Overlay.modeless: Rectangle {
color: "transparent"
}
}

View File

@ -1,91 +1,115 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Templates as T
T.RadioButton {
id: control
property ColorScheme colorScheme
property bool error: false
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, implicitContentHeight + topPadding + bottomPadding, implicitIndicatorHeight + topPadding + bottomPadding)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, implicitContentWidth + leftPadding + rightPadding)
id: control
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding,
implicitIndicatorHeight + topPadding + bottomPadding)
padding: 0
spacing: 8
contentItem: CheckLabel {
color: {
if (!enabled) {
return control.colorScheme.text_disabled;
}
if (error) {
return control.colorScheme.signal_danger;
}
return control.colorScheme.text_norm;
}
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.body_letter_spacing
font.pixelSize: ProtonStyle.body_font_size
font.weight: ProtonStyle.fontWeight_400
leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0
lineHeight: ProtonStyle.body_line_height
lineHeightMode: Text.FixedHeight
rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0
text: control.text
}
indicator: Rectangle {
border.color: {
if (!control.enabled) {
return control.colorScheme.field_disabled;
}
if (control.error) {
return control.colorScheme.signal_danger;
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover;
}
return control.colorScheme.field_norm;
}
border.width: 1
color: control.colorScheme.background_norm
implicitHeight: 20
implicitWidth: 20
implicitHeight: 20
radius: width / 2
x: text ? (control.mirrored ? control.width - width - control.rightPadding : control.leftPadding) : control.leftPadding + (control.availableWidth - width) / 2
y: control.topPadding + (control.availableHeight - height) / 2
Rectangle {
color: {
if (!control.enabled) {
return control.colorScheme.field_disabled;
}
if (control.error) {
return control.colorScheme.signal_danger;
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover;
}
return control.colorScheme.interaction_norm;
color: control.colorScheme.background_norm
border.width: 1
border.color: {
if (!control.enabled) {
return control.colorScheme.field_disabled
}
height: 8
radius: width / 2
visible: control.checked
width: 8
if (control.error) {
return control.colorScheme.signal_danger
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover
}
return control.colorScheme.field_norm
}
Rectangle {
x: (parent.width - width) / 2
y: (parent.height - height) / 2
width: 8
height: 8
radius: width / 2
color: {
if (!control.enabled) {
return control.colorScheme.field_disabled
}
if (control.error) {
return control.colorScheme.signal_danger
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover
}
return control.colorScheme.interaction_norm
}
visible: control.checked
}
}
contentItem: CheckLabel {
leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0
rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0
text: control.text
color: {
if (!enabled) {
return control.colorScheme.text_disabled
}
if (error) {
return control.colorScheme.signal_danger
}
return control.colorScheme.text_norm
}
font.family: ProtonStyle.font_family
font.weight: ProtonStyle.fontWeight_400
font.pixelSize: ProtonStyle.body_font_size
lineHeight: ProtonStyle.body_line_height
lineHeightMode: Text.FixedHeight
font.letterSpacing: ProtonStyle.body_letter_spacing
}
}

View File

@ -1,188 +1,387 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
pragma Singleton
import QtQml
import QtQuick
import "."
import "./"
// https://wiki.qt.io/Qml_Styling
// http://imaginativethinking.ca/make-qml-component-singleton/
QtObject {
id: root
// TODO: Once we will use Qt >=5.15 this should be refactored with inline components as follows:
// https://doc.qt.io/qt-5/qtqml-documents-definetypes.html#inline-components
property real account_hover_radius: 12 * root.px // px
property real account_row_radius: 12 * root.px // px
property real avatar_radius: 8 * root.px // px
property real banner_radius: 12 * root.px // px
property real big_avatar_radius: 12 * root.px // px
property int body_font_size: 14
property real body_letter_spacing: 0.2 * root.px
property int body_line_height: 20
property real button_radius: 8 * root.px // px
property int caption_font_size: 12
property real caption_letter_spacing: 0.4 * root.px
property int caption_line_height: 16
property real card_radius: 12 * root.px // px
property real checkbox_radius: 4 * root.px // px
property real context_item_radius: 8 * root.px // px
property ColorScheme currentStyle: lightStyle
property ColorScheme darkProminentStyle: ColorScheme {
id: _darkProminentStyle
// component ColorScheme: QtObject {
// property color primary_norm
// ...
// }
// Backdrop
backdrop_norm: Qt.rgba(0, 0, 0, 0.32)
background_avatar: "#6D4AFF"
property ColorScheme lightStyle: ColorScheme {
id: _lightStyle
// Background
background_norm: "#16141c"
background_strong: "#3F3B4C"
background_weak: "#292733"
prominent: lightProminentStyle
// Border
border_norm: "#4A4658"
border_weak: "#343140"
field_disabled: "#3F3B4C"
field_hover: "#6D697D"
// Field
field_norm: "#5B576B"
// Interaction-default
interaction_default: "#00000000"
interaction_default_active: Qt.rgba(91. / 255., 87. / 255., 107. / 255., 0.4)
interaction_default_hover: Qt.rgba(91. / 255., 87. / 255., 107. / 255., 0.2)
// Primary
primary_norm: "#6D4AFF"
// Interaction-norm
interaction_norm: "#6D4AFF"
interaction_norm_active: "#8A6EFF"
interaction_norm_hover: "#7C5CFF"
interaction_norm_hover: "#4D34B3"
interaction_norm_active: "#372580"
// Text
text_norm: "#0C0C14"
text_weak: "#706D6B"
text_hint: "#8F8D8A"
text_disabled: "#C2BFBC"
text_invert: "#FFFFFF"
// Field
field_norm: "#ADABA8"
field_hover: "#8F8D8A"
field_disabled: "#D1CFCD"
// Border
border_norm: "#D1CFCD"
border_weak: "#EAE7E4"
// Background
background_norm: "#FFFFFF"
background_weak: "#F5F4F2"
background_strong: "#EAE7E4"
background_avatar: "#C2BFBC"
// Interaction-weak
interaction_weak: "#4A4658"
interaction_weak_active: "#6D697D"
interaction_weak_hover: "#5B576B"
logo_img: "/qml/icons/product_logos_dark.svg"
interaction_weak: "#D1CFCD"
interaction_weak_hover: "#C2BFBC"
interaction_weak_active: "#A8A6A3"
// Interaction-default
interaction_default: Qt.rgba(0,0,0,0)
interaction_default_hover: Qt.rgba(194./255., 191./255., 188./255., 0.2)
interaction_default_active: Qt.rgba(194./255., 191./255., 188./255., 0.4)
// Scrollbar
scrollbar_norm: "#D1CFCD"
scrollbar_hover: "#C2BFBC"
// Signal
signal_danger: "#DC3251"
signal_danger_hover: "#F74F6D"
signal_danger_active: "#B72346"
signal_warning: "#FF9900"
signal_warning_hover: "#FFB800"
signal_warning_active: "#FF851A"
signal_success: "#1EA885"
signal_success_hover: "#23C299"
signal_success_active: "#198F71"
signal_info: "#239ECE"
signal_info_hover: "#27B1E8"
signal_info_active: "#1F83B5"
// Shadows
shadow_norm: Qt.rgba(0,0,0, 0.1) // #000000 10% x:0 y:1 blur:4
shadow_lifted: Qt.rgba(0,0,0, 0.16) // #000000 16% x:0 y:8 blur:24
// Backdrop
backdrop_norm: Qt.rgba(12./255., 12./255., 20./255., 0.32)
// Images
welcome_img: "/qml/icons/img-welcome.png"
logo_img: "/qml/icons/product_logos.svg"
}
property ColorScheme lightProminentStyle: ColorScheme {
id: _lightProminentStyle
prominent: this
// Primary
primary_norm: "#8A6EFF"
prominent: this
scrollbar_hover: "#5B576B"
// Scrollbar
scrollbar_norm: "#4A4658"
shadow_lifted: Qt.rgba(0, 0, 0, 0.48) // #000000 48% x+0 y+8 blur:24
// Shadows
shadow_norm: Qt.rgba(0, 0, 0, 0.4) // #000000 40% x+0 y+1 blur:4
// Signal
signal_danger: "#F5385A"
signal_danger_active: "#DC3251"
signal_danger_hover: "#FF5473"
signal_info: "#239ECE"
signal_info_active: "#1F83B5"
signal_info_hover: "#27B1E8"
signal_success: "#1EA885"
signal_success_active: "#198F71"
signal_success_hover: "#23C299"
signal_warning: "#FF9900"
signal_warning_active: "#FF8419"
signal_warning_hover: "#FFB800"
text_disabled: "#5B576B"
text_hint: "#6D697D"
text_invert: "#1C1B24"
// Interaction-norm
interaction_norm: "#6D4AFF"
interaction_norm_hover: "#7C5CFF"
interaction_norm_active: "#8A6EFF"
// Text
text_norm: "#FFFFFF"
text_weak: "#A7A4B5"
text_weak: "#9282D4"
text_hint: "#544399"
text_disabled: "#4A398F"
text_invert: "#1B1340"
// Field
field_norm: "#9282D4"
field_hover: "#7C5CFF"
field_disabled: "#38277A"
// Border
border_norm: "#413085"
border_weak: "#3C2B80"
// Background
background_norm: "#1B1340"
background_weak: "#271C57"
background_strong: "#38277A"
background_avatar: "#6D4AFF"
// Interaction-weak
interaction_weak: "#4A398F"
interaction_weak_hover: "#6D4AFF"
interaction_weak_active: "#8A6EFF"
// Interaction-default
interaction_default: Qt.rgba(0,0,0,0)
interaction_default_hover: Qt.rgba(68./255., 78./255., 114./255., 0.2)
interaction_default_active: Qt.rgba(68./255., 78./255., 114./255., 0.3)
// Scrollbar
scrollbar_norm: "#413085"
scrollbar_hover: "#4A398F"
// Signal
signal_danger: "#F5385A"
signal_danger_hover: "#FF5473"
signal_danger_active: "#DC3251"
signal_warning: "#FF9900"
signal_warning_hover: "#FFB800"
signal_warning_active: "#FF8419"
signal_success: "#1EA885"
signal_success_hover: "#23C299"
signal_success_active: "#198F71"
signal_info: "#2C89DB"
signal_info_hover: "#3491E3"
signal_info_active: "#1F83B5"
// Shadows
shadow_norm: Qt.rgba(0,0,0, 0.32) // #000000 32% x:0 y:1 blur:4
shadow_lifted: Qt.rgba(0,0,0, 0.40) // #000000 40% x:0 y:8 blur:24
// Backdrop
backdrop_norm: Qt.rgba(0,0,0, 0.32)
// Images
welcome_img: "/qml/icons/img-welcome-dark.png"
logo_img: "/qml/icons/product_logos_dark.svg"
}
property ColorScheme darkStyle: ColorScheme {
id: _darkStyle
// Backdrop
backdrop_norm: Qt.rgba(0, 0, 0, 0.32)
background_avatar: "#6D4AFF"
// Background
background_norm: "#1C1B24"
background_strong: "#3F3B4C"
background_weak: "#292733"
// Border
border_norm: "#4A4658"
border_weak: "#343140"
field_disabled: "#3F3B4C"
field_hover: "#6D697D"
// Field
field_norm: "#5B576B"
// Interaction-default
interaction_default: "#00000000"
interaction_default_active: Qt.rgba(91. / 255., 87. / 255., 107. / 255., 0.4)
interaction_default_hover: Qt.rgba(91. / 255., 87. / 255., 107. / 255., 0.2)
// Interaction-norm
interaction_norm: "#6D4AFF"
interaction_norm_active: "#8A6EFF"
interaction_norm_hover: "#7C5CFF"
// Interaction-weak
interaction_weak: "#4A4658"
interaction_weak_active: "#6D697D"
interaction_weak_hover: "#5B576B"
logo_img: "/qml/icons/product_logos_dark.svg"
prominent: darkProminentStyle
// Primary
primary_norm: "#8A6EFF"
prominent: darkProminentStyle
scrollbar_hover: "#5B576B"
// Scrollbar
scrollbar_norm: "#4A4658"
shadow_lifted: Qt.rgba(0, 0, 0, 0.48) // #000000 48% x+0 y+8 blur:24
// Shadows
shadow_norm: Qt.rgba(0, 0, 0, 0.4) // #000000 40% x+0 y+1 blur:4
// Signal
signal_danger: "#F5385A"
signal_danger_active: "#DC3251"
signal_danger_hover: "#FF5473"
signal_info: "#239ECE"
signal_info_active: "#1F83B5"
signal_info_hover: "#27B1E8"
signal_success: "#1EA885"
signal_success_active: "#198F71"
signal_success_hover: "#23C299"
signal_warning: "#FF9900"
signal_warning_active: "#FF8419"
signal_warning_hover: "#FFB800"
text_disabled: "#5B576B"
text_hint: "#6D697D"
text_invert: "#1C1B24"
// Interaction-norm
interaction_norm: "#6D4AFF"
interaction_norm_hover: "#7C5CFF"
interaction_norm_active: "#8A6EFF"
// Text
text_norm: "#FFFFFF"
text_weak: "#A7A4B5"
text_hint: "#6D697D"
text_disabled: "#5B576B"
text_invert: "#1C1B24"
// Field
field_norm: "#5B576B"
field_hover: "#6D697D"
field_disabled: "#3F3B4C"
// Border
border_norm: "#4A4658"
border_weak: "#343140"
// Background
background_norm: "#1C1B24"
background_weak: "#292733"
background_strong: "#3F3B4C"
background_avatar: "#6D4AFF"
// Interaction-weak
interaction_weak: "#4A4658"
interaction_weak_hover: "#5B576B"
interaction_weak_active: "#6D697D"
// Interaction-default
interaction_default: "#00000000"
interaction_default_hover: Qt.rgba(91./255.,87./255.,107./255.,0.2)
interaction_default_active: Qt.rgba(91./255.,87./255.,107./255.,0.4)
// Scrollbar
scrollbar_norm: "#4A4658"
scrollbar_hover: "#5B576B"
// Signal
signal_danger: "#F5385A"
signal_danger_hover: "#FF5473"
signal_danger_active: "#DC3251"
signal_warning: "#FF9900"
signal_warning_hover: "#FFB800"
signal_warning_active: "#FF8419"
signal_success: "#1EA885"
signal_success_hover: "#23C299"
signal_success_active: "#198F71"
signal_info: "#239ECE"
signal_info_hover: "#27B1E8"
signal_info_active: "#1F83B5"
// Shadows
shadow_norm: Qt.rgba(0,0,0,0.4) // #000000 40% x+0 y+1 blur:4
shadow_lifted: Qt.rgba(0,0,0,0.48) // #000000 48% x+0 y+8 blur:24
// Backdrop
backdrop_norm: Qt.rgba(0,0,0,0.32)
// Images
welcome_img: "/qml/icons/img-welcome-dark.png"
logo_img: "/qml/icons/product_logos_dark.svg"
}
property real dialog_radius: 12 * root.px // px
property ColorScheme darkProminentStyle: ColorScheme {
id: _darkProminentStyle
prominent: this
// Primary
primary_norm: "#8A6EFF"
// Interaction-norm
interaction_norm: "#6D4AFF"
interaction_norm_hover: "#7C5CFF"
interaction_norm_active: "#8A6EFF"
// Text
text_norm: "#FFFFFF"
text_weak: "#A7A4B5"
text_hint: "#6D697D"
text_disabled: "#5B576B"
text_invert: "#1C1B24"
// Field
field_norm: "#5B576B"
field_hover: "#6D697D"
field_disabled: "#3F3B4C"
// Border
border_norm: "#4A4658"
border_weak: "#343140"
// Background
background_norm: "#16141c"
background_weak: "#292733"
background_strong: "#3F3B4C"
background_avatar: "#6D4AFF"
// Interaction-weak
interaction_weak: "#4A4658"
interaction_weak_hover: "#5B576B"
interaction_weak_active: "#6D697D"
// Interaction-default
interaction_default: "#00000000"
interaction_default_hover: Qt.rgba(91./255.,87./255.,107./255.,0.2)
interaction_default_active: Qt.rgba(91./255.,87./255.,107./255.,0.4)
// Scrollbar
scrollbar_norm: "#4A4658"
scrollbar_hover: "#5B576B"
// Signal
signal_danger: "#F5385A"
signal_danger_hover: "#FF5473"
signal_danger_active: "#DC3251"
signal_warning: "#FF9900"
signal_warning_hover: "#FFB800"
signal_warning_active: "#FF8419"
signal_success: "#1EA885"
signal_success_hover: "#23C299"
signal_success_active: "#198F71"
signal_info: "#239ECE"
signal_info_hover: "#27B1E8"
signal_info_active: "#1F83B5"
// Shadows
shadow_norm: Qt.rgba(0,0,0,0.4) // #000000 40% x+0 y+1 blur:4
shadow_lifted: Qt.rgba(0,0,0,0.48) // #000000 48% x+0 y+8 blur:24
// Backdrop
backdrop_norm: Qt.rgba(0,0,0,0.32)
// Images
welcome_img: "/qml/icons/img-welcome-dark.png"
logo_img: "/qml/icons/product_logos_dark.svg"
}
property ColorScheme currentStyle: lightStyle
property string font_family: {
switch (Qt.platform.os) {
case "windows":
return "Segoe UI"
case "osx":
return ".AppleSystemUIFont" // should be SF Pro for the foreseeable future. Using "SF Pro Display" directly here is not allowed by the font's license.
case "linux":
return "Ubuntu"
default:
console.error("Unknown platform")
}
}
property real px : 1.00 // px
property real input_radius : 8 * root.px // px
property real button_radius : 8 * root.px // px
property real checkbox_radius : 4 * root.px // px
property real avatar_radius : 8 * root.px // px
property real big_avatar_radius : 12 * root.px // px
property real account_hover_radius : 12 * root.px // px
property real account_row_radius : 12 * root.px // px
property real context_item_radius : 8 * root.px // px
property real banner_radius : 12 * root.px // px
property real dialog_radius : 12 * root.px // px
property real card_radius : 12 * root.px // px
property real progress_bar_radius : 3 * root.px // px
property real tooltip_radius : 8 * root.px // px
property int heading_font_size: 28
property int heading_line_height: 36
property int title_font_size: 20
property int title_line_height: 24
property int lead_font_size: 18
property int lead_line_height: 26
property int body_font_size: 14
property int body_line_height: 20
property real body_letter_spacing: 0.2 * root.px
property int caption_font_size: 12
property int caption_line_height: 16
property real caption_letter_spacing: 0.4 * root.px
property int fontWeight_100: Font.Thin
property int fontWeight_200: Font.Light
property int fontWeight_300: Font.ExtraLight
@ -192,179 +391,4 @@ QtObject {
property int fontWeight_700: Font.Bold
property int fontWeight_800: Font.ExtraBold
property int fontWeight_900: Font.Black
property string font_family: {
switch (Qt.platform.os) {
case "windows":
return "Segoe UI";
case "osx":
return ".AppleSystemUIFont"; // should be SF Pro for the foreseeable future. Using "SF Pro Display" directly here is not allowed by the font's license.
case "linux":
return "Ubuntu";
default:
console.error("Unknown platform");
}
}
property int heading_font_size: 28
property int heading_line_height: 36
property real input_radius: 8 * root.px // px
property int lead_font_size: 18
property int lead_line_height: 26
property ColorScheme lightProminentStyle: ColorScheme {
id: _lightProminentStyle
// Backdrop
backdrop_norm: Qt.rgba(0, 0, 0, 0.32)
background_avatar: "#6D4AFF"
// Background
background_norm: "#1B1340"
background_strong: "#38277A"
background_weak: "#271C57"
// Border
border_norm: "#413085"
border_weak: "#3C2B80"
field_disabled: "#38277A"
field_hover: "#7C5CFF"
// Field
field_norm: "#9282D4"
// Interaction-default
interaction_default: Qt.rgba(0, 0, 0, 0)
interaction_default_active: Qt.rgba(68. / 255., 78. / 255., 114. / 255., 0.3)
interaction_default_hover: Qt.rgba(68. / 255., 78. / 255., 114. / 255., 0.2)
// Interaction-norm
interaction_norm: "#6D4AFF"
interaction_norm_active: "#8A6EFF"
interaction_norm_hover: "#7C5CFF"
// Interaction-weak
interaction_weak: "#4A398F"
interaction_weak_active: "#8A6EFF"
interaction_weak_hover: "#6D4AFF"
logo_img: "/qml/icons/product_logos_dark.svg"
// Primary
primary_norm: "#8A6EFF"
prominent: this
scrollbar_hover: "#4A398F"
// Scrollbar
scrollbar_norm: "#413085"
shadow_lifted: Qt.rgba(0, 0, 0, 0.40) // #000000 40% x:0 y:8 blur:24
// Shadows
shadow_norm: Qt.rgba(0, 0, 0, 0.32) // #000000 32% x:0 y:1 blur:4
// Signal
signal_danger: "#F5385A"
signal_danger_active: "#DC3251"
signal_danger_hover: "#FF5473"
signal_info: "#2C89DB"
signal_info_active: "#1F83B5"
signal_info_hover: "#3491E3"
signal_success: "#1EA885"
signal_success_active: "#198F71"
signal_success_hover: "#23C299"
signal_warning: "#FF9900"
signal_warning_active: "#FF8419"
signal_warning_hover: "#FFB800"
text_disabled: "#4A398F"
text_hint: "#544399"
text_invert: "#1B1340"
// Text
text_norm: "#FFFFFF"
text_weak: "#9282D4"
// Images
welcome_img: "/qml/icons/img-welcome-dark.png"
}
// TODO: Once we will use Qt >=5.15 this should be refactored with inline components as follows:
// https://doc.qt.io/qt-5/qtqml-documents-definetypes.html#inline-components
// component ColorScheme: QtObject {
// property color primary_norm
// ...
// }
property ColorScheme lightStyle: ColorScheme {
id: _lightStyle
// Backdrop
backdrop_norm: Qt.rgba(12. / 255., 12. / 255., 20. / 255., 0.32)
background_avatar: "#C2BFBC"
// Background
background_norm: "#FFFFFF"
background_strong: "#EAE7E4"
background_weak: "#F5F4F2"
// Border
border_norm: "#D1CFCD"
border_weak: "#EAE7E4"
field_disabled: "#D1CFCD"
field_hover: "#8F8D8A"
// Field
field_norm: "#ADABA8"
// Interaction-default
interaction_default: Qt.rgba(0, 0, 0, 0)
interaction_default_active: Qt.rgba(194. / 255., 191. / 255., 188. / 255., 0.4)
interaction_default_hover: Qt.rgba(194. / 255., 191. / 255., 188. / 255., 0.2)
// Interaction-norm
interaction_norm: "#6D4AFF"
interaction_norm_active: "#372580"
interaction_norm_hover: "#4D34B3"
// Interaction-weak
interaction_weak: "#D1CFCD"
interaction_weak_active: "#A8A6A3"
interaction_weak_hover: "#C2BFBC"
logo_img: "/qml/icons/product_logos.svg"
// Primary
primary_norm: "#6D4AFF"
prominent: lightProminentStyle
scrollbar_hover: "#C2BFBC"
// Scrollbar
scrollbar_norm: "#D1CFCD"
shadow_lifted: Qt.rgba(0, 0, 0, 0.16) // #000000 16% x:0 y:8 blur:24
// Shadows
shadow_norm: Qt.rgba(0, 0, 0, 0.1) // #000000 10% x:0 y:1 blur:4
// Signal
signal_danger: "#DC3251"
signal_danger_active: "#B72346"
signal_danger_hover: "#F74F6D"
signal_info: "#239ECE"
signal_info_active: "#1F83B5"
signal_info_hover: "#27B1E8"
signal_success: "#1EA885"
signal_success_active: "#198F71"
signal_success_hover: "#23C299"
signal_warning: "#FF9900"
signal_warning_active: "#FF851A"
signal_warning_hover: "#FFB800"
text_disabled: "#C2BFBC"
text_hint: "#8F8D8A"
text_invert: "#FFFFFF"
// Text
text_norm: "#0C0C14"
text_weak: "#706D6B"
// Images
welcome_img: "/qml/icons/img-welcome.png"
}
property real progress_bar_radius: 3 * root.px // px
property real px: 1.00 // px
property int title_font_size: 20
property int title_line_height: 24
property real tooltip_radius: 8 * root.px // px
}

View File

@ -1,124 +1,150 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Templates as T
import QtQuick.Controls
import QtQuick.Controls.impl
T.Switch {
id: control
property ColorScheme colorScheme
property bool loading: false
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, implicitContentHeight + topPadding + bottomPadding, implicitIndicatorHeight + topPadding + bottomPadding)
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, implicitContentWidth + leftPadding + rightPadding)
padding: 0
spacing: 7
contentItem: CheckLabel {
id: label
color: control.enabled || control.loading ? control.colorScheme.text_norm : control.colorScheme.text_disabled
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.body_letter_spacing
font.pixelSize: ProtonStyle.body_font_size
font.weight: ProtonStyle.fontWeight_400
leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0
lineHeight: ProtonStyle.body_line_height
lineHeightMode: Text.FixedHeight
rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0
text: control.text
}
indicator: Rectangle {
border.color: control.hovered ? control.colorScheme.field_hover : control.colorScheme.field_norm
border.width: control.enabled && !loading ? 1 : 0
color: control.enabled || control.loading ? control.colorScheme.background_norm : control.colorScheme.background_strong
implicitHeight: 24
implicitWidth: 40
radius: height / 2.
x: text ? (control.mirrored ? control.width - width - control.rightPadding : control.leftPadding) : control.leftPadding + (control.availableWidth - width) / 2
y: control.topPadding + (control.availableHeight - height) / 2
Rectangle {
color: {
if (!control.enabled) {
return control.colorScheme.field_disabled;
}
if (control.checked) {
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover;
}
return control.colorScheme.interaction_norm;
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.field_hover;
}
return control.colorScheme.field_norm;
}
height: 24
radius: parent.radius
visible: !loading
width: 24
x: Math.max(0, Math.min(parent.width - width, control.visualPosition * parent.width - (width / 2)))
y: (parent.height - height) / 2
Behavior on x {
enabled: !control.down
SmoothedAnimation {
velocity: 200
}
}
ColorImage {
color: "#FFFFFF"
height: 16
source: "/qml/icons/ic-check.svg"
sourceSize.height: 16
sourceSize.width: 16
visible: control.checked
width: 16
x: (parent.width - width) / 2
y: (parent.height - height) / 2
}
}
ColorImage {
id: loadingImage
color: control.colorScheme.interaction_norm_hover
height: 18
source: "/qml/icons/Loader_16.svg"
sourceSize.height: 18
sourceSize.width: 18
visible: control.loading
width: 18
x: parent.width - width
y: (parent.height - height) / 2
RotationAnimation {
direction: RotationAnimation.Clockwise
duration: 1000
from: 0
loops: Animation.Infinite
running: control.loading
target: loadingImage
to: 360
}
}
}
// TODO: store previous enabled state and restore it?
// For now assuming that only enabled buttons could have loading state
onLoadingChanged: {
enabled = !loading;
if (loading) {
enabled = false
} else {
enabled = true
}
}
id: control
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding,
implicitIndicatorHeight + topPadding + bottomPadding)
padding: 0
spacing: 7
indicator: Rectangle {
implicitWidth: 40
implicitHeight: 24
x: text ? (control.mirrored ? control.width - width - control.rightPadding : control.leftPadding) : control.leftPadding + (control.availableWidth - width) / 2
y: control.topPadding + (control.availableHeight - height) / 2
radius: height / 2.
color: control.enabled || control.loading ? control.colorScheme.background_norm : control.colorScheme.background_strong
border.width: control.enabled && !loading ? 1 : 0
border.color: control.hovered ? control.colorScheme.field_hover : control.colorScheme.field_norm
Rectangle {
x: Math.max(0, Math.min(parent.width - width, control.visualPosition * parent.width - (width / 2)))
y: (parent.height - height) / 2
width: 24
height: 24
radius: parent.radius
visible: !loading
color: {
if (!control.enabled) {
return control.colorScheme.field_disabled
}
if (control.checked) {
if (control.hovered || control.activeFocus) {
return control.colorScheme.interaction_norm_hover
}
return control.colorScheme.interaction_norm
}
if (control.hovered || control.activeFocus) {
return control.colorScheme.field_hover
}
return control.colorScheme.field_norm
}
ColorImage {
x: (parent.width - width) / 2
y: (parent.height - height) / 2
width: 16
height: 16
sourceSize.width: 16
sourceSize.height: 16
color: "#FFFFFF"
source: "/qml/icons/ic-check.svg"
visible: control.checked
}
Behavior on x {
enabled: !control.down
SmoothedAnimation { velocity: 200 }
}
}
ColorImage {
id: loadingImage
x: parent.width - width
y: (parent.height - height) / 2
width: 18
height: 18
sourceSize.width: 18
sourceSize.height: 18
color: control.colorScheme.interaction_norm_hover
source: "/qml/icons/Loader_16.svg"
visible: control.loading
RotationAnimation {
target: loadingImage
loops: Animation.Infinite
duration: 1000
from: 0
to: 360
direction: RotationAnimation.Clockwise
running: control.loading
}
}
}
contentItem: CheckLabel {
id: label
leftPadding: control.indicator && !control.mirrored ? control.indicator.width + control.spacing : 0
rightPadding: control.indicator && control.mirrored ? control.indicator.width + control.spacing : 0
text: control.text
color: control.enabled || control.loading ? control.colorScheme.text_norm : control.colorScheme.text_disabled
font.family: ProtonStyle.font_family
font.weight: ProtonStyle.fontWeight_400
font.pixelSize: ProtonStyle.body_font_size
lineHeight: ProtonStyle.body_line_height
lineHeightMode: Text.FixedHeight
font.letterSpacing: ProtonStyle.body_letter_spacing
}
}

View File

@ -1,37 +1,54 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Templates as T
import QtQuick.Layouts
import "." as Proton
FocusScope {
id: root
property ColorScheme colorScheme
property alias activeFocusOnPress: control.activeFocusOnPress
property string assistiveText
property alias background: control.background
property alias baseUrl: control.baseUrl
property alias bottomInset: control.bottomInset
//property alias flickable: control.flickable
property alias focusReason: control.focusReason
property alias hoverEnabled: control.hoverEnabled
property alias hovered: control.hovered
property alias implicitBackgroundHeight: control.implicitBackgroundHeight
property alias implicitBackgroundWidth: control.implicitBackgroundWidth
property alias leftInset: control.leftInset
property alias palette: control.palette
property alias placeholderText: control.placeholderText
property alias placeholderTextColor: control.placeholderTextColor
property alias rightInset: control.rightInset
property alias topInset: control.topInset
property alias activeFocusOnPress: control.activeFocusOnPress
property alias baseUrl: control.baseUrl
property alias bottomPadding: control.bottomPadding
property alias canPaste: control.canPaste
property alias canRedo: control.canRedo
property alias canUndo: control.canUndo
property alias color: control.color
property ColorScheme colorScheme
property alias contentHeight: control.contentHeight
property alias contentWidth: control.contentWidth
property alias cursorDelegate: control.cursorDelegate
@ -39,36 +56,21 @@ FocusScope {
property alias cursorRectangle: control.cursorRectangle
property alias cursorVisible: control.cursorVisible
property alias effectiveHorizontalAlignment: control.effectiveHorizontalAlignment
property bool error: false
property string errorString
//property alias flickable: control.flickable
property alias focusReason: control.focusReason
property alias font: control.font
property alias hint: hint.text
property alias horizontalAlignment: control.horizontalAlignment
property alias hoverEnabled: control.hoverEnabled
property alias hovered: control.hovered
property alias hoveredLink: control.hoveredLink
property alias implicitBackgroundHeight: control.implicitBackgroundHeight
property alias implicitBackgroundWidth: control.implicitBackgroundWidth
property alias inputMethodComposing: control.inputMethodComposing
property alias inputMethodHints: control.inputMethodHints
property alias label: label.text
property alias leftInset: control.leftInset
property alias leftPadding: control.leftPadding
property alias length: control.length
property alias lineCount: control.lineCount
property alias mouseSelectionMode: control.mouseSelectionMode
property alias overwriteMode: control.overwriteMode
property alias padding: control.padding
property alias palette: control.palette
property alias persistentSelection: control.persistentSelection
property alias placeholderText: control.placeholderText
property alias placeholderTextColor: control.placeholderTextColor
property alias preeditText: control.preeditText
property alias readOnly: control.readOnly
property alias renderType: control.renderType
property alias rightInset: control.rightInset
property alias rightPadding: control.rightPadding
property alias selectByKeyboard: control.selectByKeyboard
property alias selectByMouse: control.selectByMouse
@ -82,119 +84,61 @@ FocusScope {
property alias textDocument: control.textDocument
property alias textFormat: control.textFormat
property alias textMargin: control.textMargin
property alias topInset: control.topInset
property alias topPadding: control.topPadding
property bool validateOnEditingFinished: true
// We are using our own type of validators. It should be a function
// returning an error string in case of error and undefined if no error
property var validator
property alias verticalAlignment: control.verticalAlignment
property alias wrapMode: control.wrapMode
signal editingFinished
implicitWidth: children[0].implicitWidth
implicitHeight: children[0].implicitHeight
function append(text) {
return control.append(text);
}
function clear() {
return control.clear();
}
function copy() {
return control.copy();
}
function cut() {
return control.cut();
}
function deselect() {
return control.deselect();
}
function getFormattedText(start, end) {
return control.getFormattedText(start, end);
}
function getText(start, end) {
return control.getText(start, end);
}
property alias label: label.text
property alias hint: hint.text
property string assistiveText
property string errorString
property bool error: false
signal editingFinished()
function append(text) { return control.append(text) }
function clear() { return control.clear() }
function copy() { return control.copy() }
function cut() { return control.cut() }
function deselect() { return control.deselect() }
function getFormattedText(start, end) { return control.getFormattedText(start, end) }
function getText(start, end) { return control.getText(start, end) }
function insert(position, text) { return control.insert(position, text) }
function isRightToLeft(start, end) { return control.isRightToLeft(start, end) }
function linkAt(x, y) { return control.linkAt(x, y) }
function moveCursorSelection(position, mode) { return control.moveCursorSelection(position, mode) }
function paste() { return control.paste() }
function positionAt(x, y) { return control.positionAt(x, y) }
function positionToRectangle(position) { return control.positionToRectangle(position) }
function redo() { return control.redo() }
function remove(start, end) { return control.remove(start, end) }
function select(start, end) { return control.select(start, end) }
function selectAll() { return control.selectAll() }
function selectWord() { return control.selectWord() }
function undo() { return control.undo() }
// Calculates the height of the component to make exactly lineNum visible in edit area
function heightForLinesVisible(lineNum) {
let totalHeight = 0;
totalHeight += headerLayout.height;
totalHeight += footerLayout.height;
totalHeight += control.topPadding + control.bottomPadding;
totalHeight += lineNum * fontMetrics.height;
return totalHeight;
}
function insert(position, text) {
return control.insert(position, text);
}
function isRightToLeft(start, end) {
return control.isRightToLeft(start, end);
}
function linkAt(x, y) {
return control.linkAt(x, y);
}
function moveCursorSelection(position, mode) {
return control.moveCursorSelection(position, mode);
}
function paste() {
return control.paste();
}
function positionAt(x, y) {
return control.positionAt(x, y);
}
function positionToRectangle(position) {
return control.positionToRectangle(position);
}
function redo() {
return control.redo();
}
function remove(start, end) {
return control.remove(start, end);
}
function select(start, end) {
return control.select(start, end);
}
function selectAll() {
return control.selectAll();
}
function selectWord() {
return control.selectWord();
}
function undo() {
return control.undo();
}
function validate() {
if (validator === undefined) {
return;
}
const error = validator(text);
if (error) {
root.error = true;
root.errorString = error;
} else {
root.error = false;
root.errorString = "";
}
}
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
onEditingFinished: {
if (!validateOnEditingFinished) {
return;
}
validate();
}
onTextChanged: {
root.error = false;
root.errorString = "";
var totalHeight = 0
totalHeight += headerLayout.height
totalHeight += footerLayout.height
totalHeight += control.topPadding + control.bottomPadding
totalHeight += lineNum * fontMetrics.height
return totalHeight
}
FontMetrics {
id: fontMetrics
font: control.font
}
ColumnLayout {
anchors.fill: parent
spacing: 0
@ -205,131 +149,154 @@ FocusScope {
spacing: 0
Proton.Label {
id: label
Layout.fillWidth: true
color: root.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
colorScheme: root.colorScheme
id: label
Layout.fillWidth: true
color: root.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
type: Proton.Label.LabelType.Body_semibold
}
Proton.Label {
id: hint
Layout.fillWidth: true
color: root.enabled ? root.colorScheme.text_weak : root.colorScheme.text_disabled
colorScheme: root.colorScheme
id: hint
Layout.fillWidth: true
color: root.enabled ? root.colorScheme.text_weak : root.colorScheme.text_disabled
horizontalAlignment: Text.AlignRight
type: Proton.Label.LabelType.Caption
}
}
ScrollView {
id: controlView
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
T.TextArea {
id: control
KeyNavigation.backtab: root.KeyNavigation.backtab
KeyNavigation.down: root.KeyNavigation.down
KeyNavigation.left: root.KeyNavigation.left
KeyNavigation.priority: root.KeyNavigation.priority
KeyNavigation.right: root.KeyNavigation.right
KeyNavigation.tab: root.KeyNavigation.tab
KeyNavigation.up: root.KeyNavigation.up
bottomPadding: 8
color: {
if (!control.enabled) {
return root.colorScheme.text_disabled
}
if (control.readOnly) {
return root.colorScheme.text_hint
}
return root.colorScheme.text_norm
}
// enforcing default focus here within component
focus: root.focus
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.body_letter_spacing
font.pixelSize: ProtonStyle.body_font_size
font.weight: ProtonStyle.fontWeight_400
implicitHeight: Math.max(contentHeight + topPadding + bottomPadding, implicitBackgroundHeight + topInset + bottomInset, placeholder.implicitHeight + topPadding + bottomPadding)
implicitWidth: Math.max(contentWidth + leftPadding + rightPadding, implicitBackgroundWidth + leftInset + rightInset, placeholder.implicitWidth + leftPadding + rightPadding)
leftPadding: 12
placeholderTextColor: control.enabled ? root.colorScheme.text_hint : root.colorScheme.text_disabled
rightPadding: 12
selectByMouse: true
selectedTextColor: control.palette.highlightedText
selectionColor: control.palette.highlight
implicitWidth: Math.max(
contentWidth + leftPadding + rightPadding,
implicitBackgroundWidth + leftInset + rightInset,
placeholder.implicitWidth + leftPadding + rightPadding
)
implicitHeight: Math.max(
contentHeight + topPadding + bottomPadding,
implicitBackgroundHeight + topInset + bottomInset,
placeholder.implicitHeight + topPadding + bottomPadding
)
topPadding: 8
wrapMode: TextInput.Wrap
bottomPadding: 8
leftPadding: 12
rightPadding: 12
background: Rectangle {
anchors.fill: parent
border.color: {
if (!control.enabled || control.readOnly) {
return root.colorScheme.field_disabled;
}
if (control.activeFocus) {
return root.colorScheme.interaction_norm;
}
if (root.error) {
return root.colorScheme.signal_danger;
}
if (control.hovered) {
return root.colorScheme.field_hover;
}
return root.colorScheme.field_norm;
}
border.width: 1
color: root.colorScheme.background_norm
radius: ProtonStyle.input_radius
visible: true
}
cursorDelegate: Rectangle {
id: cursor
color: root.colorScheme.interaction_norm
visible: control.activeFocus && !control.readOnly && control.selectionStart === control.selectionEnd
width: 1
font.family: ProtonStyle.font_family
font.weight: ProtonStyle.fontWeight_400
font.pixelSize: ProtonStyle.body_font_size
font.letterSpacing: ProtonStyle.body_letter_spacing
Connections {
function onCursorPositionChanged() {
// keep a moving cursor visible
cursor.opacity = 1;
timer.restart();
}
target: control
}
Timer {
id: timer
interval: Qt.styleHints.cursorFlashTime / 2
repeat: true
running: control.activeFocus && !control.readOnly
// force the cursor visible when gaining focus
onRunningChanged: cursor.opacity = 1
onTriggered: cursor.opacity = !cursor.opacity ? 1 : 0
}
}
color: control.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
placeholderTextColor: control.enabled ? root.colorScheme.text_hint : root.colorScheme.text_disabled
selectionColor: control.palette.highlight
selectedTextColor: control.palette.highlightedText
onEditingFinished: root.editingFinished()
wrapMode: TextInput.Wrap
// enforcing default focus here within component
focus: root.focus
KeyNavigation.priority: root.KeyNavigation.priority
KeyNavigation.backtab: root.KeyNavigation.backtab
KeyNavigation.tab: root.KeyNavigation.tab
KeyNavigation.up: root.KeyNavigation.up
KeyNavigation.down: root.KeyNavigation.down
KeyNavigation.left: root.KeyNavigation.left
KeyNavigation.right: root.KeyNavigation.right
selectByMouse: true
cursorDelegate: Rectangle {
id: cursor
width: 1
color: root.colorScheme.interaction_norm
visible: control.activeFocus && !control.readOnly && control.selectionStart === control.selectionEnd
Connections {
target: control
function onCursorPositionChanged() {
// keep a moving cursor visible
cursor.opacity = 1
timer.restart()
}
}
Timer {
id: timer
running: control.activeFocus && !control.readOnly
repeat: true
interval: Qt.styleHints.cursorFlashTime / 2
onTriggered: cursor.opacity = !cursor.opacity ? 1 : 0
// force the cursor visible when gaining focus
onRunningChanged: cursor.opacity = 1
}
}
PlaceholderText {
id: placeholder
color: control.placeholderTextColor
elide: Text.ElideRight
font: control.font
height: control.height - (control.topPadding + control.bottomPadding)
renderType: control.renderType
text: control.placeholderText
verticalAlignment: control.verticalAlignment
visible: !control.length && !control.preeditText && (!control.activeFocus || control.horizontalAlignment !== Qt.AlignHCenter)
width: control.width - (control.leftPadding + control.rightPadding)
x: control.leftPadding
y: control.topPadding
width: control.width - (control.leftPadding + control.rightPadding)
height: control.height - (control.topPadding + control.bottomPadding)
text: control.placeholderText
font: control.font
color: control.placeholderTextColor
verticalAlignment: control.verticalAlignment
visible: !control.length && !control.preeditText && (!control.activeFocus || control.horizontalAlignment !== Qt.AlignHCenter)
elide: Text.ElideRight
renderType: control.renderType
}
background: Rectangle {
anchors.fill: parent
radius: ProtonStyle.input_radius
visible: true
color: root.colorScheme.background_norm
border.color: {
if (!control.enabled) {
return root.colorScheme.field_disabled
}
if (control.activeFocus) {
return root.colorScheme.interaction_norm
}
if (root.error) {
return root.colorScheme.signal_danger
}
if (control.hovered) {
return root.colorScheme.field_hover
}
return root.colorScheme.field_norm
}
border.width: 1
}
}
}
RowLayout {
id: footerLayout
Layout.fillWidth: true
@ -337,29 +304,67 @@ FocusScope {
ColorImage {
id: errorIcon
Layout.rightMargin: 4
visible: root.error && (assistiveText.text.length > 0)
source: "/qml/icons/ic-exclamation-circle-filled.svg"
color: root.colorScheme.signal_danger
height: assistiveText.height
source: "/qml/icons/ic-exclamation-circle-filled.svg"
sourceSize.height: assistiveText.height
visible: root.error && (assistiveText.text.length > 0)
}
Proton.Label {
colorScheme: root.colorScheme
id: assistiveText
Layout.fillWidth: true
text: root.error ? root.errorString : root.assistiveText
color: {
if (!root.enabled) {
return root.colorScheme.text_disabled;
return root.colorScheme.text_disabled
}
if (root.error) {
return root.colorScheme.signal_danger;
return root.colorScheme.signal_danger
}
return root.colorScheme.text_weak;
return root.colorScheme.text_weak
}
colorScheme: root.colorScheme
text: root.error ? root.errorString : root.assistiveText
type: root.error ? Proton.Label.LabelType.Caption_semibold : Proton.Label.LabelType.Caption
}
}
}
property bool validateOnEditingFinished: true
onEditingFinished: {
if (!validateOnEditingFinished) {
return
}
validate()
}
function validate() {
if (validator === undefined) {
return
}
var error = validator(text)
if (error) {
root.error = true
root.errorString = error
} else {
root.error = false
root.errorString = ""
}
}
onTextChanged: {
root.error = false
root.errorString = ""
}
}

View File

@ -1,38 +1,54 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Templates as T
import QtQuick.Layouts
import "." as Proton
FocusScope {
id: root
property ColorScheme colorScheme
property alias acceptableInput: control.acceptableInput
property alias activeFocusOnPress: control.activeFocusOnPress
property string assistiveText
property alias autoScroll: control.autoScroll
property alias background: control.background
property alias bottomInset: control.bottomInset
property alias focusReason: control.focusReason
property alias hoverEnabled: control.hoverEnabled
property alias hovered: control.hovered
property alias implicitBackgroundHeight: control.implicitBackgroundHeight
property alias implicitBackgroundWidth: control.implicitBackgroundWidth
property alias leftInset: control.leftInset
property alias palette: control.palette
property alias placeholderText: control.placeholderText
property alias placeholderTextColor: control.placeholderTextColor
property alias rightInset: control.rightInset
property alias topInset: control.topInset
property alias acceptableInput: control.acceptableInput
property alias activeFocusOnPress: control.activeFocusOnPress
property alias autoScroll: control.autoScroll
property alias bottomPadding: control.bottomPadding
property alias canPaste: control.canPaste
property alias canRedo: control.canRedo
property alias canUndo: control.canUndo
property alias color: control.color
property ColorScheme colorScheme
//property alias contentHeight: control.contentHeight
//property alias contentWidth: control.contentWidth
property alias cursorDelegate: control.cursorDelegate
@ -40,39 +56,24 @@ FocusScope {
property alias cursorRectangle: control.cursorRectangle
property alias cursorVisible: control.cursorVisible
property alias displayText: control.displayText
property int echoMode: TextInput.Normal
property alias effectiveHorizontalAlignment: control.effectiveHorizontalAlignment
property bool error: false
property string errorString
property alias focusReason: control.focusReason
property alias font: control.font
property alias hint: hint.text
property alias horizontalAlignment: control.horizontalAlignment
property alias hoverEnabled: control.hoverEnabled
property alias hovered: control.hovered
property alias implicitBackgroundHeight: control.implicitBackgroundHeight
property alias implicitBackgroundWidth: control.implicitBackgroundWidth
property alias inputMask: control.inputMask
property alias inputMethodComposing: control.inputMethodComposing
property alias inputMethodHints: control.inputMethodHints
property alias label: label.text
property alias leftInset: control.leftInset
property alias leftPadding: control.leftPadding
property alias length: control.length
property alias maximumLength: control.maximumLength
property alias mouseSelectionMode: control.mouseSelectionMode
property alias overwriteMode: control.overwriteMode
property alias padding: control.padding
property alias palette: control.palette
property alias passwordCharacter: control.passwordCharacter
property alias passwordMaskDelay: control.passwordMaskDelay
property alias persistentSelection: control.persistentSelection
property alias placeholderText: control.placeholderText
property alias placeholderTextColor: control.placeholderTextColor
property alias preeditText: control.preeditText
property alias readOnly: control.readOnly
property alias renderType: control.renderType
property alias rightInset: control.rightInset
property alias rightPadding: control.rightPadding
property alias selectByMouse: control.selectByMouse
property alias selectedText: control.selectedText
@ -81,102 +82,47 @@ FocusScope {
property alias selectionEnd: control.selectionEnd
property alias selectionStart: control.selectionStart
property alias text: control.text
property alias topInset: control.topInset
property bool validateOnEditingFinished: true
// We are using our own type of validators. It should be a function
// returning an error string in case of error and undefined if no error
property var validator
property alias verticalAlignment: control.verticalAlignment
property alias wrapMode: control.wrapMode
signal accepted
signal editingFinished
signal textEdited
function clear() {
control.clear();
}
function copy() {
control.copy();
}
function cut() {
control.cut();
}
function deselect() {
control.deselect();
}
function ensureVisible(position) {
control.ensureVisible(position);
}
function forceActiveFocus() {
control.forceActiveFocus();
}
function getText(start, end) {
control.getText(start, end);
}
function insert(position, text) {
control.insert(position, text);
}
function isRightToLeft(start, end) {
control.isRightToLeft(start, end);
}
function moveCursorSelection(position, mode) {
control.moveCursorSelection(position, mode);
}
function paste() {
control.paste();
}
function positionAt(x, y, position) {
control.positionAt(x, y, position);
}
function positionToRectangle(pos) {
control.positionToRectangle(pos);
}
function redo() {
control.redo();
}
function remove(start, end) {
control.remove(start, end);
}
function select(start, end) {
control.select(start, end);
}
function selectAll() {
control.selectAll();
}
function selectWord() {
control.selectWord();
}
function undo() {
control.undo();
}
function validate() {
if (validator === undefined) {
return;
}
const error = validator(text);
if (error) {
root.error = true;
root.errorString = error;
} else {
root.error = false;
root.errorString = "";
}
}
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
implicitHeight: children[0].implicitHeight
onEditingFinished: {
if (!validateOnEditingFinished) {
return;
}
validate();
}
onTextChanged: {
root.error = false;
root.errorString = "";
}
property alias label: label.text
property alias hint: hint.text
property string assistiveText
property string errorString
property int echoMode: TextInput.Normal
property bool error: false
signal accepted()
signal editingFinished()
signal textEdited()
function clear() { control.clear() }
function copy() { control.copy() }
function cut() { control.cut() }
function deselect() { control.deselect() }
function ensureVisible(position) { control.ensureVisible(position) }
function getText(start, end) { control.getText(start, end) }
function insert(position, text) { control.insert(position, text) }
function isRightToLeft(start, end) { control.isRightToLeft(start, end) }
function moveCursorSelection(position, mode) { control.moveCursorSelection(position, mode) }
function paste() { control.paste() }
function positionAt(x, y, position) { control.positionAt(x, y, position) }
function positionToRectangle(pos) { control.positionToRectangle(pos) }
function redo() { control.redo() }
function remove(start, end) { control.remove(start, end) }
function select(start, end) { control.select(start, end) }
function selectAll() { control.selectAll() }
function selectWord() { control.selectWord() }
function undo() { control.undo() }
function forceActiveFocus() { control.forceActiveFocus() }
ColumnLayout {
anchors.fill: parent
@ -187,18 +133,19 @@ FocusScope {
spacing: 0
Proton.Label {
colorScheme: root.colorScheme
id: label
Layout.fillHeight: true
Layout.fillWidth: true
colorScheme: root.colorScheme
type: Proton.Label.LabelType.Body_semibold
}
Proton.Label {
colorScheme: root.colorScheme
id: hint
Layout.fillHeight: true
Layout.fillWidth: true
color: root.enabled ? root.colorScheme.text_weak : root.colorScheme.text_disabled
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignRight
type: Proton.Label.LabelType.Caption
}
@ -209,29 +156,36 @@ FocusScope {
// will be adjusted to background's width making text field and eye button overlap
Rectangle {
id: background
Layout.fillHeight: true
Layout.fillWidth: true
border.color: {
if (!control.enabled) {
return root.colorScheme.field_disabled;
}
if (control.activeFocus) {
return root.colorScheme.interaction_norm;
}
if (root.error) {
return root.colorScheme.signal_danger;
}
if (control.hovered) {
return root.colorScheme.field_hover;
}
return root.colorScheme.field_norm;
}
border.width: 1
color: root.colorScheme.background_norm
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
radius: ProtonStyle.input_radius
visible: true
color: root.colorScheme.background_norm
border.color: {
if (!control.enabled) {
return root.colorScheme.field_disabled
}
if (control.activeFocus) {
return root.colorScheme.interaction_norm
}
if (root.error) {
return root.colorScheme.signal_danger
}
if (control.hovered) {
return root.colorScheme.field_hover
}
return root.colorScheme.field_norm
}
border.width: 1
implicitWidth: children[0].implicitWidth
implicitHeight: children[0].implicitHeight
RowLayout {
anchors.fill: parent
@ -239,135 +193,190 @@ FocusScope {
T.TextField {
id: control
KeyNavigation.backtab: root.KeyNavigation.backtab
KeyNavigation.down: root.KeyNavigation.down
KeyNavigation.left: root.KeyNavigation.left
KeyNavigation.priority: root.KeyNavigation.priority
KeyNavigation.right: root.KeyNavigation.right
KeyNavigation.tab: root.KeyNavigation.tab
KeyNavigation.up: root.KeyNavigation.up
Layout.fillHeight: true
Layout.fillWidth: true
implicitWidth: implicitBackgroundWidth + leftInset + rightInset
|| Math.max(contentWidth, placeholder.implicitWidth) + leftPadding + rightPadding
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
contentHeight + topPadding + bottomPadding,
placeholder.implicitHeight + topPadding + bottomPadding)
topPadding: 8
bottomPadding: 8
leftPadding: 12
rightPadding: 12
font.family: ProtonStyle.font_family
font.weight: ProtonStyle.fontWeight_400
font.pixelSize: ProtonStyle.body_font_size
font.letterSpacing: ProtonStyle.body_letter_spacing
color: control.enabled ? root.colorScheme.text_norm : root.colorScheme.text_disabled
placeholderTextColor: control.enabled ? root.colorScheme.text_hint : root.colorScheme.text_disabled
selectionColor: control.palette.highlight
selectedTextColor: control.palette.highlightedText
verticalAlignment: TextInput.AlignVCenter
echoMode: eyeButton.checked ? TextInput.Normal : root.echoMode
// enforcing default focus here within component
focus: true
font.family: ProtonStyle.font_family
font.letterSpacing: ProtonStyle.body_letter_spacing
font.pixelSize: ProtonStyle.body_font_size
font.weight: ProtonStyle.fontWeight_400
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding, placeholder.implicitHeight + topPadding + bottomPadding)
implicitWidth: implicitBackgroundWidth + leftInset + rightInset || Math.max(contentWidth, placeholder.implicitWidth) + leftPadding + rightPadding
leftPadding: 12
placeholderTextColor: control.enabled ? root.colorScheme.text_hint : root.colorScheme.text_disabled
rightPadding: 12
selectByMouse: true
selectedTextColor: control.palette.highlightedText
selectionColor: control.palette.highlight
topPadding: 8
verticalAlignment: TextInput.AlignVCenter
background: Item {
implicitHeight: 36
implicitWidth: 80
visible: false
}
KeyNavigation.priority: root.KeyNavigation.priority
KeyNavigation.backtab: root.KeyNavigation.backtab
KeyNavigation.tab: root.KeyNavigation.tab
KeyNavigation.up: root.KeyNavigation.up
KeyNavigation.down: root.KeyNavigation.down
KeyNavigation.left: root.KeyNavigation.left
KeyNavigation.right: root.KeyNavigation.right
selectByMouse: true
cursorDelegate: Rectangle {
id: cursor
width: 1
color: root.colorScheme.interaction_norm
visible: control.activeFocus && !control.readOnly && control.selectionStart === control.selectionEnd
width: 1
Connections {
target: control
function onCursorPositionChanged() {
// keep a moving cursor visible
cursor.opacity = 1;
timer.restart();
cursor.opacity = 1
timer.restart()
}
target: control
}
Timer {
id: timer
interval: Qt.styleHints.cursorFlashTime / 2
repeat: true
running: control.activeFocus && !control.readOnly
repeat: true
interval: Qt.styleHints.cursorFlashTime / 2
onTriggered: cursor.opacity = !cursor.opacity ? 1 : 0
// force the cursor visible when gaining focus
onRunningChanged: cursor.opacity = 1
onTriggered: cursor.opacity = !cursor.opacity ? 1 : 0
}
}
onAccepted: {
root.accepted();
}
onEditingFinished: {
root.editingFinished();
}
onTextEdited: {
root.textEdited();
}
PlaceholderText {
id: placeholder
color: control.placeholderTextColor
elide: Text.ElideRight
font: control.font
height: control.height - (control.topPadding + control.bottomPadding)
renderType: control.renderType
text: control.placeholderText
verticalAlignment: control.verticalAlignment
visible: !control.length && !control.preeditText && (!control.activeFocus || control.horizontalAlignment !== Qt.AlignHCenter)
width: control.width - (control.leftPadding + control.rightPadding)
x: control.leftPadding
y: control.topPadding
width: control.width - (control.leftPadding + control.rightPadding)
height: control.height - (control.topPadding + control.bottomPadding)
text: control.placeholderText
font: control.font
color: control.placeholderTextColor
verticalAlignment: control.verticalAlignment
visible: !control.length && !control.preeditText && (!control.activeFocus || control.horizontalAlignment !== Qt.AlignHCenter)
elide: Text.ElideRight
renderType: control.renderType
}
background: Item {
implicitWidth: 80
implicitHeight: 36
visible: false
}
onAccepted: {
root.accepted()
}
onEditingFinished: {
root.editingFinished()
}
onTextEdited: {
root.textEdited()
}
}
Proton.Button {
id: eyeButton
Layout.fillHeight: true
checkable: true
colorScheme: root.colorScheme
icon.color: control.color
icon.source: checked ? "../icons/ic-eye-slash.svg" : "../icons/ic-eye.svg"
id: eyeButton
Layout.fillHeight: true
visible: root.echoMode === TextInput.Password
icon.color: control.color
checkable: true
icon.source: checked ? "../icons/ic-eye-slash.svg" : "../icons/ic-eye.svg"
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 0
ColorImage {
id: errorIcon
Layout.rightMargin: 4
visible: root.error && (assistiveText.text.length > 0)
source: "../icons/ic-exclamation-circle-filled.svg"
color: root.colorScheme.signal_danger
height: assistiveText.lineHeight
source: "../icons/ic-exclamation-circle-filled.svg"
sourceSize.height: assistiveText.lineHeight
visible: root.error && (assistiveText.text.length > 0)
}
Proton.Label {
colorScheme: root.colorScheme
id: assistiveText
Layout.fillHeight: true
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: root.error ? root.errorString : root.assistiveText
color: {
if (!root.enabled) {
return root.colorScheme.text_disabled;
return root.colorScheme.text_disabled
}
if (root.error) {
return root.colorScheme.signal_danger;
return root.colorScheme.signal_danger
}
return root.colorScheme.text_weak;
return root.colorScheme.text_weak
}
colorScheme: root.colorScheme
text: root.error ? root.errorString : root.assistiveText
type: root.error ? Proton.Label.LabelType.Caption_semibold : Proton.Label.LabelType.Caption
wrapMode: Text.WordWrap
}
}
}
property bool validateOnEditingFinished: true
onEditingFinished: {
if (!validateOnEditingFinished) {
return
}
validate()
}
function validate() {
if (validator === undefined) {
return
}
var error = validator(text)
if (error) {
root.error = true
root.errorString = error
} else {
root.error = false
root.errorString = ""
}
}
onTextChanged: {
root.error = false
root.errorString = ""
}
}

View File

@ -1,15 +1,20 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
@ -17,106 +22,92 @@ import QtQuick.Controls.impl
Item {
id: root
property bool _disabled: !enabled
property bool checked
property var colorScheme
property bool checked
property bool hovered
property bool loading
signal clicked
property bool _disabled: !enabled
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
Rectangle {
id: indicator
color: {
if (root.loading)
return "transparent";
if (root._disabled)
return root.colorScheme.background_strong;
return root.colorScheme.background_norm;
}
implicitHeight: 24
implicitWidth: 40
radius: width / 2
implicitHeight: 24
border {
color: (root._disabled || root.loading) ? "transparent" : colorScheme.field_norm
width: 1
radius: width/2
color: {
if (root.loading) return "transparent"
if (root._disabled) return root.colorScheme.background_strong
return root.colorScheme.background_norm
}
border {
width: 1
color: (root._disabled || root.loading) ? "transparent" : colorScheme.field_norm
}
Rectangle {
anchors.verticalCenter: indicator.verticalCenter
anchors.left: indicator.left
anchors.leftMargin: root.checked ? 16 : 0
anchors.verticalCenter: indicator.verticalCenter
width: 24
height: 24
radius: width/2
color: {
if (root.loading)
return "transparent";
if (root._disabled)
return root.colorScheme.field_disabled;
if (root.loading) return "transparent"
if (root._disabled) return root.colorScheme.field_disabled
if (root.checked) {
if (root.hovered)
return root.colorScheme.interaction_norm_hover;
return root.colorScheme.interaction_norm;
if (root.hovered) return root.colorScheme.interaction_norm_hover
return root.colorScheme.interaction_norm
} else {
if (root.hovered)
return root.colorScheme.field_hover;
return root.colorScheme.field_norm;
if (root.hovered) return root.colorScheme.field_hover
return root.colorScheme.field_norm
}
}
height: 24
radius: width / 2
width: 24
ColorImage {
anchors.centerIn: parent
source: "/qml/icons/ic-check.svg"
color: root.colorScheme.background_norm
height: root.colorScheme.body_font_size
source: "/qml/icons/ic-check.svg"
sourceSize.height: root.colorScheme.body_font_size
visible: root.checked
}
}
ColorImage {
id: loader
anchors.centerIn: parent
source: "/qml/icons/Loader_16.svg"
color: root.colorScheme.text_norm
height: root.colorScheme.body_font_size
source: "/qml/icons/Loader_16.svg"
sourceSize.height: root.colorScheme.body_font_size
visible: root.loading
RotationAnimation {
direction: RotationAnimation.Clockwise
target: loader
loops: Animation.Infinite
duration: 1000
from: 0
loops: Animation.Infinite
running: root.loading
target: loader
to: 360
direction: RotationAnimation.Clockwise
running: root.loading
}
}
MouseArea {
anchors.fill: indicator
hoverEnabled: true
onClicked: {
if (root.enabled)
root.clicked();
}
onEntered: {
root.hovered = true;
}
onExited: {
root.hovered = false;
}
onPressed: {
root.hovered = true;
}
onReleased: {
root.hovered = containsMouse;
}
onEntered: {root.hovered = true }
onExited: {root.hovered = false }
onClicked: { if (root.enabled) root.clicked();}
onPressed: {root.hovered = true }
onReleased: { root.hovered = containsMouse }
}
}
}

View File

@ -1,254 +0,0 @@
// Copyright (c) 2023 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
Item {
id: root
enum InputType {
TextInput = 1,
Radio,
Checkbox
}
property string _typeOpen: "open"
property string _typeChoice: "choice"
property string _typeMutlichoice: "multichoice"
property var colorScheme
property var _bottomMargin: 20
property var _lineHeight: 1
property bool showSeparator: true
property string text: ""
property string tips: ""
property string label: ""
property bool mandatory: false
property var type: root._typeOpen
property var answerList: ListModel{}
property int maxChar: 150
property string answer:{
if (type === root._typeOpen) {
return textInput.text
} else if (type === root._typeChoice) {
return selectionRadio.text
} else if (type === root._typeMutlichoice) {
return selectionCheckBox.text
}
return ""
}
property bool error: {
if (root.type === root._typeOpen)
return textInput.error;
if (root.type === root._typeChoice)
return selectionRadio.error;
if (root.type === root._typeMutlichoice)
return selectionCheckBox.error;
return false
}
function setDefaultValue(defaultValue) {
textInput.setDefaultValue(defaultValue)
selectionRadio.setDefaultValue(defaultValue)
selectionCheckBox.setDefaultValue(defaultValue)
}
function validate() {
if (root.type === root._typeOpen)
textInput.validate()
else if (root.type === root._typeChoice)
selectionRadio.validate()
else if (root.type === root._typeMutlichoice)
selectionCheckBox.validate()
}
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
ColumnLayout {
anchors.fill: parent
spacing: 16
Label {
id: mainLabel
colorScheme: root.colorScheme
text: root.mandatory ? qsTr(root.text+" *") : qsTr(root.text)
type: Label.Body
}
ColumnLayout {
spacing: 16
Layout.bottomMargin: root._bottomMargin
TextArea {
id: textInput
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: root.type === root._typeOpen ? heightForLinesVisible(2) : 0
colorScheme: root.colorScheme
property int _maxLength: root.maxChar
property int _minLength: 1
label: qsTr(root.label)
hint: textInput.text.length + "/" + _maxLength
placeholderText: qsTr(root.tips)
function setDefaultValue(defaultValue) {
textInput.text = root.type === root._typeOpen ? defaultValue : ""
}
validator: function (text) {
if (mandatory && textInput.text.length < textInput._minLength) {
return qsTr("Field is mandatory");
}
if (textInput.text.length > textInput._maxLength) {
return qsTr("max. %1 characters").arg(_maxLength);
}
return;
}
onTextChanged: {
// Rise max length error immediately while typing if mandatory field
if (textInput.text.length > textInput._maxLength) {
validate();
}
}
visible: root.type === root._typeOpen
}
ButtonGroup {
id: selectionRadio
property string text: {
return checkedButton ? checkedButton.text : "";
}
property bool error: false
function setDefaultValue(defaultValue) {
const values = root.type === root._typeChoice ? defaultValue : [];
for (var i = 0; i < buttons.length; ++i) {
buttons[i].checked = values.includes(buttons[i].text);
}
}
function validate() {
if (mandatory && selectionRadio.text.length === 0) {
error = true;
return
}
error = false;
}
onTextChanged: {
validate();
}
}
Repeater {
model: root.answerList
RadioButton {
ButtonGroup.group: selectionRadio
colorScheme: root.colorScheme
text: modelData
visible: root.type === root._typeChoice
}
}
ButtonGroup {
id: selectionCheckBox
exclusive: false
property string delimitor: ", "
property string text: {
var str = "";
for (var i = 0; i < buttons.length; ++i) {
if (buttons[i].checked) {
str += buttons[i].text + delimitor;
}
}
return str.slice(0, -delimitor.length);
}
property bool error: false
function setDefaultValue(defaultValue) {
const values = root.type === root._typeMutlichoice ? defaultValue.split(delimitor) : [];
for (var i = 0; i < buttons.length; ++i) {
buttons[i].checked = values.includes(buttons[i].text);
}
}
function validate() {
if (mandatory && selectionCheckBox.text.length === 0) {
error = true;
return
}
error = false;
}
onTextChanged: {
validate();
}
}
Repeater {
model: root.answerList
CheckBox {
ButtonGroup.group: selectionCheckBox
colorScheme: root.colorScheme
text: modelData
visible: root.type === root._typeMutlichoice
}
}
RowLayout {
id: footerLayout
Layout.fillWidth: true
spacing: 0
visible: {
if (root.type === root._typeOpen)
return false
return root.error
}
ColorImage {
id: errorIcon
Layout.rightMargin: 4
color: root.colorScheme.signal_danger
height: errorChoice.height
source: "/qml/icons/ic-exclamation-circle-filled.svg"
sourceSize.height: errorChoice.height
}
Label {
id: errorChoice
Layout.fillWidth: true
color: root.colorScheme.signal_danger
colorScheme: root.colorScheme
text: "Field is mandatory"
type: Label.LabelType.Caption_semibold
}
}
}
}
Rectangle {
anchors.bottom: root.bottom
anchors.left: root.left
anchors.right: root.right
color: root.colorScheme.border_weak
height: root._lineHeight
visible: root.showSeparator
}
// fill height so the footer label will always be attached to the bottom
Item {
Layout.fillHeight: true
}
}

View File

@ -1,90 +0,0 @@
{
"metadata": {
"version": "1.0.0"
},
"data_v1.0.0": {
"categories": [
{
"name": "I cannot find emails in my email client",
"questions": [0,5,6,4],
"hint": "For example, missing old emails or not receiving new emails"
},
{
"name": "I am not able to send emails",
"questions": [0,2,3,4],
"hint": ""
},
{
"name": "Bridge is not starting correctly",
"questions": [0,2,3,4],
"hint": "For example, Bridge does not autostart or shows an error at startup"
},
{
"name": "Bridge is running slow",
"questions": [0,2,7,4],
"hint": "For example, delay in receiving email or high resource consumption"
},
{
"name": "Something else",
"questions": [0,1,2,3],
"hint": ""
}
],
"questions": [
{
"id": 0,
"text": "Please describe what happened and include any error messages.",
"tips": "Type your answer here",
"type": "open",
"mandatory": true,
"maxChar": 1000
},
{
"id": 1,
"text": "What did you want or expect to happen?",
"tips": "Type your answer here",
"type": "open",
"maxChar": 1000
},
{
"id": 2,
"text": "What were the step-by-step actions you took that led to this happening?",
"tips": "Type your answer here",
"type": "open",
"maxChar": 1000
},
{
"id": 3,
"text": "When did the issue last occur? Is it repeating?",
"tips": "Type your answer here",
"type": "open",
"maxChar": 1000
},
{
"id": 4,
"text": "Are you running any of these software? Select all that apply.",
"type": "multichoice",
"answerList": ["VPN", "Antivirus", "Firewall", "Cache cleaner", "I'm not sure"]
},
{
"id": 5,
"text": "Are you missing emails from the email client or not receiving new ones?",
"type": "choice",
"mandatory": true,
"answerList": ["Old emails are missing", "New emails are not arriving"]
},
{
"id": 6,
"text": "Can you find the emails in the web/mobile application?",
"type": "choice",
"answerList": ["Yes", "No", "I don't know"]
},
{
"id": 7,
"text": "Which of these issues are you experiencing?",
"type": "multichoice",
"answerList": ["Emails arrive with a delay", "Emails are sending slowly", "Bridge is slow to launch", "Bridge is slowing down my computer"]
}
]
}
}

View File

@ -1,44 +1,51 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
Item {
id: root
enum ActionType {
Toggle = 1,
Button,
PrimaryButton
}
property var colorScheme
property string text: "Text"
property string actionText: "Action"
property string actionIcon: ""
property string description: "Lorem ipsum dolor sit amet"
property alias descriptionWrap: descriptionLabel.wrapMode
property var type: SettingsItem.ActionType.Toggle
property bool checked: true
property bool loading: false
property bool showSeparator: true
property var _bottomMargin: 20
property var _lineWidth: 1
property var _toggleTopMargin: 6
property string actionIcon: ""
property string actionText: "Action"
property bool checked: true
property var colorScheme
property string description: "Lorem ipsum dolor sit amet"
property alias descriptionWrap: descriptionLabel.wrapMode
property bool loading: false
property bool showSeparator: true
property string text: "Text"
property var type: SettingsItem.ActionType.Toggle
signal clicked
enum ActionType {
Toggle = 1, Button = 2, PrimaryButton = 3
}
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
implicitWidth: children[0].implicitWidth + children[0].anchors.leftMargin + children[0].anchors.rightMargin
@ -47,9 +54,10 @@ Item {
spacing: 16
ColumnLayout {
Layout.bottomMargin: root._bottomMargin
Layout.fillHeight: true
Layout.fillWidth: true
Layout.bottomMargin: root._bottomMargin
spacing: 4
Label {
@ -58,51 +66,51 @@ Item {
text: root.text
type: Label.Body_semibold
}
Label {
id: descriptionLabel
Layout.fillHeight: true
Layout.fillWidth: true
Layout.preferredWidth: parent.width
color: root.colorScheme.text_weak
wrapMode: Text.WordWrap
colorScheme: root.colorScheme
text: root.description
wrapMode: Text.WordWrap
color: root.colorScheme.text_weak
}
}
Toggle {
id: toggle
Layout.alignment: Qt.AlignTop
Layout.topMargin: root._toggleTopMargin
checked: root.checked
id: toggle
colorScheme: root.colorScheme
loading: root.loading
visible: root.type === SettingsItem.ActionType.Toggle
onClicked: {
if (!root.loading)
root.clicked();
}
}
Button {
id: button
Layout.alignment: Qt.AlignTop
colorScheme: root.colorScheme
icon.source: root.actionIcon
checked: root.checked
loading: root.loading
secondary: root.type !== SettingsItem.PrimaryButton
text: root.actionText + (root.actionIcon !== "" ? " " : "")
visible: root.type === SettingsItem.Button || root.type === SettingsItem.PrimaryButton
onClicked: { if (!root.loading) root.clicked() }
}
onClicked: {
if (!root.loading)
root.clicked();
}
Button {
Layout.alignment: Qt.AlignTop
id: button
colorScheme: root.colorScheme
visible: root.type === SettingsItem.Button || root.type === SettingsItem.PrimaryButton
text: root.actionText + (root.actionIcon != "" ? " " : "")
loading: root.loading
icon.source: root.actionIcon
onClicked: { if (!root.loading) root.clicked() }
secondary: root.type !== SettingsItem.PrimaryButton
}
}
Rectangle {
anchors.bottom: root.bottom
anchors.left: root.left
anchors.right: root.right
anchors.bottom: root.bottom
color: colorScheme.border_weak
height: root._lineWidth
visible: root.showSeparator

View File

@ -1,57 +1,61 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
Item {
id: root
property int _bottomMargin: 32
property var colorScheme
default property alias items: content.children
signal back()
property int _leftMargin: 64
property int _rightMargin: 64
property int _spacing: 20
property int _topMargin: 32
property var colorScheme
property int _bottomMargin: 32
property int _spacing: 20
// fillHeight indicates whether the SettingsView should fill all available explicit height set
property bool fillHeight: false
default property alias items: content.children
function positionViewAtBegining() {
scrollView.ScrollBar.vertical.position = 0
}
signal back
ScrollView {
id: scrollView
anchors.fill: parent
clip: true
ScrollBar.vertical.policy: ScrollBar.vertical.size < 1.0 ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff;
anchors.fill: parent
Component.onCompleted: contentItem.boundsBehavior = Flickable.StopAtBounds // Disable the springy effect when scroll reaches top/bottom.
Item {
// can't use parent here because parent is not ScrollView (Flickable inside contentItem inside ScrollView)
width: scrollView.availableWidth
height: scrollView.availableHeight
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
// do not set implicitWidth because implicit width of ColumnLayout will be equal to maximum implicit width of
// internal items. And if one of internal items would be a Text or Label - implicit width of those is always
// equal to non-wrapped text (i.e. one line only). That will lead to enabling horizontal scroll when not needed
implicitWidth: width
// can't use parent here because parent is not ScrollView (Flickable inside contentItem inside ScrollView)
width: scrollView.availableWidth
ColumnLayout {
anchors.fill: parent
@ -59,13 +63,16 @@ Item {
ColumnLayout {
id: content
Layout.bottomMargin: root._bottomMargin
spacing: root._spacing
Layout.fillWidth: true
Layout.topMargin: root._topMargin
Layout.bottomMargin: root._bottomMargin
Layout.leftMargin: root._leftMargin
Layout.rightMargin: root._rightMargin
Layout.topMargin: root._topMargin
spacing: root._spacing
}
Item {
id: filler
Layout.fillHeight: true
@ -74,20 +81,19 @@ Item {
}
}
}
Button {
id: backButton
anchors {
top: parent.top
left: parent.left
topMargin: root._topMargin
leftMargin: (root._leftMargin-backButton.width) / 2
}
colorScheme: root.colorScheme
horizontalPadding: 8
onClicked: root.back()
icon.source: "/qml/icons/ic-arrow-left.svg"
secondary: true
onClicked: root.back()
anchors {
left: parent.left
leftMargin: (root._leftMargin - backButton.width) / 2
top: parent.top
topMargin: root._topMargin
}
horizontalPadding: 8
}
}

View File

@ -1,139 +1,110 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
Item {
id: root
id:root
property string address
property ColorScheme colorScheme
property var user
property string address
signal dismissed
signal finished
function reset() {
guidePages.currentIndex = 0;
clientList.currentIndex = -1;
actionList.currentIndex = -1;
}
function setupAction(actionID, clientID) {
if (user) {
user.setupGuideSeen = true;
}
switch (actionID) {
case -1:
root.dismissed();
break; // dismiss
case 0 // automatic
:
if (user) {
switch (clientID) {
case 0:
root.user.configureAppleMail(root.address);
Backend.notifyAutoconfigClicked("AppleMail");
break;
}
}
root.finished();
break;
case 1 // manual
:
let clientObj = clients.get(clientID);
if (clientObj !== undefined && clientObj.link !== "") {
Qt.openUrlExternally(clientObj.link);
Backend.notifyKBArticleClicked(clientObj.link);
} else {
console.log("unexpected client index", actionID, clientID);
}
root.finished();
break;
default:
console.log("unexpected client setup action", actionID, clientID);
}
}
signal dismissed()
signal finished()
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
ListModel {
id: clients
property string name : "Apple Mail"
property string iconSource : "/qml/icons/ic-apple-mail.svg"
property bool haveAutoSetup: true
property string iconSource: "/qml/icons/ic-apple-mail.svg"
property string link: "https://proton.me/support/protonmail-bridge-clients-apple-mail"
property string name: "Apple Mail"
Component.onCompleted: {
if (Backend.goos === "darwin") {
Component.onCompleted : {
if (Backend.goos == "darwin") {
append({
"name": "Apple Mail",
"iconSource": "/qml/icons/ic-apple-mail.svg",
"haveAutoSetup": true,
"link": "https://proton.me/support/protonmail-bridge-clients-apple-mail"
});
"name" : "Apple Mail",
"iconSource" : "/qml/icons/ic-apple-mail.svg",
"haveAutoSetup" : true,
"link" : "https://proton.me/support/protonmail-bridge-clients-apple-mail"
})
append({
"name": "Microsoft Outlook",
"iconSource": "/qml/icons/ic-microsoft-outlook.svg",
"haveAutoSetup": false,
"link": "https://proton.me/support/protonmail-bridge-clients-macos-outlook-2019"
});
"name" : "Microsoft Outlook",
"iconSource" : "/qml/icons/ic-microsoft-outlook.svg",
"haveAutoSetup" : false,
"link" : "https://proton.me/support/protonmail-bridge-clients-macos-outlook-2019"
})
}
if (Backend.goos === "windows") {
if (Backend.goos == "windows") {
append({
"name": "Microsoft Outlook",
"iconSource": "/qml/icons/ic-microsoft-outlook.svg",
"haveAutoSetup": false,
"link": "https://proton.me/support/protonmail-bridge-clients-windows-outlook-2019"
});
"name" : "Microsoft Outlook",
"iconSource" : "/qml/icons/ic-microsoft-outlook.svg",
"haveAutoSetup" : false,
"link" : "https://proton.me/support/protonmail-bridge-clients-windows-outlook-2019"
})
}
append({
"name": "Mozilla Thunderbird",
"iconSource": "/qml/icons/ic-mozilla-thunderbird.svg",
"haveAutoSetup": false,
"link": "https://proton.me/support/protonmail-bridge-clients-windows-thunderbird"
});
"name" : "Mozilla Thunderbird",
"iconSource" : "/qml/icons/ic-mozilla-thunderbird.svg",
"haveAutoSetup" : false,
"link" : "https://proton.me/support/protonmail-bridge-clients-windows-thunderbird"
})
append({
"name": "Other",
"iconSource": "/qml/icons/ic-other-mail-clients.svg",
"haveAutoSetup": false,
"link": "https://proton.me/support/protonmail-bridge-configure-client"
});
"name" : "Other",
"iconSource" : "/qml/icons/ic-other-mail-clients.svg",
"haveAutoSetup" : false,
"link" : "https://proton.me/support/protonmail-bridge-configure-client"
})
}
}
Rectangle {
anchors.fill: root
color: root.colorScheme.background_norm
}
StackLayout {
id: guidePages
anchors.bottomMargin: 70
anchors.fill: parent
anchors.leftMargin: 80
anchors.rightMargin: 80
anchors.topMargin: 30
anchors.bottomMargin: 70
ColumnLayout {
// 0: Client selection
ColumnLayout { // 0: Client selection
id: clientView
Layout.fillHeight: true
property int columnWidth: 268
Layout.fillHeight: true
spacing: 8
Label {
@ -141,14 +112,16 @@ Item {
text: qsTr("Setting up email client")
type: Label.LabelType.Heading
}
Label {
color: root.colorScheme.text_weak
colorScheme: root.colorScheme
text: address
color: root.colorScheme.text_weak
type: Label.LabelType.Lead
}
RowLayout {
Layout.topMargin: 32 - clientView.spacing
Layout.topMargin: 32-clientView.spacing
spacing: 24
ColumnLayout {
@ -161,133 +134,185 @@ Item {
text: qsTr("Choose an email client")
type: Label.LabelType.Body_semibold
}
ListView {
id: clientList
Layout.fillHeight: true
model: clients
width: clientView.columnWidth
model: clients
highlight: Rectangle {
color: root.colorScheme.interaction_default_active
radius: ProtonStyle.context_item_radius
}
delegate: Item {
implicitHeight: clientRow.height
implicitWidth: clientRow.width
implicitHeight: clientRow.height
ColumnLayout {
id: clientRow
width: clientList.width
RowLayout {
Layout.topMargin: 12
Layout.bottomMargin: 12
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 12
ColorImage {
height: 36
source: model.iconSource
height: 36
sourceSize.height: 36
}
Label {
Layout.leftMargin: 12
colorScheme: root.colorScheme
Layout.leftMargin: 12
text: model.name
type: Label.LabelType.Body
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: root.colorScheme.border_weak
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
clientList.currentIndex = index;
clientList.currentIndex = index
if (!model.haveAutoSetup) {
root.setupAction(1, index);
root.setupAction(1,index)
}
}
}
}
highlight: Rectangle {
color: root.colorScheme.interaction_default_active
radius: ProtonStyle.context_item_radius
}
}
}
ColumnLayout {
id: actionColumn
Layout.alignment: Qt.AlignTop
visible: clientList.currentIndex >= 0 && clients.get(clientList.currentIndex).haveAutoSetup
Layout.alignment: Qt.AlignTop
Label {
colorScheme: root.colorScheme
text: qsTr("Choose configuration mode")
type: Label.LabelType.Body_semibold
}
ListView {
id: actionList
Layout.fillHeight: true
model: [qsTr("Configure automatically"), qsTr("Configure manually")]
width: clientView.columnWidth
model: [
qsTr("Configure automatically"),
qsTr("Configure manually"),
]
highlight: Rectangle {
color: root.colorScheme.interaction_default_active
radius: ProtonStyle.context_item_radius
}
delegate: Item {
implicitHeight: children[0].height
implicitWidth: children[0].width
implicitHeight: children[0].height
ColumnLayout {
width: actionList.width
Label {
Layout.topMargin: 20
Layout.bottomMargin: 20
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 20
colorScheme: root.colorScheme
text: modelData
type: Label.LabelType.Body
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: root.colorScheme.border_weak
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
actionList.currentIndex = index;
root.setupAction(index, clientList.currentIndex);
actionList.currentIndex = index
root.setupAction(index,clientList.currentIndex)
}
}
}
highlight: Rectangle {
color: root.colorScheme.interaction_default_active
radius: ProtonStyle.context_item_radius
}
}
}
}
Item {
Layout.fillHeight: true
}
Item { Layout.fillHeight: true }
Button {
colorScheme: root.colorScheme
flat: true
text: qsTr("Set up later")
flat: true
onClicked: {
root.setupAction(-1, -1);
root.setupAction(-1,-1)
if (user) {
user.setupGuideSeen = true;
user.setupGuideSeen = true
}
root.dismissed();
root.dismissed()
}
}
}
}
function setupAction(actionID,clientID){
if (user) {
user.setupGuideSeen = true
}
switch (actionID) {
case -1: root.dismissed(); break; // dismiss
case 0: // automatic
if (user) {
switch (clientID) {
case 0:
root.user.configureAppleMail(root.address)
Backend.notifyAutoconfigClicked("AppleMail");
break;
}
}
root.finished()
break;
case 1: // manual
var clientObj = clients.get(clientID)
if (clientObj != undefined && clientObj.link != "" ) {
Qt.openUrlExternally(clientObj.link)
Backend.notifyKBArticleClicked(clientObj.link);
} else {
console.log("unexpected client index", actionID, clientID)
}
root.finished();
break;
default:
console.log("unexpected client setup action", actionID, clientID)
}
}
function reset(){
guidePages.currentIndex = 0
clientList.currentIndex = -1
actionList.currentIndex = -1
}
}

View File

@ -1,413 +1,473 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
FocusScope {
id: root
property ColorScheme colorScheme
property alias currentIndex: stackLayout.currentIndex
property alias username: usernameTextField.text
function reset() {
stackLayout.currentIndex = 0
loginNormalLayout.reset()
login2FALayout.reset()
login2PasswordLayout.reset()
}
function abort() {
root.reset();
Backend.loginAbort(usernameTextField.text);
}
function reset() {
stackLayout.currentIndex = 0;
loginNormalLayout.reset();
login2FALayout.reset();
login2PasswordLayout.reset();
root.reset()
Backend.loginAbort(usernameTextField.text)
}
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
property alias username: usernameTextField.text
state: "Page 1"
states: [
State {
name: "Page 1"
PropertyChanges {
currentIndex: 0
target: stackLayout
}
},
State {
name: "Page 2"
PropertyChanges {
currentIndex: 1
target: stackLayout
}
},
State {
name: "Page 3"
PropertyChanges {
currentIndex: 2
target: stackLayout
}
}
]
property alias currentIndex: stackLayout.currentIndex
StackLayout {
id: stackLayout
function loginFailed() {
signInButton.loading = false;
usernameTextField.enabled = true;
usernameTextField.error = true;
passwordTextField.enabled = true;
passwordTextField.error = true;
}
anchors.fill: parent
function loginFailed() {
signInButton.loading = false
usernameTextField.enabled = true
usernameTextField.error = true
passwordTextField.enabled = true
passwordTextField.error = true
}
Connections {
function onLogin2FAError(_) {
console.assert(stackLayout.currentIndex === 1, "Unexpected login2FAError");
twoFAButton.loading = false;
twoFactorPasswordTextField.enabled = true;
twoFactorPasswordTextField.error = true;
twoFactorPasswordTextField.errorString = qsTr("Your code is incorrect");
twoFactorPasswordTextField.focus = true;
}
function onLogin2FAErrorAbort(_) {
console.assert(stackLayout.currentIndex === 1, "Unexpected login2FAErrorAbort");
root.reset();
errorLabel.text = qsTr("Incorrect login credentials. Please try again.");
}
function onLogin2FARequested(username) {
console.assert(stackLayout.currentIndex === 0, "Unexpected login2FARequested");
twoFactorUsernameLabel.text = username;
stackLayout.currentIndex = 1;
twoFactorPasswordTextField.focus = true;
}
function onLogin2PasswordError(_) {
console.assert(stackLayout.currentIndex === 2, "Unexpected login2PasswordError");
secondPasswordButton.loading = false;
secondPasswordTextField.enabled = true;
secondPasswordTextField.error = true;
secondPasswordTextField.errorString = qsTr("Your mailbox password is incorrect");
secondPasswordTextField.focus = true;
}
function onLogin2PasswordErrorAbort(_) {
console.assert(stackLayout.currentIndex === 2, "Unexpected login2PasswordErrorAbort");
root.reset();
errorLabel.text = qsTr("Incorrect login credentials. Please try again.");
}
function onLogin2PasswordRequested() {
console.assert(stackLayout.currentIndex === 0 || stackLayout.currentIndex === 1, "Unexpected login2PasswordRequested");
stackLayout.currentIndex = 2;
secondPasswordTextField.focus = true;
}
function onLoginAlreadyLoggedIn(_) {
stackLayout.currentIndex = 0;
root.reset();
}
function onLoginConnectionError(_) {
if (stackLayout.currentIndex === 0) {
stackLayout.loginFailed();
}
}
function onLoginFinished(_) {
stackLayout.currentIndex = 0;
root.reset();
}
function onLoginFreeUserError() {
console.assert(stackLayout.currentIndex === 0, "Unexpected loginFreeUserError");
stackLayout.loginFailed();
}
target: Backend
function onLoginUsernamePasswordError(errorMsg) {
console.assert(stackLayout.currentIndex === 0, "Unexpected loginUsernamePasswordError");
stackLayout.loginFailed();
if (errorMsg !== "")
errorLabel.text = errorMsg;
else
errorLabel.text = qsTr("Incorrect login credentials");
console.assert(stackLayout.currentIndex == 0, "Unexpected loginUsernamePasswordError")
console.assert(signInButton.loading == true, "Unexpected loginUsernamePasswordError")
stackLayout.loginFailed()
if (errorMsg!="") errorLabel.text = errorMsg
else errorLabel.text = qsTr("Incorrect login credentials")
}
target: Backend
function onLoginFreeUserError() {
console.assert(stackLayout.currentIndex == 0, "Unexpected loginFreeUserError")
stackLayout.loginFailed()
}
function onLoginConnectionError(errorMsg) {
if (stackLayout.currentIndex == 0 ) {
stackLayout.loginFailed()
}
}
function onLogin2FARequested(username) {
console.assert(stackLayout.currentIndex == 0, "Unexpected login2FARequested")
twoFactorUsernameLabel.text = username
stackLayout.currentIndex = 1
twoFactorPasswordTextField.focus = true
}
function onLogin2FAError(errorMsg) {
console.assert(stackLayout.currentIndex == 1, "Unexpected login2FAError")
twoFAButton.loading = false
twoFactorPasswordTextField.enabled = true
twoFactorPasswordTextField.error = true
twoFactorPasswordTextField.errorString = qsTr("Your code is incorrect")
twoFactorPasswordTextField.focus = true
}
function onLogin2FAErrorAbort(errorMsg) {
console.assert(stackLayout.currentIndex == 1, "Unexpected login2FAErrorAbort")
root.reset()
errorLabel.text = qsTr("Incorrect login credentials. Please try again.")
}
function onLogin2PasswordRequested() {
console.assert(stackLayout.currentIndex == 0 || stackLayout.currentIndex == 1, "Unexpected login2PasswordRequested")
stackLayout.currentIndex = 2
secondPasswordTextField.focus = true
}
function onLogin2PasswordError(errorMsg) {
console.assert(stackLayout.currentIndex == 2, "Unexpected login2PasswordError")
secondPasswordButton.loading = false
secondPasswordTextField.enabled = true
secondPasswordTextField.error = true
secondPasswordTextField.errorString = qsTr("Your mailbox password is incorrect")
secondPasswordTextField.focus = true
}
function onLogin2PasswordErrorAbort(errorMsg) {
console.assert(stackLayout.currentIndex == 2, "Unexpected login2PasswordErrorAbort")
root.reset()
errorLabel.text = qsTr("Incorrect login credentials. Please try again.")
}
function onLoginFinished(index) {
stackLayout.currentIndex = 0
root.reset()
}
}
ColumnLayout {
id: loginNormalLayout
function reset() {
signInButton.loading = false;
errorLabel.text = "";
usernameTextField.enabled = true;
usernameTextField.error = false;
usernameTextField.errorString = "";
usernameTextField.focus = true;
passwordTextField.enabled = true;
passwordTextField.error = false;
passwordTextField.errorString = "";
passwordTextField.text = "";
signInButton.loading = false
errorLabel.text = ""
usernameTextField.enabled = true
usernameTextField.error = false
usernameTextField.errorString = ""
usernameTextField.focus = true
passwordTextField.enabled = true
passwordTextField.error = false
passwordTextField.errorString = ""
passwordTextField.text = ""
}
spacing: 0
Label {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 16
colorScheme: root.colorScheme
text: qsTr("Sign in")
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 16
type: Label.LabelType.Title
}
Label {
colorScheme: root.colorScheme
id: subTitle
text: qsTr("Enter your Proton Account details.")
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 8
color: root.colorScheme.text_weak
colorScheme: root.colorScheme
text: qsTr("Enter your Proton Account details.")
type: Label.LabelType.Body
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 36
spacing: 0
visible: errorLabel.text.length > 0
ColorImage {
color: root.colorScheme.signal_danger
height: errorLabel.lineHeight
source: "/qml/icons/ic-exclamation-circle-filled.svg"
height: errorLabel.lineHeight
sourceSize.height: errorLabel.lineHeight
}
Label {
colorScheme: root.colorScheme
id: errorLabel
Layout.fillWidth: true
wrapMode: Text.WordWrap
Layout.fillWidth: true;
Layout.leftMargin: 4
color: root.colorScheme.signal_danger
colorScheme: root.colorScheme
type: root.error ? Label.LabelType.Caption_semibold : Label.LabelType.Caption
wrapMode: Text.WordWrap
}
}
TextField {
colorScheme: root.colorScheme
id: usernameTextField
label: qsTr("Email or username")
focus: true
Layout.fillWidth: true
Layout.topMargin: 24
colorScheme: root.colorScheme
focus: true
label: qsTr("Email or username")
validateOnEditingFinished: false
validator: function (str) {
if (str.length === 0) {
return qsTr("Enter email or username");
onTextChanged: {
// remove "invalid username / password error"
if (error || errorLabel.text.length > 0) {
errorLabel.text = ""
usernameTextField.error = false
passwordTextField.error = false
}
}
validator: function(str) {
if (str.length === 0) {
return qsTr("Enter email or username")
}
return
}
onAccepted: passwordTextField.forceActiveFocus()
}
TextField {
colorScheme: root.colorScheme
id: passwordTextField
label: qsTr("Password")
Layout.fillWidth: true
Layout.topMargin: 8
echoMode: TextInput.Password
validateOnEditingFinished: false
onTextChanged: {
// remove "invalid username / password error"
if (error || errorLabel.text.length > 0) {
errorLabel.text = "";
usernameTextField.error = false;
passwordTextField.error = false;
errorLabel.text = ""
usernameTextField.error = false
passwordTextField.error = false
}
}
}
TextField {
id: passwordTextField
Layout.fillWidth: true
Layout.topMargin: 8
colorScheme: root.colorScheme
echoMode: TextInput.Password
label: qsTr("Password")
validateOnEditingFinished: false
validator: function (str) {
validator: function(str) {
if (str.length === 0) {
return qsTr("Enter password");
return qsTr("Enter password")
}
return
}
onAccepted: signInButton.checkAndSignIn()
onTextChanged: {
// remove "invalid username / password error"
if (error || errorLabel.text.length > 0) {
errorLabel.text = "";
usernameTextField.error = false;
passwordTextField.error = false;
}
}
}
Button {
id: signInButton
function checkAndSignIn() {
usernameTextField.validate();
passwordTextField.validate();
if (usernameTextField.error || passwordTextField.error) {
return;
}
usernameTextField.enabled = false;
passwordTextField.enabled = false;
loading = true;
Backend.login(usernameTextField.text, Qt.btoa(passwordTextField.text));
}
Button {
colorScheme: root.colorScheme
id: signInButton
text: loading ? qsTr("Signing in") : qsTr("Sign in")
enabled: !loading
Layout.fillWidth: true
Layout.topMargin: 24
colorScheme: root.colorScheme
enabled: !loading
text: loading ? qsTr("Signing in") : qsTr("Sign in")
onClicked: {
checkAndSignIn();
onClicked: checkAndSignIn()
function checkAndSignIn() {
usernameTextField.validate()
passwordTextField.validate()
if (usernameTextField.error || passwordTextField.error) {
return
}
usernameTextField.enabled = false
passwordTextField.enabled = false
loading = true
Backend.login(usernameTextField.text, Qt.btoa(passwordTextField.text))
}
}
Label {
colorScheme: root.colorScheme
textFormat: Text.StyledText
text: link("https://proton.me/mail/pricing", qsTr("Create or upgrade your account"))
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 24
colorScheme: root.colorScheme
text: link("https://proton.me/mail/pricing", qsTr("Create or upgrade your account"))
textFormat: Text.StyledText
type: Label.LabelType.Body
onLinkActivated: {
Qt.openUrlExternally(link);
Qt.openUrlExternally(link)
}
}
}
ColumnLayout {
id: login2FALayout
function reset() {
twoFAButton.loading = false;
twoFactorPasswordTextField.enabled = true;
twoFactorPasswordTextField.error = false;
twoFactorPasswordTextField.errorString = "";
twoFactorPasswordTextField.text = "";
twoFAButton.loading = false
twoFactorPasswordTextField.enabled = true
twoFactorPasswordTextField.error = false
twoFactorPasswordTextField.errorString = ""
twoFactorPasswordTextField.text = ""
}
spacing: 0
Label {
Layout.alignment: Qt.AlignCenter
Layout.topMargin: 16
colorScheme: root.colorScheme
text: qsTr("Two-factor authentication")
Layout.topMargin: 16
Layout.alignment: Qt.AlignCenter
type: Label.LabelType.Heading
}
Label {
colorScheme: root.colorScheme
id: twoFactorUsernameLabel
Layout.alignment: Qt.AlignCenter
Layout.topMargin: 8
color: root.colorScheme.text_weak
colorScheme: root.colorScheme
type: Label.LabelType.Lead
color: root.colorScheme.text_weak
}
TextField {
colorScheme: root.colorScheme
id: twoFactorPasswordTextField
label: qsTr("Two-factor code")
assistiveText: qsTr("Enter the 6-digit code")
validateOnEditingFinished: false
Layout.fillWidth: true
Layout.topMargin: 32
assistiveText: qsTr("Enter the 6-digit code")
colorScheme: root.colorScheme
label: qsTr("Two-factor code")
validateOnEditingFinished: false
validator: function (str) {
validator: function(str) {
if (str.length === 0) {
return qsTr("Enter the 6-digit code");
return qsTr("Enter the 6-digit code")
}
}
onTextChanged: {
if (text.length >= 6) {
twoFAButton.onClicked()
}
}
onAccepted: {
twoFAButton.onClicked();
}
onTextChanged: {
if (text.length >= 6) {
twoFAButton.onClicked();
}
twoFAButton.onClicked()
}
}
Button {
colorScheme: root.colorScheme
id: twoFAButton
text: loading ? qsTr("Authenticating") : qsTr("Authenticate")
enabled: !loading
Layout.fillWidth: true
Layout.topMargin: 24
colorScheme: root.colorScheme
enabled: !loading
text: loading ? qsTr("Authenticating") : qsTr("Authenticate")
onClicked: {
twoFactorPasswordTextField.validate();
twoFactorPasswordTextField.validate()
if (twoFactorPasswordTextField.error) {
return;
return
}
twoFactorPasswordTextField.enabled = false;
loading = true;
Backend.login2FA(usernameTextField.text, Qt.btoa(twoFactorPasswordTextField.text));
twoFactorPasswordTextField.enabled = false
loading = true
Backend.login2FA(usernameTextField.text, Qt.btoa(twoFactorPasswordTextField.text))
}
}
}
ColumnLayout {
id: login2PasswordLayout
function reset() {
secondPasswordButton.loading = false;
secondPasswordTextField.enabled = true;
secondPasswordTextField.error = false;
secondPasswordTextField.errorString = "";
secondPasswordTextField.text = "";
secondPasswordButton.loading = false
secondPasswordTextField.enabled = true
secondPasswordTextField.error = false
secondPasswordTextField.errorString = ""
secondPasswordTextField.text = ""
}
spacing: 0
Label {
Layout.alignment: Qt.AlignCenter
Layout.topMargin: 16
colorScheme: root.colorScheme
text: qsTr("Unlock your mailbox")
Layout.topMargin: 16
Layout.alignment: Qt.AlignCenter
type: Label.LabelType.Heading
}
TextField {
colorScheme: root.colorScheme
id: secondPasswordTextField
label: qsTr("Mailbox password")
Layout.fillWidth: true
Layout.topMargin: 8 + implicitHeight + 24 + subTitle.implicitHeight
colorScheme: root.colorScheme
echoMode: TextInput.Password
label: qsTr("Mailbox password")
validateOnEditingFinished: false
validator: function (str) {
validator: function(str) {
if (str.length === 0) {
return qsTr("Enter password");
return qsTr("Enter password")
}
return
}
onAccepted: {
secondPasswordButton.onClicked();
secondPasswordButton.onClicked()
}
}
Button {
colorScheme: root.colorScheme
id: secondPasswordButton
text: loading ? qsTr("Unlocking") : qsTr("Unlock")
enabled: !loading
Layout.fillWidth: true
Layout.topMargin: 24
colorScheme: root.colorScheme
enabled: !loading
text: loading ? qsTr("Unlocking") : qsTr("Unlock")
onClicked: {
secondPasswordTextField.validate();
secondPasswordTextField.validate()
if (secondPasswordTextField.error) {
return;
return
}
secondPasswordTextField.enabled = false;
loading = true;
Backend.login2Password(usernameTextField.text, Qt.btoa(secondPasswordTextField.text));
secondPasswordTextField.enabled = false
loading = true
Backend.login2Password(usernameTextField.text, Qt.btoa(secondPasswordTextField.text))
}
}
}
}
states: [
State {
name: "Page 1"
PropertyChanges {
target: stackLayout
currentIndex: 0
}
},
State {
name: "Page 2"
PropertyChanges {
target: stackLayout
currentIndex: 1
}
},
State {
name: "Page 3"
PropertyChanges {
target: stackLayout
currentIndex: 2
}
}
]
}

View File

@ -1,169 +1,194 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
Dialog {
id: root
leftPadding: 0
modal: true
rightPadding: 0
shouldShow: Backend.showSplashScreen
topPadding: 0
modal: true
topPadding : 0
leftPadding : 0
rightPadding : 0
ColumnLayout {
spacing: 20
Image {
Layout.alignment: Qt.AlignHCenter
Layout.preferredHeight: 144
Layout.preferredWidth: 384
source: "./icons/img-splash.png"
sourceSize.height: 144
sourceSize.width: 384
sourceSize.height: 144
Layout.preferredWidth: 384
Layout.preferredHeight: 144
source: "./icons/img-splash.png"
}
Label {
Layout.alignment: Qt.AlignHCenter
colorScheme: root.colorScheme;
Layout.alignment: Qt.AlignHCenter;
Layout.leftMargin: 24
Layout.preferredWidth: 336
Layout.rightMargin: 24
colorScheme: root.colorScheme
Layout.preferredWidth: 336
type: Label.Title
horizontalAlignment: Text.AlignHCenter
text: qsTr("What's new in Bridge")
type: Label.Title
}
RowLayout {
width: root.width
Item {
Layout.fillHeight: true
width: 24
Layout.leftMargin: 32
Layout.rightMargin: 16
width: 24
Image {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
source: "./icons/ic-splash-check.svg"
sourceSize.height: 24
sourceSize.width: 24
sourceSize.height: 24
source: "./icons/ic-splash-check.svg"
}
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.leftMargin: 0
Layout.preferredWidth: 264
Layout.rightMargin: 24
colorScheme: root.colorScheme
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter;
Layout.preferredWidth: 264
Layout.leftMargin: 0
Layout.rightMargin: 24
wrapMode: Text.WordWrap
type: Label.Body
horizontalAlignment: Text.AlignLeft
textFormat: Text.StyledText
text: qsTr("<b>New IMAP engine</b><br/>For improved stability and performance.")
textFormat: Text.StyledText
type: Label.Body
wrapMode: Text.WordWrap
}
}
RowLayout {
width: root.width
Item {
Layout.fillHeight: true
width: 24
Layout.leftMargin: 32
Layout.rightMargin: 16
width: 24
Image {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
source: "./icons/ic-splash-check.svg"
sourceSize.height: 24
sourceSize.width: 24
sourceSize.height: 24
source: "./icons/ic-splash-check.svg"
}
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.leftMargin: 0
Layout.preferredWidth: 264
Layout.rightMargin: 24
colorScheme: root.colorScheme
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter;
Layout.preferredWidth: 264
Layout.leftMargin: 0
Layout.rightMargin: 24
wrapMode: Text.WordWrap
type: Label.Body
horizontalAlignment: Text.AlignLeft
textFormat: Text.StyledText
text: qsTr("<b>Faster than ever</b><br/>Up to 10x faster syncing and receiving.")
textFormat: Text.StyledText
type: Label.Body
wrapMode: Text.WordWrap
}
}
RowLayout {
width: root.width
Item {
Layout.fillHeight: true
width: 24
Layout.leftMargin: 32
Layout.rightMargin: 16
width: 24
Image {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
source: "./icons/ic-splash-check.svg"
sourceSize.height: 24
sourceSize.width: 24
sourceSize.height: 24
source: "./icons/ic-splash-check.svg"
}
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.leftMargin: 0
Layout.preferredWidth: 264
Layout.rightMargin: 24
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignLeft
text: qsTr("<b>Extra security</b><br/>New, encrypted local database and keychain improvements.")
textFormat: Text.StyledText
type: Label.Body
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter;
Layout.preferredWidth: 264
Layout.leftMargin: 0
Layout.rightMargin: 24
wrapMode: Text.WordWrap
type: Label.Body
horizontalAlignment: Text.AlignLeft
textFormat: Text.StyledText
text: qsTr("<b>Extra security</b><br/>New, encrypted local database and keychain improvements.")
}
}
Button {
Layout.fillWidth: true
Layout.leftMargin: 24
Layout.rightMargin: 24
colorScheme: root.colorScheme
text: "Got it"
onClicked: Backend.showSplashScreen = false
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.leftMargin: 24
Layout.preferredWidth: 336
Layout.rightMargin: 24
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Note that your client will redownload all the emails.<br/>") + link("https://proton.me/blog/new-proton-mail-bridge", qsTr("Learn more about new Bridge."))
textFormat: Text.StyledText
type: Label.Body
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter;
Layout.preferredWidth: 336
Layout.leftMargin: 24
Layout.rightMargin: 24
wrapMode: Text.WordWrap
onLinkActivated: function (link) {
Qt.openUrlExternally(link);
}
type: Label.Body
horizontalAlignment: Text.AlignHCenter
textFormat: Text.StyledText
text: qsTr("Note that your client will redownload all the emails.<br/>") + link("https://proton.me/blog/new-proton-mail-bridge", qsTr("Learn more about new Bridge."))
onLinkActivated: function(link) { Qt.openUrlExternally(link) }
}
}
}

View File

@ -1,93 +1,111 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
import Proton
import Notifications
Item {
id: root
readonly property Notification activeNotification: notificationFilter.topmost
property ColorScheme colorScheme
property int notificationBlacklist: NotificationFilter.FilterConsts.None
property int notificationWhitelist: NotificationFilter.FilterConsts.All
property var notifications
property ColorScheme colorScheme
property int notificationWhitelist: NotificationFilter.FilterConsts.All
property int notificationBlacklist: NotificationFilter.FilterConsts.None
readonly property Notification activeNotification: notificationFilter.topmost
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
implicitWidth: children[0].implicitWidth + children[0].anchors.leftMargin + children[0].anchors.rightMargin
NotificationFilter {
id: notificationFilter
blacklist: root.notificationBlacklist
source: root.notifications ? root.notifications.all : undefined
whitelist: root.notificationWhitelist
blacklist: root.notificationBlacklist
onTopmostChanged: {
if (!topmost) {
image.source = "/qml/icons/ic-connected.svg";
image.color = root.colorScheme.signal_success;
label.text = qsTr("Connected");
label.color = root.colorScheme.signal_success;
image.source = "/qml/icons/ic-connected.svg"
image.color = root.colorScheme.signal_success
label.text = qsTr("Connected")
label.color = root.colorScheme.signal_success
return;
}
image.source = topmost.icon;
label.text = topmost.brief;
image.source = topmost.icon
label.text = topmost.brief
switch (topmost.type) {
case Notification.NotificationType.Danger:
image.color = root.colorScheme.signal_danger;
label.color = root.colorScheme.signal_danger;
case Notification.NotificationType.Danger:
image.color = root.colorScheme.signal_danger
label.color = root.colorScheme.signal_danger
break;
case Notification.NotificationType.Warning:
image.color = root.colorScheme.signal_warning;
label.color = root.colorScheme.signal_warning;
case Notification.NotificationType.Warning:
image.color = root.colorScheme.signal_warning
label.color = root.colorScheme.signal_warning
break;
case Notification.NotificationType.Success:
image.color = root.colorScheme.signal_success;
label.color = root.colorScheme.signal_success;
case Notification.NotificationType.Success:
image.color = root.colorScheme.signal_success
label.color = root.colorScheme.signal_success
break;
case Notification.NotificationType.Info:
image.color = root.colorScheme.signal_info;
label.color = root.colorScheme.signal_info;
case Notification.NotificationType.Info:
image.color = root.colorScheme.signal_info
label.color = root.colorScheme.signal_info
break;
}
}
}
RowLayout {
anchors.fill: parent
spacing: 8
ColorImage {
id: image
color: root.colorScheme.signal_success
height: 16
source: "/qml/icons/ic-connected.svg"
sourceSize.height: height
sourceSize.width: width
width: 16
height: 16
sourceSize.width: width
sourceSize.height: height
source: "/qml/icons/ic-connected.svg"
color: root.colorScheme.signal_success
}
Label {
colorScheme: root.colorScheme
id: label
Layout.fillHeight: true
Layout.fillWidth: true
color: root.colorScheme.signal_success
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignLeft
text: qsTr("Connected")
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
text: qsTr("Connected")
color: root.colorScheme.signal_success
}
}
}

View File

@ -1,19 +1,25 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
Item {
@ -28,218 +34,243 @@ Item {
anchors.fill: parent
spacing: 0
states: [
State {
name: "Page 1"
PropertyChanges {
currentIndex: 0
target: signInItem
}
},
State {
name: "Page 2"
PropertyChanges {
currentIndex: 1
target: signInItem
}
},
State {
name: "Page 3"
PropertyChanges {
currentIndex: 2
target: signInItem
}
}
]
Rectangle {
color: root.colorScheme.background_norm
Layout.fillHeight: true
Layout.fillWidth: true
color: root.colorScheme.background_norm
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
visible: signInItem.currentIndex === 0
visible: signInItem.currentIndex == 0
GridLayout {
anchors.fill: parent
columnSpacing: 0
columns: 3
rowSpacing: 0
columns: 3
// top margin
Item {
Layout.columnSpan: 3
Layout.fillWidth: true
// Using binding component here instead of direct binding to avoid binding loop during construction of element
Binding on Layout.preferredHeight {
Binding on Layout.preferredHeight {
value: (parent.height - welcomeContentItem.height) / 4
}
}
// left margin
Item {
Layout.fillWidth: true
Layout.maximumWidth: 80
Layout.minimumWidth: 48
Layout.maximumWidth: 80
Layout.fillWidth: true
Layout.preferredHeight: welcomeContentItem.height
}
ColumnLayout {
id: welcomeContentItem
Layout.fillWidth: true
spacing: 0
Image {
source: colorScheme.welcome_img
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 16
source: colorScheme.welcome_img
sourceSize.height: 148
sourceSize.width: 264
}
Label {
colorScheme: root.colorScheme
text: qsTr("Welcome to\nProton Mail Bridge")
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.topMargin: 16
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Welcome to\nProton Mail Bridge")
type: Label.LabelType.Heading
}
Label {
colorScheme: root.colorScheme
id: longTextLabel
text: qsTr("Add your Proton Mail account to securely access and manage your messages in your favorite email client. Bridge runs in the background and encrypts and decrypts your messages seamlessly.")
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.preferredWidth: 320
Layout.topMargin: 16
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Add your Proton Mail account to securely access and manage your messages in your favorite email client. Bridge runs in the background and encrypts and decrypts your messages seamlessly.")
type: Label.LabelType.Body
Layout.preferredWidth: 320
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
type: Label.LabelType.Body
}
}
// Right margin
Item {
Layout.fillWidth: true
Layout.maximumWidth: 80
Layout.minimumWidth: 48
Layout.maximumWidth: 80
Layout.fillWidth: true
Layout.preferredHeight: welcomeContentItem.height
}
// bottom margin
Item {
Layout.columnSpan: 3
Layout.fillHeight: true
Layout.fillWidth: true
Layout.fillHeight: true
implicitHeight: children[0].implicitHeight + children[0].anchors.bottomMargin + children[0].anchors.topMargin
implicitWidth: children[0].implicitWidth
Image {
id: logoImage
anchors.bottom: parent.bottom
anchors.bottomMargin: 48
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 48
source: colorScheme.logo_img
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.topMargin: 48
anchors.bottomMargin: 48
sourceSize.height: 25
sourceSize.width: 200
}
}
}
}
Rectangle {
color: (signInItem.currentIndex == 0) ? root.colorScheme.background_weak : root.colorScheme.background_norm
Layout.fillHeight: true
Layout.fillWidth: true
color: (signInItem.currentIndex == 0) ? root.colorScheme.background_weak : root.colorScheme.background_norm
implicitHeight: children[0].implicitHeight
implicitWidth: children[0].implicitWidth
RowLayout {
anchors.fill: parent
spacing: 0
Item {
Layout.fillHeight: true
Layout.fillWidth: true
Layout.preferredWidth: signInItem.currentIndex == 0 ? 0 : parent.width / 4
implicitHeight: children[0].implicitHeight + children[0].anchors.topMargin + children[0].anchors.bottomMargin
implicitWidth: children[0].implicitWidth + children[0].anchors.leftMargin + children[0].anchors.rightMargin
Button {
anchors.bottom: parent.bottom
anchors.bottomMargin: 80
colorScheme: root.colorScheme
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.leftMargin: 80
anchors.rightMargin: 80
anchors.topMargin: 80
colorScheme: root.colorScheme
secondary: true
text: qsTr("Back")
anchors.bottomMargin: 80
visible: signInItem.currentIndex != 0
secondary: true
text: qsTr("Back")
onClicked: {
signInItem.abort();
signInItem.abort()
}
}
}
GridLayout {
Layout.fillHeight: true
Layout.fillWidth: true
columnSpacing: 0
columns: 3
rowSpacing: 0
columns: 3
// top margin
Item {
Layout.columnSpan: 3
Layout.fillWidth: true
// Using binding component here instead of direct binding to avoid binding loop during construction of element
Binding on Layout.preferredHeight {
Binding on Layout.preferredHeight {
value: (parent.height - signInItem.height) / 4
}
}
// left margin
Item {
Layout.fillWidth: true
Layout.maximumWidth: 80
Layout.minimumWidth: 48
Layout.maximumWidth: 80
Layout.fillWidth: true
Layout.preferredHeight: signInItem.height
}
SignIn {
id: signInItem
Layout.fillWidth: true
Layout.preferredWidth: 320
colorScheme: root.colorScheme
Layout.preferredWidth: 320
Layout.fillWidth: true
focus: true
username: Backend.users.count === 1 && Backend.users.get(0) && (Backend.users.get(0).state === EUserState.SignedOut) ? Backend.users.get(0).username : ""
}
// Right margin
Item {
Layout.fillWidth: true
Layout.maximumWidth: 80
Layout.minimumWidth: 48
Layout.maximumWidth: 80
Layout.fillWidth: true
Layout.preferredHeight: signInItem.height
}
// bottom margin
Item {
Layout.columnSpan: 3
Layout.fillHeight: true
Layout.fillWidth: true
Layout.fillHeight: true
}
}
Item {
Layout.fillHeight: true
Layout.preferredWidth: signInItem.currentIndex === 0 ? 0 : parent.width / 4
Layout.preferredWidth: signInItem.currentIndex == 0 ? 0 : parent.width / 4
}
}
}
states: [
State {
name: "Page 1"
PropertyChanges {
target: signInItem
currentIndex: 0
}
},
State {
name: "Page 2"
PropertyChanges {
target: signInItem
currentIndex: 1
}
},
State {
name: "Page 3"
PropertyChanges {
target: signInItem
currentIndex: 2
}
}
]
}
}

View File

@ -1,5 +0,0 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.75 15.5V8H8.75V9.5H10.25V15.5H8.75V17H13.25V15.5H11.75Z" fill="#0C0C14"/>
<path d="M11.75 6.5V5H10.25V6.5H11.75Z" fill="#0C0C14"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 11C0.5 5.20101 5.20101 0.5 11 0.5C13.7848 0.5 16.4555 1.60625 18.4246 3.57538C20.3938 5.54451 21.5 8.21523 21.5 11C21.5 16.799 16.799 21.5 11 21.5C5.20101 21.5 0.5 16.799 0.5 11ZM2 11C2 15.9706 6.02944 20 11 20C13.3869 20 15.6761 19.0518 17.364 17.364C19.0518 15.6761 20 13.3869 20 11C20 6.02944 15.9706 2 11 2C6.02944 2 2 6.02944 2 11Z" fill="#0C0C14"/>
</svg>

Before

Width:  |  Height:  |  Size: 656 B

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