opencode commited on
Commit
f31486b
·
1 Parent(s): a1ec78c

桌面加入 Playwright Chromium:背景下載、桌面圖示與啟動器

Browse files
Files changed (4) hide show
  1. README.md +31 -2
  2. app.py +292 -1
  3. packages.txt +31 -0
  4. requirements.txt +1 -0
README.md CHANGED
@@ -20,8 +20,9 @@ OpenCode 在啟動時安裝,並重設使用者層級設定為預設值。
20
  ## 檔案
21
 
22
  - `app.py` — FastAPI + xterm.js 終端機、檔案瀏覽器、`/ws` session 管理與固定 LXDE 桌面
23
- - `packages.txt` — 系統套件(`tmux`、`xvfb`、`x11vnc`、`lxde-core`、`xterm`、`novnc`、`x11-utils`)
24
- - `requirements.txt` — Python 相依套件
 
25
 
26
  ## 終端機分頁(全部共用、全部長存)
27
 
@@ -66,6 +67,34 @@ OpenCode 在啟動時安裝,並重設使用者層級設定為預設值。
66
  (預設 `1440x900x24`)、`DESKTOP_VNC_PORT`(預設 `5900`)、`DESKTOP_NOVNC_ROOT`
67
  (預設 `/usr/share/novnc`)。noVNC 靜態檔由 `/novnc/*` 提供,缺少時前端自動改用 CDN。
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  ## 記憶體守衛(memguard)
70
 
71
  每次 Space 啟動時,`app.py` 的 `install_memguard()` 會自動:
 
20
  ## 檔案
21
 
22
  - `app.py` — FastAPI + xterm.js 終端機、檔案瀏覽器、`/ws` session 管理與固定 LXDE 桌面
23
+ - `packages.txt` — 系統套件(`tmux`、`xvfb`、`x11vnc`、`lxde-core`、`xterm`、`novnc`、
24
+ `x11-utils`,以及 Playwright Chromium 所需的系統函式庫與字型)
25
+ - `requirements.txt` — Python 相依套件(含 `playwright`)
26
 
27
  ## 終端機分頁(全部共用、全部長存)
28
 
 
67
  (預設 `1440x900x24`)、`DESKTOP_VNC_PORT`(預設 `5900`)、`DESKTOP_NOVNC_ROOT`
68
  (預設 `/usr/share/novnc`)。noVNC 靜態檔由 `/novnc/*` 提供,缺少時前端自動改用 CDN。
69
 
70
+ ## Playwright + Chromium(桌面內使用)
71
+
72
+ Space 啟動後會在背景自動安裝 Playwright 的 Chromium(下載到
73
+ `~/.cache/ms-playwright`,系統依賴已由 `packages.txt` 預裝),完成後:
74
+
75
+ - 共用 LXDE 桌面會出現 **Playwright Chromium** 圖示,點兩下即可在桌面內開瀏覽器
76
+ (以 root 執行,已自動加上 `--no-sandbox --disable-dev-shm-usage`)。
77
+ - 也可直接執行啟動器:`~/.local/bin/playwright-chromium`。
78
+ - 在終端機用 Python 操作時,記得 Chromium 需要無沙箱參數:
79
+
80
+ ```python
81
+ from playwright.sync_api import sync_playwright
82
+
83
+ with sync_playwright() as p:
84
+ browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
85
+ page = browser.new_page()
86
+ page.goto("https://example.com")
87
+ print(page.title())
88
+ browser.close()
89
+ ```
90
+
91
+ - 若要開在共用桌面(看的到視窗),把 `headless` 設為 `False` 並使用 `DISPLAY=:99`;
92
+ Playwright 會自動沿用 `PLAYWRIGHT_BROWSERS_PATH`。
93
+ - 除錯端點:`GET /api/playwright/status`(是否就緒、Chromium 路徑、最後錯誤)、
94
+ `POST /api/playwright/install`(手動觸發重裝)。安裝 log 在
95
+ `/tmp/webterm-playwright.log`。
96
+ - 手動更新 Chromium:`python3 -m playwright install chromium`。
97
+
98
  ## 記憶體守衛(memguard)
99
 
100
  每次 Space 啟動時,`app.py` 的 `install_memguard()` 會自動:
app.py CHANGED
@@ -1,8 +1,10 @@
1
  import os
2
  import pty
 
3
  import shutil
4
  import socket
5
  import subprocess
 
6
  import signal
7
  import struct
8
  import fcntl
@@ -1181,6 +1183,19 @@ DESKTOP_APT_PACKAGES = "xvfb x11vnc lxde-core xterm novnc x11-utils"
1181
  DESKTOP_TERM_TITLE = "共用終端機"
