gh-chrome 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. gh_chrome-0.1.1/.github/workflows/cd.yml +45 -0
  2. gh_chrome-0.1.1/.github/workflows/chrome.yml +30 -0
  3. gh_chrome-0.1.1/.github/workflows/ci.yml +34 -0
  4. gh_chrome-0.1.1/.gitignore +220 -0
  5. gh_chrome-0.1.1/.promptignore +2 -0
  6. gh_chrome-0.1.1/Earthfile +49 -0
  7. gh_chrome-0.1.1/PKG-INFO +13 -0
  8. gh_chrome-0.1.1/README.md +91 -0
  9. gh_chrome-0.1.1/examples/hello.py +21 -0
  10. gh_chrome-0.1.1/examples/login_and_persist.py +44 -0
  11. gh_chrome-0.1.1/flake.nix +48 -0
  12. gh_chrome-0.1.1/pyproject.toml +90 -0
  13. gh_chrome-0.1.1/ruff.toml +17 -0
  14. gh_chrome-0.1.1/src/gh_chrome_client/__init__.py +114 -0
  15. gh_chrome-0.1.1/src/gh_chrome_client/__main__.py +24 -0
  16. gh_chrome-0.1.1/src/gh_chrome_client/command.py +55 -0
  17. gh_chrome-0.1.1/src/gh_chrome_client/errors.py +66 -0
  18. gh_chrome-0.1.1/src/gh_chrome_client/http.py +120 -0
  19. gh_chrome-0.1.1/src/gh_chrome_client/session.py +374 -0
  20. gh_chrome-0.1.1/src/gh_chrome_client/stream.py +66 -0
  21. gh_chrome-0.1.1/src/gh_chrome_protocol/__init__.py +51 -0
  22. gh_chrome-0.1.1/src/gh_chrome_protocol/commands.py +283 -0
  23. gh_chrome-0.1.1/src/gh_chrome_protocol/errors.py +20 -0
  24. gh_chrome-0.1.1/src/gh_chrome_protocol/events.py +96 -0
  25. gh_chrome-0.1.1/src/gh_chrome_protocol/session.py +76 -0
  26. gh_chrome-0.1.1/src/gh_chrome_protocol/sse.py +39 -0
  27. gh_chrome-0.1.1/src/gh_chrome_runner/__init__.py +0 -0
  28. gh_chrome-0.1.1/src/gh_chrome_runner/__main__.py +33 -0
  29. gh_chrome-0.1.1/src/gh_chrome_runner/actions.py +99 -0
  30. gh_chrome-0.1.1/src/gh_chrome_runner/browser.py +122 -0
  31. gh_chrome-0.1.1/src/gh_chrome_runner/capture.py +164 -0
  32. gh_chrome-0.1.1/src/gh_chrome_runner/cdp.py +101 -0
  33. gh_chrome-0.1.1/src/gh_chrome_runner/config.py +63 -0
  34. gh_chrome-0.1.1/src/gh_chrome_runner/display.py +89 -0
  35. gh_chrome-0.1.1/src/gh_chrome_runner/extract.py +68 -0
  36. gh_chrome-0.1.1/src/gh_chrome_runner/files.py +108 -0
  37. gh_chrome-0.1.1/src/gh_chrome_runner/http.py +109 -0
  38. gh_chrome-0.1.1/src/gh_chrome_runner/input.py +160 -0
  39. gh_chrome-0.1.1/src/gh_chrome_runner/keyboard.py +58 -0
  40. gh_chrome-0.1.1/src/gh_chrome_runner/locate.py +155 -0
  41. gh_chrome-0.1.1/src/gh_chrome_runner/loop.py +155 -0
  42. gh_chrome-0.1.1/src/gh_chrome_runner/mouse.py +78 -0
  43. gh_chrome-0.1.1/src/gh_chrome_runner/navigation.py +79 -0
  44. gh_chrome-0.1.1/src/gh_chrome_runner/profile.py +69 -0
  45. gh_chrome-0.1.1/src/gh_chrome_runner/scroll.py +38 -0
  46. gh_chrome-0.1.1/src/gh_chrome_runner/subscriptions.py +43 -0
  47. gh_chrome-0.1.1/src/gh_chrome_runner/tabs.py +236 -0
  48. gh_chrome-0.1.1/src/gh_chrome_runner/waits.py +47 -0
  49. gh_chrome-0.1.1/src/gh_chrome_runner/xtest.py +153 -0
  50. gh_chrome-0.1.1/src/gh_chrome_server/__init__.py +0 -0
  51. gh_chrome-0.1.1/src/gh_chrome_server/__main__.py +26 -0
  52. gh_chrome-0.1.1/src/gh_chrome_server/api_client.py +175 -0
  53. gh_chrome-0.1.1/src/gh_chrome_server/api_player.py +77 -0
  54. gh_chrome-0.1.1/src/gh_chrome_server/api_runner.py +199 -0
  55. gh_chrome-0.1.1/src/gh_chrome_server/app.py +44 -0
  56. gh_chrome-0.1.1/src/gh_chrome_server/auth.py +43 -0
  57. gh_chrome-0.1.1/src/gh_chrome_server/config.py +39 -0
  58. gh_chrome-0.1.1/src/gh_chrome_server/db.py +66 -0
  59. gh_chrome-0.1.1/src/gh_chrome_server/deps.py +29 -0
  60. gh_chrome-0.1.1/src/gh_chrome_server/events.py +104 -0
  61. gh_chrome-0.1.1/src/gh_chrome_server/github.py +32 -0
  62. gh_chrome-0.1.1/src/gh_chrome_server/manifest.py +90 -0
  63. gh_chrome-0.1.1/src/gh_chrome_server/migrations/001_init.sql +70 -0
  64. gh_chrome-0.1.1/src/gh_chrome_server/player/index.html +45 -0
  65. gh_chrome-0.1.1/src/gh_chrome_server/sessions.py +298 -0
  66. gh_chrome-0.1.1/src/gh_chrome_server/sse.py +73 -0
  67. gh_chrome-0.1.1/src/gh_chrome_server/storage.py +67 -0
  68. gh_chrome-0.1.1/src/gh_chrome_server/watchdog.py +49 -0
  69. gh_chrome-0.1.1/uv.lock +739 -0
