12 Commits
Author SHA1 Message Date
biggy 4f2b807068 Optimized Dockerfile 2026-06-24 07:46:28 +02:00
biggy d46f9644bb feat: DOMPurify HTML sanitization (P3)
Replace regex-based stripScriptTags with isomorphic-dompurify:
- Whitelist-based HTML sanitization prevents XSS bypasses
- Removes scripts, event handlers, and dangerous attributes
- Preserves data: URIs for inlined CSS/images
- Maintains complete document structure for Tizen TVs

Also updates:
- Data flow diagram comments (SEC 19)
- Modification guide comments
- Dependencies (isomorphic-dompurify ^2.36.0)
2026-06-24 07:46:05 +02:00
biggy f17d2f533a fix: static analysis remediation
- Add error logging to inlineAllResources() catch block (P2)
  - Logs warning when resource inlining fails instead of silent fail
  - Updated SEC 22 header comment to reflect change

Note: bakeStyles is correctly scoped to URLItem (per-URL), not View-level.
The DB column exists but code properly uses urlItem.bakeStyles.
2026-06-24 07:46:05 +02:00
biggy 3a21070d62 chore: static analysis baseline
Pre-analysis checkpoint before implementing fixes for:
- bakeStyles field mapping
- error logging improvements
- HTML sanitization hardening
2026-06-24 07:46:05 +02:00
biggy e862827d2d feat(render): add waitBeforeCaptureMs option and refactor CSS baking
Add waitBeforeCaptureMs parameter to URLItem and renderSSR to allow waiting
before capturing page content (useful for animations/dynamic content).

