pentool 0.2.1__tar.gz → 0.2.3__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 (167) hide show
  1. {pentool-0.2.1 → pentool-0.2.3}/PKG-INFO +1 -1
  2. {pentool-0.2.1 → pentool-0.2.3}/pentool/__init__.py +1 -1
  3. {pentool-0.2.1 → pentool-0.2.3}/pentool/cli/main.py +27 -0
  4. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/database.py +36 -0
  5. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/license.py +86 -0
  6. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/proxy.py +48 -3
  7. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/app.py +76 -4
  8. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/match_replace_dialog.py +1 -1
  9. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/project_manager.py +121 -64
  10. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/intruder/screen.py +1 -1
  11. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/proxy/screen.py +184 -18
  12. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/proxy/screen.tcss +0 -8
  13. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/repeater/screen.py +2 -21
  14. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/settings/screen.py +7 -4
  15. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/spider/screen.py +1 -1
  16. pentool-0.2.3/pentool/tui/widgets/nice_checkbox.py +35 -0
  17. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/request_editor.py +44 -15
  18. {pentool-0.2.1 → pentool-0.2.3}/pentool.egg-info/PKG-INFO +1 -1
  19. {pentool-0.2.1 → pentool-0.2.3}/pentool.egg-info/SOURCES.txt +1 -0
  20. {pentool-0.2.1 → pentool-0.2.3}/pyproject.toml +1 -1
  21. {pentool-0.2.1 → pentool-0.2.3}/LICENSE +0 -0
  22. {pentool-0.2.1 → pentool-0.2.3}/README.md +0 -0
  23. {pentool-0.2.1 → pentool-0.2.3}/pentool/__main__.py +0 -0
  24. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/__init__.py +0 -0
  25. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/base_api.py +0 -0
  26. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/comparer_api.py +0 -0
  27. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/decoder_api.py +0 -0
  28. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/intruder_api.py +0 -0
  29. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/proxy_api.py +0 -0
  30. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/repeater_api.py +0 -0
  31. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/sequencer_api.py +0 -0
  32. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/spider_api.py +0 -0
  33. {pentool-0.2.1 → pentool-0.2.3}/pentool/api/target_api.py +0 -0
  34. {pentool-0.2.1 → pentool-0.2.3}/pentool/cli/__init__.py +0 -0
  35. {pentool-0.2.1 → pentool-0.2.3}/pentool/cli/project.py +0 -0
  36. {pentool-0.2.1 → pentool-0.2.3}/pentool/cli/proxy.py +0 -0
  37. {pentool-0.2.1 → pentool-0.2.3}/pentool/cli/scan.py +0 -0
  38. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/__init__.py +0 -0
  39. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/config.py +0 -0
  40. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/crash_reporter.py +0 -0
  41. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/event_bus.py +0 -0
  42. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/events.py +0 -0
  43. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/features.py +0 -0
  44. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/logging.py +0 -0
  45. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/plugin_manager.py +0 -0
  46. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/project.py +0 -0
  47. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/storage_interface.py +0 -0
  48. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/updater.py +0 -0
  49. {pentool-0.2.1 → pentool-0.2.3}/pentool/core/utils.py +0 -0
  50. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/__init__.py +0 -0
  51. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/comparer.py +0 -0
  52. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/decoder.py +0 -0
  53. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/intruder.py +0 -0
  54. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/match_replace.py +0 -0
  55. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/repeater.py +0 -0
  56. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/sequencer.py +0 -0
  57. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/spider.py +0 -0
  58. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/target.py +0 -0
  59. {pentool-0.2.1 → pentool-0.2.3}/pentool/modules/websocket_handler.py +0 -0
  60. {pentool-0.2.1 → pentool-0.2.3}/pentool/plugins/__init__.py +0 -0
  61. {pentool-0.2.1 → pentool-0.2.3}/pentool/plugins/builtin/__init__.py +0 -0
  62. {pentool-0.2.1 → pentool-0.2.3}/pentool/plugins/example_plugin.py +0 -0
  63. {pentool-0.2.1 → pentool-0.2.3}/pentool/plugins/example_plugin.tcss +0 -0
  64. {pentool-0.2.1 → pentool-0.2.3}/pentool/services/__init__.py +0 -0
  65. {pentool-0.2.1 → pentool-0.2.3}/pentool/services/base_service.py +0 -0
  66. {pentool-0.2.1 → pentool-0.2.3}/pentool/services/intruder_service.py +0 -0
  67. {pentool-0.2.1 → pentool-0.2.3}/pentool/services/proxy_service.py +0 -0
  68. {pentool-0.2.1 → pentool-0.2.3}/pentool/services/repeater_service.py +0 -0
  69. {pentool-0.2.1 → pentool-0.2.3}/pentool/services/scan_service.py +0 -0
  70. {pentool-0.2.1 → pentool-0.2.3}/pentool/storage/__init__.py +0 -0
  71. {pentool-0.2.1 → pentool-0.2.3}/pentool/storage/http_storage.py +0 -0
  72. {pentool-0.2.1 → pentool-0.2.3}/pentool/storage/large_body_handler.py +0 -0
  73. {pentool-0.2.1 → pentool-0.2.3}/pentool/storage/lru_cache.py +0 -0
  74. {pentool-0.2.1 → pentool-0.2.3}/pentool/t.py +0 -0
  75. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/__init__.py +0 -0
  76. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/constants.py +0 -0
  77. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/__init__.py +0 -0
  78. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/cert_dialog.py +0 -0
  79. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/cert_dialog.tcss +0 -0
  80. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/file_selector.py +0 -0
  81. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/file_selector.tcss +0 -0
  82. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/load_from_proxy.py +0 -0
  83. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/load_from_proxy.tcss +0 -0
  84. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/match_replace_dialog.tcss +0 -0
  85. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/project_dialog.py +0 -0
  86. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/project_dialog.tcss +0 -0
  87. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/scope_dialog.py +0 -0
  88. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/dialogs/scope_dialog.tcss +0 -0
  89. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/messages.py +0 -0
  90. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/mixins/__init__.py +0 -0
  91. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/mixins/app_mixin.py +0 -0
  92. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/mixins/dialog_cancel.py +0 -0
  93. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/mixins/request_context_menu.py +0 -0
  94. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/__init__.py +0 -0
  95. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/base.py +0 -0
  96. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/comparer/__init__.py +0 -0
  97. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/comparer/screen.py +0 -0
  98. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/comparer/screen.tcss +0 -0
  99. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/dashboard/__init__.py +0 -0
  100. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/dashboard/live_dashboard.py +0 -0
  101. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/dashboard/screen.py +0 -0
  102. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/dashboard/screen.tcss +0 -0
  103. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/decoder/__init__.py +0 -0
  104. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/decoder/screen.py +0 -0
  105. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/decoder/screen.tcss +0 -0
  106. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/extensions/__init__.py +0 -0
  107. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/extensions/screen.py +0 -0
  108. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/extensions/screen.tcss +0 -0
  109. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/intruder/__init__.py +0 -0
  110. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/intruder/screen.tcss +0 -0
  111. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/proxy/__init__.py +0 -0
  112. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/repeater/__init__.py +0 -0
  113. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/repeater/screen.tcss +0 -0
  114. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/scanner_unavailable.py +0 -0
  115. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/sequencer/__init__.py +0 -0
  116. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/sequencer/screen.py +0 -0
  117. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/sequencer/screen.tcss +0 -0
  118. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/settings/__init__.py +0 -0
  119. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/settings/hotkeys.py +0 -0
  120. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/settings/hotkeys.tcss +0 -0
  121. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/settings/screen.tcss +0 -0
  122. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/spider/__init__.py +0 -0
  123. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/spider/screen.tcss +0 -0
  124. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/target/__init__.py +0 -0
  125. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/target/screen.py +0 -0
  126. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/target/screen.tcss +0 -0
  127. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/terminal/__init__.py +0 -0
  128. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/terminal/screen.py +0 -0
  129. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/screens/terminal/screen.tcss +0 -0
  130. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/__init__.py +0 -0
  131. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/activity_indicator.py +0 -0
  132. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/activity_indicator.tcss +0 -0
  133. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/context_menu.py +0 -0
  134. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/context_menu.tcss +0 -0
  135. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/filter_bar.py +0 -0
  136. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/filter_bar.tcss +0 -0
  137. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/inspector_panel.py +0 -0
  138. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/inspector_panel.tcss +0 -0
  139. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/menu.tcss +0 -0
  140. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/menu_bar.tcss +0 -0
  141. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/module_tabs.py +0 -0
  142. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/module_tabs.tcss +0 -0
  143. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/payload_drop_zone.py +0 -0
  144. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/payload_drop_zone.tcss +0 -0
  145. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/request_editor.tcss +0 -0
  146. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/resize_handle.py +0 -0
  147. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/resize_handle.tcss +0 -0
  148. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/search_bar.py +0 -0
  149. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/search_bar.tcss +0 -0
  150. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/statusbar.py +0 -0
  151. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/statusbar.tcss +0 -0
  152. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/toolbar_button.py +0 -0
  153. {pentool-0.2.1 → pentool-0.2.3}/pentool/tui/widgets/toolbar_button.tcss +0 -0
  154. {pentool-0.2.1 → pentool-0.2.3}/pentool/utils/__init__.py +0 -0
  155. {pentool-0.2.1 → pentool-0.2.3}/pentool/utils/cert.py +0 -0
  156. {pentool-0.2.1 → pentool-0.2.3}/pentool/utils/coder.py +0 -0
  157. {pentool-0.2.1 → pentool-0.2.3}/pentool/utils/copy_as.py +0 -0
  158. {pentool-0.2.1 → pentool-0.2.3}/pentool/utils/diff.py +0 -0
  159. {pentool-0.2.1 → pentool-0.2.3}/pentool/utils/http_client.py +0 -0
  160. {pentool-0.2.1 → pentool-0.2.3}/pentool/utils/parser.py +0 -0
  161. {pentool-0.2.1 → pentool-0.2.3}/pentool/utils/terminal_check.py +0 -0
  162. {pentool-0.2.1 → pentool-0.2.3}/pentool.egg-info/dependency_links.txt +0 -0
  163. {pentool-0.2.1 → pentool-0.2.3}/pentool.egg-info/entry_points.txt +0 -0
  164. {pentool-0.2.1 → pentool-0.2.3}/pentool.egg-info/requires.txt +0 -0
  165. {pentool-0.2.1 → pentool-0.2.3}/pentool.egg-info/top_level.txt +0 -0
  166. {pentool-0.2.1 → pentool-0.2.3}/setup.cfg +0 -0
  167. {pentool-0.2.1 → pentool-0.2.3}/tests/test_user_workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pentool
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Modern Web Pentesting TUI - Professional security testing tool
5
5
  Author-email: Pentool Team <dev@pentool.pro>
