├── 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 |
--------------------------------------------------------------------------------