railmux 0.4.0.dev5__tar.gz → 0.4.0.dev6__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.dev5/src/railmux.egg-info → railmux-0.4.0.dev6}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev6/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/windows_msys2.py +424 -5
  4. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6/src/railmux.egg-info}/PKG-INFO +1 -1
  5. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_windows_msys2.py +265 -9
  6. railmux-0.4.0.dev5/src/railmux/__init__.py +0 -1
  7. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/LICENSE +0 -0
  8. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/MANIFEST.in +0 -0
  9. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/README.md +0 -0
  10. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/pyproject.toml +0 -0
  11. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/setup.cfg +0 -0
  12. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/__main__.py +0 -0
  13. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/atomic_file.py +0 -0
  14. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/background_index.py +0 -0
  15. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/cli.py +0 -0
  16. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/codex_index.py +0 -0
  17. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/config.py +0 -0
  18. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/config_cli.py +0 -0
  19. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/diagnostics.py +0 -0
  20. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/discovery.py +0 -0
  21. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/display_transport.py +0 -0
  22. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/entrypoint.py +0 -0
  23. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_client.py +0 -0
  24. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_history.py +0 -0
  25. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_input.py +0 -0
  26. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_protocol.py +0 -0
  27. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fast_display_server.py +0 -0
  28. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/favorites.py +0 -0
  29. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/fuzzy.py +0 -0
  30. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/help_workspace.py +0 -0
  31. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/launcher.py +0 -0
  32. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/legacy_sessions.py +0 -0
  33. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/local_clipboard.py +0 -0
  34. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/local_open.py +0 -0
  35. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/models.py +0 -0
  36. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/modes.py +0 -0
  37. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/mouse_manager.py +0 -0
  38. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/orphan_marker.py +0 -0
  39. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/pane_surface.py +0 -0
  40. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/path_codec.py +0 -0
  41. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/provider_paths.py +0 -0
  42. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/remote_config.py +0 -0
  43. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/renames.py +0 -0
  44. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/restart_state.py +0 -0
  45. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/runtime_config.py +0 -0
  46. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/scroll_agent.py +0 -0
  47. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/scroll_manager.py +0 -0
  48. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/selection_isolation.py +0 -0
  49. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/self_update.py +0 -0
  50. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/session_cache.py +0 -0
  51. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/session_index.py +0 -0
  52. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/setting_contracts.py +0 -0
  53. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/settings.py +0 -0
  54. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ssh_args.py +0 -0
  55. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ssh_compat.py +0 -0
  56. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ssh_display_diagnostics.py +0 -0
  57. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ssh_doctor.py +0 -0
  58. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/system_deps.py +0 -0
  59. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/terminal_status.py +0 -0
  60. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tmux_binding_manager.py +0 -0
  61. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tmux_ctl.py +0 -0
  62. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tmux_health.py +0 -0
  63. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tmux_server.py +0 -0
  64. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/tool_panes.py +0 -0
  65. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/transcript.py +0 -0
  66. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/__init__.py +0 -0
  67. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/_widgets.py +0 -0
  68. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/app.py +0 -0
  69. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/keymap.py +0 -0
  70. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/modals.py +0 -0
  71. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/projects_pane.py +0 -0
  72. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/running_pane.py +0 -0
  73. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/sessions_pane.py +0 -0
  74. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/sidebar.py +0 -0
  75. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/statusbar.py +0 -0
  76. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/ui/workspace.py +0 -0
  77. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux/windows_bootstrap.py +0 -0
  78. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/SOURCES.txt +0 -0
  79. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/dependency_links.txt +0 -0
  80. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/entry_points.txt +0 -0
  81. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/requires.txt +0 -0
  82. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/src/railmux.egg-info/top_level.txt +0 -0
  83. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_agent_workspace.py +0 -0
  84. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_atomic_file.py +0 -0
  85. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_background_index.py +0 -0
  86. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_cli.py +0 -0
  87. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_codex_delete.py +0 -0
  88. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_codex_index.py +0 -0
  89. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_codex_lineage.py +0 -0
  90. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_codex_transcript.py +0 -0
  91. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_config.py +0 -0
  92. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_config_cli.py +0 -0
  93. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_denested_transport_benchmark.py +0 -0
  94. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_diagnostics.py +0 -0
  95. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_discovery.py +0 -0
  96. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_display_transport.py +0 -0
  97. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_fast_full_window.py +0 -0
  98. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_focus_highlight.py +0 -0
  99. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_fullscreen_binding.py +0 -0
  100. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_fuzzy.py +0 -0
  101. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_help_agent.py +0 -0
  102. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_help_workspace.py +0 -0
  103. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_history_cleanup.py +0 -0
  104. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_keymap.py +0 -0
  105. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_launcher.py +0 -0
  106. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_layout_profile.py +0 -0
  107. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_legacy_sessions.py +0 -0
  108. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_local_clipboard.py +0 -0
  109. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_local_open.py +0 -0
  110. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_modals.py +0 -0
  111. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_mode_selection.py +0 -0
  112. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_modes.py +0 -0
  113. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_mouse_manager.py +0 -0
  114. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_new_project.py +0 -0
  115. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_options.py +0 -0
  116. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_orphan_recovery.py +0 -0
  117. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_pane_surface.py +0 -0
  118. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_path_codec.py +0 -0
  119. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_projects_pane.py +0 -0
  120. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_provider_paths.py +0 -0
  121. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_release_notes.py +0 -0
  122. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_release_workflow.py +0 -0
  123. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_remote_config.py +0 -0
  124. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_renames.py +0 -0
  125. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_restart_state.py +0 -0
  126. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_running_pane.py +0 -0
  127. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_running_sort.py +0 -0
  128. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_runtime_config.py +0 -0
  129. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_scroll_agent.py +0 -0
  130. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_scroll_manager.py +0 -0
  131. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_selection_isolation.py +0 -0
  132. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_self_update.py +0 -0
  133. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_session_cache.py +0 -0
  134. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_session_index.py +0 -0
  135. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_sessions_pane.py +0 -0
  136. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_setting_contracts.py +0 -0
  137. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_settings.py +0 -0
  138. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_sidebar.py +0 -0
  139. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_soft_quit.py +0 -0
  140. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_args.py +0 -0
  141. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_compat.py +0 -0
  142. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_display_benchmark.py +0 -0
  143. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_display_diagnostics.py +0 -0
  144. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_ssh_doctor.py +0 -0
  145. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_status.py +0 -0
  146. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_statusbar.py +0 -0
  147. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_system_deps.py +0 -0
  148. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_terminal_size.py +0 -0
  149. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_terminal_status.py +0 -0
  150. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_termux_local_touch.py +0 -0
  151. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_binding_manager.py +0 -0
  152. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_ctl.py +0 -0
  153. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_health.py +0 -0
  154. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_integration.py +0 -0
  155. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_server.py +0 -0
  156. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tmux_status.py +0 -0
  157. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_tool_panes.py +0 -0
  158. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_transcript.py +0 -0
  159. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_utf8_state.py +0 -0
  160. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_web_demo_recorder.py +0 -0
  161. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_wheel_smoke.py +0 -0
  162. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_widgets.py +0 -0
  163. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_windows_bootstrap.py +0 -0
  164. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_windows_wrapper_contract.py +0 -0
  165. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tests/test_yolo_prompt.py +0 -0
  166. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tools/denested_transport_benchmark.py +0 -0
  167. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/tools/release_notes.py +0 -0
  168. {railmux-0.4.0.dev5 → railmux-0.4.0.dev6}/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.dev5
