diff --git a/d2ypp2/services/neko/browser-launch.sh b/d2ypp2/services/neko/browser-launch.sh index 52b53fc..5190cc1 100644 --- a/d2ypp2/services/neko/browser-launch.sh +++ b/d2ypp2/services/neko/browser-launch.sh @@ -12,6 +12,45 @@ prune_path() { rm -rf "$target" } +ensure_profile_preferences() { + if ! command -v python3 >/dev/null 2>&1; then + return 0 + fi + mkdir -p "$profile_dir/Default" + PROFILE_DIR="$profile_dir" python3 - <<'PY' +import json +import os +from pathlib import Path + +profile_dir = Path(os.environ["PROFILE_DIR"]) + +def load_json(path: Path) -> dict: + if path.exists(): + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + +def save_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(data, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + +for relative_path in ("Default/Preferences", "Local State"): + path = profile_dir / relative_path + data = load_json(path) + extensions = data.setdefault("extensions", {}) + ui = extensions.setdefault("ui", {}) + ui["developer_mode"] = True + profile = data.setdefault("profile", {}) + profile["exit_type"] = "Normal" + save_json(path, data) +PY +} + find_bin() { if [ "$family" = "google-chrome" ]; then command -v google-chrome 2>/dev/null || command -v google-chrome-stable 2>/dev/null || true @@ -67,25 +106,74 @@ prune_path "$profile_dir/Default/Code Cache" prune_path "$profile_dir/Default/GPUCache" prune_path "$profile_dir/Default/DawnGraphiteCache" prune_path "$profile_dir/Default/DawnWebGPUCache" +ensure_profile_preferences -exec "$browser_bin" \ - ${render_flags} \ - --window-position=0,0 \ - --display="${DISPLAY:?DISPLAY is required}" \ - --user-data-dir="$profile_dir" \ - --password-store=basic \ - --no-first-run \ - --start-minimized \ - --force-dark-mode \ - --disable-file-system \ - --disable-dev-shm-usage \ - --disable-background-networking \ - --disable-breakpad \ - --disable-default-apps \ - --disable-extensions \ - --disable-sync \ - --disable-translate \ - --metrics-recording-only \ - --no-default-browser-check \ - --no-first-run \ - "$start_url" +browser_class="google-chrome" +if [ "$family" != "google-chrome" ]; then + browser_class="chromium" +fi + +launch_browser() { + "$browser_bin" \ + ${render_flags} \ + --window-position=0,0 \ + --display="${DISPLAY:?DISPLAY is required}" \ + --remote-debugging-address=127.0.0.1 \ + --remote-debugging-port=9222 \ + --user-data-dir="$profile_dir" \ + --password-store=basic \ + --no-first-run \ + --new-window \ + --start-maximized \ + --force-dark-mode \ + --disable-background-mode \ + --disable-dev-shm-usage \ + --disable-background-networking \ + --disable-breakpad \ + --disable-default-apps \ + --disable-sync \ + --disable-translate \ + --metrics-recording-only \ + --no-default-browser-check \ + --no-first-run \ + "$start_url" & + browser_pid=$! +} + +visible_window_count() { + if ! command -v xdotool >/dev/null 2>&1; then + return 1 + fi + DISPLAY="${DISPLAY:?DISPLAY is required}" xdotool search --onlyvisible --class "$browser_class" 2>/dev/null | wc -l | tr -d ' ' +} + +launch_browser + +seen_window=0 +startup_misses=0 +closed_misses=0 +while kill -0 "$browser_pid" 2>/dev/null; do + window_count="$(visible_window_count || true)" + if [ -n "${window_count:-}" ] && [ "$window_count" -gt 0 ] 2>/dev/null; then + seen_window=1 + startup_misses=0 + closed_misses=0 + else + if [ "$seen_window" -eq 0 ]; then + startup_misses=$((startup_misses + 1)) + if [ "$startup_misses" -ge 15 ]; then + kill -TERM "$browser_pid" 2>/dev/null || true + break + fi + else + closed_misses=$((closed_misses + 1)) + if [ "$closed_misses" -ge 3 ]; then + kill -TERM "$browser_pid" 2>/dev/null || true + break + fi + fi + fi + sleep 2 +done + +wait "$browser_pid" diff --git a/d2ypp2/services/neko/docker-compose.yml b/d2ypp2/services/neko/docker-compose.yml index 67eeef7..66fbb82 100644 --- a/d2ypp2/services/neko/docker-compose.yml +++ b/d2ypp2/services/neko/docker-compose.yml @@ -40,8 +40,9 @@ services: - "${NEKO_PROFILE_HOST_DIR_1}:/home/neko/.config/google-chrome" - "./policies:/etc/chromium/policies/managed:ro" - "./policies:/etc/opt/chrome/policies/managed:ro" + - "./provisioning:/var/www/provisioning:ro" - "./browser-launch.sh:/etc/neko/browser-launch.sh:ro" - - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro" + - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/${NEKO_BROWSER_SUPERVISOR_CONF}:ro" neko2: <<: *neko-common @@ -61,8 +62,9 @@ services: - "${NEKO_PROFILE_HOST_DIR_2}:/home/neko/.config/google-chrome" - "./policies:/etc/chromium/policies/managed:ro" - "./policies:/etc/opt/chrome/policies/managed:ro" + - "./provisioning:/var/www/provisioning:ro" - "./browser-launch.sh:/etc/neko/browser-launch.sh:ro" - - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro" + - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/${NEKO_BROWSER_SUPERVISOR_CONF}:ro" neko3: <<: *neko-common @@ -82,5 +84,6 @@ services: - "${NEKO_PROFILE_HOST_DIR_3}:/home/neko/.config/google-chrome" - "./policies:/etc/chromium/policies/managed:ro" - "./policies:/etc/opt/chrome/policies/managed:ro" + - "./provisioning:/var/www/provisioning:ro" - "./browser-launch.sh:/etc/neko/browser-launch.sh:ro" - - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro" + - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/${NEKO_BROWSER_SUPERVISOR_CONF}:ro" diff --git a/d2ypp2/services/neko/policies/policies.json b/d2ypp2/services/neko/policies/policies.json index 9ab0815..3ada497 100644 --- a/d2ypp2/services/neko/policies/policies.json +++ b/d2ypp2/services/neko/policies/policies.json @@ -17,5 +17,19 @@ "VideoCaptureAllowedUrls": ["https://*.google.com/*", "https://*.youtube.com/*"], "AudioCaptureAllowedUrls": ["https://*.google.com/*", "https://*.youtube.com/*"], "DeveloperToolsAvailability": 1, - "DefaultGeolocationSetting": 1 + "DefaultGeolocationSetting": 1, + "3rdparty": { + "extensions": { + "dhdgffkkebhmkfjojejmpbldmpobfkfo": { + "jsonImport": [ + { + "hash": "1:1b41fa75bfe51a730bb246022f619811bc508da3176463c86009fe5a0a9f16b5", + "url": "http://127.0.0.1:8080/provisioning/tampermonkey-provisioning.json", + "haltOnError": false, + "installAsSystemScripts": true + } + ] + } + } + } } diff --git a/d2ypp2/services/neko/provisioning/tampermonkey-provisioning.json b/d2ypp2/services/neko/provisioning/tampermonkey-provisioning.json new file mode 100644 index 0000000..36da46e --- /dev/null +++ b/d2ypp2/services/neko/provisioning/tampermonkey-provisioning.json @@ -0,0 +1,15 @@ +{ + "version": "1", + "scripts": [ + { + "name": "YouTube Studio Auto Dismiss", + "file_url": "https://greasyfork.org/scripts/557378-youtube-studio-auto-dismiss/code/YouTube%20Studio%20Auto%20Dismiss.user.js", + "source": "// ==UserScript==\n// @name YouTube Studio Auto Dismiss\n// @namespace http://tampermonkey.net/\n// @version 1.1\n// @description 每5秒自动检测并点击 YouTube Studio 直播页面的 Dismiss 按钮,支持延迟加载\n// @author You\n// @match https://studio.youtube.com/channel/*/livestreaming\n// @match https://studio.youtube.com/video/*/livestreaming\n// @match https://studio.youtube.com/*/livestreaming\n// @license MIT\n// @grant none\n// @downloadURL https://update.greasyfork.org/scripts/557378/YouTube%20Studio%20Auto%20Dismiss.user.js\n// @updateURL https://update.greasyfork.org/scripts/557378/YouTube%20Studio%20Auto%20Dismiss.meta.js\n// ==/UserScript==\n\n(function () {\n \"use strict\";\n\n function triggerClick(el) {\n if (!el) return;\n const evt = new MouseEvent(\"click\", {\n view: window,\n bubbles: true,\n cancelable: true,\n });\n el.dispatchEvent(evt);\n console.log(\"Dismiss 按钮已点击:\", el, \"父元素:\", el.parentElement);\n }\n\n function findDismiss(root = document) {\n const allElements = root.querySelectorAll(\"button\");\n for (const el of allElements) {\n try {\n const text = el.innerText ? el.innerText.trim() : \"\";\n if (text === \"Dismiss\" && isElementVisible(el)) {\n console.log(\"找到 Dismiss 按钮:\", el);\n return el;\n }\n\n if (el.shadowRoot) {\n const shadowBtn = findDismiss(el.shadowRoot);\n if (shadowBtn) return shadowBtn;\n }\n } catch (error) {\n console.log(\"检查元素时出错:\", error);\n }\n }\n return null;\n }\n\n function isElementVisible(el) {\n const style = window.getComputedStyle(el);\n return style.display !== \"none\" && style.visibility !== \"hidden\" && el.offsetParent !== null;\n }\n\n function checkAndClick() {\n const btn = findDismiss();\n if (btn) {\n triggerClick(btn);\n } else {\n console.log(\"未找到 Dismiss 按钮或按钮不可见\");\n }\n }\n\n if (document.readyState === \"complete\") {\n checkAndClick();\n } else {\n window.addEventListener(\"load\", checkAndClick, { once: true });\n }\n\n setInterval(checkAndClick, 5000);\n})();\n", + "enabled": true, + "position": 1 + } + ], + "settings": { + "runtime_content_mode": "content" + } +} diff --git a/d2ypp2/services/neko/provisioning/youtube-studio-auto-dismiss.user.js b/d2ypp2/services/neko/provisioning/youtube-studio-auto-dismiss.user.js new file mode 100644 index 0000000..6918665 --- /dev/null +++ b/d2ypp2/services/neko/provisioning/youtube-studio-auto-dismiss.user.js @@ -0,0 +1,72 @@ +// ==UserScript== +// @name YouTube Studio Auto Dismiss +// @namespace http://tampermonkey.net/ +// @version 1.1 +// @description 每5秒自动检测并点击 YouTube Studio 直播页面的 Dismiss 按钮,支持延迟加载 +// @author You +// @match https://studio.youtube.com/channel/*/livestreaming +// @match https://studio.youtube.com/video/*/livestreaming +// @match https://studio.youtube.com/*/livestreaming +// @license MIT +// @grant none +// @downloadURL https://update.greasyfork.org/scripts/557378/YouTube%20Studio%20Auto%20Dismiss.user.js +// @updateURL https://update.greasyfork.org/scripts/557378/YouTube%20Studio%20Auto%20Dismiss.meta.js +// ==/UserScript== + +(function () { + "use strict"; + + function triggerClick(el) { + if (!el) return; + const evt = new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + }); + el.dispatchEvent(evt); + console.log("Dismiss 按钮已点击:", el, "父元素:", el.parentElement); + } + + function findDismiss(root = document) { + const allElements = root.querySelectorAll("button"); + for (const el of allElements) { + try { + const text = el.innerText ? el.innerText.trim() : ""; + if (text === "Dismiss" && isElementVisible(el)) { + console.log("找到 Dismiss 按钮:", el); + return el; + } + + if (el.shadowRoot) { + const shadowBtn = findDismiss(el.shadowRoot); + if (shadowBtn) return shadowBtn; + } + } catch (error) { + console.log("检查元素时出错:", error); + } + } + return null; + } + + function isElementVisible(el) { + const style = window.getComputedStyle(el); + return style.display !== "none" && style.visibility !== "hidden" && el.offsetParent !== null; + } + + function checkAndClick() { + const btn = findDismiss(); + if (btn) { + triggerClick(btn); + } else { + console.log("未找到 Dismiss 按钮或按钮不可见"); + } + } + + if (document.readyState === "complete") { + checkAndClick(); + } else { + window.addEventListener("load", checkAndClick, { once: true }); + } + + setInterval(checkAndClick, 5000); +})(); diff --git a/d2ypp2/tests/test_neko_config.py b/d2ypp2/tests/test_neko_config.py index 66ae12b..891c46a 100644 --- a/d2ypp2/tests/test_neko_config.py +++ b/d2ypp2/tests/test_neko_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import json import unittest from pathlib import Path @@ -15,8 +16,14 @@ class NekoConfigTests(unittest.TestCase): self.assertIn("--disable-software-rasterizer", script) self.assertIn("--use-gl=swiftshader", script) self.assertIn("NEKO_BROWSER_RENDER_MODE", script) - self.assertIn("--start-minimized", script) + self.assertIn("--start-maximized", script) + self.assertIn("--disable-background-mode", script) self.assertIn("--disable-background-networking", script) + self.assertNotIn("--disable-extensions", script) + self.assertIn('ui["developer_mode"] = True', script) + self.assertIn('"Local State"', script) + self.assertIn('browser_class="google-chrome"', script) + self.assertIn('xdotool search --onlyvisible --class "$browser_class"', script) self.assertIn('prune_path "$profile_dir/BrowserMetrics"', script) self.assertIn('prune_path "$profile_dir/Crash Reports"', script) self.assertIn('prune_path "$profile_dir/SingletonLock"', script) @@ -29,6 +36,10 @@ class NekoConfigTests(unittest.TestCase): self.assertTrue(policies["VideoCaptureAllowed"]) self.assertTrue(policies["AudioCaptureAllowed"]) self.assertEqual(policies["DeveloperToolsAvailability"], 1) + tm_policy = policies["3rdparty"]["extensions"]["dhdgffkkebhmkfjojejmpbldmpobfkfo"]["jsonImport"][0] + self.assertEqual(tm_policy["url"], "http://127.0.0.1:8080/provisioning/tampermonkey-provisioning.json") + self.assertTrue(tm_policy["hash"].startswith("1:")) + self.assertTrue(tm_policy["installAsSystemScripts"]) def test_google_chrome_supervisor_uses_direct_stable_command(self) -> None: conf = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8") @@ -53,16 +64,16 @@ class NekoConfigTests(unittest.TestCase): def test_neko_compose_mounts_only_selected_supervisor_conf(self) -> None: compose = (ROOT / "services" / "neko" / "docker-compose.yml").read_text(encoding="utf-8") - self.assertIn("./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro", compose) + self.assertIn("./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/${NEKO_BROWSER_SUPERVISOR_CONF}:ro", compose) self.assertIn("./browser-launch.sh:/etc/neko/browser-launch.sh:ro", compose) + self.assertIn("./provisioning:/var/www/provisioning:ro", compose) self.assertIn("container_name: live-neko-1", compose) self.assertIn("container_name: live-neko-2", compose) self.assertIn("container_name: live-neko-3", compose) self.assertIn("${NEKO_HTTP_PORT_1}:8080", compose) self.assertIn("${NEKO_HTTP_PORT_2}:8080", compose) self.assertIn("${NEKO_HTTP_PORT_3}:8080", compose) - self.assertNotIn("./chromium.conf:/etc/neko/supervisord/chromium.conf:ro", compose) - self.assertNotIn("./google-chrome.conf:/etc/neko/supervisord/google-chrome.conf:ro", compose) + self.assertNotIn("/etc/neko/supervisord/browser.conf:ro", compose) def test_supervisor_uses_browser_launch_script(self) -> None: chrome = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8") @@ -72,6 +83,28 @@ class NekoConfigTests(unittest.TestCase): self.assertNotIn("--start-maximized", chrome) self.assertNotIn("--start-maximized", chromium) + def test_tampermonkey_provisioning_matches_policy_hash(self) -> None: + provisioning_path = ROOT / "services" / "neko" / "provisioning" / "tampermonkey-provisioning.json" + userscript_path = ROOT / "services" / "neko" / "provisioning" / "youtube-studio-auto-dismiss.user.js" + payload = json.loads(provisioning_path.read_text(encoding="utf-8")) + self.assertEqual(payload["version"], "1") + self.assertEqual(payload["settings"]["runtime_content_mode"], "content") + self.assertEqual(payload["scripts"][0]["file_url"], "https://greasyfork.org/scripts/557378-youtube-studio-auto-dismiss/code/YouTube%20Studio%20Auto%20Dismiss.user.js") + self.assertEqual(payload["scripts"][0]["source"], userscript_path.read_text(encoding="utf-8")) + + spec = importlib.util.spec_from_file_location( + "render_tampermonkey_provisioning", + ROOT / "tools" / "render_tampermonkey_provisioning.py", + ) + self.assertIsNotNone(spec) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + + policies = json.loads((ROOT / "services" / "neko" / "policies" / "policies.json").read_text(encoding="utf-8")) + expected_hash = policies["3rdparty"]["extensions"]["dhdgffkkebhmkfjojejmpbldmpobfkfo"]["jsonImport"][0]["hash"] + self.assertEqual(expected_hash, f"1:{module.tm_hash(payload)}") + def test_neko_scripts_use_family_specific_profile_dirs(self) -> None: service_ctl = (ROOT / "scripts" / "linux" / "service_ctl.sh").read_text(encoding="utf-8") common = (ROOT / "scripts" / "linux" / "lib" / "common.sh").read_text(encoding="utf-8") diff --git a/d2ypp2/tools/render_tampermonkey_provisioning.py b/d2ypp2/tools/render_tampermonkey_provisioning.py new file mode 100644 index 0000000..5c68a8f --- /dev/null +++ b/d2ypp2/tools/render_tampermonkey_provisioning.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_SCRIPT = ROOT / "services" / "neko" / "provisioning" / "youtube-studio-auto-dismiss.user.js" +DEFAULT_OUTPUT = ROOT / "services" / "neko" / "provisioning" / "tampermonkey-provisioning.json" +GREASYFORK_URL = "https://greasyfork.org/scripts/557378-youtube-studio-auto-dismiss/code/YouTube%20Studio%20Auto%20Dismiss.user.js" + + +def tm_sha256(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def tm_hash(value: Any, seen: list[int] | None = None) -> str: + if seen is None: + seen = [] + if value is None: + return tm_sha256("object:null") + if isinstance(value, dict): + ident = id(value) + if ident in seen: + raise ValueError("Found circular structure") + seen.append(ident) + try: + parts = [tm_hash(value[key], seen) for key in sorted(value)] + finally: + seen.pop() + return tm_sha256("".join(parts)) + if isinstance(value, list): + ident = id(value) + if ident in seen: + raise ValueError("Found circular structure") + seen.append(ident) + try: + parts = [tm_hash(item, seen) for item in value] + finally: + seen.pop() + return tm_sha256("".join(parts)) + if isinstance(value, bool): + return tm_sha256(f"boolean:{str(value).lower()}") + if isinstance(value, (int, float)) and not isinstance(value, bool): + return tm_sha256(f"number:{value}") + if isinstance(value, str): + return tm_sha256(f"string:{value}") + raise TypeError(f"Unsupported provisioning value: {type(value)!r}") + + +def build_payload(script_text: str) -> dict[str, Any]: + return { + "version": "1", + "scripts": [ + { + "name": "YouTube Studio Auto Dismiss", + "file_url": GREASYFORK_URL, + "source": script_text, + "enabled": True, + "position": 1, + } + ], + "settings": { + "runtime_content_mode": "content", + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render Tampermonkey provisioning JSON and hash.") + parser.add_argument("--script", type=Path, default=DEFAULT_SCRIPT) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument("--write", action="store_true", help="Write the rendered JSON to --output.") + args = parser.parse_args() + + script_text = args.script.read_text(encoding="utf-8").strip() + "\n" + payload = build_payload(script_text) + rendered = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + if args.write: + args.output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + print(f"hash=1:{tm_hash(payload)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())