6
6
  License: AGPL-3.0
@@ -1,6 +1,6 @@
1
1
  """Pentool — professional web security testing toolkit with Textual TUI."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.3"
4
4
  __author__ = "pentool"
5
5
 
6
6
 
@@ -126,6 +126,7 @@ def update_cmd(check_only: bool) -> None:
126
126
  click.echo("Upgrading via pip...")
127
127
  if do_pip_upgrade():
128
128
  click.echo("Upgrade successful. Restart Pentool to use the new version.")
129
+ _sync_pro_package_after_upgrade()
129
130
  else:
130
131
  click.echo(
131
132
  "pip upgrade failed. You can update manually:\n"
@@ -135,6 +136,32 @@ def update_cmd(check_only: bool) -> None:
135
136
  raise SystemExit(1)
136
137
 
137
138
 
139
+ def _sync_pro_package_after_upgrade() -> None:
140
+ """Re-download the PRO package too, if one is active on this machine.
141
+
142
+ `pip install --upgrade pentool` only touches the FREE package — the PRO
143
+ package lives separately in ~/.pentool/pro/ and is otherwise only ever
144
+ (re)downloaded by check_and_update_pro_package(), which normally runs in
145
+ the background on TUI startup. Doing it here too means a CLI-driven
146
+ upgrade doesn't leave a stale PRO build around until the next TUI launch.
147
+ Best-effort — never fails the overall upgrade if this part doesn't work.
148
+ """
149
+ import asyncio
150
+
151
+ from pentool.core.license import PRO_PACKAGE_DIR, check_and_update_pro_package
152
+
153
+ if not PRO_PACKAGE_DIR.exists():
154
+ return # PRO was never activated on this machine — nothing to sync
155
+
156
+ try:
157
+ click.echo("Checking for a newer PRO build...")
158
+ updated = asyncio.run(check_and_update_pro_package())
159
+ if updated:
160
+ click.echo("PRO package updated to the latest build.")
161
+ except Exception as exc:
162
+ click.echo(f"Could not check for a PRO update: {exc}", err=True)
163
+
164
+
138
165
  @cli.group()
139
166
  def license() -> None:
140
167
  """License management: trial, activation, status."""
@@ -95,6 +95,14 @@ CREATE TABLE IF NOT EXISTS site_map (
95
95
  );
96
96
 
97
97
  CREATE UNIQUE INDEX IF NOT EXISTS idx_site_map_host_path ON site_map (host, path);
98
+
99
+ -- Generic per-project key/value settings (e.g. "proxy.enforce_scope").
100
+ -- Lives in the project .db file itself so it travels with the project,
101
+ -- unlike pentool's global ~/.config/pentool/config.yaml.
102
+ CREATE TABLE IF NOT EXISTS project_settings (
103
+ key TEXT PRIMARY KEY,
104
+ value TEXT NOT NULL DEFAULT ''
105
+ );
98
106
  """
