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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- 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")
|