├── .gitattributes ├── .github └── workflows │ ├── publish-deno.yml │ ├── publish-python.yml │ ├── release-binary.yml │ ├── rust-binary.yml │ └── verify.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.shared.json ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── deno.lock ├── json-schema.d.ts ├── mise.toml ├── mise.windows.toml ├── renovate.json ├── schemas ├── WebViewMessage.json ├── WebViewOptions.json ├── WebViewRequest.json └── WebViewResponse.json ├── scripts ├── generate-schema │ ├── debug.ts │ ├── gen-helpers.ts │ ├── gen-python.test.ts │ ├── gen-python.ts │ ├── gen-typescript.ts │ ├── index.ts │ ├── parser.test.ts │ ├── parser.ts │ └── printer.ts └── sync-versions.ts ├── sg └── rules │ ├── .gitkeep │ └── no-mixed-enums.yml ├── sgconfig.yml └── src ├── bin ├── generate_schemas.rs └── webview.rs ├── clients ├── deno │ ├── README.md │ ├── deno.json │ ├── deno.lock │ ├── examples │ │ ├── ipc.ts │ │ ├── load-html.ts │ │ ├── load-url.ts │ │ ├── simple.ts │ │ ├── tldraw.ts │ │ └── window-size.ts │ ├── main.ts │ └── schemas.ts └── python │ ├── .python-version │ ├── README.md │ ├── examples │ ├── ipc.py │ ├── load_html.py │ ├── load_url.py │ ├── simple.py │ └── window_size.py │ ├── pyproject.toml │ ├── src │ └── justbe_webview │ │ ├── __init__.py │ │ ├── py.typed │ │ └── schemas.py │ └── uv.lock └── lib.rs /.gitattributes: -------------------------------------------------------------------------------- 1 | schemas/** linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/workflows/publish-deno.yml: -------------------------------------------------------------------------------- 1 | name: Publish Deno Client 2 | on: 3 | workflow_run: 4 | workflows: ["Verify", "Release Rust Binary"] 5 | types: 6 | - completed 7 | branches: [main] 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | defaults: 13 | run: 14 | working-directory: src/clients/deno 15 | 16 | permissions: 17 | contents: write 18 | id-token: write 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Get current version 24 | id: current_version 25 | run: echo "version=$(grep '"version"' deno.json | awk '{ print $2 }' | sed 's/[",]//g')" >> $GITHUB_OUTPUT 26 | 27 | - name: Get published version 28 | id: published_version 29 | run: echo "version=$(npx jsr show @justbe/webview | head -n 2 | npx -q strip-ansi-cli | xargs | awk '{print $4}')" >> $GITHUB_OUTPUT 30 | 31 | - name: Publish package 32 | if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version }} 33 | run: npx jsr publish 34 | env: 35 | LATEST_VERSION: ${{ env.latest_version }} 36 | 37 | - name: Get latest version 38 | if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version }} 39 | id: latest_version 40 | run: echo "version=$(npx jsr show @justbe/webview | head -n 2 | npx -q strip-ansi-cli | xargs | awk '{print $4}')" >> $GITHUB_OUTPUT 41 | 42 | - name: Tag and push if versions differ 43 | if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version && steps.latest_version.outputs.version != steps.published_version.outputs.version }} 44 | run: | 45 | git config user.name github-actions 46 | git config user.email github-actions@github.com 47 | git tag -a deno-v${{ steps.latest_version.outputs.version }} -m "Release ${{ steps.latest_version.outputs.version }}" 48 | git push origin deno-v${{ steps.latest_version.outputs.version }} 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /.github/workflows/publish-python.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Client 2 | on: 3 | workflow_run: 4 | workflows: ["Verify", "Release Rust Binary"] 5 | types: 6 | - completed 7 | branches: [main] 8 | pull_request: 9 | paths: 10 | - 'src/clients/python/**' 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | # Only run if the workflow_run was successful (for the main branch case) 16 | if: github.event_name == 'pull_request' || github.event.workflow_run.conclusion == 'success' 17 | 18 | defaults: 19 | run: 20 | working-directory: src/clients/python 21 | 22 | permissions: 23 | contents: write 24 | id-token: write 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Install mise 30 | uses: jdx/mise-action@v2 31 | 32 | - name: Install tools 33 | run: mise install 34 | 35 | - name: Get current version 36 | id: current_version 37 | run: echo "version=$(grep '^version = ' pyproject.toml | cut -d'"' -f2)" >> $GITHUB_OUTPUT 38 | 39 | - name: Get published version 40 | id: published_version 41 | run: | 42 | if ! version=$(curl -sf https://pypi.org/pypi/justbe-webview/json | jq -r '.info.version // "0.0.0"'); then 43 | echo "Failed to fetch version from PyPI, using 0.0.0" 44 | version="0.0.0" 45 | fi 46 | echo "version=$version" >> $GITHUB_OUTPUT 47 | 48 | - name: Build package 49 | if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version || github.event_name == 'pull_request' }} 50 | run: uv build 51 | 52 | - name: Dry run publish to PyPI 53 | if: ${{ github.event_name == 'pull_request' }} 54 | run: | 55 | echo "Would publish version ${{ steps.current_version.outputs.version }} to PyPI" 56 | echo "Current published version: ${{ steps.published_version.outputs.version }}" 57 | echo "Package contents:" 58 | ls -l dist/ 59 | echo "Archive contents:" 60 | tar tzf dist/*.tar.gz | sort 61 | 62 | - name: Publish to PyPI 63 | if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version && github.event_name == 'workflow_run' }} 64 | uses: pypa/gh-action-pypi-publish@release/v1 65 | with: 66 | packages-dir: src/clients/python/dist/ 67 | verbose: true 68 | 69 | - name: Tag and push if versions differ 70 | if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version && github.event_name == 'workflow_run' }} 71 | run: | 72 | # Ensure the tag doesn't already exist 73 | if ! git rev-parse "python-v${{ steps.current_version.outputs.version }}" >/dev/null 2>&1; then 74 | git config user.name github-actions 75 | git config user.email github-actions@github.com 76 | git tag -a python-v${{ steps.current_version.outputs.version }} -m "Release ${{ steps.current_version.outputs.version }}" 77 | git push origin python-v${{ steps.current_version.outputs.version }} 78 | else 79 | echo "Tag python-v${{ steps.current_version.outputs.version }} already exists, skipping tag creation" 80 | fi 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release-binary.yml: -------------------------------------------------------------------------------- 1 | name: Release Rust Binary 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Rust Binary"] 6 | types: 7 | - completed 8 | branches: [main] 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 15 | permissions: 16 | contents: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Download all artifacts 21 | uses: actions/download-artifact@v4 22 | with: 23 | github-token: ${{secrets.GITHUB_TOKEN}} 24 | run-id: ${{github.event.workflow_run.id}} 25 | 26 | - name: Check for artifacts 27 | id: check_artifacts 28 | run: | 29 | if [ -z "$(find . -name 'webview*')" ]; then 30 | echo "No artifacts found. Exiting successfully." 31 | echo "artifacts_found=false" >> $GITHUB_OUTPUT 32 | else 33 | echo "Artifacts found. Proceeding with release." 34 | echo "artifacts_found=true" >> $GITHUB_OUTPUT 35 | fi 36 | 37 | - name: Get version from Cargo.toml 38 | if: steps.check_artifacts.outputs.artifacts_found == 'true' 39 | id: get_version 40 | run: | 41 | VERSION=$(grep '^version =' Cargo.toml | sed 's/.*= *"//' | sed 's/".*//') 42 | echo "version=$VERSION" >> $GITHUB_OUTPUT 43 | 44 | - name: Create Release 45 | if: steps.check_artifacts.outputs.artifacts_found == 'true' 46 | uses: ncipollo/release-action@v1 47 | with: 48 | tag: webview-v${{ steps.get_version.outputs.version }} 49 | name: Release ${{ steps.get_version.outputs.version }} 50 | token: ${{ secrets.GITHUB_TOKEN }} 51 | prerelease: true 52 | skipIfReleaseExists: true 53 | artifacts: "release-binary-*/webview*" 54 | artifactErrorsFailBuild: true 55 | -------------------------------------------------------------------------------- /.github/workflows/rust-binary.yml: -------------------------------------------------------------------------------- 1 | name: Rust Binary 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/**/*rs' 7 | - 'Cargo.toml' 8 | - 'Cargo.lock' 9 | 10 | jobs: 11 | build: 12 | name: Build on ${{ matrix.os }} for ${{ matrix.target }} 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: ${{ github.ref == 'refs/heads/main' }} 16 | matrix: 17 | include: 18 | - os: ubuntu-latest 19 | target: x86_64-unknown-linux-gnu 20 | binary_name: webview-linux 21 | platform: linux 22 | - os: ubuntu-latest 23 | target: x86_64-pc-windows-msvc 24 | binary_name: webview-windows 25 | platform: windows 26 | - os: macos-latest 27 | target: x86_64-apple-darwin 28 | binary_name: webview-mac 29 | platform: macos 30 | - os: macos-latest 31 | target: aarch64-apple-darwin 32 | binary_name: webview-mac-arm64 33 | platform: macos 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - uses: jdx/mise-action@v2 38 | env: 39 | RUSTUP_TARGET: ${{ matrix.target }} 40 | MISE_ENV: ${{ matrix.platform }} 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | with: 43 | cache_key_prefix: mise-${{ hashFiles('mise.toml') }}-${{ matrix.target }} 44 | experimental: true 45 | 46 | - name: Setup Rust cache 47 | uses: Swatinem/rust-cache@v2 48 | with: 49 | cache-on-failure: true 50 | shared-key: "binary-${{ matrix.target }}" 51 | workspaces: "." 52 | cache-directories: | 53 | ~/.cargo/registry/index 54 | ~/.cargo/registry/cache 55 | ~/.cargo/git/db 56 | 57 | - run: mise x -- rustup target add ${{ matrix.target }} 58 | - run: mise run ci:install-deps 59 | 60 | 61 | - name: Set build flags 62 | id: build_flags 63 | run: | 64 | if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then 65 | echo "flags=--release" >> $GITHUB_OUTPUT 66 | echo "build_type=release" >> $GITHUB_OUTPUT 67 | else 68 | echo "flags=" >> $GITHUB_OUTPUT 69 | echo "build_type=debug" >> $GITHUB_OUTPUT 70 | fi 71 | 72 | - name: Build Linux 73 | if: matrix.target == 'x86_64-unknown-linux-gnu' 74 | run: | 75 | mise run build:rust -F transparent ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 76 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview ${{ matrix.binary_name }} 77 | 78 | mise run build:rust -F 'transparent devtools' ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 79 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview ${{ matrix.binary_name }}-devtools 80 | 81 | - name: Build macOS x86_64 82 | if: matrix.target == 'x86_64-apple-darwin' 83 | run: | 84 | mise run build:rust ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 85 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview ${{ matrix.binary_name }} 86 | 87 | mise run build:rust -F transparent ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 88 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview ${{ matrix.binary_name }}-transparent 89 | 90 | mise run build:rust -F 'transparent devtools' ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 91 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview ${{ matrix.binary_name }}-devtools 92 | 93 | - name: Build (aarch64-apple-darwin) 94 | if: matrix.target == 'aarch64-apple-darwin' 95 | run: | 96 | SDKROOT=$(xcrun -sdk macosx --show-sdk-path) MACOSX_DEPLOYMENT_TARGET=$(xcrun -sdk macosx --show-sdk-platform-version) mise run build:rust ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 97 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview ${{ matrix.binary_name }} 98 | 99 | SDKROOT=$(xcrun -sdk macosx --show-sdk-path) MACOSX_DEPLOYMENT_TARGET=$(xcrun -sdk macosx --show-sdk-platform-version) mise run build:rust -F transparent ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 100 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview ${{ matrix.binary_name }}-transparent 101 | 102 | SDKROOT=$(xcrun -sdk macosx --show-sdk-path) MACOSX_DEPLOYMENT_TARGET=$(xcrun -sdk macosx --show-sdk-platform-version) mise run build:rust -F 'transparent devtools' ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 103 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview ${{ matrix.binary_name }}-devtools 104 | 105 | - name: Build (Windows) 106 | if: matrix.target == 'x86_64-pc-windows-msvc' 107 | env: 108 | MISE_ENV: windows 109 | run: | 110 | mise run build:rust -F transparent ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 111 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview.exe ${{ matrix.binary_name }}.exe 112 | 113 | mise run build:rust -F 'transparent devtools' ${{ steps.build_flags.outputs.flags }} --target ${{ matrix.target }} 114 | mv target/${{ matrix.target }}/${{ steps.build_flags.outputs.build_type }}/webview.exe ${{ matrix.binary_name }}-devtools.exe 115 | 116 | - name: Upload artifact 117 | uses: actions/upload-artifact@v4 118 | with: 119 | name: ${{ steps.build_flags.outputs.build_type }}-binary-${{ matrix.target }} 120 | path: ${{ matrix.binary_name }}* 121 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | on: 3 | push: 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - uses: jdx/mise-action@v2 12 | with: 13 | cache_key_prefix: mise-{{hashFiles('mise.toml')}} 14 | experimental: true 15 | 16 | - name: Setup Rust cache 17 | uses: Swatinem/rust-cache@v2 18 | with: 19 | cache-on-failure: true 20 | shared-key: "lint" 21 | 22 | - name: Lint 23 | run: mise lint 24 | 25 | publishable: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: jdx/mise-action@v2 31 | with: 32 | experimental: true 33 | 34 | - name: Passes publish checks 35 | run: mise run verify-publish:* 36 | 37 | codegen-up-to-date: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - uses: jdx/mise-action@v2 43 | with: 44 | cache_key_prefix: mise-{{hashFiles('mise.toml')}} 45 | experimental: true 46 | 47 | - name: Setup Rust cache 48 | uses: Swatinem/rust-cache@v2 49 | with: 50 | cache-on-failure: true 51 | shared-key: "codegen" 52 | 53 | - name: Run codegen 54 | run: mise run gen 55 | 56 | - name: Check for changed files 57 | run: | 58 | if [[ -n $(git status --porcelain) ]]; then 59 | echo "Files have changed after running tests:" 60 | git status --porcelain 61 | git diff 62 | exit 1 63 | fi 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | _test* 3 | .DS_Store 4 | __pycache__ 5 | node_modules 6 | 7 | .vscode/* 8 | !.vscode/*.shared.json 9 | !.vscode/extensions.json 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "hverlin.mise-vscode", 8 | "denoland.vscode-deno", 9 | "charliermarsh.ruff", 10 | "Swellaby.workspace-config-plus" 11 | ], 12 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 13 | "unwantedRecommendations": [ 14 | 15 | ] 16 | } -------------------------------------------------------------------------------- /.vscode/settings.shared.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.semanticHighlighting.enabled": true, 3 | "[typescript]": { 4 | "editor.defaultFormatter": "denoland.vscode-deno" 5 | }, 6 | "[json]": { 7 | "editor.defaultFormatter": "denoland.vscode-deno" 8 | }, 9 | "deno.enablePaths": [ 10 | "scripts", 11 | "src/clients/deno" 12 | ], 13 | "python.analysis.typeCheckingMode": "strict", 14 | "python.analysis.extraPaths": [ 15 | "${workspaceFolder}/src/clients/python/.venv/lib/python3.*/site-packages" 16 | ], 17 | "mise.configureExtensionsAutomatically": true 18 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.0.4 Python Client; 1.0.1-rc.2 Deno Client; 0.3.1 binary -- 2025-03-05 4 | 5 | - Fixed a bug that caused webview to fail to open on linux 6 | - Upgraded various native dependencies 7 | 8 | ## 0.0.3 Python Client -- 2025-03-01 9 | 10 | - Fixed a bug where the windows binary path was still incorrect (thanks @daidalvi) 11 | 12 | ## 0.0.2 Python Client; 1.0.1-rc.1 Deno Client; 0.3.0 binary -- 2025-02-23 13 | 14 | Binary 15 | 16 | - Updated wry to 0.49.0 17 | - Updated tao to 0.32.0 18 | 19 | Python Client 20 | 21 | - Updated webview binary to 0.3.0 22 | - Fixed readme 23 | - Fixed binary path being wrong 24 | - Fixed windows binary name being incorrectly constructed 25 | - Fixed an issue where events weren't being acknowledged 26 | 27 | Deno Client 28 | 29 | - Updated webview to 0.3.0 30 | 31 | ## 0.0.1 Python Client; 0.2.0 binary -- 2025-02-18 32 | 33 | - Initial release of the python client 34 | 35 | ## 1.0.0-rc.1 Deno Client; 0.2.0 binary -- 2025-02-17 36 | 37 | - Added new logging that can be triggered with the `LOG_LEVEL` environment variable 38 | 39 | - [BREAKING] Changed some typenames/zod schemas not to include `Webview` in the name. 40 | - [BREAKING] Updated code generation to support multiple clients which necessitated a breaking change for the Deno client. 41 | 42 | ```diff 43 | using webview = await createWebView({ 44 | title: "Simple", 45 | devtools: true, 46 | + load: { 47 | html: "

Hello, World!