@@ -0,0 +1,45 @@
1
+ name: cd
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - v*
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+
12
+ permissions:
13
+ contents: read
14
+ packages: write
15
+
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+ - uses: docker/setup-qemu-action@v3
19
+ - uses: docker/setup-buildx-action@v3
20
+ - uses: earthly/actions-setup@v1
21
+ with:
22
+ version: ^0.8.0
23
+ - uses: docker/login-action@v3
24
+ with:
25
+ registry: ghcr.io
26
+ username: ${{ github.actor }}
27
+ password: ${{ secrets.GITHUB_TOKEN }}
28
+ - run: earthly --ci --push +multi --VERSION=${GITHUB_REF_NAME#v}
29
+
30
+ pypi:
31
+ runs-on: ubuntu-latest
32
+
33
+ environment: pypi
34
+
35
+ permissions:
36
+ contents: read
37
+ id-token: write
38
+
39
+ steps:
40
+ - uses: actions/checkout@v7
41
+ - uses: astral-sh/setup-uv@v5
42
+ with:
43
+ enable-cache: true
44
+ - run: uv build --out-dir dist
45
+ - run: uv publish
@@ -0,0 +1,30 @@
1
+ name: gh-chrome
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ session_id:
7
+ description: gh-chrome session id
8
+ required: true
9
+ type: string
10
+
11
+ jobs:
12
+ run:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: awalsh128/cache-apt-pkgs-action@latest
16
+ with:
17
+ packages: xvfb openbox xdotool x11-utils zstd
18
+ version: 1.0
19
+
20
+ - uses: actions/setup-python@v6
21
+ with:
22
+ python-version: "3.13"
23
+
24
+ - run: pip install gh-chrome
25
+
26
+ - run: gh-chrome-runner --session ${{ inputs.session_id }}
27
+ env:
28
+ GH_CHROME_URL: ${{ secrets.GH_CHROME_URL }}
29
+ GH_CHROME_TOKEN: ${{ secrets.GH_CHROME_TOKEN }}
30
+ GH_CHROME_WORKDIR: ${{ runner.temp }}/gh-chrome
@@ -0,0 +1,34 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ check:
9
+ runs-on: ubuntu-latest
10
+
11
+ services:
12
+ postgres:
13
+ image: postgres:17
14
+ env:
15
+ POSTGRES_PASSWORD: postgres
16
+ POSTGRES_DB: gh_chrome
17
+ options: >-
18
+ --health-cmd pg_isready --health-interval 5s
19
+ --health-timeout 5s --health-retries 10
20
+ ports:
21
+ - 5432:5432
22
+
23
+ env:
24
+ DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/gh_chrome
25
+ GH_CHROME_SKIP_X: "1"
26
+
27
+ steps:
28
+ - uses: actions/checkout@v7
29
+ - uses: astral-sh/setup-uv@v5
30
+ with:
31
+ enable-cache: true
32
+ - run: uv sync
33
+ - run: uv run ruff check .
34
+ - run: uv run ruff format --check .
@@ -0,0 +1,220 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ temp.nu
@@ -0,0 +1,2 @@
1
+ *.lock
2
+ **/tests/**
@@ -0,0 +1,49 @@
1
+ VERSION 0.8
2
+
3
+ PROJECT wprhvso/gh-browser
4
+
5
+ ARG --global IMAGE=ghcr.io/wprhvso/gh-chrome
6
+ ARG --global PYTHON=3.13
7
+
8
+ deps:
9
+ FROM ghcr.io/astral-sh/uv:python$PYTHON-bookworm-slim
10
+ ENV UV_COMPILE_BYTECODE=1
11
+ ENV UV_LINK_MODE=copy
12
+ ENV UV_PYTHON_DOWNLOADS=never
13
+ WORKDIR /app
14
+ COPY pyproject.toml uv.lock ./
15
+ RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
16
+
17
+ build:
18
+ FROM +deps
19
+ COPY src src
20
+ RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-editable
21
+ SAVE ARTIFACT /app/.venv venv
22
+
23
+ lint:
24
+ FROM +deps
25
+ COPY . .
26
+ RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen
27
+ RUN uv run ruff check .
28
+ RUN uv run ruff format --check .
29
+
30
+ docker:
31
+ FROM python:$PYTHON-slim-bookworm
32
+ ARG TARGETARCH
33
+ ARG VERSION=dev
34
+ ENV PATH=/app/.venv/bin:$PATH
35
+ ENV PYTHONUNBUFFERED=1
36
+ ENV GH_CHROME_HOST=0.0.0.0
37
+ ENV GH_CHROME_STORAGE=/var/lib/gh-chrome
38
+ WORKDIR /app
39
+ RUN useradd --system --create-home --uid 10001 gh-chrome && mkdir -p /var/lib/gh-chrome && chown gh-chrome:gh-chrome /var/lib/gh-chrome
40
+ COPY +build/venv /app/.venv
41
+ USER gh-chrome
42
+ VOLUME /var/lib/gh-chrome
43
+ EXPOSE 8000
44
+ ENTRYPOINT ["gh-chrome-server"]
45
+ SAVE IMAGE --push $IMAGE:$VERSION $IMAGE:latest
46
+
47
+ multi:
48
+ ARG VERSION=dev
49
+ BUILD --platform=linux/amd64 --platform=linux/arm64 +docker --VERSION=$VERSION
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: gh-chrome
3
+ Version: 0.1.1
4
+ Requires-Python: >=3.13
5
+ Requires-Dist: fastapi>=0.115
6
+ Requires-Dist: httpx>=0.28
7
+ Requires-Dist: psycopg[binary,pool]>=3.2
8
+ Requires-Dist: pydantic-settings>=2.7
9
+ Requires-Dist: pydantic>=2.10
10
+ Requires-Dist: python-multipart>=0.0.20
11
+ Requires-Dist: python-xlib>=0.33
12
+ Requires-Dist: uvicorn[standard]>=0.34
13
+ Requires-Dist: websockets>=14.1
@@ -0,0 +1,91 @@
1
+ # gh-chrome
2
+
3
+ Chrome, running on a GitHub Actions runner, driven from your machine over HTTPS,
4
+ with the whole session recorded and seekable in a browser.
5
+
6
+ This is a proof of concept. It exists to show the thing is possible, not to be a
7
+ substitute for a browser you own.
8
+
9
+ ## How it works
10
+
11
+ ```
12
+ your code ──POST──▶ server (VPS) ──SSE──▶ runner (GitHub Actions)
13
+ ▲ │ postgres │ Xvfb + openbox + Chrome
14
+ └────── SSE ───────┘ segments │ XTEST input
15
+ profiles └──▶ ffmpeg ──POST──▶ server
16
+ ```
17
+
18
+ Everything is plain HTTPS: server-sent events downstream, POST upstream. No
19
+ websockets anywhere in the protocol.
20
+
21
+ Calling `new()` returns a session id immediately; the server dispatches the
22
+ workflow in the background and the runner connects back with that id. Commands
23
+ go into a strictly sequential queue and return a handle you await. Input is not
24
+ CDP — the runner moves a real cursor along a WindMouse trajectory and types
25
+ through XTEST, so the page sees trusted events.
26
+
27
+ ## Quick start
28
+
29
+ ```bash
30
+ export GH_CHROME_URL=https://chrome.example.com
31
+ export GH_CHROME_TOKEN=...
32
+ python examples/hello.py
33
+ ```
34
+
35
+ Watch the session at `https://chrome.example.com/s/<id>` (user `admin`, password
36
+ is the token).
37
+
38
+ ## Deploying the server
39
+
40
+ Set `GH_CHROME_TOKEN`, `GH_CHROME_DATABASE_URL`, `GH_CHROME_STORAGE`,
41
+ `GH_CHROME_PUBLIC_URL`, `GH_CHROME_GITHUB_REPO` and `GH_CHROME_GITHUB_PAT`
42
+ (needs the `actions:write` scope), then run `gh-chrome-server`.
43
+
44
+ In the repository that runs the workflow, add `GH_CHROME_URL` and
45
+ `GH_CHROME_TOKEN` as secrets.
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ nix develop
51
+ uv sync
52
+ uv run pytest -q
53
+ ```
54
+
55
+ Tests that need a display are skipped when `Xvfb` is missing or
56
+ `GH_CHROME_SKIP_X` is set. To watch the runner locally:
57
+
58
+ ```bash
59
+ gh-chrome-runner --session <id> --server http://127.0.0.1:8000 &
60
+ x11vnc -display :99 -nopw -forever
61
+ ```
62
+
63
+ ## What this cannot do
64
+
65
+ **Six hours per session.** That is the GitHub job limit. When it expires the
66
+ runner dies, the profile is not saved, and the profile is flagged stale.
67
+
68
+ **One token, no isolation.** Anyone holding the token can read and control any
69
+ session. There is no per-user separation by design.
70
+
71
+ **A new IP every time.** Each runner comes up in a random Azure range. Cookies
72
+ survive in the profile archive, but fraud detection keys on the address, so
73
+ Google and banks will re-verify no matter how good the saved state is. Route
74
+ outbound traffic through your own VPS (`GH_CHROME_PROXY`) if you need a session
75
+ to actually hold.
76
+
77
+ **No state after a crash.** The profile is archived once, at a clean shutdown.
78
+ A killed job loses everything since the session started.
79
+
80
+ **Latency.** DASH-LL puts the recording one to three seconds behind reality.
81
+ It is a recording you can scrub, not a remote desktop.
82
+
83
+ ## Terms
84
+
85
+ GitHub Actions is meant for CI. This project runs a browser there, which is
86
+ outside what the Actions terms contemplate — it is a demonstration, and running
87
+ it at scale, or to dodge compute costs, is not something the terms allow.
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import gh_chrome_client
6
+
7
+
8
+ async def main() -> None:
9
+ session = await gh_chrome_client.new(width=1280, height=800, fps=10)
10
+ print(f"session {session.id}, player at {session.player_url}")
11
+ async with session as s:
12
+ await s.ready(timeout=300)
13
+ await s.goto("https://example.com")
14
+ print(await s.title())
15
+ await s.click("a")
16
+ await s.wait_for_load()
17
+ print(await s.url())
18
+
19
+
20
+ if __name__ == "__main__":
21
+ asyncio.run(main())
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ import gh_chrome_client
7
+ from gh_chrome_client import Topic
8
+
9
+ USERNAME = os.environ["DEMO_USERNAME"]
10
+ PASSWORD = os.environ["DEMO_PASSWORD"]
11
+
12
+
13
+ async def main() -> None:
14
+ session = await gh_chrome_client.new(
15
+ profile="demo",
16
+ subscribe=[Topic.TABS, Topic.DOWNLOADS],
17
+ timeout=45.0,
18
+ )
19
+ async with session as s:
20
+ await s.ready(timeout=300)
21
+ if s.state_stale:
22
+ print("warning: the previous runner died, the saved state may be outdated")
23
+
24
+ await s.goto("https://github.com/login")
25
+
26
+ if "login" not in await s.url():
27
+ print("already signed in, the profile survived")
28
+ return
29
+
30
+ await s.type("#login_field", USERNAME)
31
+ await s.type("#password", PASSWORD)
32
+ await s.click('input[name="commit"]')
33
+
34
+ try:
35
+ await s.wait_for_url(r"github\.com/?$", timeout=60)
36
+ except gh_chrome_client.CommandTimeout:
37
+ print("two-factor or a captcha is in the way, watch the recording")
38
+ return
39
+
40
+ print("signed in as", await s.text(".AppHeader-user"))
41
+
42
+
43
+ if __name__ == "__main__":
44
+ asyncio.run(main())
@@ -0,0 +1,48 @@
1
+ {
2
+ description = "gh-chrome";
3
+
4
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
5
+
6
+ outputs = { self, nixpkgs }:
7
+ let
8
+ system = "x86_64-linux";
9
+ pkgs = nixpkgs.legacyPackages.${system};
10
+ libs = with pkgs; [
11
+ stdenv.cc.cc.lib
12
+ zlib
13
+ xorg.libX11
14
+ xorg.libXtst
15
+ xorg.libXi
16
+ xorg.libXext
17
+ ];
18
+ in
19
+ {
20
+ devShells.${system}.default = pkgs.mkShell {
21
+ packages = with pkgs; [
22
+ python313
23
+ uv
24
+ postgresql_17
25
+ ffmpeg-full
26
+ xorg.xorgserver
27
+ xorg.xdpyinfo
28
+ xdotool
29
+ openbox
30
+ x11vnc
31
+ chromium
32
+ zstd
33
+ ];
34
+
35
+ env = {
36
+ LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath libs;
37
+ UV_PYTHON_DOWNLOADS = "never";
38
+ GH_CHROME_CHROME_BINARY = "${pkgs.chromium}/bin/chromium";
39
+ };
40
+
41
+ shellHook = ''
42
+ export PGDATA="$PWD/.pgdata"
43
+ export PGHOST="$PWD/.pgsock"
44
+ export DATABASE_URL="postgresql:///gh_chrome?host=$PGHOST"
45
+ '';
46
+ };
47
+ };
48
+ }
@@ -0,0 +1,90 @@
1
+ [project]
2
+ name = "gh-chrome"
3
+ version = "0.1.1"
4
+ requires-python = ">=3.13"
5
+ dependencies = [
6
+ "fastapi>=0.115",
7
+ "httpx>=0.28",
8
+ "psycopg[binary,pool]>=3.2",
9
+ "pydantic>=2.10",
10
+ "pydantic-settings>=2.7",
11
+ "python-multipart>=0.0.20",
12
+ "python-xlib>=0.33",
13
+ "uvicorn[standard]>=0.34",
14
+ "websockets>=14.1",
15
+ ]
16
+
17
+ [project.scripts]
18
+ gh-chrome = "gh_chrome_client.__main__:main"
19
+ gh-chrome-server = "gh_chrome_server.__main__:main"
20
+ gh-chrome-runner = "gh_chrome_runner.__main__:main"
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = [
28
+ "src/gh_chrome_client",
29
+ "src/gh_chrome_protocol",
30
+ "src/gh_chrome_runner",
31
+ "src/gh_chrome_server",
32
+ ]
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8.3", "pytest-asyncio>=0.25", "ruff>=0.9"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py313"
40
+
41
+ [tool.ruff.lint]
42
+ select = [
43
+ "A",
44
+ "ARG",
45
+ "ASYNC",
46
+ "B",
47
+ "C4",
48
+ "E",
49
+ "ERA",
50
+ "F",
51
+ "I",
52
+ "N",
53
+ "PL",
54
+ "PTH",
55
+ "RET",
56
+ "RUF",
57
+ "S",
58
+ "SIM",
59
+ "UP",
60
+ "W",
61
+ ]
62
+ ignore = ["PLR0913", "S101"]
63
+
64
+ [tool.ruff.lint.isort]
65
+ known-first-party = [
66
+ "gh_chrome_client",
67
+ "gh_chrome_protocol",
68
+ "gh_chrome_runner",
69
+ "gh_chrome_server",
70
+ ]
71
+
72
+ [tool.pytest.ini_options]
73
+ asyncio_mode = "auto"
74
+ testpaths = ["tests"]
75
+
76
+ [tool.basedpyright]
77
+ pythonVersion = "3.13"
78
+ typeCheckingMode = "strict"
79
+ reportMissingTypeStubs = false
80
+ reportImportCycles = false
81
+ reportAny = false
82
+ reportExplicitAny = false
83
+ reportUnusedCallResult = false
84
+ reportUnannotatedClassAttribute = false
85
+ reportImplicitStringConcatenation = false
86
+ reportPrivateUsage = false
87
+ reportUnknownMemberType = false
88
+ reportUnknownVariableType = false
89
+ reportUnknownArgumentType = false
90
+ executionEnvironments = [{ root = "src", extraPaths = ["src"] }]