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.
Files changed (64) hide show
  1. webtap/VISION.md +246 -0
  2. webtap/__init__.py +84 -0
  3. webtap/__main__.py +6 -0
  4. webtap/api/__init__.py +9 -0
  5. webtap/api/app.py +26 -0
  6. webtap/api/models.py +69 -0
  7. webtap/api/server.py +111 -0
  8. webtap/api/sse.py +182 -0
  9. webtap/api/state.py +89 -0
  10. webtap/app.py +79 -0
  11. webtap/cdp/README.md +275 -0
  12. webtap/cdp/__init__.py +12 -0
  13. webtap/cdp/har.py +302 -0
  14. webtap/cdp/schema/README.md +41 -0
  15. webtap/cdp/schema/cdp_protocol.json +32785 -0
  16. webtap/cdp/schema/cdp_version.json +8 -0
  17. webtap/cdp/session.py +667 -0
  18. webtap/client.py +81 -0
  19. webtap/commands/DEVELOPER_GUIDE.md +401 -0
  20. webtap/commands/TIPS.md +269 -0
  21. webtap/commands/__init__.py +29 -0
  22. webtap/commands/_builders.py +331 -0
  23. webtap/commands/_code_generation.py +110 -0
  24. webtap/commands/_tips.py +147 -0
  25. webtap/commands/_utils.py +273 -0
  26. webtap/commands/connection.py +220 -0
  27. webtap/commands/console.py +87 -0
  28. webtap/commands/fetch.py +310 -0
  29. webtap/commands/filters.py +116 -0
  30. webtap/commands/javascript.py +73 -0
  31. webtap/commands/js_export.py +73 -0
  32. webtap/commands/launch.py +72 -0
  33. webtap/commands/navigation.py +197 -0
  34. webtap/commands/network.py +136 -0
  35. webtap/commands/quicktype.py +306 -0
  36. webtap/commands/request.py +93 -0
  37. webtap/commands/selections.py +138 -0
  38. webtap/commands/setup.py +219 -0
  39. webtap/commands/to_model.py +163 -0
  40. webtap/daemon.py +185 -0
  41. webtap/daemon_state.py +53 -0
  42. webtap/filters.py +219 -0
  43. webtap/rpc/__init__.py +14 -0
  44. webtap/rpc/errors.py +49 -0
  45. webtap/rpc/framework.py +223 -0
  46. webtap/rpc/handlers.py +625 -0
  47. webtap/rpc/machine.py +84 -0
  48. webtap/services/README.md +83 -0
  49. webtap/services/__init__.py +15 -0
  50. webtap/services/console.py +124 -0
  51. webtap/services/dom.py +547 -0
  52. webtap/services/fetch.py +415 -0
  53. webtap/services/main.py +392 -0
  54. webtap/services/network.py +401 -0
  55. webtap/services/setup/__init__.py +185 -0
  56. webtap/services/setup/chrome.py +233 -0
  57. webtap/services/setup/desktop.py +255 -0
  58. webtap/services/setup/extension.py +147 -0
  59. webtap/services/setup/platform.py +162 -0
  60. webtap/services/state_snapshot.py +86 -0
  61. webtap_tool-0.11.0.dist-info/METADATA +535 -0
  62. webtap_tool-0.11.0.dist-info/RECORD +64 -0
  63. webtap_tool-0.11.0.dist-info/WHEEL +4 -0
  64. webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
@@ -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"]