99
107
 
100
108
 
@@ -170,3 +178,31 @@ def init_db_sync(db_path: str) -> None:
170
178
  db_path: Path to the SQLite file.
171
179
  """
172
180
  asyncio.run(init_db(db_path))
181
+
182
+
183
+ async def get_project_setting(db_path: str, key: str, default: str | None = None) -> str | None:
184
+ """Read a single per-project key/value setting from `project_settings`.
185
+
186
+ Returns `default` if the key is missing or the table/column doesn't
187
+ exist yet (e.g. a brand-new .db before init_db() has run).
188
+ """
189
+ try:
190
+ async with aiosqlite.connect(db_path) as db:
191
+ async with db.execute(
192
+ "SELECT value FROM project_settings WHERE key = ?", (key,)
193
+ ) as cur:
194
+ row = await cur.fetchone()
195
+ return row[0] if row else default
196
+ except Exception:
197
+ return default
198
+
199
+
200
+ async def set_project_setting(db_path: str, key: str, value: str) -> None:
201
+ """Upsert a single per-project key/value setting into `project_settings`."""
202
+ async with aiosqlite.connect(db_path) as db:
203
+ await db.execute(
204
+ "INSERT INTO project_settings (key, value) VALUES (?, ?) "
205
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
206
+ (key, value),
207
+ )
208
+ await db.commit()
@@ -503,6 +503,13 @@ def _current_platform() -> str:
503
503
  return "linux"
504
504
 
505
505
 
506
+ # File recording which PRO build is currently unpacked into PRO_PACKAGE_DIR —
507
+ # lets check_and_update_pro_package() tell "same build as last time" apart
508
+ # from "a new build was published" without re-downloading the archive to
509
+ # compare it. See _fetch_pro_build_id() / /api/pro/version on the Worker.
510
+ _PRO_BUILD_MARKER = PRO_PACKAGE_DIR / ".build_id"
511
+
512
+
506
513
  async def download_pro_package(key: str, machine_id: str) -> bool:
507
514
  """Download, verify, and install the obfuscated PRO package.
508
515
 