", 48 | + }, 49 | initializationScript: 50 | "console.log('This is printed from initializationScript!')", 51 | }); 52 | ``` 53 | `html` or `url` must be wrapped in an object and passed to `load`. 54 | 55 | ## 0.0.17 (binary 0.1.14) -- 2024-10-02 56 | 57 | - Add `webview.loadUrl` to load a new URL after the webview is initialized 58 | - Add the ability to specify headers when instantiating a webview with a URL 59 | - Add `userAgent` to `WebViewOptions` to construct a new webview with a custom user agent. 60 | 61 | ## 0.0.16 (binary 0.1.13) -- 2024-09-29 62 | 63 | - Add `initializationScript` to `WebViewOptions`. Allows providing JS that runs before `onLoad`. 64 | 65 | ## 0.0.15 (binary 0.1.12) -- 2024-09-28 66 | 67 | - Pages loaded with `html` are now considered to be in a secure context. 68 | - When creating a webview with `html` or calling `webview.loadHtml()` the webview now has a default origin which can be changed via the `origin` parameter 69 | - Improved type generation to output more doc strings and documented more code 70 | - Update TLDraw example with a persistence key 71 | 72 | ## 0.0.14 (binary 0.1.11) -- 2024-09-26 73 | 74 | - fix an issue where arm64 macs weren't downloading the correct binary 75 | 76 | ## 0.0.13 (binary 0.1.11) -- 2024-09-26 77 | 78 | - added `webview.loadHtml(...)` 79 | 80 | ## 0.0.12 (binary 0.1.10) -- 2024-09-26 81 | 82 | BREAKING CHANGES 83 | 84 | - `WebViewOptions` `accept_first_mouse` is now `acceptFirstMouse` 85 | - `WebViewOptions` `fullscreen` was removed in favor of `size` 86 | 87 | Additions 88 | 89 | - The webview size can be altered by providing `WebViewOptions` `size` as either `"maximized"`, `"fullscreen"`, or `{ width: number, height: number }` 90 | - added `webview.maximize()` 91 | - added `webview.minimize()` 92 | - added `webview.fullscreen()` 93 | - added `webview.getSize()` 94 | - added `webview.setSize({ ... })` 95 | 96 | Fixes 97 | 98 | - `webview.on` and `webivew.once` had their types improved to actually return the result of their event payload 99 | 100 | Misc 101 | 102 | - Tao updated to v0.30.2 103 | - Wry upgraded to v0.45.0 104 | 105 | ## 0.0.11 (binary 0.1.9) -- 2024-09-23 106 | 107 | - Adds more doc comments 108 | 109 | ## 0.0.10 (binary 0.1.9) -- 2024-09-23 110 | 111 | - Adds an `ipc` flag to enable sending messages from the webview back to the host deno process. 112 | - Adds an IPC example 113 | - Updates notifications to pass message bodies through 114 | 115 | ## 0.0.9 (binary 0.1.8) -- 2024-09-23 116 | 117 | - Adds a `getVersion` method to `Webview` that returns the binary version. 118 | - Adds a check on startup that compares the expected version to the current version. 119 | - Adds slightly more graceful error handling to deserialization errors in the deno code. 120 | 121 | ## 0.0.8 (binary 0.1.7) -- 2024-09-23 122 | 123 | NOTE: The binary version was bumped this release, but it doesn't actually have changes. 124 | This is just me forcing a new release to be published. 125 | 126 | - Fixed the release URL to ensure the download comes from the correct place 127 | 128 | ## 0.0.7 (binary 0.1.6) -- 2024-09-23 129 | 130 | - Added this changelog 131 | - Add the ability to show and hide the webview window via `.setVisibility(true/false)` 132 | - Added the ability to check if the webview window is visible via `.isVisible()` 133 | 134 | ## 0.0.6 (binary 0.1.5) -- 2024-09-23 135 | 136 | - Fixed a bug where `.on` and `.once` weren't firing for the webview's lifecycle events 137 | 138 | ## 0.0.5 (binary 0.1.5) -- 2024-09-23 139 | 140 | - Improved type generation to avoid publishing slow types to JSR 141 | 142 | ## 0.0.4 (binary 0.1.5) -- 2024-09-22 143 | 144 | - Added examples, doc comments to code 145 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "webview" 3 | version = "0.3.0" 4 | edition = "2021" 5 | 6 | [profile.release] 7 | strip = true 8 | lto = true 9 | opt-level = "z" 10 | codegen-units = 1 11 | 12 | [dependencies] 13 | serde = { version = "1", features = ["derive"] } 14 | serde_json = "1" 15 | tao = "0.32.0" 16 | wry = "0.51.0" 17 | schemars = "0.8.21" 18 | parking_lot = "0.12" 19 | actson = "2.0.0" 20 | tracing = "0.1" 21 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 22 | 23 | [features] 24 | transparent = ["wry/transparent"] 25 | devtools = ["wry/devtools"] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Justin Bennett 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @justbe/webview 2 | 3 | A light, cross-platform library for building web-based desktop apps. The project consists of a Rust backend that provides the core webview functionality, with multiple client libraries available for different languages and runtimes. 4 | 5 | ## Available Clients 6 | 7 | - [Deno Client](src/clients/deno/README.md) - Build desktop apps using Deno and TypeScript 8 | - [Python Client](src/clients/python/README.md) - Build desktop apps using Python 9 | 10 | ## Architecture 11 | 12 | This project is structured into two main components: 13 | 14 | 1. A Rust backend that provides the core webview functionality, compiled into a native binary for each platform 15 | 2. Client libraries that interface with the binary, available for multiple languages 16 | 17 | Each client library handles binary management, communication with the webview process over stdio (standard input/output), and provides a idiomatic API for its respective language/runtime. 18 | 19 | ## Binary Management 20 | 21 | When using any of the clients, they will check for the required binary for interfacing with the OS's webview. If it doesn't exist, it downloads it to a cache directory and executes it. The specific behavior and permissions required may vary by client - please see the respective client's documentation for details. 22 | 23 | ### Using a Custom Binary 24 | 25 | All clients support using a custom binary via the `WEBVIEW_BIN` environment variable. If present and allowed, this will override the default binary resolution process in favor of the path specified. 26 | 27 | ## Examples 28 | 29 |
30 | Deno Example 31 | 32 | ```typescript 33 | import { createWebView } from "jsr:@justbe/webview"; 34 | 35 | using webview = await createWebView({ 36 | title: "Example", 37 | html: "

Hello, World!

", 38 | devtools: true 39 | }); 40 | 41 | webview.on("started", async () => { 42 | await webview.openDevTools(); 43 | await webview.eval("console.log('This is printed from eval!')"); 44 | }); 45 | 46 | await webview.waitUntilClosed(); 47 | ``` 48 | 49 |
50 | 51 |
52 | Python Example 53 | 54 | ```python 55 | import asyncio 56 | from justbe_webview import WebView, WebViewOptions, WebViewContentHtml, WebViewNotification 57 | 58 | async def main(): 59 | config = WebViewOptions( 60 | title="Example", 61 | load=WebViewContentHtml(html="

Hello, World!

"), 62 | devtools=True 63 | ) 64 | 65 | async with WebView(config) as webview: 66 | async def handle_start(event: WebViewNotification): 67 | await webview.open_devtools() 68 | await webview.eval("console.log('This is printed from eval!')") 69 | 70 | webview.on("started", handle_start) 71 | 72 | if __name__ == "__main__": 73 | asyncio.run(main()) 74 | ``` 75 | 76 |
77 | 78 | ## Contributing 79 | 80 | This project uses [mise](https://mise.jdx.dev/) to manage runtimes (like deno, python, rust) and run scripts. If you'd like to contribute, you'll need to install it. 81 | 82 | Use the `mise tasks` command to see what you can do. -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4", 3 | "specifiers": { 4 | "jsr:@std/fs@^1.0.3": "1.0.6", 5 | "jsr:@std/path@^1.0.6": "1.0.8", 6 | "jsr:@std/path@^1.0.8": "1.0.8", 7 | "jsr:@std/ulid@1": "1.0.0", 8 | "npm:ts-pattern@*": "5.6.0", 9 | "npm:zod@^3.23.8": "3.23.8" 10 | }, 11 | "jsr": { 12 | "@std/fs@1.0.6": { 13 | "integrity": "42b56e1e41b75583a21d5a37f6a6a27de9f510bcd36c0c85791d685ca0b85fa2", 14 | "dependencies": [ 15 | "jsr:@std/path@^1.0.8" 16 | ] 17 | }, 18 | "@std/path@1.0.8": { 19 | "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" 20 | }, 21 | "@std/ulid@1.0.0": { 22 | "integrity": "d41c3d27a907714413649fee864b7cde8d42ee68437d22b79d5de4f81d808780" 23 | } 24 | }, 25 | "npm": { 26 | "ts-pattern@5.6.0": { 27 | "integrity": "sha512-SL8u60X5+LoEy9tmQHWCdPc2hhb2pKI6I1tU5Jue3v8+iRqZdcT3mWPwKKJy1fMfky6uha82c8ByHAE8PMhKHw==" 28 | }, 29 | "zod@3.23.8": { 30 | "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" 31 | } 32 | }, 33 | "remote": { 34 | "https://deno.land/std@0.190.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", 35 | "https://deno.land/std@0.190.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", 36 | "https://deno.land/std@0.190.0/fs/_util.ts": "579038bebc3bd35c43a6a7766f7d91fbacdf44bc03468e9d3134297bb99ed4f9", 37 | "https://deno.land/std@0.190.0/fs/walk.ts": "920be35a7376db6c0b5b1caf1486fb962925e38c9825f90367f8f26b5e5d0897", 38 | "https://deno.land/std@0.190.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", 39 | "https://deno.land/std@0.190.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", 40 | "https://deno.land/std@0.190.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", 41 | "https://deno.land/std@0.190.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", 42 | "https://deno.land/std@0.190.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", 43 | "https://deno.land/std@0.190.0/path/mod.ts": "ee161baec5ded6510ee1d1fb6a75a0f5e4b41f3f3301c92c716ecbdf7dae910d", 44 | "https://deno.land/std@0.190.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", 45 | "https://deno.land/std@0.190.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", 46 | "https://deno.land/std@0.190.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba" 47 | }, 48 | "workspace": { 49 | "dependencies": [ 50 | "jsr:@std/fs@^1.0.3", 51 | "jsr:@std/path@^1.0.6", 52 | "jsr:@std/ulid@1", 53 | "npm:type-fest@^4.26.1", 54 | "npm:zod@^3.23.8" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /json-schema.d.ts: -------------------------------------------------------------------------------- 1 | export type JSONSchemaTypeName = 2 | | "string" 3 | | "number" 4 | | "integer" 5 | | "boolean" 6 | | "object" 7 | | "array" 8 | | "null"; 9 | 10 | /** 11 | * Primitive type 12 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1.1 13 | */ 14 | export type JSONSchemaType = 15 | | string // 16 | | number 17 | | boolean 18 | | { [key: string]: JSONSchemaType } 19 | | JSONSchemaType[] 20 | | null; 21 | 22 | /** 23 | * JSON Schema v7 24 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01 25 | */ 26 | export interface JSONSchema { 27 | $id?: string; 28 | $ref?: string; 29 | $schema?: string; 30 | $comment?: string; 31 | 32 | /** 33 | * @see https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-8.2.4 34 | * @see https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#appendix-A 35 | */ 36 | $defs?: { 37 | [key: string]: JSONSchema; 38 | }; 39 | 40 | /** 41 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1 42 | */ 43 | type?: JSONSchemaTypeName | JSONSchemaTypeName[]; 44 | enum?: JSONSchemaType[]; 45 | const?: JSONSchemaType; 46 | 47 | /** 48 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.2 49 | */ 50 | multipleOf?: number; 51 | maximum?: number; 52 | exclusiveMaximum?: number; 53 | minimum?: number; 54 | exclusiveMinimum?: number; 55 | 56 | /** 57 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.3 58 | */ 59 | maxLength?: number; 60 | minLength?: number; 61 | pattern?: string; 62 | 63 | /** 64 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.4 65 | */ 66 | items?: JSONSchema | JSONSchema[]; 67 | additionalItems?: JSONSchema; 68 | maxItems?: number; 69 | minItems?: number; 70 | uniqueItems?: boolean; 71 | contains?: JSONSchema; 72 | 73 | /** 74 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.5 75 | */ 76 | maxProperties?: number; 77 | minProperties?: number; 78 | required?: string[]; 79 | properties?: { 80 | [key: string]: JSONSchema; 81 | }; 82 | patternProperties?: { 83 | [key: string]: JSONSchema; 84 | }; 85 | additionalProperties?: JSONSchema; 86 | dependencies?: { 87 | [key: string]: JSONSchema | string[]; 88 | }; 89 | propertyNames?: JSONSchema | undefined; 90 | 91 | /** 92 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.6 93 | */ 94 | if?: JSONSchema; 95 | then?: JSONSchema; 96 | else?: JSONSchema; 97 | 98 | /** 99 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.7 100 | */ 101 | allOf?: JSONSchema[]; 102 | anyOf?: JSONSchema[]; 103 | oneOf?: JSONSchema[]; 104 | not?: JSONSchema; 105 | 106 | /** 107 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7 108 | */ 109 | format?: string; 110 | 111 | /** 112 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-8 113 | */ 114 | contentMediaType?: string; 115 | contentEncoding?: string; 116 | 117 | /** 118 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-9 119 | */ 120 | definitions?: { 121 | [key: string]: JSONSchema; 122 | }; 123 | 124 | /** 125 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-10 126 | */ 127 | title?: string | undefined; 128 | description?: string | undefined; 129 | default?: JSONSchemaType | undefined; 130 | readOnly?: boolean | undefined; 131 | writeOnly?: boolean | undefined; 132 | examples?: JSONSchemaType | undefined; 133 | } 134 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | deno = "2.2.1" 3 | "npm:@ast-grep/cli" = "0.38.3" 4 | rust = { version = "1.78.0", postinstall = "rustup component add rustfmt clippy rust-analyzer" } 5 | ruff = "0.9.1" 6 | uv = "0.6.2" 7 | 8 | [settings] 9 | experimental = true 10 | pin = true 11 | 12 | [tasks."ci:install-deps"] 13 | hide = true 14 | description = "Install CI dependencies (only runs on CI)" 15 | run = """ 16 | {% if env.CI and os() == "linux" %} 17 | sudo apt-get update 18 | sudo apt-get install -y libwebkit2gtk-4.1-dev 19 | {% endif %} 20 | """ 21 | 22 | [tasks.sync-versions] 23 | description = "Update all version references" 24 | run = "deno run -A scripts/sync-versions.ts" 25 | 26 | ## Gen 27 | 28 | [tasks."gen:rust"] 29 | depends = ["ci:install-deps"] 30 | description = "Generate JSON schemas from the rust code" 31 | run = "cargo run --bin generate_schemas" 32 | sources = ["src/**/*.rs", "Cargo.toml", "Cargo.lock"] 33 | outputs = ["schemas/*.json"] 34 | 35 | [tasks."gen:deno"] 36 | description = "Generate the deno client" 37 | run = "deno run -A scripts/generate-schema/index.ts --language typescript" 38 | depends = ["gen:rust"] 39 | sources = ["schemas/*", "scripts/generate-schema.ts"] 40 | outputs = ["src/clients/deno/schemas/*.ts"] 41 | 42 | [tasks."gen:python"] 43 | description = "Generate the python client" 44 | run = "deno run -A scripts/generate-schema/index.ts --language python" 45 | depends = ["gen:rust"] 46 | sources = ["schemas/*", "scripts/generate-schema.ts"] 47 | outputs = ["src/clients/python/src/justbe_webview/schemas/*.py"] 48 | 49 | ## Debug 50 | 51 | [tasks."print-schema"] 52 | description = "Prints a simplified version of the schema" 53 | usage = ''' 54 | arg "[schema]" help="The schema to print; prints all if not provided" 55 | ''' 56 | run = "deno run -A scripts/generate-schema/debug.ts {{arg(name=\"schema\")}}" 57 | 58 | ## Publishing 59 | 60 | [tasks."verify-publish:deno"] 61 | description = "Verify the deno client is pulishable" 62 | dir = "src/clients/deno" 63 | run = "deno publish --dry-run" 64 | 65 | [tasks.gen] 66 | description = "Run all code gen tasks" 67 | depends = ["gen:*"] 68 | 69 | ## Build 70 | 71 | [tasks."build:rust"] 72 | description = "Build the webview binary" 73 | run = """ 74 | {% set xwin = '' %} 75 | {% set features = '' %} 76 | {% if get_env(name='MISE_ENV', default='') == 'windows' %} 77 | {% set xwin = ' xwin' %} 78 | {% endif %} 79 | {% if get_env(name='CI', default='') != 'true' %} 80 | {% set features = ' --features transparent,devtools' %} 81 | {% endif %} 82 | cargo{{xwin}} build --bin webview{{features}} 83 | """ 84 | sources = ["src/**/*.rs", "Cargo.toml", "Cargo.lock"] 85 | outputs = ["target/debug/webview"] 86 | depends = ["gen:rust"] 87 | 88 | [tasks."build:deno"] 89 | description = "Run code gen for deno and ensure the binary is built" 90 | depends = ["gen:deno", "build:rust"] 91 | 92 | [tasks."build:python"] 93 | description = "Run code gen for python and ensure the binary is built" 94 | depends = ["gen:python", "build:rust"] 95 | 96 | [tasks.build] 97 | description = "Build all targets" 98 | depends = ["build:*"] 99 | 100 | ## Lint 101 | 102 | [tasks."lint:rust"] 103 | description = "Run clippy against rust code" 104 | depends = ["ci:install-deps"] 105 | run = ["cargo fmt --check", "cargo clippy"] 106 | 107 | [tasks."lint:deno"] 108 | description = "Run deno lint" 109 | dir = "src/clients/deno" 110 | run = ["deno lint", "deno check ."] 111 | 112 | [tasks."lint:ast-grep"] 113 | description = "Run ast-grep lint" 114 | run = """ 115 | {% set format = '' %} 116 | {% if get_env(name='CI', default='') == 'true' %} 117 | {% set format = ' --format=github' %} 118 | {% endif %} 119 | sg scan {{format}} . 120 | """ 121 | 122 | [tasks."lint"] 123 | description = "Run all linting tasks" 124 | depends = ["lint:*"] 125 | 126 | ## Example 127 | 128 | [tasks."example:python"] 129 | description = "Run a python example" 130 | depends = ["build:python"] 131 | dir = "src/clients/python" 132 | run = "uv run -n examples/{{arg(name=\"example\")}}.py" 133 | env = { LOG_LEVEL = "debug", WEBVIEW_BIN = "../../../target/debug/webview" } 134 | 135 | [tasks."example:deno"] 136 | description = "Run a deno example" 137 | depends = ["build:deno"] 138 | env = { LOG_LEVEL = "debug", WEBVIEW_BIN = "../../../target/debug/webview" } 139 | run = "deno run -E -R -N --allow-run examples/{{arg(name=\"example\")}}.ts" 140 | dir = "src/clients/deno" 141 | -------------------------------------------------------------------------------- /mise.windows.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | "cargo:cargo-xwin" = "0.18.3" 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /schemas/WebViewMessage.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Message", 4 | "description": "Complete definition of all outbound messages from the webview to the client.", 5 | "oneOf": [ 6 | { 7 | "type": "object", 8 | "required": [ 9 | "$type", 10 | "data" 11 | ], 12 | "properties": { 13 | "$type": { 14 | "type": "string", 15 | "enum": [ 16 | "notification" 17 | ] 18 | }, 19 | "data": { 20 | "$ref": "#/definitions/Notification" 21 | } 22 | } 23 | }, 24 | { 25 | "type": "object", 26 | "required": [ 27 | "$type", 28 | "data" 29 | ], 30 | "properties": { 31 | "$type": { 32 | "type": "string", 33 | "enum": [ 34 | "response" 35 | ] 36 | }, 37 | "data": { 38 | "$ref": "#/definitions/Response" 39 | } 40 | } 41 | } 42 | ], 43 | "definitions": { 44 | "Notification": { 45 | "description": "Messages that are sent unbidden from the webview to the client.", 46 | "oneOf": [ 47 | { 48 | "type": "object", 49 | "required": [ 50 | "$type", 51 | "version" 52 | ], 53 | "properties": { 54 | "$type": { 55 | "type": "string", 56 | "enum": [ 57 | "started" 58 | ] 59 | }, 60 | "version": { 61 | "description": "The version of the webview binary", 62 | "type": "string" 63 | } 64 | } 65 | }, 66 | { 67 | "type": "object", 68 | "required": [ 69 | "$type", 70 | "message" 71 | ], 72 | "properties": { 73 | "$type": { 74 | "type": "string", 75 | "enum": [ 76 | "ipc" 77 | ] 78 | }, 79 | "message": { 80 | "description": "The message sent from the webview UI to the client.", 81 | "type": "string" 82 | } 83 | } 84 | }, 85 | { 86 | "type": "object", 87 | "required": [ 88 | "$type" 89 | ], 90 | "properties": { 91 | "$type": { 92 | "type": "string", 93 | "enum": [ 94 | "closed" 95 | ] 96 | } 97 | } 98 | } 99 | ] 100 | }, 101 | "Response": { 102 | "description": "Responses from the webview to the client.", 103 | "oneOf": [ 104 | { 105 | "type": "object", 106 | "required": [ 107 | "$type", 108 | "id" 109 | ], 110 | "properties": { 111 | "$type": { 112 | "type": "string", 113 | "enum": [ 114 | "ack" 115 | ] 116 | }, 117 | "id": { 118 | "type": "integer", 119 | "format": "int64" 120 | } 121 | } 122 | }, 123 | { 124 | "type": "object", 125 | "required": [ 126 | "$type", 127 | "id", 128 | "result" 129 | ], 130 | "properties": { 131 | "$type": { 132 | "type": "string", 133 | "enum": [ 134 | "result" 135 | ] 136 | }, 137 | "id": { 138 | "type": "integer", 139 | "format": "int64" 140 | }, 141 | "result": { 142 | "$ref": "#/definitions/ResultType" 143 | } 144 | } 145 | }, 146 | { 147 | "type": "object", 148 | "required": [ 149 | "$type", 150 | "id", 151 | "message" 152 | ], 153 | "properties": { 154 | "$type": { 155 | "type": "string", 156 | "enum": [ 157 | "err" 158 | ] 159 | }, 160 | "id": { 161 | "type": "integer", 162 | "format": "int64" 163 | }, 164 | "message": { 165 | "type": "string" 166 | } 167 | } 168 | } 169 | ] 170 | }, 171 | "ResultType": { 172 | "description": "Types that can be returned from webview results.", 173 | "oneOf": [ 174 | { 175 | "type": "object", 176 | "required": [ 177 | "$type", 178 | "value" 179 | ], 180 | "properties": { 181 | "$type": { 182 | "type": "string", 183 | "enum": [ 184 | "string" 185 | ] 186 | }, 187 | "value": { 188 | "type": "string" 189 | } 190 | } 191 | }, 192 | { 193 | "type": "object", 194 | "required": [ 195 | "$type", 196 | "value" 197 | ], 198 | "properties": { 199 | "$type": { 200 | "type": "string", 201 | "enum": [ 202 | "boolean" 203 | ] 204 | }, 205 | "value": { 206 | "type": "boolean" 207 | } 208 | } 209 | }, 210 | { 211 | "type": "object", 212 | "required": [ 213 | "$type", 214 | "value" 215 | ], 216 | "properties": { 217 | "$type": { 218 | "type": "string", 219 | "enum": [ 220 | "float" 221 | ] 222 | }, 223 | "value": { 224 | "type": "number", 225 | "format": "double" 226 | } 227 | } 228 | }, 229 | { 230 | "type": "object", 231 | "required": [ 232 | "$type", 233 | "value" 234 | ], 235 | "properties": { 236 | "$type": { 237 | "type": "string", 238 | "enum": [ 239 | "size" 240 | ] 241 | }, 242 | "value": { 243 | "$ref": "#/definitions/SizeWithScale" 244 | } 245 | } 246 | } 247 | ] 248 | }, 249 | "SizeWithScale": { 250 | "type": "object", 251 | "required": [ 252 | "height", 253 | "scaleFactor", 254 | "width" 255 | ], 256 | "properties": { 257 | "height": { 258 | "description": "The height of the window in logical pixels.", 259 | "type": "number", 260 | "format": "double" 261 | }, 262 | "scaleFactor": { 263 | "description": "The ratio between physical and logical sizes.", 264 | "type": "number", 265 | "format": "double" 266 | }, 267 | "width": { 268 | "description": "The width of the window in logical pixels.", 269 | "type": "number", 270 | "format": "double" 271 | } 272 | } 273 | } 274 | } 275 | } -------------------------------------------------------------------------------- /schemas/WebViewOptions.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Options", 4 | "description": "Options for creating a webview.", 5 | "type": "object", 6 | "required": [ 7 | "title" 8 | ], 9 | "properties": { 10 | "acceptFirstMouse": { 11 | "description": "Sets whether clicking an inactive window also clicks through to the webview. Default is false.", 12 | "default": false, 13 | "type": "boolean" 14 | }, 15 | "autoplay": { 16 | "description": "When true, all media can be played without user interaction. Default is false.", 17 | "default": false, 18 | "type": "boolean" 19 | }, 20 | "clipboard": { 21 | "description": "Enables clipboard access for the page rendered on Linux and Windows.\n\nmacOS doesn’t provide such method and is always enabled by default. But your app will still need to add menu item accelerators to use the clipboard shortcuts.", 22 | "default": false, 23 | "type": "boolean" 24 | }, 25 | "decorations": { 26 | "description": "When true, the window will have a border, a title bar, etc. Default is true.", 27 | "default": true, 28 | "type": "boolean" 29 | }, 30 | "devtools": { 31 | "description": "Enable or disable webview devtools.\n\nNote this only enables devtools to the webview. To open it, you can call `webview.open_devtools()`, or right click the page and open it from the context menu.", 32 | "default": false, 33 | "type": "boolean" 34 | }, 35 | "focused": { 36 | "description": "Sets whether the webview should be focused when created. Default is false.", 37 | "default": false, 38 | "type": "boolean" 39 | }, 40 | "incognito": { 41 | "description": "Run the WebView with incognito mode. Note that WebContext will be ingored if incognito is enabled.\n\nPlatform-specific: - Windows: Requires WebView2 Runtime version 101.0.1210.39 or higher, does nothing on older versions, see https://learn.microsoft.com/en-us/microsoft-edge/webview2/release-notes/archive?tabs=dotnetcsharp#10121039", 42 | "default": false, 43 | "type": "boolean" 44 | }, 45 | "initializationScript": { 46 | "description": "Run JavaScript code when loading new pages. When the webview loads a new page, this code will be executed. It is guaranteed that the code is executed before window.onload.", 47 | "default": null, 48 | "type": [ 49 | "string", 50 | "null" 51 | ] 52 | }, 53 | "ipc": { 54 | "description": "Sets whether host should be able to receive messages from the webview via `window.ipc.postMessage`.", 55 | "default": false, 56 | "type": "boolean" 57 | }, 58 | "load": { 59 | "description": "The content to load into the webview.", 60 | "anyOf": [ 61 | { 62 | "$ref": "#/definitions/Content" 63 | }, 64 | { 65 | "type": "null" 66 | } 67 | ] 68 | }, 69 | "size": { 70 | "description": "The size of the window.", 71 | "anyOf": [ 72 | { 73 | "$ref": "#/definitions/WindowSize" 74 | }, 75 | { 76 | "type": "null" 77 | } 78 | ] 79 | }, 80 | "title": { 81 | "description": "Sets the title of the window.", 82 | "type": "string" 83 | }, 84 | "transparent": { 85 | "description": "Sets whether the window should be transparent.", 86 | "default": false, 87 | "type": "boolean" 88 | }, 89 | "userAgent": { 90 | "description": "Sets the user agent to use when loading pages.", 91 | "default": null, 92 | "type": [ 93 | "string", 94 | "null" 95 | ] 96 | } 97 | }, 98 | "definitions": { 99 | "Content": { 100 | "description": "The content to load into the webview.", 101 | "anyOf": [ 102 | { 103 | "type": "object", 104 | "required": [ 105 | "url" 106 | ], 107 | "properties": { 108 | "headers": { 109 | "description": "Optional headers to send with the request.", 110 | "type": [ 111 | "object", 112 | "null" 113 | ], 114 | "additionalProperties": { 115 | "type": "string" 116 | } 117 | }, 118 | "url": { 119 | "description": "Url to load in the webview. Note: Don't use data URLs here, as they are not supported. Use the `html` field instead.", 120 | "type": "string" 121 | } 122 | } 123 | }, 124 | { 125 | "type": "object", 126 | "required": [ 127 | "html" 128 | ], 129 | "properties": { 130 | "html": { 131 | "description": "Html to load in the webview.", 132 | "type": "string" 133 | }, 134 | "origin": { 135 | "description": "What to set as the origin of the webview when loading html.", 136 | "default": "init", 137 | "type": "string" 138 | } 139 | } 140 | } 141 | ] 142 | }, 143 | "Size": { 144 | "type": "object", 145 | "required": [ 146 | "height", 147 | "width" 148 | ], 149 | "properties": { 150 | "height": { 151 | "description": "The height of the window in logical pixels.", 152 | "type": "number", 153 | "format": "double" 154 | }, 155 | "width": { 156 | "description": "The width of the window in logical pixels.", 157 | "type": "number", 158 | "format": "double" 159 | } 160 | } 161 | }, 162 | "WindowSize": { 163 | "anyOf": [ 164 | { 165 | "$ref": "#/definitions/WindowSizeStates" 166 | }, 167 | { 168 | "$ref": "#/definitions/Size" 169 | } 170 | ] 171 | }, 172 | "WindowSizeStates": { 173 | "type": "string", 174 | "enum": [ 175 | "maximized", 176 | "fullscreen" 177 | ] 178 | } 179 | } 180 | } -------------------------------------------------------------------------------- /schemas/WebViewRequest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Request", 4 | "description": "Explicit requests from the client to the webview.", 5 | "oneOf": [ 6 | { 7 | "type": "object", 8 | "required": [ 9 | "$type", 10 | "id" 11 | ], 12 | "properties": { 13 | "$type": { 14 | "type": "string", 15 | "enum": [ 16 | "getVersion" 17 | ] 18 | }, 19 | "id": { 20 | "description": "The id of the request.", 21 | "type": "integer", 22 | "format": "int64" 23 | } 24 | } 25 | }, 26 | { 27 | "type": "object", 28 | "required": [ 29 | "$type", 30 | "id", 31 | "js" 32 | ], 33 | "properties": { 34 | "$type": { 35 | "type": "string", 36 | "enum": [ 37 | "eval" 38 | ] 39 | }, 40 | "id": { 41 | "description": "The id of the request.", 42 | "type": "integer", 43 | "format": "int64" 44 | }, 45 | "js": { 46 | "description": "The javascript to evaluate.", 47 | "type": "string" 48 | } 49 | } 50 | }, 51 | { 52 | "type": "object", 53 | "required": [ 54 | "$type", 55 | "id", 56 | "title" 57 | ], 58 | "properties": { 59 | "$type": { 60 | "type": "string", 61 | "enum": [ 62 | "setTitle" 63 | ] 64 | }, 65 | "id": { 66 | "description": "The id of the request.", 67 | "type": "integer", 68 | "format": "int64" 69 | }, 70 | "title": { 71 | "description": "The title to set.", 72 | "type": "string" 73 | } 74 | } 75 | }, 76 | { 77 | "type": "object", 78 | "required": [ 79 | "$type", 80 | "id" 81 | ], 82 | "properties": { 83 | "$type": { 84 | "type": "string", 85 | "enum": [ 86 | "getTitle" 87 | ] 88 | }, 89 | "id": { 90 | "description": "The id of the request.", 91 | "type": "integer", 92 | "format": "int64" 93 | } 94 | } 95 | }, 96 | { 97 | "type": "object", 98 | "required": [ 99 | "$type", 100 | "id", 101 | "visible" 102 | ], 103 | "properties": { 104 | "$type": { 105 | "type": "string", 106 | "enum": [ 107 | "setVisibility" 108 | ] 109 | }, 110 | "id": { 111 | "description": "The id of the request.", 112 | "type": "integer", 113 | "format": "int64" 114 | }, 115 | "visible": { 116 | "description": "Whether the window should be visible or hidden.", 117 | "type": "boolean" 118 | } 119 | } 120 | }, 121 | { 122 | "type": "object", 123 | "required": [ 124 | "$type", 125 | "id" 126 | ], 127 | "properties": { 128 | "$type": { 129 | "type": "string", 130 | "enum": [ 131 | "isVisible" 132 | ] 133 | }, 134 | "id": { 135 | "description": "The id of the request.", 136 | "type": "integer", 137 | "format": "int64" 138 | } 139 | } 140 | }, 141 | { 142 | "type": "object", 143 | "required": [ 144 | "$type", 145 | "id" 146 | ], 147 | "properties": { 148 | "$type": { 149 | "type": "string", 150 | "enum": [ 151 | "openDevTools" 152 | ] 153 | }, 154 | "id": { 155 | "description": "The id of the request.", 156 | "type": "integer", 157 | "format": "int64" 158 | } 159 | } 160 | }, 161 | { 162 | "type": "object", 163 | "required": [ 164 | "$type", 165 | "id" 166 | ], 167 | "properties": { 168 | "$type": { 169 | "type": "string", 170 | "enum": [ 171 | "getSize" 172 | ] 173 | }, 174 | "id": { 175 | "description": "The id of the request.", 176 | "type": "integer", 177 | "format": "int64" 178 | }, 179 | "include_decorations": { 180 | "description": "Whether to include the title bar and borders in the size measurement.", 181 | "default": null, 182 | "type": [ 183 | "boolean", 184 | "null" 185 | ] 186 | } 187 | } 188 | }, 189 | { 190 | "type": "object", 191 | "required": [ 192 | "$type", 193 | "id", 194 | "size" 195 | ], 196 | "properties": { 197 | "$type": { 198 | "type": "string", 199 | "enum": [ 200 | "setSize" 201 | ] 202 | }, 203 | "id": { 204 | "description": "The id of the request.", 205 | "type": "integer", 206 | "format": "int64" 207 | }, 208 | "size": { 209 | "description": "The size to set.", 210 | "allOf": [ 211 | { 212 | "$ref": "#/definitions/Size" 213 | } 214 | ] 215 | } 216 | } 217 | }, 218 | { 219 | "type": "object", 220 | "required": [ 221 | "$type", 222 | "id" 223 | ], 224 | "properties": { 225 | "$type": { 226 | "type": "string", 227 | "enum": [ 228 | "fullscreen" 229 | ] 230 | }, 231 | "fullscreen": { 232 | "description": "Whether to enter fullscreen mode. If left unspecified, the window will enter fullscreen mode if it is not already in fullscreen mode or exit fullscreen mode if it is currently in fullscreen mode.", 233 | "type": [ 234 | "boolean", 235 | "null" 236 | ] 237 | }, 238 | "id": { 239 | "description": "The id of the request.", 240 | "type": "integer", 241 | "format": "int64" 242 | } 243 | } 244 | }, 245 | { 246 | "type": "object", 247 | "required": [ 248 | "$type", 249 | "id" 250 | ], 251 | "properties": { 252 | "$type": { 253 | "type": "string", 254 | "enum": [ 255 | "maximize" 256 | ] 257 | }, 258 | "id": { 259 | "description": "The id of the request.", 260 | "type": "integer", 261 | "format": "int64" 262 | }, 263 | "maximized": { 264 | "description": "Whether to maximize the window. If left unspecified, the window will be maximized if it is not already maximized or restored if it was previously maximized.", 265 | "type": [ 266 | "boolean", 267 | "null" 268 | ] 269 | } 270 | } 271 | }, 272 | { 273 | "type": "object", 274 | "required": [ 275 | "$type", 276 | "id" 277 | ], 278 | "properties": { 279 | "$type": { 280 | "type": "string", 281 | "enum": [ 282 | "minimize" 283 | ] 284 | }, 285 | "id": { 286 | "description": "The id of the request.", 287 | "type": "integer", 288 | "format": "int64" 289 | }, 290 | "minimized": { 291 | "description": "Whether to minimize the window. If left unspecified, the window will be minimized if it is not already minimized or restored if it was previously minimized.", 292 | "type": [ 293 | "boolean", 294 | "null" 295 | ] 296 | } 297 | } 298 | }, 299 | { 300 | "type": "object", 301 | "required": [ 302 | "$type", 303 | "html", 304 | "id" 305 | ], 306 | "properties": { 307 | "$type": { 308 | "type": "string", 309 | "enum": [ 310 | "loadHtml" 311 | ] 312 | }, 313 | "html": { 314 | "description": "HTML to set as the content of the webview.", 315 | "type": "string" 316 | }, 317 | "id": { 318 | "description": "The id of the request.", 319 | "type": "integer", 320 | "format": "int64" 321 | }, 322 | "origin": { 323 | "description": "What to set as the origin of the webview when loading html. If not specified, the origin will be set to the value of the `origin` field when the webview was created.", 324 | "type": [ 325 | "string", 326 | "null" 327 | ] 328 | } 329 | } 330 | }, 331 | { 332 | "type": "object", 333 | "required": [ 334 | "$type", 335 | "id", 336 | "url" 337 | ], 338 | "properties": { 339 | "$type": { 340 | "type": "string", 341 | "enum": [ 342 | "loadUrl" 343 | ] 344 | }, 345 | "headers": { 346 | "description": "Optional headers to send with the request.", 347 | "type": [ 348 | "object", 349 | "null" 350 | ], 351 | "additionalProperties": { 352 | "type": "string" 353 | } 354 | }, 355 | "id": { 356 | "description": "The id of the request.", 357 | "type": "integer", 358 | "format": "int64" 359 | }, 360 | "url": { 361 | "description": "URL to load in the webview.", 362 | "type": "string" 363 | } 364 | } 365 | } 366 | ], 367 | "definitions": { 368 | "Size": { 369 | "type": "object", 370 | "required": [ 371 | "height", 372 | "width" 373 | ], 374 | "properties": { 375 | "height": { 376 | "description": "The height of the window in logical pixels.", 377 | "type": "number", 378 | "format": "double" 379 | }, 380 | "width": { 381 | "description": "The width of the window in logical pixels.", 382 | "type": "number", 383 | "format": "double" 384 | } 385 | } 386 | } 387 | } 388 | } -------------------------------------------------------------------------------- /schemas/WebViewResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Response", 4 | "description": "Responses from the webview to the client.", 5 | "oneOf": [ 6 | { 7 | "type": "object", 8 | "required": [ 9 | "$type", 10 | "id" 11 | ], 12 | "properties": { 13 | "$type": { 14 | "type": "string", 15 | "enum": [ 16 | "ack" 17 | ] 18 | }, 19 | "id": { 20 | "type": "integer", 21 | "format": "int64" 22 | } 23 | } 24 | }, 25 | { 26 | "type": "object", 27 | "required": [ 28 | "$type", 29 | "id", 30 | "result" 31 | ], 32 | "properties": { 33 | "$type": { 34 | "type": "string", 35 | "enum": [ 36 | "result" 37 | ] 38 | }, 39 | "id": { 40 | "type": "integer", 41 | "format": "int64" 42 | }, 43 | "result": { 44 | "$ref": "#/definitions/ResultType" 45 | } 46 | } 47 | }, 48 | { 49 | "type": "object", 50 | "required": [ 51 | "$type", 52 | "id", 53 | "message" 54 | ], 55 | "properties": { 56 | "$type": { 57 | "type": "string", 58 | "enum": [ 59 | "err" 60 | ] 61 | }, 62 | "id": { 63 | "type": "integer", 64 | "format": "int64" 65 | }, 66 | "message": { 67 | "type": "string" 68 | } 69 | } 70 | } 71 | ], 72 | "definitions": { 73 | "ResultType": { 74 | "description": "Types that can be returned from webview results.", 75 | "oneOf": [ 76 | { 77 | "type": "object", 78 | "required": [ 79 | "$type", 80 | "value" 81 | ], 82 | "properties": { 83 | "$type": { 84 | "type": "string", 85 | "enum": [ 86 | "string" 87 | ] 88 | }, 89 | "value": { 90 | "type": "string" 91 | } 92 | } 93 | }, 94 | { 95 | "type": "object", 96 | "required": [ 97 | "$type", 98 | "value" 99 | ], 100 | "properties": { 101 | "$type": { 102 | "type": "string", 103 | "enum": [ 104 | "boolean" 105 | ] 106 | }, 107 | "value": { 108 | "type": "boolean" 109 | } 110 | } 111 | }, 112 | { 113 | "type": "object", 114 | "required": [ 115 | "$type", 116 | "value" 117 | ], 118 | "properties": { 119 | "$type": { 120 | "type": "string", 121 | "enum": [ 122 | "float" 123 | ] 124 | }, 125 | "value": { 126 | "type": "number", 127 | "format": "double" 128 | } 129 | } 130 | }, 131 | { 132 | "type": "object", 133 | "required": [ 134 | "$type", 135 | "value" 136 | ], 137 | "properties": { 138 | "$type": { 139 | "type": "string", 140 | "enum": [ 141 | "size" 142 | ] 143 | }, 144 | "value": { 145 | "$ref": "#/definitions/SizeWithScale" 146 | } 147 | } 148 | } 149 | ] 150 | }, 151 | "SizeWithScale": { 152 | "type": "object", 153 | "required": [ 154 | "height", 155 | "scaleFactor", 156 | "width" 157 | ], 158 | "properties": { 159 | "height": { 160 | "description": "The height of the window in logical pixels.", 161 | "type": "number", 162 | "format": "double" 163 | }, 164 | "scaleFactor": { 165 | "description": "The ratio between physical and logical sizes.", 166 | "type": "number", 167 | "format": "double" 168 | }, 169 | "width": { 170 | "description": "The width of the window in logical pixels.", 171 | "type": "number", 172 | "format": "double" 173 | } 174 | } 175 | } 176 | } 177 | } -------------------------------------------------------------------------------- /scripts/generate-schema/debug.ts: -------------------------------------------------------------------------------- 1 | import { parseSchema } from "./parser.ts"; 2 | import { printDocIR } from "./printer.ts"; 3 | 4 | const schemaFiles = [ 5 | "WebViewOptions.json", 6 | "WebViewRequest.json", 7 | "WebViewResponse.json", 8 | "WebViewMessage.json", 9 | ]; 10 | 11 | async function main() { 12 | const targetSchema = Deno.args[0]; 13 | const filesToProcess = targetSchema 14 | ? [targetSchema.endsWith(".json") ? targetSchema : `${targetSchema}.json`] 15 | : schemaFiles; 16 | 17 | for (const schemaFile of filesToProcess) { 18 | if (!schemaFiles.includes(schemaFile)) { 19 | console.error(`Invalid schema file: ${schemaFile}`); 20 | console.error(`Available schemas: ${schemaFiles.join(", ")}`); 21 | Deno.exit(1); 22 | } 23 | 24 | console.log(`Schema: ${schemaFile}`); 25 | 26 | const schema = JSON.parse(await Deno.readTextFile(`schemas/${schemaFile}`)); 27 | const doc = parseSchema(schema); 28 | console.log(printDocIR(doc)); 29 | } 30 | } 31 | 32 | if (import.meta.main) { 33 | main().catch(console.error); 34 | } 35 | -------------------------------------------------------------------------------- /scripts/generate-schema/gen-helpers.ts: -------------------------------------------------------------------------------- 1 | export class Writer { 2 | constructor(private buffer: string = "") {} 3 | 4 | append(...t: (string | false | undefined | null | 0)[]) { 5 | this.buffer += t.filter((t) => t).join(" "); 6 | } 7 | 8 | appendLine(...t: (string | false | undefined | null | 0)[]) { 9 | this.append(...t, "\n"); 10 | } 11 | 12 | prepend(...t: (string | false | undefined | null | 0)[]) { 13 | this.buffer = t.filter((t) => t).join(" ") + this.buffer; 14 | } 15 | 16 | shorthand() { 17 | return { 18 | w: this.append.bind(this), 19 | wn: this.appendLine.bind(this), 20 | }; 21 | } 22 | 23 | output() { 24 | return this.buffer; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /scripts/generate-schema/gen-python.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import dedent from "npm:dedent"; 3 | import { extractExportedNames } from "./gen-python.ts"; 4 | 5 | Deno.test("extractExportedNames - extracts class names", () => { 6 | const content = dedent` 7 | class MyClass: 8 | pass 9 | 10 | class AnotherClass(msgspec.Struct): 11 | field: str 12 | 13 | def not_a_class(): 14 | pass 15 | `; 16 | assertEquals(extractExportedNames(content), ["AnotherClass", "MyClass"]); 17 | }); 18 | 19 | Deno.test("extractExportedNames - extracts enum assignments", () => { 20 | const content = dedent` 21 | MyEnum = Union[ClassA, ClassB] 22 | AnotherEnum = Union[ClassC, ClassD, ClassE] 23 | 24 | not_an_enum = "something else" 25 | `; 26 | assertEquals(extractExportedNames(content), ["AnotherEnum", "MyEnum"]); 27 | }); 28 | 29 | Deno.test("extractExportedNames - extracts both classes and enums", () => { 30 | const content = dedent` 31 | class MyClass: 32 | pass 33 | 34 | MyEnum = Union[ClassA, ClassB] 35 | 36 | class AnotherClass: 37 | field: str 38 | 39 | AnotherEnum = Union[ClassC, ClassD] 40 | `; 41 | assertEquals(extractExportedNames(content), [ 42 | "AnotherClass", 43 | "AnotherEnum", 44 | "MyClass", 45 | "MyEnum", 46 | ]); 47 | }); 48 | 49 | Deno.test("extractExportedNames - handles empty content", () => { 50 | assertEquals(extractExportedNames(""), []); 51 | }); 52 | 53 | Deno.test("extractExportedNames - ignores indented class definitions and enum assignments", () => { 54 | const content = dedent` 55 | def some_function(): 56 | class IndentedClass: 57 | pass 58 | 59 | IndentedEnum = Union[ClassA, ClassB] 60 | 61 | class TopLevelClass: 62 | pass 63 | 64 | TopLevelEnum = Union[ClassC, ClassD] 65 | `; 66 | assertEquals(extractExportedNames(content), [ 67 | "TopLevelClass", 68 | "TopLevelEnum", 69 | ]); 70 | }); 71 | -------------------------------------------------------------------------------- /scripts/generate-schema/gen-python.ts: -------------------------------------------------------------------------------- 1 | import { type Doc, isComplexType, type Node } from "./parser.ts"; 2 | import { match, P } from "npm:ts-pattern"; 3 | import { Writer } from "./gen-helpers.ts"; 4 | import { assert } from "jsr:@std/assert"; 5 | 6 | const header = (relativePath: string) => 7 | `# DO NOT EDIT: This file is auto-generated by ${relativePath}\n` + 8 | "from enum import Enum\n" + 9 | "from typing import Any, Literal, Optional, Union\n" + 10 | "import msgspec\n\n"; 11 | 12 | export function extractExportedNames(content: string): string[] { 13 | const names = new Set(); 14 | 15 | // Match class definitions and enum assignments 16 | const classRegex = /^class\s+([a-zA-Z_][a-zA-Z0-9_]*)/gm; 17 | const enumRegex = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*Union\[/gm; 18 | 19 | let match; 20 | while ((match = classRegex.exec(content)) !== null) { 21 | names.add(match[1]); 22 | } 23 | while ((match = enumRegex.exec(content)) !== null) { 24 | names.add(match[1]); 25 | } 26 | 27 | return [...names].sort(); 28 | } 29 | 30 | export function generateAll(names: string[]): string { 31 | return `__all__ = ${JSON.stringify(names, null, 4)}\n\n`; 32 | } 33 | 34 | // Track generated definitions to avoid duplicates 35 | const generatedDefinitions = new Set(); 36 | const generatedDependentClasses = new Set(); 37 | 38 | export function generatePython( 39 | doc: Doc, 40 | name: string, 41 | relativePath: string, 42 | ): string { 43 | // Only include header for the first schema 44 | const shouldIncludeHeader = generatedDefinitions.size === 0; 45 | const content = generateTypes(doc, name); 46 | 47 | let output = ""; 48 | if (shouldIncludeHeader) { 49 | output += header(relativePath); 50 | } 51 | return output + content; 52 | } 53 | 54 | function generateTypes( 55 | doc: Doc, 56 | name: string, 57 | ) { 58 | const writer = new Writer(); 59 | 60 | let definitions = ""; 61 | const skipAssignments = ["object", "intersection", "enum", "union"]; 62 | for (const [defName, definition] of Object.entries(doc.definitions)) { 63 | // Skip if we've already generated this definition 64 | if (generatedDefinitions.has(defName)) { 65 | continue; 66 | } 67 | generatedDefinitions.add(defName); 68 | 69 | const definitionWriter = new Writer(); 70 | const { w, wn } = definitionWriter.shorthand(); 71 | if (!skipAssignments.includes(definition.type)) { 72 | w(defName, " = "); 73 | } 74 | generateNode(definition, definitionWriter); 75 | if (definition.description) { 76 | wn('"""'); 77 | wn(`${definition.description}`); 78 | wn('"""'); 79 | } 80 | definitions += definitionWriter.output(); 81 | } 82 | 83 | const { w, wn } = writer.shorthand(); 84 | 85 | if (!generatedDefinitions.has(name)) { 86 | if (!skipAssignments.includes(doc.root.type)) { 87 | w(name, " = "); 88 | } 89 | generateNode(doc.root, writer); 90 | if (doc.description && doc.root.type !== "object") { 91 | wn('"""'); 92 | wn(`${doc.description}`); 93 | wn('"""'); 94 | } 95 | } 96 | 97 | return definitions + writer.output(); 98 | } 99 | 100 | function sortByRequired( 101 | properties: T[], 102 | ): T[] { 103 | return [...properties].sort((a, b) => { 104 | if (a.required === b.required) return 0; 105 | return a.required ? -1 : 1; 106 | }); 107 | } 108 | 109 | function generateNode(node: Node, writer: Writer) { 110 | const { w, wn } = writer.shorthand(); 111 | using context = new Context(node); 112 | match(node) 113 | .with({ type: "reference" }, ({ name }) => w(name)) 114 | .with({ type: "int" }, () => w("int")) 115 | .with({ type: "float" }, () => w("float")) 116 | .with({ type: "boolean" }, () => w("bool")) 117 | .with({ type: "string" }, () => w("str")) 118 | .with({ type: "literal" }, (node) => w(`Literal["${node.value}"]`)) 119 | .with( 120 | { type: "record" }, 121 | (node) => w(`dict[str, ${mapPythonType(node.valueType)}]`), 122 | ) 123 | .with({ type: "enum" }, (node) => { 124 | wn(`class ${node.name}(str, Enum):`); 125 | for (const value of node.members) { 126 | wn(` ${value} = "${value}"`); 127 | } 128 | wn(""); 129 | }) 130 | .with({ type: "union" }, (node) => { 131 | const depWriter = new Writer(); 132 | const classes = node.members.map((m) => { 133 | let name: string = ""; 134 | if (m.name) { 135 | name = m.name; 136 | } else { 137 | const ident = m.type === "object" 138 | ? m.properties?.find((p) => p.required)?.key ?? "" 139 | : ""; 140 | name = `${node.name}${cap(ident)}`; 141 | } 142 | if (!generatedDependentClasses.has(name)) { 143 | generatedDependentClasses.add(name); 144 | if (isComplexType(m)) { 145 | generateNode(m, depWriter); 146 | } 147 | } 148 | return name; 149 | }); 150 | writer.append(depWriter.output()); 151 | wn(`${node.name} = Union[${classes.join(", ")}]`); 152 | }) 153 | .with({ type: "object" }, (node) => { 154 | match(context.parent) 155 | .with({ type: "union" }, () => { 156 | const name = context.closestName(); 157 | const ident = node.properties.find((p) => p.required)?.key ?? ""; 158 | wn( 159 | `class ${name}${ 160 | cap(ident) 161 | }(msgspec.Struct, kw_only=True, omit_defaults=True):`, 162 | ); 163 | }) 164 | .with(P.nullish, () => { 165 | wn(`class ${node.name}(msgspec.Struct, omit_defaults=True):`); 166 | }) 167 | .otherwise(() => { 168 | wn( 169 | `class ${node.name}(msgspec.Struct, kw_only=True, omit_defaults=True):`, 170 | ); 171 | }); 172 | if (node.description) { 173 | wn(` """`); 174 | wn(` ${node.description}`); 175 | wn(` """`); 176 | } 177 | 178 | const sortedProperties = sortByRequired(node.properties); 179 | 180 | for (const { key, required, description, value } of sortedProperties) { 181 | w(` ${key}: `); 182 | if (!required) w("Union["); 183 | generateNode(value, writer); 184 | if (!required) w(", None] = None"); 185 | wn(""); 186 | if (description) { 187 | wn(` """${description}"""`); 188 | } 189 | } 190 | wn(""); 191 | }) 192 | .with({ type: "descriminated-union" }, (node) => { 193 | const depWriter = new Writer(); 194 | const { w: d, wn: dn } = depWriter.shorthand(); 195 | const classes: string[] = []; 196 | w("Union["); 197 | for (const [name, properties] of Object.entries(node.members)) { 198 | for (const { value } of properties) { 199 | if (isComplexType(value)) { 200 | generateNode(value, depWriter); 201 | } 202 | } 203 | const className = `${cap(name)}${cap(node.name!)}`; 204 | classes.push(className); 205 | if (!generatedDependentClasses.has(className)) { 206 | generatedDependentClasses.add(className); 207 | dn( 208 | `class ${className}(msgspec.Struct, tag_field="${node.descriminator}", tag="${name}"):`, 209 | ); 210 | if (properties.length === 0) { 211 | dn(" pass"); 212 | } 213 | 214 | const sortedProperties = sortByRequired(properties); 215 | 216 | for ( 217 | const { key, required, description, value } of sortedProperties 218 | ) { 219 | d(` ${key}: `); 220 | if (!required) d("Union["); 221 | !isComplexType(value) 222 | ? generateNode(value, depWriter) 223 | : d(value.name ?? value.type); 224 | if (!required) d(", None] = None"); 225 | dn(""); 226 | if (description) { 227 | dn(` """${description}"""`); 228 | } 229 | } 230 | dn(""); 231 | } 232 | } 233 | w(classes.join(", ")); 234 | writer.prepend(depWriter.output()); 235 | wn("]"); 236 | }) 237 | .with({ type: "intersection" }, (node) => { 238 | assert( 239 | node.members.length === 2, 240 | "Intersection must have exactly 2 members", 241 | ); 242 | assert( 243 | node.members[0]?.type === "object", 244 | "First member of intersection must be an object", 245 | ); 246 | for (const member of node.members) { 247 | generateNode(member, writer); 248 | } 249 | }) 250 | .with({ type: "unknown" }, () => { 251 | w("Any"); 252 | }) 253 | .exhaustive(); 254 | } 255 | 256 | class Context { 257 | private static stack: Node[] = []; 258 | constructor(public readonly currentNode: Node) { 259 | Context.stack.push(this.currentNode); 260 | } 261 | 262 | get parent(): Node | undefined { 263 | return Context.stack.at(-2); 264 | } 265 | 266 | /** 267 | * When `n` is 1, this returns the parent. 268 | * When `n` is 2, this returns the grandparent. 269 | * etc. 270 | */ 271 | getNthParent(n: number): Node | undefined { 272 | return Context.stack.at(-(n + 1)); 273 | } 274 | 275 | closestName(): string | undefined { 276 | for (const node of [...Context.stack].reverse()) { 277 | // @ts-expect-error - We're looking for names on roots or declarations, this should be fine. 278 | const name = node.name || node.title; 279 | if (name) { 280 | return name; 281 | } 282 | } 283 | return undefined; 284 | } 285 | 286 | [Symbol.dispose]() { 287 | Context.stack = Context.stack.filter((n) => n !== this.currentNode); 288 | } 289 | } 290 | 291 | function cap(str: string): string { 292 | if (!str) return ""; 293 | return str.charAt(0).toUpperCase() + str.slice(1); 294 | } 295 | 296 | function mapPythonType(type: string): string { 297 | return match(type) 298 | .with("string", () => "str") 299 | .with("number", () => "float") 300 | .with("integer", () => "int") 301 | .with("boolean", () => "bool") 302 | .otherwise(() => "Any"); 303 | } 304 | -------------------------------------------------------------------------------- /scripts/generate-schema/gen-typescript.ts: -------------------------------------------------------------------------------- 1 | import type { Doc, Node } from "./parser.ts"; 2 | import { Writer } from "./gen-helpers.ts"; 3 | import { match } from "npm:ts-pattern"; 4 | 5 | const header = (relativePath: string) => 6 | `// DO NOT EDIT: This file is auto-generated by ${relativePath}\n` + 7 | "import { z } from 'npm:zod';\n\n"; 8 | 9 | // Track generated definitions to avoid duplicates 10 | const generatedTypeDefinitions = new Set(); 11 | const generatedZodDefinitions = new Set(); 12 | 13 | export function generateTypeScript( 14 | doc: Doc, 15 | name: string, 16 | relativePath: string, 17 | ) { 18 | // Only include header for the first schema 19 | const shouldIncludeHeader = generatedTypeDefinitions.size === 0; 20 | const types = generateTypes(doc, name); 21 | const zodSchema = generateZodSchema(doc, name); 22 | return (shouldIncludeHeader ? header(relativePath) : "") + types + zodSchema; 23 | } 24 | 25 | function generateTypes(doc: Doc, typeName: string) { 26 | const writer = new Writer(); 27 | const { w, wn } = writer.shorthand(); 28 | 29 | for (const [name, definition] of Object.entries(doc.definitions)) { 30 | // Skip if we've already generated this definition 31 | if (generatedTypeDefinitions.has(name)) { 32 | continue; 33 | } 34 | generatedTypeDefinitions.add(name); 35 | 36 | if (definition.description) { 37 | wn("/**"); 38 | wn(` * ${definition.description}`); 39 | wn(" */"); 40 | } 41 | wn("export type", name, " = "); 42 | generateNode(definition); 43 | wn(""); 44 | } 45 | 46 | if (!generatedTypeDefinitions.has(typeName)) { 47 | if (doc.description) { 48 | wn("/**"); 49 | wn(` * ${doc.description}`); 50 | wn(" */"); 51 | } 52 | wn("export type", typeName, " = "); 53 | generateNode(doc.root); 54 | wn(""); 55 | } 56 | 57 | function generateNode(node: Node) { 58 | match(node) 59 | .with({ type: "reference" }, (node) => w(node.name)) 60 | .with({ type: "int" }, () => w("number")) 61 | .with({ type: "float" }, () => w("number")) 62 | .with({ type: "boolean" }, () => w("boolean")) 63 | .with({ type: "string" }, () => w("string")) 64 | .with({ type: "literal" }, (node) => w(`"${node.value}"`)) 65 | .with( 66 | { type: "record" }, 67 | (node) => w(`Record`), 68 | ) 69 | .with({ type: "object" }, (node) => { 70 | wn("{"); 71 | for (const { key, required, description, value } of node.properties) { 72 | if (description) { 73 | if (description.includes("\n")) { 74 | wn(`/**`); 75 | for (const line of description.split("\n")) { 76 | wn(` * ${line}`); 77 | } 78 | wn(` */`); 79 | } else { 80 | wn(`/** ${description} */`); 81 | } 82 | } 83 | w(key, required ? ": " : "? : "); 84 | generateNode(value); 85 | wn(","); 86 | } 87 | wn("}"); 88 | }) 89 | .with({ type: "intersection" }, (node) => { 90 | wn("("); 91 | for (const member of node.members) { 92 | w("& "); 93 | generateNode(member); 94 | } 95 | wn(")"); 96 | }) 97 | .with({ type: "union" }, (node) => { 98 | wn("("); 99 | for (const member of node.members) { 100 | generateNode(member); 101 | if (member !== node.members.at(-1)) { 102 | w(" | "); 103 | } 104 | } 105 | wn(")"); 106 | }) 107 | .with({ type: "descriminated-union" }, (node) => { 108 | wn("("); 109 | for (const [name, member] of Object.entries(node.members)) { 110 | wn("| {"); 111 | wn(`${node.descriminator}: "${name}",`); 112 | for (const { key, required, description, value } of member) { 113 | wn(description ? `/** ${description} */` : ""); 114 | w(key, required ? ": " : "? : "); 115 | generateNode(value); 116 | wn(","); 117 | } 118 | wn("}"); 119 | } 120 | wn(")"); 121 | }) 122 | .with({ type: "enum" }, (node) => { 123 | w("("); 124 | w(node.members.map((m) => `"${m}"`).join(" | ")); 125 | w(")"); 126 | }) 127 | .with({ type: "unknown" }, () => w("unknown")) 128 | .exhaustive(); 129 | } 130 | return writer.output(); 131 | } 132 | 133 | export function generateZodSchema(doc: Doc, name: string) { 134 | let result = ""; 135 | const w = (...t: (string | false | undefined | null | 0)[]) => { 136 | result += t.filter((t) => t).join(" "); 137 | }; 138 | const wn = (...t: (string | false | undefined | null | 0)[]) => w(...t, "\n"); 139 | 140 | for (const [name, definition] of Object.entries(doc.definitions)) { 141 | // Skip if we've already generated this Zod definition 142 | if (generatedZodDefinitions.has(name)) { 143 | continue; 144 | } 145 | generatedZodDefinitions.add(name); 146 | 147 | wn("export const", name, `: z.ZodType<${name}> =`); 148 | generateNode(definition); 149 | wn(""); 150 | } 151 | 152 | if (!generatedZodDefinitions.has(name)) { 153 | wn("export const", name, `: z.ZodType<${name}> =`); 154 | generateNode(doc.root); 155 | wn(""); 156 | } 157 | 158 | function generateNode(node: Node) { 159 | match(node) 160 | .with({ type: "reference" }, (node) => w(node.name)) 161 | .with({ type: "int" }, (node) => { 162 | w("z.number().int()"); 163 | if (typeof node.minimum === "number") { 164 | w(`.min(${node.minimum})`); 165 | } 166 | if (typeof node.maximum === "number") { 167 | w(`.max(${node.maximum})`); 168 | } 169 | }) 170 | .with({ type: "float" }, (node) => { 171 | w("z.number()"); 172 | if (typeof node.minimum === "number") { 173 | w(`.min(${node.minimum})`); 174 | } 175 | if (typeof node.maximum === "number") { 176 | w(`.max(${node.maximum})`); 177 | } 178 | }) 179 | .with( 180 | { type: "boolean" }, 181 | (node) => w("z.boolean()", node.optional && ".optional()"), 182 | ) 183 | .with({ type: "string" }, () => w("z.string()")) 184 | .with({ type: "literal" }, (node) => w(`z.literal("${node.value}")`)) 185 | .with( 186 | { type: "record" }, 187 | (node) => w(`z.record(z.string(), z.${node.valueType}())`), 188 | ) 189 | .with({ type: "object" }, (node) => { 190 | w("z.object({"); 191 | for (const { key, required, value } of node.properties) { 192 | w(key, ":"); 193 | generateNode(value); 194 | if (!required && !("optional" in value && value.optional)) { 195 | w(".optional()"); 196 | } 197 | w(","); 198 | } 199 | wn("})"); 200 | }) 201 | .with({ type: "descriminated-union" }, (node) => { 202 | w(`z.discriminatedUnion("${node.descriminator}", [`); 203 | for (const [name, member] of Object.entries(node.members)) { 204 | w(`z.object({ ${node.descriminator}: z.literal("${name}"),`); 205 | for (const { key, required, value } of member) { 206 | w(key, ": "); 207 | generateNode(value); 208 | if (!required) { 209 | w(".optional()"); 210 | } 211 | wn(","); 212 | } 213 | wn("}),"); 214 | } 215 | wn("])"); 216 | }) 217 | .with({ type: "intersection" }, (node) => { 218 | w("z.intersection("); 219 | for (const member of node.members) { 220 | generateNode(member); 221 | w(","); 222 | } 223 | wn(")"); 224 | }) 225 | .with({ type: "union" }, (node) => { 226 | w("z.union(["); 227 | for (const member of node.members) { 228 | generateNode(member); 229 | w(","); 230 | } 231 | wn("])"); 232 | }) 233 | .with({ type: "enum" }, (node) => { 234 | w("z.enum(["); 235 | w(node.members.map((m) => `"${m}"`).join(", ")); 236 | w("])"); 237 | }) 238 | .with({ type: "unknown" }, () => w("z.unknown()")) 239 | .exhaustive(); 240 | } 241 | 242 | return result; 243 | } 244 | -------------------------------------------------------------------------------- /scripts/generate-schema/index.ts: -------------------------------------------------------------------------------- 1 | import { walk } from "jsr:@std/fs/walk"; 2 | import { join } from "jsr:@std/path"; 3 | import { parseArgs } from "jsr:@std/cli/parse-args"; 4 | import type { JSONSchema } from "../../json-schema.d.ts"; 5 | import { generateTypeScript } from "./gen-typescript.ts"; 6 | import { 7 | extractExportedNames, 8 | generateAll, 9 | generatePython, 10 | } from "./gen-python.ts"; 11 | import { parseSchema } from "./parser.ts"; 12 | 13 | const schemasDir = new URL("../../schemas", import.meta.url).pathname; 14 | const tsSchemaDir = new URL("../../src/clients/deno", import.meta.url).pathname; 15 | const pySchemaDir = 16 | new URL("../../src/clients/python/src/justbe_webview", import.meta.url) 17 | .pathname; 18 | 19 | async function ensureDir(dir: string) { 20 | try { 21 | await Deno.mkdir(dir, { recursive: true }); 22 | } catch (error) { 23 | if (!(error instanceof Deno.errors.AlreadyExists)) { 24 | throw error; 25 | } 26 | } 27 | } 28 | 29 | async function main() { 30 | const flags = parseArgs(Deno.args, { 31 | string: ["language"], 32 | alias: { language: "l" }, 33 | }); 34 | 35 | const language = flags.language?.toLowerCase(); 36 | if (language && !["typescript", "python"].includes(language)) { 37 | console.error('Language must be either "typescript" or "python"'); 38 | Deno.exit(1); 39 | } 40 | 41 | const relativePath = new URL(import.meta.url).pathname.split("/").slice(-2) 42 | .join("/"); 43 | 44 | // Only ensure directories for the languages we'll generate 45 | if (!language || language === "typescript") { 46 | await ensureDir(tsSchemaDir); 47 | } 48 | if (!language || language === "python") { 49 | await ensureDir(pySchemaDir); 50 | } 51 | 52 | const entries = []; 53 | for await (const entry of walk(schemasDir, { exts: [".json"] })) { 54 | if (entry.isFile) { 55 | entries.push(entry); 56 | } 57 | } 58 | 59 | // Sort files so that the generated code is deterministic 60 | const files = entries.sort((a, b) => a.path < b.path ? -1 : 1); 61 | 62 | // Collect all schemas first 63 | const schemas = []; 64 | for (const file of files) { 65 | const jsonSchema: JSONSchema = JSON.parse( 66 | await Deno.readTextFile(file.path), 67 | ); 68 | const doc = parseSchema(jsonSchema); 69 | schemas.push(doc); 70 | } 71 | 72 | if (!language || language === "typescript") { 73 | // Generate single TypeScript file with all schemas 74 | const tsContent = schemas.map((doc) => 75 | generateTypeScript(doc, doc.title, relativePath) 76 | ).join("\n\n\n"); 77 | const tsFilePath = join(tsSchemaDir, "schemas.ts"); 78 | await Deno.writeTextFile(tsFilePath, tsContent); 79 | console.log(`Generated TypeScript schemas: ${tsFilePath}`); 80 | } 81 | 82 | if (!language || language === "python") { 83 | // Generate single Python file with all schemas 84 | const pyContent = schemas.map((doc) => 85 | generatePython(doc, doc.title, relativePath) 86 | ).join("\n\n\n"); 87 | 88 | // Extract all exported names and generate __all__ 89 | const exportedNames = extractExportedNames(pyContent); 90 | const allContent = generateAll(exportedNames); 91 | 92 | // Insert __all__ after the header (which is in the first schema's output) 93 | const headerEndIndex = pyContent.indexOf("\n\n") + 2; 94 | const finalContent = pyContent.slice(0, headerEndIndex) + allContent + 95 | pyContent.slice(headerEndIndex); 96 | 97 | const pyFilePath = join(pySchemaDir, "schemas.py"); 98 | await Deno.writeTextFile(pyFilePath, finalContent); 99 | console.log(`Generated Python schemas: ${pyFilePath}`); 100 | } 101 | 102 | // Run deno fmt on TypeScript files if they were generated 103 | if (!language || language === "typescript") { 104 | const command = new Deno.Command("deno", { 105 | args: ["fmt", tsSchemaDir], 106 | }); 107 | await command.output(); 108 | } 109 | 110 | // Run ruff format on Python files if they were generated 111 | if (!language || language === "python") { 112 | const command = new Deno.Command("ruff", { 113 | args: ["check", "--fix", pySchemaDir], 114 | }); 115 | await command.output(); 116 | } 117 | } 118 | 119 | main().catch(console.error); 120 | -------------------------------------------------------------------------------- /scripts/generate-schema/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "jsr:@std/assert"; 2 | import { parseSchema } from "./parser.ts"; 3 | import type { JSONSchema } from "../../json-schema.d.ts"; 4 | 5 | // Helper to wrap a schema in a document with title and optional description 6 | function makeSchema( 7 | schema: Partial, 8 | title = "Test", 9 | description?: string, 10 | ): JSONSchema { 11 | return { 12 | $schema: "http://json-schema.org/draft-07/schema#", 13 | title, 14 | description, 15 | ...schema, 16 | } as JSONSchema; 17 | } 18 | 19 | Deno.test("parses primitive types", () => { 20 | // String type 21 | assertEquals( 22 | parseSchema(makeSchema({ type: "string" })), 23 | { 24 | type: "doc", 25 | title: "Test", 26 | root: { type: "string", optional: false, name: "Test" }, 27 | definitions: {}, 28 | }, 29 | ); 30 | 31 | // Boolean type 32 | assertEquals( 33 | parseSchema(makeSchema({ type: "boolean" })), 34 | { 35 | type: "doc", 36 | title: "Test", 37 | root: { type: "boolean", optional: false, name: "Test" }, 38 | definitions: {}, 39 | }, 40 | ); 41 | 42 | // Integer type 43 | assertEquals( 44 | parseSchema(makeSchema({ type: "integer", minimum: 0, maximum: 100 })), 45 | { 46 | type: "doc", 47 | title: "Test", 48 | root: { type: "int", minimum: 0, maximum: 100, name: "Test" }, 49 | definitions: {}, 50 | }, 51 | ); 52 | 53 | // Float type 54 | assertEquals( 55 | parseSchema(makeSchema({ type: "number", format: "double" })), 56 | { 57 | type: "doc", 58 | title: "Test", 59 | root: { type: "float", name: "Test" }, 60 | definitions: {}, 61 | }, 62 | ); 63 | }); 64 | 65 | Deno.test("parses string enums", () => { 66 | // Single value enum becomes literal 67 | assertEquals( 68 | parseSchema(makeSchema({ type: "string", enum: ["test"] })), 69 | { 70 | type: "doc", 71 | title: "Test", 72 | root: { type: "literal", value: "test", name: "Test" }, 73 | definitions: {}, 74 | }, 75 | ); 76 | 77 | // Multiple values become union 78 | assertEquals( 79 | parseSchema(makeSchema({ type: "string", enum: ["a", "b"] })), 80 | { 81 | type: "doc", 82 | title: "Test", 83 | root: { 84 | type: "enum", 85 | name: "Test", 86 | members: ["a", "b"], 87 | }, 88 | definitions: {}, 89 | }, 90 | ); 91 | }); 92 | 93 | Deno.test("parses simple objects", () => { 94 | assertEquals( 95 | parseSchema(makeSchema({ 96 | type: "object", 97 | properties: { 98 | name: { type: "string" }, 99 | age: { type: "integer" }, 100 | }, 101 | required: ["name"], 102 | })), 103 | { 104 | type: "doc", 105 | title: "Test", 106 | root: { 107 | name: "Test", 108 | type: "object", 109 | properties: [ 110 | { 111 | key: "name", 112 | required: true, 113 | value: { type: "string", optional: false }, 114 | }, 115 | { 116 | key: "age", 117 | required: false, 118 | value: { 119 | type: "int", 120 | }, 121 | }, 122 | ], 123 | }, 124 | definitions: {}, 125 | }, 126 | ); 127 | }); 128 | 129 | Deno.test("parses discriminated unions", () => { 130 | assertEquals( 131 | parseSchema(makeSchema({ 132 | oneOf: [ 133 | { 134 | type: "object", 135 | required: ["$type"], 136 | properties: { 137 | $type: { type: "string", enum: ["a"] }, 138 | value: { type: "string" }, 139 | }, 140 | }, 141 | { 142 | type: "object", 143 | required: ["$type"], 144 | properties: { 145 | $type: { type: "string", enum: ["b"] }, 146 | count: { type: "integer" }, 147 | }, 148 | }, 149 | ], 150 | })), 151 | { 152 | type: "doc", 153 | title: "Test", 154 | root: { 155 | name: "Test", 156 | type: "descriminated-union", 157 | descriminator: "$type", 158 | members: { 159 | a: [{ 160 | value: { type: "string", optional: false }, 161 | required: false, 162 | key: "value", 163 | }], 164 | b: [{ 165 | key: "count", 166 | value: { type: "int" }, 167 | required: false, 168 | }], 169 | }, 170 | }, 171 | definitions: {}, 172 | }, 173 | ); 174 | }); 175 | 176 | Deno.test("parses references", () => { 177 | assertEquals( 178 | parseSchema(makeSchema({ 179 | definitions: { 180 | Point: { 181 | description: "A point in 2D space", 182 | type: "object", 183 | properties: { 184 | x: { type: "integer", description: "The x coordinate" }, 185 | y: { type: "integer", description: "The y coordinate" }, 186 | }, 187 | required: ["x", "y"], 188 | }, 189 | }, 190 | $ref: "#/definitions/Point", 191 | })), 192 | { 193 | type: "doc", 194 | title: "Test", 195 | root: { 196 | type: "reference", 197 | name: "Test", 198 | }, 199 | definitions: { 200 | Point: { 201 | name: "Point", 202 | description: "A point in 2D space", 203 | type: "object", 204 | properties: [ 205 | { 206 | description: "The x coordinate", 207 | key: "x", 208 | required: true, 209 | value: { 210 | type: "int", 211 | }, 212 | }, 213 | { 214 | description: "The y coordinate", 215 | key: "y", 216 | required: true, 217 | 218 | value: { 219 | type: "int", 220 | }, 221 | }, 222 | ], 223 | }, 224 | }, 225 | }, 226 | ); 227 | }); 228 | 229 | Deno.test("sorts definitions topologically", () => { 230 | assertEquals( 231 | parseSchema(makeSchema({ 232 | definitions: { 233 | Container: { 234 | type: "object", 235 | properties: { 236 | point: { $ref: "#/definitions/Point" }, 237 | size: { $ref: "#/definitions/Size" }, 238 | }, 239 | }, 240 | Size: { 241 | type: "object", 242 | properties: { 243 | width: { type: "integer" }, 244 | height: { type: "integer" }, 245 | }, 246 | }, 247 | Point: { 248 | type: "object", 249 | properties: { 250 | x: { type: "integer" }, 251 | y: { type: "integer" }, 252 | }, 253 | }, 254 | }, 255 | $ref: "#/definitions/Container", 256 | })), 257 | { 258 | type: "doc", 259 | title: "Test", 260 | root: { 261 | type: "reference", 262 | name: "Test", 263 | }, 264 | definitions: { 265 | // Point and Size should come before Container since Container depends on them 266 | Point: { 267 | name: "Point", 268 | type: "object", 269 | properties: [ 270 | { 271 | key: "x", 272 | required: false, 273 | value: { type: "int" }, 274 | }, 275 | { 276 | key: "y", 277 | required: false, 278 | value: { type: "int" }, 279 | }, 280 | ], 281 | }, 282 | Size: { 283 | name: "Size", 284 | type: "object", 285 | properties: [ 286 | { 287 | key: "width", 288 | required: false, 289 | value: { type: "int" }, 290 | }, 291 | { 292 | key: "height", 293 | required: false, 294 | value: { type: "int" }, 295 | }, 296 | ], 297 | }, 298 | Container: { 299 | name: "Container", 300 | type: "object", 301 | properties: [ 302 | { 303 | key: "point", 304 | required: false, 305 | value: { type: "reference", name: "Point" }, 306 | }, 307 | { 308 | key: "size", 309 | required: false, 310 | value: { type: "reference", name: "Size" }, 311 | }, 312 | ], 313 | }, 314 | }, 315 | }, 316 | ); 317 | }); 318 | 319 | Deno.test("detects circular references", () => { 320 | assertThrows( 321 | () => 322 | parseSchema(makeSchema({ 323 | definitions: { 324 | A: { 325 | type: "object", 326 | properties: { 327 | b: { $ref: "#/definitions/B" }, 328 | }, 329 | }, 330 | B: { 331 | type: "object", 332 | properties: { 333 | a: { $ref: "#/definitions/A" }, 334 | }, 335 | }, 336 | }, 337 | $ref: "#/definitions/A", 338 | })), 339 | Error, 340 | "Circular reference detected", 341 | ); 342 | }); 343 | -------------------------------------------------------------------------------- /scripts/generate-schema/parser.ts: -------------------------------------------------------------------------------- 1 | import { match, P } from "npm:ts-pattern"; 2 | import type { JSONSchema, JSONSchemaTypeName } from "../../json-schema.d.ts"; 3 | import { assert } from "jsr:@std/assert"; 4 | 5 | // defining an IR 6 | export interface Doc { 7 | type: "doc"; 8 | title: string; 9 | description?: string; 10 | root: Node; 11 | definitions: Record; 12 | } 13 | export type Node = 14 | & { name?: string; description?: string } 15 | & ( 16 | | { 17 | type: "descriminated-union"; 18 | name?: string; 19 | descriminator: string; 20 | members: Record; 26 | } 27 | | { 28 | type: "object"; 29 | name?: string; 30 | properties: { 31 | key: string; 32 | required: boolean; 33 | description?: string; 34 | value: Node; 35 | }[]; 36 | } 37 | | { type: "intersection"; name?: string; members: Node[] } 38 | | { type: "union"; name?: string; members: Node[] } 39 | | { type: "enum"; members: string[] } 40 | | { type: "record"; valueType: string } 41 | | { type: "boolean"; optional?: boolean } 42 | | { type: "string"; optional?: boolean } 43 | | { type: "literal"; value: string } 44 | | { type: "int"; minimum?: number; maximum?: number } 45 | | { type: "float"; minimum?: number; maximum?: number } 46 | | { type: "reference"; name: string } 47 | | { type: "unknown" } 48 | ); 49 | 50 | // Find all references in a node recursively 51 | function findReferences(node: Node): Set { 52 | const refs = new Set(); 53 | 54 | if (node.type === "reference") { 55 | refs.add(node.name); 56 | } else if (node.type === "descriminated-union") { 57 | for (const member of Object.values(node.members).flat()) { 58 | for (const ref of findReferences(member.value)) { 59 | refs.add(ref); 60 | } 61 | } 62 | } else if ( 63 | node.type === "union" || 64 | node.type === "intersection" 65 | ) { 66 | for (const member of node.members) { 67 | for (const ref of findReferences(member)) { 68 | refs.add(ref); 69 | } 70 | } 71 | } else if (node.type === "object") { 72 | for (const prop of node.properties) { 73 | for (const ref of findReferences(prop.value)) { 74 | refs.add(ref); 75 | } 76 | } 77 | } 78 | 79 | return refs; 80 | } 81 | 82 | // Detect cycles in the dependency graph 83 | function detectCycle( 84 | graph: Map>, 85 | node: string, 86 | visited: Set, 87 | path: Set, 88 | ): string[] | null { 89 | if (path.has(node)) { 90 | const cycle = Array.from(path); 91 | const startIdx = cycle.indexOf(node); 92 | return cycle.slice(startIdx).concat(node); 93 | } 94 | 95 | if (visited.has(node)) return null; 96 | 97 | visited.add(node); 98 | path.add(node); 99 | 100 | const deps = graph.get(node) || new Set(); 101 | for (const dep of deps) { 102 | const cycle = detectCycle(graph, dep, visited, path); 103 | if (cycle) return cycle; 104 | } 105 | 106 | path.delete(node); 107 | return null; 108 | } 109 | 110 | // Sort definitions topologically 111 | function sortDefinitions( 112 | definitions: Record, 113 | ): Record { 114 | // Build dependency graph 115 | const graph = new Map>(); 116 | for (const [name, def] of Object.entries(definitions)) { 117 | graph.set(name, findReferences(def)); 118 | } 119 | 120 | // Check for cycles 121 | const cycle = detectCycle( 122 | graph, 123 | Object.keys(definitions)[0], 124 | new Set(), 125 | new Set(), 126 | ); 127 | if (cycle) { 128 | throw new Error(`Circular reference detected: ${cycle.join(" -> ")}`); 129 | } 130 | 131 | // Topological sort 132 | const sorted: string[] = []; 133 | const visited = new Set(); 134 | 135 | function visit(name: string) { 136 | if (visited.has(name)) return; 137 | visited.add(name); 138 | 139 | const deps = graph.get(name) || new Set(); 140 | for (const dep of deps) { 141 | visit(dep); 142 | } 143 | 144 | sorted.push(name); 145 | } 146 | 147 | for (const name of Object.keys(definitions)) { 148 | visit(name); 149 | } 150 | 151 | // Reconstruct definitions object in sorted order 152 | return Object.fromEntries( 153 | sorted.map((name) => [name, { ...definitions[name], name }]), 154 | ); 155 | } 156 | 157 | export const isComplexType = (node: Node) => { 158 | return node.type === "descriminated-union" || node.type === "union" || 159 | node.type === "intersection" || node.type === "object"; 160 | }; 161 | 162 | const isDescriminatedUnion = (def: JSONSchema[] | undefined) => { 163 | return def?.[0]?.required?.[0]?.startsWith("$"); 164 | }; 165 | 166 | const isOptionalType = 167 | (typeOf: string) => 168 | (type: JSONSchemaTypeName | JSONSchemaTypeName[] | undefined) => { 169 | if (type && Array.isArray(type) && type[0] === typeOf) { 170 | return true; 171 | } 172 | return false; 173 | }; 174 | 175 | const flattenUnion = (union: Node[], member: Node) => { 176 | if (member.type === "union") { 177 | return union.concat(member.members); 178 | } 179 | return union.concat(member); 180 | }; 181 | 182 | export function parseSchema(schema: JSONSchema): Doc { 183 | const nodeToIR = (node: JSONSchema): Node => { 184 | return match(node) 185 | .with({ $ref: P.string }, (node) => ({ 186 | type: "reference" as const, 187 | name: node.$ref.split("/").pop()!, 188 | })) 189 | .with( 190 | { 191 | type: P.union("boolean", P.when(isOptionalType("boolean"))), 192 | }, 193 | (node) => 194 | ({ 195 | type: "boolean" as const, 196 | optional: !!node.default, 197 | }) as const, 198 | ) 199 | .with({ type: "integer" }, (node) => ({ 200 | type: "int" as const, 201 | ...(node.minimum !== undefined && { minimum: node.minimum }), 202 | ...(node.maximum !== undefined && { maximum: node.maximum }), 203 | })) 204 | .with({ type: "number", format: "double" }, (node) => ({ 205 | type: "float" as const, 206 | ...(node.minimum !== undefined && { minimum: node.minimum }), 207 | ...(node.maximum !== undefined && { maximum: node.maximum }), 208 | })) 209 | .with( 210 | { type: P.union("string", P.when(isOptionalType("string"))) }, 211 | (node) => { 212 | const isOptional = 213 | Array.isArray(node.type) && node.type.includes("null") || false; 214 | if (node.enum) { 215 | if (node.enum.length === 1) { 216 | return { 217 | type: "literal" as const, 218 | value: node.enum[0] as string, 219 | }; 220 | } 221 | return { 222 | type: "enum" as const, 223 | members: node.enum as string[], 224 | }; 225 | } 226 | return ({ 227 | type: "string" as const, 228 | optional: !!node.default || isOptional, 229 | }); 230 | }, 231 | ) 232 | .with( 233 | { oneOf: P.when(isDescriminatedUnion) }, 234 | (node) => { 235 | const descriminator = (node.oneOf?.[0] as JSONSchema).required?.[0]!; 236 | return ({ 237 | type: "descriminated-union" as const, 238 | descriminator, 239 | members: Object.fromEntries( 240 | node.oneOf?.map((v) => { 241 | assert( 242 | v.type === "object", 243 | "Descriminated union must have an object member", 244 | ); 245 | assert( 246 | v.properties, 247 | "Descriminated union arms must have properties", 248 | ); 249 | const name = v.properties[descriminator]?.enum?.[0] as string; 250 | delete v.properties[descriminator]; 251 | assert(name, "Descriminated union must have a name"); 252 | return [ 253 | name, 254 | Object.entries(v.properties ?? {}).map(( 255 | [key, value], 256 | ) => ({ 257 | key, 258 | required: v.required?.includes(key) ?? false, 259 | ...(value.description && 260 | { description: value.description }), 261 | value: nodeToIR(value as JSONSchema), 262 | })), 263 | ]; 264 | }) ?? [], 265 | ), 266 | }); 267 | }, 268 | ) 269 | .with({ allOf: P.array() }, (node) => { 270 | if (node.allOf?.length === 1) { 271 | return nodeToIR(node.allOf[0] as JSONSchema); 272 | } 273 | return { 274 | type: "intersection" as const, 275 | members: node.allOf?.map((v) => nodeToIR(v as JSONSchema)) ?? [], 276 | }; 277 | }) 278 | .with( 279 | P.union({ oneOf: P.array() }, { anyOf: P.array() }), 280 | (node) => { 281 | if ( 282 | node.anyOf && node.anyOf.length === 2 && 283 | node.anyOf[1].type === "null" 284 | ) { 285 | return nodeToIR(node.anyOf[0] as JSONSchema); 286 | } 287 | const union = { 288 | type: "union" as const, 289 | members: 290 | ((node.oneOf ?? node.anyOf)?.map((v) => 291 | nodeToIR(v as JSONSchema) 292 | ) ?? []) 293 | .filter((v) => v.type !== "unknown") 294 | .reduce(flattenUnion, [] as Node[]), 295 | }; 296 | if (node.properties) { 297 | return ({ 298 | type: "intersection" as const, 299 | members: [ 300 | { 301 | type: "object" as const, 302 | properties: Object.entries(node.properties ?? {}).map(( 303 | [key, value], 304 | ) => ({ 305 | key, 306 | required: node.required?.includes(key) ?? false, 307 | ...(value.description && 308 | { description: value.description }), 309 | value: nodeToIR(value as JSONSchema), 310 | })), 311 | }, 312 | union, 313 | ], 314 | }); 315 | } 316 | return union; 317 | }, 318 | ) 319 | .with( 320 | { type: P.union("object", P.when(isOptionalType("object"))) }, 321 | () => { 322 | if (Object.keys(node.properties ?? {}).length === 0) { 323 | if ( 324 | typeof node.additionalProperties === "object" && 325 | "type" in node.additionalProperties 326 | ) { 327 | return { 328 | type: "record" as const, 329 | ...(node.description && { description: node.description }), 330 | valueType: typeof node.additionalProperties.type === "string" 331 | ? node.additionalProperties.type 332 | : "unknown", 333 | }; 334 | } 335 | return { 336 | type: "record" as const, 337 | ...(node.description && { description: node.description }), 338 | valueType: "unknown", 339 | }; 340 | } 341 | return ({ 342 | type: "object" as const, 343 | ...(node.description && { description: node.description }), 344 | properties: Object.entries(node.properties ?? {}).map(( 345 | [key, value], 346 | ) => ({ 347 | key, 348 | required: Boolean( 349 | node.required?.includes(key) || 350 | "anyOf" in value && 351 | !value.anyOf?.some((v) => v.type === "null"), 352 | ) || false, 353 | ...(value.description && { description: value.description }), 354 | value: nodeToIR(value as JSONSchema), 355 | })), 356 | }); 357 | }, 358 | ) 359 | .otherwise(() => ({ type: "unknown" })); 360 | }; 361 | 362 | const definitions = Object.fromEntries( 363 | Object.entries(schema.definitions ?? {}).map(( 364 | [name, type], 365 | ) => [name, { 366 | ...(type.description && { 367 | description: type.description, 368 | name, 369 | }), 370 | ...nodeToIR(type as JSONSchema), 371 | }]), 372 | ) ?? {}; 373 | 374 | return { 375 | type: "doc", 376 | title: schema.title!, 377 | ...(schema.description && { description: schema.description }), 378 | root: { ...nodeToIR(schema), name: schema.title! }, 379 | definitions: sortDefinitions(definitions), 380 | }; 381 | } 382 | -------------------------------------------------------------------------------- /scripts/generate-schema/printer.ts: -------------------------------------------------------------------------------- 1 | import { match } from "npm:ts-pattern"; 2 | import { Doc, Node } from "./parser.ts"; 3 | import { 4 | blue, 5 | bold, 6 | dimmed, 7 | type Formatter, 8 | green, 9 | mix, 10 | yellow, 11 | } from "jsr:@coven/terminal"; 12 | 13 | const comment = mix(yellow, dimmed); 14 | const string = green; 15 | const type = blue; 16 | const kind = bold; 17 | 18 | function wrapText( 19 | text: string, 20 | maxLength: number, 21 | indent: string, 22 | formatter?: Formatter, 23 | ): string[] { 24 | const words = text.split(" "); 25 | const lines: string[] = []; 26 | let currentLine = ""; 27 | 28 | for (const word of words) { 29 | if (currentLine.length + word.length + 1 <= maxLength) { 30 | currentLine += (currentLine.length === 0 ? "" : " ") + word; 31 | } else { 32 | lines.push(currentLine); 33 | currentLine = word; 34 | } 35 | } 36 | if (currentLine.length > 0) { 37 | lines.push(currentLine); 38 | } 39 | return lines 40 | .map((line) => formatter ? formatter`${line}` : line) 41 | .map((line, i) => i === 0 ? line : indent + line); 42 | } 43 | 44 | function printNodeIR( 45 | node: Node, 46 | prefix: string = "", 47 | isLast: boolean = true, 48 | ): string { 49 | const marker = isLast ? "└── " : "├── "; 50 | const childPrefix = prefix + (isLast ? " " : "│ "); 51 | 52 | return prefix + marker + match(node) 53 | .with({ type: "reference" }, ({ name }) => kind`${name}\n`) 54 | .with({ type: "descriminated-union" }, ({ descriminator, members }) => { 55 | let output = kind`discriminated-union` + ` (by ${descriminator})\n`; 56 | Object.entries(members).forEach(([name, properties], index) => { 57 | output += printNodeIR( 58 | { type: "object", name, properties }, 59 | childPrefix, 60 | index === Object.values(members).length - 1, 61 | ); 62 | }); 63 | return output; 64 | }) 65 | .with({ type: "intersection" }, ({ members }) => { 66 | let output = kind`intersection\n`; 67 | members.forEach((member, index) => { 68 | output += printNodeIR( 69 | member, 70 | childPrefix, 71 | index === members.length - 1, 72 | ); 73 | }); 74 | return output; 75 | }) 76 | .with({ type: "union" }, ({ members }) => { 77 | let output = kind`union\n`; 78 | members.forEach((member, index) => { 79 | output += printNodeIR( 80 | member, 81 | childPrefix, 82 | index === members.length - 1, 83 | ); 84 | }); 85 | return output; 86 | }) 87 | .with({ type: "object" }, ({ name, properties }) => { 88 | let output = kind`${name ?? "object"}\n`; 89 | properties.forEach(({ key, required, value, description }, index) => { 90 | description = description?.split("\n")[0]; 91 | const propDesc = `${key}${required ? "" : "?"}: `; 92 | 93 | if (description) { 94 | const wrappedDesc = wrapText( 95 | description, 96 | 96, 97 | childPrefix + "│ ", 98 | comment, 99 | ) 100 | .join("\n"); 101 | output += childPrefix + "│ " + `${wrappedDesc}\n`; 102 | output += childPrefix + 103 | (index === properties.length - 1 ? "└── " : "├── "); 104 | } else { 105 | output += childPrefix + 106 | (index === properties.length - 1 ? "└── " : "├── "); 107 | } 108 | 109 | const valueStr = match(value) 110 | .with( 111 | { type: "boolean" }, 112 | ({ optional }) => type`boolean${optional ? "?" : ""}`, 113 | ) 114 | .with( 115 | { type: "string" }, 116 | ({ optional }) => type`string${optional ? "?" : ""}`, 117 | ) 118 | .with({ type: "literal" }, ({ value }) => string`"${value}"`) 119 | .with( 120 | { type: "int" }, 121 | ({ minimum, maximum }) => 122 | type`int${minimum !== undefined ? ` min(${minimum})` : ""}${ 123 | maximum !== undefined ? ` max(${maximum})` : "" 124 | }`, 125 | ) 126 | .with( 127 | { type: "float" }, 128 | ({ minimum, maximum }) => 129 | type`float${minimum !== undefined ? ` min(${minimum})` : ""}${ 130 | maximum !== undefined ? ` max(${maximum})` : "" 131 | }`, 132 | ) 133 | .with( 134 | { type: "record" }, 135 | ({ valueType }) => type`record`, 136 | ) 137 | .with({ type: "unknown" }, () => "unknown") 138 | .with({ type: "reference" }, ({ name }) => name) 139 | .otherwise(() => { 140 | output += propDesc + "\n"; 141 | return printNodeIR( 142 | value, 143 | childPrefix + (index === properties.length - 1 ? " " : "│ "), 144 | true, 145 | ); 146 | }); 147 | 148 | if ( 149 | value.type === "union" || value.type === "intersection" || 150 | value.type === "descriminated-union" || value.type === "object" 151 | ) { 152 | output += valueStr; 153 | } else { 154 | output += propDesc + valueStr + "\n"; 155 | } 156 | }); 157 | return output; 158 | }) 159 | .with({ type: "enum" }, ({ members }) => { 160 | return kind`enum` + `[${members.join(",")}]\n`; 161 | }) 162 | .with( 163 | { type: "record" }, 164 | ({ valueType }) => `record\n`, 165 | ) 166 | .with( 167 | { type: "boolean" }, 168 | ({ optional }) => `boolean${optional ? "?" : ""}\n`, 169 | ) 170 | .with( 171 | { type: "string" }, 172 | ({ optional }) => `string${optional ? "?" : ""}\n`, 173 | ) 174 | .with({ type: "literal" }, ({ value }) => `"${value}"\n`) 175 | .with( 176 | { type: "int" }, 177 | ({ minimum, maximum }) => 178 | `int${minimum !== undefined ? ` min(${minimum})` : ""}${ 179 | maximum !== undefined ? ` max(${maximum})` : "" 180 | }\n`, 181 | ) 182 | .with( 183 | { type: "float" }, 184 | ({ minimum, maximum }) => 185 | `float${minimum !== undefined ? ` min(${minimum})` : ""}${ 186 | maximum !== undefined ? ` max(${maximum})` : "" 187 | }\n`, 188 | ) 189 | .with({ type: "unknown" }, () => "unknown\n") 190 | .exhaustive(); 191 | } 192 | 193 | export function printDocIR(doc: Doc): string { 194 | let result = `${doc.title}\n`; 195 | const description = doc.description?.split("\n")[0]; 196 | if (description) { 197 | const wrappedDesc = wrapText(description, 96, "│ description: ", comment) 198 | .join("\n"); 199 | result += "│ description: " + `${wrappedDesc}\n`; 200 | } 201 | result += printNodeIR(doc.root, "", true); 202 | for (const [name, definition] of Object.entries(doc.definitions)) { 203 | result += `${name}\n`; 204 | const description = definition.description?.split("\n")[0]; 205 | if (description) { 206 | const wrappedDesc = wrapText( 207 | description, 208 | 96, 209 | "│ description: ", 210 | comment, 211 | ).join( 212 | "\n", 213 | ); 214 | result += "│ description: " + `${wrappedDesc}\n`; 215 | } 216 | result += printNodeIR(definition, "", true); 217 | } 218 | return result; 219 | } 220 | -------------------------------------------------------------------------------- /scripts/sync-versions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Synchronizes version numbers across the project: 3 | * - Updates BIN_VERSION in Deno client (main.ts) 4 | * - Updates package version in Python client (pyproject.toml) 5 | * - Updates BIN_VERSION in Python client (__init__.py) 6 | * 7 | * All versions are synchronized with the main version from Cargo.toml 8 | */ 9 | 10 | import { parse } from "jsr:@std/toml"; 11 | 12 | // Read the source version from Cargo.toml 13 | const latestVersion = await Deno 14 | .readTextFile("./Cargo.toml").then((text) => 15 | parse(text) as { package: { version: string } } 16 | ).then((config) => config.package.version); 17 | 18 | // ===== Update Deno Client Version ===== 19 | const denoPath = "./src/clients/deno/main.ts"; 20 | const denoContent = await Deno.readTextFile(denoPath); 21 | 22 | const updatedDenoContent = denoContent.replace( 23 | /const BIN_VERSION = "[^"]+"/, 24 | `const BIN_VERSION = "${latestVersion}"`, 25 | ); 26 | 27 | await Deno.writeTextFile(denoPath, updatedDenoContent); 28 | console.log(`✓ Updated Deno BIN_VERSION to ${latestVersion}`); 29 | 30 | // ===== Update Python Client BIN_VERSION ===== 31 | const pythonInitPath = "./src/clients/python/src/justbe_webview/__init__.py"; 32 | const pythonInitContent = await Deno.readTextFile(pythonInitPath); 33 | 34 | const updatedPythonInitContent = pythonInitContent.replace( 35 | /BIN_VERSION = "[^"]+"/, 36 | `BIN_VERSION = "${latestVersion}"`, 37 | ); 38 | 39 | await Deno.writeTextFile(pythonInitPath, updatedPythonInitContent); 40 | console.log(`✓ Updated Python BIN_VERSION to ${latestVersion}`); 41 | 42 | console.log(`\n🎉 Successfully synchronized all versions to ${latestVersion}`); 43 | -------------------------------------------------------------------------------- /sg/rules/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraph/webview/f237514c9565568849c3accbf46f8cc506103785/sg/rules/.gitkeep -------------------------------------------------------------------------------- /sg/rules/no-mixed-enums.yml: -------------------------------------------------------------------------------- 1 | id: no-mixed-enum 2 | language: rust 3 | severity: error 4 | message: Don't mix call style and object style enum variants 5 | rule: 6 | matches: enum 7 | all: 8 | - has: 9 | stopBy: end 10 | all: 11 | - kind: field_declaration_list 12 | - has: 13 | stopBy: end 14 | all: 15 | - kind: ordered_field_declaration_list 16 | utils: 17 | enum: 18 | any: 19 | - pattern: enum $VARIANT{$$$BODY} 20 | - pattern: pub enum $VARIANT{$$$BODY} 21 | -------------------------------------------------------------------------------- /sgconfig.yml: -------------------------------------------------------------------------------- 1 | ruleDirs: 2 | - ./sg/rules 3 | -------------------------------------------------------------------------------- /src/bin/generate_schemas.rs: -------------------------------------------------------------------------------- 1 | use schemars::schema_for; 2 | use std::fs::File; 3 | use std::io::Write; 4 | use webview::{Message, Options, Request, Response}; 5 | 6 | fn main() { 7 | let schemas = [ 8 | ("WebViewOptions", schema_for!(Options)), 9 | ("WebViewMessage", schema_for!(Message)), 10 | ("WebViewRequest", schema_for!(Request)), 11 | ("WebViewResponse", schema_for!(Response)), 12 | ]; 13 | 14 | for (name, schema) in schemas { 15 | let schema_json = serde_json::to_string_pretty(&schema).unwrap(); 16 | let mut file = File::create(format!("schemas/{}.json", name)).unwrap(); 17 | file.write_all(schema_json.as_bytes()).unwrap(); 18 | println!("Generated schema for {}", name); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/bin/webview.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use tracing::error; 3 | use webview::{run, Options}; 4 | 5 | fn main() { 6 | let subscriber = tracing_subscriber::fmt() 7 | .with_env_filter(env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())) 8 | .with_writer(std::io::stderr) 9 | .finish(); 10 | tracing::subscriber::set_global_default(subscriber).unwrap(); 11 | 12 | let args: Vec = env::args().collect(); 13 | 14 | let webview_options: Options = match serde_json::from_str(&args[1]) { 15 | Ok(options) => options, 16 | Err(e) => { 17 | error!("Failed to parse webview options: {:?}", e); 18 | std::process::exit(1); 19 | } 20 | }; 21 | 22 | if let Err(e) = run(webview_options) { 23 | error!("Webview error: {:?}", e); 24 | std::process::exit(1); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/clients/deno/README.md: -------------------------------------------------------------------------------- 1 | # @justbe/webview Deno Client 2 | 3 | A light, cross-platform library for building web-based desktop apps with 4 | [Deno](https://deno.com/). 5 | 6 | ## Installation 7 | 8 | ```typescript 9 | import { createWebView } from "jsr:@justbe/webview"; 10 | ``` 11 | 12 | ## Example 13 | 14 | ```typescript 15 | import { createWebView } from "jsr:@justbe/webview"; 16 | 17 | using webview = await createWebView({ 18 | title: "Example", 19 | html: "

