Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
01ddaf0
Implement F1 shortcut for viewing game rules in tables
mohammad-aloufi Apr 17, 2026
7e1b79e
Add core Phase 10 game implementation: state, evaluator, and bots
odium1963 Apr 13, 2026
11fb153
Fix Phase 10 double-staging bug: block reuse of staged cards, add sta…
odium1963 Apr 13, 2026
205ca08
Phase 10: wild placement, positional card IDs, hand sorting, discard …
odium1963 Apr 13, 2026
1816451
Add Phase 10 rules document
odium1963 Apr 13, 2026
3c859f0
Fix Phase 10 rules: hit via Enter-on-card, discard via focus not sele…
odium1963 Apr 13, 2026
5c90d76
Remove escape keybind from Phase 10 to restore actions menu access
odium1963 Apr 14, 2026
3997fe7
Replace staggered broadcasts with per-player status box for end-of-ro…
odium1963 Apr 14, 2026
35b8d96
Add skip_menu_rebuild flag to suppress keybind-triggered menu rebuilds
odium1963 Apr 15, 2026
1ee0666
Phase 10: status boxes for C key and detailed scores; streamline info…
odium1963 Apr 15, 2026
493f6fb
Fix review issues: localization, dead code, keybind rebuild suppression
odium1963 Apr 18, 2026
0ac63ee
Fix game_active not cleared on game end, read_hand sort order, remove…
odium1963 Apr 19, 2026
1a8170e
Merge branch 'XGDevGroup:main' into feature-f1-game-docs
mohammad-aloufi Apr 26, 2026
6b30fad
Reuse action-locked message for missing rules doc
mohammad-aloufi Apr 26, 2026
b103282
Remove dead hasattr guard for game rules document access
mohammad-aloufi Apr 26, 2026
a6e5b27
Use DocumentManager locale selection methods in game rules handler
mohammad-aloufi Apr 26, 2026
b402fe1
Migrate redundant is_enabled callbacks to _is_always_enabled
mohammad-aloufi Apr 26, 2026
e78d9b5
Add get_documents accessor to Table to decouple game logic from server
mohammad-aloufi Apr 26, 2026
c2e4615
Remove trailing whitespace in _action_show_rules
mohammad-aloufi Apr 26, 2026
de3f72c
Provide feedback when pressing F1 outside of a game table
mohammad-aloufi Apr 26, 2026
4abdda8
Fix possible duplicate F1 keybind packet on desktop client
mohammad-aloufi Apr 26, 2026
f844e2a
Fix iPhone web client: split panels into History → Game → Chat, add t…
serrebidev May 4, 2026
5bd72fd
Fix iPhone web audio and tap latency
serrebidev May 8, 2026
33dbdc9
Harden fork PR review workflow
chrisnestrud May 16, 2026
67c3854
Trigger Claude review on label approval
chrisnestrud May 16, 2026
289a723
Fix Claude review gate syntax
chrisnestrud May 16, 2026
030261c
Harden desktop markdown viewer
chrisnestrud May 16, 2026
703094b
Fix desktop markdown navigation guard
chrisnestrud May 16, 2026
a3a80ef
Stabilize desktop markdown document URL
chrisnestrud May 16, 2026
7e245ac
Add desktop markdown SetPage regression
chrisnestrud May 16, 2026
ebddf3a
Fix desktop markdown test collection
chrisnestrud May 16, 2026
be88e56
Harden web markdown rendering
chrisnestrud May 16, 2026
14f9c6d
Tighten web markdown sanitizer coverage
chrisnestrud May 16, 2026
4077d69
Complete web markdown denylist hardening
chrisnestrud May 16, 2026
bed8ff4
Add web client CSP
chrisnestrud May 16, 2026
a953676
Polish macOS bundle spec
vlad-a-c May 28, 2026
d406428
Add chat room game with topic support
Braillerboy Jun 16, 2026
af43cdc
Fix iPhone history lag: cap web client history buffers and coalesce r…
serrebidev Jun 19, 2026
ea50b46
Merge commit 'refs/playpalace/pr/255' into HEAD
serrebidev Jun 19, 2026
e8ce803
Merge commit 'refs/playpalace/pr/254' into HEAD
serrebidev Jun 19, 2026
8fc3efe
Merge commit 'refs/playpalace/pr/252' into HEAD
serrebidev Jun 19, 2026
b9dc861
Merge commit 'refs/playpalace/pr/249' into HEAD
serrebidev Jun 19, 2026
6b9df48
Merge commit 'refs/playpalace/pr/247' into HEAD
serrebidev Jun 19, 2026
e28bb73
Merge commit 'refs/playpalace/pr/240' into HEAD
serrebidev Jun 19, 2026
4d70cf6
Merge commit 'refs/playpalace/pr/230' into HEAD
serrebidev Jun 19, 2026
d91099c
Fix bleeding-edge game regressions
serrebidev Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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'
Expand Down
19 changes: 12 additions & 7 deletions clients/desktop/PlayPalace.spec
Original file line number Diff line number Diff line change
@@ -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={},
Expand All @@ -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,
Expand All @@ -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',
)
Binary file added clients/desktop/assets/playpalace.icns
Binary file not shown.
6 changes: 5 additions & 1 deletion clients/desktop/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
253 changes: 253 additions & 0 deletions clients/desktop/tests/test_markdown_viewer_security.py
Original file line number Diff line number Diff line change
@@ -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'<img alt="{match.group(1)}" src="{match.group(2)}" />',
text,
)
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda match: f'<a href="{match.group(2)}">{match.group(1)}</a>',
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"<p>{block}</p>")
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"<script.*?>.*?</script>", "", 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"<a\b(?![^>]*\brel=)", f'<a rel="{link_rel}"', cleaned)
if tags is not None and "img" not in tags:
cleaned = re.sub(r"<img\b[^>]*>", "", 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<script>alert(1)</script>\n\n<span onclick=\"x()\">ok</span>")

