├── .gitignore ├── .prettierrc ├── Dockerfile ├── Makefile ├── README.md ├── entitlements.xml ├── main.js ├── package.json ├── script ├── build └── deploy └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /chromedata 3 | /dist 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "bracketSpacing": false, 5 | "semi": false, 6 | "arrowParens": "avoid", 7 | "jsxBracketSameLine": true, 8 | "bracketSameLine": true 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-bullseye AS base 2 | 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt-get update \ 6 | && apt-get install -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget x11vnc x11-xkb-utils xfonts-100dpi xfonts-75dpi xfonts-scalable x11-apps xvfb xserver-xorg-core x11-xserver-utils xauth 7 | 8 | RUN apt-get update \ 9 | && apt-get install -y wget gnupg \ 10 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 11 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 12 | && apt-get update \ 13 | && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 --no-install-recommends 14 | 15 | WORKDIR /home/chrome 16 | COPY main.js package.json yarn.lock /home/chrome/ 17 | 18 | FROM base 19 | RUN npm install 20 | EXPOSE 5589 21 | ENV DISPLAY :99 22 | ENV CHROME_BIN /usr/bin/google-chrome 23 | ENV DOCKER true 24 | CMD Xvfb :99 -screen 0 1920x1080x16 & node main.js 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | publish-docker: 2 | docker buildx build --platform linux/amd64 -t fancybits/chrome-capture-for-channels:latest --push . 3 | 4 | app.icns: app.png 5 | makeicns -in app.png -out app.icns 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## chrome-capture-for-channels 2 | 3 | Capture video and audio from a Chrome tab using the [`chrome.tabCapture`](https://developer.chrome.com/docs/extensions/reference/tabCapture/) API. Built on [Puppeteer](https://pptr.dev/) and [puppeteer-stream](https://github.com/SamuelScheit/puppeteer-stream) 4 | 5 | ### setup 6 | 7 | download the latest [release](https://github.com/fancybits/chrome-capture-for-channels/releases) for macOS or Windows 8 | 9 | or run in docker: 10 | 11 | ``` 12 | docker run -d --name chrome-capture -p 5589:5589 fancybits/chrome-capture-for-channels 13 | ``` 14 | 15 | ### usage 16 | 17 | a http server is listening on port 5589 and responds to these routes. the response is a webm stream with h264 video and opus audio. 18 | 19 | - `/stream/` for stream names registered in the code 20 | - `/stream?url=` for other arbitrary URLs 21 | 22 | setup a new Custom Channel using: 23 | 24 | ``` 25 | #EXTM3U 26 | #EXTINF:-1 channel-id="weatherscan",Weatherscan 27 | chrome://x.x.x.x:5589/stream?url=https://weatherscan.net 28 | ``` 29 | 30 | ### development 31 | 32 | to setup a development environment where you can edit and run `main.js`: 33 | 34 | #### windows 35 | 36 | ``` 37 | winget install -e --id Git.Git 38 | winget install -e --id OpenJS.NodeJS 39 | 40 | git clone https://github.com/fancybits/chrome-capture-for-channels 41 | cd chrome-capture-for-channels 42 | npm install 43 | node main.js 44 | ``` 45 | 46 | #### mac 47 | 48 | ``` 49 | brew install nodejs git 50 | 51 | git clone https://github.com/fancybits/chrome-capture-for-channels 52 | cd chrome-capture-for-channels 53 | npm install 54 | node main.js 55 | ``` 56 | -------------------------------------------------------------------------------- /entitlements.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const {launch: puppeteerLaunch} = require('puppeteer-core') 2 | const {launch, getStream} = require('puppeteer-stream') 3 | const fs = require('fs') 4 | const child_process = require('child_process') 5 | const process = require('process') 6 | const path = require('path') 7 | const express = require('express') 8 | const morgan = require('morgan') 9 | require('express-async-errors') 10 | require('console-stamp')(console, { 11 | format: ':date(yyyy/mm/dd HH:MM:ss.l)', 12 | }) 13 | 14 | const viewport = { 15 | width: 1920, 16 | height: 1080, 17 | } 18 | 19 | var currentBrowser, dataDir, lastPage 20 | const getCurrentBrowser = async () => { 21 | if (!currentBrowser || !currentBrowser.isConnected()) { 22 | currentBrowser = await launch( 23 | { 24 | launch: opts => { 25 | if (process.pkg) { 26 | opts.args = opts.args.filter( 27 | arg => !arg.startsWith('--load-extension=') && !arg.startsWith('--disable-extensions-except=') 28 | ) 29 | opts.args = opts.args.concat([ 30 | `--load-extension=${path.join(dataDir, 'extension')}`, 31 | `--disable-extensions-except=${path.join(dataDir, 'extension')}`, 32 | ]) 33 | } 34 | if (process.env.DOCKER || process.platform == 'win32') { 35 | opts.args = opts.args.concat(['--no-sandbox']) 36 | } 37 | if (process.env.DOCKER) { 38 | opts.args = opts.args.concat([ 39 | '--use-gl=angle', 40 | '--use-angle=gl-egl', 41 | '--enable-features=VaapiVideoDecoder,VaapiVideoEncoder', 42 | '--ignore-gpu-blocklist', 43 | '--enable-zero-copy', 44 | '--enable-drdc' 45 | ]) 46 | } 47 | return puppeteerLaunch(opts) 48 | }, 49 | }, 50 | { 51 | executablePath: getExecutablePath(), 52 | defaultViewport: null, // no viewport emulation 53 | userDataDir: path.join(dataDir, 'chromedata'), 54 | args: [ 55 | '--no-first-run', 56 | '--disable-infobars', 57 | '--hide-crash-restore-bubble', 58 | '--disable-blink-features=AutomationControlled', 59 | '--hide-scrollbars', 60 | ], 61 | ignoreDefaultArgs: [ 62 | '--enable-automation', 63 | '--disable-extensions', 64 | '--disable-default-apps', 65 | '--disable-component-update', 66 | '--disable-component-extensions-with-background-pages', 67 | '--enable-blink-features=IdleDetection', 68 | ], 69 | } 70 | ) 71 | currentBrowser.on('close', () => { 72 | currentBrowser = null 73 | }) 74 | currentBrowser.pages().then(pages => { 75 | pages.forEach(page => page.close()) 76 | }) 77 | } 78 | return currentBrowser 79 | } 80 | 81 | const getExecutablePath = () => { 82 | if (process.env.CHROME_BIN) { 83 | return process.env.CHROME_BIN 84 | } 85 | 86 | let executablePath 87 | if (process.platform === 'linux') { 88 | try { 89 | executablePath = child_process.execSync('which chromium-browser').toString().split('\n').shift() 90 | } catch (e) { 91 | // NOOP 92 | } 93 | 94 | if (!executablePath) { 95 | executablePath = child_process.execSync('which chromium').toString().split('\n').shift() 96 | if (!executablePath) { 97 | throw new Error('Chromium not found (which chromium)') 98 | } 99 | } 100 | } else if (process.platform === 'darwin') { 101 | executablePath = [ 102 | '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 103 | '/Applications/Chromium.app/Contents/MacOS/Chromium', 104 | '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', 105 | ].find(fs.existsSync) 106 | } else if (process.platform === 'win32') { 107 | executablePath = [ 108 | `C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe`, 109 | `C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe`, 110 | path.join(process.env.USERPROFILE, 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe'), 111 | path.join(process.env.USERPROFILE, 'AppData', 'Local', 'Chromium', 'Application', 'chrome.exe'), 112 | ].find(fs.existsSync) 113 | } else { 114 | throw new Error('Unsupported platform: ' + process.platform) 115 | } 116 | 117 | return executablePath 118 | } 119 | 120 | async function main() { 121 | dataDir = process.cwd() 122 | if (process.pkg) { 123 | switch (process.platform) { 124 | case 'darwin': 125 | dataDir = path.join(process.env.HOME, 'Library', 'Application Support', 'ChromeCapture') 126 | break 127 | case 'win32': 128 | dataDir = path.join(process.env.USERPROFILE, 'AppData', 'Local', 'ChromeCapture') 129 | break 130 | } 131 | let out = path.join(dataDir, 'extension') 132 | fs.mkdirSync(out, {recursive: true}) 133 | ;['manifest.json', 'background.js', 'options.html', 'options.js'].forEach(file => { 134 | fs.copyFileSync( 135 | path.join(process.pkg.entrypoint, '..', 'node_modules', 'puppeteer-stream', 'extension', file), 136 | path.join(out, file) 137 | ) 138 | }) 139 | } 140 | 141 | const app = express() 142 | 143 | const df = require('dateformat') 144 | morgan.token('mydate', function (req) { 145 | return df(new Date(), 'yyyy/mm/dd HH:MM:ss.l') 146 | }) 147 | app.use(morgan('[:mydate] :method :url from :remote-addr responded :status in :response-time ms')) 148 | 149 | app.get('/', (req, res) => { 150 | res.send( 151 | ` 152 | Chrome Capture for Channels 153 |

Chrome Capture for Channels

154 |

Usage: /stream?url=URL or /stream/<name>

155 |
156 |   #EXTM3U
157 | 
158 |   #EXTINF:-1 channel-id="windy",Windy
159 |   chrome://${req.get('host')}/stream/windy
160 | 
161 |   #EXTINF:-1 channel-id="weatherscan",Weatherscan
162 |   chrome://${req.get('host')}/stream/weatherscan
163 |   
164 | ` 165 | ) 166 | }) 167 | 168 | app.get('/debug', async (req, res) => { 169 | res.send(` 170 | 184 |