railmux 0.4.0.dev28__tar.gz → 0.4.0.dev30__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 (183) hide show
  1. {railmux-0.4.0.dev28/src/railmux.egg-info → railmux-0.4.0.dev30}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev30/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/windows_install_log.py +27 -9
  4. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/windows_msys2.py +317 -14
  5. railmux-0.4.0.dev30/src/railmux/windows_paths.py +73 -0
  6. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30/src/railmux.egg-info}/PKG-INFO +1 -1
  7. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux.egg-info/SOURCES.txt +2 -0
  8. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_windows_install_log.py +55 -2
  9. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_windows_msys2.py +331 -3
  10. railmux-0.4.0.dev30/tests/test_windows_paths.py +83 -0
  11. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tools/windows_archive_smoke.py +37 -1
  12. railmux-0.4.0.dev28/src/railmux/__init__.py +0 -1
  13. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/LICENSE +0 -0
  14. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/MANIFEST.in +0 -0
  15. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/README.md +0 -0
  16. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/pyproject.toml +0 -0
  17. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/setup.cfg +0 -0
  18. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/__main__.py +0 -0
  19. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/atomic_file.py +0 -0
  20. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/background_index.py +0 -0
  21. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/cli.py +0 -0
  22. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/codex_index.py +0 -0
  23. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/config.py +0 -0
  24. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/config_cli.py +0 -0
  25. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/diagnostics.py +0 -0
  26. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/discovery.py +0 -0
  27. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/display_transport.py +0 -0
  28. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/entrypoint.py +0 -0
  29. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/fast_display_client.py +0 -0
  30. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/fast_display_history.py +0 -0
  31. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/fast_display_input.py +0 -0
  32. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/fast_display_protocol.py +0 -0
  33. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/fast_display_server.py +0 -0
  34. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/favorites.py +0 -0
  35. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/fuzzy.py +0 -0
  36. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/help_workspace.py +0 -0
  37. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/launcher.py +0 -0
  38. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/legacy_sessions.py +0 -0
  39. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/local_clipboard.py +0 -0
  40. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/local_open.py +0 -0
  41. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/models.py +0 -0
  42. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/modes.py +0 -0
  43. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/mouse_manager.py +0 -0
  44. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/orphan_marker.py +0 -0
  45. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/pane_surface.py +0 -0
  46. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/path_codec.py +0 -0
  47. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/provider_paths.py +0 -0
  48. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/remote_config.py +0 -0
  49. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/renames.py +0 -0
  50. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/restart_state.py +0 -0
  51. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/runtime_config.py +0 -0
  52. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/scroll_agent.py +0 -0
  53. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/scroll_manager.py +0 -0
  54. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/selection_isolation.py +0 -0
  55. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/self_update.py +0 -0
  56. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/session_cache.py +0 -0
  57. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/session_index.py +0 -0
  58. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/session_lease.py +0 -0
  59. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/setting_contracts.py +0 -0
  60. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/settings.py +0 -0
  61. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ssh_args.py +0 -0
  62. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ssh_compat.py +0 -0
  63. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ssh_display_diagnostics.py +0 -0
  64. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ssh_doctor.py +0 -0
  65. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/system_deps.py +0 -0
  66. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/terminal_status.py +0 -0
  67. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/tmux_binding_manager.py +0 -0
  68. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/tmux_ctl.py +0 -0
  69. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/tmux_health.py +0 -0
  70. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/tmux_server.py +0 -0
  71. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/tool_panes.py +0 -0
  72. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/transcript.py +0 -0
  73. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/__init__.py +0 -0
  74. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/_widgets.py +0 -0
  75. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/app.py +0 -0
  76. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/keymap.py +0 -0
  77. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/modals.py +0 -0
  78. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/projects_pane.py +0 -0
  79. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/running_pane.py +0 -0
  80. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/sessions_pane.py +0 -0
  81. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/sidebar.py +0 -0
  82. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/statusbar.py +0 -0
  83. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/ui/workspace.py +0 -0
  84. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/windows_attach_relay.py +0 -0
  85. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/windows_bootstrap.py +0 -0
  86. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/windows_pacman.py +0 -0
  87. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/windows_tmux_lifecycle.py +0 -0
  88. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux/windows_ui_transition.py +0 -0
  89. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux.egg-info/dependency_links.txt +0 -0
  90. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux.egg-info/entry_points.txt +0 -0
  91. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux.egg-info/requires.txt +0 -0
  92. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/src/railmux.egg-info/top_level.txt +0 -0
  93. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_agent_workspace.py +0 -0
  94. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_atomic_file.py +0 -0
  95. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_background_index.py +0 -0
  96. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_cli.py +0 -0
  97. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_codex_delete.py +0 -0
  98. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_codex_index.py +0 -0
  99. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_codex_lineage.py +0 -0
  100. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_codex_transcript.py +0 -0
  101. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_config.py +0 -0
  102. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_config_cli.py +0 -0
  103. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_denested_transport_benchmark.py +0 -0
  104. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_diagnostics.py +0 -0
  105. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_discovery.py +0 -0
  106. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_display_transport.py +0 -0
  107. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_fast_full_window.py +0 -0
  108. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_focus_highlight.py +0 -0
  109. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_fullscreen_binding.py +0 -0
  110. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_fuzzy.py +0 -0
  111. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_help_agent.py +0 -0
  112. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_help_workspace.py +0 -0
  113. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_history_cleanup.py +0 -0
  114. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_keymap.py +0 -0
  115. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_launcher.py +0 -0
  116. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_layout_profile.py +0 -0
  117. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_legacy_sessions.py +0 -0
  118. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_local_clipboard.py +0 -0
  119. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_local_open.py +0 -0
  120. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_modals.py +0 -0
  121. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_mode_selection.py +0 -0
  122. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_modes.py +0 -0
  123. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_mouse_manager.py +0 -0
  124. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_new_project.py +0 -0
  125. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_options.py +0 -0
  126. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_orphan_recovery.py +0 -0
  127. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_pane_surface.py +0 -0
  128. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_path_codec.py +0 -0
  129. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_projects_pane.py +0 -0
  130. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_provider_paths.py +0 -0
  131. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_release_notes.py +0 -0
  132. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_release_workflow.py +0 -0
  133. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_remote_config.py +0 -0
  134. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_renames.py +0 -0
  135. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_restart_state.py +0 -0
  136. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_running_pane.py +0 -0
  137. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_running_sort.py +0 -0
  138. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_runtime_config.py +0 -0
  139. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_scroll_agent.py +0 -0
  140. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_scroll_manager.py +0 -0
  141. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_selection_isolation.py +0 -0
  142. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_self_update.py +0 -0
  143. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_session_cache.py +0 -0
  144. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_session_index.py +0 -0
  145. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_session_lease.py +0 -0
  146. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_sessions_pane.py +0 -0
  147. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_setting_contracts.py +0 -0
  148. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_settings.py +0 -0
  149. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_sidebar.py +0 -0
  150. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_soft_quit.py +0 -0
  151. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_ssh_args.py +0 -0
  152. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_ssh_compat.py +0 -0
  153. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_ssh_display_benchmark.py +0 -0
  154. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_ssh_display_diagnostics.py +0 -0
  155. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_ssh_doctor.py +0 -0
  156. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_status.py +0 -0
  157. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_statusbar.py +0 -0
  158. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_system_deps.py +0 -0
  159. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_terminal_size.py +0 -0
  160. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_terminal_status.py +0 -0
  161. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_termux_local_touch.py +0 -0
  162. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_tmux_binding_manager.py +0 -0
  163. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_tmux_ctl.py +0 -0
  164. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_tmux_health.py +0 -0
  165. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_tmux_integration.py +0 -0
  166. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_tmux_server.py +0 -0
  167. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_tmux_status.py +0 -0
  168. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_tool_panes.py +0 -0
  169. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_transcript.py +0 -0
  170. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_utf8_state.py +0 -0
  171. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_web_demo_recorder.py +0 -0
  172. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_wheel_smoke.py +0 -0
  173. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_widgets.py +0 -0
  174. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_windows_attach_relay.py +0 -0
  175. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_windows_bootstrap.py +0 -0
  176. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_windows_pacman.py +0 -0
  177. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_windows_tmux_lifecycle.py +0 -0
  178. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_windows_ui_transition.py +0 -0
  179. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_windows_wrapper_contract.py +0 -0
  180. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tests/test_yolo_prompt.py +0 -0
  181. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tools/denested_transport_benchmark.py +0 -0
  182. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/tools/release_notes.py +0 -0
  183. {railmux-0.4.0.dev28 → railmux-0.4.0.dev30}/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.dev28
