webtap-tool 0.1.3__tar.gz → 0.1.5__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.

Potentially problematic release.


This version of webtap-tool might be problematic. Click here for more details.

Files changed (55) hide show
  1. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/CHANGELOG.md +39 -0
  2. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/PKG-INFO +1 -1
  3. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/extension/popup.js +28 -17
  4. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/pyproject.toml +1 -1
  5. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/__init__.py +5 -1
  6. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/api.py +65 -58
  7. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/app.py +15 -0
  8. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/setup.py +34 -0
  9. webtap_tool-0.1.5/src/webtap/services/setup/__init__.py +66 -0
  10. webtap_tool-0.1.5/src/webtap/services/setup/chrome.py +77 -0
  11. webtap_tool-0.1.5/src/webtap/services/setup/desktop.py +151 -0
  12. webtap_tool-0.1.5/src/webtap/services/setup/extension.py +88 -0
  13. webtap_tool-0.1.5/src/webtap/services/setup/filters.py +79 -0
  14. webtap_tool-0.1.3/src/webtap/services/setup.py +0 -229
  15. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/.gitignore +0 -0
  16. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/ARCHITECTURE.md +0 -0
  17. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/README.md +0 -0
  18. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/data/filters.json +0 -0
  19. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/extension/manifest.json +0 -0
  20. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/extension/popup.html +0 -0
  21. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/llms.txt +0 -0
  22. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/VISION.md +0 -0
  23. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/cdp/README.md +0 -0
  24. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/cdp/__init__.py +0 -0
  25. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/cdp/query.py +0 -0
  26. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/cdp/schema/README.md +0 -0
  27. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/cdp/schema/cdp_protocol.json +0 -0
  28. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/cdp/schema/cdp_version.json +0 -0
  29. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/cdp/session.py +0 -0
  30. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/DEVELOPER_GUIDE.md +0 -0
  31. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/TIPS.md +0 -0
  32. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/__init__.py +0 -0
  33. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/_builders.py +0 -0
  34. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/_errors.py +0 -0
  35. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/_tips.py +0 -0
  36. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/_utils.py +0 -0
  37. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/body.py +0 -0
  38. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/connection.py +0 -0
  39. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/console.py +0 -0
  40. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/events.py +0 -0
  41. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/fetch.py +0 -0
  42. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/filters.py +0 -0
  43. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/inspect.py +0 -0
  44. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/javascript.py +0 -0
  45. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/launch.py +0 -0
  46. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/navigation.py +0 -0
  47. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/commands/network.py +0 -0
  48. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/filters.py +0 -0
  49. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/services/README.md +0 -0
  50. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/services/__init__.py +0 -0
  51. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/services/body.py +0 -0
  52. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/services/console.py +0 -0
  53. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/services/fetch.py +0 -0
  54. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/services/main.py +0 -0
  55. {webtap_tool-0.1.3 → webtap_tool-0.1.5}/src/webtap/services/network.py +0 -0
@@ -15,6 +15,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
 
16
16
  ### Removed
17
17
 
18
+ ## [0.1.5] - 2025-09-10
19
+
20
+ ### Added
21
+ - New `setup-desktop` command to install desktop entry for GUI Chrome integration
22
+ - Desktop entry installation that overrides system Chrome launcher to use debug wrapper
23
+ - Automatic detection and modification of system Chrome desktop files
24
+
25
+ ### Changed
26
+ - Refactored setup service from monolithic file into modular components
27
+ - Split `services/setup.py` into separate modules: `filters.py`, `extension.py`, `chrome.py`, `desktop.py`
28
+ - Updated type hints to use modern Python 3.9+ style (`dict` instead of `Dict`)
29
+
30
+ ### Fixed
31
+ - Code style issues across setup modules (imports, formatting, type hints)
32
+
33
+ ### Removed
34
+
35
+ ## [0.1.4] - 2025-09-08
36
+
37
+ ### Added
38
+ - Request timeout handling (3 seconds) in Chrome extension for better error detection
39
+ - Comprehensive `cleanup()` method for proper resource management on exit
40
+ - `atexit` registration to ensure cleanup happens even on unexpected termination
41
+
42
+ ### Changed
43
+ - **API Consolidation**: Merged `/pages` and `/instance` endpoints into single `/info` endpoint
44
+ - Enhanced `/status` endpoint to include fetch details inline (eliminates separate API call)
45
+ - Improved error messages in extension to distinguish between timeout, server not running, and other failures
46
+ - API server shutdown now uses `asyncio.run()` for proper task cleanup
47
+
48
+ ### Fixed
49
+ - Graceful shutdown issue that left zombie tasks with "Task was destroyed but it is pending" error
50
+ - API server cleanup on application exit now properly cancels all async tasks
51
+ - Extension error handling now provides specific feedback for different failure modes
52
+
53
+ ### Removed
54
+ - `/instance` endpoint (functionality merged into `/info`)
55
+ - `/fetch/paused` endpoint (data now included in `/status` response)
56
+
18
57
  ## [0.1.3] - 2025-09-05
19
58
 
20
59
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webtap-tool
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Terminal-based web page inspector for AI debugging sessions
5
5
  Author-email: Fredrik Angelsen <fredrikangelsen@gmail.com>
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -1,14 +1,28 @@
1
1
  // API helper - communicate with WebTap service
