├── src └── bubble │ ├── about │ └── version.txt │ ├── assets │ ├── icons │ │ ├── .keep │ │ ├── grok.png │ │ ├── kimi.png │ │ ├── qwen.png │ │ ├── zai.png │ │ ├── claude.png │ │ ├── gemini.png │ │ ├── mistral.png │ │ ├── openai.png │ │ ├── deepseek.png │ │ └── perplexity.png │ ├── images │ │ └── Bubble.gif │ ├── vendor │ │ └── driver.js │ │ │ └── driver.min.css │ ├── homepage.js │ └── homepage.css │ ├── utils │ ├── __init__.py │ ├── login_items.py │ ├── suspend_policy.py │ └── webview_guard.py │ ├── logo │ ├── logo_black.png │ └── logo_white.png │ ├── models │ ├── __init__.py │ ├── platform_config.py │ └── ai_window.py │ ├── components │ ├── __init__.py │ ├── config_manager.py │ ├── navigation_controller.py │ ├── platform_manager.py │ └── homepage_manager.py │ ├── __init__.py │ ├── constants.py │ ├── launcher.py │ ├── i18n │ ├── strings_ko.json │ ├── strings_ja.json │ ├── strings_zh.json │ ├── __init__.py │ ├── strings_fr.json │ └── strings_en.json │ ├── health_checks.py │ └── main.py ├── requirements.txt ├── pyproject.toml ├── Bubble.py ├── LICENSE ├── .github └── workflows │ ├── release-macos.yml │ └── release.yml ├── .gitignore ├── tools ├── build_macos.sh └── round_and_build_icons.py └── setup.py /src/bubble/about/version.txt: -------------------------------------------------------------------------------- 1 | 0.4.1 2 | -------------------------------------------------------------------------------- /src/bubble/assets/icons/.keep: -------------------------------------------------------------------------------- 1 | placeholder 2 | -------------------------------------------------------------------------------- /src/bubble/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility helpers for Bubble. 3 | """ 4 | 5 | -------------------------------------------------------------------------------- /src/bubble/logo/logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/logo/logo_black.png -------------------------------------------------------------------------------- /src/bubble/logo/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/logo/logo_white.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/grok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/grok.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/kimi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/kimi.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/qwen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/qwen.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/zai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/zai.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/claude.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/claude.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/gemini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/gemini.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/mistral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/mistral.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/openai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/openai.png -------------------------------------------------------------------------------- /src/bubble/assets/images/Bubble.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/images/Bubble.gif -------------------------------------------------------------------------------- /src/bubble/assets/icons/deepseek.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/deepseek.png -------------------------------------------------------------------------------- /src/bubble/assets/icons/perplexity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suge8/Bubble/HEAD/src/bubble/assets/icons/perplexity.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyobjc 2 | pyobjc-framework-WebKit 3 | setuptools 4 | py2app ; platform_system == "Darwin" 5 | packaging>=24.2 6 | -------------------------------------------------------------------------------- /src/bubble/models/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 数据模型包 3 | 4 | 定义多窗口 AI 平台管理系统的数据结构,包括: 5 | - 平台配置模型 6 | - 窗口实例模型 7 | - 用户配置模型 8 | """ 9 | 10 | from .platform_config import PlatformConfig, AIServiceConfig 11 | from .ai_window import AIWindow, WindowState 12 | 13 | __all__ = [ 14 | 'PlatformConfig', 15 | 'AIServiceConfig', 16 | 'AIWindow', 17 | 'WindowState' 18 | ] 19 | -------------------------------------------------------------------------------- /src/bubble/components/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bubble 组件模块 3 | 4 | 该模块包含 Bubble 应用的各种组件,包括主页管理器、导航控制器、多窗口管理器等。 5 | """ 6 | 7 | from .homepage_manager import HomepageManager 8 | from .navigation_controller import NavigationController 9 | from .multiwindow_manager import MultiWindowManager 10 | from .platform_manager import PlatformManager 11 | 12 | __all__ = ['HomepageManager', 'NavigationController', 'MultiWindowManager', 'PlatformManager'] 13 | -------------------------------------------------------------------------------- /src/bubble/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bubble - macOS智能AI助手应用 3 | 4 | 支持多AI窗口并行使用的现代化AI助手 5 | 6 | -- Sai Praveen 7 | """ 8 | 9 | import os 10 | 11 | DIRECTORY = os.path.dirname(os.path.abspath(__file__)) 12 | ABOUT_DIR = os.path.join(DIRECTORY, "about") 13 | 14 | def _read_about_file(fname, default=""): 15 | """读取about目录下的配置文件""" 16 | try: 17 | with open(os.path.join(ABOUT_DIR, fname)) as f: 18 | return f.read().strip() 19 | except Exception: 20 | return default 21 | 22 | __version__ = _read_about_file("version.txt", "0.0.1") 23 | __author__ = _read_about_file("author.txt", "Sai Praveen") 24 | 25 | __all__ = ["main"] 26 | 27 | from .main import main 28 | 29 | # 允许通过 "from bubble import main" 导入 30 | -------------------------------------------------------------------------------- /src/bubble/constants.py: -------------------------------------------------------------------------------- 1 | # Apple libraries 2 | from Quartz import ( 3 | kCGEventFlagMaskAlternate, 4 | kCGEventFlagMaskCommand, 5 | kCGEventFlagMaskControl, 6 | kCGEventFlagMaskShift, 7 | ) 8 | 9 | # Main settings and constants for Bubble 10 | WEBSITE = "https://www.grok.com" 11 | LOGO_WHITE_PATH = "logo/logo_white.png" 12 | LOGO_BLACK_PATH = "logo/logo_black.png" 13 | FRAME_SAVE_NAME = "BubbleWindowFrame" 14 | APP_TITLE = "Bubble" 15 | PERMISSION_CHECK_EXIT = 1 16 | # 窗口圆角(更大更柔和) 17 | CORNER_RADIUS = 24.0 18 | DRAG_AREA_HEIGHT = 30 19 | STATUS_ITEM_CONTEXT = 1 20 | 21 | # Hotkey config: Command+G (key 5 is "g" in macOS virtual keycodes) 22 | LAUNCHER_TRIGGER_MASK = kCGEventFlagMaskCommand 23 | LAUNCHER_TRIGGER = { 24 | "flags": kCGEventFlagMaskCommand, 25 | "key": 5 # 'g' key 26 | } 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "bubble" 7 | version = "0.4.1" 8 | description = "macOS智能AI助手应用,支持多窗口AI并行使用" 9 | authors = [ 10 | {name = "Sai Praveen"} 11 | ] 12 | dependencies = [ 13 | "pyobjc", 14 | "pyobjc-framework-Quartz", 15 | "pyobjc-framework-WebKit", 16 | "setuptools", 17 | ] 18 | requires-python = ">=3.10" 19 | 20 | [project.optional-dependencies] 21 | dev = [ 22 | "pytest", 23 | "flake8", 24 | "black", 25 | "pylint", 26 | ] 27 | packaging = [ 28 | "py2app ; platform_system == 'Darwin'", 29 | ] 30 | 31 | [project.scripts] 32 | bubble = "bubble.main:main" 33 | 34 | [tool.setuptools.packages.find] 35 | where = ["src"] 36 | 37 | [tool.setuptools.package-dir] 38 | "" = "src" 39 | 40 | [tool.black] 41 | line-length = 88 42 | target-version = ['py310'] 43 | 44 | [tool.pytest.ini_options] 45 | testpaths = ["tests"] 46 | python_files = ["test_*.py"] 47 | python_classes = ["Test*"] 48 | python_functions = ["test_*"] 49 | -------------------------------------------------------------------------------- /Bubble.py: -------------------------------------------------------------------------------- 1 | """Launcher for both dev (source tree) and bundled app. 2 | 3 | Tries to import from installed/bundled package first, then falls back to 4 | adding ./src to sys.path for developer runs. 5 | 6 | Usage: 7 | python bubble.py # 正常启动 8 | python bubble.py --force-tour # 强制显示导览 9 | """ 10 | 11 | import os 12 | import sys 13 | 14 | # Avoid writing .pyc into the app bundle when packaged (keeps code signature stable). 15 | os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1") 16 | sys.dont_write_bytecode = True 17 | 18 | # 开发模式:优先使用源文件 19 | here = os.path.dirname(__file__) 20 | src_path = os.path.join(here, "src") 21 | if os.path.exists(src_path) and src_path not in sys.path: 22 | sys.path.insert(0, src_path) 23 | 24 | from bubble.main import main # type: ignore 25 | 26 | 27 | if __name__ == "__main__": 28 | # 解析命令行参数 29 | force_tour = "--force-tour" in sys.argv 30 | if force_tour: 31 | sys.argv.remove("--force-tour") 32 | os.environ["BUBBLE_FORCE_TOUR"] = "1" 33 | print("DEBUG: BUBBLE_FORCE_TOUR 环境变量已设置为 1") 34 | main() 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Suge8 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release-macos.yml: -------------------------------------------------------------------------------- 1 | name: release-macos 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.12' 17 | - name: Install build deps 18 | run: | 19 | python -m pip install --upgrade pip wheel setuptools packaging 20 | python -m pip install -r requirements.txt 21 | - name: Build app 22 | env: 23 | BUBBLE_FORCE_FALLBACK_LAUNCHER: "1" 24 | run: | 25 | python setup.py py2app -q 26 | - name: Zip artifact 27 | run: | 28 | vers=$(python - <<'PY' 29 | import tomllib 30 | print(tomllib.load(open('pyproject.toml','rb'))['project']['version']) 31 | PY 32 | ) 33 | ditto -c -k --sequesterRsrc --keepParent dist/Bubble.app "dist/Bubble-v${vers}.zip" 34 | shasum -a 256 "dist/Bubble-v${vers}.zip" | awk '{print $1}' > "dist/Bubble-v${vers}.zip.sha256" 35 | - name: Upload Release 36 | uses: softprops/action-gh-release@v2 37 | with: 38 | files: | 39 | dist/Bubble-v*.zip 40 | dist/Bubble-v*.zip.sha256 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release macOS App 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-and-release: 11 | runs-on: macos-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.12' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install -r requirements.txt 27 | 28 | - name: Build .app via py2app 29 | run: | 30 | python setup.py py2app -q 31 | 32 | - name: Zip app bundle 33 | id: zip 34 | run: | 35 | VER=${GITHUB_REF_NAME#v} 36 | ZIP="Bubble-${VER}-macOS.zip" 37 | cd dist 38 | /usr/bin/zip -ry "${ZIP}" Bubble.app 39 | echo "zip_name=${ZIP}" >> $GITHUB_OUTPUT 40 | 41 | - name: Create GitHub Release 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | tag_name: ${{ github.ref_name }} 45 | name: Bubble ${{ github.ref_name }} 46 | body_path: RELEASE_NOTES.md 47 | files: | 48 | dist/${{ steps.zip.outputs.zip_name }} 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################################# 2 | # Environments 3 | ############################################# 4 | .venv/ 5 | venv/ 6 | env/ 7 | ENV/ 8 | 9 | ############################################# 10 | # Python caches & tooling 11 | ############################################# 12 | __pycache__/ 13 | *.py[cod] 14 | .pytest_cache/ 15 | .mypy_cache/ 16 | .ropeproject/ 17 | .coverage 18 | coverage.xml 19 | htmlcov/ 20 | 21 | ############################################# 22 | # Packaging / build artifacts 23 | ############################################# 24 | build/ 25 | dist/ 26 | *.app 27 | *.dmg 28 | *.spec 29 | .eggs/ 30 | *.egg 31 | *.egg-info/ 32 | src/bubblebot.egg-info/ 33 | 34 | ############################################# 35 | # Editors & local tooling 36 | ############################################# 37 | .idea/ 38 | .vscode/ 39 | .aim/ 40 | .claude/ 41 | .spec-workflow/ 42 | AGENTS.md 43 | 44 | ############################################# 45 | # Local-only tests 46 | ############################################# 47 | /tests/ 48 | 49 | ############################################# 50 | # macOS & temporary files 51 | ############################################# 52 | .DS_Store 53 | *.log 54 | *.tmp 55 | tmp.* 56 | .tmp* 57 | */tmp.* 58 | tmp_*/ 59 | *.icns 60 | *.iconset/ 61 | 62 | ############################################# 63 | # App-specific temporary assets 64 | ############################################# 65 | test.iconset/ 66 | tmp.iconset/ 67 | tmp_icon_extracted/ 68 | tmp_old_scheme.iconset/ 69 | tmp.AppIcon.iconset/ 70 | tmp.check.iconset/ 71 | tmp.squircle.verify.iconset/ 72 | tmp.verify.iconset/ 73 | 74 | ############################################# 75 | # Marketing materials 76 | ############################################# 77 | marketing/ 78 | -------------------------------------------------------------------------------- /tools/build_macos.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # macOS Bubble: one-click build script 5 | # 6 | # Usage: 7 | # tools/build_macos.sh # clean build, package, zip, checksum 8 | # tools/build_macos.sh --no-clean # keep existing build/dist 9 | # tools/build_macos.sh --install-deps # pip install -r requirements.txt first 10 | # tools/build_macos.sh --smoke # run a brief launch smoke-test (then kill) 11 | 12 | ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" 13 | # Prefer the active virtualenv's Python, fallback to python3 on PATH 14 | if [[ -n "${VIRTUAL_ENV:-}" && -x "$VIRTUAL_ENV/bin/python" ]]; then 15 | PY="$VIRTUAL_ENV/bin/python" 16 | else 17 | PY="$(command -v python3 || true)" 18 | fi 19 | if [[ -z "$PY" ]]; then 20 | echo "[!] Could not find a Python interpreter. Activate a venv or install Python 3." >&2 21 | exit 1 22 | fi 23 | echo "[*] Using Python: $PY" 24 | APP_NAME="Bubble" 25 | DIST_DIR="$ROOT_DIR/dist" 26 | BUILD_DIR="$ROOT_DIR/build" 27 | APP_PATH="$DIST_DIR/${APP_NAME}.app" 28 | # Read version from pyproject.toml (fallback to 0.0.0) 29 | VERSION=$("$PY" - << 'PY' 30 | import sys, tomllib 31 | try: 32 | with open('pyproject.toml','rb') as f: 33 | print(tomllib.load(f)['project']['version']) 34 | except Exception: 35 | print('0.0.0') 36 | PY 37 | ) 38 | ZIP_PATH="$DIST_DIR/${APP_NAME}-v${VERSION}.zip" 39 | 40 | CLEAN=1 41 | INSTALL_DEPS=0 42 | SMOKE=0 43 | for arg in "$@"; do 44 | case "$arg" in 45 | --no-clean) CLEAN=0 ;; 46 | --install-deps) INSTALL_DEPS=1 ;; 47 | --smoke) SMOKE=1 ;; 48 | *) echo "Unknown option: $arg" >&2; exit 2 ;; 49 | esac 50 | done 51 | 52 | cd "$ROOT_DIR" 53 | 54 | if [[ "$INSTALL_DEPS" == "1" ]]; then 55 | echo "[*] Installing Python build dependencies..." 56 | "$PY" -m pip install --upgrade pip wheel setuptools packaging >/dev/null 57 | "$PY" -m pip install -r requirements.txt 58 | fi 59 | 60 | if [[ "$CLEAN" == "1" ]]; then 61 | echo "[*] Cleaning previous build..." 62 | rm -rf "$BUILD_DIR" "$DIST_DIR" 63 | fi 64 | 65 | echo "[*] Building .app via py2app..." 66 | "$PY" setup.py py2app -O2 67 | 68 | if [[ ! -d "$APP_PATH" ]]; then 69 | echo "[!] Build failed (no $APP_PATH)" >&2 70 | exit 1 71 | fi 72 | 73 | echo "[*] Creating zip artifact..." 74 | ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_PATH" 75 | shasum -a 256 "$ZIP_PATH" | awk '{print $1}' > "$ZIP_PATH.sha256" 76 | 77 | echo "[*] Built: $APP_PATH" 78 | echo "[*] Zip: $ZIP_PATH" 79 | echo -n "[*] SHA256: " && cat "$ZIP_PATH.sha256" 80 | 81 | if [[ "$SMOKE" == "1" ]]; then 82 | echo "[*] Running smoke test (launch & kill after 4s)..." 83 | ("$APP_PATH"/Contents/MacOS/$APP_NAME >/tmp/bubble-smoke.log 2>&1 & echo $! > /tmp/bubble-smoke.pid) 84 | sleep 4 85 | if pkill -f "$APP_PATH/Contents/MacOS/$APP_NAME"; then 86 | echo "[*] Smoke test done. Logs: /tmp/bubble-smoke.log" 87 | else 88 | echo "[!] Smoke test could not kill process (it may have quit). Check /tmp/bubble-smoke.log" 89 | fi 90 | fi 91 | 92 | echo "[*] Done." 93 | -------------------------------------------------------------------------------- /src/bubble/launcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | launcher.py 3 | Startup/permission utilities for Bubble - the universal Mac AI overlay. 4 | """ 5 | 6 | # Python libraries. 7 | import getpass 8 | import os 9 | import subprocess 10 | import sys 11 | import time 12 | from pathlib import Path 13 | 14 | # Apple libraries. 15 | import plistlib 16 | from Foundation import NSDictionary 17 | from ApplicationServices import AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt 18 | 19 | # Local libraries 20 | from .constants import APP_TITLE 21 | from .health_checks import reset_crash_counter 22 | 23 | # --- App Path Utilities --- 24 | 25 | def get_executable(): 26 | """ 27 | Return the appropriate executable/program_args list for LaunchAgent or CLI usage. 28 | """ 29 | if getattr(sys, "frozen", False): # Running from a py2app bundle 30 | assert (".app" in sys.argv[0]), f"Expected .app in sys.argv[0], got {sys.argv[0]}" 31 | # Find the .app bundle path 32 | app_path = sys.argv[0] 33 | while not app_path.endswith(".app"): 34 | app_path = os.path.dirname(app_path) 35 | # Main binary inside .app/Contents/MacOS/Bubble 36 | executable = os.path.join(app_path, "Contents", "MacOS", APP_TITLE) 37 | program_args = [executable] 38 | else: # Running from source (pip install or python -m ...) 39 | # Use the Python package name, not the display title 40 | program_args = [sys.executable, "-m", "bubble"] 41 | return program_args 42 | 43 | # --- LaunchAgent (Autolauncher) removed in 3.1 --- 44 | 45 | # --- Accessibility Permissions --- 46 | 47 | def check_permissions(ask=True): 48 | """ 49 | Check (and optionally prompt for) macOS Accessibility permissions. 50 | """ 51 | print(f"\nChecking macOS Accessibility permissions for Bubble. If not already granted, a dialog may appear.\n", flush=True) 52 | options = NSDictionary.dictionaryWithObject_forKey_( 53 | True, kAXTrustedCheckOptionPrompt 54 | ) 55 | is_trusted = AXIsProcessTrustedWithOptions(options if ask else None) 56 | return is_trusted 57 | 58 | def get_updated_permission_status(): 59 | """ 60 | Check current permission status by spawning a subprocess. 61 | """ 62 | result = subprocess.run( 63 | get_executable() + ["--check-permissions"], 64 | capture_output=True, 65 | text=True 66 | ) 67 | return result.returncode == 0 68 | 69 | def wait_for_permissions(max_wait_sec=60, wait_interval_sec=5): 70 | """ 71 | Poll for permissions for up to max_wait_sec seconds. 72 | """ 73 | elapsed = 0 74 | while elapsed < max_wait_sec: 75 | if get_updated_permission_status(): 76 | return True 77 | time.sleep(wait_interval_sec) 78 | elapsed += wait_interval_sec 79 | reset_crash_counter() 80 | return False 81 | 82 | def ensure_accessibility_permissions(): 83 | """ 84 | Ensure Accessibility permissions are granted; otherwise, exit/uninstall. 85 | """ 86 | if check_permissions(): # Initial call to prompt 87 | return 88 | if wait_for_permissions(): 89 | print("Permissions granted! Exiting for auto-restart...") 90 | return 91 | else: 92 | print("Permissions NOT granted within time limit.") 93 | -------------------------------------------------------------------------------- /src/bubble/assets/vendor/driver.js/driver.min.css: -------------------------------------------------------------------------------- 1 | div#driver-popover-item{display:none;position:absolute;background:#fff;color:#2d2d2d;margin:0;padding:15px;border-radius:5px;min-width:250px;max-width:300px;box-shadow:0 1px 10px rgba(0,0,0,.4);z-index:1000000000}div#driver-popover-item .driver-popover-tip{border:5px solid #fff;content:"";position:absolute}div#driver-popover-item .driver-popover-tip.bottom{bottom:-10px;border-color:#fff transparent transparent}div#driver-popover-item .driver-popover-tip.bottom.position-center{left:49%}div#driver-popover-item .driver-popover-tip.bottom.position-right{right:20px}div#driver-popover-item .driver-popover-tip.left{left:-10px;top:10px;border-color:transparent #fff transparent transparent}div#driver-popover-item .driver-popover-tip.left.position-center{top:46%}div#driver-popover-item .driver-popover-tip.left.position-bottom{top:auto;bottom:20px}div#driver-popover-item .driver-popover-tip.right{right:-10px;top:10px;border-color:transparent transparent transparent #fff}div#driver-popover-item .driver-popover-tip.right.position-center{top:46%}div#driver-popover-item .driver-popover-tip.right.position-bottom{top:auto;bottom:20px}div#driver-popover-item .driver-popover-tip.top{top:-10px;border-color:transparent transparent #fff}div#driver-popover-item .driver-popover-tip.top.position-center{left:49%}div#driver-popover-item .driver-popover-tip.top.position-right{right:20px}div#driver-popover-item .driver-popover-tip.mid-center{display:none}div#driver-popover-item .driver-popover-footer{display:block;margin-top:10px}div#driver-popover-item .driver-popover-footer button{display:inline-block;padding:3px 10px;border:1px solid #d4d4d4;text-decoration:none;text-shadow:1px 1px 0 #fff;color:#2d2d2d;font:11px/normal sans-serif;cursor:pointer;outline:0;background-color:#f1f1f1;border-radius:2px;zoom:1;line-height:1.3}div#driver-popover-item .driver-popover-footer button.driver-disabled{color:grey;cursor:default;pointer-events:none}div#driver-popover-item .driver-popover-footer .driver-close-btn{float:left}div#driver-popover-item .driver-popover-footer .driver-btn-group,div#driver-popover-item .driver-popover-footer .driver-close-only-btn{float:right}div#driver-popover-item .driver-popover-title{font:19px/normal sans-serif;margin:0 0 5px;font-weight:700;display:block;position:relative;line-height:1.5;zoom:1}div#driver-popover-item .driver-popover-description{margin-bottom:0;font:14px/normal sans-serif;line-height:1.5;color:#2d2d2d;font-weight:400;zoom:1}.driver-clearfix:after,.driver-clearfix:before{content:"";display:table}.driver-clearfix:after{clear:both}.driver-stage-no-animation{-webkit-transition:none!important;-moz-transition:none!important;-ms-transition:none!important;-o-transition:none!important;transition:none!important;background:transparent!important;outline:5000px solid rgba(0,0,0,.75)}div#driver-page-overlay{background:#000;position:fixed;bottom:0;right:0;display:block;width:100%;height:100%;zoom:1;filter:alpha(opacity=75);opacity:.75;z-index:100002!important}div#driver-highlighted-element-stage,div#driver-page-overlay{top:0;left:0;-webkit-transition:all .3s;-moz-transition:all .3s;-ms-transition:all .3s;-o-transition:all .3s;transition:all .3s}div#driver-highlighted-element-stage{position:absolute;height:50px;width:300px;background:#fff;z-index:100003!important;display:none;border-radius:2px}.driver-highlighted-element{z-index:100004!important}.driver-position-relative{position:relative!important}.driver-fix-stacking{z-index:auto!important;opacity:1!important;-webkit-transform:none!important;-moz-transform:none!important;-ms-transform:none!important;-o-transform:none!important;transform:none!important;-webkit-filter:none!important;-moz-filter:none!important;-ms-filter:none!important;-o-filter:none!important;filter:none!important;-webkit-perspective:none!important;-moz-perspective:none!important;-ms-perspective:none!important;-o-perspective:none!important;perspective:none!important;-webkit-transform-style:flat!important;-moz-transform-style:flat!important;-ms-transform-style:flat!important;transform-style:flat!important;-webkit-transform-box:border-box!important;-moz-transform-box:border-box!important;-ms-transform-box:border-box!important;-o-transform-box:border-box!important;transform-box:border-box!important;will-change:unset!important} -------------------------------------------------------------------------------- /src/bubble/i18n/strings_ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "app.name": "Bubble", 3 | "menu.settings": "설정…", 4 | "menu.show": "표시", 5 | "menu.hide": "숨기기", 6 | "menu.home": "홈", 7 | "menu.clearCache": "웹 캐시 지우기", 8 | "menu.setNewTrigger": "새 단축키 설정", 9 | "menu.quit": "종료", 10 | "menu.showHideHint": "{hotkey} 를 눌러 표시/숨기기", 11 | "menu.switchHint": "{hotkey} 를 눌러 창 전환", 12 | "bubble.tooManyWindows": "창이 너무 많으면 메모리를 사용할 수 있습니다", 13 | "bubble.neverShow": "다시 보지 않기", 14 | "notice.configMigrated": "설정이 새 Bubble 폴더로 이동되었습니다. 백업을 보관했습니다.", 15 | "home.title": "Bubble", 16 | "home.welcome": "Bubble 에 오신 것을 환영합니다", 17 | "button.open": "열기", 18 | "button.add": "추가", 19 | "button.remove": "제거", 20 | "button.setDefault": "기본값으로 설정", 21 | "button.newWindow": "새 창", 22 | "button.change": "변경", 23 | "button.save": "저장", 24 | "button.cancel": "취소", 25 | "hotkey.overlay.title": "단축키 설정", 26 | "hotkey.overlay.subtitle": "하나 이상의 수정 키를 포함한 조합을 누르세요", 27 | "hotkey.overlay.subtitle.launcher": "표시/숨기기", 28 | "hotkey.overlay.subtitle.switcher": "창 전환", 29 | "hotkey.overlay.wait": "키 입력 대기 중…", 30 | "hotkey.overlay.requireModifier": "수정 키(⌘/⌥/⌃/⇧)를 하나 이상 포함하세요", 31 | "hotkey.overlay.modifierHint": "⌘ ⌥ ⌃ ⇧ 중 하나 이상 포함", 32 | "hotkey.overlay.selectType": "변경할 단축키 선택", 33 | "hotkey.overlay.listening.launcher": "새 표시/숨기기 키를 누르세요...", 34 | "hotkey.overlay.listening.switcher": "새 창 전환 키를 누르세요...", 35 | "hotkey.overlay.hint": "⌘ ⌥ ⌃ ⇧ 포함", 36 | "hotkey.overlay.saved": "단축키가 저장되었습니다!", 37 | "label.default": "기본", 38 | "status.loading": "불러오는 중…", 39 | "tooltip.back": "뒤로", 40 | "nav.home": "홈", 41 | "nav.chat": "채팅", 42 | "platform.openai": "ChatGPT", 43 | "platform.gemini": "Gemini", 44 | "platform.grok": "Grok", 45 | "platform.claude": "Claude", 46 | "platform.deepseek": "DeepSeek", 47 | "platform.zai": "GLM", 48 | "platform.qwen": "Qwen", 49 | "platform.kimi": "Kimi", 50 | "platform.mistral": "MISTRAL", 51 | "platform.perplexity": "PERPLEXITY", 52 | "platform.desc.openai": "범용 어시스턴트, 풍부한 생태계", 53 | "platform.desc.gemini": "멀티모달 이해 및 생성", 54 | "platform.desc.grok": "실시간 정보와 재치", 55 | "platform.desc.claude": "긴 컨텍스트와 안전성", 56 | "platform.desc.deepseek": "가성비와 중문 친화", 57 | "platform.desc.zai": "중문 이해와 추론", 58 | "platform.desc.qwen": "중문 + 도구 호출", 59 | "platform.desc.mistral": "가볍고 빠름", 60 | "platform.desc.perplexity": "검색 강화 답변", 61 | "platform.desc.kimi": "장문서 읽기 및 요약", 62 | "ai.manage.title": "AI 플랫폼 관리", 63 | "ai.windows.activeCount": "활성 창 {count}개", 64 | "stats.enabledPlatforms": "활성화된 플랫폼", 65 | "stats.activeWindows": "활성 창", 66 | "stats.maxWindows": "최대 창 수", 67 | "section.enabled": "활성화된 AI 플랫폼", 68 | "section.addMore": "플랫폼 추가", 69 | "home.noMorePlatformsShort": "제한에 도달했거나 모두 활성화됨", 70 | "home.noMorePlatformsLong": "최대 플랫폼 수에 도달했거나 모든 플랫폼이 활성화되었습니다", 71 | "settings.title": "설정", 72 | "settings.language": "언어", 73 | "settings.launchAtLogin": "로그인 시 실행", 74 | "settings.hotkey": "단축키", 75 | "settings.hotkeySwitch": "창 전환", 76 | "settings.suspendTime": "절전 시간", 77 | "settings.suspendOff": "절전 안 함", 78 | "settings.clearCache": "웹 캐시 지우기", 79 | "settings.suspendTimeUpdated": "절전 시간이 업데이트되었습니다", 80 | "hotkey.overlay.switcher": "창 전환", 81 | "hotkey.overlay.launcher": "표시/숨기기", 82 | "hotkey.desc.launcher": "표시/숨기기", 83 | "hotkey.desc.switcher": "창 전환", 84 | "error.navigationTitle": "로딩 실패", 85 | "button.retry": "재시도", 86 | "button.back": "뒤로", 87 | "error.navigationFailed": "네트워크 오류 또는 페이지를 불러올 수 없습니다.", 88 | "error.navigationHint": "연결을 확인하고 재시도를 클릭하세요.", 89 | "onboard.permTitle": "손쉬운 사용 및 마이크 활성화", 90 | "onboard.permBody": "단축키에는 손쉬운 사용, 음성 입력에는 마이크 권한이 필요합니다. '허용'을 눌러 요청하세요.", 91 | "button.grant": "허용", 92 | "button.later": "나중에", 93 | "onboard.title": "Bubble에 오신 것을 환영합니다", 94 | "onboard.body": "팁: 설정에서 표시/숨기기 및 페이지 전환 단축키를 설정하고 절전 시간을 조정하세요.", 95 | "button.settings": "설정", 96 | "button.gotIt": "확인", 97 | "tour.back": "↩︎ 홈으로 돌아가기", 98 | "tour.back.desc": "플랫폼 목록으로 돌아가요", 99 | "tour.step1.title": "✨ 첫 AI 추가", 100 | "tour.step1.desc": "카드를 눌러 활성화하세요", 101 | "tour.step2.title": "🧭 채팅 창 열기", 102 | "tour.step2.desc": "상단 드롭다운에서 선택하세요", 103 | "tour.practice.title": "⌘ 단축키", 104 | "tour.practice": "“{hotkey}”를 두 번 눌러 숨기기/표시", 105 | "tour.done.title": "✓ 준비 완료", 106 | "tour.done": "이제 바로 시작하세요", 107 | "settings.clearCacheDone": "웹 캐시가 삭제되었습니다", 108 | "errors.unsupportedPlatform": "지원되지 않는 플랫폼" 109 | } 110 | -------------------------------------------------------------------------------- /src/bubble/i18n/strings_ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "app.name": "Bubble", 3 | "menu.settings": "設定…", 4 | "menu.show": "表示", 5 | "menu.hide": "非表示", 6 | "menu.home": "ホーム", 7 | "menu.clearCache": "Web キャッシュを消去", 8 | "menu.setNewTrigger": "新しいショートカットを設定", 9 | "menu.quit": "終了", 10 | "menu.showHideHint": "{hotkey} を押して表示/非表示", 11 | "menu.switchHint": "{hotkey} でウィンドウを切り替え", 12 | "bubble.tooManyWindows": "ウィンドウが多すぎるとメモリを消費する可能性があります", 13 | "bubble.neverShow": "今後表示しない", 14 | "toast.tooManyPages": "ページが多すぎるとメモリ使用量が増える可能性があります", 15 | "toast.added": "追加しました", 16 | "toast.removed": "削除しました", 17 | "toast.failed": "操作に失敗しました", 18 | "notice.configMigrated": "設定は新しい Bubble フォルダに移行されました。バックアップを保持しました。", 19 | "home.title": "Bubble", 20 | "home.welcome": "ようこそ Bubble へ", 21 | "button.open": "開く", 22 | "button.add": "追加", 23 | "button.remove": "削除", 24 | "button.setDefault": "デフォルトに設定", 25 | "button.newWindow": "新しいウィンドウ", 26 | "button.change": "変更", 27 | "button.save": "保存", 28 | "button.cancel": "キャンセル", 29 | "hotkey.overlay.title": "ショートカットを設定", 30 | "hotkey.overlay.subtitle": "修飾キーを1つ以上含む組み合わせを押してください", 31 | "hotkey.overlay.subtitle.launcher": "表示/非表示", 32 | "hotkey.overlay.subtitle.switcher": "ウィンドウ切り替え", 33 | "hotkey.overlay.wait": "キー入力を待機中…", 34 | "hotkey.overlay.requireModifier": "修飾キー(⌘/⌥/⌃/⇧)を1つ以上含めてください", 35 | "hotkey.overlay.modifierHint": "⌘ ⌥ ⌃ ⇧ のいずれかを含めてください", 36 | "hotkey.overlay.selectType": "変更するショートカットを選択", 37 | "hotkey.overlay.listening.launcher": "新しい表示/非表示キーを押してください...", 38 | "hotkey.overlay.listening.switcher": "新しいウィンドウ切替キーを押してください...", 39 | "hotkey.overlay.hint": "⌘ ⌥ ⌃ ⇧ を含める", 40 | "hotkey.overlay.saved": "ショートカットを保存しました!", 41 | "label.default": "デフォルト", 42 | "status.loading": "読み込み中…", 43 | "tooltip.back": "戻る", 44 | "nav.home": "ホーム", 45 | "nav.chat": "チャット", 46 | "platform.openai": "ChatGPT", 47 | "platform.gemini": "Gemini", 48 | "platform.grok": "Grok", 49 | "platform.claude": "Claude", 50 | "platform.deepseek": "DeepSeek", 51 | "platform.zai": "GLM", 52 | "platform.qwen": "Qwen", 53 | "platform.kimi": "Kimi", 54 | "platform.mistral": "MISTRAL", 55 | "platform.perplexity": "PERPLEXITY", 56 | "platform.desc.openai": "汎用アシスタント、豊富なエコシステム", 57 | "platform.desc.gemini": "マルチモーダル理解と生成", 58 | "platform.desc.grok": "リアルタイム情報とウィット", 59 | "platform.desc.claude": "長文コンテキストと安全性", 60 | "platform.desc.deepseek": "高コスパと中国語対応", 61 | "platform.desc.zai": "中国語の理解と推論", 62 | "platform.desc.qwen": "中国語 + ツール連携", 63 | "platform.desc.mistral": "軽量で高速", 64 | "platform.desc.perplexity": "検索拡張の回答", 65 | "platform.desc.kimi": "長文ドキュメントの読解と要約", 66 | "ai.manage.title": "AI プラットフォーム管理", 67 | "ai.windows.activeCount": "アクティブなウィンドウ {count} 件", 68 | "stats.enabledPlatforms": "有効なプラットフォーム", 69 | "stats.activeWindows": "アクティブなウィンドウ", 70 | "stats.maxWindows": "最大ウィンドウ数", 71 | "section.enabled": "有効な AI プラットフォーム", 72 | "section.addMore": "プラットフォームを追加", 73 | "home.noMorePlatformsShort": "上限に達したか、すべて有効です", 74 | "home.noMorePlatformsLong": "最大プラットフォーム数に達したか、すべてが有効です", 75 | "settings.title": "設定", 76 | "settings.language": "言語", 77 | "settings.launchAtLogin": "ログイン時に起動", 78 | "settings.hotkey": "ホットキー", 79 | "settings.hotkeySwitch": "ウィンドウを切り替え", 80 | "settings.suspendTime": "スリープ時間", 81 | "settings.suspendOff": "スリープしない", 82 | "settings.clearCache": "Web キャッシュを消去", 83 | "settings.suspendTimeUpdated": "スリープ時間を更新しました", 84 | "hotkey.overlay.switcher": "ウィンドウを切り替え", 85 | "hotkey.overlay.launcher": "表示/非表示", 86 | "hotkey.desc.launcher": "表示/非表示", 87 | "hotkey.desc.switcher": "ウィンドウを切り替え", 88 | "error.navigationTitle": "読み込みに失敗しました", 89 | "button.retry": "再試行", 90 | "button.back": "戻る", 91 | "error.navigationFailed": "ネットワークエラーまたはページを読み込めませんでした。", 92 | "error.navigationHint": "接続を確認して「再試行」をクリックしてください。", 93 | "onboard.permTitle": "アクセシビリティとマイクを有効化", 94 | "onboard.permBody": "ホットキーにはアクセシビリティ、音声入力にはマイク権限が必要です。「許可」をクリックして要求します。", 95 | "button.grant": "許可", 96 | "button.later": "後で", 97 | "onboard.title": "Bubble へようこそ", 98 | "onboard.body": "ヒント: 設定で「表示/非表示」と「ページ切替」のショートカット、およびスリープ時間を調整できます。", 99 | "button.settings": "設定", 100 | "button.gotIt": "了解", 101 | "tour.back": "↩︎ ホームへ戻る", 102 | "tour.back.desc": "プラットフォーム一覧に戻ります", 103 | "tour.step1.title": "✨ 最初のAIを追加", 104 | "tour.step1.desc": "カードを選んで有効化", 105 | "tour.step2.title": "🧭 チャットを開く", 106 | "tour.step2.desc": "上部メニューから選択", 107 | "tour.practice.title": "⌘ ショートカット", 108 | "tour.practice": "「{hotkey}」を2回押して表示/非表示", 109 | "tour.done.title": "✓ 完了", 110 | "tour.done": "準備完了。始めましょう", 111 | "settings.clearCacheDone": "Webキャッシュを消去しました", 112 | "errors.unsupportedPlatform": "未対応のプラットフォーム" 113 | } 114 | -------------------------------------------------------------------------------- /src/bubble/i18n/strings_zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "app.name": "Bubble", 3 | "menu.settings": "设置…", 4 | "menu.show": "显示", 5 | "menu.hide": "隐藏", 6 | "menu.home": "主页", 7 | "menu.clearCache": "清除浏览器缓存", 8 | "menu.setNewTrigger": "设置新快捷键", 9 | "menu.quit": "退出", 10 | "menu.showHideHint": "按 {hotkey} 显示/隐藏", 11 | "menu.switchHint": "按 {hotkey} 切换窗口", 12 | "bubble.tooManyWindows": "窗口过多可能占用内存", 13 | "bubble.neverShow": "不再提示", 14 | "toast.tooManyPages": "页面较多可能占用内存导致卡顿", 15 | "toast.added": "已添加", 16 | "toast.removed": "已删除", 17 | "toast.failed": "操作失败", 18 | "notice.configMigrated": "设置已迁移到新的 Bubble 文件夹,已保留备份。", 19 | "home.title": "Bubble", 20 | "home.welcome": "欢迎使用 Bubble", 21 | "button.open": "打开", 22 | "button.add": "添加", 23 | "button.remove": "移除", 24 | "button.setDefault": "设为默认", 25 | "button.newWindow": "新页面", 26 | "button.change": "更改", 27 | "button.save": "保存", 28 | "button.cancel": "取消", 29 | "hotkey.overlay.title": "设置快捷键", 30 | "hotkey.overlay.subtitle": "⌨️ 按下包含修饰键的组合", 31 | "hotkey.overlay.subtitle.launcher": "显示/隐藏", 32 | "hotkey.overlay.subtitle.switcher": "切换窗口", 33 | "hotkey.overlay.wait": "等待按键…", 34 | "hotkey.overlay.requireModifier": "请按包含修饰键(⌘/⌥/⌃/⇧)的组合", 35 | "hotkey.overlay.modifierHint": "需要包括 ⌘ ⌥ ⌃ ⇧ 中至少一个", 36 | "hotkey.overlay.selectType": "选择要修改的快捷键", 37 | "hotkey.overlay.listening.launcher": "请按下新的显示/隐藏快捷键...", 38 | "hotkey.overlay.listening.switcher": "请按下新的切换窗口快捷键...", 39 | "hotkey.overlay.hint": "需要包含 ⌘ ⌥ ⌃ ⇧", 40 | "hotkey.overlay.saved": "快捷键已保存!", 41 | "label.default": "默认", 42 | "status.loading": "加载中…", 43 | "tooltip.back": "返回", 44 | "nav.home": "主页", 45 | "nav.chat": "聊天", 46 | "platform.openai": "ChatGPT", 47 | "platform.gemini": "Gemini", 48 | "platform.grok": "Grok", 49 | "platform.claude": "Claude", 50 | "platform.deepseek": "DeepSeek", 51 | "platform.zai": "GLM", 52 | "platform.qwen": "通义千问", 53 | "platform.kimi": "Kimi", 54 | "platform.mistral": "MISTRAL", 55 | "platform.perplexity": "PERPLEXITY", 56 | "platform.desc.openai": "通用对话,生态丰富", 57 | "platform.desc.gemini": "多模态理解与生成", 58 | "platform.desc.grok": "实时信息与风趣回复", 59 | "platform.desc.claude": "长文本与安全对话", 60 | "platform.desc.deepseek": "高性价比与中文友好", 61 | "platform.desc.zai": "中文理解与推理", 62 | "platform.desc.qwen": "中文与工具调用", 63 | "platform.desc.mistral": "轻量快速与高效", 64 | "platform.desc.perplexity": "搜索增强问答", 65 | "platform.desc.kimi": "长文档阅读与总结", 66 | "ai.manage.title": "AI 平台管理", 67 | "ai.windows.activeCount": "{count} 个活跃窗口", 68 | "stats.enabledPlatforms": "已启用平台", 69 | "stats.activeWindows": "活跃窗口", 70 | "stats.maxWindows": "最大窗口数", 71 | "section.enabled": "已启用的 AI 平台", 72 | "section.addMore": "添加更多平台", 73 | "home.noMorePlatformsShort": "已达到上限或所有平台已启用", 74 | "home.noMorePlatformsLong": "已达到最大平台数限制或所有平台已启用", 75 | "settings.title": "设置", 76 | "settings.language": "语言", 77 | "settings.launchAtLogin": "开机启动", 78 | "settings.hotkey": "快捷键", 79 | "settings.hotkeySwitch": "切换窗口", 80 | "hotkey.desc.launcher": "显示/隐藏", 81 | "hotkey.desc.switcher": "切换窗口", 82 | "settings.suspendTime": "休眠时间", 83 | "settings.suspendOff": "不休眠", 84 | "settings.appearanceMode": "主题色", 85 | "settings.appearanceAuto": "跟随系统", 86 | "settings.appearanceLight": "浅色", 87 | "settings.appearanceDark": "深色", 88 | "settings.clearCache": "清除缓存", 89 | "settings.clearCacheConfirmTitle": "清除缓存?", 90 | "settings.clearCacheConfirmMessage": "这将清除网页缓存并注销所有登录状态,是否继续?", 91 | "button.confirm": "确认", 92 | "settings.clearCacheDone": "浏览器缓存已清除", 93 | "settings.suspendTimeUpdated": "休眠时间已更新", 94 | "hotkey.overlay.switcher": "切换窗口", 95 | "hotkey.overlay.launcher": "显示/隐藏", 96 | "error.navigationTitle": "加载失败", 97 | "button.retry": "重试", 98 | "button.back": "返回", 99 | "error.navigationFailed": "网络连接异常或页面无法加载。", 100 | "error.navigationHint": "请检查网络后点击“重试”。", 101 | "onboard.permTitle": "启用辅助功能与麦克风", 102 | "onboard.permBody": "快捷键需要辅助功能权限;语音输入需要麦克风权限。点击“授权”以请求。", 103 | "button.grant": "授权", 104 | "button.later": "稍后", 105 | "onboard.title": "欢迎使用 Bubble", 106 | "onboard.body": "提示:可在设置中配置“显示/隐藏”和“切换窗口”快捷键,并调整休眠时间。", 107 | "button.settings": "设置", 108 | "button.gotIt": "知道了", 109 | "tour.back": "↩︎ 返回主页", 110 | "tour.back.desc": "回到主页管理平台", 111 | "tour.step1.title": "✨ 添加第一个 AI", 112 | "tour.step1.desc": "点选卡片即可启用该平台", 113 | "tour.step2.title": "🧭 切换到聊天窗口", 114 | "tour.step2.desc": "顶部下拉选择刚启用的平台", 115 | "tour.practice.title": "⌘ 试试快捷键", 116 | "tour.practice": "连按两次“{hotkey}”,快速收起/唤起", 117 | "tour.done.title": "✓ 完成啦", 118 | "tour.done": "已经掌握 Bubble,开始聊天吧", 119 | "tour.btn.next": "下一步", 120 | "tour.btn.done": "完成", 121 | "tour.btn.skip": "跳过", 122 | "errors.unsupportedPlatform": "不支持的 AI 平台" 123 | } 124 | -------------------------------------------------------------------------------- /src/bubble/health_checks.py: -------------------------------------------------------------------------------- 1 | """ 2 | health_checks.py 3 | Health and crash-loop protection for Bubble macOS overlay app. 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | import tempfile 10 | import traceback 11 | import functools 12 | import platform 13 | import objc 14 | from pathlib import Path 15 | 16 | # Get a path for logging errors that is persistent. 17 | def get_log_dir(): 18 | # Set a persistent log directory in the user's home folder 19 | log_dir = Path.home() / "Library" / "Logs" / "bubble" 20 | # Create the directory if it doesn't exist 21 | log_dir.mkdir(parents=True, exist_ok=True) 22 | return log_dir 23 | 24 | # Settings for crash loop detection. 25 | LOG_DIR = get_log_dir() 26 | LOG_PATH = LOG_DIR / "bubble_error_log.txt" 27 | CRASH_COUNTER_FILE = LOG_DIR / "bubble_crash_counter.txt" 28 | CRASH_THRESHOLD = 3 # Maximum allowed crashes within the time window. 29 | CRASH_TIME_WINDOW = 60 # Time window in seconds. 30 | 31 | # Returns a string containing the macOS version, Python version, and PyObjC version. 32 | def get_system_info(): 33 | macos_version = platform.mac_ver()[0] 34 | python_version = platform.python_version() 35 | pyobjc_version = getattr(objc, '__version__', 'unknown') 36 | info = ( 37 | "\n" 38 | "System Information:\n" 39 | f" macOS version: {macos_version}\n" 40 | f" Python version: {python_version}\n" 41 | f" PyObjC version: {pyobjc_version}\n" 42 | ) 43 | return info 44 | 45 | # Reads and updates the crash counter; exits if a crash loop is detected. 46 | def check_crash_loop(): 47 | current_time = time.time() 48 | count = 0 49 | last_time = 0 50 | # Read previous crash info if it exists. 51 | if os.path.exists(CRASH_COUNTER_FILE): 52 | try: 53 | with open(CRASH_COUNTER_FILE, "r") as f: 54 | line = f.read().strip() 55 | if line: 56 | last_time_str, count_str = line.split(",") 57 | last_time = float(last_time_str) 58 | count = int(count_str) 59 | except Exception: 60 | # On any error, reset the counter. 61 | count = 0 62 | # If the last crash was within the time window, increment; otherwise, reset. 63 | if current_time - last_time < CRASH_TIME_WINDOW: 64 | count += 1 65 | else: 66 | count = 1 67 | # Write the updated crash info back to the file. 68 | try: 69 | with open(CRASH_COUNTER_FILE, "w") as f: 70 | f.write(f"{current_time},{count}") 71 | except Exception as e: 72 | print("Warning: Could not update crash counter file:", e) 73 | 74 | # If the count exceeds the threshold, abort further restarts. 75 | if count > CRASH_THRESHOLD: 76 | print("ERROR: Crash loop detected (more than {} crashes within {} seconds). Crash counter file (for reference) at:\n {}\n\nAborting further restarts. To resume attempts to launch, delete the counter file with:\n rm {}\n\nError log (most recent) at:\n {}".format( 77 | CRASH_THRESHOLD, 78 | CRASH_TIME_WINDOW, 79 | CRASH_COUNTER_FILE, 80 | CRASH_COUNTER_FILE, 81 | LOG_PATH 82 | )) 83 | sys.exit(1) 84 | 85 | # Resets the crash counter after a successful run. 86 | def reset_crash_counter(): 87 | if os.path.exists(CRASH_COUNTER_FILE): 88 | try: 89 | os.remove(CRASH_COUNTER_FILE) 90 | except Exception as e: 91 | print("Warning: Could not reset crash counter file:", e) 92 | 93 | # Decorator to wrap the main function with crash loop detection and error logging. 94 | # If the wrapped function raises an exception, the error is logged (with system info) 95 | # and printed to the terminal before exiting. 96 | def health_check_decorator(func): 97 | @functools.wraps(func) 98 | def wrapper(*args, **kwargs): 99 | check_crash_loop() 100 | try: 101 | result = func(*args, **kwargs) 102 | reset_crash_counter() 103 | print("SUCCESS") 104 | return result 105 | except Exception: 106 | system_info = get_system_info() 107 | error_trace = traceback.format_exc() 108 | with open(LOG_PATH, "w") as log_file: 109 | log_file.write("An unhandled exception occurred:\n") 110 | log_file.write(system_info) 111 | log_file.write(error_trace) 112 | print("ERROR: Application failed to start properly. Details:") 113 | print(system_info) 114 | print(error_trace) 115 | print(f"Error log saved at: {LOG_PATH}", flush=True) 116 | sys.exit(1) 117 | return wrapper 118 | -------------------------------------------------------------------------------- /src/bubble/i18n/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import Any, Dict, Optional 4 | import pkgutil 5 | 6 | _CURRENT_LANG = "en" 7 | _TRANSLATIONS: Dict[str, Dict[str, Any]] = {} 8 | _BASE_DIR = os.path.dirname(__file__) 9 | 10 | _LANG_ALIASES = { 11 | "zh": "zh", 12 | "zh-cn": "zh", 13 | "zh_cn": "zh", 14 | "zh-hans": "zh", 15 | "en": "en", 16 | "en-us": "en", 17 | "en_gb": "en", 18 | "ja": "ja", 19 | "ja-jp": "ja", 20 | "ko": "ko", 21 | "ko-kr": "ko", 22 | "fr": "fr", 23 | "fr-fr": "fr", 24 | } 25 | 26 | 27 | def _normalize_lang(code: Optional[str]) -> str: 28 | if not code: 29 | return "en" 30 | c = code.strip().lower() 31 | return _LANG_ALIASES.get(c, c.split("-")[0].split("_")[0]) 32 | 33 | 34 | def _load_lang(code: str) -> Dict[str, Any]: 35 | """Load translations for a language. 36 | 37 | Zip-safe: first try pkgutil.get_data (works in py2app bundles where files 38 | are inside python313.zip). Fallback to file path during dev runs. 39 | """ 40 | # 1) Zip-safe import from package data 41 | try: 42 | data = pkgutil.get_data(__package__ or 'bubble.i18n', f"strings_{code}.json") 43 | if data: 44 | return json.loads(data.decode("utf-8")) 45 | except Exception: 46 | pass 47 | 48 | # 2) File path fallback (source tree / editable installs) 49 | path = os.path.join(_BASE_DIR, f"strings_{code}.json") 50 | try: 51 | if os.path.exists(path): 52 | with open(path, "r", encoding="utf-8") as f: 53 | return json.load(f) 54 | except Exception as e: 55 | print(f"WARNING[i18n]: Failed to load language '{code}': {e}") 56 | return {} 57 | 58 | 59 | def load_translations() -> None: 60 | global _TRANSLATIONS 61 | _TRANSLATIONS = {} 62 | for code in ("en", "zh", "ja", "ko", "fr"): 63 | _TRANSLATIONS[code] = _load_lang(code) 64 | 65 | 66 | def available_languages(): 67 | return [k for k in sorted(_TRANSLATIONS.keys())] 68 | 69 | 70 | def set_language(code: Optional[str]) -> None: 71 | global _CURRENT_LANG 72 | norm = _normalize_lang(code) 73 | if norm not in _TRANSLATIONS: 74 | # try lazy-load 75 | _TRANSLATIONS[norm] = _load_lang(norm) 76 | _CURRENT_LANG = norm if norm in _TRANSLATIONS else "en" 77 | 78 | 79 | def get_language() -> str: 80 | return _CURRENT_LANG 81 | 82 | 83 | def _get_value(d: Dict[str, Any], key: str) -> Optional[str]: 84 | # direct key first 85 | if key in d: 86 | v = d[key] 87 | return v if isinstance(v, str) else None 88 | # dot-path drill-down 89 | cur: Any = d 90 | for part in key.split('.'): 91 | if isinstance(cur, dict) and part in cur: 92 | cur = cur[part] 93 | else: 94 | return None 95 | return cur if isinstance(cur, str) else None 96 | 97 | 98 | def t(key: str, **kwargs) -> str: 99 | """Translate a key to the current language; fallback to English. 100 | 101 | Examples: 102 | t('menu.showHideHint', hotkey='⌘+G') 103 | """ 104 | if not _TRANSLATIONS: 105 | load_translations() 106 | 107 | lang = get_language() 108 | # env override if present 109 | env_lang = os.environ.get("BUBBLE_LANG") 110 | if env_lang: 111 | lang = _normalize_lang(env_lang) 112 | 113 | # Extract optional default text without feeding it into .format(**kwargs) 114 | default_text = None 115 | try: 116 | if 'default' in kwargs: 117 | default_text = kwargs.pop('default') 118 | except Exception: 119 | default_text = None 120 | 121 | bundle = _TRANSLATIONS.get(lang) or {} 122 | en_bundle = _TRANSLATIONS.get("en") or {} 123 | 124 | text = _get_value(bundle, key) 125 | if text is None: 126 | text = _get_value(en_bundle, key) 127 | if text is None and default_text is not None: 128 | text = default_text 129 | else: 130 | print(f"WARNING[i18n]: Missing key '{key}' in lang '{lang}', falling back to 'en'") 131 | if text is None: 132 | print(f"WARNING[i18n]: Missing key '{key}' in 'en' and no default; returning key") 133 | return key 134 | try: 135 | return text.format(**kwargs) if kwargs else text 136 | except Exception as e: 137 | print(f"WARNING[i18n]: Format error for key '{key}': {e}") 138 | return text 139 | 140 | 141 | # Initialize translations and current language (env override or default 'en') 142 | try: 143 | load_translations() 144 | set_language(os.environ.get("BUBBLE_LANG", "en")) 145 | except Exception: 146 | _CURRENT_LANG = "en" 147 | 148 | __all__ = [ 149 | "t", 150 | "set_language", 151 | "get_language", 152 | "available_languages", 153 | "load_translations", 154 | ] 155 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # setup.py 2 | from setuptools import setup, find_packages 3 | import sys, os, glob 4 | import ctypes.util 5 | 6 | _ROOT = os.path.dirname(os.path.abspath(__file__)) 7 | _SCRIPT = os.path.join(_ROOT, "Bubble.py") 8 | 9 | def _write_launcher(path: str) -> None: 10 | os.makedirs(os.path.dirname(path), exist_ok=True) 11 | with open(path, "w", encoding="utf-8") as f: 12 | f.write( 13 | """ 14 | import os, sys 15 | try: 16 | # Add ./src to sys.path (development-like layout) 17 | here = os.path.dirname(__file__) 18 | src_path = os.path.join(here, "src") 19 | if os.path.isdir(src_path) and src_path not in sys.path: 20 | sys.path.insert(0, src_path) 21 | from bubble.main import main 22 | except Exception: 23 | from bubble.main import main # type: ignore 24 | 25 | if __name__ == "__main__": 26 | main() 27 | """.strip() 28 | ) 29 | 30 | # Force fallback launcher in CI to avoid path assumptions 31 | _force_fallback = os.environ.get("BUBBLE_FORCE_FALLBACK_LAUNCHER") == "1" or \ 32 | os.environ.get("GITHUB_ACTIONS") == "true" or \ 33 | os.environ.get("CI") is not None 34 | 35 | if _force_fallback or not os.path.exists(_SCRIPT): 36 | _SCRIPT = os.path.join(_ROOT, "build", "Bubble.py") 37 | _write_launcher(_SCRIPT) 38 | 39 | APP = [_SCRIPT] 40 | DATA_FILES = [] 41 | def _detect_libffi(): 42 | # Try ctypes to find the library name 43 | name = ctypes.util.find_library('ffi') 44 | candidates = [] 45 | # Common locations: current interpreter prefix and conda base 46 | for base in filter(None, [sys.prefix, os.environ.get('CONDA_PREFIX'), '/opt/homebrew/Caskroom/miniconda/base', '/usr/local', '/opt/homebrew']): 47 | for pat in ( 48 | os.path.join(base, 'lib', 'libffi*.dylib'), 49 | os.path.join(base, 'Cellar', 'libffi', '*', 'lib', 'libffi*.dylib'), 50 | ): 51 | candidates.extend(glob.glob(pat)) 52 | # Prefer versioned .8 first 53 | candidates.sort(key=lambda p: ('.8.' not in p and not p.endswith('libffi.8.dylib'), len(p))) 54 | for p in candidates: 55 | if os.path.isfile(p): 56 | return p 57 | # Last resort: if ctypes found a basename, hope dyld finds it at runtime 58 | return None 59 | 60 | _extra_frameworks = [] 61 | _ffi = _detect_libffi() 62 | if _ffi: 63 | _extra_frameworks.append(_ffi) 64 | 65 | _ICON = os.path.join(_ROOT, "src", "bubble", "logo", "icon.icns") 66 | 67 | OPTIONS = { 68 | # Bundle your package directory so imports “just work” 69 | "packages": ["bubble"], 70 | # Explicitly copy image assets to app Resources for NSBundle lookups 71 | "resources": [ 72 | "src/bubble/logo", 73 | "src/bubble/assets/icons", 74 | ], 75 | "includes": [], 76 | # Exclude build-time tooling that can cause duplicate dist-info when collected 77 | "excludes": [ 78 | "pip", 79 | "setuptools", 80 | "wheel", 81 | "pkg_resources", 82 | ], 83 | # Build arch and GUI mode 84 | "arch": "universal2", 85 | # GUI app (no console window) 86 | "argv_emulation": False, 87 | # Optional: your .icns icon (only if present) 88 | **({"iconfile": _ICON} if os.path.exists(_ICON) else {}), 89 | # Allow microphone & Accessibility prompts by embedding Info.plist keys: 90 | "plist": { 91 | "CFBundleName": "Bubble", 92 | "CFBundleDisplayName": "Bubble", 93 | "CFBundleIdentifier": "com.sugeh.bubble", 94 | "NSMicrophoneUsageDescription": "Bubble needs your mic for voice input.", 95 | "NSAccessibilityUsageDescription": "Bubble needs accessibility permission for global hotkeys.", 96 | "NSInputMonitoringUsageDescription": "Bubble needs input monitoring for global hotkeys.", 97 | "NSAppleEventsUsageDescription": "Bubble needs accessibility permission for hotkeys." 98 | }, 99 | # Bundle missing dylibs that Python depends on (e.g., libffi for _ctypes) 100 | "frameworks": _extra_frameworks, 101 | } 102 | 103 | setup( 104 | name="bubble", 105 | version="0.4.1", 106 | # Explicit package list (robust on CI; ignores any sibling packages) 107 | packages=[ 108 | "bubble", 109 | "bubble.components", 110 | "bubble.components.utils", 111 | "bubble.i18n", 112 | "bubble.models", 113 | "bubble.utils", 114 | ], 115 | package_dir={"bubble": "src/bubble"}, 116 | # Ensure non-Python assets (icons) are bundled 117 | package_data={ 118 | "bubble": [ 119 | "logo/*", 120 | "logo/icon.iconset/*", 121 | "assets/icons/*", 122 | "i18n/*.json", 123 | "about/*.txt", 124 | ] 125 | }, 126 | include_package_data=True, 127 | app=APP, 128 | data_files=DATA_FILES, 129 | options={"py2app": OPTIONS}, 130 | setup_requires=["py2app"], 131 | ) 132 | -------------------------------------------------------------------------------- /src/bubble/utils/login_items.py: -------------------------------------------------------------------------------- 1 | """ 2 | login_items.py 3 | 4 | macOS login item toggle using user LaunchAgents. 5 | 6 | Behavior: 7 | - Darwin: creates/removes a LaunchAgent plist under 8 | ~/Library/LaunchAgents/com.bubble.launcher.plist and (boot)loads/unloads 9 | it using launchctl. ProgramArguments points to the app entry derived from 10 | bubble.launcher.get_executable(). 11 | - Non‑Darwin: operations are disabled; callers should disable the UI. 12 | 13 | Notes: 14 | - ServiceManagement.SMLoginItemSetEnabled requires a bundled helper with 15 | entitlements. For a Python app this is heavier, hence LaunchAgents is a 16 | practical, robust approach that doesn’t require extra permissions. 17 | """ 18 | 19 | from __future__ import annotations 20 | 21 | import os 22 | import platform 23 | import pwd 24 | import subprocess 25 | from pathlib import Path 26 | from typing import List, Tuple 27 | 28 | import plistlib 29 | 30 | # Internal label for the per-user LaunchAgent 31 | AGENT_LABEL = "com.bubble.launcher" 32 | 33 | 34 | def _is_darwin() -> bool: 35 | return platform.system().lower() == "darwin" 36 | 37 | 38 | def _launch_agents_dir() -> Path: 39 | home = Path.home() 40 | return home / "Library" / "LaunchAgents" 41 | 42 | 43 | def _plist_path() -> Path: 44 | return _launch_agents_dir() / f"{AGENT_LABEL}.plist" 45 | 46 | 47 | def _program_args() -> List[str]: 48 | # Use the app’s own executable/program args abstraction 49 | try: 50 | from ..launcher import get_executable 51 | 52 | return list(get_executable()) 53 | except Exception: 54 | # Minimal safe fallback to avoid crashing – won’t autostart 55 | return ["/usr/bin/true"] 56 | 57 | 58 | def _write_plist(program_args: List[str]) -> None: 59 | plist = { 60 | "Label": AGENT_LABEL, 61 | "ProgramArguments": program_args, 62 | # Start at login 63 | "RunAtLoad": True, 64 | # Do not respawn on crash by default (leave to user) 65 | "KeepAlive": False, 66 | # Silence logs by default 67 | "StandardOutPath": "/dev/null", 68 | "StandardErrorPath": "/dev/null", 69 | # Background process for better behavior 70 | "ProcessType": "Background", 71 | # Environment: ensure PATH includes Homebrew for userland python if needed 72 | "EnvironmentVariables": { 73 | "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", 74 | }, 75 | } 76 | d = _launch_agents_dir() 77 | d.mkdir(parents=True, exist_ok=True) 78 | with _plist_path().open("wb") as f: 79 | plistlib.dump(plist, f) 80 | 81 | 82 | def _uid() -> str: 83 | try: 84 | return str(os.getuid()) 85 | except Exception: 86 | # Fallback via pwd 87 | return str(pwd.getpwnam(os.getlogin()).pw_uid) 88 | 89 | 90 | def _launchctl(*args: str) -> Tuple[int, str, str]: 91 | try: 92 | p = subprocess.run(["launchctl", *args], capture_output=True, text=True) 93 | return p.returncode, p.stdout.strip(), p.stderr.strip() 94 | except FileNotFoundError: 95 | return 127, "", "launchctl not found" 96 | 97 | 98 | def is_supported() -> bool: 99 | return _is_darwin() 100 | 101 | 102 | def is_enabled() -> bool: 103 | if not _is_darwin(): 104 | return False 105 | label = f"gui/{_uid()}/{AGENT_LABEL}" 106 | code, _, _ = _launchctl("print", label) 107 | if code == 0: 108 | return True 109 | # Fallback: consider enabled if plist exists 110 | return _plist_path().exists() 111 | 112 | 113 | def set_enabled(enabled: bool) -> Tuple[bool, str]: 114 | """ 115 | Enable/disable login item. 116 | 117 | Returns (ok, message) 118 | """ 119 | if not _is_darwin(): 120 | return False, "Login item is only available on macOS." 121 | 122 | plist_path = _plist_path() 123 | label_ref = f"gui/{_uid()}/{AGENT_LABEL}" 124 | 125 | if enabled: 126 | try: 127 | _write_plist(_program_args()) 128 | except Exception as e: 129 | return False, f"Failed to write LaunchAgent: {e}" 130 | 131 | # Try modern bootstrapping first 132 | code, out, err = _launchctl("bootstrap", f"gui/{_uid()}", str(plist_path)) 133 | if code != 0: 134 | # Fallback to legacy load 135 | code2, out2, err2 = _launchctl("load", "-w", str(plist_path)) 136 | if code2 != 0: 137 | return False, f"launchctl failed: {err or err2 or out or out2}" 138 | # Ensure enabled 139 | _launchctl("enable", label_ref) 140 | return True, "Login item enabled" 141 | else: 142 | # Try modern bootout first 143 | _launchctl("disable", label_ref) 144 | code, out, err = _launchctl("bootout", label_ref) 145 | if code != 0: 146 | # Fallback to legacy unload 147 | _launchctl("unload", "-w", str(plist_path)) 148 | try: 149 | if plist_path.exists(): 150 | plist_path.unlink() 151 | except Exception: 152 | pass 153 | return True, "Login item disabled" 154 | -------------------------------------------------------------------------------- /src/bubble/i18n/strings_fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "app.name": "Bubble", 3 | "menu.settings": "Réglages…", 4 | "menu.show": "Afficher", 5 | "menu.hide": "Masquer", 6 | "menu.home": "Accueil", 7 | "menu.clearCache": "Vider le cache Web", 8 | "menu.setNewTrigger": "Définir un nouveau raccourci", 9 | "menu.quit": "Quitter", 10 | "menu.showHideHint": "Appuyez sur {hotkey} pour afficher/masquer", 11 | "menu.switchHint": "Appuyez sur {hotkey} pour basculer les fenêtres", 12 | "bubble.tooManyWindows": "Trop de fenêtres peuvent utiliser de la mémoire", 13 | "bubble.neverShow": "Ne plus afficher", 14 | "notice.configMigrated": "Les réglages ont été déplacés vers le nouveau dossier Bubble. Une sauvegarde a été conservée.", 15 | "home.title": "Bubble", 16 | "home.welcome": "Bienvenue sur Bubble", 17 | "button.open": "Ouvrir", 18 | "button.add": "Ajouter", 19 | "button.remove": "Supprimer", 20 | "button.setDefault": "Définir par défaut", 21 | "button.newWindow": "Nouvelle fenêtre", 22 | "button.change": "Modifier", 23 | "button.save": "Enregistrer", 24 | "button.cancel": "Annuler", 25 | "hotkey.overlay.title": "Définir le raccourci", 26 | "hotkey.overlay.subtitle": "Appuyez sur une combinaison avec au moins une touche de modification.", 27 | "hotkey.overlay.subtitle.launcher": "Afficher/Masquer", 28 | "hotkey.overlay.subtitle.switcher": "Changer de fenêtre", 29 | "hotkey.overlay.wait": "En attente d’une frappe…", 30 | "hotkey.overlay.requireModifier": "Incluez au moins une touche (⌘/⌥/⌃/⇧)", 31 | "hotkey.overlay.modifierHint": "Inclure ⌘ ⌥ ⌃ ⇧", 32 | "hotkey.overlay.selectType": "Sélectionner le raccourci à modifier", 33 | "hotkey.overlay.listening.launcher": "Appuyez sur le nouveau raccourci Afficher/Masquer...", 34 | "hotkey.overlay.listening.switcher": "Appuyez sur le nouveau raccourci de changement...", 35 | "hotkey.overlay.hint": "Inclure ⌘ ⌥ ⌃ ⇧", 36 | "hotkey.overlay.saved": "Raccourci enregistré !", 37 | "label.default": "Par défaut", 38 | "status.loading": "Chargement…", 39 | "tooltip.back": "Retour", 40 | "nav.home": "Accueil", 41 | "nav.chat": "Discussion", 42 | "platform.openai": "ChatGPT", 43 | "platform.gemini": "Gemini", 44 | "platform.grok": "Grok", 45 | "platform.claude": "Claude", 46 | "platform.deepseek": "DeepSeek", 47 | "platform.zai": "GLM", 48 | "platform.qwen": "Qwen", 49 | "platform.kimi": "Kimi", 50 | "platform.mistral": "MISTRAL", 51 | "platform.perplexity": "PERPLEXITY", 52 | "platform.desc.openai": "Assistant généraliste, riche écosystème", 53 | "platform.desc.gemini": "Compréhension et génération multimodales", 54 | "platform.desc.grok": "Infos en temps réel, ton enlevé", 55 | "platform.desc.claude": "Long contexte, sécurité", 56 | "platform.desc.deepseek": "Excellent rapport qualité/prix", 57 | "platform.desc.zai": "Compréhension et raisonnement en chinois", 58 | "platform.desc.qwen": "Chinois + outils", 59 | "platform.desc.mistral": "Léger et rapide", 60 | "platform.desc.perplexity": "Réponses augmentées par la recherche", 61 | "platform.desc.kimi": "Lecture et résumé de longs documents", 62 | "ai.manage.title": "Gestion des plateformes IA", 63 | "ai.windows.activeCount": "{count} fenêtres actives", 64 | "stats.enabledPlatforms": "Plateformes activées", 65 | "stats.activeWindows": "Fenêtres actives", 66 | "stats.maxWindows": "Nombre maximal de fenêtres", 67 | "section.enabled": "Plateformes IA activées", 68 | "section.addMore": "Ajouter d'autres plateformes", 69 | "home.noMorePlatformsShort": "Limite atteinte ou toutes les plateformes sont activées", 70 | "home.noMorePlatformsLong": "Nombre maximal de plateformes atteint ou toutes sont activées", 71 | "settings.title": "Réglages", 72 | "settings.language": "Langue", 73 | "settings.launchAtLogin": "Lancer à l’ouverture de session", 74 | "settings.hotkey": "Raccourci", 75 | "settings.hotkeySwitch": "Basculer les fenêtres", 76 | "settings.suspendTime": "Durée de veille", 77 | "settings.suspendOff": "Pas de veille", 78 | "settings.clearCache": "Vider le cache Web", 79 | "settings.suspendTimeUpdated": "Durée de veille mise à jour", 80 | "hotkey.overlay.switcher": "Basculer les fenêtres", 81 | "hotkey.overlay.launcher": "Afficher/Masquer", 82 | "hotkey.desc.launcher": "Afficher/Masquer", 83 | "hotkey.desc.switcher": "Basculer les fenêtres", 84 | "error.navigationTitle": "Échec du chargement", 85 | "button.retry": "Réessayer", 86 | "button.back": "Retour", 87 | "error.navigationFailed": "Erreur réseau ou page introuvable.", 88 | "error.navigationHint": "Vérifiez la connexion puis cliquez sur Réessayer.", 89 | "onboard.permTitle": "Activer Accessibilité et Microphone", 90 | "onboard.permBody": "Les raccourcis nécessitent Accessibilité ; l’entrée vocale nécessite le Micro. Cliquez sur Autoriser pour demander.", 91 | "button.grant": "Autoriser", 92 | "button.later": "Plus tard", 93 | "onboard.title": "Bienvenue sur Bubble", 94 | "onboard.body": "Astuce : Définissez les raccourcis Afficher/Masquer et Basculer les pages, et ajustez la durée de veille dans Réglages.", 95 | "button.settings": "Réglages", 96 | "button.gotIt": "J’ai compris", 97 | "tour.back": "↩︎ Retour accueil", 98 | "tour.back.desc": "Revenir à la liste des plateformes", 99 | "tour.step1.title": "✨ Ajoutez votre première IA", 100 | "tour.step1.desc": "Choisissez une carte pour l’activer", 101 | "tour.step2.title": "🧭 Ouvrir la fenêtre chat", 102 | "tour.step2.desc": "Utilisez le menu en haut pour la sélectionner", 103 | "tour.practice": "Appuyez deux fois sur « {hotkey} » pour afficher/masquer Bubble", 104 | "tour.done": "Vous êtes prêt à discuter", 105 | "settings.clearCacheDone": "Cache Web vidé", 106 | "errors.unsupportedPlatform": "Plateforme non prise en charge", 107 | "tour.step3.title": "Retour", 108 | "tour.practice.title": "⌘ Raccourci", 109 | "tour.done.title": "✓ C’est prêt" 110 | } 111 | -------------------------------------------------------------------------------- /src/bubble/i18n/strings_en.json: -------------------------------------------------------------------------------- 1 | { 2 | "app.name": "Bubble", 3 | "menu.settings": "Settings…", 4 | "menu.show": "Show", 5 | "menu.hide": "Hide", 6 | "menu.home": "Home", 7 | "menu.clearCache": "Clear Web Cache", 8 | "menu.setNewTrigger": "Set New Trigger", 9 | "menu.quit": "Quit", 10 | "menu.showHideHint": "Press {hotkey} to Show/Hide", 11 | "menu.switchHint": "Press {hotkey} to Switch Windows", 12 | "bubble.tooManyWindows": "Too many windows may use memory", 13 | "bubble.neverShow": "Never", 14 | "toast.tooManyPages": "Opening many pages may cause memory usage and lag", 15 | "toast.added": "Added", 16 | "toast.removed": "Removed", 17 | "toast.failed": "Failed", 18 | "notice.configMigrated": "Settings moved to new Bubble folder. Backup kept.", 19 | "home.title": "Bubble", 20 | "home.welcome": "Welcome to Bubble", 21 | "button.open": "Open", 22 | "button.add": "Add", 23 | "button.remove": "Remove", 24 | "button.setDefault": "Set as Default", 25 | "button.newWindow": "New Window", 26 | "button.change": "Change", 27 | "button.save": "Save", 28 | "button.cancel": "Cancel", 29 | "hotkey.overlay.title": "Set Shortcut", 30 | "hotkey.overlay.subtitle": "⌨️ Press a combo with a modifier.", 31 | "hotkey.overlay.subtitle.launcher": "Show/Hide", 32 | "hotkey.overlay.subtitle.switcher": "Switch Windows", 33 | "hotkey.overlay.wait": "Waiting for key press…", 34 | "hotkey.overlay.requireModifier": "Include a modifier (⌘/⌥/⌃/⇧)", 35 | "hotkey.overlay.modifierHint": "Include ⌘ ⌥ ⌃ ⇧", 36 | "hotkey.overlay.selectType": "Select shortcut type", 37 | "hotkey.overlay.listening.launcher": "Press new Show/Hide shortcut...", 38 | "hotkey.overlay.listening.switcher": "Press new Switch shortcut...", 39 | "hotkey.overlay.hint": "Include ⌘ ⌥ ⌃ ⇧", 40 | "hotkey.overlay.saved": "Shortcut saved!", 41 | "label.default": "Default", 42 | "status.loading": "Loading…", 43 | "tooltip.back": "Back", 44 | "nav.home": "Home", 45 | "nav.chat": "Chat", 46 | "platform.openai": "ChatGPT", 47 | "platform.gemini": "Gemini", 48 | "platform.grok": "Grok", 49 | "platform.claude": "Claude", 50 | "platform.deepseek": "DeepSeek", 51 | "platform.zai": "GLM", 52 | "platform.qwen": "Qwen", 53 | "platform.kimi": "Kimi", 54 | "platform.mistral": "MISTRAL", 55 | "platform.perplexity": "PERPLEXITY", 56 | "platform.desc.openai": "Generalist assistant, rich ecosystem", 57 | "platform.desc.gemini": "Multimodal understanding and generation", 58 | "platform.desc.grok": "Real-time info with witty style", 59 | "platform.desc.claude": "Long context and safety focus", 60 | "platform.desc.deepseek": "Great value and Chinese-friendly", 61 | "platform.desc.zai": "Chinese understanding and reasoning", 62 | "platform.desc.qwen": "Chinese + tool invocation", 63 | "platform.desc.mistral": "Lightweight and fast", 64 | "platform.desc.perplexity": "Search-augmented answers", 65 | "platform.desc.kimi": "Long document reading & summary", 66 | "ai.manage.title": "AI Platform Management", 67 | "ai.windows.activeCount": "{count} active windows", 68 | "stats.enabledPlatforms": "Enabled Platforms", 69 | "stats.activeWindows": "Active Windows", 70 | "stats.maxWindows": "Max Windows", 71 | "section.enabled": "Enabled AI Platforms", 72 | "section.addMore": "Add More Platforms", 73 | "home.noMorePlatformsShort": "All platforms enabled or limit reached", 74 | "home.noMorePlatformsLong": "Reached the maximum number of platforms or all are enabled", 75 | "settings.title": "Settings", 76 | "settings.language": "Language", 77 | "settings.launchAtLogin": "Launch at Login", 78 | "settings.hotkey": "Hotkey", 79 | "settings.hotkeySwitch": "Switch Windows", 80 | "hotkey.desc.launcher": "Show/Hide", 81 | "hotkey.desc.switcher": "Switch windows", 82 | "settings.suspendTime": "Sleep time", 83 | "settings.suspendOff": "No sleep", 84 | "settings.appearanceMode": "Theme", 85 | "settings.appearanceAuto": "Follow System", 86 | "settings.appearanceLight": "Light", 87 | "settings.appearanceDark": "Dark", 88 | "settings.clearCache": "Clear Cache", 89 | "settings.clearCacheConfirmTitle": "Clear Cache?", 90 | "settings.clearCacheConfirmMessage": "This will clear web cache and all login sessions. Continue?", 91 | "button.confirm": "Confirm", 92 | "settings.clearCacheDone": "Web cache cleared", 93 | "settings.suspendTimeUpdated": "Sleep time updated", 94 | "hotkey.overlay.switcher": "Switch Windows", 95 | "hotkey.overlay.launcher": "Show/Hide Bubble", 96 | "error.navigationTitle": "Load Failed", 97 | "button.retry": "Retry", 98 | "button.back": "Back", 99 | "error.navigationFailed": "Network error or the page could not be loaded.", 100 | "error.navigationHint": "Check your connection and click Retry.", 101 | "onboard.permTitle": "Enable Accessibility & Microphone", 102 | "onboard.permBody": "Hotkeys need Accessibility; voice input needs Microphone. Click Grant to request.", 103 | "button.grant": "Grant", 104 | "button.later": "Later", 105 | "onboard.title": "Welcome to Bubble", 106 | "onboard.body": "Tip: Set Show/Hide and Switch Windows hotkeys and adjust Sleep time in Settings.", 107 | "button.settings": "Settings", 108 | "button.gotIt": "Got it", 109 | "tour.back": "↩︎ Back to Home", 110 | "tour.back.desc": "Return to the platform list", 111 | "tour.step1.title": "✨ Add your first AI", 112 | "tour.step1.desc": "Pick a card to enable it", 113 | "tour.step2.title": "🧭 Open the chat window", 114 | "tour.step2.desc": "Use the top dropdown to select it", 115 | "tour.practice.title": "⌘ Try the hotkey", 116 | "tour.practice": "Press “{hotkey}” twice to hide/show Bubble", 117 | "tour.done.title": "✓ You’re all set", 118 | "tour.done": "You’re ready to chat", 119 | "tour.btn.next": "Next", 120 | "tour.btn.done": "Done", 121 | "tour.btn.skip": "Skip", 122 | "errors.unsupportedPlatform": "Unsupported platform" 123 | } 124 | -------------------------------------------------------------------------------- /src/bubble/main.py: -------------------------------------------------------------------------------- 1 | # Python libraries 2 | import argparse 3 | import sys 4 | import signal 5 | import os as _os 6 | import builtins as _builtins 7 | from Foundation import NSBundle 8 | 9 | from AppKit import NSApplication, NSApp, NSApplicationActivationPolicyRegular 10 | 11 | # Local libraries. 12 | from .constants import ( 13 | APP_TITLE, 14 | LAUNCHER_TRIGGER, 15 | LAUNCHER_TRIGGER_MASK, 16 | PERMISSION_CHECK_EXIT, 17 | ) 18 | from .app import ( 19 | AppDelegate, 20 | NSApplication 21 | ) 22 | from .launcher import ( 23 | check_permissions, 24 | ensure_accessibility_permissions, 25 | ) 26 | from .health_checks import ( 27 | health_check_decorator 28 | ) 29 | from .components import ( 30 | HomepageManager, 31 | NavigationController, 32 | MultiWindowManager, 33 | PlatformManager 34 | ) 35 | 36 | # 日志精简(默认隐藏 DEBUG 行;BB_DEBUG=1 时显示) 37 | _orig_print_main = _builtins.print 38 | def _main_print(*args, **kwargs): 39 | if _os.environ.get('BB_DEBUG') == '1': 40 | return _orig_print_main(*args, **kwargs) 41 | if not args: 42 | return 43 | s = str(args[0]) 44 | if s.startswith('DEBUG:'): 45 | return 46 | return _orig_print_main(*args, **kwargs) 47 | print = _main_print 48 | 49 | # 统一使用 NSApp.run() 的事件循环 50 | exit_requested = False # 为向后兼容保留(不再主动轮询) 51 | 52 | def signal_handler(sig, frame): 53 | """保留简单的退出处理;开发期直接关闭终端亦可结束进程。""" 54 | print(f"\n检测到信号 {sig},正在退出 Bubble...") 55 | try: 56 | app = NSApplication.sharedApplication() 57 | app.terminate_(None) 58 | except Exception: 59 | # 无法优雅退出时直接中断 60 | import os 61 | os._exit(0) 62 | 63 | 64 | def _is_packaged_app() -> bool: 65 | """检测是否运行在打包的 .app 环境中。""" 66 | try: 67 | if getattr(sys, 'frozen', False): 68 | return True 69 | bundle = NSBundle.mainBundle() 70 | if bundle is not None: 71 | bp = str(bundle.bundlePath()) 72 | return bool(bp and bp.endswith('.app')) 73 | except Exception: 74 | pass 75 | return False 76 | 77 | # Main executable for running the application from the command line. 78 | @health_check_decorator 79 | def main(): 80 | print("DEBUG: main() 函数开始执行") 81 | parser = argparse.ArgumentParser( 82 | description=f"macOS {APP_TITLE} Overlay App - Dedicated window that can be summoned and dismissed with your keyboard shortcut." 83 | ) 84 | # Autolauncher flags removed (3.1) 85 | parser.add_argument( 86 | "--check-permissions", 87 | action="store_true", 88 | help="Check Accessibility permissions only" 89 | ) 90 | parser.add_argument( 91 | "--capturable", 92 | action="store_true", 93 | help="Dev only: make windows capturable in screen recording" 94 | ) 95 | args = parser.parse_args() 96 | 97 | # 设置信号处理器:仅在终端开发模式下响应 Ctrl+C;打包版忽略 Ctrl+C 98 | try: 99 | if _is_packaged_app(): 100 | # 打包 .app:忽略 SIGINT,避免从终端运行时误触 Ctrl+C 退出 101 | signal.signal(signal.SIGINT, signal.SIG_IGN) 102 | else: 103 | # 仅当交互式终端时启用 Ctrl+C 退出 104 | if sys.stdin and hasattr(sys.stdin, 'isatty') and sys.stdin.isatty(): 105 | signal.signal(signal.SIGINT, signal_handler) 106 | else: 107 | signal.signal(signal.SIGINT, signal.SIG_IGN) 108 | # 依旧响应 SIGTERM 以支持系统关停 109 | signal.signal(signal.SIGTERM, signal_handler) 110 | except Exception: 111 | pass 112 | 113 | # Autolauncher actions removed (3.1) 114 | 115 | if args.check_permissions: 116 | is_trusted = check_permissions(ask=False) 117 | print("Permissions granted:", is_trusted) 118 | sys.exit(0 if is_trusted else PERMISSION_CHECK_EXIT) 119 | 120 | # 暂时跳过权限检查,避免阻塞 121 | print("DEBUG: 跳过权限检查,继续启动应用...") 122 | # check_permissions() 123 | # # Ensure permissions before proceeding 124 | # ensure_accessibility_permissions() 125 | 126 | # Default behavior: run the app and inform user of startup options 127 | print() 128 | print(f"Starting macOS {APP_TITLE} overlay.") 129 | # Autolauncher messaging removed (3.1) 130 | 131 | # Dev-only: allow screen/window recording to capture our windows 132 | # by lowering level and allowing window sharing (opt-in via flag). 133 | if args.capturable: 134 | try: 135 | _os.environ["BB_CAPTURABLE"] = "1" 136 | print("DEBUG: Capturable mode enabled via --capturable") 137 | except Exception: 138 | pass 139 | 140 | # 初始化应用组件 141 | print("DEBUG: 开始初始化应用组件...") 142 | homepage_manager = HomepageManager.alloc().init() 143 | navigation_controller = NavigationController.alloc().init() 144 | multiwindow_manager = MultiWindowManager.alloc().init() 145 | platform_manager = PlatformManager() 146 | print("DEBUG: 应用组件初始化完成") 147 | 148 | # 创建应用和委托 149 | print("DEBUG: 创建应用和委托...") 150 | app = NSApplication.sharedApplication() 151 | # 预先设置为前台 App,避免启动阶段菜单栏为灰色、无法激活 152 | try: 153 | app.setActivationPolicy_(NSApplicationActivationPolicyRegular) 154 | except Exception: 155 | pass 156 | delegate = AppDelegate.alloc().init() 157 | print(f"DEBUG: 应用对象创建完成: {app}") 158 | print(f"DEBUG: 委托对象创建完成: {delegate}") 159 | 160 | # 设置组件依赖关系 161 | delegate.setHomepageManager_(homepage_manager) 162 | delegate.setNavigationController_(navigation_controller) 163 | delegate.setMultiwindowManager_(multiwindow_manager) 164 | delegate.setPlatformManager_(platform_manager) 165 | navigation_controller.set_app_delegate(delegate) 166 | print("DEBUG: 组件依赖关系设置完成") 167 | 168 | # 启动应用 169 | app.setDelegate_(delegate) 170 | print("DEBUG: 应用委托设置完成") 171 | 172 | print("Bubble 初始化完成,启动主页...") 173 | 174 | # 统一使用 Cocoa 事件循环(生产/开发一致) 175 | app.run() 176 | 177 | if __name__ == "__main__": 178 | # Execute the decorated main function. 179 | main() 180 | -------------------------------------------------------------------------------- /src/bubble/assets/homepage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bubble Homepage JavaScript 3 | * 4 | * 主页交互逻辑,包括: 5 | * - 卡片响应式缩放 6 | * - 平台行交互(点击、涟漪效果) 7 | * - 多页面气泡与菜单 8 | * - 首次使用引导(Tour) 9 | */ 10 | 11 | // 全局:让卡片高度随窗口高度线性缩放(无阈值跳变) 12 | (function () { 13 | const root = document.documentElement; 14 | if (!root.style.getPropertyValue('--vhBase')) { 15 | root.style.setProperty('--vhBase', String(window.innerHeight || 600)); 16 | } 17 | function applyScale() { 18 | var base = parseFloat(getComputedStyle(root).getPropertyValue('--vhBase')) || (window.innerHeight || 600); 19 | var s = (window.innerHeight || base) / base; 20 | if (!isFinite(s) || s <= 0) s = 1; 21 | // 限制最大放大倍数(更大:1.8),避免过高造成滚动 22 | s = Math.max(1, Math.min(1.8, s)); 23 | root.style.setProperty('--vhScale', s.toFixed(4)); 24 | } 25 | applyScale(); 26 | window.addEventListener('resize', applyScale, { passive: true }); 27 | })(); 28 | 29 | // 工具函数 30 | const $ = (s, r = document) => r.querySelector(s); 31 | const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); 32 | const post = (obj) => { 33 | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.aiAction) { 34 | window.webkit.messageHandlers.aiAction.postMessage(obj); 35 | } 36 | }; 37 | 38 | // 菜单和气泡元素 39 | const menu = $('#menu'); 40 | const pop = $('#popover'); 41 | 42 | // 平台行交互 43 | $$('.hrow').forEach(row => { 44 | // Ripple on mousedown 45 | row.addEventListener('mousedown', e => { 46 | const rect = row.getBoundingClientRect(); 47 | const ripple = document.createElement('span'); 48 | const d = Math.max(rect.width, rect.height) * 1.2; 49 | ripple.className = 'ripple'; 50 | ripple.style.width = ripple.style.height = d + 'px'; 51 | ripple.style.left = (e.clientX - rect.left - d / 2) + 'px'; 52 | ripple.style.top = (e.clientY - rect.top - d / 2) + 'px'; 53 | row.appendChild(ripple); 54 | ripple.addEventListener('animationend', () => ripple.remove()); 55 | }, { passive: true }); 56 | 57 | row.addEventListener('click', e => { 58 | if (e.target.classList.contains('more') || e.target.closest('button.more')) return; 59 | const pid = row.dataset.pid; 60 | // 先本地过渡高亮/取消,保持高度不变 61 | if (row.classList.contains('active')) { 62 | row.classList.remove('active'); 63 | // 同步右侧控件(保留占位),避免移位 64 | const btn = row.querySelector('button.more'); 65 | if (btn) btn.classList.add('hidden'); 66 | const b = row.querySelector('.bubble'); 67 | if (b) { 68 | b.textContent = ''; 69 | b.classList.add('hidden'); 70 | } 71 | setTimeout(() => post({ action: 'removePlatform', platformId: pid }), 120); 72 | } else { 73 | row.classList.add('active'); 74 | const btn = row.querySelector('button.more'); 75 | if (btn) btn.classList.remove('hidden'); 76 | const bubbleEl = row.querySelector('.bubble'); 77 | if (bubbleEl) { 78 | const txt = (bubbleEl.textContent || '').trim(); 79 | if (txt) { 80 | bubbleEl.classList.remove('hidden'); 81 | } else { 82 | bubbleEl.classList.add('hidden'); 83 | } 84 | } 85 | setTimeout(() => post({ action: 'addPlatform', platformId: pid }), 120); 86 | } 87 | }); 88 | 89 | // 加号按钮:新增页面 90 | const btn = row.querySelector('button.more'); 91 | if (btn) { 92 | btn.addEventListener('click', e => { 93 | e.stopPropagation(); 94 | const pid = row.dataset.pid; 95 | // 点击加号:直接新增页面,并即时+1显示 96 | post({ action: 'addWindow', platformId: pid }); 97 | try { 98 | const b = row.querySelector('.bubble'); 99 | if (b) { 100 | const raw = (b.textContent || '0').trim(); 101 | const cur = raw.endsWith('+') ? 9 : Math.max(0, parseInt(raw || '0', 10)); 102 | const next = cur + 1; 103 | b.textContent = next > 9 ? '9+' : String(next); 104 | b.classList.remove('hidden'); 105 | } 106 | } catch (_e) { } 107 | }); 108 | } 109 | 110 | // 气泡点击:显示页面列表 111 | const bubble = row.querySelector('.bubble'); 112 | if (bubble) { 113 | bubble.addEventListener('click', e => { 114 | e.stopPropagation(); 115 | const rect = bubble.getBoundingClientRect(); 116 | const windows = JSON.parse(row.dataset.windows || '[]'); 117 | let html = '
多页面
'; 118 | windows.forEach(w => { 119 | html += `
#${w.idx}×
`; 120 | }); 121 | pop.innerHTML = html; 122 | pop.style.display = 'block'; 123 | pop.style.left = (rect.left + window.scrollX - 20) + 'px'; 124 | pop.style.top = (rect.bottom + window.scrollY + 6) + 'px'; 125 | pop.dataset.pid = row.dataset.pid; 126 | }); 127 | } 128 | }); 129 | 130 | // 菜单交互 131 | menu.addEventListener('click', e => { 132 | const it = e.target.closest('.item'); 133 | if (!it) return; 134 | const pid = menu.dataset.pid; 135 | menu.style.display = 'none'; 136 | if (it.dataset.action === 'duplicate' || it.dataset.action === 'new') { 137 | post({ action: 'addWindow', platformId: pid }); 138 | } 139 | }); 140 | 141 | // 气泡交互:删除页面 142 | pop.addEventListener('click', e => { 143 | const del = e.target.closest('.pop-del'); 144 | if (!del) return; 145 | const pid = pop.dataset.pid; 146 | const wid = del.dataset.wid; 147 | pop.style.display = 'none'; 148 | post({ action: 'removeWindow', platformId: pid, windowId: wid }); 149 | }); 150 | 151 | // 点击外部关闭菜单和气泡 152 | document.addEventListener('click', () => { 153 | menu.style.display = 'none'; 154 | pop.style.display = 'none'; 155 | }); 156 | 157 | // 创建导览锚点 158 | (function () { 159 | // 顶部下拉锚点(用于导览高亮) 160 | var anchor = document.createElement('div'); 161 | anchor.id = 'top-dropdown-anchor'; 162 | anchor.style.position = 'fixed'; 163 | anchor.style.top = '10px'; 164 | anchor.style.left = '50%'; 165 | anchor.style.transform = 'translateX(-50%)'; 166 | anchor.style.width = '24px'; 167 | anchor.style.height = '18px'; 168 | anchor.style.pointerEvents = 'none'; 169 | anchor.style.zIndex = '1'; 170 | document.body.appendChild(anchor); 171 | 172 | // 居中提示锚 173 | var center = document.createElement('div'); 174 | center.id = 'center-anchor'; 175 | center.style.position = 'fixed'; 176 | center.style.left = '50%'; 177 | center.style.top = '50%'; 178 | center.style.transform = 'translate(-50%, -50%)'; 179 | center.style.width = '10px'; 180 | center.style.height = '10px'; 181 | center.style.pointerEvents = 'none'; 182 | center.style.zIndex = '1'; 183 | document.body.appendChild(center); 184 | })(); -------------------------------------------------------------------------------- /src/bubble/utils/suspend_policy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Suspend policy and WebView suspend/resume helpers. 3 | 4 | This module provides a small, testable policy layer for deciding when a 5 | window/webview should be suspended due to inactivity, and helpers to suspend 6 | and resume a WKWebView while preserving minimal session data. 7 | 8 | Design goals: 9 | - Pure Python timing/state for easy unit testing 10 | - Defensive integration with PyObjC (all Cocoa calls wrapped in try/except) 11 | - Idempotent helpers: calling suspend/resume multiple times is safe 12 | """ 13 | 14 | from __future__ import annotations 15 | 16 | from dataclasses import dataclass, field 17 | from typing import Dict, Optional 18 | import time 19 | 20 | 21 | @dataclass 22 | class _WindowState: 23 | last_activity_ts: float = field(default_factory=lambda: time.time()) 24 | suspended: bool = False 25 | 26 | 27 | class SuspendPolicy: 28 | """Track inactivity and decide when to suspend a window/webview. 29 | 30 | Minutes can be set to 0 or None to disable suspension. 31 | """ 32 | 33 | def __init__(self, minutes: Optional[int] = 30) -> None: 34 | self._minutes: Optional[int] = None 35 | self._states: Dict[str, _WindowState] = {} 36 | self.set_timeout_minutes(minutes) 37 | 38 | # ---- configuration ---- 39 | def set_timeout_minutes(self, minutes: Optional[int]) -> None: 40 | if minutes is None: 41 | self._minutes = None 42 | return 43 | try: 44 | m = int(minutes) 45 | if m <= 0: 46 | # 0 or negative disables suspension 47 | self._minutes = None 48 | else: 49 | self._minutes = m 50 | except Exception: 51 | # Invalid input disables suspension rather than crashing 52 | self._minutes = None 53 | 54 | def get_timeout_minutes(self) -> Optional[int]: 55 | return self._minutes 56 | 57 | # ---- activity tracking ---- 58 | def note_window_activity(self, window_id: Optional[str]) -> None: 59 | if not window_id: 60 | return 61 | st = self._states.get(window_id) 62 | if st is None: 63 | st = _WindowState() 64 | self._states[window_id] = st 65 | st.last_activity_ts = time.time() 66 | 67 | def mark_suspended(self, window_id: Optional[str]) -> None: 68 | if not window_id: 69 | return 70 | st = self._states.get(window_id) 71 | if st is None: 72 | st = _WindowState() 73 | self._states[window_id] = st 74 | st.suspended = True 75 | 76 | def mark_resumed(self, window_id: Optional[str]) -> None: 77 | if not window_id: 78 | return 79 | st = self._states.get(window_id) 80 | if st is None: 81 | st = _WindowState() 82 | self._states[window_id] = st 83 | st.suspended = False 84 | st.last_activity_ts = time.time() 85 | 86 | # ---- decision ---- 87 | def should_suspend(self, window_id: Optional[str]) -> bool: 88 | """Return True if the window should be suspended now. 89 | 90 | Rules: 91 | - If suspension is disabled: False 92 | - If no state exists yet: False (not enough info) 93 | - If already suspended: False (idempotent) 94 | - Otherwise: inactive for >= minutes threshold 95 | """ 96 | if self._minutes is None or not window_id: 97 | return False 98 | st = self._states.get(window_id) 99 | if st is None: 100 | return False 101 | if st.suspended: 102 | return False 103 | try: 104 | idle_sec = time.time() - float(st.last_activity_ts) 105 | except Exception: 106 | return False 107 | return idle_sec >= (self._minutes * 60) 108 | 109 | 110 | # ---- WKWebView suspend/resume helpers ---- 111 | 112 | def _get_current_url(webview) -> Optional[str]: 113 | try: 114 | # WKWebView URL retrieval 115 | url = webview.URL() if hasattr(webview, "URL") else None 116 | if url is None and hasattr(webview, "url"): 117 | url = webview.url() 118 | if url is None: 119 | return None 120 | try: 121 | # NSURL to str 122 | return str(url.absoluteString()) 123 | except Exception: 124 | return str(url) 125 | except Exception: 126 | return None 127 | 128 | 129 | def suspend_webview(webview, ai_window) -> None: 130 | """Lightweight suspend: capture URL and show a lightweight blank page. 131 | 132 | - Store the last URL into ai_window.session_data["_suspend_last_url"]. 133 | - Attempt to stop ongoing loads and replace content with a minimal blank. 134 | - Idempotent and defensive against missing APIs. 135 | """ 136 | if webview is None: 137 | return 138 | try: 139 | last_url = _get_current_url(webview) 140 | if ai_window is not None: 141 | try: 142 | ai_window.set_session_data("_suspend_last_url", last_url) 143 | except Exception: 144 | # Fallback if helper not available 145 | try: 146 | if getattr(ai_window, "session_data", None) is not None: 147 | ai_window.session_data["_suspend_last_url"] = last_url 148 | except Exception: 149 | pass 150 | except Exception: 151 | pass 152 | 153 | # Stop and replace content with about:blank like content 154 | try: 155 | if hasattr(webview, "stopLoading"): 156 | webview.stopLoading() 157 | except Exception: 158 | pass 159 | try: 160 | # Prefer displaying a blank lightweight HTML to free resources 161 | if hasattr(webview, "loadHTMLString_baseURL_"): 162 | webview.loadHTMLString_baseURL_("", None) 163 | except Exception: 164 | pass 165 | 166 | 167 | def resume_webview(webview, ai_window) -> None: 168 | """Resume a previously suspended webview by reloading last URL if needed.""" 169 | if webview is None: 170 | return 171 | last_url = None 172 | try: 173 | if ai_window is not None: 174 | last_url = ai_window.get_session_data("_suspend_last_url", None) 175 | if last_url is None: 176 | # Fallback direct access 177 | last_url = getattr(getattr(ai_window, "session_data", {}), "get", lambda *_: None)("_suspend_last_url") 178 | except Exception: 179 | last_url = None 180 | 181 | # If current URL already valid, do nothing 182 | try: 183 | cur = _get_current_url(webview) 184 | except Exception: 185 | cur = None 186 | 187 | if not last_url or (cur and cur != "" and cur != "about:blank"): 188 | # Nothing to do or already on a real page 189 | return 190 | 191 | # Reload last URL 192 | try: 193 | from Foundation import NSURL, NSURLRequest 194 | if hasattr(webview, "loadRequest_"): 195 | req = NSURLRequest.requestWithURL_(NSURL.URLWithString_(last_url)) 196 | webview.loadRequest_(req) 197 | return 198 | except Exception: 199 | pass 200 | 201 | # Fallback: try reload 202 | try: 203 | if hasattr(webview, "reload"): 204 | webview.reload() 205 | except Exception: 206 | pass 207 | 208 | -------------------------------------------------------------------------------- /src/bubble/assets/homepage.css: -------------------------------------------------------------------------------- 1 | :root { --bg:#fafafa; --card:#fff; --border:#eaeaea; --text:#111; --muted:#666; --accent:#111; --radius:12px; --rightW:64px; } 2 | * { box-sizing: border-box; } 3 | body { margin:0; padding:56px 14px 14px; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background:var(--bg); color:var(--text); overflow-x:hidden; } 4 | /* 容器宽度:在小窗口时保留左右留空;去掉过大的最小宽度限制,最大≈888 */ 5 | .list { width: calc(100% - 40px); max-width: 888px; margin:0 auto; display:flex; flex-direction:column; gap:10px; } 6 | /* 卡片占满容器宽度,限定最小高度;高度/内边距随窗口高度线性放大(无阈值跳变) */ 7 | .hrow { position:relative; overflow:hidden; display:flex; align-items:center; justify-content:flex-start; background:#fff; border:1px solid var(--border); border-radius:12px; padding: calc(12px * var(--vhScale, 1)) 14px; padding-right: calc(14px + var(--rightW)); cursor:pointer; transition: box-shadow .18s ease, border-color .18s ease; width:100%; margin:0 auto; min-height: calc(48px * var(--vhScale, 1)); will-change: box-shadow; backface-visibility:hidden; pointer-events:auto; box-sizing:border-box; } 8 | .hrow:hover { box-shadow:0 10px 26px rgba(0,0,0,.08); } 9 | .hrow.active { border-color:#111; box-shadow:0 0 0 2px rgba(17,17,17,.18); } 10 | .hrow .title { font-size:14px; font-weight:600; display:flex; align-items:center; gap:10px; flex:1; min-width:0; } 11 | .hrow .title .icon { width:16px; height:16px; border-radius:4px; object-fit:cover; } 12 | .hrow .title .icon.invert-dark { filter: none; } 13 | .hrow .title .name { display:inline-block; max-width:46%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } 14 | .hrow .title .desc { margin-left:10px; font-weight:500; font-size:12px; color:#6b7280; opacity:.95; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1; min-width:0; text-align:center; } 15 | .hrow .right { position:absolute; right:14px; top:50%; transform:translateY(-50%); width:var(--rightW); display:flex; align-items:center; gap:6px; justify-content:flex-end; overflow:hidden; } 16 | .hrow .more { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; border-radius:999px; border:1px solid var(--border); background:#fff; cursor:pointer; font-size:14px; line-height:1; } 17 | .hidden { display:none !important; visibility:hidden; opacity:0; } 18 | .hrow .bubble { display:inline-flex; align-items:center; justify-content:center; width:auto; min-width:18px; height:18px; padding:0 6px; border-radius:999px; background:#111; color:#fff; font-size:10px; font-weight:600; } 19 | /* 在超小宽度下略微收窄容器并减小左右内边距,确保右侧留空 */ 20 | @media (max-width: 420px) { 21 | body { padding:56px 12px 12px; } 22 | .list { width: calc(100% - 36px); } 23 | } 24 | .ripple { position:absolute; border-radius:50%; transform:scale(0); background:rgba(0,0,0,.12); animation:ripple .45s ease-out; pointer-events:none; } 25 | @keyframes ripple { to { transform:scale(1); opacity:0; } } 26 | .menu { position:absolute; background:#fff; border:1px solid var(--border); border-radius:8px; box-shadow:0 12px 28px rgba(0,0,0,.16); padding:6px; display:none; } 27 | .menu .item { font-size:12px; padding:6px 10px; border-radius:6px; cursor:pointer; } 28 | .menu .item:hover { background:#f5f5f5; } 29 | .popover { position:absolute; background:#fff; border:1px solid var(--border); border-radius:10px; box-shadow:0 12px 28px rgba(0,0,0,.16); padding:8px; display:none; min-width:140px; } 30 | .pop-title { font-size:12px; color:#444; margin:0 0 6px; } 31 | .pop-item { display:flex; align-items:center; justify-content:space-between; padding:4px 6px; border-radius:6px; } 32 | .pop-item:hover { background:#f7f7f7; } 33 | .pop-del { width:18px; height:18px; border-radius:999px; background:#ef4444; color:#fff; display:inline-flex; align-items:center; justify-content:center; font-weight:700; cursor:pointer; } 34 | 35 | /* DaisyUI Skeleton */ 36 | .skeleton { position: relative; overflow: hidden; background: #e5e7eb; border-radius: 8px; } 37 | .skeleton::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent); animation: sk-shine 1.2s infinite; } 38 | @keyframes sk-shine { 100% { transform: translateX(100%); } } 39 | /* 显示品牌区,确保与打包一致 */ 40 | /* driver.js 样式美化 + 控件精简(兼容旧版选择器 #driver-popover-item 与新版 .driver-popover) */ 41 | .driver-popover, div#driver-popover-item { border-radius: 12px !important; box-shadow: 0 12px 28px rgba(0,0,0,.18) !important; border:1px solid var(--border) !important; text-align:center !important; } 42 | /* 有选择性地隐藏 prev/close(不要隐藏所有按钮,避免气泡消失) */ 43 | .driver-popover .driver-prev-btn, 44 | .driver-popover .driver-close-btn, 45 | div#driver-popover-item .driver-popover-footer .driver-prev-btn, 46 | div#driver-popover-item .driver-popover-footer .driver-close-btn { display: none !important; } 47 | /* 只保留 next/done 并居中 */ 48 | .driver-popover .driver-next-btn, .driver-popover .driver-done-btn, 49 | div#driver-popover-item .driver-popover-footer .driver-next-btn, div#driver-popover-item .driver-popover-footer .driver-done-btn { 50 | background: #111 !important; color: #fff !important; border: 1px solid #111 !important; border-radius: 8px !important; 51 | box-shadow: 0 4px 10px rgba(0,0,0,.12) !important; transition: transform .06s ease, box-shadow .12s ease, background .12s ease; 52 | display: inline-flex !important; margin: 0 auto !important; float: none !important; 53 | } 54 | .driver-popover .driver-next-btn:hover, .driver-popover .driver-done-btn:hover, 55 | div#driver-popover-item .driver-popover-footer .driver-next-btn:hover, div#driver-popover-item .driver-popover-footer .driver-done-btn:hover { background:#000 !important; } 56 | .driver-popover .driver-next-btn:active, .driver-popover .driver-done-btn:active, 57 | div#driver-popover-item .driver-popover-footer .driver-next-btn:active, div#driver-popover-item .driver-popover-footer .driver-done-btn:active { transform: translateY(1px) scale(.98); box-shadow: 0 2px 6px rgba(0,0,0,.12) !important; } 58 | /* 居中 footer */ 59 | div#driver-popover-item .driver-popover-footer { text-align:center !important; } 60 | /* 让周围更暗、突出高亮区域,并设置高亮圆角 */ 61 | div#driver-page-overlay { opacity: .65 !important; background: #000 !important; } 62 | div#driver-highlighted-element-stage { border-radius: 12px !important; box-shadow: 0 10px 30px rgba(0,0,0,.22) !important; } 63 | @media (prefers-color-scheme: dark) { 64 | :root { --bg:#111214; --card:rgba(20,20,22,0.92); --border:rgba(255,255,255,0.08); --text:#f4f4f5; --muted:#a1a1aa; --accent:#38bdf8; } 65 | body { background: var(--bg); color: var(--text); } 66 | .hrow { background: var(--card); border-color: var(--border); box-shadow:none; } 67 | .hrow:hover { box-shadow:0 12px 26px rgba(2,6,23,0.55); } 68 | .hrow.active { border-color: rgba(248,250,252,0.92); box-shadow:0 0 0 2px rgba(248,250,252,0.28); } 69 | .hrow .title .desc { color: var(--muted); } 70 | .hrow .title .icon.invert-dark { filter: invert(1) brightness(2); } 71 | .hrow .more { border-color: rgba(148,163,184,0.25); background: rgba(24,24,27,0.92); color: var(--text); } 72 | .hrow .bubble { background: rgba(248,250,252,0.96); color:#111214; border:1px solid rgba(248,250,252,0.45); } 73 | .menu { background: var(--card); border-color: var(--border); color: var(--text); box-shadow:0 18px 36px rgba(2,6,23,0.65); } 74 | .menu .item { color: var(--text); } 75 | .menu .item:hover { background: rgba(63,63,70,0.45); } 76 | .popover { background: var(--card); border-color: var(--border); color: var(--text); box-shadow:0 18px 36px rgba(2,6,23,0.65); } 77 | .pop-title { color: var(--muted); } 78 | .pop-item { color: var(--text); } 79 | .pop-item:hover { background: rgba(63,63,70,0.45); } 80 | .pop-del { background:#f87171; color:#111214; } 81 | .ripple { background: rgba(148,163,184,0.25); } 82 | .driver-popover, div#driver-popover-item { background: var(--card) !important; color: var(--text) !important; border-color: var(--border) !important; box-shadow:0 16px 34px rgba(2,6,23,0.65) !important; } 83 | .driver-popover .driver-next-btn, .driver-popover .driver-done-btn, div#driver-popover-item .driver-popover-footer .driver-next-btn, div#driver-popover-item .driver-popover-footer .driver-done-btn { background: var(--accent) !important; border-color: var(--accent) !important; color:#0f172a !important; } 84 | div#driver-page-overlay { opacity: .75 !important; background:#050509 !important; } 85 | div#driver-highlighted-element-stage { box-shadow:0 10px 30px rgba(2,6,23,0.75) !important; } 86 | } 87 | -------------------------------------------------------------------------------- /src/bubble/utils/webview_guard.py: -------------------------------------------------------------------------------- 1 | """ 2 | Navigation guard and error overlay utilities for WKWebView. 3 | 4 | Features: 5 | - Allowed host (domain) whitelist check for navigation 6 | - Error overlay with a single "Retry" action (as per spec) 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from typing import Callable, Optional, Set 12 | import objc 13 | 14 | try: 15 | from Foundation import NSObject 16 | from AppKit import NSView, NSButton, NSTextField, NSMakeRect, NSColor, NSFont, NSAnimationContext 17 | from WebKit import WKNavigationActionPolicyCancel, WKNavigationActionPolicyAllow 18 | except Exception: # pragma: no cover - allow import on non-Darwin 19 | NSObject = object # type: ignore 20 | NSView = object # type: ignore 21 | NSButton = object # type: ignore 22 | NSTextField = object # type: ignore 23 | NSMakeRect = lambda *_: (0, 0, 0, 0) # type: ignore 24 | NSColor = None # type: ignore 25 | NSFont = None # type: ignore 26 | NSAnimationContext = None # type: ignore 27 | WKNavigationActionPolicyCancel = 0 # type: ignore 28 | WKNavigationActionPolicyAllow = 1 # type: ignore 29 | 30 | 31 | def _extract_host(url_obj) -> Optional[str]: 32 | try: 33 | if url_obj is None: 34 | return None 35 | # NSURL has host method; Python str does not 36 | if hasattr(url_obj, "host"): 37 | return str(url_obj.host()) if url_obj.host() else None 38 | # Fallback: parse from string 39 | from urllib.parse import urlparse 40 | 41 | return urlparse(str(url_obj)).hostname 42 | except Exception: 43 | return None 44 | 45 | 46 | class NavigationGuard(NSObject): 47 | """WKNavigationDelegate that enforces allowed hosts and draws an error overlay.""" 48 | 49 | def init(self): # type: ignore[override] 50 | self = objc.super(NavigationGuard, self).init() 51 | if self is None: 52 | return None 53 | self._allowed_hosts: Set[str] = set() 54 | self._overlays = {} # webview -> overlay view 55 | self._retry_handler = {} # webview -> callable 56 | return self 57 | 58 | # ---- public API ---- 59 | def setAllowedHosts_(self, hosts): # ObjC-friendly: - (void)setAllowedHosts:(id)hosts 60 | try: 61 | self._allowed_hosts = {str(h).lower() for h in (hosts or [])} 62 | except Exception: 63 | self._allowed_hosts = set() 64 | 65 | def py_setAllowedHosts(self, hosts: Set[str]): # Pythonic (avoid ObjC selector) 66 | self.setAllowedHosts_(hosts) 67 | 68 | def attach_to(self, webview, on_retry: Optional[Callable] = None): 69 | try: 70 | # Keep a retry handler for fail events 71 | if on_retry is not None: 72 | self._retry_handler[webview] = on_retry 73 | # Attach as navigation delegate 74 | if hasattr(webview, "setNavigationDelegate_"): 75 | webview.setNavigationDelegate_(self) 76 | except Exception: 77 | pass 78 | 79 | # ---- error overlay ---- 80 | def show_error_overlay(self, webview, message: str = "Load failed", on_retry: Optional[Callable] = None): 81 | try: 82 | # Reuse when exists 83 | if webview in self._overlays: 84 | overlay = self._overlays[webview] 85 | overlay.setHidden_(False) 86 | return 87 | 88 | bounds = webview.bounds() if hasattr(webview, "bounds") else NSMakeRect(0, 0, 320, 240) 89 | overlay = NSView.alloc().initWithFrame_(bounds) 90 | overlay.setWantsLayer_(True) 91 | try: 92 | # Semi-transparent background for readability 93 | overlay.layer().setBackgroundColor_(NSColor.blackColor().colorWithAlphaComponent_(0.28).CGColor()) 94 | overlay.layer().setCornerRadius_(10.0) 95 | except Exception: 96 | pass 97 | 98 | # Message label 99 | label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, bounds.size.height/2 + 6, bounds.size.width - 40, 22)) 100 | label.setBezeled_(False); label.setDrawsBackground_(False); label.setEditable_(False); label.setSelectable_(False) 101 | try: 102 | label.setAlignment_(2) # center 103 | if NSFont is not None: 104 | label.setFont_(NSFont.systemFontOfSize_(14)) 105 | label.setTextColor_(NSColor.whiteColor()) 106 | except Exception: 107 | pass 108 | label.setStringValue_(message or "Load failed") 109 | overlay.addSubview_(label) 110 | 111 | # Retry button 112 | btn = NSButton.alloc().initWithFrame_(NSMakeRect((bounds.size.width-120)/2, bounds.size.height/2 - 20, 120, 30)) 113 | btn.setTitle_("Retry") 114 | btn.setBordered_(True) 115 | 116 | def _retry(_): 117 | try: 118 | overlay.setHidden_(True) 119 | except Exception: 120 | pass 121 | # Prefer local on_retry, then stored handler, else webview.reload 122 | cb = on_retry or self._retry_handler.get(webview) 123 | if callable(cb): 124 | try: 125 | cb() 126 | return 127 | except Exception: 128 | pass 129 | try: 130 | if hasattr(webview, "reload"): 131 | webview.reload() 132 | except Exception: 133 | pass 134 | 135 | # Bind PyObjC target/action via proxy 136 | Proxy = objc.lookUpClass("NSObject") 137 | 138 | class _Action(Proxy): # type: ignore 139 | def initWithHandler_(self, handler): 140 | self = objc.super(_Action, self).init() 141 | if self is None: 142 | return None 143 | self._handler = handler 144 | return self 145 | 146 | def callWithSender_(self, sender): # noqa: N802 (ObjC selector) 147 | try: 148 | if callable(self._handler): 149 | self._handler(sender) 150 | except Exception: 151 | pass 152 | 153 | proxy = _Action.alloc().initWithHandler_(_retry) 154 | try: 155 | btn.setTarget_(proxy) 156 | btn.setAction_("callWithSender:") 157 | except Exception: 158 | pass 159 | overlay._bb_retry_proxy = proxy # keep strong reference 160 | overlay.addSubview_(btn) 161 | 162 | # Attach overlay to webview 163 | try: 164 | webview.addSubview_(overlay) 165 | except Exception: 166 | pass 167 | self._overlays[webview] = overlay 168 | 169 | # Fade in 170 | try: 171 | if NSAnimationContext is not None: 172 | overlay.setAlphaValue_(0.0) 173 | NSAnimationContext.beginGrouping() 174 | NSAnimationContext.currentContext().setDuration_(0.18) 175 | overlay.animator().setAlphaValue_(1.0) 176 | NSAnimationContext.endGrouping() 177 | except Exception: 178 | pass 179 | except Exception: 180 | pass 181 | 182 | # ---- WKNavigationDelegate ---- 183 | # def webView:decidePolicyForNavigationAction:decisionHandler: 184 | def webView_decidePolicyForNavigationAction_decisionHandler_(self, webView, navigationAction, decisionHandler): # noqa: N802 185 | try: 186 | req = navigationAction.request() if hasattr(navigationAction, "request") else None 187 | url = req.URL() if (req is not None and hasattr(req, "URL")) else None 188 | host = _extract_host(url) 189 | if host and self._allowed_hosts and host.lower() not in self._allowed_hosts: 190 | # Block navigation to external host 191 | decisionHandler(WKNavigationActionPolicyCancel) 192 | # Optionally, a toast or small tip can be triggered by upper layer 193 | return 194 | decisionHandler(WKNavigationActionPolicyAllow) 195 | except Exception: 196 | # Fail open to avoid bricking navigation 197 | try: 198 | decisionHandler(WKNavigationActionPolicyAllow) 199 | except Exception: 200 | pass 201 | 202 | # def webView:didFailProvisionalNavigation:withError: 203 | def webView_didFailProvisionalNavigation_withError_(self, webView, navigation, error): # noqa: N802 204 | try: 205 | self.show_error_overlay(webView, message="Load failed", on_retry=None) 206 | except Exception: 207 | pass 208 | 209 | # def webView:didFailNavigation:withError: 210 | def webView_didFailNavigation_withError_(self, webView, navigation, error): # noqa: N802 211 | try: 212 | self.show_error_overlay(webView, message="Load failed", on_retry=None) 213 | except Exception: 214 | pass 215 | -------------------------------------------------------------------------------- /tools/round_and_build_icons.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Round all app icons from root logo.png and rebuild .icns. 4 | 5 | Requirements: 6 | - Pillow (pip install pillow) 7 | - macOS iconutil in PATH 8 | 9 | Outputs: 10 | - Rounded PNGs inside src/bubble/logo/icon.iconset/ 11 | - Monochrome rounded status icons: src/bubble/logo/logo_white.png, logo_black.png 12 | - Rebuilt src/bubble/logo/icon.icns 13 | """ 14 | from __future__ import annotations 15 | 16 | import os 17 | import math 18 | import subprocess 19 | from pathlib import Path 20 | from typing import Tuple 21 | 22 | try: 23 | from PIL import Image, ImageDraw, ImageOps, ImageChops, ImageFilter 24 | except Exception as e: # pragma: no cover - helpful error for local runs 25 | raise SystemExit("Pillow not installed. Run: pip install pillow") 26 | 27 | ROOT = Path(__file__).resolve().parents[1] 28 | SRC_LOGO = ROOT / "logo.png" 29 | ASSET_DIR = ROOT / "src" / "bubble" / "logo" 30 | ICONSET = ASSET_DIR / "icon.iconset" 31 | ICNS = ASSET_DIR / "icon.icns" 32 | 33 | # macOS iconset sizes 34 | SIZES: Tuple[int, ...] = (16, 32, 128, 256, 512) 35 | 36 | # Foreground content scale inside the outer shape (1.0 = fill the shape) 37 | ICON_CONTENT_SCALE = 0.95 38 | # Status bar icon should fill the canvas so outer silhouette is rounded 39 | STATUS_CONTENT_SCALE = 1.0 40 | # Outer shape (squircle) scale relative to canvas (e.g., 0.83 = 83% of canvas) 41 | OUTER_SHAPE_SCALE = 0.83 42 | 43 | 44 | def ensure_square_rgba(img: Image.Image, size: int, scale: float) -> Image.Image: 45 | """Return an RGBA square image with full-bleed background + centered content. 46 | - Background: image scaled to COVER the canvas (fills fully, cropped if needed) 47 | - Foreground: image scaled to CONTAIN within `scale*size` then centered 48 | This guarantees the outer silhouette can be a rounded squircle even when content < 100%. 49 | """ 50 | img = img.convert("RGBA") 51 | # Full-bleed background (cover) 52 | cover = ImageOps.fit(img, (size, size), Image.LANCZOS, bleed=0.0, centering=(0.5, 0.5)) 53 | canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) 54 | canvas.paste(cover, (0, 0)) 55 | # Foreground content (contain) 56 | content_px = max(1, int(size * float(scale))) 57 | contained = ImageOps.contain(img, (content_px, content_px), Image.LANCZOS) 58 | x = (size - contained.width) // 2 59 | y = (size - contained.height) // 2 60 | canvas.alpha_composite(contained, dest=(x, y)) 61 | return canvas 62 | 63 | 64 | def rounded_mask(size: int, radius_ratio: float = 0.18) -> Image.Image: 65 | """Simple rounded-rect mask (fallback).""" 66 | r = max(1, int(min(size, size) * radius_ratio)) 67 | mask = Image.new("L", (size, size), 0) 68 | draw = ImageDraw.Draw(mask) 69 | draw.rounded_rectangle([(0, 0), (size, size)], radius=r, fill=255) 70 | return mask 71 | 72 | 73 | def squircle_mask(size: int, exponent: float = 5.0, oversample: int = 4) -> Image.Image: 74 | """Generate an anti-aliased squircle (superellipse) mask approximating macOS Big Sur icon shape. 75 | Uses |x|^n + |y|^n <= 1 with n≈5.0 and oversampling for smooth edges. 76 | """ 77 | W = size * oversample 78 | H = size * oversample 79 | hi = Image.new("L", (W, H), 0) 80 | px = hi.load() 81 | n = float(exponent) 82 | for j in range(H): 83 | # map j -> y in [-1, 1] 84 | y = (j + 0.5) / H * 2.0 - 1.0 85 | ay_n = abs(y) ** n 86 | for i in range(W): 87 | x = (i + 0.5) / W * 2.0 - 1.0 88 | inside = (abs(x) ** n + ay_n) <= 1.0 89 | if inside: 90 | px[i, j] = 255 91 | # downsample to target size for anti-aliased edge 92 | return hi.resize((size, size), Image.LANCZOS) 93 | 94 | 95 | def make_rounded(img: Image.Image, size: int, radius_ratio: float = 0.18, scale: float = 1.0) -> Image.Image: 96 | base = ensure_square_rgba(img, size, scale) 97 | # Create scaled squircle mask centered on the canvas so the whole shape is OUTER_SHAPE_SCALE of canvas 98 | inner_size = max(1, int(size * OUTER_SHAPE_SCALE)) 99 | try: 100 | inner = squircle_mask(inner_size) 101 | except Exception: 102 | inner = rounded_mask(inner_size, radius_ratio) 103 | mask = Image.new("L", (size, size), 0) 104 | ox = (size - inner.size[0]) // 2 105 | oy = (size - inner.size[1]) // 2 106 | mask.paste(inner, (ox, oy)) 107 | base.putalpha(mask) 108 | return base 109 | 110 | 111 | def save_iconset(img: Image.Image, radius_ratio: float = 0.18) -> None: 112 | ICONSET.mkdir(parents=True, exist_ok=True) 113 | # Clear iconset folder first 114 | for p in ICONSET.glob("*.png"): 115 | p.unlink() 116 | for size in SIZES: 117 | base = make_rounded(img, size, radius_ratio, ICON_CONTENT_SCALE) 118 | base.save(ICONSET / f"icon_{size}x{size}.png") 119 | retina = make_rounded(img, size * 2, radius_ratio, ICON_CONTENT_SCALE) 120 | retina.save(ICONSET / f"icon_{size}x{size}@2x.png") 121 | 122 | 123 | def _extract_bubble_mask(img: Image.Image, work_size: int = 256) -> Image.Image: 124 | """Extract central 'bubble' as a binary mask (L-mode) from logo.png. 125 | Heuristic: detect near-white, low-saturation regions, then keep the largest 126 | connected component (the central bubble), discarding sparkles. 127 | Returns an L image at work_size with 0..255 values. 128 | """ 129 | src = ImageOps.contain(img.convert("RGB"), (work_size, work_size), Image.LANCZOS) 130 | hsv = src.convert("HSV") 131 | H, S, V = hsv.split() 132 | # Threshold: low saturation and high value 133 | s_th = 40 # 0..255, lower = more selective (near white) 134 | v_th = 210 # 0..255, higher = brighter 135 | s_px = S.load(); v_px = V.load() 136 | bw = Image.new("L", (src.width, src.height), 0) 137 | m = bw.load() 138 | for y in range(src.height): 139 | for x in range(src.width): 140 | if s_px[x, y] <= s_th and v_px[x, y] >= v_th: 141 | m[x, y] = 255 142 | # Morphological cleanup: close small holes and remove speckles 143 | bw = bw.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.MinFilter(3)) 144 | # Connected-component: keep largest 145 | data = bw.load() 146 | W, Ht = bw.size 147 | visited = [[False]*W for _ in range(Ht)] 148 | best = [] 149 | best_area = 0 150 | from collections import deque 151 | for y in range(Ht): 152 | for x in range(W): 153 | if data[x, y] < 128 or visited[y][x]: 154 | continue 155 | q = deque([(x, y)]) 156 | visited[y][x] = True 157 | comp = [] 158 | while q: 159 | cx, cy = q.popleft() 160 | comp.append((cx, cy)) 161 | for dx, dy in ((1,0),(-1,0),(0,1),(0,-1)): 162 | nx, ny = cx+dx, cy+dy 163 | if 0 <= nx < W and 0 <= ny < Ht and not visited[ny][nx] and data[nx, ny] >= 128: 164 | visited[ny][nx] = True 165 | q.append((nx, ny)) 166 | if len(comp) > best_area: 167 | best_area = len(comp) 168 | best = comp 169 | mask = Image.new("L", (W, Ht), 0) 170 | if best: 171 | mp = mask.load() 172 | for x, y in best: 173 | mp[x, y] = 255 174 | # Smooth edges a touch 175 | mask = mask.filter(ImageFilter.GaussianBlur(0.6)) 176 | return mask 177 | 178 | 179 | def save_status_icons(img: Image.Image, radius_ratio: float = 0.25) -> None: 180 | # Build transparent background status icons with only the central bubble filled 181 | size = 18 182 | mask = _extract_bubble_mask(img, work_size=256) 183 | # Fit mask into 18x18 with small padding 184 | bbox = mask.getbbox() or (0,0,mask.width, mask.height) 185 | crop = mask.crop(bbox) 186 | # Scale to 18 with slight margin 187 | pad_scale = 0.92 188 | target = int(size * pad_scale) 189 | scaled = crop.resize((target, target), Image.LANCZOS) 190 | # Center onto transparent canvas 191 | alpha = Image.new("L", (size, size), 0) 192 | ox = (size - target)//2 193 | oy = (size - target)//2 194 | alpha.paste(scaled, (ox, oy)) 195 | # Compose white and black variants 196 | for name, color in (("logo_white.png", (255,255,255,255)), ("logo_black.png", (0,0,0,255))): 197 | out = Image.new("RGBA", (size, size), (0,0,0,0)) 198 | fg = Image.new("RGBA", (size, size), color) 199 | out = Image.composite(fg, out, alpha) 200 | out.save(ASSET_DIR / name) 201 | 202 | 203 | def build_icns() -> None: 204 | if not ICONSET.exists(): 205 | raise SystemExit(f"Iconset not found: {ICONSET}") 206 | cmd = [ 207 | "/usr/bin/iconutil", 208 | "--convert", 209 | "icns", 210 | "--output", 211 | str(ICNS), 212 | str(ICONSET), 213 | ] 214 | subprocess.run(cmd, check=True) 215 | 216 | 217 | def main(): 218 | if not SRC_LOGO.exists(): 219 | raise SystemExit(f"Source logo not found: {SRC_LOGO}") 220 | img = Image.open(SRC_LOGO) 221 | # Slight rounding suitable for macOS style 222 | radius_ratio = 0.18 223 | save_iconset(img, radius_ratio) 224 | save_status_icons(img, radius_ratio=0.25) 225 | build_icns() 226 | print("Rounded icons generated and .icns rebuilt.") 227 | 228 | 229 | if __name__ == "__main__": 230 | main() 231 | -------------------------------------------------------------------------------- /src/bubble/models/platform_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | AI平台配置数据模型 3 | 4 | 定义 AI 平台的配置信息和服务配置,支持多窗口系统的基础数据结构。 5 | """ 6 | 7 | from dataclasses import dataclass, field 8 | from typing import Dict, Optional, List 9 | from enum import Enum 10 | 11 | 12 | class PlatformType(Enum): 13 | """AI平台类型枚举""" 14 | OPENAI = "openai" 15 | GEMINI = "gemini" 16 | GROK = "grok" 17 | CLAUDE = "claude" 18 | DEEPSEEK = "deepseek" 19 | ZAI = "zai" 20 | QWEN = "qwen" 21 | KIMI = "kimi" 22 | 23 | 24 | @dataclass 25 | class AIServiceConfig: 26 | """ 27 | AI服务配置类 28 | 29 | 定义单个AI服务的基本配置信息,包括URL、显示名称、状态等。 30 | """ 31 | platform_id: str 32 | name: str 33 | url: str 34 | display_name: str 35 | enabled: bool = True 36 | max_windows: int = 5 37 | description: Optional[str] = None 38 | icon_path: Optional[str] = None 39 | 40 | def __post_init__(self): 41 | """初始化后的验证""" 42 | if self.max_windows < 1: 43 | raise ValueError("max_windows必须大于0") 44 | 45 | def to_dict(self) -> Dict: 46 | """转换为字典格式""" 47 | return { 48 | "platform_id": self.platform_id, 49 | "name": self.name, 50 | "url": self.url, 51 | "display_name": self.display_name, 52 | "enabled": self.enabled, 53 | "max_windows": self.max_windows, 54 | "description": self.description, 55 | "icon_path": self.icon_path 56 | } 57 | 58 | @classmethod 59 | def from_dict(cls, data: Dict) -> 'AIServiceConfig': 60 | """从字典创建实例""" 61 | return cls( 62 | platform_id=data["platform_id"], 63 | name=data["name"], 64 | url=data["url"], 65 | display_name=data["display_name"], 66 | enabled=data.get("enabled", True), 67 | max_windows=data.get("max_windows", 5), 68 | description=data.get("description"), 69 | icon_path=data.get("icon_path") 70 | ) 71 | 72 | 73 | @dataclass 74 | class PlatformConfig: 75 | """ 76 | AI平台配置管理器 77 | 78 | 管理所有AI平台的配置信息,提供平台的增删改查操作。 79 | 支持多窗口系统的平台配置管理。 80 | """ 81 | platforms: Dict[str, AIServiceConfig] = field(default_factory=dict) 82 | default_platform: Optional[str] = None 83 | enabled_platforms: List[str] = field(default_factory=list) 84 | max_total_platforms: int = 7 85 | # None 表示启用平台数量不限制 86 | max_enabled_platforms: Optional[int] = None 87 | 88 | def __post_init__(self): 89 | """初始化默认平台配置""" 90 | if not self.platforms: 91 | self._load_default_platforms() 92 | 93 | def _load_default_platforms(self): 94 | """加载默认AI平台配置""" 95 | default_platforms = [ 96 | AIServiceConfig( 97 | platform_id="openai", 98 | name="ChatGPT", 99 | url="https://chat.openai.com", 100 | display_name="OpenAI ChatGPT", 101 | description="OpenAI的先进对话AI模型" 102 | ), 103 | AIServiceConfig( 104 | platform_id="gemini", 105 | name="Gemini", 106 | url="https://gemini.google.com", 107 | display_name="Google Gemini", 108 | description="Google的多模态AI助手" 109 | ), 110 | AIServiceConfig( 111 | platform_id="grok", 112 | name="Grok", 113 | url="https://grok.com", 114 | display_name="xAI Grok", 115 | description="xAI的幽默风趣AI助手" 116 | ), 117 | AIServiceConfig( 118 | platform_id="claude", 119 | name="Claude", 120 | url="https://claude.ai/chat", 121 | display_name="Anthropic Claude", 122 | description="Anthropic的安全可靠AI助手" 123 | ), 124 | AIServiceConfig( 125 | platform_id="deepseek", 126 | name="DeepSeek", 127 | url="https://chat.deepseek.com", 128 | display_name="DeepSeek Chat", 129 | description="深度求索的中文AI对话模型" 130 | ), 131 | AIServiceConfig( 132 | platform_id="zai", 133 | name="GLM", 134 | url="https://chat.z.ai/", 135 | display_name="GLM", 136 | description="智能对话AI平台" 137 | ), 138 | AIServiceConfig( 139 | platform_id="qwen", 140 | name="Qwen", 141 | url="https://chat.qwen.ai/", 142 | display_name="Qwen", 143 | description="阿里云的大语言模型" 144 | ), 145 | AIServiceConfig( 146 | platform_id="kimi", 147 | name="Kimi", 148 | url="https://www.kimi.com/", 149 | display_name="Kimi", 150 | description="Moonshot AI 的 Kimi" 151 | ) 152 | ] 153 | 154 | for platform in default_platforms: 155 | self.platforms[platform.platform_id] = platform 156 | 157 | def add_platform(self, platform_config: AIServiceConfig) -> bool: 158 | """ 159 | 添加AI平台配置 160 | 161 | Args: 162 | platform_config: 平台配置对象 163 | 164 | Returns: 165 | bool: 添加是否成功 166 | """ 167 | if len(self.platforms) >= self.max_total_platforms: 168 | return False 169 | 170 | if platform_config.platform_id in self.platforms: 171 | return False 172 | 173 | self.platforms[platform_config.platform_id] = platform_config 174 | return True 175 | 176 | def remove_platform(self, platform_id: str) -> bool: 177 | """ 178 | 移除AI平台配置 179 | 180 | Args: 181 | platform_id: 平台标识符 182 | 183 | Returns: 184 | bool: 移除是否成功 185 | """ 186 | if platform_id in self.platforms: 187 | del self.platforms[platform_id] 188 | 189 | # 从启用列表中移除 190 | if platform_id in self.enabled_platforms: 191 | self.enabled_platforms.remove(platform_id) 192 | 193 | # 如果是默认平台,清除默认设置 194 | if self.default_platform == platform_id: 195 | self.default_platform = None 196 | # 如果还有启用的平台,设置第一个为默认 197 | if self.enabled_platforms: 198 | self.default_platform = self.enabled_platforms[0] 199 | 200 | return True 201 | return False 202 | 203 | def enable_platform(self, platform_id: str) -> bool: 204 | """ 205 | 启用AI平台 206 | 207 | Args: 208 | platform_id: 平台标识符 209 | 210 | Returns: 211 | bool: 启用是否成功 212 | """ 213 | if platform_id not in self.platforms: 214 | return False 215 | 216 | if platform_id not in self.enabled_platforms: 217 | # 当设置了上限时才进行限制检查;None 表示不限制 218 | if isinstance(self.max_enabled_platforms, int) and len(self.enabled_platforms) >= self.max_enabled_platforms: 219 | return False 220 | self.enabled_platforms.append(platform_id) 221 | self.platforms[platform_id].enabled = True 222 | 223 | # 如果没有默认平台,设置为默认 224 | if not self.default_platform: 225 | self.default_platform = platform_id 226 | 227 | return True 228 | 229 | def disable_platform(self, platform_id: str) -> bool: 230 | """ 231 | 禁用AI平台 232 | 233 | Args: 234 | platform_id: 平台标识符 235 | 236 | Returns: 237 | bool: 禁用是否成功 238 | """ 239 | if platform_id in self.enabled_platforms: 240 | self.enabled_platforms.remove(platform_id) 241 | if platform_id in self.platforms: 242 | self.platforms[platform_id].enabled = False 243 | 244 | # 如果禁用的是默认平台,重新设置默认 245 | if self.default_platform == platform_id: 246 | self.default_platform = self.enabled_platforms[0] if self.enabled_platforms else None 247 | 248 | return True 249 | return False 250 | 251 | def set_default_platform(self, platform_id: str) -> bool: 252 | """ 253 | 设置默认AI平台 254 | 255 | Args: 256 | platform_id: 平台标识符 257 | 258 | Returns: 259 | bool: 设置是否成功 260 | """ 261 | if platform_id in self.platforms and platform_id in self.enabled_platforms: 262 | self.default_platform = platform_id 263 | return True 264 | return False 265 | 266 | def get_platform(self, platform_id: str) -> Optional[AIServiceConfig]: 267 | """获取指定平台配置""" 268 | return self.platforms.get(platform_id) 269 | 270 | def get_enabled_platforms(self) -> List[AIServiceConfig]: 271 | """获取已启用的平台列表""" 272 | return [self.platforms[pid] for pid in self.enabled_platforms if pid in self.platforms] 273 | 274 | def get_all_platforms(self) -> List[AIServiceConfig]: 275 | """获取所有平台列表""" 276 | return list(self.platforms.values()) 277 | 278 | def to_dict(self) -> Dict: 279 | """转换为字典格式""" 280 | return { 281 | "platforms": {pid: platform.to_dict() for pid, platform in self.platforms.items()}, 282 | "default_platform": self.default_platform, 283 | "enabled_platforms": self.enabled_platforms, 284 | "max_total_platforms": self.max_total_platforms 285 | } 286 | 287 | @classmethod 288 | def from_dict(cls, data: Dict) -> 'PlatformConfig': 289 | """从字典创建实例""" 290 | instance = cls( 291 | platforms={}, 292 | default_platform=data.get("default_platform"), 293 | enabled_platforms=data.get("enabled_platforms", []), 294 | max_total_platforms=data.get("max_total_platforms", 7) 295 | ) 296 | 297 | # 加载平台配置 298 | platforms_data = data.get("platforms", {}) 299 | for pid, platform_data in platforms_data.items(): 300 | instance.platforms[pid] = AIServiceConfig.from_dict(platform_data) 301 | 302 | return instance 303 | -------------------------------------------------------------------------------- /src/bubble/models/ai_window.py: -------------------------------------------------------------------------------- 1 | """ 2 | AI窗口实例数据模型 3 | 4 | 定义AI窗口的状态管理和实例信息,支持多窗口同平台管理。 5 | """ 6 | 7 | from dataclasses import dataclass, field 8 | from typing import Dict, Optional, List, Tuple 9 | from enum import Enum 10 | from datetime import datetime 11 | import uuid 12 | 13 | 14 | class WindowState(Enum): 15 | """窗口状态枚举""" 16 | INACTIVE = "inactive" # 未激活 17 | ACTIVE = "active" # 激活中 18 | MINIMIZED = "minimized" # 最小化 19 | HIDDEN = "hidden" # 隐藏 20 | LOADING = "loading" # 加载中 21 | ERROR = "error" # 错误状态 22 | 23 | 24 | class WindowType(Enum): 25 | """窗口类型枚举""" 26 | MAIN = "main" # 主窗口 27 | SECONDARY = "secondary" # 副窗口 28 | POPUP = "popup" # 弹出窗口 29 | 30 | 31 | @dataclass 32 | class WindowGeometry: 33 | """窗口几何信息""" 34 | x: int = 100 35 | y: int = 100 36 | width: int = 800 37 | height: int = 600 38 | 39 | def to_dict(self) -> Dict: 40 | """转换为字典格式""" 41 | return { 42 | "x": self.x, 43 | "y": self.y, 44 | "width": self.width, 45 | "height": self.height 46 | } 47 | 48 | @classmethod 49 | def from_dict(cls, data: Dict) -> 'WindowGeometry': 50 | """从字典创建实例""" 51 | return cls( 52 | x=data.get("x", 100), 53 | y=data.get("y", 100), 54 | width=data.get("width", 800), 55 | height=data.get("height", 600) 56 | ) 57 | 58 | 59 | @dataclass 60 | class AIWindow: 61 | """ 62 | AI窗口实例类 63 | 64 | 管理单个 AI 窗口的状态、配置和生命周期。 65 | 支持多窗口同平台管理和窗口状态追踪。 66 | """ 67 | window_id: str 68 | platform_id: str 69 | window_type: WindowType = WindowType.MAIN 70 | state: WindowState = WindowState.INACTIVE 71 | geometry: WindowGeometry = field(default_factory=WindowGeometry) 72 | created_at: datetime = field(default_factory=datetime.now) 73 | last_active_at: Optional[datetime] = None 74 | url: Optional[str] = None 75 | title: Optional[str] = None 76 | user_agent: Optional[str] = None 77 | session_data: Dict = field(default_factory=dict) 78 | 79 | def __post_init__(self): 80 | """初始化后处理""" 81 | if not self.window_id: 82 | self.window_id = str(uuid.uuid4()) 83 | 84 | def activate(self): 85 | """激活窗口""" 86 | self.state = WindowState.ACTIVE 87 | self.last_active_at = datetime.now() 88 | 89 | def deactivate(self): 90 | """取消激活窗口""" 91 | self.state = WindowState.INACTIVE 92 | 93 | def minimize(self): 94 | """最小化窗口""" 95 | self.state = WindowState.MINIMIZED 96 | 97 | def hide(self): 98 | """隐藏窗口""" 99 | self.state = WindowState.HIDDEN 100 | 101 | def show_loading(self): 102 | """显示加载状态""" 103 | self.state = WindowState.LOADING 104 | 105 | def set_error(self): 106 | """设置错误状态""" 107 | self.state = WindowState.ERROR 108 | 109 | def update_geometry(self, x: int, y: int, width: int, height: int): 110 | """更新窗口几何信息""" 111 | self.geometry.x = x 112 | self.geometry.y = y 113 | self.geometry.width = width 114 | self.geometry.height = height 115 | 116 | def update_url(self, url: str): 117 | """更新窗口URL""" 118 | self.url = url 119 | 120 | def update_title(self, title: str): 121 | """更新窗口标题""" 122 | self.title = title 123 | 124 | def set_session_data(self, key: str, value: any): 125 | """设置会话数据""" 126 | self.session_data[key] = value 127 | 128 | def get_session_data(self, key: str, default=None): 129 | """获取会话数据""" 130 | return self.session_data.get(key, default) 131 | 132 | def is_active(self) -> bool: 133 | """检查窗口是否激活""" 134 | return self.state == WindowState.ACTIVE 135 | 136 | def is_visible(self) -> bool: 137 | """检查窗口是否可见""" 138 | return self.state in [WindowState.ACTIVE, WindowState.INACTIVE, WindowState.LOADING] 139 | 140 | def get_display_name(self) -> str: 141 | """获取窗口显示名称""" 142 | return self.title or f"{self.platform_id}-{self.window_id[:8]}" 143 | 144 | def to_dict(self) -> Dict: 145 | """转换为字典格式""" 146 | return { 147 | "window_id": self.window_id, 148 | "platform_id": self.platform_id, 149 | "window_type": self.window_type.value, 150 | "state": self.state.value, 151 | "geometry": self.geometry.to_dict(), 152 | "created_at": self.created_at.isoformat(), 153 | "last_active_at": self.last_active_at.isoformat() if self.last_active_at else None, 154 | "url": self.url, 155 | "title": self.title, 156 | "user_agent": self.user_agent, 157 | "session_data": self.session_data 158 | } 159 | 160 | @classmethod 161 | def from_dict(cls, data: Dict) -> 'AIWindow': 162 | """从字典创建实例""" 163 | return cls( 164 | window_id=data["window_id"], 165 | platform_id=data["platform_id"], 166 | window_type=WindowType(data.get("window_type", "main")), 167 | state=WindowState(data.get("state", "inactive")), 168 | geometry=WindowGeometry.from_dict(data.get("geometry", {})), 169 | created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.now(), 170 | last_active_at=datetime.fromisoformat(data["last_active_at"]) if data.get("last_active_at") else None, 171 | url=data.get("url"), 172 | title=data.get("title"), 173 | user_agent=data.get("user_agent"), 174 | session_data=data.get("session_data", {}) 175 | ) 176 | 177 | 178 | @dataclass 179 | class WindowManager: 180 | """ 181 | 窗口管理器类 182 | 183 | 管理所有AI窗口实例,提供窗口的创建、删除、查找和状态管理功能。 184 | 支持多窗口同平台管理;默认不限制窗口数量,若配置上限则按上限约束。 185 | """ 186 | windows: Dict[str, AIWindow] = field(default_factory=dict) 187 | active_window_id: Optional[str] = None 188 | # None 表示不限制数量;保持向后兼容,默认不限制 189 | max_windows_per_platform: Optional[int] = None 190 | max_total_windows: Optional[int] = None 191 | 192 | def create_window(self, platform_id: str, window_type: WindowType = WindowType.MAIN, 193 | geometry: Optional[WindowGeometry] = None) -> Optional[AIWindow]: 194 | """ 195 | 创建新窗口 196 | 197 | Args: 198 | platform_id: 平台标识符 199 | window_type: 窗口类型 200 | geometry: 窗口几何信息 201 | 202 | Returns: 203 | Optional[AIWindow]: 创建的窗口实例,如果失败返回None 204 | """ 205 | # 检查总窗口与平台窗口数量限制(None 表示不限制) 206 | if isinstance(self.max_total_windows, int) and len(self.windows) >= self.max_total_windows: 207 | return None 208 | platform_windows = self.get_platform_windows(platform_id) 209 | if isinstance(self.max_windows_per_platform, int) and len(platform_windows) >= self.max_windows_per_platform: 210 | return None 211 | 212 | # 创建新窗口 213 | window = AIWindow( 214 | window_id=str(uuid.uuid4()), 215 | platform_id=platform_id, 216 | window_type=window_type, 217 | geometry=geometry or WindowGeometry() 218 | ) 219 | 220 | self.windows[window.window_id] = window 221 | return window 222 | 223 | def remove_window(self, window_id: str) -> bool: 224 | """ 225 | 移除窗口 226 | 227 | Args: 228 | window_id: 窗口标识符 229 | 230 | Returns: 231 | bool: 移除是否成功 232 | """ 233 | if window_id in self.windows: 234 | del self.windows[window_id] 235 | 236 | # 如果移除的是活动窗口,清除活动窗口设置 237 | if self.active_window_id == window_id: 238 | self.active_window_id = None 239 | 240 | return True 241 | return False 242 | 243 | def get_window(self, window_id: str) -> Optional[AIWindow]: 244 | """获取指定窗口""" 245 | return self.windows.get(window_id) 246 | 247 | def get_platform_windows(self, platform_id: str) -> List[AIWindow]: 248 | """获取指定平台的所有窗口""" 249 | return [window for window in self.windows.values() if window.platform_id == platform_id] 250 | 251 | def get_active_window(self) -> Optional[AIWindow]: 252 | """获取当前活动窗口""" 253 | if self.active_window_id: 254 | return self.windows.get(self.active_window_id) 255 | return None 256 | 257 | def set_active_window(self, window_id: str) -> bool: 258 | """ 259 | 设置活动窗口 260 | 261 | Args: 262 | window_id: 窗口标识符 263 | 264 | Returns: 265 | bool: 设置是否成功 266 | """ 267 | if window_id in self.windows: 268 | # 取消之前活动窗口的激活状态 269 | if self.active_window_id and self.active_window_id in self.windows: 270 | self.windows[self.active_window_id].deactivate() 271 | 272 | # 激活新窗口 273 | self.windows[window_id].activate() 274 | self.active_window_id = window_id 275 | return True 276 | return False 277 | 278 | def get_visible_windows(self) -> List[AIWindow]: 279 | """获取所有可见窗口""" 280 | return [window for window in self.windows.values() if window.is_visible()] 281 | 282 | def get_all_windows(self) -> List[AIWindow]: 283 | """获取所有窗口""" 284 | return list(self.windows.values()) 285 | 286 | def close_platform_windows(self, platform_id: str) -> int: 287 | """ 288 | 关闭指定平台的所有窗口 289 | 290 | Args: 291 | platform_id: 平台标识符 292 | 293 | Returns: 294 | int: 关闭的窗口数量 295 | """ 296 | platform_windows = self.get_platform_windows(platform_id) 297 | count = 0 298 | 299 | for window in platform_windows: 300 | if self.remove_window(window.window_id): 301 | count += 1 302 | 303 | return count 304 | 305 | def get_platform_window_count(self, platform_id: str) -> int: 306 | """获取指定平台的窗口数量""" 307 | return len(self.get_platform_windows(platform_id)) 308 | 309 | def can_create_window(self, platform_id: str) -> bool: 310 | """检查是否可以为指定平台创建新窗口(默认不限制)。""" 311 | if isinstance(self.max_total_windows, int) and len(self.windows) >= self.max_total_windows: 312 | return False 313 | if isinstance(self.max_windows_per_platform, int) and self.get_platform_window_count(platform_id) >= self.max_windows_per_platform: 314 | return False 315 | return True 316 | 317 | def to_dict(self) -> Dict: 318 | """转换为字典格式""" 319 | return { 320 | "windows": {wid: window.to_dict() for wid, window in self.windows.items()}, 321 | "active_window_id": self.active_window_id, 322 | "max_windows_per_platform": self.max_windows_per_platform, 323 | "max_total_windows": self.max_total_windows 324 | } 325 | 326 | @classmethod 327 | def from_dict(cls, data: Dict) -> 'WindowManager': 328 | """从字典创建实例""" 329 | # 默认不限制窗口数量:当配置缺失时,将上限设为 None 330 | instance = cls( 331 | windows={}, 332 | active_window_id=data.get("active_window_id"), 333 | max_windows_per_platform=data.get("max_windows_per_platform", None), 334 | max_total_windows=data.get("max_total_windows", None) 335 | ) 336 | 337 | # 加载窗口数据 338 | windows_data = data.get("windows", {}) 339 | for wid, window_data in windows_data.items(): 340 | instance.windows[wid] = AIWindow.from_dict(window_data) 341 | 342 | return instance 343 | -------------------------------------------------------------------------------- /src/bubble/components/config_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | ConfigManager: manages user configuration persistence for Bubble. 3 | 4 | Uses the new Bubble path; retains migration support from BubbleBot. 5 | Responsibilities here focus on default language detection and language get/set. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import json 11 | import os 12 | from typing import Any, Dict, Optional 13 | 14 | try: 15 | # Optional dependency; only available on macOS with PyObjC 16 | from Foundation import NSLocale 17 | except Exception: # pragma: no cover 18 | NSLocale = None # type: ignore 19 | 20 | 21 | class ConfigManager: 22 | _FILENAME = "config.json" 23 | 24 | @classmethod 25 | def _new_app_support_dir(cls) -> str: 26 | return os.path.expanduser("~/Library/Application Support/Bubble") 27 | 28 | @classmethod 29 | def _old_app_support_dir(cls) -> str: 30 | return os.path.expanduser("~/Library/Application Support/BubbleBot") 31 | 32 | @classmethod 33 | def config_path(cls) -> str: 34 | # New location after Task 0.2 migration 35 | return os.path.join(cls._new_app_support_dir(), cls._FILENAME) 36 | 37 | @classmethod 38 | def legacy_config_path(cls) -> str: 39 | # Legacy (pre-rename) location 40 | return os.path.join(cls._old_app_support_dir(), cls._FILENAME) 41 | 42 | @classmethod 43 | def _ensure_dir(cls) -> None: 44 | d = os.path.dirname(cls.config_path()) 45 | os.makedirs(d, exist_ok=True) 46 | 47 | @classmethod 48 | def load(cls) -> Dict[str, Any]: 49 | p = cls.config_path() 50 | try: 51 | if os.path.exists(p): 52 | with open(p, "r", encoding="utf-8") as f: 53 | return json.load(f) 54 | # Fallback read from legacy path (no writeback here; migration handles that) 55 | lp = cls.legacy_config_path() 56 | if os.path.exists(lp): 57 | with open(lp, "r", encoding="utf-8") as f: 58 | return json.load(f) 59 | except Exception: 60 | pass 61 | return {} 62 | 63 | @classmethod 64 | def save(cls, cfg: Dict[str, Any]) -> None: 65 | try: 66 | cls._ensure_dir() 67 | with open(cls.config_path(), "w", encoding="utf-8") as f: 68 | json.dump(cfg, f, indent=2, ensure_ascii=False) 69 | except Exception as e: # pragma: no cover 70 | print(f"WARNING[config]: failed to save config: {e}") 71 | 72 | @classmethod 73 | def get_language(cls) -> Optional[str]: 74 | cfg = cls.load() 75 | lang = cfg.get("language") 76 | if isinstance(lang, str) and lang: 77 | return lang 78 | return None 79 | 80 | @classmethod 81 | def set_language(cls, lang: str) -> None: 82 | cfg = cls.load() 83 | cfg["language"] = lang 84 | cls.save(cfg) 85 | 86 | # ----- Suspend (sleep) minutes ----- 87 | @classmethod 88 | def get_suspend_minutes(cls, default: int = 30) -> int: 89 | """Return configured suspend minutes; 0 means disabled. 90 | 91 | If key missing or invalid, return default (30). 92 | """ 93 | try: 94 | cfg = cls.load() 95 | val = cfg.get("suspend", {}).get("minutes", default) 96 | minutes = int(val) 97 | # 0 or greater allowed; negative treated as default 98 | return minutes if minutes >= 0 else default 99 | except Exception: 100 | return default 101 | 102 | @classmethod 103 | def set_suspend_minutes(cls, minutes: int) -> None: 104 | cfg = cls.load() 105 | suspend = cfg.get("suspend") if isinstance(cfg.get("suspend"), dict) else {} 106 | try: 107 | suspend["minutes"] = int(minutes) 108 | except Exception: 109 | suspend["minutes"] = minutes 110 | cfg["suspend"] = suspend 111 | cls.save(cfg) 112 | 113 | # ----- Navigation allow hosts ----- 114 | @classmethod 115 | def get_allowed_hosts(cls) -> list: 116 | try: 117 | cfg = cls.load() 118 | val = cfg.get("navigation", {}).get("allow_hosts", []) 119 | return list(val) if isinstance(val, (list, tuple)) else [] 120 | except Exception: 121 | return [] 122 | 123 | @classmethod 124 | def set_allowed_hosts(cls, hosts: list[str]) -> None: 125 | cfg = cls.load() 126 | nav = cfg.get("navigation") if isinstance(cfg.get("navigation"), dict) else {} 127 | nav["allow_hosts"] = list(hosts or []) 128 | cfg["navigation"] = nav 129 | cls.save(cfg) 130 | 131 | # ----- Hotkeys: switcher (cycle window) ----- 132 | @classmethod 133 | def get_switcher_hotkey(cls) -> dict: 134 | try: 135 | cfg = cls.load() 136 | hk = cfg.get("hotkeys", {}).get("switcher", {}) 137 | if isinstance(hk, dict) and "flags" in hk and "key" in hk: 138 | return {"flags": hk["flags"], "key": hk["key"]} 139 | except Exception: 140 | pass 141 | return {} 142 | 143 | @classmethod 144 | def set_switcher_hotkey(cls, flags: int, key: int) -> None: 145 | cfg = cls.load() 146 | hot = cfg.get("hotkeys") if isinstance(cfg.get("hotkeys"), dict) else {} 147 | hot["switcher"] = {"flags": int(flags), "key": int(key)} 148 | cfg["hotkeys"] = hot 149 | cls.save(cfg) 150 | 151 | # ----- Appearance mode (theme) ----- 152 | @classmethod 153 | def get_appearance_mode(cls, default: str = "auto") -> str: 154 | """Get user's appearance mode preference. 155 | 156 | Returns: 157 | "auto" (follow system), "light", or "dark" 158 | """ 159 | try: 160 | cfg = cls.load() 161 | mode = cfg.get("appearance", {}).get("mode", default) 162 | if mode in ("auto", "light", "dark"): 163 | return mode 164 | return default 165 | except Exception: 166 | return default 167 | 168 | @classmethod 169 | def set_appearance_mode(cls, mode: str) -> None: 170 | """Set user's appearance mode preference. 171 | 172 | Args: 173 | mode: "auto", "light", or "dark" 174 | """ 175 | if mode not in ("auto", "light", "dark"): 176 | mode = "auto" 177 | cfg = cls.load() 178 | appearance = cfg.get("appearance") if isinstance(cfg.get("appearance"), dict) else {} 179 | appearance["mode"] = mode 180 | cfg["appearance"] = appearance 181 | cls.save(cfg) 182 | 183 | @classmethod 184 | def migrate_config_if_needed(cls) -> bool: 185 | """Migrate config from BubbleBot -> Bubble, keeping a backup and flagging a one-time notice. 186 | 187 | Returns True if a migration was performed. 188 | """ 189 | try: 190 | new_p = cls.config_path() 191 | old_p = cls.legacy_config_path() 192 | # Ensure new dir exists 193 | os.makedirs(os.path.dirname(new_p), exist_ok=True) 194 | if os.path.exists(new_p): 195 | return False # already migrated/created 196 | if not os.path.exists(old_p): 197 | return False # nothing to migrate 198 | 199 | # Copy old -> new 200 | try: 201 | with open(old_p, "r", encoding="utf-8") as f: 202 | data = json.load(f) 203 | except Exception: 204 | data = {} 205 | 206 | # Stamp migration meta and set notice flag 207 | meta = data.get("meta") if isinstance(data.get("meta"), dict) else {} 208 | meta.update({ 209 | "migrated_from": "BubbleBot", 210 | "migration_notice_pending": True, 211 | "legacy_path": old_p, 212 | }) 213 | data["meta"] = meta 214 | 215 | # Save to new path 216 | with open(new_p, "w", encoding="utf-8") as f: 217 | json.dump(data, f, indent=2, ensure_ascii=False) 218 | 219 | # Also create a timestamped backup alongside the new file and keep ≤5 220 | try: 221 | import datetime 222 | ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S") 223 | backup_p = os.path.join(os.path.dirname(new_p), f"config.backup.{ts}.json") 224 | with open(backup_p, "w", encoding="utf-8") as bf: 225 | json.dump(data, bf, indent=2, ensure_ascii=False) 226 | # prune old backups beyond 5 227 | prefix = os.path.join(os.path.dirname(new_p), "config.backup.") 228 | backups = [p for p in os.listdir(os.path.dirname(new_p)) if p.startswith("config.backup.") and p.endswith('.json')] 229 | backups.sort(reverse=True) 230 | for old in backups[5:]: 231 | try: 232 | os.remove(os.path.join(os.path.dirname(new_p), old)) 233 | except Exception: 234 | pass 235 | except Exception: 236 | pass 237 | return True 238 | except Exception as e: 239 | print(f"WARNING[config]: migration failed: {e}") 240 | return False 241 | 242 | @classmethod 243 | def needs_migration_notice(cls) -> bool: 244 | try: 245 | cfg = cls.load() 246 | meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} 247 | return bool(meta.get("migration_notice_pending", False)) 248 | except Exception: 249 | return False 250 | 251 | @classmethod 252 | def mark_migration_notice_shown(cls) -> None: 253 | try: 254 | cfg = cls.load() 255 | meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} 256 | meta["migration_notice_pending"] = False 257 | meta["migration_notice_shown"] = True 258 | cfg["meta"] = meta 259 | cls.save(cfg) 260 | except Exception: 261 | pass 262 | 263 | # ----- First-run flags: onboarding / permissions ----- 264 | @classmethod 265 | def is_onboarding_shown(cls) -> bool: 266 | try: 267 | cfg = cls.load() 268 | meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} 269 | return bool(meta.get("onboarding_shown", False)) 270 | except Exception: 271 | return False 272 | 273 | @classmethod 274 | def mark_onboarding_shown(cls) -> None: 275 | try: 276 | cfg = cls.load() 277 | meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} 278 | meta["onboarding_shown"] = True 279 | cfg["meta"] = meta 280 | cls.save(cfg) 281 | except Exception: 282 | pass 283 | 284 | @classmethod 285 | def is_permissions_prompted(cls) -> bool: 286 | try: 287 | cfg = cls.load() 288 | meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} 289 | return bool(meta.get("permissions_prompted", False)) 290 | except Exception: 291 | return False 292 | 293 | @classmethod 294 | def mark_permissions_prompted(cls) -> None: 295 | try: 296 | cfg = cls.load() 297 | meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} 298 | meta["permissions_prompted"] = True 299 | cfg["meta"] = meta 300 | cls.save(cfg) 301 | except Exception: 302 | pass 303 | 304 | @classmethod 305 | def detect_system_language(cls) -> str: 306 | # Preferred: macOS NSLocale preferredLanguages (e.g. zh-Hans-CN) 307 | code: Optional[str] = None 308 | try: 309 | if NSLocale is not None: 310 | arr = NSLocale.preferredLanguages() 311 | if arr and len(arr) > 0: 312 | code = str(arr[0]) 313 | except Exception: 314 | code = None 315 | # Fallbacks: environment LANG or locale module 316 | if not code: 317 | code = os.environ.get("LANG", "en") 318 | code = code.lower().replace("_", "-") 319 | # Normalize to supported set 320 | if code.startswith("zh"): 321 | return "zh" 322 | if code.startswith("ja"): 323 | return "ja" 324 | if code.startswith("ko"): 325 | return "ko" 326 | if code.startswith("fr"): 327 | return "fr" 328 | return "en" 329 | -------------------------------------------------------------------------------- /src/bubble/components/navigation_controller.py: -------------------------------------------------------------------------------- 1 | """ 2 | 导航控制器 (Navigation Controller) 3 | 4 | 该模块负责管理 Bubble 应用的页面导航功能,包括: 5 | - 主页与聊天页面之间的导航 6 | - 返回按钮的显示和处理 7 | - 页面状态的管理和切换 8 | - 窗口间的无缝切换 9 | 10 | 导航控制器与主页管理器和窗口管理器协作,提供流畅的用户体验。 11 | """ 12 | 13 | from typing import Optional, Callable, Dict, Any 14 | import objc 15 | from Foundation import NSObject, NSDate 16 | from AppKit import NSApp 17 | 18 | 19 | class NavigationController(NSObject): 20 | """ 21 | 导航控制器类 22 | 23 | 负责管理应用内的页面导航,包括主页显示、聊天页面切换、 24 | 返回按钮处理等功能。 25 | """ 26 | 27 | def init(self, app_delegate=None): 28 | """初始化导航控制器""" 29 | self = objc.super(NavigationController, self).init() 30 | if self is None: 31 | return None 32 | self.app_delegate = app_delegate 33 | self.current_page = "homepage" # 当前页面状态: homepage, chat 34 | self.current_platform = None # 当前选中的AI平台 35 | self.current_window_id = None # 当前窗口ID 36 | self.navigation_history = [] # 导航历史栈 37 | self.page_change_listeners = [] # 页面变化监听器 38 | 39 | # 页面状态管理 40 | self.page_states = { 41 | "homepage": { 42 | "title": "Bubble", 43 | "show_back_button": False, 44 | "show_ai_selector": False, 45 | "content_type": "homepage" 46 | }, 47 | "chat": { 48 | "title": "Bubble - Chat", 49 | "show_back_button": True, 50 | "show_ai_selector": True, 51 | "content_type": "webview" 52 | } 53 | } 54 | return self 55 | 56 | def on_language_changed(self): 57 | """Handle language change at runtime. 58 | Currently titles are static; this hook ensures UI gets a chance to refresh. 59 | """ 60 | try: 61 | # Re-apply UI elements (back button, selector), and window title 62 | self.update_ui_elements() 63 | if self.app_delegate and hasattr(self.app_delegate, 'handle_navigation_change'): 64 | self.app_delegate.handle_navigation_change(self.current_page, self.current_platform) 65 | except Exception as e: 66 | print(f"语言切换后刷新导航失败: {e}") 67 | 68 | def set_app_delegate(self, app_delegate): 69 | """设置应用委托""" 70 | self.app_delegate = app_delegate 71 | 72 | def add_page_change_listener(self, listener: Callable[[str, str], None]): 73 | """ 74 | 添加页面变化监听器 75 | 76 | Args: 77 | listener: 监听函数,接收 (from_page, to_page) 参数 78 | """ 79 | if listener not in self.page_change_listeners: 80 | self.page_change_listeners.append(listener) 81 | 82 | def remove_page_change_listener(self, listener: Callable[[str, str], None]): 83 | """移除页面变化监听器""" 84 | if listener in self.page_change_listeners: 85 | self.page_change_listeners.remove(listener) 86 | 87 | def _notify_page_change(self, from_page: str, to_page: str): 88 | """通知页面变化""" 89 | for listener in self.page_change_listeners: 90 | try: 91 | listener(from_page, to_page) 92 | except Exception as e: 93 | print(f"页面变化监听器错误: {e}") 94 | 95 | def navigate_to_homepage(self, save_current: bool = True): 96 | """ 97 | 导航到主页 98 | 99 | Args: 100 | save_current: 是否保存当前页面到历史记录 101 | """ 102 | previous_page = self.current_page 103 | 104 | if save_current and self.current_page != "homepage": 105 | self.navigation_history.append({ 106 | "page": self.current_page, 107 | "platform": self.current_platform, 108 | "window_id": self.current_window_id, 109 | "timestamp": NSDate.date() 110 | }) 111 | 112 | self.current_page = "homepage" 113 | self.current_platform = None 114 | self.current_window_id = None 115 | 116 | # 通知应用委托更新UI 117 | if self.app_delegate and hasattr(self.app_delegate, 'handle_navigation_change'): 118 | self.app_delegate.handle_navigation_change("homepage", None) 119 | 120 | # 更新顶栏/返回按钮等 121 | self.update_ui_elements() 122 | 123 | # 通知页面变化监听器 124 | self._notify_page_change(previous_page, "homepage") 125 | 126 | print("导航到主页") 127 | 128 | def navigate_to_chat(self, platform_id: str, window_id: Optional[str] = None, save_current: bool = True): 129 | """ 130 | 导航到聊天页面 131 | 132 | Args: 133 | platform_id: AI平台标识符 134 | window_id: 窗口ID(可选) 135 | save_current: 是否保存当前页面到历史记录 136 | """ 137 | previous_page = self.current_page 138 | 139 | if save_current and self.current_page != "chat": 140 | self.navigation_history.append({ 141 | "page": self.current_page, 142 | "platform": self.current_platform, 143 | "window_id": self.current_window_id, 144 | "timestamp": NSDate.date() 145 | }) 146 | 147 | self.current_page = "chat" 148 | self.current_platform = platform_id 149 | self.current_window_id = window_id 150 | 151 | # 通知应用委托更新UI 152 | if self.app_delegate and hasattr(self.app_delegate, 'handle_navigation_change'): 153 | self.app_delegate.handle_navigation_change("chat", platform_id, window_id) 154 | 155 | # 更新顶栏/返回按钮等 156 | self.update_ui_elements() 157 | 158 | # 通知页面变化监听器 159 | self._notify_page_change(previous_page, "chat") 160 | 161 | print(f"导航到聊天页面: {platform_id}") 162 | 163 | def go_back(self) -> bool: 164 | """ 165 | 返回上一页 166 | 167 | Returns: 168 | bool: 是否成功返回 169 | """ 170 | if not self.navigation_history: 171 | # 如果没有历史记录,默认返回主页 172 | if self.current_page != "homepage": 173 | self.navigate_to_homepage(save_current=False) 174 | return True 175 | return False 176 | 177 | # 从历史记录中获取上一页 178 | last_page = self.navigation_history.pop() 179 | 180 | if last_page["page"] == "homepage": 181 | self.navigate_to_homepage(save_current=False) 182 | elif last_page["page"] == "chat": 183 | self.navigate_to_chat( 184 | last_page["platform"], 185 | last_page.get("window_id"), 186 | save_current=False 187 | ) 188 | 189 | return True 190 | 191 | def can_go_back(self) -> bool: 192 | """检查是否可以返回""" 193 | return len(self.navigation_history) > 0 or self.current_page != "homepage" 194 | 195 | def clear_history(self): 196 | """清空导航历史""" 197 | self.navigation_history = [] 198 | 199 | def get_current_page_state(self) -> Dict[str, Any]: 200 | """获取当前页面状态""" 201 | page_state = self.page_states.get(self.current_page, {}).copy() 202 | page_state.update({ 203 | "current_platform": self.current_platform, 204 | "current_window_id": self.current_window_id, 205 | "can_go_back": self.can_go_back() 206 | }) 207 | return page_state 208 | 209 | def should_show_back_button(self) -> bool: 210 | """判断是否应该显示返回按钮 211 | 212 | 约束:主页不显示返回按钮。返回按钮仅在聊天页显示, 213 | 避免在首次引导或主页场景下误显示导致混淆。 214 | """ 215 | return self.current_page == "chat" 216 | 217 | def should_show_ai_selector(self) -> bool: 218 | """主页与聊天页均显示下拉框(主页时选中“主页”项)。""" 219 | return self.current_page in ("homepage", "chat") 220 | 221 | def get_page_title(self) -> str: 222 | """获取当前页面标题(本地化)""" 223 | try: 224 | from ..i18n import t as _t 225 | except Exception: 226 | def _t(k, **kwargs): 227 | return "Bubble" if k == 'app.name' else ("Chat" if k == 'nav.chat' else k) 228 | if self.current_page == "homepage": 229 | return _t('app.name') 230 | if self.current_page == "chat": 231 | base = _t('app.name') 232 | chat_label = _t('nav.chat') 233 | if self.current_platform: 234 | platform_name = self._get_platform_display_name(self.current_platform) 235 | return f"{base} - {chat_label}: {platform_name}" 236 | return f"{base} - {chat_label}" 237 | return _t('app.name') 238 | 239 | def _get_platform_display_name(self, platform_id: str) -> str: 240 | """获取平台显示名称(i18n 简称)""" 241 | try: 242 | from ..i18n import t as _t 243 | return _t(f'platform.{platform_id}', default=platform_id.title()) 244 | except Exception: 245 | return platform_id.title() 246 | 247 | def handle_ai_selector_change(self, platform_id: str, window_id: Optional[str] = None): 248 | """ 249 | 处理AI选择器变化 250 | 251 | Args: 252 | platform_id: 选中的AI平台ID 253 | window_id: 窗口ID(可选) 254 | """ 255 | if self.current_page == "chat": 256 | # 在聊天页面切换AI,不保存到历史记录 257 | self.navigate_to_chat(platform_id, window_id, save_current=False) 258 | else: 259 | # 从其他页面切换到聊天页面 260 | self.navigate_to_chat(platform_id, window_id, save_current=True) 261 | 262 | def handle_homepage_ai_selection(self, platform_id: str): 263 | """ 264 | 处理主页AI选择 265 | 266 | Args: 267 | platform_id: 选中的AI平台ID 268 | """ 269 | # 从主页选择AI,导航到聊天页面 270 | self.navigate_to_chat(platform_id, save_current=True) 271 | 272 | def get_navigation_context(self) -> Dict[str, Any]: 273 | """ 274 | 获取导航上下文信息 275 | 276 | Returns: 277 | dict: 包含当前导航状态的字典 278 | """ 279 | return { 280 | "current_page": self.current_page, 281 | "current_platform": self.current_platform, 282 | "current_window_id": self.current_window_id, 283 | "can_go_back": self.can_go_back(), 284 | "history_depth": len(self.navigation_history), 285 | "page_state": self.get_current_page_state(), 286 | "page_title": self.get_page_title() 287 | } 288 | 289 | def handle_window_close_request(self) -> bool: 290 | """ 291 | 处理窗口关闭请求 292 | 293 | Returns: 294 | bool: 是否应该关闭窗口(True)或返回主页(False) 295 | """ 296 | if self.current_page == "homepage": 297 | # 在主页时,关闭窗口 298 | return True 299 | else: 300 | # 在其他页面时,返回主页而不是关闭 301 | self.navigate_to_homepage(save_current=False) 302 | return False 303 | 304 | def reset_navigation(self): 305 | """重置导航状态""" 306 | self.current_page = "homepage" 307 | self.current_platform = None 308 | self.current_window_id = None 309 | self.clear_history() 310 | 311 | print("导航状态已重置") 312 | 313 | def get_javascript_bridge_methods(self) -> Dict[str, Callable]: 314 | """ 315 | 获取JavaScript桥接方法 316 | 317 | Returns: 318 | dict: 可以暴露给WebView的方法字典 319 | """ 320 | return { 321 | "navigateToHomepage": lambda: self.navigate_to_homepage(), 322 | "navigateToChat": lambda platform_id, window_id=None: self.navigate_to_chat(platform_id, window_id), 323 | "goBack": lambda: self.go_back(), 324 | "canGoBack": lambda: self.can_go_back(), 325 | "getCurrentPage": lambda: self.current_page, 326 | "getCurrentPlatform": lambda: self.current_platform, 327 | "getNavigationContext": lambda: self.get_navigation_context(), 328 | "handleAISelection": lambda platform_id, window_id=None: self.handle_ai_selector_change(platform_id, window_id) 329 | } 330 | 331 | def inject_navigation_javascript(self) -> str: 332 | """ 333 | 生成要注入到WebView中的JavaScript代码 334 | 335 | Returns: 336 | str: JavaScript代码字符串 337 | """ 338 | return """ 339 | // Bubble 导航 JavaScript 桥接 340 | window.BubbleNavigation = { 341 | // 导航到主页 342 | navigateToHomepage: function() { 343 | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.navigationAction) { 344 | window.webkit.messageHandlers.navigationAction.postMessage({ 345 | action: 'navigateToHomepage' 346 | }); 347 | } 348 | }, 349 | 350 | // 导航到聊天页面 351 | navigateToChat: function(platformId, windowId) { 352 | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.navigationAction) { 353 | window.webkit.messageHandlers.navigationAction.postMessage({ 354 | action: 'navigateToChat', 355 | platformId: platformId, 356 | windowId: windowId 357 | }); 358 | } 359 | }, 360 | 361 | // 返回上一页 362 | goBack: function() { 363 | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.navigationAction) { 364 | window.webkit.messageHandlers.navigationAction.postMessage({ 365 | action: 'goBack' 366 | }); 367 | } 368 | }, 369 | 370 | // 检查是否可以返回 371 | canGoBack: function() { 372 | // 这个需要同步返回,所以需要在注入时动态设置 373 | return %s; 374 | }, 375 | 376 | // 获取当前页面 377 | getCurrentPage: function() { 378 | return '%s'; 379 | }, 380 | 381 | // 获取当前平台 382 | getCurrentPlatform: function() { 383 | return '%s'; 384 | }, 385 | 386 | // 处理AI选择 387 | handleAISelection: function(platformId, windowId) { 388 | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.navigationAction) { 389 | window.webkit.messageHandlers.navigationAction.postMessage({ 390 | action: 'handleAISelection', 391 | platformId: platformId, 392 | windowId: windowId 393 | }); 394 | } 395 | } 396 | }; 397 | 398 | // 添加返回按钮点击事件监听 399 | document.addEventListener('DOMContentLoaded', function() { 400 | // 查找返回按钮并添加事件监听 401 | const backButtons = document.querySelectorAll('[data-action="back"], .back-button, #back-button'); 402 | backButtons.forEach(button => { 403 | button.addEventListener('click', function(e) { 404 | e.preventDefault(); 405 | window.BubbleNavigation.goBack(); 406 | }); 407 | }); 408 | }); 409 | """ % ( 410 | "true" if self.can_go_back() else "false", 411 | self.current_page, 412 | self.current_platform or "null" 413 | ) 414 | 415 | def update_ui_elements(self): 416 | """更新UI元素状态""" 417 | if not self.app_delegate: 418 | return 419 | 420 | # 更新返回按钮显示状态 421 | if hasattr(self.app_delegate, 'update_back_button_visibility'): 422 | self.app_delegate.update_back_button_visibility(self.should_show_back_button()) 423 | 424 | # 更新AI选择器/主页标签显示状态 425 | if hasattr(self.app_delegate, 'update_ai_selector_visibility'): 426 | self.app_delegate.update_ai_selector_visibility(self.should_show_ai_selector()) 427 | try: 428 | show_selector = self.should_show_ai_selector() 429 | if hasattr(self.app_delegate, 'selector_bg') and self.app_delegate.selector_bg: 430 | self.app_delegate.selector_bg.setHidden_(not show_selector) 431 | # 顶栏品牌仅在主页显示 432 | if hasattr(self.app_delegate, 'brand_logo') and self.app_delegate.brand_logo: 433 | self.app_delegate.brand_logo.setHidden_(self.current_page != 'homepage') 434 | if hasattr(self.app_delegate, 'brand_label') and self.app_delegate.brand_label: 435 | self.app_delegate.brand_label.setHidden_(self.current_page != 'homepage') 436 | except Exception: 437 | pass 438 | 439 | # 更新窗口标题 440 | if hasattr(self.app_delegate, 'update_window_title'): 441 | self.app_delegate.update_window_title(self.get_page_title()) 442 | 443 | print(f"UI元素已更新 - 页面: {self.current_page}, 平台: {self.current_platform}") 444 | -------------------------------------------------------------------------------- /src/bubble/components/platform_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | AI平台管理器 3 | 4 | 实现AI平台的增删管理,支持用户自定义平台选择和配置持久化。 5 | 负责管理用户的AI平台选择、配置存储以及平台状态的统一管理。 6 | """ 7 | 8 | import json 9 | import os 10 | from pathlib import Path 11 | from typing import Dict, List, Optional, Callable 12 | from dataclasses import dataclass, field 13 | 14 | from ..models.platform_config import PlatformConfig, AIServiceConfig, PlatformType 15 | 16 | 17 | @dataclass 18 | class PlatformManagerConfig: 19 | """平台管理器配置""" 20 | config_file_path: str = "~/.bubble/platforms.json" 21 | backup_config_path: str = "~/.bubble/platforms_backup.json" 22 | auto_save: bool = True 23 | auto_backup: bool = True 24 | max_backup_files: int = 5 25 | 26 | 27 | class PlatformManager: 28 | """ 29 | AI平台管理器 30 | 31 | 负责管理AI平台的配置、用户选择和持久化存储。 32 | 提供平台的增删改查、启用禁用、默认设置等功能。 33 | """ 34 | 35 | def __init__(self, config: Optional[PlatformManagerConfig] = None): 36 | """ 37 | 初始化平台管理器 38 | 39 | Args: 40 | config: 管理器配置,如果为None使用默认配置 41 | """ 42 | self.config = config or PlatformManagerConfig() 43 | self._platform_config = PlatformConfig() 44 | self._listeners: List[Callable[[str, Dict], None]] = [] 45 | self._config_loaded = False 46 | 47 | # 确保配置目录存在 48 | self._ensure_config_directory() 49 | 50 | # 加载配置 51 | self.load_config() 52 | 53 | def _ensure_config_directory(self): 54 | """确保配置目录存在""" 55 | config_path = Path(self.config.config_file_path).expanduser() 56 | config_dir = config_path.parent 57 | config_dir.mkdir(parents=True, exist_ok=True) 58 | 59 | def add_change_listener(self, listener: Callable[[str, Dict], None]): 60 | """ 61 | 添加变更监听器 62 | 63 | Args: 64 | listener: 监听器函数,接收事件类型和数据 65 | """ 66 | self._listeners.append(listener) 67 | 68 | def remove_change_listener(self, listener: Callable[[str, Dict], None]): 69 | """移除变更监听器""" 70 | if listener in self._listeners: 71 | self._listeners.remove(listener) 72 | 73 | def _notify_listeners(self, event_type: str, data: Dict): 74 | """通知所有监听器""" 75 | for listener in self._listeners: 76 | try: 77 | listener(event_type, data) 78 | except Exception as e: 79 | print(f"监听器通知失败: {e}") 80 | 81 | def load_config(self) -> bool: 82 | """ 83 | 从文件加载平台配置 84 | 85 | Returns: 86 | bool: 加载是否成功 87 | """ 88 | try: 89 | config_path = Path(self.config.config_file_path).expanduser() 90 | 91 | if not config_path.exists(): 92 | # 如果配置文件不存在,使用默认配置并保存 93 | self._platform_config = PlatformConfig() 94 | self.save_config() 95 | self._config_loaded = True 96 | return True 97 | 98 | with open(config_path, 'r', encoding='utf-8') as f: 99 | data = json.load(f) 100 | 101 | self._platform_config = PlatformConfig.from_dict(data) 102 | self._config_loaded = True 103 | 104 | self._notify_listeners("config_loaded", {"success": True}) 105 | return True 106 | 107 | except Exception as e: 108 | print(f"加载平台配置失败: {e}") 109 | # 如果加载失败,尝试加载备份 110 | if self._load_backup_config(): 111 | return True 112 | 113 | # 如果备份也失败,使用默认配置 114 | self._platform_config = PlatformConfig() 115 | self._config_loaded = True 116 | self._notify_listeners("config_loaded", {"success": False, "error": str(e)}) 117 | return False 118 | 119 | def _load_backup_config(self) -> bool: 120 | """加载备份配置""" 121 | try: 122 | backup_path = Path(self.config.backup_config_path).expanduser() 123 | if backup_path.exists(): 124 | with open(backup_path, 'r', encoding='utf-8') as f: 125 | data = json.load(f) 126 | 127 | self._platform_config = PlatformConfig.from_dict(data) 128 | print("从备份配置加载成功") 129 | return True 130 | except Exception as e: 131 | print(f"加载备份配置失败: {e}") 132 | 133 | return False 134 | 135 | def save_config(self) -> bool: 136 | """ 137 | 保存平台配置到文件 138 | 139 | Returns: 140 | bool: 保存是否成功 141 | """ 142 | try: 143 | config_path = Path(self.config.config_file_path).expanduser() 144 | 145 | # 创建备份 146 | if self.config.auto_backup and config_path.exists(): 147 | self._create_backup() 148 | 149 | # 保存配置 150 | with open(config_path, 'w', encoding='utf-8') as f: 151 | json.dump(self._platform_config.to_dict(), f, indent=2, ensure_ascii=False) 152 | 153 | self._notify_listeners("config_saved", {"success": True}) 154 | return True 155 | 156 | except Exception as e: 157 | print(f"保存平台配置失败: {e}") 158 | self._notify_listeners("config_saved", {"success": False, "error": str(e)}) 159 | return False 160 | 161 | def _create_backup(self): 162 | """创建配置备份""" 163 | try: 164 | config_path = Path(self.config.config_file_path).expanduser() 165 | backup_path = Path(self.config.backup_config_path).expanduser() 166 | 167 | # 创建时间戳备份 168 | import time 169 | timestamp = int(time.time()) 170 | timestamped_backup = backup_path.parent / f"platforms_backup_{timestamp}.json" 171 | 172 | # 复制当前配置到备份 173 | if config_path.exists(): 174 | import shutil 175 | shutil.copy2(config_path, timestamped_backup) 176 | shutil.copy2(config_path, backup_path) 177 | 178 | # 清理旧备份文件 179 | self._cleanup_old_backups() 180 | 181 | except Exception as e: 182 | print(f"创建备份失败: {e}") 183 | 184 | def _cleanup_old_backups(self): 185 | """清理旧的备份文件""" 186 | try: 187 | backup_dir = Path(self.config.backup_config_path).expanduser().parent 188 | backup_files = list(backup_dir.glob("platforms_backup_*.json")) 189 | 190 | # 按修改时间排序,保留最新的几个 191 | backup_files.sort(key=lambda x: x.stat().st_mtime, reverse=True) 192 | 193 | # 删除超出数量限制的备份文件 194 | for backup_file in backup_files[self.config.max_backup_files:]: 195 | backup_file.unlink() 196 | 197 | except Exception as e: 198 | print(f"清理备份文件失败: {e}") 199 | 200 | def add_platform(self, platform_config: AIServiceConfig) -> bool: 201 | """ 202 | 添加新的AI平台 203 | 204 | Args: 205 | platform_config: 平台配置对象 206 | 207 | Returns: 208 | bool: 添加是否成功 209 | """ 210 | success = self._platform_config.add_platform(platform_config) 211 | 212 | if success: 213 | if self.config.auto_save: 214 | self.save_config() 215 | 216 | self._notify_listeners("platform_added", { 217 | "platform_id": platform_config.platform_id, 218 | "platform": platform_config.to_dict() 219 | }) 220 | 221 | return success 222 | 223 | def remove_platform(self, platform_id: str) -> bool: 224 | """ 225 | 移除AI平台 226 | 227 | Args: 228 | platform_id: 平台标识符 229 | 230 | Returns: 231 | bool: 移除是否成功 232 | """ 233 | platform_info = self._platform_config.get_platform(platform_id) 234 | success = self._platform_config.remove_platform(platform_id) 235 | 236 | if success: 237 | if self.config.auto_save: 238 | self.save_config() 239 | 240 | self._notify_listeners("platform_removed", { 241 | "platform_id": platform_id, 242 | "platform": platform_info.to_dict() if platform_info else None 243 | }) 244 | 245 | return success 246 | 247 | def enable_platform(self, platform_id: str) -> bool: 248 | """ 249 | 启用AI平台 250 | 251 | Args: 252 | platform_id: 平台标识符 253 | 254 | Returns: 255 | bool: 启用是否成功 256 | """ 257 | success = self._platform_config.enable_platform(platform_id) 258 | 259 | if success: 260 | if self.config.auto_save: 261 | self.save_config() 262 | 263 | self._notify_listeners("platform_enabled", {"platform_id": platform_id}) 264 | 265 | return success 266 | 267 | def disable_platform(self, platform_id: str) -> bool: 268 | """ 269 | 禁用AI平台 270 | 271 | Args: 272 | platform_id: 平台标识符 273 | 274 | Returns: 275 | bool: 禁用是否成功 276 | """ 277 | success = self._platform_config.disable_platform(platform_id) 278 | 279 | if success: 280 | if self.config.auto_save: 281 | self.save_config() 282 | 283 | self._notify_listeners("platform_disabled", {"platform_id": platform_id}) 284 | 285 | return success 286 | 287 | def set_default_platform(self, platform_id: str) -> bool: 288 | """ 289 | 设置默认AI平台 290 | 291 | Args: 292 | platform_id: 平台标识符 293 | 294 | Returns: 295 | bool: 设置是否成功 296 | """ 297 | success = self._platform_config.set_default_platform(platform_id) 298 | 299 | if success: 300 | if self.config.auto_save: 301 | self.save_config() 302 | 303 | self._notify_listeners("default_platform_changed", {"platform_id": platform_id}) 304 | 305 | return success 306 | 307 | def get_platform(self, platform_id: str) -> Optional[AIServiceConfig]: 308 | """获取指定平台配置""" 309 | return self._platform_config.get_platform(platform_id) 310 | 311 | def get_enabled_platforms(self) -> List[AIServiceConfig]: 312 | """获取已启用的平台列表""" 313 | return self._platform_config.get_enabled_platforms() 314 | 315 | def get_all_platforms(self) -> List[AIServiceConfig]: 316 | """获取所有平台列表""" 317 | return self._platform_config.get_all_platforms() 318 | 319 | def get_default_platform(self) -> Optional[AIServiceConfig]: 320 | """获取默认平台配置""" 321 | if self._platform_config.default_platform: 322 | return self.get_platform(self._platform_config.default_platform) 323 | return None 324 | 325 | def update_platform(self, platform_id: str, updates: Dict) -> bool: 326 | """ 327 | 更新平台配置 328 | 329 | Args: 330 | platform_id: 平台标识符 331 | updates: 要更新的配置字典 332 | 333 | Returns: 334 | bool: 更新是否成功 335 | """ 336 | platform = self.get_platform(platform_id) 337 | if not platform: 338 | return False 339 | 340 | try: 341 | # 更新配置 342 | for key, value in updates.items(): 343 | if hasattr(platform, key): 344 | setattr(platform, key, value) 345 | 346 | if self.config.auto_save: 347 | self.save_config() 348 | 349 | self._notify_listeners("platform_updated", { 350 | "platform_id": platform_id, 351 | "updates": updates, 352 | "platform": platform.to_dict() 353 | }) 354 | 355 | return True 356 | 357 | except Exception as e: 358 | print(f"更新平台配置失败: {e}") 359 | return False 360 | 361 | def reset_to_defaults(self) -> bool: 362 | """ 363 | 重置为默认配置 364 | 365 | Returns: 366 | bool: 重置是否成功 367 | """ 368 | try: 369 | # 创建备份 370 | if self.config.auto_backup: 371 | self._create_backup() 372 | 373 | # 重置为默认配置 374 | self._platform_config = PlatformConfig() 375 | 376 | if self.config.auto_save: 377 | self.save_config() 378 | 379 | self._notify_listeners("config_reset", {"success": True}) 380 | return True 381 | 382 | except Exception as e: 383 | print(f"重置配置失败: {e}") 384 | self._notify_listeners("config_reset", {"success": False, "error": str(e)}) 385 | return False 386 | 387 | def export_config(self, export_path: str) -> bool: 388 | """ 389 | 导出配置到指定路径 390 | 391 | Args: 392 | export_path: 导出文件路径 393 | 394 | Returns: 395 | bool: 导出是否成功 396 | """ 397 | try: 398 | export_path = Path(export_path).expanduser() 399 | 400 | with open(export_path, 'w', encoding='utf-8') as f: 401 | json.dump(self._platform_config.to_dict(), f, indent=2, ensure_ascii=False) 402 | 403 | self._notify_listeners("config_exported", {"path": str(export_path)}) 404 | return True 405 | 406 | except Exception as e: 407 | print(f"导出配置失败: {e}") 408 | return False 409 | 410 | def import_config(self, import_path: str, merge: bool = False) -> bool: 411 | """ 412 | 从指定路径导入配置 413 | 414 | Args: 415 | import_path: 导入文件路径 416 | merge: 是否与现有配置合并,False表示完全替换 417 | 418 | Returns: 419 | bool: 导入是否成功 420 | """ 421 | try: 422 | import_path = Path(import_path).expanduser() 423 | 424 | if not import_path.exists(): 425 | return False 426 | 427 | with open(import_path, 'r', encoding='utf-8') as f: 428 | data = json.load(f) 429 | 430 | if merge: 431 | # 合并配置 432 | imported_config = PlatformConfig.from_dict(data) 433 | for platform_id, platform in imported_config.platforms.items(): 434 | self._platform_config.add_platform(platform) 435 | else: 436 | # 完全替换 437 | if self.config.auto_backup: 438 | self._create_backup() 439 | 440 | self._platform_config = PlatformConfig.from_dict(data) 441 | 442 | if self.config.auto_save: 443 | self.save_config() 444 | 445 | self._notify_listeners("config_imported", { 446 | "path": str(import_path), 447 | "merge": merge 448 | }) 449 | 450 | return True 451 | 452 | except Exception as e: 453 | print(f"导入配置失败: {e}") 454 | return False 455 | 456 | def get_platform_statistics(self) -> Dict: 457 | """获取平台统计信息""" 458 | all_platforms = self.get_all_platforms() 459 | enabled_platforms = self.get_enabled_platforms() 460 | 461 | # 计算可用槽位(若无上限则为None) 462 | max_enabled = getattr(self._platform_config, 'max_enabled_platforms', None) 463 | enabled_slots = (max_enabled - len(enabled_platforms)) if isinstance(max_enabled, int) else None 464 | return { 465 | "total_platforms": len(all_platforms), 466 | "enabled_platforms": len(enabled_platforms), 467 | "available_slots": 7 - len(all_platforms), 468 | "enabled_slots": enabled_slots, 469 | "default_platform": self._platform_config.default_platform, 470 | "platforms_by_type": self._get_platforms_by_type() 471 | } 472 | 473 | def _get_platforms_by_type(self) -> Dict[str, int]: 474 | """获取按类型分组的平台数量""" 475 | type_counts = {} 476 | for platform in self.get_all_platforms(): 477 | platform_type = getattr(platform, 'platform_type', 'unknown') 478 | type_counts[platform_type] = type_counts.get(platform_type, 0) + 1 479 | 480 | return type_counts 481 | 482 | def validate_config(self) -> Dict[str, List[str]]: 483 | """ 484 | 验证配置的有效性 485 | 486 | Returns: 487 | Dict: 验证结果,包含错误和警告信息 488 | """ 489 | errors = [] 490 | warnings = [] 491 | 492 | try: 493 | all_platforms = self.get_all_platforms() 494 | enabled_platforms = self.get_enabled_platforms() 495 | 496 | # 检查平台数量限制 497 | if len(all_platforms) > 7: 498 | errors.append("平台总数超过了最大限制(7个)") 499 | 500 | max_enabled = getattr(self._platform_config, 'max_enabled_platforms', None) 501 | if isinstance(max_enabled, int) and len(enabled_platforms) > max_enabled: 502 | errors.append(f"启用的平台数量超过了最大限制({max_enabled}个)") 503 | 504 | # 检查默认平台 505 | if self._platform_config.default_platform: 506 | if self._platform_config.default_platform not in [p.platform_id for p in enabled_platforms]: 507 | errors.append("默认平台未启用或不存在") 508 | elif enabled_platforms: 509 | warnings.append("有启用的平台但未设置默认平台") 510 | 511 | # 检查平台配置 512 | for platform in all_platforms: 513 | if not platform.url: 514 | errors.append(f"平台 {platform.platform_id} 缺少URL配置") 515 | if not platform.name: 516 | errors.append(f"平台 {platform.platform_id} 缺少名称配置") 517 | 518 | # 检查重复的平台ID 519 | platform_ids = [p.platform_id for p in all_platforms] 520 | if len(platform_ids) != len(set(platform_ids)): 521 | errors.append("存在重复的平台ID") 522 | 523 | except Exception as e: 524 | errors.append(f"配置验证过程中发生错误: {e}") 525 | 526 | return { 527 | "errors": errors, 528 | "warnings": warnings, 529 | "valid": len(errors) == 0 530 | } 531 | 532 | @property 533 | def is_config_loaded(self) -> bool: 534 | """配置是否已加载""" 535 | return self._config_loaded 536 | 537 | @property 538 | def platform_config(self) -> PlatformConfig: 539 | """获取平台配置对象(只读)""" 540 | return self._platform_config 541 | -------------------------------------------------------------------------------- /src/bubble/components/homepage_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | 主页管理器 (Homepage Manager) 3 | 4 | 该模块负责管理 Bubble 应用的主页功能,包括: 5 | - AI平台选择界面 6 | - 用户首次启动时的AI选择提示 7 | - AI平台的增删功能 8 | - 用户配置的保存和加载 9 | 10 | 主页管理器复用现有的窗口创建和UI逻辑,提供用户友好的AI平台管理体验。 11 | """ 12 | 13 | import os 14 | import json 15 | from typing import Dict, List, Optional 16 | import objc 17 | from Foundation import NSObject, NSUserDefaults 18 | from .config_manager import ConfigManager 19 | from ..i18n import t as _t 20 | 21 | 22 | class HomepageManager(NSObject): 23 | """ 24 | 主页管理器类 25 | 26 | 负责管理应用主页的所有功能,包括AI平台选择、配置管理、 27 | 以及与用户的交互流程。 28 | """ 29 | 30 | def init(self): 31 | """初始化主页管理器""" 32 | self = objc.super(HomepageManager, self).init() 33 | if self is None: 34 | return None 35 | self.user_defaults = NSUserDefaults.standardUserDefaults() 36 | # Use centralized config path (migrated location) 37 | try: 38 | self.config_file_path = ConfigManager.config_path() 39 | except Exception: 40 | self.config_file_path = os.path.expanduser("~/Library/Application Support/Bubble/config.json") 41 | try: 42 | legacy_path = ConfigManager.legacy_config_path() 43 | if not os.path.exists(self.config_file_path) and os.path.exists(legacy_path): 44 | self.config_file_path = legacy_path 45 | except Exception: 46 | pass 47 | self.default_ai_platforms = { 48 | "openai": { 49 | "name": "ChatGPT", 50 | "url": "https://chat.openai.com", 51 | "display_name": "OpenAI ChatGPT", 52 | "enabled": True, 53 | "max_windows": 5 54 | }, 55 | "gemini": { 56 | "name": "Gemini", 57 | "url": "https://gemini.google.com", 58 | "display_name": "Google Gemini", 59 | "enabled": True, 60 | "max_windows": 5 61 | }, 62 | "grok": { 63 | "name": "Grok", 64 | "url": "https://grok.com", 65 | "display_name": "xAI Grok", 66 | "enabled": True, 67 | "max_windows": 5 68 | }, 69 | "claude": { 70 | "name": "Claude", 71 | "url": "https://claude.ai/chat", 72 | "display_name": "Anthropic Claude", 73 | "enabled": True, 74 | "max_windows": 5 75 | }, 76 | "deepseek": { 77 | "name": "DeepSeek", 78 | "url": "https://chat.deepseek.com", 79 | "display_name": "DeepSeek AI", 80 | "enabled": True, 81 | "max_windows": 5 82 | }, 83 | "zai": { 84 | "name": "GLM", 85 | "url": "https://chat.z.ai/", 86 | "display_name": "GLM", 87 | "enabled": False, 88 | "max_windows": 5 89 | }, 90 | "mistral": { 91 | "name": "Mistral", 92 | "url": "https://chat.mistral.ai", 93 | "display_name": "Mistral", 94 | "enabled": False, 95 | "max_windows": 5 96 | }, 97 | "perplexity": { 98 | "name": "Perplexity", 99 | "url": "https://www.perplexity.ai", 100 | "display_name": "Perplexity", 101 | "enabled": False, 102 | "max_windows": 5 103 | }, 104 | "qwen": { 105 | "name": "Qwen", 106 | "url": "https://chat.qwen.ai/", 107 | "display_name": "Qwen", 108 | "enabled": False, 109 | "max_windows": 5 110 | }, 111 | "kimi": { 112 | "name": "Kimi", 113 | "url": "https://www.kimi.com/", 114 | "display_name": "Kimi", 115 | "enabled": False, 116 | "max_windows": 5 117 | } 118 | } 119 | self._ensure_config_directory() 120 | self._load_user_config() 121 | # Runtime-only flag:调试用,强制显示一次导览 122 | self._force_tour_once = False 123 | # 尝试加载内置logo为 data URL,供主页展示 124 | try: 125 | self._load_logo_data_url() 126 | except Exception: 127 | self.logo_data_url = None 128 | return self 129 | 130 | def on_language_changed(self): 131 | """Hook for language change; homepage will be re-rendered on next load.""" 132 | # No persistent state to update here; UI will rebuild via AppDelegate._load_homepage() 133 | return True 134 | 135 | def _load_logo_data_url(self): 136 | import base64 137 | import pkgutil 138 | # Try pkg data first (py2app zip-safe) 139 | for name in ( 140 | 'logo/icon.iconset/icon_64x64.png', 141 | 'logo/icon.iconset/icon_128x128.png', 142 | 'logo/icon.iconset/icon_32x32.png', 143 | ): 144 | try: 145 | data = pkgutil.get_data('bubble', name) 146 | if data: 147 | b64 = base64.b64encode(data).decode('ascii') 148 | self.logo_data_url = f"data:image/png;base64,{b64}" 149 | return 150 | except Exception: 151 | pass 152 | # Fallback to filesystem in dev 153 | # components/ -> package root (../) 154 | base = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) 155 | for p in ( 156 | os.path.join(base, 'logo', 'icon.iconset', 'icon_64x64.png'), 157 | os.path.join(base, 'logo', 'icon.iconset', 'icon_128x128.png'), 158 | os.path.join(base, 'logo', 'icon.iconset', 'icon_32x32.png'), 159 | ): 160 | if os.path.exists(p): 161 | with open(p, 'rb') as f: 162 | b64 = base64.b64encode(f.read()).decode('ascii') 163 | self.logo_data_url = f"data:image/png;base64,{b64}" 164 | return 165 | self.logo_data_url = None 166 | 167 | def _ensure_config_directory(self): 168 | """确保配置目录存在""" 169 | config_dir = os.path.dirname(self.config_file_path) 170 | if not os.path.exists(config_dir): 171 | os.makedirs(config_dir, exist_ok=True) 172 | 173 | def _load_user_config(self): 174 | """加载用户配置""" 175 | try: 176 | if os.path.exists(self.config_file_path): 177 | with open(self.config_file_path, 'r', encoding='utf-8') as f: 178 | self.user_config = json.load(f) 179 | else: 180 | # 首次启动,创建默认配置 181 | self.user_config = { 182 | "default_ai": None, # 用户首次启动时需要选择 183 | "enabled_platforms": [], # 首次启动不启用任何平台 184 | "window_positions": {}, 185 | "ui_preferences": { 186 | "transparency": 1.0, 187 | "show_homepage_on_startup": True, 188 | "hide_memory_bubble": False 189 | }, 190 | "platform_windows": {} # 记录每个平台的窗口信息 191 | } 192 | self._save_user_config() 193 | except Exception as e: 194 | print(f"加载用户配置失败,使用默认配置: {e}") 195 | self.user_config = { 196 | "default_ai": None, 197 | "enabled_platforms": [], # 首次启动不启用任何平台 198 | "window_positions": {}, 199 | "ui_preferences": { 200 | "transparency": 1.0, 201 | "show_homepage_on_startup": True, 202 | "hide_memory_bubble": False 203 | }, 204 | "platform_windows": {} 205 | } 206 | 207 | def _save_user_config(self): 208 | """保存用户配置""" 209 | try: 210 | with open(self.config_file_path, 'w', encoding='utf-8') as f: 211 | json.dump(self.user_config, f, indent=2, ensure_ascii=False) 212 | except Exception as e: 213 | print(f"保存用户配置失败: {e}") 214 | 215 | def is_first_launch(self) -> bool: 216 | """检查是否为首次启动""" 217 | return self.user_config.get("default_ai") is None 218 | 219 | def should_show_homepage_on_startup(self) -> bool: 220 | """是否在启动时显示主页(由用户偏好控制,默认 True)""" 221 | try: 222 | return bool(self.user_config.get("ui_preferences", {}).get("show_homepage_on_startup", True)) 223 | except Exception: 224 | return True 225 | 226 | def get_enabled_platforms(self) -> Dict[str, Dict]: 227 | """获取已启用的AI平台列表""" 228 | enabled = {} 229 | for platform_id in self.user_config.get("enabled_platforms", []): 230 | if platform_id in self.default_ai_platforms: 231 | enabled[platform_id] = self.default_ai_platforms[platform_id].copy() 232 | return enabled 233 | 234 | def get_available_platforms(self) -> Dict[str, Dict]: 235 | """获取所有可用的AI平台列表""" 236 | return self.default_ai_platforms.copy() 237 | 238 | def add_platform(self, platform_id: str) -> bool: 239 | """ 240 | 添加AI平台到用户配置 241 | 242 | Args: 243 | platform_id: 平台标识符 244 | 245 | Returns: 246 | bool: 添加是否成功 247 | """ 248 | if platform_id not in self.default_ai_platforms: 249 | print(f"不支持的平台: {platform_id}") 250 | return False 251 | 252 | if platform_id not in self.user_config.get("enabled_platforms", []): 253 | # 默认不限制启用平台数量 254 | self.user_config.setdefault("enabled_platforms", []).append(platform_id) 255 | self._save_user_config() 256 | return True 257 | 258 | print(f"平台 {platform_id} 已经启用") 259 | return False 260 | 261 | def remove_platform(self, platform_id: str) -> bool: 262 | """ 263 | 从用户配置中移除AI平台 264 | 265 | Args: 266 | platform_id: 平台标识符 267 | 268 | Returns: 269 | bool: 移除是否成功 270 | """ 271 | if platform_id in self.user_config.get("enabled_platforms", []): 272 | self.user_config["enabled_platforms"].remove(platform_id) 273 | # 同时清理该平台的窗口信息 274 | if platform_id in self.user_config.get("platform_windows", {}): 275 | del self.user_config["platform_windows"][platform_id] 276 | self._save_user_config() 277 | return True 278 | 279 | print(f"平台 {platform_id} 未启用") 280 | return False 281 | 282 | def set_default_ai(self, platform_id: str) -> bool: 283 | """ 284 | 设置默认AI平台 285 | 286 | Args: 287 | platform_id: 平台标识符 288 | 289 | Returns: 290 | bool: 设置是否成功 291 | """ 292 | if platform_id in self.default_ai_platforms: 293 | self.user_config["default_ai"] = platform_id 294 | # 确保默认AI在启用列表中 295 | if platform_id not in self.user_config.get("enabled_platforms", []): 296 | self.add_platform(platform_id) 297 | self._save_user_config() 298 | return True 299 | 300 | print(f"不支持的平台: {platform_id}") 301 | return False 302 | 303 | def get_default_ai(self) -> Optional[str]: 304 | """获取默认AI平台""" 305 | return self.user_config.get("default_ai") 306 | 307 | def add_platform_window(self, platform_id: str, window_id: str, window_info: Dict) -> bool: 308 | """ 309 | 为平台添加新窗口 310 | 311 | Args: 312 | platform_id: 平台标识符 313 | window_id: 窗口标识符 314 | window_info: 窗口信息 315 | 316 | Returns: 317 | bool: 添加是否成功 318 | """ 319 | if platform_id not in self.default_ai_platforms: 320 | return False 321 | 322 | platform_windows = self.user_config.setdefault("platform_windows", {}) 323 | platform_windows.setdefault(platform_id, {}) 324 | 325 | # 默认不限制同一平台窗口数量,由上层通过内存提示进行引导 326 | platform_windows[platform_id][window_id] = window_info 327 | self._save_user_config() 328 | return True 329 | 330 | def remove_platform_window(self, platform_id: str, window_id: str) -> bool: 331 | """ 332 | 移除平台窗口 333 | 334 | Args: 335 | platform_id: 平台标识符 336 | window_id: 窗口标识符 337 | 338 | Returns: 339 | bool: 移除是否成功 340 | """ 341 | platform_windows = self.user_config.get("platform_windows", {}) 342 | if platform_id in platform_windows and window_id in platform_windows[platform_id]: 343 | del platform_windows[platform_id][window_id] 344 | # 如果该平台没有窗口了,清理空字典 345 | if not platform_windows[platform_id]: 346 | del platform_windows[platform_id] 347 | self._save_user_config() 348 | return True 349 | 350 | return False 351 | 352 | def get_platform_windows(self, platform_id: str) -> Dict[str, Dict]: 353 | """获取指定平台的所有窗口""" 354 | return self.user_config.get("platform_windows", {}).get(platform_id, {}) 355 | 356 | def get_all_windows(self) -> Dict[str, Dict[str, Dict]]: 357 | """获取所有平台的窗口信息""" 358 | return self.user_config.get("platform_windows", {}) 359 | 360 | def get_total_window_count(self) -> int: 361 | """获取总窗口数量""" 362 | total = 0 363 | for platform_windows in self.user_config.get("platform_windows", {}).values(): 364 | total += len(platform_windows) 365 | return total 366 | 367 | # MARK: - 首次使用引导(homepage 导览) 368 | def should_show_homepage_tour(self) -> bool: 369 | """是否需要显示主页导览(仅显示一次)。""" 370 | try: 371 | ui = self.user_config.get("ui_preferences", {}) 372 | # 未设置或显式为 False 时显示 373 | return not bool(ui.get("homepage_tour_done", False)) 374 | except Exception: 375 | return True 376 | 377 | def request_force_homepage_tour(self) -> None: 378 | """请求在下一次主页渲染时强制显示一次导览(不改持久化配置)。""" 379 | try: 380 | self._force_tour_once = True 381 | except Exception: 382 | pass 383 | 384 | def consume_force_homepage_tour(self) -> bool: 385 | """消费一次强制导览标志。返回 True 表示本次应强制显示,并重置标志。""" 386 | try: 387 | if getattr(self, '_force_tour_once', False): 388 | self._force_tour_once = False 389 | return True 390 | except Exception: 391 | pass 392 | return False 393 | 394 | def mark_homepage_tour_done(self) -> None: 395 | """标记主页导览已完成并持久化。""" 396 | try: 397 | ui = self.user_config.setdefault("ui_preferences", {}) 398 | ui["homepage_tour_done"] = True 399 | self._save_user_config() 400 | except Exception as _e: 401 | print(f"保存主页导览完成状态失败: {_e}") 402 | 403 | def can_add_window(self) -> bool: 404 | """检查是否还能添加新窗口 405 | 406 | 注:调整策略以允许超过5个页面常驻,后续通过 UI 气泡提示占用内存风险。 407 | """ 408 | return True 409 | 410 | def show_homepage(self): 411 | """显示主页(统一使用新版行样式)。""" 412 | return self._show_platform_management_rows() 413 | 414 | def _show_platform_management_rows(self) -> str: 415 | """横条风格主页:一行一平台,点击行切换启用;右侧省略号可“重复添加”;气泡展示多页面数量并可删除。 416 | 417 | 同时在顶部显示品牌区(包含 Bubble logo),保证开发与打包后显示一致: 418 | - 优先使用打包资源(pkgutil.get_data)生成 data URL 419 | - 开发模式回退到文件系统路径(src/bubble/logo/...) 420 | """ 421 | enabled = self.get_enabled_platforms() 422 | available = self.get_available_platforms() 423 | # Load local driver.js/css text for inline embedding (fallback to CDN when empty) 424 | def _read_asset_text(rel: str) -> str: 425 | try: 426 | import pkgutil 427 | data = pkgutil.get_data('bubble', rel) 428 | if data: 429 | return data.decode('utf-8', 'ignore') 430 | except Exception: 431 | pass 432 | # dev filesystem fallback 433 | try: 434 | base = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) 435 | p = os.path.join(base, rel) 436 | if os.path.exists(p): 437 | with open(p, 'r', encoding='utf-8') as f: 438 | return f.read() 439 | except Exception: 440 | pass 441 | return '' 442 | self._driver_css_text = _read_asset_text('assets/vendor/driver.js/driver.min.css') 443 | self._driver_js_text = _read_asset_text('assets/vendor/driver.js/driver.min.js') 444 | homepage_css = _read_asset_text('assets/homepage.css') 445 | homepage_js = _read_asset_text('assets/homepage.js') 446 | # helpers used in HTML template 447 | def _get_driver_css(): 448 | return self._driver_css_text or '' 449 | def _get_driver_js_json(): 450 | try: 451 | import json as _json 452 | return _json.dumps(self._driver_js_text or '') 453 | except Exception: 454 | return '""' 455 | # expose helpers to f-string via bound methods 456 | self._get_driver_css = _get_driver_css 457 | self._get_driver_js_json = _get_driver_js_json 458 | def _windows_list(pid): 459 | m = self.get_platform_windows(pid) 460 | items = list(m.items()) 461 | try: 462 | items.sort(key=lambda kv: kv[1].get('createdAt','')) 463 | except Exception: 464 | pass 465 | arr = [] 466 | for idx,(wid,_) in enumerate(items, start=1): 467 | arr.append({"id": wid, "idx": idx}) 468 | return arr 469 | import json as _json 470 | rows = "" 471 | for pid, info in available.items(): 472 | is_on = pid in enabled 473 | wl = _windows_list(pid) 474 | wcnt = len(wl) 475 | # 始终渲染按钮与气泡(隐藏时保留占位,避免布局抖动) 476 | # 省略号在平台启用时始终可见(即便为0页也可“新建页面”) 477 | # 将省略号替换为加号;选中卡片后显示加号,未选中隐藏 478 | more_btn = f'' 479 | # 首次添加也要有反馈:>=1 显示气泡 480 | bubble = f'= 1 else " hidden")}">{wcnt if wcnt >= 1 else ''}' 481 | # 本地化名称(简洁) 482 | try: 483 | title_txt = _t(f'platform.{pid}', default=info.get('display_name') or info.get('name') or pid.title()) 484 | except Exception: 485 | title_txt = info.get('display_name') or info.get('name') or pid.title() 486 | # 特殊:mistral/perplexity 仅首字母大写 487 | if pid in ("mistral", "perplexity"): 488 | try: 489 | title_txt = str(title_txt).capitalize() 490 | except Exception: 491 | pass 492 | # 平台描述(优势) 493 | _desc_defaults = { 494 | 'openai': '通用对话,生态丰富', 495 | 'gemini': '多模态理解与生成', 496 | 'grok': '实时信息与风趣回复', 497 | 'claude': '长文本与安全对话', 498 | 'deepseek': '高性价比与中文友好', 499 | 'zai': '中文理解与推理', 500 | 'qwen': '中文与工具调用', 501 | 'mistral': '轻量快速与高效', 502 | 'perplexity': '搜索增强问答', 503 | 'kimi': '长文档阅读与总结', 504 | } 505 | try: 506 | sub_txt = _t(f'platform.desc.{pid}', default=_desc_defaults.get(pid, '')) 507 | except Exception: 508 | sub_txt = _desc_defaults.get(pid, '') 509 | # icon:仅使用打包资源,转为 data URL(避免 WKWebView 对 file:// 的限制) 510 | icon_src = '' 511 | try: 512 | import pkgutil, base64 513 | data = pkgutil.get_data('bubble', f'assets/icons/{pid}.png') 514 | if data: 515 | icon_src = 'data:image/png;base64,' + base64.b64encode(data).decode('ascii') 516 | except Exception: 517 | icon_src = '' 518 | if not icon_src: 519 | try: 520 | import base64 521 | base = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) 522 | p = os.path.join(base, 'assets', 'icons', f'{pid}.png') 523 | if os.path.exists(p): 524 | with open(p, 'rb') as f: 525 | icon_src = 'data:image/png;base64,' + base64.b64encode(f.read()).decode('ascii') 526 | except Exception: 527 | icon_src = '' 528 | # Add invert-dark class for icons that need color inversion in dark mode (e.g., grok) 529 | icon_class = "icon" 530 | if pid == "grok": 531 | icon_class = "icon invert-dark" 532 | icon_html = f"\"\"" if icon_src else "" 533 | rows += f""" 534 |
535 |
{icon_html}{title_txt}{sub_txt}
536 |
{bubble}{more_btn}
537 |
538 | """ 539 | html = f""" 540 | 541 | 542 | 543 | 544 | 545 | Bubble 546 | 547 | 548 | 549 | 550 | 551 |
{rows}
552 | 553 |
554 | 555 | 556 | 557 | 558 | """ 559 | return html 560 | 561 | def _get_launcher_hotkey_display(self) -> str: 562 | """获取启动器快捷键的显示字符串。""" 563 | try: 564 | from ..constants import LAUNCHER_TRIGGER 565 | from ..listener import SPECIAL_KEY_NAMES 566 | 567 | flags_val = LAUNCHER_TRIGGER.get('flags') 568 | key_val = LAUNCHER_TRIGGER.get('key') 569 | 570 | if flags_val in (None, 0) and key_val in (None, 0): 571 | return '⌘+G' # 默认值 572 | 573 | flags = int(flags_val or 0) 574 | key = int(key_val or 0) 575 | 576 | parts = [] 577 | try: 578 | from Quartz import ( 579 | kCGEventFlagMaskCommand, 580 | kCGEventFlagMaskAlternate, 581 | kCGEventFlagMaskShift, 582 | kCGEventFlagMaskControl 583 | ) 584 | if flags & kCGEventFlagMaskCommand: 585 | parts.append('⌘') 586 | if flags & kCGEventFlagMaskAlternate: 587 | parts.append('⌥') 588 | if flags & kCGEventFlagMaskShift: 589 | parts.append('⇧') 590 | if flags & kCGEventFlagMaskControl: 591 | parts.append('⌃') 592 | except Exception: 593 | pass 594 | 595 | # 键名 596 | keyname = SPECIAL_KEY_NAMES.get(key) 597 | if not keyname: 598 | keymap = { 599 | 0: 'A', 1: 'S', 2: 'D', 3: 'F', 4: 'H', 5: 'G', 6: 'Z', 7: 'X', 600 | 8: 'C', 9: 'V', 11: 'B', 12: 'Q', 13: 'W', 14: 'E', 15: 'R', 601 | 16: 'Y', 17: 'T', 31: 'O', 32: 'U', 34: 'I', 35: 'P', 37: 'L', 602 | 38: 'J', 40: 'K', 45: 'N', 46: 'M', 49: 'Space', 36: 'Return', 53: 'Esc' 603 | } 604 | keyname = keymap.get(key) or str(key) 605 | 606 | parts.append(keyname) 607 | return '+'.join(parts) 608 | except Exception: 609 | return '⌘+G' 610 | --------------------------------------------------------------------------------