railmux 0.4.0.dev4__tar.gz → 0.4.0.dev5__tar.gz

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 (168) hide show
  1. {railmux-0.4.0.dev4/src/railmux.egg-info → railmux-0.4.0.dev5}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev5/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/windows_msys2.py +158 -9
  4. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5/src/railmux.egg-info}/PKG-INFO +1 -1
  5. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_windows_msys2.py +173 -6
  6. railmux-0.4.0.dev4/src/railmux/__init__.py +0 -1
  7. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/LICENSE +0 -0
  8. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/MANIFEST.in +0 -0
  9. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/README.md +0 -0
  10. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/pyproject.toml +0 -0
  11. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/setup.cfg +0 -0
  12. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/__main__.py +0 -0
  13. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/atomic_file.py +0 -0
  14. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/background_index.py +0 -0
  15. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/cli.py +0 -0
  16. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/codex_index.py +0 -0
  17. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/config.py +0 -0
  18. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/config_cli.py +0 -0
  19. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/diagnostics.py +0 -0
  20. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/discovery.py +0 -0
  21. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/display_transport.py +0 -0
  22. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/entrypoint.py +0 -0
  23. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/fast_display_client.py +0 -0
  24. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/fast_display_history.py +0 -0
  25. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/fast_display_input.py +0 -0
  26. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/fast_display_protocol.py +0 -0
  27. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/fast_display_server.py +0 -0
  28. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/favorites.py +0 -0
  29. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/fuzzy.py +0 -0
  30. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/help_workspace.py +0 -0
  31. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/launcher.py +0 -0
  32. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/legacy_sessions.py +0 -0
  33. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/local_clipboard.py +0 -0
  34. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/local_open.py +0 -0
  35. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/models.py +0 -0
  36. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/modes.py +0 -0
  37. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/mouse_manager.py +0 -0
  38. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/orphan_marker.py +0 -0
  39. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/pane_surface.py +0 -0
  40. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/path_codec.py +0 -0
  41. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/provider_paths.py +0 -0
  42. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/remote_config.py +0 -0
  43. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/renames.py +0 -0
  44. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/restart_state.py +0 -0
  45. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/runtime_config.py +0 -0
  46. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/scroll_agent.py +0 -0
  47. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/scroll_manager.py +0 -0
  48. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/selection_isolation.py +0 -0
  49. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/self_update.py +0 -0
  50. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/session_cache.py +0 -0
  51. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/session_index.py +0 -0
  52. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/setting_contracts.py +0 -0
  53. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/settings.py +0 -0
  54. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ssh_args.py +0 -0
  55. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ssh_compat.py +0 -0
  56. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ssh_display_diagnostics.py +0 -0
  57. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ssh_doctor.py +0 -0
  58. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/system_deps.py +0 -0
  59. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/terminal_status.py +0 -0
  60. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/tmux_binding_manager.py +0 -0
  61. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/tmux_ctl.py +0 -0
  62. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/tmux_health.py +0 -0
  63. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/tmux_server.py +0 -0
  64. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/tool_panes.py +0 -0
  65. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/transcript.py +0 -0
  66. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/__init__.py +0 -0
  67. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/_widgets.py +0 -0
  68. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/app.py +0 -0
  69. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/keymap.py +0 -0
  70. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/modals.py +0 -0
  71. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/projects_pane.py +0 -0
  72. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/running_pane.py +0 -0
  73. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/sessions_pane.py +0 -0
  74. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/sidebar.py +0 -0
  75. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/statusbar.py +0 -0
  76. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/ui/workspace.py +0 -0
  77. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux/windows_bootstrap.py +0 -0
  78. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux.egg-info/SOURCES.txt +0 -0
  79. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux.egg-info/dependency_links.txt +0 -0
  80. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux.egg-info/entry_points.txt +0 -0
  81. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux.egg-info/requires.txt +0 -0
  82. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/src/railmux.egg-info/top_level.txt +0 -0
  83. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_agent_workspace.py +0 -0
  84. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_atomic_file.py +0 -0
  85. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_background_index.py +0 -0
  86. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_cli.py +0 -0
  87. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_codex_delete.py +0 -0
  88. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_codex_index.py +0 -0
  89. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_codex_lineage.py +0 -0
  90. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_codex_transcript.py +0 -0
  91. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_config.py +0 -0
  92. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_config_cli.py +0 -0
  93. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_denested_transport_benchmark.py +0 -0
  94. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_diagnostics.py +0 -0
  95. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_discovery.py +0 -0
  96. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_display_transport.py +0 -0
  97. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_fast_full_window.py +0 -0
  98. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_focus_highlight.py +0 -0
  99. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_fullscreen_binding.py +0 -0
  100. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_fuzzy.py +0 -0
  101. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_help_agent.py +0 -0
  102. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_help_workspace.py +0 -0
  103. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_history_cleanup.py +0 -0
  104. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_keymap.py +0 -0
  105. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_launcher.py +0 -0
  106. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_layout_profile.py +0 -0
  107. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_legacy_sessions.py +0 -0
  108. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_local_clipboard.py +0 -0
  109. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_local_open.py +0 -0
  110. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_modals.py +0 -0
  111. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_mode_selection.py +0 -0
  112. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_modes.py +0 -0
  113. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_mouse_manager.py +0 -0
  114. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_new_project.py +0 -0
  115. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_options.py +0 -0
  116. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_orphan_recovery.py +0 -0
  117. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_pane_surface.py +0 -0
  118. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_path_codec.py +0 -0
  119. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_projects_pane.py +0 -0
  120. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_provider_paths.py +0 -0
  121. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_release_notes.py +0 -0
  122. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_release_workflow.py +0 -0
  123. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_remote_config.py +0 -0
  124. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_renames.py +0 -0
  125. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_restart_state.py +0 -0
  126. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_running_pane.py +0 -0
  127. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_running_sort.py +0 -0
  128. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_runtime_config.py +0 -0
  129. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_scroll_agent.py +0 -0
  130. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_scroll_manager.py +0 -0
  131. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_selection_isolation.py +0 -0
  132. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_self_update.py +0 -0
  133. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_session_cache.py +0 -0
  134. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_session_index.py +0 -0
  135. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_sessions_pane.py +0 -0
  136. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_setting_contracts.py +0 -0
  137. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_settings.py +0 -0
  138. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_sidebar.py +0 -0
  139. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_soft_quit.py +0 -0
  140. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_ssh_args.py +0 -0
  141. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_ssh_compat.py +0 -0
  142. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_ssh_display_benchmark.py +0 -0
  143. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_ssh_display_diagnostics.py +0 -0
  144. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_ssh_doctor.py +0 -0
  145. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_status.py +0 -0
  146. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_statusbar.py +0 -0
  147. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_system_deps.py +0 -0
  148. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_terminal_size.py +0 -0
  149. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_terminal_status.py +0 -0
  150. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_termux_local_touch.py +0 -0
  151. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_tmux_binding_manager.py +0 -0
  152. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_tmux_ctl.py +0 -0
  153. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_tmux_health.py +0 -0
  154. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_tmux_integration.py +0 -0
  155. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_tmux_server.py +0 -0
  156. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_tmux_status.py +0 -0
  157. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_tool_panes.py +0 -0
  158. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_transcript.py +0 -0
  159. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_utf8_state.py +0 -0
  160. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_web_demo_recorder.py +0 -0
  161. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_wheel_smoke.py +0 -0
  162. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_widgets.py +0 -0
  163. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_windows_bootstrap.py +0 -0
  164. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_windows_wrapper_contract.py +0 -0
  165. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tests/test_yolo_prompt.py +0 -0
  166. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tools/denested_transport_benchmark.py +0 -0
  167. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tools/release_notes.py +0 -0
  168. {railmux-0.4.0.dev4 → railmux-0.4.0.dev5}/tools/wheel_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev4