3
+ Version: 0.4.0.dev30
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.dev30"
@@ -22,6 +22,7 @@ from railmux.terminal_status import (
22
22
  STYLE_WARNING,
23
23
  styled,
24
24
  )
25
+ from railmux.windows_paths import managed_windows_data_root
25
26
 
26
27
 
27
28
  _LOG_KEEP_COUNT = 5
@@ -60,6 +61,7 @@ _MSYS2_RESTART_MARKER = (
60
61
  "to complete this update all msys2 processes including this terminal "
61
62
  "will be closed"
62
63
  )
64
+ _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
63
65
  _EXTRACTION_PERCENT_RE = re.compile(r"(?:^|\s)([0-9]{1,3})%\s")
64
66
  _PACKAGE_COUNT_RE = re.compile(r"^Packages \(([0-9]+)\)")
65
67
  _PACKAGE_DOWNLOAD_RE = re.compile(r"^\s*([^ ]+) downloading\.\.\.\s*$")
@@ -78,20 +80,25 @@ def install_log_path(
78
80
  *,
79
81
  version: str,
80
82
  clock: Callable[[], float] = time.time,
83
+ data_root: Path | None = None,
81
84
  ) -> Path:
82
- local_app_data = environ.get("LOCALAPPDATA", "").strip()
83
- if not local_app_data:
84
- raise OSError("LOCALAPPDATA is unavailable")
85
+ root = managed_windows_data_root(environ) if data_root is None else data_root
86
+ if root is None:
87
+ raise OSError("a non-virtualized Windows data directory is unavailable")
85
88
  stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(clock()))
