diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b43b4a2c..cb2185e2 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -11,7 +11,7 @@ name: Claude Code Review # the safe path. If you add steps here, preserve this invariant. on: pull_request_target: - types: [opened, synchronize, ready_for_review, reopened] + types: [opened, synchronize, ready_for_review, reopened, labeled] # Optional: Only run on specific file changes # paths: # - "src/**/*.ts" @@ -21,11 +21,13 @@ on: jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + # Maintainers apply this label to explicitly approve privileged review on fork PRs. + if: | + github.event.pull_request.head.repo.full_name == github.repository || + github.event.pull_request.author_association == 'OWNER' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.author_association == 'COLLABORATOR' || + contains(github.event.pull_request.labels.*.name, 'safe-to-run-claude-review') runs-on: ubuntu-latest permissions: @@ -36,13 +38,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 1 - name: Run Claude Code Review id: claude-review - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@51ea8ea73a139f2a74ff649e3092c25a904aed7e with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' diff --git a/clients/desktop/PlayPalace.spec b/clients/desktop/PlayPalace.spec index f9ee271a..660d335a 100644 --- a/clients/desktop/PlayPalace.spec +++ b/clients/desktop/PlayPalace.spec @@ -1,10 +1,15 @@ # -*- mode: python ; coding: utf-8 -*- +from pathlib import Path + + +CLIENT_DIR = Path(SPECPATH) + a = Analysis( - ['client.py'], - pathex=[], + [str(CLIENT_DIR / 'client.py')], + pathex=[str(CLIENT_DIR)], binaries=[], - datas=[('sounds', 'sounds')], + datas=[(str(CLIENT_DIR / 'sounds'), 'sounds')], hiddenimports=[], hookspath=[], hooksconfig={}, @@ -24,7 +29,7 @@ exe = EXE( debug=False, bootloader_ignore_signals=False, strip=False, - upx=True, + upx=False, console=False, disable_windowed_traceback=False, argv_emulation=False, @@ -37,13 +42,13 @@ coll = COLLECT( a.binaries, a.datas, strip=False, - upx=True, + upx=False, upx_exclude=[], name='PlayPalace', ) app = BUNDLE( coll, name='PlayPalace.app', - icon=None, - bundle_identifier=None, + icon=str(CLIENT_DIR / 'assets' / 'playpalace.icns'), + bundle_identifier='com.xgdevgroup.playpalace', ) diff --git a/clients/desktop/assets/playpalace.icns b/clients/desktop/assets/playpalace.icns new file mode 100644 index 00000000..25d6e6b3 Binary files /dev/null and b/clients/desktop/assets/playpalace.icns differ diff --git a/clients/desktop/tests/conftest.py b/clients/desktop/tests/conftest.py index a72e4eb8..48080789 100644 --- a/clients/desktop/tests/conftest.py +++ b/clients/desktop/tests/conftest.py @@ -1,10 +1,14 @@ import pytest -import wx @pytest.fixture(scope="session") def wx_app(): """Ensure a wx App exists for dialog tests.""" + try: + import wx + except ModuleNotFoundError: + pytest.skip("wxPython is not installed") + if not wx.App.IsDisplayAvailable(): pytest.skip("GUI display is not available for wx dialogs") app = wx.App(False) diff --git a/clients/desktop/tests/test_markdown_viewer_security.py b/clients/desktop/tests/test_markdown_viewer_security.py new file mode 100644 index 00000000..9dd55913 --- /dev/null +++ b/clients/desktop/tests/test_markdown_viewer_security.py @@ -0,0 +1,253 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import re +import sys +import types + + +def _load_markdown_viewer_dialog_module(): + if "markdown" not in sys.modules: + markdown_module = types.ModuleType("markdown") + + def _markdown_to_html(text, extensions=None): + text = re.sub( + r"!\[([^\]]*)\]\(([^)]+)\)", + lambda match: f'{match.group(1)}', + text, + ) + text = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", + lambda match: f'{match.group(1)}', + text, + ) + blocks = [block.strip() for block in text.split("\n\n") if block.strip()] + html_blocks = [] + for block in blocks: + if block.startswith("<"): + html_blocks.append(block) + else: + html_blocks.append(f"

{block}