3
+ Version: 0.4.0.dev5
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev5"
@@ -7,13 +7,16 @@ remain Windows-native.
7
7
  from __future__ import annotations
8
8
 
9
9
  import hashlib
10
+ import http.client
10
11
  import json
11
12
  import os
12
13
  import re
13
14
  import subprocess
15
+ import sys
14
16
  import tempfile
15
17
  import time
16
18
  import urllib.error
19
+ import urllib.parse
17
20
  import urllib.request
18
21
  from collections.abc import Callable, Iterator, Mapping, Sequence
19
22
  from contextlib import contextmanager
@@ -23,10 +26,27 @@ from pathlib import Path
23
26
 
24
27
  MSYS2_RELEASE = "2026-03-22"
25
28
  MSYS2_ARCHIVE_NAME = f"msys2-base-x86_64-{MSYS2_RELEASE.replace('-', '')}.sfx.exe"
26
- MSYS2_ARCHIVE_URL = (
27
- "https://github.com/msys2/msys2-installer/releases/download/"
28
- f"{MSYS2_RELEASE}/{MSYS2_ARCHIVE_NAME}"
29
+ MSYS2_ARCHIVE_SOURCES = (
30
+ (
31
+ "GitHub",
32
+ "https://github.com/msys2/msys2-installer/releases/download/"
33
+ f"{MSYS2_RELEASE}/{MSYS2_ARCHIVE_NAME}",
34
+ ),
35
+ (
36
+ "MSYS2 repository",
37
+ f"https://repo.msys2.org/distrib/x86_64/{MSYS2_ARCHIVE_NAME}",
38
+ ),
39
+ (
40
+ "TUNA mirror",
41
+ f"https://mirrors.tuna.tsinghua.edu.cn/msys2/distrib/x86_64/"
42
+ f"{MSYS2_ARCHIVE_NAME}",
43
+ ),
44
+ (
45
+ "NJU mirror",
46
+ f"https://mirror.nju.edu.cn/msys2/distrib/x86_64/{MSYS2_ARCHIVE_NAME}",
47
+ ),
29
48
  )
49
+ MSYS2_ARCHIVE_SIZE = 52_820_994
30
50
  MSYS2_ARCHIVE_SHA256 = (
31
51
  "6fe0cc8154132040e034ff4daface2a4163a9d1f6ebaaa1133394bff460bd5cf"
32
52
  )
@@ -42,6 +62,7 @@ _HANDOFF_COMMAND = (
42
62
  )
43
63
  _PROBE_TIMEOUT_SECONDS = 15.0
44
64
  _DOWNLOAD_LIMIT = 128 * 1024 * 1024
65
+ _DOWNLOAD_LOG_STEP = 8 * 1024 * 1024
45
66
  _VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?\Z")
46
67
 
47
68
  _INITIAL_UPDATE_COMMAND = (
@@ -71,6 +92,61 @@ Runner = Callable[..., subprocess.CompletedProcess]
71
92
  Downloader = Callable[[str, Path, str], None]
72
93
 
73
94
 
95
+ def _format_download_size(size: int) -> str:
96
+ if size >= 1024 * 1024:
97
+ return f"{size / (1024 * 1024):.1f} MiB"
98
+ if size >= 1024:
99
+ return f"{size / 1024:.1f} KiB"
100
+ return f"{size} B"
101
+
102
+
103
+ class _DownloadProgress:
104
+ """Render bounded progress without flooding redirected logs."""
105
+
106
+ def __init__(self, url: str) -> None:
107
+ self._stream = sys.stderr
108
+ self._source = urllib.parse.urlsplit(url).hostname or "approved source"
109
+ self._interactive = bool(getattr(self._stream, "isatty", lambda: False)())
110
+ self._last_logged = 0
111
+ self._active_line = False
112
+
113
+ def _message(self, downloaded: int, expected: int | None) -> str:
114
+ current = _format_download_size(downloaded)
115
+ if expected:
116
+ percent = min(100.0, downloaded * 100.0 / expected)
117
+ return (
118
+ f" {self._source}: {current} / "
119
+ f"{_format_download_size(expected)} ({percent:.1f}%)"
120
+ )
121
+ return f" {self._source}: {current} downloaded"
122
+
123
+ def update(
124
+ self,
125
+ downloaded: int,
126
+ expected: int | None,
127
+ *,
128
+ final: bool = False,
129
+ ) -> None:
130
+ message = self._message(downloaded, expected)
131
+ if self._interactive:
132
+ self._stream.write(f"\r{message}")
133
+ self._active_line = not final
134
+ if final:
135
+ self._stream.write("\n")
136
+ self._stream.flush()
137
+ return
138
+ if final or downloaded - self._last_logged >= _DOWNLOAD_LOG_STEP:
139
+ self._stream.write(f"{message}\n")
140
+ self._stream.flush()
141
+ self._last_logged = downloaded
142
+
143
+ def abort(self) -> None:
144
+ if self._interactive and self._active_line:
145
+ self._stream.write("\n")
146
+ self._stream.flush()
147
+ self._active_line = False
148
+
149
+
74
150
  @dataclass(frozen=True)
75
151
  class Msys2Runtime:
76
152
  root: Path
@@ -227,10 +303,30 @@ def find_runtime(
227
303
 
228
304
  def download_verified(url: str, destination: Path, sha256: str) -> None:
229
305
  """Download one bounded artifact and verify it before execution."""
306
+ if urllib.parse.urlsplit(url).scheme.lower() != "https":
307
+ raise RuntimeInstallError("the MSYS2 runtime source did not use HTTPS")
230
308
  digest = hashlib.sha256()
231
309
  total = 0
310
+ expected: int | None = None
311
+ progress = _DownloadProgress(url)
232
312
  try:
233
313
  with urllib.request.urlopen(url, timeout=60) as response:
314
+ final_url = getattr(response, "geturl", lambda: url)()
315
+ if urllib.parse.urlsplit(final_url).scheme.lower() != "https":
316
+ raise RuntimeInstallError(
317
+ "the MSYS2 runtime source redirected outside HTTPS"
318
+ )
319
+ raw_length = response.headers.get("Content-Length")
320
+ if raw_length is not None:
321
+ try:
322
+ parsed_length = int(raw_length)
323
+ except (TypeError, ValueError):
324
+ parsed_length = 0
325
+ if parsed_length > 0:
326
+ expected = parsed_length
327
+ if expected is not None and expected > _DOWNLOAD_LIMIT:
328
+ raise RuntimeInstallError("MSYS2 download exceeded its size limit")
329
+ progress.update(0, expected)
234
330
  with destination.open("xb") as output:
235
331
  while True:
236
332
  chunk = response.read(1024 * 1024)
@@ -241,12 +337,61 @@ def download_verified(url: str, destination: Path, sha256: str) -> None:
241
337
  raise RuntimeInstallError("MSYS2 download exceeded its size limit")
242
338
  digest.update(chunk)
243
339
  output.write(chunk)
244
- except RuntimeInstallError:
245
- raise
246
- except (OSError, urllib.error.URLError) as exc:
340
+ progress.update(total, expected)
341
+ if digest.hexdigest().lower() != sha256.lower():
342
+ raise RuntimeInstallError(
343
+ "the downloaded MSYS2 archive failed SHA-256 verification"
344
+ )
345
+ except (
346
+ RuntimeInstallError,
347
+ OSError,
348
+ urllib.error.URLError,
349
+ http.client.HTTPException,
350
+ ) as exc:
351
+ progress.abort()
352
+ try:
353
+ destination.unlink(missing_ok=True)
354
+ except OSError as cleanup_error:
355
+ raise RuntimeInstallError(
356
+ "could not remove an incomplete MSYS2 download"
357
+ ) from cleanup_error
358
+ if isinstance(exc, RuntimeInstallError):
359
+ raise
247
360
  raise RuntimeInstallError("could not download the pinned MSYS2 runtime") from exc
248
- if digest.hexdigest().lower() != sha256.lower():
249
- raise RuntimeInstallError("the downloaded MSYS2 archive failed SHA-256 verification")
361
+ progress.update(total, expected, final=True)
362
+
363
+
364
+ def download_from_sources(
365
+ destination: Path,
366
+ sha256: str,
367
+ *,
368
+ sources: Sequence[tuple[str, str]] = MSYS2_ARCHIVE_SOURCES,
369
+ downloader: Downloader = download_verified,
370
+ ) -> str:
371
+ """Try approved transports in order; every source uses the same pinned hash."""
372
+ last_error: RuntimeInstallError | None = None
373
+ for index, (label, url) in enumerate(sources, start=1):
374
+ print(f"Downloading from {label} ({index}/{len(sources)})…")
375
+ try:
376
+ downloader(url, destination, sha256)
377
+ except RuntimeInstallError as exc:
378
+ last_error = exc
379
+ try:
380
+ destination.unlink(missing_ok=True)
381
+ except OSError as cleanup_error:
382
+ raise RuntimeInstallError(
383
+ "could not remove an incomplete MSYS2 download"
384
+ ) from cleanup_error
385
+ if index < len(sources):
386
+ print(
387
+ f"{label} failed ({exc}); trying the next approved source…",
388
+ file=sys.stderr,
389
+ )
390
+ continue
391
+ return label
392
+ raise RuntimeInstallError(
393
+ "could not download the pinned MSYS2 runtime from any approved source"
394
+ ) from last_error
250
395
 
251
396
 
252
397
  def _run_checked(
@@ -366,7 +511,11 @@ def install_managed_runtime(
366
511
  stage = Path(raw_stage)
367
512
  archive = stage / MSYS2_ARCHIVE_NAME
368
513
  print(f"Downloading verified MSYS2 {MSYS2_RELEASE} runtime…")
369
- downloader(MSYS2_ARCHIVE_URL, archive, MSYS2_ARCHIVE_SHA256)
514
+ download_from_sources(
515
+ archive,
516
+ MSYS2_ARCHIVE_SHA256,
517
+ downloader=downloader,
518
+ )
370
519
 
371
520
  _run_checked(
372
521
  [str(archive), "-y", f"-o{stage}"],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev4
3
+ Version: 0.4.0.dev5
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
4
+ import io
3
5
  import json
4
6
  import subprocess
5
7
  from contextlib import contextmanager
@@ -8,11 +10,17 @@ from unittest.mock import MagicMock
8
10
 
9
11
  import pytest
10
12
 
13
+ from railmux import windows_msys2
11
14
  from railmux.windows_msys2 import (
12
15
  MSYS2_ARCHIVE_NAME,
16
+ MSYS2_ARCHIVE_SHA256,
17
+ MSYS2_ARCHIVE_SIZE,
18
+ MSYS2_ARCHIVE_SOURCES,
13
19
  MSYS2_RUNTIME_ID,
14
20
  Msys2Runtime,
15
21
  RuntimeInstallError,
22
+ download_from_sources,
23
+ download_verified,
16
24
  find_runtime,
17
25
  install_managed_runtime,
18
26
  managed_root,
@@ -20,7 +28,36 @@ from railmux.windows_msys2 import (
20
28
  )
21
29
 
22
30
 
23
- VERSION = "0.4.0.dev4"
31
+ VERSION = "0.4.0.dev5"
32
+
33
+
34
+ class TtyBuffer(io.StringIO):
35
+ def isatty(self):
36
+ return True
37
+
38
+
39
+ class FakeResponse(io.BytesIO):
40
+ def __init__(
41
+ self,
42
+ payload: bytes,
43
+ *,
44
+ content_length: str | None = None,
45
+ final_url: str = "https://example.invalid/archive.exe",
46
+ ):
47
+ super().__init__(payload)
48
+ self._final_url = final_url
49
+ self.headers = {}
50
+ if content_length is not None:
51
+ self.headers["Content-Length"] = content_length
52
+
53
+ def __enter__(self):
54
+ return self
55
+
56
+ def __exit__(self, _exc_type, _exc, _traceback):
57
+ self.close()
58
+
59
+ def geturl(self):
60
+ return self._final_url
24
61
 
25
62
 
26
63
  def completed(argv, returncode=0, stdout=b"", stderr=b""):
@@ -41,6 +78,136 @@ def make_runtime(root: Path, *, managed: bool) -> Msys2Runtime:
41
78
  return Msys2Runtime(root, managed=managed)
42
79
 
43
80
 
81
+ def test_approved_archive_sources_are_https_and_share_one_pinned_artifact():
82
+ assert MSYS2_ARCHIVE_SIZE == 52_820_994
83
+ assert len(MSYS2_ARCHIVE_SOURCES) == 4
84
+ assert len({url for _label, url in MSYS2_ARCHIVE_SOURCES}) == 4
85
+ for label, url in MSYS2_ARCHIVE_SOURCES:
86
+ assert label
87
+ assert url.startswith("https://")
88
+ assert url.endswith(f"/{MSYS2_ARCHIVE_NAME}")
89
+
90
+
91
+ def test_download_reports_bytes_and_percentage_on_a_terminal(tmp_path, monkeypatch):
92
+ payload = b"a" * (2 * 1024 * 1024)
93
+ progress = TtyBuffer()
94
+ monkeypatch.setattr(windows_msys2.sys, "stderr", progress)
95
+ monkeypatch.setattr(
96
+ windows_msys2.urllib.request,
97
+ "urlopen",
98
+ lambda _url, timeout: FakeResponse(
99
+ payload,
100
+ content_length=str(len(payload)),
101
+ ),
102
+ )
103
+ destination = tmp_path / "archive.exe"
104
+
105
+ download_verified(
106
+ "https://example.invalid/archive.exe",
107
+ destination,
108
+ hashlib.sha256(payload).hexdigest(),
109
+ )
110
+
111
+ rendered = progress.getvalue()
112
+ assert "\r example.invalid: 1.0 MiB / 2.0 MiB (50.0%)" in rendered
113
+ assert "\r example.invalid: 2.0 MiB / 2.0 MiB (100.0%)\n" in rendered
114
+ assert destination.read_bytes() == payload
115
+
116
+
117
+ def test_download_hash_failure_removes_the_untrusted_archive(tmp_path, monkeypatch):
118
+ payload = b"not the pinned archive"
119
+ monkeypatch.setattr(
120
+ windows_msys2.urllib.request,
121
+ "urlopen",
122
+ lambda _url, timeout: FakeResponse(
123
+ payload,
124
+ content_length=str(len(payload)),
125
+ ),
126
+ )
127
+ destination = tmp_path / "archive.exe"
128
+
129
+ with pytest.raises(RuntimeInstallError, match="SHA-256 verification"):
130
+ download_verified(
131
+ "https://example.invalid/archive.exe",
132
+ destination,
133
+ MSYS2_ARCHIVE_SHA256,
134
+ )
135
+
136
+ assert not destination.exists()
137
+
138
+
139
+ def test_download_rejects_an_https_downgrade_before_writing(tmp_path, monkeypatch):
140
+ monkeypatch.setattr(
141
+ windows_msys2.urllib.request,
142
+ "urlopen",
143
+ lambda _url, timeout: FakeResponse(
144
+ b"untrusted",
145
+ final_url="http://mirror.invalid/archive.exe",
146
+ ),
147
+ )
148
+ destination = tmp_path / "archive.exe"
149
+
150
+ with pytest.raises(RuntimeInstallError, match="redirected outside HTTPS"):
151
+ download_verified(
152
+ "https://example.invalid/archive.exe",
153
+ destination,
154
+ MSYS2_ARCHIVE_SHA256,
155
+ )
156
+
157
+ assert not destination.exists()
158
+
159
+
160
+ def test_download_falls_back_after_removing_a_partial_archive(tmp_path):
161
+ destination = tmp_path / "archive.exe"
162
+ sources = (
163
+ ("first", "https://first.invalid/a"),
164
+ ("second", "https://second.invalid/a"),
165
+ )
166
+ attempted = []
167
+
168
+ def downloader(url, target, sha256):
169
+ attempted.append((url, sha256))
170
+ if "first" in url:
171
+ target.write_bytes(b"partial")
172
+ raise RuntimeInstallError("could not download the pinned MSYS2 runtime")
173
+ assert not target.exists()
174
+ target.write_bytes(b"verified")
175
+
176
+ selected = download_from_sources(
177
+ destination,
178
+ MSYS2_ARCHIVE_SHA256,
179
+ sources=sources,
180
+ downloader=downloader,
181
+ )
182
+
183
+ assert selected == "second"
184
+ assert [url for url, _sha256 in attempted] == [url for _label, url in sources]
185
+ assert {sha256 for _url, sha256 in attempted} == {MSYS2_ARCHIVE_SHA256}
186
+ assert destination.read_bytes() == b"verified"
187
+
188
+
189
+ def test_download_fails_closed_after_every_approved_source(tmp_path):
190
+ destination = tmp_path / "archive.exe"
191
+ sources = (
192
+ ("first", "https://first.invalid/a"),
193
+ ("second", "https://second.invalid/a"),
194
+ )
195
+
196
+ def downloader(_url, target, _sha256):
197
+ target.write_bytes(b"untrusted")
198
+ raise RuntimeInstallError("archive failed verification")
199
+
200
+ with pytest.raises(RuntimeInstallError, match="any approved source"):
201
+ download_from_sources(
202
+ destination,
203
+ MSYS2_ARCHIVE_SHA256,
204
+ sources=sources,
205
+ downloader=downloader,
206
+ )
207
+
208
+ assert not destination.exists()
209
+
210
+
44
211
  def test_handoff_preserves_argv_and_uses_child_only_msys_environment(tmp_path):
45
212
  runtime = make_runtime(tmp_path / "msys", managed=False)
46
213
  arguments = ["ssh", "user@example", "--ssh-args=-J jump host", "开发"]
@@ -82,10 +249,10 @@ def test_managed_runtime_requires_utf8_marker_and_exact_package_version(tmp_path
82
249
  root = managed_root(environ, version=VERSION)
83
250
  assert root is not None
84
251
  runtime = make_runtime(root, managed=True)
85
- probe = MagicMock(return_value=completed([], stdout=b"railmux 0.4.0.dev4\n"))
252
+ probe = MagicMock(return_value=completed([], stdout=b"railmux 0.4.0.dev5\n"))
86
253
 
87
254
  assert probe_runtime(runtime, version=VERSION, environ=environ, probe=probe)
88
- assert not probe_runtime(runtime, version="0.4.0.dev5", environ=environ, probe=probe)
255
+ assert not probe_runtime(runtime, version="0.4.0.dev6", environ=environ, probe=probe)
89
256
 
90
257
 
91
258
  def test_runtime_probe_retries_one_transient_cold_start_failure(tmp_path):
@@ -93,7 +260,7 @@ def test_runtime_probe_retries_one_transient_cold_start_failure(tmp_path):
93
260
  probe = MagicMock(
94
261
  side_effect=[
95
262
  completed([], returncode=1),
96
- completed([], stdout=b"railmux 0.4.0.dev4\n"),
263
+ completed([], stdout=b"railmux 0.4.0.dev5\n"),
97
264
  ]
98
265
  )
99
266
 
@@ -114,7 +281,7 @@ def test_each_preview_version_uses_a_separate_runtime_generation(tmp_path):
114
281
  def test_explicit_user_runtime_is_probed_but_never_requires_managed_marker(tmp_path):
115
282
  root = tmp_path / "用户-owned-msys"
116
283
  runtime = make_runtime(root, managed=False)
117
- probe = MagicMock(return_value=completed([], stdout=b"railmux 0.4.0.dev4\n"))
284
+ probe = MagicMock(return_value=completed([], stdout=b"railmux 0.4.0.dev5\n"))
118
285
  environ = {"RAILMUX_MSYS2_ROOT": str(root), "USERPROFILE": r"C:\Users\u"}
119
286
 
120
287
  found = find_runtime(version=VERSION, environ=environ, probe=probe)
@@ -186,7 +353,7 @@ def test_install_is_staged_and_activated_only_after_exact_probe(tmp_path):
186
353
  return completed(argv)
187
354
 
188
355
  def probe(argv, *, env, timeout):
189
- return completed(argv, stdout=b"railmux 0.4.0.dev4\n")
356
+ return completed(argv, stdout=b"railmux 0.4.0.dev5\n")
190
357
 
191
358
  @contextmanager
192
359
  def unlocked(_base):
@@ -1 +0,0 @@
1
- __version__ = "0.4.0.dev4"
File without changes
File without changes
File without changes
File without changes