1182
  DESKTOP_WATCHDOG = None
1183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184
  DESKTOP_PAGE = r"""<!DOCTYPE html>
1185
  <html lang="zh-TW">
1186
  <head>
@@ -1824,6 +1839,276 @@ class DesktopManager:
1824
  desktop_manager = DesktopManager()
1825
 
1826
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1827
  async def desktop_watchdog():
1828
  """每 3 秒確認桌面還在,且永遠只有一份;不影響終端機流程。"""
1829
  await asyncio.sleep(1)
@@ -1844,7 +2129,7 @@ def dummy_gpu():
1844
 
1845
  @app.on_event("startup")
1846
  async def startup_event():
1847
- global DESKTOP_WATCHDOG
1848
  _load_tabs()
1849
  _write_shell_launcher()
1850
  await ensure_tmux()
@@ -1855,6 +2140,12 @@ async def startup_event():
1855
  DESKTOP_WATCHDOG = asyncio.create_task(desktop_watchdog())
1856
  except Exception as _desktop_err: # pragma: no cover
1857
  log.warning("[desktop] 無法啟動看門狗(終端機照常運作):%r", _desktop_err)
 
 
 
 
 
 
1858
  dummy_gpu()
1859
  if SKIP_INSTALL:
1860
  log.info("WEBTERM_SKIP_INSTALL=1:跳過 opencode / 編輯器安裝")
 
1
  import os
2
  import pty
3
+ import shlex
4
  import shutil
5
  import socket
6
  import subprocess
7
+ import sys
8
  import signal
9
  import struct
10
  import fcntl
 
1183
  DESKTOP_TERM_TITLE = "共用終端機"
1184
  DESKTOP_WATCHDOG = None
1185
 
1186
+ # ──────────────────────────── Playwright + Chromium(桌面用)─────────────────
1187
+ # Chromium 由 Playwright 管理,安裝在 ~/.cache/ms-playwright;完成後在共用桌面
1188
+ # 建立「Playwright Chromium」圖示,並提供一支可直接執行的啟動器。
1189
+ PW_BROWSERS = os.path.join(HOME, ".cache", "ms-playwright")
1190
+ os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", PW_BROWSERS)
1191
+ PW_LOG = "/tmp/webterm-playwright.log"
1192
+ PW_TITLE = "Playwright Chromium"
1193
+ PW_LAUNCHER = os.path.join(HOME, ".local", "bin", "playwright-chromium")
1194
+ PW_APP_ENTRY = os.path.join(HOME, ".local", "share", "applications", "playwright-chromium.desktop")
1195
+ PW_DESKTOP_ENTRY = os.path.join(HOME, "Desktop", "Playwright Chromium.desktop")
1196
+ PW_WATCHDOG = None
1197
+ PW_TASK = None
1198
+
1199
  DESKTOP_PAGE = r"""<!DOCTYPE html>
1200
  <html lang="zh-TW">
1201
  <head>
 
1839
  desktop_manager = DesktopManager()
1840
 
1841
 
