├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── push-dev.yml │ └── release.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── gulpfile.ts ├── manifest.json ├── native ├── .clang-format ├── extra_script.py ├── install │ ├── linux_x86_64.sh │ ├── windows_x86.json │ └── windows_x86.nsi ├── platformio.ini └── src │ ├── include.h │ ├── main.c │ ├── printf.c │ ├── serial.c │ ├── serial.h │ ├── serial_linux.c │ ├── serial_win.c │ ├── stdmsg.c │ ├── stdmsg.h │ ├── webserial_config.h │ ├── websocket.c │ └── websocket.h ├── package-lock.json ├── package.json ├── src ├── background.ts ├── content.ts ├── messaging │ ├── background.ts │ ├── index.ts │ ├── native.ts │ ├── popup.ts │ └── promises.ts ├── polyfill.ts ├── serial │ ├── sink.ts │ ├── source.ts │ ├── types.ts │ └── websocket.ts ├── ui.ts ├── ui │ ├── components │ │ ├── Common.tsx │ │ ├── NativeInfo.tsx │ │ └── NativeInstaller.tsx │ ├── controls │ │ ├── Button.tsx │ │ └── List.tsx │ ├── index.html │ ├── index.scss │ ├── index.ts │ ├── pages │ │ └── PortChooser.tsx │ └── test.html └── utils │ ├── auth.ts │ ├── logging.ts │ ├── types.ts │ └── utils.ts ├── tsconfig.json └── webserial.d.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | gulpfile.ts 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "@typescript-eslint", 5 | "react" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "env": { 13 | "browser": true, 14 | "node": false 15 | }, 16 | "rules": { 17 | "@typescript-eslint/triple-slash-reference": "off", 18 | "@typescript-eslint/ban-ts-comment": "off", 19 | "@typescript-eslint/no-explicit-any": "off", 20 | "@typescript-eslint/no-inferrable-types": "off", 21 | "no-unused-vars": "off", 22 | "@typescript-eslint/no-unused-vars": [ 23 | "warn", 24 | { 25 | "argsIgnorePattern": "^_", 26 | "varsIgnorePattern": "^_", 27 | "caughtErrorsIgnorePattern": "^_" 28 | } 29 | ] 30 | }, 31 | "parserOptions": { 32 | "project": "./tsconfig.json" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/push-dev.yml: -------------------------------------------------------------------------------- 1 | name: Push (dev), Pull Request 2 | on: 3 | push: 4 | branches: ["**"] 5 | pull_request: 6 | jobs: 7 | lint-node: 8 | name: Run Node.js lint 9 | uses: kuba2k2/kuba2k2/.github/workflows/lint-node.yml@master 10 | lint-clang: 11 | name: Run Clang lint 12 | uses: kuba2k2/kuba2k2/.github/workflows/lint-clang.yml@master 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: ["v*.*.*"] 5 | jobs: 6 | lint-node: 7 | name: Run Node.js lint 8 | uses: kuba2k2/kuba2k2/.github/workflows/lint-node.yml@master 9 | build-node: 10 | name: Build Node.js project 11 | needs: 12 | - lint-node 13 | uses: kuba2k2/kuba2k2/.github/workflows/build-node.yml@master 14 | with: 15 | files: | 16 | dist/ 17 | manifest.json 18 | LICENSE 19 | output-artifact: firefox-webserial-build-node 20 | 21 | lint-clang: 22 | name: Run Clang lint 23 | uses: kuba2k2/kuba2k2/.github/workflows/lint-clang.yml@master 24 | build-pio: 25 | name: Build PlatformIO project 26 | needs: 27 | - lint-clang 28 | uses: kuba2k2/kuba2k2/.github/workflows/build-pio.yml@master 29 | strategy: 30 | matrix: 31 | os: 32 | - runs-on: windows-latest 33 | pio-env: windows_x86 34 | post-build: "" 35 | output-bin: firefox-webserial.exe 36 | output-artifact: firefox-webserial-raw-native-windows-x86 37 | - runs-on: ubuntu-latest 38 | pio-env: linux_x86_64 39 | post-build: | 40 | cd .pio/build/linux_x86_64/ 41 | cp firefox-webserial firefox-webserial-linux-x86-64 42 | output-bin: firefox-webserial-linux-x86-64 43 | output-artifact: firefox-webserial-native-linux-x86-64 44 | with: 45 | runs-on: ${{ matrix.os.runs-on }} 46 | project-directory: ./native/ 47 | args: -e ${{ matrix.os.pio-env }} 48 | post-build: ${{ matrix.os.post-build }} 49 | files: | 50 | native/.pio/build/${{ matrix.os.pio-env }}/${{ matrix.os.output-bin }} 51 | output-artifact: ${{ matrix.os.output-artifact }} 52 | 53 | build-nsis: 54 | name: Build NSIS installer 55 | needs: 56 | - build-pio 57 | uses: kuba2k2/kuba2k2/.github/workflows/build-nsis.yml@master 58 | with: 59 | input-artifact: firefox-webserial-raw-native-windows-x86 60 | input-path: native/install/ 61 | script-file: native/install/windows_x86.nsi 62 | files: | 63 | native/install/firefox-webserial-v*.exe 64 | output-artifact: firefox-webserial-native-windows-x86 65 | 66 | release-amo: 67 | name: Publish addons.mozilla.org release 68 | runs-on: ubuntu-latest 69 | needs: 70 | - build-node 71 | permissions: 72 | contents: write 73 | steps: 74 | - name: Download artifact 75 | uses: actions/download-artifact@v4 76 | with: 77 | name: firefox-webserial-build-node 78 | path: src 79 | 80 | - name: Build Firefox extension 81 | id: build 82 | uses: kewisch/action-web-ext@v1 83 | with: 84 | cmd: build 85 | source: src 86 | filename: "{name}-{version}.xpi" 87 | 88 | - name: Sign Firefox extension 89 | id: sign 90 | uses: kewisch/action-web-ext@v1 91 | with: 92 | cmd: sign 93 | source: ${{ steps.build.outputs.target }} 94 | channel: listed 95 | apiKey: ${{ secrets.AMO_SIGN_KEY }} 96 | apiSecret: ${{ secrets.AMO_SIGN_SECRET }} 97 | timeout: 900000 98 | 99 | - name: Publish GitHub release 100 | uses: softprops/action-gh-release@v1 101 | with: 102 | files: | 103 | ${{ steps.sign.outputs.target }} 104 | fail_on_unmatched_files: false 105 | generate_release_notes: true 106 | 107 | release: 108 | name: Publish GitHub release 109 | runs-on: ubuntu-latest 110 | needs: 111 | - build-pio 112 | - build-nsis 113 | - release-amo 114 | permissions: 115 | contents: write 116 | steps: 117 | - name: Download artifact 118 | uses: actions/download-artifact@v4 119 | with: 120 | pattern: firefox-webserial-native-* 121 | path: dist 122 | merge-multiple: true 123 | 124 | - name: Publish GitHub release 125 | uses: softprops/action-gh-release@v1 126 | with: 127 | files: | 128 | dist/firefox-webserial-* 129 | fail_on_unmatched_files: true 130 | generate_release_notes: true 131 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/c,node,sass,platformio,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=c,node,sass,platformio,visualstudiocode 3 | 4 | ### C ### 5 | # Prerequisites 6 | *.d 7 | 8 | # Object files 9 | *.o 10 | *.ko 11 | *.obj 12 | *.elf 13 | 14 | # Linker output 15 | *.ilk 16 | *.map 17 | *.exp 18 | 19 | # Precompiled Headers 20 | *.gch 21 | *.pch 22 | 23 | # Libraries 24 | *.lib 25 | *.a 26 | *.la 27 | *.lo 28 | 29 | # Shared objects (inc. Windows DLLs) 30 | *.dll 31 | *.so 32 | *.so.* 33 | *.dylib 34 | 35 | # Executables 36 | *.exe 37 | *.out 38 | *.app 39 | *.i*86 40 | *.x86_64 41 | *.hex 42 | 43 | # Debug files 44 | *.dSYM/ 45 | *.su 46 | *.idb 47 | *.pdb 48 | 49 | # Kernel Module Compile Results 50 | *.mod* 51 | *.cmd 52 | .tmp_versions/ 53 | modules.order 54 | Module.symvers 55 | Mkfile.old 56 | dkms.conf 57 | 58 | ### Node ### 59 | # Logs 60 | logs 61 | *.log 62 | npm-debug.log* 63 | yarn-debug.log* 64 | yarn-error.log* 65 | lerna-debug.log* 66 | .pnpm-debug.log* 67 | 68 | # Diagnostic reports (https://nodejs.org/api/report.html) 69 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 70 | 71 | # Runtime data 72 | pids 73 | *.pid 74 | *.seed 75 | *.pid.lock 76 | 77 | # Directory for instrumented libs generated by jscoverage/JSCover 78 | lib-cov 79 | 80 | # Coverage directory used by tools like istanbul 81 | coverage 82 | *.lcov 83 | 84 | # nyc test coverage 85 | .nyc_output 86 | 87 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 88 | .grunt 89 | 90 | # Bower dependency directory (https://bower.io/) 91 | bower_components 92 | 93 | # node-waf configuration 94 | .lock-wscript 95 | 96 | # Compiled binary addons (https://nodejs.org/api/addons.html) 97 | build/Release 98 | 99 | # Dependency directories 100 | node_modules/ 101 | jspm_packages/ 102 | 103 | # Snowpack dependency directory (https://snowpack.dev/) 104 | web_modules/ 105 | 106 | # TypeScript cache 107 | *.tsbuildinfo 108 | 109 | # Optional npm cache directory 110 | .npm 111 | 112 | # Optional eslint cache 113 | .eslintcache 114 | 115 | # Optional stylelint cache 116 | .stylelintcache 117 | 118 | # Microbundle cache 119 | .rpt2_cache/ 120 | .rts2_cache_cjs/ 121 | .rts2_cache_es/ 122 | .rts2_cache_umd/ 123 | 124 | # Optional REPL history 125 | .node_repl_history 126 | 127 | # Output of 'npm pack' 128 | *.tgz 129 | 130 | # Yarn Integrity file 131 | .yarn-integrity 132 | 133 | # dotenv environment variable files 134 | .env 135 | .env.development.local 136 | .env.test.local 137 | .env.production.local 138 | .env.local 139 | 140 | # parcel-bundler cache (https://parceljs.org/) 141 | .cache 142 | .parcel-cache 143 | 144 | # Next.js build output 145 | .next 146 | out 147 | 148 | # Nuxt.js build / generate output 149 | .nuxt 150 | dist 151 | 152 | # Gatsby files 153 | .cache/ 154 | # Comment in the public line in if your project uses Gatsby and not Next.js 155 | # https://nextjs.org/blog/next-9-1#public-directory-support 156 | # public 157 | 158 | # vuepress build output 159 | .vuepress/dist 160 | 161 | # vuepress v2.x temp and cache directory 162 | .temp 163 | 164 | # Docusaurus cache and generated files 165 | .docusaurus 166 | 167 | # Serverless directories 168 | .serverless/ 169 | 170 | # FuseBox cache 171 | .fusebox/ 172 | 173 | # DynamoDB Local files 174 | .dynamodb/ 175 | 176 | # TernJS port file 177 | .tern-port 178 | 179 | # Stores VSCode versions used for testing VSCode extensions 180 | .vscode-test 181 | 182 | # yarn v2 183 | .yarn/cache 184 | .yarn/unplugged 185 | .yarn/build-state.yml 186 | .yarn/install-state.gz 187 | .pnp.* 188 | 189 | ### Node Patch ### 190 | # Serverless Webpack directories 191 | .webpack/ 192 | 193 | # Optional stylelint cache 194 | 195 | # SvelteKit build / generate output 196 | .svelte-kit 197 | 198 | ### PlatformIO ### 199 | .pioenvs 200 | .piolibdeps 201 | .clang_complete 202 | .gcc-flags.json 203 | .pio 204 | 205 | ### Sass ### 206 | .sass-cache/ 207 | *.css.map 208 | *.sass.map 209 | *.scss.map 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | !.vscode/*.code-snippets 218 | 219 | # Local History for Visual Studio Code 220 | .history/ 221 | 222 | # Built Visual Studio Code Extensions 223 | *.vsix 224 | 225 | ### VisualStudioCode Patch ### 226 | # Ignore all local history of files 227 | .history 228 | .ionide 229 | 230 | # End of https://www.toptal.com/developers/gitignore/api/c,node,sass,platformio,visualstudiocode 231 | 232 | browserify-cache.json 233 | .vscode 234 | *.xpi 235 | *.zip 236 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": true, 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kuba Szczodrzyński 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebSerial for Firefox 2 | 3 | WebSerial API Polyfill for Mozilla Firefox browser 4 | 5 | ## Introduction 6 | 7 | This add-on allows to use the WebSerial API in Firefox. 8 | 9 | It uses a native application to communicate with serial ports. 10 | 11 | **NOTE:** Currently, the add-on only works on Windows and Linux (x86-64). 12 | 13 | ## Installation 14 | 15 | The add-on is available for download from Mozilla Addons: 16 | [WebSerial for Firefox](https://addons.mozilla.org/pl/firefox/addon/webserial-for-firefox/). 17 | 18 | The native application needs to be installed on the computer first. The GUI will offer to download the 19 | native application when you first try to open a serial port. 20 | 21 | ### Installation on Windows 22 | 23 | The .exe file is an installer - just open it and install the native application. 24 | 25 | ### Installation on Linux 26 | 27 | Run script: 28 | 29 | ```sh 30 | curl -s -L https://raw.githubusercontent.com/kuba2k2/firefox-webserial/master/native/install/linux_x86_64.sh | bash 31 | ``` 32 | 33 | #### or install manually 34 | 35 | 1. Put the downloaded file in `~/.mozilla/native-messaging-hosts` 36 | 2. Rename it to just `firefox-webserial`. 37 | 3. Make it executable: `chmod +x ~/.mozilla/native-messaging-hosts/firefox-webserial`. 38 | 4. Create a file named `io.github.kuba2k2.webserial.json` in the same directory, with this content: 39 | ```json 40 | { 41 | "name": "io.github.kuba2k2.webserial", 42 | "description": "WebSerial for Firefox", 43 | "path": "/home/USER/.mozilla/native-messaging-hosts/firefox-webserial", 44 | "type": "stdio", 45 | "allowed_extensions": ["webserial@kuba2k2.github.io"] 46 | } 47 | ``` 48 | Adjust `/home/USER` to match your username. 49 | 5. Restart the browser and use the extension. 50 | 51 | **NOTE:** On Alpine Linux (or other musl-based distros) you will need to have `gcompat` installed. 52 | 53 | ## Usage 54 | 55 | Some applications that can work on Firefox with this add-on: 56 | 57 | - [Spacehuhn Serial Terminal](https://serial.huhn.me/) 58 | - [Google Chrome Labs Serial Terminal](https://googlechromelabs.github.io/serial-terminal/) 59 | - [ESPWebTool by Spacehuhn](https://esp.huhn.me/) 60 | - [ESP Tool by Espressif](https://espressif.github.io/esptool-js/) 61 | - [ESPHome Web](https://web.esphome.io/) 62 | - [ESP Web Tools by ESPHome](https://esphome.github.io/esp-web-tools/) 63 | - [NinjaTerm by Geoffrey Hunter](https://ninjaterm.mbedded.ninja/) 64 | 65 | ## Debugging 66 | 67 | To view logs produced by the extension for debugging purposes: 68 | 69 | - Open [about:debugging](about:debugging), click `This Firefox` 70 | - Find `WebSerial for Firefox`, click `Inspect` 71 | - Type in the console: `window.wsdebug = true` 72 | - Go to a website of choice, try connecting to a serial port - the console should show extension logs. 73 | 74 | ## License 75 | 76 | ``` 77 | MIT License 78 | 79 | Copyright (c) 2023 Kuba Szczodrzyński 80 | 81 | Permission is hereby granted, free of charge, to any person obtaining a copy 82 | of this software and associated documentation files (the "Software"), to deal 83 | in the Software without restriction, including without limitation the rights 84 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 85 | copies of the Software, and to permit persons to whom the Software is 86 | furnished to do so, subject to the following conditions: 87 | 88 | The above copyright notice and this permission notice shall be included in all 89 | copies or substantial portions of the Software. 90 | 91 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 92 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 93 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 94 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 95 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 96 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 97 | SOFTWARE. 98 | ``` 99 | -------------------------------------------------------------------------------- /gulpfile.ts: -------------------------------------------------------------------------------- 1 | import gulp from "gulp" 2 | import sourcemaps from "gulp-sourcemaps" 3 | import concat from "gulp-concat" 4 | import { BrowserifyObject } from "browserify" 5 | import uglify from "gulp-uglify" 6 | import rename from "gulp-rename" 7 | import gulpIf from "gulp-if" 8 | import bro from "gulp-bro" 9 | const sass = require("gulp-sass")(require("sass")) 10 | 11 | function tsifyBabelify(b: BrowserifyObject, opts: { debug: boolean }) { 12 | b.plugin("tsify") 13 | b.transform("babelify", { 14 | presets: ["@babel/preset-typescript", "@babel/preset-react"], 15 | extensions: [".ts", ".tsx"], 16 | sourceMaps: opts.debug, 17 | }) 18 | b.transform("envify", { 19 | DEBUG: opts.debug ? "true" : "false", 20 | }) 21 | } 22 | 23 | function css(debug?: boolean) { 24 | return gulp 25 | .src("src/ui/index.scss") 26 | .pipe(gulpIf(debug, sourcemaps.init())) 27 | .pipe( 28 | sass 29 | .sync({ outputStyle: debug ? "expanded" : "compressed" }) 30 | .on("error", sass.logError) 31 | ) 32 | .pipe(concat("webserial.css")) 33 | .pipe(gulpIf(debug, sourcemaps.write("."))) 34 | .pipe(gulp.dest("dist/")) 35 | } 36 | 37 | function resCopy() { 38 | return gulp.src("src/ui/*.html").pipe(gulp.dest("dist/")) 39 | } 40 | 41 | function js(debug?: boolean, src?: string) { 42 | const sources = { 43 | "background": "webserial.background", 44 | "content": "webserial.content", 45 | "polyfill": "webserial.polyfill", 46 | "ui": "webserial.ui", 47 | } 48 | 49 | return gulp 50 | .src(src || Object.keys(sources).map((s) => `src/${s}.ts`)) 51 | .pipe( 52 | bro({ 53 | debug: debug, 54 | cacheFile: "browserify-cache.json", 55 | plugin: [[tsifyBabelify, { debug }]], 56 | }) 57 | ) 58 | .pipe(gulpIf(debug, sourcemaps.init({ loadMaps: true }))) 59 | .pipe(gulpIf(!debug, uglify())) 60 | .pipe( 61 | rename((opt) => { 62 | opt.basename = sources[opt.basename] 63 | opt.extname = ".js" 64 | }) 65 | ) 66 | .pipe(gulpIf(debug, sourcemaps.write("."))) 67 | .pipe(gulp.dest("dist/")) 68 | } 69 | 70 | gulp.task("css", () => { 71 | return css(false) 72 | }) 73 | 74 | gulp.task("css:dev", () => { 75 | return css(true) 76 | }) 77 | 78 | gulp.task("css:watch", () => { 79 | return gulp.watch( 80 | ["src/**/*.scss"], 81 | { ignoreInitial: false }, 82 | gulp.task("css:dev") 83 | ) 84 | }) 85 | 86 | gulp.task("html", () => { 87 | return resCopy() 88 | }) 89 | 90 | gulp.task("html:dev", () => { 91 | return resCopy() 92 | }) 93 | 94 | gulp.task("html:watch", () => { 95 | return gulp.watch( 96 | ["src/ui/*.html"], 97 | { ignoreInitial: false }, 98 | gulp.task("html:dev") 99 | ) 100 | }) 101 | 102 | gulp.task("js", () => { 103 | return js(false) 104 | }) 105 | 106 | gulp.task("js:dev", () => { 107 | return js(true) 108 | }) 109 | 110 | gulp.task("js:watch", () => { 111 | gulp.series(gulp.task("js:dev"))(null) 112 | return gulp.watch(["src/**/*.ts", "src/**/*.tsx"]).on("change", (file) => { 113 | gulp.series(gulp.task("js:dev"))(null) 114 | }) 115 | }) 116 | 117 | gulp.task("build", gulp.parallel("css", "js", "html")) 118 | gulp.task("build:dev", gulp.parallel("css:dev", "js:dev", "html:dev")) 119 | gulp.task("watch", gulp.parallel("css:watch", "js:watch", "html:watch")) 120 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "WebSerial for Firefox", 4 | "version": "0.4.0", 5 | "browser_specific_settings": { 6 | "gecko": { 7 | "id": "webserial@kuba2k2.github.io" 8 | } 9 | }, 10 | "content_scripts": [ 11 | { 12 | "matches": [ 13 | "*://*/*" 14 | ], 15 | "js": [ 16 | "dist/webserial.content.js" 17 | ], 18 | "all_frames": true, 19 | "run_at": "document_start" 20 | } 21 | ], 22 | "background": { 23 | "scripts": [ 24 | "dist/webserial.background.js" 25 | ], 26 | "persistent": true, 27 | "type": "module" 28 | }, 29 | "permissions": [ 30 | "*://*/*", 31 | "nativeMessaging", 32 | "storage" 33 | ], 34 | "content_security_policy": "default-src 'self' 'unsafe-inline'; script-src 'self' http://localhost:8097; connect-src 'self' ws://localhost:8097" 35 | } 36 | -------------------------------------------------------------------------------- /native/.clang-format: -------------------------------------------------------------------------------- 1 | # 2025-02-12 2 | Language: Cpp 3 | BasedOnStyle: LLVM 4 | AlignAfterOpenBracket: BlockIndent 5 | AlignArrayOfStructures: Left 6 | AlignConsecutiveAssignments: true 7 | AlignConsecutiveMacros: AcrossComments 8 | AlignEscapedNewlinesLeft: true 9 | AlignTrailingComments: true 10 | AllowAllArgumentsOnNextLine: false 11 | AllowAllParametersOfDeclarationOnNextLine: false 12 | AllowShortBlocksOnASingleLine: Empty 13 | AllowShortFunctionsOnASingleLine: Empty 14 | AlwaysBreakTemplateDeclarations: Yes 15 | BinPackArguments: false 16 | BinPackParameters: false 17 | BreakBeforeTernaryOperators: true 18 | ColumnLimit: 120 19 | ContinuationIndentWidth: 4 20 | EmptyLineBeforeAccessModifier: Always 21 | FixNamespaceComments: true 22 | IndentAccessModifiers: false 23 | IndentCaseLabels: true 24 | IndentWidth: 4 25 | LambdaBodyIndentation: Signature 26 | MaxEmptyLinesToKeep: 1 27 | PenaltyReturnTypeOnItsOwnLine: 1000 28 | QualifierAlignment: Left 29 | ReflowComments: true 30 | SeparateDefinitionBlocks: Always 31 | TabWidth: 4 32 | UseTab: Always 33 | -------------------------------------------------------------------------------- /native/extra_script.py: -------------------------------------------------------------------------------- 1 | Import("env") 2 | 3 | env.Replace(PROGNAME="firefox-webserial") 4 | -------------------------------------------------------------------------------- /native/install/linux_x86_64.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p ~/.mozilla/native-messaging-hosts 4 | curl -L -o ~/.mozilla/native-messaging-hosts/firefox-webserial https://github.com/kuba2k2/firefox-webserial/releases/latest/download/firefox-webserial-linux-x86-64 5 | chmod +x ~/.mozilla/native-messaging-hosts/firefox-webserial 6 | cat > ~/.mozilla/native-messaging-hosts/io.github.kuba2k2.webserial.json < 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | 18 | #include "webserial_config.h" 19 | 20 | #include "serial.h" 21 | #include "stdmsg.h" 22 | #include "websocket.h" 23 | -------------------------------------------------------------------------------- /native/src/main.c: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-21. */ 2 | 3 | #include "include.h" 4 | 5 | int main(void) { 6 | websocket_start(); 7 | 8 | while (true) { 9 | int ret = stdmsg_receive(); 10 | if (ret == 0) 11 | break; 12 | if (ret < 0) { 13 | printf("ERROR %d\n", ret); 14 | return -ret; 15 | } 16 | } 17 | 18 | return 0; 19 | } 20 | -------------------------------------------------------------------------------- /native/src/printf.c: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-22. */ 2 | 3 | #include "include.h" 4 | 5 | int __wrap___mingw_vprintf(const char *format, va_list argv) { 6 | uint32_t len = vsnprintf(NULL, 0, format, argv); 7 | 8 | char *buf = malloc(len + 1); 9 | int ret = vsprintf(buf, format, argv); 10 | 11 | stdmsg_send_log(buf); 12 | free(buf); 13 | 14 | return ret; 15 | } 16 | 17 | int __wrap_printf(const char *format, ...) { 18 | va_list argv; 19 | va_start(argv, format); 20 | uint32_t len = vsnprintf(NULL, 0, format, argv); 21 | va_end(argv); 22 | 23 | char *buf = malloc(len + 1); 24 | int ret = vsprintf(buf, format, argv); 25 | 26 | stdmsg_send_log(buf); 27 | free(buf); 28 | 29 | return ret; 30 | } 31 | -------------------------------------------------------------------------------- /native/src/serial.c: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-22. */ 2 | 3 | #include "serial.h" 4 | 5 | static const char *SP_TRANSPORT_STR[] = { 6 | [SP_TRANSPORT_NATIVE] = "NATIVE", 7 | [SP_TRANSPORT_USB] = "USB", 8 | [SP_TRANSPORT_BLUETOOTH] = "BLUETOOTH", 9 | }; 10 | 11 | static serial_port_t *port_arr = NULL; 12 | static int port_arr_len = 0; 13 | 14 | cJSON *serial_list_ports_json() { 15 | struct sp_port **ports = NULL; 16 | if (sp_list_ports(&ports) != SP_OK) 17 | return NULL; 18 | 19 | cJSON *data = cJSON_CreateArray(); 20 | if (data == NULL) 21 | goto end; 22 | 23 | for (int i = 0; ports[i] != NULL; i++) { 24 | struct sp_port *port = ports[i]; 25 | 26 | cJSON *item = cJSON_CreateObject(); 27 | if (item == NULL) 28 | goto end; 29 | 30 | char *id = serial_port_get_id(port); 31 | if (serial_port_fix_details != NULL) 32 | serial_port_fix_details(port, id); 33 | cJSON_AddStringToObject(item, "id", id); 34 | free(id); 35 | 36 | const char *name = sp_get_port_name(port); 37 | cJSON_AddStringToObject(item, "name", name); 38 | enum sp_transport transport = sp_get_port_transport(port); 39 | cJSON_AddStringToObject(item, "transport", SP_TRANSPORT_STR[transport]); 40 | 41 | if (serial_port_get_description != NULL) { 42 | char *description = serial_port_get_description(port); 43 | cJSON_AddStringToObject(item, "description", description); 44 | free(description); 45 | } else { 46 | const char *description = sp_get_port_description(port); 47 | cJSON_AddStringToObject(item, "description", description); 48 | } 49 | 50 | switch (transport) { 51 | case SP_TRANSPORT_NATIVE: 52 | break; 53 | 54 | case SP_TRANSPORT_USB: { 55 | cJSON *usb = cJSON_CreateObject(); 56 | if (usb == NULL) 57 | goto end; 58 | if (cJSON_AddItemToObject(item, "usb", usb) == 0) { 59 | cJSON_Delete(usb); 60 | goto end; 61 | } 62 | 63 | int bus = 0; 64 | int address = 0; 65 | if (sp_get_port_usb_bus_address(port, &bus, &address) == SP_OK) { 66 | cJSON_AddNumberToObject(usb, "bus", bus); 67 | cJSON_AddNumberToObject(usb, "address", address); 68 | } 69 | 70 | int vid = 0; 71 | int pid = 0; 72 | if (sp_get_port_usb_vid_pid(port, &vid, &pid) == SP_OK) { 73 | cJSON_AddNumberToObject(usb, "vid", vid); 74 | cJSON_AddNumberToObject(usb, "pid", pid); 75 | } 76 | 77 | const char *manufacturer = sp_get_port_usb_manufacturer(port); 78 | cJSON_AddStringToObject(usb, "manufacturer", manufacturer); 79 | const char *product = sp_get_port_usb_product(port); 80 | cJSON_AddStringToObject(usb, "product", product); 81 | const char *serial = sp_get_port_usb_serial(port); 82 | cJSON_AddStringToObject(usb, "serial", serial); 83 | break; 84 | } 85 | 86 | case SP_TRANSPORT_BLUETOOTH: { 87 | cJSON *bluetooth = cJSON_CreateObject(); 88 | if (bluetooth == NULL) 89 | goto end; 90 | if (cJSON_AddItemToObject(item, "bluetooth", bluetooth) == 0) { 91 | cJSON_Delete(bluetooth); 92 | goto end; 93 | } 94 | 95 | const char *address = sp_get_port_bluetooth_address(port); 96 | cJSON_AddStringToObject(bluetooth, "address", address); 97 | break; 98 | } 99 | } 100 | 101 | cJSON_AddItemToArray(data, item); 102 | } 103 | 104 | sp_free_port_list(ports); 105 | return data; 106 | 107 | end: 108 | sp_free_port_list(ports); 109 | cJSON_Delete(data); 110 | return NULL; 111 | } 112 | 113 | static char *serial_auth_make_key(const char *port_name) { 114 | UUID4_STATE_T state; 115 | UUID4_T uuid; 116 | uuid4_seed(&state); 117 | uuid4_gen(&state, &uuid); 118 | char *auth_key = malloc(UUID4_STR_BUFFER_SIZE); 119 | if (auth_key == NULL) 120 | return NULL; 121 | uuid4_to_s(uuid, auth_key, UUID4_STR_BUFFER_SIZE); 122 | return auth_key; 123 | } 124 | 125 | const char *serial_auth_grant(const char *port_name) { 126 | serial_port_t *serial = port_arr; 127 | for (int i = 0; i < port_arr_len; i++, serial++) { 128 | if (strcmp(port_name, serial->port_name) == 0) { 129 | if (serial->auth_key == NULL) 130 | serial->auth_key = serial_auth_make_key(port_name); 131 | return serial->auth_key; 132 | } 133 | } 134 | 135 | serial_port_t *prev = port_arr; 136 | port_arr = realloc(port_arr, ++port_arr_len * sizeof(serial_port_t)); 137 | if (port_arr == NULL) { 138 | free(prev); 139 | return NULL; 140 | } 141 | 142 | serial = &port_arr[port_arr_len - 1]; 143 | serial->auth_key = serial_auth_make_key(port_name); 144 | serial->port_name = strdup(port_name); 145 | serial->port = NULL; 146 | serial->conn = NULL; 147 | serial->thread = 0; 148 | serial->event_set = NULL; 149 | return serial->auth_key; 150 | } 151 | 152 | void serial_auth_revoke(const char *port_name) { 153 | serial_port_t *serial = port_arr; 154 | for (int i = 0; i < port_arr_len; i++, serial++) { 155 | if (strcmp(port_name, serial->port_name) == 0) { 156 | free(serial->auth_key); 157 | serial->auth_key = NULL; 158 | } 159 | } 160 | } 161 | 162 | serial_port_t *serial_get_by_auth(const char *auth_key) { 163 | serial_port_t *serial = port_arr; 164 | for (int i = 0; i < port_arr_len; i++, serial++) { 165 | if (strcmp(auth_key, serial->auth_key) != 0) 166 | continue; 167 | return serial; 168 | } 169 | return NULL; 170 | } 171 | 172 | serial_port_t *serial_get_by_conn(ws_cli_conn_t *conn) { 173 | serial_port_t *serial = port_arr; 174 | for (int i = 0; i < port_arr_len; i++, serial++) { 175 | if (serial->conn == conn) 176 | return serial; 177 | } 178 | return NULL; 179 | } 180 | 181 | bool serial_open(serial_port_t *serial, ws_cli_conn_t *conn) { 182 | if (sp_get_port_by_name(serial->port_name, &serial->port) != SP_OK) 183 | return false; 184 | 185 | if (sp_open(serial->port, SP_MODE_READ_WRITE) != SP_OK) 186 | return false; 187 | 188 | if (sp_new_event_set(&serial->event_set) != SP_OK) 189 | return false; 190 | if (sp_add_port_events(serial->event_set, serial->port, SP_EVENT_RX_READY) != SP_OK) 191 | return false; 192 | 193 | serial->conn = conn; 194 | 195 | if (pthread_create(&serial->thread, NULL, websocket_serial_thread, (void *)serial) != 0) 196 | return false; 197 | 198 | return true; 199 | } 200 | 201 | bool serial_close(serial_port_t *serial) { 202 | if (serial->event_set != NULL) { 203 | sp_free_event_set(serial->event_set); 204 | serial->event_set = NULL; 205 | } 206 | if (serial->port != NULL) { 207 | sp_close(serial->port); 208 | sp_free_port(serial->port); 209 | serial->port = NULL; 210 | } 211 | if (serial->thread != 0) { 212 | pthread_cancel(serial->thread); 213 | serial->thread = 0; 214 | } 215 | serial->conn = NULL; 216 | return true; 217 | } 218 | -------------------------------------------------------------------------------- /native/src/serial.h: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-22. */ 2 | 3 | #pragma once 4 | 5 | #include "include.h" 6 | 7 | typedef struct { 8 | char *auth_key; 9 | char *port_name; 10 | struct sp_port *port; 11 | ws_cli_conn_t *conn; 12 | pthread_t thread; 13 | struct sp_event_set *event_set; 14 | } serial_port_t; 15 | 16 | cJSON *serial_list_ports_json(); 17 | 18 | const char *serial_auth_grant(const char *port_name); 19 | void serial_auth_revoke(const char *port_name); 20 | 21 | char *serial_port_get_id(struct sp_port *port); 22 | __attribute__((weak)) char *serial_port_get_description(struct sp_port *port); 23 | __attribute__((weak)) void serial_port_fix_details(struct sp_port *port, const char *id); 24 | 25 | serial_port_t *serial_get_by_auth(const char *auth_key); 26 | serial_port_t *serial_get_by_conn(ws_cli_conn_t *conn); 27 | 28 | bool serial_open(serial_port_t *serial, ws_cli_conn_t *conn); 29 | bool serial_close(serial_port_t *serial); 30 | -------------------------------------------------------------------------------- /native/src/serial_linux.c: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2024-04-05. */ 2 | 3 | #ifdef __linux__ 4 | 5 | #define LIBSERIALPORT_ATBUILD 6 | #include "libserialport_internal.h" 7 | #undef DEBUG 8 | 9 | #include "serial.h" 10 | 11 | char *serial_port_get_id(struct sp_port *port) { 12 | const char *name = sp_get_port_name(port); 13 | enum sp_transport transport = sp_get_port_transport(port); 14 | 15 | int id_length = strlen(name); 16 | 17 | switch (transport) { 18 | case SP_TRANSPORT_USB: { 19 | int vid = 0, pid = 0; 20 | const char *serial = sp_get_port_usb_serial(port); 21 | if (sp_get_port_usb_vid_pid(port, &vid, &pid) == SP_OK) 22 | id_length += sizeof("#VID=xxxx#PID=xxxx") - 1; 23 | if (serial != NULL) 24 | id_length += sizeof("#SN=") - 1 + strlen(serial); 25 | 26 | char *id = malloc(id_length + 1); 27 | if (id == NULL) 28 | return NULL; 29 | 30 | if (vid != 0 && pid != 0) { 31 | if (serial != NULL) 32 | sprintf(id, "%s#VID=%04X#PID=%04X#SN=%s", name, vid, pid, serial); 33 | else 34 | sprintf(id, "%s#VID=%04X#PID=%04X", name, vid, pid); 35 | } else { 36 | if (serial != NULL) 37 | sprintf(id, "%s#SN=%s", name, serial); 38 | else 39 | strcpy(id, name); 40 | } 41 | 42 | return id; 43 | } 44 | 45 | case SP_TRANSPORT_BLUETOOTH: { 46 | const char *address = sp_get_port_bluetooth_address(port); 47 | if (address != NULL) 48 | id_length += sizeof("#ADDR=") - 1 + strlen(address); 49 | 50 | char *id = malloc(id_length + 1); 51 | if (id == NULL) 52 | return NULL; 53 | 54 | if (address != NULL) 55 | sprintf(id, "%s#ADDR=%s", name, address); 56 | else 57 | strcpy(id, name); 58 | 59 | return id; 60 | } 61 | 62 | case SP_TRANSPORT_NATIVE: 63 | default: { 64 | return strdup(port->name); 65 | } 66 | } 67 | } 68 | 69 | char *serial_port_get_description(struct sp_port *port) { 70 | const char *name = sp_get_port_name(port); 71 | const char *description = sp_get_port_description(port); 72 | 73 | if (strstr(name, "/dev/") == name) 74 | name += sizeof("/dev/") - 1; 75 | 76 | char *full_description = malloc(strlen(name) + sizeof(" - ") + strlen(description)); 77 | if (full_description == NULL) 78 | return NULL; 79 | 80 | full_description[0] = '\0'; 81 | if (strstr(description, name) == NULL) { 82 | strcat(full_description, name); 83 | strcat(full_description, " - "); 84 | } 85 | strcat(full_description, description); 86 | return full_description; 87 | } 88 | 89 | #endif 90 | -------------------------------------------------------------------------------- /native/src/serial_win.c: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-24. */ 2 | 3 | #ifdef WINNT 4 | 5 | #include "serial.h" 6 | 7 | #define LIBSERIALPORT_MSBUILD 8 | #undef DEBUG 9 | #include "libserialport_internal.h" 10 | 11 | char *serial_port_get_id(struct sp_port *port) { 12 | char *instance_id = NULL; 13 | 14 | SP_DEVINFO_DATA device_info_data = {.cbSize = sizeof(device_info_data)}; 15 | HDEVINFO device_info; 16 | 17 | device_info = SetupDiGetClassDevs(NULL, 0, 0, DIGCF_PRESENT | DIGCF_ALLCLASSES); 18 | if (device_info == INVALID_HANDLE_VALUE) 19 | return NULL; 20 | 21 | for (int i = 0; SetupDiEnumDeviceInfo(device_info, i, &device_info_data); i++) { 22 | HKEY device_key; 23 | DEVINST dev_inst; 24 | char value[8], class[16]; 25 | DWORD size, type; 26 | 27 | /* Check if this is the device we are looking for. */ 28 | device_key = 29 | SetupDiOpenDevRegKey(device_info, &device_info_data, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_QUERY_VALUE); 30 | if (device_key == INVALID_HANDLE_VALUE) 31 | continue; 32 | size = sizeof(value); 33 | if (RegQueryValueExA(device_key, "PortName", NULL, &type, (LPBYTE)value, &size) != ERROR_SUCCESS || 34 | type != REG_SZ) { 35 | RegCloseKey(device_key); 36 | continue; 37 | } 38 | RegCloseKey(device_key); 39 | value[sizeof(value) - 1] = 0; 40 | if (strcmp(value, port->name)) 41 | continue; 42 | 43 | size = 0; 44 | SetupDiGetDeviceInstanceId(device_info, &device_info_data, NULL, 0, &size); 45 | if (size == 0) 46 | continue; 47 | 48 | instance_id = malloc(size + 1); 49 | if (SetupDiGetDeviceInstanceId(device_info, &device_info_data, instance_id, size, NULL) != TRUE) { 50 | free(instance_id); 51 | continue; 52 | } 53 | 54 | break; 55 | } 56 | 57 | SetupDiDestroyDeviceInfoList(device_info); 58 | 59 | return instance_id; 60 | } 61 | 62 | void serial_port_fix_details(struct sp_port *port, const char *id) { 63 | if (port->transport != SP_TRANSPORT_USB) 64 | return; 65 | 66 | if (strlen(id) <= sizeof("USB\\VID_xxxx&PID_xxxx\\") - 1) 67 | return; 68 | 69 | if (port->usb_vid < 0 || port->usb_pid < 0) { 70 | sscanf(id, "USB\\VID_%04X&PID_%04X\\", &port->usb_vid, &port->usb_pid); 71 | } 72 | 73 | if (port->usb_serial == NULL) { 74 | port->usb_serial = strdup(id + sizeof("USB\\VID_xxxx&PID_xxxx\\") - 1); 75 | } 76 | } 77 | 78 | #endif 79 | -------------------------------------------------------------------------------- /native/src/stdmsg.c: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-22. */ 2 | 3 | #include "stdmsg.h" 4 | 5 | static void stdmsg_write(cJSON *message) { 6 | char *json = cJSON_PrintUnformatted(message); 7 | if (json == NULL) 8 | return; 9 | uint32_t len = strlen(json); 10 | fwrite(&len, sizeof(uint32_t), 1, stdout); 11 | fwrite(json, sizeof(char), len, stdout); 12 | fflush(stdout); 13 | free(json); 14 | } 15 | 16 | void stdmsg_send_log(const char *fmt, ...) { 17 | va_list argv; 18 | va_start(argv, fmt); 19 | char data[256]; 20 | vsnprintf(data, 256, fmt, argv); 21 | va_end(argv); 22 | 23 | cJSON *message = cJSON_CreateObject(); 24 | if (message == NULL) 25 | goto end; 26 | if (cJSON_AddStringToObject(message, "data", data) == NULL) 27 | goto end; 28 | stdmsg_write(message); 29 | end: 30 | cJSON_Delete(message); 31 | return; 32 | } 33 | 34 | void stdmsg_send_json(const char *id, cJSON *data) { 35 | cJSON *message = cJSON_CreateObject(); 36 | if (message == NULL) 37 | goto end; 38 | if (cJSON_AddStringToObject(message, "id", id) == NULL) 39 | goto end; 40 | if (cJSON_AddItemToObject(message, "data", data) == 0) 41 | goto end; 42 | stdmsg_write(message); 43 | end: 44 | cJSON_Delete(message); 45 | return; 46 | } 47 | 48 | void stdmsg_send_error(const char *id, int error) { 49 | cJSON *message = cJSON_CreateObject(); 50 | if (message == NULL) 51 | goto end; 52 | if (id != NULL && cJSON_AddStringToObject(message, "id", id) == NULL) 53 | goto end; 54 | if (cJSON_AddNumberToObject(message, "error", error) == NULL) 55 | goto end; 56 | stdmsg_write(message); 57 | end: 58 | cJSON_Delete(message); 59 | return; 60 | } 61 | 62 | void stdmsg_parse(char *json) { 63 | int error = 50; 64 | const char *action = NULL; 65 | const char *id = NULL; 66 | 67 | cJSON *message = cJSON_Parse(json); 68 | if (message == NULL) 69 | goto error; 70 | cJSON *j_action = cJSON_GetObjectItem(message, "action"); 71 | if (j_action == NULL) 72 | goto error; 73 | cJSON *j_id = cJSON_GetObjectItem(message, "id"); 74 | if (j_id == NULL) 75 | goto error; 76 | action = cJSON_GetStringValue(j_action); 77 | id = cJSON_GetStringValue(j_id); 78 | 79 | if (strcmp(action, "ping") == 0) { 80 | cJSON *data = cJSON_CreateObject(); 81 | if (data == NULL) { 82 | error = 70; 83 | goto error; 84 | } 85 | if (cJSON_AddStringToObject(data, "version", NATIVE_VERSION) == NULL) { 86 | error = 71; 87 | goto error; 88 | } 89 | if (cJSON_AddNumberToObject(data, "protocol", NATIVE_PROTOCOL) == NULL) { 90 | error = 72; 91 | goto error; 92 | } 93 | if (cJSON_AddNumberToObject(data, "wsPort", WEBSOCKET_PORT) == NULL) { 94 | error = 73; 95 | goto error; 96 | } 97 | stdmsg_send_json(id, data); 98 | } 99 | 100 | else if (strcmp(action, "listPorts") == 0) { 101 | cJSON *data = serial_list_ports_json(); 102 | if (data == NULL) { 103 | error = 60; 104 | goto error; 105 | } 106 | stdmsg_send_json(id, data); 107 | } 108 | 109 | else if (strcmp(action, "authGrant") == 0) { 110 | cJSON *port = cJSON_GetObjectItem(message, "port"); 111 | if (port == NULL) { 112 | error = 61; 113 | goto error; 114 | } 115 | const char *auth_key = serial_auth_grant(cJSON_GetStringValue(port)); 116 | cJSON *data = cJSON_CreateStringReference(auth_key); 117 | if (data == NULL) { 118 | error = 62; 119 | goto error; 120 | } 121 | stdmsg_send_json(id, data); 122 | } 123 | 124 | else if (strcmp(action, "authRevoke") == 0) { 125 | cJSON *port = cJSON_GetObjectItem(message, "port"); 126 | if (port == NULL) { 127 | error = 63; 128 | goto error; 129 | } 130 | serial_auth_revoke(cJSON_GetStringValue(port)); 131 | stdmsg_send_json(id, cJSON_CreateNull()); 132 | } 133 | 134 | else { 135 | error = 51; 136 | goto error; 137 | } 138 | 139 | goto end; 140 | error: 141 | stdmsg_send_error(id, error); 142 | end: 143 | cJSON_Delete(message); 144 | } 145 | 146 | int stdmsg_receive() { 147 | uint32_t len = 0; 148 | fread((void *)&len, sizeof(uint32_t), 1, stdin); 149 | if (len == 0) 150 | return 0; 151 | if (len > 4096) 152 | return -1; 153 | 154 | char *json = malloc(len + 1); 155 | if (json == NULL) 156 | return -2; 157 | fread(json, sizeof(char), len, stdin); 158 | json[len] = '\0'; 159 | 160 | stdmsg_parse(json); 161 | free(json); 162 | return len; 163 | } 164 | -------------------------------------------------------------------------------- /native/src/stdmsg.h: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-22. */ 2 | 3 | #pragma once 4 | 5 | #include "include.h" 6 | 7 | void stdmsg_send_log(const char *fmt, ...); 8 | void stdmsg_send_json(const char *id, cJSON *data); 9 | void stdmsg_send_error(const char *id, int error); 10 | int stdmsg_receive(); 11 | -------------------------------------------------------------------------------- /native/src/webserial_config.h: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-09-22. */ 2 | 3 | #pragma once 4 | 5 | #define NATIVE_VERSION "0.4.0" 6 | #define NATIVE_PROTOCOL 2 7 | #define WEBSOCKET_PORT 23290 8 | -------------------------------------------------------------------------------- /native/src/websocket.c: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-22. */ 2 | 3 | #include "websocket.h" 4 | 5 | #define WS_RESPONSE(opc) \ 6 | do { \ 7 | ws_message_opcode_t opcode = opc; \ 8 | ws_sendframe_bin(conn, (const char *)&opcode, 1); \ 9 | } while (0) 10 | 11 | void websocket_start() { 12 | struct ws_events evs; 13 | evs.onopen = &websocket_on_open; 14 | evs.onclose = &websocket_on_close; 15 | evs.onmessage = &websocket_on_message; 16 | 17 | ws_socket(&evs, WEBSOCKET_PORT, true, 1000); 18 | } 19 | 20 | void websocket_on_open(ws_cli_conn_t *conn) { 21 | stdmsg_send_log("WS connection opened"); 22 | } 23 | 24 | void websocket_on_close(ws_cli_conn_t *conn) { 25 | stdmsg_send_log("WS connection closed"); 26 | serial_port_t *serial = serial_get_by_conn(conn); 27 | if (serial != NULL) 28 | serial_close(serial); 29 | } 30 | 31 | static void websocket_send_error(ws_message_opcode_t code, ws_cli_conn_t *conn) { 32 | char *error_msg; 33 | error: 34 | error_msg = sp_last_error_message(); 35 | if (error_msg == NULL) { 36 | WS_RESPONSE(code); 37 | } else { 38 | int error_len = strlen(error_msg); 39 | unsigned char *message = malloc(1 + error_len + 1); 40 | if (message == NULL) { 41 | WS_RESPONSE(code); 42 | return; 43 | } 44 | message[0] = code; 45 | strcpy(&message[1], error_msg); 46 | ws_sendframe_bin(conn, (const char *)message, 1 + error_len); 47 | free(message); 48 | sp_free_error_message(error_msg); 49 | } 50 | } 51 | 52 | void websocket_on_message(ws_cli_conn_t *conn, const unsigned char *msg, uint64_t msg_len, int msg_type) { 53 | if (msg_len < 1) 54 | return; 55 | uint8_t opcode = msg[0]; 56 | ws_message_t *data = (ws_message_t *)(msg + 1); 57 | int data_len = msg_len - 1; 58 | 59 | serial_port_t *serial = NULL; 60 | if (opcode == WSM_PORT_OPEN) { 61 | // check auth_key string bounds 62 | if (memchr(&data->auth_key, '\0', data_len) == NULL) { 63 | WS_RESPONSE(WSM_ERR_AUTH); 64 | return; 65 | } 66 | // find object by auth_key 67 | if ((serial = serial_get_by_auth(data->auth_key)) == NULL) { 68 | WS_RESPONSE(WSM_ERR_AUTH); 69 | return; 70 | } 71 | // make sure it's closed 72 | if (serial->port != NULL) { 73 | WS_RESPONSE(WSM_ERR_IS_OPEN); 74 | return; 75 | } 76 | } else { 77 | // find object by WS connection 78 | if ((serial = serial_get_by_conn(conn)) == NULL) { 79 | WS_RESPONSE(WSM_ERR_NOT_OPEN); 80 | return; 81 | } 82 | // make sure it's open 83 | if (serial->port == NULL) { 84 | // allow closing twice 85 | if (opcode == WSM_PORT_CLOSE) { 86 | WS_RESPONSE(WSM_OK); 87 | return; 88 | } 89 | WS_RESPONSE(WSM_ERR_NOT_OPEN); 90 | return; 91 | } 92 | } 93 | 94 | switch (opcode) { 95 | case WSM_PORT_OPEN: 96 | if (!serial_open(serial, conn)) { 97 | serial_close(serial); 98 | goto error; 99 | } 100 | break; 101 | 102 | case WSM_PORT_CLOSE: 103 | // try to close the port 104 | if (!serial_close(serial)) 105 | goto error; 106 | break; 107 | 108 | case WSM_SET_CONFIG: 109 | if (sp_set_baudrate(serial->port, data->baudrate) != SP_OK) 110 | goto error; 111 | if (sp_set_bits(serial->port, data->data_bits) != SP_OK) 112 | goto error; 113 | if (sp_set_parity(serial->port, data->parity) != SP_OK) 114 | goto error; 115 | if (sp_set_stopbits(serial->port, data->stop_bits) != SP_OK) 116 | goto error; 117 | break; 118 | 119 | case WSM_SET_SIGNALS: 120 | if (sp_set_dtr(serial->port, data->dtr) != SP_OK) 121 | goto error; 122 | if (sp_set_rts(serial->port, data->rts) != SP_OK) 123 | goto error; 124 | break; 125 | 126 | case WSM_GET_SIGNALS: { 127 | enum sp_signal signals; 128 | if (sp_get_signals(serial->port, &signals) != SP_OK) 129 | goto error; 130 | uint8_t response[2] = {WSM_OK, signals}; 131 | ws_sendframe_bin(conn, (const char *)&response, 2); 132 | break; 133 | } 134 | 135 | case WSM_START_BREAK: 136 | if (sp_start_break(serial->port) != SP_OK) 137 | goto error; 138 | break; 139 | 140 | case WSM_END_BREAK: 141 | if (sp_end_break(serial->port) != SP_OK) 142 | goto error; 143 | break; 144 | 145 | case WSM_DATA: 146 | if (sp_blocking_write(serial->port, data->data, data_len - 1, 0) < 0) 147 | goto error; 148 | if (data->drain) { 149 | if (sp_drain(serial->port) != SP_OK) 150 | goto error; 151 | } 152 | break; 153 | 154 | case WSM_DRAIN: 155 | if (sp_drain(serial->port) != SP_OK) 156 | goto error; 157 | break; 158 | 159 | default: 160 | WS_RESPONSE(WSM_ERR_OPCODE); 161 | return; 162 | } 163 | 164 | WS_RESPONSE(WSM_OK); 165 | return; 166 | error: 167 | websocket_send_error(WSM_ERROR, conn); 168 | } 169 | 170 | void *websocket_serial_thread(void *arg) { 171 | stdmsg_send_log("WS thread running"); 172 | 173 | pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL); 174 | serial_port_t *serial = arg; 175 | uint8_t buf[4096 + 1]; 176 | buf[0] = WSM_DATA; 177 | 178 | while (1) { 179 | struct sp_port *port = serial->port; 180 | if (port == NULL) 181 | goto error; 182 | if (sp_wait(serial->event_set, 1000) != SP_OK) 183 | goto error; 184 | int read = sp_nonblocking_read(port, buf + 1, sizeof(buf) - 1); 185 | if (read == 0) 186 | continue; 187 | if (read < 0) 188 | goto error; 189 | if (serial->conn == NULL) 190 | goto ret; 191 | ws_sendframe_bin(serial->conn, buf, read + 1); 192 | } 193 | 194 | error: 195 | websocket_send_error(WSM_ERR_READER, serial->conn); 196 | ret: 197 | serial->thread = 0; 198 | stdmsg_send_log("WS thread finished"); 199 | return NULL; 200 | } 201 | -------------------------------------------------------------------------------- /native/src/websocket.h: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Kuba Szczodrzyński 2023-08-22. */ 2 | 3 | #pragma once 4 | 5 | #include "include.h" 6 | 7 | #include "serial.h" 8 | 9 | typedef enum { 10 | WSM_OK = 0, 11 | WSM_PORT_OPEN = 10, 12 | WSM_PORT_CLOSE = 11, 13 | WSM_SET_CONFIG = 20, 14 | WSM_SET_SIGNALS = 30, 15 | WSM_GET_SIGNALS = 31, 16 | WSM_START_BREAK = 40, 17 | WSM_END_BREAK = 41, 18 | WSM_DATA = 50, 19 | WSM_DRAIN = 51, 20 | WSM_ERROR = 128, 21 | WSM_ERR_OPCODE = 129, 22 | WSM_ERR_AUTH = 130, 23 | WSM_ERR_IS_OPEN = 131, 24 | WSM_ERR_NOT_OPEN = 132, 25 | WSM_ERR_READER = 133, 26 | } ws_message_opcode_t; 27 | 28 | typedef union { 29 | char auth_key[1]; 30 | uint8_t signals; 31 | 32 | struct __attribute__((packed)) { 33 | uint32_t baudrate; 34 | uint8_t data_bits; 35 | uint8_t parity; 36 | uint8_t stop_bits; 37 | }; 38 | 39 | struct __attribute__((packed)) { 40 | uint8_t dtr; 41 | uint8_t rts; 42 | }; 43 | 44 | struct __attribute__((packed)) { 45 | bool drain; 46 | uint8_t data[1]; 47 | }; 48 | } ws_message_t; 49 | 50 | void websocket_start(); 51 | void websocket_on_open(ws_cli_conn_t *client); 52 | void websocket_on_close(ws_cli_conn_t *client); 53 | void websocket_on_message(ws_cli_conn_t *conn, const unsigned char *msg, uint64_t msg_len, int msg_type); 54 | void *websocket_serial_thread(void *arg); 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firefox-webserial", 3 | "description": "WebSerial for Firefox", 4 | "version": "0.4.0", 5 | "author": "kuba2k2", 6 | "license": "MIT", 7 | "private": true, 8 | "dependencies": { 9 | "buffer": "^6.0.3", 10 | "python-struct": "^1.1.3", 11 | "react": "^18.2.0", 12 | "styled-components": "^6.0.7", 13 | "tslib": "^2.3.1", 14 | "typescript": "^4.5.3", 15 | "uuid": "^9.0.0" 16 | }, 17 | "browser": "dist/webserial.page.js", 18 | "scripts": { 19 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 20 | "format": "prettier --write \"src/**/*.ts\" \"src/**/*.tsx\"", 21 | "format-check": "prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"", 22 | "version": "cross-var replace '\\d+\\.\\d+\\.\\d+' $npm_package_version manifest.json native/install/windows_x86.nsi native/src/webserial_config.h && git add .", 23 | "build": "gulp build", 24 | "build:dev": "gulp build:dev", 25 | "watch": "gulp watch", 26 | "start": "gulp watch" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.22.10", 30 | "@babel/preset-react": "^7.22.5", 31 | "@babel/preset-typescript": "^7.22.5", 32 | "@types/browserify": "^12.0.37", 33 | "@types/firefox": "^0.0.31", 34 | "@types/firefox-webext-browser": "^111.0.1", 35 | "@types/gulp": "^4.0.9", 36 | "@types/gulp-concat": "0.0.33", 37 | "@types/gulp-if": "0.0.34", 38 | "@types/gulp-rename": "^2.0.1", 39 | "@types/gulp-sourcemaps": "0.0.35", 40 | "@types/gulp-uglify": "^3.0.7", 41 | "@types/python-struct": "^1.0.1", 42 | "@types/react": "^18.2.21", 43 | "@types/react-dom": "^18.2.7", 44 | "@types/styled-components": "^5.1.26", 45 | "@types/stylis": "^4.2.0", 46 | "@types/uuid": "^9.0.2", 47 | "@types/w3c-web-serial": "^1.0.3", 48 | "@typescript-eslint/eslint-plugin": "^5.6.0", 49 | "@typescript-eslint/parser": "^5.6.0", 50 | "babelify": "^10.0.0", 51 | "browserify": "^17.0.0", 52 | "cross-var": "^1.1.0", 53 | "envify": "^4.1.0", 54 | "eslint": "^8.4.1", 55 | "eslint-config-prettier": "^8.3.0", 56 | "eslint-plugin-react": "^7.27.1", 57 | "gulp": "^4.0.2", 58 | "gulp-bro": "^2.0.0", 59 | "gulp-concat": "^2.6.1", 60 | "gulp-if": "^3.0.0", 61 | "gulp-rename": "^2.0.0", 62 | "gulp-sass": "^5.1.0", 63 | "gulp-sourcemaps": "^3.0.0", 64 | "gulp-transform": "^3.0.5", 65 | "gulp-uglify": "^3.0.2", 66 | "npm-run-all": "^4.1.5", 67 | "prettier": "^2.5.1", 68 | "replace": "^1.2.1", 69 | "sass": "^1.66.1", 70 | "ts-node": "^10.4.0", 71 | "tsify": "^5.0.4" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { choosePort, listPortsNative } from "./messaging" 2 | import { getNativeParamsFromBackground } from "./messaging/native" 3 | import { 4 | extendPromiseFromBackground, 5 | rejectPromiseFromBackground, 6 | resolvePromiseFromBackground, 7 | } from "./messaging/promises" 8 | import { 9 | clearPortAuthKeyCache, 10 | getPortAuthKey, 11 | readOriginAuth, 12 | writeOriginAuth, 13 | } from "./utils/auth" 14 | import { BackgroundRequest } from "./utils/types" 15 | 16 | console.clear() 17 | 18 | class MessageHandler { 19 | /** 20 | * Get native app state & parameters. 21 | * 22 | * ACCESS: 23 | * - Page Script (via Content Script) 24 | * - Popup Script 25 | */ 26 | async getNativeParams() { 27 | return await getNativeParamsFromBackground() 28 | } 29 | 30 | /** 31 | * List authorized ports. 32 | * 33 | * ACCESS: 34 | * - Page Script (via Content Script) 35 | */ 36 | async getPorts({ origin }: BackgroundRequest) { 37 | const originAuth = await readOriginAuth(origin) 38 | if (Object.keys(originAuth).length == 0) return [] 39 | 40 | const ports = await listPortsNative() 41 | const authPorts = ports.filter((port) => port.id in originAuth) 42 | 43 | for (const port of authPorts) { 44 | port.isPaired = true 45 | port.authKey = await getPortAuthKey(port) 46 | } 47 | return authPorts 48 | } 49 | 50 | /** 51 | * Request authorization for a single port. 52 | * 53 | * ACCESS: 54 | * - Page Script (via Content Script) 55 | */ 56 | async requestPort({ origin, options }: BackgroundRequest) { 57 | const port = await choosePort(origin, options) 58 | await writeOriginAuth(origin, port) 59 | 60 | port.isPaired = true 61 | port.authKey = await getPortAuthKey(port) 62 | return port 63 | } 64 | 65 | /** 66 | * List all available ports. 67 | * 68 | * ACCESS: 69 | * - Popup Script 70 | */ 71 | async listAvailablePorts({ origin, options }: BackgroundRequest) { 72 | const originAuth = await readOriginAuth(origin) 73 | const ports = await listPortsNative() 74 | for (const port of ports) { 75 | port.isPaired = port.id in originAuth 76 | } 77 | return ports 78 | } 79 | 80 | /** 81 | * Clear local port authKey cache. 82 | * 83 | * ACCESS: 84 | * - Background Script (via sendNative()) 85 | */ 86 | async clearAuthKeyCache() { 87 | clearPortAuthKeyCache() 88 | } 89 | 90 | /** 91 | * ACCESS: 92 | * - Popup Script 93 | */ 94 | async extendPromise({ id, timeoutMs }: BackgroundRequest) { 95 | extendPromiseFromBackground(id, timeoutMs) 96 | } 97 | async resolvePromise({ id, value }: BackgroundRequest) { 98 | resolvePromiseFromBackground(id, value) 99 | } 100 | async rejectPromise({ id, reason }: BackgroundRequest) { 101 | rejectPromiseFromBackground(id, reason) 102 | } 103 | } 104 | 105 | browser.runtime.onMessage.addListener(async (message: BackgroundRequest) => { 106 | const handler = new MessageHandler() 107 | return await handler[message.action](message) 108 | }) 109 | 110 | browser.runtime.getBackgroundPage().then((window) => { 111 | window.addEventListener("message", async (ev) => { 112 | const handler = new MessageHandler() 113 | const message: BackgroundRequest = ev.data 114 | return await handler[message.action](message) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import { getNativeParams, getPorts, requestPort } from "./messaging" 2 | 3 | // @ts-ignore 4 | window.wrappedJSObject.navigator.serial = cloneInto({}, window.navigator, {}) 5 | 6 | document.addEventListener("readystatechange", () => { 7 | if (document.readyState == "interactive") { 8 | const script = document.createElement("script") 9 | script.src = browser.runtime.getURL("dist/webserial.polyfill.js") 10 | document.head.prepend(script) 11 | } 12 | }) 13 | 14 | function wrapPromise(promise: Promise): Promise { 15 | return new window.Promise( 16 | exportFunction((resolve, reject) => { 17 | promise 18 | .then((value) => { 19 | resolve(cloneInto(value, window)) 20 | }) 21 | .catch((reason) => { 22 | reject(cloneInto(reason, window)) 23 | }) 24 | }, window.wrappedJSObject) 25 | ) 26 | } 27 | 28 | window.WebSerialPolyfill = { 29 | getNativeParams: () => { 30 | return wrapPromise(getNativeParams()) 31 | }, 32 | getPorts: () => { 33 | return wrapPromise(getPorts(window.location.origin)) 34 | }, 35 | requestPort: (options?: SerialPortRequestOptions) => { 36 | return wrapPromise(requestPort(window.location.origin, options)) 37 | }, 38 | } 39 | 40 | window.wrappedJSObject.WebSerialPolyfill = cloneInto( 41 | window.WebSerialPolyfill, 42 | window, 43 | { cloneFunctions: true } 44 | ) 45 | -------------------------------------------------------------------------------- /src/messaging/background.ts: -------------------------------------------------------------------------------- 1 | import { BackgroundRequest } from "../utils/types" 2 | 3 | export async function sendToBackground( 4 | message: BackgroundRequest 5 | ): Promise { 6 | if ( 7 | browser?.runtime?.getBackgroundPage && 8 | window === (await browser.runtime.getBackgroundPage()) 9 | ) 10 | return window.postMessage(message) 11 | return await browser.runtime.sendMessage(message) 12 | } 13 | -------------------------------------------------------------------------------- /src/messaging/index.ts: -------------------------------------------------------------------------------- 1 | import { sendToBackground } from "./background" 2 | import { sendToNative } from "./native" 3 | import { sendToPopup } from "./popup" 4 | import { SerialPortData } from "../serial/types" 5 | import { NativeParams } from "../utils/types" 6 | 7 | export async function getNativeParams(): Promise { 8 | return await sendToBackground({ action: "getNativeParams", origin }) 9 | } 10 | 11 | export async function getPorts(origin: string): Promise { 12 | return await sendToBackground({ action: "getPorts", origin }) 13 | } 14 | 15 | export async function requestPort( 16 | origin: string, 17 | options?: SerialPortRequestOptions 18 | ): Promise { 19 | return await sendToBackground({ action: "requestPort", origin, options }) 20 | } 21 | 22 | export async function listAvailablePorts( 23 | origin?: string, 24 | options?: SerialPortRequestOptions 25 | ): Promise { 26 | return await sendToBackground({ 27 | action: "listAvailablePorts", 28 | origin, 29 | options, 30 | }) 31 | } 32 | 33 | export async function clearAuthKeyCache(): Promise { 34 | return await sendToBackground({ action: "clearAuthKeyCache" }) 35 | } 36 | 37 | export async function choosePort( 38 | origin: string, 39 | options?: SerialPortRequestOptions 40 | ): Promise { 41 | return await sendToPopup({ action: "choosePort", origin, options }) 42 | } 43 | 44 | export async function extendPromise( 45 | id: string, 46 | timeoutMs: number 47 | ): Promise { 48 | await sendToBackground({ action: "extendPromise", id, timeoutMs }) 49 | } 50 | 51 | export async function resolvePromise(id: string, value: any): Promise { 52 | await sendToBackground({ action: "resolvePromise", id, value }) 53 | } 54 | 55 | export async function rejectPromise(id: string, reason?: any): Promise { 56 | await sendToBackground({ action: "rejectPromise", id, reason }) 57 | } 58 | 59 | export async function listPortsNative(): Promise { 60 | return await sendToNative({ action: "listPorts" }) 61 | } 62 | 63 | export async function authGrant(port: string): Promise { 64 | return await sendToNative({ action: "authGrant", port }) 65 | } 66 | 67 | export async function authRevoke(port: string): Promise { 68 | await sendToNative({ action: "authRevoke", port }) 69 | } 70 | -------------------------------------------------------------------------------- /src/messaging/native.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from "uuid" 2 | import { clearAuthKeyCache, rejectPromise, resolvePromise } from "." 3 | import { debugLog, debugRx, debugTx } from "../utils/logging" 4 | import { NativeParams, NativeRequest } from "../utils/types" 5 | import { catchIgnore } from "../utils/utils" 6 | import { keepPromise } from "./promises" 7 | 8 | const NATIVE_PROTOCOL = 2 9 | 10 | let globalPort: browser.runtime.Port = undefined 11 | 12 | type RawNativeResponse = { 13 | id?: string 14 | data?: any 15 | error?: number 16 | } 17 | 18 | let nativeParams: NativeParams = { 19 | state: "checking", 20 | } 21 | 22 | async function setNativeParams(params: NativeParams) { 23 | params.platform = nativeParams.platform 24 | nativeParams = params 25 | debugLog("NATIVE", "setNativeParams", params) 26 | } 27 | 28 | async function getNativePort(): Promise { 29 | if (!nativeParams.platform) { 30 | nativeParams.platform = await browser.runtime.getPlatformInfo() 31 | } 32 | 33 | if (globalPort != undefined && globalPort.error == null) return globalPort 34 | 35 | debugLog("NATIVE", "getNativePort", "Connecting to native...") 36 | const newPort = browser.runtime.connectNative("io.github.kuba2k2.webserial") 37 | 38 | // clear local authKey cache, as the native app is starting fresh 39 | await clearAuthKeyCache() 40 | 41 | // post a ping message to check if the connection succeeds 42 | const pingRequest: NativeRequest = { 43 | action: "ping", 44 | id: v4(), 45 | } 46 | try { 47 | newPort.postMessage(pingRequest) 48 | } catch (e) { 49 | debugLog("NATIVE", "getNativePort", e) 50 | await setNativeParams({ state: "error" }) 51 | throw e 52 | } 53 | 54 | // update native params 55 | await setNativeParams({ state: "checking" }) 56 | 57 | globalPort = await new Promise((resolve, reject) => { 58 | let isOutdated = false 59 | 60 | newPort.onMessage.addListener(async (message: RawNativeResponse) => { 61 | if (!message.id) { 62 | if (message.data) debugRx("NATIVE", message.data) 63 | return 64 | } 65 | 66 | debugRx("NATIVE", message) 67 | 68 | if (message.id == pingRequest.id) { 69 | const version = message.data?.version 70 | const protocol = message.data?.protocol 71 | if (protocol !== NATIVE_PROTOCOL) { 72 | const error = `Native protocol incompatible: expected v${NATIVE_PROTOCOL}, found v${protocol}` 73 | debugLog("NATIVE", "onMessage", error) 74 | 75 | await setNativeParams({ 76 | state: "outdated", 77 | version, 78 | protocol, 79 | }) 80 | isOutdated = true 81 | newPort.disconnect() 82 | 83 | reject(new Error(error)) 84 | return 85 | } 86 | const wsPort = message.data?.wsPort 87 | debugLog( 88 | "NATIVE", 89 | "onMessage", 90 | `Connection successful: native v${version} @ port ${wsPort}` 91 | ) 92 | await setNativeParams({ 93 | state: "connected", 94 | version, 95 | protocol, 96 | wsPort, 97 | }) 98 | resolve(newPort) 99 | return 100 | } 101 | 102 | if (message.data !== undefined) 103 | await resolvePromise(message.id, message.data) 104 | 105 | if (message.error !== undefined) 106 | await rejectPromise( 107 | message.id, 108 | new Error(`Native error ${message.error}`) 109 | ) 110 | }) 111 | 112 | const onDisconnect = async (port: browser.runtime.Port) => { 113 | debugLog("NATIVE", "onDisconnect", "Disconnected:", port.error) 114 | if (isOutdated) return 115 | globalPort = null 116 | 117 | const error = `${port.error}` 118 | if (error.includes("No such native application")) 119 | await setNativeParams({ 120 | state: "not-installed", 121 | error: port.error, 122 | }) 123 | else await setNativeParams({ state: "error", error: port.error }) 124 | 125 | reject(port.error) 126 | } 127 | if (newPort.error !== null) { 128 | onDisconnect(newPort) 129 | } else { 130 | newPort.onDisconnect.addListener(onDisconnect) 131 | } 132 | }) 133 | 134 | return globalPort 135 | } 136 | 137 | export async function sendToNative(message: NativeRequest): Promise { 138 | const [id, promise]: [string, Promise] = keepPromise() 139 | const port = await getNativePort() 140 | message.id = id 141 | debugTx("NATIVE", message) 142 | port.postMessage(message) 143 | return await promise 144 | } 145 | 146 | export async function getNativeParamsFromBackground(): Promise { 147 | // ignore errors, which are reflected in nativeParams instead 148 | await catchIgnore(getNativePort()) 149 | return nativeParams 150 | } 151 | -------------------------------------------------------------------------------- /src/messaging/popup.ts: -------------------------------------------------------------------------------- 1 | import { rejectPromise } from "." 2 | import { PopupRequest } from "../utils/types" 3 | import { keepPromise } from "./promises" 4 | 5 | const windows: { [key: number]: string } = {} 6 | 7 | const onRemovedListener = async (windowId: number) => { 8 | if (!(windowId in windows)) return 9 | const id = windows[windowId] 10 | delete windows[windowId] 11 | await rejectPromise(id, new Error("No port selected by the user.")) 12 | } 13 | 14 | export async function sendToPopup(message: PopupRequest): Promise { 15 | const [id, promise]: [string, Promise] = keepPromise() 16 | const url = new URL(browser.runtime.getURL("dist/index.html")) 17 | url.searchParams.set("id", id) 18 | url.searchParams.set("message", btoa(JSON.stringify(message))) 19 | 20 | const anchor = await browser.windows.getCurrent() 21 | const win = await browser.windows.create({ 22 | url: [url.href], 23 | type: "popup", 24 | top: anchor.top, 25 | left: anchor.left, 26 | width: 400, 27 | height: 450, 28 | allowScriptsToClose: true, 29 | }) 30 | 31 | windows[win.id] = id 32 | if (!browser.windows.onRemoved.hasListener(onRemovedListener)) 33 | browser.windows.onRemoved.addListener(onRemovedListener) 34 | 35 | try { 36 | const response = await promise 37 | delete windows[win.id] 38 | return response 39 | } catch (e) { 40 | delete windows[win.id] 41 | throw e 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/messaging/promises.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from "uuid" 2 | 3 | const promises: { 4 | [key: string]: [ 5 | (value: unknown) => void, 6 | (reason?: any) => void, 7 | NodeJS.Timeout? 8 | ] 9 | } = {} 10 | 11 | export function keepPromise(timeoutMs: number = 5000): [string, Promise] { 12 | const id = v4() 13 | const promise = new Promise((resolve, reject) => { 14 | let timeout: NodeJS.Timeout 15 | if (timeoutMs != 0) { 16 | timeout = setTimeout(function () { 17 | resolvePromiseFromBackground(id, new Error("Timeout")) 18 | }, timeoutMs) 19 | } 20 | promises[id] = [resolve, reject, timeout] 21 | }) 22 | return [id, promise] 23 | } 24 | 25 | export function extendPromiseFromBackground(id: string, timeoutMs: number) { 26 | if (!(id in promises)) { 27 | console.error("Promise id", id, "missing in extendPromise") 28 | return 29 | } 30 | const [_1, _2, timeout] = promises[id] 31 | if (!timeout) return 32 | clearTimeout(timeout) 33 | promises[id][2] = setTimeout(function () { 34 | resolvePromiseFromBackground(id, new Error("Timeout")) 35 | }, timeoutMs) 36 | } 37 | 38 | export function resolvePromiseFromBackground(id: string, value: any) { 39 | if (!(id in promises)) { 40 | console.error("Promise id", id, "missing in resolvePromise") 41 | return 42 | } 43 | const [resolve, _, timeout] = promises[id] 44 | resolve(value) 45 | clearTimeout(timeout) 46 | delete promises[id] 47 | } 48 | 49 | export function rejectPromiseFromBackground(id: string, reason?: any) { 50 | if (!(id in promises)) { 51 | console.error("Promise id", id, "missing in rejectPromise") 52 | return 53 | } 54 | const [_, reject, timeout] = promises[id] 55 | reject(reason) 56 | clearTimeout(timeout) 57 | delete promises[id] 58 | } 59 | -------------------------------------------------------------------------------- /src/polyfill.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { SerialSink } from "./serial/sink" 4 | import { SerialSource } from "./serial/source" 5 | import { SerialOpcode, SerialPortData, SerialTransport } from "./serial/types" 6 | import { SerialWebSocket } from "./serial/websocket" 7 | import { pack } from "python-struct" 8 | import { debugLog } from "./utils/logging" 9 | import { catchIgnore } from "./utils/utils" 10 | 11 | export class SerialPort extends EventTarget { 12 | onconnect: EventListener 13 | ondisconnect: EventListener 14 | 15 | private port_: SerialPortData 16 | private transport_: SerialTransport | null 17 | private readable_: ReadableStream | null 18 | private writable_: WritableStream | null 19 | 20 | private options_: SerialOptions | null 21 | private outputSignals_: SerialOutputSignals 22 | private inputSignals_: SerialInputSignals 23 | 24 | public constructor(port: SerialPortData) { 25 | super() 26 | this.port_ = port 27 | this.transport_ = null 28 | this.readable_ = null 29 | this.writable_ = null 30 | this.options_ = null 31 | this.outputSignals_ = { 32 | dataTerminalReady: false, 33 | requestToSend: false, 34 | break: false, 35 | } 36 | this.inputSignals_ = { 37 | dataCarrierDetect: false, 38 | clearToSend: false, 39 | ringIndicator: false, 40 | dataSetReady: false, 41 | } 42 | this.onTransportDisconnect = this.onTransportDisconnect.bind(this) 43 | } 44 | 45 | private get state_(): "closed" | "opening" | "opened" { 46 | if (this.transport_ === null) return "closed" 47 | if (this.transport_.connected !== true) return "opening" 48 | return "opened" 49 | } 50 | 51 | public get readable(): ReadableStream { 52 | if (this.readable_ !== null) return this.readable_ 53 | if (this.state_ !== "opened") return null 54 | this.readable_ = new ReadableStream( 55 | new SerialSource(this.transport_, () => { 56 | this.readable_ = null 57 | }), 58 | { 59 | highWaterMark: this.options_?.bufferSize ?? 255, 60 | } 61 | ) 62 | return this.readable_ 63 | } 64 | 65 | public get writable(): WritableStream { 66 | if (this.writable_ !== null) return this.writable_ 67 | if (this.state_ !== "opened") return null 68 | this.writable_ = new WritableStream( 69 | new SerialSink(this.transport_, () => { 70 | this.writable_ = null 71 | }), 72 | new ByteLengthQueuingStrategy({ 73 | highWaterMark: this.options_?.bufferSize ?? 255, 74 | }) 75 | ) 76 | return this.writable_ 77 | } 78 | 79 | public getInfo(): SerialPortInfo { 80 | const info: SerialPortInfo = {} 81 | if (this.port_.transport == "USB") { 82 | info.usbVendorId = this.port_.usb?.vid 83 | info.usbProductId = this.port_.usb?.pid 84 | } 85 | return info 86 | } 87 | 88 | public async open(options: SerialOptions): Promise { 89 | debugLog( 90 | "SERIAL", 91 | "open", 92 | "options:", 93 | options, 94 | "transport:", 95 | this.transport_ 96 | ) 97 | if (this.transport_ !== null && this.transport_.connected) 98 | throw new DOMException( 99 | "The port is already open.", 100 | "InvalidStateError" 101 | ) 102 | if ( 103 | options.dataBits !== undefined && 104 | ![7, 8].includes(options.dataBits) 105 | ) 106 | throw new TypeError("Requested number of data bits must be 7 or 8.") 107 | if ( 108 | options.stopBits !== undefined && 109 | ![1, 2].includes(options.stopBits) 110 | ) 111 | throw new TypeError("Requested number of stop bits must be 1 or 2.") 112 | if (options.bufferSize !== undefined && options.bufferSize <= 0) 113 | throw new TypeError( 114 | `Requested buffer size (${options.bufferSize} bytes) must be greater than zero.` 115 | ) 116 | 117 | // close the socket if it's open somehow 118 | if (this.transport_ !== null) await catchIgnore(this.close()) 119 | 120 | // assume 8-N-1 as defaults 121 | if (options.dataBits === undefined) options.dataBits = 8 122 | if (options.parity === undefined) options.parity = "none" 123 | if (options.stopBits === undefined) options.stopBits = 1 124 | 125 | try { 126 | // connect & open the port 127 | this.options_ = options 128 | this.transport_ = new SerialWebSocket() 129 | this.transport_.addEventListener( 130 | "disconnect", 131 | this.onTransportDisconnect 132 | ) 133 | await this.transport_.connect() 134 | await this.transport_.send( 135 | pack(` { 166 | if (this.transport_ !== null) await catchIgnore(this.close()) 167 | } 168 | 169 | public async close(): Promise { 170 | debugLog("SERIAL", "close", "transport:", this.transport_) 171 | if (this.transport_ === null) 172 | throw new DOMException( 173 | "The port is already closed.", 174 | "InvalidStateError" 175 | ) 176 | 177 | const promises = [] 178 | if (this.readable_) promises.push(this.readable_.cancel()) 179 | if (this.writable_) promises.push(this.writable_.abort()) 180 | await Promise.all(promises) 181 | this.readable_ = null 182 | this.writable_ = null 183 | 184 | // indicate that the client is not ready 185 | await catchIgnore( 186 | this.setSignals({ 187 | dataTerminalReady: false, 188 | requestToSend: false, 189 | }) 190 | ) 191 | 192 | // close & disconnect the port 193 | await catchIgnore( 194 | this.transport_.send(pack(" { 211 | const { 212 | dataTerminalReady: oldDTR, 213 | requestToSend: oldRTS, 214 | break: oldBRK, 215 | } = this.outputSignals_ 216 | const { 217 | dataTerminalReady: newDTR, 218 | requestToSend: newRTS, 219 | break: newBRK, 220 | } = signals 221 | 222 | if ( 223 | (newDTR !== undefined && oldDTR !== newDTR) || 224 | (newRTS !== undefined && oldRTS !== newRTS) 225 | ) { 226 | debugLog( 227 | "SERIAL", 228 | "signals", 229 | `DTR: ${newDTR ?? oldDTR}, RTS: ${newRTS ?? oldRTS}` 230 | ) 231 | await this.transport_.send( 232 | pack(" { 253 | return this.inputSignals_ 254 | } 255 | 256 | public async forget(): Promise { 257 | console.log("Not implemented") 258 | } 259 | } 260 | 261 | class Serial extends EventTarget { 262 | onconnect: EventListener 263 | ondisconnect: EventListener 264 | 265 | private async translateError(promise: Promise): Promise { 266 | try { 267 | return await promise 268 | } catch (e) { 269 | const message = (e as Error).message 270 | let name = "WebSerialError" 271 | switch (message) { 272 | case "No port selected by the user.": 273 | name = "NotFoundError" 274 | break 275 | } 276 | throw new DOMException(message, name) 277 | } 278 | } 279 | 280 | async getPorts(): Promise { 281 | const ports = await this.translateError(WebSerialPolyfill.getPorts()) 282 | return ports.map((port) => new SerialPort(port)) 283 | } 284 | 285 | async requestPort(options?: SerialPortRequestOptions): Promise { 286 | const port = await this.translateError( 287 | WebSerialPolyfill.requestPort(options) 288 | ) 289 | return new SerialPort(port) 290 | } 291 | } 292 | 293 | // copy functions instead of creating a new object, 294 | // so that references to navigator.serial are always valid 295 | const serial = new Serial() 296 | navigator.serial.addEventListener = serial.addEventListener.bind(serial) 297 | navigator.serial.dispatchEvent = serial.dispatchEvent.bind(serial) 298 | navigator.serial.getPorts = serial.getPorts.bind(serial) 299 | navigator.serial.removeEventListener = serial.removeEventListener.bind(serial) 300 | navigator.serial.requestPort = serial.requestPort.bind(serial) 301 | -------------------------------------------------------------------------------- /src/serial/sink.ts: -------------------------------------------------------------------------------- 1 | import { debugLog } from "../utils/logging" 2 | import { catchIgnore } from "../utils/utils" 3 | import { SerialTransport } from "./types" 4 | 5 | export class SerialSink implements UnderlyingSink { 6 | controller: WritableStreamDefaultController = null 7 | 8 | public constructor( 9 | private transport_: SerialTransport, 10 | private onClose_: () => void 11 | ) { 12 | debugLog("STREAM", "sink", "CONSTRUCTOR") 13 | this.onDisconnect = this.onDisconnect.bind(this) 14 | this.transport_.addEventListener("disconnect", this.onDisconnect) 15 | } 16 | 17 | onDisconnect() { 18 | debugLog("STREAM", "sink", "onDisconnect()") 19 | catchIgnore(this.controller?.error) 20 | this.controller = null 21 | this.close() 22 | } 23 | 24 | start(controller: WritableStreamDefaultController) { 25 | debugLog("STREAM", "sink", "start()") 26 | this.controller = controller 27 | } 28 | 29 | async write( 30 | chunk: Uint8Array, 31 | controller: WritableStreamDefaultController 32 | ): Promise { 33 | try { 34 | await this.transport_.sendData(chunk) 35 | } catch (e) { 36 | debugLog("STREAM", "sink", "write() ERROR", e) 37 | controller.error(e) 38 | this.onClose_() 39 | } 40 | } 41 | 42 | close() { 43 | debugLog("STREAM", "sink", "close()") 44 | this.transport_.removeEventListener("disconnect", this.onDisconnect) 45 | this.onClose_() 46 | } 47 | 48 | abort() { 49 | debugLog("STREAM", "sink", "abort()") 50 | this.close() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/serial/source.ts: -------------------------------------------------------------------------------- 1 | import { debugLog } from "../utils/logging" 2 | import { catchIgnore } from "../utils/utils" 3 | import { SerialTransport } from "./types" 4 | 5 | export class SerialSource implements UnderlyingSource { 6 | type: undefined 7 | controller: ReadableStreamController = null 8 | buffer: Uint8Array = null 9 | bufferUsed: number = 0 10 | wantData: boolean = false 11 | 12 | public constructor( 13 | private transport_: SerialTransport, 14 | private onClose_: () => void 15 | ) { 16 | // @ts-ignore 17 | this.type = "bytes" 18 | this.onDisconnect = this.onDisconnect.bind(this) 19 | this.transport_.addEventListener("disconnect", this.onDisconnect) 20 | } 21 | 22 | onDisconnect() { 23 | debugLog("STREAM", "source", "onDisconnect()") 24 | this.cancel() 25 | } 26 | 27 | start(controller: ReadableStreamController) { 28 | debugLog("STREAM", "source", "start()") 29 | this.controller = controller 30 | 31 | const bufferSize = controller.desiredSize 32 | this.buffer = new Uint8Array(bufferSize) 33 | this.bufferUsed = 0 34 | this.wantData = false 35 | 36 | this.transport_.sourceFeedData = (data) => { 37 | if (this.bufferUsed + data.length >= bufferSize) { 38 | // the buffer would overflow 39 | const newSize = bufferSize - this.bufferUsed 40 | const newData = data.slice(0, newSize) 41 | // cut from data whatever fits in the buffer 42 | data = data.slice(newSize) 43 | // write it at the end 44 | this.buffer.set(newData, this.bufferUsed) 45 | // reset the buffer usage 46 | this.bufferUsed = 0 47 | // pass the entire buffer to the controller 48 | controller.enqueue(new Uint8Array(this.buffer)) 49 | } 50 | if (data.length > 0) { 51 | // the buffer will NOT overflow anymore, whatever's left in 'data' will fit 52 | this.buffer.set(data, this.bufferUsed) 53 | this.bufferUsed += data.length 54 | // if reader is waiting for data, enqueue it using pull() 55 | if (this.wantData) this.pull(controller) 56 | } 57 | } 58 | } 59 | 60 | pull(controller: ReadableStreamController) { 61 | if (this.bufferUsed == 0) { 62 | // nothing to read, but the reader is waiting for data 63 | this.wantData = true 64 | return 65 | } 66 | // consume the entire buffer 67 | const newData = this.buffer.slice(0, this.bufferUsed) 68 | this.bufferUsed = 0 69 | // enqueue after consume, since it may unblock and call pull() 70 | controller.enqueue(newData) 71 | this.wantData = false 72 | } 73 | 74 | cancel(_reason?: any) { 75 | debugLog("STREAM", "source", "cancel()") 76 | catchIgnore(this.controller?.close) 77 | this.controller = null 78 | this.transport_.removeEventListener("disconnect", this.onDisconnect) 79 | this.transport_.sourceFeedData = null 80 | this.onClose_() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/serial/types.ts: -------------------------------------------------------------------------------- 1 | export type SerialPortData = { 2 | id: string 3 | name: string 4 | description?: string 5 | transport: "NATIVE" | "USB" | "BLUETOOTH" 6 | usb?: { 7 | bus?: number 8 | address?: number 9 | vid?: number 10 | pid?: number 11 | manufacturer?: string 12 | product?: string 13 | serial?: string 14 | } 15 | bluetooth?: { 16 | address?: string 17 | } 18 | authKey?: string 19 | isPaired?: boolean 20 | } 21 | 22 | export type SerialPortAuth = { 23 | [key: string]: { 24 | name: string 25 | description?: string 26 | } 27 | } 28 | 29 | export interface SerialTransport extends EventTarget { 30 | connected: boolean 31 | sourceFeedData?: (data: Uint8Array) => void 32 | connect(): Promise 33 | disconnect(): Promise 34 | send(msg: Uint8Array): Promise 35 | sendData(data: Uint8Array): Promise 36 | } 37 | 38 | export enum SerialOpcode { 39 | WSM_OK = 0, 40 | WSM_PORT_OPEN = 10, 41 | WSM_PORT_CLOSE = 11, 42 | WSM_SET_CONFIG = 20, 43 | WSM_SET_SIGNALS = 30, 44 | WSM_GET_SIGNALS = 31, 45 | WSM_START_BREAK = 40, 46 | WSM_END_BREAK = 41, 47 | WSM_DATA = 50, 48 | WSM_DRAIN = 51, 49 | WSM_ERROR = 128, 50 | WSM_ERR_OPCODE = 129, 51 | WSM_ERR_AUTH = 130, 52 | WSM_ERR_IS_OPEN = 131, 53 | WSM_ERR_NOT_OPEN = 132, 54 | } 55 | -------------------------------------------------------------------------------- /src/serial/websocket.ts: -------------------------------------------------------------------------------- 1 | import { debugLog, debugRx, debugTx } from "../utils/logging" 2 | import { SerialOpcode, SerialTransport } from "./types" 3 | 4 | export class SerialWebSocket extends EventTarget implements SerialTransport { 5 | private ws_: WebSocket | null = null 6 | 7 | private promise_?: Promise 8 | private resolve_?: (value: Uint8Array) => void 9 | private reject_?: (reason?: any) => void 10 | 11 | sourceFeedData?: (data: Uint8Array) => void 12 | 13 | public get connected(): boolean { 14 | return this.ws_ !== null && this.ws_.readyState === WebSocket.OPEN 15 | } 16 | 17 | async connect(): Promise { 18 | debugLog("SOCKET", "state", "Connecting socket") 19 | 20 | if (this.connected) await this.disconnect() 21 | 22 | const params = await WebSerialPolyfill.getNativeParams() 23 | if (params.state !== "connected") 24 | throw Error("Native application not connected") 25 | 26 | await new Promise((resolve, reject) => { 27 | this.ws_ = new WebSocket(`ws://localhost:${params.wsPort}`) 28 | this.ws_.binaryType = "arraybuffer" 29 | this.ws_.onmessage = this.receive.bind(this) 30 | this.ws_.onopen = resolve 31 | this.ws_.onerror = reject 32 | }) 33 | 34 | debugLog("SOCKET", "state", "Connected socket") 35 | this.ws_.onerror = () => { 36 | debugLog("SOCKET", "state", "WS error") 37 | this.disconnect() 38 | } 39 | this.ws_.onclose = () => { 40 | debugLog("SOCKET", "state", "WS close") 41 | this.disconnect() 42 | } 43 | } 44 | 45 | async disconnect(): Promise { 46 | if (this.reject_) this.reject_(new Error("Disconnecting")) 47 | this.dispatchEvent(new Event("disconnect")) 48 | if (this.ws_) { 49 | debugLog("SOCKET", "state", "Disconnecting socket...") 50 | // remove onclose and onerror listeners, as they would call disconnect() again 51 | this.ws_.onclose = null 52 | this.ws_.onerror = null 53 | this.ws_.close() 54 | this.ws_ = null 55 | debugLog("SOCKET", "state", "Disconnected socket") 56 | } 57 | this.clearPromise() 58 | } 59 | 60 | private clearPromise() { 61 | this.promise_ = null 62 | this.resolve_ = null 63 | this.reject_ = null 64 | } 65 | 66 | private async receive(ev: MessageEvent) { 67 | const data = new Uint8Array(ev.data) 68 | debugRx("SOCKET", data) 69 | if (data[0] == SerialOpcode.WSM_DATA) { 70 | if (this.sourceFeedData) this.sourceFeedData(data.subarray(1)) 71 | return 72 | } 73 | if (data[0] >= SerialOpcode.WSM_ERROR) { 74 | if (this.reject_) { 75 | const decoder = new TextDecoder() 76 | let message = `Native error ${data[0]}` 77 | switch (data[0]) { 78 | case SerialOpcode.WSM_ERR_OPCODE: 79 | message = "Invalid operation" 80 | break 81 | case SerialOpcode.WSM_ERR_AUTH: 82 | message = "Port not found (auth)" 83 | break 84 | case SerialOpcode.WSM_ERR_IS_OPEN: 85 | message = "Port is already open" 86 | break 87 | case SerialOpcode.WSM_ERR_NOT_OPEN: 88 | message = "Port is not open" 89 | break 90 | default: 91 | message = 92 | decoder.decode(data.subarray(1)) + ` (${data[0]})` 93 | } 94 | this.reject_(new Error(message)) 95 | } else { 96 | await this.disconnect() 97 | } 98 | 99 | this.clearPromise() 100 | return 101 | } 102 | if (this.resolve_) this.resolve_(data) 103 | this.clearPromise() 104 | } 105 | 106 | async send(msg: Uint8Array): Promise { 107 | if (!this.connected) throw Error("Not connected") 108 | 109 | // await this.promise_ 110 | this.promise_ = new Promise((resolve, reject) => { 111 | this.resolve_ = resolve 112 | this.reject_ = reject 113 | this.ws_.send(msg.buffer) 114 | debugTx("SOCKET", msg) 115 | }) 116 | 117 | const timeout = setTimeout(() => { 118 | if (this.reject_) 119 | this.reject_(new Error("Timeout waiting for native response")) 120 | this.clearPromise() 121 | }, 5000) 122 | 123 | const response = await this.promise_ 124 | clearTimeout(timeout) 125 | this.clearPromise() 126 | return response 127 | } 128 | 129 | async sendData(data: Uint8Array): Promise { 130 | const msg = new Uint8Array(data.length + 2) 131 | msg[0] = SerialOpcode.WSM_DATA 132 | msg[1] = 0 133 | msg.set(data, 2) 134 | return await this.send(msg) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- 1 | import { startUI } from "./ui/index" 2 | ;(async () => { 3 | startUI() 4 | })() 5 | -------------------------------------------------------------------------------- /src/ui/components/Common.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | import { PopupRequest } from "../../utils/types" 3 | import { Button } from "../controls/Button" 4 | 5 | export type CommonProps = { 6 | resolve: (value: any) => void 7 | reject: (reason?: any) => void 8 | } & PopupRequest 9 | 10 | export const MainContainer = styled.div` 11 | display: flex; 12 | flex-direction: column; 13 | height: 100%; 14 | ` 15 | 16 | export const MessageContainer = styled.div` 17 | flex: 0 0 auto; 18 | margin: 0 12px; 19 | position: relative; 20 | min-height: 40px; 21 | padding-right: 56px; 22 | ` 23 | 24 | export const MessageDivider = styled.hr` 25 | height: 2px; 26 | margin: 8px 12px 0; 27 | ` 28 | 29 | export const ListContainer = styled.div` 30 | flex: 1 1 auto; 31 | overflow: auto; 32 | padding: 8px 12px; 33 | ` 34 | 35 | export const ReloadButton = styled(Button)` 36 | position: absolute; 37 | top: 50%; 38 | right: 0; 39 | transform: translateY(-50%); 40 | ` 41 | 42 | export const ButtonContainer = styled.div` 43 | display: flex; 44 | padding: 12px; 45 | ` 46 | 47 | export const ButtonSpacer = styled.span` 48 | margin-right: 12px; 49 | ` 50 | 51 | export const ButtonMessage = styled.div` 52 | display: flex; 53 | flex-grow: 1; 54 | opacity: 0.5; 55 | ` 56 | -------------------------------------------------------------------------------- /src/ui/components/NativeInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styled from "styled-components" 3 | import { NativeParams } from "../../utils/types" 4 | 5 | const Text = styled.small` 6 | margin: 0; 7 | padding: 0; 8 | ` 9 | 10 | export class NativeInfo extends React.Component { 11 | render() { 12 | const version = browser.runtime.getManifest().version 13 | switch (this.props.state) { 14 | case "outdated": 15 | return ( 16 | 17 | Add-on version: v{version} 18 |
19 | Native outdated: v{this.props.version} 20 |
21 | ) 22 | 23 | case "connected": 24 | return ( 25 | 26 | Add-on version: v{version} 27 |
28 | Native version: v{this.props.version} 29 |
30 | ) 31 | 32 | default: 33 | return null 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/components/NativeInstaller.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styled from "styled-components" 3 | import { NativeParams } from "../../utils/types" 4 | import { Button } from "../controls/Button" 5 | 6 | const Container = styled.div` 7 | text-align: center; 8 | ` 9 | 10 | type InstallerOs = { 11 | name: string 12 | arch: { [arch in browser.runtime.PlatformArch]?: InstallerArch } 13 | } 14 | 15 | type InstallerArch = { 16 | file: string 17 | isInstaller?: boolean 18 | } 19 | 20 | const releaseInfoUrl = 21 | "https://github.com/kuba2k2/firefox-webserial/releases/tag/vVERSION" 22 | const releaseDownloadUrl = 23 | "https://github.com/kuba2k2/firefox-webserial/releases/download/vVERSION/" 24 | 25 | const installers: { [os in browser.runtime.PlatformOs]?: InstallerOs } = { 26 | win: { 27 | name: "Windows", 28 | arch: { 29 | "x86-32": { 30 | file: "firefox-webserial-vVERSION.exe", 31 | isInstaller: true, 32 | }, 33 | "x86-64": { 34 | file: "firefox-webserial-vVERSION.exe", 35 | isInstaller: true, 36 | }, 37 | }, 38 | }, 39 | linux: { 40 | name: "Linux", 41 | arch: { 42 | // "x86-32": { 43 | // file: "firefox-webserial-linux-x86-32", 44 | // }, 45 | "x86-64": { 46 | file: "firefox-webserial-linux-x86-64", 47 | }, 48 | // "arm": { 49 | // file: "firefox-webserial-linux-arm", 50 | // }, 51 | // "aarch64": { 52 | // file: "firefox-webserial-linux-aarch64", 53 | // }, 54 | }, 55 | }, 56 | } 57 | 58 | export class NativeInstaller extends React.Component { 59 | handleDownloadClick() { 60 | const onBlur = document.onblur 61 | document.onblur = null 62 | document.onfocus = () => { 63 | document.onfocus = null 64 | document.onblur = onBlur 65 | } 66 | } 67 | 68 | render() { 69 | const os = installers[this.props.platform.os] 70 | const arch = os?.arch[this.props.platform.arch] 71 | 72 | if (!arch) { 73 | return ( 74 | 75 |

Native add-on not available

76 | 77 |

78 | The native application is not available on your 79 | operating system. 80 |

81 | 82 |

83 | Your OS is:{" "} 84 | 85 | {this.props.platform.os}, {this.props.platform.arch} 86 | 87 |

88 | 89 |

90 | Please{" "} 91 | 95 | report the issue on GitHub 96 | 97 | . 98 |

99 |
100 | ) 101 | } 102 | 103 | const version = browser.runtime.getManifest().version 104 | const infoUrl = releaseInfoUrl.replace("VERSION", version) 105 | const downloadUrl = 106 | releaseDownloadUrl.replace("VERSION", version) + 107 | arch.file.replace("VERSION", version) 108 | 109 | return ( 110 | 111 | {this.props.state == "not-installed" && ( 112 |

Native add-on not installed

113 | )} 114 | {this.props.state == "outdated" && ( 115 |

Native add-on outdated

116 | )} 117 | {this.props.state == "error" &&

Native add-on error

} 118 | 119 | {this.props.state == "not-installed" && ( 120 |

121 | The native application is not installed on this 122 | computer. 123 |

124 | )} 125 | {this.props.state == "outdated" && ( 126 |

127 | The native application is outdated and has to be 128 | reinstalled. 129 |

130 | )} 131 | {this.props.state == "error" && ( 132 |

133 | The add-on couldn't communicate with the native 134 | application, so it has to be reinstalled. 135 |

136 | )} 137 | 138 |

139 | Press the button below to download the native application. 140 |

141 | 142 | {arch.isInstaller && ( 143 |

Then, open the downloaded file and install it.

144 | )} 145 | {!arch.isInstaller && ( 146 |

147 | Then, follow the instructions in the{" "} 148 | 152 | add-on README page 153 | 154 | . 155 |

156 | )} 157 | 158 | 165 | 42 | ) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ui/controls/List.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styled from "styled-components" 3 | 4 | const Container = styled.ul` 5 | list-style-type: none; 6 | margin: -8px -8px; 7 | padding: 0; 8 | ` 9 | 10 | const Item = styled.li<{ $active: boolean }>` 11 | appearance: none; 12 | color: ButtonText; 13 | border-radius: 4px; 14 | padding-inline: 8px 8px; 15 | 16 | ${(props) => 17 | props.$active 18 | ? ` 19 | background-color: color-mix(in srgb, currentColor 20%, ButtonFace); 20 | border: 2px solid ButtonText;` 21 | : ` 22 | border: 2px solid transparent; 23 | 24 | &:hover { 25 | background-color: color-mix(in srgb, currentColor 10%, ButtonFace); 26 | } 27 | 28 | &:hover:active { 29 | background-color: color-mix(in srgb, currentColor 20%, ButtonFace); 30 | }`} 31 | ` 32 | 33 | type ListProps = { 34 | items: string[] 35 | active?: number 36 | onClick: (index: number) => void 37 | } 38 | 39 | export class List extends React.Component { 40 | render() { 41 | return ( 42 | 43 | {this.props.items.map((value, index) => ( 44 | 49 | {value} 50 | 51 | ))} 52 | 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/ui/index.scss: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | width: 100%; 7 | height: 100%; 8 | user-select: none; 9 | } 10 | 11 | #root { 12 | height: 100%; 13 | } 14 | 15 | h2, 16 | h3, 17 | h4, 18 | h5, 19 | h6, 20 | p, 21 | small, 22 | li { 23 | padding-block: 4px; 24 | margin: 5px 0 3px; 25 | } 26 | 27 | button { 28 | margin: 0; 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/index.ts: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { createRoot } from "react-dom/client" 3 | import { extendPromise, rejectPromise, resolvePromise } from "../messaging" 4 | import { SerialPortData } from "../serial/types" 5 | import { PopupRequest } from "../utils/types" 6 | import { PortChooser } from "./pages/PortChooser" 7 | 8 | async function renderAndWait( 9 | component: any, 10 | message: PopupRequest 11 | ): Promise { 12 | return await new Promise((resolve, reject) => { 13 | const element = React.createElement(component, { 14 | resolve, 15 | reject, 16 | ...message, 17 | }) 18 | const container = document.getElementById("root") 19 | const root = createRoot(container) 20 | root.render(element) 21 | }) 22 | } 23 | 24 | class MessageHandler { 25 | async openPopup() { 26 | return null 27 | } 28 | 29 | async choosePort(message: PopupRequest) { 30 | return await renderAndWait(PortChooser, message) 31 | } 32 | } 33 | 34 | export async function startUI() { 35 | const handler = new MessageHandler() 36 | const url = new URL(location.href) 37 | 38 | if (url.searchParams.has("id") && url.searchParams.has("message")) { 39 | const id = url.searchParams.get("id") 40 | 41 | setInterval(async () => { 42 | await extendPromise(id, 5000) 43 | }, 2000) 44 | 45 | document.onblur = () => { 46 | window.close() 47 | } 48 | 49 | try { 50 | const message: PopupRequest = JSON.parse( 51 | atob(url.searchParams.get("message")) 52 | ) 53 | const response = await handler[message.action](message) 54 | await resolvePromise(id, response) 55 | } catch (e) { 56 | await rejectPromise(id, e) 57 | } 58 | 59 | window.close() 60 | } else { 61 | await handler.openPopup() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ui/pages/PortChooser.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { getNativeParams, listAvailablePorts } from "../../messaging" 3 | import { SerialPortData } from "../../serial/types" 4 | import { NativeParams } from "../../utils/types" 5 | import { 6 | ButtonContainer, 7 | ButtonMessage, 8 | ButtonSpacer, 9 | CommonProps, 10 | ListContainer, 11 | MainContainer, 12 | MessageContainer, 13 | MessageDivider, 14 | ReloadButton, 15 | } from "../components/Common" 16 | import { NativeInfo } from "../components/NativeInfo" 17 | import { NativeInstaller } from "../components/NativeInstaller" 18 | import { Button } from "../controls/Button" 19 | import { List } from "../controls/List" 20 | 21 | type PortChooserState = { 22 | params: NativeParams | null 23 | ports: SerialPortData[] | null 24 | error?: any 25 | active: number | null 26 | } 27 | 28 | export class PortChooser extends React.Component< 29 | CommonProps, 30 | PortChooserState 31 | > { 32 | constructor(props: CommonProps) { 33 | super(props) 34 | this.state = { params: null, ports: null, active: null } 35 | this.handleItemClick = this.handleItemClick.bind(this) 36 | this.handleRefresh = this.handleRefresh.bind(this) 37 | this.handleOkClick = this.handleOkClick.bind(this) 38 | this.handleCancelClick = this.handleCancelClick.bind(this) 39 | } 40 | 41 | handleItemClick(index: number) { 42 | this.setState({ active: index }) 43 | } 44 | 45 | async handleRefresh() { 46 | this.setState({ params: null, ports: null, active: null }) 47 | const params = await getNativeParams() 48 | try { 49 | if (params.state !== "connected") { 50 | this.setState({ params, ports: null }) 51 | return 52 | } 53 | const ports = await listAvailablePorts( 54 | this.props.origin, 55 | this.props.options 56 | ) 57 | this.setState({ params, ports }) 58 | } catch (error) { 59 | this.setState({ params, error }) 60 | } 61 | } 62 | 63 | handleCancelClick() { 64 | this.props.reject(new Error("No port selected by the user.")) 65 | } 66 | 67 | handleOkClick() { 68 | const active = this.state.active 69 | if (active == null) return 70 | const port = this.state.ports[active] 71 | if (!port) return 72 | this.props.resolve(port) 73 | } 74 | 75 | componentDidMount() { 76 | this.handleRefresh() 77 | } 78 | 79 | render() { 80 | let hostname: string 81 | if (this.props.origin) { 82 | hostname = new URL(this.props.origin).hostname 83 | } 84 | 85 | return ( 86 | 87 | 88 |

{hostname ?? "A website"}

89 | 90 | wants to connect to a serial port on your computer. 91 | 92 | 93 |
94 | 95 | 96 | 97 | 98 | {!this.state.params && 99 | !this.state.ports && 100 | !this.state.error && ( 101 | Looking for serial ports... 102 | )} 103 | {this.state.params && !this.state.ports && ( 104 | 105 | )} 106 | {this.state.ports && ( 107 | port.description || port.name 110 | )} 111 | active={this.state.active} 112 | onClick={this.handleItemClick} 113 | /> 114 | )} 115 | {this.state.ports && this.state.ports.length == 0 && ( 116 | No serial ports found 117 | )} 118 | {this.state.error && ( 119 | 120 | Failed to enumerate serial ports:{" "} 121 | {`${this.state.error}`} 122 | 123 | )} 124 | 125 | 126 | 127 | 128 | {this.state.params && ( 129 | 130 | )} 131 | 132 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { authGrant } from "../messaging" 2 | import { SerialPortAuth, SerialPortData } from "../serial/types" 3 | 4 | let authKeyCache: { [key: string]: string } = {} 5 | 6 | export async function readOriginAuth(origin: string): Promise { 7 | const { originAuth } = await browser.storage.local.get("originAuth") 8 | if (!originAuth || !originAuth[origin]) return {} 9 | return originAuth[origin] 10 | } 11 | 12 | export async function writeOriginAuth( 13 | origin: string, 14 | port: SerialPortData 15 | ): Promise { 16 | let { originAuth } = await browser.storage.local.get("originAuth") 17 | if (!originAuth) originAuth = {} 18 | if (!originAuth[origin]) originAuth[origin] = {} 19 | originAuth[origin][port.id] = { 20 | name: port.name, 21 | description: port.description, 22 | } 23 | await browser.storage.local.set({ originAuth }) 24 | } 25 | 26 | export async function getPortAuthKey(port: SerialPortData): Promise { 27 | if (port.id in authKeyCache) { 28 | return authKeyCache[port.id] 29 | } else { 30 | const authKey = await authGrant(port.name) 31 | authKeyCache[port.id] = authKey 32 | return authKey 33 | } 34 | } 35 | 36 | export function clearPortAuthKeyCache(): void { 37 | authKeyCache = {} 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/logging.ts: -------------------------------------------------------------------------------- 1 | type ModuleType = "SERIAL" | "NATIVE" | "SOCKET" | "STREAM" 2 | 3 | if (process.env.DEBUG === "true") 4 | // @ts-ignore 5 | window.wsdebug = true 6 | 7 | // [log, rx, tx] 8 | const colors: Record = { 9 | SERIAL: ["#C62817", "#C62817", "#C62817"], 10 | NATIVE: ["#FA0064", "#5BC0DE", "#F0AD4E"], 11 | SOCKET: ["#1ED760", "#5BC0DE", "#F0AD4E"], 12 | STREAM: ["#FE5A00", "", ""], 13 | } 14 | 15 | function getNow(): string { 16 | return new Date().toISOString().substring(11, 23) 17 | } 18 | 19 | function buf2hex(data: Uint8Array) { 20 | return [...data].map((x) => x.toString(16).padStart(2, "0")).join(" ") 21 | } 22 | 23 | function log(...args: any[]) { 24 | if ("wsdebug" in window && window["wsdebug"] === true) { 25 | console.log(...args) 26 | } 27 | } 28 | 29 | export function debugLog(module: ModuleType, type: string, ...message: any[]) { 30 | log( 31 | `%s / %c%s -- %s:`, 32 | getNow(), 33 | `color: ${colors[module][0]}; font-weight: bold`, 34 | module, 35 | type, 36 | ...message 37 | ) 38 | } 39 | 40 | export function debugRx(module: ModuleType, data: Uint8Array | object) { 41 | if (data instanceof Uint8Array) { 42 | log( 43 | `%s / %c%s -> RX(%3d):`, 44 | getNow(), 45 | `color: ${colors[module][1]}; font-weight: bold`, 46 | module, 47 | data.length, 48 | buf2hex(data) 49 | ) 50 | } else { 51 | log( 52 | `%s / %c%s -> RX(obj):`, 53 | getNow(), 54 | `color: ${colors[module][1]}; font-weight: bold`, 55 | module, 56 | data 57 | ) 58 | } 59 | } 60 | 61 | export function debugTx(module: ModuleType, data: Uint8Array | object) { 62 | if (data instanceof Uint8Array) { 63 | log( 64 | `%s / %c%s <- TX(%3d):`, 65 | getNow(), 66 | `color: ${colors[module][2]}; font-weight: bold`, 67 | module, 68 | data.length, 69 | buf2hex(data) 70 | ) 71 | } else { 72 | log( 73 | `%s / %c%s <- TX(obj):`, 74 | getNow(), 75 | `color: ${colors[module][2]}; font-weight: bold`, 76 | module, 77 | data 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type BackgroundRequest = { 2 | action: 3 | | "getNativeParams" 4 | | "getPorts" 5 | | "requestPort" 6 | | "listAvailablePorts" 7 | | "clearAuthKeyCache" 8 | | "extendPromise" 9 | | "resolvePromise" 10 | | "rejectPromise" 11 | // getPorts, requestPort, listAvailablePorts 12 | origin?: string 13 | options?: SerialPortRequestOptions 14 | // extendPromise, resolvePromise, rejectPromise 15 | id?: string 16 | timeoutMs?: number 17 | value?: any 18 | reason?: any 19 | } 20 | 21 | export type NativeRequest = { 22 | action?: "ping" | "listPorts" | "authGrant" | "authRevoke" 23 | id?: string 24 | port?: string 25 | } 26 | 27 | export type PopupRequest = { 28 | action?: "choosePort" 29 | // choosePort 30 | origin?: string 31 | options?: SerialPortRequestOptions 32 | } 33 | 34 | export type NativeParams = { 35 | state: "checking" | "not-installed" | "error" | "outdated" | "connected" 36 | platform?: browser.runtime.PlatformInfo 37 | error?: any 38 | version?: string 39 | protocol?: number 40 | wsPort?: number 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export async function catchIgnore( 2 | promise: Promise | (() => void) 3 | ): Promise { 4 | try { 5 | await promise 6 | } catch { 7 | // ignore 8 | } 9 | } 10 | 11 | export function sleep(milliseconds: number): Promise { 12 | return new Promise((resolve) => { 13 | setTimeout(resolve, milliseconds) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "experimentalDecorators": true, 6 | "jsx": "react", 7 | "lib": ["es2019", "dom"], 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "sourceMap": true, 12 | "target": "es2019" 13 | }, 14 | "include": [ 15 | "./src/**/*" 16 | ], 17 | "exclude": [ 18 | "node_modules", 19 | "dist" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /webserial.d.ts: -------------------------------------------------------------------------------- 1 | import { SerialPortData } from "./src/serial/types"; 2 | import { NativeParams } from "./src/utils/types"; 3 | 4 | export { } 5 | 6 | declare global { 7 | interface WebSerialPolyfill { 8 | getNativeParams: () => Promise 9 | getPorts: () => Promise 10 | requestPort: (options?: SerialPortRequestOptions) => Promise 11 | } 12 | 13 | interface Window { 14 | WebSerialPolyfill: WebSerialPolyfill 15 | wrappedJSObject: Window 16 | } 17 | 18 | const WebSerialPolyfill: WebSerialPolyfill 19 | 20 | function cloneInto(obj: T, target: object, options?: object): T 21 | function exportFunction(obj: T, target: object): T 22 | } 23 | --------------------------------------------------------------------------------