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.
Files changed (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. gitpane-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,135 @@
1
+ """Read, without changing anything, what the session bus tells of its Secret Service.
2
+
3
+ Four questions only, always in this order, and the read stops at the first answer that decides: the bus daemon is
4
+ asked whether anyone owns the Secret Service name, then which process owns it, then the service which object its
5
+ ``default`` alias names, then that object whether it is locked. The owning process is turned into a program name
6
+ through /proc, which tells which keyring answers. Asking the bus daemon first, and refusing D-Bus activation on every
7
+ message, keeps a Secret Service that does not run from being started by this read; nothing is created, aliased or
8
+ unlocked, and no secret is ever read. Each call is bounded, and the read blocks, so it belongs on a worker thread only.
9
+
10
+ Only Linux reads, through a table indexed by the system; the connection is received through a factory, so the tests
11
+ read a private bus and only ``read_session_keyring_state`` reaches the user's session bus.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from typing import TYPE_CHECKING
18
+
19
+ from PySide6.QtDBus import QDBus, QDBusConnection, QDBusMessage, QDBusObjectPath, QDBusVariant
20
+
21
+ from gitpane.account.keyring_state import NO_DEFAULT_COLLECTION_PATH, UNKNOWN_KEYRING_READING, KeyringReading, SecretServiceAnswers, keyring_reading
22
+ from gitpane.config import KEYRING_READ_TIMEOUT_MS, LINUX_PLATFORM
23
+ from gitpane.system.process_program import program_name_of_process
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable, Sequence
27
+
28
+ ConnectionFactory = Callable[[], QDBusConnection]
29
+
30
+ SECRET_SERVICE_NAME = "org.freedesktop.secrets"
31
+ SECRET_SERVICE_PATH = "/org/freedesktop/secrets"
32
+ SECRET_SERVICE_INTERFACE = "org.freedesktop.Secret.Service"
33
+ SECRET_COLLECTION_INTERFACE = "org.freedesktop.Secret.Collection"
34
+ DBUS_SERVICE_NAME = "org.freedesktop.DBus"
35
+ DBUS_PATH = "/org/freedesktop/DBus"
36
+ DBUS_INTERFACE = "org.freedesktop.DBus"
37
+ PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
38
+ NAME_HAS_OWNER_METHOD = "NameHasOwner"
39
+ GET_CONNECTION_UNIX_PROCESS_ID_METHOD = "GetConnectionUnixProcessID"
40
+ READ_ALIAS_METHOD = "ReadAlias"
41
+ GET_PROPERTY_METHOD = "Get"
42
+ DEFAULT_ALIAS = "default"
43
+ LOCKED_PROPERTY = "Locked"
44
+ # The only methods this read ever calls: each one reads, none changes the keyring or returns a secret.
45
+ READ_ONLY_METHOD_NAME_SET = frozenset({NAME_HAS_OWNER_METHOD, GET_CONNECTION_UNIX_PROCESS_ID_METHOD, READ_ALIAS_METHOD, GET_PROPERTY_METHOD})
46
+ # Each of the four replies carries exactly one value.
47
+ REPLY_ARGUMENT_COUNT = 1
48
+ REPLY_VALUE_INDEX = 0
49
+
50
+
51
+ def read_only_call(connection: QDBusConnection, service: str, path: str, interface: str, method: str, arguments: Sequence[object], timeout_ms: int) -> QDBusMessage:
52
+ """Send one method call that may not start its service, wait at most ``timeout_ms`` for the reply, and return it.
53
+
54
+ D-Bus activation is refused on the message, so a Secret Service that is installed but not running is never
55
+ started by GitPane. A reply that does not come in time comes back as an error message.
56
+ """
57
+ message = QDBusMessage.createMethodCall(service, path, interface, method)
58
+ message.setArguments(list(arguments))
59
+ message.setAutoStartService(False)
60
+ return connection.call(message, QDBus.CallMode.Block, timeout_ms)
61
+
62
+
63
+ def single_reply_value(reply: QDBusMessage) -> object:
64
+ """Return the one value a successful reply carries, or None for an error or a reply of another shape."""
65
+ if reply.type() != QDBusMessage.MessageType.ReplyMessage:
66
+ return None
67
+ values = reply.arguments()
68
+ if len(values) != REPLY_ARGUMENT_COUNT:
69
+ return None
70
+ return values[REPLY_VALUE_INDEX]
71
+
72
+
73
+ def read_secret_service_answers(connection: QDBusConnection, timeout_ms: int, read_program_name: Callable[[int], str] = program_name_of_process) -> SecretServiceAnswers:
74
+ """Ask the four questions in order and keep the answers, stopping at the first one that decides.
75
+
76
+ No owner stops before anything else is asked. The owner's process number is asked of the bus daemon and passed to
77
+ ``read_program_name``; a number that cannot be read leaves the program name empty and the read goes on, since the
78
+ state of the collection does not depend on it. An alias that names no collection, or an empty one, stops before
79
+ the property read. Each answer is kept only when it has the type the question expects (a boolean, an integer, an
80
+ object path, a variant), and the aliased path is passed on exactly as the service wrote it.
81
+ """
82
+ owner = single_reply_value(read_only_call(connection, DBUS_SERVICE_NAME, DBUS_PATH, DBUS_INTERFACE, NAME_HAS_OWNER_METHOD, (SECRET_SERVICE_NAME,), timeout_ms))
83
+ if type(owner) is not bool:
84
+ return SecretServiceAnswers()
85
+ if not owner:
86
+ return SecretServiceAnswers(owner_present=False)
87
+ pid = single_reply_value(read_only_call(connection, DBUS_SERVICE_NAME, DBUS_PATH, DBUS_INTERFACE, GET_CONNECTION_UNIX_PROCESS_ID_METHOD, (SECRET_SERVICE_NAME,), timeout_ms))
88
+ program = read_program_name(pid) if type(pid) is int else ""
89
+ alias = single_reply_value(read_only_call(connection, SECRET_SERVICE_NAME, SECRET_SERVICE_PATH, SECRET_SERVICE_INTERFACE, READ_ALIAS_METHOD, (DEFAULT_ALIAS,), timeout_ms))
90
+ if type(alias) is not QDBusObjectPath:
91
+ return SecretServiceAnswers(owner_present=True, owner_program=program)
92
+ alias_path = alias.path()
93
+ if not alias_path or alias_path == NO_DEFAULT_COLLECTION_PATH:
94
+ return SecretServiceAnswers(owner_present=True, alias_path=alias_path, owner_program=program)
95
+ locked_reply = read_only_call(connection, SECRET_SERVICE_NAME, alias_path, PROPERTIES_INTERFACE, GET_PROPERTY_METHOD, (SECRET_COLLECTION_INTERFACE, LOCKED_PROPERTY), timeout_ms)
96
+ if locked_reply.type() == QDBusMessage.MessageType.ErrorMessage:
97
+ return SecretServiceAnswers(owner_present=True, alias_path=alias_path, locked_error_name=locked_reply.errorName(), owner_program=program)
98
+ locked = single_reply_value(locked_reply)
99
+ if type(locked) is not QDBusVariant:
100
+ return SecretServiceAnswers(owner_present=True, alias_path=alias_path, owner_program=program)
101
+ return SecretServiceAnswers(owner_present=True, alias_path=alias_path, locked=locked.variant(), owner_program=program)
102
+
103
+
104
+ def read_linux_keyring_state(connect: ConnectionFactory, timeout_ms: int, read_program_name: Callable[[int], str] = program_name_of_process) -> KeyringReading:
105
+ """Read the state of the default collection and its owning program on the bus ``connect`` opens; a bus that cannot be reached is UNKNOWN, with no call."""
106
+ connection = connect()
107
+ if not connection.isConnected():
108
+ return UNKNOWN_KEYRING_READING
109
+ return keyring_reading(read_secret_service_answers(connection, timeout_ms, read_program_name))
110
+
111
+
112
+ # The systems whose keyring GitPane can read without changing it. Any other system reads as UNKNOWN without opening a
113
+ # connection: macOS and Windows keep their credentials elsewhere, and GitPane says nothing it does not know.
114
+ KEYRING_READ_BY_PLATFORM = {
115
+ LINUX_PLATFORM: read_linux_keyring_state,
116
+ }
117
+
118
+
119
+ def read_keyring_state(platform: str, connect: ConnectionFactory, timeout_ms: int = KEYRING_READ_TIMEOUT_MS) -> KeyringReading:
120
+ """Read the state of the default keyring collection on ``platform``, and the program that holds it, through the bus ``connect`` opens.
121
+
122
+ A system missing from the table gives UNKNOWN and never calls ``connect``.
123
+ """
124
+ read = KEYRING_READ_BY_PLATFORM.get(platform)
125
+ if read is None:
126
+ return UNKNOWN_KEYRING_READING
127
+ return read(connect, timeout_ms)
128
+
129
+
130
+ def read_session_keyring_state() -> KeyringReading:
131
+ """Read the state of the default keyring collection of the running session, and the program that holds it.
132
+
133
+ Blocks for up to four times the read bound when the service does not answer, so it runs only on a worker thread.
134
+ """
135
+ return read_keyring_state(sys.platform, QDBusConnection.sessionBus)
@@ -0,0 +1,158 @@
1
+ """Classify the answers of a read-only Secret Service read, without any D-Bus.
2
+
3
+ The read asks four questions in turn: whether anyone owns the Secret Service name on the bus, which process owns it,
4
+ which object the ``default`` alias names, and whether that object is locked. The answers are classified here, so every
5
+ case can be checked from any system. An alias that names an object absent from the bus, the case where the GNOME
6
+ keyring cannot store anything, is told apart from an alias that names no collection at all, where the credential
7
+ manager's library asks the desktop to create one, and both from a state GitPane could not read; any answer of an
8
+ unexpected shape stays unknown, which gives no sentence rather than a wrong one.
9
+
10
+ The program that owns the name says which keyring answers: KDE Wallet, the GNOME keyring, KeePassXC or another one. It
11
+ only chooses the words said to the user, except for one answer already seen with the GNOME keyring, which stops a
12
+ sign-in before anything is sent; the same answer from any other program lets the sign-in go.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from enum import Enum
19
+
20
+
21
+ class KeyringState(Enum):
22
+ """What a read that changes nothing can tell of the Secret Service's default collection."""
23
+
24
+ AVAILABLE = "available"
25
+ """The default collection is published and unlocked."""
26
+ LOCKED = "locked"
27
+ """The default collection is published but locked: the desktop asks for its password before anything is stored."""
28
+ UNPUBLISHED = "unpublished"
29
+ """The alias names an object absent from the bus, the defect of the very first session of a new system or user: nothing can be stored."""
30
+ NO_DEFAULT_COLLECTION = "no_default_collection"
31
+ """The alias names no collection: the credential manager's library asks the desktop to create one when it stores, so nothing is stopped."""
32
+ NO_SERVICE = "no_service"
33
+ """Nobody owns the Secret Service name on the bus, as on a desktop without the GNOME keyring."""
34
+ UNKNOWN = "unknown"
35
+ """No bus, no answer in time, an error or an answer of an unexpected shape, or a system without this read."""
36
+
37
+
38
+ # What the ``default`` alias reads when no default collection is assigned.
39
+ NO_DEFAULT_COLLECTION_PATH = "/"
40
+ # The errors a property read on the aliased object can answer when that object is absent from the bus. It is the set on
41
+ # which libsecret concludes that the collection does not exist: the GNOME keyring answers UnknownMethod, a QtDBus
42
+ # service UnknownObject, and the Secret Service specification names NoSuchObject.
43
+ UNPUBLISHED_ERROR_NAME_SET = frozenset({
44
+ "org.freedesktop.DBus.Error.UnknownMethod",
45
+ "org.freedesktop.DBus.Error.UnknownObject",
46
+ "org.freedesktop.Secret.Error.NoSuchObject",
47
+ })
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SecretServiceAnswers:
52
+ """The answers of the reads, each None when that read gave no usable answer or was not made."""
53
+
54
+ owner_present: bool | None = None
55
+ """Whether someone owns the Secret Service name, as the bus daemon answers it."""
56
+ alias_path: str | None = None
57
+ """The object path the ``default`` alias names, exactly as the service wrote it."""
58
+ locked: object = None
59
+ """The value of the ``Locked`` property of that object; only a boolean is a usable answer."""
60
+ locked_error_name: str = ""
61
+ """The D-Bus error name the ``Locked`` read answered, empty when it answered a value or was not made."""
62
+ owner_program: str = ""
63
+ """The name of the program that owns the Secret Service name, read in /proc; empty when it could not be read."""
64
+
65
+
66
+ def keyring_state(answers: SecretServiceAnswers) -> KeyringState:
67
+ """Classify the answers of a read-only Secret Service read into the state of its default collection.
68
+
69
+ Each step decides only on an answer of the expected type: the owner on a boolean, the alias on a non-empty path
70
+ compared character for character with ``/``, the lock on a boolean or on one of the errors of an absent object.
71
+ Anything else is UNKNOWN, so GitPane never says more than the bus told it.
72
+ """
73
+ if type(answers.owner_present) is not bool:
74
+ return KeyringState.UNKNOWN
75
+ if not answers.owner_present:
76
+ return KeyringState.NO_SERVICE
77
+ if not answers.alias_path:
78
+ return KeyringState.UNKNOWN
79
+ if answers.alias_path == NO_DEFAULT_COLLECTION_PATH:
80
+ return KeyringState.NO_DEFAULT_COLLECTION
81
+ if answers.locked_error_name:
82
+ if answers.locked_error_name in UNPUBLISHED_ERROR_NAME_SET:
83
+ return KeyringState.UNPUBLISHED
84
+ return KeyringState.UNKNOWN
85
+ if type(answers.locked) is not bool:
86
+ return KeyringState.UNKNOWN
87
+ if answers.locked:
88
+ return KeyringState.LOCKED
89
+ return KeyringState.AVAILABLE
90
+
91
+
92
+ class KeyringProvider(Enum):
93
+ """Which keyring answers as the Secret Service, told by the name of the program that owns the name on the bus."""
94
+
95
+ GNOME_KEYRING = "gnome_keyring"
96
+ """The GNOME keyring, ``gnome-keyring-daemon``."""
97
+ KDE_WALLET = "kde_wallet"
98
+ """KDE Wallet, through ``ksecretd`` or an older ``kwalletd``."""
99
+ KEEPASSXC = "keepassxc"
100
+ """KeePassXC with its Secret Service integration turned on."""
101
+ OTHER = "other"
102
+ """A program GitPane does not know; the sentences name it as read."""
103
+ UNREADABLE = "unreadable"
104
+ """The program could not be read, or nobody owns the name; the sentences name no program."""
105
+
106
+
107
+ # The keyring each known program name stands for. The key is the program name exactly as /proc gives it, compared as
108
+ # is: the base name of the executable, or the command name of a process whose executable link is refused.
109
+ KEYRING_PROVIDER_BY_PROGRAM = {
110
+ "ksecretd": KeyringProvider.KDE_WALLET,
111
+ "kwalletd6": KeyringProvider.KDE_WALLET,
112
+ "kwalletd5": KeyringProvider.KDE_WALLET,
113
+ "gnome-keyring-daemon": KeyringProvider.GNOME_KEYRING,
114
+ # The daemon's name as /proc/<pid>/comm gives it, cut to 15 characters: read when the kernel refuses its executable
115
+ # link, which happens where the daemon is started with a file capability. Debian 13 installs it without any, so its
116
+ # link reads and this name is not needed there; the refusal itself was seen for kwin_wayland and its cap_sys_nice.
117
+ "gnome-keyring-d": KeyringProvider.GNOME_KEYRING,
118
+ "keepassxc": KeyringProvider.KEEPASSXC,
119
+ }
120
+
121
+
122
+ def keyring_provider(program: str) -> KeyringProvider:
123
+ """Return the keyring a program name stands for: UNREADABLE for an empty name, OTHER for a name the table does not know."""
124
+ if not program:
125
+ return KeyringProvider.UNREADABLE
126
+ return KEYRING_PROVIDER_BY_PROGRAM.get(program, KeyringProvider.OTHER)
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class KeyringReading:
131
+ """What a read of the Secret Service tells: the state of its default collection and which program answered."""
132
+
133
+ state: KeyringState
134
+ """The state of the default collection."""
135
+ provider: KeyringProvider
136
+ """The keyring the owning program stands for."""
137
+ program: str
138
+ """The name of the owning program as read, empty when it could not be read."""
139
+
140
+
141
+ def keyring_reading(answers: SecretServiceAnswers) -> KeyringReading:
142
+ """Classify the answers into the state of the default collection and the keyring that answered."""
143
+ return KeyringReading(keyring_state(answers), keyring_provider(answers.owner_program), answers.owner_program)
144
+
145
+
146
+ # What a read that could not be made, or failed, tells: nothing, so no sentence and no stop.
147
+ UNKNOWN_KEYRING_READING = KeyringReading(KeyringState.UNKNOWN, KeyringProvider.UNREADABLE, "")
148
+
149
+ # The readings that stop a sign-in before anything is sent. Only an answer already seen with this owner stops it: the
150
+ # GNOME keyring's unpublished collection of the very first session, where nothing can be stored. The same answer from
151
+ # another program, or from a program that could not be read, lets the exchange go, so the credential manager's own
152
+ # output tells what happened instead of a guess.
153
+ SIGN_IN_STOPPING_READING_SET = frozenset({(KeyringProvider.GNOME_KEYRING, KeyringState.UNPUBLISHED)})
154
+
155
+
156
+ def keyring_stops_sign_in(reading: KeyringReading) -> bool:
157
+ """Tell whether this reading stops a sign-in before anything is sent to the credential manager."""
158
+ return (reading.provider, reading.state) in SIGN_IN_STOPPING_READING_SET
gitpane/account/ssh.py ADDED
@@ -0,0 +1,312 @@
1
+ """Give Git network commands a verified OpenSSH client with a private prompt channel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, replace
10
+ from typing import TYPE_CHECKING
11
+
12
+ from PySide6.QtCore import QObject, QTimer, Signal
13
+
14
+ from gitpane.account.ssh_prompt import SshPromptBridge
15
+ from gitpane.config import GIT_OPEN_TIMEOUT_MS, SYSTEM_ROOT_ENVIRONMENT_NAME, WINDOWS_PLATFORM
16
+ from gitpane.process.process_runner import LOCAL_WRITE_PROFILE, NETWORK_PROFILE, ProcessRequest, ProcessResult, ProcessRunner, ProcessStatus
17
+ from gitpane.system.network_folder import network_directory
18
+ from gitpane.tool.git_executable import GIT_BINARY_DIR_BASENAME, GIT_SHELL_DIR_BASENAME, PATH_ENVIRONMENT_NAME, git_root_dirs
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Mapping
22
+
23
+ from gitpane.history.repository_value import RepositoryContext
24
+ from gitpane.operation.repository_operation import RepositoryOperationService
25
+ from gitpane.preference.preference_store import PreferenceStore
26
+
27
+
28
+ SSH_UNAVAILABLE_TEXT = "SSH client is unavailable. Choose an executable in Settings and verify it."
29
+ # OpenSSH option that turns every question (host key, passphrase, password) into a failure: a background read must never show one.
30
+ SSH_BATCH_MODE_OPTION = "-o BatchMode=yes"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SshConfiguration:
35
+ """Keep an exact executable and optional Windows utility directory per launch."""
36
+
37
+ ssh_filename: str = ""
38
+ utility_dir: str = ""
39
+ variant: str = "ssh"
40
+
41
+ def validate(self, *, windows: bool | None = None, optional: bool = False) -> None:
42
+ """Reject shell expressions and unavailable tools before launching native SSH."""
43
+ windows = os.name == "nt" if windows is None else windows
44
+ if not self.ssh_filename:
45
+ if not optional:
46
+ raise ValueError(SSH_UNAVAILABLE_TEXT)
47
+ elif not os.path.isabs(self.ssh_filename) or not os.path.isfile(self.ssh_filename):
48
+ raise ValueError("SSH executable was not found. Choose an existing absolute filename in Settings.")
49
+ elif not os.access(self.ssh_filename, os.X_OK) or (windows and os.path.splitext(self.ssh_filename)[1].lower() != ".exe"):
50
+ raise ValueError("SSH client is not an executable file. Choose an OpenSSH executable in Settings.")
51
+ if self.variant != "ssh":
52
+ raise ValueError("Only verified OpenSSH clients are supported.")
53
+ if windows and self.utility_dir and (not os.path.isabs(self.utility_dir) or not os.path.isdir(self.utility_dir)):
54
+ raise ValueError("Git utilities directory (Windows) must be an existing absolute directory.")
55
+
56
+
57
+ SSH_WINDOWS_BASENAME = "ssh.exe"
58
+ SSH_BASENAME = "ssh"
59
+ # Where Windows installs its own OpenSSH client, below the Windows folder.
60
+ WINDOWS_OPENSSH_DIR_PARTS = ("System32", "OpenSSH")
61
+ GIT_FOR_WINDOWS_SOURCE_TEXT = "Git for Windows"
62
+ WINDOWS_OPENSSH_SOURCE_TEXT = "Windows OpenSSH"
63
+ PATH_SOURCE_TEXT = "PATH"
64
+ SAVED_SOURCE_TEXT = "Application settings"
65
+ DETECTED_SOURCE_PREFIX = "Detected: "
66
+ NO_SSH_SOURCE_TEXT = "No SSH client found"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SshDetection:
71
+ """Name the OpenSSH client found on this machine and where it was found; both are empty when none was found."""
72
+
73
+ filename: str = ""
74
+ source: str = ""
75
+
76
+
77
+ def absolute_executable_available(filename: str) -> bool:
78
+ """Tell whether a candidate is an existing executable file given by an absolute name.
79
+
80
+ A relative name is refused because it would depend on the folder GitPane happens to run in.
81
+ """
82
+ return os.path.isabs(filename) and os.path.isfile(filename) and os.access(filename, os.X_OK)
83
+
84
+
85
+ def no_ssh_detection() -> SshDetection:
86
+ """Report that no client was found, for a service that is given no way to search."""
87
+ return SshDetection()
88
+
89
+
90
+ def detect_ssh_client(git_filename: str, platform: str, environment_by_name: Mapping[str, str],
91
+ available: Callable[[str], bool] = absolute_executable_available, network: Callable[[str], bool] = network_directory) -> SshDetection:
92
+ """Find the OpenSSH client used while none is saved: the one of the Git in use, then Windows OpenSSH, then the PATH.
93
+
94
+ On Windows, the ssh.exe of Git for Windows sits in usr/bin below the installation root, one or two folders above
95
+ git.exe. Linux and macOS only look at the PATH. Nothing is started and nothing is saved.
96
+
97
+ The search runs on the window's thread the first time an operation needs the client, so on Windows a PATH entry on
98
+ a network share is skipped: a disconnected share would freeze the window. A client kept on a share can still be
99
+ chosen in Settings.
100
+ """
101
+ candidates: list[tuple[str, str]] = []
102
+ windows = platform == WINDOWS_PLATFORM
103
+ if windows:
104
+ for root_dir in git_root_dirs(git_filename):
105
+ candidates.append((os.path.join(root_dir, GIT_SHELL_DIR_BASENAME, GIT_BINARY_DIR_BASENAME, SSH_WINDOWS_BASENAME), GIT_FOR_WINDOWS_SOURCE_TEXT))
106
+ system_root_dir = environment_by_name.get(SYSTEM_ROOT_ENVIRONMENT_NAME, "")
107
+ if system_root_dir:
108
+ candidates.append((os.path.join(system_root_dir, *WINDOWS_OPENSSH_DIR_PARTS, SSH_WINDOWS_BASENAME), WINDOWS_OPENSSH_SOURCE_TEXT))
109
+ # The PATH is read here instead of with shutil.which, which on Windows looks in the current folder first: a
110
+ # client dropped there, or in a relative PATH entry, must never be launched for every network operation.
111
+ for path_dir in environment_by_name.get(PATH_ENVIRONMENT_NAME, "").split(os.pathsep):
112
+ if os.path.isabs(path_dir) and not (windows and network(path_dir)):
113
+ candidates.append((os.path.join(path_dir, SSH_WINDOWS_BASENAME if windows else SSH_BASENAME), PATH_SOURCE_TEXT))
114
+ for filename, source in candidates:
115
+ if available(filename):
116
+ return SshDetection(os.path.abspath(filename), source)
117
+ return SshDetection()
118
+
119
+
120
+ class SshClientFinder:
121
+ """Find the OpenSSH client used while none is saved, once per Git executable for this launch.
122
+
123
+ The search only looks at files, but operations ask for it every time they start; keeping the answer per Git
124
+ executable means a Git changed in Settings is searched again. The answer is never saved in the preferences.
125
+ """
126
+
127
+ def __init__(self, current_git_filename: Callable[[], str], platform: str, environment_by_name: Mapping[str, str], *, log_line: Callable[[str], None]) -> None:
128
+ """Take the Git in use, read at each search, and the line writer of the --log file, which ignores lines when there is none."""
129
+ self.current_git_filename = current_git_filename
130
+ self.platform = platform
131
+ self.environment_by_name = environment_by_name
132
+ self.log_line = log_line
133
+ # The OpenSSH client found on this machine, per Git executable, used while none is saved.
134
+ self.ssh_detection_by_git_filename: dict[str, SshDetection] = {}
135
+
136
+ def find(self) -> SshDetection:
137
+ """Answer the client of the Git in use, searching the machine only the first time this Git asks."""
138
+ git_filename = self.current_git_filename()
139
+ detection = self.ssh_detection_by_git_filename.get(git_filename)
140
+ if detection is not None:
141
+ return detection
142
+ detection = detect_ssh_client(git_filename, self.platform, self.environment_by_name)
143
+ self.ssh_detection_by_git_filename[git_filename] = detection
144
+ self.log_line("SSH client detected: " + (detection.filename + " (" + detection.source + ")" if detection.filename else "none"))
145
+ return detection
146
+
147
+
148
+ def effective_source(saved: SshConfiguration, detection: SshDetection) -> str:
149
+ """Say where the client used by operations comes from: the saved setting, a detection, or nowhere."""
150
+ if saved.ssh_filename:
151
+ return SAVED_SOURCE_TEXT
152
+ if detection.filename:
153
+ return DETECTED_SOURCE_PREFIX + detection.source
154
+ return NO_SSH_SOURCE_TEXT
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class SshVerification:
159
+ """Report the captured candidate and native version without publishing a draft."""
160
+
161
+ configuration: SshConfiguration
162
+ version: str = ""
163
+ cause: str = ""
164
+
165
+ @property
166
+ def succeeded(self) -> bool:
167
+ """Require an identified OpenSSH version before a candidate can be saved."""
168
+ return bool(self.version) and not self.cause
169
+
170
+
171
+ class SshService(QObject):
172
+ """Verify tools asynchronously and provide isolated network environment values."""
173
+
174
+ question_requested = Signal(object)
175
+ question_closed = Signal(str)
176
+
177
+ def __init__(self, preference: PreferenceStore, runner: ProcessRunner, parent: QObject | None = None, *, repository_operation: RepositoryOperationService | None = None,
178
+ detect_client: Callable[[], SshDetection] = no_ssh_detection) -> None:
179
+ """Reuse the application process owner so verification drains during shutdown.
180
+
181
+ detect_client names the client to use while none is saved; the application gives one that searches the machine.
182
+ """
183
+ # Retain the runner in Python without making it an implicit Qt owner: releasing
184
+ # its last reference during deferred service deletion must not delete this child again.
185
+ super().__init__(parent)
186
+ self.preference = preference
187
+ self.runner = runner
188
+ self.repository_operation = repository_operation
189
+ self.detect_client = detect_client
190
+ self.bridge_by_id: dict[str, SshPromptBridge] = {}
191
+ self.verification_by_id: dict[str, tuple[SshConfiguration, Callable[[SshVerification], None], bytearray]] = {}
192
+ runner.stdout_chunk.connect(self._stdout)
193
+ runner.finished.connect(self._finished)
194
+ runner.stop_requested.connect(self._stopping)
195
+
196
+ def prompt(self, request: ProcessRequest, *, context: RepositoryContext | None = None, generation: int = 0,
197
+ is_current: Callable[[], bool] | None = None) -> ProcessRequest:
198
+ """Attach a private prompt owner to a captured network command before it is started."""
199
+ if request.profile != NETWORK_PROFILE or not request.operation_id or request.request_id in self.bridge_by_id:
200
+ raise ValueError("SSH prompting requires a unique supervised network operation")
201
+ if context is not None:
202
+ owner = self.repository_operation.active_by_id.get(request.operation_id) if self.repository_operation is not None else None
203
+ if owner is None or owner.context != context or owner.generation != generation or owner.session_id != request.session_id:
204
+ raise ValueError("SSH operation does not own the captured repository session")
205
+ try:
206
+ bridge = SshPromptBridge(request, self.runner, is_current or (lambda: True), self)
207
+ except OSError as error:
208
+ raise OSError("SSH prompt helper could not be prepared. Check temporary-directory permissions or repair the GitPane installation.") from error
209
+ self.bridge_by_id[request.request_id] = bridge
210
+ bridge.question_requested.connect(self.question_requested)
211
+ bridge.dismissed.connect(lambda: self.question_closed.emit(request.operation_id))
212
+ environment_by_name = dict(request.environment_by_name)
213
+ environment_by_name.update(bridge.environment())
214
+ return replace(request, environment_by_name=environment_by_name)
215
+
216
+ def close(self) -> None:
217
+ """Refuse questions while retaining each bridge until its process has drained."""
218
+ for request_id, bridge in tuple(self.bridge_by_id.items()):
219
+ bridge.refuse()
220
+ if request_id in self.runner.active_by_id or any(request.request_id == request_id for request in self.runner.pending_requests):
221
+ self.runner.cancel(request_id)
222
+ else:
223
+ self.bridge_by_id.pop(request_id, None)
224
+ bridge.close()
225
+
226
+ def _stopping(self, request: ProcessRequest, cause: str) -> None:
227
+ """Dismiss pending questions when their captured command begins stopping."""
228
+ bridge = self.bridge_by_id.get(request.request_id)
229
+ if bridge is not None:
230
+ bridge.refuse()
231
+
232
+ def saved_configuration(self) -> SshConfiguration:
233
+ """Read only accepted preferences: what the Settings form shows and what its Save may write back."""
234
+ self.preference.refresh()
235
+ return SshConfiguration(str(self.preference.value_by_key["ssh_filename"]), str(self.preference.value_by_key["git_utility_dir"]))
236
+
237
+ def configuration(self) -> SshConfiguration:
238
+ """Give the tools a new operation captures: the saved client, or the detected one while none is saved.
239
+
240
+ The detected client is never written to the preferences, so a later installation is found again.
241
+ """
242
+ saved = self.saved_configuration()
243
+ if saved.ssh_filename:
244
+ return saved
245
+ return replace(saved, ssh_filename=self.detection().filename)
246
+
247
+ def detection(self) -> SshDetection:
248
+ """Report the client found on this machine, whether or not one is saved."""
249
+ return self.detect_client()
250
+
251
+ def verify(self, configuration: SshConfiguration, complete: Callable[[SshVerification], None]) -> str:
252
+ """Identify the selected executable with a bounded native version request."""
253
+ request_id = uuid.uuid4().hex
254
+ try:
255
+ configuration.validate()
256
+ except ValueError as error:
257
+ QTimer.singleShot(0, self, lambda cause=str(error): complete(SshVerification(configuration, cause=cause)))
258
+ return request_id
259
+ self.verification_by_id[request_id] = (configuration, complete, bytearray())
260
+ self.runner.start(ProcessRequest(request_id, configuration.ssh_filename, ["-V"], os.path.dirname(configuration.ssh_filename), GIT_OPEN_TIMEOUT_MS,
261
+ operation="Verify SSH client", profile=LOCAL_WRITE_PROFILE, operation_id=request_id))
262
+ return request_id
263
+
264
+ def process_environment(self, configuration: SshConfiguration | None = None, *, parent_path: str | None = None, windows: bool | None = None) -> dict[str, str]:
265
+ """Preserve native Host, identity and agent settings without editing the parent PATH."""
266
+ configuration = configuration or self.configuration()
267
+ configuration.validate(windows=windows)
268
+ environment_by_name = {"GIT_SSH": configuration.ssh_filename, "GIT_SSH_VARIANT": configuration.variant}
269
+ windows = os.name == "nt" if windows is None else windows
270
+ if windows and configuration.utility_dir:
271
+ environment_by_name[PATH_ENVIRONMENT_NAME] = os.pathsep.join((configuration.utility_dir, os.environ.get(PATH_ENVIRONMENT_NAME, "") if parent_path is None else parent_path))
272
+ return environment_by_name
273
+
274
+ def command_configuration(self, configuration: SshConfiguration | None = None, *, batch_mode: bool = False) -> tuple[list[str], dict[str, str]]:
275
+ """Keep a repository's core.sshCommand from silently replacing the selected executable.
276
+
277
+ With ``batch_mode`` OpenSSH never asks anything: an unknown host key or a key protected by a passphrase makes
278
+ the command fail instead. Work the user did not start explicitly, such as reading branches while a URL is typed,
279
+ uses it.
280
+ """
281
+ configuration = configuration or self.configuration()
282
+ environment_by_name = self.process_environment(configuration)
283
+ # Git's core.sshCommand precedes GIT_SSH. Quote only the verified filename, never user-supplied command text.
284
+ ssh_command = shlex.quote(configuration.ssh_filename.replace("\\", "/") if os.name == "nt" else configuration.ssh_filename)
285
+ if batch_mode:
286
+ ssh_command += " " + SSH_BATCH_MODE_OPTION
287
+ arguments = ["-c", "core.sshCommand=" + ssh_command]
288
+ return arguments, environment_by_name
289
+
290
+ def _stdout(self, request: ProcessRequest, data: bytes) -> None:
291
+ """Collect version output only for the matching executable verification."""
292
+ pending = self.verification_by_id.get(request.request_id)
293
+ if pending is not None:
294
+ _, _, output = pending
295
+ output.extend(data)
296
+
297
+ def _finished(self, result: ProcessResult) -> None:
298
+ """Release drained prompt resources and publish the captured tool verification."""
299
+ bridge = self.bridge_by_id.pop(result.request.request_id, None)
300
+ if bridge is not None:
301
+ bridge.close()
302
+ pending = self.verification_by_id.pop(result.request.request_id, None)
303
+ if pending is None:
304
+ return
305
+ configuration, complete, output = pending
306
+ version = (bytes(output) + result.stderr).decode("utf-8", "replace").strip()
307
+ if result.status != ProcessStatus.SUCCESS:
308
+ complete(SshVerification(configuration, cause=result.cause or "SSH verification failed. Choose another executable in Settings."))
309
+ elif not version.startswith("OpenSSH_"):
310
+ complete(SshVerification(configuration, cause="This executable did not identify itself as OpenSSH. Choose an OpenSSH client in Settings."))
311
+ else:
312
+ complete(SshVerification(configuration, version=version))