1842
+ class PlaywrightManager:
1843
+ """背景安裝 Playwright Chromium,完成後在共用桌面建立啟動圖示。"""
1844
+
1845
+ def __init__(self):
1846
+ self._lock = asyncio.Lock()
1847
+ self._logf = None
1848
+ self.ready = False
1849
+ self.installing = False
1850
+ self.chromium_path = None
1851
+ self.version = ""
1852
+ self.last_error = ""
1853
+ self.installed_at = 0.0
1854
+ self._last_attempt = 0.0
1855
+
1856
+ # ── 日誌 ──────────────────────────────────────────────────
1857
+ def _log(self, message: str, level: int = logging.INFO):
1858
+ line = f"[playwright] {message}"
1859
+ log.log(level, "%s", line)
1860
+ try:
1861
+ f = self._logfd()
1862
+ if hasattr(f, "write"):
1863
+ f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + line + "\n")
1864
+ f.flush()
1865
+ except OSError:
1866
+ pass
1867
+
1868
+ def _logfd(self):
1869
+ if self._logf is None:
1870
+ try:
1871
+ self._logf = open(PW_LOG, "a", buffering=1)
1872
+ except OSError:
1873
+ self._logf = subprocess.DEVNULL
1874
+ return self._logf
1875
+
1876
+ # ── 執行工具 ──────────────────────────────────────────────
1877
+ @staticmethod
1878
+ def _env():
1879
+ env = os.environ.copy()
1880
+ env["PLAYWRIGHT_BROWSERS_PATH"] = PW_BROWSERS
1881
+ env["DEBIAN_FRONTEND"] = "noninteractive"
1882
+ return env
1883
+
1884
+ async def _exec(self, args, timeout=900):
1885
+ proc = await asyncio.create_subprocess_exec(
1886
+ *args, env=self._env(),
1887
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1888
+ )
1889
+ try:
1890
+ out, err = await asyncio.wait_for(proc.communicate(), timeout)
1891
+ except asyncio.TimeoutError:
1892
+ proc.kill()
1893
+ await proc.wait()
1894
+ return 124, "", f"逾時 {timeout}s"
1895
+ return proc.returncode, out.decode(errors="replace"), err.decode(errors="replace")
1896
+
1897
+ def _probe_path(self):
1898
+ code = (
1899
+ "from playwright.sync_api import sync_playwright\n"
1900
+ "with sync_playwright() as p:\n"
1901
+ " print(p.chromium.executable_path)\n"
1902
+ )
1903
+ try:
1904
+ proc = subprocess.run(
1905
+ [sys.executable, "-c", code], capture_output=True, text=True,
1906
+ timeout=120, env=self._env(),
1907
+ )
1908
+ except Exception as exc:
1909
+ self.last_error = f"無法取得 Chromium 路徑:{exc}"
1910
+ return None
1911
+ lines = [ln.strip() for ln in (proc.stdout or "").splitlines() if ln.strip()]
1912
+ path = lines[-1] if lines else ""
1913
+ if proc.returncode != 0 or not path or not os.path.isfile(path):
1914
+ return None
1915
+ return path
1916
+
1917
+ def _launch_check(self):
1918
+ code = (
1919
+ "from playwright.sync_api import sync_playwright\n"
1920
+ "with sync_playwright() as p:\n"
1921
+ " b = p.chromium.launch(headless=True, args=['--no-sandbox','--disable-dev-shm-usage'])\n"
1922
+ " pg = b.new_page()\n"
1923
+ " pg.set_content('<title>webterm-playwright-ok</title>')\n"
1924
+ " print(pg.title())\n"
1925
+ " b.close()\n"
1926
+ )
1927
+ try:
1928
+ proc = subprocess.run(
1929
+ [sys.executable, "-c", code], capture_output=True, text=True,
1930
+ timeout=180, env=self._env(),
1931
+ )
1932
+ except Exception as exc:
1933
+ self.last_error = f"Chromium 啟動測試例外:{exc}"
1934
+ return False
1935
+ if proc.returncode != 0 or "webterm-playwright-ok" not in (proc.stdout or ""):
1936
+ detail = ((proc.stderr or "") or (proc.stdout or "")).strip()
1937
+ self.last_error = f"Chromium 啟動測試失敗:{detail[-500:]}"
1938
+ return False
1939
+ return True
1940
+
1941
+ def _write_launchers(self):
1942
+ icon = ""
1943
+ for name in ("product_logo_48.png", "product_logo_32.png"):
1944
+ candidate = os.path.join(os.path.dirname(self.chromium_path), name)
1945
+ if os.path.isfile(candidate):
1946
+ icon = candidate
1947
+ break
1948
+ script = (
1949
+ "#!/bin/bash\n"
1950
+ f"export DISPLAY=\"${{DISPLAY:-{DESKTOP_DISPLAY}}}\"\n"
1951
+ f"exec {shlex.quote(self.chromium_path)} --no-sandbox --disable-dev-shm-usage "
1952
+ "--no-first-run --no-default-browser-check --start-maximized \"$@\"\n"
1953
+ )
1954
+ entry = (
1955
+ "[Desktop Entry]\n"
1956
+ "Type=Application\n"
1957
+ "Version=1.0\n"
1958
+ f"Name={PW_TITLE}\n"
1959
+ "Comment=Chromium (Playwright) on the shared LXDE desktop\n"
1960
+ f"Exec={PW_LAUNCHER} %U\n"
1961
+ f"Icon={icon or 'web-browser'}\n"
1962
+ "Terminal=false\n"
1963
+ "Categories=Network;WebBrowser;\n"
1964
+ "StartupNotify=true\n"
1965
+ )
1966
+ os.makedirs(os.path.dirname(PW_LAUNCHER), exist_ok=True)
1967
+ with open(PW_LAUNCHER, "w") as f:
1968
+ f.write(script)
1969
+ os.chmod(PW_LAUNCHER, 0o755)
1970
+ for path in (PW_APP_ENTRY, PW_DESKTOP_ENTRY):
1971
+ os.makedirs(os.path.dirname(path), exist_ok=True)
1972
+ with open(path, "w") as f:
1973
+ f.write(entry)
1974
+ os.chmod(path, 0o755)
1975
+
1976
+ # ── 安裝 ──────────────────────────────────────────────────
1977
+ async def _install(self) -> bool:
1978
+ self.ready = False
1979
+ self.last_error = ""
1980
+ self._log("開始安裝 Playwright Chromium…")
1981
+ rc, _, _ = await self._exec([sys.executable, "-c", "import playwright"], timeout=60)
1982
+ if rc != 0:
1983
+ self._log("Python 套件 playwright 不存在,先以 pip 安裝", logging.WARNING)
1984
+ rc, out, err = await self._exec(
1985
+ [sys.executable, "-m", "pip", "install", "--no-cache-dir", "playwright"],
1986
+ timeout=600,
1987
+ )
1988
+ if rc != 0:
1989
+ self.last_error = f"pip install playwright 失敗:{(err or out)[-500:]}"
1990
+ self._log(self.last_error, logging.WARNING)
1991
+ return False
1992
+
1993
+ rc, out, err = await self._exec(
1994
+ [sys.executable, "-m", "playwright", "install", "chromium"], timeout=1800)
1995
+ if rc != 0:
1996
+ self._log("Chromium 下載失敗,先安裝系統依賴再重試", logging.WARNING)
1997
+ await self._exec(
1998
+ [sys.executable, "-m", "playwright", "install-deps", "chromium"], timeout=1800)
1999
+ rc, out, err = await self._exec(
2000
+ [sys.executable, "-m", "playwright", "install", "chromium"], timeout=1800)
2001
+ if rc != 0:
2002
+ self.last_error = f"playwright install chromium 失敗:{(err or out)[-500:]}"
2003
+ self._log(self.last_error, logging.WARNING)
2004
+ return False
2005
+
2006
+ path = await asyncio.to_thread(self._probe_path)
2007
+ if not path:
2008
+ self.last_error = self.last_error or "找不到 Playwright 的 Chromium 執行檔"
2009
+ self._log(self.last_error, logging.WARNING)
2010
+ return False
2011
+ self.chromium_path = path
2012
+ self.version = os.path.basename(os.path.dirname(os.path.dirname(path)))
2013
+ self._log(f"Chromium 已下載:{path}")
2014
+
2015
+ if not await asyncio.to_thread(self._launch_check):
2016
+ self._log(self.last_error + ";嘗試安裝系統依賴", logging.WARNING)
2017
+ await self._exec(
2018
+ [sys.executable, "-m", "playwright", "install-deps", "chromium"], timeout=1800)
2019
+ if not await asyncio.to_thread(self._launch_check):
2020
+ self._log(self.last_error, logging.WARNING)
2021
+ return False
2022
+
2023
+ try:
2024
+ await asyncio.to_thread(self._write_launchers)
2025
+ except Exception as exc:
2026
+ self.last_error = f"建立桌面啟動圖示失敗:{exc!r}"
2027
+ self._log(self.last_error, logging.WARNING)
2028
+ return False
2029
+
2030
+ self.ready = True
2031
+ self.installed_at = time.time()
2032
+ self.last_error = ""
2033
+ self._log(f"Playwright Chromium 就緒({self.version}),桌面圖示:{PW_DESKTOP_ENTRY}")
2034
+ try:
2035
+ pcman = shutil.which("pcmanfm")
2036
+ if pcman and _desktop_port_open():
2037
+ subprocess.Popen(
2038
+ [pcman, "--reconfigure"],
2039
+ env={**os.environ, "DISPLAY": DESKTOP_DISPLAY},
2040
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
2041
+ )
2042
+ except Exception:
2043
+ pass
2044
+ return True
2045
+
2046
+ async def ensure(self, force: bool = False) -> bool:
2047
+ async with self._lock:
2048
+ if not force and self.chromium_path and os.path.isfile(self.chromium_path):
2049
+ self.ready = True
2050
+ if not os.path.isfile(PW_LAUNCHER):
2051
+ try:
2052
+ await asyncio.to_thread(self._write_launchers)
2053
+ except Exception:
2054
+ pass
2055
+ return True
2056
+ now = time.time()
2057
+ if not force and now - self._last_attempt < 300:
2058
+ return False
2059
+ self._last_attempt = now
2060
+ self.installing = True
2061
+ try:
2062
+ return await self._install()
2063
+ except Exception as exc:
2064
+ self.last_error = f"安裝例外:{exc!r}"
2065
+ self._log(self.last_error, logging.WARNING)
2066
+ return False
2067
+ finally:
2068
+ self.installing = False
2069
+
2070
+ def status(self) -> dict:
2071
+ return {
2072
+ "ready": self.ready,
2073
+ "installing": self.installing,
2074
+ "chromium": self.chromium_path,
2075
+ "version": self.version,
2076
+ "browsers_path": PW_BROWSERS,
2077
+ "launcher": PW_LAUNCHER if os.path.isfile(PW_LAUNCHER) else None,
2078
+ "desktop_entry": PW_DESKTOP_ENTRY if os.path.isfile(PW_DESKTOP_ENTRY) else None,
2079
+ "installed_at": round(self.installed_at, 3) if self.installed_at else None,
2080
+ "last_error": self.last_error,
2081
+ }
2082
+
2083
+
2084
+ playwright_manager = PlaywrightManager()
2085
+
2086
+
2087
+ @app.get("/api/playwright/status")
2088
+ async def api_playwright_status():
2089
+ return playwright_manager.status()
2090
+
2091
+
2092
+ @app.post("/api/playwright/install")
2093
+ async def api_playwright_install():
2094
+ global PW_TASK
2095
+ if PW_TASK is None or PW_TASK.done():
2096
+ PW_TASK = asyncio.create_task(playwright_manager.ensure(force=True))
2097
+ return {"ok": True, "started": True, **playwright_manager.status()}
2098
+
2099
+
2100
+ async def playwright_watchdog():
2101
+ """背景安裝 Chromium;完成後每 5 分鐘確認執行檔還在。"""
2102
+ await asyncio.sleep(10)
2103
+ while True:
2104
+ ok = False
2105
+ try:
2106
+ ok = await playwright_manager.ensure()
2107
+ except Exception as exc:
2108
+ log.warning("[playwright] 背景安裝例外(終端機不受影響):%s", exc)
2109
+ await asyncio.sleep(300 if ok else 60)
2110
+
2111
+
2112
  async def desktop_watchdog():