@@ -555,6 +562,85 @@ async def download_pro_package(key: str, machine_id: str) -> bool:
555
562
  finally:
556
563
  tmp_archive.unlink(missing_ok=True)
557
564
 
565
+ # Record which build this is, so a later check_and_update_pro_package()
566
+ # can tell "nothing changed" from "a new build was published" without
567
+ # re-downloading the archive just to compare it.
568
+ try:
569
+ build_id = await _fetch_pro_build_id(plat)
570
+ if build_id:
571
+ _PRO_BUILD_MARKER.write_text(build_id, encoding="utf-8")
572
+ except Exception:
573
+ pass # non-fatal — worst case, next check just re-downloads once more
574
+
558
575
  return True
559
576
  except Exception:
560
577
  return False
578
+
579
+
580
+ async def _fetch_pro_build_id(plat: str) -> str | None:
581
+ """Query /api/pro/version for the current PRO release's build_id.
582
+
583
+ No license key needed — this only identifies which build is published,
584
+ not whether the caller is entitled to it (that's still enforced by
585
+ /api/download re-validating the key server-side). Returns None on any
586
+ failure (network, missing asset, etc.) — callers should treat that as
587
+ "couldn't check, don't act on it" rather than "no update available".
588
+ """
589
+ try:
590
+ import aiohttp
591
+ async with aiohttp.ClientSession(
592
+ timeout=aiohttp.ClientTimeout(total=8)
593
+ ) as session:
594
+ async with session.get(
595
+ f"{_LICENSE_API_BASE}/api/pro/version",
596
+ params={"platform": plat},
597
+ ssl=False,
598
+ ) as resp:
599
+ if resp.status != 200:
600
+ return None
601
+ data = await resp.json()
602
+ return data.get("build_id")
603
+ except Exception:
604
+ return None
605
+
606
+
607
+ async def check_and_update_pro_package() -> bool:
608
+ """Re-download the PRO package if a newer build has been published.
609
+
610
+ Called on TUI startup (background worker, same pattern as
611
+ _check_for_updates for the pip package) and after `pentool update` — this
612
+ is what closes the gap where pentool-pro ships a fix but a machine that
613
+ already activated its license keeps running the stale code from
614
+ ~/.pentool/pro/ forever, because download_pro_package() otherwise only
615
+ ever runs once, at initial activation/trial-start.
616
+
617
+ Silently does nothing (returns False) if:
618
+ - there's no active PRO license (nothing to update)
619
+ - the PRO package was never downloaded in the first place (that's
620
+ activate_license's job, not this one)
621
+ - the version check or download fails for any reason
622
+
623
+ Returns True if a new build was found and successfully installed.
624
+ """
625
+ info = get_license()
626
+ if not info.valid or not info.features:
627
+ return False
628
+ if not PRO_PACKAGE_DIR.exists():
629
+ return False # never activated on this machine — nothing to refresh
630
+
631
+ plat = _current_platform()
632
+ remote_build_id = await _fetch_pro_build_id(plat)
633
+ if not remote_build_id:
634
+ return False # couldn't reach the server — don't touch anything
635
+
636
+ local_build_id = None
637
+ try:
638
+ if _PRO_BUILD_MARKER.exists():
639
+ local_build_id = _PRO_BUILD_MARKER.read_text(encoding="utf-8").strip()
640
+ except Exception:
641
+ pass
642
+
643
+ if local_build_id == remote_build_id:
644
+ return False # already on the latest build
645
+
646
+ return await download_pro_package(info.license_key, info.machine_id)
@@ -150,6 +150,14 @@ class ProxyServer:
150
150
 
151
151
  self.intercept_enabled: bool = False
152
152
  self.scope: list[str] = [] # Empty = intercept everything
153
+ # When False (default), scope is informational only — ALL traffic is
154
+ # captured/shown regardless of self.scope. Scope filtering (dropping
155
+ # out-of-scope requests from capture) only takes effect when this is
156
+ # explicitly turned on via the "Skip out-of-scope" toggle button.
157
+ # Without this flag, a leftover/stale `scope` list (e.g. saved from a
158
+ # previous project) would silently blackhole all non-matching traffic
159
+ # the moment the proxy starts, with no visible indication why.
160
+ self.enforce_scope: bool = False
153
161
 
154
162
  # Match/Replace via dedicated engine
155
163
  self._match_replace_engine = MatchReplaceEngine()
@@ -205,7 +213,10 @@ class ProxyServer:
205
213
  self._running = False
206
214
  if self._server:
207
215
  self._server.close()
208
- await self._server.wait_closed()
216
+ try:
217
+ await asyncio.wait_for(self._server.wait_closed(), timeout=2.0)
218
+ except asyncio.TimeoutError:
219
+ logger.debug("ProxyServer.stop: wait_closed timeout, continuing")
209
220
  self._server = None
210
221
  # Close singleton HTTP client
211
222
  if self._http_client:
@@ -214,6 +225,25 @@ class ProxyServer:
214
225
  except Exception as e:
215
226
  logger.warning("ProxyServer.stop: http_client.close() error: %s", e)
216
227
  self._http_client = None