Hello, World!

", 20 | devtools: true, 21 | }); 22 | 23 | webview.on("started", async () => { 24 | await webview.openDevTools(); 25 | await webview.eval("console.log('This is printed from eval!')"); 26 | }); 27 | 28 | await webview.waitUntilClosed(); 29 | ``` 30 | 31 | You can run this yourself with: 32 | 33 | ```sh 34 | deno run https://raw.githubusercontent.com/zephraph/webview/refs/heads/main/examples/simple.ts 35 | ``` 36 | 37 | Check out the [examples directory](examples/) for more examples. 38 | 39 | ## Permissions 40 | 41 | When executing this package, it checks to see if you have the required binary 42 | for interfacing with the OS's webview. If it doesn't exist, it downloads it to a 43 | cache directory and executes it. This yields a few different permission code 44 | paths to be aware of. 45 | 46 | ### Binary not in cache 47 | 48 | This will be true of a first run of the package. These are the following 49 | permission requests you can expect to see: 50 | 51 | - Read HOME env -- Used to locate the cache directory 52 | - Read /webview/webview- -- Tries to read the binary from cache 53 | - net to github.com:443 -- Connects to GitHub releases to try to download the 54 | binary (will be redirected) 55 | - net to objects.githubusercontent.com:443 -- GitHub's CDN for the actual 56 | download 57 | - Read /webview/ -- Reads the cache directory 58 | - Write /webview/webview- -- Writes the binary 59 | - Run /webview/webview- -- Runs the binary 60 | 61 | ### Binary cached 62 | 63 | On subsequent runs you can expect fewer permission requests: 64 | 65 | - Read HOME env -- Use to locate the cache directory 66 | - Read /deno-webview/deno-webview- 67 | - Run /deno-webview/deno-webview- 68 | 69 | ### Using a Custom Binary 70 | 71 | You can specify a custom binary path using the `WEBVIEW_BIN` environment 72 | variable. When set and allowed, this will bypass the default binary resolution 73 | process. In this case, only one permission is needed: 74 | 75 | - Run 76 | 77 | Note that this environment variable will never be _explicitly_ requested. If the 78 | script detects it's not allowed to read this env var it just skips this code 79 | path altogether. 80 | -------------------------------------------------------------------------------- /src/clients/deno/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@justbe/webview", 3 | "exports": "./main.ts", 4 | "license": "MIT", 5 | "version": "1.0.1-rc.2", 6 | "publish": { 7 | "include": ["README.md", "LICENSE", "*.ts", "schemas/*.ts"] 8 | }, 9 | "imports": { 10 | "ts-pattern": "jsr:@gabriel/ts-pattern@^5.6.2", 11 | "tracing": "jsr:@bcheidemann/tracing@^0.6.3", 12 | "jsr:@std/fs": "jsr:@std/fs@^1.0.3", 13 | "jsr:@std/path": "jsr:@std/path@^1.0.6", 14 | "npm:zod": "npm:zod@^3.23.8", 15 | "npm:type-fest": "npm:type-fest@^4.26.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/clients/deno/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4", 3 | "specifiers": { 4 | "jsr:@bcheidemann/parse-params@0.5": "0.5.0", 5 | "jsr:@bcheidemann/tracing@~0.6.3": "0.6.3", 6 | "jsr:@gabriel/ts-pattern@^5.6.2": "5.6.2", 7 | "jsr:@std/assert@0.226": "0.226.0", 8 | "jsr:@std/assert@0.226.0": "0.226.0", 9 | "jsr:@std/fmt@~0.225.4": "0.225.6", 10 | "jsr:@std/fs@^1.0.3": "1.0.6", 11 | "jsr:@std/internal@1": "1.0.5", 12 | "jsr:@std/path@^1.0.6": "1.0.8", 13 | "jsr:@std/path@^1.0.8": "1.0.8", 14 | "npm:acorn@8.12.0": "8.12.0", 15 | "npm:type-fest@^4.26.1": "4.30.2", 16 | "npm:zod@^3.23.8": "3.23.8" 17 | }, 18 | "jsr": { 19 | "@bcheidemann/parse-params@0.5.0": { 20 | "integrity": "a13a95163fad0cbd34890cb5d43df3466cc96e97e76674052592a90bbf56bbab", 21 | "dependencies": [ 22 | "jsr:@std/assert@0.226.0", 23 | "npm:acorn" 24 | ] 25 | }, 26 | "@bcheidemann/tracing@0.6.3": { 27 | "integrity": "d6f38e77ef142a88b9eaeff3a2df54a8a24b2226fff747119ddc40726a05abeb", 28 | "dependencies": [ 29 | "jsr:@bcheidemann/parse-params", 30 | "jsr:@std/assert@0.226", 31 | "jsr:@std/fmt" 32 | ] 33 | }, 34 | "@gabriel/ts-pattern@5.6.2": { 35 | "integrity": "49a1069523e2ba53d024d6b17a30029f8ffc80b24fec86d724d986a9774a7197" 36 | }, 37 | "@std/assert@0.226.0": { 38 | "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3", 39 | "dependencies": [ 40 | "jsr:@std/internal" 41 | ] 42 | }, 43 | "@std/fmt@0.225.6": { 44 | "integrity": "aba6aea27f66813cecfd9484e074a9e9845782ab0685c030e453a8a70b37afc8" 45 | }, 46 | "@std/fs@1.0.6": { 47 | "integrity": "42b56e1e41b75583a21d5a37f6a6a27de9f510bcd36c0c85791d685ca0b85fa2", 48 | "dependencies": [ 49 | "jsr:@std/path@^1.0.8" 50 | ] 51 | }, 52 | "@std/internal@1.0.5": { 53 | "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" 54 | }, 55 | "@std/path@1.0.8": { 56 | "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" 57 | } 58 | }, 59 | "npm": { 60 | "acorn@8.12.0": { 61 | "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==" 62 | }, 63 | "type-fest@4.30.2": { 64 | "integrity": "sha512-UJShLPYi1aWqCdq9HycOL/gwsuqda1OISdBO3t8RlXQC4QvtuIz4b5FCfe2dQIWEpmlRExKmcTBfP1r9bhY7ig==" 65 | }, 66 | "zod@3.23.8": { 67 | "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" 68 | } 69 | }, 70 | "remote": { 71 | "https://deno.land/x/esbuild@v0.24.0/wasm.js": "5cd1dd0c40214d06bd86177b4ffebfbb219a22114f78c14c23606f7ad216c174" 72 | }, 73 | "workspace": { 74 | "dependencies": [ 75 | "jsr:@bcheidemann/tracing@~0.6.3", 76 | "jsr:@gabriel/ts-pattern@^5.6.2", 77 | "jsr:@std/fs@^1.0.3", 78 | "jsr:@std/path@^1.0.6", 79 | "npm:type-fest@^4.26.1", 80 | "npm:zod@^3.23.8" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/clients/deno/examples/ipc.ts: -------------------------------------------------------------------------------- 1 | import { createWebView } from "../main.ts"; 2 | 3 | using webview = await createWebView({ 4 | title: "Simple", 5 | load: { 6 | html: 7 | '', 8 | }, 9 | ipc: true, 10 | }); 11 | 12 | webview.on("ipc", ({ message }) => { 13 | console.log(message); 14 | }); 15 | 16 | await webview.waitUntilClosed(); 17 | -------------------------------------------------------------------------------- /src/clients/deno/examples/load-html.ts: -------------------------------------------------------------------------------- 1 | import { createWebView } from "../main.ts"; 2 | 3 | using webview = await createWebView({ 4 | title: "Load Html Example", 5 | load: { 6 | html: "

Initial html

", 7 | // Note: This origin is used with a custom protocol so it doesn't match 8 | // https://example.com. This doesn't need to be set, but can be useful if 9 | // you want to control resources that are scoped to a specific origin like 10 | // local storage or indexeddb. 11 | origin: "example.com", 12 | }, 13 | devtools: true, 14 | }); 15 | 16 | webview.on("started", async () => { 17 | await webview.openDevTools(); 18 | await webview.loadHtml("

Updated html!

"); 19 | }); 20 | 21 | await webview.waitUntilClosed(); 22 | -------------------------------------------------------------------------------- /src/clients/deno/examples/load-url.ts: -------------------------------------------------------------------------------- 1 | import { createWebView } from "../main.ts"; 2 | 3 | using webview = await createWebView({ 4 | title: "Load Url Example", 5 | load: { 6 | url: "https://example.com", 7 | headers: { 8 | "Content-Type": "text/html", 9 | }, 10 | }, 11 | userAgent: "curl/7.81.0", 12 | devtools: true, 13 | }); 14 | 15 | webview.on("started", async () => { 16 | await webview.openDevTools(); 17 | await sleep(2000); 18 | await webview.loadUrl("https://val.town/", { 19 | "Content-Type": "text/html", 20 | }); 21 | }); 22 | 23 | await webview.waitUntilClosed(); 24 | 25 | function sleep(ms: number) { 26 | return new Promise((resolve) => setTimeout(resolve, ms)); 27 | } 28 | -------------------------------------------------------------------------------- /src/clients/deno/examples/simple.ts: -------------------------------------------------------------------------------- 1 | import { createWebView } from "../main.ts"; 2 | 3 | using webview = await createWebView({ 4 | title: "Simple", 5 | devtools: true, 6 | load: { 7 | html: "

Hello, World!

", 8 | }, 9 | initializationScript: 10 | "console.log('This is printed from initializationScript!')", 11 | }); 12 | 13 | webview.on("started", async () => { 14 | await webview.setTitle("Title set from Deno"); 15 | await webview.getTitle(); 16 | await webview.openDevTools(); 17 | await webview.eval("console.log('This is printed from eval!')"); 18 | }); 19 | 20 | await webview.waitUntilClosed(); 21 | -------------------------------------------------------------------------------- /src/clients/deno/examples/tldraw.ts: -------------------------------------------------------------------------------- 1 | import { createWebView } from "../main.ts"; 2 | import * as esbuild from "https://deno.land/x/esbuild@v0.24.0/wasm.js"; 3 | 4 | const tldrawApp = ` 5 | import { Tldraw } from "tldraw"; 6 | import { createRoot } from "react-dom/client"; 7 | 8 | function App() { 9 | return ( 10 | <> 11 |
12 | 13 |
14 | 15 | ); 16 | } 17 | 18 | createRoot(document.querySelector("main")).render(); 19 | `; 20 | 21 | const app = await esbuild.transform(tldrawApp, { 22 | loader: "jsx", 23 | jsx: "automatic", 24 | target: "esnext", 25 | format: "esm", 26 | minify: false, 27 | sourcemap: false, 28 | }); 29 | 30 | using webview = await createWebView({ 31 | title: "TLDraw", 32 | load: { 33 | html: ` 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 | 53 | 56 | 57 | 58 | `, 59 | }, 60 | }); 61 | 62 | await webview.waitUntilClosed(); 63 | -------------------------------------------------------------------------------- /src/clients/deno/examples/window-size.ts: -------------------------------------------------------------------------------- 1 | import { createWebView } from "../main.ts"; 2 | 3 | using webview = await createWebView({ 4 | title: "Window Size", 5 | load: { 6 | html: ` 7 |

Window Sizes

8 |
9 | 10 | 11 | 12 |
13 | `, 14 | }, 15 | size: { 16 | height: 200, 17 | width: 800, 18 | }, 19 | ipc: true, 20 | }); 21 | 22 | webview.on("ipc", ({ message }) => { 23 | switch (message) { 24 | case "maximize": 25 | webview.maximize(); 26 | break; 27 | case "minimize": 28 | webview.minimize(); 29 | break; 30 | case "fullscreen": 31 | webview.fullscreen(); 32 | break; 33 | default: 34 | console.error("Unknown message", message); 35 | } 36 | }); 37 | 38 | await webview.waitUntilClosed(); 39 | -------------------------------------------------------------------------------- /src/clients/deno/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A library for creating and interacting with native webview windows. 3 | * 4 | * @module 5 | * 6 | * @example 7 | * ```ts 8 | * import { createWebView } from "jsr:@justbe/webview"; 9 | * 10 | * using webview = await createWebView({ 11 | * title: "Example", 12 | * html: "

Hello, World!

", 13 | * devtools: true 14 | * }); 15 | * 16 | * webview.on("started", async () => { 17 | * await webview.openDevTools(); 18 | * await webview.eval("console.log('This is printed from eval!')"); 19 | * }); 20 | * 21 | * await webview.waitUntilClosed(); 22 | * ``` 23 | */ 24 | 25 | import { EventEmitter } from "node:events"; 26 | import { 27 | Message, 28 | type Options, 29 | type Request as WebViewRequest, 30 | Response as WebViewResponse, 31 | } from "./schemas.ts"; 32 | import type { Except, Simplify } from "npm:type-fest"; 33 | import { join } from "jsr:@std/path"; 34 | import { ensureDir, exists } from "jsr:@std/fs"; 35 | import { error, FmtSubscriber, instrument, Level, trace, warn } from "tracing"; 36 | import { match, P } from "ts-pattern"; 37 | 38 | export * from "./schemas.ts"; 39 | 40 | if ( 41 | Deno.permissions.querySync({ name: "env", variable: "LOG_LEVEL" }).state === 42 | "granted" 43 | ) { 44 | const level = match(Deno.env.get("LOG_LEVEL")) 45 | .with("trace", () => Level.TRACE) 46 | .with("debug", () => Level.DEBUG) 47 | .with("info", () => Level.INFO) 48 | .with("warn", () => Level.WARN) 49 | .with("error", () => Level.ERROR) 50 | .with("fatal", () => Level.CRITICAL) 51 | .otherwise(() => Level.INFO); 52 | 53 | FmtSubscriber.setGlobalDefault({ level, color: true }); 54 | } 55 | 56 | // Should match the cargo package version 57 | /** The version of the webview binary that's expected */ 58 | export const BIN_VERSION = "0.3.1"; 59 | 60 | type WebViewNotification = Extract< 61 | Message, 62 | { $type: "notification" } 63 | >["data"]; 64 | 65 | type ResultType = Extract; 66 | 67 | /** 68 | * A helper function for extracting the result from a webview response. 69 | * Throws if the response includes unexpected results. 70 | * 71 | * @param result - The result of the webview request. 72 | * @param expectedType - The format of the expected result. 73 | */ 74 | function returnResult< 75 | Response extends WebViewResponse, 76 | E extends ResultType["result"]["$type"], 77 | >( 78 | result: Response, 79 | expectedType: E, 80 | ): Extract["value"] { 81 | if (result.$type === "result") { 82 | if (result.result.$type === expectedType) { 83 | // @ts-expect-error TS doesn't correctly narrow this type, but it's correct 84 | return result.result.value; 85 | } 86 | throw new Error(`unexpected result type: ${result.result.$type}`); 87 | } 88 | throw new Error(`unexpected response: ${result.$type}`); 89 | } 90 | 91 | /** 92 | * A helper function for acknowledging a webview response. 93 | * Throws if the response includes unexpected results. 94 | */ 95 | const returnAck = (result: WebViewResponse) => { 96 | return match(result) 97 | .with({ $type: "ack" }, () => undefined) 98 | .with({ $type: "err" }, (err) => { 99 | throw new Error(err.message); 100 | }) 101 | .otherwise(() => { 102 | throw new Error(`unexpected response: ${result.$type}`); 103 | }); 104 | }; 105 | 106 | async function getWebViewBin(options: Options) { 107 | if ( 108 | Deno.permissions.querySync({ name: "env", variable: "WEBVIEW_BIN" }) 109 | .state === "granted" 110 | ) { 111 | const binPath = Deno.env.get("WEBVIEW_BIN"); 112 | if (binPath) return binPath; 113 | } 114 | 115 | const flags = options.devtools 116 | ? "-devtools" 117 | : options.transparent && Deno.build.os === "darwin" 118 | ? "-transparent" 119 | : ""; 120 | 121 | const cacheDir = getCacheDir(); 122 | const fileName = `webview-${BIN_VERSION}${flags}${ 123 | Deno.build.os === "windows" ? ".exe" : "" 124 | }`; 125 | const filePath = join(cacheDir, fileName); 126 | 127 | // Check if the file already exists in cache 128 | if (await exists(filePath)) { 129 | return filePath; 130 | } 131 | 132 | // If not in cache, download it 133 | let url = 134 | `https://github.com/zephraph/webview/releases/download/webview-v${BIN_VERSION}/webview`; 135 | url += match(Deno.build.os) 136 | .with( 137 | "darwin", 138 | () => "-mac" + (Deno.build.arch === "aarch64" ? "-arm64" : "") + flags, 139 | ) 140 | .with("linux", () => "-linux" + flags) 141 | .with("windows", () => "-windows" + flags + ".exe") 142 | .otherwise(() => { 143 | throw new Error("unsupported OS"); 144 | }); 145 | 146 | const res = await fetch(url); 147 | 148 | // Ensure the cache directory exists 149 | await ensureDir(cacheDir); 150 | 151 | // Write the binary to disk 152 | await Deno.writeFile(filePath, new Uint8Array(await res.arrayBuffer()), { 153 | mode: 0o755, 154 | }); 155 | 156 | return filePath; 157 | } 158 | 159 | // Helper function to get the OS-specific cache directory 160 | function getCacheDir(): string { 161 | return match(Deno.build.os) 162 | .with( 163 | "darwin", 164 | () => join(Deno.env.get("HOME")!, "Library", "Caches", "webview"), 165 | ) 166 | .with("linux", () => join(Deno.env.get("HOME")!, ".cache", "webview")) 167 | .with( 168 | "windows", 169 | () => join(Deno.env.get("LOCALAPPDATA")!, "webview", "Cache"), 170 | ) 171 | .otherwise(() => { 172 | throw new Error("Unsupported OS"); 173 | }); 174 | } 175 | 176 | /** 177 | * Creates a new webview window. 178 | * 179 | * Will automatically fetch the webview binary if it's not already downloaded 180 | */ 181 | export async function createWebView(options: Options): Promise { 182 | const binPath = await getWebViewBin(options); 183 | return new WebView(options, binPath); 184 | } 185 | 186 | /** 187 | * A webview window. It's recommended to use the `createWebView` function 188 | * because it provides a means of automatically fetching the webview binary 189 | * that's compatible with your OS and architecture. 190 | * 191 | * Each instance of `WebView` spawns a new process that governs a single webview window. 192 | */ 193 | export class WebView implements Disposable { 194 | #process: Deno.ChildProcess; 195 | #stdin: WritableStreamDefaultWriter; 196 | #stdout: ReadableStreamDefaultReader; 197 | #buffer = ""; 198 | #internalEvent = new EventEmitter(); 199 | #externalEvent = new EventEmitter(); 200 | #messageLoop: Promise; 201 | #options: Options; 202 | #messageId = 0; 203 | 204 | /** 205 | * Creates a new webview window. 206 | * 207 | * @param options - The options for the webview. 208 | * @param webviewBinaryPath - The path to the webview binary. 209 | */ 210 | constructor(options: Options, webviewBinaryPath: string) { 211 | this.#options = options; 212 | this.#process = new Deno.Command(webviewBinaryPath, { 213 | args: [JSON.stringify(options)], 214 | stdin: "piped", 215 | stdout: "piped", 216 | stderr: "inherit", 217 | }).spawn(); 218 | this.#stdin = this.#process.stdin.getWriter(); 219 | this.#stdout = this.#process.stdout.getReader(); 220 | this.#messageLoop = this.#processMessageLoop(); 221 | } 222 | 223 | #send(request: Except): Promise { 224 | const id = this.#messageId++; 225 | return new Promise((resolve) => { 226 | // Setup listener before sending the message to avoid race conditions 227 | this.#internalEvent.once(id.toString(), (event) => { 228 | const result = WebViewResponse.safeParse(event); 229 | if (result.success) { 230 | resolve(result.data); 231 | } else { 232 | resolve({ $type: "err", id, message: result.error.message }); 233 | } 234 | }); 235 | this.#stdin.write( 236 | new TextEncoder().encode( 237 | JSON.stringify({ ...request, id }), 238 | ), 239 | ); 240 | }); 241 | } 242 | 243 | @instrument() 244 | async #recv() { 245 | while (true) { 246 | const { value, done } = await this.#stdout.read(); 247 | if (done) { 248 | break; 249 | } 250 | this.#buffer += new TextDecoder().decode(value); 251 | 252 | const newlineIndex = this.#buffer.indexOf("\n"); 253 | if (newlineIndex === -1) { 254 | continue; 255 | } 256 | trace("buffer", { buffer: this.#buffer }); 257 | const result = Message.safeParse( 258 | JSON.parse(this.#buffer.slice(0, newlineIndex)), 259 | ); 260 | this.#buffer = this.#buffer.slice(newlineIndex + 1); 261 | if (result.success) { 262 | return result.data; 263 | } else { 264 | error("Error parsing message", { error: result.error }); 265 | return result; 266 | } 267 | } 268 | } 269 | 270 | async #processMessageLoop() { 271 | while (true) { 272 | const result = await this.#recv(); 273 | if (!result) return; 274 | match(result) 275 | .with({ error: { issues: [{ code: "invalid_type" }] } }, (result) => { 276 | error("Invalid type", { error: result.error }); 277 | }) 278 | .with({ error: P.nonNullable }, (result) => { 279 | error("Unknown error", { error: result.error }); 280 | }) 281 | .with({ $type: "notification" }, ({ data }) => { 282 | const { $type, ...body } = data; 283 | this.#externalEvent.emit($type, body); 284 | if (data.$type === "started" && data.version !== BIN_VERSION) { 285 | warn( 286 | `Expected webview to be version ${BIN_VERSION} but got ${data.version}. Some features may not work as expected.`, 287 | ); 288 | } 289 | }) 290 | .with({ $type: "response" }, ({ data }) => { 291 | this.#internalEvent.emit(data.id.toString(), data); 292 | }) 293 | .exhaustive(); 294 | } 295 | } 296 | 297 | /** 298 | * Returns a promise that resolves when the webview window is closed. 299 | */ 300 | async waitUntilClosed() { 301 | await this.#messageLoop; 302 | } 303 | 304 | /** 305 | * Listens for events emitted by the webview. 306 | */ 307 | on( 308 | event: E, 309 | callback: ( 310 | event: Simplify< 311 | Omit, "$type"> 312 | >, 313 | ) => void, 314 | ) { 315 | if (event === "ipc" && !this.#options.ipc) { 316 | throw new Error("IPC is not enabled for this webview"); 317 | } 318 | this.#externalEvent.on(event, callback); 319 | } 320 | 321 | /** 322 | * Listens for a single event emitted by the webview. 323 | */ 324 | once( 325 | event: E, 326 | callback: ( 327 | event: Simplify< 328 | Omit, "$type"> 329 | >, 330 | ) => void, 331 | ) { 332 | if (event === "ipc" && !this.#options.ipc) { 333 | throw new Error("IPC is not enabled for this webview"); 334 | } 335 | this.#externalEvent.once(event, callback); 336 | } 337 | 338 | /** 339 | * Gets the version of the webview binary. 340 | */ 341 | @instrument() 342 | async getVersion(): Promise { 343 | const result = await this.#send({ $type: "getVersion" }); 344 | return returnResult(result, "string"); 345 | } 346 | 347 | /** 348 | * Sets the size of the webview window. 349 | * 350 | * Note: this is the logical size of the window, not the physical size. 351 | * @see https://docs.rs/dpi/0.1.1/x86_64-unknown-linux-gnu/dpi/index.html#position-and-size-types 352 | */ 353 | @instrument() 354 | async setSize(size: { width: number; height: number }): Promise { 355 | const result = await this.#send({ $type: "setSize", size }); 356 | return returnAck(result); 357 | } 358 | 359 | /** 360 | * Gets the size of the webview window. 361 | * 362 | * Note: this is the logical size of the window, not the physical size. 363 | * @see https://docs.rs/dpi/0.1.1/x86_64-unknown-linux-gnu/dpi/index.html#position-and-size-types 364 | */ 365 | @instrument() 366 | async getSize( 367 | includeDecorations?: boolean, 368 | ): Promise<{ width: number; height: number; scaleFactor: number }> { 369 | const result = await this.#send({ 370 | $type: "getSize", 371 | include_decorations: includeDecorations, 372 | }); 373 | return returnResult( 374 | result, 375 | "size", 376 | ); 377 | } 378 | 379 | /** 380 | * Enters or exits fullscreen mode for the webview. 381 | * 382 | * @param fullscreen - If true, the webview will enter fullscreen mode. If false, the webview will exit fullscreen mode. If not specified, the webview will toggle fullscreen mode. 383 | */ 384 | @instrument() 385 | async fullscreen(fullscreen?: boolean): Promise { 386 | const result = await this.#send({ $type: "fullscreen", fullscreen }); 387 | return returnAck(result); 388 | } 389 | 390 | /** 391 | * Maximizes or unmaximizes the webview window. 392 | * 393 | * @param maximized - If true, the webview will be maximized. If false, the webview will be unmaximized. If not specified, the webview will toggle maximized state. 394 | */ 395 | @instrument() 396 | async maximize(maximized?: boolean): Promise { 397 | const result = await this.#send({ $type: "maximize", maximized }); 398 | return returnAck(result); 399 | } 400 | 401 | /** 402 | * Minimizes or unminimizes the webview window. 403 | * 404 | * @param minimized - If true, the webview will be minimized. If false, the webview will be unminimized. If not specified, the webview will toggle minimized state. 405 | */ 406 | @instrument() 407 | async minimize(minimized?: boolean): Promise { 408 | const result = await this.#send({ $type: "minimize", minimized }); 409 | return returnAck(result); 410 | } 411 | 412 | /** 413 | * Sets the title of the webview window. 414 | */ 415 | @instrument() 416 | async setTitle(title: string): Promise { 417 | const result = await this.#send({ 418 | $type: "setTitle", 419 | title, 420 | }); 421 | return returnAck(result); 422 | } 423 | 424 | /** 425 | * Gets the title of the webview window. 426 | */ 427 | @instrument() 428 | async getTitle(): Promise { 429 | const result = await this.#send({ $type: "getTitle" }); 430 | return returnResult(result, "string"); 431 | } 432 | 433 | /** 434 | * Sets the visibility of the webview window. 435 | */ 436 | @instrument() 437 | async setVisibility(visible: boolean): Promise { 438 | const result = await this.#send({ $type: "setVisibility", visible }); 439 | return returnAck(result); 440 | } 441 | 442 | /** 443 | * Returns true if the webview window is visible. 444 | */ 445 | @instrument() 446 | async isVisible(): Promise { 447 | const result = await this.#send({ $type: "isVisible" }); 448 | return returnResult(result, "boolean"); 449 | } 450 | 451 | /** 452 | * Evaluates JavaScript code in the webview. 453 | */ 454 | @instrument() 455 | async eval(code: string): Promise { 456 | const result = await this.#send({ $type: "eval", js: code }); 457 | return returnAck(result); 458 | } 459 | 460 | /** 461 | * Opens the developer tools for the webview. 462 | */ 463 | @instrument() 464 | async openDevTools(): Promise { 465 | const result = await this.#send({ $type: "openDevTools" }); 466 | return returnAck(result); 467 | } 468 | 469 | /** 470 | * Reloads the webview with the provided html. 471 | */ 472 | @instrument() 473 | async loadHtml(html: string): Promise { 474 | const result = await this.#send({ $type: "loadHtml", html }); 475 | return returnAck(result); 476 | } 477 | 478 | /** 479 | * Loads a URL in the webview. 480 | */ 481 | @instrument() 482 | async loadUrl(url: string, headers?: Record): Promise { 483 | const result = await this.#send({ $type: "loadUrl", url, headers }); 484 | return returnAck(result); 485 | } 486 | 487 | /** 488 | * Destroys the webview and cleans up resources. 489 | * 490 | * Alternatively you can use the disposible interface. 491 | * 492 | * @example 493 | * ```ts 494 | * // The `using` keyword will automatically call `destroy` on the webview when 495 | * // the webview goes out of scope. 496 | * using webview = await createWebView({ title: "My Webview" }); 497 | * ``` 498 | */ 499 | @instrument() 500 | destroy() { 501 | this[Symbol.dispose](); 502 | } 503 | 504 | /** 505 | * Part of the explicit resource management feature added in TS 5.2 506 | * 507 | * When a reference to the webview is stored with `using` this method 508 | * will be called automatically when the webview goes out of scope. 509 | * 510 | * @example 511 | * 512 | * ```ts 513 | * { 514 | * using webview = await createWebView({ title: "My Webview" }); 515 | * } // Webview will be cleaned up here 516 | * 517 | * ``` 518 | * 519 | * @see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management 520 | */ 521 | [Symbol.dispose](): void { 522 | this.#internalEvent.removeAllListeners(); 523 | this.#stdin.releaseLock(); 524 | try { 525 | this.#process.kill(); 526 | } catch (_) { 527 | _; 528 | } 529 | } 530 | } 531 | -------------------------------------------------------------------------------- /src/clients/deno/schemas.ts: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT: This file is auto-generated by generate-schema/index.ts 2 | import { z } from "npm:zod"; 3 | 4 | /** 5 | * Messages that are sent unbidden from the webview to the client. 6 | */ 7 | export type Notification = 8 | | { 9 | $type: "started"; 10 | /** The version of the webview binary */ 11 | version: string; 12 | } 13 | | { 14 | $type: "ipc"; 15 | /** The message sent from the webview UI to the client. */ 16 | message: string; 17 | } 18 | | { 19 | $type: "closed"; 20 | }; 21 | 22 | export type SizeWithScale = { 23 | /** The height of the window in logical pixels. */ 24 | height: number; 25 | /** The ratio between physical and logical sizes. */ 26 | scaleFactor: number; 27 | /** The width of the window in logical pixels. */ 28 | width: number; 29 | }; 30 | 31 | /** 32 | * Types that can be returned from webview results. 33 | */ 34 | export type ResultType = 35 | | { 36 | $type: "string"; 37 | 38 | value: string; 39 | } 40 | | { 41 | $type: "boolean"; 42 | 43 | value: boolean; 44 | } 45 | | { 46 | $type: "float"; 47 | 48 | value: number; 49 | } 50 | | { 51 | $type: "size"; 52 | 53 | value: SizeWithScale; 54 | }; 55 | 56 | /** 57 | * Responses from the webview to the client. 58 | */ 59 | export type Response = 60 | | { 61 | $type: "ack"; 62 | 63 | id: number; 64 | } 65 | | { 66 | $type: "result"; 67 | 68 | id: number; 69 | 70 | result: ResultType; 71 | } 72 | | { 73 | $type: "err"; 74 | 75 | id: number; 76 | 77 | message: string; 78 | }; 79 | 80 | /** 81 | * Complete definition of all outbound messages from the webview to the client. 82 | */ 83 | export type Message = 84 | | { 85 | $type: "notification"; 86 | 87 | data: Notification; 88 | } 89 | | { 90 | $type: "response"; 91 | 92 | data: Response; 93 | }; 94 | 95 | export const Notification: z.ZodType = z.discriminatedUnion( 96 | "$type", 97 | [ 98 | z.object({ $type: z.literal("started"), version: z.string() }), 99 | z.object({ $type: z.literal("ipc"), message: z.string() }), 100 | z.object({ $type: z.literal("closed") }), 101 | ], 102 | ); 103 | 104 | export const SizeWithScale: z.ZodType = z.object({ 105 | height: z.number(), 106 | scaleFactor: z.number(), 107 | width: z.number(), 108 | }); 109 | 110 | export const ResultType: z.ZodType = z.discriminatedUnion("$type", [ 111 | z.object({ $type: z.literal("string"), value: z.string() }), 112 | z.object({ $type: z.literal("boolean"), value: z.boolean() }), 113 | z.object({ $type: z.literal("float"), value: z.number() }), 114 | z.object({ $type: z.literal("size"), value: SizeWithScale }), 115 | ]); 116 | 117 | export const Response: z.ZodType = z.discriminatedUnion("$type", [ 118 | z.object({ $type: z.literal("ack"), id: z.number().int() }), 119 | z.object({ 120 | $type: z.literal("result"), 121 | id: z.number().int(), 122 | result: ResultType, 123 | }), 124 | z.object({ 125 | $type: z.literal("err"), 126 | id: z.number().int(), 127 | message: z.string(), 128 | }), 129 | ]); 130 | 131 | export const Message: z.ZodType = z.discriminatedUnion("$type", [ 132 | z.object({ $type: z.literal("notification"), data: Notification }), 133 | z.object({ $type: z.literal("response"), data: Response }), 134 | ]); 135 | 136 | /** 137 | * The content to load into the webview. 138 | */ 139 | export type Content = 140 | | { 141 | /** Optional headers to send with the request. */ 142 | headers?: Record; 143 | /** Url to load in the webview. Note: Don't use data URLs here, as they are not supported. Use the `html` field instead. */ 144 | url: string; 145 | } 146 | | { 147 | /** Html to load in the webview. */ 148 | html: string; 149 | /** What to set as the origin of the webview when loading html. */ 150 | origin?: string; 151 | }; 152 | 153 | export type Size = { 154 | /** The height of the window in logical pixels. */ 155 | height: number; 156 | /** The width of the window in logical pixels. */ 157 | width: number; 158 | }; 159 | 160 | export type WindowSizeStates = "maximized" | "fullscreen"; 161 | export type WindowSize = WindowSizeStates | Size; 162 | 163 | /** 164 | * Options for creating a webview. 165 | */ 166 | export type Options = { 167 | /** Sets whether clicking an inactive window also clicks through to the webview. Default is false. */ 168 | acceptFirstMouse?: boolean; 169 | /** When true, all media can be played without user interaction. Default is false. */ 170 | autoplay?: boolean; 171 | /** 172 | * Enables clipboard access for the page rendered on Linux and Windows. 173 | * 174 | * macOS doesn’t provide such method and is always enabled by default. But your app will still need to add menu item accelerators to use the clipboard shortcuts. 175 | */ 176 | clipboard?: boolean; 177 | /** When true, the window will have a border, a title bar, etc. Default is true. */ 178 | decorations?: boolean; 179 | /** 180 | * Enable or disable webview devtools. 181 | * 182 | * Note this only enables devtools to the webview. To open it, you can call `webview.open_devtools()`, or right click the page and open it from the context menu. 183 | */ 184 | devtools?: boolean; 185 | /** Sets whether the webview should be focused when created. Default is false. */ 186 | focused?: boolean; 187 | /** 188 | * Run the WebView with incognito mode. Note that WebContext will be ingored if incognito is enabled. 189 | * 190 | * Platform-specific: - Windows: Requires WebView2 Runtime version 101.0.1210.39 or higher, does nothing on older versions, see https://learn.microsoft.com/en-us/microsoft-edge/webview2/release-notes/archive?tabs=dotnetcsharp#10121039 191 | */ 192 | incognito?: boolean; 193 | /** Run JavaScript code when loading new pages. When the webview loads a new page, this code will be executed. It is guaranteed that the code is executed before window.onload. */ 194 | initializationScript?: string; 195 | /** Sets whether host should be able to receive messages from the webview via `window.ipc.postMessage`. */ 196 | ipc?: boolean; 197 | /** The content to load into the webview. */ 198 | load?: Content; 199 | /** The size of the window. */ 200 | size?: WindowSize; 201 | /** Sets the title of the window. */ 202 | title: string; 203 | /** Sets whether the window should be transparent. */ 204 | transparent?: boolean; 205 | /** Sets the user agent to use when loading pages. */ 206 | userAgent?: string; 207 | }; 208 | 209 | export const Content: z.ZodType = z.union([ 210 | z.object({ 211 | headers: z.record(z.string(), z.string()).optional(), 212 | url: z.string(), 213 | }), 214 | z.object({ html: z.string(), origin: z.string() }), 215 | ]); 216 | 217 | export const Size: z.ZodType = z.object({ 218 | height: z.number(), 219 | width: z.number(), 220 | }); 221 | 222 | export const WindowSizeStates: z.ZodType = z.enum([ 223 | "maximized", 224 | "fullscreen", 225 | ]); 226 | export const WindowSize: z.ZodType = z.union([ 227 | WindowSizeStates, 228 | Size, 229 | ]); 230 | 231 | export const Options: z.ZodType = z.object({ 232 | acceptFirstMouse: z.boolean().optional(), 233 | autoplay: z.boolean().optional(), 234 | clipboard: z.boolean().optional(), 235 | decorations: z.boolean().optional(), 236 | devtools: z.boolean().optional(), 237 | focused: z.boolean().optional(), 238 | incognito: z.boolean().optional(), 239 | initializationScript: z.string(), 240 | ipc: z.boolean().optional(), 241 | load: Content.optional(), 242 | size: WindowSize.optional(), 243 | title: z.string(), 244 | transparent: z.boolean().optional(), 245 | userAgent: z.string(), 246 | }); 247 | 248 | /** 249 | * Explicit requests from the client to the webview. 250 | */ 251 | export type Request = 252 | | { 253 | $type: "getVersion"; 254 | /** The id of the request. */ 255 | id: number; 256 | } 257 | | { 258 | $type: "eval"; 259 | /** The id of the request. */ 260 | id: number; 261 | /** The javascript to evaluate. */ 262 | js: string; 263 | } 264 | | { 265 | $type: "setTitle"; 266 | /** The id of the request. */ 267 | id: number; 268 | /** The title to set. */ 269 | title: string; 270 | } 271 | | { 272 | $type: "getTitle"; 273 | /** The id of the request. */ 274 | id: number; 275 | } 276 | | { 277 | $type: "setVisibility"; 278 | /** The id of the request. */ 279 | id: number; 280 | /** Whether the window should be visible or hidden. */ 281 | visible: boolean; 282 | } 283 | | { 284 | $type: "isVisible"; 285 | /** The id of the request. */ 286 | id: number; 287 | } 288 | | { 289 | $type: "openDevTools"; 290 | /** The id of the request. */ 291 | id: number; 292 | } 293 | | { 294 | $type: "getSize"; 295 | /** The id of the request. */ 296 | id: number; 297 | /** Whether to include the title bar and borders in the size measurement. */ 298 | include_decorations?: boolean; 299 | } 300 | | { 301 | $type: "setSize"; 302 | /** The id of the request. */ 303 | id: number; 304 | /** The size to set. */ 305 | size: Size; 306 | } 307 | | { 308 | $type: "fullscreen"; 309 | /** Whether to enter fullscreen mode. If left unspecified, the window will enter fullscreen mode if it is not already in fullscreen mode or exit fullscreen mode if it is currently in fullscreen mode. */ 310 | fullscreen?: boolean; 311 | /** The id of the request. */ 312 | id: number; 313 | } 314 | | { 315 | $type: "maximize"; 316 | /** The id of the request. */ 317 | id: number; 318 | /** Whether to maximize the window. If left unspecified, the window will be maximized if it is not already maximized or restored if it was previously maximized. */ 319 | maximized?: boolean; 320 | } 321 | | { 322 | $type: "minimize"; 323 | /** The id of the request. */ 324 | id: number; 325 | /** Whether to minimize the window. If left unspecified, the window will be minimized if it is not already minimized or restored if it was previously minimized. */ 326 | minimized?: boolean; 327 | } 328 | | { 329 | $type: "loadHtml"; 330 | /** HTML to set as the content of the webview. */ 331 | html: string; 332 | /** The id of the request. */ 333 | id: number; 334 | /** What to set as the origin of the webview when loading html. If not specified, the origin will be set to the value of the `origin` field when the webview was created. */ 335 | origin?: string; 336 | } 337 | | { 338 | $type: "loadUrl"; 339 | /** Optional headers to send with the request. */ 340 | headers?: Record; 341 | /** The id of the request. */ 342 | id: number; 343 | /** URL to load in the webview. */ 344 | url: string; 345 | }; 346 | 347 | export const Request: z.ZodType = z.discriminatedUnion("$type", [ 348 | z.object({ $type: z.literal("getVersion"), id: z.number().int() }), 349 | z.object({ $type: z.literal("eval"), id: z.number().int(), js: z.string() }), 350 | z.object({ 351 | $type: z.literal("setTitle"), 352 | id: z.number().int(), 353 | title: z.string(), 354 | }), 355 | z.object({ $type: z.literal("getTitle"), id: z.number().int() }), 356 | z.object({ 357 | $type: z.literal("setVisibility"), 358 | id: z.number().int(), 359 | visible: z.boolean(), 360 | }), 361 | z.object({ $type: z.literal("isVisible"), id: z.number().int() }), 362 | z.object({ $type: z.literal("openDevTools"), id: z.number().int() }), 363 | z.object({ 364 | $type: z.literal("getSize"), 365 | id: z.number().int(), 366 | include_decorations: z.boolean().optional(), 367 | }), 368 | z.object({ $type: z.literal("setSize"), id: z.number().int(), size: Size }), 369 | z.object({ 370 | $type: z.literal("fullscreen"), 371 | fullscreen: z.boolean().optional(), 372 | id: z.number().int(), 373 | }), 374 | z.object({ 375 | $type: z.literal("maximize"), 376 | id: z.number().int(), 377 | maximized: z.boolean().optional(), 378 | }), 379 | z.object({ 380 | $type: z.literal("minimize"), 381 | id: z.number().int(), 382 | minimized: z.boolean().optional(), 383 | }), 384 | z.object({ 385 | $type: z.literal("loadHtml"), 386 | html: z.string(), 387 | id: z.number().int(), 388 | origin: z.string().optional(), 389 | }), 390 | z.object({ 391 | $type: z.literal("loadUrl"), 392 | headers: z.record(z.string(), z.string()).optional(), 393 | id: z.number().int(), 394 | url: z.string(), 395 | }), 396 | ]); 397 | -------------------------------------------------------------------------------- /src/clients/python/.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /src/clients/python/README.md: -------------------------------------------------------------------------------- 1 | # justbe-webview 2 | 3 | A light, cross-platform library for building web-based desktop apps with Python. 4 | 5 | ## Installation 6 | 7 | You can install justbe-webview using either `uv` (recommended) or `pip`: 8 | 9 | ```bash 10 | # Using uv (recommended) 11 | uv pip install justbe-webview 12 | 13 | # Using pip 14 | pip install justbe-webview 15 | ``` 16 | 17 | ## Example 18 | 19 | ```python 20 | import asyncio 21 | from justbe_webview import ( 22 | WebView, 23 | Options, 24 | ContentHtml, 25 | Notification, 26 | ) 27 | 28 | async def main(): 29 | config = Options( 30 | title="Simple", 31 | load=ContentHtml(html="

Hello, World!

"), 32 | ) 33 | 34 | async with WebView(config) as webview: 35 | async def handle_start(event: Notification): 36 | await webview.eval("console.log('This is printed from eval!')") 37 | 38 | webview.on("started", handle_start) 39 | 40 | if __name__ == "__main__": 41 | asyncio.run(main()) 42 | ``` 43 | 44 | You can find more examples in the [examples directory](examples/), including: 45 | - Loading URLs 46 | - Loading HTML content 47 | - Window size management 48 | - IPC (Inter-Process Communication) 49 | 50 | ### Binary Management 51 | 52 | On first run, the client will: 53 | 1. Check for a cached binary in the user's cache directory 54 | 2. If not found, download the appropriate binary for the current platform 55 | 3. Cache the binary for future use 56 | 57 | The exact cache location depends on your operating system: 58 | - Linux: `~/.cache/justbe-webview/` 59 | - macOS: `~/Library/Caches/justbe-webview/` 60 | - Windows: `%LOCALAPPDATA%\justbe-webview\Cache\` 61 | 62 | ### Using a Custom Binary 63 | 64 | You can specify a custom binary path using the `WEBVIEW_BIN` environment variable: 65 | 66 | ```bash 67 | export WEBVIEW_BIN=/path/to/webview/binary 68 | python your_app.py 69 | ``` 70 | 71 | When set, this will bypass the default binary resolution process. 72 | -------------------------------------------------------------------------------- /src/clients/python/examples/ipc.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.13" 3 | # dependencies = [ 4 | # "justbe-webview", 5 | # ] 6 | # 7 | # [tool.uv.sources] 8 | # justbe-webview = { path = "../" } 9 | # /// 10 | import asyncio 11 | 12 | from justbe_webview import WebView, Options, ContentHtml, IpcNotification 13 | 14 | 15 | async def main(): 16 | print("Creating webview") 17 | config = Options( 18 | title="Simple", 19 | load=ContentHtml( 20 | html='' 21 | ), 22 | ipc=True, 23 | ) 24 | 25 | async with WebView(config) as webview: 26 | 27 | async def handle_ipc(event: IpcNotification): 28 | print(event.message) 29 | 30 | webview.on("ipc", handle_ipc) 31 | 32 | print("Webview closed") 33 | 34 | 35 | if __name__ == "__main__": 36 | asyncio.run(main()) 37 | -------------------------------------------------------------------------------- /src/clients/python/examples/load_html.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.13" 3 | # dependencies = [ 4 | # "justbe-webview", 5 | # ] 6 | # 7 | # [tool.uv.sources] 8 | # justbe-webview = { path = "../" } 9 | # /// 10 | import asyncio 11 | 12 | from justbe_webview import ( 13 | WebView, 14 | Options, 15 | ContentHtml, 16 | Notification, 17 | ) 18 | 19 | 20 | async def main(): 21 | print("Creating webview") 22 | config = Options( 23 | title="Load Html Example", 24 | load=ContentHtml( 25 | html="

Initial html

", 26 | # Note: This origin is used with a custom protocol so it doesn't match 27 | # https://example.com. This doesn't need to be set, but can be useful if 28 | # you want to control resources that are scoped to a specific origin like 29 | # local storage or indexeddb. 30 | origin="example.com", 31 | ), 32 | devtools=True, 33 | ) 34 | 35 | async with WebView(config) as webview: 36 | 37 | async def handle_start(event: Notification): 38 | await webview.open_devtools() 39 | await webview.load_html("

Updated html!

") 40 | 41 | webview.on("started", handle_start) 42 | 43 | print("Webview closed") 44 | 45 | 46 | if __name__ == "__main__": 47 | asyncio.run(main()) 48 | -------------------------------------------------------------------------------- /src/clients/python/examples/load_url.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.13" 3 | # dependencies = [ 4 | # "justbe-webview", 5 | # ] 6 | # 7 | # [tool.uv.sources] 8 | # justbe-webview = { path = "../" } 9 | # /// 10 | import asyncio 11 | 12 | from justbe_webview import ( 13 | WebView, 14 | Options, 15 | ContentUrl, 16 | Notification, 17 | ) 18 | 19 | 20 | async def main(): 21 | print("Creating webview") 22 | config = Options( 23 | title="Load Url Example", 24 | load=ContentUrl( 25 | url="https://example.com", 26 | headers={ 27 | "Content-Type": "text/html", 28 | }, 29 | ), 30 | userAgent="curl/7.81.0", 31 | devtools=True, 32 | ) 33 | 34 | async with WebView(config) as webview: 35 | 36 | async def handle_start(event: Notification): 37 | await webview.open_devtools() 38 | await asyncio.sleep(2) # Sleep for 2 seconds 39 | await webview.load_url( 40 | "https://val.town/", 41 | headers={ 42 | "Content-Type": "text/html", 43 | }, 44 | ) 45 | 46 | webview.on("started", handle_start) 47 | 48 | print("Webview closed") 49 | 50 | 51 | if __name__ == "__main__": 52 | asyncio.run(main()) 53 | -------------------------------------------------------------------------------- /src/clients/python/examples/simple.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.13" 3 | # dependencies = [ 4 | # "justbe-webview", 5 | # ] 6 | # 7 | # [tool.uv.sources] 8 | # justbe-webview = { path = "../" } 9 | # /// 10 | import asyncio 11 | 12 | from justbe_webview import ( 13 | WebView, 14 | Options, 15 | ContentHtml, 16 | Notification, 17 | ) 18 | 19 | 20 | async def main(): 21 | print("Creating webview") 22 | config = Options( 23 | title="Simple", 24 | load=ContentHtml(html="

Hello, World!

"), 25 | devtools=True, 26 | initializationScript="console.log('This is printed from initializationScript!')", 27 | ) 28 | 29 | async with WebView(config) as webview: 30 | 31 | async def handle_start(event: Notification): 32 | print("handle_start called") 33 | await webview.set_title("Title set from Python") 34 | current_title = await webview.get_title() 35 | print(f"Current title: {current_title}") 36 | await webview.open_devtools() 37 | await webview.eval("console.log('This is printed from eval!')") 38 | 39 | webview.on("started", handle_start) 40 | 41 | print("Webview closed") 42 | 43 | 44 | if __name__ == "__main__": 45 | asyncio.run(main()) 46 | -------------------------------------------------------------------------------- /src/clients/python/examples/window_size.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.13" 3 | # dependencies = [ 4 | # "justbe-webview", 5 | # ] 6 | # 7 | # [tool.uv.sources] 8 | # justbe-webview = { path = "../" } 9 | # /// 10 | import asyncio 11 | 12 | from justbe_webview import WebView, Options, ContentHtml, IpcNotification, Size 13 | 14 | 15 | async def main(): 16 | print("Creating webview") 17 | config = Options( 18 | title="Window Size", 19 | load=ContentHtml( 20 | html=""" 21 |

Window Sizes

22 |
23 | 24 | 25 | 26 |
27 | """ 28 | ), 29 | size=Size(width=800, height=200), 30 | ipc=True, 31 | ) 32 | 33 | async with WebView(config) as webview: 34 | 35 | async def handle_ipc(event: IpcNotification): 36 | message = event.message 37 | if message == "maximize": 38 | await webview.maximize() 39 | elif message == "minimize": 40 | await webview.minimize() 41 | elif message == "fullscreen": 42 | await webview.fullscreen() 43 | else: 44 | print(f"Unknown message: {message}") 45 | 46 | webview.on("ipc", handle_ipc) 47 | 48 | print("Webview closed") 49 | 50 | 51 | if __name__ == "__main__": 52 | asyncio.run(main()) 53 | -------------------------------------------------------------------------------- /src/clients/python/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "justbe-webview" 7 | version = "0.0.4" 8 | description = "A simple webview client" 9 | readme = "README.md" 10 | requires-python = ">=3.12" 11 | dependencies = [ 12 | "aiofiles>=24.1.0", 13 | "msgspec>=0.19.0", 14 | "httpx>=0.27.2", 15 | "pyee>=12.0.0", 16 | "types-aiofiles>=24.1.0.20240626", 17 | "python-ulid>=3.0.0", 18 | ] 19 | [dependency-groups] 20 | dev = ["ruff>=0.6.9"] 21 | -------------------------------------------------------------------------------- /src/clients/python/src/justbe_webview/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import platform 4 | import subprocess 5 | from typing import Any, Callable, Literal, Union, cast, TypeVar 6 | from pathlib import Path 7 | import aiofiles 8 | import httpx 9 | from pyee.asyncio import AsyncIOEventEmitter 10 | import msgspec 11 | import sys 12 | 13 | from .schemas import * 14 | 15 | # Import schemas 16 | from .schemas import ( 17 | NotificationMessage, 18 | ResponseMessage, 19 | StartedNotification, 20 | ClosedNotification, 21 | StringResultType, 22 | BooleanResultType, 23 | SizeResultType, 24 | ResultType, 25 | AckResponse, 26 | ResultResponse, 27 | ErrResponse, 28 | Response as WebViewResponse, 29 | Notification as WebViewNotification, 30 | Message as WebViewMessage, 31 | Request as WebViewRequest, 32 | Options as WebViewOptions, 33 | GetVersionRequest, 34 | EvalRequest, 35 | SetTitleRequest, 36 | GetTitleRequest, 37 | SetVisibilityRequest, 38 | IsVisibleRequest, 39 | OpenDevToolsRequest, 40 | GetSizeRequest, 41 | SetSizeRequest, 42 | FullscreenRequest, 43 | MaximizeRequest, 44 | MinimizeRequest, 45 | LoadHtmlRequest, 46 | LoadUrlRequest, 47 | Size, 48 | ) 49 | 50 | # Constants 51 | BIN_VERSION = "0.3.1" 52 | 53 | T = TypeVar("T", bound=WebViewNotification) 54 | 55 | 56 | def return_result( 57 | result: Union[AckResponse, ResultResponse, ErrResponse], 58 | expected_type: type[ResultType], 59 | ) -> Any: 60 | print(f"Return result: {result}") 61 | if isinstance(result, ResultResponse) and isinstance(result.result, expected_type): 62 | return result.result.value 63 | raise ValueError(f"Expected {expected_type.__name__} result got: {result}") 64 | 65 | 66 | def return_ack(result: Union[AckResponse, ResultResponse, ErrResponse]) -> None: 67 | print(f"Return ack: {result}") 68 | if isinstance(result, AckResponse): 69 | return 70 | if isinstance(result, ErrResponse): 71 | raise ValueError(result.message) 72 | raise ValueError(f"Unexpected response type: {type(result).__name__}") 73 | 74 | 75 | async def get_webview_bin(options: WebViewOptions) -> str: 76 | if "WEBVIEW_BIN" in os.environ: 77 | return os.environ["WEBVIEW_BIN"] 78 | 79 | flags = "-devtools" if cast(bool, getattr(options, "devtools", False)) else "" 80 | if ( 81 | not flags 82 | and cast(bool, getattr(options, "transparent", False)) 83 | and platform.system() == "Darwin" 84 | ): 85 | flags = "-transparent" 86 | 87 | cache_dir = get_cache_dir() 88 | file_name = f"webview-{BIN_VERSION}{flags}" 89 | if platform.system() == "Windows": 90 | file_name += ".exe" 91 | file_path = cache_dir / file_name 92 | 93 | if file_path.exists(): 94 | return str(file_path) 95 | 96 | url = f"https://github.com/zephraph/webview/releases/download/webview-v{BIN_VERSION}/webview" 97 | if platform.system() == "Darwin": 98 | url += "-mac" 99 | if platform.machine() == "arm64": 100 | url += "-arm64" 101 | elif platform.system() == "Linux": 102 | url += "-linux" 103 | elif platform.system() == "Windows": 104 | url += "-windows" 105 | else: 106 | raise ValueError("Unsupported OS") 107 | 108 | url += flags 109 | 110 | if platform.system() == "Windows": 111 | url += ".exe" 112 | 113 | async with httpx.AsyncClient() as client: 114 | response = await client.get(url, follow_redirects=True) 115 | response.raise_for_status() 116 | 117 | cache_dir.mkdir(parents=True, exist_ok=True) 118 | async with aiofiles.open(file_path, "wb") as f: 119 | await f.write(response.content) 120 | 121 | os.chmod(file_path, 0o755) 122 | return str(file_path) 123 | 124 | 125 | def get_cache_dir() -> Path: 126 | if platform.system() == "Darwin": 127 | return Path.home() / "Library" / "Caches" / "python-webview" 128 | 129 | if platform.system() == "Linux": 130 | return Path.home() / ".cache" / "python-webview" 131 | 132 | if platform.system() == "Windows": 133 | return Path(os.environ["LOCALAPPDATA"]) / "python-webview" / "Cache" 134 | 135 | raise ValueError("Unsupported OS") 136 | 137 | 138 | class WebView: 139 | process: subprocess.Popen[bytes] | None = None 140 | __message_id = 0 141 | 142 | def __init__(self, options: WebViewOptions, webview_binary_path: str | None = None): 143 | self.options = options 144 | if webview_binary_path is not None: 145 | self.__start_process(webview_binary_path) 146 | self.internal_event = AsyncIOEventEmitter() 147 | self.external_event = AsyncIOEventEmitter() 148 | self.buffer = b"" 149 | 150 | @property 151 | def message_id(self) -> int: 152 | current_id = self.__message_id 153 | self.__message_id += 1 154 | return current_id 155 | 156 | def __start_process(self, webview_binary_path: str): 157 | encoded_options = str(msgspec.json.encode(self.options), "utf-8") 158 | self.process = subprocess.Popen( 159 | [webview_binary_path, encoded_options], 160 | stdin=subprocess.PIPE, 161 | stdout=subprocess.PIPE, 162 | stderr=subprocess.PIPE, # Capture stderr properly 163 | text=False, 164 | bufsize=0, 165 | env=os.environ, 166 | ) 167 | assert self.process.stdin is not None 168 | assert self.process.stdout is not None 169 | assert self.process.stderr is not None 170 | 171 | # Create StreamReader for non-blocking reads 172 | loop = asyncio.get_event_loop() 173 | self.stdout_reader = asyncio.StreamReader() 174 | protocol = asyncio.StreamReaderProtocol(self.stdout_reader) 175 | loop.create_task(loop.connect_read_pipe(lambda: protocol, self.process.stdout)) 176 | 177 | # Also handle stderr 178 | self.stderr_reader = asyncio.StreamReader() 179 | stderr_protocol = asyncio.StreamReaderProtocol(self.stderr_reader) 180 | loop.create_task( 181 | loop.connect_read_pipe(lambda: stderr_protocol, self.process.stderr) 182 | ) 183 | loop.create_task(self._pipe_stderr()) 184 | 185 | async def _pipe_stderr(self): 186 | """Pipe stderr from the subprocess to Python's stderr""" 187 | while True: 188 | try: 189 | line = await self.stderr_reader.readline() 190 | if not line: 191 | break 192 | sys.stderr.buffer.write(line) 193 | sys.stderr.buffer.flush() 194 | except Exception: 195 | break 196 | 197 | async def __aenter__(self): 198 | bin_path = await get_webview_bin(self.options) 199 | if self.process is None: 200 | self.__start_process(bin_path) 201 | return self 202 | 203 | async def __aexit__( 204 | self, 205 | exc_type: type[BaseException] | None, 206 | exc_val: BaseException | None, 207 | exc_tb: Any | None, 208 | ) -> None: 209 | await self.wait_until_closed() 210 | self.destroy() 211 | 212 | async def send(self, request: WebViewRequest) -> WebViewResponse: 213 | if self.process is None: 214 | raise RuntimeError("Webview process not started") 215 | future: asyncio.Future[Union[AckResponse, ResultResponse, ErrResponse]] = ( 216 | asyncio.Future() 217 | ) 218 | 219 | def set_result(event: Union[AckResponse, ResultResponse, ErrResponse]) -> None: 220 | future.set_result(event) 221 | 222 | self.internal_event.once(str(request.id), set_result) # type: ignore 223 | 224 | assert self.process.stdin is not None 225 | encoded = msgspec.json.encode(request) 226 | self.process.stdin.write(encoded + b"\n") 227 | self.process.stdin.flush() 228 | 229 | result = await future 230 | return result 231 | 232 | async def recv(self) -> Union[WebViewNotification, None]: 233 | if self.process is None: 234 | raise RuntimeError("Webview process not started") 235 | 236 | print("Receiving messages from webview process...", flush=True) 237 | 238 | while True: 239 | try: 240 | # Non-blocking read using StreamReader 241 | chunk = await self.stdout_reader.read(8192) 242 | print( 243 | f"Read chunk size: {len(chunk) if chunk else 0} bytes", flush=True 244 | ) 245 | if not chunk: 246 | print( 247 | "Received empty chunk, process may have closed stdout", 248 | flush=True, 249 | ) 250 | return None 251 | 252 | self.buffer += chunk 253 | while b"\n" in self.buffer: 254 | message, self.buffer = self.buffer.split(b"\n", 1) 255 | print(f"Received raw message: {message}", flush=True) 256 | try: 257 | msg = msgspec.json.decode(message, type=WebViewMessage) 258 | print(f"Decoded message: {msg}", flush=True) 259 | if isinstance(msg, NotificationMessage): 260 | return msg.data 261 | elif isinstance(msg, ResponseMessage): 262 | self.internal_event.emit(str(msg.data.id), msg.data) 263 | except msgspec.DecodeError as e: 264 | print(f"Error parsing message: {message}", flush=True) 265 | print(f"Parse error details: {str(e)}", flush=True) 266 | except Exception as e: 267 | print(f"Error reading from stdout: {str(e)}", flush=True) 268 | return None 269 | 270 | async def process_message_loop(self): 271 | print("Processing message loop", flush=True) 272 | while True: 273 | notification = await self.recv() 274 | print(f"Received notification: {notification}", flush=True) 275 | if not notification: 276 | return 277 | 278 | if isinstance(notification, StartedNotification): 279 | version = notification.version 280 | if version != BIN_VERSION: 281 | print( 282 | f"Warning: Expected webview version {BIN_VERSION} but got {version}", 283 | flush=True, 284 | ) 285 | 286 | tag = notification.__struct_config__.tag 287 | assert isinstance(tag, str) 288 | self.external_event.emit(tag, notification) 289 | 290 | if isinstance(notification, ClosedNotification): 291 | return 292 | 293 | async def wait_until_closed(self): 294 | await self.process_message_loop() 295 | 296 | def on( 297 | self, 298 | event: str, 299 | callback: Callable[[T], Any], 300 | ) -> None: 301 | if event == "ipc" and not getattr(self.options, "ipc", False): 302 | raise ValueError("IPC is not enabled for this webview") 303 | 304 | self.external_event.on(event, callback) 305 | 306 | def once(self, event: str, callback: Callable[[WebViewNotification], Any]): 307 | if event == "ipc" and not getattr(self.options, "ipc", False): 308 | raise ValueError("IPC is not enabled for this webview") 309 | 310 | self.external_event.once(event, callback) # type: ignore 311 | 312 | async def get_version(self) -> str: 313 | result = await self.send(GetVersionRequest(id=self.message_id)) 314 | return return_result(result, StringResultType) 315 | 316 | async def set_size(self, size: dict[Literal["width", "height"], float]): 317 | result = await self.send(SetSizeRequest(id=self.message_id, size=Size(**size))) 318 | return_ack(result) 319 | 320 | async def get_size( 321 | self, include_decorations: bool = False 322 | ) -> dict[Literal["width", "height", "scaleFactor"], Union[int, float]]: 323 | result = await self.send( 324 | GetSizeRequest(id=self.message_id, include_decorations=include_decorations) 325 | ) 326 | size_data = return_result(result, SizeResultType) 327 | return { 328 | "width": size_data.width, 329 | "height": size_data.height, 330 | "scaleFactor": size_data.scale_factor, 331 | } 332 | 333 | async def fullscreen(self, fullscreen: bool | None = None): 334 | result = await self.send( 335 | FullscreenRequest(id=self.message_id, fullscreen=fullscreen) 336 | ) 337 | return_ack(result) 338 | 339 | async def maximize(self, maximized: bool | None = None): 340 | result = await self.send( 341 | MaximizeRequest(id=self.message_id, maximized=maximized) 342 | ) 343 | return_ack(result) 344 | 345 | async def minimize(self, minimized: bool | None = None): 346 | result = await self.send( 347 | MinimizeRequest(id=self.message_id, minimized=minimized) 348 | ) 349 | return_ack(result) 350 | 351 | async def set_title(self, title: str): 352 | result = await self.send(SetTitleRequest(id=self.message_id, title=title)) 353 | return_ack(result) 354 | 355 | async def get_title(self) -> str: 356 | result = await self.send(GetTitleRequest(id=self.message_id)) 357 | return return_result(result, StringResultType) 358 | 359 | async def set_visibility(self, visible: bool): 360 | result = await self.send( 361 | SetVisibilityRequest(id=self.message_id, visible=visible) 362 | ) 363 | return_ack(result) 364 | 365 | async def is_visible(self) -> bool: 366 | result = await self.send(IsVisibleRequest(id=self.message_id)) 367 | return return_result(result, BooleanResultType) 368 | 369 | async def eval(self, code: str): 370 | result = await self.send(EvalRequest(id=self.message_id, js=code)) 371 | return_ack(result) 372 | 373 | async def open_devtools(self): 374 | result = await self.send(OpenDevToolsRequest(id=self.message_id)) 375 | return_ack(result) 376 | 377 | async def load_html(self, html: str): 378 | result = await self.send(LoadHtmlRequest(id=self.message_id, html=html)) 379 | return_ack(result) 380 | 381 | async def load_url(self, url: str, headers: dict[str, str] | None = None): 382 | result = await self.send( 383 | LoadUrlRequest(id=self.message_id, url=url, headers=headers) 384 | ) 385 | return_ack(result) 386 | 387 | def destroy(self): 388 | if self.process is None: 389 | raise RuntimeError("Webview process not started") 390 | self.process.terminate() 391 | self.process.wait() 392 | 393 | 394 | async def create_webview( 395 | options: WebViewOptions, 396 | ) -> WebView: 397 | bin_path = await get_webview_bin(options) 398 | print(f"Created webview with bin path: {bin_path}") 399 | return WebView(options, bin_path) 400 | -------------------------------------------------------------------------------- /src/clients/python/src/justbe_webview/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraph/webview/f237514c9565568849c3accbf46f8cc506103785/src/clients/python/src/justbe_webview/py.typed -------------------------------------------------------------------------------- /src/clients/python/src/justbe_webview/schemas.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT: This file is auto-generated by generate-schema/index.ts 2 | from enum import Enum 3 | from typing import Union 4 | import msgspec 5 | 6 | __all__ = [ 7 | "AckResponse", 8 | "BooleanResultType", 9 | "ClosedNotification", 10 | "Content", 11 | "ContentHtml", 12 | "ContentUrl", 13 | "ErrResponse", 14 | "EvalRequest", 15 | "FloatResultType", 16 | "FullscreenRequest", 17 | "GetSizeRequest", 18 | "GetTitleRequest", 19 | "GetVersionRequest", 20 | "IpcNotification", 21 | "IsVisibleRequest", 22 | "LoadHtmlRequest", 23 | "LoadUrlRequest", 24 | "MaximizeRequest", 25 | "Message", 26 | "MinimizeRequest", 27 | "Notification", 28 | "NotificationMessage", 29 | "OpenDevToolsRequest", 30 | "Options", 31 | "Request", 32 | "Response", 33 | "ResponseMessage", 34 | "ResultResponse", 35 | "ResultType", 36 | "SetSizeRequest", 37 | "SetTitleRequest", 38 | "SetVisibilityRequest", 39 | "Size", 40 | "SizeResultType", 41 | "SizeWithScale", 42 | "StartedNotification", 43 | "StringResultType", 44 | "WindowSize", 45 | "WindowSizeStates" 46 | ] 47 | 48 | class StartedNotification(msgspec.Struct, tag_field="$type", tag="started"): 49 | version: str 50 | """The version of the webview binary""" 51 | 52 | class IpcNotification(msgspec.Struct, tag_field="$type", tag="ipc"): 53 | message: str 54 | """The message sent from the webview UI to the client.""" 55 | 56 | class ClosedNotification(msgspec.Struct, tag_field="$type", tag="closed"): 57 | pass 58 | 59 | Notification = Union[StartedNotification, IpcNotification, ClosedNotification] 60 | """ 61 | Messages that are sent unbidden from the webview to the client. 62 | """ 63 | class SizeWithScale(msgspec.Struct, omit_defaults=True): 64 | height: float 65 | """The height of the window in logical pixels.""" 66 | scaleFactor: float 67 | """The ratio between physical and logical sizes.""" 68 | width: float 69 | """The width of the window in logical pixels.""" 70 | 71 | class StringResultType(msgspec.Struct, tag_field="$type", tag="string"): 72 | value: str 73 | 74 | class BooleanResultType(msgspec.Struct, tag_field="$type", tag="boolean"): 75 | value: bool 76 | 77 | class FloatResultType(msgspec.Struct, tag_field="$type", tag="float"): 78 | value: float 79 | 80 | class SizeResultType(msgspec.Struct, tag_field="$type", tag="size"): 81 | value: SizeWithScale 82 | 83 | ResultType = Union[StringResultType, BooleanResultType, FloatResultType, SizeResultType] 84 | """ 85 | Types that can be returned from webview results. 86 | """ 87 | class AckResponse(msgspec.Struct, tag_field="$type", tag="ack"): 88 | id: int 89 | 90 | class ResultResponse(msgspec.Struct, tag_field="$type", tag="result"): 91 | id: int 92 | result: ResultType 93 | 94 | class ErrResponse(msgspec.Struct, tag_field="$type", tag="err"): 95 | id: int 96 | message: str 97 | 98 | Response = Union[AckResponse, ResultResponse, ErrResponse] 99 | """ 100 | Responses from the webview to the client. 101 | """ 102 | class NotificationMessage(msgspec.Struct, tag_field="$type", tag="notification"): 103 | data: Notification 104 | 105 | class ResponseMessage(msgspec.Struct, tag_field="$type", tag="response"): 106 | data: Response 107 | 108 | Message = Union[NotificationMessage, ResponseMessage] 109 | """ 110 | Complete definition of all outbound messages from the webview to the client. 111 | """ 112 | 113 | 114 | 115 | class ContentUrl(msgspec.Struct, kw_only=True, omit_defaults=True): 116 | url: str 117 | """Url to load in the webview. Note: Don't use data URLs here, as they are not supported. Use the `html` field instead.""" 118 | headers: Union[dict[str, str], None] = None 119 | """Optional headers to send with the request.""" 120 | 121 | class ContentHtml(msgspec.Struct, kw_only=True, omit_defaults=True): 122 | html: str 123 | """Html to load in the webview.""" 124 | origin: Union[str, None] = None 125 | """What to set as the origin of the webview when loading html.""" 126 | 127 | Content = Union[ContentUrl, ContentHtml] 128 | """ 129 | The content to load into the webview. 130 | """ 131 | class Size(msgspec.Struct, omit_defaults=True): 132 | height: float 133 | """The height of the window in logical pixels.""" 134 | width: float 135 | """The width of the window in logical pixels.""" 136 | 137 | class WindowSizeStates(str, Enum): 138 | maximized = "maximized" 139 | fullscreen = "fullscreen" 140 | 141 | WindowSize = Union[WindowSizeStates, Size] 142 | class Options(msgspec.Struct, omit_defaults=True): 143 | """ 144 | Options for creating a webview. 145 | """ 146 | title: str 147 | """Sets the title of the window.""" 148 | acceptFirstMouse: Union[bool, None] = None 149 | """Sets whether clicking an inactive window also clicks through to the webview. Default is false.""" 150 | autoplay: Union[bool, None] = None 151 | """When true, all media can be played without user interaction. Default is false.""" 152 | clipboard: Union[bool, None] = None 153 | """Enables clipboard access for the page rendered on Linux and Windows. 154 | 155 | macOS doesn’t provide such method and is always enabled by default. But your app will still need to add menu item accelerators to use the clipboard shortcuts.""" 156 | decorations: Union[bool, None] = None 157 | """When true, the window will have a border, a title bar, etc. Default is true.""" 158 | devtools: Union[bool, None] = None 159 | """Enable or disable webview devtools. 160 | 161 | Note this only enables devtools to the webview. To open it, you can call `webview.open_devtools()`, or right click the page and open it from the context menu.""" 162 | focused: Union[bool, None] = None 163 | """Sets whether the webview should be focused when created. Default is false.""" 164 | incognito: Union[bool, None] = None 165 | """Run the WebView with incognito mode. Note that WebContext will be ingored if incognito is enabled. 166 | 167 | Platform-specific: - Windows: Requires WebView2 Runtime version 101.0.1210.39 or higher, does nothing on older versions, see https://learn.microsoft.com/en-us/microsoft-edge/webview2/release-notes/archive?tabs=dotnetcsharp#10121039""" 168 | initializationScript: Union[str, None] = None 169 | """Run JavaScript code when loading new pages. When the webview loads a new page, this code will be executed. It is guaranteed that the code is executed before window.onload.""" 170 | ipc: Union[bool, None] = None 171 | """Sets whether host should be able to receive messages from the webview via `window.ipc.postMessage`.""" 172 | load: Union[Content, None] = None 173 | """The content to load into the webview.""" 174 | size: Union[WindowSize, None] = None 175 | """The size of the window.""" 176 | transparent: Union[bool, None] = None 177 | """Sets whether the window should be transparent.""" 178 | userAgent: Union[str, None] = None 179 | """Sets the user agent to use when loading pages.""" 180 | 181 | 182 | 183 | 184 | class GetVersionRequest(msgspec.Struct, tag_field="$type", tag="getVersion"): 185 | id: int 186 | """The id of the request.""" 187 | 188 | class EvalRequest(msgspec.Struct, tag_field="$type", tag="eval"): 189 | id: int 190 | """The id of the request.""" 191 | js: str 192 | """The javascript to evaluate.""" 193 | 194 | class SetTitleRequest(msgspec.Struct, tag_field="$type", tag="setTitle"): 195 | id: int 196 | """The id of the request.""" 197 | title: str 198 | """The title to set.""" 199 | 200 | class GetTitleRequest(msgspec.Struct, tag_field="$type", tag="getTitle"): 201 | id: int 202 | """The id of the request.""" 203 | 204 | class SetVisibilityRequest(msgspec.Struct, tag_field="$type", tag="setVisibility"): 205 | id: int 206 | """The id of the request.""" 207 | visible: bool 208 | """Whether the window should be visible or hidden.""" 209 | 210 | class IsVisibleRequest(msgspec.Struct, tag_field="$type", tag="isVisible"): 211 | id: int 212 | """The id of the request.""" 213 | 214 | class OpenDevToolsRequest(msgspec.Struct, tag_field="$type", tag="openDevTools"): 215 | id: int 216 | """The id of the request.""" 217 | 218 | class GetSizeRequest(msgspec.Struct, tag_field="$type", tag="getSize"): 219 | id: int 220 | """The id of the request.""" 221 | include_decorations: Union[bool, None] = None 222 | """Whether to include the title bar and borders in the size measurement.""" 223 | 224 | class SetSizeRequest(msgspec.Struct, tag_field="$type", tag="setSize"): 225 | id: int 226 | """The id of the request.""" 227 | size: Size 228 | """The size to set.""" 229 | 230 | class FullscreenRequest(msgspec.Struct, tag_field="$type", tag="fullscreen"): 231 | id: int 232 | """The id of the request.""" 233 | fullscreen: Union[bool, None] = None 234 | """Whether to enter fullscreen mode. If left unspecified, the window will enter fullscreen mode if it is not already in fullscreen mode or exit fullscreen mode if it is currently in fullscreen mode.""" 235 | 236 | class MaximizeRequest(msgspec.Struct, tag_field="$type", tag="maximize"): 237 | id: int 238 | """The id of the request.""" 239 | maximized: Union[bool, None] = None 240 | """Whether to maximize the window. If left unspecified, the window will be maximized if it is not already maximized or restored if it was previously maximized.""" 241 | 242 | class MinimizeRequest(msgspec.Struct, tag_field="$type", tag="minimize"): 243 | id: int 244 | """The id of the request.""" 245 | minimized: Union[bool, None] = None 246 | """Whether to minimize the window. If left unspecified, the window will be minimized if it is not already minimized or restored if it was previously minimized.""" 247 | 248 | class LoadHtmlRequest(msgspec.Struct, tag_field="$type", tag="loadHtml"): 249 | html: str 250 | """HTML to set as the content of the webview.""" 251 | id: int 252 | """The id of the request.""" 253 | origin: Union[str, None] = None 254 | """What to set as the origin of the webview when loading html. If not specified, the origin will be set to the value of the `origin` field when the webview was created.""" 255 | 256 | class LoadUrlRequest(msgspec.Struct, tag_field="$type", tag="loadUrl"): 257 | id: int 258 | """The id of the request.""" 259 | url: str 260 | """URL to load in the webview.""" 261 | headers: Union[dict[str, str], None] = None 262 | """Optional headers to send with the request.""" 263 | 264 | Request = Union[GetVersionRequest, EvalRequest, SetTitleRequest, GetTitleRequest, SetVisibilityRequest, IsVisibleRequest, OpenDevToolsRequest, GetSizeRequest, SetSizeRequest, FullscreenRequest, MaximizeRequest, MinimizeRequest, LoadHtmlRequest, LoadUrlRequest] 265 | """ 266 | Explicit requests from the client to the webview. 267 | """ 268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /src/clients/python/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.12" 4 | 5 | [[package]] 6 | name = "aiofiles" 7 | version = "24.1.0" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 }, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.6.0" 17 | source = { registry = "https://pypi.org/simple" } 18 | dependencies = [ 19 | { name = "idna" }, 20 | { name = "sniffio" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 }, 25 | ] 26 | 27 | [[package]] 28 | name = "certifi" 29 | version = "2024.8.30" 30 | source = { registry = "https://pypi.org/simple" } 31 | sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } 32 | wheels = [ 33 | { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, 34 | ] 35 | 36 | [[package]] 37 | name = "h11" 38 | version = "0.14.0" 39 | source = { registry = "https://pypi.org/simple" } 40 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 41 | wheels = [ 42 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 43 | ] 44 | 45 | [[package]] 46 | name = "httpcore" 47 | version = "1.0.6" 48 | source = { registry = "https://pypi.org/simple" } 49 | dependencies = [ 50 | { name = "certifi" }, 51 | { name = "h11" }, 52 | ] 53 | sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } 54 | wheels = [ 55 | { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, 56 | ] 57 | 58 | [[package]] 59 | name = "httpx" 60 | version = "0.27.2" 61 | source = { registry = "https://pypi.org/simple" } 62 | dependencies = [ 63 | { name = "anyio" }, 64 | { name = "certifi" }, 65 | { name = "httpcore" }, 66 | { name = "idna" }, 67 | { name = "sniffio" }, 68 | ] 69 | sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } 70 | wheels = [ 71 | { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, 72 | ] 73 | 74 | [[package]] 75 | name = "idna" 76 | version = "3.10" 77 | source = { registry = "https://pypi.org/simple" } 78 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 79 | wheels = [ 80 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 81 | ] 82 | 83 | [[package]] 84 | name = "justbe-webview" 85 | version = "0.0.1" 86 | source = { editable = "." } 87 | dependencies = [ 88 | { name = "aiofiles" }, 89 | { name = "httpx" }, 90 | { name = "msgspec" }, 91 | { name = "pyee" }, 92 | { name = "python-ulid" }, 93 | { name = "types-aiofiles" }, 94 | ] 95 | 96 | [package.dev-dependencies] 97 | dev = [ 98 | { name = "ruff" }, 99 | ] 100 | 101 | [package.metadata] 102 | requires-dist = [ 103 | { name = "aiofiles", specifier = ">=24.1.0" }, 104 | { name = "httpx", specifier = ">=0.27.2" }, 105 | { name = "msgspec", specifier = ">=0.19.0" }, 106 | { name = "pyee", specifier = ">=12.0.0" }, 107 | { name = "python-ulid", specifier = ">=3.0.0" }, 108 | { name = "types-aiofiles", specifier = ">=24.1.0.20240626" }, 109 | ] 110 | 111 | [package.metadata.requires-dev] 112 | dev = [{ name = "ruff", specifier = ">=0.6.9" }] 113 | 114 | [[package]] 115 | name = "msgspec" 116 | version = "0.19.0" 117 | source = { registry = "https://pypi.org/simple" } 118 | sdist = { url = "https://files.pythonhosted.org/packages/cf/9b/95d8ce458462b8b71b8a70fa94563b2498b89933689f3a7b8911edfae3d7/msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e", size = 216934 } 119 | wheels = [ 120 | { url = "https://files.pythonhosted.org/packages/b2/5f/a70c24f075e3e7af2fae5414c7048b0e11389685b7f717bb55ba282a34a7/msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f", size = 190485 }, 121 | { url = "https://files.pythonhosted.org/packages/89/b0/1b9763938cfae12acf14b682fcf05c92855974d921a5a985ecc197d1c672/msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2", size = 183910 }, 122 | { url = "https://files.pythonhosted.org/packages/87/81/0c8c93f0b92c97e326b279795f9c5b956c5a97af28ca0fbb9fd86c83737a/msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12", size = 210633 }, 123 | { url = "https://files.pythonhosted.org/packages/d0/ef/c5422ce8af73928d194a6606f8ae36e93a52fd5e8df5abd366903a5ca8da/msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc", size = 213594 }, 124 | { url = "https://files.pythonhosted.org/packages/19/2b/4137bc2ed45660444842d042be2cf5b18aa06efd2cda107cff18253b9653/msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c", size = 214053 }, 125 | { url = "https://files.pythonhosted.org/packages/9d/e6/8ad51bdc806aac1dc501e8fe43f759f9ed7284043d722b53323ea421c360/msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537", size = 219081 }, 126 | { url = "https://files.pythonhosted.org/packages/b1/ef/27dd35a7049c9a4f4211c6cd6a8c9db0a50647546f003a5867827ec45391/msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0", size = 187467 }, 127 | { url = "https://files.pythonhosted.org/packages/3c/cb/2842c312bbe618d8fefc8b9cedce37f773cdc8fa453306546dba2c21fd98/msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86", size = 190498 }, 128 | { url = "https://files.pythonhosted.org/packages/58/95/c40b01b93465e1a5f3b6c7d91b10fb574818163740cc3acbe722d1e0e7e4/msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314", size = 183950 }, 129 | { url = "https://files.pythonhosted.org/packages/e8/f0/5b764e066ce9aba4b70d1db8b087ea66098c7c27d59b9dd8a3532774d48f/msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e", size = 210647 }, 130 | { url = "https://files.pythonhosted.org/packages/9d/87/bc14f49bc95c4cb0dd0a8c56028a67c014ee7e6818ccdce74a4862af259b/msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5", size = 213563 }, 131 | { url = "https://files.pythonhosted.org/packages/53/2f/2b1c2b056894fbaa975f68f81e3014bb447516a8b010f1bed3fb0e016ed7/msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9", size = 213996 }, 132 | { url = "https://files.pythonhosted.org/packages/aa/5a/4cd408d90d1417e8d2ce6a22b98a6853c1b4d7cb7669153e4424d60087f6/msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327", size = 219087 }, 133 | { url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432 }, 134 | ] 135 | 136 | [[package]] 137 | name = "pyee" 138 | version = "12.0.0" 139 | source = { registry = "https://pypi.org/simple" } 140 | dependencies = [ 141 | { name = "typing-extensions" }, 142 | ] 143 | sdist = { url = "https://files.pythonhosted.org/packages/d2/a7/8faaa62a488a2a1e0d56969757f087cbd2729e9bcfa508c230299f366b4c/pyee-12.0.0.tar.gz", hash = "sha256:c480603f4aa2927d4766eb41fa82793fe60a82cbfdb8d688e0d08c55a534e145", size = 29675 } 144 | wheels = [ 145 | { url = "https://files.pythonhosted.org/packages/1d/0d/95993c08c721ec68892547f2117e8f9dfbcef2ca71e098533541b4a54d5f/pyee-12.0.0-py3-none-any.whl", hash = "sha256:7b14b74320600049ccc7d0e0b1becd3b4bd0a03c745758225e31a59f4095c990", size = 14831 }, 146 | ] 147 | 148 | [[package]] 149 | name = "python-ulid" 150 | version = "3.0.0" 151 | source = { registry = "https://pypi.org/simple" } 152 | sdist = { url = "https://files.pythonhosted.org/packages/9a/db/e5e67aeca9c2420cb91f94007f30693cc3628ae9783a565fd33ffb3fbfdd/python_ulid-3.0.0.tar.gz", hash = "sha256:e50296a47dc8209d28629a22fc81ca26c00982c78934bd7766377ba37ea49a9f", size = 28822 } 153 | wheels = [ 154 | { url = "https://files.pythonhosted.org/packages/63/4e/cc2ba2c0df2589f35a4db8473b8c2ba9bbfc4acdec4a94f1c78934d2350f/python_ulid-3.0.0-py3-none-any.whl", hash = "sha256:e4c4942ff50dbd79167ad01ac725ec58f924b4018025ce22c858bfcff99a5e31", size = 11194 }, 155 | ] 156 | 157 | [[package]] 158 | name = "ruff" 159 | version = "0.6.9" 160 | source = { registry = "https://pypi.org/simple" } 161 | sdist = { url = "https://files.pythonhosted.org/packages/26/0d/6148a48dab5662ca1d5a93b7c0d13c03abd3cc7e2f35db08410e47cef15d/ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", size = 3095355 } 162 | wheels = [ 163 | { url = "https://files.pythonhosted.org/packages/6e/8f/f7a0a0ef1818662efb32ed6df16078c95da7a0a3248d64c2410c1e27799f/ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", size = 10440526 }, 164 | { url = "https://files.pythonhosted.org/packages/8b/69/b179a5faf936a9e2ab45bb412a668e4661eded964ccfa19d533f29463ef6/ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", size = 10034612 }, 165 | { url = "https://files.pythonhosted.org/packages/c7/ef/fd1b4be979c579d191eeac37b5cfc0ec906de72c8bcd8595e2c81bb700c1/ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", size = 9706197 }, 166 | { url = "https://files.pythonhosted.org/packages/29/61/b376d775deb5851cb48d893c568b511a6d3625ef2c129ad5698b64fb523c/ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", size = 10751855 }, 167 | { url = "https://files.pythonhosted.org/packages/13/d7/def9e5f446d75b9a9c19b24231a3a658c075d79163b08582e56fa5dcfa38/ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", size = 10200889 }, 168 | { url = "https://files.pythonhosted.org/packages/6c/d6/7f34160818bcb6e84ce293a5966cba368d9112ff0289b273fbb689046047/ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", size = 11038678 }, 169 | { url = "https://files.pythonhosted.org/packages/13/34/a40ff8ae62fb1b26fb8e6fa7e64bc0e0a834b47317880de22edd6bfb54fb/ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", size = 11808682 }, 170 | { url = "https://files.pythonhosted.org/packages/2e/6d/25a4386ae4009fc798bd10ba48c942d1b0b3e459b5403028f1214b6dd161/ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", size = 11330446 }, 171 | { url = "https://files.pythonhosted.org/packages/f7/f6/bdf891a9200d692c94ebcd06ae5a2fa5894e522f2c66c2a12dd5d8cb2654/ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", size = 12483048 }, 172 | { url = "https://files.pythonhosted.org/packages/a7/86/96f4252f41840e325b3fa6c48297e661abb9f564bd7dcc0572398c8daa42/ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", size = 10936855 }, 173 | { url = "https://files.pythonhosted.org/packages/45/87/801a52d26c8dbf73424238e9908b9ceac430d903c8ef35eab1b44fcfa2bd/ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", size = 10713007 }, 174 | { url = "https://files.pythonhosted.org/packages/be/27/6f7161d90320a389695e32b6ebdbfbedde28ccbf52451e4b723d7ce744ad/ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", size = 10274594 }, 175 | { url = "https://files.pythonhosted.org/packages/00/52/dc311775e7b5f5b19831563cb1572ecce63e62681bccc609867711fae317/ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", size = 10608024 }, 176 | { url = "https://files.pythonhosted.org/packages/98/b6/be0a1ddcbac65a30c985cf7224c4fce786ba2c51e7efeb5178fe410ed3cf/ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", size = 10982085 }, 177 | { url = "https://files.pythonhosted.org/packages/bb/a4/c84bc13d0b573cf7bb7d17b16d6d29f84267c92d79b2f478d4ce322e8e72/ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d", size = 8522088 }, 178 | { url = "https://files.pythonhosted.org/packages/74/be/fc352bd8ca40daae8740b54c1c3e905a7efe470d420a268cd62150248c91/ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", size = 9359275 }, 179 | { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879 }, 180 | ] 181 | 182 | [[package]] 183 | name = "sniffio" 184 | version = "1.3.1" 185 | source = { registry = "https://pypi.org/simple" } 186 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 187 | wheels = [ 188 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 189 | ] 190 | 191 | [[package]] 192 | name = "types-aiofiles" 193 | version = "24.1.0.20240626" 194 | source = { registry = "https://pypi.org/simple" } 195 | sdist = { url = "https://files.pythonhosted.org/packages/13/e9/013940b017c313c2e15c64017268fdb0c25e0638621fb8a5d9ebe00fb0f4/types-aiofiles-24.1.0.20240626.tar.gz", hash = "sha256:48604663e24bc2d5038eac05ccc33e75799b0779e93e13d6a8f711ddc306ac08", size = 9357 } 196 | wheels = [ 197 | { url = "https://files.pythonhosted.org/packages/c3/ad/c4b3275d21c5be79487c4f6ed7cd13336997746fe099236cb29256a44a90/types_aiofiles-24.1.0.20240626-py3-none-any.whl", hash = "sha256:7939eca4a8b4f9c6491b6e8ef160caee9a21d32e18534a57d5ed90aee47c66b4", size = 9389 }, 198 | ] 199 | 200 | [[package]] 201 | name = "typing-extensions" 202 | version = "4.12.2" 203 | source = { registry = "https://pypi.org/simple" } 204 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 205 | wheels = [ 206 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 207 | ] 208 | --------------------------------------------------------------------------------