2
2
  async function api(endpoint, method = "GET", body = null) {
3
3
  try {
4
- const opts = { method };
4
+ const opts = {
5
+ method,
6
+ // Add timeout to detect unresponsive server faster
7
+ signal: AbortSignal.timeout(3000)
8
+ };
5
9
  if (body) {
6
10
  opts.headers = { "Content-Type": "application/json" };
7
11
  opts.body = JSON.stringify(body);
8
12
  }
9
13
  const resp = await fetch(`http://localhost:8765${endpoint}`, opts);
14
+ if (!resp.ok) {
15
+ return { error: `HTTP ${resp.status}: ${resp.statusText}` };
16
+ }
10
17
  return await resp.json();
11
18
  } catch (e) {
19
+ // Better error messages
20
+ if (e.name === 'AbortError') {
21
+ return { error: "WebTap not responding (timeout)" };
22
+ }
23
+ if (e.message.includes('Failed to fetch')) {
24
+ return { error: "WebTap not running" };
25
+ }
12
26
  return { error: e.message };
13
27
  }
14
28
  }
@@ -17,22 +31,20 @@ async function api(endpoint, method = "GET", body = null) {
17
31
 
18
32
  // Load available pages from WebTap
19
33
  async function loadPages() {
20
- const result = await api("/pages");
21
-
22
- if (result.error) {
34
+ // Use combined /info endpoint for single round trip
35
+ const info = await api("/info");
36
+
37
+ if (info.error) {
23
38
  document.getElementById("pageList").innerHTML =
24
- "<option disabled>WebTap not running</option>";
39
+ `<option disabled>${info.error === "WebTap not initialized" ? "WebTap not running" : "Error loading pages"}</option>`;
25
40
  return;
26
41
  }
27
42
 
28
- // Also fetch instance info if available
29
- const instanceInfo = await api("/instance");
30
- if (instanceInfo && !instanceInfo.error) {
31
- document.getElementById("switchInstance").title =
32
- `PID: ${instanceInfo.pid} | Events: ${instanceInfo.events}`;
33
- }
43
+ // Update instance info tooltip
44
+ document.getElementById("switchInstance").title =
45
+ `PID: ${info.pid} | Events: ${info.events}`;
34
46
 
35
- const pages = result.pages || [];
47
+ const pages = info.pages || [];
36
48
  const select = document.getElementById("pageList");
37
49
 
38
50
  select.innerHTML = "";
@@ -296,13 +308,12 @@ async function updateStatus() {
296
308
  document.getElementById("status").innerHTML =
297
309
  `<span class="connected">Connected</span> - Events: ${status.events}`;
298
310
 
299
- // Get fetch details if enabled
300
- if (status.fetch_enabled) {
301
- const fetchDetails = await api("/fetch/paused");
311
+ // Use fetch details from enhanced status (no extra API call needed)
312
+ if (status.fetch_enabled && status.fetch_details) {
302
313
  updateFetchStatus(
303
314
  true,
304
- status.paused_requests || 0,
305
- fetchDetails.response_stage || false,
315
+ status.fetch_details.paused_count || 0,
316
+ status.fetch_details.response_stage || false,
306
317
  );
307
318
  } else {
308
319
  updateFetchStatus(false);
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webtap-tool"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Terminal-based web page inspector for AI debugging sessions"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,6 +9,7 @@ PUBLIC API:
9
9
  - main: Entry point function for CLI
10
10
  """
11
11
 
12
+ import atexit
12
13
  import sys
13
14
  import logging
14
15
 
@@ -45,12 +46,15 @@ def main():
45
46
 
46
47
 
47
48
  def _start_api_server_safely():
48
- """Start API server with error handling."""
49
+ """Start API server with error handling and cleanup registration."""
49
50
  try:
50
51
  thread = start_api_server(app.state)
51
52
  if thread and app.state:
52
53
  app.state.api_thread = thread
53
54
  logger.info("API server started on port 8765")
55
+
56
+ # Register cleanup to shut down API server on exit
57
+ atexit.register(lambda: app.state.cleanup() if app.state else None)
54
58
  else:
55
59
  logger.info("Port 8765 in use by another instance")
56
60
  except Exception as e:
@@ -50,22 +50,54 @@ api.add_middleware(
50
50
  app_state = None
51
51
 
52
52
 
53
- @api.get("/pages")
54
- async def list_pages() -> Dict[str, Any]:
55
- """List available Chrome pages for extension selection."""
53
+ @api.get("/health")
54
+ async def health_check() -> Dict[str, Any]:
55
+ """Quick health check endpoint for extension."""
56
+ return {"status": "ok", "pid": os.getpid()}
57
+
58
+
59
+ @api.get("/info")
60
+ async def get_info() -> Dict[str, Any]:
61
+ """Combined endpoint for pages and instance info - reduces round trips."""
56
62
  if not app_state:
57
- return {"error": "WebTap not initialized", "pages": []}
63
+ return {"error": "WebTap not initialized", "pages": [], "pid": os.getpid()}
64
+
65
+ # Get pages
66
+ pages_data = app_state.service.list_pages()
67
+
68
+ # Get instance info
69
+ connected_to = None
70
+ if app_state.cdp.is_connected and app_state.cdp.page_info:
71
+ connected_to = app_state.cdp.page_info.get("title", "Untitled")
58
72
 
59
- return app_state.service.list_pages()
73
+ return {
74
+ "pid": os.getpid(),
75
+ "connected_to": connected_to,
76
+ "events": app_state.service.event_count,
77
+ "pages": pages_data.get("pages", []),
78
+ "error": pages_data.get("error"),
79
+ }
60
80
 
61
81
 
62
82
  @api.get("/status")
63
83
  async def get_status() -> Dict[str, Any]:
64
- """Get current connection status and event count."""
84
+ """Get comprehensive status including connection, events, and fetch details."""
65
85
  if not app_state:
66
86
  return {"connected": False, "error": "WebTap not initialized", "events": 0}
67
87
 
68
- return app_state.service.get_status()
88
+ status = app_state.service.get_status()
89
+
90
+ # Add fetch details if fetch is enabled
91
+ if status.get("fetch_enabled"):
92
+ fetch_service = app_state.service.fetch
93
+ paused_list = fetch_service.get_paused_list()
94
+ status["fetch_details"] = {
95
+ "paused_requests": paused_list,
96
+ "paused_count": len(paused_list),
97
+ "response_stage": fetch_service.enable_response_stage,
98
+ }
99
+
100
+ return status
69
101
 
70
102
 
71
103
  @api.post("/connect")
@@ -108,25 +140,6 @@ async def set_fetch_interception(request: FetchRequest) -> Dict[str, Any]:
108
140
  return result
109
141
 
110
142
 
111
- @api.get("/fetch/paused")
112
- async def get_paused_requests() -> Dict[str, Any]:
113
- """Get list of currently paused fetch requests."""
114
- if not app_state:
115
- return {"error": "WebTap not initialized", "requests": []}
116
-
117
- fetch_service = app_state.service.fetch
118
- if not fetch_service.enabled:
119
- return {"enabled": False, "requests": []}
120
-
121
- paused_list = fetch_service.get_paused_list()
122
- return {
123
- "enabled": True,
124
- "requests": paused_list,
125
- "count": len(paused_list),
126
- "response_stage": fetch_service.enable_response_stage,
127
- }
128
-
129
-
130
143
  @api.get("/filters/status")
131
144
  async def get_filter_status() -> Dict[str, Any]:
132
145
  """Get current filter configuration and enabled categories."""
@@ -186,19 +199,6 @@ async def disable_all_filters() -> Dict[str, Any]:
186
199
  return {"enabled": [], "total": 0}
187
200
 
188
201
 
189
- @api.get("/instance")
190
- async def get_instance_info() -> Dict[str, Any]:
191
- """Get info about this WebTap instance."""
192
- if not app_state:
193
- return {"error": "WebTap not initialized"}
194
-
195
- return {
196
- "pid": os.getpid(),
197
- "connected_to": app_state.cdp.current_page_title if app_state.cdp.is_connected else None,
198
- "events": app_state.cdp.event_count,
199
- }
200
-
201
-
202
202
  @api.post("/release")
203
203
  async def release_port() -> Dict[str, Any]:
204
204
  """Release API port for another WebTap instance."""
@@ -237,8 +237,9 @@ def start_api_server(state, host: str = "127.0.0.1", port: int = 8765) -> thread
237
237
  logger.info(f"Port {port} already in use")
238
238
  return None
239
239
 
240
- global app_state
240
+ global app_state, _shutdown_requested
241
241
  app_state = state
242
+ _shutdown_requested = False # Reset flag for new instance
242
243
 
243
244
  thread = threading.Thread(target=run_server, args=(host, port), daemon=True)
244
245
  thread.start()
@@ -249,7 +250,10 @@ def start_api_server(state, host: str = "127.0.0.1", port: int = 8765) -> thread
249
250
 
250
251
  def run_server(host: str, port: int):
251
252
  """Run the FastAPI server in a thread."""
252
- try:
253
+ import asyncio
254
+
255
+ async def run():
256
+ """Run server with proper shutdown handling."""
253
257
  config = uvicorn.Config(
254
258
  api,
255
259
  host=host,
@@ -259,30 +263,33 @@ def run_server(host: str, port: int):
259
263
  )
260
264
  server = uvicorn.Server(config)
261
265
 
262
- # Run with checking for shutdown flag
263
- import asyncio
264
-
265
- loop = asyncio.new_event_loop()
266
- asyncio.set_event_loop(loop)
266
+ # Start server in background task
267
+ serve_task = asyncio.create_task(server.serve())
267
268
 
268
- async def serve():
269
- await server.serve()
270
-
271
- # Start serving
272
- task = loop.create_task(serve())
273
-
274
- # Check for shutdown flag
269
+ # Wait for shutdown signal
275
270
  while not _shutdown_requested:
276
- loop.run_until_complete(asyncio.sleep(0.1))
277
- if task.done():
271
+ await asyncio.sleep(0.1)
272
+ if serve_task.done():
278
273
  break
279
274
 
280
- # Shutdown if requested
281
- if _shutdown_requested:
275
+ # Trigger shutdown
276
+ if not serve_task.done():
282
277
  logger.info("API server shutting down")
283
278
  server.should_exit = True
284
- loop.run_until_complete(server.shutdown())
279
+ # Wait a bit for graceful shutdown
280
+ try:
281
+ await asyncio.wait_for(serve_task, timeout=1.0)
282
+ except asyncio.TimeoutError:
283
+ logger.debug("Server task timeout - cancelling")
284
+ serve_task.cancel()
285
+ try:
286
+ await serve_task
287
+ except asyncio.CancelledError:
288
+ pass
285
289
 
290
+ try:
291
+ # Use asyncio.run() which properly cleans up
292
+ asyncio.run(run())
286
293
  except Exception as e:
287
294
  logger.error(f"API server failed: {e}")
288
295
 
@@ -36,6 +36,21 @@ class WebTapState:
36
36
  """Initialize service with self reference after dataclass init."""
37
37
  self.service = WebTapService(self)
38
38
 
39
+ def cleanup(self):
40
+ """Cleanup resources on exit."""
41
+ # Disconnect CDP if connected
42
+ if self.cdp.is_connected:
43
+ self.cdp.disconnect()
44
+
45
+ # Stop API server if we own it
46
+ if self.api_thread and self.api_thread.is_alive():
47
+ # Import here to avoid circular dependency
48
+ import webtap.api
49
+
50
+ webtap.api._shutdown_requested = True
51
+ # Wait up to 1 second for graceful shutdown
52
+ self.api_thread.join(timeout=1.0)
53
+
39
54
 
40
55
  # Must be created before command imports for decorator registration
41
56
  app = App(
@@ -61,6 +61,28 @@ def setup_chrome(state, force: bool = False) -> dict:
61
61
  return _format_setup_result(result, "chrome")
62
62
 
63
63
 
64
+ @app.command(
65
+ display="markdown",
66
+ typer={"name": "setup-desktop", "help": "Install desktop entry to use Chrome wrapper from GUI"},
67
+ fastmcp={"enabled": False},
68
+ )
69
+ def setup_desktop(state, force: bool = False) -> dict:
70
+ """Install desktop entry to ~/.local/share/applications/google-chrome.desktop.
71
+
72
+ This makes the Chrome wrapper work when launching from GUI (app launcher, dock, etc).
73
+ The desktop entry overrides the system Chrome launcher to use our debug-enabled wrapper.
74
+
75
+ Args:
76
+ force: Overwrite existing desktop entry (default: False)
77
+
78
+ Returns:
79
+ Markdown-formatted result with success/error messages
80
+ """
81
+ service = SetupService()
82
+ result = service.install_desktop_entry(force=force)
83
+ return _format_setup_result(result, "desktop")
84
+
85
+
64
86
  def _format_setup_result(result: dict, component: str) -> dict:
65
87
  """Format setup result as markdown."""
66
88
  elements = []
@@ -123,5 +145,17 @@ def _format_setup_result(result: dict, component: str) -> dict:
123
145
  ],
124
146
  }
125
147
  )
148
+ elif component == "desktop":
149
+ elements.append({"type": "text", "content": "\n**Next steps:**"})
150
+ elements.append(
151
+ {
152
+ "type": "list",
153
+ "items": [
154
+ "Log out and log back in (or run `update-desktop-database ~/.local/share/applications`)",
155
+ "Chrome will now launch with debugging enabled from GUI",
156
+ "The wrapper at ~/.local/bin/wrappers/google-chrome-stable will be used",
157
+ ],
158
+ }
159
+ )
126
160
 
127
161
  return {"elements": elements}
@@ -0,0 +1,66 @@
1
+ """Setup service for installing WebTap components.
2
+
3
+ PUBLIC API:
4
+ - SetupService: Main service class for all setup operations
5
+ """
6
+
7
+ from typing import Dict, Any
8
+
9
+ from .filters import install_filters
10
+ from .extension import install_extension
11
+ from .chrome import install_chrome_wrapper
12
+ from .desktop import install_desktop_entry
13
+
14
+
15
+ class SetupService:
16
+ """Service for installing WebTap components.
17
+
18
+ Delegates to specialized modules for each component type.
19
+ """
20
+
21
+ def install_filters(self, force: bool = False) -> Dict[str, Any]:
22
+ """Install filter configuration.
23
+
24
+ Args:
25
+ force: Overwrite existing file
26
+
27
+ Returns:
28
+ Dict with success, message, path, details
29
+ """
30
+ return install_filters(force=force)
31
+
32
+ def install_extension(self, force: bool = False) -> Dict[str, Any]:
33
+ """Install Chrome extension files.
34
+
35
+ Args:
36
+ force: Overwrite existing files
37
+
38
+ Returns:
39
+ Dict with success, message, path, details
40
+ """
41
+ return install_extension(force=force)
42
+
43
+ def install_chrome_wrapper(self, force: bool = False) -> Dict[str, Any]:
44
+ """Install Chrome wrapper script.
45
+
46
+ Args:
47
+ force: Overwrite existing script
48
+
49
+ Returns:
50
+ Dict with success, message, path, details
51
+ """
52
+ return install_chrome_wrapper(force=force)
53
+
54
+ def install_desktop_entry(self, force: bool = False) -> Dict[str, Any]:
55
+ """Install desktop entry for GUI integration.
56
+
57
+ Args:
58
+ force: Overwrite existing entry
59
+
60
+ Returns:
61
+ Dict with success, message, path, details
62
+ """
63
+ return install_desktop_entry(force=force)
64
+
65
+
66
+ __all__ = ["SetupService"]
@@ -0,0 +1,77 @@
1
+ """Chrome wrapper setup functionality for WebTap."""
2
+
3
+ import os
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Dict, Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def install_chrome_wrapper(force: bool = False) -> Dict[str, Any]:
12
+ """Install Chrome wrapper script for debugging.
13
+
14
+ Args:
15
+ force: Overwrite existing script
16
+
17
+ Returns:
18
+ Dict with success, message, path, details
19
+ """
20
+ target_path = Path.home() / ".local" / "bin" / "wrappers" / "google-chrome-stable"
21
+
22
+ if target_path.exists() and not force:
23
+ return {
24
+ "success": False,
25
+ "message": "Chrome wrapper already exists",
26
+ "path": str(target_path),
27
+ "details": "Use --force to overwrite",
28
+ }
29
+
30
+ wrapper_script = """#!/bin/bash
31
+ # Chrome wrapper using bindfs for perfect state sync with debug port
32
+
33
+ DEBUG_DIR="$HOME/.config/google-chrome-debug"
34
+ REAL_DIR="$HOME/.config/google-chrome"
35
+
36
+ # Check if bindfs is installed
37
+ if ! command -v bindfs &>/dev/null; then
38
+ echo "Error: bindfs not installed. Install with: yay -S bindfs" >&2
39
+ exit 1
40
+ fi
41
+
42
+ # Mount real profile via bindfs if not already mounted
43
+ if ! mountpoint -q "$DEBUG_DIR" 2>/dev/null; then
44
+ mkdir -p "$DEBUG_DIR"
45
+ if ! bindfs --no-allow-other "$REAL_DIR" "$DEBUG_DIR"; then
46
+ echo "Error: Failed to mount Chrome profile via bindfs" >&2
47
+ exit 1
48
+ fi
49
+ echo "Chrome debug profile mounted. To unmount: fusermount -u $DEBUG_DIR" >&2
50
+ fi
51
+
52
+ # Launch Chrome with debugging on bindfs mount
53
+ exec /usr/bin/google-chrome-stable \\
54
+ --remote-debugging-port=9222 \\
55
+ --remote-allow-origins='*' \\
56
+ --user-data-dir="$DEBUG_DIR" \\
57
+ "$@"
58
+ """
59
+
60
+ # Create directory and save
61
+ target_path.parent.mkdir(parents=True, exist_ok=True)
62
+ target_path.write_text(wrapper_script)
63
+ target_path.chmod(0o755) # Make executable
64
+
65
+ # Check PATH
66
+ path_dirs = os.environ.get("PATH", "").split(":")
67
+ wrapper_dir = str(target_path.parent)
68
+ in_path = wrapper_dir in path_dirs
69
+
70
+ logger.info(f"Installed Chrome wrapper to {target_path}")
71
+
72
+ return {
73
+ "success": True,
74
+ "message": "Installed Chrome wrapper script",
75
+ "path": str(target_path),
76
+ "details": "Already in PATH ✓" if in_path else "Add to PATH",
77
+ }
@@ -0,0 +1,151 @@
1
+ """Desktop entry setup functionality for WebTap."""
2
+
3
+ import re
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Dict, Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def install_desktop_entry(force: bool = False) -> Dict[str, Any]:
12
+ """Install desktop entry to override system Chrome launcher.
13
+
14
+ Creates a .desktop file that makes Chrome launch with our debug wrapper
15
+ when started from GUI (app launcher, dock, etc).
16
+
17
+ Args:
18
+ force: Overwrite existing desktop entry
19
+
20
+ Returns:
21
+ Dict with success, message, path, details
22
+ """
23
+ # Check if wrapper exists first
24
+ wrapper_path = Path.home() / ".local" / "bin" / "wrappers" / "google-chrome-stable"
25
+ if not wrapper_path.exists():
26
+ return {
27
+ "success": False,
28
+ "message": "Chrome wrapper not found. Run 'setup-chrome' first",
29
+ "path": None,
30
+ "details": f"Expected wrapper at {wrapper_path}",
31
+ }
32
+
33
+ # Desktop entry location (user override of system entry)
34
+ desktop_path = Path.home() / ".local" / "share" / "applications" / "google-chrome.desktop"
35
+
36
+ if desktop_path.exists() and not force:
37
+ return {
38
+ "success": False,
39
+ "message": "Desktop entry already exists",
40
+ "path": str(desktop_path),
41
+ "details": "Use --force to overwrite",
42
+ }
43
+
44
+ # Get system Chrome desktop file for reference
45
+ system_desktop = _find_system_desktop_file()
46
+
47
+ if system_desktop:
48
+ desktop_content = _create_from_system_desktop(system_desktop, wrapper_path)
49
+ source_info = system_desktop.name
50
+ else:
51
+ desktop_content = _create_minimal_desktop(wrapper_path)
52
+ source_info = "minimal template"
53
+
54
+ # Create directory and save
55
+ desktop_path.parent.mkdir(parents=True, exist_ok=True)
56
+ desktop_path.write_text(desktop_content)
57
+ desktop_path.chmod(0o644) # Standard permissions for desktop files
58
+
59
+ logger.info(f"Installed desktop entry to {desktop_path}")
60
+
61
+ return {
62
+ "success": True,
63
+ "message": "Installed Chrome desktop entry with debug wrapper",
64
+ "path": str(desktop_path),
65
+ "details": f"Based on {source_info}",
66
+ }
67
+
68
+
69
+ def _find_system_desktop_file() -> Path | None:
70
+ """Find the system Chrome desktop file."""
71
+ for system_path in [
72
+ Path("/usr/share/applications/google-chrome.desktop"),
73
+ Path("/usr/share/applications/google-chrome-stable.desktop"),
74
+ Path("/usr/local/share/applications/google-chrome.desktop"),
75
+ ]:
76
+ if system_path.exists():
77
+ return system_path
78
+ return None
79
+
80
+
81
+ def _create_from_system_desktop(system_desktop: Path, wrapper_path: Path) -> str:
82
+ """Create desktop content by modifying the system desktop file."""
83
+ try:
84
+ system_content = system_desktop.read_text()
85
+
86
+ # Replace all Exec= lines to use our wrapper
87
+ # Match Exec= lines that point to chrome executables
88
+ exec_pattern = re.compile(
89
+ r"^Exec=.*?(?:google-chrome-stable|google-chrome|chromium|chrome)(?:\s|$)", re.MULTILINE
90
+ )
91
+
92
+ # Replace with our wrapper, preserving arguments
93
+ def replace_exec(match):
94
+ line = match.group(0)
95
+ # Find where the executable ends and arguments begin
96
+ # Look for common chrome executables
97
+ for exe in ["google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"]:
98
+ if exe in line:
99
+ # Split at the executable name
100
+ parts = line.split(exe, 1)
101
+ if len(parts) > 1:
102
+ # Has arguments after executable
103
+ return f"Exec={wrapper_path}{parts[1]}"
104
+ else:
105
+ # No arguments
106
+ return f"Exec={wrapper_path}"
107
+ # Fallback - shouldn't happen
108
+ return f"Exec={wrapper_path}"
109
+
110
+ desktop_content = exec_pattern.sub(replace_exec, system_content)
111
+
112
+ # Also handle TryExec if present (used to check if program exists)
113
+ tryexec_pattern = re.compile(
114
+ r"^TryExec=.*?(?:google-chrome-stable|google-chrome|chromium|chrome)", re.MULTILINE
115
+ )
116
+ desktop_content = tryexec_pattern.sub(f"TryExec={wrapper_path}", desktop_content)
117
+
118
+ return desktop_content
119
+
120
+ except Exception as e:
121
+ logger.warning(f"Failed to parse system desktop file, using minimal entry: {e}")
122
+ return _create_minimal_desktop(wrapper_path)
123
+
124
+
125
+ def _create_minimal_desktop(wrapper_path: Path) -> str:
126
+ """Create a minimal desktop entry from scratch."""
127
+ return f"""[Desktop Entry]
128
+ Version=1.0
129
+ Type=Application
130
+ Name=Google Chrome
131
+ GenericName=Web Browser
132
+ Comment=Access the Internet
133
+ Icon=google-chrome
134
+ Categories=Network;WebBrowser;
135
+ MimeType=application/pdf;application/rdf+xml;application/rss+xml;application/xhtml+xml;application/xhtml_xml;application/xml;image/gif;image/jpeg;image/png;image/webp;text/html;text/xml;x-scheme-handler/ftp;x-scheme-handler/http;x-scheme-handler/https;
136
+ StartupWMClass=Google-chrome
137
+ StartupNotify=true
138
+ Terminal=false
139
+ Exec={wrapper_path} %U
140
+ Actions=new-window;new-private-window;
141
+
142
+ [Desktop Action new-window]
143
+ Name=New Window
144
+ StartupWMClass=Google-chrome
145
+ Exec={wrapper_path}
146
+
147
+ [Desktop Action new-private-window]
148
+ Name=New Incognito Window
149
+ StartupWMClass=Google-chrome
150
+ Exec={wrapper_path} --incognito
151
+ """
@@ -0,0 +1,88 @@
1
+ """Chrome extension setup functionality for WebTap."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Dict, Any
7
+
8
+ import requests
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # GitHub URLs for extension files
13
+ EXTENSION_BASE_URL = "https://raw.githubusercontent.com/angelsen/tap-tools/main/packages/webtap/extension"
14
+ EXTENSION_FILES = ["manifest.json", "popup.html", "popup.js"]
15
+
16
+
17
+ def install_extension(force: bool = False) -> Dict[str, Any]:
18
+ """Install Chrome extension to ~/.config/webtap/extension/.
19
+
20
+ Args:
21
+ force: Overwrite existing files
22
+
23
+ Returns:
24
+ Dict with success, message, path, details
25
+ """
26
+ # XDG config directory for Linux
27
+ target_dir = Path.home() / ".config" / "webtap" / "extension"
28
+
29
+ # Check if exists (manifest.json is required file)
30
+ if (target_dir / "manifest.json").exists() and not force:
31
+ return {
32
+ "success": False,
33
+ "message": f"Extension already exists at {target_dir}",
34
+ "path": str(target_dir),
35
+ "details": "Use --force to overwrite",
36
+ }
37
+
38
+ # Create directory
39
+ target_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ # Download each file
42
+ downloaded = []
43
+ failed = []
44
+
45
+ for filename in EXTENSION_FILES:
46
+ url = f"{EXTENSION_BASE_URL}/{filename}"
47
+ target_file = target_dir / filename
48
+
49
+ try:
50
+ logger.info(f"Downloading {filename}")
51
+ response = requests.get(url, timeout=10)
52
+ response.raise_for_status()
53
+
54
+ # For manifest.json, validate it's proper JSON
55
+ if filename == "manifest.json":
56
+ json.loads(response.text)
57
+
58
+ target_file.write_text(response.text)
59
+ downloaded.append(filename)
60
+
61
+ except Exception as e:
62
+ logger.error(f"Failed to download {filename}: {e}")
63
+ failed.append(filename)
64
+
65
+ # Determine success level
66
+ if not downloaded:
67
+ return {
68
+ "success": False,
69
+ "message": "Failed to download any extension files",
70
+ "path": None,
71
+ "details": "Check network connection and try again",
72
+ }
73
+
74
+ if failed:
75
+ # Partial success - some files downloaded
76
+ return {
77
+ "success": True, # Partial is still success
78
+ "message": f"Downloaded {len(downloaded)}/{len(EXTENSION_FILES)} files",
79
+ "path": str(target_dir),
80
+ "details": f"Failed: {', '.join(failed)}",
81
+ }
82
+
83
+ return {
84
+ "success": True,
85
+ "message": "Downloaded Chrome extension",
86
+ "path": str(target_dir),
87
+ "details": f"Files: {', '.join(downloaded)}",
88
+ }
@@ -0,0 +1,79 @@
1
+ """Filter setup functionality for WebTap."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Dict, Any
7
+
8
+ import requests
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # GitHub URL for filters
13
+ FILTERS_URL = "https://raw.githubusercontent.com/angelsen/tap-tools/main/packages/webtap/data/filters.json"
14
+
15
+
16
+ def install_filters(force: bool = False) -> Dict[str, Any]:
17
+ """Install filters to .webtap/filters.json.
18
+
19
+ Args:
20
+ force: Overwrite existing file
21
+
22
+ Returns:
23
+ Dict with success, message, path, details
24
+ """
25
+ # Same path that FilterManager uses
26
+ target_path = Path.cwd() / ".webtap" / "filters.json"
27
+
28
+ # Check if exists
29
+ if target_path.exists() and not force:
30
+ return {
31
+ "success": False,
32
+ "message": f"Filters already exist at {target_path}",
33
+ "path": str(target_path),
34
+ "details": "Use --force to overwrite",
35
+ }
36
+
37
+ # Download from GitHub
38
+ try:
39
+ logger.info(f"Downloading filters from {FILTERS_URL}")
40
+ response = requests.get(FILTERS_URL, timeout=10)
41
+ response.raise_for_status()
42
+
43
+ # Validate it's proper JSON
44
+ filters_data = json.loads(response.text)
45
+
46
+ # Quick validation - should have dict structure
47
+ if not isinstance(filters_data, dict):
48
+ return {
49
+ "success": False,
50
+ "message": "Invalid filter format - expected JSON object",
51
+ "path": None,
52
+ "details": None,
53
+ }
54
+
55
+ # Count categories for user feedback
56
+ category_count = len(filters_data)
57
+
58
+ # Create directory and save
59
+ target_path.parent.mkdir(parents=True, exist_ok=True)
60
+ target_path.write_text(response.text)
61
+
62
+ logger.info(f"Saved {category_count} filter categories to {target_path}")
63
+
64
+ return {
65
+ "success": True,
66
+ "message": f"Downloaded {category_count} filter categories",
67
+ "path": str(target_path),
68
+ "details": f"Categories: {', '.join(filters_data.keys())}",
69
+ }
70
+
71
+ except requests.RequestException as e:
72
+ logger.error(f"Network error downloading filters: {e}")
73
+ return {"success": False, "message": f"Network error: {e}", "path": None, "details": None}
74
+ except json.JSONDecodeError as e:
75
+ logger.error(f"Invalid JSON in filters: {e}")
76
+ return {"success": False, "message": f"Invalid JSON format: {e}", "path": None, "details": None}
77
+ except Exception as e:
78
+ logger.error(f"Unexpected error: {e}")
79
+ return {"success": False, "message": f"Failed to download filters: {e}", "path": None, "details": None}
@@ -1,229 +0,0 @@
1
- """Setup service for installing WebTap components."""
2
-
3
- import os
4
- import json
5
- import logging
6
- from pathlib import Path
7
- from typing import Dict, Any
8
-
9
- import requests
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class SetupService:
15
- """Service for installing WebTap components."""
16
-
17
- # GitHub URLs
18
- FILTERS_URL = "https://raw.githubusercontent.com/angelsen/tap-tools/main/packages/webtap/data/filters.json"
19
- EXTENSION_BASE_URL = "https://raw.githubusercontent.com/angelsen/tap-tools/main/packages/webtap/extension"
20
- EXTENSION_FILES = ["manifest.json", "popup.html", "popup.js"]
21
-
22
- def install_filters(self, force: bool = False) -> Dict[str, Any]:
23
- """Install filters to .webtap/filters.json.
24
-
25
- Args:
26
- force: Overwrite existing file
27
-
28
- Returns:
29
- Dict with success, message, path, details
30
- """
31
- # Same path that FilterManager uses
32
- target_path = Path.cwd() / ".webtap" / "filters.json"
33
-
34
- # Check if exists
35
- if target_path.exists() and not force:
36
- return {
37
- "success": False,
38
- "message": f"Filters already exist at {target_path}",
39
- "path": str(target_path),
40
- "details": "Use --force to overwrite",
41
- }
42
-
43
- # Download from GitHub
44
- try:
45
- logger.info(f"Downloading filters from {self.FILTERS_URL}")
46
- response = requests.get(self.FILTERS_URL, timeout=10)
47
- response.raise_for_status()
48
-
49
- # Validate it's proper JSON
50
- filters_data = json.loads(response.text)
51
-
52
- # Quick validation - should have dict structure
53
- if not isinstance(filters_data, dict):
54
- return {
55
- "success": False,
56
- "message": "Invalid filter format - expected JSON object",
57
- "path": None,
58
- "details": None,
59
- }
60
-
61
- # Count categories for user feedback
62
- category_count = len(filters_data)
63
-
64
- # Create directory and save
65
- target_path.parent.mkdir(parents=True, exist_ok=True)
66
- target_path.write_text(response.text)
67
-
68
- logger.info(f"Saved {category_count} filter categories to {target_path}")
69
-
70
- return {
71
- "success": True,
72
- "message": f"Downloaded {category_count} filter categories",
73
- "path": str(target_path),
74
- "details": f"Categories: {', '.join(filters_data.keys())}",
75
- }
76
-
77
- except requests.RequestException as e:
78
- logger.error(f"Network error downloading filters: {e}")
79
- return {"success": False, "message": f"Network error: {e}", "path": None, "details": None}
80
- except json.JSONDecodeError as e:
81
- logger.error(f"Invalid JSON in filters: {e}")
82
- return {"success": False, "message": f"Invalid JSON format: {e}", "path": None, "details": None}
83
- except Exception as e:
84
- logger.error(f"Unexpected error: {e}")
85
- return {"success": False, "message": f"Failed to download filters: {e}", "path": None, "details": None}
86
-
87
- def install_extension(self, force: bool = False) -> Dict[str, Any]:
88
- """Install Chrome extension to ~/.config/webtap/extension/.
89
-
90
- Args:
91
- force: Overwrite existing files
92
-
93
- Returns:
94
- Dict with success, message, path, details
95
- """
96
- # XDG config directory for Linux
97
- target_dir = Path.home() / ".config" / "webtap" / "extension"
98
-
99
- # Check if exists (manifest.json is required file)
100
- if (target_dir / "manifest.json").exists() and not force:
101
- return {
102
- "success": False,
103
- "message": f"Extension already exists at {target_dir}",
104
- "path": str(target_dir),
105
- "details": "Use --force to overwrite",
106
- }
107
-
108
- # Create directory
109
- target_dir.mkdir(parents=True, exist_ok=True)
110
-
111
- # Download each file
112
- downloaded = []
113
- failed = []
114
-
115
- for filename in self.EXTENSION_FILES:
116
- url = f"{self.EXTENSION_BASE_URL}/{filename}"
117
- target_file = target_dir / filename
118
-
119
- try:
120
- logger.info(f"Downloading {filename}")
121
- response = requests.get(url, timeout=10)
122
- response.raise_for_status()
123
-
124
- # For manifest.json, validate it's proper JSON
125
- if filename == "manifest.json":
126
- json.loads(response.text)
127
-
128
- target_file.write_text(response.text)
129
- downloaded.append(filename)
130
-
131
- except Exception as e:
132
- logger.error(f"Failed to download {filename}: {e}")
133
- failed.append(filename)
134
-
135
- # Determine success level
136
- if not downloaded:
137
- return {
138
- "success": False,
139
- "message": "Failed to download any extension files",
140
- "path": None,
141
- "details": "Check network connection and try again",
142
- }
143
-
144
- if failed:
145
- # Partial success - some files downloaded
146
- return {
147
- "success": True, # Partial is still success
148
- "message": f"Downloaded {len(downloaded)}/{len(self.EXTENSION_FILES)} files",
149
- "path": str(target_dir),
150
- "details": f"Failed: {', '.join(failed)}",
151
- }
152
-
153
- return {
154
- "success": True,
155
- "message": "Downloaded Chrome extension",
156
- "path": str(target_dir),
157
- "details": f"Files: {', '.join(downloaded)}",
158
- }
159
-
160
- def install_chrome_wrapper(self, force: bool = False) -> Dict[str, Any]:
161
- """Install Chrome wrapper script for debugging.
162
-
163
- Args:
164
- force: Overwrite existing script
165
-
166
- Returns:
167
- Dict with success, message, path, details
168
- """
169
- target_path = Path.home() / ".local" / "bin" / "wrappers" / "google-chrome-stable"
170
-
171
- if target_path.exists() and not force:
172
- return {
173
- "success": False,
174
- "message": "Chrome wrapper already exists",
175
- "path": str(target_path),
176
- "details": "Use --force to overwrite",
177
- }
178
-
179
- wrapper_script = """#!/bin/bash
180
- # Chrome wrapper using bindfs for perfect state sync with debug port
181
-
182
- DEBUG_DIR="$HOME/.config/google-chrome-debug"
183
- REAL_DIR="$HOME/.config/google-chrome"
184
-
185
- # Check if bindfs is installed
186
- if ! command -v bindfs &>/dev/null; then
187
- echo "Error: bindfs not installed. Install with: yay -S bindfs" >&2
188
- exit 1
189
- fi
190
-
191
- # Mount real profile via bindfs if not already mounted
192
- if ! mountpoint -q "$DEBUG_DIR" 2>/dev/null; then
193
- mkdir -p "$DEBUG_DIR"
194
- if ! bindfs --no-allow-other "$REAL_DIR" "$DEBUG_DIR"; then
195
- echo "Error: Failed to mount Chrome profile via bindfs" >&2
196
- exit 1
197
- fi
198
- echo "Chrome debug profile mounted. To unmount: fusermount -u $DEBUG_DIR" >&2
199
- fi
200
-
201
- # Launch Chrome with debugging on bindfs mount
202
- exec /usr/bin/google-chrome-stable \\
203
- --remote-debugging-port=9222 \\
204
- --remote-allow-origins='*' \\
205
- --user-data-dir="$DEBUG_DIR" \\
206
- "$@"
207
- """
208
-
209
- # Create directory and save
210
- target_path.parent.mkdir(parents=True, exist_ok=True)
211
- target_path.write_text(wrapper_script)
212
- target_path.chmod(0o755) # Make executable
213
-
214
- # Check PATH
215
- path_dirs = os.environ.get("PATH", "").split(":")
216
- wrapper_dir = str(target_path.parent)
217
- in_path = wrapper_dir in path_dirs
218
-
219
- logger.info(f"Installed Chrome wrapper to {target_path}")
220
-
221
- return {
222
- "success": True,
223
- "message": "Installed Chrome wrapper script",
224
- "path": str(target_path),
225
- "details": "Already in PATH ✓" if in_path else "Add to PATH",
226
- }
227
-
228
-
229
- __all__ = ["SetupService"]
File without changes
File without changes
File without changes
File without changes