86
89
  return (
87
- Path(local_app_data)
88
- / "Railmux"
90
+ root
89
91
  / "logs"
90
92
  / f"install-{version}-{stamp}-{os.getpid()}.log"
91
93
  )
92
94
 
93
95
 
94
- def _prune_install_logs(directory: Path, *, keep: int = _LOG_KEEP_COUNT) -> None:
96
+ def _prune_install_logs(
97
+ directory: Path,
98
+ *,
99
+ keep: int = _LOG_KEEP_COUNT,
100
+ preserve: Path | None = None,
101
+ ) -> None:
95
102
  candidates: list[Path] = []
96
103
  try:
97
104
  entries = list(directory.iterdir())
@@ -107,7 +114,11 @@ def _prune_install_logs(directory: Path, *, keep: int = _LOG_KEEP_COUNT) -> None
107
114
  )
108
115
  except OSError:
109
116
  return
110
- for stale in candidates[max(keep, 1) :]:
117
+ preserved = preserve if preserve in candidates else None
118
+ others = [candidate for candidate in candidates if candidate != preserved]
119
+ other_limit = max(keep, 1) - (1 if preserved is not None else 0)
120
+ stale_candidates = others[max(other_limit, 0) :]
121
+ for stale in stale_candidates:
111
122
  try:
112
123
  stale.unlink()
113
124
  except OSError:
@@ -147,12 +158,15 @@ class InstallReporter:
147
158
  self._pip_network_failure = False
148
159
  self._hard_failed_hosts: set[str] = set()
149
160
  self._msys2_restart_requested = False
161
+ self._msys2_restart_lines: deque[str] = deque(maxlen=3)
150
162
 
151
163
  def __enter__(self) -> InstallReporter:
152
164
  self.path.parent.mkdir(parents=True, exist_ok=True)
153
165
  self._log = self.path.open("x", encoding="utf-8", newline="\n")
154
166
  self._write_log("Railmux Windows runtime installation\n")
155
- _prune_install_logs(self.path.parent)
167
+ # Windows may defer the visible last-write timestamp of an open file.
168
+ # Never let retention mistake the active evidence log for an old one.
169
+ _prune_install_logs(self.path.parent, preserve=self.path)
156
170
  return self
157
171
 
158
172
  def __exit__(self, _exc_type, _exc, _traceback) -> None:
@@ -215,6 +229,7 @@ class InstallReporter:
215
229
  self._pip_network_failure = False
216
230
  self._hard_failed_hosts.clear()
217
231
  self._msys2_restart_requested = False
232
+ self._msys2_restart_lines.clear()
218
233
  self._write_log(f"\n--- {label} ---\n")
219
234
 
220
235
  def command_output(self, output: bytes | str | None) -> None:
@@ -265,7 +280,10 @@ class InstallReporter:
265
280
  if line.strip():
266
281
  self._tail.append(line)
267
282
  lowered = line.lower()
268
- if _MSYS2_RESTART_MARKER in lowered:
283
+ normalized = " ".join(_ANSI_ESCAPE_RE.sub("", lowered).split())
284
+ if normalized:
285
+ self._msys2_restart_lines.append(normalized)
286
+ if _MSYS2_RESTART_MARKER in " ".join(self._msys2_restart_lines):
269
287
  self._msys2_restart_requested = True
270
288
  if any(marker in lowered for marker in _NETWORK_ERROR_MARKERS):
271
289
  self._network_failure = True
@@ -42,6 +42,10 @@ from railmux.windows_pacman import (
42
42
  validate_transaction_package_mirrors,
43
43
  write_msys_only_pacman_config,
44
44
  )
45
+ from railmux.windows_paths import (
46
+ legacy_local_app_data_root,
47
+ managed_windows_data_root,
48
+ )
45
49
  from railmux.terminal_status import STYLE_ACCENT, STYLE_MUTED, styled
46
50
 
47
51
 
@@ -120,6 +124,28 @@ _PACKAGE_INVENTORY_COMMAND = "set -o pipefail; pacman -Q | LC_ALL=C sort"
120
124
  _PACKAGE_INVENTORY_LIMIT = 1024 * 1024
121
125
  _CORE_RUNTIME_PACKAGES = ("tmux", "python", "python-pip")
122
126
  _MAX_BASE_UPDATE_PASSES = 3
