├── .editor └── .gitignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── release.yml └── workflows │ └── release.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── .typesafe-i18n.json ├── .vale └── styles │ ├── 18F │ ├── Abbreviations.yml │ ├── Ages.yml │ ├── Brands.yml │ ├── Clarity.yml │ ├── Contractions.yml │ ├── DropDown.yml │ ├── Headings.yml │ ├── Quotes.yml │ ├── Reading.yml │ ├── SentenceLength.yml │ ├── Spacing.yml │ ├── Terms.yml │ ├── ToDo.yml │ └── UnexpandedAcronyms.yml │ ├── documentation │ ├── Abbreviations.yml │ ├── Ages.yml │ ├── CaseSensitiveTerms.yml │ ├── Clarity.yml │ ├── DropDown.yml │ ├── LineLength.yml │ ├── SentenceLength.yml │ ├── Spacing.yml │ ├── Terms.yml │ ├── ToDo.yml │ └── UnexpandedAcronyms.yml │ └── proselint │ ├── Airlinese.yml │ ├── AnimalLabels.yml │ ├── Annotations.yml │ ├── Apologizing.yml │ ├── Archaisms.yml │ ├── But.yml │ ├── Cliches.yml │ ├── CorporateSpeak.yml │ ├── Currency.yml │ ├── Cursing.yml │ ├── DateCase.yml │ ├── DateMidnight.yml │ ├── DateRedundancy.yml │ ├── DateSpacing.yml │ ├── DenizenLabels.yml │ ├── Diacritical.yml │ ├── GenderBias.yml │ ├── GroupTerms.yml │ ├── Hedging.yml │ ├── Hyperbole.yml │ ├── Jargon.yml │ ├── LGBTOffensive.yml │ ├── LGBTTerms.yml │ ├── Malapropisms.yml │ ├── Needless.yml │ ├── Nonwords.yml │ ├── Oxymorons.yml │ ├── P-Value.yml │ ├── RASSyndrome.yml │ ├── Skunked.yml │ ├── Spelling.yml │ ├── Typography.yml │ ├── Uncomparables.yml │ ├── Very.yml │ └── meta.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── .yamllint.yaml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── PRIVACY.md ├── README.md ├── SECURITY.md ├── TOS.md ├── assets └── open-graph.xcf ├── build ├── entitlements.mac.plist ├── icon.icns ├── icon.ico ├── icon.png ├── icon_1024.png ├── icon_128.png ├── icon_256.png ├── icon_32.png ├── icon_512.png └── icon_64.png ├── bun.lockb ├── dev-app-update.yml ├── electron-builder.yml ├── electron.vite.config.ts ├── logo.png ├── logo.svg ├── package.json ├── resources └── icon.png ├── scripts ├── build.sh ├── notarize.js ├── release.sh └── set-version.sh ├── src ├── i18n │ ├── de │ │ └── index.ts │ ├── en │ │ └── index.ts │ ├── formatters.ts │ ├── fr │ │ └── index.ts │ ├── i18n-svelte.ts │ ├── i18n-types.ts │ ├── i18n-util.async.ts │ ├── i18n-util.sync.ts │ ├── i18n-util.ts │ └── zh │ │ └── index.ts ├── main │ ├── cursors.ts │ ├── index.ts │ ├── ipcMainHandlers.ts │ ├── stateKeeper.ts │ └── utils.ts ├── preload │ ├── cursor.svg │ ├── cursors.ts │ ├── index.d.ts │ └── index.ts └── renderer │ ├── cursors.html │ ├── index.html │ └── src │ ├── About.shoulders-of-giants.json │ ├── About.svelte │ ├── App.svelte │ ├── AudioVisualizer.svelte │ ├── BananasTypes.ts │ ├── Config.ts │ ├── Host.svelte │ ├── Join.svelte │ ├── Navigation.svelte │ ├── Settings.svelte │ ├── UseSharedStore.ts │ ├── Utils.ts │ ├── WebRTC.svelte │ ├── cursors.css │ ├── env.d.ts │ ├── main.ts │ ├── overrides.css │ ├── stores.ts │ └── translations.ts ├── svelte.config.mjs ├── tsconfig.json └── tsconfig.node.json /.editor/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .gitignore 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | extraFileExtensions: ['.svelte'] 4 | }, 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:svelte/recommended', 8 | '@electron-toolkit/eslint-config-ts/recommended', 9 | '@electron-toolkit/eslint-config-prettier' 10 | ], 11 | overrides: [ 12 | { 13 | files: ['*.svelte'], 14 | parser: 'svelte-eslint-parser', 15 | parserOptions: { 16 | parser: '@typescript-eslint/parser' 17 | } 18 | } 19 | ], 20 | rules: { 21 | 'svelte/no-unused-svelte-ignore': 'off' 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | changelog: 3 | exclude: 4 | labels: 5 | - ignore-for-release 6 | categories: 7 | - title: Breaking Changes 💥 8 | labels: 9 | - breaking-change 10 | - title: Documentation 📚 11 | labels: 12 | - documentation 13 | - title: Exciting New Features ✨ 14 | labels: 15 | - enhancement 16 | - title: Bug Fixes 🐛 17 | labels: 18 | - bug 19 | - title: Dependencies 📦 20 | labels: 21 | - dependencies 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v[0-9]+.[0-9]+.[0-9]+' 8 | jobs: 9 | build-linux-arm64: 10 | name: Build Linux ARM64 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Set up env 18 | run: | 19 | VERSION=${GITHUB_REF_NAME#v} 20 | echo "VERSION=$VERSION" >> $GITHUB_ENV 21 | echo "PLATFORM=linux-arm64" >> $GITHUB_ENV 22 | - name: Install OS packages 23 | run: | 24 | sudo apt-get install -y flatpak flatpak-builder && \ 25 | sudo snap install snapcraft --classic && \ 26 | flatpak remote-add \ 27 | --user \ 28 | --if-not-exists \ 29 | flathub https://flathub.org/repo/flathub.flatpakrepo 30 | - name: Set up Bun 31 | uses: oven-sh/setup-bun@v2 32 | - name: Cache Bun 33 | uses: actions/cache@v4 34 | with: 35 | path: ~/.bun/install/cache 36 | key: linux-arm64-bun-${{ hashFiles('**/bun.lockb') }} 37 | restore-keys: | 38 | linux-arm64-bun-${{ hashFiles('**/bun.lockb') }} 39 | - name: Cache Electron 40 | uses: actions/cache@v4 41 | with: 42 | path: ~/.cache/electron/ 43 | key: linux-arm64-electron-${{ hashFiles('~/.cache/electron/**') }} 44 | restore-keys: | 45 | linux-arm64-electron-${{ hashFiles('~/.cache/electron/**') }} 46 | - name: Install dependencies 47 | run: bun install --frozen-lockfile 48 | - name: Build Linux ARM64 49 | run: ./scripts/build.sh 50 | - name: Linux Release 51 | run: ./scripts/release.sh 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | build-linux: 55 | name: Build Linux 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v4 60 | with: 61 | fetch-depth: 0 62 | - name: Set up env 63 | run: | 64 | VERSION=${GITHUB_REF_NAME#v} 65 | echo "VERSION=$VERSION" >> $GITHUB_ENV 66 | echo "PLATFORM=linux" >> $GITHUB_ENV 67 | - name: Install OS packages 68 | run: | 69 | sudo apt-get install -y flatpak flatpak-builder && \ 70 | sudo snap install snapcraft --classic && \ 71 | flatpak remote-add \ 72 | --user \ 73 | --if-not-exists \ 74 | flathub https://flathub.org/repo/flathub.flatpakrepo 75 | - name: Set up Bun 76 | uses: oven-sh/setup-bun@v2 77 | - name: Cache Bun 78 | uses: actions/cache@v4 79 | with: 80 | path: ~/.bun/install/cache 81 | key: linux-bun-${{ hashFiles('**/bun.lockb') }} 82 | restore-keys: | 83 | linux-bun-${{ hashFiles('**/bun.lockb') }} 84 | - name: Cache Electron 85 | uses: actions/cache@v4 86 | with: 87 | path: ~/.cache/electron/ 88 | key: linux-electron-${{ hashFiles('~/.cache/electron/**') }} 89 | restore-keys: | 90 | linux-electron-${{ hashFiles('~/.cache/electron/**') }} 91 | - name: Install dependencies 92 | run: bun install --frozen-lockfile 93 | - name: Build Linux 94 | run: ./scripts/build.sh 95 | - name: Linux Release 96 | run: ./scripts/release.sh 97 | env: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 99 | build-windows: 100 | name: Build Windows 101 | runs-on: windows-latest 102 | steps: 103 | - name: Checkout 104 | uses: actions/checkout@v4 105 | with: 106 | fetch-depth: 0 107 | - name: Set up env 108 | shell: bash 109 | run: | 110 | VERSION=${GITHUB_REF_NAME#v} 111 | echo "VERSION=$VERSION" >> $GITHUB_ENV 112 | echo "PLATFORM=windows" >> $GITHUB_ENV 113 | - name: Set up Bun 114 | uses: oven-sh/setup-bun@v2 115 | - name: Cache Bun 116 | uses: actions/cache@v4 117 | with: 118 | path: ~/.bun/install/cache 119 | key: windows-bun-${{ hashFiles('**/bun.lockb') }} 120 | restore-keys: | 121 | windows-bun-${{ hashFiles('**/bun.lockb') }} 122 | - name: Cache Electron 123 | uses: actions/cache@v4 124 | with: 125 | path: ~/AppData/Local/electron/Cache/ 126 | key: windows-electron-${{ hashFiles('~/AppData/Local/electron/Cache/**') }} 127 | restore-keys: | 128 | windows-electron-${{ hashFiles('~/AppData/Local/electron/Cache/**') }} 129 | - name: Install dependencies 130 | run: bun install --frozen-lockfile 131 | - name: Build Windows 132 | shell: bash 133 | run: ./scripts/build.sh 134 | - name: Windows Release 135 | shell: bash 136 | run: ./scripts/release.sh 137 | env: 138 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 139 | build-macos: 140 | name: Build MacOS 141 | runs-on: macos-latest 142 | steps: 143 | - name: Checkout 144 | uses: actions/checkout@v4 145 | with: 146 | fetch-depth: 0 147 | - name: Install bash 148 | run: brew install bash 149 | - name: Set up env 150 | run: | 151 | VERSION=${GITHUB_REF_NAME#v} 152 | echo "VERSION=$VERSION" >> $GITHUB_ENV 153 | echo "PLATFORM=macos" >> $GITHUB_ENV 154 | - name: Set up Bun 155 | uses: oven-sh/setup-bun@v2 156 | - name: Cache Bun 157 | uses: actions/cache@v4 158 | with: 159 | path: ~/.bun/install/cache 160 | key: macos-bun-${{ hashFiles('**/bun.lockb') }} 161 | restore-keys: | 162 | macos-bun-${{ hashFiles('**/bun.lockb') }} 163 | - name: Cache Electron 164 | uses: actions/cache@v4 165 | with: 166 | path: ~/Library/Caches/electron/ 167 | key: macos-electron-${{ hashFiles('~/Library/Caches/electron/**') }} 168 | restore-keys: | 169 | macos-electron-${{ hashFiles('~/Library/Caches/electron/**') }} 170 | - name: Install dependencies 171 | run: bun install --frozen-lockfile 172 | - name: Build MacOS 173 | env: 174 | CSC_LINK: ${{ secrets.CSC_LINK }} 175 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 176 | APPLE_ID: ${{ secrets.APPLE_ID }} 177 | APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} 178 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 179 | APPLE_NOTARIZATION_WEBHOOK_URL: ${{ secrets.APPLE_NOTARIZATION_WEBHOOK_URL }} 180 | run: ./scripts/build.sh 181 | - name: MacOS Release 182 | run: ./scripts/release.sh 183 | env: 184 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 185 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | *.log* 6 | .nvim 7 | .svelte-kit 8 | .vite 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | .vale 8 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | plugins: 6 | - prettier-plugin-svelte 7 | overrides: 8 | - files: '*.svelte' 9 | options: 10 | parser: svelte 11 | -------------------------------------------------------------------------------- /.typesafe-i18n.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json", 3 | "adapter": "svelte", 4 | "baseLocale": "en" 5 | } 6 | -------------------------------------------------------------------------------- /.vale/styles/18F/Abbreviations.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Use '%s' instead of '%s'." 4 | link: https://content-guide.18f.gov/specific-words-and-phrases/ 5 | ignorecase: false 6 | level: error 7 | nonword: true 8 | swap: 9 | '\beg\b': e.g., 10 | '\bie\b': i.e., 11 | 'e\.g\.(?:[^,]|$)': e.g., 12 | 'i\.e\.(?:[^,]|$)': i.e., 13 | '(?i)\d{1,2} ?[ap]m': a.m. or p.m. 14 | 'D\.C\.': DC 15 | '\bUSA?\b': U.S. 16 | -------------------------------------------------------------------------------- /.vale/styles/18F/Ages.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: existence 3 | message: Avoid hyphens in ages unless it clarifies the text. 4 | link: https://content-guide.18f.gov/specific-words-and-phrases/ 5 | level: warning 6 | tokens: 7 | - '\d{1,3}-year-old' 8 | -------------------------------------------------------------------------------- /.vale/styles/18F/Brands.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Use '%s' instead of '%s'." 4 | link: https://content-guide.18f.gov/trademarks-and-brands/ 5 | level: warning 6 | ignorecase: true 7 | swap: 8 | Band-Aid: bandage 9 | Bubble Wrap: packaging bubbles 10 | Chapstick: lip balm 11 | Crayola: crayons 12 | Dumpster: waste container 13 | Hi-Liter: highlighting marker 14 | iPod: MP3 player 15 | Kleenex: tissue 16 | Plexiglas: plastic glass 17 | Post-it note: adhesive note 18 | Q-Tips: cotton swabs 19 | Scotch tape: transparent tape 20 | Styrofoam: plastic foam 21 | Taser: stun gun 22 | Xerox: photocopy 23 | -------------------------------------------------------------------------------- /.vale/styles/18F/Clarity.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: '%s' 4 | link: https://content-guide.18f.gov/plain-language/#words-to-avoid 5 | level: warning 6 | ignorecase: true 7 | swap: 8 | (?:commit|pledge): > 9 | Be more specific — we’re either doing something or we’re not. 10 | advancing: Avoid using 'advancing.' 11 | agenda: Avoid using 'agenda' (unless you’re talking about a meeting). 12 | deploy: > 13 | Avoid using 'deploy', unless you’re talking about the military or 14 | software. 15 | disincentivize: Avoid using 'disincentivize.' 16 | empower: Avoid using 'empower.' 17 | focusing: Avoid using 'focusing.' 18 | foster: Avoid using 'foster' (unless it’s children). 19 | impact(?:ful)?: Avoid using impact or impactful. 20 | incentivize: Avoid using 'incentivize.' 21 | innovative: Use words that describe the positive outcome of the innovation. 22 | key: Avoid using 'key' (unless it unlocks something). 23 | leverage: > 24 | Avoid using 'leverage' (unless you're using it in the financial sense). 25 | progress: What are you actually doing? 26 | promote: Avoid using 'promote' (unless you’re talking about an ad campaign). 27 | streamline: Avoid using 'streamline.' 28 | strengthening: > 29 | Avoid using 'strengthening' (unless you’re referring to bridges or other 30 | structures). 31 | tackling: > 32 | Avoid using 'tackling' (unless you’re referring to football or another 33 | contact sport). 34 | touchpoint: Mention specific system components. 35 | transforming: What are you actually doing to change it? 36 | -------------------------------------------------------------------------------- /.vale/styles/18F/Contractions.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: 'Use "%s" instead of "%s".' 4 | link: https://content-guide.18f.gov/voice-and-tone/#use-contractions 5 | level: error 6 | ignorecase: true 7 | swap: 8 | are not: aren't 9 | cannot: can't 10 | could not: couldn't 11 | did not: didn't 12 | do not: don't 13 | does not: doesn't 14 | has not: hasn't 15 | have not: haven't 16 | how is: how's 17 | how will: how'll 18 | is not: isn't 19 | it is: it's 20 | it will: it'll 21 | should not: shouldn't 22 | that is: that's 23 | that will: that'll 24 | they are: they're 25 | they will: they'll 26 | was not: wasn't 27 | we are: we're 28 | we have: we've 29 | we will: we'll 30 | were not: weren't 31 | what is: what's 32 | what will: what'll 33 | when is: when's 34 | when will: when'll 35 | where is: where's 36 | where will: where'll 37 | who is: who's 38 | who will: who'll 39 | why is: why's 40 | why will: why'll 41 | will not: won't 42 | -------------------------------------------------------------------------------- /.vale/styles/18F/DropDown.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Use %s instead of '%s'." 4 | link: https://content-guide.18f.gov/specific-words-and-phrases/ 5 | level: error 6 | # 'drop-down' when used as an adjective. For example, drop-down menu. 7 | # 'drop down' when used as a noun. For example, an option from the drop down. 8 | # Never dropdown. 9 | pos: 'drop/\w+ down/RP|dropdown/\w+|drop-down/NN' 10 | ignorecase: true 11 | swap: 12 | drop down: "'drop-down'" 13 | drop-down: "'drop down'" 14 | dropdown: "'drop-down' or 'drop down'" 15 | -------------------------------------------------------------------------------- /.vale/styles/18F/Headings.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: capitalization 3 | message: "'%s' should be in sentence case." 4 | link: https://content-guide.18f.gov/capitalization/#headings 5 | level: warning 6 | scope: heading 7 | match: $sentence 8 | -------------------------------------------------------------------------------- /.vale/styles/18F/Quotes.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: existence 3 | message: Punctuation should be inside the quotes. 4 | link: https://content-guide.18f.gov/punctuation/#quotes 5 | level: error 6 | nonword: true 7 | tokens: 8 | - '"[^"]+"[.,?]' 9 | -------------------------------------------------------------------------------- /.vale/styles/18F/Reading.yml: -------------------------------------------------------------------------------- 1 | --- 2 | message: Craft sentences at 25 words or fewer, whenever possible. 3 | link: 'https://content-guide.18f.gov/be-concise/#keep-sentences-short-and-sweet' 4 | extends: occurrence 5 | scope: sentence 6 | level: warning 7 | max: 25 8 | token: '\b(\w+)\b' 9 | -------------------------------------------------------------------------------- /.vale/styles/18F/SentenceLength.yml: -------------------------------------------------------------------------------- 1 | --- 2 | message: Craft sentences at 25 words or fewer, whenever possible. 3 | link: 'https://content-guide.18f.gov/be-concise/#keep-sentences-short-and-sweet' 4 | extends: occurrence 5 | scope: sentence 6 | level: warning 7 | max: 25 8 | token: '\b(\w+)\b' 9 | -------------------------------------------------------------------------------- /.vale/styles/18F/Spacing.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: existence 3 | message: "'%s' should have one space." 4 | link: https://content-guide.18f.gov/punctuation/#spaces 5 | level: error 6 | nonword: true 7 | tokens: 8 | - '[.?!] {2,}[A-Z]' 9 | - '[.?!][A-Z]' 10 | -------------------------------------------------------------------------------- /.vale/styles/18F/Terms.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Prefer %s over '%s'." 4 | level: warning 5 | ignorecase: false 6 | swap: 7 | 'e(?:-| )mail': email 8 | (?:illegals|illegal aliens): "'undocumented immigrants'" 9 | back-end: "'back end'" 10 | citizen: "'people' or 'The public'" 11 | combating: "'working against' or 'fighting'" 12 | countering: "'answering' or 'responding'" 13 | dialogue: "'speaking(ing)'" 14 | dropdown: "'drop-down' or 'drop down'" 15 | execute: "'run' or 'do'" 16 | open-source: "'open source'" 17 | simple: "'straightforward', 'uncomplicated', or 'clear'" 18 | user testing: "'usability testing'" 19 | -------------------------------------------------------------------------------- /.vale/styles/18F/ToDo.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Use '%s' instead of '%s'." 4 | link: https://content-guide.18f.gov/specific-words-and-phrases/ 5 | level: error 6 | # 'to do' (noun) and 'to-do' (adjective). For example, your to dos or your 7 | # to-do list. 8 | pos: 'to/TO do/VB' 9 | ignorecase: true 10 | swap: 11 | to do: to-do 12 | -------------------------------------------------------------------------------- /.vale/styles/18F/UnexpandedAcronyms.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: conditional 3 | message: "'%s' should be parenthetically defined." 4 | link: https://content-guide.18f.gov/abbreviations-and-acronyms/ 5 | level: warning 6 | first: '\b([A-Z]{3,5})\b' 7 | second: '(?:\b[A-Z][a-z]+['']? )+\(([A-Z]{3,5})\)' 8 | exceptions: 9 | - ABC 10 | - ADD 11 | - ADHD 12 | - AIDS 13 | - APA 14 | - API 15 | - CBS 16 | - CIA 17 | - CSI 18 | - CSS 19 | - CST 20 | - ESPN 21 | - EST 22 | - FAQ 23 | - FBI 24 | - FBI 25 | - FIXME 26 | - GNU 27 | - GOV 28 | - HIV 29 | - HR 30 | - HTML 31 | - HTTP 32 | - HTTPS 33 | - JSON 34 | - LAN 35 | - MIT 36 | - MLA 37 | - MLB 38 | - MTV 39 | - NAACP 40 | - NAACP 41 | - NASA 42 | - NASA 43 | - NATO 44 | - NBA 45 | - NBC 46 | - NCAA 47 | - NCAAB 48 | - NCAAF 49 | - NFL 50 | - NHL 51 | - NOTE 52 | - PDF 53 | - PGA 54 | - PPV 55 | - PST 56 | - SGML 57 | - SSN 58 | - TNT 59 | - TODO 60 | - URL 61 | - USA 62 | - USSR 63 | - XML 64 | - XXX 65 | -------------------------------------------------------------------------------- /.vale/styles/documentation/Abbreviations.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Use '%s' instead of '%s'." 4 | link: https://content-guide.18f.gov/specific-words-and-phrases/ 5 | ignorecase: false 6 | level: error 7 | nonword: true 8 | swap: 9 | '\beg\b': e.g., 10 | '\bie\b': i.e., 11 | 'e\.g\.(?:[^,]|$)': e.g., 12 | 'i\.e\.(?:[^,]|$)': i.e., 13 | '(?i)\d{1,2} ?[ap]m': a.m. or p.m. 14 | 'D\.C\.': DC 15 | '\bUSA?\b': U.S. 16 | -------------------------------------------------------------------------------- /.vale/styles/documentation/Ages.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: existence 3 | message: Avoid hyphens in ages unless it clarifies the text. 4 | link: https://content-guide.18f.gov/specific-words-and-phrases/ 5 | level: warning 6 | tokens: 7 | - '\d{1,3}-year-old' 8 | -------------------------------------------------------------------------------- /.vale/styles/documentation/CaseSensitiveTerms.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Prefer %s over '%s'." 4 | level: warning 5 | ignorecase: false 6 | swap: 7 | devops: "'DevOps'" 8 | github: "'GitHub'" 9 | javascript: "'JavaScript'" 10 | readme: "'README'" 11 | -------------------------------------------------------------------------------- /.vale/styles/documentation/Clarity.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: '%s' 4 | link: https://content-guide.18f.gov/plain-language/#words-to-avoid 5 | level: warning 6 | ignorecase: true 7 | swap: 8 | pledge: > 9 | Be more specific than 'pledge' — we’re either doing something or we’re not. 10 | advancing: Avoid using 'advancing.' 11 | agenda: Avoid using 'agenda' (unless you’re talking about a meeting). 12 | disincentivize: Avoid using 'disincentivize.' 13 | empower: Avoid using 'empower.' 14 | focusing: Avoid using 'focusing.' 15 | foster: Avoid using 'foster' (unless it’s children). 16 | impactful: Avoid using impactful. 17 | incentivize: Avoid using 'incentivize.' 18 | innovative: Use words that describe the positive outcome of the innovation. 19 | leverage: > 20 | Avoid using 'leverage' (unless you're using it in the financial sense). 21 | promote: Avoid using 'promote' (unless you’re talking about an ad campaign). 22 | streamline: Avoid using 'streamline.' 23 | strengthening: > 24 | Avoid using 'strengthening' (unless you’re referring to bridges or other 25 | structures). 26 | tackling: > 27 | Avoid using 'tackling' (unless you’re referring to football or another 28 | contact sport). 29 | touchpoint: Mention specific system components. 30 | transforming: What are you actually doing to change it? 31 | -------------------------------------------------------------------------------- /.vale/styles/documentation/DropDown.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Use %s instead of '%s'." 4 | link: https://content-guide.18f.gov/specific-words-and-phrases/ 5 | level: error 6 | # 'drop-down' when used as an adjective. For example, drop-down menu. 7 | # 'drop down' when used as a noun. For example, an option from the drop down. 8 | # Never dropdown. 9 | pos: 'drop/\w+ down/RP|dropdown/\w+|drop-down/NN' 10 | ignorecase: true 11 | swap: 12 | drop down: "'drop-down'" 13 | drop-down: "'drop down'" 14 | dropdown: "'drop-down' or 'drop down'" 15 | -------------------------------------------------------------------------------- /.vale/styles/documentation/LineLength.yml: -------------------------------------------------------------------------------- 1 | --- 2 | message: A line should be at most 80 characters long. 3 | link: https://tengolang.com/ 4 | extends: script 5 | scope: raw 6 | level: warning 7 | script: | 8 | text := import("text") 9 | matches := [] 10 | // Remove all instances of code blocks since we don't want to count 11 | // inter-block newlines as a new paragraph. 12 | document := text.re_replace("(?s) *(\n```.*?```\n)", scope, "") 13 | for line in text.split(document, "\n") { 14 | // Skip links 15 | if text.re_match("\\[(.*?)\\]: ", line) { 16 | continue 17 | } 18 | if len(line) > 80 { 19 | start := text.index(scope, line) 20 | matches = append(matches, {begin: start, end: start + len(line)}) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.vale/styles/documentation/SentenceLength.yml: -------------------------------------------------------------------------------- 1 | --- 2 | message: Craft sentences at 25 words or fewer, whenever possible. 3 | link: 'https://content-guide.18f.gov/be-concise/#keep-sentences-short-and-sweet' 4 | extends: occurrence 5 | scope: sentence 6 | level: warning 7 | max: 25 8 | token: '\b(\w+)\b' 9 | -------------------------------------------------------------------------------- /.vale/styles/documentation/Spacing.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: existence 3 | message: "'%s' should have one space." 4 | link: https://content-guide.18f.gov/punctuation/#spaces 5 | level: error 6 | nonword: true 7 | tokens: 8 | - '[.?!] {2,}[A-Z]' 9 | - '[.?!][A-Z]' 10 | -------------------------------------------------------------------------------- /.vale/styles/documentation/Terms.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Prefer %s over '%s'." 4 | level: warning 5 | ignorecase: true 6 | swap: 7 | 'e(?:-| )mail': email 8 | back-end: "'back end'" 9 | combating: "'working against' or 'fighting'" 10 | countering: "'answering' or 'responding'" 11 | dropdown: "'drop-down' or 'drop down'" 12 | open-source: "'open source'" 13 | simple: "'straightforward', 'uncomplicated', or 'clear'" 14 | user testing: "'usability testing'" 15 | -------------------------------------------------------------------------------- /.vale/styles/documentation/ToDo.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: substitution 3 | message: "Use '%s' instead of '%s'." 4 | link: https://content-guide.18f.gov/specific-words-and-phrases/ 5 | level: suggestion 6 | # 'to do' (noun) and 'to-do' (adjective). For example, your to dos or your 7 | # to-do list. 8 | pos: 'to/TO do/VB' 9 | ignorecase: true 10 | swap: 11 | to do: to-do 12 | -------------------------------------------------------------------------------- /.vale/styles/documentation/UnexpandedAcronyms.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: conditional 3 | message: "'%s' should be parenthetically defined." 4 | link: https://content-guide.18f.gov/abbreviations-and-acronyms/ 5 | level: warning 6 | first: '\b([A-Z]{3,5})\b' 7 | second: '(?:\b[A-Z][a-z]+['']? )+\(([A-Z]{3,5})\)' 8 | exceptions: 9 | - ABI 10 | - ACM 11 | - ADC 12 | - ADR 13 | - AKA 14 | - APA 15 | - API 16 | - ARM 17 | - ASAP 18 | - AVR 19 | - CAN 20 | - CBS 21 | - CEO 22 | - CLI 23 | - CMSIS 24 | - CPU 25 | - CRC 26 | - CSS 27 | - CST 28 | - DMA 29 | - DRAM 30 | - DSP 31 | - EST 32 | - ETL 33 | - FAQ 34 | - FIFO 35 | - FILE 36 | - FIXME 37 | - GCC 38 | - GNU 39 | - GOV 40 | - GPIO 41 | - GPS 42 | - HAL 43 | - HTML 44 | - HTTP 45 | - HTTPS 46 | - IDE 47 | - IEEE 48 | - IRQ 49 | - I2C 50 | - JSON 51 | - LAN 52 | - LFS 53 | - LED 54 | - LLC 55 | - MCAPI 56 | - MIPI 57 | - MIPS 58 | - MIT 59 | - MLA 60 | - MLB 61 | - MMU 62 | - MRAPI 63 | - MSVC 64 | - MTAPI 65 | - NASA 66 | - NFC 67 | - NOT 68 | - NOTE 69 | - 'NULL' 70 | - OLED 71 | - OTA 72 | - PCB 73 | - PDF 74 | - PGA 75 | - PIC 76 | - POSIX 77 | - PPV 78 | - PST 79 | - RAII 80 | - RAM 81 | - RFID 82 | - ROM 83 | - RPC 84 | - RTTI 85 | - RTOS 86 | - SDK 87 | - SFR 88 | - SGML 89 | - SPI 90 | - SRAM 91 | - SSN 92 | - STL 93 | - TBD 94 | - TDD 95 | - TODO 96 | - UART 97 | - USART 98 | - UML 99 | - URL 100 | - USA 101 | - USB 102 | - WIP 103 | - XMI 104 | - XML 105 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Airlinese.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is airlinese." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - enplan(?:e|ed|ing|ement) 7 | - deplan(?:e|ed|ing|ement) 8 | - taking off momentarily 9 | -------------------------------------------------------------------------------- /.vale/styles/proselint/AnimalLabels.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: "Consider using '%s' instead of '%s'." 3 | level: error 4 | action: 5 | name: replace 6 | swap: 7 | (?:bull|ox)-like: taurine 8 | (?:calf|veal)-like: vituline 9 | (?:crow|raven)-like: corvine 10 | (?:leopard|panther)-like: pardine 11 | bird-like: avine 12 | centipede-like: scolopendrine 13 | crab-like: cancrine 14 | crocodile-like: crocodiline 15 | deer-like: damine 16 | eagle-like: aquiline 17 | earthworm-like: lumbricine 18 | falcon-like: falconine 19 | ferine: wild animal-like 20 | fish-like: piscine 21 | fox-like: vulpine 22 | frog-like: ranine 23 | goat-like: hircine 24 | goose-like: anserine 25 | gull-like: laridine 26 | hare-like: leporine 27 | hawk-like: accipitrine 28 | hippopotamus-like: hippopotamine 29 | lizard-like: lacertine 30 | mongoose-like: viverrine 31 | mouse-like: murine 32 | ostrich-like: struthionine 33 | peacock-like: pavonine 34 | porcupine-like: hystricine 35 | rattlesnake-like: crotaline 36 | sable-like: zibeline 37 | sheep-like: ovine 38 | shrew-like: soricine 39 | sparrow-like: passerine 40 | swallow-like: hirundine 41 | swine-like: suilline 42 | tiger-like: tigrine 43 | viper-like: viperine 44 | vulture-like: vulturine 45 | wasp-like: vespine 46 | wolf-like: lupine 47 | woodpecker-like: picine 48 | zebra-like: zebrine 49 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Annotations.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' left in text." 3 | ignorecase: false 4 | level: error 5 | tokens: 6 | - XXX 7 | - FIXME 8 | - TODO 9 | - NOTE 10 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Apologizing.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "Excessive apologizing: '%s'" 3 | ignorecase: true 4 | level: error 5 | action: 6 | name: remove 7 | tokens: 8 | - More research is needed 9 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Archaisms.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is archaic." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - alack 7 | - anent 8 | - begat 9 | - belike 10 | - betimes 11 | - boughten 12 | - brocage 13 | - brokage 14 | - camarade 15 | - chiefer 16 | - chiefest 17 | - Christiana 18 | - completely obsolescent 19 | - cozen 20 | - divers 21 | - deflexion 22 | - fain 23 | - forsooth 24 | - foreclose from 25 | - haply 26 | - howbeit 27 | - illumine 28 | - in sooth 29 | - maugre 30 | - meseems 31 | - methinks 32 | - nigh 33 | - peradventure 34 | - perchance 35 | - saith 36 | - shew 37 | - sistren 38 | - spake 39 | - to wit 40 | - verily 41 | - whilom 42 | - withal 43 | - wot 44 | - enclosed please find 45 | - please find enclosed 46 | - enclosed herewith 47 | - enclosed herein 48 | - inforce 49 | - ex postfacto 50 | - foreclose from 51 | - forewent 52 | - for ever 53 | -------------------------------------------------------------------------------- /.vale/styles/proselint/But.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "Do not start a paragraph with a 'but'." 3 | level: error 4 | scope: paragraph 5 | action: 6 | name: remove 7 | tokens: 8 | - ^But 9 | -------------------------------------------------------------------------------- /.vale/styles/proselint/CorporateSpeak.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is corporate speak." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - at the end of the day 7 | - back to the drawing board 8 | - hit the ground running 9 | - get the ball rolling 10 | - low-hanging fruit 11 | - thrown under the bus 12 | - think outside the box 13 | - let's touch base 14 | - get my manager's blessing 15 | - it's on my radar 16 | - ping me 17 | - i don't have the bandwidth 18 | - no brainer 19 | - par for the course 20 | - bang for your buck 21 | - synergy 22 | - move the goal post 23 | - apples to apples 24 | - win-win 25 | - circle back around 26 | - all hands on deck 27 | - take this offline 28 | - drill-down 29 | - elephant in the room 30 | - on my plate 31 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Currency.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "Incorrect use of symbols in '%s'." 3 | ignorecase: true 4 | raw: 5 | - \$[\d]* ?(?:dollars|usd|us dollars) 6 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Cursing.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "Consider replacing '%s'." 3 | level: error 4 | ignorecase: true 5 | tokens: 6 | - shit 7 | - piss 8 | - fuck 9 | - cunt 10 | - cocksucker 11 | - motherfucker 12 | - tits 13 | - fart 14 | - turd 15 | - twat 16 | -------------------------------------------------------------------------------- /.vale/styles/proselint/DateCase.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: With lowercase letters, the periods are standard. 3 | ignorecase: true 4 | level: error 5 | nonword: true 6 | tokens: 7 | - '\d{1,2} ?[ap]m' 8 | -------------------------------------------------------------------------------- /.vale/styles/proselint/DateMidnight.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "Use 'midnight' or 'noon'." 3 | ignorecase: true 4 | level: error 5 | nonword: true 6 | tokens: 7 | - '12 ?[ap]\.?m\.?' 8 | -------------------------------------------------------------------------------- /.vale/styles/proselint/DateRedundancy.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'a.m.' is always morning; 'p.m.' is always night." 3 | ignorecase: true 4 | level: error 5 | nonword: true 6 | tokens: 7 | - '\d{1,2} ?a\.?m\.? in the morning' 8 | - '\d{1,2} ?p\.?m\.? in the evening' 9 | - '\d{1,2} ?p\.?m\.? at night' 10 | - '\d{1,2} ?p\.?m\.? in the afternoon' 11 | -------------------------------------------------------------------------------- /.vale/styles/proselint/DateSpacing.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "It's standard to put a space before '%s'" 3 | ignorecase: true 4 | level: error 5 | nonword: true 6 | tokens: 7 | - '\d{1,2}[ap]\.?m\.?' 8 | -------------------------------------------------------------------------------- /.vale/styles/proselint/DenizenLabels.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: Did you mean '%s'? 3 | ignorecase: false 4 | action: 5 | name: replace 6 | swap: 7 | (?:Afrikaaner|Afrikander): Afrikaner 8 | (?:Hong Kongite|Hong Kongian): Hong Konger 9 | (?:Indianan|Indianian): Hoosier 10 | (?:Michiganite|Michiganian): Michigander 11 | (?:New Hampshireite|New Hampshireman): New Hampshirite 12 | (?:Newcastlite|Newcastleite): Novocastrian 13 | (?:Providencian|Providencer): Providentian 14 | (?:Trentian|Trentonian): Tridentine 15 | (?:Warsawer|Warsawian): Varsovian 16 | (?:Wolverhamptonite|Wolverhamptonian): Wulfrunian 17 | Alabaman: Alabamian 18 | Albuquerquian: Albuquerquean 19 | Anchoragite: Anchorageite 20 | Arizonian: Arizonan 21 | Arkansawyer: Arkansan 22 | Belarusan: Belarusian 23 | Cayman Islander: Caymanian 24 | Coloradoan: Coloradan 25 | Connecticuter: Nutmegger 26 | Fairbanksian: Fairbanksan 27 | Fort Worther: Fort Worthian 28 | Grenadian: Grenadan 29 | Halifaxer: Haligonian 30 | Hartlepoolian: Hartlepudlian 31 | Illinoisian: Illinoisan 32 | Iowegian: Iowan 33 | Leedsian: Leodenisian 34 | Liverpoolian: Liverpudlian 35 | Los Angelean: Angeleno 36 | Manchesterian: Mancunian 37 | Minneapolisian: Minneapolitan 38 | Missouran: Missourian 39 | Monacan: Monegasque 40 | Neopolitan: Neapolitan 41 | New Jerseyite: New Jerseyan 42 | New Orleansian: New Orleanian 43 | Oklahoma Citian: Oklahoma Cityan 44 | Oklahomian: Oklahoman 45 | Saudi Arabian: Saudi 46 | Seattlite: Seattleite 47 | Surinamer: Surinamese 48 | Tallahassean: Tallahasseean 49 | Tennesseean: Tennessean 50 | Trois-Rivièrester: Trifluvian 51 | Utahan: Utahn 52 | Valladolidian: Vallisoletano 53 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Diacritical.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: Consider using '%s' instead of '%s'. 3 | ignorecase: true 4 | level: error 5 | action: 6 | name: replace 7 | swap: 8 | beau ideal: beau idéal 9 | boutonniere: boutonnière 10 | bric-a-brac: bric-à-brac 11 | cafe: café 12 | cause celebre: cause célèbre 13 | chevre: chèvre 14 | cliche: cliché 15 | consomme: consommé 16 | coup de grace: coup de grâce 17 | crudites: crudités 18 | creme brulee: crème brûlée 19 | creme de menthe: crème de menthe 20 | creme fraice: crème fraîche 21 | creme fresh: crème fraîche 22 | crepe: crêpe 23 | debutante: débutante 24 | decor: décor 25 | deja vu: déjà vu 26 | denouement: dénouement 27 | facade: façade 28 | fiance: fiancé 29 | fiancee: fiancée 30 | flambe: flambé 31 | garcon: garçon 32 | lycee: lycée 33 | maitre d: maître d 34 | menage a trois: ménage à trois 35 | negligee: négligée 36 | protege: protégé 37 | protegee: protégée 38 | puree: purée 39 | my resume: my résumé 40 | your resume: your résumé 41 | his resume: his résumé 42 | her resume: her résumé 43 | a resume: a résumé 44 | the resume: the résumé 45 | risque: risqué 46 | roue: roué 47 | soiree: soirée 48 | souffle: soufflé 49 | soupcon: soupçon 50 | touche: touché 51 | tete-a-tete: tête-à-tête 52 | voila: voilà 53 | a la carte: à la carte 54 | a la mode: à la mode 55 | emigre: émigré 56 | 57 | # Spanish loanwords 58 | El Nino: El Niño 59 | jalapeno: jalapeño 60 | La Nina: La Niña 61 | pina colada: piña colada 62 | senor: señor 63 | senora: señora 64 | senorita: señorita 65 | 66 | # Portuguese loanwords 67 | acai: açaí 68 | 69 | # German loanwords 70 | doppelganger: doppelgänger 71 | Fuhrer: Führer 72 | Gewurztraminer: Gewürztraminer 73 | vis-a-vis: vis-à-vis 74 | Ubermensch: Übermensch 75 | 76 | # Swedish loanwords 77 | filmjolk: filmjölk 78 | smorgasbord: smörgåsbord 79 | 80 | # Names, places, and companies 81 | Beyonce: Beyoncé 82 | Bronte: Brontë 83 | Bronte: Brontë 84 | Champs-Elysees: Champs-Élysées 85 | Citroen: Citroën 86 | Curacao: Curaçao 87 | Lowenbrau: Löwenbräu 88 | Monegasque: Monégasque 89 | Motley Crue: Mötley Crüe 90 | Nescafe: Nescafé 91 | Queensryche: Queensrÿche 92 | Quebec: Québec 93 | Quebecois: Québécois 94 | Angstrom: Ångström 95 | angstrom: ångström 96 | Skoda: Škoda 97 | -------------------------------------------------------------------------------- /.vale/styles/proselint/GenderBias.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: Consider using '%s' instead of '%s'. 3 | ignorecase: true 4 | level: error 5 | action: 6 | name: replace 7 | swap: 8 | (?:alumnae|alumni): graduates 9 | (?:alumna|alumnus): graduate 10 | air(?:m[ae]n|wom[ae]n): pilot(s) 11 | anchor(?:m[ae]n|wom[ae]n): anchor(s) 12 | authoress: author 13 | camera(?:m[ae]n|wom[ae]n): camera operator(s) 14 | chair(?:m[ae]n|wom[ae]n): chair(s) 15 | congress(?:m[ae]n|wom[ae]n): member(s) of congress 16 | door(?:m[ae]|wom[ae]n): concierge(s) 17 | draft(?:m[ae]n|wom[ae]n): drafter(s) 18 | fire(?:m[ae]n|wom[ae]n): firefighter(s) 19 | fisher(?:m[ae]n|wom[ae]n): fisher(s) 20 | fresh(?:m[ae]n|wom[ae]n): first-year student(s) 21 | garbage(?:m[ae]n|wom[ae]n): waste collector(s) 22 | lady lawyer: lawyer 23 | ladylike: courteous 24 | landlord: building manager 25 | mail(?:m[ae]n|wom[ae]n): mail carriers 26 | man and wife: husband and wife 27 | man enough: strong enough 28 | mankind: human kind 29 | manmade: manufactured 30 | men and girls: men and women 31 | middle(?:m[ae]n|wom[ae]n): intermediary 32 | news(?:m[ae]n|wom[ae]n): journalist(s) 33 | ombuds(?:man|woman): ombuds 34 | oneupmanship: upstaging 35 | poetess: poet 36 | police(?:m[ae]n|wom[ae]n): police officer(s) 37 | repair(?:m[ae]n|wom[ae]n): technician(s) 38 | sales(?:m[ae]n|wom[ae]n): salesperson or sales people 39 | service(?:m[ae]n|wom[ae]n): soldier(s) 40 | steward(?:ess)?: flight attendant 41 | tribes(?:m[ae]n|wom[ae]n): tribe member(s) 42 | waitress: waiter 43 | woman doctor: doctor 44 | woman scientist[s]?: scientist(s) 45 | work(?:m[ae]n|wom[ae]n): worker(s) 46 | -------------------------------------------------------------------------------- /.vale/styles/proselint/GroupTerms.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: Consider using '%s' instead of '%s'. 3 | ignorecase: true 4 | action: 5 | name: replace 6 | swap: 7 | (?:bunch|group|pack|flock) of chickens: brood of chickens 8 | (?:bunch|group|pack|flock) of crows: murder of crows 9 | (?:bunch|group|pack|flock) of hawks: cast of hawks 10 | (?:bunch|group|pack|flock) of parrots: pandemonium of parrots 11 | (?:bunch|group|pack|flock) of peacocks: muster of peacocks 12 | (?:bunch|group|pack|flock) of penguins: muster of penguins 13 | (?:bunch|group|pack|flock) of sparrows: host of sparrows 14 | (?:bunch|group|pack|flock) of turkeys: rafter of turkeys 15 | (?:bunch|group|pack|flock) of woodpeckers: descent of woodpeckers 16 | (?:bunch|group|pack|herd) of apes: shrewdness of apes 17 | (?:bunch|group|pack|herd) of baboons: troop of baboons 18 | (?:bunch|group|pack|herd) of badgers: cete of badgers 19 | (?:bunch|group|pack|herd) of bears: sloth of bears 20 | (?:bunch|group|pack|herd) of bullfinches: bellowing of bullfinches 21 | (?:bunch|group|pack|herd) of bullocks: drove of bullocks 22 | (?:bunch|group|pack|herd) of caterpillars: army of caterpillars 23 | (?:bunch|group|pack|herd) of cats: clowder of cats 24 | (?:bunch|group|pack|herd) of colts: rag of colts 25 | (?:bunch|group|pack|herd) of crocodiles: bask of crocodiles 26 | (?:bunch|group|pack|herd) of dolphins: school of dolphins 27 | (?:bunch|group|pack|herd) of foxes: skulk of foxes 28 | (?:bunch|group|pack|herd) of gorillas: band of gorillas 29 | (?:bunch|group|pack|herd) of hippopotami: bloat of hippopotami 30 | (?:bunch|group|pack|herd) of horses: drove of horses 31 | (?:bunch|group|pack|herd) of jellyfish: fluther of jellyfish 32 | (?:bunch|group|pack|herd) of kangeroos: mob of kangeroos 33 | (?:bunch|group|pack|herd) of monkeys: troop of monkeys 34 | (?:bunch|group|pack|herd) of oxen: yoke of oxen 35 | (?:bunch|group|pack|herd) of rhinoceros: crash of rhinoceros 36 | (?:bunch|group|pack|herd) of wild boar: sounder of wild boar 37 | (?:bunch|group|pack|herd) of wild pigs: drift of wild pigs 38 | (?:bunch|group|pack|herd) of zebras: zeal of wild pigs 39 | (?:bunch|group|pack|school) of trout: hover of trout 40 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Hedging.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is hedging." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - I would argue that 7 | - ', so to speak' 8 | - to a certain degree 9 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Hyperbole.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is hyperbolic." 3 | level: error 4 | nonword: true 5 | tokens: 6 | - '[a-z]+[!?]{2,}' 7 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Jargon.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is jargon." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - in the affirmative 7 | - in the negative 8 | - agendize 9 | - per your order 10 | - per your request 11 | - disincentivize 12 | -------------------------------------------------------------------------------- /.vale/styles/proselint/LGBTOffensive.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is offensive. Remove it or consider the context." 3 | ignorecase: true 4 | tokens: 5 | - fag 6 | - faggot 7 | - dyke 8 | - sodomite 9 | - homosexual agenda 10 | - gay agenda 11 | - transvestite 12 | - homosexual lifestyle 13 | - gay lifestyle 14 | -------------------------------------------------------------------------------- /.vale/styles/proselint/LGBTTerms.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: "Consider using '%s' instead of '%s'." 3 | ignorecase: true 4 | action: 5 | name: replace 6 | swap: 7 | homosexual man: gay man 8 | homosexual men: gay men 9 | homosexual woman: lesbian 10 | homosexual women: lesbians 11 | homosexual people: gay people 12 | homosexual couple: gay couple 13 | sexual preference: sexual orientation 14 | (?:admitted homosexual|avowed homosexual): openly gay 15 | special rights: equal rights 16 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Malapropisms.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is a malapropism." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - the infinitesimal universe 7 | - a serial experience 8 | - attack my voracity 9 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Needless.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: Prefer '%s' over '%s' 3 | ignorecase: true 4 | action: 5 | name: replace 6 | swap: 7 | '(?:cell phone|cell-phone)': cellphone 8 | '(?:cliquey|cliquy)': cliquish 9 | '(?:pygmean|pygmaen)': pygmy 10 | '(?:retributional|retributionary)': retributive 11 | '(?:revokable|revokeable)': revocable 12 | abolishment: abolition 13 | accessary: accessory 14 | accreditate: accredit 15 | accruement: accrual 16 | accusee: accused 17 | acquaintanceship: acquaintance 18 | acquitment: acquittal 19 | administrate: administer 20 | administrated: administered 21 | administrating: administering 22 | adulterate: adulterous 23 | advisatory: advisory 24 | advocator: advocate 25 | aggrievance: grievance 26 | allegator: alleger 27 | allusory: allusive 28 | amative: amorous 29 | amortizement: amortization 30 | amphiboly: amphibology 31 | anecdotalist: anecdotist 32 | anilinctus: anilingus 33 | anticipative: anticipatory 34 | antithetic: antithetical 35 | applicative: applicable 36 | applicatory: applicable 37 | applier: applicator 38 | approbative: approbatory 39 | arbitrager: arbitrageur 40 | arsenous: arsenious 41 | ascendance: ascendancy 42 | ascendence: ascendancy 43 | ascendency: ascendancy 44 | auctorial: authorial 45 | averral: averment 46 | barbwire: barbed wire 47 | benefic: beneficent 48 | benignant: benign 49 | bestowment: bestowal 50 | betrothment: betrothal 51 | blamableness: blameworthiness 52 | butt naked: buck naked 53 | camarade: comrade 54 | carta blanca: carte blanche 55 | casualities: casualties 56 | casuality: casualty 57 | catch on fire: catch fire 58 | catholicly: catholically 59 | cease fire: ceasefire 60 | channelize: channel 61 | chaplainship: chaplaincy 62 | chrysalid: chrysalis 63 | chrysalids: chrysalises 64 | cigaret: cigarette 65 | coemployee: coworker 66 | cognitional: cognitive 67 | cohabitate: cohabit 68 | cohabitor: cohabitant 69 | collodium: collodion 70 | collusory: collusive 71 | commemoratory: commemorative 72 | commonty: commonage 73 | communicatory: communicative 74 | compensative: compensatory 75 | complacence: complacency 76 | complicitous: complicit 77 | computate: compute 78 | conciliative: conciliatory 79 | concomitancy: concomitance 80 | condonance: condonation 81 | confirmative: confirmatory 82 | congruency: congruence 83 | connotate: connote 84 | consanguineal: consanguine 85 | conspicuity: conspicuousness 86 | conspiratorialist: conspirator 87 | constitutionist: constitutionalist 88 | contingence: contigency 89 | contributary: contributory 90 | contumacity: contumacy 91 | conversible: convertible 92 | conveyal: conveyance 93 | copartner: partner 94 | copartnership: partnership 95 | corroboratory: corroborative 96 | cotemporaneous: contemporaneous 97 | cotemporary: contemporary 98 | criminate: incriminate 99 | culpatory: inculpatory 100 | cumbrance: encumbrance 101 | cumulate: accumulate 102 | curatory: curative 103 | daredeviltry: daredevilry 104 | deceptious: deceptive 105 | defamative: defamatory 106 | defraudulent: fraudulent 107 | degeneratory: degenerative 108 | delimitate: delimit 109 | delusory: delusive 110 | denouncement: denunciation 111 | depositee: depositary 112 | depreciative: depreciatory 113 | deprival: deprivation 114 | derogative: derogatory 115 | destroyable: destructible 116 | detoxicate: detoxify 117 | detractory: detractive 118 | deviancy: deviance 119 | deviationist: deviant 120 | digamy: deuterogamy 121 | digitalize: digitize 122 | diminishment: diminution 123 | diplomatist: diplomat 124 | disassociate: dissociate 125 | disciplinatory: disciplinary 126 | discriminant: discriminating 127 | disenthrone: dethrone 128 | disintegratory: disintegrative 129 | dismission: dismissal 130 | disorientate: disorient 131 | disorientated: disoriented 132 | disquieten: disquiet 133 | distraite: distrait 134 | divergency: divergence 135 | dividable: divisible 136 | doctrinary: doctrinaire 137 | documental: documentary 138 | domesticize: domesticate 139 | duplicatory: duplicative 140 | duteous: dutiful 141 | educationalist: educationist 142 | educatory: educative 143 | enigmatas: enigmas 144 | enlargen: enlarge 145 | enswathe: swathe 146 | epical: epic 147 | erotism: eroticism 148 | ethician: ethicist 149 | ex officiis: ex officio 150 | exculpative: exculpatory 151 | exigeant: exigent 152 | exigence: exigency 153 | exotism: exoticism 154 | expedience: expediency 155 | expediential: expedient 156 | expediential: expedient 157 | extensible: extendable 158 | eying: eyeing 159 | fiefdom: fief 160 | flagrance: flagrancy 161 | flatulency: flatulence 162 | fraudful: fraudulent 163 | funebrial: funereal 164 | geographical: geographic 165 | geometrical: geometric 166 | gerry-rigged: jury-rigged 167 | goatherder: goatherd 168 | gustatorial: gustatory 169 | habitude: habit 170 | henceforward: henceforth 171 | hesitance: hesitancy 172 | heterogenous: heterogeneous 173 | hierarchic: hierarchical 174 | hindermost: hindmost 175 | honorand: honoree 176 | hypostasize: hypostatize 177 | hysteric: hysterical 178 | idolatrize: idolize 179 | impanel: empanel 180 | imperviable: impervious 181 | importunacy: importunity 182 | impotency: impotence 183 | imprimatura: imprimatur 184 | improprietous: improper 185 | inalterable: unalterable 186 | incitation: incitement 187 | incommunicative: uncommunicative 188 | inconsistence: inconsistency 189 | incontrollable: uncontrollable 190 | incurment: incurrence 191 | indow: endow 192 | indue: endue 193 | inhibitive: inhibitory 194 | innavigable: unnavigable 195 | innovational: innovative 196 | inquisitional: inquisitorial 197 | insistment: insistence 198 | insolvable: unsolvable 199 | instillment: instillation 200 | instinctual: instinctive 201 | insuror: insurer 202 | insurrectional: insurrectionary 203 | interpretate: interpret 204 | intervenience: intervention 205 | ironical: ironic 206 | jerry-rigged: jury-rigged 207 | judgmatic: judgmental 208 | labyrinthian: labyrinthine 209 | laudative: laudatory 210 | legitimatization: legitimation 211 | legitimatize: legitimize 212 | legitimization: legitimation 213 | lengthways: lengthwise 214 | life-sized: life-size 215 | liquorice: licorice 216 | lithesome: lithe 217 | lollipop: lollypop 218 | loth: loath 219 | lubricous: lubricious 220 | maihem: mayhem 221 | medicinal marijuana: medical marijuana 222 | meliorate: ameliorate 223 | minimalize: minimize 224 | mirk: murk 225 | mirky: murky 226 | misdoubt: doubt 227 | monetarize: monetize 228 | moveable: movable 229 | narcism: narcissism 230 | neglective: neglectful 231 | negligency: negligence 232 | neologizer: neologist 233 | neurologic: neurological 234 | nicknack: knickknack 235 | nictate: nictitate 236 | nonenforceable: unenforceable 237 | normalcy: normality 238 | numbedness: numbness 239 | omittable: omissible 240 | onomatopoetic: onomatopoeic 241 | opinioned: opined 242 | optimum advantage: optimal advantage 243 | orientate: orient 244 | outsized: outsize 245 | oversized: oversize 246 | overthrowal: overthrow 247 | pacificist: pacifist 248 | paederast: pederast 249 | parachronism: anachronism 250 | parti-color: parti-colored 251 | participative: participatory 252 | party-colored: parti-colored 253 | pediatrist: pediatrician 254 | penumbrous: penumbral 255 | perjorative: pejorative 256 | permissory: permissive 257 | permutate: permute 258 | personation: impersonation 259 | pharmaceutic: pharmaceutical 260 | pleuritis: pleurisy 261 | policy holder: policyholder 262 | policyowner: policyholder 263 | politicalize: politicize 264 | precedency: precedence 265 | preceptoral: preceptorial 266 | precipitance: precipitancy 267 | precipitant: precipitate 268 | preclusory: preclusive 269 | precolumbian: pre-Columbian 270 | prefectoral: prefectorial 271 | preponderately: preponderantly 272 | preserval: preservation 273 | preventative: preventive 274 | proconsulship: proconsulate 275 | procreational: procreative 276 | procurance: procurement 277 | propelment: propulsion 278 | propulsory: propulsive 279 | prosecutive: prosecutory 280 | protectory: protective 281 | provocatory: provocative 282 | pruriency: prurience 283 | psychal: psychical 284 | punitory: punitive 285 | quantitate: quantify 286 | questionary: questionnaire 287 | quiescency: quiescence 288 | rabbin: rabbi 289 | reasonability: reasonableness 290 | recidivistic: recidivous 291 | recriminative: recriminatory 292 | recruital: recruitment 293 | recurrency: recurrence 294 | recusance: recusancy 295 | recusation: recusal 296 | recusement: recusal 297 | redemptory: redemptive 298 | referrable: referable 299 | referrible: referable 300 | refutatory: refutative 301 | remitment: remittance 302 | remittal: remission 303 | renouncement: renunciation 304 | renunciable: renounceable 305 | reparatory: reparative 306 | repudiative: repudiatory 307 | requitement: requital 308 | rescindment: rescission 309 | restoral: restoration 310 | reticency: reticence 311 | reviewal: review 312 | revisal: revision 313 | revisional: revisionary 314 | revolute: revolt 315 | saliency: salience 316 | salutiferous: salutary 317 | sensatory: sensory 318 | sessionary: sessional 319 | shareowner: shareholder 320 | sicklily: sickly 321 | signator: signatory 322 | slanderize: slander 323 | societary: societal 324 | sodomist: sodomite 325 | solicitate: solicit 326 | speculatory: speculative 327 | spiritous: spirituous 328 | statutorial: statutory 329 | submergeable: submersible 330 | submittal: submission 331 | subtile: subtle 332 | succuba: succubus 333 | sufficience: sufficiency 334 | suppliant: supplicant 335 | surmisal: surmise 336 | suspendible: suspendable 337 | synthetize: synthesize 338 | systemize: systematize 339 | tactual: tactile 340 | tangental: tangential 341 | tautologous: tautological 342 | tee-shirt: T-shirt 343 | thenceforward: thenceforth 344 | transiency: transience 345 | transposal: transposition 346 | transposal: transposition 347 | unfrequent: infrequent 348 | unreasonability: unreasonableness 349 | unrevokable: irrevocable 350 | unsubstantial: insubstantial 351 | usurpature: usurpation 352 | variative: variational 353 | vegetive: vegetative 354 | vindicative: vindictive 355 | vituperous: vituperative 356 | vociferant: vociferous 357 | volitive: volitional 358 | wolverene: wolverine 359 | wolvish: wolfish 360 | Zoroastrism: Zoroastrianism 361 | 362 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Nonwords.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: "Consider using '%s' instead of '%s'." 3 | ignorecase: true 4 | level: error 5 | action: 6 | name: replace 7 | swap: 8 | affrontery: effrontery 9 | analyzation: analysis 10 | annoyment: annoyance 11 | confirmant: confirmand 12 | confirmants: confirmands 13 | conversate: converse 14 | crained: cranded 15 | discomforture: discomfort|discomfiture 16 | dispersement: disbursement|dispersal 17 | doubtlessly: doubtless|undoubtedly 18 | forebearance: forbearance 19 | improprietous: improper 20 | inclimate: inclement 21 | inimicable: inimical 22 | irregardless: regardless 23 | minimalize: minimize 24 | minimalized: minimized 25 | minimalizes: minimizes 26 | minimalizing: minimizing 27 | optimalize: optimize 28 | paralyzation: paralysis 29 | pettifogger: pettifog 30 | proprietous: proper 31 | relative inexpense: relatively low price|affordability 32 | seldomly: seldom 33 | thusly: thus 34 | uncategorically: categorically 35 | undoubtably: undoubtedly|indubitably 36 | unequivocable: unequivocal 37 | unmercilessly: mercilessly 38 | unrelentlessly: unrelentingly|relentlessly 39 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Oxymorons.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is an oxymoron." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - amateur expert 7 | - increasingly less 8 | - advancing backwards 9 | - alludes explicitly to 10 | - explicitly alludes to 11 | - totally obsolescent 12 | - completely obsolescent 13 | - generally always 14 | - usually always 15 | - increasingly less 16 | - build down 17 | - conspicuous absence 18 | - exact estimate 19 | - found missing 20 | - intense apathy 21 | - mandatory choice 22 | - organized mess 23 | -------------------------------------------------------------------------------- /.vale/styles/proselint/P-Value.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "You should use more decimal places, unless '%s' is really true." 3 | ignorecase: true 4 | level: suggestion 5 | tokens: 6 | - 'p = 0\.0{2,4}' 7 | -------------------------------------------------------------------------------- /.vale/styles/proselint/RASSyndrome.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is redundant." 3 | level: error 4 | action: 5 | name: edit 6 | params: 7 | - split 8 | - ' ' 9 | - '0' 10 | tokens: 11 | - ABM missile 12 | - ACT test 13 | - ABM missiles 14 | - ABS braking system 15 | - ATM machine 16 | - CD disc 17 | - CPI Index 18 | - GPS system 19 | - GUI interface 20 | - HIV virus 21 | - ISBN number 22 | - LCD display 23 | - PDF format 24 | - PIN number 25 | - RAS syndrome 26 | - RIP in peace 27 | - please RSVP 28 | - SALT talks 29 | - SAT test 30 | - UPC codes 31 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Skunked.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is a bit of a skunked term — impossible to use without issue." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - bona fides 7 | - deceptively 8 | - decimate 9 | - effete 10 | - fulsome 11 | - hopefully 12 | - impassionate 13 | - Thankfully 14 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Spelling.yml: -------------------------------------------------------------------------------- 1 | extends: consistency 2 | message: "Inconsistent spelling of '%s'." 3 | level: error 4 | ignorecase: true 5 | either: 6 | advisor: adviser 7 | centre: center 8 | colour: color 9 | emphasise: emphasize 10 | finalise: finalize 11 | focussed: focused 12 | labour: labor 13 | learnt: learned 14 | organise: organize 15 | organised: organized 16 | organising: organizing 17 | recognise: recognize 18 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Typography.yml: -------------------------------------------------------------------------------- 1 | extends: substitution 2 | message: Consider using the '%s' symbol instead of '%s'. 3 | level: error 4 | nonword: true 5 | swap: 6 | '\.\.\.': … 7 | '\([cC]\)': © 8 | '\(TM\)': ™ 9 | '\(tm\)': ™ 10 | '\([rR]\)': ® 11 | '[0-9]+ ?x ?[0-9]+': × 12 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Uncomparables.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "'%s' is not comparable" 3 | ignorecase: true 4 | level: error 5 | action: 6 | name: edit 7 | params: 8 | - split 9 | - ' ' 10 | - '1' 11 | raw: 12 | - \b(?:absolutely|most|more|less|least|very|quite|largely|extremely|increasingly|kind of|mildy|hardly|greatly|sort of)\b\s* 13 | tokens: 14 | - absolute 15 | - adequate 16 | - complete 17 | - correct 18 | - certain 19 | - devoid 20 | - entire 21 | - 'false' 22 | - fatal 23 | - favorite 24 | - final 25 | - ideal 26 | - impossible 27 | - inevitable 28 | - infinite 29 | - irrevocable 30 | - main 31 | - manifest 32 | - only 33 | - paramount 34 | - perfect 35 | - perpetual 36 | - possible 37 | - preferable 38 | - principal 39 | - singular 40 | - stationary 41 | - sufficient 42 | - 'true' 43 | - unanimous 44 | - unavoidable 45 | - unbroken 46 | - uniform 47 | - unique 48 | - universal 49 | - void 50 | - whole 51 | -------------------------------------------------------------------------------- /.vale/styles/proselint/Very.yml: -------------------------------------------------------------------------------- 1 | extends: existence 2 | message: "Remove '%s'." 3 | ignorecase: true 4 | level: error 5 | tokens: 6 | - very 7 | -------------------------------------------------------------------------------- /.vale/styles/proselint/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "jdkato", 3 | "description": "A Vale-compatible implementation of the proselint linter.", 4 | "email": "support@errata.ai", 5 | "lang": "en", 6 | "url": "https://github.com/errata-ai/proselint/releases/latest/download/proselint.zip", 7 | "feed": "https://github.com/errata-ai/proselint/releases.atom", 8 | "issues": "https://github.com/errata-ai/proselint/issues/new", 9 | "license": "BSD-3-Clause", 10 | "name": "proselint", 11 | "sources": [ 12 | "https://github.com/amperser/proselint" 13 | ], 14 | "vale_version": ">=1.0.0", 15 | "coverage": 0.0, 16 | "version": "0.1.0" 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[svelte]": { 12 | "editor.defaultFormatter": "svelte.svelte-vscode" 13 | }, 14 | "svelte.enable-ts-plugin": true, 15 | "eslint.validate": ["javascript", "javascriptreact", "svelte"] 16 | } 17 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | rules: 4 | truthy: 5 | check-keys: false 6 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gorillamoe 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community includes using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting an individual maintainer on: 59 | 60 | ### GitHub 61 | 62 | - @gorillamoe 63 | 64 | > (at `GitHub username` + `@github.com`). 65 | 66 | ### Discord 67 | 68 | - gorillamoe 69 | 70 | All complaints will be reviewed and investigated and will result in a response that 71 | is deemed necessary and appropriate to the circumstances. The project team is 72 | obligated to maintain confidentiality with regard to the reporter of an incident. 73 | Further details of specific enforcement policies may be posted separately. 74 | 75 | Project maintainers who do not follow or enforce the Code of Conduct in good 76 | faith may face temporary or permanent repercussions as determined by other 77 | members of the project's leadership. 78 | 79 | ## Attribution 80 | 81 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 82 | available at [https://contributor-covenant.org/version/1/4][version] 83 | 84 | [homepage]: https://contributor-covenant.org 85 | [version]: https://contributor-covenant.org/version/1/4/ 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Bananas Screen Sharing 2 | 3 | Thanks for checking out Bananas Screen Sharing! 4 | We're excited to hear and learn from you. 5 | 6 | We've put together the following guidelines to 7 | help you figure out where you can best be helpful. 8 | 9 | ## Table of Contents 10 | 11 | 0. [Types of contributions we're looking for](#types-of-contributions-were-looking-for) 12 | 1. [Ground rules & expectations](#ground-rules--expectations) 13 | 2. [How to contribute](#how-to-contribute) 14 | 3. [Style guide](#style-guide) 15 | 4. [Documentation](#documentation) 16 | 5. [Code](#code) 17 | 6. [Setting up your environment](#setting-up-your-environment) 18 | 7. [Community](#community) 19 | 20 | ## Types of contributions we're looking for 21 | 22 | There are many ways you can directly contribute to Bananas Screen Sharing: 23 | 24 | - Feature requests 25 | - Bug reports 26 | - Code contributions 27 | - Writing or editing documentation 28 | 29 | ## Ground rules & expectations 30 | 31 | Before we get started, 32 | here are a few things we expect from you (and that you should expect from others): 33 | 34 | - Be kind and thoughtful in your conversations around this project. 35 | We all come from different backgrounds and projects, 36 | which means we likely have different perspectives on "how open source is done." 37 | Try to listen to others rather than convince them that your way is correct. 38 | - Bananas is released with a [Contributor Code of Conduct](./CODE_OF_CONDUCT.md). 39 | By participating in this project, you agree to abide by its terms. 40 | - Please ensure that your contribution passes all tests if you open a pull request. 41 | If there are test failures, you will need to address them before we can merge your contribution. 42 | - When adding content, please consider if it is widely valuable. 43 | Please don't add references or links to things you or your employer have created, 44 | as others will do so if they appreciate it. 45 | 46 | ## How to contribute 47 | 48 | If you'd like to contribute, 49 | start by searching through the [pull requests](https://github.com/mistweaverco/bananas/pulls) to 50 | see whether someone else has raised a similar idea or question. 51 | 52 | If you don't see your idea listed, and you think it fits into the goals of this guide, open a pull request. 53 | 54 | ## Style guide 55 | 56 | ### Documentation 57 | 58 | If you're writing documentation, 59 | see the [style guide](.vale/styles) (which uses [vale](https://vale.sh)) to 60 | help your prose match the rest of the documentation. 61 | 62 | ### Code 63 | 64 | When writing code, 65 | please follow these configurations: 66 | 67 | - [eslint](./eslintrc.cjs) 68 | - [EditorConfig](./.editorconfig) 69 | - [yaml-lint](./.yamllint.yaml) 70 | 71 | Most of them are automatically checked by the CI, 72 | so you don't need to worry about them. 73 | 74 | ## Community 75 | 76 | Discussions about the Bananas take place on: 77 | 78 | - This repository's [Issues](https://github.com/mistweaverco/bananas/issues) and 79 | [Pull Requests](https://github.com/mistweaverco/bananas/pulls) sections 80 | - The [Bananas Discord server](https://discord.gg/BeN43eJVWS) 81 | 82 | Anybody is welcome to join these conversations. 83 | 84 | Wherever possible, 85 | do not take these conversations to private channels, 86 | including contacting the maintainers directly. 87 | 88 | Keeping communication public means everybody can benefit and learn from the conversation. 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024+ mistweaverco 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 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | Effective Date: 2024-12-03 4 | 5 | Your privacy is important to us. 6 | This Privacy Policy outlines how we handle and protect your information when you use 7 | Bananas Screen Sharing (the "App"). 8 | 9 | By using the App, you agree to the terms of this Privacy Policy. 10 | 11 | ### 1. Data Collection 12 | 13 | We do not collect, store, or process any personal or usage data from users of the App. 14 | The App functions solely to establish a peer-to-peer connection between users 15 | No personal information, identifiers, or activity data are transmitted to us or any third-party; 16 | excluding stun and turn servers for negotiation of connection details like IP addresses and ports. 17 | 18 | ### 2. Data Usage 19 | 20 | The App interacts with stun and turn servers to establish a peer-to-peer connection between users. 21 | 22 | ### 3. Third-Party Services 23 | 24 | The App uses the official stun server provided by Google 25 | if not provided by the user. 26 | 27 | This is required for the negotiation of connection details like IP addresses and ports, 28 | to establish a peer-to-peer connection between users. 29 | 30 | ### 4. Security 31 | 32 | Although we do not collect any data, 33 | we prioritize security in the development and maintenance of the App to 34 | ensure that your use of it remains safe. 35 | 36 | The App only communicates with the stun or turn servers 37 | that you can configure yourself, 38 | for the purpose of establishing a peer-to-peer connection and 39 | does not expose your data to any other services or entities. 40 | 41 | ### 5. User Consent 42 | 43 | By using the App, 44 | you consent to the interaction between the configured stun or turn servers and the App, 45 | as described in this policy. 46 | 47 | You understand that the App does not collect or store any personal data. 48 | 49 | ### 6. Changes to the Privacy Policy 50 | 51 | We reserve the right to modify this Privacy Policy at any time. 52 | Any changes will be posted on this page with an updated "Effective Date." 53 | 54 | Your continued use of the App after changes to this Privacy Policy indicates 55 | your acceptance of the revised terms. 56 | 57 | ### 7. Contact Us 58 | 59 | If you have any questions or concerns about this Privacy Policy or the App, 60 | please contact us via filing an issue on the 61 | [GitHub repository](https://github.com/mistweaverco/bananas/issues/new). 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <div align="center"> 2 | 3 |  4 | 5 | # Bananas Screen Sharing 6 | 7 | [](https://getbananas.net/) 8 | [](https://github.com/mistweaverco/bananas/releases/latest) 9 | 10 | [Install](#install) • [Website](https://getbananas.net/) • [Tutorial](https://getbananas.net/tutorial) • [Privacy Policy](./PRIVACY.md) • [Terms of Service](./TOS.md) • [Code of Conduct](./CODE_OF_CONDUCT.md) 11 | 12 | <p></p> 13 | 14 | Bananas Screen Sharing is a simple and 15 | easy-to-use screen sharing tool for Mac, Windows, and Linux. 16 | 17 | It utilizes a peer-to-peer connection to share your screen with others, 18 | without the need for an account or any server infrastructure 19 | (except for the stun, turn and signaling servers that are needed for exchanging the initial connection information) 20 | 21 | <p></p> 22 | 23 | </div> 24 | 25 | ## Install 26 | 27 | Grab the latest release from the 28 | [GitHub releases page](https://github.com/mistweaverco/bananas/releases/latest). 29 | 30 | Or if you are on Mac you can install it via homebrew with 31 | 32 | ```shell 33 | brew install --cask bananas 34 | ``` 35 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Versions currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 0.0.x | :white_check_mark: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | Security vulnerabilites ahould be communicated with 14 | the maintainers in private. 15 | 16 | ### GitHub 17 | 18 | - @gorillamoe 19 | 20 | > (at `GitHub username` + `@github.com`). 21 | 22 | ### Discord 23 | 24 | - gorillamoe 25 | -------------------------------------------------------------------------------- /TOS.md: -------------------------------------------------------------------------------- 1 | # Terms of Service 2 | 3 | Terms of Service for Bananas Screen Sharing 4 | Effective Date: 2024-12-03 5 | 6 | These Terms of Service ("Terms") govern your use of the Bananas Screen Sharing (the "App"). 7 | 8 | By using the App, you agree to these Terms. 9 | If you do not agree to these Terms, you may not use the App. 10 | 11 | ### 1. Acceptance of Terms 12 | 13 | By installing, accessing, or using the App, you agree to comply with these Terms. 14 | If you do not agree to these Terms, 15 | you must not use the App. 16 | 17 | We reserve the right to modify these Terms at any time, 18 | and your continued use of the App after any such modifications indicates your acceptance of the new terms. 19 | 20 | ### 2. License 21 | 22 | See [LICENSE](LICENSE) for the license under which the App is provided. 23 | 24 | ### 4. User Conduct 25 | 26 | When using the App, you agree to: 27 | 28 | Use the App solely for its intended purpose. 29 | Not attempt to interfere with the App's functionality or access any other users' information. 30 | 31 | ### 5. Disclaimer of Warranties 32 | 33 | The App is provided on an "as-is" and "as-available" basis, 34 | without any warranties of any kind. We do not guarantee that the App will be error-free, 35 | secure, or uninterrupted. You use the App at your own risk. 36 | 37 | ### 6. Limitation of Liability 38 | 39 | To the maximum extent permitted by law, 40 | we shall not be liable for any damages arising from the use or inability to use the App, 41 | including but not limited to direct, indirect, incidental, or consequential damages. 42 | This includes, without limitation, any loss of data, 43 | interruption of service, or 44 | issues resulting from third-party interactions (e.g., stun or turn servers). 45 | 46 | ### 7. Termination 47 | 48 | We reserve the right to terminate your access to the App at any time for any reason, 49 | including but not limited to your violation of these Terms. 50 | Upon termination, the rights and licenses granted to you under these Terms will immediately cease. 51 | 52 | ### 8. Third-Party Services 53 | 54 | The App may use third-party services, such as stun or turn servers, 55 | to facilitate its functionality. 56 | 57 | ### 9. Governing Law 58 | 59 | These Terms shall be governed and construed in accordance with the laws of Germany. 60 | 61 | Any legal actions or disputes arising in connection with these Terms 62 | will be resolved in the courts of Germany. 63 | 64 | ### 10. Changes to These Terms 65 | 66 | We reserve the right to modify these Terms at any time. 67 | Any changes will be posted on this page with an updated "Effective Date." 68 | Your continued use of the App after changes to 69 | the Terms indicates your acceptance of the modified terms. 70 | 71 | ### 11. Contact Us 72 | 73 | If you have any questions or concerns about these Terms, 74 | please contact us via filing an issue on the 75 | [GitHub repository](https://github.com/mistweaverco/bananas/issues/new). 76 | -------------------------------------------------------------------------------- /assets/open-graph.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/assets/open-graph.xcf -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | <plist version="1.0"> 4 | <dict> 5 | <key>com.apple.security.cs.allow-jit</key> 6 | <true/> 7 | <key>com.apple.security.cs.allow-unsigned-executable-memory</key> 8 | <true/> 9 | <key>com.apple.security.cs.allow-dyld-environment-variables</key> 10 | <true/> 11 | <key>com.apple.security.device.audio-input</key> 12 | <true/> 13 | </dict> 14 | </plist> 15 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon.ico -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon.png -------------------------------------------------------------------------------- /build/icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon_1024.png -------------------------------------------------------------------------------- /build/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon_128.png -------------------------------------------------------------------------------- /build/icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon_256.png -------------------------------------------------------------------------------- /build/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon_32.png -------------------------------------------------------------------------------- /build/icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon_512.png -------------------------------------------------------------------------------- /build/icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/build/icon_64.png -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/bun.lockb -------------------------------------------------------------------------------- /dev-app-update.yml: -------------------------------------------------------------------------------- 1 | --- 2 | provider: generic 3 | url: https://auto-updates.getbananas.net 4 | updaterCacheDirName: bananas-updater 5 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | --- 2 | appId: net.getbananas.desktop 3 | productName: bananas 4 | directories: 5 | buildResources: build 6 | files: 7 | - '!**/.vscode/*' 8 | - '!src/*' 9 | - '!electron.vite.config.{js,ts,mjs,cjs}' 10 | - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' 11 | - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' 12 | - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' 13 | asarUnpack: 14 | - resources/** 15 | afterSign: scripts/notarize.js 16 | win: 17 | executableName: bananas 18 | icon: build/icon.ico 19 | nsis: 20 | artifactName: ${name}-setup_${arch}.${ext} 21 | shortcutName: ${productName} 22 | uninstallDisplayName: ${productName} 23 | createDesktopShortcut: always 24 | installerIcon: build/icon.ico 25 | oneClick: false 26 | perMachine: false 27 | allowToChangeInstallationDirectory: true 28 | mac: 29 | entitlementsInherit: build/entitlements.mac.plist 30 | extendInfo: 31 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 32 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 33 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 34 | hardenedRuntime: true 35 | notarize: false 36 | icon: build/icon.icns 37 | target: 38 | - target: dmg 39 | arch: 40 | - universal 41 | dmg: 42 | artifactName: ${name}_${arch}.${ext} 43 | icon: build/icon.icns 44 | linux: 45 | target: 46 | - AppImage 47 | - snap 48 | - flatpak 49 | - deb 50 | maintainer: getbananas.net 51 | category: Utility 52 | icon: build/ 53 | appImage: 54 | artifactName: ${name}_${arch}.${ext} 55 | snap: 56 | artifactName: ${name}_${arch}.${ext} 57 | flatpak: 58 | artifactName: ${name}_${arch}.${ext} 59 | deb: 60 | artifactName: ${name}_${arch}.${ext} 61 | npmRebuild: false 62 | publish: 63 | provider: generic 64 | url: https://auto-updates.getbananas.net/ 65 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 2 | import { svelte } from '@sveltejs/vite-plugin-svelte' 3 | 4 | export default defineConfig({ 5 | main: { 6 | build: { 7 | rollupOptions: { 8 | input: { 9 | index: 'src/main/index.ts' 10 | } 11 | } 12 | }, 13 | plugins: [externalizeDepsPlugin()] 14 | }, 15 | preload: { 16 | build: { 17 | rollupOptions: { 18 | input: { 19 | index: 'src/preload/index.ts', 20 | cursors: 'src/preload/cursors.ts' 21 | } 22 | } 23 | }, 24 | plugins: [externalizeDepsPlugin()] 25 | }, 26 | renderer: { 27 | build: { 28 | rollupOptions: { 29 | input: { 30 | index: 'src/renderer/index.html', 31 | cursors: 'src/renderer/cursors.html' 32 | } 33 | } 34 | }, 35 | plugins: [svelte()] 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bananas", 3 | "version": "0.0.22", 4 | "description": "Bananas Screen Sharing is a simple and easy-to-use screen sharing tool for Mac, Windows, and Linux.", 5 | "main": "./out/main/index.js", 6 | "author": { 7 | "name": "Marco Kellershoff", 8 | "email": "marco@kellershoff.net" 9 | }, 10 | "license": "MIT", 11 | "homepage": "https://getbananas.net", 12 | "scripts": { 13 | "format": "prettier --plugin prettier-plugin-svelte --write .", 14 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", 15 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 16 | "svelte-check": "svelte-check --tsconfig ./tsconfig.json", 17 | "typecheck": "bun run typecheck:node && bun run svelte-check", 18 | "start": "electron-vite preview", 19 | "dev": "electron-vite dev", 20 | "build": "bun run typecheck && electron-vite build", 21 | "postinstall": "electron-builder install-app-deps", 22 | "build:unpack": "bun run build && electron-builder --dir", 23 | "build:windows": "bun run build && electron-builder --win --publish never", 24 | "build:macos": "./scripts/build.sh PLATFORM=macos", 25 | "build:linux": "./scripts/build.sh PLATFORM=linux", 26 | "build:linux-arm64": "./scripts/build.sh PLATFORM=linux-arm64", 27 | "build:linux-debug": "./scripts/build.sh PLATFORM=linux-debug", 28 | "node:packages:update": "ncu -u", 29 | "node:packages:upgrade": "ncu -u && bun install", 30 | "translations": "typesafe-i18n --no-watch", 31 | "translations:watch": "typesafe-i18n" 32 | }, 33 | "dependencies": { 34 | "@electron-toolkit/preload": "3.0.1", 35 | "@electron-toolkit/utils": "3.0.0", 36 | "@sweetalert2/theme-bulma": "5.0.18", 37 | "electron-settings": "4.0.4", 38 | "electron-updater": "6.3.9", 39 | "sweetalert2": "11.14.5", 40 | "typesafe-i18n": "5.26.2" 41 | }, 42 | "devDependencies": { 43 | "@electron-toolkit/eslint-config-prettier": "^2.0.0", 44 | "@electron-toolkit/eslint-config-ts": "^2.0.0", 45 | "@electron-toolkit/tsconfig": "^1.0.1", 46 | "@fortawesome/fontawesome-free": "6.6.0", 47 | "@mistweaverco/electron-notarize-async": "0.0.4", 48 | "@sveltejs/adapter-node": "5.2.9", 49 | "@sveltejs/vite-plugin-svelte": "3.1.2", 50 | "@types/bun": "^1.1.14", 51 | "bulma": "1.0.2", 52 | "dotenv": "16.4.5", 53 | "electron": "^31.0.2", 54 | "electron-builder": "^24.13.3", 55 | "electron-log": "5.2.3", 56 | "electron-vite": "^2.3.0", 57 | "eslint": "^8.57.0", 58 | "eslint-plugin-svelte": "^2.41.0", 59 | "kit-monorepo": "github:sveltejs/kit", 60 | "prettier": "^3.3.2", 61 | "prettier-plugin-svelte": "^3.2.5", 62 | "svelte": "4.2.19", 63 | "svelte-awesome-color-picker": "3.1.4", 64 | "svelte-check": "3.8.6", 65 | "tslib": "^2.6.3", 66 | "typescript": "^5.5.2", 67 | "vite": "^5.3.1" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistweaverco/bananas/4b03d3f0475dc2a9936da78580fcab500a8014cf/resources/icon.png -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$VERSION" ]; then echo "Error: VERSION is not set"; exit 1; fi 4 | if [ -z "$PLATFORM" ]; then echo "Error: PLATFORM is not set"; exit 1; fi 5 | 6 | update_package_json_version() { 7 | local tmp 8 | tmp=$(mktemp) 9 | jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json 10 | } 11 | 12 | update_package_json_version 13 | 14 | build_windows() { 15 | bun run build && ./node_modules/.bin/electron-builder --win --publish never 16 | } 17 | 18 | build_linux() { 19 | bun run build && ./node_modules/.bin/electron-builder --linux --publish never 20 | } 21 | 22 | build_linux_arm64() { 23 | # NOTE: 24 | # One might imagine we could use `electron-builder --arm64` here, but 25 | # it doesn't work as expected. 26 | # There is an issue when building snap packages. 27 | # Instead, we build each target individually. 28 | bun run build && ./node_modules/.bin/electron-builder --linux deb --publish never --arm64 && \ 29 | bun run build && ./node_modules/.bin/electron-builder --linux flatpak --publish never --arm64 && \ 30 | bun run build && ./node_modules/.bin/electron-builder --linux appimage --publish never --arm64 31 | } 32 | 33 | build_linux_debug() { 34 | bun run build && ./node_modules/.bin/electron-builder --linux deb --publish never 35 | } 36 | 37 | build_macos() { 38 | bun run build && ./node_modules/.bin/electron-builder --mac --publish never 39 | } 40 | 41 | case $PLATFORM in 42 | "linux") 43 | build_linux 44 | ;; 45 | "linux-arm64") 46 | build_linux_arm64 47 | ;; 48 | "linux-debug") 49 | build_linux_debug 50 | ;; 51 | "macos") 52 | build_macos 53 | ;; 54 | "windows") 55 | build_windows 56 | ;; 57 | *) 58 | echo "Error: PLATFORM $PLATFORM is not supported" 59 | exit 1 60 | ;; 61 | esac 62 | -------------------------------------------------------------------------------- /scripts/notarize.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const { notarize } = require('@mistweaverco/electron-notarize-async') 3 | 4 | exports.default = async function notarizing(context) { 5 | const { electronPlatformName, appOutDir } = context 6 | if (electronPlatformName !== 'darwin') { 7 | return 8 | } 9 | 10 | const appName = context.packager.appInfo.productFilename 11 | 12 | return await notarize({ 13 | tool: 'notarytool', 14 | teamId: process.env.APPLE_TEAM_ID, 15 | appPath: `${appOutDir}/${appName}.app`, 16 | appleId: process.env.APPLE_ID, 17 | appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, 18 | webhook: process.env.APPLE_NOTARIZATION_WEBHOOK_URL 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$VERSION" ]; then echo "Error: VERSION is not set"; exit 1; fi 4 | if [ -z "$PLATFORM" ]; then echo "Error: PLATFORM is not set"; exit 1; fi 5 | 6 | BIN_NAME="bananas" 7 | RELEASE_ACTION="create" 8 | GH_TAG="v$VERSION" 9 | FILES=() 10 | 11 | LINUX_FILES=( 12 | "dist/${BIN_NAME}_amd64.deb" 13 | "dist/${BIN_NAME}_amd64.snap" 14 | "dist/${BIN_NAME}_x86_64.AppImage" 15 | "dist/${BIN_NAME}_x86_64.flatpak" 16 | ) 17 | 18 | LINUX_ARM64_FILES=( 19 | "dist/${BIN_NAME}_arm64.deb" 20 | "dist/${BIN_NAME}_arm64.AppImage" 21 | "dist/${BIN_NAME}_aarch64.flatpak" 22 | ) 23 | 24 | WINDOWS_FILES=( 25 | "dist/${BIN_NAME}-setup_x64.exe" 26 | ) 27 | 28 | MACOS_FILES=( 29 | "dist/${BIN_NAME}_universal.dmg" 30 | ) 31 | 32 | set_release_action() { 33 | if gh release view "$GH_TAG" --json id --jq .id > /dev/null 2>&1; then 34 | echo "Release $GH_TAG already exists, updating it" 35 | RELEASE_ACTION="edit" 36 | else 37 | echo "Release $GH_TAG does not exist, creating it" 38 | RELEASE_ACTION="create" 39 | fi 40 | } 41 | 42 | check_files_exist() { 43 | files=() 44 | for file in "${FILES[@]}"; do 45 | if [ ! -f "$file" ]; then 46 | files+=("$file") 47 | fi 48 | done 49 | if [ ${#files[@]} -gt 0 ]; then 50 | echo "Error: the following files do not exist:" 51 | for file in "${files[@]}"; do 52 | printf " - %s\n" "$file" 53 | done 54 | echo "This is the content of the dist directory:" 55 | ls -l dist/ 56 | exit 1 57 | fi 58 | } 59 | 60 | set_files_based_on_platform() { 61 | case $PLATFORM in 62 | linux) 63 | FILES=("${LINUX_FILES[@]}") 64 | ;; 65 | linux-arm64) 66 | FILES=("${LINUX_ARM64_FILES[@]}") 67 | ;; 68 | windows) 69 | FILES=("${WINDOWS_FILES[@]}") 70 | ;; 71 | macos) 72 | FILES=("${MACOS_FILES[@]}") 73 | ;; 74 | *) 75 | echo "Error: PLATFORM $PLATFORM is not supported" 76 | exit 1 77 | ;; 78 | esac 79 | } 80 | 81 | print_files() { 82 | echo "Files to upload:" 83 | for file in "${FILES[@]}"; do 84 | printf " - %s\n" "$file" 85 | done 86 | } 87 | 88 | do_gh_release() { 89 | if [ "$RELEASE_ACTION" == "edit" ]; then 90 | if [ -z "$REPLACE" ]; then 91 | echo "Trying to upload files to existing release $GH_TAG" 92 | print_files 93 | gh release upload "$GH_TAG" "${FILES[@]}" 94 | else 95 | echo "Overwriting existing release $GH_TAG" 96 | print_files 97 | gh release upload --clobber "$GH_TAG" "${FILES[@]}" 98 | fi 99 | else 100 | echo "Creating new release $GH_TAG" 101 | print_files 102 | gh release create --generate-notes "$GH_TAG" "${FILES[@]}" 103 | fi 104 | } 105 | 106 | release() { 107 | set_release_action 108 | set_files_based_on_platform 109 | check_files_exist 110 | do_gh_release 111 | } 112 | 113 | release 114 | -------------------------------------------------------------------------------- /scripts/set-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | if [ -z "$VERSION" ]; then echo "Error: VERSION is not set"; exit 1; fi 4 | 5 | update_package_json_version() { 6 | local tmp 7 | tmp=$(mktemp) 8 | jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json 9 | } 10 | 11 | update_package_json_version 12 | -------------------------------------------------------------------------------- /src/i18n/de/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseTranslation } from '../i18n-types' 2 | 3 | const en = { 4 | about: 'Über', 5 | advanced: 'Erweitert', 6 | basic: 'Grundlegend', 7 | cancel: 'Abbrechen', 8 | choose_a_color: 'Wähle eine Farbe', 9 | code_of_conduct: 'Verhaltenskodex', 10 | color: 'Farbe', 11 | connect: 'Verbinden', 12 | connection_established: 'Verbindung hergestellt', 13 | copy_my_connection_string: 'Kopiere meine Verbindungszeichenfolge', 14 | disconnect: 'Trennen', 15 | fullscreen: 'Vollbild', 16 | host_a_session: 'Eine Sitzung hosten', 17 | host_connection_string: 'Host-Verbindungszeichenfolge', 18 | hosting_a_session: 'Eine Sitzung hosten', 19 | is_microphone_active_on_connect: 'Ist das Mikrofon bei der Verbindung aktiviert?', 20 | join_a_session: 'Einer Sitzung beitreten', 21 | joined_a_session: 'Einer Sitzung beigetreten', 22 | language: 'Sprache', 23 | language_description: 24 | 'Wähle deine bevorzugte Sprache (nach dem Ändern der Sprache musst du die App neu starten)', 25 | media: 'Media', 26 | microphone_active: 'Mikrofon aktiv', 27 | microphone_inactive: 'Mikrofon inaktiv', 28 | not_streaming_your_display: 'Dein Bildschirm wird nicht gestreamt', 29 | participant_connection_string: 'Teilnehmer-Verbindungszeichenfolge', 30 | privacty_policy: 'Privatsphärenrichtlinie', 31 | remote_cursors_disabled: 'Remote cursors deaktiviert', 32 | remote_cursors_enabled: 'Remote cursors aktiviert', 33 | remote_screen: 'Remote screen', 34 | report_a_bug: 'Einen Fehler melden', 35 | save: 'Speichern', 36 | see_the_code: 'Sieh dir den Code an', 37 | session_started: 'Sitzung gestartet', 38 | settings: 'Einstellungen', 39 | shoulders_of_giants: 'Auf den Schultern von Giganten', 40 | shoulders_of_giants_description: 41 | 'Bananas Screen Sharing baut auf den folgenden Open-Source-Projekten auf (in keiner bestimmten Reihenfolge)', 42 | start_a_new_session: 'Eine neue Sitzung starten', 43 | streaming_your_display: 'Dein Bildschirm wird gestreamt', 44 | stun_turn_server_objects: 'STUN/TURN Objekte (getrennt durch Zeilenumbruch)', 45 | terms_of_service: 'Nutzungsbedingungen', 46 | username: 'Benutzername', 47 | website: 'Website', 48 | zoom_in: 'Hineinzoomen', 49 | zoom_out: 'Herauszoomen' 50 | } satisfies BaseTranslation 51 | 52 | export default en 53 | -------------------------------------------------------------------------------- /src/i18n/en/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseTranslation } from '../i18n-types' 2 | 3 | const en = { 4 | about: 'About', 5 | advanced: 'Advanced', 6 | basic: 'Basic', 7 | cancel: 'Cancel', 8 | choose_a_color: 'Choose a color', 9 | code_of_conduct: 'Code of conduct', 10 | color: 'Color', 11 | connect: 'Connect', 12 | connection_established: 'Connection established', 13 | copy_my_connection_string: 'Copy my connection string', 14 | disconnect: 'Disconnect', 15 | fullscreen: 'Fullscreen', 16 | host_a_session: 'Host a session', 17 | host_connection_string: 'Host connection string', 18 | hosting_a_session: 'Hosting a session', 19 | is_microphone_active_on_connect: 'Is microphone active on connect', 20 | join_a_session: 'Join a session', 21 | joined_a_session: 'Joined a session', 22 | language: 'Language', 23 | language_description: 24 | 'Choose your preferred language (after changing the language, you need to restart the app)', 25 | media: 'Media', 26 | microphone_active: 'Microphone active', 27 | microphone_inactive: 'Microphone inactive', 28 | not_streaming_your_display: 'Not streaming your display', 29 | participant_connection_string: 'Participant connection string', 30 | privacty_policy: 'Privacy policy', 31 | remote_cursors_disabled: 'Remote cursors disabled', 32 | remote_cursors_enabled: 'Remote cursors enabled', 33 | remote_screen: 'Remote screen', 34 | report_a_bug: 'Report a bug', 35 | save: 'Save', 36 | see_the_code: 'See the code', 37 | session_started: 'Session started', 38 | settings: 'Settings', 39 | shoulders_of_giants: 'Shoulders of giants', 40 | shoulders_of_giants_description: 41 | 'Bananas Screen Sharing is built on top of the following open-source projects (in no particular order)', 42 | start_a_new_session: 'Start a new session', 43 | streaming_your_display: 'Streaming your display', 44 | stun_turn_server_objects: 'STUN/TURN Server Objects (separated by new lines)', 45 | terms_of_service: 'Terms of service', 46 | username: 'Username', 47 | website: 'Website', 48 | zoom_in: 'Zoom in', 49 | zoom_out: 'Zoom out' 50 | } satisfies BaseTranslation 51 | 52 | export default en 53 | -------------------------------------------------------------------------------- /src/i18n/formatters.ts: -------------------------------------------------------------------------------- 1 | import type { FormattersInitializer } from 'typesafe-i18n' 2 | import type { Locales, Formatters } from './i18n-types' 3 | 4 | export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => { 5 | const formatters: Formatters = { 6 | // add your formatter functions here 7 | } 8 | 9 | console.log(locale) 10 | 11 | return formatters 12 | } 13 | -------------------------------------------------------------------------------- /src/i18n/fr/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseTranslation } from '../i18n-types' 2 | 3 | const fr = { 4 | about: 'À propos', 5 | advanced: 'Avancé', 6 | basic: 'De base', 7 | cancel: 'Annuler', 8 | choose_a_color: 'Choisir une couleur', 9 | code_of_conduct: 'Code de conduite', 10 | color: 'Couleur', 11 | connect: 'Connecter', 12 | connection_established: 'Connexion établie', 13 | copy_my_connection_string: 'Copier ma chaîne de connexion', 14 | disconnect: 'Déconnecter', 15 | fullscreen: 'Plein écran', 16 | host_a_session: 'Héberger une session', 17 | host_connection_string: 'Chaîne de connexion de l’hôte', 18 | hosting_a_session: 'Hébergement d’une session', 19 | is_microphone_active_on_connect: 'Le microphone est-il actif à la connexion', 20 | join_a_session: 'Rejoindre une session', 21 | joined_a_session: 'Session rejointe', 22 | language: 'Langue', 23 | language_description: 24 | "Choisissez votre langue préférée (après avoir changé la langue, vous devez redémarrer l'application)", 25 | media: 'Médias', 26 | microphone_active: 'Microphone actif', 27 | microphone_inactive: 'Microphone inactif', 28 | not_streaming_your_display: 'Ne diffuse pas votre écran', 29 | participant_connection_string: 'Chaîne de connexion du participant', 30 | privacty_policy: 'Politique de confidentialité', 31 | remote_cursors_disabled: 'Curseurs distants désactivés', 32 | remote_cursors_enabled: 'Curseurs distants activés', 33 | remote_screen: 'Écran distant', 34 | report_a_bug: 'Signaler un bug', 35 | save: 'Enregistrer', 36 | see_the_code: 'Voir le code', 37 | session_started: 'Session commencée', 38 | settings: 'Paramètres', 39 | shoulders_of_giants: 'Épaules des géants', 40 | shoulders_of_giants_description: 41 | 'Bananas Screen Sharing est construit sur les projets open-source suivants (dans un ordre aléatoire)', 42 | start_a_new_session: 'Commencer une nouvelle session', 43 | streaming_your_display: 'Diffusion de votre écran', 44 | stun_turn_server_objects: 'Objets du serveur STUN/TURN (séparés par des nouvelles lignes)', 45 | terms_of_service: 'Conditions de service', 46 | username: 'Nom d’utilisateur', 47 | website: 'Site web', 48 | zoom_in: 'Zoomer', 49 | zoom_out: 'Dézoomer' 50 | } satisfies BaseTranslation 51 | 52 | export default fr 53 | -------------------------------------------------------------------------------- /src/i18n/i18n-svelte.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { initI18nSvelte } from 'typesafe-i18n/svelte' 5 | import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types' 6 | import { loadedFormatters, loadedLocales } from './i18n-util' 7 | 8 | const { locale, LL, setLocale } = initI18nSvelte< 9 | Locales, 10 | Translations, 11 | TranslationFunctions, 12 | Formatters 13 | >(loadedLocales, loadedFormatters) 14 | 15 | export { locale, LL, setLocale } 16 | 17 | export default LL 18 | -------------------------------------------------------------------------------- /src/i18n/i18n-types.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | import type { BaseTranslation as BaseTranslationType, LocalizedString } from 'typesafe-i18n' 4 | 5 | export type BaseTranslation = BaseTranslationType 6 | export type BaseLocale = 'en' 7 | 8 | export type Locales = 'de' | 'en' | 'fr' | 'zh' 9 | 10 | export type Translation = RootTranslation 11 | 12 | export type Translations = RootTranslation 13 | 14 | type RootTranslation = { 15 | /** 16 | * About 17 | */ 18 | about: string 19 | /** 20 | * Advanced 21 | */ 22 | advanced: string 23 | /** 24 | * Basic 25 | */ 26 | basic: string 27 | /** 28 | * Cancel 29 | */ 30 | cancel: string 31 | /** 32 | * Choose a color 33 | */ 34 | choose_a_color: string 35 | /** 36 | * Code of conduct 37 | */ 38 | code_of_conduct: string 39 | /** 40 | * Color 41 | */ 42 | color: string 43 | /** 44 | * Connect 45 | */ 46 | connect: string 47 | /** 48 | * Connection established 49 | */ 50 | connection_established: string 51 | /** 52 | * Copy my connection string 53 | */ 54 | copy_my_connection_string: string 55 | /** 56 | * Disconnect 57 | */ 58 | disconnect: string 59 | /** 60 | * Fullscreen 61 | */ 62 | fullscreen: string 63 | /** 64 | * Host a session 65 | */ 66 | host_a_session: string 67 | /** 68 | * Host connection string 69 | */ 70 | host_connection_string: string 71 | /** 72 | * Hosting a session 73 | */ 74 | hosting_a_session: string 75 | /** 76 | * Is microphone active on connect 77 | */ 78 | is_microphone_active_on_connect: string 79 | /** 80 | * Join a session 81 | */ 82 | join_a_session: string 83 | /** 84 | * Joined a session 85 | */ 86 | joined_a_session: string 87 | /** 88 | * Language 89 | */ 90 | language: string 91 | /** 92 | * Choose your preferred language (after changing the language, you need to restart the app) 93 | */ 94 | language_description: string 95 | /** 96 | * Media 97 | */ 98 | media: string 99 | /** 100 | * Microphone active 101 | */ 102 | microphone_active: string 103 | /** 104 | * Microphone inactive 105 | */ 106 | microphone_inactive: string 107 | /** 108 | * Not streaming your display 109 | */ 110 | not_streaming_your_display: string 111 | /** 112 | * Participant connection string 113 | */ 114 | participant_connection_string: string 115 | /** 116 | * Privacy policy 117 | */ 118 | privacty_policy: string 119 | /** 120 | * Remote cursors disabled 121 | */ 122 | remote_cursors_disabled: string 123 | /** 124 | * Remote cursors enabled 125 | */ 126 | remote_cursors_enabled: string 127 | /** 128 | * Remote screen 129 | */ 130 | remote_screen: string 131 | /** 132 | * Report a bug 133 | */ 134 | report_a_bug: string 135 | /** 136 | * Save 137 | */ 138 | save: string 139 | /** 140 | * See the code 141 | */ 142 | see_the_code: string 143 | /** 144 | * Session started 145 | */ 146 | session_started: string 147 | /** 148 | * Settings 149 | */ 150 | settings: string 151 | /** 152 | * Shoulders of giants 153 | */ 154 | shoulders_of_giants: string 155 | /** 156 | * Bananas Screen Sharing is built on top of the following open-source projects (in no particular order) 157 | */ 158 | shoulders_of_giants_description: string 159 | /** 160 | * Start a new session 161 | */ 162 | start_a_new_session: string 163 | /** 164 | * Streaming your display 165 | */ 166 | streaming_your_display: string 167 | /** 168 | * STUN/TURN Server Objects (separated by new lines) 169 | */ 170 | stun_turn_server_objects: string 171 | /** 172 | * Terms of service 173 | */ 174 | terms_of_service: string 175 | /** 176 | * Username 177 | */ 178 | username: string 179 | /** 180 | * Website 181 | */ 182 | website: string 183 | /** 184 | * Zoom in 185 | */ 186 | zoom_in: string 187 | /** 188 | * Zoom out 189 | */ 190 | zoom_out: string 191 | } 192 | 193 | export type TranslationFunctions = { 194 | /** 195 | * About 196 | */ 197 | about: () => LocalizedString 198 | /** 199 | * Advanced 200 | */ 201 | advanced: () => LocalizedString 202 | /** 203 | * Basic 204 | */ 205 | basic: () => LocalizedString 206 | /** 207 | * Cancel 208 | */ 209 | cancel: () => LocalizedString 210 | /** 211 | * Choose a color 212 | */ 213 | choose_a_color: () => LocalizedString 214 | /** 215 | * Code of conduct 216 | */ 217 | code_of_conduct: () => LocalizedString 218 | /** 219 | * Color 220 | */ 221 | color: () => LocalizedString 222 | /** 223 | * Connect 224 | */ 225 | connect: () => LocalizedString 226 | /** 227 | * Connection established 228 | */ 229 | connection_established: () => LocalizedString 230 | /** 231 | * Copy my connection string 232 | */ 233 | copy_my_connection_string: () => LocalizedString 234 | /** 235 | * Disconnect 236 | */ 237 | disconnect: () => LocalizedString 238 | /** 239 | * Fullscreen 240 | */ 241 | fullscreen: () => LocalizedString 242 | /** 243 | * Host a session 244 | */ 245 | host_a_session: () => LocalizedString 246 | /** 247 | * Host connection string 248 | */ 249 | host_connection_string: () => LocalizedString 250 | /** 251 | * Hosting a session 252 | */ 253 | hosting_a_session: () => LocalizedString 254 | /** 255 | * Is microphone active on connect 256 | */ 257 | is_microphone_active_on_connect: () => LocalizedString 258 | /** 259 | * Join a session 260 | */ 261 | join_a_session: () => LocalizedString 262 | /** 263 | * Joined a session 264 | */ 265 | joined_a_session: () => LocalizedString 266 | /** 267 | * Language 268 | */ 269 | language: () => LocalizedString 270 | /** 271 | * Choose your preferred language (after changing the language, you need to restart the app) 272 | */ 273 | language_description: () => LocalizedString 274 | /** 275 | * Media 276 | */ 277 | media: () => LocalizedString 278 | /** 279 | * Microphone active 280 | */ 281 | microphone_active: () => LocalizedString 282 | /** 283 | * Microphone inactive 284 | */ 285 | microphone_inactive: () => LocalizedString 286 | /** 287 | * Not streaming your display 288 | */ 289 | not_streaming_your_display: () => LocalizedString 290 | /** 291 | * Participant connection string 292 | */ 293 | participant_connection_string: () => LocalizedString 294 | /** 295 | * Privacy policy 296 | */ 297 | privacty_policy: () => LocalizedString 298 | /** 299 | * Remote cursors disabled 300 | */ 301 | remote_cursors_disabled: () => LocalizedString 302 | /** 303 | * Remote cursors enabled 304 | */ 305 | remote_cursors_enabled: () => LocalizedString 306 | /** 307 | * Remote screen 308 | */ 309 | remote_screen: () => LocalizedString 310 | /** 311 | * Report a bug 312 | */ 313 | report_a_bug: () => LocalizedString 314 | /** 315 | * Save 316 | */ 317 | save: () => LocalizedString 318 | /** 319 | * See the code 320 | */ 321 | see_the_code: () => LocalizedString 322 | /** 323 | * Session started 324 | */ 325 | session_started: () => LocalizedString 326 | /** 327 | * Settings 328 | */ 329 | settings: () => LocalizedString 330 | /** 331 | * Shoulders of giants 332 | */ 333 | shoulders_of_giants: () => LocalizedString 334 | /** 335 | * Bananas Screen Sharing is built on top of the following open-source projects (in no particular order) 336 | */ 337 | shoulders_of_giants_description: () => LocalizedString 338 | /** 339 | * Start a new session 340 | */ 341 | start_a_new_session: () => LocalizedString 342 | /** 343 | * Streaming your display 344 | */ 345 | streaming_your_display: () => LocalizedString 346 | /** 347 | * STUN/TURN Server Objects (separated by new lines) 348 | */ 349 | stun_turn_server_objects: () => LocalizedString 350 | /** 351 | * Terms of service 352 | */ 353 | terms_of_service: () => LocalizedString 354 | /** 355 | * Username 356 | */ 357 | username: () => LocalizedString 358 | /** 359 | * Website 360 | */ 361 | website: () => LocalizedString 362 | /** 363 | * Zoom in 364 | */ 365 | zoom_in: () => LocalizedString 366 | /** 367 | * Zoom out 368 | */ 369 | zoom_out: () => LocalizedString 370 | } 371 | 372 | export type Formatters = {} 373 | -------------------------------------------------------------------------------- /src/i18n/i18n-util.async.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { initFormatters } from './formatters' 5 | import type { Locales, Translations } from './i18n-types' 6 | import { loadedFormatters, loadedLocales, locales } from './i18n-util' 7 | 8 | const localeTranslationLoaders = { 9 | de: () => import('./de'), 10 | en: () => import('./en'), 11 | fr: () => import('./fr'), 12 | zh: () => import('./zh') 13 | } 14 | 15 | const updateDictionary = (locale: Locales, dictionary: Partial<Translations>): Translations => 16 | (loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }) 17 | 18 | export const importLocaleAsync = async (locale: Locales): Promise<Translations> => 19 | (await localeTranslationLoaders[locale]()).default as unknown as Translations 20 | 21 | export const loadLocaleAsync = async (locale: Locales): Promise<void> => { 22 | updateDictionary(locale, await importLocaleAsync(locale)) 23 | loadFormatters(locale) 24 | } 25 | 26 | export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync)) 27 | 28 | export const loadFormatters = (locale: Locales): void => 29 | void (loadedFormatters[locale] = initFormatters(locale)) 30 | -------------------------------------------------------------------------------- /src/i18n/i18n-util.sync.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { initFormatters } from './formatters' 5 | import type { Locales, Translations } from './i18n-types' 6 | import { loadedFormatters, loadedLocales, locales } from './i18n-util' 7 | 8 | import de from './de' 9 | import en from './en' 10 | import fr from './fr' 11 | import zh from './zh' 12 | 13 | const localeTranslations = { 14 | de, 15 | en, 16 | fr, 17 | zh 18 | } 19 | 20 | export const loadLocale = (locale: Locales): void => { 21 | if (loadedLocales[locale]) return 22 | 23 | loadedLocales[locale] = localeTranslations[locale] as unknown as Translations 24 | loadFormatters(locale) 25 | } 26 | 27 | export const loadAllLocales = (): void => locales.forEach(loadLocale) 28 | 29 | export const loadFormatters = (locale: Locales): void => 30 | void (loadedFormatters[locale] = initFormatters(locale)) 31 | -------------------------------------------------------------------------------- /src/i18n/i18n-util.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { 5 | i18n as initI18n, 6 | i18nObject as initI18nObject, 7 | i18nString as initI18nString 8 | } from 'typesafe-i18n' 9 | import type { LocaleDetector } from 'typesafe-i18n/detectors' 10 | import type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n' 11 | import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors' 12 | import { initExtendDictionary } from 'typesafe-i18n/utils' 13 | import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types' 14 | 15 | export const baseLocale: Locales = 'en' 16 | 17 | export const locales: Locales[] = ['de', 'en', 'fr', 'zh'] 18 | 19 | export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales) 20 | 21 | export const loadedLocales: Record<Locales, Translations> = {} as Record<Locales, Translations> 22 | 23 | export const loadedFormatters: Record<Locales, Formatters> = {} as Record<Locales, Formatters> 24 | 25 | export const extendDictionary = initExtendDictionary<Translations>() 26 | 27 | export const i18nString = (locale: Locales): TranslateByString => 28 | initI18nString<Locales, Formatters>(locale, loadedFormatters[locale]) 29 | 30 | export const i18nObject = (locale: Locales): TranslationFunctions => 31 | initI18nObject<Locales, Translations, TranslationFunctions, Formatters>( 32 | locale, 33 | loadedLocales[locale], 34 | loadedFormatters[locale] 35 | ) 36 | 37 | export const i18n = (): LocaleTranslationFunctions<Locales, Translations, TranslationFunctions> => 38 | initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters) 39 | 40 | export const detectLocale = (...detectors: LocaleDetector[]): Locales => 41 | detectLocaleFn<Locales>(baseLocale, locales, ...detectors) 42 | -------------------------------------------------------------------------------- /src/i18n/zh/index.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from '../i18n-types' 2 | 3 | const zh = { 4 | about: '关于', 5 | advanced: '高级', 6 | basic: '基本', 7 | cancel: '取消', 8 | choose_a_color: '选择一个颜色', 9 | code_of_conduct: '行为准则', 10 | color: '颜色', 11 | connect: '连接', 12 | connection_established: '连接建立成功', 13 | copy_my_connection_string: '复制我的连接码', 14 | disconnect: '连接失败', 15 | fullscreen: '全屏', 16 | host_a_session: '发起一个屏幕共享', 17 | host_connection_string: '创建此共享的邀请码', 18 | hosting_a_session: '正在屏幕共享', 19 | is_microphone_active_on_connect: '连接时麦克风是否打开', 20 | join_a_session: '加入一个屏幕共享', 21 | joined_a_session: '已加入屏幕共享', 22 | language: '语言', 23 | language_description: '选择您的首选语言(更改语言后,您需要重新启动应用程序)', 24 | media: '媒体', 25 | microphone_active: '打开麦克风', 26 | microphone_inactive: '关闭麦克风', 27 | not_streaming_your_display: '不要流失传输您的显示器', 28 | participant_connection_string: '参与者的连接码', 29 | privacty_policy: '隐私政策', 30 | remote_cursors_disabled: '运程光标已关闭', 31 | remote_cursors_enabled: '远程光标已启用', 32 | remote_screen: '远程屏幕', 33 | report_a_bug: '报告一个错误', 34 | save: '保存', 35 | see_the_code: '查看代码', 36 | session_started: '共享已开始', 37 | settings: '设置', 38 | shoulders_of_giants: '巨人的肩膀', 39 | shoulders_of_giants_description: 40 | 'Bananas Screen Sharing是建立在以下开源项目之上的(没有特定顺序)', 41 | start_a_new_session: '开始一个新共享', 42 | streaming_your_display: '流式传输您的显示器', 43 | stun_turn_server_objects: 'STUN/TURN服务器对象(用新行分隔)', 44 | terms_of_service: '服务条款', 45 | username: '用户名', 46 | website: '网站', 47 | zoom_in: '放大', 48 | zoom_out: '缩小' 49 | } satisfies Translation 50 | 51 | export default zh 52 | -------------------------------------------------------------------------------- /src/main/cursors.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, screen } from 'electron' 2 | import { loadWindowContents } from './utils' 3 | import { join } from 'path' 4 | 5 | export const createCursorsWindow = async (): Promise<BrowserWindow> => { 6 | const dimensions = screen.getPrimaryDisplay().workAreaSize 7 | const win = new BrowserWindow({ 8 | width: dimensions.width, 9 | height: dimensions.height, 10 | x: 0, 11 | y: 0, 12 | show: true, 13 | frame: false, 14 | autoHideMenuBar: true, 15 | transparent: true, 16 | closable: true, 17 | fullscreen: true, 18 | minimizable: false, 19 | webPreferences: { 20 | preload: join(__dirname, '../preload/cursors.js'), 21 | sandbox: false, 22 | contextIsolation: true, 23 | nodeIntegration: true 24 | } 25 | }) 26 | loadWindowContents(win, 'cursors.html') 27 | win.setIgnoreMouseEvents(true) 28 | win.setAlwaysOnTop(true, 'normal', 1) 29 | win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) 30 | return win 31 | } 32 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, shell, BrowserWindow, session, desktopCapturer } from 'electron' 2 | import path from 'path' 3 | import { join } from 'path' 4 | import { electronApp, optimizer, is } from '@electron-toolkit/utils' 5 | import icon from '../../resources/icon.png?asset' 6 | import { windowStateKeeper } from './stateKeeper' 7 | import { ipcMainHandlersInit } from './ipcMainHandlers' 8 | import { isInProductionMode } from './utils' 9 | 10 | const CUSTOM_PROTOCOL = 'bananas' 11 | 12 | let MAIN_WINDOW: BrowserWindow 13 | 14 | if (process.defaultApp) { 15 | if (process.argv.length >= 2) { 16 | app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [ 17 | path.resolve(process.argv[1]) 18 | ]) 19 | } 20 | } else { 21 | app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL) 22 | } 23 | 24 | if (isInProductionMode()) { 25 | const SINGLE_INSTANCE_LOCK = app.requestSingleInstanceLock() 26 | 27 | if (!SINGLE_INSTANCE_LOCK) { 28 | app.quit() 29 | } 30 | } 31 | 32 | const sendOpenBananasUrlToRenderer = (url: string): void => { 33 | MAIN_WINDOW.webContents.send('openBananasURL', url) 34 | } 35 | 36 | app.on('second-instance', (_, commandLine) => { 37 | if (MAIN_WINDOW) { 38 | if (MAIN_WINDOW.isMinimized()) MAIN_WINDOW.restore() 39 | MAIN_WINDOW.focus() 40 | } 41 | const url = commandLine.pop() 42 | if (url) sendOpenBananasUrlToRenderer(url) 43 | }) 44 | 45 | app.on('open-url', (evt, url: string) => { 46 | evt.preventDefault() 47 | sendOpenBananasUrlToRenderer(url) 48 | }) 49 | 50 | async function createWindow(): Promise<void> { 51 | const mainWindowState = await windowStateKeeper('main') 52 | 53 | MAIN_WINDOW = new BrowserWindow({ 54 | width: mainWindowState.width, 55 | height: mainWindowState.height, 56 | minWidth: 400, 57 | minHeight: 200, 58 | x: mainWindowState.x, 59 | y: mainWindowState.y, 60 | show: false, 61 | autoHideMenuBar: true, 62 | ...(process.platform === 'linux' ? { icon } : {}), 63 | webPreferences: { 64 | preload: join(__dirname, '../preload/index.js'), 65 | sandbox: false, 66 | contextIsolation: true, 67 | nodeIntegration: true 68 | } 69 | }) 70 | 71 | mainWindowState.track(MAIN_WINDOW) 72 | 73 | session.defaultSession.setDisplayMediaRequestHandler((_, callback) => { 74 | desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { 75 | callback({ video: sources[0] }) 76 | }) 77 | }) 78 | 79 | MAIN_WINDOW.on('ready-to-show', () => { 80 | MAIN_WINDOW.show() 81 | }) 82 | 83 | MAIN_WINDOW.webContents.setWindowOpenHandler((details) => { 84 | shell.openExternal(details.url) 85 | return { action: 'deny' } 86 | }) 87 | 88 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 89 | MAIN_WINDOW.loadURL(process.env['ELECTRON_RENDERER_URL']) 90 | } else { 91 | MAIN_WINDOW.loadFile(join(__dirname, '../renderer/index.html')) 92 | } 93 | 94 | if (mainWindowState.isMaximized) { 95 | MAIN_WINDOW.maximize() 96 | } 97 | } 98 | 99 | app.whenReady().then(async () => { 100 | electronApp.setAppUserModelId('net.getbananas') 101 | 102 | app.on('browser-window-created', (_, window) => { 103 | optimizer.watchWindowShortcuts(window) 104 | }) 105 | 106 | ipcMainHandlersInit() 107 | 108 | await createWindow() 109 | const coldStartUrl = process.argv.find((arg) => arg.startsWith(CUSTOM_PROTOCOL + '://')) 110 | if (coldStartUrl) { 111 | sendOpenBananasUrlToRenderer(coldStartUrl) 112 | } 113 | 114 | app.on('activate', function () { 115 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 116 | }) 117 | }) 118 | 119 | app.on('window-all-closed', () => { 120 | if (process.platform !== 'darwin') { 121 | app.quit() 122 | } 123 | }) 124 | -------------------------------------------------------------------------------- /src/main/ipcMainHandlers.ts: -------------------------------------------------------------------------------- 1 | import type { SettingsData } from './stateKeeper' 2 | import { app, BrowserWindow, ipcMain, screen } from 'electron' 3 | import { createCursorsWindow } from './cursors' 4 | import { settingsKeeper } from './stateKeeper' 5 | 6 | export const ipcMainHandlersInit = (): void => { 7 | const availableDimensions = screen.getPrimaryDisplay().workAreaSize 8 | let remoteCursorsWindow: BrowserWindow | null = null 9 | let remoteCursorsActive = false 10 | 11 | ipcMain.handle('toggleRemoteCursors', async (_, state) => { 12 | remoteCursorsActive = state 13 | if (!remoteCursorsWindow && remoteCursorsActive) { 14 | remoteCursorsWindow = await createCursorsWindow() 15 | remoteCursorsWindow.on('closed', () => { 16 | remoteCursorsWindow = null 17 | remoteCursorsActive = false 18 | }) 19 | return 20 | } 21 | if (remoteCursorsWindow && !remoteCursorsActive) { 22 | remoteCursorsWindow.close() 23 | remoteCursorsWindow = null 24 | return 25 | } 26 | console.error('Invalid state') 27 | }) 28 | ipcMain.handle('updateRemoteCursor', async (_, state): Promise<void> => { 29 | if (!remoteCursorsActive) return 30 | if (!remoteCursorsWindow) return 31 | const realX: string = (state.x * availableDimensions.width).toString() 32 | const realY: string = (state.y * availableDimensions.height).toString() 33 | const x = parseInt(realX, 10) 34 | const y = parseInt(realY, 10) 35 | const data = { 36 | ...state, 37 | x, 38 | y 39 | } 40 | remoteCursorsWindow.webContents.send('updateRemoteCursor', data) 41 | }) 42 | ipcMain.handle('remoteCursorPing', async (_, cursorId): Promise<void> => { 43 | if (!remoteCursorsActive) return 44 | if (!remoteCursorsWindow) return 45 | remoteCursorsWindow.webContents.send('remoteCursorPing', cursorId) 46 | }) 47 | ipcMain.handle('updateSettings', async (_, settings): Promise<void> => { 48 | const settingsKeeperInstance = await settingsKeeper() 49 | settingsKeeperInstance.set(settings) 50 | }) 51 | ipcMain.handle('getSettings', async (): Promise<SettingsData> => { 52 | const settingsKeeperInstance = await settingsKeeper() 53 | return settingsKeeperInstance.get() 54 | }) 55 | ipcMain.handle('getAppVersion', (): string => { 56 | return app.getVersion() 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /src/main/stateKeeper.ts: -------------------------------------------------------------------------------- 1 | import { screen } from 'electron' 2 | import settings from 'electron-settings' 3 | import { debounce } from './utils' 4 | 5 | type IceServer = { 6 | urls: string 7 | username?: string 8 | credential?: string 9 | } 10 | 11 | export type SettingsData = { 12 | username: string 13 | color: string 14 | language: string 15 | isMicrophoneEnabledOnConnect: boolean 16 | iceServers: IceServer[] 17 | } 18 | 19 | type Settings = { 20 | get: () => SettingsData 21 | set: (data: SettingsData) => void 22 | } 23 | 24 | type WindowState = { 25 | x?: number 26 | y?: number 27 | width: number 28 | height: number 29 | isMaximized: boolean 30 | } 31 | 32 | type WindowStateKeeper = WindowState & { 33 | track: (win: Electron.BrowserWindow) => void 34 | } 35 | 36 | export const settingsKeeper = async (): Promise<Settings> => { 37 | const defaultSettings: SettingsData = { 38 | username: 'Banana Joe', 39 | color: '#ffffff', 40 | language: 'en', 41 | isMicrophoneEnabledOnConnect: true, 42 | iceServers: [ 43 | { 44 | urls: 'stun:stun.l.google.com:19302' 45 | } 46 | ] 47 | } 48 | const hasSettings = await settings.has('settings') 49 | if (hasSettings) { 50 | const data = (await settings.get('settings')) as unknown as SettingsData 51 | return { 52 | get: (): SettingsData => { 53 | return { 54 | ...defaultSettings, 55 | ...data 56 | } 57 | }, 58 | set: (data: SettingsData) => settings.set('settings', data) 59 | } 60 | } 61 | return { 62 | get: (): SettingsData => defaultSettings, 63 | set: (data: SettingsData) => settings.set('settings', data) 64 | } 65 | } 66 | 67 | export const windowStateKeeper = async (windowName: string): Promise<WindowStateKeeper> => { 68 | let window: Electron.BrowserWindow 69 | let windowState: WindowState 70 | 71 | const setBounds = async (): Promise<WindowState> => { 72 | const hasState = await settings.has(`windowState.${windowName}`) 73 | if (hasState) { 74 | return (await settings.get(`windowState.${windowName}`)) as unknown as WindowState 75 | } 76 | 77 | const size = screen.getPrimaryDisplay().workAreaSize 78 | 79 | return { 80 | width: size.width / 2, 81 | height: size.height / 2, 82 | isMaximized: false 83 | } 84 | } 85 | 86 | const saveState = async (): Promise<void> => { 87 | const bounds = window.getBounds() 88 | windowState = { 89 | ...bounds, 90 | isMaximized: window.isMaximized() 91 | } 92 | await settings.set(`windowState.${windowName}`, windowState) 93 | } 94 | 95 | const track = async (win: Electron.BrowserWindow): Promise<void> => { 96 | window = win 97 | win.on('move', debounce(saveState, 400)) 98 | win.on('resize', debounce(saveState, 400)) 99 | win.on('unmaximize', debounce(saveState, 400)) 100 | } 101 | 102 | windowState = await setBounds() 103 | 104 | return { 105 | x: windowState.x, 106 | y: windowState.y, 107 | width: windowState.width, 108 | height: windowState.height, 109 | isMaximized: windowState.isMaximized, 110 | track 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/main/utils.ts: -------------------------------------------------------------------------------- 1 | import { is } from '@electron-toolkit/utils' 2 | import { join } from 'path' 3 | 4 | export const loadWindowContents = (win: Electron.BrowserWindow, file: string): void => { 5 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 6 | win.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/' + file) 7 | } else { 8 | win.loadFile(join(__dirname, '../renderer/' + file)) 9 | } 10 | } 11 | 12 | export const isInProductionMode = (): boolean => { 13 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) return false 14 | return true 15 | } 16 | 17 | export const debounce = <T extends (...args: unknown[]) => void>( 18 | func: T, 19 | wait: number 20 | ): ((...args: Parameters<T>) => void) => { 21 | let timeout: NodeJS.Timeout 22 | return (...args: Parameters<T>): void => { 23 | clearTimeout(timeout) 24 | timeout = setTimeout(() => { 25 | func(...args) 26 | }, wait) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/preload/cursor.svg: -------------------------------------------------------------------------------- 1 | <svg fill="#ffffff" stroke="#000000" stroke-width="1" height="24px" width="24px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" 2 | y="0px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve"> 3 | <g> 4 | <path d="M14.1,15.74l2.77,6.47l-3.68,1.58l-2.76-6.45L6.03,21V2l14,13L14.1,15.74z"/> 5 | </g> 6 | </svg> 7 | -------------------------------------------------------------------------------- /src/preload/cursors.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="vite/client" /> 2 | import { ipcRenderer } from 'electron' 3 | import cursorSvg from './cursor.svg?raw' 4 | 5 | type RemoteCursorData = { 6 | id: string 7 | name: string 8 | color: string 9 | x: number 10 | y: number 11 | } 12 | 13 | type RemoteCursor = { 14 | ping: () => void 15 | getId: () => string 16 | getRootEl: () => HTMLDivElement 17 | getCursorEl: () => HTMLOrSVGElement 18 | getNameEl: () => HTMLSpanElement 19 | getName: () => string 20 | getData: () => RemoteCursorData 21 | update: (data: RemoteCursorData) => void 22 | } 23 | 24 | const remoteCursors: RemoteCursor[] = [] 25 | 26 | const domParser = new DOMParser() 27 | 28 | class Cursor { 29 | private root = document.createElement('div') 30 | private cursorEl = document.createElement('svg') 31 | private pingEl = document.createElement('div') 32 | private nameEl = document.createElement('span') 33 | private data: RemoteCursorData 34 | constructor(data: RemoteCursorData) { 35 | this.data = data 36 | this.cursorEl = domParser.parseFromString(cursorSvg, 'image/svg+xml').documentElement 37 | this.root.classList.add('cursor') 38 | this.pingEl.classList.add('ping', 'is-hidden') 39 | this.nameEl.classList.add('name') 40 | this.pingEl.style.setProperty('--ping-color', data.color) 41 | this.cursorEl.style.setProperty('--cursor-color', data.color) 42 | this.nameEl.style.setProperty('--name-color', data.color) 43 | this.nameEl.innerText = data.name 44 | this.root.id = data.id 45 | this.root.appendChild(this.pingEl) 46 | this.root.appendChild(this.cursorEl) 47 | this.root.appendChild(this.nameEl) 48 | } 49 | ping = (): void => { 50 | this.pingEl.classList.toggle('is-hidden') 51 | setTimeout(() => { 52 | this.pingEl.classList.toggle('is-hidden') 53 | }, 1000) 54 | } 55 | getId = (): string => this.data.id 56 | getRootEl = (): HTMLDivElement => this.root 57 | getCursorEl = (): HTMLOrSVGElement => this.cursorEl 58 | getNameEl = (): HTMLSpanElement => this.nameEl 59 | getName = (): string => this.data.name 60 | getData = (): RemoteCursorData => this.data 61 | update = (data: RemoteCursorData): void => { 62 | this.data = data 63 | this.root.style.left = `${data.x - 24}px` 64 | this.root.style.top = `${data.y}px` 65 | } 66 | } 67 | 68 | ipcRenderer.on('updateRemoteCursor', (_, data) => { 69 | let cursor: RemoteCursor | undefined = remoteCursors.find((c) => c.getId() === data.id) 70 | if (cursor) { 71 | cursor.update(data) 72 | } else { 73 | cursor = new Cursor(data) 74 | remoteCursors.push(cursor) 75 | document.body.appendChild(cursor.getRootEl()) 76 | } 77 | }) 78 | 79 | ipcRenderer.on('remoteCursorPing', (_, cursorId) => { 80 | remoteCursors.find((c) => c.getId() === cursorId)?.ping() 81 | }) 82 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronAPI } from '@electron-toolkit/preload' 2 | 3 | type IceServer = { 4 | urls: string 5 | username?: string 6 | credential?: string 7 | } 8 | 9 | declare global { 10 | interface Window { 11 | electron: ElectronAPI 12 | BananasApi: { 13 | toggleRemoteCursors: (state: boolean) => Promise<void> 14 | remoteCursorPing: (cursorId: string) => Promise<void> 15 | updateRemoteCursor: (state: { 16 | id: string 17 | name: string 18 | color: string 19 | x: number 20 | y: number 21 | }) => Promise<void> 22 | updateSettings: (settings: { 23 | username: string 24 | color: string 25 | isMicrophoneEnabledOnConnect: boolean 26 | iceServers: IceServer[] 27 | }) => Promise<void> 28 | getSettings: () => Promise<{ 29 | username: string 30 | color: string 31 | isMicrophoneEnabledOnConnect: boolean 32 | iceServers: IceServer[] 33 | }> 34 | getAppVersion: () => Promise<string> 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | import { contextBridge } from 'electron' 3 | import { electronAPI } from '@electron-toolkit/preload' 4 | 5 | let HANDLE_URL_CLICKS = true 6 | 7 | const onDocumentReady = (callback: () => void): void => { 8 | if (document.readyState !== 'complete') { 9 | document.addEventListener('DOMContentLoaded', callback) 10 | } else { 11 | callback() 12 | } 13 | } 14 | 15 | ipcRenderer.on('openBananasURL', (_, url) => { 16 | if (!HANDLE_URL_CLICKS) return 17 | onDocumentReady(() => { 18 | window.postMessage({ type: 'openBananasURL', url }, '*') 19 | }) 20 | }) 21 | 22 | type IceServer = { 23 | urls: string 24 | username?: string 25 | credential?: string 26 | } 27 | 28 | const BananasApi = { 29 | getAppVersion: async (): Promise<string> => { 30 | return await ipcRenderer.invoke('getAppVersion') 31 | }, 32 | handleUrlClicks: (state: boolean | undefined): boolean => { 33 | if (state) HANDLE_URL_CLICKS = state 34 | return HANDLE_URL_CLICKS 35 | }, 36 | getSettings: async (): Promise<{ 37 | username: string 38 | color: string 39 | isMicrophoneEnabledOnConnect: boolean 40 | iceServers: IceServer[] 41 | }> => { 42 | return await ipcRenderer.invoke('getSettings') 43 | }, 44 | updateSettings: async (settings: { 45 | username: string 46 | color: string 47 | isMicrophoneEnabledOnConnect: boolean 48 | iceServers: IceServer[] 49 | }): Promise<void> => { 50 | ipcRenderer.invoke('updateSettings', settings) 51 | }, 52 | toggleRemoteCursors: async (state: boolean): Promise<void> => { 53 | ipcRenderer.invoke('toggleRemoteCursors', state) 54 | }, 55 | remoteCursorPing: async (cursorId: string): Promise<void> => { 56 | ipcRenderer.invoke('remoteCursorPing', cursorId) 57 | }, 58 | updateRemoteCursor: async (state: { 59 | id: string 60 | name: string 61 | color: string 62 | x: number 63 | y: number 64 | }): Promise<void> => { 65 | ipcRenderer.invoke('updateRemoteCursor', state) 66 | } 67 | } 68 | 69 | try { 70 | contextBridge.exposeInMainWorld('electron', electronAPI) 71 | contextBridge.exposeInMainWorld('BananasApi', BananasApi) 72 | } catch (error) { 73 | console.error(error) 74 | } 75 | -------------------------------------------------------------------------------- /src/renderer/cursors.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <title>Bananas: Cursors</title> 6 | <meta 7 | http-equiv="Content-Security-Policy" 8 | content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" 9 | /> 10 | <link rel="stylesheet" href="./src/cursors.css" /> 11 | </head> 12 | <body></body> 13 | </html> 14 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <title>Bananas</title> 6 | <meta 7 | http-equiv="Content-Security-Policy" 8 | content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" 9 | /> 10 | </head> 11 | 12 | <body> 13 | <div id="app"></div> 14 | <script type="module" src="/src/main.ts"></script> 15 | </body> 16 | </html> 17 | -------------------------------------------------------------------------------- /src/renderer/src/About.shoulders-of-giants.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Discord", 4 | "description": "Discord is a voice, video and text communication service to talk and hang out with your friends and communities.", 5 | "usage": "Used for supporting the community and providing help.", 6 | "license": null, 7 | "url": "https://discord.com/" 8 | }, 9 | { 10 | "title": "GitHub", 11 | "description": "GitHub is a code hosting platform for version control and collaboration.", 12 | "usage": "Used for hosting the source code, building and distributing the releases.", 13 | "license": null, 14 | "url": "https://github.com/github" 15 | }, 16 | { 17 | "title": "Electron", 18 | "description": "Build cross-platform desktop apps with JavaScript, HTML, and CSS.", 19 | "usage": "Used for building the cross-platform desktop application.", 20 | "license": "MIT", 21 | "url": "https://github.com/electron/electron" 22 | }, 23 | { 24 | "title": "Electron Builder", 25 | "description": "A complete solution to package and build a ready for distribution Electron app with 'auto update' support out of the box.", 26 | "usage": "Used for building the cross-platform desktop application.", 27 | "license": "MIT", 28 | "url": "https://github.com/electron-userland/electron-builder" 29 | }, 30 | { 31 | "title": "Svelte", 32 | "description": "Cybernetically enhanced web apps.", 33 | "usage": "Used for building the user interface.", 34 | "license": "MIT", 35 | "url": "https://github.com/sveltejs/svelte" 36 | }, 37 | { 38 | "title": "Bulma", 39 | "description": "A modern CSS framework based on Flexbox.", 40 | "usage": "Used for styling the user interface.", 41 | "license": "MIT", 42 | "url": "https://github.com/jgthms/bulma" 43 | }, 44 | { 45 | "title": "eslint", 46 | "description": "Find and fix problems in your JavaScript code.", 47 | "usage": "Used for linting the application.", 48 | "license": "MIT", 49 | "url": "https://github.com/eslint/eslint" 50 | }, 51 | { 52 | "title": "prettier", 53 | "description": "An opinionated code formatter.", 54 | "usage": "Used for formatting the application.", 55 | "license": "MIT", 56 | "url": "https://github.com/prettier/prettier" 57 | }, 58 | { 59 | "title": "Vscode", 60 | "description": "Visual Studio Code is a code editor redefined and optimized for building and debugging modern web and cloud applications.", 61 | "usage": "Used for writing the application.", 62 | "license": "MIT", 63 | "url": "https://github.com/microsoft/vscode" 64 | }, 65 | { 66 | "title": "Neovim", 67 | "description": "Vim-fork focused on extensibility and usability.", 68 | "usage": "Used for writing the application.", 69 | "license": "Apache-2.0", 70 | "url": "https://github.com/neovim/neovim" 71 | }, 72 | { 73 | "title": "Vite", 74 | "description": "A build tool that aims to provide a faster and leaner development experience for modern web projects.", 75 | "usage": "Used for building and developing the application.", 76 | "license": "MIT", 77 | "url": "https://github.com/vitejs/vite" 78 | }, 79 | { 80 | "title": "TypeScript", 81 | "description": "TypeScript is a superset of JavaScript that compiles to clean JavaScript output.", 82 | "usage": "Used for writing the application in a statically typed language.", 83 | "license": "Apache-2.0", 84 | "url": "https://github.com/microsoft/TypeScript" 85 | }, 86 | { 87 | "title": "Bun", 88 | "description": "Bun is an all-in-one toolkit for JavaScript and TypeScript apps.", 89 | "usage": "Used for installing and managing dependencies and running scripts.", 90 | "license": "MIT", 91 | "url": "https://github.com/oven-sh/bun" 92 | }, 93 | { 94 | "title": "SweetAlert2", 95 | "description": "A beautiful, responsive, customizable, accessible (WAI-ARIA) replacement for JavaScript's popup boxes.", 96 | "usage": "Used for displaying alerts and modals.", 97 | "license": "MIT", 98 | "url": "https://github.com/sweetalert2/sweetalert2" 99 | } 100 | ] 101 | -------------------------------------------------------------------------------- /src/renderer/src/About.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { L } from './translations' 3 | import { externalLinkClickHandler } from './Utils' 4 | import shoulders from './About.shoulders-of-giants.json' 5 | 6 | // randomize the order of the shoulders 7 | const randomizedShoulders = shoulders.sort(() => Math.random() - 0.5) 8 | 9 | let version: string 10 | ;(async function (): Promise<void> { 11 | version = await window.BananasApi.getAppVersion() 12 | })() 13 | 14 | const GITHUB_REPO_URL = 'https://github.com/mistweaverco/bananas' 15 | 16 | function openExternalURL(e: MouseEvent & { currentTarget: HTMLButtonElement }): void { 17 | const url = e.currentTarget.dataset.url 18 | if (!url) return 19 | 20 | externalLinkClickHandler(e.currentTarget, url) 21 | } 22 | </script> 23 | 24 | <div class="container p-5 content"> 25 | <h1 class="title">{L.about()}</h1> 26 | <p>You are using <code>{version}</code> of Bananas Screen Sharing</p> 27 | <hr /> 28 | 29 | <button class="button is-secondary" data-url="https://getbananas.net" on:click={openExternalURL}> 30 | <span class="icon"> 31 | <i class="fa-solid fa-globe"></i> 32 | </span> 33 | <strong>{L.website()}</strong> 34 | </button> 35 | <button 36 | class="button is-secondary" 37 | data-url="{GITHUB_REPO_URL}/issues/new" 38 | on:click={openExternalURL} 39 | > 40 | <span class="icon"> 41 | <i class="fa-solid fa-bug"></i> 42 | </span> 43 | <strong>{L.report_a_bug()}</strong> 44 | </button> 45 | <button class="button is-secondary" data-url={GITHUB_REPO_URL} on:click={openExternalURL}> 46 | <span class="icon"> 47 | <i class="fa-solid fa-code"></i> 48 | </span> 49 | <strong>{L.see_the_code()}</strong> 50 | </button> 51 | <button 52 | class="button is-secondary" 53 | data-url="{GITHUB_REPO_URL}/blob/main/PRIVACY.md" 54 | on:click={openExternalURL} 55 | > 56 | <span class="icon"> 57 | <i class="fa-solid fa-lock"></i> 58 | </span> 59 | <strong>{L.privacty_policy()}</strong> 60 | </button> 61 | <button 62 | class="button is-secondary" 63 | data-url="{GITHUB_REPO_URL}/blob/main/TOS.md" 64 | on:click={openExternalURL} 65 | > 66 | <span class="icon"> 67 | <i class="fa-solid fa-book"></i> 68 | </span> 69 | <strong>{L.terms_of_service()}</strong> 70 | </button> 71 | <button 72 | class="button is-secondary" 73 | data-url="{GITHUB_REPO_URL}/blob/main/CODE_OF_CONDUCT.md" 74 | on:click={openExternalURL} 75 | > 76 | <span class="icon"> 77 | <i class="fa-solid fa-heart"></i> 78 | </span> 79 | <strong>{L.code_of_conduct()}</strong> 80 | </button> 81 | <hr /> 82 | <h2 class="title is-4">{L.shoulders_of_giants()}</h2> 83 | <p> 84 | {L.shoulders_of_giants_description()} 85 | </p> 86 | <ul> 87 | {#each randomizedShoulders as shoulder} 88 | <li> 89 | <p> 90 | <a href={shoulder.url} target="_blank" rel="noopener"> 91 | <strong>{shoulder.title}</strong> 92 | {shoulder.license ? '- ' + shoulder.license : ''} 93 | </a> 94 | </p> 95 | <p>{shoulder.description}</p> 96 | <p>{shoulder.usage}</p> 97 | <hr /> 98 | </li> 99 | {/each} 100 | </ul> 101 | </div> 102 | -------------------------------------------------------------------------------- /src/renderer/src/App.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import Navigation from './Navigation.svelte' 3 | import Join from './Join.svelte' 4 | import Host from './Host.svelte' 5 | import Settings from './Settings.svelte' 6 | import About from './About.svelte' 7 | import { 8 | useActiveView, 9 | useNavigationEnabled, 10 | useIsHosting, 11 | useIsWatching, 12 | useParticipantUrl, 13 | useHostUrl 14 | } from './stores' 15 | import { getDataFromBananasUrl } from './Utils' 16 | const activeView = useActiveView() 17 | const participantUrl = useParticipantUrl() 18 | const hostUrl = useHostUrl() 19 | const isHosting = useIsHosting() 20 | useNavigationEnabled() 21 | useIsWatching() 22 | window.onmessage = async (evt: MessageEvent): Promise<void> => { 23 | const { data } = evt 24 | if (data.type !== 'openBananasURL') return 25 | const urlData = await getDataFromBananasUrl(data.url) 26 | switch (urlData.type) { 27 | case 'host': 28 | $activeView = 'join' 29 | $participantUrl = data.url 30 | break 31 | case 'participant': 32 | if ($activeView !== 'host' || !$isHosting) return 33 | $hostUrl = data.url 34 | break 35 | } 36 | } 37 | </script> 38 | 39 | <Navigation /> 40 | 41 | {#if $activeView === 'join'} 42 | <Join /> 43 | {:else if $activeView === 'host'} 44 | <Host /> 45 | {:else if $activeView === 'settings'} 46 | <Settings /> 47 | {:else if $activeView === 'about'} 48 | <About /> 49 | {/if} 50 | -------------------------------------------------------------------------------- /src/renderer/src/AudioVisualizer.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { onMount, onDestroy } from 'svelte' 3 | 4 | export let stream: MediaStream | null = null 5 | export let className: string = '' 6 | export let visualizerIsActive: boolean = false 7 | 8 | let canvas: HTMLCanvasElement 9 | let audioCtx: AudioContext | null = null 10 | let analyser: AnalyserNode | null = null 11 | let animationFrameId: number 12 | 13 | const ACTIVE_THRESHOLD = 0.006 // Threshold for activation, using normalized RMS 14 | const ACTIVATION_PERCENTAGE = 0.1 15 | const HEIGHT_SCALE = 4 16 | const BUFFER_SIZE = 256 // Size of the circular buffer for signal data 17 | const DELAY_FRAMES = 50 // Number of frames delay 18 | const EASING_FACTOR = 0.1 // Smoothing factor for displayed values 19 | const DECAY_RATE = 0.05 // Rate at which bars decay when signal weakens 20 | 21 | let smoothedRMS = 0 22 | let buffer: number[] = [] // Rolling buffer for RMS activation 23 | let signalBuffer: number[][] = [] // Circular buffer for signal data 24 | let displayedValues: number[] = [] // Smoothed displayed values 25 | let signalBufferIndex = 0 // Current write position in signal buffer 26 | 27 | function visualize(s: MediaStream): void { 28 | if (!canvas) return 29 | 30 | if (!audioCtx) { 31 | audioCtx = new AudioContext() 32 | } 33 | 34 | const canvasCtx = canvas.getContext('2d') 35 | const source = audioCtx.createMediaStreamSource(s) 36 | 37 | analyser = audioCtx.createAnalyser() 38 | analyser.fftSize = 2048 39 | const bufferLength = analyser.frequencyBinCount 40 | const dataArray = new Uint8Array(bufferLength) 41 | 42 | // Initialize the signal buffer 43 | signalBuffer = Array.from({ length: DELAY_FRAMES }, () => new Array(bufferLength).fill(0)) 44 | displayedValues = new Array(bufferLength).fill(0) // Initialize displayed values 45 | 46 | source.connect(analyser) 47 | 48 | function calculateRMS(data: Uint8Array): number { 49 | const squaredSum = data.reduce((sum, value) => sum + Math.pow(value / 128.0 - 1.0, 2), 0) 50 | return Math.sqrt(squaredSum / data.length) 51 | } 52 | 53 | function draw(): void { 54 | animationFrameId = requestAnimationFrame(draw) 55 | 56 | if (!canvas || !canvasCtx || !analyser) return 57 | 58 | const canvasWidth = canvas.width 59 | const canvasHeight = canvas.height 60 | const centerY = canvasHeight / 2 61 | 62 | analyser.getByteTimeDomainData(dataArray) 63 | 64 | // Store the current signal in the circular buffer 65 | signalBuffer[signalBufferIndex] = [...dataArray] 66 | signalBufferIndex = (signalBufferIndex + 1) % DELAY_FRAMES 67 | 68 | // Retrieve the delayed signal 69 | const delayedSignal = signalBuffer[(signalBufferIndex + 1) % DELAY_FRAMES] 70 | 71 | // Smoothly update displayed values 72 | for (let i = 0; i < delayedSignal.length; i++) { 73 | const targetValue = (delayedSignal[i] / 128.0 - 1.0) * 3 // Amplify signal, for better visualization 74 | if (Math.abs(targetValue) > Math.abs(displayedValues[i])) { 75 | // Easing up to the target value 76 | displayedValues[i] += (targetValue - displayedValues[i]) * EASING_FACTOR 77 | } else { 78 | // Decay toward zero when the signal weakens 79 | displayedValues[i] *= 1 - DECAY_RATE 80 | } 81 | displayedValues[i] = Math.min(Math.max(displayedValues[i], -1), 1) // Clamp to visible range 82 | } 83 | 84 | canvasCtx.clearRect(0, 0, canvasWidth, canvasHeight) 85 | canvasCtx.fillStyle = 'transparent' 86 | canvasCtx.fillRect(0, 0, canvasWidth, canvasHeight) 87 | 88 | canvasCtx.lineWidth = 2 89 | canvasCtx.strokeStyle = 'rgb(0, 0, 0)' 90 | canvasCtx.beginPath() 91 | 92 | const sliceWidth = canvasWidth / bufferLength 93 | let x = 0 94 | 95 | for (let i = 0; i < bufferLength; i++) { 96 | const y = centerY + displayedValues[i] * (centerY * HEIGHT_SCALE * 1.5) // Additional scaling 97 | 98 | if (i === 0) { 99 | canvasCtx.moveTo(x, y) 100 | } else { 101 | canvasCtx.lineTo(x, y) 102 | } 103 | x += sliceWidth 104 | } 105 | 106 | canvasCtx.lineTo(canvasWidth, centerY) 107 | canvasCtx.stroke() 108 | 109 | // Calculate RMS 110 | const rms = calculateRMS(dataArray) 111 | 112 | // Smooth RMS 113 | smoothedRMS = 0.8 * smoothedRMS + 0.2 * rms 114 | 115 | // Update rolling buffer for activation 116 | buffer.push(smoothedRMS) 117 | if (buffer.length > BUFFER_SIZE) { 118 | buffer.shift() 119 | } 120 | 121 | // Analyze buffer to determine activation 122 | const activeCount = buffer.filter((value) => value > ACTIVE_THRESHOLD).length 123 | const activePercentage = activeCount / buffer.length 124 | 125 | visualizerIsActive = activePercentage >= ACTIVATION_PERCENTAGE 126 | } 127 | 128 | draw() 129 | } 130 | 131 | onMount(() => { 132 | if (stream) { 133 | visualize(stream) 134 | } 135 | }) 136 | 137 | onDestroy(() => { 138 | if (animationFrameId) { 139 | cancelAnimationFrame(animationFrameId) 140 | } 141 | if (audioCtx) { 142 | audioCtx.close() 143 | } 144 | audioCtx = null 145 | analyser = null 146 | }) 147 | </script> 148 | 149 | <canvas class={className} bind:this={canvas}></canvas> 150 | 151 | <style> 152 | canvas { 153 | margin-inline-end: initial important; 154 | margin-inline-start: initial !important; 155 | } 156 | </style> 157 | -------------------------------------------------------------------------------- /src/renderer/src/BananasTypes.ts: -------------------------------------------------------------------------------- 1 | export type BananasRemoteCursorData = { 2 | id: string 3 | name: string 4 | color: string 5 | x: number 6 | y: number 7 | } 8 | 9 | type IceServer = { 10 | urls: string 11 | username?: string 12 | credential?: string 13 | } 14 | 15 | export type SettingsData = { 16 | username: string 17 | color: string 18 | isMicrophoneEnabledOnConnect: boolean 19 | iceServers: IceServer[] 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/src/Config.ts: -------------------------------------------------------------------------------- 1 | type IceServer = { 2 | urls: string 3 | username?: string 4 | credential?: string 5 | } 6 | 7 | export const getRTCPeerConnectionConfig = async (): Promise<RTCConfiguration> => { 8 | const settings = await window.BananasApi.getSettings() 9 | const iceServers = settings.iceServers.map((server: IceServer) => { 10 | return { 11 | urls: server.urls, 12 | username: server.username, 13 | credential: server.credential 14 | } 15 | }) 16 | return { 17 | iceServers 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/src/Host.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { onMount } from 'svelte' 3 | import Swal from 'sweetalert2' 4 | import { L } from './translations' 5 | import { useNavigationEnabled, useIsHosting, useHostUrl } from './stores' 6 | import { mayBeConnectionString, getDataFromBananasUrl, ConnectionType } from './Utils' 7 | import AudioVisualizer from './AudioVisualizer.svelte' 8 | import WebRTC from './WebRTC.svelte' 9 | 10 | const navigationEnabled = useNavigationEnabled() 11 | const isHosting = useIsHosting() 12 | 13 | let webRTCComponent: WebRTC 14 | let connectButton: HTMLButtonElement 15 | let copyButton: HTMLButtonElement 16 | 17 | let connectionState = 'disconnected' 18 | let cursorsActive = false 19 | let displayStreamActive = false 20 | let microphoneActive = false 21 | let isStreaming = false 22 | let sessionStarted = false 23 | let connectionStringIsValid: boolean | null = null 24 | let connectToUserName = '' 25 | let copyButtonIsLoading = false 26 | let connectionString = useHostUrl() 27 | let hasAudioInput = false 28 | let visualizerIsActive: boolean = true 29 | 30 | const onConnectionStringChange = async (): Promise<void> => { 31 | if ($connectionString === '') { 32 | connectionStringIsValid = null 33 | return 34 | } 35 | connectionStringIsValid = mayBeConnectionString(ConnectionType.PARTICIPANT, $connectionString) 36 | if (connectionStringIsValid) { 37 | const banansData = await getDataFromBananasUrl($connectionString) 38 | connectToUserName = banansData.data.username 39 | } 40 | } 41 | 42 | const onConnectionStateChange = (): void => { 43 | switch (connectionState) { 44 | case 'connected': 45 | Swal.fire({ 46 | position: 'top-end', 47 | icon: 'success', 48 | title: 'Connection established', 49 | showConfirmButton: false, 50 | timer: 1500 51 | }) 52 | break 53 | case 'failed': 54 | Swal.fire({ 55 | position: 'top-end', 56 | icon: 'error', 57 | title: 'Connection failed', 58 | showConfirmButton: false, 59 | timer: 1500 60 | }) 61 | break 62 | case 'closed': 63 | Swal.fire({ 64 | position: 'top-end', 65 | icon: 'info', 66 | title: 'Connection closed', 67 | showConfirmButton: false, 68 | timer: 1500 69 | }) 70 | break 71 | default: 72 | break 73 | } 74 | } 75 | 76 | $: $connectionString, onConnectionStringChange() 77 | $: connectionState, onConnectionStateChange() 78 | 79 | const toggleRemoteCursors = (): void => { 80 | cursorsActive = !cursorsActive 81 | window.BananasApi.toggleRemoteCursors(cursorsActive) 82 | webRTCComponent.ToggleRemoteCursors(cursorsActive) 83 | } 84 | 85 | onMount(async () => { 86 | const settings = await window.BananasApi.getSettings() 87 | microphoneActive = settings.isMicrophoneEnabledOnConnect 88 | connectButton.addEventListener('click', async () => { 89 | const data = await getDataFromBananasUrl($connectionString) 90 | await webRTCComponent.Connect(data.rtcSessionDescription) 91 | isStreaming = true 92 | displayStreamActive = true 93 | }) 94 | copyButton.addEventListener('click', async () => { 95 | copyButtonIsLoading = true 96 | const offer = await webRTCComponent.CreateHostUrl({ 97 | username: settings.username 98 | }) 99 | navigator.clipboard.writeText(offer) 100 | setTimeout(() => { 101 | copyButtonIsLoading = false 102 | }, 400) 103 | }) 104 | }) 105 | const onStartSessionButtonClick = async (): Promise<void> => { 106 | await webRTCComponent.Setup() 107 | sessionStarted = true 108 | $navigationEnabled = false 109 | $isHosting = true 110 | hasAudioInput = webRTCComponent.HasAudioInput() 111 | } 112 | const reset = (): void => { 113 | $connectionString = '' 114 | cursorsActive = false 115 | displayStreamActive = false 116 | microphoneActive = true 117 | isStreaming = false 118 | sessionStarted = false 119 | connectionStringIsValid = null 120 | copyButtonIsLoading = false 121 | $navigationEnabled = true 122 | $isHosting = false 123 | } 124 | const onDisconnectClick = async (): Promise<void> => { 125 | await webRTCComponent.Disconnect() 126 | reset() 127 | } 128 | const onMicrophoneToggle = async (): Promise<void> => { 129 | microphoneActive = !microphoneActive 130 | webRTCComponent.ToggleMicrophone() 131 | } 132 | const onDisplayStreamToggle = async (): Promise<void> => { 133 | displayStreamActive = !displayStreamActive 134 | webRTCComponent.ToggleDisplayStream() 135 | if (!displayStreamActive) { 136 | cursorsActive = false 137 | window.BananasApi.toggleRemoteCursors(cursorsActive) 138 | webRTCComponent.ToggleRemoteCursors(cursorsActive) 139 | } 140 | } 141 | </script> 142 | 143 | <WebRTC bind:connectionState bind:this={webRTCComponent} /> 144 | 145 | <div class="container p-5"> 146 | <h1 class="title">{!isStreaming ? L.host_a_session() : L.hosting_a_session()}</h1> 147 | <div class={!isStreaming ? 'is-hidden' : ''}> 148 | <div class="fixed-grid"> 149 | <div class="grid"> 150 | <div class="cell"> 151 | <button 152 | title={displayStreamActive ? L.streaming_your_display() : L.streaming_your_display()} 153 | class="button {displayStreamActive ? 'is-success' : 'is-danger'}" 154 | on:click={onDisplayStreamToggle} 155 | > 156 | <span class="icon"> 157 | <i class="fa-solid fa-display"></i> 158 | </span> 159 | </button> 160 | {#if hasAudioInput} 161 | <button 162 | title={microphoneActive ? 'Microphone active' : 'Microphone muted'} 163 | class="button {microphoneActive ? 'is-success' : 'is-danger'}" 164 | on:click={onMicrophoneToggle} 165 | > 166 | <span class="icon"> 167 | {#if microphoneActive} 168 | <AudioVisualizer 169 | className="icon {!visualizerIsActive ? 'is-hidden' : ''}" 170 | bind:visualizerIsActive 171 | stream={webRTCComponent.GetAudioStream()} 172 | /> 173 | <i class="fas fa-microphone {visualizerIsActive ? 'is-hidden' : ''}"></i> 174 | {:else} 175 | <i class="fas fa-microphone-slash"></i> 176 | {/if} 177 | </span> 178 | </button> 179 | {/if} 180 | <button 181 | title={cursorsActive ? L.remote_cursors_enabled() : L.remote_cursors_disabled()} 182 | class="button {cursorsActive ? 'is-success' : 'is-danger'} {!displayStreamActive 183 | ? 'is-hidden' 184 | : ''}" 185 | on:click={toggleRemoteCursors} 186 | > 187 | <span class="icon"> 188 | <i class="fas fa-mouse-pointer"></i> 189 | </span> 190 | </button> 191 | </div> 192 | <div class="cell has-text-right"> 193 | <button class="button is-danger" on:click={onDisconnectClick}> 194 | <span class="icon"> 195 | <i class="fas fa-unlink"></i> 196 | </span> 197 | <span>{L.disconnect()}</span> 198 | </button> 199 | </div> 200 | </div> 201 | </div> 202 | </div> 203 | <div class="fixed-grid has-2-cols"> 204 | <div class="grid"> 205 | <div class="cell"> 206 | <button 207 | class="button is-link {isStreaming ? 'is-hidden' : ''}" 208 | disabled={sessionStarted} 209 | on:click={onStartSessionButtonClick} 210 | > 211 | <span class="icon"> 212 | <i class="fas fa-play"></i> 213 | </span> 214 | <span>{!sessionStarted ? L.start_a_new_session() : L.session_started()}</span> 215 | </button> 216 | </div> 217 | 218 | <div class="cell"> 219 | <button 220 | class="button is-danger {!sessionStarted || isStreaming ? 'is-hidden' : ''}" 221 | on:click={onDisconnectClick} 222 | > 223 | <span class="icon"> 224 | <i class="fas fa-unlink"></i> 225 | </span> 226 | <span>{L.cancel()}</span> 227 | </button> 228 | </div> 229 | 230 | <div class="cell"> 231 | <button 232 | class="button is-link {!sessionStarted || isStreaming 233 | ? 'is-hidden' 234 | : ''} {copyButtonIsLoading ? 'is-loading' : ''}" 235 | bind:this={copyButton} 236 | > 237 | <span class="icon"> 238 | <i class="fas fa-copy"></i> 239 | </span> 240 | <span>{L.copy_my_connection_string()}</span> 241 | </button> 242 | </div> 243 | </div> 244 | 245 | <div class="field has-addons {!sessionStarted || isStreaming ? 'is-hidden' : ''}"> 246 | <div class="control has-icons-left has-icons-right"> 247 | <input 248 | bind:value={$connectionString} 249 | placeholder="participant connection string" 250 | class="input {connectionStringIsValid === null 251 | ? '' 252 | : connectionStringIsValid 253 | ? 'is-success' 254 | : 'is-danger'}" 255 | type="text" 256 | /> 257 | <span class="icon is-small is-left"> 258 | <i class="fas fa-user"></i> 259 | </span> 260 | <span class="icon is-small is-right"> 261 | <i 262 | class="fas fa-question {connectionStringIsValid === null 263 | ? 'fa-question' 264 | : connectionStringIsValid 265 | ? 'fa-check' 266 | : 'fa-times'}" 267 | ></i> 268 | </span> 269 | </div> 270 | <div class="control"> 271 | <button 272 | class="button {connectionStringIsValid === null 273 | ? 'is-link' 274 | : connectionStringIsValid 275 | ? 'is-success' 276 | : 'is-danger'}" 277 | bind:this={connectButton} 278 | disabled={connectionStringIsValid ? false : true} 279 | > 280 | <span class="icon"> 281 | <i class="fas fa-link"></i> 282 | </span> 283 | <span>{L.connect()} {connectionStringIsValid ? connectToUserName : ''} </span> 284 | </button> 285 | </div> 286 | </div> 287 | </div> 288 | </div> 289 | -------------------------------------------------------------------------------- /src/renderer/src/Join.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { onMount } from 'svelte' 3 | import Swal from 'sweetalert2' 4 | import { L } from './translations' 5 | import { 6 | makeVideoDraggable, 7 | mayBeConnectionString, 8 | getDataFromBananasUrl, 9 | ConnectionType, 10 | getUUIDv4 11 | } from './Utils' 12 | import { useNavigationEnabled, useIsWatching, useParticipantUrl } from './stores' 13 | import WebRTC from './WebRTC.svelte' 14 | import AudioVisualizer from './AudioVisualizer.svelte' 15 | 16 | const navigationEnabled = useNavigationEnabled() 17 | const isWatching = useIsWatching() 18 | 19 | let connectionState = 'disconnected' 20 | let webRTCComponent: WebRTC 21 | let connectButton: HTMLButtonElement 22 | let copyButton: HTMLButtonElement 23 | let remoteScreen: HTMLVideoElement 24 | let UUID = getUUIDv4() 25 | let zoomFactor = 1 26 | let microphoneActive = false 27 | let isStreaming = false 28 | let isConnected = false 29 | let connectionStringIsValid: boolean | null = null 30 | let connectToUserName = '' 31 | let copyButtonIsLoading = false 32 | let connectionString = useParticipantUrl() 33 | let visualizerIsActive: boolean = true 34 | 35 | const onConnectionStringChange = async (): Promise<void> => { 36 | if ($connectionString === '') { 37 | connectionStringIsValid = null 38 | return 39 | } 40 | connectionStringIsValid = mayBeConnectionString(ConnectionType.HOST, $connectionString) 41 | if (connectionStringIsValid) { 42 | const bananasData = await getDataFromBananasUrl($connectionString) 43 | connectToUserName = bananasData.data.username 44 | } 45 | } 46 | 47 | const onConnectionStateChange = (): void => { 48 | switch (connectionState) { 49 | case 'connected': 50 | Swal.fire({ 51 | position: 'top-end', 52 | icon: 'success', 53 | title: 'Connection established', 54 | showConfirmButton: false, 55 | timer: 1500 56 | }) 57 | break 58 | case 'failed': 59 | Swal.fire({ 60 | position: 'top-end', 61 | icon: 'error', 62 | title: 'Connection failed', 63 | showConfirmButton: false, 64 | timer: 1500 65 | }) 66 | break 67 | case 'closed': 68 | Swal.fire({ 69 | position: 'top-end', 70 | icon: 'info', 71 | title: 'Connection closed', 72 | showConfirmButton: false, 73 | timer: 1500 74 | }) 75 | break 76 | default: 77 | break 78 | } 79 | } 80 | 81 | $: $connectionString, onConnectionStringChange() 82 | $: connectionState, onConnectionStateChange() 83 | 84 | onMount(async () => { 85 | const settings = await window.BananasApi.getSettings() 86 | microphoneActive = settings.isMicrophoneEnabledOnConnect 87 | makeVideoDraggable(remoteScreen) 88 | connectButton.addEventListener('click', async () => { 89 | await webRTCComponent.Setup(remoteScreen) 90 | const data = await getDataFromBananasUrl($connectionString) 91 | await webRTCComponent.Connect(data.rtcSessionDescription) 92 | isConnected = true 93 | $isWatching = true 94 | $navigationEnabled = false 95 | }) 96 | copyButton.addEventListener('click', async () => { 97 | copyButtonIsLoading = true 98 | const remoteData = await getDataFromBananasUrl($connectionString) 99 | const data = await webRTCComponent.CreateParticipantUrl(remoteData.rtcSessionDescription, { 100 | username: settings.username 101 | }) 102 | navigator.clipboard.writeText(data) 103 | setTimeout(() => { 104 | copyButtonIsLoading = false 105 | }, 400) 106 | }) 107 | remoteScreen.addEventListener('dblclick', () => { 108 | webRTCComponent.PingRemoteCursor('cursor-' + UUID) 109 | }) 110 | remoteScreen.addEventListener('mousemove', (e) => { 111 | const { offsetX, offsetY } = e 112 | // TODO: Batch cursor updates 113 | webRTCComponent.UpdateRemoteCursor({ 114 | x: offsetX / remoteScreen.clientWidth, 115 | y: offsetY / remoteScreen.clientHeight, 116 | name: settings.username, 117 | id: 'cursor-' + UUID, 118 | color: settings.color 119 | }) 120 | }) 121 | remoteScreen.addEventListener('play', () => { 122 | if (!webRTCComponent.IsConnected()) return 123 | isStreaming = true 124 | }) 125 | }) 126 | const reset = (): void => { 127 | $connectionString = '' 128 | connectionStringIsValid = null 129 | isStreaming = false 130 | microphoneActive = false 131 | isConnected = false 132 | $navigationEnabled = true 133 | $isWatching = false 134 | } 135 | const onDisconnectClick = async (): Promise<void> => { 136 | await webRTCComponent.Disconnect() 137 | reset() 138 | } 139 | const onFullscreenClick = (): void => { 140 | remoteScreen.requestFullscreen() 141 | } 142 | const onZoomInClick = (): void => { 143 | zoomFactor += 0.1 144 | remoteScreen.style.scale = zoomFactor.toString() 145 | } 146 | const onZoomOutClick = (): void => { 147 | if (zoomFactor <= 1) return 148 | zoomFactor -= 0.1 149 | remoteScreen.style.scale = zoomFactor.toString() 150 | } 151 | const onMicrophoneToggle = async (): Promise<void> => { 152 | microphoneActive = !microphoneActive 153 | webRTCComponent.ToggleMicrophone() 154 | } 155 | </script> 156 | 157 | <WebRTC bind:connectionState bind:this={webRTCComponent} /> 158 | 159 | <div class="container p-5"> 160 | <h1 class="title">{!isStreaming ? L.join_a_session() : L.joined_a_session()}</h1> 161 | <div class={!isStreaming ? 'is-hidden' : ''}> 162 | <div class="fixed-grid"> 163 | <div class="grid"> 164 | <div class="cell"> 165 | <button 166 | aria-label="{microphoneActive ? L.microphone_active() : L.microphone_inactive()}}" 167 | title={microphoneActive ? L.microphone_active() : L.microphone_inactive()} 168 | class="button {microphoneActive ? 'is-success' : 'is-danger'}" 169 | on:click={onMicrophoneToggle} 170 | > 171 | <span class="icon"> 172 | {#if microphoneActive} 173 | <AudioVisualizer 174 | className="icon {!visualizerIsActive ? 'is-hidden' : ''}" 175 | bind:visualizerIsActive 176 | stream={webRTCComponent.GetAudioStream()} 177 | /> 178 | <i class="fas fa-microphone {visualizerIsActive ? 'is-hidden' : ''}"></i> 179 | {:else} 180 | <i class="fas fa-microphone-slash"></i> 181 | {/if} 182 | </span> 183 | </button> 184 | </div> 185 | <div class="cell has-text-right"> 186 | <button class="button is-danger" aria-label={L.disconnect()} on:click={onDisconnectClick}> 187 | <span class="icon"> 188 | <i class="fas fa-unlink"></i> 189 | </span> 190 | <span>{L.disconnect()}</span> 191 | </button> 192 | </div> 193 | </div> 194 | </div> 195 | </div> 196 | <div class="fixed-grid has-2-cols"> 197 | <div class="grid"> 198 | <div class="cell"> 199 | <div class="field has-addons {isStreaming || isConnected ? 'is-hidden' : ''}"> 200 | <div class="control has-icons-left has-icons-right"> 201 | <input 202 | bind:value={$connectionString} 203 | placeholder={L.host_connection_string()} 204 | class="input {connectionStringIsValid === null 205 | ? '' 206 | : connectionStringIsValid 207 | ? 'is-success' 208 | : 'is-danger'}" 209 | type="text" 210 | /> 211 | <span class="icon is-small is-left"> 212 | <i class="fas fa-user"></i> 213 | </span> 214 | <span class="icon is-small is-right"> 215 | <i 216 | class="fas fa-question {connectionStringIsValid === null 217 | ? 'fa-question' 218 | : connectionStringIsValid 219 | ? 'fa-check' 220 | : 'fa-times'}" 221 | ></i> 222 | </span> 223 | </div> 224 | <div class="control"> 225 | <button 226 | class="button {connectionStringIsValid === null 227 | ? 'is-link' 228 | : connectionStringIsValid 229 | ? 'is-success' 230 | : 'is-danger'}" 231 | bind:this={connectButton} 232 | disabled={connectionStringIsValid ? false : true} 233 | > 234 | <span class="icon"> 235 | <i class="fas fa-link"></i> 236 | </span> 237 | <span>{L.connect()} {connectionStringIsValid ? connectToUserName : ''} </span> 238 | </button> 239 | </div> 240 | </div> 241 | <div class="control"> 242 | <button 243 | class="button is-link {!isConnected || isStreaming 244 | ? 'is-hidden' 245 | : ''} {copyButtonIsLoading ? 'is-loading' : ''}" 246 | bind:this={copyButton} 247 | > 248 | <span class="icon"> 249 | <i class="fas fa-copy"></i> 250 | </span> 251 | <span>{L.copy_my_connection_string()}</span> 252 | </button> 253 | </div> 254 | </div> 255 | <div class="cell"> 256 | <button 257 | class="button is-danger {isStreaming || !isConnected ? 'is-hidden' : ''}" 258 | on:click={onDisconnectClick} 259 | > 260 | <span class="icon"> 261 | <i class="fas fa-unlink"></i> 262 | </span> 263 | <span>{L.cancel()}</span> 264 | </button> 265 | </div> 266 | </div> 267 | </div> 268 | </div> 269 | 270 | <div class={!isStreaming ? 'is-hidden' : ''}> 271 | <div class="field"> 272 | <label class="label" for="remote_screen">{L.remote_screen()}</label> 273 | <div class="control"> 274 | <div class="video-overflow"> 275 | <video bind:this={remoteScreen} id="remote_screen" class="video" autoplay playsinline muted 276 | ></video> 277 | </div> 278 | </div> 279 | </div> 280 | <div class="field"> 281 | <div class="control"> 282 | <button class="button is-info" on:click={onZoomInClick}> 283 | <span class="icon"> 284 | <i class="fas fa-search-plus"></i> 285 | </span> 286 | <span>{L.zoom_in()}</span> 287 | </button> 288 | <button class="button is-info" on:click={onZoomOutClick}> 289 | <span class="icon"> 290 | <i class="fas fa-search-minus"></i> 291 | </span> 292 | <span>{L.zoom_out()}</span> 293 | </button> 294 | <button class="button is-info" on:click={onFullscreenClick}> 295 | <span class="icon"> 296 | <i class="fas fa-expand"></i> 297 | </span> 298 | <span>{L.fullscreen()}</span> 299 | </button> 300 | </div> 301 | </div> 302 | </div> 303 | 304 | <style> 305 | .video { 306 | width: 100%; 307 | height: auto; 308 | transition: transform 0.5s linear; 309 | } 310 | .video-overflow { 311 | width: 100%; 312 | height: auto; 313 | overflow: hidden; 314 | } 315 | </style> 316 | -------------------------------------------------------------------------------- /src/renderer/src/Navigation.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { useActiveView, useNavigationEnabled, useIsWatching, useIsHosting } from './stores' 3 | import { L } from './translations' 4 | const activeView = useActiveView() 5 | const isHosting = useIsHosting() 6 | const isWatching = useIsWatching() 7 | const navigationEnabled = useNavigationEnabled() 8 | 9 | const handleTopButtonsClick = (evt: MouseEvent): void => { 10 | evt.preventDefault() 11 | const target = evt.target as HTMLButtonElement 12 | const root = target.closest('button') 13 | 14 | $activeView = root.dataset.action 15 | } 16 | </script> 17 | 18 | <div class="container"> 19 | <nav class="navbar" aria-label="main navigation"> 20 | <div class="navbar-brand p-2"> 21 | <div class="navbar-start"> 22 | <div class="navbar-item"> 23 | <div class="buttons"> 24 | <button 25 | class="button {$activeView === 'join' ? 'is-active is-primary' : 'is-secondary'}" 26 | data-action="join" 27 | on:click={handleTopButtonsClick} 28 | disabled={!$navigationEnabled} 29 | > 30 | <span class="icon"> 31 | <i class="fa-solid fa-right-to-bracket"></i> 32 | </span> 33 | <strong>{!$isWatching ? L.join_a_session() : L.joined_a_session()}</strong> 34 | </button> 35 | <button 36 | class="button is-secondary {$activeView === 'host' 37 | ? 'is-active is-primary' 38 | : 'is-secondary'}" 39 | data-action="host" 40 | on:click={handleTopButtonsClick} 41 | disabled={!$navigationEnabled} 42 | > 43 | <span class="icon"> 44 | <i class="fa-solid fa-earth-africa"></i> 45 | </span> 46 | <strong>{!$isHosting ? L.host_a_session() : L.hosting_a_session()}</strong> 47 | </button> 48 | <button 49 | class="button is-secondary {$activeView === 'settings' 50 | ? 'is-active is-primary' 51 | : 'is-secondary'}" 52 | data-action="settings" 53 | on:click={handleTopButtonsClick} 54 | disabled={!$navigationEnabled} 55 | > 56 | <span class="icon"> 57 | <i class="fa-solid fa-gear"></i> 58 | </span> 59 | <strong>{L.settings()}</strong> 60 | </button> 61 | <button 62 | class="button is-secondary {$activeView === 'about' 63 | ? 'is-active is-primary' 64 | : 'is-secondary'}" 65 | data-action="about" 66 | on:click={handleTopButtonsClick} 67 | disabled={!$navigationEnabled} 68 | > 69 | <span class="icon"> 70 | <i class="fa-solid fa-question"></i> 71 | </span> 72 | <strong>{L.about()}</strong> 73 | </button> 74 | </div> 75 | </div> 76 | </div> 77 | </div> 78 | </nav> 79 | </div> 80 | -------------------------------------------------------------------------------- /src/renderer/src/Settings.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { onMount } from 'svelte' 3 | import ColorPicker from 'svelte-awesome-color-picker' 4 | import { L } from './translations' 5 | 6 | let colorPreviewIcon: HTMLElement 7 | let usernameValue: string = 'Banana Joe' 8 | let colorValue: string = '#ffffff' 9 | let language = 'en' 10 | const languageOptions = ['en', 'de', 'fr', 'zh'] 11 | let iceServersValue: string = '{ "urls": "stun:stun.l.google.com:19302" }' 12 | let isUsernameValid = false 13 | let isColorValid = false 14 | let isIceServersValid = true 15 | let modalSuccessIsActive = false 16 | let modalFailureIsActive = false 17 | let isMicrophoneEnabledOnConnect = true 18 | 19 | $: colorValue, checkColor() 20 | $: usernameValue, checkUsername() 21 | $: iceServersValue, checkIceServers() 22 | $: isMicrophoneEnabledOnConnect 23 | 24 | const checkIceServers = (): void => { 25 | const serversObjects = iceServersValue.split('\n') 26 | isIceServersValid = serversObjects.every((serverObject) => { 27 | try { 28 | const srv = JSON.parse(serverObject) 29 | return srv.urls && srv.urls.length > 0 30 | } catch (e) { 31 | return false 32 | } 33 | }) 34 | } 35 | const checkIsValidHexColor = (color: string): boolean => { 36 | return /^#[0-9A-F]{6}$/i.test(color) 37 | } 38 | function checkColor(): void { 39 | if (checkIsValidHexColor(colorValue)) { 40 | isColorValid = true 41 | colorPreviewIcon?.style.setProperty('--color', colorValue) 42 | } else { 43 | isColorValid = false 44 | } 45 | } 46 | function checkUsername(): void { 47 | if (usernameValue.length > 0 && usernameValue.length < 32) { 48 | isUsernameValid = true 49 | } else { 50 | isUsernameValid = false 51 | } 52 | } 53 | async function onSubmit(evt: Event): Promise<void> { 54 | evt.preventDefault() 55 | if (isUsernameValid && isColorValid && isIceServersValid) { 56 | await window.BananasApi.updateSettings({ 57 | username: usernameValue, 58 | color: colorValue, 59 | language, 60 | isMicrophoneEnabledOnConnect, 61 | iceServers: iceServersValue.split('\n').map((srv) => JSON.parse(srv)) 62 | }) 63 | modalSuccessIsActive = true 64 | setTimeout(() => { 65 | modalSuccessIsActive = false 66 | }, 2000) 67 | } else { 68 | modalFailureIsActive = true 69 | setTimeout(() => { 70 | modalFailureIsActive = false 71 | }, 2000) 72 | } 73 | } 74 | onMount(async () => { 75 | const settings = await window.BananasApi.getSettings() 76 | usernameValue = settings.username 77 | colorValue = settings.color 78 | language = settings.language 79 | isMicrophoneEnabledOnConnect = settings.isMicrophoneEnabledOnConnect 80 | iceServersValue = settings.iceServers.map((srv) => JSON.stringify(srv)).join('\n') 81 | }) 82 | </script> 83 | 84 | <div class="modal {modalSuccessIsActive ? 'is-active' : ''}"> 85 | <div class="modal-background"></div> 86 | <div class="modal-content"> 87 | <div class="box"> 88 | <h1 class="title has-text-success">Success</h1> 89 | <p>Settings successfully saved.</p> 90 | </div> 91 | </div> 92 | </div> 93 | 94 | <div class="modal {modalFailureIsActive ? 'is-active' : ''}"> 95 | <div class="modal-background"></div> 96 | <div class="modal-content"> 97 | <div class="box"> 98 | <h1 class="title has-text-danger">Failure</h1> 99 | <p>Settings could not be saved.</p> 100 | </div> 101 | </div> 102 | </div> 103 | 104 | <div class="container p-5 content"> 105 | <h1 class="title">{L.settings()}</h1> 106 | <h2>{L.basic()}</h2> 107 | <form class="form" on:submit={onSubmit}> 108 | <div class="field"> 109 | <label class="label" for="username">{L.username()}</label> 110 | <div class="control has-icons-left has-icons-right"> 111 | <input 112 | bind:value={usernameValue} 113 | class="input {isUsernameValid ? 'is-success' : 'is-danger'}" 114 | type="text" 115 | id="username" 116 | placeholder="Banana Joe" 117 | /> 118 | <span class="icon is-small is-left"> 119 | <i class="fas fa-user"></i> 120 | </span> 121 | </div> 122 | </div> 123 | 124 | <div class="field"> 125 | <label class="label" for="color">{L.color()}</label> 126 | <div class="control has-icons-left has-icons-right"> 127 | <input 128 | bind:value={colorValue} 129 | class="input {isColorValid ? 'is-success' : 'is-danger'}" 130 | type="text" 131 | id="color" 132 | placeholder="#fffff" 133 | /> 134 | <span class="icon is-small is-left"> 135 | <i bind:this={colorPreviewIcon} class="fas fa-palette"></i> 136 | </span> 137 | <ColorPicker bind:hex={colorValue} isTextInput={false} isAlpha={false} /> 138 | </div> 139 | </div> 140 | <div class="field"> 141 | <label class="label" for="translation">{L.language()}</label> 142 | <div class="control has-icons-left has-icons-right"> 143 | <div class="select"> 144 | <select bind:value={language}> 145 | {#each languageOptions as lang} 146 | <option>{lang}</option> 147 | {/each} 148 | </select> 149 | </div> 150 | <span class="icon is-small is-left"> 151 | <i class="fa fa-language"></i> 152 | </span> 153 | </div> 154 | <p class="help">{L.language_description()}</p> 155 | </div> 156 | 157 | <h2>Media</h2> 158 | 159 | <div class="field"> 160 | <div class="control"> 161 | <label class="checkbox" for="microphone_active_on_connect"> 162 | <input 163 | bind:checked={isMicrophoneEnabledOnConnect} 164 | class="checkbox" 165 | type="checkbox" 166 | id="microphone_active_on_connect" 167 | placeholder="#fffff" 168 | /> 169 | {L.is_microphone_active_on_connect()} 170 | </label> 171 | </div> 172 | </div> 173 | 174 | <h2>{L.advanced()}</h2> 175 | 176 | <div class="field"> 177 | <label class="label" for="ice_servers">{L.stun_turn_server_objects()}</label> 178 | <div class="control has-icons-left has-icons-right"> 179 | <textarea 180 | bind:value={iceServersValue} 181 | class="textarea {isIceServersValid ? 'is-success' : 'is-danger'}" 182 | id="ice_servers" 183 | placeholder="{ "urls": "stun:stun.l.google.com:19302" }" 184 | ></textarea> 185 | </div> 186 | </div> 187 | 188 | <div class="field"> 189 | <div class="control"> 190 | <button class="button is-link">Save</button> 191 | </div> 192 | </div> 193 | </form> 194 | </div> 195 | 196 | <style> 197 | span.icon i.fa-palette:before { 198 | color: var(--color); 199 | text-shadow: '-1px 0 black, 0 1px black, 1px 0 black, 0 -1px black'; 200 | } 201 | </style> 202 | -------------------------------------------------------------------------------- /src/renderer/src/UseSharedStore.ts: -------------------------------------------------------------------------------- 1 | import type { Readable, Writable } from 'svelte/store' 2 | import { getContext, hasContext, setContext } from 'svelte' 3 | import { readable, writable } from 'svelte/store' 4 | 5 | export const useSharedStore = <T, A>(name: string, fn: (value?: A) => T, defaultValue?: A): T => { 6 | if (hasContext(name)) { 7 | return getContext<T>(name) 8 | } 9 | const _value = fn(defaultValue) 10 | setContext(name, _value) 11 | return _value 12 | } 13 | 14 | export const useWritable = <T>(name: string, value: T): Writable<T> => 15 | useSharedStore(name, writable, value) 16 | 17 | export const useReadable = <T>(name: string, value: T): Readable<T> => 18 | useSharedStore(name, readable, value) 19 | -------------------------------------------------------------------------------- /src/renderer/src/Utils.ts: -------------------------------------------------------------------------------- 1 | export const enum ConnectionType { 2 | HOST = 'host', 3 | PARTICIPANT = 'participant' 4 | } 5 | 6 | export type RTCSessionDescriptionOptions = RTCSessionDescriptionInit 7 | 8 | export const externalLinkClickHandler = (root: HTMLButtonElement, url: string): void => { 9 | root.classList.add('is-loading') 10 | setTimeout(() => { 11 | root.classList.remove('is-loading') 12 | }, 3000) 13 | window.open(url) 14 | } 15 | 16 | export const getUUIDv4 = (): string => { 17 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 18 | const r = (Math.random() * 16) | 0 19 | const v = c === 'x' ? r : (r & 0x3) | 0x8 20 | return v.toString(16) 21 | }) 22 | } 23 | 24 | /* eslint-disable @typescript-eslint/no-explicit-any */ 25 | export const compressJson = async (data: any): Promise<string> => { 26 | const stream = new Blob([JSON.stringify(data)], { 27 | type: 'application/json' 28 | }).stream() 29 | const compressedStream = stream.pipeThrough(new CompressionStream('gzip')) 30 | const compressedResponse = new Response(compressedStream) 31 | const blob = await compressedResponse.blob() 32 | const buffer = await blob.arrayBuffer() 33 | return btoa(String.fromCharCode(...new Uint8Array(buffer))) 34 | } 35 | 36 | /* eslint-disable @typescript-eslint/no-explicit-any */ 37 | export const decompressJson = async (data: string): Promise<any> => { 38 | const buffer = new Uint8Array( 39 | atob(data) 40 | .split('') 41 | .map((c) => c.charCodeAt(0)) 42 | ) 43 | const stream = new Blob([buffer], { 44 | type: 'application/json' 45 | }).stream() 46 | const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip')) 47 | const res = new Response(decompressedStream) 48 | const blob = await res.blob() 49 | return JSON.parse(await blob.text()) 50 | } 51 | 52 | export const mayBeConnectionString = (ct: ConnectionType, str: string): boolean => { 53 | try { 54 | const url = new URL(str) 55 | if (url.protocol !== 'bananas:') return false 56 | if (url.pathname.slice(2) !== ct) return false 57 | const token = url.searchParams.get('token') 58 | const username = url.searchParams.get('username') 59 | if (!token || !username) return false 60 | decompressJson(token) 61 | return true 62 | } catch (err) { 63 | return false 64 | } 65 | } 66 | 67 | export const getConnectionString = async ( 68 | ct: ConnectionType, 69 | offer: RTCSessionDescriptionInit, 70 | data: { 71 | username: string 72 | } 73 | ): Promise<string> => { 74 | const { username } = data 75 | const token = encodeURIComponent(await compressJson(offer)) 76 | return `bananas://${ct}?username=${encodeURIComponent(username)}&token=${token}` 77 | } 78 | 79 | export const getDataFromBananasUrl = async ( 80 | url: string 81 | ): Promise<{ 82 | type: ConnectionType 83 | data: { username: string } 84 | rtcSessionDescription: RTCSessionDescriptionInit 85 | }> => { 86 | const u = new URL(url) 87 | const token = u.searchParams.get('token') 88 | const username = u.searchParams.get('username') 89 | return { 90 | type: u.pathname.slice(2) as ConnectionType, 91 | data: { 92 | username: username 93 | }, 94 | rtcSessionDescription: await decompressJson(decodeURIComponent(token)) 95 | } 96 | } 97 | 98 | export const makeVideoDraggable = (video: HTMLVideoElement): void => { 99 | let startX: number 100 | let startY: number 101 | let initialX: number 102 | let initialY: number 103 | let isDragging = false 104 | video.addEventListener('mousedown', (e) => { 105 | isDragging = true 106 | startX = e.clientX 107 | startY = e.clientY 108 | const transform = getComputedStyle(video).transform 109 | 110 | if (transform !== 'none') { 111 | const values = transform.split('(')[1].split(')')[0].split(',') 112 | initialX = parseFloat(values[4]) 113 | initialY = parseFloat(values[5]) 114 | } else { 115 | initialX = 0 116 | initialY = 0 117 | } 118 | video.style.cursor = 'grabbing' 119 | }) 120 | document.addEventListener('mousemove', (e) => { 121 | if (!isDragging) return 122 | 123 | const deltaX = e.clientX - startX 124 | const deltaY = e.clientY - startY 125 | 126 | const moveX = initialX + deltaX 127 | const moveY = initialY + deltaY 128 | 129 | video.style.transform = `translate(${moveX}px, ${moveY}px)` 130 | }) 131 | 132 | document.addEventListener('mouseup', () => { 133 | if (!isDragging) return 134 | isDragging = false 135 | video.style.cursor = 'default' 136 | }) 137 | } 138 | 139 | export const debounce = <T extends (...args: unknown[]) => void>( 140 | func: T, 141 | wait: number 142 | ): ((...args: Parameters<T>) => void) => { 143 | let timeout: ReturnType<typeof setTimeout> 144 | return (...args: Parameters<T>): void => { 145 | clearTimeout(timeout) 146 | timeout = setTimeout(() => { 147 | func(...args) 148 | }, wait) 149 | } 150 | } 151 | 152 | export const throttle = <T extends (...args: unknown[]) => void>( 153 | func: T, 154 | wait: number 155 | ): ((...args: Parameters<T>) => void) => { 156 | let lastCalled = 0 157 | return (...args: Parameters<T>): void => { 158 | const now = Date.now() 159 | if (now - lastCalled < wait) return 160 | lastCalled = now 161 | func(...args) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/renderer/src/WebRTC.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import type { RTCSessionDescriptionOptions } from './Utils' 3 | import type { BananasRemoteCursorData, SettingsData } from './BananasTypes' 4 | import { getConnectionString, ConnectionType } from './Utils' 5 | import { getRTCPeerConnectionConfig } from './Config' 6 | 7 | export let connectionState: string = 'disconnected' 8 | 9 | const errorHander = (e: ErrorEvent): void => { 10 | console.error(e) 11 | } 12 | 13 | let remoteVideo: HTMLVideoElement | null = null 14 | let pc: RTCPeerConnection | null = null 15 | let remoteCursorPositionsEnabled = false 16 | let remoteMouseCursorPositionsChannel: RTCDataChannel | null = null 17 | let remoteCursorPingChannel: RTCDataChannel | null = null 18 | let audioStream: MediaStream | null = null 19 | let stream: MediaStream | null = null 20 | let audioElement: HTMLAudioElement | null = null 21 | let userSettings: SettingsData | null = null 22 | 23 | const remoteMouseCursorPositionsChannelIsReady = (): boolean => { 24 | if (!remoteMouseCursorPositionsChannel) return false 25 | if (remoteMouseCursorPositionsChannel.readyState === 'open') return true 26 | return false 27 | } 28 | 29 | const remoteCursorPingChannelIsReady = (): boolean => { 30 | if (!remoteCursorPingChannel) return false 31 | if (remoteCursorPingChannel.readyState === 'open') return true 32 | return false 33 | } 34 | 35 | const setupDataChannel = (dc: RTCDataChannel): void => { 36 | if (dc.label === 'remoteMouseCursorPositions') { 37 | remoteMouseCursorPositionsChannel = dc 38 | dc.onmessage = function (e: MessageEvent): void { 39 | if (!remoteCursorPositionsEnabled) return 40 | if (remoteVideo) return 41 | const data = JSON.parse(e.data) 42 | window.BananasApi.updateRemoteCursor(data) 43 | } 44 | } 45 | if (dc.label === 'remoteCursorPing') { 46 | remoteCursorPingChannel = dc 47 | dc.onmessage = function (e: MessageEvent): void { 48 | if (!remoteCursorPositionsEnabled) return 49 | if (remoteVideo) return 50 | window.BananasApi.remoteCursorPing(e.data) 51 | } 52 | } 53 | } 54 | export function PingRemoteCursor(cursorId: string): void { 55 | if (!remoteCursorPingChannelIsReady()) { 56 | console.error('remoteCursorPingChannel not ready') 57 | return 58 | } 59 | remoteCursorPingChannel.send(cursorId) 60 | } 61 | export function UpdateRemoteCursor(cursorData: BananasRemoteCursorData): void { 62 | if (!remoteMouseCursorPositionsChannelIsReady()) { 63 | console.error('remoteMouseCursorPositionsChannel not ready') 64 | return 65 | } 66 | remoteMouseCursorPositionsChannel.send(JSON.stringify(cursorData)) 67 | } 68 | export function HasAudioInput(): boolean { 69 | return audioStream !== null 70 | } 71 | export function GetAudioStream(): MediaStream | null { 72 | return audioStream 73 | } 74 | export function ToggleRemoteCursors(enabled: boolean): boolean { 75 | if (!remoteMouseCursorPositionsChannel) return false 76 | if (remoteMouseCursorPositionsChannel.readyState !== 'open') return false 77 | remoteCursorPositionsEnabled = enabled 78 | return enabled 79 | } 80 | export async function Setup(v: HTMLVideoElement = null): Promise<void> { 81 | userSettings = await window.BananasApi.getSettings() 82 | remoteVideo = v 83 | audioElement = document.createElement('audio') 84 | audioElement.controls = true 85 | audioElement.autoplay = true 86 | if (pc) { 87 | pc.close() 88 | pc = null 89 | } 90 | pc = new RTCPeerConnection(await getRTCPeerConnectionConfig()) 91 | pc.ondatachannel = (e: RTCDataChannelEvent): void => { 92 | if (e.channel.label === 'remoteMouseCursorPositions') { 93 | setupDataChannel(e.channel) 94 | } 95 | if (e.channel.label === 'remoteCursorPing') { 96 | setupDataChannel(e.channel) 97 | } 98 | } 99 | pc.ontrack = (evt): void => { 100 | if (remoteVideo) { 101 | remoteVideo.srcObject = evt.streams[0] 102 | } 103 | if (audioStream) { 104 | audioElement.srcObject = evt.streams[0] 105 | } 106 | } 107 | pc.onicecandidate = function (e: RTCPeerConnectionIceEvent): void { 108 | const cand = e.candidate 109 | if (!cand) { 110 | console.log('icecandidate gathering: complete') 111 | } else { 112 | console.log('new icecandidate') 113 | } 114 | } 115 | pc.oniceconnectionstatechange = function (): void { 116 | connectionState = pc.iceConnectionState 117 | } 118 | try { 119 | audioStream = await navigator.mediaDevices.getUserMedia({ 120 | video: false, 121 | audio: true 122 | }) 123 | } catch (e) { 124 | errorHander(e) 125 | } 126 | if (!remoteVideo) { 127 | try { 128 | stream = await navigator.mediaDevices.getDisplayMedia({ 129 | video: true, 130 | audio: false 131 | }) 132 | for (const track of stream.getTracks()) { 133 | pc.addTrack(track, stream) 134 | } 135 | if (audioStream) { 136 | for (const track of audioStream.getTracks()) { 137 | track.enabled = userSettings.isMicrophoneEnabledOnConnect 138 | pc.addTrack(track, stream) 139 | } 140 | } 141 | } catch (e) { 142 | errorHander(e) 143 | } 144 | } else { 145 | if (audioStream) { 146 | for (const track of audioStream.getTracks()) { 147 | track.enabled = userSettings.isMicrophoneEnabledOnConnect 148 | pc.addTrack(track, audioStream) 149 | } 150 | } 151 | } 152 | } 153 | export async function CreateParticipantUrl( 154 | c: RTCSessionDescriptionOptions, 155 | data: { username: string } 156 | ): Promise<string> { 157 | try { 158 | const desc = new RTCSessionDescription(c) 159 | await pc.setRemoteDescription(desc) 160 | if (desc.type === 'offer') { 161 | const answer = await pc.createAnswer() 162 | await pc.setLocalDescription(answer) 163 | } 164 | } catch (e) { 165 | errorHander(e) 166 | } 167 | return await getConnectionString(ConnectionType.PARTICIPANT, pc.localDescription, data) 168 | } 169 | export async function CreateHostUrl(data: { username: string }): Promise<string> { 170 | remoteMouseCursorPositionsChannel = pc.createDataChannel('remoteMouseCursorPositions') 171 | remoteCursorPingChannel = pc.createDataChannel('remoteCursorPing') 172 | setupDataChannel(remoteMouseCursorPositionsChannel) 173 | setupDataChannel(remoteCursorPingChannel) 174 | const desc = await pc.createOffer() 175 | await pc.setLocalDescription(desc) 176 | return await getConnectionString(ConnectionType.HOST, pc.localDescription, data) 177 | } 178 | export function ToggleDisplayStream(): void { 179 | if (stream) { 180 | for (const track of stream.getVideoTracks()) { 181 | track.enabled = !track.enabled 182 | } 183 | } 184 | } 185 | export function ToggleMicrophone(): void { 186 | if (audioStream) { 187 | for (const track of audioStream.getAudioTracks()) { 188 | track.enabled = !track.enabled 189 | } 190 | } 191 | } 192 | export function IsMicrophoneActive(): boolean { 193 | if (audioStream) { 194 | for (const track of audioStream.getAudioTracks()) { 195 | return track.enabled 196 | } 197 | } 198 | return false 199 | } 200 | export async function Connect(c: RTCSessionDescriptionOptions): Promise<void> { 201 | try { 202 | const desc = new RTCSessionDescription(c) 203 | await pc.setRemoteDescription(desc) 204 | if (desc.type === 'offer') { 205 | const answer = await pc.createAnswer() 206 | await pc.setLocalDescription(answer) 207 | } 208 | } catch (e) { 209 | errorHander(e) 210 | } 211 | } 212 | export function IsConnected(): boolean { 213 | return pc ? pc.connectionState === 'connected' : false 214 | } 215 | export async function Disconnect(): Promise<void> { 216 | try { 217 | pc.close() 218 | pc = null 219 | if (stream) { 220 | for (const track of stream.getTracks()) { 221 | track.stop() 222 | } 223 | stream = null 224 | } 225 | if (audioStream) { 226 | for (const track of audioStream.getTracks()) { 227 | track.stop() 228 | } 229 | audioStream = null 230 | } 231 | } catch (e) { 232 | errorHander(e) 233 | } 234 | } 235 | </script> 236 | -------------------------------------------------------------------------------- /src/renderer/src/cursors.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | overflow: hidden; 4 | } 5 | .cursor { 6 | display: block; 7 | position: absolute; 8 | width: 24px; 9 | height: 24px; 10 | } 11 | 12 | .cursor .name { 13 | display: block; 14 | text-shadow: '-1px 0 black, 0 1px black, 1px 0 black, 0 -1px black'; 15 | color: var(--name-color); 16 | } 17 | 18 | .cursor svg { 19 | max-width: 24px; 20 | fill: var(--cursor-color); 21 | } 22 | 23 | .is-hidden { 24 | display: none !important; 25 | } 26 | 27 | .cursor .ping { 28 | width: 48px; 29 | height: 48px; 30 | border: 5px solid var(--ping-color); 31 | border-radius: 50%; 32 | display: inline-block; 33 | box-sizing: border-box; 34 | position: absolute; 35 | top: -12px; 36 | left: -12px; 37 | animation: pulse 1s linear infinite; 38 | } 39 | 40 | .cursor .ping:after { 41 | content: ''; 42 | position: absolute; 43 | width: 48px; 44 | height: 48px; 45 | border: 5px solid var(--ping-color); 46 | border-radius: 50%; 47 | display: inline-block; 48 | box-sizing: border-box; 49 | left: 50%; 50 | top: 50%; 51 | transform: translate(-50%, -50%); 52 | animation: scaleUp 1s linear infinite; 53 | } 54 | 55 | @keyframes scaleUp { 56 | 0% { 57 | transform: translate(-50%, -50%) scale(0); 58 | } 59 | 60%, 60 | 100% { 61 | transform: translate(-50%, -50%) scale(1); 62 | } 63 | } 64 | @keyframes pulse { 65 | 0%, 66 | 60%, 67 | 100% { 68 | transform: scale(1); 69 | } 70 | 80% { 71 | transform: scale(1.2); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/renderer/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="svelte" /> 2 | /// <reference types="vite/client" /> 3 | import { ElectronAPI } from '@electron-toolkit/preload' 4 | 5 | type IceServer = { 6 | urls: string 7 | username?: string 8 | credential?: string 9 | } 10 | 11 | declare global { 12 | interface Window { 13 | electron: ElectronAPI 14 | BananasApi: { 15 | toggleRemoteCursors: (state: boolean) => Promise<void> 16 | remoteCursorPing: (cursorId: string) => Promise<void> 17 | updateRemoteCursor: (state: { 18 | id: string 19 | name: string 20 | color: string 21 | x: number 22 | y: number 23 | }) => Promise<void> 24 | updateSettings: (settings: { 25 | username: string 26 | language: string 27 | color: string 28 | isMicrophoneEnabledOnConnect: boolean 29 | iceServers: IceServer[] 30 | }) => Promise<void> 31 | getSettings: () => Promise<{ 32 | username: string 33 | color: string 34 | language: string 35 | isMicrophoneEnabledOnConnect: boolean 36 | iceServers: IceServer[] 37 | }> 38 | getAppVersion: () => Promise<string> 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/renderer/src/main.ts: -------------------------------------------------------------------------------- 1 | import '@fortawesome/fontawesome-free/css/all.min.css' 2 | import 'bulma/css/bulma.min.css' 3 | import '@sweetalert2/theme-bulma/bulma.min.css' 4 | import App from './App.svelte' 5 | import './overrides.css' 6 | 7 | const app = new App({ 8 | target: document.getElementById('app') 9 | }) 10 | 11 | export default app 12 | -------------------------------------------------------------------------------- /src/renderer/src/overrides.css: -------------------------------------------------------------------------------- 1 | html { 2 | overflow-y: auto; 3 | } 4 | 5 | span.color-picker div.wrapper.is-open { 6 | background-color: var(--bulma-border); 7 | } 8 | 9 | div.swal2-container div.swal2-popup { 10 | background-color: var(--bulma-background); 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/src/stores.ts: -------------------------------------------------------------------------------- 1 | import type { Writable } from 'svelte/store' 2 | import { useWritable } from './UseSharedStore' 3 | 4 | export const useActiveView = (): Writable<string> => useWritable('activeView', 'join') 5 | 6 | export const useNavigationEnabled = (): Writable<boolean> => useWritable('navigationEnabled', true) 7 | 8 | export const useIsHosting = (): Writable<boolean> => useWritable('isHosting', false) 9 | 10 | export const useIsWatching = (): Writable<boolean> => useWritable('useIsWatching', false) 11 | 12 | export const useHostUrl = (): Writable<string> => useWritable('useHostUrl', '') 13 | 14 | export const useParticipantUrl = (): Writable<string> => useWritable('useParticipantUrl', '') 15 | -------------------------------------------------------------------------------- /src/renderer/src/translations.ts: -------------------------------------------------------------------------------- 1 | import { loadLocale } from '../../i18n/i18n-util.sync' 2 | import { i18nObject } from '../../i18n/i18n-util' 3 | import type { Locales } from '../../i18n/i18n-types' 4 | 5 | const settings = await window.BananasApi.getSettings() 6 | 7 | const locale = (settings?.language as Locales) || 'en' 8 | 9 | loadLocale(locale) 10 | export const L = i18nObject(locale) 11 | -------------------------------------------------------------------------------- /svelte.config.mjs: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-node' 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 3 | 4 | export default { 5 | kit: { 6 | adapter: adapter({}) 7 | }, 8 | extensions: ['.svelte'], 9 | preprocess: vitePreprocess() 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.json", 3 | "include": [ 4 | "src/renderer/src/env.d.ts", 5 | "src/renderer/src/**/*", 6 | "src/renderer/src/**/*.svelte", 7 | "src/preload/*.d.ts" 8 | ], 9 | "compilerOptions": { 10 | "verbatimModuleSyntax": true, 11 | "useDefineForClassFields": true, 12 | "strict": false, 13 | "allowJs": true, 14 | "checkJs": true, 15 | "lib": ["ESNext", "DOM", "DOM.Iterable"] 16 | }, 17 | "references": [{ "path": "./tsconfig.node.json" }] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["electron-vite/node"] 7 | } 8 | } 9 | --------------------------------------------------------------------------------