228
+ # Cancel all remaining active tasks (open TLS tunnels, keep-alive
229
+ # connections, intercept waits) so the event loop can exit promptly.
230
+ # Without this, _handle_connect loops on _READ_TIMEOUT=30s and
231
+ # _INTERCEPT_TIMEOUT=300s — the proxy thread would not exit for up to
232
+ # 5 minutes after stop(), blocking project switches and restarts.
233
+ loop = asyncio.get_running_loop()
234
+ current = asyncio.current_task()
235
+ pending = [t for t in asyncio.all_tasks(loop) if t is not current and not t.done()]
236
+ if pending:
237
+ logger.debug("ProxyServer.stop: cancelling %d active task(s)", len(pending))
238
+ for task in pending:
239
+ task.cancel()
240
+ try:
241
+ await asyncio.wait_for(
242
+ asyncio.gather(*pending, return_exceptions=True),
243
+ timeout=2.0,
244
+ )
245
+ except asyncio.TimeoutError:
246
+ logger.debug("ProxyServer.stop: task cancellation timed out")
217
247
  logger.info("Proxy stopped")
218
248
 
219
249
  async def serve_forever(self) -> None:
@@ -296,6 +326,19 @@ class ProxyServer:
296
326
  else:
297
327
  self.intercept_enabled = enabled
298
328
 
329
+ def set_enforce_scope(self, enabled: bool) -> None:
330
+ """Thread-safe setting of enforce_scope flag from any thread.
331
+
332
+ Same rationale as set_intercept — the proxy loop reads this flag
333
+ in _handle_http/_handle_connect, so writes from the TUI thread must
334
+ go through call_soon_threadsafe to avoid a torn read.
335
+ """
336
+ loop = self._loop
337
+ if loop is not None and loop.is_running():
338
+ loop.call_soon_threadsafe(setattr, self, "enforce_scope", enabled)
339
+ else:
340
+ self.enforce_scope = enabled
341
+
299
342
  def _find_request(self, req_id: str) -> InterceptedRequest | None:
300
343
  with self._requests_lock:
301
344
  for r in reversed(self.requests):
@@ -367,7 +410,8 @@ class ProxyServer:
367
410
  await writer.drain()
368
411
 
369
412
  # If host is out of scope — just tunnel without interception
370
- if not self.is_in_scope(domain):
413
+ # (only enforced when the user turned on "Skip out-of-scope")
414
+ if self.enforce_scope and not self.is_in_scope(domain):
371
415
  await self._tunnel_raw(domain, host_port, reader, writer)
372
416
  return
373
417
 
@@ -491,7 +535,8 @@ class ProxyServer:
491
535
  req.method, req.url, is_https, self.intercept_enabled, self.is_in_scope(host))
492
536
 
493
537
  # Check scope (for HTTP; HTTPS already checked in _handle_connect)
494
- if not is_https and not self.is_in_scope(host):
538
+ # (only enforced when the user turned on "Skip out-of-scope")
539
+ if self.enforce_scope and not is_https and not self.is_in_scope(host):
495
540
  logger.debug("PROXY: _handle_http: host %s out of scope, forwarding direct", host)
496
541
  return await self._forward_direct(req, writer)
497
542
 
@@ -408,6 +408,12 @@ class PentoolApp(App):
408
408
  if getattr(self._cfg, "check_updates", True):
409
409
  self.run_worker(self._check_for_updates(), exclusive=False, thread=False)
410
410
 
411
+ # Check for a newer PRO build in background (if a PRO license is
412
+ # active) — closes the gap where pentool-pro ships a fix but an
413
+ # already-activated machine keeps running the stale code forever,
414
+ # since the PRO package otherwise only downloads once, at activation.
415
+ self.run_worker(self._check_for_pro_update(), exclusive=False, thread=False)
416
+
411
417
  async def _auto_open_last_project(self) -> None:
412
418
  """Open the last project from recent_projects at startup."""
413
419
  await asyncio.sleep(0.2) # give Textual time to render the UI
@@ -439,6 +445,30 @@ class PentoolApp(App):
439
445
  except Exception as exc:
440
446
  logger.debug("APP: update check failed: %s", exc)
441
447
 
448
+ async def _check_for_pro_update(self) -> None:
449
+ """Re-download the PRO package in the background if a newer build
450
+ has been published, then notify so the user knows to restart.
451
+
452
+ No-op (silently) if there's no active PRO license or the package was
453
+ never downloaded in the first place — see
454
+ pentool.core.license.check_and_update_pro_package for the actual
455
+ build_id comparison logic.
456
+ """
457
+ import asyncio as _asyncio
458
+ await _asyncio.sleep(4.0) # after the pip-update check, UI settled
459
+ try:
460
+ from pentool.core.license import check_and_update_pro_package
461
+ updated = await check_and_update_pro_package()
462
+ if updated:
463
+ self.notify(
464
+ "PRO package updated to the latest build — restart Pentool to use it.",
465
+ severity="information",
466
+ timeout=10,
467
+ )
468
+ logger.info("APP: PRO package updated to a newer build")
469
+ except Exception as exc:
470
+ logger.debug("APP: PRO update check failed: %s", exc)
471
+
442
472
  def _setup_auto_save(self) -> None:
443
473
  """Configure / restart the auto-save timer from the current config.
444
474
 
@@ -671,6 +701,14 @@ class PentoolApp(App):
671
701
  pass
672
702
  self.query_one(ContentSwitcher).current = f"screen-{module_id}"
673
703
  self._active_module = module_id
704
+ # _update_proxy_screen_labels() only refreshes the ProxyScreen label
705
+ # when it IS the active module — so if the proxy was stopped/started
706
+ # while the user was on a different tab (e.g. right after creating a
707
+ # new project, which force-stops the proxy while Dashboard is shown),
708
+ # the label update was skipped and stayed stale until the next actual
709
+ # start/stop toggle. Refresh it explicitly on every switch into Proxy.
710
+ if module_id == "proxy":
711
+ self._update_proxy_screen_labels()
674
712
 
675
713
  def get_proxy(self) -> ProxyServer | None:
676
714
  return self._proxy
@@ -728,6 +766,21 @@ class PentoolApp(App):
728
766
  if self._proxy.is_running:
729
767
  self._stop_proxy()
730
768
  else:
769
+ if not self._project_loaded:
770
+ # Project DB switch/open (auto-open at startup, New/Open
771
+ # Project) is still finishing in the background — starting
772
+ # the proxy now would race HttpStorage.switch_db() (its
773
+ # connection may be mid-close/reopen) and silently lose or
774
+ # fail to persist the first captured requests. _project_loaded
775
+ # is set as soon as the DB switch itself completes (see
776
+ # ProjectManager._do_switch) — the other screens may still be
777
+ # reloading, but that's independent of Proxy.
778
+ self.notify(
779
+ "Проект ещё открывается — подождите пару секунд перед запуском Proxy",
780
+ severity="warning",
781
+ timeout=4,
782
+ )
783
+ return
731
784
  self._start_proxy()
732
785
 
733
786
  def action_toggle_intercept(self) -> None:
@@ -847,13 +900,25 @@ class PentoolApp(App):
847
900
  self._proxy.stop(), self._proxy_loop
848
901
  )
849
902
  try:
850
- future.result(timeout=5)
903
+ # stop() now cancels all active tasks and waits up to ~4s
904
+ # internally, so 6s here is generous headroom
905
+ future.result(timeout=6)
851
906
  except Exception as e:
852
907
  logger.warning("APP: proxy.stop() error or timeout: %s", e)
908
+ # Force-cancel anything still running in the proxy loop so
909
+ # the thread can exit even if stop() itself timed out
910
+ if self._proxy_loop and not self._proxy_loop.is_closed():
911
+ try:
912
+ def _cancel_all():
913
+ for t in asyncio.all_tasks(self._proxy_loop):
914
+ t.cancel()
915
+ self._proxy_loop.call_soon_threadsafe(_cancel_all)
916
+ except Exception:
917
+ pass
853
918
  if self._proxy_thread and self._proxy_thread.is_alive():
854
- self._proxy_thread.join(timeout=3)
919
+ self._proxy_thread.join(timeout=5)
855
920
  if self._proxy_thread.is_alive():
856
- logger.warning("APP: proxy thread did not stop in 3s")
921
+ logger.warning("APP: proxy thread did not stop in 5s — port 8080 may still be in use")
857
922
  self.call_after_refresh(self._update_status)
858
923
  self.call_after_refresh(self._update_proxy_screen_labels)
859
924
 
@@ -944,7 +1009,14 @@ class PentoolApp(App):
944
1009
  target = self.query_one(SCREEN_TARGET, TargetScreen)
945
1010
  api = target._get_api()
946
1011
  api.sitemap.set_in_scope(msg.host, msg.in_scope)
947
- target._load_sitemap()
1012
+ # Rebuild the tree from the in-memory sitemap only — NOT
1013
+ # target._load_sitemap(), which does a full api.load() from
1014
+ # the DB. The sitemap is persisted in batches of 20 rows
1015
+ # (see target/screen.py _do_save_sitemap), so any host/path
1016
+ # added in-memory since the last batch save would be wiped
1017
+ # out by that reload, making a simple "Add to Scope" click
1018
+ # from Proxy appear to clear the whole Target tree.
1019
+ target._refresh_tree()
948
1020
  except Exception as e:
949
1021
  logger.debug("on_sync_scope_to_target: %s", e)
950
1022
 
@@ -13,7 +13,6 @@ from textual.screen import ModalScreen
13
13
  _CSS = (Path(__file__).parent / "match_replace_dialog.tcss").read_text(encoding="utf-8")
14
14
  from textual.widgets import (
15
15
  Button,
16
- Checkbox,
17
16
  DataTable,
18
17
  Input,
19
18
  Label,
@@ -21,6 +20,7 @@ from textual.widgets import (
21
20
  )
22
21
 
23
22
  from pentool.api.proxy_api import MatchReplaceRule
23
+ from pentool.tui.widgets.nice_checkbox import NiceCheckbox as Checkbox
24
24
  from pentool.tui.widgets.toolbar_button import ToolbarButton
25
25
 
26
26
  _TARGET_OPTIONS = [("request", "Request"), ("response", "Response"), ("both", "Both")]
@@ -325,9 +325,14 @@ class ProjectManager:
325
325
  )
326
326
  self._app._stop_proxy()
327
327
 
328
+ # Gate Start Proxy (action_toggle_proxy) until _do_switch flips this
329
+ # back to True once HttpStorage.switch_db() has actually completed —
330
+ # otherwise a Start Proxy click during the switch could race the
331
+ # storage connection being closed/reopened underneath it.
332
+ self._app._project_loaded = False
333
+
328
334
  self._cfg.db_path = path
329
335
  self._app._project_path = path
330
- # Note: _project_loaded will be set AFTER _reload_project_screens in _do_switch
331
336
 
332
337
  if self._proxy:
333
338
  self._proxy.db_path = path
@@ -337,6 +342,10 @@ class ProjectManager:
337
342
  self._proxy.clear_requests()
338
343
  self._proxy.scope = []
339
344
  self._proxy.match_replace_rules = []
345
+ # New project's DB has no project_settings row yet — reset to
346
+ # the documented default (OFF) rather than carrying over
347
+ # whatever the previous project had in memory.
348
+ self._proxy.set_enforce_scope(False)
340
349
  except Exception:
341
350
  pass
342
351
 
@@ -371,12 +380,44 @@ class ProjectManager:
371
380
  """Single entry point for all project switches.