127
+ _PACMAN_LOG_RELATIVE = Path("var") / "log" / "pacman.log"
128
+ _PACMAN_LOG_MAX_SIZE = 8 * 1024 * 1024
129
+ _PACMAN_LOG_APPEND_LIMIT = 2 * 1024 * 1024
130
+ _PACMAN_LOCAL_DB_ENTRY_LIMIT = 4096
131
+ _PACMAN_LOCAL_DESC_LIMIT = 64 * 1024
132
+ # Keep this aligned with alpm_pkg_is_core_package in the pacman build shipped
133
+ # by the pinned base. Any one of these packages makes MSYS2 close its processes.
134
+ _PACMAN_CORE_PACKAGES = frozenset(
135
+ {
136
+ "bash",
137
+ "filesystem",
138
+ "mintty",
139
+ "msys2-runtime",
140
+ "msys2-runtime-devel",
141
+ "pacman",
142
+ "pacman-mirrors",
143
+ }
144
+ )
145
+ _PACMAN_CORE_CHANGE_RE = re.compile(
146
+ r"\[ALPM] (?:upgraded|downgraded) ([^ ]+) \(([^ ]+) -> ([^)]+)\)",
147
+ re.IGNORECASE,
148
+ )
123
149
  _PRIVATE_GPG_SHUTDOWN_COMMAND = (
124
150
  "gpgconf --homedir /etc/pacman.d/gnupg --kill all"
125
151
  )
@@ -200,6 +226,16 @@ ArchiveProbe = Callable[[str, str, int], _ArchiveProbe]
200
226
  ArchiveResume = Callable[[str, str, int, int, Callable[[bytes], None]], None]
201
227
 
202
228
 
229
+ @dataclass(frozen=True)
230
+ class _PacmanLogCheckpoint:
231
+ existed: bool
232
+ size: int
233
+ identity: tuple[int, int] | None
234
+ tail_offset: int
235
+ tail_digest: str
236
+ core_versions: tuple[tuple[str, str], ...]
237
+
238
+
203
239
  def _format_download_size(size: int) -> str:
204
240
  if size >= 1024 * 1024:
205
241
  return f"{size / (1024 * 1024):.1f} MiB"
@@ -522,17 +558,17 @@ class Msys2Runtime:
522
558
 
523
559
 
524
560
  def _managed_base(environ: Mapping[str, str]) -> Path | None:
525
- local_app_data = environ.get("LOCALAPPDATA", "").strip()
526
- if not local_app_data:
561
+ data_root = managed_windows_data_root(environ)
562
+ if data_root is None:
527
563
  return None
528
- return Path(local_app_data) / "Railmux" / "runtimes"
564
+ return data_root / "runtimes"
529
565
 
530
566
 
531
567
  def _managed_cache(environ: Mapping[str, str]) -> Path | None:
532
- local_app_data = environ.get("LOCALAPPDATA", "").strip()
533
- if not local_app_data:
568
+ data_root = managed_windows_data_root(environ)
569
+ if data_root is None:
534
570
  return None
535
- return Path(local_app_data) / "Railmux" / "cache"
571
+ return data_root / "cache"
536
572
 
537
573
 
538
574
  def _path_is_link_or_reparse(path: Path) -> bool:
@@ -563,6 +599,7 @@ def _prepare_cached_archive(
563
599
  cache: Path,
564
600
  *,
565
601
  downloader: Downloader | None,
602
+ prior_caches: Sequence[Path] = (),
566
603
  ) -> tuple[Path, str]:
567
604
  cache.mkdir(parents=True, exist_ok=True)
568
605
  archive = cache / MSYS2_ARCHIVE_NAME
