webtap-tool 0.11.0__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.
- webtap/VISION.md +246 -0
- webtap/__init__.py +84 -0
- webtap/__main__.py +6 -0
- webtap/api/__init__.py +9 -0
- webtap/api/app.py +26 -0
- webtap/api/models.py +69 -0
- webtap/api/server.py +111 -0
- webtap/api/sse.py +182 -0
- webtap/api/state.py +89 -0
- webtap/app.py +79 -0
- webtap/cdp/README.md +275 -0
- webtap/cdp/__init__.py +12 -0
- webtap/cdp/har.py +302 -0
- webtap/cdp/schema/README.md +41 -0
- webtap/cdp/schema/cdp_protocol.json +32785 -0
- webtap/cdp/schema/cdp_version.json +8 -0
- webtap/cdp/session.py +667 -0
- webtap/client.py +81 -0
- webtap/commands/DEVELOPER_GUIDE.md +401 -0
- webtap/commands/TIPS.md +269 -0
- webtap/commands/__init__.py +29 -0
- webtap/commands/_builders.py +331 -0
- webtap/commands/_code_generation.py +110 -0
- webtap/commands/_tips.py +147 -0
- webtap/commands/_utils.py +273 -0
- webtap/commands/connection.py +220 -0
- webtap/commands/console.py +87 -0
- webtap/commands/fetch.py +310 -0
- webtap/commands/filters.py +116 -0
- webtap/commands/javascript.py +73 -0
- webtap/commands/js_export.py +73 -0
- webtap/commands/launch.py +72 -0
- webtap/commands/navigation.py +197 -0
- webtap/commands/network.py +136 -0
- webtap/commands/quicktype.py +306 -0
- webtap/commands/request.py +93 -0
- webtap/commands/selections.py +138 -0
- webtap/commands/setup.py +219 -0
- webtap/commands/to_model.py +163 -0
- webtap/daemon.py +185 -0
- webtap/daemon_state.py +53 -0
- webtap/filters.py +219 -0
- webtap/rpc/__init__.py +14 -0
- webtap/rpc/errors.py +49 -0
- webtap/rpc/framework.py +223 -0
- webtap/rpc/handlers.py +625 -0
- webtap/rpc/machine.py +84 -0
- webtap/services/README.md +83 -0
- webtap/services/__init__.py +15 -0
- webtap/services/console.py +124 -0
- webtap/services/dom.py +547 -0
- webtap/services/fetch.py +415 -0
- webtap/services/main.py +392 -0
- webtap/services/network.py +401 -0
- webtap/services/setup/__init__.py +185 -0
- webtap/services/setup/chrome.py +233 -0
- webtap/services/setup/desktop.py +255 -0
- webtap/services/setup/extension.py +147 -0
- webtap/services/setup/platform.py +162 -0
- webtap/services/state_snapshot.py +86 -0
- webtap_tool-0.11.0.dist-info/METADATA +535 -0
- webtap_tool-0.11.0.dist-info/RECORD +64 -0
- webtap_tool-0.11.0.dist-info/WHEEL +4 -0
- webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
webtap/commands/setup.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Setup commands for WebTap components."""
|
|
2
|
+
|
|
3
|
+
from webtap.app import app
|
|
4
|
+
from webtap.services.setup import SetupService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@app.command(
|
|
8
|
+
display="markdown",
|
|
9
|
+
typer={"name": "setup-extension", "help": "Download Chrome extension from GitHub"},
|
|
10
|
+
fastmcp={"enabled": False},
|
|
11
|
+
)
|
|
12
|
+
def setup_extension(state, force: bool = False) -> dict:
|
|
13
|
+
"""Download Chrome extension to platform-appropriate location.
|
|
14
|
+
|
|
15
|
+
Linux: ~/.local/share/webtap/extension/
|
|
16
|
+
macOS: ~/Library/Application Support/webtap/extension/
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
force: Overwrite existing files (default: False)
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Markdown-formatted result with success/error messages
|
|
23
|
+
"""
|
|
24
|
+
service = SetupService()
|
|
25
|
+
result = service.install_extension(force=force)
|
|
26
|
+
return _format_setup_result(result, "extension")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command(
|
|
30
|
+
display="markdown",
|
|
31
|
+
typer={"name": "setup-chrome", "help": "Install Chrome wrapper script for debugging"},
|
|
32
|
+
fastmcp={"enabled": False},
|
|
33
|
+
)
|
|
34
|
+
def setup_chrome(state, force: bool = False, bindfs: bool = False) -> dict:
|
|
35
|
+
"""Install Chrome wrapper script 'chrome-debug' to ~/.local/bin/.
|
|
36
|
+
|
|
37
|
+
The wrapper enables remote debugging on port 9222.
|
|
38
|
+
Same location on both Linux and macOS: ~/.local/bin/chrome-debug
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
force: Overwrite existing script (default: False)
|
|
42
|
+
bindfs: Use bindfs to mount real Chrome profile for debugging (Linux only, default: False)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Markdown-formatted result with success/error messages
|
|
46
|
+
"""
|
|
47
|
+
service = SetupService()
|
|
48
|
+
result = service.install_chrome_wrapper(force=force, bindfs=bindfs)
|
|
49
|
+
return _format_setup_result(result, "chrome")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command(
|
|
53
|
+
display="markdown",
|
|
54
|
+
typer={"name": "setup-desktop", "help": "Install Chrome Debug GUI launcher"},
|
|
55
|
+
fastmcp={"enabled": False},
|
|
56
|
+
)
|
|
57
|
+
def setup_desktop(state, force: bool = False) -> dict:
|
|
58
|
+
"""Install Chrome Debug GUI launcher (separate from system Chrome).
|
|
59
|
+
|
|
60
|
+
Linux: Creates desktop entry at ~/.local/share/applications/chrome-debug.desktop
|
|
61
|
+
Shows as "Chrome Debug" in application menu.
|
|
62
|
+
|
|
63
|
+
macOS: Creates app bundle at ~/Applications/Chrome Debug.app
|
|
64
|
+
Shows as "Chrome Debug" in Launchpad and Spotlight.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
force: Overwrite existing launcher (default: False)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Markdown-formatted result with success/error messages
|
|
71
|
+
"""
|
|
72
|
+
service = SetupService()
|
|
73
|
+
result = service.install_desktop_entry(force=force)
|
|
74
|
+
return _format_setup_result(result, "desktop")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _format_setup_result(result: dict, component: str) -> dict:
|
|
78
|
+
"""Format setup result as markdown."""
|
|
79
|
+
elements = []
|
|
80
|
+
|
|
81
|
+
# Main message as alert (using "message" key for consistency)
|
|
82
|
+
level = "success" if result["success"] else "error"
|
|
83
|
+
elements.append({"type": "alert", "message": result["message"], "level": level})
|
|
84
|
+
|
|
85
|
+
# Add details if present
|
|
86
|
+
if result.get("path"):
|
|
87
|
+
elements.append({"type": "text", "content": f"**Location:** `{result['path']}`"})
|
|
88
|
+
if result.get("details"):
|
|
89
|
+
elements.append({"type": "text", "content": f"**Details:** {result['details']}"})
|
|
90
|
+
|
|
91
|
+
# Component-specific next steps
|
|
92
|
+
if result["success"]:
|
|
93
|
+
if component == "extension":
|
|
94
|
+
elements.append({"type": "text", "content": "\n**To install in Chrome:**"})
|
|
95
|
+
elements.append(
|
|
96
|
+
{
|
|
97
|
+
"type": "list",
|
|
98
|
+
"items": [
|
|
99
|
+
"Open chrome://extensions/",
|
|
100
|
+
"Enable Developer mode",
|
|
101
|
+
"Click 'Load unpacked'",
|
|
102
|
+
f"Select {result['path']}",
|
|
103
|
+
],
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
elif component == "chrome":
|
|
107
|
+
if "Add to PATH" in result.get("details", ""):
|
|
108
|
+
elements.append({"type": "text", "content": "\n**Setup PATH:**"})
|
|
109
|
+
elements.append(
|
|
110
|
+
{
|
|
111
|
+
"type": "code_block",
|
|
112
|
+
"language": "bash",
|
|
113
|
+
"content": 'export PATH="$HOME/.local/bin/wrappers:$PATH"',
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
elements.append({"type": "text", "content": "Add to ~/.bashrc to make permanent"})
|
|
117
|
+
else:
|
|
118
|
+
elements.append({"type": "text", "content": "\n**Usage:**"})
|
|
119
|
+
elements.append(
|
|
120
|
+
{
|
|
121
|
+
"type": "list",
|
|
122
|
+
"items": [
|
|
123
|
+
"Run `chrome-debug` to start Chrome with debugging",
|
|
124
|
+
"Or use `run-chrome` command for direct launch",
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
elif component == "desktop":
|
|
129
|
+
# Platform-specific instructions are already in the service's details
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
return {"elements": elements}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command(
|
|
136
|
+
display="markdown",
|
|
137
|
+
typer={"name": "setup-cleanup", "help": "Clean up old WebTap installations"},
|
|
138
|
+
fastmcp={"enabled": False},
|
|
139
|
+
)
|
|
140
|
+
def setup_cleanup(state, dry_run: bool = True) -> dict:
|
|
141
|
+
"""Clean up old WebTap installations from previous versions.
|
|
142
|
+
|
|
143
|
+
Checks for and removes:
|
|
144
|
+
- Old extension location (~/.config/webtap/extension/)
|
|
145
|
+
- Old desktop entries created by webtap
|
|
146
|
+
- Unmounted bindfs directories
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
dry_run: Only show what would be cleaned (default: True)
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Markdown report of cleanup actions
|
|
153
|
+
"""
|
|
154
|
+
service = SetupService()
|
|
155
|
+
result = service.cleanup_old_installations(dry_run=dry_run)
|
|
156
|
+
|
|
157
|
+
elements = []
|
|
158
|
+
|
|
159
|
+
# Header
|
|
160
|
+
elements.append({"type": "heading", "level": 2, "content": "WebTap Cleanup Report"})
|
|
161
|
+
|
|
162
|
+
# Old installations found
|
|
163
|
+
if result.get("old_extension"):
|
|
164
|
+
elements.append({"type": "heading", "level": 3, "content": "Old Extension Location"})
|
|
165
|
+
elements.append({"type": "text", "content": f"Found: `{result['old_extension']['path']}`"})
|
|
166
|
+
elements.append({"type": "text", "content": f"Size: {result['old_extension']['size']}"})
|
|
167
|
+
if not dry_run and result["old_extension"].get("removed"):
|
|
168
|
+
elements.append({"type": "alert", "message": "✓ Removed old extension", "level": "success"})
|
|
169
|
+
elif dry_run:
|
|
170
|
+
elements.append({"type": "alert", "message": "Would remove (dry-run mode)", "level": "info"})
|
|
171
|
+
|
|
172
|
+
# Old Chrome wrapper
|
|
173
|
+
if result.get("old_wrapper"):
|
|
174
|
+
elements.append({"type": "heading", "level": 3, "content": "Old Chrome Wrapper"})
|
|
175
|
+
elements.append({"type": "text", "content": f"Found: `{result['old_wrapper']['path']}`"})
|
|
176
|
+
if not dry_run and result["old_wrapper"].get("removed"):
|
|
177
|
+
elements.append({"type": "alert", "message": "✓ Removed old wrapper", "level": "success"})
|
|
178
|
+
elif dry_run:
|
|
179
|
+
elements.append({"type": "alert", "message": "Would remove (dry-run mode)", "level": "info"})
|
|
180
|
+
|
|
181
|
+
# Old desktop entry
|
|
182
|
+
if result.get("old_desktop"):
|
|
183
|
+
elements.append({"type": "heading", "level": 3, "content": "Old Desktop Entry"})
|
|
184
|
+
elements.append({"type": "text", "content": f"Found: `{result['old_desktop']['path']}`"})
|
|
185
|
+
if not dry_run and result["old_desktop"].get("removed"):
|
|
186
|
+
elements.append({"type": "alert", "message": "✓ Removed old desktop entry", "level": "success"})
|
|
187
|
+
elif dry_run:
|
|
188
|
+
elements.append({"type": "alert", "message": "Would remove (dry-run mode)", "level": "info"})
|
|
189
|
+
|
|
190
|
+
# Check for bindfs mounts
|
|
191
|
+
if result.get("bindfs_mount"):
|
|
192
|
+
elements.append({"type": "heading", "level": 3, "content": "Bindfs Mount Detected"})
|
|
193
|
+
elements.append({"type": "text", "content": f"Mount: `{result['bindfs_mount']}`"})
|
|
194
|
+
elements.append(
|
|
195
|
+
{"type": "alert", "message": "To unmount: fusermount -u " + result["bindfs_mount"], "level": "warning"}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Summary
|
|
199
|
+
elements.append({"type": "heading", "level": 3, "content": "Summary"})
|
|
200
|
+
if dry_run:
|
|
201
|
+
elements.append({"type": "text", "content": "**Dry-run mode** - no changes made"})
|
|
202
|
+
elements.append({"type": "text", "content": "To perform cleanup: `setup-cleanup --no-dry-run`"})
|
|
203
|
+
else:
|
|
204
|
+
elements.append({"type": "alert", "message": "Cleanup completed", "level": "success"})
|
|
205
|
+
|
|
206
|
+
# Next steps
|
|
207
|
+
elements.append({"type": "heading", "level": 3, "content": "Next Steps"})
|
|
208
|
+
elements.append(
|
|
209
|
+
{
|
|
210
|
+
"type": "list",
|
|
211
|
+
"items": [
|
|
212
|
+
"Run `setup-extension` to install extension in new location",
|
|
213
|
+
"Run `setup-chrome --bindfs` for bindfs mode or `setup-chrome` for standard mode",
|
|
214
|
+
"Run `setup-desktop` to create Chrome Debug launcher",
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return {"elements": elements}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Generate Pydantic models from HTTP request/response bodies."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datamodel_code_generator import generate, InputFileType, DataModelType
|
|
5
|
+
from webtap.app import app
|
|
6
|
+
from webtap.commands._builders import success_response, error_response
|
|
7
|
+
from webtap.commands._code_generation import (
|
|
8
|
+
ensure_output_directory,
|
|
9
|
+
parse_json,
|
|
10
|
+
extract_json_path,
|
|
11
|
+
validate_generation_data,
|
|
12
|
+
)
|
|
13
|
+
from webtap.commands._utils import evaluate_expression, fetch_body_content
|
|
14
|
+
from webtap.commands._tips import get_mcp_description
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
mcp_desc = get_mcp_description("to_model")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": mcp_desc or ""})
|
|
21
|
+
def to_model(
|
|
22
|
+
state,
|
|
23
|
+
id: int,
|
|
24
|
+
output: str,
|
|
25
|
+
model_name: str,
|
|
26
|
+
field: str = "response.content",
|
|
27
|
+
json_path: str = None, # pyright: ignore[reportArgumentType]
|
|
28
|
+
expr: str = None, # pyright: ignore[reportArgumentType]
|
|
29
|
+
) -> dict: # pyright: ignore[reportArgumentType]
|
|
30
|
+
"""Generate Pydantic model from request or response body.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
id: Row ID from network() output
|
|
34
|
+
output: Output file path for generated model (e.g., "models/user.py")
|
|
35
|
+
model_name: Class name for generated model (e.g., "User")
|
|
36
|
+
field: Body to use - "response.content" (default) or "request.postData"
|
|
37
|
+
json_path: Optional JSON path to extract nested data (e.g., "data[0]")
|
|
38
|
+
expr: Optional Python expression to transform data (has 'body' variable)
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
to_model(5, "models/user.py", "User")
|
|
42
|
+
to_model(5, "models/user.py", "User", json_path="data[0]")
|
|
43
|
+
to_model(5, "models/form.py", "Form", field="request.postData")
|
|
44
|
+
to_model(5, "models/clean.py", "Clean", expr="{k: v for k, v in json.loads(body).items() if k != 'meta'}")
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Success message with generation details
|
|
48
|
+
"""
|
|
49
|
+
# Get HAR entry via RPC - need full entry with request_id for body fetch
|
|
50
|
+
try:
|
|
51
|
+
result = state.client.call("request", id=id, fields=["*"])
|
|
52
|
+
har_entry = result.get("entry")
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return error_response(f"Failed to get request: {e}")
|
|
55
|
+
|
|
56
|
+
if not har_entry:
|
|
57
|
+
return error_response(f"Request {id} not found")
|
|
58
|
+
|
|
59
|
+
# Fetch body content
|
|
60
|
+
body_content, err = fetch_body_content(state, har_entry, field)
|
|
61
|
+
if err or body_content is None:
|
|
62
|
+
return error_response(
|
|
63
|
+
err or "Failed to fetch body",
|
|
64
|
+
suggestions=[
|
|
65
|
+
f"Field '{field}' could not be fetched",
|
|
66
|
+
"For response body: field='response.content'",
|
|
67
|
+
"For POST data: field='request.postData'",
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Transform via expression or parse as JSON
|
|
72
|
+
if expr:
|
|
73
|
+
try:
|
|
74
|
+
namespace = {"body": body_content}
|
|
75
|
+
data, _ = evaluate_expression(expr, namespace)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return error_response(
|
|
78
|
+
f"Expression failed: {e}",
|
|
79
|
+
suggestions=[
|
|
80
|
+
"Variable available: 'body' (str)",
|
|
81
|
+
"Example: json.loads(body)['data'][0]",
|
|
82
|
+
"Example: dict(urllib.parse.parse_qsl(body))",
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
if not body_content.strip():
|
|
87
|
+
return error_response("Body is empty")
|
|
88
|
+
|
|
89
|
+
data, parse_err = parse_json(body_content)
|
|
90
|
+
if parse_err:
|
|
91
|
+
return error_response(
|
|
92
|
+
parse_err,
|
|
93
|
+
suggestions=[
|
|
94
|
+
"Body must be valid JSON, or use expr to transform it",
|
|
95
|
+
'For form data: expr="dict(urllib.parse.parse_qsl(body))"',
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Extract nested path if specified
|
|
100
|
+
if json_path:
|
|
101
|
+
data, err = extract_json_path(data, json_path)
|
|
102
|
+
if err:
|
|
103
|
+
return error_response(
|
|
104
|
+
err,
|
|
105
|
+
suggestions=[
|
|
106
|
+
f"Path '{json_path}' not found in body",
|
|
107
|
+
'Try a simpler path like "data" or "data[0]"',
|
|
108
|
+
],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Validate structure
|
|
112
|
+
is_valid, validation_err = validate_generation_data(data)
|
|
113
|
+
if not is_valid:
|
|
114
|
+
return error_response(
|
|
115
|
+
validation_err or "Invalid data structure",
|
|
116
|
+
suggestions=[
|
|
117
|
+
"Code generation requires dict or list structure",
|
|
118
|
+
"Use json_path or expr to extract a complex object",
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Ensure output directory exists
|
|
123
|
+
output_path = ensure_output_directory(output)
|
|
124
|
+
|
|
125
|
+
# Generate model
|
|
126
|
+
try:
|
|
127
|
+
generate(
|
|
128
|
+
json.dumps(data),
|
|
129
|
+
input_file_type=InputFileType.Json,
|
|
130
|
+
input_filename="response.json",
|
|
131
|
+
output=output_path,
|
|
132
|
+
output_model_type=DataModelType.PydanticV2BaseModel,
|
|
133
|
+
class_name=model_name,
|
|
134
|
+
snake_case_field=True,
|
|
135
|
+
use_standard_collections=True,
|
|
136
|
+
use_union_operator=True,
|
|
137
|
+
)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
return error_response(
|
|
140
|
+
f"Model generation failed: {e}",
|
|
141
|
+
suggestions=[
|
|
142
|
+
"Check that the JSON structure is valid",
|
|
143
|
+
"Try simplifying with json_path",
|
|
144
|
+
"Ensure output directory is writable",
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Count fields
|
|
149
|
+
try:
|
|
150
|
+
model_content = output_path.read_text()
|
|
151
|
+
field_count = model_content.count(": ")
|
|
152
|
+
except Exception:
|
|
153
|
+
field_count = "unknown"
|
|
154
|
+
|
|
155
|
+
return success_response(
|
|
156
|
+
"Model generated successfully",
|
|
157
|
+
details={
|
|
158
|
+
"Class": model_name,
|
|
159
|
+
"Output": str(output_path),
|
|
160
|
+
"Fields": field_count,
|
|
161
|
+
"Size": f"{output_path.stat().st_size} bytes",
|
|
162
|
+
},
|
|
163
|
+
)
|
webtap/daemon.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Daemon lifecycle management for WebTap.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- daemon_running: Check if daemon is running
|
|
5
|
+
- ensure_daemon: Spawn daemon if not running
|
|
6
|
+
- start_daemon: Run daemon in foreground (--daemon flag)
|
|
7
|
+
- stop_daemon: Gracefully shut down daemon
|
|
8
|
+
- daemon_status: Get daemon status information
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
PIDFILE = Path("~/.local/state/webtap/daemon.pid").expanduser()
|
|
25
|
+
DAEMON_URL = "http://localhost:8765"
|
|
26
|
+
LOG_FILE = Path("~/.local/state/webtap/daemon.log").expanduser()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def daemon_running() -> bool:
|
|
30
|
+
"""Check if daemon is running.
|
|
31
|
+
|
|
32
|
+
Verifies both pidfile existence and health endpoint response.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
True if daemon is running and responsive, False otherwise.
|
|
36
|
+
"""
|
|
37
|
+
if not PIDFILE.exists():
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
# Check if process exists
|
|
41
|
+
try:
|
|
42
|
+
pid = int(PIDFILE.read_text().strip())
|
|
43
|
+
os.kill(pid, 0) # Signal 0 just checks if process exists
|
|
44
|
+
except (ValueError, ProcessLookupError, OSError):
|
|
45
|
+
# Stale pidfile
|
|
46
|
+
PIDFILE.unlink(missing_ok=True)
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
# Check if health endpoint responds
|
|
50
|
+
try:
|
|
51
|
+
response = httpx.get(f"{DAEMON_URL}/health", timeout=1.0)
|
|
52
|
+
return response.status_code == 200
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ensure_daemon() -> None:
|
|
58
|
+
"""Spawn daemon if not running.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RuntimeError: If daemon fails to start within 5 seconds.
|
|
62
|
+
"""
|
|
63
|
+
if daemon_running():
|
|
64
|
+
logger.debug("Daemon already running")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
logger.info("Starting daemon...")
|
|
68
|
+
|
|
69
|
+
# Ensure directories exist
|
|
70
|
+
PIDFILE.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
# Spawn daemon process
|
|
74
|
+
with open(LOG_FILE, "a") as log:
|
|
75
|
+
subprocess.Popen(
|
|
76
|
+
[sys.executable, "-m", "webtap", "--daemon"],
|
|
77
|
+
start_new_session=True,
|
|
78
|
+
stdout=log,
|
|
79
|
+
stderr=subprocess.STDOUT,
|
|
80
|
+
close_fds=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Wait for daemon to be ready
|
|
84
|
+
for i in range(50): # 5 seconds total
|
|
85
|
+
time.sleep(0.1)
|
|
86
|
+
if daemon_running():
|
|
87
|
+
logger.info("Daemon started successfully")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
raise RuntimeError(f"Daemon failed to start. Check log: {LOG_FILE}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def start_daemon() -> None:
|
|
94
|
+
"""Run daemon in foreground (--daemon flag).
|
|
95
|
+
|
|
96
|
+
This function blocks until the daemon is shut down. It:
|
|
97
|
+
1. Creates pidfile
|
|
98
|
+
2. Starts API server
|
|
99
|
+
3. Cleans up on exit
|
|
100
|
+
|
|
101
|
+
The API server initialization is handled in api.py.
|
|
102
|
+
Uvicorn handles SIGINT/SIGTERM signals for graceful shutdown.
|
|
103
|
+
"""
|
|
104
|
+
# Ensure directories exist
|
|
105
|
+
PIDFILE.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
|
|
107
|
+
# Check if already running
|
|
108
|
+
if daemon_running():
|
|
109
|
+
print(f"Daemon already running (pid: {PIDFILE.read_text().strip()})")
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
|
|
112
|
+
# Write pidfile
|
|
113
|
+
PIDFILE.write_text(str(os.getpid()))
|
|
114
|
+
logger.info(f"Daemon started (pid: {os.getpid()})")
|
|
115
|
+
|
|
116
|
+
# Note: Don't register signal handlers here - uvicorn handles SIGINT/SIGTERM
|
|
117
|
+
# and calling sys.exit() in a signal handler conflicts with uvicorn's shutdown
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
# Initialize and run daemon server (blocks)
|
|
121
|
+
from webtap.api import run_daemon_server
|
|
122
|
+
|
|
123
|
+
run_daemon_server()
|
|
124
|
+
finally:
|
|
125
|
+
PIDFILE.unlink(missing_ok=True)
|
|
126
|
+
logger.info("Daemon stopped")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def stop_daemon() -> None:
|
|
130
|
+
"""Send SIGTERM to daemon.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
RuntimeError: If daemon is not running.
|
|
134
|
+
"""
|
|
135
|
+
if not PIDFILE.exists():
|
|
136
|
+
raise RuntimeError("Daemon is not running (no pidfile)")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
pid = int(PIDFILE.read_text().strip())
|
|
140
|
+
os.kill(pid, signal.SIGTERM)
|
|
141
|
+
logger.info(f"Sent SIGTERM to daemon (pid: {pid})")
|
|
142
|
+
|
|
143
|
+
# Wait for daemon to stop
|
|
144
|
+
for _ in range(30): # 3 seconds
|
|
145
|
+
time.sleep(0.1)
|
|
146
|
+
if not daemon_running():
|
|
147
|
+
logger.info("Daemon stopped")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
logger.warning("Daemon did not stop gracefully, may need manual intervention")
|
|
151
|
+
except (ValueError, ProcessLookupError, OSError) as e:
|
|
152
|
+
PIDFILE.unlink(missing_ok=True)
|
|
153
|
+
raise RuntimeError(f"Failed to stop daemon: {e}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def daemon_status() -> dict:
|
|
157
|
+
"""Get daemon status information.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dictionary with status information:
|
|
161
|
+
- running: bool
|
|
162
|
+
- pid: int or None
|
|
163
|
+
- connected: bool
|
|
164
|
+
- event_count: int
|
|
165
|
+
- Other status fields from /status endpoint
|
|
166
|
+
"""
|
|
167
|
+
if not daemon_running():
|
|
168
|
+
return {"running": False, "pid": None}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
pid = int(PIDFILE.read_text().strip())
|
|
172
|
+
except Exception:
|
|
173
|
+
pid = None
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
response = httpx.get(f"{DAEMON_URL}/status", timeout=2.0)
|
|
177
|
+
status = response.json()
|
|
178
|
+
status["running"] = True
|
|
179
|
+
status["pid"] = pid
|
|
180
|
+
return status
|
|
181
|
+
except Exception as e:
|
|
182
|
+
return {"running": False, "pid": pid, "error": str(e)}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = ["daemon_running", "ensure_daemon", "start_daemon", "stop_daemon", "daemon_status"]
|
webtap/daemon_state.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Daemon-side state with CDP session and services.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- DaemonState: State container for daemon process
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from webtap.cdp import CDPSession
|
|
13
|
+
from webtap.services import WebTapService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DaemonState:
|
|
17
|
+
"""Daemon-side state with CDP session and services.
|
|
18
|
+
|
|
19
|
+
This class is only used in daemon mode (--daemon flag).
|
|
20
|
+
It holds the CDP session and service layer that manage
|
|
21
|
+
browser connections and state.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
cdp: CDP session for Chrome DevTools Protocol communication
|
|
25
|
+
browser_data: DOM selections and inspection state
|
|
26
|
+
service: WebTapService orchestrator for all operations
|
|
27
|
+
error_state: Current error state dict or None
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
cdp: "CDPSession"
|
|
31
|
+
browser_data: Any
|
|
32
|
+
service: "WebTapService"
|
|
33
|
+
error_state: dict[str, Any] | None
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
"""Initialize daemon state with CDP session and services."""
|
|
37
|
+
from webtap.cdp import CDPSession
|
|
38
|
+
from webtap.services import WebTapService
|
|
39
|
+
|
|
40
|
+
self.cdp = CDPSession()
|
|
41
|
+
self.browser_data = None
|
|
42
|
+
self.service = WebTapService(self)
|
|
43
|
+
self.error_state = None
|
|
44
|
+
|
|
45
|
+
def cleanup(self):
|
|
46
|
+
"""Clean up resources on shutdown."""
|
|
47
|
+
if self.service:
|
|
48
|
+
self.service.disconnect() # Cleans up DOM, fetch, body services
|
|
49
|
+
if self.cdp:
|
|
50
|
+
self.cdp.cleanup()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["DaemonState"]
|