2113
  """每 3 秒確認桌面還在,且永遠只有一份;不影響終端機流程。"""
2114
  await asyncio.sleep(1)
 
2129
 
2130
  @app.on_event("startup")
2131
  async def startup_event():
2132
+ global DESKTOP_WATCHDOG, PW_WATCHDOG
2133
  _load_tabs()
2134
  _write_shell_launcher()
2135
  await ensure_tmux()
 
2140
  DESKTOP_WATCHDOG = asyncio.create_task(desktop_watchdog())
2141
  except Exception as _desktop_err: # pragma: no cover
2142
  log.warning("[desktop] 無法啟動看門狗(終端機照常運作):%r", _desktop_err)
2143
+ # Playwright Chromium 背景下載;完成後桌面會出現啟動圖示。
2144
+ try:
2145
+ if PW_WATCHDOG is None or PW_WATCHDOG.done():
2146
+ PW_WATCHDOG = asyncio.create_task(playwright_watchdog())
2147
+ except Exception as _pw_err: # pragma: no cover
2148
+ log.warning("[playwright] 無法啟動背景安裝(終端機照常運作):%r", _pw_err)
2149
  dummy_gpu()
2150
  if SKIP_INSTALL:
2151
  log.info("WEBTERM_SKIP_INSTALL=1:跳過 opencode / 編輯器安裝")
