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,536 @@
1
+ """Put the shortcuts that start the installed GitPane where the user's system lists them, in the form each system reads.
2
+
3
+ pip and pipx cannot run anything at install time, so GitPane writes its own shortcut once it is installed. The
4
+ shortcut starts the gitpane command of the running installation or, when it has none, the running Python with ``-P
5
+ -m`` and the application module; never with ``--log``. Writing it again rewrites the same file with the current paths,
6
+ for example after a Python upgrade moved the launcher; nothing else on the desktop is ever touched or deleted. A desktop
7
+ folder that is the home folder itself counts as no desktop folder, so nothing is written among the user's files. On Linux the same click also writes the same entry into the user's applications menu,
8
+ where the desktop looks up the name and the icon of a running GitPane, whether the desktop folder exists or not. Each
9
+ file gets its own line in the report, so a file written is never announced as failed because of the other one.
10
+
11
+ On macOS the shortcut is a small application bundle whose program is a sh script that hands its process over to the
12
+ command. No process watches the bundle afterwards, so the Dock may name the running GitPane after Python rather than
13
+ after the bundle; that is something to check on a Mac. On Windows a ``.lnk`` file is made from scratch by PowerShell
14
+ through the WScript.Shell object, the paths and the arguments travelling only in environment variables.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib.resources
20
+ import ntpath
21
+ import os
22
+ import plistlib
23
+ import shlex
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from dataclasses import dataclass
28
+ from enum import Enum
29
+ from typing import TYPE_CHECKING
30
+
31
+ from PySide6.QtCore import QStandardPaths
32
+
33
+ from gitpane.config import (
34
+ APPLICATION_ICON_ICO_BASENAME,
35
+ APPLICATION_NAME,
36
+ DESKTOP_SHORTCUT_HELPER_TIMEOUT_MS,
37
+ LINUX_PLATFORM,
38
+ MACOS_BUNDLE_IDENTIFIER,
39
+ MACOS_PLATFORM,
40
+ MILLISECONDS_PER_SECOND,
41
+ SYSTEM_ROOT_ENVIRONMENT_NAME,
42
+ UI_RESOURCE_DIR_NAME,
43
+ UI_RESOURCE_PACKAGE_NAME,
44
+ WINDOWS_PLATFORM,
45
+ application_icon_png_basename,
46
+ )
47
+ from gitpane.system.icns_image import ICNS_256_PNG_TYPE, icns_bytes
48
+ from gitpane.system.installed_launcher import StartCommand, command_line, installation_script_dirs, running_in_virtual_environment, start_command, start_command_line
49
+ from gitpane.system.path_identity import canonical_path
50
+ from gitpane.system.qt_folder import native_folder_path
51
+ from gitpane.tool.app_bundle import BUNDLE_EXECUTABLE_KEY, BUNDLE_IDENTIFIER_KEY, BUNDLE_SUFFIX, CONTENTS_DIR_BASENAME, INFO_PLIST_BASENAME, MACOS_DIR_BASENAME
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Callable, Mapping, Sequence
55
+
56
+ ShortcutRun = Callable[..., subprocess.CompletedProcess[bytes]]
57
+
58
+ # The largest packaged picture of the logo: desktops scale it down, never up.
59
+ SHORTCUT_ICON_SIDE = 256
60
+ DESKTOP_ENTRY_EXTENSION = ".desktop"
61
+ DESKTOP_ENTRY_COMMENT = "Browse Git repositories"
62
+ DESKTOP_ENTRY_CATEGORIES = "Development;RevisionControl;"
63
+ # Desktops only start a launcher file its owner may run; nobody else may change it.
64
+ SHORTCUT_PROGRAM_MODE = 0o755
65
+ # The entry of the applications menu is read by the desktop, never run: everyone may read it, only its owner change it.
66
+ APPLICATION_ENTRY_MODE = 0o644
67
+ SHORTCUT_LINE_END = "\n"
68
+ SHORTCUT_TEXT_ENCODING = "utf-8"
69
+ # Names the temporary file an entry is written to before it takes the entry's name, in the same folder. Its suffix is
70
+ # not the entry's: a file left by a GitPane killed during the write must not be listed by the menu as a second GitPane.
71
+ DESKTOP_ENTRY_TEMPORARY_PREFIX = "." + APPLICATION_NAME + "-"
72
+ DESKTOP_ENTRY_TEMPORARY_SUFFIX = ".tmp"
73
+ GIO_FILENAME = "gio"
74
+ GIO_TRUSTED_ATTRIBUTE = "metadata::trusted"
75
+ GIO_TRUSTED_VALUE = "true"
76
+ # Characters the Desktop Entry specification reserves inside a quoted Exec argument, each escaped with a backslash.
77
+ # The backslash itself comes first, so the backslashes added for the others are not escaped twice.
78
+ EXEC_RESERVED_CHARACTERS = ("\\", '"', "`", "$")
79
+ EXEC_QUOTE = '"'
80
+ EXEC_FIELD_CODE_MARK = "%"
81
+ STRING_ESCAPE_CHARACTER = "\\"
82
+ # Below this code point, and at DELETE, a character is a control character no desktop entry value may hold.
83
+ FIRST_PRINTABLE_CODE_POINT = 0x20
84
+ DELETE_CODE_POINT = 0x7F
85
+ # The macOS bundle: its resources folder, the icon file's extension, and the Info.plist keys app_bundle does not read.
86
+ RESOURCES_DIR_BASENAME = "Resources"
87
+ ICNS_EXTENSION = ".icns"
88
+ BUNDLE_NAME_KEY = "CFBundleName"
89
+ BUNDLE_DISPLAY_NAME_KEY = "CFBundleDisplayName"
90
+ BUNDLE_ICON_FILE_KEY = "CFBundleIconFile"
91
+ BUNDLE_PACKAGE_TYPE_KEY = "CFBundlePackageType"
92
+ BUNDLE_APPLICATION_PACKAGE_TYPE = "APPL"
93
+ BUNDLE_INFO_DICTIONARY_VERSION_KEY = "CFBundleInfoDictionaryVersion"
94
+ BUNDLE_INFO_DICTIONARY_VERSION = "6.0"
95
+ HIGH_RESOLUTION_CAPABLE_KEY = "NSHighResolutionCapable"
96
+ BUNDLE_SCRIPT_SHELL_LINE = "#!/bin/sh"
97
+ # The Windows shortcut: its extension, where PowerShell lives under the Windows folder, and the variables that carry the paths.
98
+ WINDOWS_SHORTCUT_EXTENSION = ".lnk"
99
+ WINDOWS_POWERSHELL_PATH_PARTS = ("System32", "WindowsPowerShell", "v1.0", "powershell.exe")
100
+ WINDOWS_POWERSHELL_OPTIONS = ("-NoProfile", "-NonInteractive", "-Command")
101
+ WINDOWS_SHORTCUT_FILENAME_VARIABLE = "GITPANE_SHORTCUT_FILENAME"
102
+ WINDOWS_SHORTCUT_TARGET_VARIABLE = "GITPANE_SHORTCUT_TARGET"
103
+ WINDOWS_SHORTCUT_ICON_VARIABLE = "GITPANE_SHORTCUT_ICON"
104
+ WINDOWS_SHORTCUT_ARGUMENTS_VARIABLE = "GITPANE_SHORTCUT_ARGUMENTS"
105
+ # The paths and the arguments travel only in the environment, so PowerShell never parses them as part of its script: a
106
+ # quote, a dollar or a semicolon in a folder name stays plain text. [string] turns a variable Windows dropped because it
107
+ # was empty (the gitpane command takes no argument) into an empty Arguments field. The icon index 0 is the first
108
+ # picture of the .ico file.
109
+ WINDOWS_SHORTCUT_SCRIPT = "; ".join([
110
+ "$ErrorActionPreference = 'Stop'",
111
+ "$shell = New-Object -ComObject WScript.Shell",
112
+ "$shortcut = $shell.CreateShortcut($env:" + WINDOWS_SHORTCUT_FILENAME_VARIABLE + ")",
113
+ "$shortcut.TargetPath = $env:" + WINDOWS_SHORTCUT_TARGET_VARIABLE,
114
+ "$shortcut.Arguments = [string]$env:" + WINDOWS_SHORTCUT_ARGUMENTS_VARIABLE,
115
+ "$shortcut.IconLocation = $env:" + WINDOWS_SHORTCUT_ICON_VARIABLE + " + ',0'",
116
+ "$shortcut.Save()",
117
+ ])
118
+ POWERSHELL_EXIT_TEMPLATE = "PowerShell exited with code {code}"
119
+ NO_APPLICATIONS_FOLDER_DETAIL = "the system names no applications folder"
120
+
121
+
122
+ class ShortcutOutcomeKind(Enum):
123
+ """What a result line says of its file, without any colour: the colour belongs to the interface."""
124
+
125
+ WRITTEN = "written"
126
+ NOT_POSSIBLE = "not possible"
127
+ FAILED = "failed"
128
+
129
+
130
+ class ShortcutStatus(Enum):
131
+ """How the write of one shortcut file ended: each value pairs the line Settings shows, ``{detail}`` filled by the outcome, with what it says of the file."""
132
+
133
+ CREATED = ("Desktop shortcut created: {detail}", ShortcutOutcomeKind.WRITTEN)
134
+ CREATED_NOT_SHOWN = ("As stated by the note below, {detail}: GitPane is added to the searchable applications list, and its desktop icon will appear once the steps are done.",
135
+ ShortcutOutcomeKind.NOT_POSSIBLE)
136
+ """The shortcut is written and marked trusted, so its icon appears as soon as the desktop can show it, without a new click.
137
+
138
+ The desktop cannot show it yet; ``{detail}`` says why. Used when the applications entry of the same report was
139
+ added, since the line says so. This module never decides this status: the writer reports CREATED, and the decision
140
+ about the desktop replaces it.
141
+ """
142
+ CREATED_NOT_SHOWN_ENTRY_FAILED = ("As stated by the note below, {detail}: GitPane's desktop icon will appear once the steps are done.", ShortcutOutcomeKind.NOT_POSSIBLE)
143
+ """The same shortcut written and marked trusted, when the applications entry of the same click could not be written.
144
+
145
+ The line says nothing of the applications list, whose red line follows it. This module never decides this status
146
+ either: the decision about the desktop picks it from the entry's line of the same report.
147
+ """
148
+ NO_DESKTOP = ("Desktop folder not found: {detail}", ShortcutOutcomeKind.NOT_POSSIBLE)
149
+ NO_DESKTOP_NAMED = ("The system names no desktop folder", ShortcutOutcomeKind.NOT_POSSIBLE)
150
+ UNSUPPORTED_SYSTEM = ("No desktop shortcut on this system: {detail}", ShortcutOutcomeKind.FAILED)
151
+ WRITE_FAILED = ("Could not create the desktop shortcut: {detail}", ShortcutOutcomeKind.FAILED)
152
+ NO_START_COMMAND = ("Cannot tell which program starts this GitPane, so no shortcut was written", ShortcutOutcomeKind.FAILED)
153
+ APPLICATION_ENTRY_ADDED = ("Added to the applications list: {detail}", ShortcutOutcomeKind.WRITTEN)
154
+ APPLICATION_ENTRY_FAILED = ("Could not add GitPane to the applications list: {detail}", ShortcutOutcomeKind.FAILED)
155
+
156
+ @property
157
+ def text(self) -> str:
158
+ """Return the line Settings shows, before ``{detail}`` is filled."""
159
+ text, _outcome_kind = self.value
160
+ return text
161
+
162
+ @property
163
+ def outcome_kind(self) -> ShortcutOutcomeKind:
164
+ """Return whether the file was written, could not be, or failed."""
165
+ _text, outcome_kind = self.value
166
+ return outcome_kind
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class ShortcutOutcome:
171
+ """The end of the write of one shortcut file, with the path or the reason its line names."""
172
+
173
+ status: ShortcutStatus
174
+ detail: str = ""
175
+
176
+ def line(self) -> str:
177
+ """Return the one line Settings shows for this file."""
178
+ return self.status.text.format(detail=self.detail)
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class ShortcutReport:
183
+ """One line per file written or tried, the desktop shortcut first, then the command line the shortcut starts.
184
+
185
+ ``start_command_line`` is empty when nothing was written for want of a shortcut form or of a command to start.
186
+ """
187
+
188
+ file_outcomes: tuple[ShortcutOutcome, ...]
189
+ start_command_line: str = ""
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class ShortcutForm:
194
+ """What a system reads as a desktop shortcut: the name it has on the desktop, the function that writes it, and the one that writes its applications menu entry.
195
+
196
+ ``write`` receives the shortcut's path, the command it starts, the helper runner and the environment;
197
+ ``application_entry_write`` receives the entry's path and the command, and is None on a system whose menu GitPane
198
+ does not write.
199
+ """
200
+
201
+ basename: str
202
+ write: Callable[[str, StartCommand, ShortcutRun, Mapping[str, str]], None]
203
+ application_entry_write: Callable[[str, StartCommand], None] | None = None
204
+
205
+
206
+ def _refuse_control_characters(text: str) -> None:
207
+ """Raise ValueError when ``text`` holds a line break or another control character, which no desktop entry value may hold."""
208
+ for character in text:
209
+ code_point = ord(character)
210
+ if code_point < FIRST_PRINTABLE_CODE_POINT or code_point == DELETE_CODE_POINT:
211
+ raise ValueError("The text holds a control character: " + repr(text))
212
+
213
+
214
+ def desktop_entry_string_value(text: str) -> str:
215
+ """Write ``text`` as a desktop entry string value: backslashes doubled, control characters refused with ValueError."""
216
+ _refuse_control_characters(text)
217
+ return text.replace(STRING_ESCAPE_CHARACTER, STRING_ESCAPE_CHARACTER + STRING_ESCAPE_CHARACTER)
218
+
219
+
220
+ def quoted_exec_argument(argument: str) -> str:
221
+ """Quote one argument of an Exec key whole, so no desktop splits or expands it; the string escape of the key is left to the caller.
222
+
223
+ The Desktop Entry specification escapes ``\\ " ` $`` inside the quotes with a backslash; ``%`` starts a field code,
224
+ so a literal one is doubled. An argument with a line break or another control character raises ValueError.
225
+ """
226
+ _refuse_control_characters(argument)
227
+ quoted = argument
228
+ for character in EXEC_RESERVED_CHARACTERS:
229
+ quoted = quoted.replace(character, STRING_ESCAPE_CHARACTER + character)
230
+ quoted = EXEC_QUOTE + quoted + EXEC_QUOTE
231
+ return quoted.replace(EXEC_FIELD_CODE_MARK, EXEC_FIELD_CODE_MARK + EXEC_FIELD_CODE_MARK)
232
+
233
+
234
+ def desktop_entry_exec_value(arguments: Sequence[str]) -> str:
235
+ """Write the program and its arguments as the value of an Exec key, each one quoted whole, separated by a space.
236
+
237
+ Exec is also a string value, whose own escape a desktop undoes before it reads the quoting. That escape is applied
238
+ once, to the whole joined line: applied to each argument and then to the line, every backslash would be doubled
239
+ twice. A literal backslash of a path therefore takes four.
240
+ """
241
+ quoted_arguments = []
242
+ for argument in arguments:
243
+ quoted_arguments.append(quoted_exec_argument(argument))
244
+ return desktop_entry_string_value(" ".join(quoted_arguments))
245
+
246
+
247
+ def desktop_entry_text(command: StartCommand, icon_filename: str) -> str:
248
+ """Build the desktop entry that starts ``command`` with the packaged logo, grouped under GitPane's own window class."""
249
+ entry_lines = [
250
+ "[Desktop Entry]",
251
+ "Type=Application",
252
+ "Name=" + desktop_entry_string_value(APPLICATION_NAME),
253
+ "Comment=" + desktop_entry_string_value(DESKTOP_ENTRY_COMMENT),
254
+ "Exec=" + desktop_entry_exec_value((command.program_filename, *command.arguments)),
255
+ "Icon=" + desktop_entry_string_value(icon_filename),
256
+ "Terminal=false",
257
+ "Categories=" + DESKTOP_ENTRY_CATEGORIES,
258
+ "StartupWMClass=" + desktop_entry_string_value(APPLICATION_NAME),
259
+ ]
260
+ return SHORTCUT_LINE_END.join(entry_lines) + SHORTCUT_LINE_END
261
+
262
+
263
+ def packaged_resource_filename(basename: str) -> str:
264
+ """Return the path of a data file GitPane ships in its resource folder, read without importing any interface code."""
265
+ return str(importlib.resources.files(UI_RESOURCE_PACKAGE_NAME).joinpath(UI_RESOURCE_DIR_NAME, basename))
266
+
267
+
268
+ def packaged_resource_bytes(basename: str) -> bytes:
269
+ """Read a data file GitPane ships in its resource folder."""
270
+ return importlib.resources.files(UI_RESOURCE_PACKAGE_NAME).joinpath(UI_RESOURCE_DIR_NAME, basename).read_bytes()
271
+
272
+
273
+ def helper_timeout_seconds() -> float:
274
+ """Return the bound of a helper program the shortcut runs, in the seconds ``subprocess`` counts in."""
275
+ return DESKTOP_SHORTCUT_HELPER_TIMEOUT_MS / MILLISECONDS_PER_SECOND
276
+
277
+
278
+ def write_desktop_entry(entry_filename: str, text: str, mode: int) -> None:
279
+ """Write ``text`` as the whole desktop entry at ``entry_filename``, with ``mode``, replacing what was there.
280
+
281
+ The text goes to a temporary file of the same folder, which then takes the entry's name: a symbolic link left at
282
+ that name is replaced, never followed, so the write cannot land in another file of the user. A failed write
283
+ removes the temporary file and raises OSError.
284
+ """
285
+ entry_dir = os.path.dirname(entry_filename)
286
+ descriptor, temporary_filename = tempfile.mkstemp(dir=entry_dir, prefix=DESKTOP_ENTRY_TEMPORARY_PREFIX, suffix=DESKTOP_ENTRY_TEMPORARY_SUFFIX)
287
+ try:
288
+ with os.fdopen(descriptor, "w", encoding=SHORTCUT_TEXT_ENCODING, newline=SHORTCUT_LINE_END) as stream:
289
+ stream.write(text)
290
+ os.chmod(temporary_filename, mode)
291
+ os.replace(temporary_filename, entry_filename)
292
+ except BaseException:
293
+ if os.path.lexists(temporary_filename):
294
+ os.remove(temporary_filename)
295
+ raise
296
+
297
+
298
+ def linux_desktop_entry_text(command: StartCommand) -> str:
299
+ """Build the desktop entry of ``command`` with the largest packaged logo, the one text of the desktop shortcut and of the applications entry."""
300
+ return desktop_entry_text(command, packaged_resource_filename(application_icon_png_basename(SHORTCUT_ICON_SIDE)))
301
+
302
+
303
+ def write_linux_shortcut(shortcut_filename: str, command: StartCommand, run: ShortcutRun, environment_by_name: Mapping[str, str]) -> None:
304
+ """Write the desktop entry, let its owner run it, then ask gio to mark it trusted.
305
+
306
+ The environment is not needed here: gio inherits GitPane's own, which holds the session bus it talks to.
307
+ """
308
+ write_desktop_entry(shortcut_filename, linux_desktop_entry_text(command), SHORTCUT_PROGRAM_MODE)
309
+ # GNOME's desktop icons only start a launcher marked trusted, other desktops ignore the mark, and a desktop without
310
+ # gio simply has no such mark: whatever gio answers, the shortcut is written.
311
+ try:
312
+ run([GIO_FILENAME, "set", shortcut_filename, GIO_TRUSTED_ATTRIBUTE, GIO_TRUSTED_VALUE], shell=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
313
+ stderr=subprocess.DEVNULL, check=False, timeout=helper_timeout_seconds())
314
+ except (OSError, subprocess.SubprocessError):
315
+ pass
316
+
317
+
318
+ def write_linux_application_entry(entry_filename: str, command: StartCommand) -> None:
319
+ """Write the applications menu entry of ``command``, the same text as the desktop shortcut.
320
+
321
+ GNOME finds the name and the icon of a running GitPane in this entry, matched by its file name and its
322
+ StartupWMClass; it needs no trust mark, since only the desktop's icons ask for one.
323
+ """
324
+ write_desktop_entry(entry_filename, linux_desktop_entry_text(command), APPLICATION_ENTRY_MODE)
325
+
326
+
327
+ def bundle_info_plist_bytes() -> bytes:
328
+ """Return the Info.plist of the desktop bundle: GitPane's name, program, icon and reverse-DNS identifier."""
329
+ info_by_key = {
330
+ BUNDLE_NAME_KEY: APPLICATION_NAME,
331
+ BUNDLE_DISPLAY_NAME_KEY: APPLICATION_NAME,
332
+ BUNDLE_EXECUTABLE_KEY: APPLICATION_NAME,
333
+ BUNDLE_ICON_FILE_KEY: APPLICATION_NAME,
334
+ BUNDLE_IDENTIFIER_KEY: MACOS_BUNDLE_IDENTIFIER,
335
+ BUNDLE_PACKAGE_TYPE_KEY: BUNDLE_APPLICATION_PACKAGE_TYPE,
336
+ BUNDLE_INFO_DICTIONARY_VERSION_KEY: BUNDLE_INFO_DICTIONARY_VERSION,
337
+ HIGH_RESOLUTION_CAPABLE_KEY: True,
338
+ }
339
+ return plistlib.dumps(info_by_key)
340
+
341
+
342
+ def bundle_launcher_script(command: StartCommand) -> str:
343
+ """Return the bundle's program: a sh script that hands its process to ``command``, each part quoted for sh, followed by every argument it got."""
344
+ return BUNDLE_SCRIPT_SHELL_LINE + SHORTCUT_LINE_END + "exec " + shlex.join((command.program_filename, *command.arguments)) + ' "$@"' + SHORTCUT_LINE_END
345
+
346
+
347
+ def write_bytes(filename: str, data: bytes) -> None:
348
+ """Write ``data`` as the whole content of ``filename``, replacing what was there."""
349
+ with open(filename, "wb") as stream:
350
+ stream.write(data)
351
+
352
+
353
+ def write_macos_shortcut(shortcut_filename: str, command: StartCommand, run: ShortcutRun, environment_by_name: Mapping[str, str]) -> None:
354
+ """Build, or build again, the ``.app`` folder whose program starts ``command``, with the packaged logo as its icon.
355
+
356
+ Only the bundle's three own files are written: Info.plist, the program and the icon. A file anyone else put in the
357
+ folder stays, and nothing is deleted. No helper program is needed, so ``run`` and the environment are not used.
358
+ """
359
+ contents_dir = os.path.join(shortcut_filename, CONTENTS_DIR_BASENAME)
360
+ program_dir = os.path.join(contents_dir, MACOS_DIR_BASENAME)
361
+ resources_dir = os.path.join(contents_dir, RESOURCES_DIR_BASENAME)
362
+ os.makedirs(program_dir, exist_ok=True)
363
+ os.makedirs(resources_dir, exist_ok=True)
364
+ write_bytes(os.path.join(contents_dir, INFO_PLIST_BASENAME), bundle_info_plist_bytes())
365
+ program_filename = os.path.join(program_dir, APPLICATION_NAME)
366
+ with open(program_filename, "w", encoding=SHORTCUT_TEXT_ENCODING, newline=SHORTCUT_LINE_END) as stream:
367
+ stream.write(bundle_launcher_script(command))
368
+ os.chmod(program_filename, SHORTCUT_PROGRAM_MODE)
369
+ icon_png = packaged_resource_bytes(application_icon_png_basename(SHORTCUT_ICON_SIDE))
370
+ write_bytes(os.path.join(resources_dir, APPLICATION_NAME + ICNS_EXTENSION), icns_bytes([(ICNS_256_PNG_TYPE, icon_png)]))
371
+ # Finder only draws a bundle's icon again when the folder's date changes, so a rewritten icon shows at once.
372
+ os.utime(shortcut_filename)
373
+
374
+
375
+ def windows_shortcut_command(system_root: str, shortcut_filename: str, command: StartCommand, icon_filename: str,
376
+ base_environment: Mapping[str, str]) -> tuple[list[str], dict[str, str]]:
377
+ """Return the PowerShell command that makes the .lnk, and the environment that carries its three paths and the command's arguments.
378
+
379
+ The arguments are joined the way Windows splits a command line, empty for the gitpane command.
380
+
381
+ PowerShell is taken from the Windows folder, never looked up in PATH. ``base_environment`` is copied, never changed.
382
+ """
383
+ # This only runs on Windows; ntpath keeps the path the same when the tests run it on another system.
384
+ powershell_filename = ntpath.join(system_root, *WINDOWS_POWERSHELL_PATH_PARTS)
385
+ arguments = [powershell_filename, *WINDOWS_POWERSHELL_OPTIONS, WINDOWS_SHORTCUT_SCRIPT]
386
+ environment_by_name = dict(base_environment)
387
+ environment_by_name[WINDOWS_SHORTCUT_FILENAME_VARIABLE] = shortcut_filename
388
+ environment_by_name[WINDOWS_SHORTCUT_TARGET_VARIABLE] = command.program_filename
389
+ environment_by_name[WINDOWS_SHORTCUT_ARGUMENTS_VARIABLE] = command_line(command.arguments, WINDOWS_PLATFORM)
390
+ environment_by_name[WINDOWS_SHORTCUT_ICON_VARIABLE] = icon_filename
391
+ return arguments, environment_by_name
392
+
393
+
394
+ def first_error_line(stderr: bytes, returncode: int) -> str:
395
+ """Return the first non-empty line PowerShell wrote on its error output, or a line naming its exit code when it wrote none."""
396
+ for line in stderr.decode(SHORTCUT_TEXT_ENCODING, errors="replace").splitlines():
397
+ if line.strip():
398
+ return line.strip()
399
+ return POWERSHELL_EXIT_TEMPLATE.format(code=returncode)
400
+
401
+
402
+ def write_windows_shortcut(shortcut_filename: str, command: StartCommand, run: ShortcutRun, environment_by_name: Mapping[str, str]) -> None:
403
+ """Make, or make again, the .lnk that starts ``command`` with the packaged logo, through PowerShell; a refusal raises OSError.
404
+
405
+ An earlier GitPane.lnk is removed first, so the new one starts empty and holds only the target, its arguments and
406
+ the icon.
407
+ """
408
+ system_root = environment_by_name.get(SYSTEM_ROOT_ENVIRONMENT_NAME, "")
409
+ if not system_root:
410
+ raise OSError(SYSTEM_ROOT_ENVIRONMENT_NAME + " is not set, so PowerShell cannot be found")
411
+ # WScript.Shell's CreateShortcut opens an existing .lnk and Save keeps every field the script does not set: an
412
+ # earlier GitPane.lnk that started a checkout's main.py kept that argument, which the launcher refused, so the
413
+ # shortcut started nothing. Removing the old file makes CreateShortcut start from an empty shortcut.
414
+ if os.path.isfile(shortcut_filename):
415
+ os.remove(shortcut_filename)
416
+ arguments, command_environment = windows_shortcut_command(system_root, shortcut_filename, command, packaged_resource_filename(APPLICATION_ICON_ICO_BASENAME),
417
+ environment_by_name)
418
+ completed = run(arguments, env=command_environment, stdin=subprocess.DEVNULL, capture_output=True, check=False, timeout=helper_timeout_seconds(),
419
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
420
+ if completed.returncode != 0:
421
+ raise OSError(first_error_line(completed.stderr or b"", completed.returncode))
422
+
423
+
424
+ SHORTCUT_FORM_BY_PLATFORM = {
425
+ WINDOWS_PLATFORM: ShortcutForm(APPLICATION_NAME + WINDOWS_SHORTCUT_EXTENSION, write_windows_shortcut),
426
+ LINUX_PLATFORM: ShortcutForm(APPLICATION_NAME + DESKTOP_ENTRY_EXTENSION, write_linux_shortcut, write_linux_application_entry),
427
+ MACOS_PLATFORM: ShortcutForm(APPLICATION_NAME + BUNDLE_SUFFIX, write_macos_shortcut),
428
+ }
429
+
430
+
431
+ def shortcut_form(platform: str) -> ShortcutForm:
432
+ """Return the shortcut form of ``platform``; a system missing from the table raises OSError."""
433
+ form = SHORTCUT_FORM_BY_PLATFORM.get(platform)
434
+ if form is None:
435
+ raise OSError(platform)
436
+ return form
437
+
438
+
439
+ def named_desktop_dir(desktop_dir: str, home_dir: str) -> str:
440
+ """Return the desktop folder the shortcut may go to: ``desktop_dir`` as given, or an empty text when the system names none.
441
+
442
+ xdg-user-dirs makes the home folder the desktop folder once the user has removed theirs; a launcher written there
443
+ would land among the user's files, not on a desktop, so that folder counts as none. The folders are compared by
444
+ identity, so a trailing separator, a ``..`` or a link changes nothing; that reads the disk, as both callers do,
445
+ which run off the window's thread. An empty ``home_dir`` compares with nothing.
446
+ """
447
+ if not desktop_dir:
448
+ return ""
449
+ if home_dir and canonical_path(desktop_dir) == canonical_path(home_dir):
450
+ return ""
451
+ return desktop_dir
452
+
453
+
454
+ def desktop_shortcut_outcome(desktop_dir: str, form: ShortcutForm, command: StartCommand, run: ShortcutRun, environment_by_name: Mapping[str, str]) -> ShortcutOutcome:
455
+ """Write, or write again, the desktop shortcut of ``command`` into ``desktop_dir``, and say how it went.
456
+
457
+ A desktop folder the system does not name, or that is not there, is no failure: nothing can be written, and the
458
+ folder is never created.
459
+ """
460
+ if not desktop_dir:
461
+ return ShortcutOutcome(ShortcutStatus.NO_DESKTOP_NAMED)
462
+ if not os.path.isdir(desktop_dir):
463
+ return ShortcutOutcome(ShortcutStatus.NO_DESKTOP, desktop_dir)
464
+ shortcut_filename = os.path.join(desktop_dir, form.basename)
465
+ try:
466
+ form.write(shortcut_filename, command, run, environment_by_name)
467
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
468
+ return ShortcutOutcome(ShortcutStatus.WRITE_FAILED, str(error))
469
+ return ShortcutOutcome(ShortcutStatus.CREATED, shortcut_filename)
470
+
471
+
472
+ def application_entry_outcome(applications_dir: str, application_entry_write: Callable[[str, StartCommand], None], basename: str, command: StartCommand) -> ShortcutOutcome:
473
+ """Write, or write again, the applications menu entry of ``command`` into ``applications_dir``, created when missing, and say how it went."""
474
+ if not applications_dir:
475
+ return ShortcutOutcome(ShortcutStatus.APPLICATION_ENTRY_FAILED, NO_APPLICATIONS_FOLDER_DETAIL)
476
+ entry_filename = os.path.join(applications_dir, basename)
477
+ try:
478
+ os.makedirs(applications_dir, exist_ok=True)
479
+ application_entry_write(entry_filename, command)
480
+ except (OSError, ValueError) as error:
481
+ return ShortcutOutcome(ShortcutStatus.APPLICATION_ENTRY_FAILED, str(error))
482
+ return ShortcutOutcome(ShortcutStatus.APPLICATION_ENTRY_ADDED, entry_filename)
483
+
484
+
485
+ def create_desktop_shortcut(desktop_dir: str, command: StartCommand | None, platform: str = sys.platform, run: ShortcutRun = subprocess.run,
486
+ environment_by_name: Mapping[str, str] | None = None, applications_dir: str = "", home_dir: str = "") -> ShortcutReport:
487
+ """Write, or write again, the shortcut that starts ``command`` into ``desktop_dir``, then on Linux its applications entry into ``applications_dir``.
488
+
489
+ The applications menu is where the desktop finds the name and the icon of a running GitPane, so its entry does not
490
+ depend on the desktop folder: it is written whether that folder exists or not, and whatever happened to the
491
+ desktop shortcut. Each file has its own line, so a file written is never announced as failed because of the other.
492
+ The applications folder belongs to the user's own data and is created when missing; the desktop folder never is.
493
+ A desktop folder that is ``home_dir`` itself counts as no desktop folder named, so no launcher lands among the
494
+ user's files.
495
+
496
+ Nothing is written when the system has no shortcut form or when there is no command to start. Only the shortcut's
497
+ and the entry's own fixed names are written or replaced, nothing else is deleted.
498
+ """
499
+ try:
500
+ form = shortcut_form(platform)
501
+ except OSError:
502
+ return ShortcutReport((ShortcutOutcome(ShortcutStatus.UNSUPPORTED_SYSTEM, platform),))
503
+ if command is None:
504
+ return ShortcutReport((ShortcutOutcome(ShortcutStatus.NO_START_COMMAND),))
505
+ file_outcomes = [desktop_shortcut_outcome(named_desktop_dir(desktop_dir, home_dir), form, command, run, environment_by_name if environment_by_name is not None else os.environ)]
506
+ if form.application_entry_write is not None:
507
+ file_outcomes.append(application_entry_outcome(applications_dir, form.application_entry_write, form.basename, command))
508
+ return ShortcutReport(tuple(file_outcomes), start_command_line(command, platform))
509
+
510
+
511
+ def installed_start_command(application_module_name: str) -> StartCommand | None:
512
+ """Choose what the shortcut of the running installation starts: its gitpane command, or the running Python on ``application_module_name``.
513
+
514
+ The path of the running Python is kept as Python gives it. A system with no known launcher name leaves nothing to
515
+ start; ``create_desktop_shortcut`` then says the system has no shortcut form.
516
+ """
517
+ try:
518
+ return start_command(installation_script_dirs(running_in_virtual_environment()), sys.platform, sys.executable or "", application_module_name)
519
+ except OSError:
520
+ return None
521
+
522
+
523
+ def create_installed_desktop_shortcut(application_module_name: str = "") -> ShortcutReport:
524
+ """Write the shortcut of the running installation into the desktop folder the system names, and on Linux its applications entry.
525
+
526
+ ``application_module_name`` is the module the running Python starts with ``-m`` when the installation has no
527
+ gitpane command; the wiring passes the name the import system gave the application module. Qt follows XDG
528
+ user-dirs on Linux and the Desktop known folder on Windows, OneDrive included; the applications folder is
529
+ ``$XDG_DATA_HOME/applications`` on Linux. The home folder goes with them, read as the desktop icon facts read it,
530
+ so a desktop folder that is the home folder gets no launcher. Qt only reads the system's folder settings, so this
531
+ can run on the Settings page's worker thread. Both folders are written with the system's own separator, which Qt
532
+ does not use on Windows, so the paths Settings shows read as the system writes them.
533
+ """
534
+ desktop_dir = native_folder_path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), sys.platform)
535
+ applications_dir = native_folder_path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.ApplicationsLocation), sys.platform)
536
+ return create_desktop_shortcut(desktop_dir, installed_start_command(application_module_name), applications_dir=applications_dir, home_dir=os.path.expanduser("~"))
@@ -0,0 +1,35 @@
1
+ """Pack PNG pictures into the ICNS file a macOS application bundle shows as its icon.
2
+
3
+ An ICNS file is a small container: a four-byte magic, the whole file's length, then one entry per picture, each made
4
+ of a four-byte type naming its size, its own length (header included) and its data. All lengths are big-endian.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Sequence
14
+
15
+ ICNS_MAGIC = b"icns"
16
+ # A 256x256 PNG entry, read by macOS since 10.7.
17
+ ICNS_256_PNG_TYPE = b"ic08"
18
+ ICNS_LENGTH_FORMAT = ">I"
19
+ ICNS_TYPE_LENGTH = 4
20
+ # The magic or the type, followed by the four-byte length.
21
+ ICNS_HEADER_LENGTH = ICNS_TYPE_LENGTH + struct.calcsize(ICNS_LENGTH_FORMAT)
22
+ ICNS_ENTRY_HEADER_LENGTH = ICNS_TYPE_LENGTH + struct.calcsize(ICNS_LENGTH_FORMAT)
23
+
24
+
25
+ def icns_bytes(entries: Sequence[tuple[bytes, bytes]]) -> bytes:
26
+ """Return the ICNS file holding each ``(type, data)`` entry in the given order; a type that is not four bytes raises ValueError."""
27
+ entry_chunks = []
28
+ total_length = ICNS_HEADER_LENGTH
29
+ for entry_type, data in entries:
30
+ if len(entry_type) != ICNS_TYPE_LENGTH:
31
+ raise ValueError("An ICNS entry type is four bytes: " + repr(entry_type))
32
+ entry_length = ICNS_ENTRY_HEADER_LENGTH + len(data)
33
+ entry_chunks.append(entry_type + struct.pack(ICNS_LENGTH_FORMAT, entry_length) + data)
34
+ total_length += entry_length
35
+ return ICNS_MAGIC + struct.pack(ICNS_LENGTH_FORMAT, total_length) + b"".join(entry_chunks)