@@ -572,6 +609,18 @@ def _prepare_cached_archive(
572
609
  archive.unlink(missing_ok=True)
573
610
  except OSError as exc:
574
611
  raise RuntimeInstallError("could not replace the private archive cache") from exc
612
+ for prior_cache in prior_caches:
613
+ prior_archive = prior_cache / MSYS2_ARCHIVE_NAME
614
+ if prior_archive == archive or not _archive_cache_is_valid(prior_archive):
615
+ continue
616
+ try:
617
+ shutil.copyfile(prior_archive, archive)
618
+ except OSError:
619
+ archive.unlink(missing_ok=True)
620
+ continue
621
+ if _archive_cache_is_valid(archive):
622
+ return archive, "verified prior local cache"
623
+ archive.unlink(missing_ok=True)
575
624
  source = download_from_sources(
576
625
  archive,
577
626
  MSYS2_ARCHIVE_SHA256,
@@ -1146,7 +1195,9 @@ def apply_managed_runtime_prune(
1146
1195
  """Re-plan under the install lock, then remove only unchanged candidates."""
1147
1196
  base = _managed_base(environ)
1148
1197
  if base is None:
1149
- raise RuntimeInstallError("LOCALAPPDATA is unavailable")
1198
+ raise RuntimeInstallError(
1199
+ "a non-virtualized Windows user data directory is unavailable"
1200
+ )
1150
1201
  effective_lock = install_lock if lock_factory is None else lock_factory
1151
1202
  with effective_lock(base):
1152
1203
  current = plan_managed_runtime_prune(
@@ -1638,9 +1689,11 @@ def _run_checked(
1638
1689
  def _explain_windows_status(returncode: int, reporter: InstallReporter) -> None:
1639
1690
  if _NATIVE_WINDOWS and returncode & 0xFFFFFFFF == 0xC0000135:
1640
1691
  reporter.note(
1641
- "Windows reported STATUS_DLL_NOT_FOUND outside a confirmed MSYS2 "
1642
- "core-update restart. Check whether security software quarantined "
1643
- "a file in the private MSYS2 staging tree.",
1692
+ "Windows reported STATUS_DLL_NOT_FOUND, but this pass had neither "
1693
+ "pacman's restart announcement nor a newly completed core "
1694
+ "transaction with a matching package-database version change. "
1695
+ "The temporary base will not be published; a missing or "
1696
+ "quarantined staged DLL remains possible.",
1644
1697
  level="error",
1645
1698
  )
1646
1699
 
@@ -1676,6 +1729,215 @@ def _report_mirror_decision(
1676
1729
  )
1677
1730
 
1678
1731
 
1732
+ def _pacman_desc_value(text: str, field: str) -> str | None:
1733
+ lines = text.splitlines()
1734
+ marker = f"%{field}%"
1735
+ for index, line in enumerate(lines[:-1]):
1736
+ if line == marker and lines[index + 1]:
1737
+ return lines[index + 1]
1738
+ return None
1739
+
1740
+
1741
+ def _pacman_core_versions(root: Path) -> dict[str, str]:
1742
+ local_db = root / "var" / "lib" / "pacman" / "local"
1743
+ try:
1744
+ if _path_is_link_or_reparse(local_db) or not local_db.is_dir():
1745
+ raise RuntimeInstallError("the private pacman database is unsafe")
1746
+ entries = list(local_db.iterdir())
1747
+ except FileNotFoundError:
1748
+ return {}
1749
+ except OSError as exc:
1750
+ raise RuntimeInstallError("could not inspect the private pacman database") from exc
1751
+ if len(entries) > _PACMAN_LOCAL_DB_ENTRY_LIMIT:
1752
+ raise RuntimeInstallError("the private pacman database is unexpectedly large")
1753
+ versions: dict[str, str] = {}
1754
+ for entry in entries:
1755
+ if not any(
1756
+ entry.name.startswith(prefix)
1757
+ for prefix in (
1758
+ "bash-",
1759
+ "filesystem-",
1760
+ "mintty-",
1761
+ "msys2-runtime-",
1762
+ "pacman-",
1763
+ )
1764
+ ):
1765
+ continue
1766
+ desc = entry / "desc"
1767
+ try:
1768
+ if (
1769
+ _path_is_link_or_reparse(entry)
1770
+ or not entry.is_dir()
1771
+ or _path_is_link_or_reparse(desc)
1772
+ or not desc.is_file()
1773
+ or desc.stat().st_size > _PACMAN_LOCAL_DESC_LIMIT
1774
+ ):
1775
+ raise RuntimeInstallError("the private pacman database is unsafe")
1776
+ text = desc.read_text(encoding="utf-8")
1777
+ except RuntimeInstallError:
1778
+ raise
1779
+ except (OSError, UnicodeError) as exc:
1780
+ raise RuntimeInstallError(
1781
+ "could not read the private pacman database"
1782
+ ) from exc
1783
+ name = _pacman_desc_value(text, "NAME")
1784
+ version = _pacman_desc_value(text, "VERSION")
1785
+ if name is None or version is None or not _is_pacman_core_package(name):
1786
+ continue
1787
+ if name in versions:
1788
+ raise RuntimeInstallError(
1789
+ "the private pacman database contains duplicate core packages"
1790
+ )
1791
+ versions[name] = version
1792
+ return versions
1793
+
1794
+
1795
+ def _pacman_log_checkpoint(root: Path) -> _PacmanLogCheckpoint:
1796
+ """Pin the append-only pacman journal before a staged transaction."""
1797
+ path = root / _PACMAN_LOG_RELATIVE
1798
+ try:
1799
+ info = path.lstat()
1800
+ except FileNotFoundError:
1801
+ return _PacmanLogCheckpoint(
1802
+ False,
1803
+ 0,
1804
+ None,
1805
+ 0,
1806
+ hashlib.sha256().hexdigest(),
1807
+ tuple(sorted(_pacman_core_versions(root).items())),
1808
+ )
1809
+ except OSError as exc:
1810
+ raise RuntimeInstallError("could not inspect the private pacman log") from exc
1811
+ try:
1812
+ unsafe = _path_is_link_or_reparse(path)
1813
+ except OSError as exc:
1814
+ raise RuntimeInstallError("could not inspect the private pacman log") from exc
1815
+ if (
1816
+ unsafe
1817
+ or not stat.S_ISREG(info.st_mode)
1818
+ or info.st_size < 0
1819
+ or info.st_size > _PACMAN_LOG_MAX_SIZE
1820
+ ):
1821
+ raise RuntimeInstallError("the private pacman log is unsafe")
1822
+ tail_offset = max(0, info.st_size - 4096)
1823
+ try:
1824
+ with path.open("rb") as stream:
1825
+ stream.seek(tail_offset)
1826
+ tail = stream.read(info.st_size - tail_offset)
1827
+ except OSError as exc:
1828
+ raise RuntimeInstallError("could not read the private pacman log") from exc
1829
+ return _PacmanLogCheckpoint(
1830
+ True,
1831
+ info.st_size,
1832
+ (info.st_dev, info.st_ino),
1833
+ tail_offset,
1834
+ hashlib.sha256(tail).hexdigest(),
1835
+ tuple(sorted(_pacman_core_versions(root).items())),
1836
+ )
1837
+
1838
+
1839
+ def _is_pacman_core_package(name: str) -> bool:
1840
+ return name in _PACMAN_CORE_PACKAGES or name.startswith("msys2-runtime-")
1841
+
1842
+
1843
+ def _pacman_log_core_transaction(
1844
+ root: Path, checkpoint: _PacmanLogCheckpoint
1845
+ ) -> tuple[str, ...]:
1846
+ """Return core packages from one newly completed journal transaction."""
1847
+ path = root / _PACMAN_LOG_RELATIVE
1848
+ try:
1849
+ info = path.lstat()
1850
+ unsafe = _path_is_link_or_reparse(path)
1851
+ except OSError:
1852
+ return ()
1853
+ if (
1854
+ unsafe
1855
+ or not stat.S_ISREG(info.st_mode)
1856
+ or info.st_size < checkpoint.size
1857
+ or info.st_size > _PACMAN_LOG_MAX_SIZE
1858
+ or info.st_size - checkpoint.size > _PACMAN_LOG_APPEND_LIMIT
1859
+ ):
1860
+ return ()
1861
+ if checkpoint.existed and (info.st_dev, info.st_ino) != checkpoint.identity:
1862
+ return ()
1863
+ try:
1864
+ with path.open("rb") as stream:
1865
+ if checkpoint.existed:
1866
+ stream.seek(checkpoint.tail_offset)
1867
+ previous_tail = stream.read(checkpoint.size - checkpoint.tail_offset)
1868
+ if hashlib.sha256(previous_tail).hexdigest() != checkpoint.tail_digest:
1869
+ return ()
1870
+ stream.seek(checkpoint.size)
1871
+ appended = stream.read(_PACMAN_LOG_APPEND_LIMIT + 1)
1872
+ except OSError:
1873
+ return ()
1874
+ if not appended or len(appended) > _PACMAN_LOG_APPEND_LIMIT:
1875
+ return ()
1876
+
1877
+ core_started = False
1878
+ transaction_started = False
1879
+ changed: dict[str, tuple[str, str]] = {}
1880
+ for line in appended.decode("utf-8", errors="replace").splitlines():
1881
+ lowered = line.lower()
1882
+ if "[pacman] starting core system upgrade" in lowered:
1883
+ core_started = True
1884
+ transaction_started = False
1885
+ changed.clear()
1886
+ continue
1887
+ if not core_started:
1888
+ continue
1889
+ if "[alpm] transaction started" in lowered:
1890
+ transaction_started = True
1891
+ changed.clear()
1892
+ continue
1893
+ if not transaction_started:
1894
+ continue
1895
+ match = _PACMAN_CORE_CHANGE_RE.search(line)
1896
+ if match is not None and _is_pacman_core_package(match.group(1)):
1897
+ changed[match.group(1)] = (match.group(2), match.group(3))
1898
+ if "[alpm] transaction completed" in lowered:
1899
+ if changed:
1900
+ try:
1901
+ after = _pacman_core_versions(root)
1902
+ except RuntimeInstallError:
1903
+ return ()
1904
+ before = dict(checkpoint.core_versions)
1905
+ proven = tuple(
1906
+ sorted(
1907
+ name
1908
+ for name, (old_version, new_version) in changed.items()
1909
+ if before.get(name) == old_version
1910
+ and after.get(name) == new_version
1911
+ and old_version != new_version
1912
+ )
1913
+ )
1914
+ if proven:
1915
+ return proven
1916
+ transaction_started = False
1917
+ return ()
1918
+
1919
+
1920
+ def _restart_evidence(
1921
+ root: Path,
1922
+ checkpoint: _PacmanLogCheckpoint,
1923
+ reporter: InstallReporter,
1924
+ returncode: int,
1925
+ ) -> bool:
1926
+ if reporter.command_requested_msys2_restart:
1927
+ return True
1928
+ if returncode & 0xFFFFFFFF != 0xC0000135:
1929
+ return False
1930
+ packages = _pacman_log_core_transaction(root, checkpoint)
1931
+ if not packages:
1932
+ return False
1933
+ reporter.note(
1934
+ "The private pacman journal confirms a completed core update "
1935
+ f"({', '.join(packages)}); its final console prompt was not captured.",
1936
+ level="warning",
1937
+ )
1938
+ return True
1939
+
1940
+
1679
1941
  def _refresh_pacman_mirrors(
1680
1942
  root: Path,
1681
1943
  *,
@@ -1705,6 +1967,7 @@ def _run_pacman_with_recovery(
1705
1967
  mirror_optimizer: MirrorOptimizer,
1706
1968
  allow_core_restart: bool = False,
1707
1969
  ) -> bool:
1970
+ checkpoint = _pacman_log_checkpoint(root) if not packages else None
1708
1971
  argv = _bash_command(root, _pacman_command(packages=packages, relaxed=False), str(cache))
1709
1972
  returncode = _run_checked(
1710
1973
  argv,
@@ -1715,9 +1978,23 @@ def _run_pacman_with_recovery(
1715
1978
  progress="pacman",
1716
1979
  allow_failure=True,
1717
1980
  )
1718
- restart_requested = reporter.command_requested_msys2_restart
1981
+ restart_requested = (
1982
+ _restart_evidence(root, checkpoint, reporter, returncode)
1983
+ if checkpoint is not None
1984
+ else False
1985
+ )
1719
1986
  if returncode == 0:
1720
1987
  return restart_requested
1988
+ if not packages and restart_requested and not allow_core_restart:
1989
+ reporter.note(
1990
+ "MSYS2 requested another core restart on the final permitted "
1991
+ "update pass; the temporary base will not be published.",
1992
+ level="error",
1993
+ )
1994
+ reporter.command_failed(label, returncode)
1995
+ raise RuntimeInstallError(
1996
+ f"{label} exhausted the permitted core-update restarts"
1997
+ )
1721
1998
  if allow_core_restart and not packages and restart_requested:
1722
1999
  reporter.note(
1723
2000
  "MSYS2 replaced core runtime files and closed its own process as "
@@ -1759,6 +2036,7 @@ def _run_pacman_with_recovery(
1759
2036
  _pacman_command(packages=packages, relaxed=True),
1760
2037
  str(cache),
1761
2038
  )
2039
+ retry_checkpoint = _pacman_log_checkpoint(root) if not packages else None
1762
2040
  retry_returncode = _run_checked(
1763
2041
  retry_argv,
1764
2042
  env=env,
@@ -1768,9 +2046,24 @@ def _run_pacman_with_recovery(
1768
2046
  progress="pacman",
1769
2047
  allow_failure=True,
1770
2048
  )
1771
- restart_requested = reporter.command_requested_msys2_restart
2049
+ restart_requested = (
2050
+ _restart_evidence(root, retry_checkpoint, reporter, retry_returncode)
2051
+ if retry_checkpoint is not None
2052
+ else False
2053
+ )
1772
2054
  if retry_returncode == 0:
1773
2055
  return restart_requested
2056
+ if not packages and restart_requested and not allow_core_restart:
2057
+ reporter.note(
2058
+ "MSYS2 requested another core restart on the final permitted "
2059
+ "update pass; the temporary base will not be published.",
2060
+ level="error",
2061
+ )
2062
+ reporter.command_failed(f"{label} resilient retry", retry_returncode)
2063
+ raise RuntimeInstallError(
2064
+ f"{label} resilient retry exhausted the permitted "
2065
+ "core-update restarts"
2066
+ )
1774
2067
  if allow_core_restart and not packages and restart_requested:
1775
2068
  reporter.note(
1776
2069
  "MSYS2 replaced core runtime files and closed its own process as "
@@ -2355,11 +2648,16 @@ def install_managed_runtime(
2355
2648
  base = _managed_base(environ)
2356
2649
  cache_base = _managed_cache(environ)
2357
2650
  if base is None or cache_base is None:
2358
- raise RuntimeInstallError("LOCALAPPDATA is unavailable")
2651
+ raise RuntimeInstallError(
2652
+ "a non-virtualized Windows user data directory is unavailable"
2653
+ )
2654
+ data_root = base.parent
2359
2655
  try:
2360
2656
  base.mkdir(parents=True, exist_ok=True)
2361
2657
  cache_base.mkdir(parents=True, exist_ok=True)
2362
- log_path = install_log_path(environ, version=version)
2658
+ log_path = install_log_path(
2659
+ environ, version=version, data_root=data_root
2660
+ )
2363
2661
  except OSError as exc:
2364
2662
  raise RuntimeInstallError("could not create the private runtime log") from exc
2365
2663
  final_root = base / "shared" / MSYS2_RUNTIME_ID
@@ -2475,6 +2773,11 @@ def install_managed_runtime(
2475
2773
  archive, source = _prepare_cached_archive(
2476
2774
  cache_base,
2477
2775
  downloader=downloader,
2776
+ prior_caches=tuple(
2777
+ legacy / "cache"
2778
+ for legacy in (legacy_local_app_data_root(environ),)
2779
+ if legacy is not None and legacy != data_root
2780
+ ),
2478
2781
  )
2479
2782
  reporter.done(source)
2480
2783
 
@@ -0,0 +1,73 @@
1
+ """Stable native-Windows storage paths for the managed runtime.
2
+
3
+ Microsoft Store and other MSIX-packaged Python interpreters can virtualize new
4
+ writes beneath AppData. Railmux's runtime contains executables that must be
5
+ opened by child processes outside that virtualized view, so packaged Python
6
+ uses a user-profile directory instead.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ import os
13
+ import sys
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+
18
+ _ERROR_INSUFFICIENT_BUFFER = 122
19
+ _APPMODEL_ERROR_NO_PACKAGE = 15700
20
+ _PACKAGED_PROFILE_DIRECTORY = ".railmux"
21
+ _PACKAGED_WINDOWS_DIRECTORY = "windows"
22
+
23
+
24
+ def _has_windows_package_identity() -> bool:
25
+ """Return whether this process has an MSIX/AppX package identity."""
26
+ if os.name != "nt":
27
+ return False
28
+ try:
29
+ get_family = ctypes.windll.kernel32.GetCurrentPackageFamilyName
30
+ length = ctypes.c_uint32(0)
31
+ result = int(get_family(ctypes.byref(length), None))
32
+ except (AttributeError, OSError, TypeError, ValueError):
33
+ return _looks_like_packaged_executable(sys.executable)
34
+ if result == _APPMODEL_ERROR_NO_PACKAGE:
35
+ return False
36
+ if result == _ERROR_INSUFFICIENT_BUFFER and length.value > 0:
37
+ return True
38
+ # A Store Python launcher and interpreter both have characteristic physical
39
+ # paths. Keep this fallback fail-safe if the package API is unavailable or
40
+ # returns an unexpected platform error.
41
+ return _looks_like_packaged_executable(sys.executable)
42
+
43
+
44
+ def _looks_like_packaged_executable(executable: str) -> bool:
45
+ normalized = executable.replace("/", "\\").casefold()
46
+ return "\\windowsapps\\" in normalized or (
47
+ "\\appdata\\local\\packages\\" in normalized and "\\localcache\\" in normalized
48
+ )
49
+
50
+
51
+ def managed_windows_data_root(environ: Mapping[str, str]) -> Path | None:
52
+ """Select one data root visible to both Python and MSYS2 child processes."""
53
+ if _has_windows_package_identity():
54
+ user_profile = environ.get("USERPROFILE", "").strip()
55
+ if not user_profile:
56
+ return None
57
+ return (
58
+ Path(user_profile)
59
+ / _PACKAGED_PROFILE_DIRECTORY
60
+ / _PACKAGED_WINDOWS_DIRECTORY
61
+ )
62
+ local_app_data = environ.get("LOCALAPPDATA", "").strip()
63
+ if not local_app_data:
64
+ return None
65
+ return Path(local_app_data) / "Railmux"
66
+
67
+
68
+ def legacy_local_app_data_root(environ: Mapping[str, str]) -> Path | None:
69
+ """Return the pre-packaged-Python-fix location for read-only cache reuse."""
70
+ local_app_data = environ.get("LOCALAPPDATA", "").strip()
71
+ if not local_app_data:
72
+ return None
73
+ return Path(local_app_data) / "Railmux"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev28
3
+ Version: 0.4.0.dev30
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
@@ -63,6 +63,7 @@ src/railmux/windows_bootstrap.py
63
63
  src/railmux/windows_install_log.py
64
64
  src/railmux/windows_msys2.py
65
65
  src/railmux/windows_pacman.py
66
+ src/railmux/windows_paths.py
66
67
  src/railmux/windows_tmux_lifecycle.py
67
68
  src/railmux/windows_ui_transition.py
68
69
  src/railmux.egg-info/PKG-INFO
@@ -168,6 +169,7 @@ tests/test_windows_bootstrap.py
168
169
  tests/test_windows_install_log.py
169
170
  tests/test_windows_msys2.py
170
171
  tests/test_windows_pacman.py
172
+ tests/test_windows_paths.py
171
173
  tests/test_windows_tmux_lifecycle.py
172
174
  tests/test_windows_ui_transition.py
173
175
  tests/test_windows_wrapper_contract.py