372
381
 
373
382
  Runs in an exclusive worker — no two switches can overlap.
374
- Order is strict:
383
+ Order:
384
+ 0. Wait for the proxy thread to fully exit (if _stop_proxy was
385
+ called but the thread didn't die within join(timeout) — the
386
+ old loop may still hold the SQLite WAL lock or the TCP port).
375
387
  1. init_db (CREATE TABLE IF NOT EXISTS, migrations)
376
388
  2. switch_db on ProxyService (close old connection, open new one)
377
389
  3. clear in-memory proxy state if new project
378
- 4. reload all screens sequentially (no nested run_worker)
390
+ 4. set _project_loaded — DB is ready, Start Proxy is now safe,
391
+ even though the other screens (Repeater/Scanner/Target/Dashboard)
392
+ may still be reloading in the background (step 5). Proxy only
393
+ depends on the HttpStorage connection from step 2, not on any
394
+ of those screens, so there is no reason to make the user wait
395
+ for all of them before allowing Start Proxy.
396
+ 5. reload the remaining screens — run concurrently (they read
397
+ independent tables/state), not sequentially.
379
398
  """
399
+ # 0. Wait for the proxy thread to die before touching the DB.
400
+ # _stop_proxy() already called join(timeout=5), but if the thread
401
+ # is still alive (e.g. a 30-second _READ_TIMEOUT blocked task didn't
402
+ # get cancelled in time) we must not open a new SQLite connection
403
+ # while the old proxy event loop might still be writing to it.
404
+ proxy_thread = self._app._proxy_thread
405
+ if proxy_thread is not None and proxy_thread.is_alive():
406
+ logger.info("_do_switch: waiting for proxy thread to exit before DB switch…")
407
+ loop = asyncio.get_running_loop()
408
+ # Poll in the async event loop so we don't block the TUI thread
409
+ for _ in range(50): # up to 5 seconds in 100ms steps
410
+ alive = await loop.run_in_executor(
411
+ None, lambda: proxy_thread.is_alive()
412
+ )
413
+ if not alive:
414
+ break
415
+ await asyncio.sleep(0.1)
416
+ if proxy_thread.is_alive():
417
+ logger.warning("_do_switch: proxy thread still alive after extra 5s — proceeding anyway")
418
+ else:
419
+ logger.info("_do_switch: proxy thread exited, proceeding with DB switch")
420
+
380
421
  # 1. Ensure schema exists (safe for both new and existing DBs)
381
422
  await self._init_new_db(path)
382
423
 
@@ -405,75 +446,91 @@ class ProjectManager:
405
446
  except Exception as exc:
406
447
  logger.debug("_do_switch: clear for new project: %s", exc)
407
448
 
408
- # 4. Reload all screens — all awaited, never spawning sub-workers
409
- await self._reload_project_screens(path, is_new=is_new)
410
-
411
- # 5. Set flag AFTER all screens are reloaded (fixes race condition)
449
+ # 4. Set flag as soon as the DB itself is ready — Start Proxy only
450
+ # needs HttpStorage (step 2) done, not the other screens below.
412
451
  self._app._project_loaded = True
413
- logger.info("_do_switch: project loaded flag set")
452
+ logger.info("_do_switch: project loaded flag set (DB ready)")
453
+
454
+ # 5. Reload the remaining screens concurrently — independent of
455
+ # each other and of the flag above.
456
+ await self._reload_project_screens(path, is_new=is_new)
457
+ logger.info("_do_switch: all screens reloaded")
414
458
 
415
459
  async def _reload_project_screens(self, path: str, is_new: bool = False) -> None:
416
- """Reload data from DB into all screens. Called from _do_switch only."""
460
+ """Reload data from DB into all screens. Called from _do_switch only.
417
461
 