assert "javascript:" not in html
assert "<script" not in html
assert "onclick" not in html
assert "ok" in html


def test_sanitize_markdown_strips_remote_images():
html = sanitize_markdown("![track](https://tracker.example/pixel.png)")

assert "<img" not in html
assert "tracker.example" not in html


def test_should_allow_navigation_allows_internal_document_url():
assert should_allow_navigation(MARKDOWN_DOCUMENT_BASE_URL)


def test_should_allow_navigation_allows_internal_document_fragment():
assert should_allow_navigation(f"{MARKDOWN_DOCUMENT_BASE_URL}#section-1")


def test_should_allow_navigation_blocks_external_url():
assert not should_allow_navigation("https://example.com")


def test_on_webview_navigating_skips_internal_urls_and_vetoes_external_urls():
dialog = MarkdownViewerDialog.__new__(MarkdownViewerDialog)

class _Event:
def __init__(self, url):
self._url = url
self.skipped = False
self.vetoed = False

def GetURL(self):
return self._url

def Skip(self):
self.skipped = True

def Veto(self):
self.vetoed = True

internal_event = _Event(f"{MARKDOWN_DOCUMENT_BASE_URL}#top")
MarkdownViewerDialog._on_webview_navigating(dialog, internal_event)
assert internal_event.skipped
assert not internal_event.vetoed

external_event = _Event("https://example.com")
MarkdownViewerDialog._on_webview_navigating(dialog, external_event)
assert external_event.vetoed
assert not external_event.skipped


def test_create_ui_sets_page_with_explicit_internal_base_url():
dialog = MarkdownViewerDialog(None, "Security", "hello")

set_page_calls = dialog.web_view.set_page_calls
assert set_page_calls
_html, base_url = set_page_calls[-1]
assert base_url == MARKDOWN_DOCUMENT_BASE_URL
Loading
Loading