3
+ Version: 0.4.0.dev6
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.dev6"
@@ -6,6 +6,7 @@ remain Windows-native.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ import concurrent.futures
9
10
  import hashlib
10
11
  import http.client
11
12
  import json
@@ -63,6 +64,13 @@ _HANDOFF_COMMAND = (
63
64
  _PROBE_TIMEOUT_SECONDS = 15.0
64
65
  _DOWNLOAD_LIMIT = 128 * 1024 * 1024
65
66
  _DOWNLOAD_LOG_STEP = 8 * 1024 * 1024
67
+ _DOWNLOAD_PROBE_BYTES = 1024 * 1024
68
+ _DOWNLOAD_PROBE_SECONDS = 8.0
69
+ _DOWNLOAD_PROBE_READ_SIZE = 64 * 1024
70
+ _DOWNLOAD_PROBE_TIMEOUT = 10.0
71
+ _DOWNLOAD_SLOW_REMAINING_SECONDS = 60.0
72
+ _DOWNLOAD_SWITCH_RATIO = 1.25
73
+ _CONTENT_RANGE_RE = re.compile(r"bytes (\d+)-(\d+)/(\d+)\Z")
66
74
  _VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?\Z")
67
75
 
68
76
  _INITIAL_UPDATE_COMMAND = (
@@ -92,6 +100,22 @@ Runner = Callable[..., subprocess.CompletedProcess]
92
100
  Downloader = Callable[[str, Path, str], None]
93
101
 
94
102
 
103
+ @dataclass(frozen=True)
104
+ class _ArchiveProbe:
105
+ label: str
106
+ url: str
107
+ data: bytes
108
+ elapsed: float
109
+
110
+ @property
111
+ def rate(self) -> float:
112
+ return len(self.data) / max(self.elapsed, 0.001)
113
+
114
+
115
+ ArchiveProbe = Callable[[str, str, int], _ArchiveProbe]
116
+ ArchiveResume = Callable[[str, str, int, int, Callable[[bytes], None]], None]
117
+
118
+
95
119
  def _format_download_size(size: int) -> str:
96
120
  if size >= 1024 * 1024:
97
121
  return f"{size / (1024 * 1024):.1f} MiB"
@@ -147,6 +171,198 @@ class _DownloadProgress:
147
171
  self._active_line = False
148
172
 
149
173
 
174
+ def _format_download_rate(rate: float) -> str:
175
+ return f"{_format_download_size(int(rate))}/s"
176
+
177
+
178
+ def _validate_https_response(url: str, response: object) -> None:
179
+ if urllib.parse.urlsplit(url).scheme.lower() != "https":
180
+ raise RuntimeInstallError("the MSYS2 runtime source did not use HTTPS")
181
+ final_url = getattr(response, "geturl", lambda: url)()
182
+ if urllib.parse.urlsplit(final_url).scheme.lower() != "https":
183
+ raise RuntimeInstallError(
184
+ "the MSYS2 runtime source redirected outside HTTPS"
185
+ )
186
+
187
+
188
+ def _validate_range_response(
189
+ url: str,
190
+ response: object,
191
+ *,
192
+ start: int,
193
+ end: int,
194
+ expected_size: int,
195
+ ) -> None:
196
+ _validate_https_response(url, response)
197
+ if getattr(response, "status", None) != 206:
198
+ raise RuntimeInstallError("the MSYS2 source did not honor a range request")
199
+ raw_range = response.headers.get("Content-Range", "")
200
+ match = _CONTENT_RANGE_RE.fullmatch(raw_range)
201
+ if match is None:
202
+ raise RuntimeInstallError("the MSYS2 source returned an invalid content range")
203
+ actual_start, actual_end, actual_size = (int(value) for value in match.groups())
204
+ if (
205
+ actual_start != start
206
+ or actual_end != end
207
+ or actual_size != expected_size
208
+ ):
209
+ raise RuntimeInstallError("the MSYS2 source returned the wrong content range")
210
+ raw_length = response.headers.get("Content-Length")
211
+ if raw_length is not None:
212
+ try:
213
+ actual_length = int(raw_length)
214
+ except (TypeError, ValueError):
215
+ actual_length = -1
216
+ if actual_length != end - start + 1:
217
+ raise RuntimeInstallError("the MSYS2 source returned the wrong range size")
218
+
219
+
220
+ def _range_request(url: str, *, start: int, end: int) -> urllib.request.Request:
221
+ return urllib.request.Request(
222
+ url,
223
+ headers={
224
+ "Accept-Encoding": "identity",
225
+ "Range": f"bytes={start}-{end}",
226
+ "User-Agent": "Railmux-MSYS2-bootstrap/1",
227
+ },
228
+ )
229
+
230
+
231
+ def _probe_archive_source(
232
+ label: str,
233
+ url: str,
234
+ expected_size: int,
235
+ *,
236
+ opener: Callable[..., object] = urllib.request.urlopen,
237
+ clock: Callable[[], float] = time.monotonic,
238
+ ) -> _ArchiveProbe:
239
+ """Read a bounded prefix used both for selection and final assembly."""
240
+ end = min(_DOWNLOAD_PROBE_BYTES, expected_size) - 1
241
+ request = _range_request(url, start=0, end=end)
242
+ started = clock()
243
+ data = bytearray()
244
+ try:
245
+ with opener(request, timeout=_DOWNLOAD_PROBE_TIMEOUT) as response:
246
+ _validate_range_response(
247
+ url,
248
+ response,
249
+ start=0,
250
+ end=end,
251
+ expected_size=expected_size,
252
+ )
253
+ while len(data) <= end:
254
+ remaining = end + 1 - len(data)
255
+ try:
256
+ chunk = response.read(
257
+ min(_DOWNLOAD_PROBE_READ_SIZE, remaining)
258
+ )
259
+ except (OSError, http.client.HTTPException):
260
+ if data:
261
+ break
262
+ raise
263
+ if not chunk:
264
+ break
265
+ data.extend(chunk)
266
+ if clock() - started >= _DOWNLOAD_PROBE_SECONDS:
267
+ break
268
+ except RuntimeInstallError:
269
+ raise
270
+ except (OSError, urllib.error.URLError, http.client.HTTPException) as exc:
271
+ raise RuntimeInstallError("could not probe the MSYS2 source") from exc
272
+ if not data:
273
+ raise RuntimeInstallError("the MSYS2 source returned no probe data")
274
+ return _ArchiveProbe(
275
+ label=label,
276
+ url=url,
277
+ data=bytes(data),
278
+ elapsed=max(clock() - started, 0.001),
279
+ )
280
+
281
+
282
+ def _resume_archive_source(
283
+ _label: str,
284
+ url: str,
285
+ offset: int,
286
+ expected_size: int,
287
+ write_chunk: Callable[[bytes], None],
288
+ *,
289
+ opener: Callable[..., object] = urllib.request.urlopen,
290
+ ) -> None:
291
+ """Append the exact remaining range from one approved source."""
292
+ if offset >= expected_size:
293
+ return
294
+ request = _range_request(url, start=offset, end=expected_size - 1)
295
+ try:
296
+ with opener(request, timeout=60) as response:
297
+ _validate_range_response(
298
+ url,
299
+ response,
300
+ start=offset,
301
+ end=expected_size - 1,
302
+ expected_size=expected_size,
303
+ )
304
+ while True:
305
+ chunk = response.read(1024 * 1024)
306
+ if not chunk:
307
+ break
308
+ write_chunk(chunk)
309
+ except RuntimeInstallError:
310
+ raise
311
+ except (OSError, urllib.error.URLError, http.client.HTTPException) as exc:
312
+ raise RuntimeInstallError("could not resume the MSYS2 source") from exc
313
+
314
+
315
+ def _probe_other_sources(
316
+ sources: Sequence[tuple[str, str]],
317
+ *,
318
+ expected_size: int,
319
+ probe_source: ArchiveProbe,
320
+ ) -> list[_ArchiveProbe]:
321
+ if not sources:
322
+ return []
323
+ results: list[_ArchiveProbe] = []
324
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(sources)) as pool:
325
+ futures = {
326
+ pool.submit(probe_source, label, url, expected_size): label
327
+ for label, url in sources
328
+ }
329
+ for future in concurrent.futures.as_completed(futures):
330
+ label = futures[future]
331
+ try:
332
+ result = future.result()
333
+ except RuntimeInstallError as exc:
334
+ print(f" {label}: unavailable ({exc})", file=sys.stderr)
335
+ else:
336
+ results.append(result)
337
+ print(
338
+ f" {label}: {_format_download_rate(result.rate)}",
339
+ file=sys.stderr,
340
+ )
341
+ return results
342
+
343
+
344
+ def _probe_is_slow(probe: _ArchiveProbe, *, expected_size: int) -> bool:
345
+ remaining = max(0, expected_size - len(probe.data))
346
+ return remaining / probe.rate > _DOWNLOAD_SLOW_REMAINING_SECONDS
347
+
348
+
349
+ def _select_probe(
350
+ primary: _ArchiveProbe | None,
351
+ alternatives: Sequence[_ArchiveProbe],
352
+ ) -> _ArchiveProbe:
353
+ candidates = ([primary] if primary is not None else []) + list(alternatives)
354
+ if not candidates:
355
+ raise RuntimeInstallError("no approved MSYS2 source could be probed")
356
+ fastest = max(candidates, key=lambda candidate: candidate.rate)
357
+ if (
358
+ primary is not None
359
+ and fastest is not primary
360
+ and fastest.rate < primary.rate * _DOWNLOAD_SWITCH_RATIO
361
+ ):
362
+ return primary
363
+ return fastest
364
+
365
+
150
366
  @dataclass(frozen=True)
151
367
  class Msys2Runtime:
152
368
  root: Path
@@ -361,17 +577,183 @@ def download_verified(url: str, destination: Path, sha256: str) -> None:
361
577
  progress.update(total, expected, final=True)
362
578
 
363
579
 
364
- def download_from_sources(
580
+ def download_adaptive(
365
581
  destination: Path,
366
582
  sha256: str,
367
583
  *,
368
584
  sources: Sequence[tuple[str, str]] = MSYS2_ARCHIVE_SOURCES,
369
- downloader: Downloader = download_verified,
585
+ expected_size: int = MSYS2_ARCHIVE_SIZE,
586
+ probe_source: ArchiveProbe = _probe_archive_source,
587
+ resume_source: ArchiveResume = _resume_archive_source,
588
+ ) -> str:
589
+ """Select a responsive approved source, then resume across failures."""
590
+ if not sources:
591
+ raise RuntimeInstallError("no approved MSYS2 download source is configured")
592
+ if expected_size <= 0 or expected_size > _DOWNLOAD_LIMIT:
593
+ raise RuntimeInstallError("the pinned MSYS2 archive size is invalid")
594
+ for _label, url in sources:
595
+ if urllib.parse.urlsplit(url).scheme.lower() != "https":
596
+ raise RuntimeInstallError("the MSYS2 runtime source did not use HTTPS")
597
+
598
+ primary: _ArchiveProbe | None = None
599
+ remaining_sources = list(sources[1:])
600
+ primary_label, primary_url = sources[0]
601
+ print(f"Probing {primary_label}…", flush=True)
602
+ try:
603
+ primary = probe_source(primary_label, primary_url, expected_size)
604
+ except RuntimeInstallError as exc:
605
+ print(
606
+ f"{primary_label} probe failed ({exc}); checking approved alternatives…",
607
+ file=sys.stderr,
608
+ )
609
+ else:
610
+ print(
611
+ f" {primary.label}: {_format_download_rate(primary.rate)}",
612
+ file=sys.stderr,
613
+ )
614
+
615
+ alternatives: list[_ArchiveProbe] = []
616
+ if primary is None or _probe_is_slow(primary, expected_size=expected_size):
617
+ if primary is not None:
618
+ remaining_seconds = (
619
+ expected_size - len(primary.data)
620
+ ) / primary.rate
621
+ print(
622
+ f"{primary.label} is slow (~{remaining_seconds:.0f}s remaining); "
623
+ "checking approved alternatives…",
624
+ file=sys.stderr,
625
+ )
626
+ alternatives = _probe_other_sources(
627
+ remaining_sources,
628
+ expected_size=expected_size,
629
+ probe_source=probe_source,
630
+ )
631
+ selected = _select_probe(primary, alternatives)
632
+ if primary is not None and selected is not primary:
633
+ print(
634
+ f"Switching to {selected.label} "
635
+ f"({_format_download_rate(selected.rate)}).",
636
+ file=sys.stderr,
637
+ )
638
+ elif alternatives:
639
+ print(
640
+ f"Continuing with {selected.label}; no approved source was "
641
+ "materially faster.",
642
+ file=sys.stderr,
643
+ )
644
+
645
+ measured = {probe.url: probe for probe in alternatives}
646
+ if primary is not None:
647
+ measured[primary.url] = primary
648
+ ordered = [selected]
649
+ ordered.extend(
650
+ probe
651
+ for probe in sorted(
652
+ measured.values(),
653
+ key=lambda candidate: candidate.rate,
654
+ reverse=True,
655
+ )
656
+ if probe.url != selected.url
657
+ )
658
+ measured_urls = set(measured)
659
+ ordered.extend(
660
+ _ArchiveProbe(label, url, b"", 1.0)
661
+ for label, url in sources
662
+ if url not in measured_urls
663
+ )
664
+
665
+ digest = hashlib.sha256()
666
+ offset = 0
667
+ try:
668
+ with destination.open("xb") as output:
669
+ output.write(selected.data)
670
+ digest.update(selected.data)
671
+ offset = len(selected.data)
672
+ if offset > expected_size:
673
+ raise RuntimeInstallError("the MSYS2 probe exceeded the pinned size")
674
+
675
+ last_error: RuntimeInstallError | None = None
676
+ for candidate in ordered:
677
+ progress = _DownloadProgress(candidate.url)
678
+ progress.update(offset, expected_size)
679
+
680
+ def write_chunk(chunk: bytes) -> None:
681
+ nonlocal offset
682
+ if offset + len(chunk) > expected_size:
683
+ raise RuntimeInstallError(
684
+ "the MSYS2 download exceeded its pinned size"
685
+ )
686
+ output.write(chunk)
687
+ digest.update(chunk)
688
+ offset += len(chunk)
689
+ progress.update(offset, expected_size)
690
+
691
+ try:
692
+ resume_source(
693
+ candidate.label,
694
+ candidate.url,
695
+ offset,
696
+ expected_size,
697
+ write_chunk,
698
+ )
699
+ except RuntimeInstallError as exc:
700
+ last_error = exc
701
+ progress.abort()
702
+ print(
703
+ f"{candidate.label} transfer failed ({exc}); "
704
+ "resuming from another approved source…",
705
+ file=sys.stderr,
706
+ )
707
+ continue
708
+ if offset != expected_size:
709
+ last_error = RuntimeInstallError(
710
+ "the MSYS2 source ended before the pinned size"
711
+ )
712
+ progress.abort()
713
+ print(
714
+ f"{candidate.label} transfer ended early; resuming "
715
+ "from another approved source…",
716
+ file=sys.stderr,
717
+ )
718
+ continue
719
+ progress.update(offset, expected_size, final=True)
720
+ break
721
+ else:
722
+ raise RuntimeInstallError(
723
+ "every approved MSYS2 source failed during transfer"
724
+ ) from last_error
725
+
726
+ if digest.hexdigest().lower() != sha256.lower():
727
+ raise RuntimeInstallError(
728
+ "the downloaded MSYS2 archive failed SHA-256 verification"
729
+ )
730
+ except (RuntimeInstallError, OSError) as exc:
731
+ try:
732
+ destination.unlink(missing_ok=True)
733
+ except OSError as cleanup_error:
734
+ raise RuntimeInstallError(
735
+ "could not remove an incomplete MSYS2 download"
736
+ ) from cleanup_error
737
+ if isinstance(exc, RuntimeInstallError):
738
+ raise
739
+ raise RuntimeInstallError("could not write the pinned MSYS2 runtime") from exc
740
+ return selected.label
741
+
742
+
743
+ def _download_from_sources_sequentially(
744
+ destination: Path,
745
+ sha256: str,
746
+ *,
747
+ sources: Sequence[tuple[str, str]],
748
+ downloader: Downloader,
370
749
  ) -> str:
371
- """Try approved transports in order; every source uses the same pinned hash."""
750
+ """Compatibility fallback when range selection is unavailable."""
372
751
  last_error: RuntimeInstallError | None = None
373
752
  for index, (label, url) in enumerate(sources, start=1):
374
- print(f"Downloading from {label} ({index}/{len(sources)})…")
753
+ print(
754
+ f"Downloading from {label} ({index}/{len(sources)})…",
755
+ flush=True,
756
+ )
375
757
  try:
376
758
  downloader(url, destination, sha256)
377
759
  except RuntimeInstallError as exc:
@@ -394,6 +776,43 @@ def download_from_sources(
394
776
  ) from last_error
395
777
 
396
778
 
779
+ def download_from_sources(
780
+ destination: Path,
781
+ sha256: str,
782
+ *,
783
+ sources: Sequence[tuple[str, str]] = MSYS2_ARCHIVE_SOURCES,
784
+ downloader: Downloader | None = None,
785
+ ) -> str:
786
+ """Use adaptive range selection, retaining a full-download fallback."""
787
+ if downloader is not None:
788
+ return _download_from_sources_sequentially(
789
+ destination,
790
+ sha256,
791
+ sources=sources,
792
+ downloader=downloader,
793
+ )
794
+ try:
795
+ return download_adaptive(destination, sha256, sources=sources)
796
+ except RuntimeInstallError as exc:
797
+ print(
798
+ f"Adaptive MSYS2 download failed ({exc}); retrying ordinary "
799
+ "approved-source downloads…",
800
+ file=sys.stderr,
801
+ )
802
+ try:
803
+ destination.unlink(missing_ok=True)
804
+ except OSError as cleanup_error:
805
+ raise RuntimeInstallError(
806
+ "could not remove an incomplete MSYS2 download"
807
+ ) from cleanup_error
808
+ return _download_from_sources_sequentially(
809
+ destination,
810
+ sha256,
811
+ sources=sources,
812
+ downloader=download_verified,
813
+ )
814
+
815
+
397
816
  def _run_checked(
398
817
  argv: Sequence[str],
399
818
  *,
@@ -471,7 +890,7 @@ def install_managed_runtime(
471
890
  *,
472
891
  version: str,
473
892
  environ: Mapping[str, str],
474
- downloader: Downloader = download_verified,
893
+ downloader: Downloader | None = None,
475
894
  runner: Runner = subprocess.run,
476
895
  probe: Probe = _probe,
477
896
  lock_factory: Callable[[Path], object] = install_lock,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev5
3
+ Version: 0.4.0.dev6
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