gitpane 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|