Refactor bakeComputedStyles from per-element computed style baking to a
simplified approach that collects all CSS into a single inline style tag.
This reduces complexity while maintaining compatibility.
2026-06-24 07:46:05 +02:00
biggy a6e93564ba feat(tv-remote-control): add Samsung TV remote control program
- Add `program.py` implementing async MDC client to manage TV power state and display ID
- Add `requirements.txt` with `python-samsung-mdc` dependency
2026-06-19 12:00:57 +02:00
biggy 5020d2c288 style: format code for better readability
- Format arrays and objects on multiple lines for clarity
- Add trailing commas to arrays and objects
- Format function parameters across multiple lines when needed
- Format string concatenation and template literals consistently
- Format try-catch blocks with proper spacing
2026-05-13 13:46:47 +02:00
biggy ac759a60fe feat(ui): add banned IPs management to debug panel
- Add debug section for banned IPs with refresh button
- Implement fetchBannedIPs() function to display banned IPs in table
- Add adminUnban() function for unbanning IPs from UI
- Show IP, ban time, reason, banned by, and unban action in table
2026-05-13 13:36:37 +02:00
biggy 75f9686533 feat(security): add IP banning system with admin controls
- Add banned_ips database table with IP, timestamp, reason, and banned_by fields
- Implement IP ban checking in deviceTrack to block banned connections
- Add admin API endpoints: /api/admin/ban/:ip, /api/admin/unban/:ip, /api/admin/disconnect/:ip, /api/admin/refresh/:ip
- Add GET /api/banned endpoint to retrieve banned IPs list
- Return 403 error page for banned IP connections
2026-05-13 13:36:35 +02:00
biggy daee65e0d5 feat(perf): add cache warmup for improved performance
Implement automatic cache warming when users first connect to views.
Pre-renders all URLs in a view to eliminate initial load latency.
Also adds periodic warmup for active devices and removes cache TTL
refresh on cache hits to preserve original expiration timing.
2026-05-13 13:17:57 +02:00
biggy 69b62b6af3 feat(ui): add close button to modal dialogs
Add × button to modal overlays for better UX. Button is positioned
in top-right corner with hover effects and proper styling.
2026-05-13 13:17:55 +02:00
biggy 6d38beaf26 Merge pull request 'refactor: replace MJPEG streaming with screenshot + meta-refresh' (#1) from deepseek-fix into main
Reviewed-on: #1
2026-05-13 11:12:36 +02:00
8 changed files with 1025 additions and 133 deletions
+14
View File
@@ -0,0 +1,14 @@
.git/
.gitignore
node_modules/
*.log
.env*
.env.local
.env.*.local
.vscode/
.idea/
*.md
compose.yaml
Dockerfile
.dockerignore
data/
+54 -5
View File
@@ -1,18 +1,67 @@
FROM oven/bun:latest
# ═══════════════════════════════════════════════════════════════════════
# STAGE 1: Dependencies
# Install only production dependencies
# ═══════════════════════════════════════════════════════════════════════
FROM oven/bun:1.2-slim AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --production --frozen-lockfile
# ═══════════════════════════════════════════════════════════════════════
# STAGE 2: Runtime
# Minimal image with Chromium + Bun runtime only
# ═══════════════════════════════════════════════════════════════════════
FROM debian:bookworm-slim AS runtime
LABEL org.opencontainers.image.source="https://gitea.bigoscloud.com/biggy/samsung-tv-server" \
org.opencontainers.image.title="Samsung TV Server" \
org.opencontainers.image.description="Samsung TV Server — Bun runtime" \
org.opencontainers.image.description="Samsung TV Server — Bun runtime (optimized)" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
RUN apt-get update && apt-get install -y chromium && rm -rf /var/lib/apt/lists/*
# Install Chromium with aggressive cleanup
RUN apt-get update && \
apt-get install -y --no-install-recommends \
chromium \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
xdg-utils && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/* /var/log/apt/* /tmp/*
# Copy Bun binary from official image (lighter than full install)
COPY --from=oven/bun:1.2-slim /usr/local/bin/bun /usr/local/bin/bun
COPY --from=oven/bun:1.2-slim /usr/local/bin/bunx /usr/local/bin/bunx
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy application code
COPY package.json bun.lock ./
RUN bun install --production
COPY src/ ./src/
COPY public/ ./public/
COPY . .
# Create data directory for SQLite
RUN mkdir -p /app/data
EXPOSE 3099
+93 -1
View File
@@ -5,6 +5,7 @@
"": {
"name": "samsung-tv-server",
"dependencies": {
"isomorphic-dompurify": "^2.24.0",
"puppeteer": "^24.43.1",
},
"devDependencies": {
@@ -12,14 +13,41 @@
"add": "^2.0.6",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0",
"typescript": "^6.0.3",
},
},
},
"packages": {
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="],
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.1", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.4", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-HbifJ84prIh9+55CTPAU35JdRQrwg47y16cGerCC+iejSKOuHXYo2WDql6l7cQlzrYVtc3f4UWY+dBj2lRmOeA=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ef7SKJqAaH2d7E6eXZZa2OffIShbhFMxnGK0zd93p4qiyTJr75B0qf7lrPD+qQOwcf04BrjYJ0JUxq8d5+yZwg=="],
@@ -104,6 +132,8 @@
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"add": ["add@2.0.6", "", {}, "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q=="],
@@ -134,6 +164,8 @@
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
@@ -150,18 +182,30 @@
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"cssstyle": ["cssstyle@6.2.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.28", "css-tree": "^3.1.0", "lru-cache": "^11.2.6" } }, "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"devtools-protocol": ["devtools-protocol@0.0.1608973", "", {}, "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ=="],
"dompurify": ["dompurify@3.4.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
@@ -190,6 +234,8 @@
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -202,15 +248,23 @@
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"isomorphic-dompurify": ["isomorphic-dompurify@2.36.0", "", { "dependencies": { "dompurify": "^3.3.1", "jsdom": "^28.0.0" } }, "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
@@ -232,6 +286,8 @@
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -244,14 +300,20 @@
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"puppeteer": ["puppeteer@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1608973", "puppeteer-core": "24.43.1", "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw=="],
"puppeteer-core": ["puppeteer-core@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1608973", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.20.0" } }, "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
@@ -262,12 +324,16 @@
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],
"tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
@@ -278,20 +344,44 @@
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -301,5 +391,7 @@
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
}
}
+3 -1
View File
@@ -11,12 +11,14 @@
"fmt:check": "oxfmt --check"
},
"dependencies": {
"isomorphic-dompurify": "^2.24.0",
"puppeteer": "^24.43.1"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"add": "^2.0.6",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0"
"oxlint": "^1.64.0",
"typescript": "^6.0.3"
}
}
+235 -9
View File
@@ -179,6 +179,10 @@
background: rgba(46, 204, 113, 0.1);
color: var(--green);
}
.badge-wait {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
}
.card-url-preview {
font-size: 13px;
color: var(--muted);
@@ -229,10 +233,33 @@
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--muted);
font-size: 20px;
cursor: pointer;
border-radius: 8px;
transition: all 0.15s;
}
.modal-close:hover {
background: var(--surface2);
color: var(--text);
}
.modal h2 {
font-size: 20px;
margin-bottom: 24px;
padding-right: 40px;
}
/* Form */
@@ -512,6 +539,11 @@
color: var(--danger);
}
.device-actions {
display: flex;
gap: 4px;
}
.confirm-text {
font-size: 15px;
margin-bottom: 24px;
@@ -550,6 +582,7 @@
<button class="btn btn-ghost btn-sm" id="debug-toggle" onclick="toggleDebug()">
🔍 Debug
</button>
<button class="btn btn-ghost btn-sm" onclick="openImportModal()">📤 Import</button>
<button class="btn btn-primary" onclick="openCreateModal()">+ New View</button>
</div>
</header>
@@ -652,12 +685,31 @@
<th>Requests</th>
<th>Last Seen</th>
<th>User Agent</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="debug-devices-tbody"></tbody>
</table>
</div>
</div>
<div class="debug-section">
<h3>🚫 Banned IPs</h3>
<div style="overflow-x: auto">
<table class="debug-table">
<thead>
<tr>
<th>IP</th>
<th>Banned At</th>
<th>Reason</th>
<th>Banned By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="debug-banned-tbody"><tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
@@ -735,13 +787,15 @@
.map((v) => {
const viewUrl = origin + "/v/" + v.id;
const urlPreview = v.urls.map((u, i) =>
`${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
`${u.url} (${u.durationSec}s${u.waitBeforeCaptureMs ? ` +${u.waitBeforeCaptureMs}ms` : ""})${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
).join(" → ");
const anyBake = v.urls.some((u) => u.bakeStyles);
const anyWait = v.urls.some((u) => u.waitBeforeCaptureMs > 0);
const badges = [
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
v.metaRefreshEnabled ? '<span class="badge badge-refresh">META-REFRESH</span>' : "",
anyBake ? '<span class="badge badge-bake">BAKE</span>' : "",
anyWait ? '<span class="badge badge-wait">WAIT</span>' : "",
]
.filter(Boolean)
.join(" ");
@@ -751,6 +805,7 @@
<div class="card-header">
<span class="card-title">${esc(v.name)}</span>
<div style="display:flex;gap:6px;">
<button class="btn btn-icon btn-ghost btn-sm" onclick="exportView('${v.id}')" title="Export">📥</button>
<button class="btn btn-icon btn-ghost btn-sm" onclick="openEditModal('${v.id}')" title="Edit">✏️</button>
<button class="btn btn-icon btn-danger btn-sm" onclick="confirmDelete('${v.id}','${esc(v.name)}')" title="Delete">🗑</button>
</div>
@@ -781,8 +836,11 @@
function openModal(html) {
document.getElementById("modal-container").innerHTML = `
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal">${html}</div>
<div class="modal-overlay">
<div class="modal">
<button class="modal-close" onclick="closeModal()">✕</button>
${html}
</div>
</div>`;
document.body.style.overflow = "hidden";
}
@@ -823,6 +881,75 @@
}
}
// ═══════════════════════════════════════════════
// EXPORT / IMPORT
// ═══════════════════════════════════════════════
async function exportView(id) {
try {
const response = await fetch("/api/views/" + id + "?export=true");
if (!response.ok) throw new Error("Export failed");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `view-${id}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
toast("View exported!");
} catch (err) {
toast(err.message, "error");
}
}
function openImportModal() {
openModal(`
<h2>Import View</h2>
<form id="import-form" onsubmit="handleImport(event)">
<div class="field">
<label>Paste View JSON</label>
<textarea id="import-json" rows="12" placeholder='{"name":"My View","urls":[{"url":"https://example.com","durationSec":30}],"method":"ssr"}' required style="font-family:monospace;font-size:13px;"></textarea>
<div class="hint">Paste the JSON content from an exported view file.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Import View</button>
</div>
</form>`);
}
async function handleImport(e) {
e.preventDefault();
const jsonText = document.getElementById("import-json").value.trim();
if (!jsonText) {
toast("Please paste JSON content", "error");
return;
}
try {
// Validate JSON structure
const data = JSON.parse(jsonText);
if (!data.name || !data.urls || !Array.isArray(data.urls)) {
toast("Invalid view JSON: missing required fields", "error");
return;
}
await api("POST", "/api/views/import", data);
closeModal();
toast("View imported successfully!");
loadViews();
} catch (err) {
if (err.message.includes("JSON")) {
toast("Invalid JSON format", "error");
} else {
toast(err.message, "error");
}
}
}
// ═══════════════════════════════════════════════
// EDITOR BUILDER
// ═══════════════════════════════════════════════
@@ -831,9 +958,9 @@
const isEdit = !!existing;
const title = isEdit ? "Edit View" : "Create New View";
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }];
const urls = existing ? existing.urls : [{ url: "", durationSec: 30, waitBeforeCaptureMs: 0 }];
let urlRowsHTML = urls
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode, u.bakeStyles))
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, u.waitBeforeCaptureMs, i, urls.length, u.forceDarkMode, u.bakeStyles))
.join("");
const method = existing ? existing.method : "ssr";
const metaChecked = existing ? existing.metaRefreshEnabled : false;
@@ -877,7 +1004,7 @@
<div class="field">
<label>Cache TTL (seconds)</label>
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
<div class="hint">How long a render stays in cache. 0 = always re-render. Each access extends TTL.</div>
<div class="hint">How long a render stays in cache. 0 = always re-render.</div>
</div>
<div class="field" style="display:flex;gap:12px;">
@@ -900,16 +1027,20 @@
</form>`;
}
function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode, bakeStyles) {
function buildUrlRowHTML(url, durationSec, waitBeforeCaptureMs, index, total, forceDarkMode, bakeStyles) {
const isSingle = total <= 1;
const disabledAttr = isSingle ? "disabled" : "";
const grayStyle = isSingle ? 'style="opacity:0.4;"' : "";
const darkChecked = forceDarkMode ? "checked" : "";
const bakeChecked = bakeStyles ? "checked" : "";
const waitVal = waitBeforeCaptureMs ?? 0;
return `
<div class="url-row" data-index="${index}">
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required>
<div style="display:flex;gap:8px;flex:1;">
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required title="Display duration">
<input type="number" placeholder="Wait ms" value="${waitVal}" min="0" max="60000" class="wait-input" title="Wait before capture (ms)">
</div>
<label class="dark-toggle" title="Force dark mode">
<input type="checkbox" class="dark-mode-input" ${darkChecked}>
🌙
@@ -930,7 +1061,7 @@
const container = document.getElementById("url-rows");
const rows = container.querySelectorAll(".url-row");
const div = document.createElement("div");
div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false, false);
div.innerHTML = buildUrlRowHTML("", 30, 0, rows.length, rows.length + 1, false, false);
container.appendChild(div.firstElementChild);
updateUrlRowStates();
}
@@ -987,9 +1118,11 @@
if (!urlVal) continue;
const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false;
const bakeStyles = row.querySelector(".bake-styles-input")?.checked ?? false;
const waitBeforeCaptureMs = parseInt(row.querySelector(".wait-input")?.value) || 0;
urls.push({
url: urlVal,
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
waitBeforeCaptureMs,
forceDarkMode,
bakeStyles,
});
@@ -1138,6 +1271,7 @@
document.getElementById("debug-devices-tbody").innerHTML = data.devices
.map((d) => {
const ago = Math.round((Date.now() - d.lastSeen) / 1000);
const ipEnc = encodeURIComponent(d.ip);
return `
<tr>
<td>${esc(d.ip)}</td>
@@ -1145,10 +1279,20 @@
<td>${d.requestCount}</td>
<td>${ago}s ago</td>
<td class="url-cell" title="${esc(d.userAgent)}">${esc(d.userAgent.slice(0, 60))}</td>
<td>
<div class="device-actions">
<button class="btn btn-xs btn-ghost" onclick="adminRefresh('${ipEnc}')" title="Force cache refresh">↺</button>
<button class="btn btn-xs btn-ghost" onclick="adminDisconnect('${ipEnc}')" title="Disconnect">⏏</button>
<button class="btn btn-xs btn-danger" onclick="adminBan('${ipEnc}', '${esc(d.ip)}')" title="Ban IP">🚫</button>
</div>
</td>
</tr>`;
})
.join("");
// ── Banned IPs table ──
fetchBannedIPs();
if (!debugTickInterval) {
debugTickInterval = setInterval(tickDebugValues, 500);
}
@@ -1177,6 +1321,88 @@
});
}
// ═══════════════════════════════════════════════
// ADMIN ACTIONS
// ═══════════════════════════════════════════════
async function adminDisconnect(ipEnc) {
try {
await api("POST", "/api/admin/disconnect/" + ipEnc);
toast("Device disconnected");
} catch (err) {
toast(err.message, "error");
}
}
async function adminRefresh(ipEnc) {
try {
await api("POST", "/api/admin/refresh/" + ipEnc);
toast("Cache refreshed");
} catch (err) {
toast(err.message, "error");
}
}
function adminBan(ipEnc, ipDisplay) {
openModal(`
<h2>Ban IP</h2>
<div class="confirm-text">Ban <strong>${esc(ipDisplay)}</strong>? Future connections will receive 403.</div>
<div class="field">
<label>Reason</label>
<input type="text" id="ban-reason" placeholder="Reason (optional)" value="Banned by administrator">
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger" onclick="doAdminBan('${ipEnc}', '${esc(ipDisplay)}')">Ban</button>
</div>`);
}
async function doAdminBan(ipEnc, ipDisplay) {
const reason = document.getElementById("ban-reason")?.value || "Banned by administrator";
try {
await api("POST", "/api/admin/ban/" + ipEnc, { reason });
closeModal();
toast(`Banned ${decodeURIComponent(ipEnc)}`);
fetchBannedIPs();
} catch (err) {
toast(err.message, "error");
}
}
async function adminUnban(ipEnc) {
try {
await api("POST", "/api/admin/unban/" + ipEnc);
toast("IP unbanned");
fetchBannedIPs();
} catch (err) {
toast(err.message, "error");
}
}
async function fetchBannedIPs() {
try {
const banned = await api("GET", "/api/banned");
const tbody = document.getElementById("debug-banned-tbody");
if (!tbody) return;
if (!banned.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr>';
return;
}
tbody.innerHTML = banned.map((b) => {
const ipEnc = encodeURIComponent(b.ip);
const dt = new Date(b.banned_at * 1000).toLocaleString();
return `
<tr>
<td>${esc(b.ip)}</td>
<td>${esc(dt)}</td>
<td>${esc(b.reason || "—")}</td>
<td>${esc(b.banned_by || "—")}</td>
<td><button class="btn btn-xs btn-ghost" onclick="adminUnban('${ipEnc}')">Unban</button></td>
</tr>`;
}).join("");
} catch { /* silent */ }
}
// ═══════════════════════════════════════════════
// KEYBOARD
// ═══════════════════════════════════════════════
+597 -117
View File
@@ -1,6 +1,177 @@
/**
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ SAMSUNG TV PROXY — Bun + Puppeteer SSR Server ║
* ╚══════════════════════════════════════════════════════════════════════╝
*
* Renders web pages to SSR HTML (with full CSS/resource inlining) or MJPEG
* screenshots, served to Samsung TVs (Tizen browser) and other limited
* clients. Multi-browser page-pool, render-cache, URL-rotation views,
* WebSocket debug dashboard, IP ban management, and device tracking.
*
* ─────────────────────────────────────────────────────────────────────────
* QUICK-SEARCH INDEX — jump by section name or semantic topic:
* ─────────────────────────────────────────────────────────────────────────
*
* SEC | ~LINE | FLAG / TOPIC | WHAT IT DOES
* ────┼───────┼───────────────────────────────────┼───────────────────────
* 01 | 35 | CONFIG | All tunables (pool, timeouts, intervals)
* 02 | 57 | TYPES / interfaces | View, URLItem, CachedRender, DeviceEntry…
* 03 | 115 | DATABASE / SQLite / schema | views + banned_ips tables, migrations
* 04 | 158 | DB HELPERS / dbLoad / dbInsert | CRUD: dbLoadAll, dbInsert, dbUpdate, dbDelete
* 05 | 202 | BAN SYSTEM / dbBanIP / dbIsBanned | IP ban persistence layer
* 06 | 219 | VIEW CACHE / viewCache | In-memory mirror of DB views table
* 07 | 230 | DEVICE TRACKING / devices | Per-IP tracking, ban gate, warmup trigger
* 08 | 252 | DEBUG DATA / getDebugData | Aggregates server+views+devices for API/WS
* 09 | 285 | PAGE POOL / PagePool / acquire | Multi-browser page lifecycle (acquire/release/reset)
* 10 | 383 | WS DEBUG / debugClients | Real-time debug-over-WebSocket push
* 11 | 393 | RENDER CACHE / renderCache | URL→HTML cache with TTL, inFlight tracking
* 12 | 417 | WARMUP / warmupView / warmupCache | Pre-cache active-device views on interval
* 13 | 461 | CACHE INVALIDATION / invalidate | Purge all cache entries for a view
* 14 | 475 | RENDER QUEUE / renderQueue | FIFO queue with timeout; drains into PagePool
* 15 | 506 | RENDER ENGINE / renderViewToCache | Core: SSR or MJPEG render → cache write
* 16 | 549 | GET-OR-RENDER / getOrRender | Cache-hit → inFlight → queue → render decision tree
* 17 | 590 | CACHE STATS / cacheStats | Telemetry: per-cache-entry TTL/URL info
* 18 | 620 | ROTATION / getCurrentURLIndex | Time-based URL cycling (+ remainingSec)
* 19 | 645 | SSR INLINING / injectMetaRefresh | Meta-refresh injection, DOMPurify sanitization
* 20 | 663 | CSS IMPORT RESOLVER / @import | Recursive @import resolver (max 3 levels)
* 21 | 694 | COMPUTED STYLE BAKING / bake | Inline computed styles for Tizen compat
* 22 | 777 | RESOURCE INLINING / inlineAll | CSS/images/favicons/bg→data: URIs in-page (logs errors)
* 23 | 887 | SSR RENDER / renderSSR | Full SSR pipeline: acquire→goto→inline→strip
* 24 | 932 | SCREENSHOT / renderScreenshotFrame| JPEG screenshot renderer (MJPEG mode)
* 25 | 961 | SCREENSHOT HTML / screenshotHTML | Wrap JPEG base64 in refresh-page HTML
* 26 | 981 | ERROR PAGE / errorPage | Styled error HTML with auto-retry meta-refresh
* 27 | 1002 | VIEW SERVING / serveView | /v/:id → rotation→render→response pipeline
* 28 | 1032 | API / handleAPI | REST: CRUD views, ban/unban, disconnect, refresh
* 29 | 1181 | STATIC FILES / serveStatic | Serves ../public (SPA fallback to index.html)
* 30 | 1198 | HTTP SERVER / Bun.serve | Main fetch handler: /ws, /v/:id, /api, static
* 31 | 1293 | INIT+SHUTDOWN / init / shutdown | Bootstrap + graceful drain + signal handlers
*
* ─────────────────────────────────────────────────────────────────────────
* DATA-FLOW MAP (how a GET /v/:id request travels):
* ─────────────────────────────────────────────────────────────────────────
*
* Client GET /v/:id
* │
* ▼
* Bun.serve.fetch() [ SEC 30 ]
* │
* ├─ deviceTrack(ip…) [ SEC 07 ] ← blocks banned IPs
* │
* ▼
* serveView(view) [ SEC 27 ]
* │
* ├─ getCurrentURLIndex(view) [ SEC 18 ] ← time-based rotation
* │
* ▼
* getOrRender(view, idx) [ SEC 16 ]
* │
* ├── cache HIT? ──▶ return cached body
* ├── inFlight? ──▶ await existing promise
* ├── pool free? ──▶ renderViewToCache() [ SEC 15 ]
* │ │
* │ ├─ renderSSR() [ SEC 23 ]
* │ │ ├─ pagePool.acquire() [ SEC 09 ]
* │ │ ├─ page.goto(url)
* │ │ ├─ inlineAllResources() [ SEC 22 ]
* │ │ │ └─ resolveCSSImports() [ SEC 20 ]
* │ │ ├─ [opt] bakeComputedStyles() [ SEC 21 ]
* │ │ ├─ sanitizeHTML()
* │ │ └─ pagePool.release()
* │ │
* │ └─ renderScreenshotFrame() [ SEC 24 ]
* │ └─ screenshotHTML() [ SEC 25 ]
* │
* └── pool full? ──▶ enqueue → drainRenderQueue() [ SEC 14 ]
*
* ─────────────────────────────────────────────────────────────────────────
* KEY DATA STRUCTURES (grep these to find type definitions & usage):
* ─────────────────────────────────────────────────────────────────────────
*
* View — id, name, urls(URLItem[]), method(ssr|mjpeg), cacheTtl, viewport
* CachedRender — body(string), urlIndex, createdAt, expiresAt
* ActiveRender — promise(Promise<string>), urlIndex (in-flight dedup)
* QueuedRender — resolve/reject callbacks, key, queuedAt (FIFO overflow)
* DeviceEntry — ip, viewId, userAgent, lastSeen, requestCount
* BrowserSlot — browser, available(Page[]), inUse, max
*
* ─────────────────────────────────────────────────────────────────────────
* HOW TO SEARCH THIS FILE (for humans & AIs):
* ─────────────────────────────────────────────────────────────────────────
*
* There is no need to read the file line-by-line. Use grep / text-search
* on any of the keywords below to jump directly to what you need:
*
* "CONFIG" → tunables (port, timeouts, pool sizing)
* "interface View" → View type definition
* "CREATE TABLE" → DB schema
* "dbLoadAll|dbInsert" → DB CRUD operations
* "dbBanIP|banned" → IP banning
* "viewCache" → in-memory view mirror
* "deviceTrack" → device tracking & ban gate
* "class PagePool" → browser/page pool management
* "acquire|release" → page lifecycle
* "renderCache|CachedRender" → render cache
* "getOrRender" → cache/inflight/queue decision
* "renderQueue|drainRenderQueue" → overflow queue
* "renderViewToCache" → actual render execution
* "warmupView|warmupCache" → pre-caching
* "cacheInvalidate" → cache purging
* "getCurrentURLIndex" → URL rotation logic
* "renderSSR" → SSR rendering
* "renderScreenshotFrame" → MJPEG rendering
* "inlineAllResources" → CSS/image inlining
* "bakeComputedStyles" → style baking for Tizen
* "resolveCSSImports" → @import recursion
* "serveView" → /v/:id response builder
* "handleAPI" → REST API handler
* "Bun.serve" → HTTP server entry
* "shutdown" → graceful shutdown
* "getDebugData" → debug telemetry
* "WebSocket|ws" → debug WS
* "errorPage" → error HTML template
* "screenshotHTML" → MJPEG wrapper HTML
*
* ─────────────────────────────────────────────────────────────────────────
* MODIFICATION GUIDE (what to touch when adding/changing features):
* ─────────────────────────────────────────────────────────────────────────
*
* To add a new View field:
* 1. Add to `interface View` [ SEC 02 ]
* 2. Add column migration [ SEC 03 ]
* 3. Update `rowToView()` [ SEC 03 ]
* 4. Update `dbInsert()` / `dbUpdate()` [ SEC 04 ]
* 5. Update `CreateViewBody` [ SEC 02 ]
* 6. Update API handler [ SEC 28 ]
* 7. Update `getDebugData()` [ SEC 08 ] (if visible in debug)
*
* To add a new API endpoint:
* 1. Add route in `handleAPI()` [ SEC 28 ]
* 2. If real-time: add to `getDebugData()` [ SEC 08 ]
* 3. If admin: consider ban/auth gate [ SEC 07 ]
*
* To change rendering behavior:
* - SSR pipeline: `renderSSR()` [ SEC 23 ]
* - MJPEG pipeline: `renderScreenshotFrame()` [ SEC 24 ]
* - Inlining logic: `inlineAllResources()` [ SEC 22 ]
* - Style baking: `bakeComputedStyles()` [ SEC 21 ]
* - Script removal: `sanitizeHTML()` [ SEC 19 ]
*
* ─────────────────────────────────────────────────────────────────────────
* EXTERNAL DEPENDENCIES:
* ─────────────────────────────────────────────────────────────────────────
*
* bun:sqlite — embedded SQLite (views.db, banned_ips.db)
* puppeteer — headless Chromium (launched via /usr/bin/chromium)
* Bun — runtime: Bun.file(), Bun.serve(), WebSocket upgrade
* crypto — randomUUID() for view IDs
*
* ═══════════════════════════════════════════════════════════════════════
*/
import { Database } from "bun:sqlite";
import type { Browser, Page } from "puppeteer";
import puppeteer from "puppeteer";
import DOMPurify from "isomorphic-dompurify";
// ═══════════════════════════════════════════════════════════════
// CONFIG
@@ -16,6 +187,7 @@ const CONFIG = {
MAX_CONCURRENT_RENDERS: 16,
QUEUE_TIMEOUT_MS: 30000,
CACHE_CLEANUP_INTERVAL_MS: 30000,
WARMUP_INTERVAL_MS: 60000,
} as const;
// ═══════════════════════════════════════════════════════════════
@@ -25,6 +197,7 @@ const CONFIG = {
interface URLItem {
url: string;
durationSec: number;
waitBeforeCaptureMs?: number;
forceDarkMode?: boolean;
bakeStyles?: boolean;
}
@@ -115,6 +288,15 @@ db.run(`
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS banned_ips (
ip TEXT PRIMARY KEY,
banned_at INTEGER NOT NULL DEFAULT (unixepoch()),
reason TEXT,
banned_by TEXT
)
`);
// Migrations for upgrading from older schema
try {
db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60");
@@ -143,6 +325,30 @@ function rowToView(row: ViewRow): View {
};
}
// Seed a default view on first run (only when table is empty)
const viewCount = (db.query("SELECT COUNT(*) as n FROM views").get() as { n: number }).n;
if (viewCount === 0) {
db.run(
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
[
crypto.randomUUID(),
"Demo View",
JSON.stringify([
{ url: "https://www.google.com", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
{ url: "https://duck.ai", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
{ url: "https://www.msn.com", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
]),
"ssr",
0,
60,
1920,
1080,
Math.floor(Date.now() / 1000),
],
);
}
function dbLoadAll(): View[] {
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
rowToView,
@@ -192,6 +398,35 @@ function dbDelete(id: string): boolean {
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
}
function dbBanIP(ip: string, reason?: string): void {
db.run(
"INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)",
[ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"],
);
}
function dbUnbanIP(ip: string): boolean {
return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0;
}
function dbIsBanned(ip: string): boolean {
return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null;
}
function dbGetBannedIPs(): Array<{
ip: string;
banned_at: number;
reason: string;
banned_by: string;
}> {
return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{
ip: string;
banned_at: number;
reason: string;
banned_by: string;
}>;
}
// ═══════════════════════════════════════════════════════════════
// IN-MEMORY VIEW CACHE
// ═══════════════════════════════════════════════════════════════
@@ -215,9 +450,16 @@ function viewCacheAll(): View[] {
// ═══════════════════════════════════════════════════════════════
const devices = new Map<string, DeviceEntry>();
const warmedUpViews = new Set<string>();
const DEVICE_TTL_MS = 120_000;
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void {
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): boolean {
// Check if IP is banned
if (dbIsBanned(ip)) {
console.warn(`[security] Blocked connection from banned IP: ${ip}`);
return false;
}
const existing = devices.get(ip);
if (existing) {
existing.viewId = viewId;
@@ -227,7 +469,16 @@ function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: st
existing.requestCount++;
} else {
devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 });
// First user connecting to this view - trigger warmup
if (!warmedUpViews.has(viewId)) {
warmedUpViews.add(viewId);
warmupView(viewId).catch((err) => {
console.error(`[warmup] Failed to warm up view ${viewId}:`, err);
});
}
}
return true;
}
function devicesPrune(): void {
@@ -247,7 +498,7 @@ function devicesSnapshot(): DeviceEntry[] {
// ═══════════════════════════════════════════════════════════════
function getDebugData() {
const viewsDebug = viewCacheAll().map(v => {
const viewsDebug = viewCacheAll().map((v) => {
const idx = getCurrentURLIndex(v);
return {
id: v.id,
@@ -262,7 +513,7 @@ function getDebugData() {
currentUrl: v.urls[idx]?.url ?? "",
remainingSec: getRemainingSec(v),
totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0),
cacheEntries: cacheStats().filter(c => c.viewId === v.id),
cacheEntries: cacheStats().filter((c) => c.viewId === v.id),
};
});
@@ -364,7 +615,9 @@ class PagePool {
return true;
} catch (err) {
console.warn(`[page-pool] resetPage failed, discarding page: ${err}`);
try { await page.close(); } catch {}
try {
await page.close();
} catch {}
return false;
}
}
@@ -439,10 +692,7 @@ let pagePool: PagePool;
// WEBSOCKET DEBUG CLIENTS
// ═══════════════════════════════════════════════════════════════
const debugClients = new Map<
any,
{ interval: number; timer: ReturnType<typeof setInterval> }
>();
const debugClients = new Map<any, { interval: number; timer: ReturnType<typeof setInterval> }>();
const DEBUG_DEFAULT_INTERVAL_MS = 1000;
const DEBUG_MIN_INTERVAL_MS = 100;
@@ -467,6 +717,64 @@ function cachePurgeExpired(): void {
setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS);
async function warmupView(viewId: string): Promise<void> {
const view = viewCacheGet(viewId);
if (!view) {
console.warn(`[warmup] View ${viewId} not found`);
return;
}
console.log(`[warmup] Starting precache for view "${view.name}" (${view.urls.length} URLs)`);
for (let i = 0; i < view.urls.length; i++) {
const key = renderCacheKey(view.id, i);
const cached = renderCache.get(key);
const now = Date.now();
if (!cached || now >= cached.expiresAt) {
try {
await getOrRender(view, i);
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
} catch (err: any) {
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
}
}
}
console.log(`[warmup] Precache complete for view "${view.name}"`);
}
async function warmupCache(): Promise<void> {
const activeDevices = devicesSnapshot();
if (activeDevices.length === 0) {
return;
}
console.log(`[warmup] Starting periodic precache for ${activeDevices.length} active device(s)`);
const views = viewCacheAll();
for (const view of views) {
for (let i = 0; i < view.urls.length; i++) {
const key = renderCacheKey(view.id, i);
const cached = renderCache.get(key);
const now = Date.now();
if (!cached || now >= cached.expiresAt) {
try {
await getOrRender(view, i);
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
} catch (err: any) {
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
}
}
}
}
console.log(`[warmup] Periodic precache complete`);
}
setInterval(warmupCache, CONFIG.WARMUP_INTERVAL_MS);
function cacheInvalidate(viewId: string): void {
const prefix = `${viewId}:`;
for (const key of renderCache.keys()) {
@@ -478,6 +786,8 @@ function cacheInvalidate(viewId: string): void {
renderQueue.splice(i, 1);
}
}
// Allow re-warming if view is recreated
warmedUpViews.delete(viewId);
}
function drainRenderQueue(): void {
@@ -504,7 +814,6 @@ function drainRenderQueue(): void {
const cached = renderCache.get(next.key);
if (cached && Date.now() < cached.expiresAt) {
cached.expiresAt = Date.now() + view.cacheTtlSec * 1000;
next.resolve(cached.body);
continue;
}
@@ -531,16 +840,26 @@ async function renderViewToCache(view: View, urlIndex: number, key: string): Pro
const urlItem = view.urls[urlIndex];
const forceDarkMode = urlItem.forceDarkMode ?? false;
const bakeStyles = urlItem.bakeStyles ?? false;
const waitBeforeCaptureMs = urlItem.waitBeforeCaptureMs ?? 0;
let body: string;
if (view.method === "mjpeg") {
const jpeg = await renderScreenshotFrame(
urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode,
urlItem.url,
view.viewportWidth,
view.viewportHeight,
forceDarkMode,
waitBeforeCaptureMs,
);
body = jpeg.toString("base64");
} else {
body = await renderSSR(
urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, bakeStyles,
urlItem.url,
view.viewportWidth,
view.viewportHeight,
forceDarkMode,
bakeStyles,
waitBeforeCaptureMs,
);
}
@@ -564,7 +883,6 @@ async function getOrRender(view: View, urlIndex: number): Promise<string> {
const cached = renderCache.get(key);
if (cached && now < cached.expiresAt) {
cached.expiresAt = now + view.cacheTtlSec * 1000;
return cached.body;
}
@@ -681,17 +999,69 @@ function injectMetaRefresh(html: string, intervalSec: number): string {
return `<html><head>${tag}</head><body>${html}</body></html>`;
}
function stripScriptTags(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\s+on\w+\s*=\s*'[^']*'/gi, "");
/**
* Sanitize HTML using DOMPurify to remove scripts and event handlers.
* More robust than regex-based stripping — prevents bypasses via
* malformed tags, encoding tricks, and nested structures.
*/
function sanitizeHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
// Document structure
"html", "head", "body", "title", "meta", "link", "base",
// Content sections
"h1", "h2", "h3", "h4", "h5", "h6", "header", "footer", "main", "section", "article", "aside", "nav",
// Text content
"p", "div", "span", "blockquote", "pre", "code", "em", "strong", "i", "b", "u", "s", "small", "sub", "sup",
// Lists
"ul", "ol", "li", "dl", "dt", "dd",
// Tables
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
// Forms (static display only)
"form", "input", "button", "select", "option", "textarea", "label", "fieldset", "legend",
// Media
"img", "figure", "figcaption", "audio", "video", "source", "track", "canvas", "svg",
// Embedded content
"iframe", "embed", "object", "param",
// Formatting
"br", "hr", "wbr", "mark", "del", "ins", "time", "address", "cite", "q",
// Style
"style"
],
ALLOWED_ATTR: [
// Generic
"id", "class", "style", "title", "dir", "lang", "role", "tabindex",
// Links
"href", "target", "rel", "download",
// Media
"src", "alt", "width", "height", "loading", "decoding", "crossorigin",
"poster", "preload", "controls", "autoplay", "loop", "muted", "playsinline",
// Forms
"type", "name", "value", "placeholder", "required", "disabled", "readonly",
"checked", "selected", "multiple", "min", "max", "step", "pattern", "minlength", "maxlength",
// Tables
"colspan", "rowspan", "headers", "scope",
// Meta/Link
"charset", "content", "http-equiv", "media", "sizes", "as",
// Iframe
"srcdoc", "sandbox", "allow", "allowfullscreen", "frameborder", "scrolling",
// SVG
"viewBox", "xmlns", "fill", "stroke", "stroke-width", "d", "cx", "cy", "r", "x", "y",
// Data
"data-*"
],
// Keep the HTML intact (don't wrap in body)
KEEP_DOCUMENT_FRAGMENT: true,
// Allow data URIs for inlined resources
ALLOW_DATA_ATTR: true,
});
}
// ── Recursive @import resolver (max 3 levels deep) ──
async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): Promise<string> {
if (depth > 3) return cssText;
const importRe = /@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi;
const importRe =
/@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi;
const replacements: Array<{ match: string; resolved: string }> = [];
let m: RegExpExecArray | null;
@@ -707,7 +1077,10 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
if (!res.ok) throw new Error("failed");
let imported = await res.text();
imported = await resolveCSSImports(imported, importUrl, depth + 1);
replacements.push({ match: m[0], resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */" });
replacements.push({
match: m[0],
resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */",
});
} catch {
replacements.push({ match: m[0], resolved: "/* unresolved import: " + importUrl + " */" });
}
@@ -720,108 +1093,57 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
}
// ═══════════════════════════════════════════════════════════════
// COMPUTED STYLE BAKING
// CSS COLLECTION (simplified — all CSS into single style tag)
// ═══════════════════════════════════════════════════════════════
/**
* Bake computed styles into inline style attributes.
* This ensures styles work on browsers with limited CSS support (e.g., Samsung TV).
* Compares each element against a same-tagname fresh element so tag-specific defaults
* (e.g. block display on div, inline on span, bold on h1) are accounted for.
* Collect all CSS from loaded stylesheets and inject into a single <style> tag in head.
* Simplified alternative to per-element computed style baking.
* Toggle via URLItem.bakeStyles.
*/
async function bakeComputedStyles(page: Page): Promise<void> {
await page.evaluate((): void => {
const computedProperties = [
// Layout
"display", "position", "top", "right", "bottom", "left",
"width", "height", "min-width", "min-height", "max-width", "max-height",
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
"padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
// Flex/Grid
"flex", "flex-direction", "flex-wrap", "flex-flow", "flex-grow", "flex-shrink", "flex-basis",
"justify-content", "align-items", "align-content", "align-self", "gap", "row-gap", "column-gap",
"grid", "grid-template", "grid-template-columns", "grid-template-rows", "grid-area",
// Box model
"box-sizing", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
"border-top-style", "border-right-style", "border-bottom-style", "border-left-style",
"border-top-color", "border-right-color", "border-bottom-color", "border-left-color",
"border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius",
// Colors & Background
"color", "background-color", "background-image", "background-size", "background-position",
"background-repeat", "background-attachment", "background-clip", "background-origin",
// Typography
"font-family", "font-size", "font-weight", "font-style", "font-variant",
"line-height", "text-align", "text-decoration-line", "text-decoration-color",
"text-transform", "letter-spacing", "word-spacing",
"white-space", "overflow-wrap", "word-break",
// Visual
"opacity", "visibility", "z-index", "overflow-x", "overflow-y",
"box-shadow", "text-shadow", "transform", "transform-origin",
// List/Table
"list-style-type", "list-style-position", "border-collapse", "border-spacing",
// Cursor/pointer
"cursor", "pointer-events",
];
const cssTexts: string[] = [];
// Cache of default computed values keyed by tagName
const defaultCache = new Map<string, Map<string, string>>();
const hiddenContainer = document.createElement("div");
hiddenContainer.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;top:-9999px;";
document.body.appendChild(hiddenContainer);
function getTagDefaults(tag: string): Map<string, string> {
let cache = defaultCache.get(tag);
if (cache) return cache;
cache = new Map<string, string>();
// Collect from all stylesheets (external and inline)
for (const sheet of document.styleSheets) {
try {
const el = document.createElement(tag);
hiddenContainer.appendChild(el);
const cs = window.getComputedStyle(el);
for (const prop of computedProperties) {
cache.set(prop, cs.getPropertyValue(prop));
const rules = sheet.cssRules || sheet.rules;
if (!rules) continue;
let cssText = "";
for (const rule of rules) {
cssText += rule.cssText + "\n";
}
el.remove();
} catch { /* unknown tag, cache will be empty */ }
defaultCache.set(tag, cache);
return cache;
}
function shouldBake(value: string, defaultValue: string): boolean {
if (!value) return false;
// Skip browser keywords that cannot be serialized as inline style values
if (value === "initial" || value === "inherit" || value === "unset" || value === "revert") return false;
return value !== defaultValue;
}
// Walk all elements and bake computed styles
const elements = document.querySelectorAll<HTMLElement>("*");
for (const el of elements) {
if (el === hiddenContainer) continue;
// Skip non-visual tags
const tag = el.tagName.toLowerCase();
if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag)) continue;
const computed = window.getComputedStyle(el);
const defaults = getTagDefaults(tag);
const stylesToBake: string[] = [];
for (const prop of computedProperties) {
const value = computed.getPropertyValue(prop);
const defaultValue = defaults.get(prop) ?? "";
if (shouldBake(value, defaultValue)) {
stylesToBake.push(`${prop}:${value}`);
if (cssText.trim()) {
cssTexts.push(`/* ${sheet.href || "inline"} */\n${cssText}`);
}
}
if (stylesToBake.length > 0) {
// Existing inline styles take precedence — put them after baked styles
const existingInline = el.getAttribute("style") || "";
const bakedStyles = stylesToBake.join(";");
el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles);
} catch {
// Cross-origin stylesheets — skip (already inlined by inlineAllResources)
}
}
hiddenContainer.remove();
// Also grab any inline <style> tags content directly
const styleTags = document.querySelectorAll("style");
for (const tag of styleTags) {
const content = tag.textContent?.trim();
if (content) {
cssTexts.push(`/* inline style tag */\n${content}`);
}
}
// Create single consolidated style tag in head
if (cssTexts.length > 0) {
const consolidated = document.createElement("style");
consolidated.textContent = cssTexts.join("\n\n");
// Insert as first element in head so it can be overridden
const head = document.head;
if (head.firstChild) {
head.insertBefore(consolidated, head.firstChild);
} else {
head.appendChild(consolidated);
}
}
});
}
@@ -926,7 +1248,8 @@ async function inlineAllResources(page: Page, html: string): Promise<string> {
}
return collect();
});
} catch {
} catch (err) {
console.warn(`[inline] Resource inlining failed: ${err}`);
return html;
}
@@ -949,7 +1272,7 @@ async function inlineAllResources(page: Page, html: string): Promise<string> {
html = html.replace(
linkRegex,
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`,
);
}
for (const img of payload.images) {
@@ -988,6 +1311,7 @@ async function renderSSR(
viewportHeight = 1080,
forceDarkMode = false,
bakeStyles = false,
waitBeforeCaptureMs = 0,
): Promise<string> {
const page = await pagePool.acquire();
try {
@@ -1007,16 +1331,24 @@ async function renderSSR(
}
}
// Wait before capture (for animations, dynamic content, etc.)
if (waitBeforeCaptureMs > 0) {
await new Promise((r) => setTimeout(r, waitBeforeCaptureMs));
}
let html = await page.content();
html = await inlineAllResources(page, html);
// Bake computed styles into inline attributes (for Samsung TV compatibility)
// Set inlined HTML back to page so bake can collect all CSS
await page.setContent(html, { waitUntil: "domcontentloaded" });
// Bake: collect all CSS into single style tag
if (bakeStyles) {
await bakeComputedStyles(page);
html = await page.content();
}
html = stripScriptTags(html);
html = sanitizeHTML(html);
return html;
} finally {
await pagePool.release(page);
@@ -1032,6 +1364,7 @@ async function renderScreenshotFrame(
viewportWidth = 1920,
viewportHeight = 1080,
forceDarkMode = false,
waitBeforeCaptureMs = 0,
): Promise<Buffer> {
const page = await pagePool.acquire();
try {
@@ -1050,6 +1383,11 @@ async function renderScreenshotFrame(
}
}
// Wait before capture (for animations, dynamic content, etc.)
if (waitBeforeCaptureMs > 0) {
await new Promise((r) => setTimeout(r, waitBeforeCaptureMs));
}
return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false }));
} finally {
await pagePool.release(page);
@@ -1152,6 +1490,7 @@ function json(data: unknown, status = 200): Response {
async function handleAPI(req: Request, path: string): Promise<Response> {
const method = req.method;
const url = new URL(req.url);
// GET /api/views
if (path === "/api/views" && method === "GET") {
@@ -1163,6 +1502,11 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
return json(getDebugData());
}
// GET /api/banned
if (path === "/api/banned" && method === "GET") {
return json(dbGetBannedIPs());
}
// POST /api/views
if (path === "/api/views" && method === "POST") {
try {
@@ -1183,6 +1527,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
urls: body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
})),
@@ -1226,6 +1571,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
view.urls = body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
}));
@@ -1260,6 +1606,126 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
console.log(`[api] Deleted ${viewId}`);
return json({ ok: true });
}
// GET /api/views/:id/export - Export single view as JSON
if (method === "GET" && url.searchParams.get("export") === "true") {
const view = viewCacheGet(viewId);
if (!view) return json({ error: "View not found" }, 404);
const exportData = {
name: view.name,
urls: view.urls,
method: view.method,
metaRefreshEnabled: view.metaRefreshEnabled,
cacheTtlSec: view.cacheTtlSec,
viewportWidth: view.viewportWidth,
viewportHeight: view.viewportHeight,
};
return new Response(JSON.stringify(exportData, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="view-${viewId}.json"`,
},
});
}
}
// POST /api/views/import - Import a view from JSON
if (path === "/api/views/import" && method === "POST") {
try {
const importData = JSON.parse(await req.text());
if (!importData.name?.trim()) return json({ error: "Name is required" }, 400);
if (!importData.urls?.length) return json({ error: "At least one URL is required" }, 400);
for (const u of importData.urls) {
if (!u.url?.trim()) return json({ error: "Each URL must have a url" }, 400);
if (typeof u.durationSec !== "number" || u.durationSec <= 0)
return json({ error: "Each URL must have positive durationSec" }, 400);
}
const method = importData.method ?? "ssr";
if (!["mjpeg", "ssr"].includes(method))
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
const view: View = {
id: crypto.randomUUID(),
name: importData.name.trim(),
urls: importData.urls.map((u: URLItem) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
})),
method: method,
metaRefreshEnabled: importData.metaRefreshEnabled ?? false,
cacheTtlSec: Math.max(0, importData.cacheTtlSec ?? 60),
viewportWidth: Math.max(320, Math.min(7680, importData.viewportWidth ?? 1920)),
viewportHeight: Math.max(240, Math.min(4320, importData.viewportHeight ?? 1080)),
createdAt: Math.floor(Date.now() / 1000),
};
dbInsert(view);
viewCache.set(view.id, view);
console.log(`[api] Imported "${view.name}" (${view.id}) method=${view.method}`);
return json(view, 201);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// POST /api/admin/disconnect/:ip
const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/);
if (disconnectMatch && method === "POST") {
const ip = decodeURIComponent(disconnectMatch[1]);
devices.delete(ip);
console.log(`[admin] Disconnected device: ${ip}`);
return json({ ok: true, message: `Device ${ip} disconnected` });
}
// POST /api/admin/ban/:ip
const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/);
if (banMatch && method === "POST") {
const ip = decodeURIComponent(banMatch[1]);
try {
const body = JSON.parse(await req.text());
const reason = body.reason || "Banned by administrator";
const bannedDevice = devices.get(ip);
dbBanIP(ip, reason);
devices.delete(ip);
if (bannedDevice) cacheInvalidate(bannedDevice.viewId);
console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`);
return json({ ok: true, message: `IP ${ip} banned` });
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// POST /api/admin/unban/:ip
const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/);
if (unbanMatch && method === "POST") {
const ip = decodeURIComponent(unbanMatch[1]);
const success = dbUnbanIP(ip);
if (success) {
console.log(`[admin] Unbanned IP: ${ip}`);
return json({ ok: true, message: `IP ${ip} unbanned` });
} else {
return json({ error: "IP not found in ban list" }, 404);
}
}
// POST /api/admin/refresh/:ip
const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/);
if (refreshMatch && method === "POST") {
const ip = decodeURIComponent(refreshMatch[1]);
const device = devices.get(ip);
if (device) {
cacheInvalidate(device.viewId);
console.log(`[admin] Forced cache refresh for view: ${device.viewId}`);
return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` });
} else {
return json({ error: "Device not found" }, 404);
}
}
return json({ error: "Not found" }, 404);
@@ -1307,7 +1773,9 @@ const server = Bun.serve<{ interval: number }>({
}, interval);
debugClients.set(ws, { interval, timer });
// Send initial payload immediately
try { ws.send(JSON.stringify(getDebugData())); } catch {}
try {
ws.send(JSON.stringify(getDebugData()));
} catch {}
},
message(ws, msg) {
try {
@@ -1319,15 +1787,21 @@ const server = Bun.serve<{ interval: number }>({
clearInterval(client.timer);
client.interval = interval;
client.timer = setInterval(() => {
try { ws.send(JSON.stringify(getDebugData())); } catch {
try {
ws.send(JSON.stringify(getDebugData()));
} catch {
clearInterval(client.timer);
debugClients.delete(ws);
}
}, interval);
// Push immediately after rate change
try { ws.send(JSON.stringify(getDebugData())); } catch {}
try {
ws.send(JSON.stringify(getDebugData()));
} catch {}
}
} catch { /* ignore malformed messages */ }
} catch {
/* ignore malformed messages */
}
},
close(ws) {
const client = debugClients.get(ws);
@@ -1373,12 +1847,18 @@ const server = Bun.serve<{ interval: number }>({
});
}
deviceTrack(
const tracked = deviceTrack(
ip,
view.id,
view.name,
req.headers.get("User-Agent") ?? "unknown",
);
if (!tracked) {
return new Response(errorPage("Access denied"), {
status: 403,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return serveView(view);
}
+28
View File
@@ -0,0 +1,28 @@
import asyncio
from samsung_mdc import MDC
async def main(ip, display_id):
async with MDC(ip, verbose=True) as mdc:
# First argument of command is always display_id
status = await mdc.status(display_id)
print(status) # Result is always tuple
if status[0] != MDC.power.POWER_STATE.ON:
# Command arguments are always Sequence (tuple, list)
await mdc.power(display_id, [MDC.power.POWER_STATE.ON])
await mdc.close() # Force reconnect on next command
await asyncio.sleep(15)
await mdc.display_id(display_id, [MDC.display_id.DISPLAY_ID_STATE.ON])
# You may also use names or values instead of enums
await mdc.display_id(display_id, ['ON']) # same
await mdc.display_id(display_id, [1]) # same
if True:
await mdc.power(display_id, [MDC.power.POWER_STATE.OFF])
await mdc.close() # Force reconnect on next command
# If you see "Connected" and timeout error, try other display_id (0, 1)
asyncio.run(main('10.184.27.132', 1))
+1
View File
@@ -0,0 +1 @@
python-samsung-mdc