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,677 @@
|
|
|
1
|
+
"""Manage the accounts GitPane uses to sign in to remote Git servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shiboken6 import isValid
|
|
8
|
+
|
|
9
|
+
from gitpane.account.account_choice import check_url_by_origin
|
|
10
|
+
from gitpane.account.authentication import EMPTY_CHECK_URL_TEXT, GENERIC_PROVIDER, store_was_refused, transfer_account
|
|
11
|
+
from gitpane.account.credential_manager import INSTALL_WINDOW_PROBLEM_SET, CredentialManagerProblem, credential_manager_check_again_text, credential_manager_setting_text, ignore_line
|
|
12
|
+
from gitpane.account.keyring_state import UNKNOWN_KEYRING_READING, keyring_stops_sign_in
|
|
13
|
+
from gitpane.config import AUTHENTICATION_PREFERENCE_KEY
|
|
14
|
+
from gitpane.history.file_history import WindowCapability
|
|
15
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
16
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
17
|
+
from gitpane.tool.git_executable import terminal_git_filename
|
|
18
|
+
from gitpane.transport.refusal_notice import account_notice_text
|
|
19
|
+
from gitpane.ui.setting.account_page import removal_failure_text, removal_result_text
|
|
20
|
+
from gitpane.ui.setting.add_account_dialog import EMPTY_BITBUCKET_EMAIL_TEXT, EMPTY_PASSWORD_TEXT, EMPTY_SERVER_TEXT, EMPTY_SERVICE_NAME_TEXT, EMPTY_TOKEN_TEXT, EMPTY_USERNAME_TEXT, SELF_HOSTED_BROWSER_TEXT, AddAccountDialog, sign_in_result_text
|
|
21
|
+
from gitpane.ui.setting.credential_manager_dialog import CredentialManagerDialog
|
|
22
|
+
from gitpane.ui.setting.ssh_widget import SshPromptDialog
|
|
23
|
+
from gitpane.ui.widget.result_label import ResultTone
|
|
24
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
from PySide6.QtWidgets import QWidget
|
|
30
|
+
|
|
31
|
+
from gitpane.account.authentication import AccountSelection, AuthenticationService, AuthOutcome, SecretLookup
|
|
32
|
+
from gitpane.account.credential_manager import CredentialManagerDetection
|
|
33
|
+
from gitpane.account.keyring_state import KeyringReading
|
|
34
|
+
from gitpane.account.ssh_prompt import SshQuestion
|
|
35
|
+
from gitpane.controller.operation_policy import OperationPolicy
|
|
36
|
+
from gitpane.controller.session_registry import SessionRegistry
|
|
37
|
+
from gitpane.controller.setting_controller import SettingController
|
|
38
|
+
from gitpane.history.repository import GitRepository
|
|
39
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
40
|
+
from gitpane.operation.diagnostic import DiagnosticLog
|
|
41
|
+
from gitpane.transport.refusal_notice import AccountNotice
|
|
42
|
+
from gitpane.transport.remote import RemoteService
|
|
43
|
+
from gitpane.transport.remote_config import RemoteSnapshot
|
|
44
|
+
from gitpane.ui.setting.account_page import AccountPage
|
|
45
|
+
from gitpane.ui.setting.setting_dialog import SettingDialog
|
|
46
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
47
|
+
|
|
48
|
+
# The --log lines of the keyring reads made for a sign-in: the state's value and the name of the program that holds the
|
|
49
|
+
# Secret Service, never a collection name or a secret.
|
|
50
|
+
KEYRING_STATE_LOG_TEMPLATE = "Keyring before sign-in: {state}, held by {program}"
|
|
51
|
+
KEYRING_STATE_AFTER_REFUSAL_LOG_TEMPLATE = "Keyring after a refused store: {state}, held by {program}"
|
|
52
|
+
UNREADABLE_PROGRAM_LOG_TEXT = "an unreadable program"
|
|
53
|
+
|
|
54
|
+
class AccountController:
|
|
55
|
+
"""The accounts page of Settings, its sign-in windows, the SSH questions of running operations and the window explaining how to install the credential manager."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, setting_controller: SettingController, repository: GitRepository, worktree_service: WorktreeService, remote: RemoteService,
|
|
58
|
+
authentication: AuthenticationService, diagnostic_log: DiagnosticLog | None, read_keyring: Callable[[], KeyringReading]) -> None:
|
|
59
|
+
"""Receive the services and the lower controllers the accounts go through; the wiring builds it after the Settings controller, whose opening and preference changes reach it through the wiring.
|
|
60
|
+
|
|
61
|
+
``read_keyring`` reads, without changing anything, the state of the keyring the credential manager stores
|
|
62
|
+
secrets in and the program that holds it; it may wait on the session bus, so it is only called on a worker thread.
|
|
63
|
+
"""
|
|
64
|
+
self.session_registry_controller = session_registry_controller
|
|
65
|
+
self.operation_policy_controller = operation_policy_controller
|
|
66
|
+
self.setting_controller = setting_controller
|
|
67
|
+
self.repository = repository
|
|
68
|
+
self.worktree_service = worktree_service
|
|
69
|
+
self.remote = remote
|
|
70
|
+
self.authentication = authentication
|
|
71
|
+
self.diagnostic_log = diagnostic_log
|
|
72
|
+
self.read_keyring = read_keyring
|
|
73
|
+
# The only window explaining how to install the credential manager; a refusal while it is open adds only its red line.
|
|
74
|
+
self.credential_manager_dialog: CredentialManagerDialog | None = None
|
|
75
|
+
# The sign-in form whose refusal the open install window explains, kept while that window is open: once Check
|
|
76
|
+
# again finds a usable credential manager, the form no longer says signing in is impossible.
|
|
77
|
+
self.credential_manager_refusal_form: AddAccountDialog | None = None
|
|
78
|
+
self.ssh_dialog_by_id: dict[str, SshPromptDialog] = {}
|
|
79
|
+
|
|
80
|
+
def bind_setting_account_page(self, dialog: SettingDialog, account_page: AccountPage, session_id: str | None, context: RepositoryContext | None) -> None:
|
|
81
|
+
"""Connect the accounts page of a Settings window being built, then fill it: accounts, their stored secrets, the remotes and the credential manager.
|
|
82
|
+
|
|
83
|
+
``session_id`` is the window that asked for Settings, None from the welcome window; ``context`` is the repository
|
|
84
|
+
Settings opened for, None without one, and then no remote is read.
|
|
85
|
+
"""
|
|
86
|
+
account_page.add_account_requested.connect(lambda: self.show_add_account(session_id))
|
|
87
|
+
account_page.reconnect_requested.connect(lambda: self.show_reconnect(session_id))
|
|
88
|
+
account_page.remove_account_requested.connect(lambda namespace: self.remove_account(session_id, namespace))
|
|
89
|
+
account_page.save_global_requested.connect(self.save_global_accounts)
|
|
90
|
+
account_page.save_repository_requested.connect(self.save_repository_accounts)
|
|
91
|
+
if self.refresh_account_page():
|
|
92
|
+
self.lookup_account_secrets(dialog)
|
|
93
|
+
if context is not None:
|
|
94
|
+
self.read_account_remotes(dialog)
|
|
95
|
+
self.check_credential_manager_for_setting(dialog)
|
|
96
|
+
|
|
97
|
+
def offer_account_after_refusal(self, session_id: str | None, notice: AccountNotice) -> None:
|
|
98
|
+
"""Open Settings on Accounts with an orange notice naming what Git refused, so the user can add or reconnect the account.
|
|
99
|
+
|
|
100
|
+
The failed operation is never started again: the user runs it again once the account works. An already open
|
|
101
|
+
Settings window is brought back as it is, keeping every unsaved draft; a window that may not manage settings
|
|
102
|
+
opens nothing.
|
|
103
|
+
"""
|
|
104
|
+
dialog = self.setting_controller.show_setting(session_id, section="accounts")
|
|
105
|
+
if dialog is None:
|
|
106
|
+
return
|
|
107
|
+
dialog.account_page.show_account_notice(account_notice_text(notice), notice.address)
|
|
108
|
+
|
|
109
|
+
def offer_credential_manager_install(self, detection: CredentialManagerDetection, parent: QWidget | None) -> bool:
|
|
110
|
+
"""Open the window explaining how to install the credential manager, for a refusal that stopped an operation the user asked for.
|
|
111
|
+
|
|
112
|
+
Returns True when an install window is open after the call, whether it was just opened or already open; a second
|
|
113
|
+
refusal never opens a second window. An open install window is offered again only while it can take clicks: once
|
|
114
|
+
a modal window it does not belong to opened after it, such as Settings, Qt blocks it, so it is closed and a new
|
|
115
|
+
one opens over ``parent``. A console blocks it the same way, so it is replaced too, when the console is modal for
|
|
116
|
+
the whole application, as on macOS, or modal for the install window's own repository window; only a console over
|
|
117
|
+
another repository window on Windows and Linux leaves it taking clicks, so it stays. Does nothing for an
|
|
118
|
+
unsupported system, which has nothing to install, nor while GitPane is closing. No operation is started again:
|
|
119
|
+
the user runs it again once the credential manager is installed.
|
|
120
|
+
"""
|
|
121
|
+
if detection.problem not in INSTALL_WINDOW_PROBLEM_SET:
|
|
122
|
+
return False
|
|
123
|
+
if self.session_registry_controller.shutdown_requested:
|
|
124
|
+
return False
|
|
125
|
+
existing = self.credential_manager_dialog
|
|
126
|
+
if existing is not None and isValid(existing):
|
|
127
|
+
if not existing.blocked_by_modal:
|
|
128
|
+
return True
|
|
129
|
+
# Its finished signal forgets it, and only if it is still the window kept here, so the new one stays kept.
|
|
130
|
+
existing.close()
|
|
131
|
+
dialog = CredentialManagerDialog(self.authentication.credential_manager.install_lines(detection), detection, parent)
|
|
132
|
+
self.credential_manager_dialog = dialog
|
|
133
|
+
dialog.finished.connect(lambda _result, dialog=dialog: self._forget_credential_manager_dialog(dialog))
|
|
134
|
+
dialog.check_again_requested.connect(lambda dialog=dialog: self._check_credential_manager_again(dialog))
|
|
135
|
+
dialog.show()
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def _check_credential_manager_again(self, dialog: CredentialManagerDialog) -> None:
|
|
139
|
+
"""Search again for the credential manager of the Git the operations will use, once the user says it is installed.
|
|
140
|
+
|
|
141
|
+
The window closes when a usable credential manager is found, and the sign-in form whose refusal it explained, if
|
|
142
|
+
still open, takes that refusal and its install lines away; otherwise the window says what changed and its install
|
|
143
|
+
text becomes the one of the refusal found, and the form keeps its refusal. No operation is started again: the
|
|
144
|
+
user runs the refused one again. The search runs off the window's thread.
|
|
145
|
+
"""
|
|
146
|
+
dialog.set_checking(True)
|
|
147
|
+
|
|
148
|
+
def found(detection: CredentialManagerDetection | None, error: Exception | None) -> None:
|
|
149
|
+
"""Report the answer in the install window, only while it is still the open one."""
|
|
150
|
+
# An open Settings window shows what this search found too, even once the install window is gone.
|
|
151
|
+
self._show_credential_manager_setting(detection, error)
|
|
152
|
+
if self.credential_manager_dialog is not dialog or not isValid(dialog):
|
|
153
|
+
return
|
|
154
|
+
dialog.set_checking(False)
|
|
155
|
+
if error is not None:
|
|
156
|
+
dialog.show_check_result(str(error))
|
|
157
|
+
return
|
|
158
|
+
if detection.problem == CredentialManagerProblem.NONE:
|
|
159
|
+
form = self.credential_manager_refusal_form
|
|
160
|
+
if form is not None and isValid(form) and not form.closed:
|
|
161
|
+
form.clear_credential_manager_refusal()
|
|
162
|
+
dialog.accept()
|
|
163
|
+
return
|
|
164
|
+
platform = self.authentication.credential_manager.platform
|
|
165
|
+
text = credential_manager_check_again_text(dialog.detection, detection, platform)
|
|
166
|
+
# The text is rewritten for every refusal found, so the short answer of a later Check again always refers
|
|
167
|
+
# to what the window shows.
|
|
168
|
+
dialog.show_detection(self.authentication.credential_manager.install_lines(detection), detection)
|
|
169
|
+
dialog.show_check_result(text)
|
|
170
|
+
|
|
171
|
+
self.authentication.locate_credential_manager(terminal_git_filename(self.repository.git_filename), found)
|
|
172
|
+
|
|
173
|
+
def check_credential_manager_for_setting(self, dialog: SettingDialog) -> None:
|
|
174
|
+
"""Search in the background for the credential manager shown in the Accounts page of a Settings window that just opened.
|
|
175
|
+
|
|
176
|
+
A success already kept for this Git is shown at once; the search started next confirms it without starting
|
|
177
|
+
--version again. The answer only reaches the Settings window that asked while it is still the open one, and
|
|
178
|
+
never opens the install window.
|
|
179
|
+
"""
|
|
180
|
+
git_filename = terminal_git_filename(self.repository.git_filename)
|
|
181
|
+
# Workaround: the kept success is read without the search's lock, because a running search may hold that lock
|
|
182
|
+
# up to the probe time limit of each candidate and the window's thread must never wait for it. Reading one dict
|
|
183
|
+
# entry is atomic, and the search started just below confirms or replaces what is shown.
|
|
184
|
+
kept = self.authentication.credential_manager.detection_by_git_filename.get(git_filename)
|
|
185
|
+
if kept is not None:
|
|
186
|
+
dialog.account_page.show_credential_manager(credential_manager_setting_text(kept, self.authentication.credential_manager.platform), False)
|
|
187
|
+
else:
|
|
188
|
+
dialog.account_page.show_credential_manager_checking()
|
|
189
|
+
|
|
190
|
+
def found(detection: CredentialManagerDetection | None, error: Exception | None) -> None:
|
|
191
|
+
"""Show the answer only while the Settings window that asked is still the open one."""
|
|
192
|
+
if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog):
|
|
193
|
+
return
|
|
194
|
+
self._show_credential_manager_setting(detection, error)
|
|
195
|
+
|
|
196
|
+
self.authentication.locate_credential_manager(git_filename, found)
|
|
197
|
+
|
|
198
|
+
def _show_credential_manager_setting(self, detection: CredentialManagerDetection | None, error: Exception | None) -> None:
|
|
199
|
+
"""Write in the credential manager line of the open Settings window what a search found; does nothing while Settings is closed."""
|
|
200
|
+
dialog = self.session_registry_controller.setting_dialog
|
|
201
|
+
if dialog is None or not isValid(dialog):
|
|
202
|
+
return
|
|
203
|
+
if error is not None:
|
|
204
|
+
dialog.account_page.show_credential_manager(str(error), True)
|
|
205
|
+
return
|
|
206
|
+
text = credential_manager_setting_text(detection, self.authentication.credential_manager.platform)
|
|
207
|
+
dialog.account_page.show_credential_manager(text, detection.problem != CredentialManagerProblem.NONE)
|
|
208
|
+
|
|
209
|
+
def _forget_credential_manager_dialog(self, dialog: CredentialManagerDialog) -> None:
|
|
210
|
+
"""Forget the install window once it closes, with the sign-in form it explained, unless a newer one already replaced it, so the next refusal can open one again."""
|
|
211
|
+
if self.credential_manager_dialog is dialog:
|
|
212
|
+
self.credential_manager_dialog = None
|
|
213
|
+
self.credential_manager_refusal_form = None
|
|
214
|
+
|
|
215
|
+
def show_ssh_question(self, question: SshQuestion) -> None:
|
|
216
|
+
"""Open a private credential question owned by the captured running operation."""
|
|
217
|
+
dialog = SshPromptDialog(question)
|
|
218
|
+
self.ssh_dialog_by_id[question.operation_id] = dialog
|
|
219
|
+
dialog.finished.connect(lambda: self.ssh_dialog_by_id.pop(question.operation_id, None))
|
|
220
|
+
open_window_modal(dialog)
|
|
221
|
+
|
|
222
|
+
def close_ssh_question(self, operation_id: str) -> None:
|
|
223
|
+
"""Discard a question when its socket closes or its operation is cancelled."""
|
|
224
|
+
dialog = self.ssh_dialog_by_id.pop(operation_id, None)
|
|
225
|
+
if dialog is not None and isValid(dialog):
|
|
226
|
+
dialog.reject()
|
|
227
|
+
dialog.deleteLater()
|
|
228
|
+
|
|
229
|
+
def show_add_account(self, session_id: str | None) -> None:
|
|
230
|
+
"""Open the sign-in window that adds a new account to Connected accounts."""
|
|
231
|
+
self._open_account_sign_in(session_id, None)
|
|
232
|
+
|
|
233
|
+
def show_reconnect(self, session_id: str | None) -> None:
|
|
234
|
+
"""Open the sign-in window that renews the account selected in Connected accounts."""
|
|
235
|
+
dialog = self.session_registry_controller.setting_dialog
|
|
236
|
+
if dialog is None:
|
|
237
|
+
return
|
|
238
|
+
namespace = dialog.account_page.selected_namespace()
|
|
239
|
+
if namespace is None:
|
|
240
|
+
return
|
|
241
|
+
catalogue = self.authentication.catalogue()
|
|
242
|
+
for selection in catalogue.accounts:
|
|
243
|
+
if selection.namespace == namespace:
|
|
244
|
+
self._open_account_sign_in(session_id, selection, catalogue.service_name_by_origin.get(selection.origin, ""))
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
def _open_account_sign_in(self, session_id: str | None, reconnect: AccountSelection | None, service_name: str = "") -> None:
|
|
248
|
+
"""Show one sign-in window at a time for Settings; the window that asked must be allowed to manage settings.
|
|
249
|
+
|
|
250
|
+
``service_name`` is the name of the renewed account's address, shown in the Reconnect window. The remotes already
|
|
251
|
+
read for This repository give the window a repository URL per address, so a username + password on the address
|
|
252
|
+
of one of them is tested there without asking; remotes read later reach the open window as they arrive.
|
|
253
|
+
"""
|
|
254
|
+
dialog = self.session_registry_controller.setting_dialog
|
|
255
|
+
if dialog is None or session_id is not None and not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
256
|
+
return
|
|
257
|
+
page = dialog.account_page
|
|
258
|
+
if page.sign_in_dialog is not None and isValid(page.sign_in_dialog):
|
|
259
|
+
page.sign_in_dialog.showNormal()
|
|
260
|
+
page.sign_in_dialog.raise_()
|
|
261
|
+
page.sign_in_dialog.activateWindow()
|
|
262
|
+
return
|
|
263
|
+
add_dialog = AddAccountDialog(dialog, reconnect=reconnect, service_name=service_name, service_name_by_origin=self.service_name_by_origin(),
|
|
264
|
+
known_check_url_by_origin=check_url_by_origin(page.remote_entries or ()), browser_timeout_ms=self.authentication.browser_timeout_ms)
|
|
265
|
+
page.sign_in_dialog = add_dialog
|
|
266
|
+
|
|
267
|
+
def owner_is_current() -> bool:
|
|
268
|
+
"""Publish only while the Settings window that opened the sign-in is still the open one."""
|
|
269
|
+
return self.session_registry_controller.setting_dialog is dialog
|
|
270
|
+
|
|
271
|
+
def on_success(outcome: AuthOutcome) -> None:
|
|
272
|
+
"""Show the refreshed list and the result in the Settings window that asked."""
|
|
273
|
+
self._account_signed_in(dialog, outcome, reconnect is not None)
|
|
274
|
+
|
|
275
|
+
def finished() -> None:
|
|
276
|
+
"""Forget the closed window so the next Add account or Reconnect opens a fresh one.
|
|
277
|
+
|
|
278
|
+
Nothing is raised here: a browser sign-in ends while the browser is in front, and raising a window asks macOS
|
|
279
|
+
to bring GitPane forward. Settings stays above the other GitPane windows because it is modal.
|
|
280
|
+
"""
|
|
281
|
+
if isValid(page) and page.sign_in_dialog is add_dialog:
|
|
282
|
+
page.sign_in_dialog = None
|
|
283
|
+
add_dialog.deleteLater()
|
|
284
|
+
|
|
285
|
+
add_dialog.sign_in_requested.connect(lambda token_mode: self.start_account_sign_in(add_dialog, token_mode, owner_is_current, on_success))
|
|
286
|
+
add_dialog.cancel_requested.connect(lambda: self.cancel_account_sign_in(add_dialog))
|
|
287
|
+
add_dialog.finished.connect(finished)
|
|
288
|
+
# Modal over Settings: a form left non-modal would take the level of the modal Settings on macOS and stay above
|
|
289
|
+
# the browser while GitPane waits for a browser sign-in.
|
|
290
|
+
open_window_modal(add_dialog)
|
|
291
|
+
|
|
292
|
+
def start_account_sign_in(self, add_dialog: AddAccountDialog, token_mode: bool, owner_is_current: Callable[[], bool], on_success: Callable[[AuthOutcome], None]) -> None:
|
|
293
|
+
"""Capture the window's authority and generation before the private sign-in exchange starts.
|
|
294
|
+
|
|
295
|
+
``token_mode`` is true when a secret was typed, false for browser sign-in. On GitHub, gitlab.com and Bitbucket
|
|
296
|
+
Cloud a typed secret is a personal access token whose identity the service's API checks; on any other server it
|
|
297
|
+
is the password or token of a generic account, saved only once Git lists the repository typed in the window with
|
|
298
|
+
them; a failed test is also written to the --log file, without any secret. Which browser sign-in
|
|
299
|
+
fits the address is only the window's answer. Reconnect renews exactly the selected account. Any window that
|
|
300
|
+
owns an Add account or Reconnect window can use this: ``owner_is_current`` tells whether the window that opened
|
|
301
|
+
it still accepts results, and ``on_success`` shows the result there. No repository is reserved, because the
|
|
302
|
+
account list belongs to no repository.
|
|
303
|
+
|
|
304
|
+
Before any exchange, the keyring is read off the window's thread: when the GNOME keyring's default collection is
|
|
305
|
+
not published on the session bus, the credential manager could not store the account, so nothing is sent and the
|
|
306
|
+
window says why. The typed secret is kept only until that read answers.
|
|
307
|
+
"""
|
|
308
|
+
if add_dialog.operation_id:
|
|
309
|
+
return
|
|
310
|
+
reconnect = add_dialog.reconnect
|
|
311
|
+
refusal = self._account_form_refusal(add_dialog, token_mode)
|
|
312
|
+
if refusal:
|
|
313
|
+
add_dialog.clear_password()
|
|
314
|
+
add_dialog.show_refusal(refusal)
|
|
315
|
+
return
|
|
316
|
+
# The password or token is the one value taken exactly as typed, blanks included: a space may be part of a secret.
|
|
317
|
+
secret = add_dialog.password_edit.text() if token_mode else ""
|
|
318
|
+
check_url = add_dialog.check_url()
|
|
319
|
+
api_email = add_dialog.bitbucket_email() if add_dialog.asks_bitbucket_email() else ""
|
|
320
|
+
username = add_dialog.username()
|
|
321
|
+
service_name = add_dialog.service_name()
|
|
322
|
+
server_url = add_dialog.server_url()
|
|
323
|
+
cloud_provider = add_dialog.cloud_provider()
|
|
324
|
+
browser_provider = add_dialog.browser_provider()
|
|
325
|
+
generation = add_dialog.generation
|
|
326
|
+
add_dialog.refused_outcome = None
|
|
327
|
+
add_dialog.set_running(True, token=token_mode)
|
|
328
|
+
|
|
329
|
+
def is_current() -> bool:
|
|
330
|
+
"""Authorize nonsecret publication only while the captured sign-in window still owns it."""
|
|
331
|
+
return owner_is_current() and isValid(add_dialog) and not add_dialog.closed and add_dialog.generation == generation
|
|
332
|
+
|
|
333
|
+
def complete(outcome: AuthOutcome) -> None:
|
|
334
|
+
"""Close the window after a full success; otherwise keep it open with the failed step and its cause.
|
|
335
|
+
|
|
336
|
+
When the secure store refused to save, the keyring is read again first: a collection still locked, not
|
|
337
|
+
published, or no default collection at all explains the refusal and is said with it. The refusal is kept on
|
|
338
|
+
the window during that read, so Cancel there shows it rather than a cancelled try.
|
|
339
|
+
"""
|
|
340
|
+
if not is_current():
|
|
341
|
+
return
|
|
342
|
+
add_dialog.operation_id = ""
|
|
343
|
+
if outcome.status == OperationStatus.SUCCESS and outcome.selection is not None:
|
|
344
|
+
add_dialog.set_running(False)
|
|
345
|
+
on_success(outcome)
|
|
346
|
+
add_dialog.accept()
|
|
347
|
+
return
|
|
348
|
+
if not store_was_refused(outcome):
|
|
349
|
+
self._report_sign_in_failure(add_dialog, outcome, UNKNOWN_KEYRING_READING)
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
def keyring_read_after_refusal(reading: KeyringReading) -> None:
|
|
353
|
+
"""Show the refused store with what the keyring read again says, unless the window no longer accepts results."""
|
|
354
|
+
if not is_current():
|
|
355
|
+
return
|
|
356
|
+
add_dialog.refused_outcome = None
|
|
357
|
+
self._log_keyring_reading(KEYRING_STATE_AFTER_REFUSAL_LOG_TEMPLATE, reading)
|
|
358
|
+
self._report_sign_in_failure(add_dialog, outcome, reading)
|
|
359
|
+
|
|
360
|
+
add_dialog.refused_outcome = outcome
|
|
361
|
+
add_dialog.read_keyring_state(self.read_keyring, keyring_read_after_refusal, log_line=self._log_line())
|
|
362
|
+
|
|
363
|
+
def start_exchange() -> None:
|
|
364
|
+
"""Start the exchange the window asked for: renew the selected account, or sign in with a token, a password or the browser."""
|
|
365
|
+
if reconnect is not None:
|
|
366
|
+
add_dialog.operation_id = self.authentication.approve(reconnect, secret, complete, api_email=api_email, is_current=is_current, check_url=check_url,
|
|
367
|
+
git_filename=self.repository.git_filename)
|
|
368
|
+
return
|
|
369
|
+
if token_mode and cloud_provider is not None:
|
|
370
|
+
add_dialog.operation_id = self.authentication.sign_in(cloud_provider, server_url, complete, token=secret, api_email=api_email, service_name=service_name,
|
|
371
|
+
git_filename=self.repository.git_filename, is_current=is_current)
|
|
372
|
+
return
|
|
373
|
+
if token_mode:
|
|
374
|
+
add_dialog.operation_id = self.authentication.sign_in(GENERIC_PROVIDER, server_url, complete, token=secret, username=username, service_name=service_name,
|
|
375
|
+
git_filename=self.repository.git_filename, is_current=is_current, check_url=check_url)
|
|
376
|
+
return
|
|
377
|
+
add_dialog.operation_id = self.authentication.sign_in(browser_provider, server_url, complete, service_name=service_name, git_filename=self.repository.git_filename,
|
|
378
|
+
is_current=is_current)
|
|
379
|
+
|
|
380
|
+
def keyring_answered(reading: KeyringReading) -> None:
|
|
381
|
+
"""Stop before anything is sent when the keyring cannot store the account; any other reading lets the exchange start as before.
|
|
382
|
+
|
|
383
|
+
Only the answer already seen with the GNOME keyring stops it: its default collection not published, in the
|
|
384
|
+
very first session of a new system or user. The same answer from KDE Wallet, KeePassXC, any other program or
|
|
385
|
+
a program that could not be read lets the exchange go, so the credential manager's own output says what
|
|
386
|
+
happened. A locked collection does not stop it: the keyring asks for its password when the credential
|
|
387
|
+
manager stores the account. A keyring with no default collection does not stop it either: the credential
|
|
388
|
+
manager's library asks the desktop to create one when it stores the account, and the user decides. A state
|
|
389
|
+
that could not be read says nothing and stops nothing.
|
|
390
|
+
"""
|
|
391
|
+
if not is_current():
|
|
392
|
+
return
|
|
393
|
+
self._log_keyring_reading(KEYRING_STATE_LOG_TEMPLATE, reading)
|
|
394
|
+
if keyring_stops_sign_in(reading):
|
|
395
|
+
add_dialog.show_keyring_refusal(reading)
|
|
396
|
+
return
|
|
397
|
+
start_exchange()
|
|
398
|
+
|
|
399
|
+
add_dialog.read_keyring_state(self.read_keyring, keyring_answered, log_line=self._log_line())
|
|
400
|
+
|
|
401
|
+
def _account_form_refusal(self, add_dialog: AddAccountDialog, token_mode: bool) -> str:
|
|
402
|
+
"""Say what the sign-in form still misses before anything starts, or return an empty text when it can start.
|
|
403
|
+
|
|
404
|
+
Fields are checked in the order the form shows them. A new account needs a service name, whatever the way it
|
|
405
|
+
signs in. A Reconnect only needs the new secret: its server, service name and username are fixed, and a
|
|
406
|
+
Bitbucket account already has its e-mail saved. A username + password account, new or reconnected, also needs a
|
|
407
|
+
repository of its server to be tested on.
|
|
408
|
+
"""
|
|
409
|
+
adding = add_dialog.reconnect is None
|
|
410
|
+
if adding and not add_dialog.server_url():
|
|
411
|
+
return EMPTY_SERVER_TEXT
|
|
412
|
+
if adding and not add_dialog.service_name():
|
|
413
|
+
return EMPTY_SERVICE_NAME_TEXT
|
|
414
|
+
if not token_mode:
|
|
415
|
+
if adding and add_dialog.browser_provider() is None:
|
|
416
|
+
return SELF_HOSTED_BROWSER_TEXT
|
|
417
|
+
return ""
|
|
418
|
+
if adding and add_dialog.asks_username() and not add_dialog.username():
|
|
419
|
+
return EMPTY_USERNAME_TEXT
|
|
420
|
+
if add_dialog.asks_check_url() and not add_dialog.check_url():
|
|
421
|
+
return EMPTY_CHECK_URL_TEXT.format(address=add_dialog.address())
|
|
422
|
+
# A secret made only of spaces is still a secret, so it is checked exactly as typed.
|
|
423
|
+
if not add_dialog.password_edit.text():
|
|
424
|
+
return EMPTY_TOKEN_TEXT if add_dialog.asks_token() else EMPTY_PASSWORD_TEXT
|
|
425
|
+
if adding and add_dialog.asks_bitbucket_email() and not add_dialog.bitbucket_email():
|
|
426
|
+
return EMPTY_BITBUCKET_EMAIL_TEXT
|
|
427
|
+
return ""
|
|
428
|
+
|
|
429
|
+
def service_name_by_origin(self) -> dict[str, str]:
|
|
430
|
+
"""Read the names already given to service addresses, for the sign-in window to propose; an unreadable list proposes none."""
|
|
431
|
+
try:
|
|
432
|
+
return self.authentication.catalogue().service_name_by_origin
|
|
433
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
434
|
+
return {}
|
|
435
|
+
|
|
436
|
+
def _report_sign_in_failure(self, add_dialog: AddAccountDialog, outcome: AuthOutcome, keyring_reading: KeyringReading) -> None:
|
|
437
|
+
"""Keep the sign-in window open with the failed step, the keyring sentence when the keyring explains it, and the cause.
|
|
438
|
+
|
|
439
|
+
A failed Git test is also written to the --log journal, without any secret. A refused credential manager adds
|
|
440
|
+
its install lines under the refusal and offers the window that explains how to install it.
|
|
441
|
+
"""
|
|
442
|
+
if outcome.check is not None and outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
443
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
444
|
+
add_dialog.show_failure(outcome, keyring_reading=keyring_reading)
|
|
445
|
+
# The form keeps the refusal sentence and the install lines; closing the install window brings the user back
|
|
446
|
+
# to it, to click the sign-in button again once the credential manager is installed. Only Check again that
|
|
447
|
+
# finds a usable one takes them away.
|
|
448
|
+
refusal = outcome.credential_manager_refusal
|
|
449
|
+
if refusal is not None:
|
|
450
|
+
if refusal.problem in INSTALL_WINDOW_PROBLEM_SET:
|
|
451
|
+
add_dialog.show_install_text(self.authentication.credential_manager.install_lines(refusal))
|
|
452
|
+
if self.offer_credential_manager_install(refusal, add_dialog):
|
|
453
|
+
self.credential_manager_refusal_form = add_dialog
|
|
454
|
+
|
|
455
|
+
def cancel_account_sign_in(self, add_dialog: AddAccountDialog) -> None:
|
|
456
|
+
"""Stop the exact running exchange and make sure its late result is ignored.
|
|
457
|
+
|
|
458
|
+
Once the exchange has ended with a refused store, only the keyring read that explains it is still running: the
|
|
459
|
+
refusal is already known, so Cancel shows it, with its output and without any keyring sentence, rather than
|
|
460
|
+
saying the sign-in was cancelled; the keyring's late answer then changes nothing.
|
|
461
|
+
"""
|
|
462
|
+
if not isValid(add_dialog):
|
|
463
|
+
return
|
|
464
|
+
refused_outcome = add_dialog.refused_outcome
|
|
465
|
+
if refused_outcome is not None and not add_dialog.operation_id:
|
|
466
|
+
add_dialog.refused_outcome = None
|
|
467
|
+
add_dialog.generation += 1
|
|
468
|
+
add_dialog.forget_keyring_answer()
|
|
469
|
+
if add_dialog.closed:
|
|
470
|
+
add_dialog.set_running(False)
|
|
471
|
+
return
|
|
472
|
+
self._report_sign_in_failure(add_dialog, refused_outcome, UNKNOWN_KEYRING_READING)
|
|
473
|
+
return
|
|
474
|
+
add_dialog.refused_outcome = None
|
|
475
|
+
if add_dialog.operation_id:
|
|
476
|
+
self.authentication.cancel(add_dialog.operation_id)
|
|
477
|
+
add_dialog.operation_id = ""
|
|
478
|
+
add_dialog.generation += 1
|
|
479
|
+
add_dialog.forget_keyring_answer()
|
|
480
|
+
add_dialog.set_running(False)
|
|
481
|
+
if not add_dialog.closed:
|
|
482
|
+
add_dialog.show_cancelled()
|
|
483
|
+
|
|
484
|
+
def _log_line(self) -> Callable[[str], None]:
|
|
485
|
+
"""Return what writes one line to the --log journal from any thread, or what writes nothing without a journal."""
|
|
486
|
+
if self.diagnostic_log is None:
|
|
487
|
+
return ignore_line
|
|
488
|
+
return self.diagnostic_log.write_line
|
|
489
|
+
|
|
490
|
+
def _log_keyring_reading(self, template: str, reading: KeyringReading) -> None:
|
|
491
|
+
"""Write a keyring reading made for a sign-in to the --log journal: the state's value and the program's name, never a collection name or a secret."""
|
|
492
|
+
program = reading.program or UNREADABLE_PROGRAM_LOG_TEXT
|
|
493
|
+
self._log_line()(template.format(state=reading.state.value, program=program))
|
|
494
|
+
|
|
495
|
+
def _account_signed_in(self, dialog: SettingDialog, outcome: AuthOutcome, reconnect: bool) -> None:
|
|
496
|
+
"""Reread the account list into Settings and say what happened to the account, and that it is already saved.
|
|
497
|
+
|
|
498
|
+
A refusal notice for the same address is cleared, since the account it asked for is now connected; the refused
|
|
499
|
+
operation is not started again, the user runs it again.
|
|
500
|
+
"""
|
|
501
|
+
if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog):
|
|
502
|
+
return
|
|
503
|
+
account = transfer_account(outcome.selection)
|
|
504
|
+
if account is not None:
|
|
505
|
+
dialog.account_page.clear_account_notice(account.address)
|
|
506
|
+
if not self.refresh_account_page():
|
|
507
|
+
return
|
|
508
|
+
dialog.account_page.show_message(sign_in_result_text(outcome, reconnect=reconnect, saved_note=True), ResultTone.SUCCESS)
|
|
509
|
+
|
|
510
|
+
def remove_account(self, session_id: str | None, namespace: str) -> None:
|
|
511
|
+
"""Remove the confirmed account: its stored secret is erased, then it leaves Connected accounts; no repository is touched.
|
|
512
|
+
|
|
513
|
+
The window that asked must still be allowed to manage settings. The result is shown only in the Settings window
|
|
514
|
+
that asked; if it closed meanwhile, the service still finishes a removal whose secret was erased.
|
|
515
|
+
"""
|
|
516
|
+
dialog = self.session_registry_controller.setting_dialog
|
|
517
|
+
if dialog is None or not isValid(dialog) or session_id is not None and not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
518
|
+
return
|
|
519
|
+
page = dialog.account_page
|
|
520
|
+
try:
|
|
521
|
+
catalogue = self.authentication.catalogue()
|
|
522
|
+
except (OSError, ValueError, TypeError) as error:
|
|
523
|
+
page.show_message(Diagnostic("Read accounts", "", "", [], str(error)).prepare().cause, ResultTone.FAILURE)
|
|
524
|
+
return
|
|
525
|
+
selection = None
|
|
526
|
+
for account in catalogue.accounts:
|
|
527
|
+
if account.namespace == namespace:
|
|
528
|
+
selection = account
|
|
529
|
+
if selection is None:
|
|
530
|
+
self.refresh_account_page()
|
|
531
|
+
return
|
|
532
|
+
page.set_removing(True, selection.label)
|
|
533
|
+
|
|
534
|
+
def is_current() -> bool:
|
|
535
|
+
"""Accept the result only while the Settings window that asked is still the open one."""
|
|
536
|
+
return self.session_registry_controller.setting_dialog is dialog
|
|
537
|
+
|
|
538
|
+
def complete(outcome: AuthOutcome) -> None:
|
|
539
|
+
"""Show the refreshed list and say what happened: removed and what to choose again, or which step failed."""
|
|
540
|
+
if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog):
|
|
541
|
+
return
|
|
542
|
+
page.set_removing(False)
|
|
543
|
+
self.refresh_account_page()
|
|
544
|
+
if outcome.status == OperationStatus.SUCCESS and outcome.removal is not None:
|
|
545
|
+
page.show_message(removal_result_text(selection, outcome.removal), ResultTone.SUCCESS)
|
|
546
|
+
return
|
|
547
|
+
page.show_message(removal_failure_text(selection, outcome), ResultTone.FAILURE)
|
|
548
|
+
if outcome.diagnostic is not None and self.diagnostic_log is not None:
|
|
549
|
+
self.diagnostic_log.write(outcome.diagnostic)
|
|
550
|
+
# The account stays listed and the page says it was not removed; the install window comes on top of Settings.
|
|
551
|
+
if outcome.credential_manager_refusal is not None:
|
|
552
|
+
self.offer_credential_manager_install(outcome.credential_manager_refusal, dialog)
|
|
553
|
+
|
|
554
|
+
self.authentication.remove_account(selection, complete, is_current=is_current)
|
|
555
|
+
|
|
556
|
+
def refresh_account_page(self) -> bool:
|
|
557
|
+
"""Reread the accounts into the open Settings window; the page keeps every row the user changed.
|
|
558
|
+
|
|
559
|
+
Returns False when the read failed, after showing why on the page, or when no Settings window is open.
|
|
560
|
+
"""
|
|
561
|
+
dialog = self.session_registry_controller.setting_dialog
|
|
562
|
+
if dialog is None or not isValid(dialog):
|
|
563
|
+
return False
|
|
564
|
+
page = dialog.account_page
|
|
565
|
+
try:
|
|
566
|
+
page.show_catalogue(self.authentication.catalogue(dialog.git_setting_widget.context))
|
|
567
|
+
except (OSError, ValueError, TypeError) as error:
|
|
568
|
+
page.show_message(Diagnostic("Read accounts", "", "", [], str(error)).prepare().cause, ResultTone.FAILURE)
|
|
569
|
+
return False
|
|
570
|
+
return True
|
|
571
|
+
|
|
572
|
+
def lookup_account_secrets(self, dialog: SettingDialog) -> None:
|
|
573
|
+
"""Check, in the background, which listed accounts lost their stored secret, and mark them in this Settings window when it answers.
|
|
574
|
+
|
|
575
|
+
The check never prompts, never contacts a server and writes nothing; it does not delay the opening of Settings,
|
|
576
|
+
and its answer only reaches the window that asked for it while it is still the open one.
|
|
577
|
+
"""
|
|
578
|
+
try:
|
|
579
|
+
accounts = self.authentication.catalogue().accounts
|
|
580
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
581
|
+
return
|
|
582
|
+
if not accounts:
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
def is_current() -> bool:
|
|
586
|
+
"""Accept the answer only while the Settings window that asked is still the open one."""
|
|
587
|
+
return self.session_registry_controller.setting_dialog is dialog and isValid(dialog)
|
|
588
|
+
|
|
589
|
+
self.authentication.lookup_secrets(accounts, self.show_account_status, is_current=is_current)
|
|
590
|
+
|
|
591
|
+
def show_account_status(self, lookup: SecretLookup) -> None:
|
|
592
|
+
"""Mark again, in the open Settings window, the listed accounts whose stored secret is known to be gone, and those this check found."""
|
|
593
|
+
dialog = self.session_registry_controller.setting_dialog
|
|
594
|
+
if dialog is None or not isValid(dialog):
|
|
595
|
+
return
|
|
596
|
+
dialog.account_page.show_needs_reconnect(frozenset(self.authentication.missing_secret_namespace_set), lookup.present_namespace_set)
|
|
597
|
+
|
|
598
|
+
def save_global_accounts(self) -> None:
|
|
599
|
+
"""Write only the defaults changed in the Global block; a refusal is shown in that block and its draft is kept."""
|
|
600
|
+
dialog = self.session_registry_controller.setting_dialog
|
|
601
|
+
if dialog is None or not isValid(dialog):
|
|
602
|
+
return
|
|
603
|
+
page = dialog.account_page
|
|
604
|
+
changes = page.global_changes()
|
|
605
|
+
if not changes:
|
|
606
|
+
return
|
|
607
|
+
try:
|
|
608
|
+
self.authentication.save_global_defaults(changes)
|
|
609
|
+
except (OSError, ValueError, TypeError) as error:
|
|
610
|
+
diagnostic = Diagnostic("Save global settings", "", "", [], str(error)).prepare()
|
|
611
|
+
page.show_global_error(diagnostic.cause)
|
|
612
|
+
if self.diagnostic_log is not None:
|
|
613
|
+
self.diagnostic_log.write(diagnostic)
|
|
614
|
+
return
|
|
615
|
+
page.global_saved()
|
|
616
|
+
|
|
617
|
+
def save_repository_accounts(self) -> None:
|
|
618
|
+
"""Write only the rows changed in This repository, for the repository Settings was opened for; a refusal stays in that block with its draft."""
|
|
619
|
+
dialog = self.session_registry_controller.setting_dialog
|
|
620
|
+
if dialog is None or not isValid(dialog):
|
|
621
|
+
return
|
|
622
|
+
context = dialog.git_setting_widget.context
|
|
623
|
+
page = dialog.account_page
|
|
624
|
+
changes = page.repository_changes()
|
|
625
|
+
if context is None or not changes:
|
|
626
|
+
return
|
|
627
|
+
try:
|
|
628
|
+
self.authentication.save_repository_choices(changes, context)
|
|
629
|
+
except (OSError, ValueError, TypeError) as error:
|
|
630
|
+
diagnostic = Diagnostic("Save this repository settings", context.repository_dir, "", [], str(error)).prepare()
|
|
631
|
+
page.show_repository_error(diagnostic.cause)
|
|
632
|
+
if self.diagnostic_log is not None:
|
|
633
|
+
self.diagnostic_log.write(diagnostic)
|
|
634
|
+
return
|
|
635
|
+
page.repository_saved()
|
|
636
|
+
|
|
637
|
+
def read_account_remotes(self, dialog: SettingDialog) -> None:
|
|
638
|
+
"""Read the remotes of the repository Settings was opened for, so This repository can show one row per HTTPS address.
|
|
639
|
+
|
|
640
|
+
Only reads: nothing is written to Git or to the preferences. The result reaches the page only while the same
|
|
641
|
+
Settings window is open and no newer read was started for it; an Add account or Reconnect window already open
|
|
642
|
+
from that page also learns the repository URL of each address, to test a username + password there.
|
|
643
|
+
"""
|
|
644
|
+
context = dialog.git_setting_widget.context
|
|
645
|
+
if context is None:
|
|
646
|
+
return
|
|
647
|
+
page = dialog.account_page
|
|
648
|
+
page.remote_generation += 1
|
|
649
|
+
generation = page.remote_generation
|
|
650
|
+
git_filename = self.repository.git_filename
|
|
651
|
+
|
|
652
|
+
def complete(snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
|
|
653
|
+
"""Show the remotes, or why they could not be read, in the page that asked for this very read."""
|
|
654
|
+
if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog) or not isValid(page) or generation != page.remote_generation:
|
|
655
|
+
return
|
|
656
|
+
if error is not None or snapshot is None:
|
|
657
|
+
diagnostic = Diagnostic("Read remotes", context.repository_dir, "", [], str(error)).prepare()
|
|
658
|
+
page.show_remote_error(diagnostic.cause)
|
|
659
|
+
if self.diagnostic_log is not None:
|
|
660
|
+
self.diagnostic_log.write(diagnostic)
|
|
661
|
+
return
|
|
662
|
+
entries = tuple(snapshot.entry_by_name.values())
|
|
663
|
+
page.show_remotes(entries)
|
|
664
|
+
sign_in_dialog = page.sign_in_dialog
|
|
665
|
+
if sign_in_dialog is None or not isValid(sign_in_dialog):
|
|
666
|
+
return
|
|
667
|
+
for origin, url in check_url_by_origin(entries).items():
|
|
668
|
+
sign_in_dialog.set_known_check_url(origin, url)
|
|
669
|
+
|
|
670
|
+
self.worktree_service.run(context, git_filename, self.remote.capture(context, git_filename, generation), complete)
|
|
671
|
+
|
|
672
|
+
def preference_changed_for_accounts(self, changed_value_by_key: dict[str, object]) -> None:
|
|
673
|
+
"""Show again, in an open Settings window, the accounts a change of the shared preferences touched."""
|
|
674
|
+
# A sign-in, a block save or another window may have changed the accounts; an open Settings shows them without
|
|
675
|
+
# losing what the user chose.
|
|
676
|
+
if AUTHENTICATION_PREFERENCE_KEY in changed_value_by_key:
|
|
677
|
+
self.refresh_account_page()
|