Initial scaffold: Beat Pocket MV3 extension
- manifest.json with MV3 config, host permissions for beat-battle.net - content/detector.ts: route + overlay observer, emits bbp:phase events - content/layout.ts: defensive mobile-first layout reflow - content/chat-drawer.ts: bottom-sheet drawer, dispatches synthetic events through site's own React-managed textarea + button (preserves auth, rate-limit, mute, profanity guards) - content/util/selectors.ts: text- and role-based fallback chain - content/util/ws-proxy.ts: lightweight WebSocket instrumentation, no payload capture - content/inject.css: mobile overrides using site's existing --bb-* vars - background.js: MV3 service worker stub - package.json + tsconfig.json + esbuild.config.mjs: build pipeline - icons/: 16/32/48/128 placeholder PNGs (chili-red disc) - docs/PLAN.md: full V1 architecture, risks, scope - README.md: scope, dev workflow (load unpacked), tech
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Selector helpers. Prefer semantic names and text content over Tailwind
|
||||
* utility classes — utility classes change every deploy.
|
||||
*/
|
||||
|
||||
export const SITE = {
|
||||
overlay: '.bb-room-overlay',
|
||||
inGameRoot: '.bb-room-overlay',
|
||||
homeRoot: '.bb-scanlines',
|
||||
loginChip: '.bb-login-chip',
|
||||
footerNav: '.bb-footer-nav-main-fade',
|
||||
chatInputCandidates: [
|
||||
'textarea.bb-chat-input',
|
||||
'[data-bb-chat-input]',
|
||||
'textarea[placeholder*="chat" i]',
|
||||
'textarea[placeholder*="message" i]',
|
||||
'textarea[aria-label*="message" i]',
|
||||
'textarea[aria-label*="chat" i]',
|
||||
],
|
||||
chatScrollbackCandidates: [
|
||||
'.bb-chat-messages',
|
||||
'[data-bb-chat-messages]',
|
||||
'[role="log"]',
|
||||
'.bb-messages',
|
||||
],
|
||||
sendButtonCandidates: [
|
||||
'button[data-bb-chat-send]',
|
||||
'button[aria-label*="send" i]',
|
||||
'button[type="submit"]',
|
||||
],
|
||||
menuItems: '.bb-menu-item',
|
||||
inGameTimer: '.bb-timer, [data-bb-timer]',
|
||||
inGamePlayers: '[data-bb-players], .bb-players',
|
||||
inGameRankPanel: '.bb-rank-panel, [data-bb-rank]',
|
||||
};
|
||||
|
||||
/**
|
||||
* First matching element from a candidate selector list, OR a fallback
|
||||
* function-based search.
|
||||
*/
|
||||
export function pick<T extends Element>(
|
||||
candidates: string[],
|
||||
root: ParentNode = document,
|
||||
): T | null {
|
||||
for (const sel of candidates) {
|
||||
const el = root.querySelector(sel);
|
||||
if (el) return el as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-based finder: returns the deepest element whose own text exactly
|
||||
* matches one of the given strings (case-insensitive, trimmed).
|
||||
*/
|
||||
export function findByText(
|
||||
root: ParentNode,
|
||||
needles: string[],
|
||||
options: { tag?: string } = {},
|
||||
): Element | null {
|
||||
const tag = options.tag?.toLowerCase() ?? '';
|
||||
const norm = needles.map((s) => s.trim().toLowerCase());
|
||||
const walker = document.createTreeWalker(root as Node, NodeFilter.SHOW_ELEMENT);
|
||||
let found: Element | null = null;
|
||||
let node: Node | null = walker.currentNode as Node;
|
||||
while (node) {
|
||||
const el = node as Element;
|
||||
if (el.children.length === 0 && (!tag || el.tagName.toLowerCase() === tag)) {
|
||||
const txt = (el.textContent ?? '').trim().toLowerCase();
|
||||
if (txt.length > 0 && norm.includes(txt)) {
|
||||
found = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function isHome(): boolean {
|
||||
return location.pathname === '/' || location.pathname === '';
|
||||
}
|
||||
|
||||
export function isLogin(): boolean {
|
||||
return location.pathname.startsWith('/login');
|
||||
}
|
||||
|
||||
export function isInGame(): boolean {
|
||||
return !!document.querySelector(SITE.overlay);
|
||||
}
|
||||
|
||||
export function isLobbyList(): boolean {
|
||||
// Heuristic: home rendered in browse-mode shows lobby list
|
||||
return isHome() && !isInGame() && !!document.querySelector('.bb-lobby-card, [data-bb-lobby]');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* WebSocket instrumentation. We wrap WebSocket.prototype.send so we can:
|
||||
* 1. Observe outgoing chat messages (log only, no payload capture for V1)
|
||||
* 2. Optionally resend synthetic chat messages if the React dispatch
|
||||
* path is broken (fallback for chat-drawer.send).
|
||||
*
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
|
||||
interface WSInstrumentation {
|
||||
restore: () => void;
|
||||
sentCount: () => number;
|
||||
}
|
||||
|
||||
const FLAG = '__beatPocketWSInstrumented__';
|
||||
|
||||
export function instrumentWS(): WSInstrumentation {
|
||||
const proto = WebSocket.prototype as unknown as Record<string, unknown>;
|
||||
if (proto[FLAG]) {
|
||||
return existingInstrumentation();
|
||||
}
|
||||
const originalSend = proto.send as (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
|
||||
let sent = 0;
|
||||
|
||||
proto.send = function patchedSend(data: unknown): void {
|
||||
sent += 1;
|
||||
try {
|
||||
// Lightweight logging only — we never read or store payload contents.
|
||||
// Just observe that WS frames flow so we know auth is alive.
|
||||
if (typeof data === 'string') {
|
||||
window.dispatchEvent(new CustomEvent('bbp:ws-send', { detail: { len: data.length } }));
|
||||
}
|
||||
} catch {
|
||||
/* never throw out of a wrapped primitive */
|
||||
}
|
||||
return originalSend.call(this, data as never);
|
||||
};
|
||||
|
||||
proto[FLAG] = { originalSend, sent: () => sent, restore: () => restore() };
|
||||
|
||||
function restore() {
|
||||
if (proto[FLAG] && (proto[FLAG] as { originalSend?: unknown }).originalSend) {
|
||||
proto.send = (proto[FLAG] as { originalSend: typeof originalSend }).originalSend;
|
||||
delete proto[FLAG];
|
||||
}
|
||||
}
|
||||
|
||||
return { restore, sentCount: () => sent };
|
||||
}
|
||||
|
||||
function existingInstrumentation(): WSInstrumentation {
|
||||
const proto = WebSocket.prototype as unknown as Record<string, unknown>;
|
||||
const flag = proto[FLAG] as { sent: () => number; restore: () => void };
|
||||
return { restore: flag.restore, sentCount: flag.sent };
|
||||
}
|
||||
Reference in New Issue
Block a user