") + return "\n".join(html_blocks) + + markdown_module.markdown = _markdown_to_html + sys.modules["markdown"] = markdown_module + + if "nh3" not in sys.modules: + nh3_module = types.ModuleType("nh3") + + def _clean_html(html, tags=None, attributes=None, url_schemes=None, link_rel=None): + cleaned = re.sub(r".*?", "", html, flags=re.IGNORECASE | re.DOTALL) + cleaned = re.sub(r'\s+on\w+="[^"]*"', "", cleaned) + cleaned = re.sub(r'\s+on\w+=\'[^\']*\'', "", cleaned) + cleaned = re.sub(r'(href|src)="javascript:[^"]*"', "", cleaned, flags=re.IGNORECASE) + if link_rel: + cleaned = re.sub(r"]*\brel=)", f']*>", "", cleaned) + return cleaned + + nh3_module.clean = _clean_html + sys.modules["nh3"] = nh3_module + + wx_module = types.ModuleType("wx") + + class _Dialog: + def __init__(self, *args, **kwargs): + pass + + def Bind(self, *args, **kwargs): + pass + + def SetAcceleratorTable(self, *args, **kwargs): + pass + + def SetSize(self, *args, **kwargs): + pass + + def CenterOnScreen(self): + pass + + def EndModal(self, *args, **kwargs): + pass + + class _Panel: + def __init__(self, *args, **kwargs): + pass + + def SetSizer(self, *args, **kwargs): + pass + + class _BoxSizer: + def __init__(self, *args, **kwargs): + pass + + def Add(self, *args, **kwargs): + pass + + class _Button: + def __init__(self, *args, **kwargs): + pass + + def Bind(self, *args, **kwargs): + pass + + class _AcceleratorTable: + def __init__(self, *args, **kwargs): + pass + + class _AcceleratorEntry: + def __init__(self, *args, **kwargs): + pass + + class _Color: + def Red(self): + return 0 + + def Green(self): + return 0 + + def Blue(self): + return 0 + + class _SystemSettings: + @staticmethod + def GetColour(_color_id): + return _Color() + + wx_module.Dialog = _Dialog + wx_module.Panel = _Panel + wx_module.BoxSizer = _BoxSizer + wx_module.Button = _Button + wx_module.AcceleratorTable = _AcceleratorTable + wx_module.AcceleratorEntry = _AcceleratorEntry + wx_module.SystemSettings = _SystemSettings + wx_module.DEFAULT_DIALOG_STYLE = 0 + wx_module.RESIZE_BORDER = 0 + wx_module.VERTICAL = 0 + wx_module.EXPAND = 0 + wx_module.ALL = 0 + wx_module.ALIGN_RIGHT = 0 + wx_module.ID_CLOSE = 0 + wx_module.EVT_BUTTON = object() + wx_module.EVT_CLOSE = object() + wx_module.ACCEL_NORMAL = 0 + wx_module.WXK_ESCAPE = 27 + wx_module.SYS_COLOUR_WINDOW = 0 + wx_module.SYS_COLOUR_WINDOWTEXT = 1 + wx_module.SYS_COLOUR_GRAYTEXT = 2 + wx_module.SYS_COLOUR_HOTLIGHT = 3 + + wx_html2_module = types.ModuleType("wx.html2") + + class _WebView: + last_created = None + + def __init__(self): + self.bound_events = [] + self.set_page_calls = [] + + @staticmethod + def New(*args, **kwargs): + web_view = _WebView() + _WebView.last_created = web_view + return web_view + + def Bind(self, *args, **kwargs): + self.bound_events.append((args, kwargs)) + + def SetPage(self, html, base_url): + self.set_page_calls.append((html, base_url)) + + def SetFocus(self): + pass + + def RunScript(self, *args, **kwargs): + pass + + wx_html2_module.WebView = _WebView + wx_html2_module.EVT_WEBVIEW_LOADED = object() + wx_html2_module.EVT_WEBVIEW_NAVIGATING = object() + + wx_module.html2 = wx_html2_module + sys.modules["wx"] = wx_module + sys.modules["wx.html2"] = wx_html2_module + + module_path = Path(__file__).resolve().parents[1] / "ui" / "markdown_viewer_dialog.py" + spec = spec_from_file_location("test_markdown_viewer_dialog", module_path) + module = module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + return module + + +markdown_viewer_dialog = _load_markdown_viewer_dialog_module() +MARKDOWN_DOCUMENT_BASE_URL = markdown_viewer_dialog.MARKDOWN_DOCUMENT_BASE_URL +MarkdownViewerDialog = markdown_viewer_dialog.MarkdownViewerDialog +sanitize_markdown = markdown_viewer_dialog.sanitize_markdown +should_allow_navigation = markdown_viewer_dialog.should_allow_navigation + + +def test_sanitize_markdown_strips_scripts_and_event_handlers(): + html = sanitize_markdown("[x](javascript:alert(1))\n\n\n\nok") + + assert "javascript:" not in html + assert " str | def _map_function_key(self, event, key_code: int) -> str | None: key_map = { - wx.WXK_F1: "f1", wx.WXK_F3: "f3", wx.WXK_F5: "f5", } - if key_code in (wx.WXK_F2, wx.WXK_F4): + if key_code in (wx.WXK_F1, wx.WXK_F2, wx.WXK_F4): event.Skip() return None return key_map.get(key_code) @@ -730,7 +737,6 @@ def _send_keybind_if_allowed(self, event, key_name: str) -> bool: has_shift = (modifiers & wx.MOD_SHIFT) != 0 is_function_key = key_name in [ - "f1", "f2", "f3", "f5", @@ -840,9 +846,8 @@ def on_chat_enter(self, event): self.chat_input.Clear() def get_language_name(self, text: str = "") -> str: - """Get the name of a language based on input.""" if not text: - return self.client_options["social"]["chat_input_language"] + return self.client_options.get("social", {}).get("chat_input_language", "English") text = text.lower() if text in self.lang_codes.keys(): return self.lang_codes[text] @@ -852,9 +857,8 @@ def get_language_name(self, text: str = "") -> str: return "" def get_language_code(self, name: str = "") -> str: - """Get a language code from its name.""" if not name: - name = self.client_options["social"]["chat_input_language"] + name = self.client_options.get("social", {}).get("chat_input_language", "English") try: return tuple(self.lang_codes.keys())[tuple(self.lang_codes.values()).index(name)] except ValueError: @@ -1679,7 +1683,7 @@ def on_receive_chat(self, packet): convo = packet.get("convo") lang = packet.get("language") # For now all chats are in English - same_user = packet.get("sender") == self.credentials["username"] + same_user = packet.get("sender") == self.credentials.get("username") """comment out all of this code for now if lang not in self.lang_codes.values(): lang = "Other" @@ -1811,7 +1815,13 @@ def on_server_get_playlist_duration(self, packet): def on_table_create(self, packet): host = packet.get("host") game = packet.get("game") - if not self.client_options["local_table"]["creation_notifications"][game]: + notification_enabled = ( + self.client_options + .get("local_table", {}) + .get("creation_notifications", {}) + .get(game, False) + ) + if not notification_enabled: return self.sound_manager.play("notify.ogg") self.add_history(f"{host} is hosting {game}.", "activity") diff --git a/clients/desktop/ui/markdown_viewer_dialog.py b/clients/desktop/ui/markdown_viewer_dialog.py index b08e4a6d..54894986 100644 --- a/clients/desktop/ui/markdown_viewer_dialog.py +++ b/clients/desktop/ui/markdown_viewer_dialog.py @@ -1,5 +1,7 @@ """Markdown viewer dialog for displaying rendered document content.""" +from urllib.parse import urldefrag + import wx import wx.html2 import markdown @@ -29,7 +31,6 @@ def _sys_color_hex(color_id): "pre", "code", "em", "strong", "del", "s", "a", - "img", "table", "thead", "tbody", "tr", "th", "td", "div", "span", "sub", "sup", @@ -39,7 +40,6 @@ def _sys_color_hex(color_id): _ALLOWED_ATTRIBUTES = { "a": {"href", "title"}, - "img": {"src", "alt", "title", "width", "height"}, "abbr": {"title"}, "td": {"align"}, "th": {"align"}, @@ -128,6 +128,29 @@ def _sys_color_hex(color_id): """ +MARKDOWN_DOCUMENT_BASE_URL = "playpalace://document/viewer" + + +def sanitize_markdown(markdown_content: str) -> str: + """Convert untrusted Markdown to sanitized HTML for document viewing.""" + html_body = markdown.markdown( + markdown_content, + extensions=_MD_EXTENSIONS, + ) + return nh3.clean( + html_body, + tags=_ALLOWED_TAGS, + attributes=_ALLOWED_ATTRIBUTES, + url_schemes=_ALLOWED_URL_SCHEMES, + link_rel="noopener noreferrer", + ) + + +def should_allow_navigation(url: str) -> bool: + """Allow only navigation that stays within the rendered document.""" + base_url, _fragment = urldefrag(url) + return base_url == MARKDOWN_DOCUMENT_BASE_URL + class MarkdownViewerDialog(wx.Dialog): """Modal dialog for viewing rendered Markdown content. @@ -154,20 +177,7 @@ def _create_ui(self, markdown_content): panel = wx.Panel(self) sizer = wx.BoxSizer(wx.VERTICAL) - # Convert markdown to HTML, then sanitize to prevent XSS. - # Documents are user-editable and transmitted over the network; - # python-markdown passes raw HTML through by default. - html_body = markdown.markdown( - markdown_content, - extensions=_MD_EXTENSIONS, - ) - html_body = nh3.clean( - html_body, - tags=_ALLOWED_TAGS, - attributes=_ALLOWED_ATTRIBUTES, - url_schemes=_ALLOWED_URL_SCHEMES, - link_rel="noopener noreferrer", - ) + html_body = sanitize_markdown(markdown_content) # Build full HTML page with system colours bg = _sys_color_hex(wx.SYS_COLOUR_WINDOW) @@ -203,7 +213,11 @@ def _create_ui(self, markdown_content): # WebView for rendered content self.web_view = wx.html2.WebView.New(panel) - self.web_view.SetPage(full_html, "") + self.web_view.Bind( + wx.html2.EVT_WEBVIEW_NAVIGATING, + self._on_webview_navigating, + ) + self.web_view.SetPage(full_html, MARKDOWN_DOCUMENT_BASE_URL) sizer.Add(self.web_view, 1, wx.EXPAND | wx.ALL, 5) # Close button @@ -234,6 +248,13 @@ def _on_webview_loaded(self, event): # Help screen readers pick up the content self.web_view.RunScript("document.body.focus();") + def _on_webview_navigating(self, event): + """Prevent untrusted document links from navigating the embedded browser.""" + if should_allow_navigation(event.GetURL()): + event.Skip() + return + event.Veto() + def _on_close(self, event): """Close the dialog.""" self.EndModal(wx.ID_CLOSE) diff --git a/clients/web/app.js b/clients/web/app.js index b2f8006c..9baf154e 100644 --- a/clients/web/app.js +++ b/clients/web/app.js @@ -1230,6 +1230,7 @@ function handlePacket(packet) { case "menu": { const previousMenu = store.state.currentMenu; const items = parseMenuItems(packet.items); + audio.preloadEffects(items.map((item) => item.sound).filter(Boolean)); if (pendingActionsMenuRequest) { if (packet.menu_id === "actions_menu") { diff --git a/clients/web/audio.js b/clients/web/audio.js index 14fd4c57..46206cbb 100644 --- a/clients/web/audio.js +++ b/clients/web/audio.js @@ -36,6 +36,69 @@ export function createAudioEngine(options = {}) { const soundBaseUrl = options.soundBaseUrl || "./sounds"; const AudioCtx = window.AudioContext || window.webkitAudioContext; const context = AudioCtx ? new AudioCtx() : null; + const MAX_EFFECT_START_DELAY_MS = 1500; + const MAX_EFFECT_CACHE_ENTRIES = 128; + const MAX_PENDING_EFFECTS = 24; + const PRELOAD_CONCURRENCY = 4; + const DEFAULT_PRELOAD_EFFECTS = [ + "menuclick.ogg", + "menuenter.ogg", + "chat.ogg", + "chatlocal.ogg", + "typing1.ogg", + "typing2.ogg", + "typing3.ogg", + "typing4.ogg", + "mention.ogg", + "game_cards/play1.ogg", + "game_cards/play2.ogg", + "game_cards/play3.ogg", + "game_cards/play4.ogg", + "game_cards/draw1.ogg", + "game_cards/draw2.ogg", + "game_cards/draw3.ogg", + "game_cards/draw4.ogg", + "game_cards/discard1.ogg", + "game_cards/discard2.ogg", + "game_cards/discard3.ogg", + "game_cards/shuffle1.ogg", + "game_cards/shuffle2.ogg", + "game_cards/shuffle3.ogg", + "game_milebymile/25miles1.ogg", + "game_milebymile/25miles2.ogg", + "game_milebymile/50miles1.ogg", + "game_milebymile/50miles2.ogg", + "game_milebymile/50miles3.ogg", + "game_milebymile/75miles1.ogg", + "game_milebymile/75miles2.ogg", + "game_milebymile/75miles3.ogg", + "game_milebymile/100miles1.ogg", + "game_milebymile/100miles2.ogg", + "game_milebymile/100miles3.ogg", + "game_milebymile/200miles1.ogg", + "game_milebymile/200miles2.ogg", + "game_milebymile/200miles3.ogg", + "game_milebymile/crash1.ogg", + "game_milebymile/crash2.ogg", + "game_milebymile/drivingace.ogg", + "game_milebymile/extratank1.ogg", + "game_milebymile/extratank2.ogg", + "game_milebymile/flat.ogg", + "game_milebymile/gas.ogg", + "game_milebymile/greenlight1.ogg", + "game_milebymile/greenlight2.ogg", + "game_milebymile/greenlight3.ogg", + "game_milebymile/outofgas.ogg", + "game_milebymile/punctureproof.ogg", + "game_milebymile/repair1.ogg", + "game_milebymile/repair2.ogg", + "game_milebymile/rightofway.ogg", + "game_milebymile/sparetyre.ogg", + "game_milebymile/speedlimit.ogg", + "game_milebymile/speedlimitend.ogg", + "game_milebymile/stop.ogg", + "game_milebymile/winround.ogg", + ]; let effectsGain = null; let musicGain = null; @@ -52,8 +115,12 @@ export function createAudioEngine(options = {}) { let currentAmbienceLoopName = ""; let pendingAmbiencePacket = null; const pendingEffectPackets = []; - const MAX_PENDING_EFFECTS = 24; const activeEffects = new Map(); + const activeBufferEffects = new Set(); + const effectBufferCache = new Map(); + const preloadQueue = []; + const preloadQueued = new Set(); + let activePreloads = 0; let muted = false; if (context) { @@ -78,6 +145,7 @@ export function createAudioEngine(options = {}) { if (context.state !== "running") { await context.resume(); } + preloadEffects(DEFAULT_PRELOAD_EFFECTS); retryPendingPlayback(); return context.state === "running"; } @@ -136,13 +204,173 @@ export function createAudioEngine(options = {}) { } } - function playSound(packet) { - const name = packet.name || packet.sound || ""; + function canUseBufferedEffect(url) { + return Boolean( + context + && effectsGain + && typeof fetch === "function" + && !isCrossOriginUrl(url) + ); + } + + function rememberEffectCacheUse(url, entry) { + effectBufferCache.delete(url); + effectBufferCache.set(url, entry); + while (effectBufferCache.size > MAX_EFFECT_CACHE_ENTRIES) { + const oldestUrl = effectBufferCache.keys().next().value; + effectBufferCache.delete(oldestUrl); + } + } + + function decodeAudioBuffer(arrayBuffer) { + if (!context) { + return Promise.reject(new Error("Audio context unavailable")); + } + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) { + return; + } + settled = true; + callback(value); + }; + + try { + const maybePromise = context.decodeAudioData( + arrayBuffer.slice(0), + (buffer) => finish(resolve, buffer), + (error) => finish(reject, error) + ); + if (maybePromise && typeof maybePromise.then === "function") { + maybePromise.then( + (buffer) => finish(resolve, buffer), + (error) => finish(reject, error) + ); + } + } catch (error) { + finish(reject, error); + } + }); + } + + function loadEffectBuffer(url) { + const cached = effectBufferCache.get(url); + if (cached) { + rememberEffectCacheUse(url, cached); + return cached.promise; + } + + const entry = { + promise: fetch(url, { cache: "force-cache" }) + .then((response) => { + if (!response.ok) { + throw new Error(`Sound fetch failed: ${response.status}`); + } + return response.arrayBuffer(); + }) + .then((arrayBuffer) => decodeAudioBuffer(arrayBuffer)), + }; + rememberEffectCacheUse(url, entry); + return entry.promise; + } + + function queueEffectPreload(name) { const url = toSoundUrl(name, soundBaseUrl); - if (!url) { + if (!url || !canUseBufferedEffect(url) || preloadQueued.has(url) || effectBufferCache.has(url)) { + return; + } + preloadQueued.add(url); + preloadQueue.push(url); + pumpEffectPreloads(); + } + + function pumpEffectPreloads() { + while (activePreloads < PRELOAD_CONCURRENCY && preloadQueue.length > 0) { + const url = preloadQueue.shift(); + activePreloads += 1; + loadEffectBuffer(url) + .catch(() => { + effectBufferCache.delete(url); + preloadQueued.delete(url); + }) + .finally(() => { + activePreloads -= 1; + pumpEffectPreloads(); + }); + } + } + + function preloadEffects(names = []) { + for (const name of names) { + queueEffectPreload(name); + } + } + + function stopBufferEffect(effect) { + try { effect.source.stop(); } catch { /* already stopped */ } + try { effect.source.disconnect(); } catch { /* already disconnected */ } + try { effect.gain.disconnect(); } catch { /* already disconnected */ } + if (effect.panner) { + try { effect.panner.disconnect(); } catch { /* already disconnected */ } + } + activeBufferEffects.delete(effect); + } + + function startBufferedEffect(buffer, packet, queuedAtMs) { + if (!context || !effectsGain || muted) { return; } + if (performance.now() - queuedAtMs > MAX_EFFECT_START_DELAY_MS) { + return; + } + + const source = context.createBufferSource(); + source.buffer = buffer; + source.playbackRate.value = Math.max(0.5, Math.min(2, (packet.pitch ?? 100) / 100)); + + const gain = context.createGain(); + const volume = Math.max(0, Math.min(1, (packet.volume ?? 100) / 100)); + gain.gain.value = muted ? 0 : volume; + + let panner = null; + if (typeof context.createStereoPanner === "function") { + panner = context.createStereoPanner(); + panner.pan.value = Math.max(-1, Math.min(1, (packet.pan ?? 0) / 100)); + source.connect(panner); + panner.connect(gain); + } else { + source.connect(gain); + } + gain.connect(effectsGain); + const effect = { source, gain, panner, volume }; + activeBufferEffects.add(effect); + if (typeof source.addEventListener === "function") { + source.addEventListener("ended", () => stopBufferEffect(effect), { once: true }); + } else { + source.onended = () => stopBufferEffect(effect); + } + try { + source.start(0); + } catch { + stopBufferEffect(effect); + } + } + + function queuePendingEffect(packet) { + if (pendingEffectPackets.length >= MAX_PENDING_EFFECTS) { + pendingEffectPackets.shift(); + } + pendingEffectPackets.push({ + name: packet.name || packet.sound || "", + volume: packet.volume ?? 100, + pan: packet.pan ?? 0, + pitch: packet.pitch ?? 100, + }); + } + + function playSoundWithElement(packet, url, name) { const audio = createAudioElement(url); audio.muted = muted; audio.preload = "auto"; @@ -164,10 +392,7 @@ export function createAudioEngine(options = {}) { safePlay(audio, { onRejected: () => { cleanup(); - if (pendingEffectPackets.length >= MAX_PENDING_EFFECTS) { - pendingEffectPackets.shift(); - } - pendingEffectPackets.push({ + queuePendingEffect({ name, volume: packet.volume ?? 100, pan: packet.pan ?? 0, @@ -180,6 +405,53 @@ export function createAudioEngine(options = {}) { } } + function playSound(packet) { + const name = packet.name || packet.sound || ""; + const url = toSoundUrl(name, soundBaseUrl); + if (!url) { + return; + } + + if (canUseBufferedEffect(url)) { + if (context.state !== "running") { + queuePendingEffect(packet); + queueEffectPreload(name); + return; + } + const queuedAtMs = performance.now(); + loadEffectBuffer(url) + .then((buffer) => { + startBufferedEffect(buffer, packet, queuedAtMs); + }) + .catch(() => { + effectBufferCache.delete(url); + playSoundWithElement(packet, url, name); + }); + return; + } + + playSoundWithElement(packet, url, name); + } + + if (context) { + setTimeout(() => { + preloadEffects([ + "menuclick.ogg", + "menuenter.ogg", + "chat.ogg", + "chatlocal.ogg", + "game_cards/play1.ogg", + "game_cards/play2.ogg", + "game_cards/play3.ogg", + "game_cards/play4.ogg", + "game_cards/draw1.ogg", + "game_cards/draw2.ogg", + "game_cards/draw3.ogg", + "game_cards/draw4.ogg", + ]); + }, 0); + } + function playMusic(packet) { const name = packet.name || packet.music || ""; const url = toSoundUrl(name, soundBaseUrl); @@ -419,6 +691,9 @@ export function createAudioEngine(options = {}) { for (const effect of activeEffects.keys()) { effect.muted = muted; } + for (const effect of activeBufferEffects) { + effect.gain.gain.value = muted ? 0 : effect.volume; + } } function isMuted() { @@ -458,6 +733,9 @@ export function createAudioEngine(options = {}) { disconnectNodes(nodes); } activeEffects.clear(); + for (const effect of Array.from(activeBufferEffects)) { + stopBufferEffect(effect); + } } return { @@ -475,5 +753,6 @@ export function createAudioEngine(options = {}) { setMuted, isMuted, retryPendingPlayback, + preloadEffects, }; } diff --git a/clients/web/bootstrap.js b/clients/web/bootstrap.js new file mode 100644 index 00000000..e103b13a --- /dev/null +++ b/clients/web/bootstrap.js @@ -0,0 +1,35 @@ +(function () { + const cacheBust = Date.now(); + + function loadScript(src, { type = "text/javascript", optional = false } = {}) { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.type = type; + script.onload = () => resolve(); + script.onerror = () => { + if (optional) { + resolve(); + } else { + reject(new Error(`Failed to load script: ${src}`)); + } + }; + document.body.appendChild(script); + }); + } + + (async () => { + await loadScript(`./version.js?${cacheBust}`, { optional: true }); + await loadScript(`./config.sample.js?${cacheBust}`, { optional: true }); + await loadScript(`./config.js?${cacheBust}`, { optional: true }); + await loadScript(`./libs/marked.min.js?${cacheBust}`, { optional: true }); + await loadScript(`./libs/purify.min.js?${cacheBust}`, { optional: true }); + + const version = String(window.PLAYPALACE_WEB_VERSION || "2026.02.08.1").trim(); + await loadScript(`./app.js?v=${encodeURIComponent(version)}&${cacheBust}`, { + type: "module", + }); + })().catch((error) => { + console.error(error); + }); +})(); diff --git a/clients/web/index.html b/clients/web/index.html index 260e8101..480df570 100644 --- a/clients/web/index.html +++ b/clients/web/index.html @@ -5,6 +5,10 @@ PlayPalace Web Client +
@@ -35,7 +39,23 @@

PlayPalace V11 Web

- + diff --git a/clients/web/store.js b/clients/web/store.js index 6a88e28f..72de5fd5 100644 --- a/clients/web/store.js +++ b/clients/web/store.js @@ -1,3 +1,16 @@ +// Cap each history buffer so a long session can't grow the arrays (and the +// per-render join/DOM rebuild) without bound. Older lines drop off the front. +// This is the main guard against the mobile "taps get slower over time" lag. +const HISTORY_BUFFER_LIMIT = 500; + +function pushCappedHistory(lines, text) { + lines.push(text); + const overflow = lines.length - HISTORY_BUFFER_LIMIT; + if (overflow > 0) { + lines.splice(0, overflow); + } +} + export function createStore() { const state = { connection: { @@ -58,9 +71,9 @@ export function createStore() { if (!state.historyBuffers[buffer]) { state.historyBuffers[buffer] = []; } - state.historyBuffers[buffer].push(text); + pushCappedHistory(state.historyBuffers[buffer], text); if (buffer !== "all") { - state.historyBuffers.all.push(text); + pushCappedHistory(state.historyBuffers.all, text); } notify(); }, diff --git a/clients/web/styles.css b/clients/web/styles.css index bb07ab62..e58d4990 100644 --- a/clients/web/styles.css +++ b/clients/web/styles.css @@ -49,6 +49,14 @@ box-sizing: border-box; } +/* `display:` rules in author CSS (e.g. `.grid { display: grid }`) outrank the + UA stylesheet's `[hidden] { display: none }`. Force `hidden` to win so + `element.hidden = true` actually hides — without this, #game-shell stays + rendered when the login dialog is supposed to be the only thing on screen. */ +[hidden] { + display: none !important; +} + html, body { min-height: 100%; @@ -181,9 +189,16 @@ textarea:focus-visible, .grid { display: grid; grid-template-columns: minmax(260px, 0.8fr) minmax(520px, 1.9fr); + grid-template-areas: + "game history" + "game chat"; gap: 12px; } +.game-panel { grid-area: game; } +.history-panel { grid-area: history; } +.chat-panel { grid-area: chat; } + .menu-list { list-style: none; margin: 0; @@ -193,6 +208,7 @@ textarea:focus-visible, max-height: 440px; overflow-y: auto; background: var(--panel-surface); + -webkit-overflow-scrolling: touch; } .menu-item { @@ -224,6 +240,17 @@ textarea:focus-visible, border-radius: 0; background: transparent; color: var(--text); + /* iOS Safari tap reliability: skip the 300ms tap delay heuristic, suppress + the auto-zoom on double-tap, and give a visible flash so the user knows + the tap registered. Without these, taps on buttons inside scrollable + lists can feel unresponsive on iPhone. */ + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: var(--menu-active-surface); +} + +.menu-item-touch:active { + background: var(--menu-active-surface); } .history { @@ -300,6 +327,13 @@ textarea:focus-visible, background: transparent; color: var(--text); min-height: 44px; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: var(--menu-active-surface); +} + +.actions-item-btn:active { + background: var(--menu-active-surface); } .actions-list li:last-child .actions-item-btn { @@ -345,15 +379,10 @@ dialog::backdrop { @media (max-width: 900px) { .grid { grid-template-columns: 1fr; - } - - /* Show History above Game on narrow viewports without changing DOM - order (keeps Tab/screen-reader sequence desktop-correct: Game → History). */ - #game-shell > .panel:nth-child(1) { - order: 2; - } - #game-shell > .panel:nth-child(2) { - order: 1; + grid-template-areas: + "history" + "game" + "chat"; } main { @@ -376,13 +405,8 @@ dialog::backdrop { gap: 8px; } - .volume-controls { - flex-direction: column; - align-items: flex-start; - } - .volume-controls input[type="range"] { - width: 120px; + width: 110px; } .history { diff --git a/clients/web/tests/markdown_viewer_security.test.mjs b/clients/web/tests/markdown_viewer_security.test.mjs new file mode 100644 index 00000000..38d755db --- /dev/null +++ b/clients/web/tests/markdown_viewer_security.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderMarkdownHtml } from "../ui/markdown_viewer.js"; + +const fakeMarked = { + parse(markdown) { + return String(markdown) + .replace("SCRIPT", "") + .replace("IMG", '') + .replace("AUDIO", '') + .replace("VIDEO", '') + .replace("SOURCE", '') + .replace("BUTTON", "") + .replace("TEXTAREA", "") + .replace("SELECT", '') + .replace("DETAILS", "
openbody
") + .replace("DIALOG", "modal") + .replace("FIELDSET", '
') + .replace("BACKGROUND", '
x
') + .replace("JSURL", '
bad') + .replace("OKURL", 'ok'); + }, +}; + +const fakePurifier = { + sanitize(html, config) { + const expectedForbiddenAttrs = ["style", "srcset", "background"]; + const expectedForbiddenTags = [ + "img", + "svg", + "math", + "iframe", + "object", + "embed", + "form", + "style", + "audio", + "video", + "source", + "track", + "picture", + "input", + "button", + "link", + "meta", + "textarea", + "select", + "option", + "optgroup", + "details", + "summary", + "dialog", + "fieldset", + "label", + "datalist", + "legend", + "output", + ]; + + assert.deepEqual(config?.USE_PROFILES, { html: true }); + assert.equal(config?.ALLOWED_URI_REGEXP?.test("https://example.com"), true); + assert.equal(config?.ALLOWED_URI_REGEXP?.test("mailto:test@example.com"), true); + assert.equal(config?.ALLOWED_URI_REGEXP?.test("javascript:alert(1)"), false); + for (const attr of expectedForbiddenAttrs) { + assert.equal(config?.FORBID_ATTR?.includes(attr), true, `missing forbidden attr ${attr}`); + } + for (const tag of expectedForbiddenTags) { + assert.equal(config?.FORBID_TAGS?.includes(tag), true, `missing forbidden tag ${tag}`); + } + return html + .replace(//gi, "") + .replace(/]*>/gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(/]*>/gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(/\sbackground="[^"]*"/gi, "") + .replace(/href="javascript:[^"]*"/gi, ""); + }, +}; + +test("renderMarkdownHtml removes executable and remote-resource HTML", () => { + const html = renderMarkdownHtml( + "SCRIPT\nIMG\nAUDIO\nVIDEO\nSOURCE\nBUTTON\nTEXTAREA\nSELECT\nDETAILS\nDIALOG\nFIELDSET\nBACKGROUND\nJSURL\nOKURL", + fakeMarked, + fakePurifier, + ); + + assert.equal(html.includes(" { + const html = renderMarkdownHtml("", null, null); + + assert.equal(html.includes("