packages.txt CHANGED
@@ -5,3 +5,34 @@ lxde-core
5
  xterm
6
  novnc
7
  x11-utils
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  xterm
6
  novnc
7
  x11-utils
8
+ libasound2t64
9
+ libatk-bridge2.0-0t64
10
+ libatk1.0-0t64
11
+ libatspi2.0-0t64
12
+ libcairo2
13
+ libcups2t64
14
+ libdbus-1-3
15
+ libdrm2
16
+ libgbm1
17
+ libglib2.0-0t64
18
+ libnspr4
19
+ libnss3
20
+ libpango-1.0-0
21
+ libx11-6
22
+ libxcb1
23
+ libxcomposite1
24
+ libxdamage1
25
+ libxext6
26
+ libxfixes3
27
+ libxkbcommon0
28
+ libxrandr2
29
+ fonts-noto-color-emoji
30
+ fonts-unifont
31
+ libfontconfig1
32
+ libfreetype6
33
+ xfonts-scalable
34
+ fonts-liberation
35
+ fonts-ipafont-gothic
36
+ fonts-wqy-zenhei
37
+ fonts-tlwg-loma-otf
38
+ fonts-freefont-ttf
requirements.txt CHANGED
@@ -2,3 +2,4 @@ fastapi
2
  uvicorn[standard]
3
  websockets
4
  httpx
 
 
2
  uvicorn[standard]
3
  websockets
4
  httpx
5
+ playwright