hexproxy 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hexproxy/__init__.py +7 -0
- hexproxy/__main__.py +5 -0
- hexproxy/app.py +192 -0
- hexproxy/bodyview.py +435 -0
- hexproxy/certs.py +222 -0
- hexproxy/clipboard.py +89 -0
- hexproxy/extensions.py +739 -0
- hexproxy/mcp.py +2114 -0
- hexproxy/models.py +72 -0
- hexproxy/preferences.py +131 -0
- hexproxy/proxy.py +1178 -0
- hexproxy/store.py +1001 -0
- hexproxy/themes.py +274 -0
- hexproxy/tui.py +8796 -0
- hexproxy-0.2.2.dist-info/METADATA +556 -0
- hexproxy-0.2.2.dist-info/RECORD +20 -0
- hexproxy-0.2.2.dist-info/WHEEL +5 -0
- hexproxy-0.2.2.dist-info/entry_points.txt +2 -0
- hexproxy-0.2.2.dist-info/licenses/LICENSE +37 -0
- hexproxy-0.2.2.dist-info/top_level.txt +1 -0
hexproxy/extensions.py
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import importlib.util
|
|
5
|
+
import inspect
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any, Callable, Protocol, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .preferences import ApplicationPreferences
|
|
12
|
+
from .proxy import ParsedRequest, ParsedResponse
|
|
13
|
+
from .store import PendingInterceptionView, TrafficStore
|
|
14
|
+
from .models import TrafficEntry
|
|
15
|
+
from .themes import ThemeManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
BUILTIN_WORKSPACE_IDS = (
|
|
19
|
+
"overview",
|
|
20
|
+
"intercept",
|
|
21
|
+
"repeater",
|
|
22
|
+
"sitemap",
|
|
23
|
+
"match_replace",
|
|
24
|
+
"http",
|
|
25
|
+
"export",
|
|
26
|
+
"settings",
|
|
27
|
+
"scope",
|
|
28
|
+
"filters",
|
|
29
|
+
"keybindings",
|
|
30
|
+
"rule_builder",
|
|
31
|
+
"theme_builder",
|
|
32
|
+
)
|
|
33
|
+
SETTING_FIELD_KINDS = ("toggle", "choice", "text", "action")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class HookContext:
|
|
38
|
+
entry_id: int
|
|
39
|
+
client_addr: str
|
|
40
|
+
store: "TrafficStore"
|
|
41
|
+
plugin_manager: "PluginManager | None" = None
|
|
42
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
43
|
+
metadata: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
44
|
+
findings: dict[str, list[str]] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def set_metadata(self, plugin_id: str, key: str, value: object) -> None:
|
|
47
|
+
plugin_name = str(plugin_id).strip()
|
|
48
|
+
field_name = str(key).strip()
|
|
49
|
+
if not plugin_name or not field_name:
|
|
50
|
+
return
|
|
51
|
+
bucket = dict(self.metadata.get(plugin_name, {}))
|
|
52
|
+
bucket[field_name] = str(value)
|
|
53
|
+
self.metadata[plugin_name] = bucket
|
|
54
|
+
|
|
55
|
+
def add_finding(self, plugin_id: str, text: object) -> None:
|
|
56
|
+
plugin_name = str(plugin_id).strip()
|
|
57
|
+
message = str(text).strip()
|
|
58
|
+
if not plugin_name or not message:
|
|
59
|
+
return
|
|
60
|
+
notes = list(self.findings.get(plugin_name, []))
|
|
61
|
+
notes.append(message)
|
|
62
|
+
self.findings[plugin_name] = notes
|
|
63
|
+
|
|
64
|
+
def global_state(self, plugin_id: str) -> dict[str, object]:
|
|
65
|
+
if self.plugin_manager is None:
|
|
66
|
+
return {}
|
|
67
|
+
return self.plugin_manager.global_state(plugin_id)
|
|
68
|
+
|
|
69
|
+
def set_global_value(self, plugin_id: str, key: str, value: object) -> None:
|
|
70
|
+
if self.plugin_manager is None:
|
|
71
|
+
return
|
|
72
|
+
self.plugin_manager.set_global_value(plugin_id, key, value)
|
|
73
|
+
|
|
74
|
+
def project_state(self, plugin_id: str) -> dict[str, object]:
|
|
75
|
+
if self.plugin_manager is None:
|
|
76
|
+
return {}
|
|
77
|
+
return self.plugin_manager.project_state(plugin_id)
|
|
78
|
+
|
|
79
|
+
def set_project_value(self, plugin_id: str, key: str, value: object) -> None:
|
|
80
|
+
if self.plugin_manager is None:
|
|
81
|
+
return
|
|
82
|
+
self.plugin_manager.set_project_value(plugin_id, key, value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(slots=True)
|
|
86
|
+
class PluginRenderContext:
|
|
87
|
+
plugin_id: str
|
|
88
|
+
plugin_manager: "PluginManager"
|
|
89
|
+
store: "TrafficStore"
|
|
90
|
+
entry: "TrafficEntry | None" = None
|
|
91
|
+
request: "ParsedRequest | None" = None
|
|
92
|
+
response: "ParsedResponse | None" = None
|
|
93
|
+
intercept: "PendingInterceptionView | None" = None
|
|
94
|
+
export_source: object | None = None
|
|
95
|
+
tui: object | None = None
|
|
96
|
+
workspace_id: str = ""
|
|
97
|
+
panel_id: str = ""
|
|
98
|
+
|
|
99
|
+
def set_status(self, message: str) -> None:
|
|
100
|
+
if self.tui is None:
|
|
101
|
+
return
|
|
102
|
+
setter = getattr(self.tui, "_set_status", None)
|
|
103
|
+
if callable(setter):
|
|
104
|
+
setter(message)
|
|
105
|
+
|
|
106
|
+
def open_workspace(self, workspace_id: str) -> None:
|
|
107
|
+
if self.tui is None:
|
|
108
|
+
return
|
|
109
|
+
opener = getattr(self.tui, "open_workspace_by_id", None)
|
|
110
|
+
if callable(opener):
|
|
111
|
+
opener(workspace_id)
|
|
112
|
+
|
|
113
|
+
def global_state(self, plugin_id: str | None = None) -> dict[str, object]:
|
|
114
|
+
return self.plugin_manager.global_state(plugin_id or self.plugin_id)
|
|
115
|
+
|
|
116
|
+
def set_global_value(self, key: str, value: object, plugin_id: str | None = None) -> None:
|
|
117
|
+
self.plugin_manager.set_global_value(plugin_id or self.plugin_id, key, value)
|
|
118
|
+
|
|
119
|
+
def project_state(self, plugin_id: str | None = None) -> dict[str, object]:
|
|
120
|
+
return self.plugin_manager.project_state(plugin_id or self.plugin_id)
|
|
121
|
+
|
|
122
|
+
def set_project_value(self, key: str, value: object, plugin_id: str | None = None) -> None:
|
|
123
|
+
self.plugin_manager.set_project_value(plugin_id or self.plugin_id, key, value)
|
|
124
|
+
|
|
125
|
+
def theme_manager(self) -> "ThemeManager | None":
|
|
126
|
+
return self.plugin_manager.theme_manager()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class HexProxyPlugin(Protocol):
|
|
130
|
+
name: str
|
|
131
|
+
|
|
132
|
+
def on_loaded(self) -> None: ...
|
|
133
|
+
|
|
134
|
+
def before_request_forward(self, context: HookContext, request: "ParsedRequest") -> "ParsedRequest": ...
|
|
135
|
+
|
|
136
|
+
def on_response_received(
|
|
137
|
+
self,
|
|
138
|
+
context: HookContext,
|
|
139
|
+
request: "ParsedRequest",
|
|
140
|
+
response: "ParsedResponse",
|
|
141
|
+
) -> None: ...
|
|
142
|
+
|
|
143
|
+
def on_error(self, context: HookContext, error: Exception) -> None: ...
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(slots=True)
|
|
147
|
+
class LoadedPlugin:
|
|
148
|
+
plugin_id: str
|
|
149
|
+
name: str
|
|
150
|
+
path: Path
|
|
151
|
+
instance: object
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(slots=True)
|
|
155
|
+
class PluginWorkspaceContribution:
|
|
156
|
+
plugin_id: str
|
|
157
|
+
workspace_id: str
|
|
158
|
+
label: str
|
|
159
|
+
description: str = ""
|
|
160
|
+
order: int = 100
|
|
161
|
+
shortcut: str = ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(slots=True)
|
|
165
|
+
class PluginPanelContribution:
|
|
166
|
+
plugin_id: str
|
|
167
|
+
workspace_id: str
|
|
168
|
+
panel_id: str
|
|
169
|
+
title: str
|
|
170
|
+
description: str = ""
|
|
171
|
+
order: int = 100
|
|
172
|
+
render_lines: Callable[[PluginRenderContext], str | list[str] | None] | None = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(slots=True)
|
|
176
|
+
class PluginExporterContribution:
|
|
177
|
+
plugin_id: str
|
|
178
|
+
exporter_id: str
|
|
179
|
+
label: str
|
|
180
|
+
description: str
|
|
181
|
+
render: Callable[[PluginRenderContext], str]
|
|
182
|
+
order: int = 100
|
|
183
|
+
style_kind: str | None = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(slots=True)
|
|
187
|
+
class PluginKeybindingContribution:
|
|
188
|
+
plugin_id: str
|
|
189
|
+
action: str
|
|
190
|
+
key: str
|
|
191
|
+
description: str
|
|
192
|
+
handler: Callable[[PluginRenderContext], bool | None]
|
|
193
|
+
section: str = "Plugin Actions"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(slots=True)
|
|
197
|
+
class PluginAnalyzerContribution:
|
|
198
|
+
plugin_id: str
|
|
199
|
+
analyzer_id: str
|
|
200
|
+
label: str
|
|
201
|
+
description: str = ""
|
|
202
|
+
order: int = 100
|
|
203
|
+
analyze: Callable[[PluginRenderContext], str | list[str] | None] | None = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(slots=True)
|
|
207
|
+
class PluginMetadataContribution:
|
|
208
|
+
plugin_id: str
|
|
209
|
+
metadata_id: str
|
|
210
|
+
label: str
|
|
211
|
+
description: str = ""
|
|
212
|
+
order: int = 100
|
|
213
|
+
collect: Callable[[PluginRenderContext], dict[str, object] | list[tuple[str, object]] | None] | None = None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(slots=True)
|
|
217
|
+
class PluginSettingFieldContribution:
|
|
218
|
+
plugin_id: str
|
|
219
|
+
field_id: str
|
|
220
|
+
section: str
|
|
221
|
+
label: str
|
|
222
|
+
description: str
|
|
223
|
+
kind: str
|
|
224
|
+
scope: str = "global"
|
|
225
|
+
default: object = None
|
|
226
|
+
options: list[str] = field(default_factory=list)
|
|
227
|
+
placeholder: str = ""
|
|
228
|
+
action_label: str = "Run"
|
|
229
|
+
on_change: Callable[[PluginRenderContext, object], object | None] | None = None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class PluginAPI:
|
|
233
|
+
def __init__(self, manager: "PluginManager", plugin_id: str) -> None:
|
|
234
|
+
self._manager = manager
|
|
235
|
+
self._plugin_id = plugin_id
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def plugin_id(self) -> str:
|
|
239
|
+
return self._plugin_id
|
|
240
|
+
|
|
241
|
+
def set_plugin_id(self, plugin_id: str) -> None:
|
|
242
|
+
normalized = str(plugin_id).strip()
|
|
243
|
+
if not normalized:
|
|
244
|
+
raise ValueError("plugin id must not be empty")
|
|
245
|
+
if normalized == self._plugin_id:
|
|
246
|
+
return
|
|
247
|
+
self._manager._reassign_plugin_id(self._plugin_id, normalized)
|
|
248
|
+
self._plugin_id = normalized
|
|
249
|
+
|
|
250
|
+
def add_workspace(
|
|
251
|
+
self,
|
|
252
|
+
workspace_id: str,
|
|
253
|
+
label: str,
|
|
254
|
+
description: str = "",
|
|
255
|
+
*,
|
|
256
|
+
order: int = 100,
|
|
257
|
+
shortcut: str = "",
|
|
258
|
+
) -> None:
|
|
259
|
+
self._manager.register_workspace(
|
|
260
|
+
PluginWorkspaceContribution(
|
|
261
|
+
plugin_id=self._plugin_id,
|
|
262
|
+
workspace_id=str(workspace_id).strip(),
|
|
263
|
+
label=str(label).strip(),
|
|
264
|
+
description=str(description),
|
|
265
|
+
order=int(order),
|
|
266
|
+
shortcut=str(shortcut),
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def add_panel(
|
|
271
|
+
self,
|
|
272
|
+
workspace_id: str,
|
|
273
|
+
panel_id: str,
|
|
274
|
+
title: str,
|
|
275
|
+
*,
|
|
276
|
+
description: str = "",
|
|
277
|
+
order: int = 100,
|
|
278
|
+
render_lines: Callable[[PluginRenderContext], str | list[str] | None] | None = None,
|
|
279
|
+
) -> None:
|
|
280
|
+
self._manager.register_panel(
|
|
281
|
+
PluginPanelContribution(
|
|
282
|
+
plugin_id=self._plugin_id,
|
|
283
|
+
workspace_id=str(workspace_id).strip(),
|
|
284
|
+
panel_id=str(panel_id).strip(),
|
|
285
|
+
title=str(title).strip(),
|
|
286
|
+
description=str(description),
|
|
287
|
+
order=int(order),
|
|
288
|
+
render_lines=render_lines,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def add_exporter(
|
|
293
|
+
self,
|
|
294
|
+
exporter_id: str,
|
|
295
|
+
label: str,
|
|
296
|
+
description: str,
|
|
297
|
+
*,
|
|
298
|
+
render: Callable[[PluginRenderContext], str],
|
|
299
|
+
order: int = 100,
|
|
300
|
+
style_kind: str | None = None,
|
|
301
|
+
) -> None:
|
|
302
|
+
self._manager.register_exporter(
|
|
303
|
+
PluginExporterContribution(
|
|
304
|
+
plugin_id=self._plugin_id,
|
|
305
|
+
exporter_id=str(exporter_id).strip(),
|
|
306
|
+
label=str(label).strip(),
|
|
307
|
+
description=str(description),
|
|
308
|
+
render=render,
|
|
309
|
+
order=int(order),
|
|
310
|
+
style_kind=style_kind,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def add_keybinding(
|
|
315
|
+
self,
|
|
316
|
+
action: str,
|
|
317
|
+
key: str,
|
|
318
|
+
description: str,
|
|
319
|
+
*,
|
|
320
|
+
handler: Callable[[PluginRenderContext], bool | None],
|
|
321
|
+
section: str = "Plugin Actions",
|
|
322
|
+
) -> None:
|
|
323
|
+
self._manager.register_keybinding(
|
|
324
|
+
PluginKeybindingContribution(
|
|
325
|
+
plugin_id=self._plugin_id,
|
|
326
|
+
action=str(action).strip(),
|
|
327
|
+
key=str(key),
|
|
328
|
+
description=str(description),
|
|
329
|
+
handler=handler,
|
|
330
|
+
section=str(section).strip() or "Plugin Actions",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def add_analyzer(
|
|
335
|
+
self,
|
|
336
|
+
analyzer_id: str,
|
|
337
|
+
label: str,
|
|
338
|
+
*,
|
|
339
|
+
description: str = "",
|
|
340
|
+
order: int = 100,
|
|
341
|
+
analyze: Callable[[PluginRenderContext], str | list[str] | None] | None = None,
|
|
342
|
+
) -> None:
|
|
343
|
+
self._manager.register_analyzer(
|
|
344
|
+
PluginAnalyzerContribution(
|
|
345
|
+
plugin_id=self._plugin_id,
|
|
346
|
+
analyzer_id=str(analyzer_id).strip(),
|
|
347
|
+
label=str(label).strip(),
|
|
348
|
+
description=str(description),
|
|
349
|
+
order=int(order),
|
|
350
|
+
analyze=analyze,
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def add_metadata(
|
|
355
|
+
self,
|
|
356
|
+
metadata_id: str,
|
|
357
|
+
label: str,
|
|
358
|
+
*,
|
|
359
|
+
description: str = "",
|
|
360
|
+
order: int = 100,
|
|
361
|
+
collect: Callable[[PluginRenderContext], dict[str, object] | list[tuple[str, object]] | None] | None = None,
|
|
362
|
+
) -> None:
|
|
363
|
+
self._manager.register_metadata(
|
|
364
|
+
PluginMetadataContribution(
|
|
365
|
+
plugin_id=self._plugin_id,
|
|
366
|
+
metadata_id=str(metadata_id).strip(),
|
|
367
|
+
label=str(label).strip(),
|
|
368
|
+
description=str(description),
|
|
369
|
+
order=int(order),
|
|
370
|
+
collect=collect,
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def add_setting_field(
|
|
375
|
+
self,
|
|
376
|
+
field_id: str,
|
|
377
|
+
section: str,
|
|
378
|
+
label: str,
|
|
379
|
+
description: str,
|
|
380
|
+
*,
|
|
381
|
+
kind: str,
|
|
382
|
+
scope: str = "global",
|
|
383
|
+
default: object = None,
|
|
384
|
+
options: list[str] | None = None,
|
|
385
|
+
placeholder: str = "",
|
|
386
|
+
action_label: str = "Run",
|
|
387
|
+
on_change: Callable[[PluginRenderContext, object], object | None] | None = None,
|
|
388
|
+
) -> None:
|
|
389
|
+
kind_name = str(kind).strip()
|
|
390
|
+
if kind_name not in SETTING_FIELD_KINDS:
|
|
391
|
+
raise ValueError(f"unsupported setting field kind {kind_name!r}")
|
|
392
|
+
scope_name = str(scope).strip().lower() or "global"
|
|
393
|
+
if scope_name not in {"global", "project"}:
|
|
394
|
+
raise ValueError("setting field scope must be 'global' or 'project'")
|
|
395
|
+
self._manager.register_setting_field(
|
|
396
|
+
PluginSettingFieldContribution(
|
|
397
|
+
plugin_id=self._plugin_id,
|
|
398
|
+
field_id=str(field_id).strip(),
|
|
399
|
+
section=str(section).strip() or "Plugin Settings",
|
|
400
|
+
label=str(label).strip(),
|
|
401
|
+
description=str(description),
|
|
402
|
+
kind=kind_name,
|
|
403
|
+
scope=scope_name,
|
|
404
|
+
default=default,
|
|
405
|
+
options=list(options or []),
|
|
406
|
+
placeholder=str(placeholder),
|
|
407
|
+
action_label=str(action_label).strip() or "Run",
|
|
408
|
+
on_change=on_change,
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class PluginManager:
|
|
414
|
+
def __init__(self) -> None:
|
|
415
|
+
self._plugins: list[LoadedPlugin] = []
|
|
416
|
+
self._load_errors: list[str] = []
|
|
417
|
+
self._plugin_dirs: list[Path] = []
|
|
418
|
+
self._workspaces: list[PluginWorkspaceContribution] = []
|
|
419
|
+
self._panels: list[PluginPanelContribution] = []
|
|
420
|
+
self._exporters: list[PluginExporterContribution] = []
|
|
421
|
+
self._keybindings: list[PluginKeybindingContribution] = []
|
|
422
|
+
self._analyzers: list[PluginAnalyzerContribution] = []
|
|
423
|
+
self._metadata: list[PluginMetadataContribution] = []
|
|
424
|
+
self._setting_fields: list[PluginSettingFieldContribution] = []
|
|
425
|
+
self._store: TrafficStore | None = None
|
|
426
|
+
self._preferences: ApplicationPreferences | None = None
|
|
427
|
+
self._theme_manager: ThemeManager | None = None
|
|
428
|
+
|
|
429
|
+
def bind_runtime(
|
|
430
|
+
self,
|
|
431
|
+
*,
|
|
432
|
+
store: "TrafficStore | None" = None,
|
|
433
|
+
preferences: "ApplicationPreferences | None" = None,
|
|
434
|
+
theme_manager: "ThemeManager | None" = None,
|
|
435
|
+
) -> None:
|
|
436
|
+
if store is not None:
|
|
437
|
+
self._store = store
|
|
438
|
+
if preferences is not None:
|
|
439
|
+
self._preferences = preferences
|
|
440
|
+
if theme_manager is not None:
|
|
441
|
+
self._theme_manager = theme_manager
|
|
442
|
+
|
|
443
|
+
def theme_manager(self) -> "ThemeManager | None":
|
|
444
|
+
return self._theme_manager
|
|
445
|
+
|
|
446
|
+
def global_state(self, plugin_id: str) -> dict[str, object]:
|
|
447
|
+
if self._preferences is None:
|
|
448
|
+
return {}
|
|
449
|
+
state = self._preferences.plugin_state(str(plugin_id).strip())
|
|
450
|
+
return state if isinstance(state, dict) else {}
|
|
451
|
+
|
|
452
|
+
def set_global_state(self, plugin_id: str, values: dict[str, object]) -> None:
|
|
453
|
+
if self._preferences is None:
|
|
454
|
+
return
|
|
455
|
+
self._preferences.set_plugin_state(plugin_id, values)
|
|
456
|
+
self._preferences.save()
|
|
457
|
+
|
|
458
|
+
def global_value(self, plugin_id: str, key: str, default: object = None) -> object:
|
|
459
|
+
if self._preferences is None:
|
|
460
|
+
return default
|
|
461
|
+
return self._preferences.plugin_value(plugin_id, key, default)
|
|
462
|
+
|
|
463
|
+
def set_global_value(self, plugin_id: str, key: str, value: object) -> None:
|
|
464
|
+
if self._preferences is None:
|
|
465
|
+
return
|
|
466
|
+
self._preferences.set_plugin_value(plugin_id, key, value)
|
|
467
|
+
self._preferences.save()
|
|
468
|
+
|
|
469
|
+
def project_state(self, plugin_id: str) -> dict[str, object]:
|
|
470
|
+
if self._store is None:
|
|
471
|
+
return {}
|
|
472
|
+
state = self._store.plugin_state(str(plugin_id).strip())
|
|
473
|
+
return state if isinstance(state, dict) else {}
|
|
474
|
+
|
|
475
|
+
def set_project_state(self, plugin_id: str, values: dict[str, object]) -> None:
|
|
476
|
+
if self._store is None:
|
|
477
|
+
return
|
|
478
|
+
self._store.set_plugin_state(plugin_id, values)
|
|
479
|
+
|
|
480
|
+
def project_value(self, plugin_id: str, key: str, default: object = None) -> object:
|
|
481
|
+
if self._store is None:
|
|
482
|
+
return default
|
|
483
|
+
return self._store.plugin_value(plugin_id, key, default)
|
|
484
|
+
|
|
485
|
+
def set_project_value(self, plugin_id: str, key: str, value: object) -> None:
|
|
486
|
+
if self._store is None:
|
|
487
|
+
return
|
|
488
|
+
self._store.set_plugin_value(plugin_id, key, value)
|
|
489
|
+
|
|
490
|
+
def load_from_dirs(self, directories: list[Path]) -> None:
|
|
491
|
+
for directory in directories:
|
|
492
|
+
if directory not in self._plugin_dirs:
|
|
493
|
+
self._plugin_dirs.append(directory)
|
|
494
|
+
if not directory.exists() or not directory.is_dir():
|
|
495
|
+
continue
|
|
496
|
+
for path in sorted(directory.glob("*.py")):
|
|
497
|
+
if path.name.startswith("_"):
|
|
498
|
+
continue
|
|
499
|
+
self._load_plugin(path)
|
|
500
|
+
|
|
501
|
+
def loaded_plugins(self) -> list[LoadedPlugin]:
|
|
502
|
+
return list(self._plugins)
|
|
503
|
+
|
|
504
|
+
def load_errors(self) -> list[str]:
|
|
505
|
+
return list(self._load_errors)
|
|
506
|
+
|
|
507
|
+
def plugin_dirs(self) -> list[Path]:
|
|
508
|
+
return list(self._plugin_dirs)
|
|
509
|
+
|
|
510
|
+
def workspace_contributions(self) -> list[PluginWorkspaceContribution]:
|
|
511
|
+
return sorted(self._workspaces, key=lambda item: (item.order, item.label.lower(), item.workspace_id))
|
|
512
|
+
|
|
513
|
+
def panel_contributions(self, workspace_id: str | None = None) -> list[PluginPanelContribution]:
|
|
514
|
+
items = self._panels
|
|
515
|
+
if workspace_id is not None:
|
|
516
|
+
items = [item for item in items if item.workspace_id == workspace_id]
|
|
517
|
+
return sorted(items, key=lambda item: (item.order, item.title.lower(), item.panel_id))
|
|
518
|
+
|
|
519
|
+
def exporter_contributions(self) -> list[PluginExporterContribution]:
|
|
520
|
+
return sorted(self._exporters, key=lambda item: (item.order, item.label.lower(), item.exporter_id))
|
|
521
|
+
|
|
522
|
+
def keybinding_contributions(self) -> list[PluginKeybindingContribution]:
|
|
523
|
+
return sorted(self._keybindings, key=lambda item: (item.section.lower(), item.description.lower(), item.action))
|
|
524
|
+
|
|
525
|
+
def analyzer_contributions(self) -> list[PluginAnalyzerContribution]:
|
|
526
|
+
return sorted(self._analyzers, key=lambda item: (item.order, item.label.lower(), item.analyzer_id))
|
|
527
|
+
|
|
528
|
+
def metadata_contributions(self) -> list[PluginMetadataContribution]:
|
|
529
|
+
return sorted(self._metadata, key=lambda item: (item.order, item.label.lower(), item.metadata_id))
|
|
530
|
+
|
|
531
|
+
def setting_field_contributions(self) -> list[PluginSettingFieldContribution]:
|
|
532
|
+
return sorted(self._setting_fields, key=lambda item: (item.section.lower(), item.label.lower(), item.field_id))
|
|
533
|
+
|
|
534
|
+
def register_workspace(self, contribution: PluginWorkspaceContribution) -> None:
|
|
535
|
+
if not contribution.workspace_id:
|
|
536
|
+
raise ValueError("workspace id must not be empty")
|
|
537
|
+
if contribution.workspace_id in BUILTIN_WORKSPACE_IDS:
|
|
538
|
+
raise ValueError(f"workspace id {contribution.workspace_id!r} collides with a built-in workspace")
|
|
539
|
+
self._replace_or_append(
|
|
540
|
+
self._workspaces,
|
|
541
|
+
contribution,
|
|
542
|
+
lambda item: item.workspace_id == contribution.workspace_id,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def register_panel(self, contribution: PluginPanelContribution) -> None:
|
|
546
|
+
if not contribution.workspace_id or not contribution.panel_id:
|
|
547
|
+
raise ValueError("workspace id and panel id must not be empty")
|
|
548
|
+
self._replace_or_append(
|
|
549
|
+
self._panels,
|
|
550
|
+
contribution,
|
|
551
|
+
lambda item: (
|
|
552
|
+
item.workspace_id == contribution.workspace_id
|
|
553
|
+
and item.panel_id == contribution.panel_id
|
|
554
|
+
),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def register_exporter(self, contribution: PluginExporterContribution) -> None:
|
|
558
|
+
if not contribution.exporter_id:
|
|
559
|
+
raise ValueError("exporter id must not be empty")
|
|
560
|
+
self._replace_or_append(
|
|
561
|
+
self._exporters,
|
|
562
|
+
contribution,
|
|
563
|
+
lambda item: item.exporter_id == contribution.exporter_id,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def register_keybinding(self, contribution: PluginKeybindingContribution) -> None:
|
|
567
|
+
if not contribution.action:
|
|
568
|
+
raise ValueError("keybinding action must not be empty")
|
|
569
|
+
self._replace_or_append(
|
|
570
|
+
self._keybindings,
|
|
571
|
+
contribution,
|
|
572
|
+
lambda item: item.action == contribution.action,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def register_analyzer(self, contribution: PluginAnalyzerContribution) -> None:
|
|
576
|
+
if not contribution.analyzer_id:
|
|
577
|
+
raise ValueError("analyzer id must not be empty")
|
|
578
|
+
self._replace_or_append(
|
|
579
|
+
self._analyzers,
|
|
580
|
+
contribution,
|
|
581
|
+
lambda item: item.analyzer_id == contribution.analyzer_id,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def register_metadata(self, contribution: PluginMetadataContribution) -> None:
|
|
585
|
+
if not contribution.metadata_id:
|
|
586
|
+
raise ValueError("metadata id must not be empty")
|
|
587
|
+
self._replace_or_append(
|
|
588
|
+
self._metadata,
|
|
589
|
+
contribution,
|
|
590
|
+
lambda item: item.metadata_id == contribution.metadata_id,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
def register_setting_field(self, contribution: PluginSettingFieldContribution) -> None:
|
|
594
|
+
if not contribution.field_id:
|
|
595
|
+
raise ValueError("setting field id must not be empty")
|
|
596
|
+
self._replace_or_append(
|
|
597
|
+
self._setting_fields,
|
|
598
|
+
contribution,
|
|
599
|
+
lambda item: (
|
|
600
|
+
item.plugin_id == contribution.plugin_id
|
|
601
|
+
and item.field_id == contribution.field_id
|
|
602
|
+
),
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def before_request_forward(self, context: HookContext, request: "ParsedRequest") -> "ParsedRequest":
|
|
606
|
+
current = request
|
|
607
|
+
context.plugin_manager = self
|
|
608
|
+
for plugin in self._plugins:
|
|
609
|
+
hook = getattr(plugin.instance, "before_request_forward", None)
|
|
610
|
+
if hook is None:
|
|
611
|
+
continue
|
|
612
|
+
candidate = hook(context, current)
|
|
613
|
+
if candidate is not None:
|
|
614
|
+
current = candidate
|
|
615
|
+
return current
|
|
616
|
+
|
|
617
|
+
def on_response_received(
|
|
618
|
+
self,
|
|
619
|
+
context: HookContext,
|
|
620
|
+
request: "ParsedRequest",
|
|
621
|
+
response: "ParsedResponse",
|
|
622
|
+
) -> None:
|
|
623
|
+
context.plugin_manager = self
|
|
624
|
+
for plugin in self._plugins:
|
|
625
|
+
hook = getattr(plugin.instance, "on_response_received", None)
|
|
626
|
+
if hook is None:
|
|
627
|
+
continue
|
|
628
|
+
hook(context, request, response)
|
|
629
|
+
|
|
630
|
+
def on_error(self, context: HookContext, error: Exception) -> None:
|
|
631
|
+
context.plugin_manager = self
|
|
632
|
+
for plugin in self._plugins:
|
|
633
|
+
hook = getattr(plugin.instance, "on_error", None)
|
|
634
|
+
if hook is None:
|
|
635
|
+
continue
|
|
636
|
+
hook(context, error)
|
|
637
|
+
|
|
638
|
+
def persist_hook_context(self, context: HookContext) -> None:
|
|
639
|
+
if self._store is None:
|
|
640
|
+
return
|
|
641
|
+
for plugin_id, metadata in context.metadata.items():
|
|
642
|
+
self._store.set_entry_plugin_metadata(context.entry_id, plugin_id, metadata)
|
|
643
|
+
for plugin_id, findings in context.findings.items():
|
|
644
|
+
self._store.set_entry_plugin_findings(context.entry_id, plugin_id, findings)
|
|
645
|
+
|
|
646
|
+
def _load_plugin(self, path: Path) -> None:
|
|
647
|
+
provisional_id = path.stem
|
|
648
|
+
api = PluginAPI(self, provisional_id)
|
|
649
|
+
try:
|
|
650
|
+
module = self._load_module(path)
|
|
651
|
+
plugin = self._instantiate_plugin(module, api)
|
|
652
|
+
final_id = str(getattr(plugin, "plugin_id", provisional_id)).strip() or provisional_id
|
|
653
|
+
if final_id != provisional_id:
|
|
654
|
+
api.set_plugin_id(final_id)
|
|
655
|
+
name = str(getattr(plugin, "name", final_id))
|
|
656
|
+
module_contribute = getattr(module, "contribute", None)
|
|
657
|
+
if callable(module_contribute):
|
|
658
|
+
self._call_plugin_factory(module_contribute, api)
|
|
659
|
+
contribute = getattr(plugin, "contribute", None)
|
|
660
|
+
if callable(contribute):
|
|
661
|
+
self._call_plugin_factory(contribute, api)
|
|
662
|
+
on_loaded = getattr(plugin, "on_loaded", None)
|
|
663
|
+
if callable(on_loaded):
|
|
664
|
+
on_loaded()
|
|
665
|
+
self._plugins.append(
|
|
666
|
+
LoadedPlugin(plugin_id=api.plugin_id, name=name, path=path, instance=plugin)
|
|
667
|
+
)
|
|
668
|
+
except Exception as exc:
|
|
669
|
+
self._load_errors.append(f"{path}: {exc}")
|
|
670
|
+
|
|
671
|
+
@staticmethod
|
|
672
|
+
def _load_module(path: Path) -> ModuleType:
|
|
673
|
+
spec = importlib.util.spec_from_file_location(f"hexproxy_plugin_{path.stem}", path)
|
|
674
|
+
if spec is None or spec.loader is None:
|
|
675
|
+
raise RuntimeError("unable to create import spec")
|
|
676
|
+
module = importlib.util.module_from_spec(spec)
|
|
677
|
+
spec.loader.exec_module(module)
|
|
678
|
+
return module
|
|
679
|
+
|
|
680
|
+
def _instantiate_plugin(self, module: ModuleType, api: PluginAPI) -> object:
|
|
681
|
+
register = getattr(module, "register", None)
|
|
682
|
+
if callable(register):
|
|
683
|
+
plugin = self._call_plugin_factory(register, api)
|
|
684
|
+
return module if plugin is None else plugin
|
|
685
|
+
plugin = getattr(module, "PLUGIN", None)
|
|
686
|
+
if plugin is not None:
|
|
687
|
+
return plugin
|
|
688
|
+
contribute = getattr(module, "contribute", None)
|
|
689
|
+
if callable(contribute):
|
|
690
|
+
self._call_plugin_factory(contribute, api)
|
|
691
|
+
return module
|
|
692
|
+
raise RuntimeError("plugin module must export register(api) / register(), PLUGIN, or contribute(api)")
|
|
693
|
+
|
|
694
|
+
@staticmethod
|
|
695
|
+
def _call_plugin_factory(factory: Callable[..., object], api: PluginAPI) -> object:
|
|
696
|
+
signature = inspect.signature(factory)
|
|
697
|
+
positional = [
|
|
698
|
+
parameter
|
|
699
|
+
for parameter in signature.parameters.values()
|
|
700
|
+
if parameter.kind in (
|
|
701
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
702
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
703
|
+
)
|
|
704
|
+
]
|
|
705
|
+
required = [
|
|
706
|
+
parameter
|
|
707
|
+
for parameter in positional
|
|
708
|
+
if parameter.default is inspect._empty
|
|
709
|
+
]
|
|
710
|
+
if len(required) == 0:
|
|
711
|
+
return factory()
|
|
712
|
+
if len(required) == 1 and len(positional) == 1:
|
|
713
|
+
return factory(api)
|
|
714
|
+
raise RuntimeError("plugin factory must accept either zero arguments or a single PluginAPI")
|
|
715
|
+
|
|
716
|
+
@staticmethod
|
|
717
|
+
def _replace_or_append(items: list[Any], new_item: Any, matcher: Callable[[Any], bool]) -> None:
|
|
718
|
+
for index, existing in enumerate(items):
|
|
719
|
+
if matcher(existing):
|
|
720
|
+
items[index] = new_item
|
|
721
|
+
return
|
|
722
|
+
items.append(new_item)
|
|
723
|
+
|
|
724
|
+
def _reassign_plugin_id(self, old_id: str, new_id: str) -> None:
|
|
725
|
+
if old_id == new_id:
|
|
726
|
+
return
|
|
727
|
+
for collection_name in (
|
|
728
|
+
"_workspaces",
|
|
729
|
+
"_panels",
|
|
730
|
+
"_exporters",
|
|
731
|
+
"_keybindings",
|
|
732
|
+
"_analyzers",
|
|
733
|
+
"_metadata",
|
|
734
|
+
"_setting_fields",
|
|
735
|
+
):
|
|
736
|
+
collection = getattr(self, collection_name)
|
|
737
|
+
for item in collection:
|
|
738
|
+
if getattr(item, "plugin_id", None) == old_id:
|
|
739
|
+
item.plugin_id = new_id
|