gitpane 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
"""Add an account for any HTTPS server, or renew the sign-in of a listed one, from one small window.
|
|
2
|
+
|
|
3
|
+
The window picks the way to sign in from the server address. GitHub, gitlab.com and Bitbucket Cloud refuse the account
|
|
4
|
+
password over Git HTTPS, so for them it offers browser sign-in, with a personal access token checked by the service as
|
|
5
|
+
the other way. Any other server gets a username with a password or token, tested with Git on a repository of that
|
|
6
|
+
server before it is saved. When that test is refused, the window advises the method the server accepts: browser
|
|
7
|
+
sign-in for a server recognised as offering it, otherwise a personal access token and where to create one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import threading
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import QDeadlineTimer, QRect, Qt, QTimer, Signal
|
|
18
|
+
from PySide6.QtGui import QTextCursor
|
|
19
|
+
from PySide6.QtWidgets import QApplication, QDialog, QFormLayout, QHBoxLayout, QLabel, QLayout, QLineEdit, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget
|
|
20
|
+
from shiboken6 import isValid
|
|
21
|
+
|
|
22
|
+
from gitpane.account.authentication import (
|
|
23
|
+
BITBUCKET_PROVIDER,
|
|
24
|
+
BROWSER_TIMEOUT_STATUS_SET,
|
|
25
|
+
BROWSER_TIMEOUT_TEXT,
|
|
26
|
+
CHECK_FAILED_TEXT,
|
|
27
|
+
GENERIC_PROVIDER,
|
|
28
|
+
HTTPS_PREFIX,
|
|
29
|
+
endpoint_origin,
|
|
30
|
+
origin_address,
|
|
31
|
+
provider_for_browser_origin,
|
|
32
|
+
store_was_refused,
|
|
33
|
+
)
|
|
34
|
+
from gitpane.account.credential_manager import ignore_line
|
|
35
|
+
from gitpane.account.keyring_state import UNKNOWN_KEYRING_READING, KeyringProvider, KeyringReading, KeyringState
|
|
36
|
+
from gitpane.config import BROWSER_SIGN_IN_TIMEOUT_MS, MILLISECONDS_PER_SECOND, SPACING_SM, SharedText, control_height, fit_window
|
|
37
|
+
from gitpane.text.typed_text import typed_value
|
|
38
|
+
from gitpane.thread.weak_signal import hand_over_to_the_page, weak_signal_emitter
|
|
39
|
+
from gitpane.transport.git_refusal import RefusalKind, refusal_kind
|
|
40
|
+
from gitpane.ui.look.fixed_font import body_sized_fixed_font
|
|
41
|
+
from gitpane.ui.widget.busy_indicator import BusyIndicator
|
|
42
|
+
from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
|
|
43
|
+
from gitpane.ui.widget.content_measure import LayoutContentMeasure
|
|
44
|
+
from gitpane.ui.widget.dialog_layout import apply_left_aligned_form
|
|
45
|
+
from gitpane.ui.widget.long_text import FRAME_SIDE_COUNT
|
|
46
|
+
from gitpane.ui.widget.result_label import ResultLabel, ResultTone
|
|
47
|
+
from gitpane.ui.window_control.compositor_answer import after_compositor_answer
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Callable
|
|
51
|
+
|
|
52
|
+
from gitpane.account.authentication import AccountCheck, AccountSelection, AuthOutcome
|
|
53
|
+
|
|
54
|
+
ADD_ACCOUNT_WIDTH, ADD_ACCOUNT_HEIGHT = 560, 420
|
|
55
|
+
ADD_ACCOUNT_MIN_WIDTH, ADD_ACCOUNT_MIN_HEIGHT = 400, 280
|
|
56
|
+
# Lines of output the field shows at least when an output arrives: the form grows to give it that height, the rest scrolls.
|
|
57
|
+
ACCOUNT_OUTPUT_LINE_COUNT = 10
|
|
58
|
+
# The output document's margin is counted twice in the field's height: once above its text, once below it.
|
|
59
|
+
DOCUMENT_MARGIN_SIDE_COUNT = 2
|
|
60
|
+
ADD_ACCOUNT_TITLE = "Add account"
|
|
61
|
+
RECONNECT_TITLE = "Reconnect account"
|
|
62
|
+
ADD_BUTTON_TEXT = "Add account"
|
|
63
|
+
RECONNECT_BUTTON_TEXT = "Reconnect"
|
|
64
|
+
SERVER_URL_LABEL = "Server URL"
|
|
65
|
+
SERVICE_NAME_LABEL = "Service name"
|
|
66
|
+
USERNAME_LABEL = "Username"
|
|
67
|
+
PASSWORD_LABEL = "Password or token"
|
|
68
|
+
TOKEN_LABEL = "Personal access token"
|
|
69
|
+
BITBUCKET_EMAIL_LABEL = "Bitbucket API email"
|
|
70
|
+
CHECK_URL_LABEL = "Repository URL to test"
|
|
71
|
+
SERVER_URL_PLACEHOLDER = "https://gitlab.mycompany.example"
|
|
72
|
+
SERVICE_NAME_PLACEHOLDER = "Name of your service (GitHub, GitLab, etc.)"
|
|
73
|
+
CHECK_URL_PLACEHOLDER = "https://gitlab.mycompany.example/group/project.git"
|
|
74
|
+
SERVER_HELP_TEXT = ("Any HTTPS server works, including your company's own server: change the Server URL above. "
|
|
75
|
+
"The service name is only used in GitPane's account lists.")
|
|
76
|
+
PASSWORD_HELP_TEXT = ("Before saving the account, GitPane tests the username and password on a repository of this server. "
|
|
77
|
+
"Nothing is saved if the server refuses them.")
|
|
78
|
+
CLOUD_HELP_TEXT = ("GitHub, gitlab.com and Bitbucket Cloud do not accept your account password for Git. "
|
|
79
|
+
"Sign in with your browser, or use a personal access token created on the service's website.")
|
|
80
|
+
TOKEN_HELP_TEXT = "GitPane checks the personal access token with the service before saving it."
|
|
81
|
+
HELP_TEXT_SEPARATOR = "\n"
|
|
82
|
+
BROWSER_BUTTON_TEXT = "Sign in with browser"
|
|
83
|
+
# Once a token or password field is shown, the browser is the other way to sign in, and its button says so.
|
|
84
|
+
BROWSER_INSTEAD_BUTTON_TEXT = "Sign in with browser instead"
|
|
85
|
+
TOKEN_LINK_TEXT = "Use a token instead"
|
|
86
|
+
SELF_HOSTED_BROWSER_TEXT = ("On this server, sign in with your username and password or a token. Browser sign-in needs an OAuth application "
|
|
87
|
+
"installed on the server, so it is offered only if the server refuses passwords and supports it.")
|
|
88
|
+
# Said in orange, with a turning indicator, while the browser has not answered yet: the time left before GitPane stops
|
|
89
|
+
# waiting, updated every second.
|
|
90
|
+
BROWSER_COUNTDOWN_TEXT = "Waiting for browser response {minutes:02d}:{seconds:02d}"
|
|
91
|
+
COUNTDOWN_INTERVAL_MS = 1000
|
|
92
|
+
SECONDS_PER_MINUTE = 60
|
|
93
|
+
TOKEN_WAIT_TEXT = "Checking token…"
|
|
94
|
+
CHECKING_TEXT = "Testing the sign-in on {address}…"
|
|
95
|
+
CHECK_NOTE_TEXT = "The sign-in is tested with {url}."
|
|
96
|
+
# Said in orange when the server's answer says account passwords are refused for Git: not an error, another method is
|
|
97
|
+
# needed, the browser when the server was recognised as offering it, otherwise a personal access token.
|
|
98
|
+
BROWSER_INSTEAD_TEXT = "This server does not accept passwords for Git. Sign in with your browser instead."
|
|
99
|
+
TOKEN_INSTEAD_TEXT = "This server does not accept passwords for Git. Use a personal access token instead."
|
|
100
|
+
TOKEN_PAGE_TEXT = "Create a personal access token at {url}, then enter it above."
|
|
101
|
+
TOKEN_PAGE_GENERIC_TEXT = "Create a personal access token in your account settings on this server, then enter it above."
|
|
102
|
+
CREDENTIALS_REFUSED_TEXT = "The server refused the username or password. Nothing was saved. Check them, or use a personal access token instead."
|
|
103
|
+
# Status line of Add account after the user cancelled a sign-in.
|
|
104
|
+
SIGN_IN_CANCELLED_TEXT = "Sign-in cancelled. Sign in again when ready."
|
|
105
|
+
EMPTY_SERVER_TEXT = "Enter the server URL, for example " + SERVER_URL_PLACEHOLDER + "."
|
|
106
|
+
EMPTY_SERVICE_NAME_TEXT = "Enter the name of your service."
|
|
107
|
+
EMPTY_USERNAME_TEXT = "Enter the username you use on this server."
|
|
108
|
+
EMPTY_PASSWORD_TEXT = "Enter a password or token."
|
|
109
|
+
EMPTY_TOKEN_TEXT = "Enter a personal access token."
|
|
110
|
+
EMPTY_BITBUCKET_EMAIL_TEXT = "Enter the e-mail address of your Bitbucket account."
|
|
111
|
+
URL_SCHEME_SEPARATOR = "://"
|
|
112
|
+
# Settings keeps the account list without a Save: after a sign-in there, the user is told nothing is left to save.
|
|
113
|
+
SIGN_IN_SAVED_TEXT = "It is saved automatically; you can close this window."
|
|
114
|
+
# The keyring is read on a worker thread of this name, so the window never waits on the session bus.
|
|
115
|
+
KEYRING_READ_THREAD_NAME = "gitpane-keyring-read"
|
|
116
|
+
KEYRING_READ_FAILURE_LOG_TEMPLATE = "Reading the state of the keyring failed unexpectedly: {error_class}: {error}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class AddAccountText(Enum):
|
|
120
|
+
"""Texts added to the sign-in form, one member per text so a misspelt name is an error."""
|
|
121
|
+
|
|
122
|
+
OUTPUT_NAME = "Sign-in output"
|
|
123
|
+
"""The accessible name of the field that shows the whole output of a failed sign-in."""
|
|
124
|
+
LIST_SAVE_FAILED = "Signed in and stored securely, but Connected accounts could not be saved."
|
|
125
|
+
"""The sign-in and the secure store worked, but the account list could not be written."""
|
|
126
|
+
GENERIC_STORE_FAILED = "The password or token could not be saved in the system credential store."
|
|
127
|
+
"""A username + password account passed its test, but the secure store refused to save it."""
|
|
128
|
+
STORE_FAILED = "Signed in, but the secure store could not save this account."
|
|
129
|
+
"""The service accepted the sign-in, but the secure store refused to save it."""
|
|
130
|
+
SIGN_IN_FAILED = "Sign-in failed."
|
|
131
|
+
"""The sign-in itself failed, before anything was saved."""
|
|
132
|
+
SIGN_IN_NOT_STARTED = "Sign-in did not start."
|
|
133
|
+
"""The sign-in was stopped before anything was sent to the credential manager, because the keyring could not store it."""
|
|
134
|
+
KEYRING_FIRST_SESSION = "This is a known bug of the GNOME keyring in the very first session of a new system or user: log out, log back in, then try again."
|
|
135
|
+
"""Said in orange when the keyring's default collection is not published on the session bus, which happens in the very first session only."""
|
|
136
|
+
KEYRING_LOCKED = "The GNOME keyring is locked: unlock it when GNOME asks, then try again."
|
|
137
|
+
"""Said in orange when the secure store refused to save while the keyring's default collection is still locked."""
|
|
138
|
+
KEYRING_NO_DEFAULT_COLLECTION = "The keyring has no default collection to save the account in: create one when the desktop asks for it, or in Passwords and Keys, then try again."
|
|
139
|
+
"""Said in orange when the secure store refused to save while the keyring still has no default collection."""
|
|
140
|
+
KDE_WALLET_CLOSED = "KDE Wallet is closed: open it when KDE asks, then try again."
|
|
141
|
+
"""Said in orange when KDE Wallet holds the Secret Service and its default wallet is closed."""
|
|
142
|
+
KDE_WALLET_NO_DEFAULT_WALLET = "KDE Wallet has no default wallet to save the account in: create one when KDE asks for it, or in KWalletManager, then try again."
|
|
143
|
+
"""Said in orange when KDE Wallet holds the Secret Service but no wallet stands for its default one."""
|
|
144
|
+
KEEPASSXC_LOCKED = "The KeePassXC database is locked: unlock it when KeePassXC asks, then try again."
|
|
145
|
+
"""Said in orange when KeePassXC holds the Secret Service and the database it shares is locked."""
|
|
146
|
+
KEEPASSXC_NO_SHARED_DATABASE = "KeePassXC shares no database with other applications: open a database and expose one of its groups in its Secret Service settings, then try again."
|
|
147
|
+
"""Said in orange when KeePassXC holds the Secret Service but exposes no group of an open database."""
|
|
148
|
+
NAMED_KEYRING_LOCKED = "The keyring ({program}) is locked: unlock it when it asks, then try again."
|
|
149
|
+
"""Said in orange when a keyring GitPane names only by its program holds a locked default collection."""
|
|
150
|
+
NAMED_KEYRING_NO_DEFAULT_COLLECTION = "The keyring ({program}) has no default collection to save the account in: create one, then try again."
|
|
151
|
+
"""Said in orange when a keyring GitPane names only by its program has no default collection."""
|
|
152
|
+
NAMED_KEYRING_UNPUBLISHED = "The keyring ({program}) does not publish its default collection: check it, then try again."
|
|
153
|
+
"""Said in orange when a keyring other than the GNOME keyring names a default collection absent from the bus."""
|
|
154
|
+
UNNAMED_KEYRING_LOCKED = "The keyring is locked: unlock it when it asks, then try again."
|
|
155
|
+
"""Said in orange when the program that holds the Secret Service could not be read and its default collection is locked."""
|
|
156
|
+
UNNAMED_KEYRING_NO_DEFAULT_COLLECTION = "The keyring has no default collection to save the account in: create one, then try again."
|
|
157
|
+
"""Said in orange when the program that holds the Secret Service could not be read and it has no default collection."""
|
|
158
|
+
UNNAMED_KEYRING_UNPUBLISHED = "The keyring does not publish its default collection: check it, then try again."
|
|
159
|
+
"""Said in orange when the program that holds the Secret Service could not be read and its default collection is absent from the bus."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# The orange sentence of each keyring and state that gives the user something to do. The key comes from the keyring
|
|
163
|
+
# read, and the sentence belongs to this window. The GNOME keyring keeps its own sentences; KDE Wallet and KeePassXC get
|
|
164
|
+
# theirs for the states they are known to give, and the neutral sentence that names them otherwise; any other program
|
|
165
|
+
# is named as read, and a program that could not be read is not named. A state missing from the table (available, no
|
|
166
|
+
# service, unknown) gives no sentence.
|
|
167
|
+
KEYRING_ADVICE_BY_PROVIDER_AND_STATE: dict[tuple[KeyringProvider, KeyringState], AddAccountText] = {
|
|
168
|
+
(KeyringProvider.GNOME_KEYRING, KeyringState.UNPUBLISHED): AddAccountText.KEYRING_FIRST_SESSION,
|
|
169
|
+
(KeyringProvider.GNOME_KEYRING, KeyringState.LOCKED): AddAccountText.KEYRING_LOCKED,
|
|
170
|
+
(KeyringProvider.GNOME_KEYRING, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.KEYRING_NO_DEFAULT_COLLECTION,
|
|
171
|
+
(KeyringProvider.KDE_WALLET, KeyringState.LOCKED): AddAccountText.KDE_WALLET_CLOSED,
|
|
172
|
+
(KeyringProvider.KDE_WALLET, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.KDE_WALLET_NO_DEFAULT_WALLET,
|
|
173
|
+
(KeyringProvider.KDE_WALLET, KeyringState.UNPUBLISHED): AddAccountText.NAMED_KEYRING_UNPUBLISHED,
|
|
174
|
+
(KeyringProvider.KEEPASSXC, KeyringState.LOCKED): AddAccountText.KEEPASSXC_LOCKED,
|
|
175
|
+
(KeyringProvider.KEEPASSXC, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.KEEPASSXC_NO_SHARED_DATABASE,
|
|
176
|
+
(KeyringProvider.KEEPASSXC, KeyringState.UNPUBLISHED): AddAccountText.NAMED_KEYRING_UNPUBLISHED,
|
|
177
|
+
(KeyringProvider.OTHER, KeyringState.LOCKED): AddAccountText.NAMED_KEYRING_LOCKED,
|
|
178
|
+
(KeyringProvider.OTHER, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.NAMED_KEYRING_NO_DEFAULT_COLLECTION,
|
|
179
|
+
(KeyringProvider.OTHER, KeyringState.UNPUBLISHED): AddAccountText.NAMED_KEYRING_UNPUBLISHED,
|
|
180
|
+
(KeyringProvider.UNREADABLE, KeyringState.LOCKED): AddAccountText.UNNAMED_KEYRING_LOCKED,
|
|
181
|
+
(KeyringProvider.UNREADABLE, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.UNNAMED_KEYRING_NO_DEFAULT_COLLECTION,
|
|
182
|
+
(KeyringProvider.UNREADABLE, KeyringState.UNPUBLISHED): AddAccountText.UNNAMED_KEYRING_UNPUBLISHED,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def keyring_advice(reading: KeyringReading) -> str | None:
|
|
187
|
+
"""Return the orange sentence the form adds for a keyring reading, ready to show, or None when it gives the user nothing to do.
|
|
188
|
+
|
|
189
|
+
The sentence is chosen by the keyring that holds the Secret Service and the state of its default collection, so a
|
|
190
|
+
KDE user never reads a sentence about the GNOME keyring. A sentence that names the program gets the name as read,
|
|
191
|
+
shown as plain text by the label. Any state GitPane could not read says nothing rather than something it does not know.
|
|
192
|
+
"""
|
|
193
|
+
advice = KEYRING_ADVICE_BY_PROVIDER_AND_STATE.get((reading.provider, reading.state))
|
|
194
|
+
if advice is None:
|
|
195
|
+
return None
|
|
196
|
+
return advice.value.format(program=reading.program)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_keyring_read(read: Callable[[], KeyringReading], hand_over: Callable[[KeyringReading], None], log_line: Callable[[str], None] = ignore_line) -> None:
|
|
200
|
+
"""Read the keyring on the worker thread and hand the reading over to the form's thread, UNKNOWN when the read failed.
|
|
201
|
+
|
|
202
|
+
The form always gets an answer, so a sign-in waiting on it is never left waiting. An error nobody expects shows a
|
|
203
|
+
defect of GitPane, not of the keyring: it is written through ``log_line``, and the answer stays UNKNOWN, which says
|
|
204
|
+
nothing to the user.
|
|
205
|
+
"""
|
|
206
|
+
reading = UNKNOWN_KEYRING_READING
|
|
207
|
+
try:
|
|
208
|
+
reading = read()
|
|
209
|
+
except (OSError, ValueError, RuntimeError):
|
|
210
|
+
reading = UNKNOWN_KEYRING_READING
|
|
211
|
+
except Exception as error: # noqa: BLE001 -- the worker must always answer the form; the error is written to the --log journal
|
|
212
|
+
reading = UNKNOWN_KEYRING_READING
|
|
213
|
+
log_line(KEYRING_READ_FAILURE_LOG_TEMPLATE.format(error_class=type(error).__name__, error=error))
|
|
214
|
+
finally:
|
|
215
|
+
hand_over_to_the_page(hand_over, reading)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def sign_in_result_text(outcome: AuthOutcome, *, reconnect: bool, saved_note: bool = False) -> str:
|
|
219
|
+
"""Describe a successful sign-in in the words the user expects: added, renewed, or reconnected.
|
|
220
|
+
|
|
221
|
+
saved_note adds the sentence saying the account is already saved and the window can be closed. Only Settings asks
|
|
222
|
+
for it: the Clone form that also signs in stays open for the clone the user is setting up.
|
|
223
|
+
"""
|
|
224
|
+
label = outcome.selection.label
|
|
225
|
+
if reconnect:
|
|
226
|
+
text = label + " was reconnected."
|
|
227
|
+
elif outcome.renewed:
|
|
228
|
+
text = label + " was already connected. Its sign-in was renewed."
|
|
229
|
+
else:
|
|
230
|
+
text = label + " was added to Connected accounts."
|
|
231
|
+
if outcome.became_default:
|
|
232
|
+
text += " It is now the default account for " + origin_address(outcome.selection.origin) + "."
|
|
233
|
+
if saved_note:
|
|
234
|
+
text += " " + SIGN_IN_SAVED_TEXT
|
|
235
|
+
return text
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def sign_in_failure_text(outcome: AuthOutcome) -> str:
|
|
239
|
+
"""Say which step failed: the sign-in, the secure store or the account list, never the output of the program that ran.
|
|
240
|
+
|
|
241
|
+
That output is shown whole in the form's own output field, under this sentence. A failed Git test of a username +
|
|
242
|
+
password account is described by ``check_failure_text`` instead. Whether the secure store is the step that failed is
|
|
243
|
+
decided by ``store_was_refused`` alone, the rule the keyring read after a refusal also follows, so a refusal before
|
|
244
|
+
any program ran or an account GitPane refused, which never reached the store, says the sign-in failed.
|
|
245
|
+
"""
|
|
246
|
+
if outcome.stored:
|
|
247
|
+
return AddAccountText.LIST_SAVE_FAILED.value
|
|
248
|
+
if not store_was_refused(outcome):
|
|
249
|
+
return AddAccountText.SIGN_IN_FAILED.value
|
|
250
|
+
if outcome.selection is not None and outcome.selection.provider == GENERIC_PROVIDER:
|
|
251
|
+
return AddAccountText.GENERIC_STORE_FAILED.value
|
|
252
|
+
return AddAccountText.STORE_FAILED.value
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def outcome_cause(outcome: AuthOutcome) -> str:
|
|
256
|
+
"""Return the prepared cause of a failed exchange, already filtered of secrets, or an empty text when there is none."""
|
|
257
|
+
if outcome.diagnostic is None:
|
|
258
|
+
return ""
|
|
259
|
+
return outcome.diagnostic.cause
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def check_failure_text(check: AccountCheck) -> str:
|
|
263
|
+
"""Say in plain words why the Git test of a username + password failed, read from the words of the server's answer.
|
|
264
|
+
|
|
265
|
+
A plain refusal is the usual answer to a mistyped password, so it asks to check them. Anything else says the test
|
|
266
|
+
could not be done. An answer saying passwords are not accepted is no error and is told apart by ``passwords_refused``.
|
|
267
|
+
"""
|
|
268
|
+
if refusal_kind(check.git_output) is RefusalKind.CREDENTIALS_REFUSED:
|
|
269
|
+
return CREDENTIALS_REFUSED_TEXT
|
|
270
|
+
return CHECK_FAILED_TEXT.format(url=check.url)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def passwords_refused(check: AccountCheck | None) -> bool:
|
|
274
|
+
"""Tell whether the server answered the Git test by saying it refuses account passwords, so only another method can work."""
|
|
275
|
+
return check is not None and refusal_kind(check.git_output) is RefusalKind.PASSWORD_NOT_ACCEPTED
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class AddAccountDialog(ContentGrowthMixin, QDialog):
|
|
279
|
+
"""Show the fields that fit the typed server and report what the user asked for; the account controller runs the exchange.
|
|
280
|
+
|
|
281
|
+
GitHub, gitlab.com and Bitbucket Cloud get browser sign-in with a button beside it to use a personal access token; any other
|
|
282
|
+
server gets a username with a password or token. Opened from Settings, which is modal for the whole application,
|
|
283
|
+
the window is modal over it; opened from a clone form, it is a plain window over that form. In Reconnect mode the
|
|
284
|
+
server, service name and username of the renewed account are shown and cannot be changed; a clone form fixes the
|
|
285
|
+
server only.
|
|
286
|
+
|
|
287
|
+
While the browser has not answered a browser sign-in, the window counts down the time left in orange beside a
|
|
288
|
+
turning indicator, so the user is not left wondering whether GitPane froze. Cancel stops the sign-in and keeps the
|
|
289
|
+
window open; the close button and Escape stop it and close the window: a browser tab the user closed must not keep
|
|
290
|
+
them waiting until the end of the countdown, which only stays as a limit for a forgotten tab.
|
|
291
|
+
|
|
292
|
+
The window's height follows its rows both ways: an output grows it until its field shows ACCOUNT_OUTPUT_LINE_COUNT
|
|
293
|
+
lines, and a new try or a cancelled sign-in, which hide the field, bring it back to the height it had before. While
|
|
294
|
+
the output shows, the user cannot make the window shorter than those lines.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
sign_in_requested = Signal(bool)
|
|
298
|
+
cancel_requested = Signal()
|
|
299
|
+
# The number of the keyring read and the KeyringReading it gave, handed over from the worker thread.
|
|
300
|
+
keyring_state_read = Signal(int, object)
|
|
301
|
+
|
|
302
|
+
def __init__(self, parent: QWidget | None = None, *, reconnect: AccountSelection | None = None, locked_origin: str = "", service_name: str = "",
|
|
303
|
+
service_name_by_origin: dict[str, str] | None = None, known_check_url_by_origin: dict[str, str] | None = None,
|
|
304
|
+
browser_timeout_ms: int = BROWSER_SIGN_IN_TIMEOUT_MS) -> None:
|
|
305
|
+
"""Build the form; ``reconnect`` renews that account (``service_name`` is its address's name), ``locked_origin`` fixes the server of a new account.
|
|
306
|
+
|
|
307
|
+
``service_name_by_origin`` holds the names already given to known addresses, so adding a second account to one
|
|
308
|
+
of them proposes the same name instead of renaming the address by accident. ``known_check_url_by_origin`` holds
|
|
309
|
+
the repository URL the caller already knows for an address (a remote of the open repository, the URL being
|
|
310
|
+
cloned): a username + password on that address is tested there, and the window says so instead of asking.
|
|
311
|
+
``browser_timeout_ms`` is how long the account service waits for the browser; the countdown starts from it.
|
|
312
|
+
"""
|
|
313
|
+
super().__init__(parent)
|
|
314
|
+
self.reconnect = reconnect
|
|
315
|
+
self.service_name_by_origin = dict(service_name_by_origin or {})
|
|
316
|
+
self.known_check_url_by_origin = dict(known_check_url_by_origin or {})
|
|
317
|
+
self.operation_id = ""
|
|
318
|
+
self.generation = 0
|
|
319
|
+
self.closed = False
|
|
320
|
+
self.running = False
|
|
321
|
+
self.token_mode = False
|
|
322
|
+
self.browser_timeout_ms = browser_timeout_ms
|
|
323
|
+
self.browser_waiting = False
|
|
324
|
+
self.browser_deadline = QDeadlineTimer(browser_timeout_ms)
|
|
325
|
+
self.countdown_timer = QTimer(self)
|
|
326
|
+
self.countdown_timer.setInterval(COUNTDOWN_INTERVAL_MS)
|
|
327
|
+
self.countdown_timer.timeout.connect(self._show_countdown)
|
|
328
|
+
# The failed Git test of the typed address, kept until the address changes: it decides which method is advised next.
|
|
329
|
+
self.refused_check: AccountCheck | None = None
|
|
330
|
+
self.prefilled_service_name = ""
|
|
331
|
+
# What to do with the keyring reading the running read gives; it may hold the typed secret, so it is dropped as
|
|
332
|
+
# soon as the answer arrives, the sign-in is cancelled or the window closes.
|
|
333
|
+
self.keyring_answer: Callable[[KeyringReading], None] | None = None
|
|
334
|
+
# Only the answer of the latest read is used: a read started for an earlier, cancelled try answers into nothing.
|
|
335
|
+
self.keyring_read_number = 0
|
|
336
|
+
# The refused store whose keyring read again is still awaited: Cancel during that read shows this refusal, which
|
|
337
|
+
# is already known, instead of saying the sign-in was cancelled.
|
|
338
|
+
self.refused_outcome: AuthOutcome | None = None
|
|
339
|
+
# True while the form shows the refusal of a credential manager and its install lines, from the try that met
|
|
340
|
+
# it until anything else is shown; only then may a later search that finds one take that refusal away.
|
|
341
|
+
self.credential_manager_refusal_shown = False
|
|
342
|
+
self.server_locked = reconnect is not None or bool(locked_origin)
|
|
343
|
+
self.account_locked = reconnect is not None
|
|
344
|
+
self.setModal(False)
|
|
345
|
+
self.setWindowTitle(RECONNECT_TITLE if reconnect is not None else ADD_ACCOUNT_TITLE)
|
|
346
|
+
self.resize(ADD_ACCOUNT_WIDTH, ADD_ACCOUNT_HEIGHT)
|
|
347
|
+
layout = QVBoxLayout(self)
|
|
348
|
+
layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
|
349
|
+
layout.setSpacing(SPACING_SM)
|
|
350
|
+
self.form = QFormLayout()
|
|
351
|
+
self.form.setSpacing(SPACING_SM)
|
|
352
|
+
apply_left_aligned_form(self.form)
|
|
353
|
+
self.server_edit = self._line_edit(SERVER_URL_LABEL, SERVER_URL_PLACEHOLDER)
|
|
354
|
+
self.form.addRow(SERVER_URL_LABEL, self.server_edit)
|
|
355
|
+
self.service_name_edit = self._line_edit(SERVICE_NAME_LABEL, SERVICE_NAME_PLACEHOLDER)
|
|
356
|
+
self.form.addRow(SERVICE_NAME_LABEL, self.service_name_edit)
|
|
357
|
+
self.username_edit = self._line_edit(USERNAME_LABEL, "")
|
|
358
|
+
self.form.addRow(USERNAME_LABEL, self.username_edit)
|
|
359
|
+
self.check_url_edit = self._line_edit(CHECK_URL_LABEL, CHECK_URL_PLACEHOLDER)
|
|
360
|
+
self.form.addRow(CHECK_URL_LABEL, self.check_url_edit)
|
|
361
|
+
self.check_note_label = self._plain_label("")
|
|
362
|
+
self.form.addRow(self.check_note_label)
|
|
363
|
+
self.password_label = QLabel(PASSWORD_LABEL)
|
|
364
|
+
self.password_edit = self._line_edit(PASSWORD_LABEL, "")
|
|
365
|
+
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
|
366
|
+
self.form.addRow(self.password_label, self.password_edit)
|
|
367
|
+
self.token_page_label = self._plain_label("")
|
|
368
|
+
self.form.addRow(self.token_page_label)
|
|
369
|
+
self.bitbucket_email_edit = self._line_edit(BITBUCKET_EMAIL_LABEL, "")
|
|
370
|
+
self.form.addRow(BITBUCKET_EMAIL_LABEL, self.bitbucket_email_edit)
|
|
371
|
+
layout.addLayout(self.form)
|
|
372
|
+
self.help_label = self._plain_label("")
|
|
373
|
+
layout.addWidget(self.help_label)
|
|
374
|
+
self.browser_button = QPushButton(BROWSER_BUTTON_TEXT)
|
|
375
|
+
self.browser_button.setAutoDefault(False)
|
|
376
|
+
self.browser_button.clicked.connect(lambda: self.sign_in_requested.emit(False))
|
|
377
|
+
# The token is the other way to sign in on the same services, so its button stands beside the browser's, as an
|
|
378
|
+
# ordinary button: a flat link-like one did not look clickable.
|
|
379
|
+
self.token_link_button = QPushButton(TOKEN_LINK_TEXT)
|
|
380
|
+
self.token_link_button.setAccessibleName(TOKEN_LINK_TEXT)
|
|
381
|
+
self.token_link_button.setAutoDefault(False)
|
|
382
|
+
self.token_link_button.clicked.connect(self._token_link_clicked)
|
|
383
|
+
browser_layout = QHBoxLayout()
|
|
384
|
+
browser_layout.addWidget(self.browser_button)
|
|
385
|
+
browser_layout.addWidget(self.token_link_button)
|
|
386
|
+
browser_layout.addStretch()
|
|
387
|
+
layout.addLayout(browser_layout)
|
|
388
|
+
self.add_button = QPushButton(RECONNECT_BUTTON_TEXT if reconnect is not None else ADD_BUTTON_TEXT)
|
|
389
|
+
self.add_button.setAutoDefault(False)
|
|
390
|
+
self.add_button.clicked.connect(lambda: self.sign_in_requested.emit(True))
|
|
391
|
+
self.self_hosted_label = self._plain_label(SELF_HOSTED_BROWSER_TEXT)
|
|
392
|
+
layout.addWidget(self.self_hosted_label)
|
|
393
|
+
self.busy_indicator = BusyIndicator()
|
|
394
|
+
self.status_label = ResultLabel()
|
|
395
|
+
status_layout = QHBoxLayout()
|
|
396
|
+
status_layout.addWidget(self.busy_indicator, 0, Qt.AlignmentFlag.AlignTop)
|
|
397
|
+
status_layout.addWidget(self.status_label, 1)
|
|
398
|
+
layout.addLayout(status_layout)
|
|
399
|
+
# The orange sentence about the keyring, under the red line of the step and above the output, shown only
|
|
400
|
+
# when the keyring explains the failure.
|
|
401
|
+
self.keyring_label = ResultLabel()
|
|
402
|
+
self.keyring_label.hide()
|
|
403
|
+
layout.addWidget(self.keyring_label)
|
|
404
|
+
# The whole output of a failed sign-in, as the program wrote it once its secrets were filtered: plain text the
|
|
405
|
+
# user can read, select and copy into a bug report or an assistant. It takes the height given to the window;
|
|
406
|
+
# while it is hidden, the stretch below takes it back.
|
|
407
|
+
self.output_edit = QPlainTextEdit()
|
|
408
|
+
self.output_edit.setReadOnly(True)
|
|
409
|
+
# A read-only text field only keeps mouse selection: the keyboard selection lets Ctrl+A select it all.
|
|
410
|
+
self.output_edit.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
|
|
411
|
+
self.output_edit.setAccessibleName(AddAccountText.OUTPUT_NAME.value)
|
|
412
|
+
self.output_edit.hide()
|
|
413
|
+
layout.addWidget(self.output_edit, 1)
|
|
414
|
+
self.copy_output_button = QPushButton(SharedText.COPY_DETAILS.value)
|
|
415
|
+
self.copy_output_button.setAutoDefault(False)
|
|
416
|
+
self.copy_output_button.clicked.connect(self.copy_output)
|
|
417
|
+
self.copy_output_button.hide()
|
|
418
|
+
copy_layout = QHBoxLayout()
|
|
419
|
+
copy_layout.addWidget(self.copy_output_button)
|
|
420
|
+
copy_layout.addStretch()
|
|
421
|
+
layout.addLayout(copy_layout)
|
|
422
|
+
# The exact text the output field shows, which Copy details copies.
|
|
423
|
+
self.output_text = ""
|
|
424
|
+
layout.addStretch()
|
|
425
|
+
# The confirm button sits just left of Cancel in the bottom-right row, as in every other dialog, whatever the
|
|
426
|
+
# way to sign in shown above.
|
|
427
|
+
action_layout = QHBoxLayout()
|
|
428
|
+
action_layout.addStretch()
|
|
429
|
+
action_layout.addWidget(self.add_button)
|
|
430
|
+
self.cancel_button = QPushButton("Cancel")
|
|
431
|
+
self.cancel_button.setAutoDefault(False)
|
|
432
|
+
self.cancel_button.clicked.connect(self._cancel_clicked)
|
|
433
|
+
action_layout.addWidget(self.cancel_button)
|
|
434
|
+
layout.addLayout(action_layout)
|
|
435
|
+
self._apply_lock(reconnect, locked_origin, service_name)
|
|
436
|
+
self.service_name_edit.textEdited.connect(self._service_name_edited)
|
|
437
|
+
self.server_edit.textChanged.connect(self._server_changed)
|
|
438
|
+
self.keyring_state_read.connect(self._keyring_state_answered, Qt.ConnectionType.QueuedConnection)
|
|
439
|
+
self._prefill_service_name()
|
|
440
|
+
self._update_presentation()
|
|
441
|
+
self.refresh_metrics()
|
|
442
|
+
# The form has no scroll area: its own layout, whose minimum counts the output field at its height of
|
|
443
|
+
# ACCOUNT_OUTPUT_LINE_COUNT lines while it is shown, tells the height its rows need.
|
|
444
|
+
self.install_content_growth(LayoutContentMeasure(self), OpeningFit.GROW_ONLY, ADD_ACCOUNT_HEIGHT)
|
|
445
|
+
|
|
446
|
+
def server_url(self) -> str:
|
|
447
|
+
"""Return the typed server address; one typed without a scheme is taken as HTTPS, anything else is left for the service to check."""
|
|
448
|
+
text = typed_value(self.server_edit.text())
|
|
449
|
+
if not text or URL_SCHEME_SEPARATOR in text:
|
|
450
|
+
return text
|
|
451
|
+
return HTTPS_PREFIX + text
|
|
452
|
+
|
|
453
|
+
def address(self) -> str:
|
|
454
|
+
"""Return the address this window is about, as shown to the user: the renewed account's, else the typed server's, else the typed text."""
|
|
455
|
+
if self.reconnect is not None:
|
|
456
|
+
return origin_address(self.reconnect.origin)
|
|
457
|
+
try:
|
|
458
|
+
return origin_address(self.server_url())
|
|
459
|
+
except ValueError:
|
|
460
|
+
return self.server_url()
|
|
461
|
+
|
|
462
|
+
def check_url(self) -> str:
|
|
463
|
+
"""Return the URL of the repository on which the username and password are tested before the account is saved.
|
|
464
|
+
|
|
465
|
+
It is the URL the caller already knows for this address when there is one, since the field is then hidden;
|
|
466
|
+
otherwise it is the typed one.
|
|
467
|
+
"""
|
|
468
|
+
known_check_url = self.known_check_url()
|
|
469
|
+
if known_check_url:
|
|
470
|
+
return known_check_url
|
|
471
|
+
return typed_value(self.check_url_edit.text())
|
|
472
|
+
|
|
473
|
+
def service_name(self) -> str:
|
|
474
|
+
"""Return the typed service name without the blanks around it; a name made only of blanks counts as empty."""
|
|
475
|
+
return typed_value(self.service_name_edit.text())
|
|
476
|
+
|
|
477
|
+
def username(self) -> str:
|
|
478
|
+
"""Return the typed username without the blanks around it."""
|
|
479
|
+
return typed_value(self.username_edit.text())
|
|
480
|
+
|
|
481
|
+
def bitbucket_email(self) -> str:
|
|
482
|
+
"""Return the typed Bitbucket e-mail without the blanks around it."""
|
|
483
|
+
return typed_value(self.bitbucket_email_edit.text())
|
|
484
|
+
|
|
485
|
+
def known_check_url(self) -> str:
|
|
486
|
+
"""Return the repository URL the caller already knows for this address, or an empty text when the user has to type one.
|
|
487
|
+
|
|
488
|
+
Only a username + password account is tested with Git, so any other account has none.
|
|
489
|
+
"""
|
|
490
|
+
if not self.asks_username():
|
|
491
|
+
return ""
|
|
492
|
+
origin = self._origin()
|
|
493
|
+
if not origin:
|
|
494
|
+
return ""
|
|
495
|
+
return self.known_check_url_by_origin.get(origin, "")
|
|
496
|
+
|
|
497
|
+
def set_known_check_url(self, origin: str, url: str) -> None:
|
|
498
|
+
"""Remember the repository URL to test for this address, learnt after the window opened, and show it if that address is the one shown."""
|
|
499
|
+
self.known_check_url_by_origin[origin] = url
|
|
500
|
+
self._update_presentation()
|
|
501
|
+
|
|
502
|
+
def asks_check_url(self) -> bool:
|
|
503
|
+
"""Tell whether a repository to test is asked: only for a username + password account, and only when no URL of its address is known."""
|
|
504
|
+
return self.asks_username() and not self.known_check_url()
|
|
505
|
+
|
|
506
|
+
def cloud_provider(self) -> str | None:
|
|
507
|
+
"""Name the online service (GitHub, GitLab or Bitbucket) whose own sign-in fits this address, or None for any other server.
|
|
508
|
+
|
|
509
|
+
A username + password account being reconnected keeps its kind whatever its address, so it has none.
|
|
510
|
+
"""
|
|
511
|
+
if self._generic_reconnect():
|
|
512
|
+
return None
|
|
513
|
+
return self._address_provider()
|
|
514
|
+
|
|
515
|
+
def browser_provider(self) -> str | None:
|
|
516
|
+
"""Name the service whose browser sign-in fits this address, or None when only a password or token can be used.
|
|
517
|
+
|
|
518
|
+
A service account being reconnected on another server has no browser sign-in: only a token can renew it. Any
|
|
519
|
+
other server offers the browser sign-in of the provider it was recognised as after a refused test, if any.
|
|
520
|
+
"""
|
|
521
|
+
provider = self.cloud_provider()
|
|
522
|
+
if provider is None:
|
|
523
|
+
return self._recognised_browser_provider()
|
|
524
|
+
if self.reconnect is None:
|
|
525
|
+
return provider
|
|
526
|
+
return self.reconnect.provider
|
|
527
|
+
|
|
528
|
+
def checks_token(self) -> bool:
|
|
529
|
+
"""Tell whether the typed secret is a service token that the service's API checks before anything is saved."""
|
|
530
|
+
if self.reconnect is not None:
|
|
531
|
+
return self.reconnect.provider != GENERIC_PROVIDER
|
|
532
|
+
return self.cloud_provider() is not None
|
|
533
|
+
|
|
534
|
+
def asks_token(self) -> bool:
|
|
535
|
+
"""Tell whether the secret field asks for a personal access token, because the server refuses account passwords or checks the token."""
|
|
536
|
+
return self.checks_token() or self._address_provider() is not None or passwords_refused(self.refused_check)
|
|
537
|
+
|
|
538
|
+
def asks_username(self) -> bool:
|
|
539
|
+
"""Tell whether a username is part of this account: only a username + password account has one."""
|
|
540
|
+
if self.reconnect is not None:
|
|
541
|
+
return self.reconnect.provider == GENERIC_PROVIDER
|
|
542
|
+
return self.cloud_provider() is None
|
|
543
|
+
|
|
544
|
+
def asks_bitbucket_email(self) -> bool:
|
|
545
|
+
"""Tell whether a Bitbucket token is being typed, which the Bitbucket API only checks together with the account e-mail."""
|
|
546
|
+
return self.browser_provider() == BITBUCKET_PROVIDER
|
|
547
|
+
|
|
548
|
+
def set_running(self, running: bool, *, token: bool = False) -> None:
|
|
549
|
+
"""Block duplicate submissions and discard the typed password as soon as a sign-in starts or ends.
|
|
550
|
+
|
|
551
|
+
A browser sign-in (``token`` false) starts the countdown and the turning indicator; the other ways to sign in
|
|
552
|
+
show a plain wait text. Every sign-in can be cancelled.
|
|
553
|
+
"""
|
|
554
|
+
self.running = running
|
|
555
|
+
self.server_edit.setEnabled(not running and not self.server_locked)
|
|
556
|
+
for control in (self.service_name_edit, self.username_edit):
|
|
557
|
+
control.setEnabled(not running and not self.account_locked)
|
|
558
|
+
for control in (self.check_url_edit, self.password_edit, self.bitbucket_email_edit, self.add_button, self.browser_button, self.token_link_button):
|
|
559
|
+
control.setEnabled(not running)
|
|
560
|
+
self.password_edit.clear()
|
|
561
|
+
self._set_browser_waiting(running and not token)
|
|
562
|
+
if not running:
|
|
563
|
+
return
|
|
564
|
+
self._hide_output()
|
|
565
|
+
self.show_keyring_note(UNKNOWN_KEYRING_READING)
|
|
566
|
+
if token:
|
|
567
|
+
self.status_label.setText(self._wait_text())
|
|
568
|
+
return
|
|
569
|
+
self._show_countdown()
|
|
570
|
+
|
|
571
|
+
def clear_password(self) -> None:
|
|
572
|
+
"""Drop the typed password or token, for example after a form check refused the request."""
|
|
573
|
+
self.password_edit.clear()
|
|
574
|
+
|
|
575
|
+
def show_refusal(self, text: str) -> None:
|
|
576
|
+
"""Say in red what the form still misses; the output and keyring sentence of an earlier try no longer apply and go away."""
|
|
577
|
+
self._hide_output()
|
|
578
|
+
self.show_keyring_note(UNKNOWN_KEYRING_READING)
|
|
579
|
+
self.status_label.show_failure(text)
|
|
580
|
+
|
|
581
|
+
def read_keyring_state(self, read: Callable[[], KeyringReading], answer: Callable[[KeyringReading], None], log_line: Callable[[str], None] = ignore_line) -> None:
|
|
582
|
+
"""Read the keyring on a worker thread, then call ``answer`` with the reading on the window's thread.
|
|
583
|
+
|
|
584
|
+
The read may wait on the session bus, so it never runs on the window's thread. ``answer`` is kept only until the
|
|
585
|
+
answer arrives, the sign-in is cancelled or the window closes; an answer arriving after that, or the answer of an
|
|
586
|
+
earlier read, calls nothing.
|
|
587
|
+
"""
|
|
588
|
+
self.keyring_read_number += 1
|
|
589
|
+
read_number = self.keyring_read_number
|
|
590
|
+
self.keyring_answer = answer
|
|
591
|
+
emit = weak_signal_emitter(self, lambda dialog: dialog.keyring_state_read)
|
|
592
|
+
|
|
593
|
+
def hand_over(reading: KeyringReading) -> None:
|
|
594
|
+
"""Hand the reading over to the window's thread together with the number of the read that gave it."""
|
|
595
|
+
emit(read_number, reading)
|
|
596
|
+
|
|
597
|
+
thread = threading.Thread(target=run_keyring_read, args=(read, hand_over, log_line), name=KEYRING_READ_THREAD_NAME, daemon=True)
|
|
598
|
+
thread.start()
|
|
599
|
+
|
|
600
|
+
def forget_keyring_answer(self) -> None:
|
|
601
|
+
"""Drop what the running keyring read would call, so its late answer does nothing and keeps no typed secret alive."""
|
|
602
|
+
self.keyring_answer = None
|
|
603
|
+
|
|
604
|
+
def show_keyring_note(self, reading: KeyringReading) -> None:
|
|
605
|
+
"""Show in orange the sentence that explains this keyring reading, under the red line, or hide the sentence when the reading gives none."""
|
|
606
|
+
advice = keyring_advice(reading)
|
|
607
|
+
if advice is None:
|
|
608
|
+
self.keyring_label.clear()
|
|
609
|
+
self.keyring_label.hide()
|
|
610
|
+
return
|
|
611
|
+
self.keyring_label.show_warning(advice)
|
|
612
|
+
self.keyring_label.show()
|
|
613
|
+
|
|
614
|
+
def show_keyring_refusal(self, reading: KeyringReading) -> None:
|
|
615
|
+
"""End the wait of a sign-in stopped before anything was sent, because the keyring could not store it, and say why.
|
|
616
|
+
|
|
617
|
+
Nothing ran, so there is no output: the red line only says the sign-in did not start, and the orange sentence
|
|
618
|
+
under it says what to do about the keyring.
|
|
619
|
+
"""
|
|
620
|
+
self.set_running(False)
|
|
621
|
+
self._hide_output()
|
|
622
|
+
self.status_label.show_failure(AddAccountText.SIGN_IN_NOT_STARTED.value)
|
|
623
|
+
self.show_keyring_note(reading)
|
|
624
|
+
|
|
625
|
+
def show_failure(self, outcome: AuthOutcome, keyring_reading: KeyringReading = UNKNOWN_KEYRING_READING) -> None:
|
|
626
|
+
"""End the wait, say in red which step failed and show below it the whole output of the program that ran.
|
|
627
|
+
|
|
628
|
+
The status line says the step; the output field shows what the program that ran wrote, the prepared cause exactly
|
|
629
|
+
as received, already filtered of secrets, never summarised, cut or sorted, so the user can copy it whole. The
|
|
630
|
+
field stays hidden when there is no output. A cause that is GitPane's own sentence rather than a program's
|
|
631
|
+
output goes on the status line after the step, with no output field: a refusal before any program ran, or an
|
|
632
|
+
account the sign-in returned that GitPane refused (signed in as someone else than the account being
|
|
633
|
+
reconnected). A browser that did not answer in time gets its own short sentence only: the exchange was stopped
|
|
634
|
+
and nothing changed.
|
|
635
|
+
|
|
636
|
+
``keyring_reading`` is the keyring read again after the secure store refused to save: when it explains the
|
|
637
|
+
refusal (a collection still locked, not published, or no default collection at all), its sentence, chosen by
|
|
638
|
+
the keyring that holds the Secret Service, comes in orange between the red line and the output, which does not
|
|
639
|
+
change. Every other result hides that sentence.
|
|
640
|
+
|
|
641
|
+
A failed Git test of a username + password says why in plain words, with Git's own output in the field; without
|
|
642
|
+
any output from Git, the cause (a time limit, for example) is shown there instead. A server that says it refuses
|
|
643
|
+
account passwords is no error: the window says in orange which method to use instead and switches to it, keeping
|
|
644
|
+
the typed username. The browser is only offered, never opened by the window itself.
|
|
645
|
+
"""
|
|
646
|
+
browser_waited = self.browser_waiting
|
|
647
|
+
self.set_running(False)
|
|
648
|
+
self.credential_manager_refusal_shown = False
|
|
649
|
+
self.show_keyring_note(UNKNOWN_KEYRING_READING)
|
|
650
|
+
if browser_waited and outcome.status in BROWSER_TIMEOUT_STATUS_SET:
|
|
651
|
+
self._hide_output()
|
|
652
|
+
self.status_label.show_failure(BROWSER_TIMEOUT_TEXT)
|
|
653
|
+
return
|
|
654
|
+
cause = outcome_cause(outcome)
|
|
655
|
+
if outcome.check is None:
|
|
656
|
+
if outcome.refused_before_start or outcome.refused_by_gitpane:
|
|
657
|
+
self._hide_output()
|
|
658
|
+
self.status_label.show_failure(sign_in_failure_text(outcome) + (" " + cause if cause else ""))
|
|
659
|
+
return
|
|
660
|
+
self.status_label.show_failure(sign_in_failure_text(outcome))
|
|
661
|
+
# The sentence is shown before the output, so the output's growth measures the final form.
|
|
662
|
+
self.show_keyring_note(keyring_reading)
|
|
663
|
+
self._show_output(cause)
|
|
664
|
+
return
|
|
665
|
+
self.refused_check = outcome.check
|
|
666
|
+
if passwords_refused(outcome.check):
|
|
667
|
+
self.status_label.show_warning(BROWSER_INSTEAD_TEXT if self._recognised_browser_provider() is not None else TOKEN_INSTEAD_TEXT)
|
|
668
|
+
else:
|
|
669
|
+
self.status_label.show_failure(check_failure_text(outcome.check))
|
|
670
|
+
# The fields of the advised method are shown before the output, so the output's growth measures the final form.
|
|
671
|
+
self._update_presentation()
|
|
672
|
+
self._show_output(outcome.check.git_output or cause)
|
|
673
|
+
|
|
674
|
+
def show_install_text(self, lines: tuple[str, ...]) -> None:
|
|
675
|
+
"""Show, in the output field under the refusal, the install lines of the credential manager the sign-in could not find.
|
|
676
|
+
|
|
677
|
+
The install window says the same over the form, but the user reads it, closes it, then installs: the form keeps
|
|
678
|
+
the lines so they stay in sight until the next try. They are selectable and copyable, so a command can be copied.
|
|
679
|
+
"""
|
|
680
|
+
self._show_output("\n".join(lines))
|
|
681
|
+
self.credential_manager_refusal_shown = True
|
|
682
|
+
|
|
683
|
+
def clear_credential_manager_refusal(self) -> None:
|
|
684
|
+
"""Take away the refusal of the credential manager, its install lines and Copy details, once a later search found a usable one.
|
|
685
|
+
|
|
686
|
+
The form then looks as before the try, so it no longer says signing in is impossible; what the user typed stays.
|
|
687
|
+
A form that shows something else since, the result of a newer try for example, is left as it is.
|
|
688
|
+
"""
|
|
689
|
+
if not self.credential_manager_refusal_shown:
|
|
690
|
+
return
|
|
691
|
+
self._hide_output()
|
|
692
|
+
self.show_keyring_note(UNKNOWN_KEYRING_READING)
|
|
693
|
+
self.status_label.clear()
|
|
694
|
+
|
|
695
|
+
def show_cancelled(self) -> None:
|
|
696
|
+
"""End the wait after the user cancelled, so the form is ready for another try."""
|
|
697
|
+
self.set_running(False)
|
|
698
|
+
self._hide_output()
|
|
699
|
+
self.show_keyring_note(UNKNOWN_KEYRING_READING)
|
|
700
|
+
self.status_label.setText(SIGN_IN_CANCELLED_TEXT)
|
|
701
|
+
|
|
702
|
+
def copy_output(self) -> None:
|
|
703
|
+
"""Copy exactly the already-filtered text the output field shows."""
|
|
704
|
+
QApplication.clipboard().setText(self.output_text)
|
|
705
|
+
|
|
706
|
+
def abandon(self) -> None:
|
|
707
|
+
"""Stop accepting any result for this window and drop the typed password, cancelling a running sign-in."""
|
|
708
|
+
self.closed = True
|
|
709
|
+
self.generation += 1
|
|
710
|
+
self.forget_keyring_answer()
|
|
711
|
+
self.password_edit.clear()
|
|
712
|
+
if self.running:
|
|
713
|
+
self.cancel_requested.emit()
|
|
714
|
+
|
|
715
|
+
def reject(self) -> None:
|
|
716
|
+
"""Close the window, also from Escape and the close button; a running sign-in is cancelled first and the typed password is never kept."""
|
|
717
|
+
self.closed = True
|
|
718
|
+
if self.running:
|
|
719
|
+
self.cancel_requested.emit()
|
|
720
|
+
self.forget_keyring_answer()
|
|
721
|
+
self.password_edit.clear()
|
|
722
|
+
super().reject()
|
|
723
|
+
|
|
724
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
725
|
+
"""Size controls for the current font and keep the window inside the usable screen.
|
|
726
|
+
|
|
727
|
+
The output field asks at least ACCOUNT_OUTPUT_LINE_COUNT lines of its font, with the document margins above and
|
|
728
|
+
below them and its frame around them: the window's layout counts that height while the field is shown, so the
|
|
729
|
+
growth rule gives the form room for those lines when an output arrives, and takes it back when the field hides.
|
|
730
|
+
"""
|
|
731
|
+
self.output_edit.setFont(body_sized_fixed_font())
|
|
732
|
+
for control in (self.server_edit, self.service_name_edit, self.username_edit, self.check_url_edit, self.password_edit, self.bitbucket_email_edit, self.add_button,
|
|
733
|
+
self.browser_button, self.token_link_button, self.cancel_button, self.copy_output_button):
|
|
734
|
+
control.setMinimumHeight(control_height(control.fontMetrics().height()))
|
|
735
|
+
document_margin = math.ceil(self.output_edit.document().documentMargin())
|
|
736
|
+
output_viewport_height = self.output_edit.fontMetrics().lineSpacing() * ACCOUNT_OUTPUT_LINE_COUNT + DOCUMENT_MARGIN_SIDE_COUNT * document_margin
|
|
737
|
+
self.output_edit.setMinimumHeight(output_viewport_height + FRAME_SIDE_COUNT * self.output_edit.frameWidth())
|
|
738
|
+
fit_window(self, ADD_ACCOUNT_MIN_WIDTH, ADD_ACCOUNT_MIN_HEIGHT, available)
|
|
739
|
+
|
|
740
|
+
def _show_output(self, text: str) -> None:
|
|
741
|
+
"""Show the whole output in its field, scrolled to its end where a program writes its error, with Copy details under it; an empty text hides both.
|
|
742
|
+
|
|
743
|
+
The window follows the field both ways through the common growth rule: it grows, inside the usable area, until
|
|
744
|
+
the field shows ACCOUNT_OUTPUT_LINE_COUNT lines, the rest of the output scrolling, and takes back its height when
|
|
745
|
+
the field hides. While the field is shown, the window cannot be made shorter than its rows with those
|
|
746
|
+
ACCOUNT_OUTPUT_LINE_COUNT lines: the field asks them as its minimum height, and Qt keeps a window at its layout's
|
|
747
|
+
minimum. Above that, a height the user set by hand stays as long as the content asks for no more at the same width.
|
|
748
|
+
"""
|
|
749
|
+
if not text:
|
|
750
|
+
self._hide_output()
|
|
751
|
+
return
|
|
752
|
+
# The field is shown before its text is set: a text set on a field that has no size yet stays scrolled to its
|
|
753
|
+
# middle, whereas once shown, moving the cursor to the end keeps the view there, even when the field grows.
|
|
754
|
+
self.output_edit.show()
|
|
755
|
+
self.copy_output_button.show()
|
|
756
|
+
self.output_text = text
|
|
757
|
+
self.output_edit.setPlainText(text)
|
|
758
|
+
self.output_edit.moveCursor(QTextCursor.MoveOperation.End)
|
|
759
|
+
after_compositor_answer(self, self._scroll_output_to_end)
|
|
760
|
+
|
|
761
|
+
def _keyring_state_answered(self, read_number: int, reading: KeyringReading) -> None:
|
|
762
|
+
"""Pass the keyring reading of the latest read to what the sign-in asked, once, unless the window closed meanwhile."""
|
|
763
|
+
if read_number != self.keyring_read_number:
|
|
764
|
+
return
|
|
765
|
+
answer = self.keyring_answer
|
|
766
|
+
self.keyring_answer = None
|
|
767
|
+
if answer is None or self.closed:
|
|
768
|
+
return
|
|
769
|
+
answer(reading)
|
|
770
|
+
|
|
771
|
+
def _scroll_output_to_end(self) -> None:
|
|
772
|
+
"""Put the view back on the end of the output once the form has grown, where a program writes its error.
|
|
773
|
+
|
|
774
|
+
Under Wayland the growth waits for the compositor's answer to the show; the form may be gone or the field hidden
|
|
775
|
+
by a new try by then, and nothing is done.
|
|
776
|
+
"""
|
|
777
|
+
if not isValid(self) or not self.output_edit.isVisibleTo(self):
|
|
778
|
+
return
|
|
779
|
+
self.layout().activate()
|
|
780
|
+
self.output_edit.moveCursor(QTextCursor.MoveOperation.End)
|
|
781
|
+
|
|
782
|
+
def _hide_output(self) -> None:
|
|
783
|
+
"""Empty and hide the output field and its Copy details button, for a new try or a result without output."""
|
|
784
|
+
self.credential_manager_refusal_shown = False
|
|
785
|
+
self.output_edit.clear()
|
|
786
|
+
self.output_edit.hide()
|
|
787
|
+
self.copy_output_button.hide()
|
|
788
|
+
self.output_text = ""
|
|
789
|
+
|
|
790
|
+
def _generic_reconnect(self) -> bool:
|
|
791
|
+
"""Tell whether this window renews a username + password account."""
|
|
792
|
+
return self.reconnect is not None and self.reconnect.provider == GENERIC_PROVIDER
|
|
793
|
+
|
|
794
|
+
def _origin(self) -> str:
|
|
795
|
+
"""Return the service address this window is about in its stored form, or an empty text while the typed server is not valid."""
|
|
796
|
+
if self.reconnect is not None:
|
|
797
|
+
return self.reconnect.origin
|
|
798
|
+
try:
|
|
799
|
+
return endpoint_origin(self.server_url())
|
|
800
|
+
except ValueError:
|
|
801
|
+
return ""
|
|
802
|
+
|
|
803
|
+
def _recognised_browser_provider(self) -> str | None:
|
|
804
|
+
"""Name the provider whose browser sign-in the server offers, as recognised after a refused test of a new account, else None.
|
|
805
|
+
|
|
806
|
+
A Reconnect keeps its username + password account, so it never switches to a browser account.
|
|
807
|
+
"""
|
|
808
|
+
if self.reconnect is not None or self.refused_check is None or not self.refused_check.browser_provider:
|
|
809
|
+
return None
|
|
810
|
+
return self.refused_check.browser_provider
|
|
811
|
+
|
|
812
|
+
def _address_provider(self) -> str | None:
|
|
813
|
+
"""Name the online service at exactly the typed address, whatever kind of account is being added or renewed."""
|
|
814
|
+
try:
|
|
815
|
+
origin = endpoint_origin(self.server_url())
|
|
816
|
+
except ValueError:
|
|
817
|
+
return None
|
|
818
|
+
return provider_for_browser_origin(origin)
|
|
819
|
+
|
|
820
|
+
def _wait_text(self) -> str:
|
|
821
|
+
"""Say what a sign-in with a typed secret waits for: the check of a service token, or the Git test of a password."""
|
|
822
|
+
if self.checks_token():
|
|
823
|
+
return TOKEN_WAIT_TEXT
|
|
824
|
+
return CHECKING_TEXT.format(address=self.address())
|
|
825
|
+
|
|
826
|
+
def _set_browser_waiting(self, waiting: bool) -> None:
|
|
827
|
+
"""Start or end the browser wait: the countdown and the turning indicator."""
|
|
828
|
+
self.browser_waiting = waiting
|
|
829
|
+
if not waiting:
|
|
830
|
+
self.countdown_timer.stop()
|
|
831
|
+
self.busy_indicator.stop()
|
|
832
|
+
return
|
|
833
|
+
self.browser_deadline = QDeadlineTimer(self.browser_timeout_ms)
|
|
834
|
+
self.countdown_timer.start()
|
|
835
|
+
self.busy_indicator.start()
|
|
836
|
+
|
|
837
|
+
def _show_countdown(self) -> None:
|
|
838
|
+
"""Show in orange the time left before GitPane stops waiting for the browser, rounded up to the second and never below zero.
|
|
839
|
+
|
|
840
|
+
The countdown stops at zero; the indicator keeps turning until the account service reports the end of the wait.
|
|
841
|
+
"""
|
|
842
|
+
remaining_seconds = math.ceil(self.browser_deadline.remainingTime() / MILLISECONDS_PER_SECOND)
|
|
843
|
+
if remaining_seconds <= 0:
|
|
844
|
+
remaining_seconds = 0
|
|
845
|
+
self.countdown_timer.stop()
|
|
846
|
+
minutes, seconds = divmod(remaining_seconds, SECONDS_PER_MINUTE)
|
|
847
|
+
self.status_label.show_warning(BROWSER_COUNTDOWN_TEXT.format(minutes=minutes, seconds=seconds))
|
|
848
|
+
|
|
849
|
+
def _apply_lock(self, reconnect: AccountSelection | None, locked_origin: str, service_name: str) -> None:
|
|
850
|
+
"""Show and fix the server, service name and username of the renewed account, or only the server given by the caller."""
|
|
851
|
+
if reconnect is not None:
|
|
852
|
+
self.server_edit.setText(reconnect.origin)
|
|
853
|
+
self.service_name_edit.setText(service_name)
|
|
854
|
+
self.username_edit.setText(reconnect.label)
|
|
855
|
+
elif locked_origin:
|
|
856
|
+
self.server_edit.setText(locked_origin)
|
|
857
|
+
self.server_edit.setEnabled(not self.server_locked)
|
|
858
|
+
self.service_name_edit.setEnabled(not self.account_locked)
|
|
859
|
+
self.username_edit.setEnabled(not self.account_locked)
|
|
860
|
+
|
|
861
|
+
def _server_changed(self) -> None:
|
|
862
|
+
"""Start over for a new address: back to its first way of signing in, without the secret typed for the previous one.
|
|
863
|
+
|
|
864
|
+
What the last try said stays: its red sentence and its output, which the user may be reading or copying, until
|
|
865
|
+
the next try or the window closes. Only the orange advice, which follows the way to sign in reset here, and a
|
|
866
|
+
neutral line said for the previous address go away.
|
|
867
|
+
"""
|
|
868
|
+
self.token_mode = False
|
|
869
|
+
self.refused_check = None
|
|
870
|
+
self.password_edit.clear()
|
|
871
|
+
if self.status_label.tone is not ResultTone.FAILURE:
|
|
872
|
+
self.status_label.clear()
|
|
873
|
+
self._prefill_service_name()
|
|
874
|
+
self._update_presentation()
|
|
875
|
+
|
|
876
|
+
def _service_name_edited(self) -> None:
|
|
877
|
+
"""Remember that the user typed the service name, so a later address change never replaces it."""
|
|
878
|
+
self.prefilled_service_name = ""
|
|
879
|
+
|
|
880
|
+
def _prefill_service_name(self) -> None:
|
|
881
|
+
"""Propose the address's name: the one already saved for it, else the online service's own name.
|
|
882
|
+
|
|
883
|
+
Only an empty field or one still holding the previous proposal is written, so a name the user typed is kept; a
|
|
884
|
+
proposal left untouched goes away with the address it was made for.
|
|
885
|
+
"""
|
|
886
|
+
if self.account_locked:
|
|
887
|
+
return
|
|
888
|
+
current = self.service_name()
|
|
889
|
+
if current and current != self.prefilled_service_name:
|
|
890
|
+
return
|
|
891
|
+
proposal = self._proposed_service_name()
|
|
892
|
+
self.service_name_edit.setText(proposal)
|
|
893
|
+
self.prefilled_service_name = proposal
|
|
894
|
+
|
|
895
|
+
def _proposed_service_name(self) -> str:
|
|
896
|
+
"""Return the name already given to the typed address, the online service's name, or nothing for an unknown server."""
|
|
897
|
+
try:
|
|
898
|
+
origin = endpoint_origin(self.server_url())
|
|
899
|
+
except ValueError:
|
|
900
|
+
return ""
|
|
901
|
+
saved_name = self.service_name_by_origin.get(origin, "")
|
|
902
|
+
if saved_name:
|
|
903
|
+
return saved_name
|
|
904
|
+
return provider_for_browser_origin(origin) or ""
|
|
905
|
+
|
|
906
|
+
def _token_link_clicked(self) -> None:
|
|
907
|
+
"""Switch from browser sign-in to a personal access token and move the focus to it.
|
|
908
|
+
|
|
909
|
+
A red line here can only come from a browser try, since the link is hidden once a secret field is shown: the
|
|
910
|
+
click leaves that way to sign in, so what its try said (the red line, the keyring sentence and the credential
|
|
911
|
+
manager's output) no longer applies to the token and goes away. An orange advice is what pointed to this link,
|
|
912
|
+
after a server refused an account password: it stays, with Git's output that explains the refusal. The link is
|
|
913
|
+
disabled while a try runs; any keyring answer still awaited is dropped anyway, as Cancel does, with the refused
|
|
914
|
+
store it was read for, so it can never show a sentence on a form that has moved on.
|
|
915
|
+
"""
|
|
916
|
+
self.forget_keyring_answer()
|
|
917
|
+
self.refused_outcome = None
|
|
918
|
+
if self.status_label.tone is ResultTone.FAILURE:
|
|
919
|
+
self._hide_output()
|
|
920
|
+
self.show_keyring_note(UNKNOWN_KEYRING_READING)
|
|
921
|
+
self.status_label.clear()
|
|
922
|
+
self.token_mode = True
|
|
923
|
+
self._update_presentation()
|
|
924
|
+
self.password_edit.setFocus()
|
|
925
|
+
|
|
926
|
+
def _update_presentation(self) -> None:
|
|
927
|
+
"""Show only the fields and buttons of the way to sign in that fits the address and the account being renewed."""
|
|
928
|
+
browser_offered = self.browser_provider() is not None
|
|
929
|
+
# After a plain refusal the password may just be mistyped, so its field stays next to the browser offered by
|
|
930
|
+
# the recognised server; only a server that refuses passwords puts the browser first.
|
|
931
|
+
password_kept = self._recognised_browser_provider() is not None and not passwords_refused(self.refused_check)
|
|
932
|
+
secret_visible = not browser_offered or self.token_mode or password_kept
|
|
933
|
+
self.browser_button.setVisible(browser_offered)
|
|
934
|
+
self.browser_button.setText(BROWSER_INSTEAD_BUTTON_TEXT if secret_visible else BROWSER_BUTTON_TEXT)
|
|
935
|
+
self.token_link_button.setVisible(browser_offered and not secret_visible)
|
|
936
|
+
self.form.setRowVisible(self.username_edit, self.asks_username())
|
|
937
|
+
known_check_url = self.known_check_url()
|
|
938
|
+
self.form.setRowVisible(self.check_url_edit, self.asks_check_url())
|
|
939
|
+
self.form.setRowVisible(self.check_note_label, bool(known_check_url))
|
|
940
|
+
self.check_note_label.setText(CHECK_NOTE_TEXT.format(url=known_check_url) if known_check_url else "")
|
|
941
|
+
self.form.setRowVisible(self.password_edit, secret_visible)
|
|
942
|
+
self.form.setRowVisible(self.bitbucket_email_edit, secret_visible and self.asks_bitbucket_email())
|
|
943
|
+
self.add_button.setVisible(secret_visible)
|
|
944
|
+
secret_label = TOKEN_LABEL if self.asks_token() else PASSWORD_LABEL
|
|
945
|
+
self.password_label.setText(secret_label)
|
|
946
|
+
self.password_edit.setAccessibleName(secret_label)
|
|
947
|
+
token_page_text = self._token_page_text()
|
|
948
|
+
self.token_page_label.setText(token_page_text)
|
|
949
|
+
self.form.setRowVisible(self.token_page_label, secret_visible and bool(token_page_text))
|
|
950
|
+
self.self_hosted_label.setVisible(not browser_offered and not self._generic_reconnect() and self._address_valid())
|
|
951
|
+
self.help_label.setText(self._help_text())
|
|
952
|
+
self.browser_button.setDefault(not secret_visible)
|
|
953
|
+
self.add_button.setDefault(secret_visible)
|
|
954
|
+
|
|
955
|
+
def _help_text(self) -> str:
|
|
956
|
+
"""Explain the way to sign in shown: why the online services need a browser or a token, or what a password account is."""
|
|
957
|
+
if self._address_provider() is not None:
|
|
958
|
+
return CLOUD_HELP_TEXT
|
|
959
|
+
if self.checks_token():
|
|
960
|
+
return TOKEN_HELP_TEXT
|
|
961
|
+
return SERVER_HELP_TEXT + HELP_TEXT_SEPARATOR + PASSWORD_HELP_TEXT
|
|
962
|
+
|
|
963
|
+
def _token_page_text(self) -> str:
|
|
964
|
+
"""Say where to create a personal access token once the server refused the typed password for Git; empty otherwise.
|
|
965
|
+
|
|
966
|
+
The page is named when the server was recognised; a server that refuses passwords but was not recognised gets
|
|
967
|
+
the general advice.
|
|
968
|
+
"""
|
|
969
|
+
check = self.refused_check
|
|
970
|
+
if check is None:
|
|
971
|
+
return ""
|
|
972
|
+
if check.token_page_url:
|
|
973
|
+
return TOKEN_PAGE_TEXT.format(url=check.token_page_url)
|
|
974
|
+
if passwords_refused(check):
|
|
975
|
+
return TOKEN_PAGE_GENERIC_TEXT
|
|
976
|
+
return ""
|
|
977
|
+
|
|
978
|
+
def _address_valid(self) -> bool:
|
|
979
|
+
"""Tell whether the typed server is an address the service would accept."""
|
|
980
|
+
try:
|
|
981
|
+
endpoint_origin(self.server_url())
|
|
982
|
+
except ValueError:
|
|
983
|
+
return False
|
|
984
|
+
return True
|
|
985
|
+
|
|
986
|
+
def _cancel_clicked(self) -> None:
|
|
987
|
+
"""Cancel only the running sign-in and keep the window open; with nothing running, close the window."""
|
|
988
|
+
if self.running:
|
|
989
|
+
self.cancel_requested.emit()
|
|
990
|
+
return
|
|
991
|
+
self.reject()
|
|
992
|
+
|
|
993
|
+
def _line_edit(self, accessible_name: str, placeholder: str) -> QLineEdit:
|
|
994
|
+
"""Create one form field whose accessible name matches its visible label."""
|
|
995
|
+
edit = QLineEdit()
|
|
996
|
+
edit.setAccessibleName(accessible_name)
|
|
997
|
+
edit.setPlaceholderText(placeholder)
|
|
998
|
+
return edit
|
|
999
|
+
|
|
1000
|
+
def _plain_label(self, text: str) -> QLabel:
|
|
1001
|
+
"""Create a wrapping label that shows account and diagnostic text literally."""
|
|
1002
|
+
label = QLabel(text)
|
|
1003
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
1004
|
+
label.setWordWrap(True)
|
|
1005
|
+
return label
|