cct-cli 0.7.9.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 (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1386 @@
1
+ """
2
+ CAT Host — Browser Surface Window
3
+
4
+ This is CAT's own graphical browser window. When CAT needs to render a
5
+ real webpage (HTML/CSS/JavaScript), it creates this window containing
6
+ the embedded Chromium rendering surface (QWebEngineView).
7
+
8
+ Architecture:
9
+
10
+ cat (→ main.py → cli.bootstrap → CAT's own runtime)
11
+ ↓
12
+ Browser Mode requested (Ctrl+Shift+B / cat browse / Fomoji auth)
13
+ ↓
14
+ CAT creates this window (same process, owned by CAT)
15
+ ↓
16
+ ┌─────────────────────────────────────────────┐
17
+ │ CAT Browser │
18
+ │ ← → ⟳ ⌂ [address bar] │
19
+ │ ┌─────────────────────────────────────────┐ │
20
+ │ │ │ │
21
+ │ │ EmbeddedBrowserPane (QWebEngineView) │ │
22
+ │ │ REAL HTML / CSS / JavaScript │ │
23
+ │ │ Chromium via Qt WebEngine │ │
24
+ │ │ │ │
25
+ │ └─────────────────────────────────────────┘ │
26
+ └─────────────────────────────────────────────┘
27
+
28
+ The browser rendering surface is a REAL graphical web renderer:
29
+ - QWebEngineView per tab (Chromium via Qt WebEngine == WebView2-class)
30
+ - Persistent QWebEngineProfile (cookies, localStorage, sessionStorage,
31
+ IndexedDB, Cache) under ~/.cat_browser_storage so Fomoji login persists
32
+ - Full HTML/CSS/JS/DOM, images/SVG/fonts/animations, fetch/XHR,
33
+ WebSockets/SSE, responsive layout, media queries, flexbox, etc.
34
+ - True mouse/keyboard/scroll/touchpad/text-input/drag-drop via the
35
+ native view (not simulated)
36
+ - WebAuthn / Passkeys / Credential APIs delegated to the underlying
37
+ Chromium/WebView2 — no fake, no bypass.
38
+
39
+ This window is NOT a separate application. It is owned by CAT and
40
+ created on demand when Browser Mode is requested. When the user closes
41
+ this window, CAT resumes (Textual TUI or fallback REPL).
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import os
47
+ import sys
48
+ import re
49
+ import pathlib
50
+ import urllib.parse
51
+ from typing import Optional
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Qt availability — keep import-time safe so `import calc_terminal.host`
55
+ # never crashes on headless CI (fallback paths handle it).
56
+ # ---------------------------------------------------------------------------
57
+ _QT_AVAILABLE = False
58
+ _QT_BINDING = None
59
+ try:
60
+ from PySide6.QtWidgets import (
61
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
62
+ QStackedWidget, QPushButton, QLabel, QTabBar, QLineEdit
63
+ )
64
+ from PySide6.QtCore import Qt, QUrl, Signal, QTimer, QSize
65
+ from PySide6.QtGui import QAction, QKeySequence, QShortcut
66
+ from PySide6.QtWebEngineWidgets import QWebEngineView
67
+ from PySide6.QtWebEngineCore import (
68
+ QWebEngineProfile, QWebEnginePage, QWebEngineDownloadRequest
69
+ )
70
+ _QT_AVAILABLE = True
71
+ _QT_BINDING = "PySide6"
72
+ except ImportError:
73
+ try:
74
+ from PyQt6.QtWidgets import (
75
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
76
+ QStackedWidget, QPushButton, QLabel, QTabBar, QLineEdit
77
+ )
78
+ from PyQt6.QtCore import Qt, QUrl, pyqtSignal as Signal, QTimer, QSize
79
+ from PyQt6.QtGui import QAction, QKeySequence, QShortcut
80
+ from PyQt6.QtWebEngineWidgets import QWebEngineView
81
+ from PyQt6.QtWebEngineCore import (
82
+ QWebEngineProfile, QWebEnginePage, QWebEngineDownloadRequest
83
+ )
84
+ _QT_AVAILABLE = True
85
+ _QT_BINDING = "PyQt6"
86
+ except ImportError:
87
+ _QT_AVAILABLE = False
88
+ _QT_BINDING = None
89
+
90
+
91
+ if _QT_AVAILABLE:
92
+ # Reuse existing CAT state + theme helpers
93
+ try:
94
+ from ..browser.browser_state import BrowserState # type: ignore
95
+ except Exception:
96
+ BrowserState = None # type: ignore
97
+
98
+ try:
99
+ from .. import theme as _theme # type: ignore
100
+ except Exception:
101
+ _theme = None # type: ignore
102
+
103
+ _FOMOJI_PAGE_NAMES = {
104
+ "connector.html": "Fomoji Connect",
105
+ "home.html": "Fomoji Home",
106
+ "security.html": "Fomoji Security",
107
+ "identities.html": "Fomoji Identities",
108
+ "settings.html": "Fomoji Settings",
109
+ ":8765": "Fatty CAT",
110
+ "8765": "Fatty CAT",
111
+ }
112
+
113
+ def _friendly_url(url: str) -> str:
114
+ if not url or url in ("about:home", "about:blank"):
115
+ return ""
116
+ if "8765" in url:
117
+ return "Fatty CAT"
118
+ if "localhost" in url or "127.0.0.1" in url:
119
+ for page, name in _FOMOJI_PAGE_NAMES.items():
120
+ if page in url:
121
+ return name
122
+ return "CAT Internal"
123
+ return url
124
+
125
+ def _cat_colors():
126
+ if _theme is not None:
127
+ try:
128
+ obj = _theme.get_theme_obj()
129
+ return {
130
+ "bg": obj.hex("background"),
131
+ "surface": obj.hex("surface"),
132
+ "surface_hover": obj.hex("surface_hover"),
133
+ "text": obj.hex("text"),
134
+ "text_muted": obj.hex("text_muted"),
135
+ "border": obj.hex("border"),
136
+ "accent": obj.hex("accent"),
137
+ }
138
+ except Exception:
139
+ pass
140
+ return {
141
+ "bg": "#1a1b26", "surface": "#1e2030", "surface_hover": "#222436",
142
+ "text": "#e8ebfa", "text_muted": "#878caf", "border": "#737aa2",
143
+ "accent": "#82aaff",
144
+ }
145
+
146
+ # -------------------------------------------------------------------
147
+ # Embedded Browser Pane — real Chromium child widget
148
+ # -------------------------------------------------------------------
149
+ class EmbeddedBrowserPane(QWidget):
150
+ """
151
+ Browser pane for CAT Host. Lives INSIDE the host window as a child
152
+ widget (not a separate OS window).
153
+
154
+ Provides:
155
+ * Tab bar (favicon + title + close, + new tab, movable)
156
+ * Toolbar (Back/Forward/Reload/Home, lock, address/search, ☆, ⋮)
157
+ * QWebEngineView per tab (real HTML/CSS/JS, persistent profile)
158
+ * New-tab page as fast native widget (no WebEngine needed for it)
159
+ * history/bookmarks/downloads via BrowserState persistence
160
+ * Fomoji-ready: profile persists cookies/storage under
161
+ ~/.cat_browser_storage so connector auth survives restarts.
162
+ """
163
+
164
+ navigateRequested = Signal(str)
165
+
166
+ def __init__(self, initial_url: str = "about:home", parent=None):
167
+ super().__init__(parent)
168
+ self._initial_url = initial_url or "about:home"
169
+ self.state = BrowserState() if BrowserState else None # type: ignore
170
+ # Persistent profile — cookies, localStorage, service workers,
171
+ # WebAuthn credential store all live here.
172
+ self.profile = QWebEngineProfile("CATBrowser", self)
173
+ try:
174
+ self.profile.setPersistentCookiesPolicy(
175
+ QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
176
+ )
177
+ cache = str(pathlib.Path.home() / ".cat_browser_cache" / "embedded")
178
+ storage = str(pathlib.Path.home() / ".cat_browser_storage" / "embedded")
179
+ pathlib.Path(cache).mkdir(parents=True, exist_ok=True)
180
+ pathlib.Path(storage).mkdir(parents=True, exist_ok=True)
181
+ self.profile.setCachePath(cache)
182
+ self.profile.setPersistentStoragePath(storage)
183
+ # Enable WebAuthn / webauthn virtual authenticator is
184
+ # available via Chromium; no extra flag needed on WebView2.
185
+ except Exception:
186
+ pass
187
+
188
+ self._views: list[QWidget] = []
189
+ self._view_index: dict[int, int] = {} # id(view) -> index for O(1) lookup
190
+ self._setup_ui()
191
+ # Initial tab — defer so window paints first
192
+ QTimer.singleShot(0, lambda: self._ensure_initial_tab())
193
+
194
+ def _setup_ui(self):
195
+ layout = QVBoxLayout(self)
196
+ layout.setContentsMargins(0, 0, 0, 0)
197
+ layout.setSpacing(0)
198
+ c = _cat_colors()
199
+
200
+ # ---- Unified browser chrome (tab bar + toolbar as one surface) --
201
+ chrome = QWidget()
202
+ chrome_layout = QVBoxLayout(chrome)
203
+ chrome_layout.setContentsMargins(0, 0, 0, 0)
204
+ chrome_layout.setSpacing(0)
205
+ chrome.setStyleSheet(f"background: {c['bg']};")
206
+
207
+ # ---- Tab bar row -------------------------------------------------
208
+ try:
209
+ from PySide6.QtWidgets import QTabBar as _QTabBar
210
+ except ImportError:
211
+ from PyQt6.QtWidgets import QTabBar as _QTabBar # type: ignore
212
+ tab_row = QHBoxLayout()
213
+ tab_row.setContentsMargins(6, 4, 4, 0)
214
+ tab_row.setSpacing(0)
215
+
216
+ self.tab_bar = _QTabBar()
217
+ self.tab_bar.setTabsClosable(True)
218
+ self.tab_bar.setMovable(True)
219
+ self.tab_bar.setExpanding(True)
220
+ self.tab_bar.setDrawBase(False)
221
+ self.tab_bar.tabBarClicked.connect(self._on_tab_clicked)
222
+ self.tab_bar.tabCloseRequested.connect(self._close_tab)
223
+ try:
224
+ self.tab_bar.currentChanged.connect(self._on_current_changed)
225
+ except Exception:
226
+ pass
227
+ self.tab_bar.setStyleSheet(
228
+ f"QTabBar {{ background: transparent; }}"
229
+ f"QTabBar::tab {{ "
230
+ f" background: transparent; color: {c['text_muted']}; "
231
+ f" border: none; border-bottom: 2px solid transparent; "
232
+ f" padding: 5px 10px; margin-right: 1px; "
233
+ f" min-width: 80px; max-width: 200px; "
234
+ f" font-size: 12px; border-radius: 4px 4px 0 0; "
235
+ f"}} "
236
+ f"QTabBar::tab:selected {{ "
237
+ f" background: {c['surface']}; color: {c['text']}; "
238
+ f" border-bottom: 2px solid {c['accent']}; "
239
+ f"}} "
240
+ f"QTabBar::tab:hover {{ "
241
+ f" background: {c['surface_hover']}; color: {c['text']}; "
242
+ f"}} "
243
+ f"QTabBar::close-button {{ "
244
+ f" image: none; subcontrol-position: right; padding: 2px; "
245
+ f" subcontrol-origin: padding; "
246
+ f"}} "
247
+ f"QTabBar::close-button:hover {{ image: none; }}"
248
+ )
249
+ tab_row.addWidget(self.tab_bar, 1)
250
+
251
+ self.new_tab_btn = QPushButton("+")
252
+ self.new_tab_btn.setFixedSize(24, 24)
253
+ self.new_tab_btn.setCursor(Qt.CursorShape.PointingHandCursor)
254
+ self.new_tab_btn.setToolTip("New tab (Ctrl+T)")
255
+ self.new_tab_btn.setStyleSheet(
256
+ f"QPushButton {{ background: transparent; color: {c['text_muted']}; "
257
+ f"border: none; border-radius: 4px; font-size: 16px; font-weight: 500; "
258
+ f"margin: 0 4px 0 2px; }}"
259
+ f"QPushButton:hover {{ color: {c['accent']}; background: {c['surface']}; }}"
260
+ )
261
+ self.new_tab_btn.clicked.connect(lambda: self.new_tab())
262
+ tab_row.addWidget(self.new_tab_btn, 0)
263
+
264
+ tab_row_w = QWidget()
265
+ tab_row_w.setLayout(tab_row)
266
+ tab_row_w.setFixedHeight(32)
267
+ tab_row_w.setStyleSheet(f"background: {c['bg']};")
268
+ chrome_layout.addWidget(tab_row_w)
269
+
270
+ # ---- Toolbar row (nav + address + menu) --------------------------
271
+ toolbar = QHBoxLayout()
272
+ toolbar.setContentsMargins(6, 2, 6, 4)
273
+ toolbar.setSpacing(2)
274
+
275
+ nav_btn_style = (
276
+ f"QPushButton {{ background: transparent; color: {c['text_muted']}; "
277
+ f"border: none; border-radius: 4px; font-size: 14px; "
278
+ f"padding: 2px 6px; min-width: 24px; max-width: 28px; "
279
+ f"min-height: 22px; max-height: 22px; }}"
280
+ f"QPushButton:hover {{ color: {c['text']}; background: {c['surface']}; }}"
281
+ f"QPushButton:disabled {{ color: {c['border']}; }}"
282
+ )
283
+
284
+ self.back_btn = QPushButton("◀")
285
+ self.back_btn.setFixedSize(26, 22)
286
+ self.back_btn.setToolTip("Back (Alt+Left)")
287
+ self.back_btn.setStyleSheet(nav_btn_style)
288
+ self.back_btn.clicked.connect(self.go_back)
289
+ toolbar.addWidget(self.back_btn)
290
+
291
+ self.forward_btn = QPushButton("▶")
292
+ self.forward_btn.setFixedSize(26, 22)
293
+ self.forward_btn.setToolTip("Forward (Alt+Right)")
294
+ self.forward_btn.setStyleSheet(nav_btn_style)
295
+ self.forward_btn.clicked.connect(self.go_forward)
296
+ toolbar.addWidget(self.forward_btn)
297
+
298
+ self.reload_btn = QPushButton("⟳")
299
+ self.reload_btn.setFixedSize(26, 22)
300
+ self.reload_btn.setToolTip("Reload (Ctrl+R)")
301
+ self.reload_btn.setStyleSheet(nav_btn_style)
302
+ self.reload_btn.clicked.connect(self.reload)
303
+ toolbar.addWidget(self.reload_btn)
304
+
305
+ toolbar.addSpacing(4)
306
+
307
+ # Address bar — compact pill
308
+ addr_frame = QWidget()
309
+ addr_layout = QHBoxLayout(addr_frame)
310
+ addr_layout.setContentsMargins(8, 1, 8, 1)
311
+ addr_layout.setSpacing(4)
312
+
313
+ self.lock_label = QLabel("🔒")
314
+ self.lock_label.setFixedWidth(14)
315
+ self.lock_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
316
+ self.lock_label.setStyleSheet(f"color: {c['accent']}; font-size: 11px; background: transparent;")
317
+ addr_layout.addWidget(self.lock_label)
318
+
319
+ self.address_input = QLineEdit()
320
+ self.address_input.setPlaceholderText("Search or enter address")
321
+ self.address_input.returnPressed.connect(self._on_address_enter)
322
+ self.address_input.setClearButtonEnabled(True)
323
+ self.address_input.setStyleSheet(
324
+ f"background: transparent; border: none; color: {c['text']}; "
325
+ f"font-size: 12px; padding: 0 2px; selection-background-color: {c['accent']};"
326
+ )
327
+ addr_layout.addWidget(self.address_input, 1)
328
+
329
+ addr_frame.setStyleSheet(
330
+ f"background: {c['surface']}; border: 1px solid {c['border']}; "
331
+ f"border-radius: 12px;"
332
+ )
333
+ toolbar.addWidget(addr_frame, 1)
334
+
335
+ self.menu_btn = QPushButton("⋮")
336
+ self.menu_btn.setFixedSize(24, 22)
337
+ self.menu_btn.setToolTip("Menu")
338
+ self.menu_btn.setStyleSheet(
339
+ f"QPushButton {{ background: transparent; color: {c['text_muted']}; "
340
+ f"border: none; border-radius: 4px; font-size: 14px; }}"
341
+ f"QPushButton:hover {{ color: {c['text']}; background: {c['surface']}; }}"
342
+ )
343
+ self.menu_btn.clicked.connect(self.show_menu)
344
+ toolbar.addWidget(self.menu_btn)
345
+
346
+ toolbar_w = QWidget()
347
+ toolbar_w.setLayout(toolbar)
348
+ toolbar_w.setFixedHeight(28)
349
+ toolbar_w.setStyleSheet(f"background: {c['bg']}; border-bottom: 1px solid {c['border']};")
350
+ chrome_layout.addWidget(toolbar_w)
351
+
352
+ # ---- Progress bar (thin, integrated into chrome) -----------------
353
+ self.progress_bar = None
354
+ try:
355
+ from PySide6.QtWidgets import QProgressBar
356
+ except ImportError:
357
+ from PyQt6.QtWidgets import QProgressBar # type: ignore
358
+ self.progress_bar = QProgressBar()
359
+ self.progress_bar.setFixedHeight(2)
360
+ self.progress_bar.setTextVisible(False)
361
+ self.progress_bar.setRange(0, 100)
362
+ self.progress_bar.setValue(0)
363
+ self.progress_bar.setStyleSheet(
364
+ f"QProgressBar {{ background: transparent; border: none; }}"
365
+ f"QProgressBar::chunk {{ background: {c['accent']}; border-radius: 1px; }}"
366
+ )
367
+ self.progress_bar.hide()
368
+ chrome_layout.addWidget(self.progress_bar)
369
+
370
+ layout.addWidget(chrome)
371
+
372
+ # ---- Viewport — stacked WebEngineViews --------------------------
373
+ self.viewport_stack = QStackedWidget()
374
+ self.viewport_stack.setStyleSheet(f"background: {c['bg']};")
375
+ layout.addWidget(self.viewport_stack, 1)
376
+
377
+ # ----------------------------------------------------------------
378
+ # Tab management — native new-tab + WebEngineView swap
379
+ # ----------------------------------------------------------------
380
+ def _new_tab_widget(self):
381
+ """Fast native New Tab — no WebEngine, instant."""
382
+ try:
383
+ from .terminal import CATTerminalWidget # avoid circular at class body
384
+ except Exception:
385
+ pass
386
+ w = QWidget()
387
+ v = QVBoxLayout(w)
388
+ v.setAlignment(Qt.AlignmentFlag.AlignCenter)
389
+ v.setSpacing(12)
390
+ c = _cat_colors()
391
+
392
+ wrap = QWidget()
393
+ wrap.setMaximumWidth(560)
394
+ vw = QVBoxLayout(wrap)
395
+ vw.setAlignment(Qt.AlignmentFlag.AlignCenter)
396
+ vw.setSpacing(14)
397
+
398
+ logo = QLabel()
399
+ logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
400
+ logo.setTextFormat(Qt.TextFormat.RichText)
401
+ logo.setText(
402
+ f'<span style="font-size:36px;font-weight:800;letter-spacing:5px;'
403
+ f'color:{c["accent"]};">CAT</span>'
404
+ )
405
+ logo.setStyleSheet(f"font-size: 36px; font-weight: 800; letter-spacing: 5px;")
406
+ vw.addWidget(logo)
407
+
408
+ subtitle = QLabel("Browser")
409
+ subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
410
+ subtitle.setStyleSheet(f"color: {c['text_muted']}; font-size: 12px; margin-top: -6px;")
411
+ vw.addWidget(subtitle)
412
+
413
+ # Search
414
+ search_frame = QWidget()
415
+ sh = QHBoxLayout(search_frame)
416
+ sh.setContentsMargins(12, 6, 12, 6)
417
+ sh.setSpacing(6)
418
+ search_icon = QLabel("🔍")
419
+ search_icon.setStyleSheet(f"color: {c['text_muted']}; font-size: 13px; background: transparent;")
420
+ sh.addWidget(search_icon)
421
+ search_input = QLineEdit()
422
+ search_input.setPlaceholderText("Search or enter a URL")
423
+ search_input.setStyleSheet(
424
+ f"background: transparent; border: none; color: {c['text']}; font-size: 13px;"
425
+ )
426
+ w._search_input = search_input # type: ignore
427
+ def _submit():
428
+ text = search_input.text().strip()
429
+ if text:
430
+ self._handle_newtab_search(text)
431
+ search_input.returnPressed.connect(_submit)
432
+ sh.addWidget(search_input, 1)
433
+ search_frame.setStyleSheet(
434
+ f"background: {c['surface']}; border: 1px solid {c['border']}; border-radius: 16px;"
435
+ )
436
+ vw.addWidget(search_frame)
437
+
438
+ # Shortcuts — minimal set
439
+ try:
440
+ from PySide6.QtWidgets import QGridLayout
441
+ except ImportError:
442
+ from PyQt6.QtWidgets import QGridLayout # type: ignore
443
+ grid = QGridLayout()
444
+ grid.setSpacing(6)
445
+ for idx, (icon, label, url) in enumerate([
446
+ ("🐱", "Fatty CAT", "http://localhost:8765/"),
447
+ ("🔗", "Fomoji", "http://localhost:3000/home.html"),
448
+ ("○", "New Tab", "about:home"),
449
+ ]):
450
+ btn = QPushButton(f"{icon} {label}")
451
+ btn.setFixedHeight(30)
452
+ btn.setStyleSheet(
453
+ f"QPushButton {{ background: {c['surface']}; border: 1px solid {c['border']}; "
454
+ f"border-radius: 6px; color: {c['text']}; padding: 0 12px; font-size: 12px; }}"
455
+ f"QPushButton:hover {{ border: 1px solid {c['accent']}; background: {c['surface_hover']}; }}"
456
+ )
457
+ btn.clicked.connect(lambda _=None, u=url: self.navigate(u))
458
+ grid.addWidget(btn, 0, idx)
459
+ grid_w = QWidget()
460
+ grid_w.setLayout(grid)
461
+ vw.addWidget(grid_w)
462
+
463
+ foot = QLabel("CAT Browser")
464
+ foot.setAlignment(Qt.AlignmentFlag.AlignCenter)
465
+ foot.setStyleSheet(f"color: {c['text_muted']}; font-size: 10px; margin-top: 8px;")
466
+ vw.addWidget(foot)
467
+
468
+ v.addWidget(wrap)
469
+ QTimer.singleShot(100, lambda: search_input.setFocus())
470
+ w.setObjectName("newtab")
471
+ return w
472
+
473
+ def _handle_newtab_search(self, text: str):
474
+ clean = text.strip()
475
+ if clean.lower() in ("fatty", "fattycat", "fatty-cat", "fatty cat", "cat web"):
476
+ self.navigate("http://localhost:8765/")
477
+ return
478
+ if clean.lower() in ("fomoji", "login", "auth"):
479
+ self.navigate("http://localhost:3000/home.html")
480
+ return
481
+ has_scheme = bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", clean))
482
+ is_local = "localhost" in clean.lower() or "127.0.0.1" in clean
483
+ if has_scheme or is_local or ("." in clean.split()[0] and " " not in clean and "/" in clean):
484
+ url = clean if has_scheme else ("http://" + clean if is_local else "https://" + clean)
485
+ else:
486
+ url = "https://www.google.com/search?q=" + urllib.parse.quote_plus(clean) + "&hl=en"
487
+ self.navigate(url)
488
+
489
+ def _ensure_initial_tab(self):
490
+ if self.viewport_stack.count() == 0:
491
+ self.new_tab(getattr(self, "_initial_url", "about:home"))
492
+
493
+ def _is_newtab(self, w: QWidget) -> bool:
494
+ return w is not None and w.objectName() == "newtab"
495
+
496
+ def _view_index(self, view: QWidget) -> int:
497
+ """O(1) view index lookup via cached dict."""
498
+ return self._view_index.get(id(view), -1)
499
+
500
+ def _rebuild_view_index(self):
501
+ """Rebuild the O(1) index cache after list changes."""
502
+ self._view_index = {id(v): i for i, v in enumerate(self._views)}
503
+
504
+ def new_tab(self, url: str = "about:home"):
505
+ is_newtab_url = url in ("about:home", "about:blank", "")
506
+ if is_newtab_url:
507
+ w = self._new_tab_widget()
508
+ idx = self.viewport_stack.addWidget(w)
509
+ self._views.append(w)
510
+ fav = "○"
511
+ title = "New Tab"
512
+ else:
513
+ view = QWebEngineView()
514
+ try:
515
+ page = QWebEnginePage(self.profile, view)
516
+ view.setPage(page)
517
+ view.titleChanged.connect(lambda t, v=view: self._on_title(v, t))
518
+ view.urlChanged.connect(lambda q, v=view: self._on_url(v, q))
519
+ view.loadStarted.connect(lambda v=view: self._on_load_started(v))
520
+ view.loadFinished.connect(lambda ok, v=view: self._on_load_finished(v, ok))
521
+ try:
522
+ view.page().newWindowRequested.connect(self._on_new_window)
523
+ except Exception:
524
+ pass
525
+ # Downloads
526
+ try:
527
+ if not getattr(self, "_dl_connected", False):
528
+ self.profile.downloadRequested.connect(self._on_download)
529
+ self._dl_connected = True
530
+ except Exception:
531
+ pass
532
+ except Exception:
533
+ pass
534
+ idx = self.viewport_stack.addWidget(view)
535
+ self._views.append(view)
536
+ fav = "○"
537
+ title = "Loading…"
538
+ # Keep BrowserState in sync
539
+ if self.state:
540
+ self.state.new_tab(url)
541
+ try:
542
+ view.load(QUrl(url))
543
+ except Exception:
544
+ pass
545
+
546
+ tab_idx = self.tab_bar.addTab(f"{fav} {title[:28]}")
547
+ self.tab_bar.setCurrentIndex(tab_idx)
548
+ self.viewport_stack.setCurrentIndex(idx)
549
+ if not is_newtab_url and self.state:
550
+ self.state.active_tab_id = self.state.tabs[-1].id if self.state.tabs else ""
551
+ self._rebuild_view_index()
552
+ self._sync_chrome()
553
+ return tab_idx
554
+
555
+ def navigate(self, raw: str):
556
+ url = raw.strip()
557
+ if not url:
558
+ return
559
+
560
+ # Fast keyword shortcuts
561
+ if url.lower() in ("fatty", "fattycat", "fatty-cat", "fatty cat", "cat web"):
562
+ url = "http://localhost:8765/"
563
+ elif url.lower() in ("fomoji", "login", "auth"):
564
+ url = "http://localhost:3000/home.html"
565
+
566
+ # Auto-ensure Fatty CAT server is running when navigating to port 8765
567
+ if "8765" in url:
568
+ try:
569
+ from ..web.server import ensure_fatty_server
570
+ ensure_fatty_server(timeout=5.0, auto_start=True)
571
+ except Exception:
572
+ pass
573
+
574
+ has_scheme = bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url))
575
+ is_about = url.lower().startswith("about:")
576
+ is_local = "localhost" in url.lower() or "127.0.0.1" in url
577
+ # Search detection
578
+ def _is_search(q):
579
+ if has_scheme or is_about or is_local:
580
+ return False
581
+ if " " in q.strip():
582
+ return True
583
+ first = q.strip().split()[0] if q.strip() else ""
584
+ if "." not in first and "/" not in q:
585
+ return True
586
+ return False
587
+ if _is_search(url):
588
+ url = "https://www.google.com/search?q=" + urllib.parse.quote_plus(url) + "&hl=en"
589
+ elif not has_scheme and not is_about and "." in url.split("/")[0] and " " not in url:
590
+ url = "https://" + url
591
+
592
+ cur = self.tab_bar.currentIndex()
593
+ if not (0 <= cur < len(self._views)):
594
+ self.new_tab(url)
595
+ return
596
+ cur_w = self._views[cur]
597
+ # Cases:
598
+ # 1. newtab -> real URL : replace widget with WebEngineView
599
+ # 2. real -> about:home : replace with newtab
600
+ # 3. otherwise : load in place
601
+ if self._is_newtab(cur_w) and url not in ("about:home", "about:blank"):
602
+ self._replace_newtab_with_webview(cur, url)
603
+ return
604
+ if not self._is_newtab(cur_w) and url in ("about:home", "about:blank"):
605
+ self._replace_webview_with_newtab(cur)
606
+ return
607
+ if not self._is_newtab(cur_w):
608
+ # Normal navigation in WebEngineView
609
+ try:
610
+ cur_w.load(QUrl(url)) # type: ignore
611
+ except Exception:
612
+ pass
613
+ if self.state and 0 <= cur < len(self.state.tabs):
614
+ self.state.tabs[cur].url = url
615
+ self.state.tabs[cur].title = "Loading…"
616
+ self.tab_bar.setTabText(cur, "⟳ Loading…")
617
+ if self.progress_bar:
618
+ self.progress_bar.setValue(0)
619
+ self.progress_bar.show()
620
+ else:
621
+ # newtab -> newtab (should not happen, but handle)
622
+ pass
623
+ self._sync_chrome()
624
+
625
+ def _replace_newtab_with_webview(self, idx: int, url: str):
626
+ old = self._views[idx]
627
+ self.viewport_stack.removeWidget(old)
628
+ old.deleteLater()
629
+ view = QWebEngineView()
630
+ try:
631
+ page = QWebEnginePage(self.profile, view)
632
+ view.setPage(page)
633
+ view.titleChanged.connect(lambda t, v=view: self._on_title(v, t))
634
+ view.urlChanged.connect(lambda q, v=view: self._on_url(v, q))
635
+ view.loadStarted.connect(lambda v=view: self._on_load_started(v))
636
+ view.loadFinished.connect(lambda ok, v=view: self._on_load_finished(v, ok))
637
+ try:
638
+ view.page().newWindowRequested.connect(self._on_new_window)
639
+ except Exception:
640
+ pass
641
+ except Exception:
642
+ pass
643
+ self.viewport_stack.insertWidget(idx, view)
644
+ self.viewport_stack.setCurrentIndex(idx)
645
+ self._views[idx] = view
646
+ # State
647
+ if self.state and 0 <= idx < len(self.state.tabs):
648
+ self.state.tabs[idx].url = url
649
+ self.state.tabs[idx].title = "Loading…"
650
+ else:
651
+ if self.state:
652
+ try:
653
+ self.state.tabs[idx].url = url
654
+ except Exception:
655
+ pass
656
+ self.tab_bar.setTabText(idx, "⟳ Loading…")
657
+ if self.progress_bar:
658
+ self.progress_bar.setValue(0)
659
+ self.progress_bar.show()
660
+ try:
661
+ view.load(QUrl(url))
662
+ except Exception:
663
+ pass
664
+ self._sync_chrome()
665
+
666
+ def _replace_webview_with_newtab(self, idx: int):
667
+ old = self._views[idx]
668
+ self.viewport_stack.removeWidget(old)
669
+ old.deleteLater()
670
+ w = self._new_tab_widget()
671
+ self.viewport_stack.insertWidget(idx, w)
672
+ self.viewport_stack.setCurrentIndex(idx)
673
+ self._views[idx] = w
674
+ self.tab_bar.setTabText(idx, "○ New Tab")
675
+ if self.state and 0 <= idx < len(self.state.tabs):
676
+ self.state.tabs[idx].url = "about:home"
677
+ self.state.tabs[idx].title = "New Tab"
678
+ self.state.tabs[idx].favicon = "○"
679
+ if self.progress_bar:
680
+ self.progress_bar.hide()
681
+ self._sync_chrome()
682
+
683
+ # ----------------------------------------------------------------
684
+ # Signals from WebEngineView
685
+ # ----------------------------------------------------------------
686
+ def _on_title(self, view, title: str):
687
+ try:
688
+ idx = self._view_index(view)
689
+ if idx < 0:
690
+ return
691
+ display = title[:28] if title else "Untitled"
692
+ self.tab_bar.setTabText(idx, f"{self._fav(idx)} {display}")
693
+ if self.state and 0 <= idx < len(self.state.tabs):
694
+ self.state.tabs[idx].title = title[:60]
695
+ if idx == self.tab_bar.currentIndex():
696
+ self._sync_chrome()
697
+ except Exception:
698
+ pass
699
+
700
+ def _on_url(self, view, qurl):
701
+ try:
702
+ idx = self._view_index(view)
703
+ if idx < 0:
704
+ return
705
+ url = qurl.toString()
706
+ if self.state and 0 <= idx < len(self.state.tabs):
707
+ self.state.tabs[idx].url = url
708
+ self.state.tabs[idx].favicon = "🔒" if url.startswith("https://") else "○"
709
+ if idx == self.tab_bar.currentIndex():
710
+ display = _friendly_url(url)
711
+ self.address_input.setText(display)
712
+ self.lock_label.setText("🔒" if url.startswith("https") else "🌐")
713
+ self._sync_nav()
714
+ # Update tab text with favicon
715
+ title = ""
716
+ if self.state and 0 <= idx < len(self.state.tabs):
717
+ title = self.state.tabs[idx].title[:28]
718
+ fav = self._fav(idx)
719
+ if title:
720
+ self.tab_bar.setTabText(idx, f"{fav} {title}")
721
+ except Exception:
722
+ pass
723
+
724
+ def _on_load_started(self, view):
725
+ try:
726
+ idx = self._view_index(view)
727
+ if idx < 0:
728
+ return
729
+ if idx == self.tab_bar.currentIndex():
730
+ self.reload_btn.setText("×")
731
+ self.reload_btn.setToolTip("Stop")
732
+ if self.progress_bar:
733
+ self.progress_bar.setValue(0)
734
+ self.progress_bar.show()
735
+ self.tab_bar.setTabText(idx, f"⟳ Loading…")
736
+ except Exception:
737
+ pass
738
+
739
+ def _on_load_finished(self, view, ok: bool):
740
+ try:
741
+ idx = self._view_index(view)
742
+ if idx < 0:
743
+ return
744
+ if idx == self.tab_bar.currentIndex():
745
+ self.reload_btn.setText("⟳")
746
+ self.reload_btn.setToolTip("Reload (Ctrl+R)")
747
+ if self.progress_bar:
748
+ self.progress_bar.hide()
749
+ self.progress_bar.setValue(0)
750
+ title = ""
751
+ try:
752
+ title = view.title() or ""
753
+ except Exception:
754
+ title = ""
755
+ if title:
756
+ display = title[:28] if title else "Untitled"
757
+ self.tab_bar.setTabText(idx, f"{self._fav(idx)} {display}")
758
+ if self.state and 0 <= idx < len(self.state.tabs):
759
+ self.state.tabs[idx].title = title[:60]
760
+ if idx == self.tab_bar.currentIndex():
761
+ self._sync_chrome()
762
+ except Exception:
763
+ pass
764
+
765
+ def _on_new_window(self, request):
766
+ try:
767
+ url = request.requestedUrl().toString()
768
+ self.new_tab(url)
769
+ # Need to route the request to new page
770
+ try:
771
+ view = self._views[-1]
772
+ if not self._is_newtab(view):
773
+ request.openIn(view.page()) # type: ignore
774
+ except Exception:
775
+ pass
776
+ except Exception:
777
+ pass
778
+
779
+ def _on_download(self, download):
780
+ try:
781
+ filename = download.downloadFileName() or "download"
782
+ home_dl = str(pathlib.Path.home() / "Downloads")
783
+ pathlib.Path(home_dl).mkdir(parents=True, exist_ok=True)
784
+ download.setDownloadDirectory(home_dl)
785
+ download.setDownloadFileName(filename)
786
+ download.accept()
787
+ if self.state:
788
+ try:
789
+ self.state.add_download(filename, download.url().toString(), str(pathlib.Path(home_dl) / filename))
790
+ except Exception:
791
+ pass
792
+ except Exception:
793
+ try:
794
+ download.accept() # type: ignore
795
+ except Exception:
796
+ pass
797
+
798
+ # ----------------------------------------------------------------
799
+ # Helpers
800
+ # ----------------------------------------------------------------
801
+ def _fav(self, idx: int) -> str:
802
+ try:
803
+ if self.state and 0 <= idx < len(self.state.tabs):
804
+ return self.state.tabs[idx].favicon or "○"
805
+ w = self._views[idx]
806
+ if not self._is_newtab(w):
807
+ url = w.url().toString() if hasattr(w, "url") else "" # type: ignore
808
+ if "localhost" in url or "127.0.0.1" in url:
809
+ return "⬢"
810
+ if url.startswith("https://"):
811
+ return "🔒"
812
+ if url.startswith("http://"):
813
+ return "○"
814
+ return "○"
815
+ except Exception:
816
+ return "○"
817
+
818
+ def _sync_chrome(self):
819
+ try:
820
+ cur = self.tab_bar.currentIndex()
821
+ if cur < 0 or cur >= len(self._views):
822
+ return
823
+ w = self._views[cur]
824
+ if self._is_newtab(w):
825
+ if self.address_input.text():
826
+ self.address_input.setText("")
827
+ self.lock_label.setText("🌐")
828
+ else:
829
+ try:
830
+ url = w.url().toString() # type: ignore
831
+ display = _friendly_url(url)
832
+ if self.address_input.text() != display:
833
+ self.address_input.setText(display)
834
+ lock = "🔒" if url.startswith("https") else "🌐"
835
+ if self.lock_label.text() != lock:
836
+ self.lock_label.setText(lock)
837
+ except Exception:
838
+ pass
839
+ if self.state and 0 <= cur < len(self.state.tabs):
840
+ tab = self.state.tabs[cur]
841
+ fav = self._fav(cur)
842
+ title = tab.title[:28] if tab.title else "Untitled"
843
+ new_text = f"{fav} {title}"
844
+ if self.tab_bar.tabText(cur) != new_text:
845
+ self.tab_bar.setTabText(cur, new_text)
846
+ self._sync_nav()
847
+ except Exception:
848
+ pass
849
+
850
+ def _sync_nav(self):
851
+ try:
852
+ cur = self.tab_bar.currentIndex()
853
+ if 0 <= cur < len(self._views) and not self._is_newtab(self._views[cur]):
854
+ view = self._views[cur]
855
+ # Use WebEngine history, not BrowserState, for accuracy
856
+ try:
857
+ # QWebEngineHistory has canGoBack/canGoForward
858
+ hist = view.history() # type: ignore
859
+ self.back_btn.setEnabled(hist.canGoBack())
860
+ self.forward_btn.setEnabled(hist.canGoForward())
861
+ return
862
+ except Exception:
863
+ pass
864
+ self.back_btn.setEnabled(False)
865
+ self.forward_btn.setEnabled(False)
866
+ except Exception:
867
+ pass
868
+
869
+ def _sync_bookmark(self):
870
+ pass # Bookmark button removed for cleaner toolbar
871
+
872
+ # ----------------------------------------------------------------
873
+ # Public API (called by host window)
874
+ # ----------------------------------------------------------------
875
+ def _on_tab_clicked(self, idx: int):
876
+ if 0 <= idx < len(self._views):
877
+ self.viewport_stack.setCurrentIndex(idx)
878
+ self._sync_chrome()
879
+
880
+ def _on_current_changed(self, idx: int):
881
+ # Keep viewport and BrowserState active in sync
882
+ if 0 <= idx < len(self._views):
883
+ self.viewport_stack.setCurrentIndex(idx)
884
+ if self.state and 0 <= idx < len(self.state.tabs):
885
+ self.state.active_tab_id = self.state.tabs[idx].id
886
+ self._sync_chrome()
887
+
888
+ def _close_tab(self, idx: int):
889
+ if not (0 <= idx < len(self._views)):
890
+ return
891
+ w = self._views.pop(idx)
892
+ self.viewport_stack.removeWidget(w)
893
+ w.deleteLater()
894
+ self.tab_bar.removeTab(idx)
895
+ self._rebuild_view_index()
896
+ if self.state and 0 <= idx < len(self.state.tabs):
897
+ try:
898
+ tab_id = self.state.tabs[idx].id
899
+ self.state.close_tab(tab_id)
900
+ except Exception:
901
+ pass
902
+ if self.progress_bar and self.tab_bar.count() == 0:
903
+ self.progress_bar.hide()
904
+ if self.tab_bar.count() == 0:
905
+ self.new_tab("about:home")
906
+ else:
907
+ new_idx = min(idx, self.tab_bar.count() - 1)
908
+ self.tab_bar.setCurrentIndex(new_idx)
909
+ self.viewport_stack.setCurrentIndex(new_idx)
910
+ self._sync_chrome()
911
+
912
+ def _on_address_enter(self):
913
+ self.navigate(self.address_input.text())
914
+
915
+ def go_back(self):
916
+ cur = self.tab_bar.currentIndex()
917
+ if 0 <= cur < len(self._views) and not self._is_newtab(self._views[cur]):
918
+ try:
919
+ self._views[cur].back() # type: ignore
920
+ except Exception:
921
+ pass
922
+
923
+ def go_forward(self):
924
+ cur = self.tab_bar.currentIndex()
925
+ if 0 <= cur < len(self._views) and not self._is_newtab(self._views[cur]):
926
+ try:
927
+ self._views[cur].forward() # type: ignore
928
+ except Exception:
929
+ pass
930
+
931
+ def reload(self):
932
+ cur = self.tab_bar.currentIndex()
933
+ if 0 <= cur < len(self._views) and not self._is_newtab(self._views[cur]):
934
+ view = self._views[cur]
935
+ try:
936
+ if hasattr(view, "isLoading") and view.isLoading(): # type: ignore
937
+ view.stop() # type: ignore
938
+ if self.progress_bar:
939
+ self.progress_bar.hide()
940
+ else:
941
+ view.reload() # type: ignore
942
+ if self.progress_bar:
943
+ self.progress_bar.setValue(0)
944
+ self.progress_bar.show()
945
+ except Exception:
946
+ try:
947
+ view.reload() # type: ignore
948
+ except Exception:
949
+ pass
950
+
951
+ def toggle_bookmark(self):
952
+ pass # Bookmark button removed for cleaner toolbar
953
+
954
+ def show_menu(self):
955
+ try:
956
+ from PySide6.QtWidgets import QMenu
957
+ except ImportError:
958
+ from PyQt6.QtWidgets import QMenu # type: ignore
959
+ menu = QMenu(self)
960
+ c = _cat_colors()
961
+ menu.setStyleSheet(
962
+ f"QMenu {{ background: {c['surface']}; color: {c['text']}; "
963
+ f"border: 1px solid {c['border']}; border-radius: 8px; padding: 4px; }}"
964
+ f"QMenu::item {{ padding: 6px 20px; border-radius: 4px; }}"
965
+ f"QMenu::item:selected {{ background: {c['accent']}; color: white; }}"
966
+ f"QMenu::separator {{ height: 1px; background: {c['border']}; margin: 2px 8px; }}"
967
+ )
968
+
969
+ def _add(label, fn=None, sep=False):
970
+ if sep:
971
+ menu.addSeparator()
972
+ return
973
+ act = menu.addAction(label)
974
+ if fn:
975
+ act.triggered.connect(fn)
976
+
977
+ _add("New Tab", lambda: self.new_tab())
978
+ _add("", sep=True)
979
+ _add("History", lambda: self._show_history())
980
+ _add("Bookmarks", lambda: self._show_bookmarks())
981
+ _add("Downloads", lambda: self._show_downloads())
982
+ _add("", sep=True)
983
+ _add("Fomoji Dashboard", lambda: self.navigate("http://localhost:3000/home.html"))
984
+ _add("Fomoji Settings", lambda: self.navigate("http://localhost:3000/settings.html"))
985
+ _add("", sep=True)
986
+ _add("Developer Tools", lambda: self._show_devtools())
987
+ menu.exec(self.menu_btn.mapToGlobal(self.menu_btn.rect().bottomLeft()))
988
+
989
+ def _show_history(self):
990
+ # Use BrowserState grouped history; show in simple dialog
991
+ if not self.state:
992
+ return
993
+ try:
994
+ from PySide6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QListWidgetItem, QDialogButtonBox
995
+ except ImportError:
996
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QListWidgetItem, QDialogButtonBox # type: ignore
997
+ dlg = QDialog(self)
998
+ dlg.setWindowTitle("History — CAT Browser")
999
+ dlg.resize(700, 480)
1000
+ lay = QVBoxLayout(dlg)
1001
+ lw = QListWidget()
1002
+ groups = self.state.history_grouped() if hasattr(self.state, "history_grouped") else {}
1003
+ for gname in ("Today", "Yesterday", "Earlier"):
1004
+ for h in groups.get(gname, [])[:50]:
1005
+ item = QListWidgetItem(f"{h.favicon} {h.title[:40]} — {h.url[:60]}")
1006
+ try:
1007
+ item.setData(Qt.ItemDataRole.UserRole, h.url)
1008
+ except Exception:
1009
+ pass
1010
+ lw.addItem(item)
1011
+ def _open(item):
1012
+ try:
1013
+ url = item.data(Qt.ItemDataRole.UserRole)
1014
+ if url:
1015
+ dlg.accept()
1016
+ self.navigate(url)
1017
+ except Exception:
1018
+ pass
1019
+ lw.itemDoubleClicked.connect(_open)
1020
+ lay.addWidget(lw)
1021
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1022
+ btns.rejected.connect(dlg.reject)
1023
+ lay.addWidget(btns)
1024
+ dlg.exec()
1025
+
1026
+ def _show_bookmarks(self):
1027
+ if not self.state:
1028
+ return
1029
+ try:
1030
+ from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton
1031
+ except ImportError:
1032
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton # type: ignore
1033
+ dlg = QDialog(self)
1034
+ dlg.setWindowTitle("Bookmarks — CAT Browser")
1035
+ dlg.resize(600, 400)
1036
+ lay = QVBoxLayout(dlg)
1037
+ lw = QListWidget()
1038
+ for bm in self.state.bookmarks:
1039
+ item = QListWidgetItem(f"{bm.favicon} {bm.title} — {bm.url}")
1040
+ try:
1041
+ item.setData(Qt.ItemDataRole.UserRole, bm.id)
1042
+ except Exception:
1043
+ pass
1044
+ lw.addItem(item)
1045
+ lay.addWidget(lw)
1046
+ row = QHBoxLayout()
1047
+ del_btn = QPushButton("Delete")
1048
+ open_btn = QPushButton("Open")
1049
+ close_btn = QPushButton("Close")
1050
+ def _del():
1051
+ it = lw.currentItem()
1052
+ if not it:
1053
+ return
1054
+ bid = it.data(Qt.ItemDataRole.UserRole)
1055
+ self.state.remove_bookmark(bid)
1056
+ lw.takeItem(lw.row(it))
1057
+ def _open():
1058
+ it = lw.currentItem()
1059
+ if not it:
1060
+ return
1061
+ bid = it.data(Qt.ItemDataRole.UserRole)
1062
+ bm = next((b for b in self.state.bookmarks if b.id == bid), None)
1063
+ if bm:
1064
+ dlg.accept()
1065
+ self.navigate(bm.url)
1066
+ del_btn.clicked.connect(_del)
1067
+ open_btn.clicked.connect(_open)
1068
+ close_btn.clicked.connect(dlg.reject)
1069
+ lw.itemDoubleClicked.connect(lambda it: _open())
1070
+ row.addWidget(del_btn)
1071
+ row.addStretch()
1072
+ row.addWidget(open_btn)
1073
+ row.addWidget(close_btn)
1074
+ lay.addLayout(row)
1075
+ dlg.exec()
1076
+
1077
+ def _show_downloads(self):
1078
+ if not self.state:
1079
+ return
1080
+ try:
1081
+ from PySide6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QListWidgetItem, QDialogButtonBox
1082
+ except ImportError:
1083
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QListWidgetItem, QDialogButtonBox # type: ignore
1084
+ dlg = QDialog(self)
1085
+ dlg.setWindowTitle("Downloads — CAT Browser")
1086
+ dlg.resize(600, 400)
1087
+ lay = QVBoxLayout(dlg)
1088
+ lw = QListWidget()
1089
+ for d in self.state.downloads:
1090
+ lw.addItem(QListWidgetItem(f"{d.filename} — {d.status} {d.progress}% {d.url[:50]}"))
1091
+ lay.addWidget(lw)
1092
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1093
+ btns.rejected.connect(dlg.reject)
1094
+ lay.addWidget(btns)
1095
+ dlg.exec()
1096
+
1097
+ def _show_devtools(self):
1098
+ try:
1099
+ from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox
1100
+ except ImportError:
1101
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox # type: ignore
1102
+ dlg = QDialog(self)
1103
+ dlg.setWindowTitle("Developer Tools — CAT Browser")
1104
+ dlg.resize(700, 500)
1105
+ lay = QVBoxLayout(dlg)
1106
+ lay.addWidget(QLabel("Console — browser console + page errors appear in QWebEngine's devtools (F12 via inspect)"))
1107
+ lay.addWidget(QLabel("Network — use QWebEngine's Network panel (right-click → Inspect)"))
1108
+ lay.addWidget(QLabel("Storage — cookies/localStorage under ~/.cat_browser_storage"))
1109
+ lay.addWidget(QLabel("Tip: right-click any page → Inspect to open Chromium DevTools"))
1110
+ # Add inspect button for current view
1111
+ try:
1112
+ from PySide6.QtWidgets import QPushButton
1113
+ except ImportError:
1114
+ from PyQt6.QtWidgets import QPushButton # type: ignore
1115
+ cur = self.tab_bar.currentIndex()
1116
+ if 0 <= cur < len(self._views) and not self._is_newtab(self._views[cur]):
1117
+ btn = QPushButton("Inspect current page (open DevTools)")
1118
+ def _inspect():
1119
+ try:
1120
+ page = self._views[cur].page()
1121
+ page.triggerAction(QWebEnginePage.WebAction.InspectElement)
1122
+ except Exception:
1123
+ pass
1124
+ btn.clicked.connect(_inspect)
1125
+ lay.addWidget(btn)
1126
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1127
+ btns.rejected.connect(dlg.reject)
1128
+ lay.addWidget(btns)
1129
+ dlg.exec()
1130
+
1131
+ # -------------------------------------------------------------------
1132
+ # CAT Desktop Window — the single CAT OS window
1133
+ # -------------------------------------------------------------------
1134
+ class CATDesktopWindow(QMainWindow):
1135
+ """
1136
+ CAT's own Browser window — the embedded graphical browser surface.
1137
+
1138
+ This is NOT a separate application. It is created by CAT on demand
1139
+ when Browser Mode is requested. The browser renderer (QWebEngineView)
1140
+ is a child widget of this window, owned and controlled by CAT.
1141
+
1142
+ When this window is closed, CAT resumes its previous state
1143
+ (Textual TUI or fallback REPL).
1144
+ """
1145
+
1146
+ def __init__(self, repl=None, start_browser_url: Optional[str] = None, parent=None):
1147
+ super().__init__(parent)
1148
+ self.repl = repl
1149
+ self._pending_browser_url = start_browser_url or "about:home"
1150
+ self.setWindowTitle("CAT Browser")
1151
+ self.resize(1280, 860)
1152
+ self.setMinimumSize(960, 640)
1153
+ self._center_on_screen()
1154
+ self._build_ui()
1155
+ self._setup_shortcuts()
1156
+ if start_browser_url and start_browser_url not in ("about:home", "about:blank"):
1157
+ QTimer.singleShot(150, lambda: self.navigate(start_browser_url))
1158
+
1159
+ # Recurring heartbeat and IPC check for real-time pop-up and URL redirects
1160
+ self._pending_timer = QTimer(self)
1161
+ self._pending_timer.setInterval(250)
1162
+ self._pending_timer.timeout.connect(self._check_pending_url)
1163
+ self._pending_timer.start()
1164
+ QTimer.singleShot(200, self._check_pending_url)
1165
+ QTimer.singleShot(250, self._bring_to_front)
1166
+ QTimer.singleShot(600, self._bring_to_front)
1167
+ QTimer.singleShot(1200, self._bring_to_front)
1168
+
1169
+ def _center_on_screen(self):
1170
+ try:
1171
+ try:
1172
+ from PySide6.QtGui import QGuiApplication
1173
+ except ImportError:
1174
+ from PyQt6.QtGui import QGuiApplication # type: ignore
1175
+ screen = QGuiApplication.primaryScreen()
1176
+ if screen:
1177
+ geo = screen.availableGeometry()
1178
+ w = min(self.width(), geo.width())
1179
+ h = min(self.height(), geo.height())
1180
+ x = max(0, (geo.width() - w) // 2 + geo.x())
1181
+ y = max(0, (geo.height() - h) // 2 + geo.y())
1182
+ self.setGeometry(x, y, w, h)
1183
+ except Exception:
1184
+ pass
1185
+
1186
+ def _build_ui(self):
1187
+ c = _cat_colors()
1188
+ central = QWidget()
1189
+ self.setCentralWidget(central)
1190
+ layout = QVBoxLayout(central)
1191
+ layout.setContentsMargins(0, 0, 0, 0)
1192
+ layout.setSpacing(0)
1193
+
1194
+ # Browser — the ONLY content in this window
1195
+ try:
1196
+ init_url = getattr(self, "_pending_browser_url", None) or "about:home"
1197
+ self.browser = EmbeddedBrowserPane(initial_url=init_url)
1198
+ try:
1199
+ from .browser_manager import BrowserManager
1200
+ self.browser_manager = BrowserManager(self.browser)
1201
+ except Exception:
1202
+ self.browser_manager = None
1203
+ except Exception as e:
1204
+ self.browser = QLabel(f"Browser unavailable: {e}\nInstall: pip install PySide6")
1205
+ try:
1206
+ self.browser.setAlignment(Qt.AlignmentFlag.AlignCenter)
1207
+ except Exception:
1208
+ pass
1209
+ self.browser_manager = None
1210
+ layout.addWidget(self.browser)
1211
+
1212
+ def _setup_shortcuts(self):
1213
+ shortcuts = [
1214
+ ("F11", self.toggle_fullscreen),
1215
+ ("Ctrl+Q", self.close),
1216
+ ("Ctrl+L", self._focus_address),
1217
+ ("Alt+Left", self._browser_back),
1218
+ ("Alt+Right", self._browser_forward),
1219
+ ("Ctrl+R", self._browser_reload),
1220
+ ("Ctrl+T", self._new_tab),
1221
+ ]
1222
+ for seq, fn in shortcuts:
1223
+ try:
1224
+ sc = QShortcut(QKeySequence(seq), self)
1225
+ sc.activated.connect(fn) # type: ignore
1226
+ except Exception:
1227
+ pass
1228
+
1229
+ def navigate(self, url: str):
1230
+ if url and hasattr(self.browser, "navigate"):
1231
+ QTimer.singleShot(100, lambda: self.browser.navigate(url)) # type: ignore
1232
+
1233
+ def toggle_fullscreen(self):
1234
+ if self.isFullScreen():
1235
+ self.showNormal()
1236
+ else:
1237
+ self.showFullScreen()
1238
+
1239
+ def _focus_address(self):
1240
+ try:
1241
+ if hasattr(self.browser, "address_input"):
1242
+ self.browser.address_input.setFocus() # type: ignore
1243
+ self.browser.address_input.selectAll() # type: ignore
1244
+ except Exception:
1245
+ pass
1246
+
1247
+ def _browser_back(self):
1248
+ try:
1249
+ if hasattr(self.browser, "go_back"):
1250
+ self.browser.go_back() # type: ignore
1251
+ except Exception:
1252
+ pass
1253
+
1254
+ def _browser_forward(self):
1255
+ try:
1256
+ if hasattr(self.browser, "go_forward"):
1257
+ self.browser.go_forward() # type: ignore
1258
+ except Exception:
1259
+ pass
1260
+
1261
+ def _browser_reload(self):
1262
+ try:
1263
+ if hasattr(self.browser, "reload"):
1264
+ self.browser.reload() # type: ignore
1265
+ except Exception:
1266
+ pass
1267
+
1268
+ def _new_tab(self):
1269
+ try:
1270
+ if hasattr(self.browser, "new_tab"):
1271
+ self.browser.new_tab() # type: ignore
1272
+ except Exception:
1273
+ pass
1274
+
1275
+ def _bring_to_front(self):
1276
+ try:
1277
+ state = self.windowState()
1278
+ try:
1279
+ from PySide6.QtCore import Qt as _Qt
1280
+ except ImportError:
1281
+ from PyQt6.QtCore import Qt as _Qt # type: ignore
1282
+ if state & _Qt.WindowState.WindowMinimized:
1283
+ self.setWindowState(state & ~_Qt.WindowState.WindowMinimized | _Qt.WindowState.WindowActive)
1284
+ self.show()
1285
+ self.raise_()
1286
+ self.activateWindow()
1287
+ if sys.platform == "win32":
1288
+ try:
1289
+ import ctypes
1290
+ hwnd = int(self.winId()) if hasattr(self, "winId") else 0
1291
+ if hwnd > 0:
1292
+ user32 = ctypes.windll.user32
1293
+ kernel32 = ctypes.windll.kernel32
1294
+ user32.ShowWindow(hwnd, 9) # SW_RESTORE
1295
+ fg_hwnd = user32.GetForegroundWindow()
1296
+ fg_thread = user32.GetWindowThreadProcessId(fg_hwnd, None) if fg_hwnd else 0
1297
+ cur_thread = kernel32.GetCurrentThreadId()
1298
+ attached = False
1299
+ if fg_thread and fg_thread != cur_thread:
1300
+ attached = bool(user32.AttachThreadInput(cur_thread, fg_thread, True))
1301
+ swp_flags = 0x0001 | 0x0002 | 0x0040 # SWP_NOSIZE | SWP_NOMOVE | SWP_SHOWWINDOW
1302
+ user32.SetWindowPos(hwnd, -1, 0, 0, 0, 0, swp_flags) # HWND_TOPMOST
1303
+ user32.SetWindowPos(hwnd, -2, 0, 0, 0, 0, swp_flags) # HWND_NOTOPMOST
1304
+ user32.BringWindowToTop(hwnd)
1305
+ user32.SetForegroundWindow(hwnd)
1306
+ if attached:
1307
+ user32.AttachThreadInput(cur_thread, fg_thread, False)
1308
+ except Exception:
1309
+ pass
1310
+ except Exception:
1311
+ pass
1312
+
1313
+ def _check_pending_url(self):
1314
+ try:
1315
+ import json, time, os
1316
+ from pathlib import Path as _P
1317
+ # Heartbeat with PID and HWND so launcher accurately tracks this window
1318
+ hb_path = _P.home() / ".cat_browser_heartbeat"
1319
+ try:
1320
+ hwnd = int(self.winId()) if hasattr(self, "winId") else 0
1321
+ hb_data = json.dumps({"pid": os.getpid(), "ts": time.time(), "hwnd": hwnd})
1322
+ hb_path.write_text(hb_data, encoding="utf-8")
1323
+ except Exception:
1324
+ pass
1325
+
1326
+ pending_path = _P.home() / ".cat_pending_verification.json"
1327
+ if pending_path.exists():
1328
+ try:
1329
+ raw = pending_path.read_text(encoding="utf-8").strip()
1330
+ if raw:
1331
+ data = json.loads(raw)
1332
+ url = data.get("url")
1333
+ ts = float(data.get("ts", 0))
1334
+ # Accept pending requests from the last 60 seconds
1335
+ if url and (time.time() - ts) < 60:
1336
+ try:
1337
+ pending_path.unlink(missing_ok=True)
1338
+ except Exception:
1339
+ pass
1340
+ self.navigate(url)
1341
+ self._bring_to_front()
1342
+ QTimer.singleShot(150, self._bring_to_front)
1343
+ QTimer.singleShot(400, self._bring_to_front)
1344
+ except Exception:
1345
+ pass
1346
+ except Exception:
1347
+ pass
1348
+
1349
+ def closeEvent(self, event):
1350
+ try:
1351
+ from pathlib import Path as _P
1352
+ (_P.home() / ".cat_browser_heartbeat").unlink(missing_ok=True)
1353
+ (_P.home() / ".cat_pending_verification.json").unlink(missing_ok=True)
1354
+ (_P.home() / ".cat_pending_verification.tmp").unlink(missing_ok=True)
1355
+ except Exception:
1356
+ pass
1357
+ try:
1358
+ if hasattr(self, "browser") and hasattr(self.browser, "_views"):
1359
+ for v in self.browser._views:
1360
+ if not v:
1361
+ continue
1362
+ try:
1363
+ if hasattr(v, "page") and callable(v.page):
1364
+ p = v.page()
1365
+ if p:
1366
+ p.deleteLater()
1367
+ except Exception:
1368
+ pass
1369
+ if hasattr(self.browser, "profile"):
1370
+ try:
1371
+ self.browser.profile.deleteLater()
1372
+ except Exception:
1373
+ pass
1374
+ except Exception:
1375
+ pass
1376
+ event.accept()
1377
+
1378
+ else:
1379
+ # Stub so import never crashes when Qt absent
1380
+ class CATDesktopWindow: # type: ignore
1381
+ def __init__(self, *a, **k):
1382
+ raise ImportError("Qt not installed — CAT Host requires PySide6 (pip install PySide6) or PyQt6")
1383
+
1384
+ class EmbeddedBrowserPane: # type: ignore
1385
+ def __init__(self, *a, **k):
1386
+ raise ImportError("Qt not installed")