418
- # 0. RepeaterScreen — сброс или загрузка вкладок
419
- try:
420
- from pentool.tui.constants import SCREEN_REPEATER
421
- from pentool.tui.screens.repeater.screen import RepeaterScreen
422
- repeater = self._app.query_one(SCREEN_REPEATER, RepeaterScreen)
423
- if is_new:
424
- await repeater.reset_for_new_project()
425
- else:
426
- await repeater.reload_from_project(path)
427
- logger.info("_reload_project_screens: repeater %s",
428
- "reset" if is_new else "reloaded")
429
- except Exception as exc:
430
- logger.debug("_reload_project_screens repeater: %s", exc)
462
+ Each screen's reload is independent (different tables/state), so they
463
+ run concurrently via asyncio.gather instead of one after another —
464
+ total time is bounded by the slowest single reload, not the sum.
465
+ """
431
466
 
432
- # 1. ProxyScreen — must come after switch_db is fully done
433
- try:
434
- screen = self._app.query_one(SCREEN_PROXY, ProxyScreen)
435
- await screen._reload_from_storage()
436
- logger.info("_reload_project_screens: proxy reloaded from %s", path)
437
- except Exception as exc:
438
- logger.debug("_reload_project_screens proxy: %s", exc)
467
+ async def _reload_repeater() -> None:
468
+ try:
469
+ from pentool.tui.constants import SCREEN_REPEATER
470
+ from pentool.tui.screens.repeater.screen import RepeaterScreen
471
+ repeater = self._app.query_one(SCREEN_REPEATER, RepeaterScreen)
472
+ if is_new:
473
+ await repeater.reset_for_new_project()
474
+ else:
475
+ await repeater.reload_from_project(path)
476
+ logger.info("_reload_project_screens: repeater %s",
477
+ "reset" if is_new else "reloaded")
478
+ except Exception as exc:
479
+ logger.debug("_reload_project_screens repeater: %s", exc)
439
480
 
440
- # 2. ScannerScreen
441
- try:
442
- from pentool.tui.screens.scanner.screen import ScannerScreen
443
- scanner_screen = self._app.query_one(SCREEN_SCANNER, ScannerScreen)
444
- if scanner_screen._scanner_api is not None:
445
- scanner_screen._scanner_api._db_path = path
446
- scanner_screen._scanner_api = None
447
- scanner_screen._scanner_api = scanner_screen._get_or_create_api(path)
448
- scanner_screen._populate_from_db([])
449
- scanner_screen._load_findings_worker()
450
- logger.info("_reload_project_screens: scanner reloaded")
451
- except Exception as exc:
452
- logger.debug("_reload_project_screens scanner: %s", exc)
481
+ async def _reload_proxy() -> None:
482
+ try:
483
+ screen = self._app.query_one(SCREEN_PROXY, ProxyScreen)
484
+ await screen._reload_from_storage()
485
+ logger.info("_reload_project_screens: proxy reloaded from %s", path)
486
+ except Exception as exc:
487
+ logger.debug("_reload_project_screens proxy: %s", exc)
453
488
 
454
- # 3. TargetScreen
455
- try:
456
- from pentool.tui.screens.target.screen import TargetScreen
457
- target_screen = self._app.query_one(SCREEN_TARGET, TargetScreen)
458
- if target_screen._target_api is not None:
459
- try:
460
- await target_screen._target_api.save()
461
- except Exception:
462
- pass
463
- target_screen._target_api = None
464
- target_screen._get_api()
465
- target_screen._load_sitemap()
466
- logger.info("_reload_project_screens: target reloaded from %s", path)
467
- except Exception as exc:
468
- logger.debug("_reload_project_screens target: %s", exc)
489
+ async def _reload_scanner() -> None:
490
+ try:
491
+ from pentool.tui.screens.scanner.screen import ScannerScreen
492
+ scanner_screen = self._app.query_one(SCREEN_SCANNER, ScannerScreen)
493
+ if scanner_screen._scanner_api is not None:
494
+ scanner_screen._scanner_api._db_path = path
495
+ scanner_screen._scanner_api = None
496
+ scanner_screen._scanner_api = scanner_screen._get_or_create_api(path)
497
+ scanner_screen._populate_from_db([])
498
+ scanner_screen._load_findings_worker()
499
+ logger.info("_reload_project_screens: scanner reloaded")
500
+ except Exception as exc:
501
+ logger.debug("_reload_project_screens scanner: %s", exc)
469
502
 
470
- # 4. Dashboard
471
- try:
472
- from pentool.tui.screens.dashboard.screen import DashboardScreen
473
- dash = self._app.query_one(SCREEN_DASHBOARD, DashboardScreen)
474
- dash.refresh_stats()
475
- except Exception as exc:
476
- logger.debug("_reload_project_screens dashboard: %s", exc)
503
+ async def _reload_target() -> None:
504
+ try:
505
+ from pentool.tui.screens.target.screen import TargetScreen
506
+ target_screen = self._app.query_one(SCREEN_TARGET, TargetScreen)
507
+ if target_screen._target_api is not None:
508
+ try:
509
+ await target_screen._target_api.save()
510
+ except Exception:
511
+ pass
512
+ target_screen._target_api = None
513
+ target_screen._get_api()
514
+ target_screen._load_sitemap()
515
+ logger.info("_reload_project_screens: target reloaded from %s", path)
516
+ except Exception as exc:
517
+ logger.debug("_reload_project_screens target: %s", exc)
518
+
519
+ async def _reload_dashboard() -> None:
520
+ try:
521
+ from pentool.tui.screens.dashboard.screen import DashboardScreen
522
+ dash = self._app.query_one(SCREEN_DASHBOARD, DashboardScreen)
523
+ dash.refresh_stats()
524
+ except Exception as exc:
525
+ logger.debug("_reload_project_screens dashboard: %s", exc)
526
+
527
+ await asyncio.gather(
528
+ _reload_repeater(),
529
+ _reload_proxy(),
530
+ _reload_scanner(),
531
+ _reload_target(),
532
+ _reload_dashboard(),
533
+ )
477
534
 
478
535
  async def _init_new_db(self, path: str) -> None:
479
536
  try: