Repository: buttercup/buttercup-browser-extension Branch: master Commit: 4a5e2c359fb4 Files: 165 Total size: 334.7 KB Directory structure: gitextract_eo89ckcu/ ├── .editorconfig ├── .github/ │ └── workflows/ │ └── test.yml ├── .gitignore ├── .prettierrc ├── .vscode/ │ └── settings.json ├── CHANGELOG.md ├── LICENSE ├── PRIVACY_POLICY.md ├── README.md ├── package.json ├── resources/ │ ├── full.pug │ ├── manifest.v2.json │ ├── manifest.v3.json │ └── popup.pug ├── scripts/ │ └── version.js ├── source/ │ ├── background/ │ │ ├── index.ts │ │ ├── library/ │ │ │ └── domain.ts │ │ ├── services/ │ │ │ ├── autoLogin.ts │ │ │ ├── config.ts │ │ │ ├── crypto.ts │ │ │ ├── cryptoKeys.ts │ │ │ ├── desktop/ │ │ │ │ ├── actions.ts │ │ │ │ ├── header.ts │ │ │ │ └── request.ts │ │ │ ├── disabledDomains.ts │ │ │ ├── entry.ts │ │ │ ├── init.ts │ │ │ ├── log.ts │ │ │ ├── loginMemory.ts │ │ │ ├── messaging.ts │ │ │ ├── notifications.ts │ │ │ ├── recents.ts │ │ │ ├── storage/ │ │ │ │ └── BrowserStorageInterface.ts │ │ │ ├── storage.ts │ │ │ └── tabs.ts │ │ └── types.ts │ ├── full/ │ │ ├── applications/ │ │ │ └── full.tsx │ │ ├── components/ │ │ │ ├── App.tsx │ │ │ ├── Layout.tsx │ │ │ └── pages/ │ │ │ ├── AttributionsPage.tsx │ │ │ ├── DisabledDomainsPage.tsx │ │ │ ├── NotificationsPage.tsx │ │ │ ├── connect/ │ │ │ │ ├── CodeInput.tsx │ │ │ │ ├── ConnectPage.tsx │ │ │ │ └── index.tsx │ │ │ └── saveCredentials/ │ │ │ ├── CredentialsSaver.tsx │ │ │ ├── CredentialsSelector.tsx │ │ │ ├── NewEntrySavePrompt.tsx │ │ │ └── index.tsx │ │ ├── hooks/ │ │ │ ├── credentials.ts │ │ │ ├── disabledDomains.ts │ │ │ ├── document.ts │ │ │ └── vaultContents.ts │ │ ├── index.pug │ │ ├── services/ │ │ │ ├── credentials.ts │ │ │ ├── disabledDomains.ts │ │ │ ├── init.ts │ │ │ ├── log.ts │ │ │ ├── notifications.ts │ │ │ └── vaults.ts │ │ ├── styles/ │ │ │ └── full.sass │ │ └── types.ts │ ├── popup/ │ │ ├── applications/ │ │ │ └── popup.tsx │ │ ├── components/ │ │ │ ├── App.tsx │ │ │ ├── contexts/ │ │ │ │ └── LaunchContext.tsx │ │ │ ├── entries/ │ │ │ │ ├── EntryInfoDialog.tsx │ │ │ │ ├── EntryItem.tsx │ │ │ │ └── EntryItemList.tsx │ │ │ ├── navigation/ │ │ │ │ └── Navigator.tsx │ │ │ ├── otps/ │ │ │ │ ├── OTPItem.tsx │ │ │ │ └── OTPItemList.tsx │ │ │ ├── pages/ │ │ │ │ ├── AboutPage.tsx │ │ │ │ ├── EntriesPage.tsx │ │ │ │ ├── OTPsPage.tsx │ │ │ │ ├── SaveDialogPage.tsx │ │ │ │ ├── SettingsPage.tsx │ │ │ │ └── VaultsPage.tsx │ │ │ └── vaults/ │ │ │ ├── VaultItem.tsx │ │ │ ├── VaultItemList.tsx │ │ │ └── VaultStateIndicator.tsx │ │ ├── hooks/ │ │ │ ├── credentials.ts │ │ │ ├── desktop.ts │ │ │ ├── document.ts │ │ │ ├── otp.ts │ │ │ └── tab.ts │ │ ├── index.pug │ │ ├── queries/ │ │ │ ├── desktop.ts │ │ │ ├── disabledDomains.ts │ │ │ └── loginMemory.ts │ │ ├── services/ │ │ │ ├── clipboard.ts │ │ │ ├── entry.ts │ │ │ ├── init.ts │ │ │ ├── log.ts │ │ │ ├── recents.ts │ │ │ ├── reset.ts │ │ │ └── tab.ts │ │ ├── state/ │ │ │ └── app.ts │ │ ├── styles/ │ │ │ └── popup.sass │ │ └── types.ts │ ├── shared/ │ │ ├── components/ │ │ │ ├── ConfirmDialog.tsx │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── ErrorMessage.tsx │ │ │ ├── RouteError.tsx │ │ │ ├── ThemeProvider.tsx │ │ │ └── loading/ │ │ │ └── BusyLoader.tsx │ │ ├── extension.ts │ │ ├── hooks/ │ │ │ ├── async.ts │ │ │ ├── config.ts │ │ │ ├── global.ts │ │ │ ├── theme.ts │ │ │ └── timer.ts │ │ ├── i18n/ │ │ │ ├── trans.ts │ │ │ └── translations/ │ │ │ ├── en.json │ │ │ └── nl.json │ │ ├── library/ │ │ │ ├── buffer.ts │ │ │ ├── clone.ts │ │ │ ├── domain.ts │ │ │ ├── error.ts │ │ │ ├── extension.ts │ │ │ ├── i18n.ts │ │ │ ├── log.ts │ │ │ ├── otp.ts │ │ │ ├── url.ts │ │ │ ├── vaultTypes.ts │ │ │ └── version.ts │ │ ├── notifications/ │ │ │ ├── index.ts │ │ │ └── pages/ │ │ │ └── WelcomeV3.tsx │ │ ├── queries/ │ │ │ └── config.ts │ │ ├── services/ │ │ │ ├── messaging.ts │ │ │ └── notifications.ts │ │ ├── styles/ │ │ │ ├── base.sass │ │ │ └── fonts.sass │ │ ├── symbols.ts │ │ ├── themes.ts │ │ └── types.ts │ ├── tab/ │ │ ├── index.ts │ │ ├── library/ │ │ │ ├── disable.ts │ │ │ ├── dismount.ts │ │ │ ├── frames.ts │ │ │ ├── page.ts │ │ │ ├── position.ts │ │ │ ├── resize.ts │ │ │ ├── styles.ts │ │ │ └── zIndex.ts │ │ ├── services/ │ │ │ ├── LoginTracker.ts │ │ │ ├── autoLogin.ts │ │ │ ├── config.ts │ │ │ ├── form.ts │ │ │ ├── formDetection.ts │ │ │ ├── init.ts │ │ │ ├── log.ts │ │ │ ├── logins/ │ │ │ │ ├── disabled.ts │ │ │ │ ├── saving.ts │ │ │ │ └── watcher.ts │ │ │ └── messaging.ts │ │ ├── state/ │ │ │ ├── form.ts │ │ │ └── frame.ts │ │ ├── types.ts │ │ └── ui/ │ │ ├── launch.ts │ │ ├── popup.ts │ │ └── saveDialog.ts │ └── typings/ │ ├── assets.d.ts │ └── globals.d.ts ├── tsconfig.json └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 [{.prettierrc,package.json,package-lock.json,.babelrc}] indent_size = 2 ================================================ FILE: .github/workflows/test.yml ================================================ name: Tests on: push jobs: lint: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x] steps: - uses: actions/checkout@v2 - name: Node.js specs ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm run test:format release: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x] steps: - uses: actions/checkout@v2 - name: Node.js specs ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm run release ================================================ FILE: .gitignore ================================================ *.log .DS_Store .history node_modules /dist /release Archive.zip dist.zip /secrets.json ================================================ FILE: .prettierrc ================================================ { "printWidth": 120, "tabWidth": 4, "trailingComma": "none" } ================================================ FILE: .vscode/settings.json ================================================ { "vsicons.presets.angular": false, "eslint.enable": false, "files.associations": { "**/*.js": "javascriptreact" }, "typescript.preferences.importModuleSpecifierEnding": "js" } ================================================ FILE: CHANGELOG.md ================================================ # Buttercup browser extension changelog ## v3.2.0 _2024-04-09_ * Input button customisation (global) * Dutch language (not yet selectable) * **Bugfix**: * Some fields not filled correctly (partial fix) * Login save prompt shown for existing credentials ## v3.1.0 _2024-03-27_ * ([#467](https://github.com/buttercup/buttercup-browser-extension/issues/467)) Entry info popup * **Bugfix**: * ([#469](https://github.com/buttercup/buttercup-browser-extension/issues/469)) Forms recognised as login forms when they should not be * ([#466](https://github.com/buttercup/buttercup-browser-extension/issues/466)) Bad OTP URIs crash application * Entry page search does nothing when first opening the extension ## v3.0.0 _2024-03-26_ * **Major rewrite** * Requires desktop application for vault access * Nested iframe traversal * OTP support ## v2.26.0 _2023-11-08_ * **Important version 3 update notice** * Updates for core libraries and datasources ## v2.25.3 _2023-01-31_ * **Bugfix**: * Google Drive would fail when tokens expire (new response format) ## v2.25.2 _2022-09-03_ * **Bugfix**: * Fixed Dropbox connectivity issues * Fixed Google Drive re-authentication loop, short auth time ## v2.25.1 _2022-08-16_ * **Bugfix**: * Format B saving new properties would fail * Format B conversion would omit history ## v2.25.0 _2022-06-02_ * Buttercup upgrade: v6 * Improved Dropbox/Google Drive integrations * Improved vault stability and performance * Improved support for Vault Format B * Removed My Buttercup integration * Vault editor page redesign * Updated vault UI ## v2.24.3 _2021-05-24_ * **Bugfix**: * Vault pages not taking up 100% of the height of the window ## v2.24.2 _2021-05-22_ * **Bugfix**: * ([#381](https://github.com/buttercup/buttercup-browser-extension/issues/381)) Search results not showing most recent result (URL detection) * WebDAV connection crashes tab during failed connection attempt (when adding a vault) * **Critical auto-update issue**: Core crash when receiving updated vaults in the background ## v2.24.1 _2021-01-06_ * Remove `activeTab` permission requirement * **Bugfix**: * ([#393](https://github.com/buttercup/buttercup-browser-extension/issues/393)) Copy to clipboard not working for in-page dialog ## v2.24.0 _2020-12-09_ * Site icons for results within in-page search dialog * Performance improvements regarding search * Vault unlock/save performance improvements ## v2.23.1 _2020-11-27_ * **Bugfix**: * ([#368](https://github.com/buttercup/buttercup-browser-extension/issues/368)) Popup / Dialog menus very slow to open (performance bugfix for search) ## v2.23.0 _2020-09-05_ * Dynamic icons defaults to **enabled** * Removed dynamic icons setting popup page * **Bugfix**: * ([#366](https://github.com/buttercup/buttercup-browser-extension/issues/366)) Google Drive bad refresh-token method call ## v2.22.0 _2020-09-04_ * Core group/entry lookup performance upgrades * **Bugfix**: * ([#370](https://github.com/buttercup/buttercup-browser-extension/issues/370)) Critical CPU/memory use after some time * Entries not able to be moved from group to group ## v2.21.0 _2020-08-30_ * **Buttercup Core v5** * Improved performance * Improved stability * Future support for **Vault Format B** * Dynamic icons for entries (optional) * Reduced extension size (< 50% of the size of 2.20.2) * Removed `Buffer` dependencies * **Bugfix**: * Search wouldn't work (no results) ## v2.20.2 _2020-08-19_ * **Bugfix**: * ([buttercup-core#287](https://github.com/buttercup/buttercup-core/issues/287)) Vaults grow to enormous size ## v2.20.1 _2020-08-03_ * **Attachments** (My Buttercup) * Add, remove, preview and download attachments when using My Buttercup vaults * Core memory/stability improvements when merging vault changes from remote sources ## v2.19.0 _2020-07-25_ * New Buttercup form button behaviour (to improve login form stability) * Clipboard-writing permission for certain browsers * Improved auto-update stability ## v2.18.0 _2020-07-07_ * Search results won't show items in trash * **Bugfix**: * ([#337](https://github.com/buttercup/buttercup-browser-extension/pull/337)) No login-save-prompt when entry selected for login form (includes auto login) ## v2.17.0 _2020-07-05_ * New search functionality * Result scoring per domain * Vault type icons on unlock-all-vaults page ## v2.16.2 _2020-07-02_ * **Bugfix**: * Unable to enter form details when selecting entry result in dialog ## v2.16.1 _2020-07-01_ * **Bugfix**: * Unable to select vault in save credentials form * No search results in popup dialog * Broken vault lock state in menu ## v2.16.0 _2020-06-30_ * Core version 4 * My Buttercup datasource support * ([#340](https://github.com/buttercup/buttercup-browser-extension/pull/340)) Allow localhost in disabled domains ## v2.15.1 _2020-04-02_ * **Bugfix**: * WebDAV would fail to connect on some services, such as ownCloud ## v2.15.0 _2020-03-18_ * Disable save prompt for domains * Memory for all login form inputs * **Bugfix**: * Buttons would disappear from some forms (Dropbox) ## v2.14.0 _2020-02-03_ * Upgrade webdav for reduced application size * **Bugfix**: * ([#325](https://github.com/buttercup/buttercup-browser-extension/issues/325)) New vaults fail to create * ([#286](https://github.com/buttercup/buttercup-browser-extension/issues/286)) Unlock-vaults page not opening in FF after clicking button in on-page dialog ## v2.13.1 _2020-01-24_ * **Bugfix**: * ([#324](https://github.com/buttercup/buttercup-browser-extension/issues/324)) Very slow vault contents navigation * ([#323](https://github.com/buttercup/buttercup-browser-extension/issues/323)) OTP (HOTP) failures crashing entire vault management UI * ([#322](https://github.com/buttercup/buttercup-browser-extension/issues/322)) Auto-update of search results by URL not working ## v2.13.0 _2020-01-22_ * Group context menu: creation, renaming, moving and deletion * New group in root button * Entry field history (basic) * **Bugfix**: * Credit card entry type would crash application * State sync for vault management interface inconsistent ## v2.12.0 _2020-01-18_ * ([#320](https://github.com/buttercup/buttercup-browser-extension/issues/320)) Open permissions option when adding Google Drive vaults * **Bugfix**: * ([#270](https://github.com/buttercup/buttercup-browser-extension/issues/270)) _(2nd attempt)_: Support multiple Google accounts * ([#319](https://github.com/buttercup/buttercup-browser-extension/issues/319)) Google Drive authentication not working in Microsoft Edge (unofficial patch - pre-release) ## v2.11.1 _2020-01-08_ * **Bugfix**: * ([#316](https://github.com/buttercup/buttercup-browser-extension/issues/316)) `createSession` is not defined (local file host vaults) ## v2.11.0 _2020-01-05_ * Core integration with App-Env for web-based crypto improvement * ([#270](https://github.com/buttercup/buttercup-browser-extension/issues/270)) Prompt for account-selection on Google authentication (Google Drive) to support multiple accounts * ([#245](https://github.com/buttercup/buttercup-browser-extension/issues/245)) Google Drive permissions reduced - Only files touched by Buttercup are accessible to the application * **Bugfix**: * ([#314](https://github.com/buttercup/buttercup-browser-extension/issues/314)) Unable to open Google Drive vault ## v2.10.1 _2019-12-26_ * **Bugfix**: * ([#312](https://github.com/buttercup/buttercup-browser-extension/issues/312)) No login prompt visible on Firefox ## v2.10.0 _2019-12-24_ * Ability to change vault password * **Bugfix**: * ([#307](https://github.com/buttercup/buttercup-browser-extension/issues/307)) Cannot save new note-type entry (or any other custom types) ## v2.9.0 _2019-11-12_ * My Buttercup preparation * ownCloud/Nextcloud removed in favour of WebDAV (existing connections should still function) * ([#95](https://github.com/buttercup/buttercup-browser-extension/issues/95)) Context menus to choose credentials for form-filling and login ## v2.8.2 _2019-09-01_ * **Bugfix**: * ([#269](https://github.com/buttercup/buttercup-browser-extension/issues/269)) Password field in popup menu not copy-able and always visible ## v2.8.1 _2019-09-01_ * **Bugfix**: * ([#253](https://github.com/buttercup/buttercup-browser-extension/issues/253)) Vault saving via editing UI (second attempt) ## v2.8.0 _2019-07-23_ * **Bugfix**: * ~~([#253](https://github.com/buttercup/buttercup-browser-extension/issues/253)) Vault saving via editing UI~~ * ([#259](https://github.com/buttercup/buttercup-browser-extension/pull/259)) General improvements to the add-vault page * Unlock button on in-page dialog when vaults are locked * Unlock button for single-vault now navigates to edit page * TOTP / HOTP support via vault UI (display only, no form-fill) * Entry value type support via vault UI * Updated Dropbox/Google Drive clients for compatibiltiy ## v2.7.0 _2019-04-28_ * Vault editing interface ## v2.6.0 _2019-04-14_ * ([#246](https://github.com/buttercup/buttercup-browser-extension/issues/246)) Google Drive refresh token support ## v2.5.1 _2019-03-13_ * **Bugfix**: * ([#244](https://github.com/buttercup/buttercup-browser-extension/issues/244)) Google Drive fetching fails on large directories ## v2.5.0 _2019-03-09_ * **Google Drive** support * Unlock-vaults button in popup ## v2.4.1 _2019-02-05_ * **Bugfix**: * Regression in auto-unlock functionality ## v2.4.0 _2019-02-04_ * ([#235](https://github.com/buttercup/buttercup-browser-extension/issues/235)) Use local (static) icons and don't request them from remote sources * ([#171](https://github.com/buttercup/buttercup-browser-extension/issues/171)) Auto-lock vaults after a configurable time * Auto-login button in popup menu ## v2.3.1 _2019-01-19_ * **Bugfixes**: * ([#214](https://github.com/buttercup/buttercup-browser-extension/issues/214)) Popup menu layout broken for long items * ([#216](https://github.com/buttercup/buttercup-browser-extension/issues/216)) Autofocus extension popover search input ## v2.3.0 _2018-01-12_ * **Bugfixes**: * ([#217](https://github.com/buttercup/buttercup-browser-extension/issues/217)) Popup menu layout broken * ([#218](https://github.com/buttercup/buttercup-browser-extension/issues/218)) Some website forms not recognised * Improved entry details UI in popup menus * Improved entry results in popup menus * What's New section on auto-unlock page * Improved login form detection ## v2.2.0 _2019-01-05_ * ([#212](https://github.com/buttercup/buttercup-browser-extension/issues/212)) Poor search results performance * ([#202](https://github.com/buttercup/buttercup-browser-extension/issues/202)) Auto-unlock setting not working ## v2.1.3 _2018-12-16_ * ([#190](https://github.com/buttercup/buttercup-browser-extension/issues/190)) Dropbox connection never completes loading procedure (UI spinner) ## v2.1.2 _2018-12-08_ * ([#203](https://github.com/buttercup/buttercup-browser-extension/issues/203)) Failure saving Dropbox changes ## v2.1.1 _2018-11-27_ * **Bugfix**: ownCloud / Nextcloud / WebDAV vaults could not be added (Chrome) ## v2.1.0 _2018-11-24_ * ([#91](https://github.com/buttercup/buttercup-browser-extension/issues/91)) Connect through desktop application (local filesystem access) * New WebDAV client * New Dropbox client ## v2.0.0 _2018-10-29_ * **Major UI overhaul** * ([#180](https://github.com/buttercup/buttercup-browser-extension/issues/180)) Option to disable "save-new" dialog * ([#160](https://github.com/buttercup/buttercup-browser-extension/issues/160)) Settings page * ([#173](https://github.com/buttercup/buttercup-browser-extension/issues/173)) Source type is empty - display glitches (final cleanup) * Dark/Light mode themes * Setting for showing the auto-unlock page (default on) * Vault/Account sync via Chrome/Firefox's account logins (sync storage) * Show/Copy entry properties in dialog/popup menus ## v1.12.1 _2018-10-18_ * ([#173](https://github.com/buttercup/buttercup-browser-extension/issues/173)) Source type is empty - display glitches * ([#182](https://github.com/buttercup/buttercup-browser-extension/issues/182)) Add bundling process for Chrome/Firefox ## v1.12.0 _2018-10-07_ * ([#110](https://github.com/buttercup/buttercup-browser-extension/issues/110)) Reload archives after some time * ([#174](https://github.com/buttercup/buttercup-browser-extension/issues/174)) Unable to access via WebDAV (Seafile) ## v1.11.1 _2018-08-27_ * ([#164](https://github.com/buttercup/buttercup-browser-extension/issues/164)) `t is null` error when unlocking archives ## v1.11.0 _2018-08-24_ * ([#136](https://github.com/buttercup/buttercup-browser-extension/issues/136)) Use last generated password form context menu * ([#153](https://github.com/buttercup/buttercup-browser-extension/issues/153)) **Bugfix**: Button layout issues * Upgraded login form targetting ## v1.10.0 _2018-07-11_ * New popup menu design * Search and open items from the popup ## v1.9.1 _2018-07-08_ * ([#152](https://github.com/buttercup/buttercup-browser-extension/issues/152)) **Bugfix**: Failure while adding WebDAV archives ## v1.9.0 _2018-06-29_ * ([#131](https://github.com/buttercup/buttercup-browser-extension/issues/131)) Upgrade core to v2 * New archive format (supporting future encryption standards) * ([#130](https://github.com/buttercup/buttercup-browser-extension/issues/130)) Auto unlock prompt shown when browser is opened * ([#147](https://github.com/buttercup/buttercup-browser-extension/issues/147)) Remove settings page link ## v1.8.0 _2018-06-22_ * Upgrade core to 1.7.1 * Future proofing for archive format ## v1.7.0 _2018-04-28_ * ([#124](https://github.com/buttercup/buttercup-browser-extension/issues/124)) Simplify save-new login screen by removing password confirmation * ([#141](https://github.com/buttercup/buttercup-browser-extension/issues/141)) Buttercup launch button layout issues * Dependency updates ## v1.6.2 _2018-04-24_ * ([#139](https://github.com/buttercup/buttercup-browser-extension/issues/139)) No "Generate password" option shown when right-clicking inputs (Firefox/Chrome) ## v1.6.1 _2018-04-01_ * ([#137](https://github.com/buttercup/buttercup-browser-extension/issues/137)) Unable to close save-new dialog ## v1.6.0 _2018-04-01_ * "password" type input support for Firefox and the password generator * ([#134](https://github.com/buttercup/buttercup-browser-extension/issues/134)) Password generator "Use this" button fails on Firefox * ([#133](https://github.com/buttercup/buttercup-browser-extension/issues/133)) Password generator bad padding issue ## v1.5.0 _2018-03-31_ * Password generator * Right-click context menu ## v1.4.0 _2018-03-23_ * ([#126](https://github.com/buttercup/buttercup-browser-extension/issues/126)) Add an attribute to allow inputs and forms to be ignored by Buttercup * Support new login forms * Update login form detection priorities ## v1.3.1 _2018-02-20_ * ([#121](https://github.com/buttercup/buttercup-browser-extension/issues/121)) Unable to click "Save" for new logins ## v1.3.0 _2018-02-05_ * Use `chrome.storage` for better data persistence ## v1.2.1 _2018-02-04_ * Update code splitting configuration (Firefox submission fixes) ## v1.1.1 _2018-02-04_ * Improve URL filtering for new credentials saving ## v1.1.0 _2018-02-04_ * ([#112](https://github.com/buttercup/buttercup-browser-extension/issues/106)) Save newly-entered credentials ## v1.0.7 _2018-01-22_ * Add data collection event listeners for form attachments ## v1.0.6 _2018-01-21_ * Fix component communication in Firefox * Add storage permission ## v1.0.5 _2018-01-21_ * ([#106](https://github.com/buttercup/buttercup-browser-extension/issues/106)) Fix Nextcloud archive searching ## v1.0.4 _2018-01-20_ * First 1.* release for Firefox * Fix character encoding * Fix button icon not showing on some sites * Fix scrollbars in popup ## v1.0.3 _2018-01-19_ * Fix GitHub login * Fix logins that have multiple detected inputs ## v1.0.2 _2018-01-18_ * ([#97](https://github.com/buttercup/buttercup-browser-extension/issues/97)) Fixed Twitter login bug ## **v1.0.1** _2018-01-18_ * Full re-work of the entire extension * **Nextcloud** official support * ([#92](https://github.com/buttercup/buttercup-browser-extension/issues/92)) Fixed security vulnerability ## v0.14.2 _2017-07-15_ * Bugfix: [ownCloud subfolder installations: Subfolder ignored](https://github.com/buttercup/buttercup-browser-extension/issues/80) ## v0.14.1 _2017-06-24_ * Bugfix: [Unable to connect to certain WebDAV services](https://github.com/buttercup/buttercup-desktop/issues/303) ## v0.14.0 _2017-06-07_ * Bugfix: [Malformed URL error](https://github.com/buttercup/buttercup-browser-extension/issues/71) - old WebDAV client caused issues with special characters in passwords * Add back old Buttercup-core-web classes for compatibility ## v0.13.1 _2017-05-27_ * Bugfix: Archive creation in root level ## v0.13.0 _2017-04-23_ * Added right-click context menu drilldown for choosing which entry to fill form with * Updated hotkey for auto-login (Command+Shift+L for Mac, Ctrl+Shift+L for Windows/Linux) * Fixed deprecation warnings during build and updated some packages ## v0.12.1 _2017-04-19_ * Spring clean: * Reduced permissions requirements in manifest * Improved last submitted form security ## v0.12.0 _2017-04-15_ * Bugfix: Popup formatting issues * Added hotkey for auto-login (Command+B (Mac) / Ctrl+B (Windows/Linux)) ## v0.11.0 _2017-04-14_ * Bugfix: Clicking "No" on save prompt would not cancel further popups * Bugfix: Form submission error when selecting credentials * Prefill title when saving new credentials ## v0.10.0 _2017-03-31_ * Added right-click context menu on form inputs * Fixed overflow on remote filesystem explorer when adding archives * Finalised styling on unlock-archive form ## v0.9.0 _2017-03-29_ * Improved UI for popup password list (archive + groups pathing) * Bugfix: Fixed wrong-password during unlock sequence breaking state ## v0.8.0 _2017-03-22_ * Fuzzy searching for entries on login-forms * Login-form popup available on password fields ## v0.7.0 _2017-03-15_ * **Support for Firefox** * Auto-submit when selecting credentials on a form * Styles normalisation ## v0.6.0 _2017-03-14_ * Upgrade core * Use serialisation for archive properties ## v0.5.0 _2017-03-09_ * Upgrade core * Drastically increase PBKDF2 rounds * Improve URI matching ## v0.4.0 _2017-02-11_ * First alpha release ## v0.3.0 * Pre-release development build ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016 Perry Mitchell Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: PRIVACY_POLICY.md ================================================ # Privacy Policy This privacy policy concerns the Browser Extension for Buttercup, its use and the data it makes use of. ## About Buttercup Buttercup is a software suite designed to provide a secure application with which to store highly-sensitive information (such as account credentials) in encrypted vault files. Great care is taken to ensure that all secure data remains protected, with as little information as possible being handled by the application and the Buttercup platform therein. ### Terms The following terms will appear throughout this document and their meaning is important to grasp for a proper understanding of this policy. | Term | Description | |-------------------|-------------------------------------------------------| | Archive | See "vault". | | Call-to-action | A button or link that indicates to the user that it performs some kind of action. A button with "Login" on it is a good example. | | Encryption | The process of transforming vault contents into a secure, un-readable format for storage that can only be _read_ by providing a master password. | | Master password | The highly secure secret password used to lock and unlock vaults, known only to the user. | | Vault | An encrypted password vault, stored as a file either locally or remotely (on some kind of service). | ### Types of data Vaults, in their locked state, contain **encrypted data** which represent secret information that Buttercup uses to function (passwords etc.). When unlocked, the data is **unencrypted** and resides in memory on the user's device. Buttercup applications may store **unencrypted configuration information** on the device in a standard directory or location. Configuration data does not include any sensitive information. Configuration refers to settings that allow the application to function in a desired manner for the user. Buttercup for Browsers does not collect any **analytics data**, but the hosting platforms (eg. Mozilla/Google) may collect anonymous analytics with regards to the extension itself (installations vs uninstallations, etc.). Due to the fact that Buttercup interacts with webpages, the **Document Object Model (DOM)** may be modified by its use. ## Data storage and transfer with regards to 3rd Parties ### Remote vault storage Buttercup vault files, especially with regards to this browser extension, are stored remotely on hosting services. These services (eg. Dropbox, Nextcloud etc.) utilise their own privacy policies and standards, and are responsible for the files stored within their platform. Vaults transferred from the extension to these services are encrypted **before** they leave the application. No unencrypted data is stored on these remote services, besides the filename itself. It should be noted that connection logs may be kept on various services. It is the responsibility of the user to take care as to what services they choose to use and connect to, if any. ### DOM (Document Object Model) The browser extention modifies the DOM of open webpages so that it can track several different items while the page is live: * Detected login forms * Detected inputs that can be used for login (username/email, password etc.) * Submit buttons * Potential submit buttons (tracking text that resembles login call-to-actions) The extension may add attributes to existing elements, as well as adding entirely new elements (eg. buttons to open the Buttercup login menu). These modifications may indicate to the site, as well as to scripts running on the site, the fact that the current user has the Buttercup browser extension installed. ### Analytics Buttercup does not track any analytics directly. Platforms that Buttercup uses, such as Mozilla (for Firefox addons) or Google (for Chrome extensions), may track analytics that relate to the use of the extension in an anonymous manner. Buttercup makes use of this data for the purpose of analysing product health and potential market expansions. ## Internal data storage and use ### In-memory vaults When the vaults are decrypted using the user's master password, the contents of the vault are loaded into memory within the browser. The contents of the vaults are stored within the extension and are not accessible outside of the extension's context. Raw credentials may be transferred to webpages not owned by Buttercup at the request of the user when trying to log in to a website. ## Your responsibility as a user By using Buttercup's applications or services, you acknowledge that Buttercup stores highly-sensitive information for you using a **master password**. This password must not be shared with anyone, and should sufficiently strong to ensure that vault integrity is not broken if a bad actor gains access to it. Both the password and the encrypted vault file are the responsibility of the user, and both should be treated as highly sensitive. ## Our responsibility to you, the user We provide Buttercup as free-to-use software - we take every reasonable precaution to ensure that the vaults are encrypted in a manner that is secure by industry standards. We must ensure that only encrypted data leaves the user's device when storing vaults. We must ensure that only the bare minimum information is shared with 3rd party services to allow Buttercup to integrate with them. ================================================ FILE: README.md ================================================


Buttercup for Browsers


# Buttercup Browser Extension Buttercup credentials manager extension for the browser.

[![Buttercup](https://cdn.rawgit.com/buttercup-pw/buttercup-assets/6582a033/badge/buttercup-slim.svg)](https://buttercup.pw) ![Tests status](https://github.com/buttercup/buttercup-core/actions/workflows/test.yml/badge.svg) [![Chrome version](https://img.shields.io/chrome-web-store/v/heflipieckodmcppbnembejjmabajjjj)](https://chrome.google.com/webstore/detail/buttercup/heflipieckodmcppbnembejjmabajjjj?hl=en-GB) [![Chrome users](https://img.shields.io/chrome-web-store/d/heflipieckodmcppbnembejjmabajjjj.svg?label=Chrome%20users)](https://chrome.google.com/webstore/detail/buttercup/heflipieckodmcppbnembejjmabajjjj?hl=en-GB) [![Firefox version](https://img.shields.io/amo/v/buttercup-pw)](https://addons.mozilla.org/en-US/firefox/addon/buttercup-pw/) [![Firefox users](https://img.shields.io/amo/users/buttercup-pw.svg?color=38c543&label=Firefox%20users)](https://addons.mozilla.org/en-US/firefox/addon/buttercup-pw/) [![Chat securely on Keybase](https://img.shields.io/badge/keybase-bcup-blueviolet)](https://keybase.io/team/bcup) --- ⚠️ **Project Closure** ⚠️ The Buttercup project has come to an end, and these repositories are in transition to becoming public archives. No public-facing resources will be removed, wherever possible. Please do not create issues or PRs - they will unfortunately be ignored. Discussion can be found [here](https://github.com/buttercup/buttercup-desktop/discussions/1395), and explanation [here](https://gist.github.com/perry-mitchell/43ebfcec4d874b77a704be1d4f2262e6). --- ## About This browser extension allows users to interface with password archives authored by the [Buttercup password manager](https://github.com/buttercup-pw/buttercup) (it _requires_ v2.26 and later of the desktop application to be installed to function). The extension makes secured requests to the desktop application for information within its unlocked vaults, and makes those credentials available within the browser. It is also able to save new logins from the browser extension as they're recognised. Besides a username and password, the extension can also enter OTP codes when required. ### Forms & Logins Buttercup for Browsers auto-detects some login forms and login inputs, allowing the user to auto-fill them at their discretion. This extension uses [Locust](https://github.com/buttercup/locust) under the hood to **detect forms and inputs** (any issues with detecting forms and inputs should be opened there). ### Supported browsers [Chrome](https://chrome.google.com/webstore/detail/buttercup/heflipieckodmcppbnembejjmabajjjj?hl=en-GB), [Firefox](https://addons.mozilla.org/en-US/firefox/addon/buttercup-pw/), [Edge](https://www.microsoft.com/en-us/edge) (version 79+) and [Brave](https://chrome.google.com/webstore/detail/buttercup/heflipieckodmcppbnembejjmabajjjj) are supported. _Some browsers, such as **Brave** for example, will be able to install Buttercup via the Google Chrome web store._ Other browsers will be supported in order of request/popularity. Issues created for unsupported browsers, or for browsers not on the roadmap, may be closed without warning. **Opera** is not supported due to their incredibly slow and unreliable release process. We will not be adding support for Opera. ### Integrated platforms The extension allows for connections to several services, such as Dropbox and Google Drive. The extension supports whatever platforms the desktop application does, including local vaults. #### Supported platforms The browsers listed above, running on Windows, Mac or Linux on a desktop platform. This extension is not supported on any mobile or tablet devices. ### Usage The browser extension can be controlled from the **popup menu**, which is launched by pressing the Buttercup button in the browser menu. This menu displays a list of archives as well as settings and other items. When viewing pages that contain login forms, Buttercup can assist logging in when you interact with the login buttons (displayed beside detected login inputs). Buttercup can also remember new logins, which are detected as they occur. You can **block** Buttercup from detecting forms and inputs by applying the attribute `data-bcupignore=true`: ```html ``` ### Development Development of features and bugfixes is supported in the following environment: * NodeJS version 20 (latest minor version) * Linux / Mac * Tested in at least Chrome / Firefox To set up your development environment: * Clone this repo * Ensure API keys are available (Google Drive) * Execute `npm install` inside the project directory * Run `npm run dev:chrome` or similar * Load the unpacked `dist` folder in your browser addons #### Chrome Run the following to develop the extension: 1. Execute `npm run dev:chrome` to build and watch the project (to build production code, execute `npm run build`) 2. Go to [chrome://extensions](chrome://extensions) and enable _"Developer mode"_ 3. Select the new button _"Load unpacked"_, then select the `./dist` directory built on step 1 #### Firefox Run the following to develop the extension: * Execute `npm run dev:firefox` to build and watch the project (to build production code, execute `npm run release`) #### Releasing To build release-ready zip archives, run the command `npm run release` after having set up the development environment. The archives will be written to `release/(browser)` where `(browser)` is the browser type. Archives named `extension.zip` contain the built extension sourcecode and `source.zip` contains the raw source. ### Adding to Chrome You can load an **unpacked extension** in Chrome by navigating to [chrome://extensions/](chrome://extensions/). Simply locate the project's directory and use **dist/** as the extension directory. ### Adding to Firefox You can load an **unpacked extension** in Firefox by navigating to [about:debugging](about:debugging). Click "Load Temporary Add-on" and locate the project's directory, using **dist/** as the extension directory. ================================================ FILE: package.json ================================================ { "name": "buttercup-browser-extension", "version": "3.2.0", "description": "Buttercup browser extension", "exports": "./dist/background/index.js", "type": "module", "types": "./dist/background/index.d.ts", "scripts": { "build": "run-s set-version build:production", "build:chrome": "BROWSER=chrome npm run build", "build:edge": "BROWSER=edge npm run build", "build:firefox": "BROWSER=firefox npm run build", "build:production": "webpack --mode production --progress --config webpack.config.js", "clean": "rimraf dist/* release/*", "dev": "npm run clean && npm run set-version && webpack --mode development -w --progress --config webpack.config.js", "dev:chrome": "BROWSER=chrome npm run dev", "dev:edge": "BROWSER=edge npm run dev", "dev:firefox": "concurrently -k \"BROWSER=firefox npm run dev\" \"cd dist && web-ext run\" --restart-tries 20 --restart-after 5000 --devtools --keep-profile-changes", "format": "prettier --write '{{source,test}/**/*.{ts,js},webpack.config.js}'", "release": "run-s clean release:chrome release:firefox release:edge", "release:chrome": "npm run build:chrome && mkdirp release/chrome && zip -r release/chrome/extension.zip ./dist", "release:edge": "npm run build:edge && mkdirp release/edge && zip -r release/edge/extension.zip ./dist", "release:firefox": "npm run build:firefox && mkdirp release/firefox && run-s release:firefox:extension release:firefox:source", "release:firefox:extension": "cd dist && web-ext build --overwrite-dest && cd .. && mv ./dist/web-ext-artifacts/*.zip ./release/firefox/", "release:firefox:source": "zip -r release/firefox/source.zip . --exclude=/.git* --exclude=/node_modules* --exclude=/.history* --exclude=/dist* --exclude=/release* --exclude=*.DS_Store*", "set-version": "node scripts/version.js", "test": "run-s test:format", "test:format": "prettier --check '{{source,test}/**/*.{ts,js},webpack.config.js}'" }, "lint-staged": { "{{source,test}/**/*.{ts,js},webpack.config.js}": [ "prettier --write" ] }, "husky": { "hooks": { "pre-commit": "lint-staged" } }, "repository": { "type": "git", "url": "git+https://github.com/buttercup/buttercup-browser-extension.git" }, "keywords": [ "buttercup", "password", "vault", "login", "secure" ], "author": "Perry Mitchell ", "license": "MIT", "bugs": { "url": "https://github.com/buttercup/buttercup-browser-extension/issues" }, "homepage": "https://github.com/buttercup/buttercup-browser-extension#readme", "devDependencies": { "@babel/core": "^7.23.3", "@babel/preset-env": "^7.23.3", "@babel/preset-react": "^7.23.3", "@blueprintjs/core": "^4.20.2", "@blueprintjs/icons": "^4.16.0", "@blueprintjs/popover2": "^1.14.11", "@blueprintjs/select": "^4.9.24", "@buttercup/channel-queue": "^1.4.0", "@buttercup/locust": "^2.3.1", "@buttercup/ui": "^6.2.2", "@types/chrome": "^0.0.251", "@types/ms": "^0.7.34", "@types/react": "^17.0.38", "@types/react-dom": "^17.0.11", "babel-loader": "^9.1.3", "buttercup": "^7.6.0", "classnames": "^2.2.6", "concurrently": "^8.2.2", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.8.1", "eventemitter3": "^5.0.1", "expiry-map": "^2.0.0", "file-loader": "^6.0.0", "gle": "^1.0.3", "husky": "^4.2.5", "i18next": "^23.7.6", "iocane": "^5.1.1", "layerr": "^2.0.0", "lint-staged": "^15.1.0", "mkdirp": "^3.0.1", "ms": "^2.1.3", "mucus": "^1.0.0", "npm-run-all": "^4.1.5", "obstate": "^0.1.4", "on-navigate": "^0.1.1", "otpauth": "^9.1.5", "parse-domain": "^8.0.2", "prettier": "^3.1.0", "pug-plugin": "^5.0.2", "react": "^17.0.2", "react-dom": "^17.0.2", "react-obstate": "^0.1.3", "react-router-dom": "^6.21.3", "redom": "^3.29.1", "resolve-typescript-plugin": "^2.0.1", "rimraf": "^5.0.5", "sass": "^1.69.5", "sass-loader": "^13.3.2", "style-loader": "^3.3.3", "styled-components": "^5.3.11", "ts-loader": "^9.5.0", "typescript": "^5.2.2", "ulidx": "^2.2.1", "url-join": "^5.0.0", "url-loader": "^4.1.1", "web-ext": "^7.8.0", "webpack": "^5.90.3", "webpack-cli": "^5.1.4", "webpack-merge": "^5.10.0" } } ================================================ FILE: resources/full.pug ================================================ doctype html html head title Buttercup meta(charset="utf-8") link(rel="icon", type="image/png", href=require("./buttercup-256.png").default) link(rel="stylesheet" href="full.css") link(rel="stylesheet" href="vendors.css") script(defer, src="full.js") script(defer, src="vendors.js") body div#root ================================================ FILE: resources/manifest.v2.json ================================================ { "manifest_version": 2, "name": "Buttercup", "description": "Browser extension for Buttercup, the secure and easy-to-use password manager.", "version": "0.0.0", "icons": { "256": "manifest-res/buttercup-256.png", "128": "manifest-res/buttercup-128.png", "48": "manifest-res/buttercup-48.png", "16": "manifest-res/buttercup-16.png" }, "background": { "scripts": [ "background.js" ] }, "browser_action": { "default_icon": "manifest-res/buttercup-256.png", "default_popup": "popup.html#/" }, "content_scripts" : [ { "matches": ["http://*/*", "https://*/*"], "run_at": "document_end", "all_frames": true, "js": ["tab.js"] } ], "permissions": [ "clipboardWrite", "http://*/*", "https://*/*", "storage", "tabs", "unlimitedStorage" ], "web_accessible_resources": [ "*.png", "*.jpg" ], "applications": { "gecko": { "id": "{10e7d273-2e63-47c9-82af-76c45dc1b624}" } } } ================================================ FILE: resources/manifest.v3.json ================================================ { "manifest_version": 3, "name": "Buttercup", "description": "Browser extension for Buttercup, the secure and easy-to-use password manager.", "version": "0.0.0", "icons": { "256": "manifest-res/buttercup-256.png", "128": "manifest-res/buttercup-128.png", "48": "manifest-res/buttercup-48.png", "16": "manifest-res/buttercup-16.png" }, "background": { "service_worker": "background.js" }, "action": { "default_title": "Buttercup", "default_icon": "manifest-res/buttercup-256.png", "default_popup": "/popup.html#/" }, "content_scripts" : [ { "matches": ["http://*/*", "https://*/*"], "run_at": "document_end", "all_frames": true, "js": ["tab.js"] } ], "permissions": [ "clipboardWrite", "storage", "tabs", "unlimitedStorage" ], "web_accessible_resources": [ { "resources": ["*.png", "*.jpg"], "matches": ["http://*/*", "https://*/*"] }, { "resources": ["popup.html"], "matches": ["http://*/*", "https://*/*"] } ] } ================================================ FILE: resources/popup.pug ================================================ doctype html html head title Menu ⋅ Buttercup meta(charset="utf-8") link(rel="icon", type="image/png", href=require("./buttercup-256.png").default) link(rel="stylesheet" href="popup.css") link(rel="stylesheet" href="vendors.css") script(defer, src="popup.js") script(defer, src="vendors.js") body div#root ================================================ FILE: scripts/version.js ================================================ import { readFileSync, writeFileSync } from "fs"; import { resolve } from "path"; import { fileURLToPath } from "url"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); const packageInfo = JSON.parse(readFileSync( resolve(__dirname, "../package.json"), "utf8" )); const buildDate = new Date(); const built = `${buildDate.getUTCFullYear()}-${String(buildDate.getUTCMonth() + 1).padStart(2, "0")}-${String(buildDate.getUTCDate()).padStart(2, "0")}`; const output = `// Do not edit this file - it is generated automatically at build time export const BUILD_DATE = "${built}"; export const VERSION = "${packageInfo.version}"; `; writeFileSync( resolve(__dirname, "../source/shared/library/version.ts"), output ); ================================================ FILE: source/background/index.ts ================================================ import { initialise } from "./services/init.js"; import { log } from "./services/log.js"; initialise().catch((err) => { console.error(err); log("initialisation failed"); }); ================================================ FILE: source/background/library/domain.ts ================================================ import { UsedCredentials } from "../types.js"; export function extractDomainFromCredentials(credentials: UsedCredentials): string | null { const match = /^https?:\/\/([^\/]+)/.exec(credentials.url); return match ? match[1] : null; } ================================================ FILE: source/background/services/autoLogin.ts ================================================ import { SearchResult } from "buttercup"; import ExpiryMap from "expiry-map"; interface RegisteredItem { entry: SearchResult; tabID: number; } const REGISTER_MAX_AGE = 30 * 1000; // 30 seconds let __register: ExpiryMap | null = null; export function getAutoLoginForTab(tabID: number): SearchResult | null { const register = getRegister(); const key = `tab-${tabID}`; if (register.has(key)) { const item = (register.get(key) as RegisteredItem).entry; register.delete(key); return item; } return null; } function getRegister(): ExpiryMap { if (!__register) { __register = new ExpiryMap(REGISTER_MAX_AGE); } return __register; } export function registerAutoLogin(entry: SearchResult, tabID: number): void { const register = getRegister(); register.set(`tab-${tabID}`, { entry, tabID }); } ================================================ FILE: source/background/services/config.ts ================================================ import { getSyncValue, setSyncValue } from "./storage.js"; import { Configuration, InputButtonType, SyncStorageItem } from "../types.js"; import { naiveClone } from "../../shared/library/clone.js"; const DEFAULTS: Configuration = { entryIcons: true, inputButtonDefault: InputButtonType.LargeButton, saveNewLogins: true, theme: "light", useSystemTheme: true }; let __lastConfig: Configuration | null = null; export function getConfig(): Configuration { if (!__lastConfig) { throw new Error("No configuration available"); } return __lastConfig; } export async function initialise() { __lastConfig = await updateConfigWithDefaults(); } export async function updateConfigValue(key: T, value: Configuration[T]): Promise { const configRaw = await getSyncValue(SyncStorageItem.Configuration); const config = configRaw ? JSON.parse(configRaw) : naiveClone(DEFAULTS); config[key] = value; __lastConfig = config; await setSyncValue(SyncStorageItem.Configuration, JSON.stringify(config)); } async function updateConfigWithDefaults(): Promise { let configRaw = await getSyncValue(SyncStorageItem.Configuration); const config = configRaw ? JSON.parse(configRaw) : { ...DEFAULTS }; for (const key in DEFAULTS) { if (typeof config[key] === "undefined") { config[key] = DEFAULTS[key]; } } await setSyncValue(SyncStorageItem.Configuration, JSON.stringify(config)); return config; } ================================================ FILE: source/background/services/crypto.ts ================================================ import { EncryptionAlgorithm, createAdapter } from "iocane"; import { deriveSecretKey, importECDHKey } from "./cryptoKeys.js"; export async function decryptPayload( payload: string, sourcePublicKey: string, targetPrivateKey: string ): Promise { const privateKey = await importECDHKey(targetPrivateKey); const publicKey = await importECDHKey(sourcePublicKey); const secret = await deriveSecretKey(privateKey, publicKey); return createAdapter().decrypt(payload, secret) as Promise; } export async function encryptPayload( payload: string, sourcePrivateKey: string, targetPublicKey: string ): Promise { const privateKey = await importECDHKey(sourcePrivateKey); const publicKey = await importECDHKey(targetPublicKey); const secret = await deriveSecretKey(privateKey, publicKey); return createAdapter() .setAlgorithm(EncryptionAlgorithm.GCM) .setDerivationRounds(100000) .encrypt(payload, secret) as Promise; } ================================================ FILE: source/background/services/cryptoKeys.ts ================================================ import { ulid } from "ulidx"; import { log } from "./log.js"; import { getLocalValue, setLocalValue } from "./storage.js"; import { arrayBufferToHex } from "../../shared/library/buffer.js"; import { API_KEY_ALGO, API_KEY_CURVE } from "../../shared/symbols.js"; import { LocalStorageItem } from "../types.js"; import { Layerr } from "layerr"; async function createKeys(): Promise<{ privateKey: string; publicKey: string; }> { const { privateKey, publicKey } = await window.crypto.subtle.generateKey( { name: API_KEY_ALGO, namedCurve: API_KEY_CURVE }, true, ["deriveKey"] ); log("generating public and private key pair for browser auth"); const privateKeyStr = await exportECDHKey(privateKey); const publicKeyStr = await exportECDHKey(publicKey); log("generated new browser auth keys"); return { privateKey: privateKeyStr, publicKey: publicKeyStr }; } export async function deriveSecretKey(privateKey: CryptoKey, publicKey: CryptoKey): Promise { let cryptoKey: CryptoKey; try { cryptoKey = await window.crypto.subtle.deriveKey( { name: API_KEY_ALGO, public: publicKey }, privateKey, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); } catch (err) { throw new Layerr(err, "Failed deriving secret key"); } const exported = await window.crypto.subtle.exportKey("raw", cryptoKey); return arrayBufferToHex(exported); } async function exportECDHKey(key: CryptoKey): Promise { try { const exported = await window.crypto.subtle.exportKey("jwk", key); return JSON.stringify(exported); } catch (err) { throw new Layerr(err, "Failed exporting ECDH key"); } } export async function generateKeys(): Promise { let [apiPrivate, apiPublic, clientID] = await Promise.all([ getLocalValue(LocalStorageItem.APIPrivateKey), getLocalValue(LocalStorageItem.APIPublicKey), getLocalValue(LocalStorageItem.APIClientID) ]); if (apiPrivate && apiPublic && clientID) return; // Regenerate log("api keys missing: will generate"); const { privateKey, publicKey } = await createKeys(); clientID = ulid(); await setLocalValue(LocalStorageItem.APIPrivateKey, privateKey); await setLocalValue(LocalStorageItem.APIPublicKey, publicKey); await setLocalValue(LocalStorageItem.APIClientID, clientID); } export async function importECDHKey(key: string): Promise { let jwk: JsonWebKey; try { jwk = JSON.parse(key) as JsonWebKey; } catch (err) { throw new Layerr(err, "Failed importing ECDH key"); } const usages: Array = jwk.key_ops && jwk.key_ops.includes("deriveKey") ? ["deriveKey"] : []; return window.crypto.subtle.importKey( "jwk", jwk, { name: API_KEY_ALGO, namedCurve: API_KEY_CURVE }, true, usages ); } ================================================ FILE: source/background/services/desktop/actions.ts ================================================ import { Layerr } from "layerr"; import { EntryID, EntryType, GroupID, SearchResult, VaultFacade, VaultSourceID } from "buttercup"; import { getLocalValue } from "../storage.js"; import { sendDesktopRequest } from "./request.js"; import { generateAuthHeader } from "./header.js"; import { LocalStorageItem, OTP, VaultSourceDescription, VaultsTree } from "../../types.js"; export async function authenticateBrowserAccess(code: string): Promise { const localPublicKey = await getLocalValue(LocalStorageItem.APIPublicKey); const clientID = await getLocalValue(LocalStorageItem.APIClientID); if (!localPublicKey) { throw new Error("No local public key available"); } if (!clientID) { throw new Error("No API client ID set"); } const { publicKey } = (await sendDesktopRequest({ method: "POST", route: "/v1/auth/response", payload: { code, id: clientID, publicKey: localPublicKey } })) as { publicKey: string }; if (!publicKey) { throw new Layerr("No server public key received from browser authentication"); } return publicKey; } export async function getEntrySearchResults( entries: Array<{ entryID: EntryID; sourceID: VaultSourceID }> ): Promise> { const authHeader = await generateAuthHeader(); const { results } = (await sendDesktopRequest({ method: "POST", route: "/v1/entries/specific", auth: authHeader, payload: { entries } })) as { results: Array; }; return results; } export async function getOTPs(): Promise> { const authHeader = await generateAuthHeader(); const { otps } = (await sendDesktopRequest({ method: "GET", route: "/v1/otps", auth: authHeader })) as { otps: Array; }; return otps; } export async function getVaultSources(): Promise> { const authHeader = await generateAuthHeader(); const { sources } = (await sendDesktopRequest({ method: "GET", route: "/v1/vaults", auth: authHeader })) as { sources: Array; }; return sources; } export async function getVaultsTree(): Promise { const authHeader = await generateAuthHeader(); const { names, tree } = (await sendDesktopRequest({ method: "GET", route: "/v1/vaults-tree", auth: authHeader })) as { names?: Record; tree: Record; }; return Object.keys(tree).reduce((output, sourceID) => { return { ...output, [sourceID]: { ...tree[sourceID], name: names?.[sourceID] ?? "Untitled vault" } }; }, {}); } export async function hasConnection(): Promise { const serverPublicKey = await getLocalValue(LocalStorageItem.APIServerPublicKey); return !!serverPublicKey; } export async function initiateConnection(): Promise { await sendDesktopRequest({ method: "POST", route: "/v1/auth/request", payload: { client: "browser", purpose: "vaults-access", rev: 1 } }); } export async function promptSourceLock(sourceID: VaultSourceID): Promise { const authHeader = await generateAuthHeader(); const status = await sendDesktopRequest({ method: "POST", route: `/v1/vaults/${sourceID}/lock`, auth: authHeader, output: "status" }); return status === 200; } export async function promptSourceUnlock(sourceID: VaultSourceID): Promise { const authHeader = await generateAuthHeader(); await sendDesktopRequest({ method: "POST", route: `/v1/vaults/${sourceID}/unlock`, auth: authHeader }); } export async function saveExistingEntry( sourceID: VaultSourceID, groupID: GroupID, entryID: EntryID, properties: Record ): Promise { const authHeader = await generateAuthHeader(); await sendDesktopRequest({ method: "PATCH", route: `/v1/vaults/${sourceID}/group/${groupID}/entry/${entryID}`, auth: authHeader, payload: { properties } }); } export async function saveNewEntry( sourceID: VaultSourceID, groupID: GroupID, entryType: EntryType, properties: Record ): Promise { const authHeader = await generateAuthHeader(); const { entryID } = (await sendDesktopRequest({ method: "POST", route: `/v1/vaults/${sourceID}/group/${groupID}/entry`, auth: authHeader, payload: { properties, type: entryType } })) as { entryID: EntryID; }; return entryID; } export async function searchEntriesByURL(url: string): Promise> { const authHeader = await generateAuthHeader(); const { results } = (await sendDesktopRequest({ method: "GET", route: "/v1/entries", payload: { type: "url", url }, auth: authHeader })) as { results: Array; }; return results; } export async function searchEntriesByTerm(term: string): Promise> { const authHeader = await generateAuthHeader(); const { results } = (await sendDesktopRequest({ method: "GET", route: "/v1/entries", payload: { type: "term", term }, auth: authHeader })) as { results: Array; }; return results; } export async function testAuth(): Promise { const authHeader = await generateAuthHeader(); try { await sendDesktopRequest({ method: "POST", route: "/v1/auth/test", payload: { client: "browser", purpose: "vaults-access", rev: 1 }, auth: authHeader }); } catch (err) { console.error(err); throw new Layerr(err, "Desktop connection failed"); } } ================================================ FILE: source/background/services/desktop/header.ts ================================================ import { Layerr } from "layerr"; import { LocalStorageItem } from "../../types.js"; import { getLocalValue } from "../storage.js"; export async function generateAuthHeader(): Promise { const clientID = await getLocalValue(LocalStorageItem.APIClientID); if (!clientID) { throw new Layerr( { info: { i18n: "error.code.desktop-connection-not-authorised" } }, "No API client ID set" ); } return `Client ${clientID}`; } ================================================ FILE: source/background/services/desktop/request.ts ================================================ import { Layerr } from "layerr"; import joinURL from "url-join"; import { DESKTOP_API_PORT } from "../../../shared/symbols.js"; import { decryptPayload, encryptPayload } from "../crypto.js"; import { getLocalValue } from "../storage.js"; import { LocalStorageItem } from "../../types.js"; type OutputType = "body" | "status" | undefined; interface DesktopRequestConfig { auth?: string | null; method: string; output?: O; payload?: Record | null; route: string; } const DESKTOP_URL_BASE = `http://localhost:${DESKTOP_API_PORT}`; export async function sendDesktopRequest( config: DesktopRequestConfig ): Promise>; export async function sendDesktopRequest(config: DesktopRequestConfig): Promise; export async function sendDesktopRequest( config: DesktopRequestConfig ): Promise>; export async function sendDesktopRequest( config: DesktopRequestConfig ): Promise | number> { const { auth = null, method, output = "body", payload = null, route } = config; // Prepare un-encrypted configuration first let url = joinURL(DESKTOP_URL_BASE, route); const requestConfig: RequestInit = { method, headers: {} }; if (payload !== null) { if (/^get$/i.test(method)) { const newURL = new URL(url); for (const prop in payload) { if (payload.hasOwnProperty(prop)) { newURL.searchParams.set(prop, payload[prop]); } } url = newURL.toString(); } else { requestConfig.body = JSON.stringify(payload); Object.assign(requestConfig.headers as HeadersInit, { "Content-Type": "application/json" }); } } if (auth !== null) { requestConfig.headers = requestConfig.headers || {}; // Request requires encryption, perform setup now requestConfig.headers["Authorization"] = auth; if (typeof requestConfig.body === "string") { requestConfig.headers["X-Content-Type"] = requestConfig.headers["Content-Type"]; requestConfig.headers["Content-Type"] = "text/plain"; // Encrypt const privateKey = await getLocalValue(LocalStorageItem.APIPrivateKey); const publicKey = await getLocalValue(LocalStorageItem.APIServerPublicKey); if (!privateKey) { throw new Error("Authenticated request failed: No private key available"); } if (!publicKey) { throw new Error("Authenticated request failed: No public key available"); } requestConfig.body = await encryptPayload(requestConfig.body, privateKey, publicKey); } } // Make request const resp = await fetch(url, requestConfig); if (!resp.ok) { throw new Layerr( { info: { code: "desktop-request-failed", status: resp.status, statusText: resp.statusText } }, `Desktop request failed: ${resp.status} ${resp.statusText}` ); } if (output === "status") { return resp.status; } // Handle encrypted response if (resp.headers.get("X-Bcup-API")) { const components = resp.headers.get("X-Bcup-API")?.split(",") ?? []; if (components.includes("enc")) { const content = await resp.text(); const contentType = resp.headers.get("X-Content-Type") || resp.headers.get("Content-Type") || "text/plain"; // Decrypt const privateKey = await getLocalValue(LocalStorageItem.APIPrivateKey); const publicKey = await getLocalValue(LocalStorageItem.APIServerPublicKey); if (!privateKey) { throw new Error("Decrypting response failed: No private key available"); } if (!publicKey) { throw new Error("Decrypting response failed: No public key available"); } const rawDecrypted = await decryptPayload(content, publicKey, privateKey); return /application\/json/.test(contentType) ? JSON.parse(rawDecrypted) : rawDecrypted; } } // Standard, unencrypted response if (/application\/json/.test(resp.headers.get("Content-Type") ?? "")) { return resp.json(); } return resp.text(); } ================================================ FILE: source/background/services/disabledDomains.ts ================================================ import { getSyncValue, setSyncValue } from "./storage.js"; import { SyncStorageItem } from "../types.js"; export async function disableLoginsOnDomain(domain: string): Promise { const currentDomains = new Set(await getDisabledDomains()); currentDomains.add(domain); await setSyncValue(SyncStorageItem.DisabledDomains, JSON.stringify([...currentDomains])); } export async function getDisabledDomains(): Promise> { const currentDomainsRaw = await getSyncValue(SyncStorageItem.DisabledDomains); return currentDomainsRaw ? JSON.parse(currentDomainsRaw) : []; } export async function removeDisabledFlagForDomain(domain: string): Promise { const currentDomains = new Set(await getDisabledDomains()); currentDomains.delete(domain); await setSyncValue(SyncStorageItem.DisabledDomains, JSON.stringify([...currentDomains])); } ================================================ FILE: source/background/services/entry.ts ================================================ import { SearchResult } from "buttercup"; import { createNewTab } from "../../shared/library/extension.js"; import { formatURL } from "../../shared/library/url.js"; export async function openEntryPageInNewTab(_: SearchResult, url: string): Promise { const tab = await createNewTab(formatURL(url)); if (typeof tab?.id !== "number") { throw new Error("No tab ID for created tab"); } return tab.id; } ================================================ FILE: source/background/services/init.ts ================================================ import { EventEmitter } from "eventemitter3"; import { log } from "./log.js"; import { initialise as initialiseMessaging } from "./messaging.js"; import { initialise as initialiseStorage } from "./storage.js"; import { initialise as initialiseConfig } from "./config.js"; import { generateKeys } from "./cryptoKeys.js"; import { initialise as initialiseI18n } from "../../shared/i18n/trans.js"; import { getLanguage } from "../../shared/library/i18n.js"; import { showPendingNotifications } from "./notifications.js"; enum Initialisation { Complete = "complete", Idle = "idle", Running = "running" } const __initEE = new EventEmitter(); let __initialisation: Initialisation = Initialisation.Idle; export async function initialise(): Promise { if (__initialisation !== Initialisation.Idle) return; __initialisation = Initialisation.Running; log("initialising"); initialiseMessaging(); await initialiseStorage(); await initialiseConfig(); await initialiseI18n(getLanguage()); await generateKeys(); log("initialisation complete"); __initialisation = Initialisation.Complete; __initEE.emit("initialised"); await showPendingNotifications(); } export async function resetInitialisation(): Promise { log("resetting initialisation"); __initialisation = Initialisation.Idle; await initialise(); } export async function waitForInitialisation(): Promise { return new Promise((resolve) => { if (__initialisation === Initialisation.Complete) return resolve(); __initEE.once("initialised", resolve); }); } ================================================ FILE: source/background/services/log.ts ================================================ import { createLog } from "../../shared/library/log.js"; const LOG_NAME = "buttercup:browser:background"; let __logger: ReturnType; export function log(...args: Array): void { if (!__logger) { __logger = createLog(LOG_NAME, true); } return __logger(...args); } ================================================ FILE: source/background/services/loginMemory.ts ================================================ import ExpiryMap from "expiry-map"; import { searchEntriesByTerm } from "./desktop/actions.js"; import { domainsReferToSameParent, extractDomain } from "../../shared/library/domain.js"; import { UsedCredentials } from "../types.js"; interface LoginMemoryItem { credentials: UsedCredentials; tabID: number; } const LOGIN_MAX_AGE = 15 * 60 * 1000; // 15 min let __loginMemory: ExpiryMap | null = null; export function clearCredentials(id: string): void { const memory = getLoginMemory(); if (memory.has(id)) { memory.delete(id); } if (memory.has("last")) { const last = memory.get("last"); if (last?.credentials.id === id) { memory.delete("last"); } } } export async function credentialsAlreadyStored(credentials: UsedCredentials): Promise { const results = await searchEntriesByTerm(credentials.username); const usedDomain = extractDomain(credentials.url); return results.some((result) => { // Check username if (credentials.username !== result.properties.username) return false; // Skip if search result has no URLs if (result.urls.length <= 0) return false; // Check if any of the domains match this one const resultDomains = result.urls.map((url) => extractDomain(url)); if (!resultDomains.some((resDomain) => domainsReferToSameParent(resDomain, usedDomain))) { // No matches return false; } // Check if props match return ( result.properties.username === credentials.username && result.properties.password === credentials.password ); }); } export function getAllCredentials(): Array { const memory = getLoginMemory(); const credentials: Array = []; for (const [key, item] of memory.entries()) { if (key === "last") continue; credentials.push(item.credentials); } return credentials; } export function getCredentialsForID(id: string): UsedCredentials | null { const memory = getLoginMemory(); return memory.has(id) ? (memory.get(id) as LoginMemoryItem).credentials : null; } export function getLastCredentials(tabID: number): UsedCredentials | null { const memory = getLoginMemory(); const last = memory.has("last") ? memory.get("last") : null; if (!last) return null; return last.tabID === tabID ? last.credentials : null; } function getLoginMemory(): ExpiryMap { if (!__loginMemory) { __loginMemory = new ExpiryMap(LOGIN_MAX_AGE); } return __loginMemory; } export function stopPromptForID(id: string): void { const memory = getLoginMemory(); if (memory.has(id)) { const existing = memory.get(id) as LoginMemoryItem; memory.set(id, { ...existing, credentials: { ...existing.credentials, promptSave: false } }); } const last = memory.has("last") ? memory.get("last") : null; if (last?.credentials.id === id) { memory.set("last", { ...last, credentials: { ...last.credentials, promptSave: false } }); } } export function updateUsedCredentials(credentials: UsedCredentials, tabID: number): void { const memory = getLoginMemory(); const payload: LoginMemoryItem = { credentials, tabID }; memory.set(credentials.id, payload); memory.set("last", payload); } ================================================ FILE: source/background/services/messaging.ts ================================================ import { Layerr } from "layerr"; import { EntryType, EntryURLType, VaultSourceID, VaultSourceStatus, getEntryURLs } from "buttercup"; import { getExtensionAPI } from "../../shared/extension.js"; import { authenticateBrowserAccess, getEntrySearchResults, getOTPs, getVaultSources, getVaultsTree, hasConnection, initiateConnection, promptSourceLock, promptSourceUnlock, saveExistingEntry, saveNewEntry, searchEntriesByTerm, searchEntriesByURL, testAuth } from "./desktop/actions.js"; import { clearLocalStorage, removeLocalValue, setLocalValue } from "./storage.js"; import { errorToString } from "../../shared/library/error.js"; import { clearCredentials, credentialsAlreadyStored, getAllCredentials, getCredentialsForID, getLastCredentials, stopPromptForID, updateUsedCredentials } from "./loginMemory.js"; import { getConfig, updateConfigValue } from "./config.js"; import { disableLoginsOnDomain, getDisabledDomains, removeDisabledFlagForDomain } from "./disabledDomains.js"; import { log } from "./log.js"; import { resetInitialisation } from "./init.js"; import { getRecents, trackRecentUsage } from "./recents.js"; import { openEntryPageInNewTab } from "./entry.js"; import { getAutoLoginForTab, registerAutoLogin } from "./autoLogin.js"; import { extractDomainFromCredentials } from "../library/domain.js"; import { BackgroundMessage, BackgroundMessageType, BackgroundResponse, LocalStorageItem, TabEventType } from "../types.js"; import { markNotificationRead } from "./notifications.js"; import { createNewTab, getExtensionURL } from "../../shared/library/extension.js"; import { sendTabsMessage } from "./tabs.js"; async function handleMessage( msg: BackgroundMessage, sender: chrome.runtime.MessageSender, sendResponse: (resp: BackgroundResponse) => void ) { switch (msg.type) { case BackgroundMessageType.AuthenticateDesktopConnection: { const { code } = msg; if (!code) { throw new Error("No auth code provided"); } log("complete desktop authentication"); const publicKey = await authenticateBrowserAccess(code); await setLocalValue(LocalStorageItem.APIServerPublicKey, publicKey); sendResponse({}); break; } case BackgroundMessageType.CheckDesktopConnection: { const available = await hasConnection(); if (available) { await testAuth(); } sendResponse({ available }); break; } case BackgroundMessageType.ClearDesktopAuthentication: { log("clear desktop authentication"); await removeLocalValue(LocalStorageItem.APIClientID); await removeLocalValue(LocalStorageItem.APIServerPublicKey); sendResponse({}); break; } case BackgroundMessageType.ClearSavedCredentials: { const { credentialsID } = msg; if (!credentialsID) { throw new Error("No credentials ID provided"); } log(`clear saved credentials: ${credentialsID}`); clearCredentials(credentialsID); sendResponse({}); break; } case BackgroundMessageType.ClearSavedCredentialsPrompt: { const { credentialsID } = msg; if (!credentialsID) { throw new Error("No credentials ID provided"); } log(`clear saved credentials prompt: ${credentialsID}`); stopPromptForID(credentialsID); await sendTabsMessage({ type: TabEventType.CloseSaveDialog }); sendResponse({}); break; } case BackgroundMessageType.DeleteDisabledDomains: { const { domains } = msg; if (!domains) { throw new Error("No domains list provided"); } log(`remove disabled domains: ${domains.join(", ")}`); for (const domain of domains) { await removeDisabledFlagForDomain(domain); } sendResponse({}); break; } case BackgroundMessageType.DisableSavePromptForCredentials: { const { credentialsID } = msg; if (!credentialsID) { throw new Error("No credentials ID provided"); } log(`disable save prompt for credentials: ${credentialsID}`); try { const credentials = getCredentialsForID(credentialsID); const domain = credentials ? extractDomainFromCredentials(credentials) : null; if (domain) { log(`disable save prompt for domain: ${domain}`); await disableLoginsOnDomain(domain); } } catch (err) { throw new Layerr(err, "Failed disabling save prompt for domain"); } await sendTabsMessage({ type: TabEventType.CloseSaveDialog }); sendResponse({}); break; } case BackgroundMessageType.GetAutoLoginForTab: { const tabID = sender.tab?.id; if (!tabID) { sendResponse({ autoLogin: null }); break; } const entry = getAutoLoginForTab(tabID); sendResponse({ autoLogin: entry }); break; } case BackgroundMessageType.GetConfiguration: { const config = getConfig(); sendResponse({ config }); break; } case BackgroundMessageType.GetDesktopVaultSources: { const sources = await getVaultSources(); sendResponse({ vaultSources: sources }); break; } case BackgroundMessageType.GetDesktopVaultsTree: { const tree = await getVaultsTree(); sendResponse({ vaultsTree: tree }); break; } case BackgroundMessageType.GetDisabledDomains: { const domains = await getDisabledDomains(); sendResponse({ domains }); break; } case BackgroundMessageType.GetLastSavedCredentials: { const { excludeSaved = false } = msg; const tabID = sender.tab?.id; if (!tabID) { sendResponse({ credentials: [null] }); break; } let credentials = getLastCredentials(tabID); if (credentials && excludeSaved && (await credentialsAlreadyStored(credentials))) { credentials = null; } sendResponse({ credentials: [credentials] }); break; } case BackgroundMessageType.GetOTPs: { const otps = await getOTPs(); sendResponse({ otps }); break; } case BackgroundMessageType.GetRecentEntries: { const { count = 10 } = msg; const sources = await getVaultSources(); const unlockedIDs: Array = sources.reduce((output, source) => { if (source.state === VaultSourceStatus.Unlocked) { return [...output, source.id]; } return output; }, []); const recentItems = await getRecents(unlockedIDs); recentItems.splice(count, Infinity); const searchResults = await getEntrySearchResults( recentItems.map((item) => ({ entryID: item.entryID, sourceID: item.sourceID })) ); sendResponse({ searchResults }); break; } case BackgroundMessageType.GetSavedCredentials: { const credentials = getAllCredentials(); sendResponse({ credentials }); break; } case BackgroundMessageType.GetSavedCredentialsForID: { const { credentialsID, excludeSaved = false } = msg; if (!credentialsID) { throw new Error("No credentials ID provided"); } let credentials = getCredentialsForID(credentialsID); if (credentials && excludeSaved && (await credentialsAlreadyStored(credentials))) { credentials = null; } sendResponse({ credentials: [credentials] }); break; } case BackgroundMessageType.InitiateDesktopConnection: { log("start desktop authentication"); await initiateConnection(); await createNewTab(getExtensionURL("full.html#/connect")); sendResponse({}); break; } case BackgroundMessageType.MarkNotificationRead: { const { notification } = msg; if (!notification) { throw new Error("No notification provided"); } log(`mark notification read: ${notification}`); await markNotificationRead(notification); sendResponse({}); break; } case BackgroundMessageType.OpenEntryPage: { const { autoLogin, entry } = msg; if (!entry) { throw new Error("No entry provided"); } const [url = null] = getEntryURLs(entry.properties, EntryURLType.Login); if (!url) { sendResponse({ opened: false }); return; } log(`open entry page by url: ${entry.id} (${url})`); const tabID = await openEntryPageInNewTab(entry, url); if (autoLogin) { registerAutoLogin(entry, tabID); } sendResponse({ opened: true }); break; } case BackgroundMessageType.OpenSaveCredentialsPage: { await createNewTab(getExtensionURL("full.html#/save-credentials")); await sendTabsMessage({ type: TabEventType.CloseSaveDialog }); sendResponse({}); break; } case BackgroundMessageType.PromptLockSource: { const { sourceID } = msg; if (!sourceID) { throw new Error("No source ID provided"); } log(`request lock source: ${sourceID}`); const locked = await promptSourceLock(sourceID); sendResponse({ locked }); break; } case BackgroundMessageType.PromptUnlockSource: { const { sourceID } = msg; if (!sourceID) { throw new Error("No source ID provided"); } log(`request unlock source: ${sourceID}`); await promptSourceUnlock(sourceID); sendResponse({}); break; } case BackgroundMessageType.ResetSettings: { log(`reset settings`); await clearLocalStorage(); await resetInitialisation(); sendResponse({}); break; } case BackgroundMessageType.SaveCredentialsToVault: { const { sourceID, groupID, entryID = null, entryProperties, entryType = EntryType.Website } = msg; if (!sourceID) { throw new Error("No source ID provided"); } if (!groupID) { throw new Error("No group ID provided"); } if (!entryProperties) { throw new Error("No entry properties provided"); } if (entryID) { log(`save credentials to existing entry: ${entryID} (source=${sourceID})`); await saveExistingEntry(sourceID, groupID, entryID, entryProperties); sendResponse({ entryID: null }); } else { log(`save credentials to new entry (source=${sourceID})`); const entryID = await saveNewEntry(sourceID, groupID, entryType, entryProperties); sendResponse({ entryID }); } break; } case BackgroundMessageType.SaveUsedCredentials: { const { credentials } = msg; if (!credentials) { throw new Error("No source ID provided"); } if (!sender.tab?.id) { throw new Error("No tab ID available for background message"); } updateUsedCredentials(credentials, sender.tab.id); sendResponse({}); break; } case BackgroundMessageType.SearchEntriesByTerm: { const { searchTerm } = msg; if (!searchTerm) { throw new Error("No search term provided"); } const searchResults = await searchEntriesByTerm(searchTerm); sendResponse({ searchResults }); break; } case BackgroundMessageType.SearchEntriesByURL: { const { url } = msg; if (!url) { throw new Error("No URL provided"); } const searchResults = await searchEntriesByURL(url); sendResponse({ searchResults }); break; } case BackgroundMessageType.SetConfigurationValue: { const { configKey, configValue } = msg; if (!configKey || typeof configValue === "undefined") { throw new Error("Invalid configuration proivided provided"); } await updateConfigValue(configKey, configValue); sendResponse({}); break; } case BackgroundMessageType.TrackRecentEntry: { const { entry } = msg; if (!entry) { throw new Error("No entry provided"); } if (!entry.sourceID) { throw new Error(`No source ID in entry result: ${entry.id}`); } await trackRecentUsage(entry.sourceID, entry.id); sendResponse({}); break; } default: throw new Layerr(`Unrecognised message type: ${(msg as any).type}`); } } export function initialise() { getExtensionAPI().runtime.onMessage.addListener((request, sender, sendResponse) => { handleMessage(request, sender, sendResponse).catch((err) => { console.error(err); sendResponse({ error: errorToString(new Layerr(err, "Background task failed")) }); }); return true; }); } ================================================ FILE: source/background/services/notifications.ts ================================================ import { getSyncValue, setSyncValue } from "./storage.js"; import { SyncStorageItem } from "../types.js"; import { NOTIFICATION_NAMES } from "../../shared/notifications/index.js"; import { createNewTab, getExtensionURL } from "../../shared/library/extension.js"; async function getPendingNotifications(): Promise> { const existingNotificationsRaw = await getSyncValue(SyncStorageItem.Notifications); const existingNotifications = existingNotificationsRaw ? existingNotificationsRaw.split(",") : []; return NOTIFICATION_NAMES.filter((name) => !existingNotifications.includes(name)); } export async function markNotificationRead(name: string): Promise { const existingNotificationsRaw = await getSyncValue(SyncStorageItem.Notifications); const notifications = existingNotificationsRaw ? existingNotificationsRaw.split(",") : []; if (!notifications.includes(name)) { notifications.push(name); } await setSyncValue(SyncStorageItem.Notifications, notifications.join(",")); } export async function showPendingNotifications(): Promise { const notifications = await getPendingNotifications(); if (notifications.length <= 0) return; await createNewTab(getExtensionURL(`full.html#/notifications?notifications=${notifications.join(",")}`)); } ================================================ FILE: source/background/services/recents.ts ================================================ import { EntryID, VaultSourceID } from "buttercup"; import { ChannelQueue, TaskPriority } from "@buttercup/channel-queue"; import ms from "ms"; import { getSyncValue, setSyncValue } from "./storage.js"; import { SyncStorageItem } from "../types.js"; export interface RecentItem { entryID: EntryID; sourceID: VaultSourceID; uses: Array; } const MAX_USE_AGE = ms("30d"); let __queue: ChannelQueue | null = null; function getQueue(): ChannelQueue { if (!__queue) { __queue = new ChannelQueue(); } return __queue; } export async function getRecents(sourceIDs: Array): Promise> { const currentRecentsRaw = await getSyncValue(SyncStorageItem.RecentItems); if (!currentRecentsRaw) return []; const currentRecents = JSON.parse(currentRecentsRaw) as Array; return currentRecents.filter((recent) => sourceIDs.includes(recent.sourceID)); } function sortUses(itemA: RecentItem, itemB: RecentItem): number { if (itemA.uses.length > itemB.uses.length) return -1; if (itemB.uses.length > itemA.uses.length) return 1; return 0; } function stripOldUses(items: Array): Array { const earliestTs = Date.now() - MAX_USE_AGE; return items.reduce((output: Array, item: RecentItem) => { const hasRecentUse = item.uses.some((ts) => ts >= earliestTs); if (!hasRecentUse) return output; return [ ...output, { ...item, uses: item.uses.filter((ts) => ts >= earliestTs) } ]; }, []); } export async function trackRecentUsage(sourceID: VaultSourceID, entryID: EntryID): Promise { const channel = getQueue().channel("write"); await channel.enqueue( async () => { const currentRecentsRaw = await getSyncValue(SyncStorageItem.RecentItems); let currentRecents = currentRecentsRaw ? (JSON.parse(currentRecentsRaw) as Array) : []; let existingResult = currentRecents.find( (recent) => recent.sourceID === sourceID && recent.entryID === entryID ); if (existingResult) { existingResult.uses.unshift(Date.now()); } else { existingResult = { entryID, sourceID, uses: [Date.now()] }; currentRecents.push(existingResult); } currentRecents = stripOldUses(currentRecents); currentRecents.sort(sortUses); await setSyncValue(SyncStorageItem.RecentItems, JSON.stringify(currentRecents)); }, TaskPriority.Normal, `${sourceID}-${entryID}` ); } ================================================ FILE: source/background/services/storage/BrowserStorageInterface.ts ================================================ import { StorageInterface } from "buttercup"; import { getExtensionAPI } from "../../../shared/extension.js"; export function getSyncStorage() { return getExtensionAPI().storage.sync; } export function getNonSyncStorage() { return getExtensionAPI().storage.local; } export class BrowserStorageInterface extends StorageInterface { protected _storage: chrome.storage.StorageArea; constructor(storage: chrome.storage.StorageArea = getSyncStorage()) { super(); this._storage = storage; } get storage() { return this._storage; } async getAllKeys() { return new Promise>((resolve) => { this.storage.get(null, (allItems) => { resolve(Object.keys(allItems)); }); }); } async getValue(name: string) { return new Promise((resolve) => { this.storage.get(name, (items) => { resolve(items[name]); }); }); } async removeKey(name: string) { return new Promise((resolve) => { this.storage.remove(name, () => resolve()); }); } async setValue(name: string, value: any) { return new Promise((resolve) => { this.storage.set({ [name]: value }, () => resolve()); }); } } ================================================ FILE: source/background/services/storage.ts ================================================ import { log } from "./log.js"; import { BrowserStorageInterface, getNonSyncStorage, getSyncStorage } from "./storage/BrowserStorageInterface.js"; import { LocalStorageItem, SyncStorageItem } from "../types.js"; const VALID_LOCAL_KEYS = Object.values(LocalStorageItem); const VALID_SYNC_KEYS = Object.values(SyncStorageItem); export async function clearLocalStorage(): Promise { const localStorage = getLocalStorage(); const syncStorage = getSynchronisedStorage(); const keys = await localStorage.getAllKeys(); for (const key of keys) { log(`clearing local storage key: ${key}`); await localStorage.removeKey(key); } await syncStorage.removeKey(SyncStorageItem.Notifications); } function getLocalStorage(): BrowserStorageInterface { return new BrowserStorageInterface(getNonSyncStorage()); } export async function getLocalValue(key: LocalStorageItem): Promise { return getLocalStorage().getValue(key) ?? null; } export async function getSyncValue(key: SyncStorageItem): Promise { return getSynchronisedStorage().getValue(key) ?? null; } function getSynchronisedStorage(): BrowserStorageInterface { return new BrowserStorageInterface(getSyncStorage()); } export async function initialise() { const localStorage = getLocalStorage(); { const keys = await localStorage.getAllKeys(); for (const key of keys) { const valid = VALID_LOCAL_KEYS.find((local) => key === local || key.indexOf(local) === 0); if (!valid) { log(`remove unrecognised local storage key: ${key}`); await localStorage.removeKey(key); } } } const syncStorage = getSynchronisedStorage(); { const keys = await syncStorage.getAllKeys(); for (const key of keys) { const valid = VALID_SYNC_KEYS.find((local) => key === local || key.indexOf(local) === 0); if (!valid) { log(`remove unrecognised sync storage key: ${key}`); await syncStorage.removeKey(key); } } } } export async function removeLocalValue(key: LocalStorageItem): Promise { await getLocalStorage().removeKey(key); } export async function removeSyncValue(key: SyncStorageItem): Promise { await getSynchronisedStorage().removeKey(key); } export async function setLocalValue(key: LocalStorageItem, value: string): Promise { return getLocalStorage().setValue(key, value); } export async function setSyncValue(key: SyncStorageItem, value: string): Promise { return getSynchronisedStorage().setValue(key, value); } ================================================ FILE: source/background/services/tabs.ts ================================================ import { getExtensionAPI } from "../../shared/extension.js"; import { TabEvent } from "../types.js"; export async function sendTabsMessage(payload: TabEvent, tabIDs: Array | null = null): Promise { const browser = getExtensionAPI(); const targetTabIDs = Array.isArray(tabIDs) ? tabIDs : ( await browser.tabs.query({ status: "complete" }) ).reduce((output: Array, tab) => { if (!tab.id) return output; return [...output, tab.id]; }, []); await Promise.all( targetTabIDs.map(async (tabID) => { browser.tabs.sendMessage(tabID, payload); }) ); } ================================================ FILE: source/background/types.ts ================================================ export * from "../shared/types.js"; export enum LocalStorageItem { APIClientID = "bcup:api:clientID", APIPrivateKey = "bcup:api:privateKey", APIPublicKey = "bcup:api:publicKey", APIServerPublicKey = "bcup:api:serverPublicKey" } export enum SyncStorageItem { Configuration = "bcup:configuration", DisabledDomains = "bcup:disabledDomains", Notifications = "bcup:notifications", RecentItems = "bcup:recents" } ================================================ FILE: source/full/applications/full.tsx ================================================ import React from "react"; import ReactDOM from "react-dom"; import { App } from "../components/App.js"; import { initialise } from "../services/init.js"; initialise() .then(() => { ReactDOM.render( , document.getElementById("root"), ); }) .catch(err => { console.error(err); }); ================================================ FILE: source/full/components/App.tsx ================================================ import React from "react"; import { createHashRouter, RouterProvider } from "react-router-dom"; import { ThemeProvider } from "../../shared/components/ThemeProvider.jsx"; import { ConnectPage } from "./pages/connect/index.jsx"; import { AttributionsPage } from "./pages/AttributionsPage.jsx"; import { SaveCredentialsPage } from "./pages/saveCredentials/index.js"; import { useBodyThemeClass, useTheme } from "../../shared/hooks/theme.js"; import { RouteError } from "../../shared/components/RouteError.js"; import { DisabledDomainsPage } from "./pages/DisabledDomainsPage.js"; import { NotificationsPage } from "./pages/NotificationsPage.js"; const ROUTER = createHashRouter([ { path: "/connect", element: , errorElement: }, { path: "/attributions", element: , errorElement: }, { path: "/disabled-domains", element: , errorElement: }, { path: "/notifications", element: , errorElement: , loader: ({ request }) => { const url = new URL(request.url); const notifications = url.searchParams.get("notifications"); return { notifications }; } }, { path: "/save-credentials", element: , errorElement: } ]); export function App() { const theme = useTheme(); useBodyThemeClass(theme); return ( ); } ================================================ FILE: source/full/components/Layout.tsx ================================================ import React from "react"; import styled from "styled-components"; import { Divider } from "@blueprintjs/core"; import { ChildElements } from "../types.js"; import BUTTERCUP_LOGO from "../../../resources/buttercup-128.png"; interface LayoutProps { children: ChildElements; title: string; } const ContentContainer = styled.div` padding: 1rem; `; const Header = styled.div` margin: 0.5rem 1rem 0; padding: 0.3rem 0; display: flex; justify-content: flex-start; align-items: center; `; const MainContent = styled.div` width: 100vw; min-height: 100vh; padding: 3rem 0; `; const Title = styled.h1` margin: 0px 0px 4px 0px; padding: 0; font-size: 18px; flex: 1; `; const TitleImage = styled.img` width: 28px; height: 28px; margin-bottom: 3px; margin-right: 6px; `; const Wrapper = styled.div` width: 680px; margin: 0 auto; display: flex; flex-direction: column; @media screen and (max-width: 700px) { width: 100%; } `; export function Layout({ children, title }: LayoutProps) { return (
{title}
{children}
); } ================================================ FILE: source/full/components/pages/AttributionsPage.tsx ================================================ import React from "react"; import styled from "styled-components"; import { Layout } from "../Layout.js"; import { t } from "../../../shared/i18n/trans.js"; import { useTitle } from "../../hooks/document.js"; import COMPUTER_ICON from "../../../../resources/providers/local-256.png"; const AttributionLI = styled.li` display: flex; flex-direction: row; align-items: center; `; const ImageIcon = styled.img` width: auto; height: 32px; margin-right: 12px; `; export function AttributionsPage() { useTitle(t("attributions-page.title")); return (

Buttercup is Open Source Software and makes use of many free and openly available libraries and resources.

Below are a list of resource attributions that this browser extension makes use of.

); } ================================================ FILE: source/full/components/pages/DisabledDomainsPage.tsx ================================================ import React, { Fragment, useCallback, useState } from "react"; import cn from "classnames"; import styled from "styled-components"; import { Button, Classes, Intent, NonIdealState, Spinner } from "@blueprintjs/core"; import { useTitle } from "../../hooks/document.js"; import { t } from "../../../shared/i18n/trans.js"; import { Layout } from "../Layout.js"; import { useDisabledDomains } from "../../hooks/disabledDomains.js"; import { ErrorMessage } from "../../../shared/components/ErrorMessage.js"; import { ConfirmDialog } from "../../../shared/components/ConfirmDialog.js"; import { getToaster } from "../../../shared/services/notifications.js"; import { localisedErrorMessage } from "../../../shared/library/error.js"; import { removeDisabledDomain } from "../../services/disabledDomains.js"; const ActionCell = styled.td` vertical-align: middle !important; `; const CenteredContent = styled.div` width: 100%; display: flex; flex-direction: column; align-items: center; `; const LoaderContainer = styled.div` width: 100%; display: flex; flex-direction: row; justify-content: center; padding: 20px 0px; `; const Table = styled.table` min-width: 80%; table-layout: fixed; `; export function DisabledDomainsPage() { useTitle(t("disabled-domains-page.title")); const [reloadCount, setReloadCount] = useState(0); const [domains, loading, error] = useDisabledDomains([reloadCount]); const [removeDomain, setRemoveDomain] = useState(null); const handleDomainRemove = useCallback(async () => { if (!removeDomain) return; try { removeDisabledDomain(removeDomain); setReloadCount(count => count += 1); } catch (err) { console.error(err); getToaster().show({ intent: Intent.DANGER, message: t("error.generic", { message: localisedErrorMessage(err) }), timeout: 10000 }); } setRemoveDomain(null); }, [removeDomain]); return (

{t("disabled-domains-page.disabled-domains.heading")}

{error && ( )} {loading && ( )} {!error && !loading && Array.isArray(domains) && ( {domains.length > 0 && ( {domains.map((domain, ind) => ( ))}
{t("disabled-domains-page.table.domain-heading")} {t("disabled-domains-page.table.action-heading")}
{domain}
) || ( )}
)} setRemoveDomain(null)} onConfirm={handleDomainRemove} title={t("disabled-domains-page.delete-dialog.title")} >
); } ================================================ FILE: source/full/components/pages/NotificationsPage.tsx ================================================ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useLoaderData } from "react-router-dom"; import { Icon, Intent, Tab, Tabs } from "@blueprintjs/core"; import { NOTIFICATIONS } from "../../../shared/notifications/index.js"; import { Layout } from "../Layout.js"; import { t } from "../../../shared/i18n/trans.js"; import { updateReadNotifications } from "../../services/notifications.js"; import { getToaster } from "../../../shared/services/notifications.js"; import { localisedErrorMessage } from "../../../shared/library/error.js"; export function NotificationsPage() { const { notifications: notificationsRaw = "" } = useLoaderData() as { notifications: string }; const notificationKeys = useMemo(() => notificationsRaw.split(","), [notificationsRaw]); const notifications = useMemo(() => notificationKeys.map(key => NOTIFICATIONS[key]), [notificationKeys]); const [currentTab, setCurrentTab] = useState(null); const [readNotifications, setReadNotifications] = useState>([]); const handleTabChange = useCallback((newTabID: string) => { setReadNotifications(current => [...new Set([ ...current, newTabID ])]); setCurrentTab(newTabID); const key = Object.keys(NOTIFICATIONS).find(nKey => NOTIFICATIONS[nKey][0] === newTabID); if (!key) return; updateReadNotifications(key).catch(err => { console.error(err); getToaster().show({ intent: Intent.DANGER, message: t("error.generic", { message: localisedErrorMessage(err) }), timeout: 10000 }); }); }, []); useEffect(() => { if (currentTab || notifications.length <= 0) return; handleTabChange(notifications[0][0]); }, [currentTab, handleTabChange, notifications]); return ( {notifications.map(([nameKey, Component]) => (   {t(nameKey)} )} panel={} /> ))} ); } ================================================ FILE: source/full/components/pages/connect/CodeInput.tsx ================================================ import React, { KeyboardEvent, useCallback } from "react"; import { Button, ControlGroup, InputGroup, Intent } from "@blueprintjs/core"; import styled from "styled-components"; import { t } from "../../../../shared/i18n/trans.js"; interface CodeInputProps { authenticating: boolean; onChange: (newValue: string) => void; onSubmit: () => void; value: string; } const Container = styled.div` width: 100%; margin-top: 32px; display: flex; flex-direction: row; justify-content: center; align-items: center; `; export function CodeInput(props: CodeInputProps) { const handleKeyPress = useCallback((event: KeyboardEvent) => { if ((event.key === "Enter" || event.keyCode === 13) && !event.ctrlKey && !event.shiftKey) { props.onSubmit(); } }, [props.onSubmit]); return ( props.onChange(evt.target.value)} onKeyDown={handleKeyPress} placeholder={t("connect-page.code-plc")} type="password" value={props.value} /> ); } ================================================ FILE: source/popup/components/pages/EntriesPage.tsx ================================================ import React, { useCallback, useContext, useMemo, useState } from "react"; import styled from "styled-components"; import { Button, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core"; import { SearchResult, VaultSourceStatus } from "buttercup"; import { t } from "../../../shared/i18n/trans.js"; import { useDesktopConnectionState, useEntriesForURL, useRecentEntries, useSearchedEntries, useVaultSources } from "../../hooks/desktop.js"; import { EntryItemList } from "../entries/EntryItemList.js"; import { LaunchContext } from "../contexts/LaunchContext.js"; import { sendEntryResultToTabForInput } from "../../services/tab.js"; import { trackEntryRecentUse } from "../../services/recents.js"; import { getToaster } from "../../../shared/services/notifications.js"; import { localisedErrorMessage } from "../../../shared/library/error.js"; import { DesktopConnectionState } from "../../types.js"; import { openPageForEntry } from "../../services/entry.js"; import { EntryInfoDialog } from "../entries/EntryInfoDialog.js"; interface EntriesPageProps { onConnectClick: () => Promise; onReconnectClick: () => Promise; searchTerm: string; } interface EntriesPageControlsProps { onSearchTermChange: (term: string) => void; searchTerm: string; } const Container = styled.div` display: flex; flex-direction: column; justify-content: flex-start; align-items: stretch; `; const Input = styled(InputGroup)` margin-right: 2px !important; `; const InvalidState = styled(NonIdealState)` margin-top: 28px; `; export function EntriesPage(props: EntriesPageProps) { const desktopState = useDesktopConnectionState(); return ( {desktopState === DesktopConnectionState.NotConnected && ( )} /> )} {desktopState === DesktopConnectionState.Connected && ( )} {desktopState === DesktopConnectionState.Pending && ( )} {desktopState === DesktopConnectionState.Error && ( )} /> )} ); } function EntriesPageList(props: EntriesPageProps) { const sources = useVaultSources(); const unlockedCount = useMemo( () => sources.reduce( (count, source) => source.state === VaultSourceStatus.Unlocked ? count + 1 : count, 0 ), [sources] ); const searchedEntries = useSearchedEntries(props.searchTerm); const { formID, source: popupSource, url } = useContext(LaunchContext); const [selectedEntryInfo, setSelectedEntryInfo] = useState(null); const urlEntries = useEntriesForURL(url); const recentEntries = useRecentEntries(); const handleEntryClick = useCallback((entry: SearchResult, autoLogin: boolean) => { if (popupSource === "page" && formID) { sendEntryResultToTabForInput(formID, entry); } else if (popupSource === "popup") { openPageForEntry(entry, autoLogin) .then(opened => { if (!opened) { getToaster().show({ intent: Intent.PRIMARY, message: t("popup.entries.click.no-url-available"), timeout: 3000 }); } }) .catch(err => { console.error(err); getToaster().show({ intent: Intent.DANGER, message: t("popup.entries.click.open-error", { message: localisedErrorMessage(err) }), timeout: 10000 }); }); } trackEntryRecentUse(entry).catch(err => { console.error(err); getToaster().show({ intent: Intent.DANGER, message: t("popup.entries.click.recent-set-error", { message: localisedErrorMessage(err) }), timeout: 10000 }); }); }, [popupSource]); const handleEntryAutoLoginClick = useCallback((entry: SearchResult) => { handleEntryClick(entry, true); }, [handleEntryClick]); const handleEntryBodyClick = useCallback((entry: SearchResult) => { handleEntryClick(entry, false); }, [handleEntryClick]); const handleEntryInfoClick = useCallback((entry: SearchResult) => { setSelectedEntryInfo(entry); }, []); // Render return ( <> {unlockedCount === 0 && ( ) || searchedEntries.length > 0 && ( ) || (urlEntries.length <= 0 && recentEntries.length <= 0) && ( ) || ( )} setSelectedEntryInfo(null)} /> ); } export function EntriesPageControls(props: EntriesPageControlsProps) { const desktopState = useDesktopConnectionState(); return ( <> props.onSearchTermChange(evt.target.value)} placeholder={t("popup.entries.search.placeholder")} round value={props.searchTerm} /> {hasSavedCredentials && ( )} setValue("entryIcons", evt.currentTarget.checked)} /> )} /> ); } ================================================ FILE: source/shared/components/ErrorBoundary.tsx ================================================ import React, { Component } from "react"; import styled from "styled-components"; import { Callout, Intent } from "@blueprintjs/core"; import { t } from "../i18n/trans.js"; const ErrorCallout = styled(Callout)` margin: 4px; box-sizing: border-box; width: calc(100% - 8px) !important; height: calc(100% - 8px) !important; overflow: scroll; `; const PreForm = styled.pre` margin: 0px; `; function stripBlanks(txt = "") { return txt .split(/(\r\n|\n)/g) .filter(ln => ln.trim().length > 0) .join("\n"); } export class ErrorBoundary extends Component { static getDerivedStateFromError(error: Error) { return { error }; } state: { error: null | Error; errorStack: string | null; } = { error: null, errorStack: null }; componentDidCatch(error: Error, errorInfo) { this.setState({ errorStack: errorInfo.componentStack || null }); } render() { if (!this.state.error) { return this.props.children || null; } return (

{t("error.fatal-boundary")}

{this.state.error.toString()} {this.state.errorStack && ( {stripBlanks(this.state.errorStack)} )}
); } } ================================================ FILE: source/shared/components/ErrorMessage.tsx ================================================ import React from "react"; import { Callout, Intent } from "@blueprintjs/core"; import styled from "styled-components"; interface ErrorMessageProps { message: string; scroll?: boolean; } const ErrorCallout = styled(Callout)` margin: 4px; box-sizing: border-box; width: calc(100% - 8px) !important; height: calc(100% - 8px) !important; overflow: ${p => p.scroll ? "scroll" : "hidden"}; `; export function ErrorMessage(props: ErrorMessageProps) { const { message, scroll = true } = props; return ( {message} ); } ================================================ FILE: source/shared/components/RouteError.tsx ================================================ import React from "react"; import styled from "styled-components"; import { useRouteError } from "react-router-dom"; import { Callout, Intent } from "@blueprintjs/core"; import { t } from "../i18n/trans.js"; const ErrorCallout = styled(Callout)` margin: 4px; box-sizing: border-box; width: calc(100% - 8px) !important; height: calc(100% - 8px) !important; overflow: scroll; `; const PreForm = styled.pre` margin: 0px; `; function stripBlanks(txt = "") { return txt .split(/(\r\n|\n)/g) .filter(ln => ln.trim().length > 0) .join("\n"); } export function RouteError() { const err = useRouteError() as Error | null; if (!err) return null; return (

{t("error.fatal-boundary")}

{(!err.stack || err.stack.includes(err.message) === false) && ( {err.message} )} {err.stack && ( {stripBlanks(err.stack)} )}
); } ================================================ FILE: source/shared/components/ThemeProvider.tsx ================================================ import React from "react"; import { ThemeProvider as StyledThemeProvider } from "styled-components"; import themesInternal from "../themes.js"; import { ChildElements } from "../types.js"; interface ThemeProviderProps { children: ChildElements; darkMode: boolean; } export function ThemeProvider(props: ThemeProviderProps) { const { children, darkMode } = props; return ( {children} ); } ================================================ FILE: source/shared/components/loading/BusyLoader.tsx ================================================ import React from "react"; import { Classes, Overlay, H4, Spinner, Text } from "@blueprintjs/core"; import styled from "styled-components"; import cn from "classnames"; interface BusyLoaderProps { description: string; title: string; } const OverlayBody = styled.div` display: flex; flex-direction: column; justify-content: flex-start; align-items: center; `; const OverlayContainer = styled(Overlay)` display: flex; justify-content: center; align-items: center; `; export function BusyLoader(props: BusyLoaderProps) { return (

{props.title}

{props.description}
); } ================================================ FILE: source/shared/extension.ts ================================================ export function getExtensionAPI(): typeof chrome { if (BROWSER === "firefox") { return browser; } return self.chrome || self["browser"]; } ================================================ FILE: source/shared/hooks/async.ts ================================================ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DependencyList } from "react"; export interface AsyncResult { error: Error | null; loading: boolean; value: T | null; } export function useAsync( fn: () => Promise, deps: DependencyList = [], { clearOnExec = true, updateInterval = null, valuesDiffer = () => true }: { clearOnExec?: boolean; updateInterval?: number | null; valuesDiffer?: (existingValue: T | null, newValue: T | null) => boolean; } = {} ): AsyncResult { const mounted = useRef(false); const executing = useRef(false); const [value, setValue] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(null); const [, setTimer] = useState>(null); const execute = useCallback(async () => { if (!mounted.current) return; if (executing.current) return; if (clearOnExec) setValue(null); executing.current = true; setError(null); setLoading((isLoading) => (isLoading === null ? true : isLoading)); await fn() .then((result: T) => { executing.current = false; if (!mounted.current) return; setValue((existing) => { return valuesDiffer(existing, result) ? result : existing; }); setLoading(false); }) .catch((err) => { executing.current = false; if (!mounted.current) return; setError(err); setLoading(false); }); }, [fn]); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); useEffect(() => { if (updateInterval === null) return; setTimer((existing) => { clearTimeout(existing as any); return null; }); let newTimer: ReturnType; const startNewTimer = () => { newTimer = setTimeout(() => { execute().then(() => { startNewTimer(); }); }, updateInterval); setTimer(newTimer); }; startNewTimer(); return () => { clearTimeout(newTimer); }; }, [execute, updateInterval]); useEffect(() => { if (!mounted.current) return; execute(); }, [execute, ...deps]); const output = useMemo( () => ({ error, loading: typeof loading === "boolean" ? loading : false, value }), [error, loading, value] ); return output; } export function useAsyncWithTimer( fn: () => Promise, delay: number, deps: DependencyList = [] ): { error: Error | null; loading: boolean; value: T | null; } { const mounted = useRef(false); const allTimers = useRef>>([]); const [time, setTime] = useState(Date.now()); const [timer, setTimer] = useState | null>(null); const { error, loading, value } = useAsync(fn, [...deps, time]); const [lastValue, setLastValue] = useState(value); useEffect(() => { mounted.current = true; return () => { mounted.current = false; allTimers.current.forEach((currentTimer) => { clearInterval(currentTimer); }); }; }, []); useEffect(() => { if (time === 0) return; if (error) { clearInterval(timer as any); setTime(0); setTimer(null); return; } if (!timer) { const thisTimer = setInterval(() => { if (!mounted.current) return; setTime(Date.now()); }, delay); allTimers.current.push(thisTimer as any); setTimer(thisTimer); } }, [time, timer, error, delay]); useEffect(() => { if (value !== null) { setLastValue(value); } }, [value]); return { error, loading, value: lastValue }; } ================================================ FILE: source/shared/hooks/config.ts ================================================ import { useCallback, useState } from "react"; import { useAsync } from "./async.js"; import { getConfig } from "../queries/config.js"; import { setConfigValue as setNewBackgroundValue } from "../queries/config.js"; import { Configuration } from "../types.js"; import { useGlobal } from "./global.js"; export function useConfig(): [ Configuration | null, Error | null, (setKey: T, value: Configuration[T]) => void ] { const [ts, setTs] = useGlobal("configFlagTs"); const { value, error } = useAsync(getConfig, [ts], { clearOnExec: false }); const [changeError, setChangeError] = useState(null); const setConfigValue = useCallback((setKey: T, value: Configuration[T]) => { setChangeError(null); setNewBackgroundValue(setKey, value) .then(() => { setTs(Date.now()); }) .catch((err) => { console.error(err); setChangeError(err); }); }, []); return [value || null, changeError || error, setConfigValue]; } ================================================ FILE: source/shared/hooks/global.ts ================================================ import EventEmitter from "eventemitter3"; import { useCallback, useEffect, useState } from "react"; interface Globals { configFlagTs: number | null; } const __globals: Globals = { configFlagTs: null }; let __ee: EventEmitter | null = null; export function useGlobal(key: K): [Globals[K], (value: Globals[K]) => void] { useEffect(() => { if (!__ee) { __ee = new EventEmitter(); } }, []); const handleEventUpdate = useCallback(() => { setCurrentValue(__globals[key]); }, [key]); useEffect(() => { if (!__ee) return; handleEventUpdate(); __ee.on("update", handleEventUpdate); return () => { if (!__ee) return; __ee.off("update", handleEventUpdate); }; }, [handleEventUpdate]); const [currentValue, setCurrentValue] = useState(__globals[key]); const handleValueChange = useCallback( (value: Globals[K]) => { __globals[key] = value; setCurrentValue(value); if (__ee) { __ee.emit("update"); } }, [key] ); return [currentValue, handleValueChange]; } ================================================ FILE: source/shared/hooks/theme.ts ================================================ import { useEffect } from "react"; import { useConfig } from "./config.js"; import { Classes } from "@blueprintjs/core"; let __bodyThemeAttached: boolean = false; export function useBodyThemeClass(theme: "dark" | "light"): void { useEffect(() => { if (__bodyThemeAttached) { console.warn("Multiple body theme controllers running"); return; } __bodyThemeAttached = true; if (theme === "dark") { document.body.classList.add(Classes.DARK); } else { document.body.classList.remove(Classes.DARK); } return () => { __bodyThemeAttached = false; }; }, [theme]); } export function useTheme(): "dark" | "light" { const [config] = useConfig(); if (!config || config.useSystemTheme) { const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); return darkThemeMq.matches ? "dark" : "light"; } return config?.theme === "dark" ? "dark" : "light"; } ================================================ FILE: source/shared/hooks/timer.ts ================================================ import { useEffect } from "react"; import { DependencyList } from "react"; export function useTimer(callback: () => void, delay: number, dependencies: DependencyList) { useEffect(() => { const timer = setInterval(callback, delay); return () => clearInterval(timer); }, dependencies); } ================================================ FILE: source/shared/i18n/trans.ts ================================================ import i18next, { TOptions } from "i18next"; import en from "./translations/en.json"; import nl from "./translations/nl.json"; export const DEFAULT_LANGUAGE = "en"; export const TRANSLATIONS = { en, // Keep as first item // All others sorted alphabetically: nl }; export async function changeLanguage(lang: string) { await i18next.changeLanguage(lang); } export async function initialise(lang: string) { await i18next.init({ lng: lang, fallbackLng: DEFAULT_LANGUAGE, debug: false, resources: Object.keys(TRANSLATIONS).reduce( (output, lang) => ({ ...output, [lang]: { translation: TRANSLATIONS[lang] } }), {} ) }); } export function onLanguageChanged(callback: (lang: string) => void): () => void { const cb = (lang: string) => callback(lang); i18next.on("languageChanged", cb); return () => { i18next.off("languageChanged", cb); }; } export function t(key: string, options?: TOptions) { return i18next.t(key, options); } ================================================ FILE: source/shared/i18n/translations/en.json ================================================ { "_": "English (UK)", "about": { "attributions": "Attributions", "info": { "build-date": "Build Date", "title": "Info", "version": "Version" } }, "attributions-page": { "title": "Attributions" }, "config": { "default-hint": "(default)", "input-button-type": { "innericon": "Small interior icon", "largebutton": "Large external button" }, "reset-dialog": { "cancel-button": "Cancel", "confirm-button": "Reset", "message": "Are you sure you wish to reset the application? This clears all cached data, settings and keys." }, "section": { "advanced": "Advanced Settings", "forms": "Forms", "logins": "Logins", "privacy": "Privacy", "theme": "Theme" }, "setting": { "entryIcons": "Fetch dynamic entry icons (anonymous)", "manageDisabledDomains": "Manage disabled domains", "reset": "Reset Application Data & Settings", "reviewSavedLogins": "Review saved logins", "saveNewLogins": "Prompt for saving new logins", "theme": "Theme", "useSystemTheme": "Use System Theme" } }, "confirm-dialog": { "cancel": "Cancel", "confirm-default": "Confirm" }, "connect-page": { "auth-error": "Failed authenticating: {{message}}", "auth-success": "Successfully connected", "code-plc": "Code...", "description": "Connect the Buttercup extension to the Buttercup Desktop application so you can use your vaults and entries to easily access your web-based accounts.", "instruction": "The desktop application should have appeared. If it hasn't, please close this page and try again. You must have opened the desktop application before trying. Wait for an authorisation code to appear in the desktop application, and then enter it here:", "title": "Connect desktop application" }, "disabled-domains-page": { "delete-dialog": { "confirm": "Delete domain", "description": "Remove the disabled domain so that new logins will prompt to save credentials: {{domain}}", "title": "Remove disabled domain" }, "description": "Manage which domains should not display a save-credentials prompt after logging in.", "disabled-domains": { "heading": "Currently disabled domains" }, "table": { "action": { "delete": "Remove disabled domain" }, "action-heading": "Action", "domain-heading": "Domain", "empty-description": "Disabled domains will appear here after clicking 'Disable' on the save credentials dialog after logging into a website.", "empty-title": "No disabled domains" }, "title": "Disabled Domains" }, "error": { "code": { "desktop-connection-not-authorised": "Desktop connection has not been authorised", "desktop-request-failed": "Desktop request failed" }, "desktop": { "connection-check-failed": "Failed checking desktop connection: {{message}}", "otps-fetch-failed": "Failed fetching OTPs: {{message}}", "search-failed": "Failed searching for entries: {{message}}", "sources-fetch-failed": "Failed fetching vaults: {{message}}" }, "fatal-boundary": "A fatal error has occurred - we're sorry this has happened. Please check out the details below in case they help diagnose the issue:", "generic": "Requested action failed: {{message}}", "otp-generate": "Failed generating an OTP code for an OTP URI: {{message}}", "reset": "Failed resetting application settings: {{message}}" }, "form": { "invalid": { "required-non-empty": "Invalid: value is required (cannot be empty)" }, "required": "(required)" }, "notifications": { "page": { "welcome-v3": { "line-1": "You're now using the new version of the Buttercup browser addon. It uses the latest core libraries to manage your password vaults and integrate with web pages and login forms. Version 3 is more light-weight and accurate when compared to previous versions, and it supports far more login forms as well.", "line-2": "It should be noted that version 3 of the extension requires the Buttercup desktop application version 2.26 or later be installed and running, at least in the background. This browser addon uses an encrypted connection with the desktop application to transfer vault credentials during login and saving. This addon cannot function without the desktop application.", "line-3": "The latest version of the Buttercup desktop application should be downloaded from Buttercup.pw", "line-4": "We hope that you enjoy using this new version!", "title": "Welcome to V3" } }, "title": "Notifications" }, "popup": { "all-locked": { "description": "All vaults are currently locked.", "title": "No Unlocked Vaults" }, "connection": { "check-error": { "description": "Unable to establish a connection to the desktop application. Check that it's open or consider re-authenticating.", "title": "Connection Failed" }, "open-error": "Failed starting connection: {{message}}", "reauth-error": "Failed re-authenticating: {{message}}" }, "entries": { "auto-login": { "tooltip": "Open entry URL and automatically log in" }, "click": { "no-url-available": "No URL available for this entry", "open-error": "Could not open page for entry: {{message}}", "recent-set-error": "Failed recording recent entry: {{message}}" }, "info": { "copy-error": "Failed copying value: {{message}}", "copy-success": "Copied value to clipboard: {{property}}", "copy-tooltip": "Copy to clipoard", "tooltip": "Show entry properties" }, "otp": { "code-error": "ERROR", "label-error": "Bad OTP Item" }, "search": { "button": "Search entries", "placeholder": "Search..." } }, "no-entries": { "description": "No available entries - recent items and results for the current tab will appear here.", "title": "No Entries" }, "no-otps": { "description": "No OTP entries found in unlocked vaults.", "title": "No OTP Entries" }, "otps": { "click": { "no-url-available": "No URL available for this OTP", "open-error": "Could not open page for OTP: {{message}}" } }, "tab": { "about": { "title": "About" }, "entries": { "title": "Entries" }, "otps": { "title": "One-Time Passwords (OTPs)" }, "settings": { "title": "Settings" }, "vaults": { "title": "Vaults" } }, "vault": { "lock": "Lock vault", "locking": { "error": "Failed locking vault: {{message}}", "success": "Locked vault: {{vault}}" }, "remove": "Remove vault", "remove-dialog": { "cancel-button": "Cancel", "confirm-button": "Remove", "message": "Are you sure that you want to remove the vault \"{{vault}}\"?" }, "removing": { "description": "Vault is being removed...", "error": "Failed removing: {{message}}", "success": "Successfully removed: {{vault}}", "title": "Removing" }, "state-pending": "Vault state is pending", "unlock": "Unlock vault", "unlock-dialog": { "cancel-button": "Cancel", "password-label": "Vault Password", "title": "Unlock {{title}}", "unlock-button": "Unlock" }, "unlocking": { "description": "Vault is being unlocked...", "error": "Failed unlocking vault: {{message}}", "invalid-password": "Password is invalid", "success": "Successfully unlocked: {{vault}}", "title": "Unlocking" } }, "vaults": { "controls": { "add-vault": "Add vault", "lock-vaults": "Lock all vaults" }, "empty": { "description": "No vaults have been added to your desktop application, yet.", "title": "No Vaults" }, "no-connection": { "action-text": "Connect", "description": "You haven't connected to the desktop application, yet.", "title": "Not Connected" } } }, "save-credentials-dialog": { "close-button": "Close", "credentials-fetch-error": "Failed fetching credentials: {{message}}", "description": "One or more logins have been recorded and are ready to be saved to your vault.", "disable-button": "Disable", "disable-confirm-button": "Confirm Disable", "error-description": "We weren't able to get the details you wanted, sorry.", "error-title": "Whoops...", "last-login-heading": "Last login", "title": "Save Login", "view-button": "View" }, "save-credentials-page": { "credentials-saver": { "create-new": { "heading": "New Entry Details", "label": { "password": "Password", "title": "Title", "url": "URL", "username": "Username" }, "loader": { "description": "Fetching vaults and their contents...", "title": "Vaults" }, "password": { "hide": "Hide password", "show": "Show password" }, "placeholder": { "title": "New entry title", "url": "New entry URL", "username": "New entry username" }, "save": "Save New Entry", "tab": "Create New Entry" }, "heading": "Save Login", "no-vaults": { "description": "No vaults are currently available. Either add a vault or unlock some.", "title": "No Available Vaults" }, "update-existing": { "tab": "Update Existing Entry" } }, "description": "Save detected login details to a connected vault. Make sure to first unlock the vault you wish to save to in the desktop application.", "detected-logins": { "heading": "Detected Logins" }, "save-error": "Failed saving entry: {{message}}", "save-success": "Successfully saved entry: {{title}}", "title": "Save Logins" }, "theme": { "dark": "Dark", "light": "Light" }, "vault-state": { "locked": "Locked", "pending": "Pending", "unlocked": "Unlocked" }, "vault-type": { "dropbox": { "add-error": "Failed adding Dropbox vault", "configure-btn": "Authenticate", "description": "Host your vault within your Dropbox cloud storage account, so that you can access it from any device you own. You must have an account with Dropbox to be able to use this vault type.", "title": "Dropbox" }, "googledrive": { "configure-btn": "Authenticate", "description": "Host your vault within your Google Drive cloud storage, so that you can access it from any device you own. You must have a Google account to be able to use this vault type.", "title": "Google Drive" }, "localfile": { "configure-btn": "Connect", "description": "Use the Buttercup desktop application to supply access to local files on your computer. Requires the desktop application to be installed and running.", "title": "Local File" }, "webdav": { "configure-btn": "Configure", "description": "Use the DAV protocol that certain services provide to store your vault. Self-hosted cloud storage providers such as Nextcloud and ownCloud support this protocol, allowing you to access your vault from any device you own.", "title": "WebDAV" } } } ================================================ FILE: source/shared/i18n/translations/nl.json ================================================ { "_": "Nederlands (NL)", "about": { "attributions": "Toeschrijvingen", "info": { "build-date": "Build-datum", "title": "Info", "version": "Versie" } }, "attributions-page": { "title": "Toeschrijvingen" }, "config": { "default-hint": "(standaard)", "input-button-type": { "innericon": "Klein icoon binnenin", "largebutton": "Grote externe knop" }, "reset-dialog": { "cancel-button": "Annuleren", "confirm-button": "Reset", "message": "Weet je zeker dat je de toepassing wilt resetten? Dit wist alle gecachte gegevens, instellingen en sleutels." }, "section": { "advanced": "Geavanceerde instellingen", "forms": "Formulieren", "logins": "Logins", "privacy": "Privacy", "theme": "Thema" }, "setting": { "entryIcons": "Ophalen van website-iconen (anoniem)", "manageDisabledDomains": "Beheer uitgezonderde domeinen", "reset": "Reset toepassinggegevens en instellingen", "reviewSavedLogins": "Beoordeel opgeslagen logins", "saveNewLogins": "Prompt voor het opslaan van nieuwe logins", "theme": "Thema", "useSystemTheme": "Gebruik systeemthema" } }, "confirm-dialog": { "cancel": "Annuleren", "confirm-default": "Bevestigen" }, "connect-page": { "auth-error": "Authenticatie mislukt: {{message}}", "auth-success": "Succesvol verbonden", "code-plc": "Code...", "description": "Verbind de Buttercup-extensie met de Buttercup-desktoptoepassing, zodat je jouw kluizen en gegevens kunt gebruiken om gemakkelijk toegang te krijgen tot je webgebaseerde accounts.", "instruction": "De desktopapplicatie zou moeten zijn verschenen. Als dat niet het geval is, sluit dan deze pagina en probeer het opnieuw. Je moet de desktopapplicatie hebben geopend voordat je het probeert. Wacht tot er een autorisatiecode verschijnt in de desktopapplicatie en voer deze hier in:", "title": "Verbind desktop-toepassing" }, "disabled-domains-page": { "delete-dialog": { "confirm": "Uitzonderen domein", "description": "Verwijder het uitgezonderde domein zodat nieuwe inlogpogingen worden gevraagd om referenties op te slaan: {{domain}}", "title": "Uitgezonderd domein verwijderen" }, "description": "Beheer welke domeinen niet een prompt moeten weergeven om referenties op te slaan na het inloggen.", "disabled-domains": { "heading": "Momenteel uitgezonderde domeinen" }, "table": { "action": { "delete": "Verwijder uitgezonderd domein" }, "action-heading": "Actie", "domain-heading": "Domein", "empty-description": "Uitgezonderde domeinen verschijnen hier nadat je op 'Uitzonderen domein' hebt geklikt in het dialoogvenster voor het opslaan van referenties nadat je hebt aangemeld op een website.", "empty-title": "Geen uitgezonderde domeinen" }, "title": "Uitgezonderde domeinen" }, "error": { "code": { "desktop-connection-not-authorised": "Desktop verbinding is niet geautoriseerd", "desktop-request-failed": "Desktop verzoek mislukt" }, "desktop": { "connection-check-failed": "Controleren desktop verbinding mislukt: {{message}}", "otps-fetch-failed": "Ophalen OTPs mislukt: {{message}}", "search-failed": "Zoeken naar items mislukt: {{message}}", "sources-fetch-failed": "Ophalen kluizen mislukt: {{message}}" }, "fatal-boundary": "Een fatale fout is opgetreden - het spijt ons dat dit is gebeurd. Controleer de onderstaande details om het probleem te diagnosticeren:", "generic": "Gevraagde actie mislukt: {{message}}", "otp-generate": "Genereren OTP code voor OTP URI mislukt: {{message}}", "reset": "Resetten van toepassinginstellingen mislukt: {{message}}" }, "form": { "invalid": { "required-non-empty": "Ongeldig: waarde is vereist (kan niet leeg zijn)" }, "required": "(vereist)" }, "notifications": { "page": { "welcome-v3": { "line-1": "Je gebruikt nu de nieuwe versie van de Buttercup-browserextensie. Het maakt gebruik van de nieuwste kernbibliotheken om jouw wachtwoordkluizen te beheren en te integreren met webpagina's en inlogformulieren. Versie 3 is lichter en nauwkeuriger in vergelijking met eerdere versies, en ondersteunt ook veel meer inlogformulieren.", "line-2": "Het moet worden opgemerkt dat versie 3 de Buttercup desktoptoepassing vereist om geïnstalleerd en actief te zijn, op zijn minst op de achtergrond. Deze browserextensie gebruikt een versleutelde verbinding met de desktoptoepassing om kluisreferenties over te dragen tijdens het inloggen en opslaan. Deze extensie kan niet functioneren zonder de desktoptoepassing.Opgemerkt moet worden dat versie 3 van de extensie vereist dat de Buttercup desktop-applicatie versie 2.26 of hoger geïnstalleerd is en, tenminste op de achtergrond. Deze browser-add-on gebruikt een gecodeerde verbinding met de desktopapplicatie om kluisreferenties over te dragen tijdens het inloggen en opslaan. Deze add-on kan niet functioneren zonder de desktopapplicatie.", "line-3": "De nieuwste versie van de Buttercup-desktopapp moet worden gedownload van Buttercup.pw", "line-4": "Wij hopen dat je deze nieuwe versie met veel plezier zal gebruiken!", "title": "Welkom bij V3" } }, "title": "Meldingen" }, "popup": { "all-locked": { "description": "Alle kluizen zijn momenteel vergrendeld.", "title": "Geen ontgrendelde kluizen" }, "connection": { "check-error": { "description": "Kan geen verbinding maken met de desktop toepassing. Controleer of deze geopend is of overweeg opnieuw te authenticeren.", "title": "Verbinding mislukt" }, "open-error": "Opzetten verbinding mislukt: {{message}}", "reauth-error": "Opnieuw authenticeren mislukt: {{message}}" }, "entries": { "auto-login": { "tooltip": "Open item URL en log automatisch in" }, "click": { "no-url-available": "Geen URL beschikbaar voor dit item", "open-error": "Kan pagina niet openen voor item: {{message}}", "recent-set-error": "Onlangs gebruikte items niet geregistreerd: {{message}}" }, "info": { "copy-error": "Waarde kopieren mislukt: {{message}}", "copy-success": "Waarde gekopieerd naar klembord: {{property}}", "copy-tooltip": "Kopieer naar klembord", "tooltip": "Toon item eigenschappen" }, "otp": { "code-error": "ERROR", "label-error": "Ongeldig OTP-item" }, "search": { "button": "Zoek items", "placeholder": "Zoeken..." } }, "no-entries": { "description": "Geen beschikbare items - recente items en resultaten voor het huidige tabblad zullen hier verschijnen.", "title": "Geen items" }, "no-otps": { "description": "Geen OTP-items gevonden in de ontgrendelde kluizen.", "title": "Geen OTP-items" }, "otps": { "click": { "no-url-available": "Geen URL beschikbaar voor dit OTP", "open-error": "Kan pagina niet openen voor OTP: {{message}}" } }, "tab": { "about": { "title": "Over" }, "entries": { "title": "Items" }, "otps": { "title": "One-Time Passwords (OTPs)" }, "settings": { "title": "Instellingen" }, "vaults": { "title": "Kluizen" } }, "vault": { "lock": "Vergrendel kluis", "locking": { "error": "Vergrendelen kluis mislukt: {{message}}", "success": "Vergrendelde kluis: {{vault}}" }, "remove": "Verwijder kluis", "remove-dialog": { "cancel-button": "Annuleren", "confirm-button": "Verwijderen", "message": "Weet je zeker dat je de kluis \"{{vault}}\" wilt verwijderen?" }, "removing": { "description": "Kluis wordt verwijderd...", "error": "Verwijderen mislukt: {{message}}", "success": "Succesvol verwijderd: {{vault}}", "title": "Verwijderen" }, "state-pending": "Kluis is bezig", "unlock": "Ongrendel kluis", "unlock-dialog": { "cancel-button": "Annuleren", "password-label": "Kluiswachtwoord", "title": "Ontgrendel {{title}}", "unlock-button": "Ontgrendelen" }, "unlocking": { "description": "Kluis wordt ontgrendeld...", "error": "Kluis ontgrendelen mislukt: {{message}}", "invalid-password": "Wachtwoord onjuist", "success": "Succesvol ontgrendeld: {{vault}}", "title": "Ontgrendelen" } }, "vaults": { "controls": { "add-vault": "Toevoegen kluis", "lock-vaults": "Vergrendel alle kluizen" }, "empty": { "description": "Er zijn nog geen kluizen toegevoegd aan de desktop-toepassing.", "title": "Geen kluizen" }, "no-connection": { "action-text": "Verbinden", "description": "Er is nog geen verbinding met de desktop-toepassing.", "title": "Niet verbonden" } } }, "save-credentials-dialog": { "close-button": "Sluiten", "credentials-fetch-error": "Ophalen referenties mislukt: {{message}}", "description": "Een of meer login zijn gedetecteerd en zijn klaar om te slaan in jouw kluis.", "disable-button": "Uitzondering", "disable-confirm-button": "Bevestig uitzondering", "error-description": "We konden de details niet ophalen, sorry.", "error-title": "Whoops...", "last-login-heading": "Laatste login", "title": "Bewaar login", "view-button": "Bekijken" }, "save-credentials-page": { "credentials-saver": { "create-new": { "heading": "Nieuwe item details", "label": { "password": "Wachtwoord", "title": "Titel", "url": "URL", "username": "Gebruikersnaam" }, "loader": { "description": "Ophalen van kluizen en hun inhoud...", "title": "Kluizen" }, "password": { "hide": "Verberg wachtwoord", "show": "Toon wachtwoord" }, "placeholder": { "title": "Nieuw item naam", "url": "Nieuw item URL", "username": "Nieuw item gebruikersnaam" }, "save": "Nieuw item opslaan", "tab": "Nieuw item maken" }, "heading": "Login opslaan", "no-vaults": { "description": "Momenteel zijn er geen kluizen beschikbaar. Voeg een nieuwe kluis toe of ontgrendel er een.", "title": "Geen beschikbare kluizen" }, "update-existing": { "tab": "Update bestaand item" } }, "description": "Sla de gedetecteerde inloggegevens op in een verbonden kluis. Zorg ervoor dat je eerst de kluis ontgrendelt waarin je wilt opslaan in de desktoptoepassing.", "detected-logins": { "heading": "Gedetecteerde logins" }, "save-error": "Opslaan item mislukt: {{message}}", "save-success": "Item succesvol opgeslagen: {{title}}", "title": "Logins opslaan" }, "theme": { "dark": "Donker", "light": "Licht" }, "vault-state": { "locked": "Vergrendeld", "pending": "Bezig", "unlocked": "Ontgrendeld" }, "vault-type": { "dropbox": { "add-error": "Kluis vanuit Dropbox toevoegen mislukt", "configure-btn": "Authenticatie", "description": "Host je kluis binnen jouw Dropbox-cloudopslagaccount, zodat je er vanaf elk apparaat dat je bezit toegang toe hebt. Je moet een account bij Dropbox hebben om dit kluis type te kunnen gebruiken.", "title": "Dropbox" }, "googledrive": { "configure-btn": "Authenticatie", "description": "Host je kluis binnen jouw Google Drive-cloudopslag, zodat je er vanaf elk apparaat dat je bezit toegang toe hebt. Je moet een Google-account hebben om dit kluis type te kunnen gebruiken.", "title": "Google Drive" }, "localfile": { "configure-btn": "Verbind", "description": "Gebruik de Buttercup desktopapplicatie om toegang te krijgen tot lokale bestanden op jouw computer. Vereist dat de desktopapplicatie is geïnstalleerd en actief is.", "title": "Lokaal bestand" }, "webdav": { "configure-btn": "Configureer", "description": "Gebruik het DAV-protocol dat door bepaalde services wordt geleverd om jouw kluis op te slaan. Zelf-gehoste cloudopslagproviders zoals Nextcloud en ownCloud ondersteunen dit protocol, waardoor je toegang hebt tot jouw kluis vanaf elk apparaat dat je bezit.", "title": "WebDAV" } } } ================================================ FILE: source/shared/library/buffer.ts ================================================ export function arrayBufferToHex(buffer: ArrayBuffer): string { return [...new Uint8Array(buffer)].map((x) => x.toString(16).padStart(2, "0")).join(""); } export function arrayBufferToString(buffer: ArrayBuffer): string { return String.fromCharCode.apply(null, new Uint8Array(buffer)); } export function base64DecodeUnicode(str: string): string { return window.atob(str); } export function base64EncodeUnicode(str: string): string { return window.btoa(str); } export function stringToArrayBuffer(str: string): ArrayBuffer { const buffer = new ArrayBuffer(str.length); const bufferView = new Uint8Array(buffer); for (let i = 0; i < str.length; i += 1) { bufferView[i] = str.charCodeAt(i); } return buffer; } ================================================ FILE: source/shared/library/clone.ts ================================================ export function naiveClone | Record>(item: T): T { if (Array.isArray(item)) { return naiveCloneArray(item); } return naiveCloneObject(item); } function naiveCloneArray>(arr: T): T { const clone = [...arr] as T; for (let i = 0; i < clone.length; i += 1) { if (Array.isArray(clone[i])) { clone[i] = naiveCloneArray(clone[i]); } else if (clone[i] && typeof clone[i] === "object") { clone[i] = naiveCloneObject(clone[i]); } } return clone; } function naiveCloneObject>(obj: T): T { const clone = { ...obj }; for (const key in clone) { if (Array.isArray(clone[key])) { clone[key] = naiveCloneArray(clone[key]); } else if (typeof clone[key] === "object" && clone[key]) { clone[key] = naiveCloneObject(clone[key]); } } return clone; } ================================================ FILE: source/shared/library/domain.ts ================================================ import { EntryURLType, PropertyKeyValueObject, getEntryURLs } from "buttercup"; import { ParseResultListed, ParseResultType, parseDomain } from "parse-domain"; export function domainsReferToSameParent(domain1: string, domain2: string): boolean { if (domain1 === domain2) return true; const res1 = parseDomain(domain1); const res2 = parseDomain(domain2); if (res1.type !== res2.type) return false; if (res1.type !== ParseResultType.Listed) return false; const r1 = (res1 as ParseResultListed).icann; const r2 = (res2 as ParseResultListed).icann; if (r1.topLevelDomains.join(".") !== r2.topLevelDomains.join(".")) return false; return r1.domain === r2.domain; } export function extractDomain(str: string): string { const domainMatch = str.match(/^https?:\/\/([^\/]+)/i); if (!domainMatch) return str; const [, domainPortion] = domainMatch; const [domain] = domainPortion.split(":"); return domain; } export function extractEntryDomain(entryProperties: PropertyKeyValueObject): string | null { const [url] = [ ...getEntryURLs(entryProperties, EntryURLType.Icon), ...getEntryURLs(entryProperties, EntryURLType.Any) ]; return url ? extractDomain(url) : null; } ================================================ FILE: source/shared/library/error.ts ================================================ import { isError, Layerr } from "layerr"; import { t } from "../i18n/trans.js"; export function errorToString(error: Error | Layerr): string { return localisedErrorMessage(error); } export function localisedErrorMessage(error: Error | Layerr): string { if (!isError(error)) { return `${error}`; } const { i18n } = Layerr.info(error); if (i18n) { const translated = t(i18n); if (translated) return translated; } return error.message; } export function stringToError(error: Error | Layerr | string): Layerr | Error { if (isError(error as Error)) return error as Error; const isI18N = /^[a-z0-9_-]+(\.[a-z0-9_-]+){1,}$/i.test(error as string); return isI18N ? new Error(t(error as string)) : new Error(error as string); } ================================================ FILE: source/shared/library/extension.ts ================================================ import { getExtensionAPI } from "../extension.js"; const NOOP = () => {}; export async function createNewTab(url: string): Promise { const browser = getExtensionAPI(); if (!browser.tabs) { // Handle non-background scripts browser.runtime.sendMessage({ type: "open-tab", url }); return null; } return new Promise((resolve) => chrome.tabs.create({ url }, resolve)); } export function closeCurrentTab() { const browser = getExtensionAPI(); browser.tabs.getCurrent((tab) => { if (!tab?.id) return; browser.tabs.remove(tab.id, NOOP); }); } export async function getAllTabs(): Promise> { const browser = getExtensionAPI(); return new Promise>((resolve) => { browser.tabs.query({ discarded: false }, (tabs) => { resolve(tabs); }); }); } export async function getCurrentTab(): Promise { const browser = getExtensionAPI(); return new Promise((resolve) => { browser.tabs.query({ active: true, currentWindow: true }, (tabs) => { resolve(tabs[0]); }); }); } export function getExtensionURL(path: string): string { return getExtensionAPI().runtime.getURL(path); } export async function sendTabMessage(tabID: number, message: any) { return new Promise((resolve) => { chrome.tabs.sendMessage(tabID, message, (response) => { resolve(response); }); }); } ================================================ FILE: source/shared/library/i18n.ts ================================================ export function getLanguage(/*preferences: Preferences, locale: string*/): string { // return preferences.language || locale || DEFAULT_LANGUAGE; return window.navigator.language; } ================================================ FILE: source/shared/library/log.ts ================================================ import { createLog as createLogger, toggleContext } from "gle"; export type Logger = ReturnType; export function createLog(name: string, force: boolean = false): (...args: Array) => void { if (force) { toggleContext(name, true); } return createLogger(name); } ================================================ FILE: source/shared/library/otp.ts ================================================ import { SearchResult } from "buttercup"; import { Layerr } from "layerr"; import * as OTPAuth from "otpauth"; function extractFirstOTPURI(entry: SearchResult): string | null { let key: string | null = null, value: string | null = null; for (const prop in entry.properties) { if (!/^otpauth:\/\//.test(entry.properties[prop])) continue; if (!key || prop.length < key.length) { key = prop; value = entry.properties[prop]; } } return value ?? null; } export function otpURIToDigits(uri: string): string { try { const otp = OTPAuth.URI.parse(uri); return otp.generate(); } catch (err) { throw new Layerr(err, "Failed generating OTP code for URI"); } } export function searchResultToOTP(entry: SearchResult): string | null { const uri = extractFirstOTPURI(entry); if (!uri) return null; return otpURIToDigits(uri); } ================================================ FILE: source/shared/library/url.ts ================================================ export function formatURL(base: string): string { if (/^\d+\.\d+\.\d+\.\d+/.test(base)) { return `http://${base}`; } else if (/^https?:\/\//i.test(base) === false) { return `https://${base}`; } return base; } ================================================ FILE: source/shared/library/vaultTypes.ts ================================================ import { VaultType } from "../types.js"; import VAULT_TYPE_IMAGE_DROPBOX from "../../../resources/providers/dropbox-256.png"; import VAULT_TYPE_IMAGE_FILE from "../../../resources/providers/file-256.png"; import VAULT_TYPE_IMAGE_GOOGLEDRIVE from "../../../resources/providers/googledrive-256.png"; import VAULT_TYPE_IMAGE_WEBDAV from "../../../resources/providers/webdav-256.png"; interface VaultTypeDescription { image: any; invertOnDarkMode: boolean; } export const VAULT_TYPES: Record = { [VaultType.Dropbox]: { image: VAULT_TYPE_IMAGE_DROPBOX, invertOnDarkMode: false }, [VaultType.File]: { image: VAULT_TYPE_IMAGE_FILE, invertOnDarkMode: false }, [VaultType.GoogleDrive]: { image: VAULT_TYPE_IMAGE_GOOGLEDRIVE, invertOnDarkMode: false }, [VaultType.WebDAV]: { image: VAULT_TYPE_IMAGE_WEBDAV, invertOnDarkMode: true } }; ================================================ FILE: source/shared/library/version.ts ================================================ // Do not edit this file - it is generated automatically at build time export const BUILD_DATE = "2024-04-09"; export const VERSION = "3.2.0"; ================================================ FILE: source/shared/notifications/index.ts ================================================ import { TITLE as WelcomeV3Title, Page as WelcomeV3Page } from "./pages/WelcomeV3.jsx"; export const NOTIFICATIONS: Record JSX.Element]> = { "2024-03-welcome-v3": [WelcomeV3Title, WelcomeV3Page] }; export const NOTIFICATION_NAMES = Object.keys(NOTIFICATIONS); ================================================ FILE: source/shared/notifications/pages/WelcomeV3.tsx ================================================ import { Fragment } from "react"; import { t } from "../../i18n/trans.js"; export const TITLE = "notifications.page.welcome-v3.title"; export function Page() { return (

- The Buttercup Team

); } ================================================ FILE: source/shared/queries/config.ts ================================================ import { Layerr } from "layerr"; import { sendBackgroundMessage } from "../services/messaging.js"; import { BackgroundMessageType, Configuration } from "../../popup/types.js"; export async function getConfig(): Promise { const resp = await sendBackgroundMessage({ type: BackgroundMessageType.GetConfiguration }); if (resp.error) { throw new Layerr(resp.error, "Failed fetching application configuration"); } if (!resp.config) { throw new Error("No config returned from background"); } return resp.config; } export async function setConfigValue( key: T, value: Configuration[T] ): Promise { const resp = await sendBackgroundMessage({ configKey: key, configValue: value, type: BackgroundMessageType.SetConfigurationValue }); if (resp.error) { throw new Layerr(resp.error, "Failed fetching application configuration"); } if (!resp.config) { throw new Error("No config returned from background"); } return resp.config; } ================================================ FILE: source/shared/services/messaging.ts ================================================ import { getExtensionAPI } from "../extension.js"; import { stringToError } from "../library/error.js"; import { MESSAGE_DEFAULT_TIMEOUT } from "../symbols.js"; import { BackgroundMessage, BackgroundResponse, TabEvent } from "../../popup/types.js"; export async function sendBackgroundMessage( msg: BackgroundMessage, timeout: number = MESSAGE_DEFAULT_TIMEOUT ): Promise { const browser = getExtensionAPI(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Timed out waiting for response to message: ${msg.type} (${timeout} ms)`)); }, timeout); browser.runtime.sendMessage(msg, (resp) => { clearTimeout(timer); if (resp.error) { reject(stringToError(resp.error)); return; } resolve(resp as BackgroundResponse); }); }); } ================================================ FILE: source/shared/services/notifications.ts ================================================ import { Position, Toaster, ToasterInstance } from "@blueprintjs/core"; const __toaster = Toaster.create({ position: Position.BOTTOM_RIGHT }); export function getToaster(): ToasterInstance { return __toaster; } ================================================ FILE: source/shared/styles/base.sass ================================================ @import "~@blueprintjs/core/lib/css/blueprint.css" @import "~@blueprintjs/icons/lib/css/blueprint-icons.css" @import "~@blueprintjs/popover2/lib/css/blueprint-popover2.css" @import "~@blueprintjs/select/lib/css/blueprint-select.css" @import "fonts" $bc-brand-colour: #00B7AC $bc-brand-colour-dark: #179E94 html, body, textarea, select, input, button, div font-family: "OpenSans" body font-size: 14px &.bp4-dark background-color: #383E47 // Dark Grey 4 .bp4-input:focus, .bp4-input.bp4-active box-shadow: inset 0 0 0 1px $bc-brand-colour, 0 0 0 2px rgba(45, 114, 210, 0.3), inset 0 1px 1px rgba(17, 20, 24, 0.2) a color: $bc-brand-colour-dark &:hover color: $bc-brand-colour ================================================ FILE: source/shared/styles/fonts.sass ================================================ @font-face font-family: "OpenSans" src: url("../../../resources/OpenSans-Regular.woff2") format("woff2") font-weight: normal font-style: normal @font-face font-family: "OpenSans" src: url("../../../resources/OpenSans-SemiBold.woff2") format("woff2") font-weight: bold font-style: normal ================================================ FILE: source/shared/symbols.ts ================================================ export const API_KEY_ALGO = "ECDH"; export const API_KEY_CURVE = "P-256"; export const BRAND_COLOUR = "#00B7AC"; export const BRAND_COLOUR_DARK = "#179E94"; export const DESKTOP_API_PORT = 12822; export const MESSAGE_DEFAULT_TIMEOUT = 15000; ================================================ FILE: source/shared/themes.ts ================================================ import { Colors } from "@blueprintjs/core"; export default { dark: { backgroundColor: Colors.DARK_GRAY4, backgroundFrameColor: Colors.DARK_GRAY2, listItemHover: Colors.DARK_GRAY2 // codeBlock: Colors.DARK_GRAY2, // codeAccent: Colors.GREEN5, // vault: { // list: { // focusedBackgroundColor: Colors.DARK_GRAY5, // selectedBackgroundColor: Colors.TURQUOISE3, // selectedTextColor: "#fff" // }, // colors: { // divider: Colors.DARK_GRAY5, // paneDivider: Colors.GRAY3, // uiBackground: Colors.DARK_GRAY4, // mainPaneBackground: Colors.DARK_GRAY3 // }, // tree: { // selectedBackgroundColor: Colors.DARK_GRAY5, // hoverBackgroundColor: "transparent", // selectedTextColor: Colors.LIGHT_GRAY5, // selectedIconColor: Colors.LIGHT_GRAY5 // }, // entry: { // primaryContainer: Colors.DARK_GRAY3, // separatorTextColor: Colors.GRAY3, // separatorBorder: Colors.GRAY1, // fieldHoverBorder: Colors.GRAY1 // }, // attachment: { // dropBackground: Colors.DARK_GRAY3, // dropBorder: Colors.DARK_GRAY5, // dropText: Colors.GRAY2 // } // } }, light: { backgroundColor: Colors.WHITE, backgroundFrameColor: Colors.GRAY5, listItemHover: Colors.LIGHT_GRAY3 // codeBlock: Colors.LIGHT_GRAY1, // codeAccent: Colors.GREEN1, // vault: { // list: { // focusedBackgroundColor: Colors.LIGHT_GRAY5, // selectedBackgroundColor: Colors.TURQUOISE3, // selectedTextColor: "#fff" // }, // colors: { // divider: Colors.LIGHT_GRAY4, // paneDivider: Colors.GRAY3, // uiBackground: "#fff", // mainPaneBackground: Colors.LIGHT_GRAY5 // }, // tree: { // selectedBackgroundColor: Colors.LIGHT_GRAY2, // hoverBackgroundColor: "transparent", // selectedTextColor: Colors.DARK_GRAY1, // selectedIconColor: Colors.DARK_GRAY5 // }, // entry: { // primaryContainer: Colors.LIGHT_GRAY5, // separatorTextColor: Colors.GRAY3, // separatorBorder: Colors.LIGHT_GRAY2, // fieldHoverBorder: Colors.LIGHT_GRAY1 // }, // attachment: { // dropBackground: Colors.LIGHT_GRAY5, // dropBorder: Colors.LIGHT_GRAY2, // dropText: Colors.GRAY4 // } // } } }; ================================================ FILE: source/shared/types.ts ================================================ import { EntryID, EntryType, GroupID, SearchResult, VaultFacade, VaultFormatID, VaultSourceID, VaultSourceStatus } from "buttercup"; import { ReactChild, ReactChildren } from "react"; export interface AddVaultPayload { createNew: boolean; dropboxToken?: string; masterPassword: string; name: string; type: VaultType; vaultPath: string; } export interface BackgroundMessage { autoLogin?: boolean; code?: string; configKey?: keyof Configuration; configValue?: any; count?: number; credentials?: UsedCredentials; credentialsID?: string; domains?: Array; entry?: SearchResult; entryID?: EntryID; entryProperties?: Record; entryType?: EntryType; excludeSaved?: boolean; groupID?: GroupID; notification?: string; searchTerm?: string; sourceID?: VaultSourceID; text?: string; type: BackgroundMessageType; url?: string; } export enum BackgroundMessageType { AuthenticateDesktopConnection = "authenticateDesktopConnection", CheckDesktopConnection = "checkDesktopConnection", ClearDesktopAuthentication = "clearDesktopAuthentication", ClearSavedCredentials = "clearSavedCredentials", ClearSavedCredentialsPrompt = "clearSavedCredentialsPrompt", DisableSavePromptForCredentials = "disableSavePromptForCredentials", DeleteDisabledDomains = "deleteDisabledDomains", InitiateDesktopConnection = "initiateDesktopConnection", GetAutoLoginForTab = "getTabAutoLogin", GetConfiguration = "getConfiguration", GetDesktopVaultSources = "getDesktopVaultSources", GetDesktopVaultsTree = "getDesktopVaultsTree", GetDisabledDomains = "getDisabledDomains", GetLastSavedCredentials = "getLastSavedCredentials", GetOTPs = "getOTPs", GetRecentEntries = "getRecentEntries", GetSavedCredentials = "getCredentials", GetSavedCredentialsForID = "getCredentialsForID", MarkNotificationRead = "markNotificationRead", OpenEntryPage = "openEntryPage", OpenSaveCredentialsPage = "openSaveCredentials", PromptLockSource = "promptLockSource", PromptUnlockSource = "promptUnlockSource", ResetSettings = "resetSettings", SaveCredentialsToVault = "saveCredentialsToVault", SaveUsedCredentials = "saveUsedCredentials", SearchEntriesByTerm = "searchEntriesByTerm", SearchEntriesByURL = "searchEntriesByURL", SetConfigurationValue = "setConfigurationValue", TrackRecentEntry = "trackRecentEntry" } export interface BackgroundResponse { available?: boolean; autoLogin?: SearchResult | null; config?: Configuration; credentials?: Array; domains?: Array; entryID?: EntryID | null; error?: Error; locked?: boolean; opened?: boolean; otps?: Array; searchResults?: Array; vaultSources?: Array; vaultsTree?: VaultsTree; } type ChildElement = ReactChild | ReactChildren | false | null; export type ChildElements = ChildElement | Array; export interface Configuration { entryIcons: boolean; inputButtonDefault: InputButtonType; saveNewLogins: boolean; theme: "light" | "dark"; useSystemTheme: boolean; } export interface ElementRect { x: number; y: number; width: number; height: number; } export enum InputButtonType { InnerIcon = "innericon", LargeButton = "largebutton" } export enum InputType { OTP = "otp", UserPassword = "user-password" } export interface OTP { entryID: EntryID; entryProperty: string; entryTitle: string; loginURL: string | null; otpTitle?: string; otpURL: string; sourceID: VaultSourceID; } export enum PopupPage { About = "about", Entries = "entries", OTPs = "otps", Settings = "settings", Vaults = "vaults" } export interface SavedCredentials extends UsedCredentials { entryID?: EntryID; groupID: GroupID; sourceID: VaultSourceID; } export interface TabEvent { formID?: string; inputDetails?: { otp?: string; password?: string; username?: string; }; inputPosition?: ElementRect; inputType?: InputType; source?: MessageEventSource; sourceURL?: string; type: TabEventType; } export enum TabEventType { CloseSaveDialog = "closeSaveDialog", GetFrameID = "getFrameID", InputDetails = "inputDetails", OpenPopupDialog = "openPopupDialog" } export interface UsedCredentials { fromEntry: boolean; id: string; password: string; promptSave: boolean; timestamp: number; title: string; url: string; username: string; } export interface VaultSourceDescription { id: VaultSourceID; name: string; state: VaultSourceStatus; type: VaultType; order: number; format?: VaultFormatID; } export interface VaultsTree { [key: string]: VaultsTreeItem; } export interface VaultsTreeItem extends VaultFacade { name: string; } export enum VaultType { Dropbox = "dropbox", File = "file", GoogleDrive = "googledrive", WebDAV = "webdav" } ================================================ FILE: source/tab/index.ts ================================================ import { FRAME } from "./state/frame.js"; import { initialise } from "./services/init.js"; import { log } from "./services/log.js"; FRAME.isTop = window.parent === window; initialise().catch((err) => { console.error(err); log(`initialisation failed: ${err.message}`); }); ================================================ FILE: source/tab/library/disable.ts ================================================ export function itemIsIgnored(element: HTMLElement): boolean { return element.matches("[data-bcupignore=true] *, [data-bcupignore=true]"); } ================================================ FILE: source/tab/library/dismount.ts ================================================ export function onElementDismount(el: HTMLElement, callback: () => void): void { let active = true, timer: ReturnType; const disconnect = () => { active = false; mutObs.disconnect(); clearTimeout(timer); }; const mutObs = new MutationObserver((records) => { if (!active) return; const wasRemoved = records.some((record) => { return [...record.removedNodes].includes(el); }); if (wasRemoved) { disconnect(); callback(); } }); if (!el.parentElement) { throw new Error("No parent element found for target"); } mutObs.observe(el.parentElement, { childList: true }); timer = setTimeout(() => { if (!el.parentElement) { disconnect(); callback(); } }, 50); } ================================================ FILE: source/tab/library/frames.ts ================================================ export function findIframeForWindow(url: string): HTMLIFrameElement | null { const iframes = [...document.getElementsByTagName("iframe")]; return iframes.find((frame) => frame.src === url) || null; } ================================================ FILE: source/tab/library/page.ts ================================================ import { extractDomain } from "../../shared/library/domain.js"; export function currentDomainDisabled( disabledDomains: Array, currentDomain: string = getCurrentDomain() ): boolean { return disabledDomains.some((disabledDomain) => { const idx = currentDomain.indexOf(disabledDomain); return idx === currentDomain.length - disabledDomain.length; }); } export function getCurrentDomain(): string { return extractDomain(getCurrentURL()); } export function getCurrentTitle(): string { return document.title; } export function getCurrentURL(): string { return window.location.href; } ================================================ FILE: source/tab/library/position.ts ================================================ import { ElementRect } from "../types.js"; export function getElementRectInDocument(el: HTMLElement): ElementRect { const boundingRect = el.getBoundingClientRect(); return { x: boundingRect.left + document.documentElement.scrollLeft, y: boundingRect.top + document.documentElement.scrollTop, width: boundingRect.width, height: boundingRect.height }; } export function recalculateRectForIframe(rect: ElementRect, iframe: HTMLIFrameElement): ElementRect { const framePos = getElementRectInDocument(iframe); return { ...rect, x: framePos.x + rect.x, y: framePos.y + rect.y }; } ================================================ FILE: source/tab/library/resize.ts ================================================ export function onBodyResize( callback: (newWidth: number, newHeight: number, lastWidth: number, lastHeight: number) => void ): () => void { let lastWidth = 0, lastHeight = 0; const watch = setInterval(() => { const newWidth = document.body.offsetWidth; const newHeight = document.body.offsetHeight; if (newWidth !== lastWidth || newHeight !== lastHeight) { callback(newWidth, newHeight, lastWidth, lastHeight); lastWidth = newWidth; lastHeight = newHeight; } }, 200); return () => { clearInterval(watch); }; } export function onBodyWidthResize(callback: (newWidth: number, lastWidth: number) => void): () => void { let lastWidth = 0; const watch = setInterval(() => { const newWidth = document.body.offsetWidth; if (newWidth !== lastWidth) { callback(newWidth, lastWidth); lastWidth = newWidth; } }, 200); return () => { clearInterval(watch); }; } ================================================ FILE: source/tab/library/styles.ts ================================================ export const CLEAR_STYLES = { margin: "0px", minWidth: "0px", minHeight: "0px", padding: "0px" }; export function findBestZIndexInContainer(parentElement: HTMLElement) { let highest = 0; [...parentElement.children].forEach((child) => { const { zIndex } = window.getComputedStyle(child); if (zIndex) { const num = parseInt(zIndex, 10); if (!isNaN(num) && num > highest) { highest = num; } } }); return highest + 1; } ================================================ FILE: source/tab/library/zIndex.ts ================================================ export function findBestZIndexInContainer(parentElement: HTMLElement): number { let highest: number = 0; [...parentElement.children].forEach((child) => { const { zIndex } = window.getComputedStyle(child); if (zIndex) { const num = parseInt(zIndex, 10); if (!isNaN(num) && num > highest) { highest = num; } } }); return highest + 1; } ================================================ FILE: source/tab/services/LoginTracker.ts ================================================ import { ulid } from "ulidx"; import EventEmitter from "eventemitter3"; import { getCurrentTitle, getCurrentURL } from "../library/page.js"; import { LoginTarget } from "@buttercup/locust"; interface Connection { id: string; loginTarget: LoginTarget; entry: boolean; username: string; password: string; _username: string; _password: string; } interface LoginTrackerEvents { credentialsChanged: (event: { id: string; username: string; password: string; entry: boolean }) => void; } let __sharedTracker: LoginTracker | null = null; export class LoginTracker extends EventEmitter { protected _connections: Array = []; protected _title = getCurrentTitle(); protected _url = getCurrentURL(); get title() { return this._title; } get url() { return this._url; } getConnection(loginTarget: LoginTarget): Connection | null { return ( this._connections.find( (conn) => conn.loginTarget === loginTarget || conn.loginTarget.form === loginTarget.form ) || null ); } registerConnection(loginTarget: LoginTarget) { const _this = this; const connection: Connection = { id: ulid(), loginTarget, entry: false, _username: "", _password: "", get username() { return connection._username; }, get password() { return connection._password; }, set username(un) { connection._username = un; _this.emit("credentialsChanged", { id: connection.id, username: connection.username, password: connection.password, entry: connection.entry }); }, set password(pw) { connection._password = pw; _this.emit("credentialsChanged", { id: connection.id, username: connection.username, password: connection.password, entry: connection.entry }); } }; this._connections.push(connection); } } export function getSharedTracker(): LoginTracker { if (!__sharedTracker) { __sharedTracker = new LoginTracker(); } return __sharedTracker; } ================================================ FILE: source/tab/services/autoLogin.ts ================================================ import { LoginTarget } from "@buttercup/locust"; import { SearchResult } from "buttercup"; import { Layerr } from "layerr"; import { sendBackgroundMessage } from "../../shared/services/messaging.js"; import { BackgroundMessageType } from "../types.js"; import { searchResultToOTP } from "../../shared/library/otp.js"; async function getAutoLogin(): Promise { const resp = await sendBackgroundMessage({ type: BackgroundMessageType.GetAutoLoginForTab }); if (resp.error) { throw new Layerr(resp.error, "Failed fetching auto-login data"); } return resp.autoLogin ?? null; } export async function processTargetAutoLogin(loginTarget: LoginTarget): Promise { const entry = await getAutoLogin(); if (!entry) return; if (entry.properties.username) { loginTarget.fillUsername(entry.properties.username); } if (entry.properties.password) { loginTarget.fillPassword(entry.properties.password); } if (loginTarget.otpField) { const otpDigits = searchResultToOTP(entry); if (otpDigits) { loginTarget.fillOTP(otpDigits); } } loginTarget.submit(); } ================================================ FILE: source/tab/services/config.ts ================================================ import { Layerr } from "layerr"; import { sendBackgroundMessage } from "../../shared/services/messaging.js"; import { BackgroundMessageType, Configuration } from "../types.js"; export async function getConfig(): Promise { const resp = await sendBackgroundMessage({ type: BackgroundMessageType.GetConfiguration }); if (resp.error) { throw new Layerr(resp.error, "Failed fetching configuration"); } if (!resp.config) { throw new Error("No config returned from background"); } return resp.config; } ================================================ FILE: source/tab/services/form.ts ================================================ import { ulid } from "ulidx"; import { getElementRectInDocument, recalculateRectForIframe } from "../library/position.js"; import { FORM } from "../state/form.js"; import { FRAME } from "../state/frame.js"; import { closePopup, togglePopup } from "../ui/popup.js"; import { waitAndAttachLaunchButtons } from "./formDetection.js"; import { broadcastFrameMessage, listenForTabEvents, sendTabEvent } from "./messaging.js"; import { findIframeForWindow } from "../library/frames.js"; import { FrameEvent, FrameEventType, TabEventType } from "../types.js"; export function fillFormDetails(frameEvent: FrameEvent) { const { currentLoginTarget: loginTarget } = FORM; const { inputDetails } = frameEvent; if (!inputDetails) { throw new Error("No input details for form fill action"); } if (!loginTarget) { throw new Error("No login target found"); } if (inputDetails.username) { loginTarget.fillUsername(inputDetails.username); } if (inputDetails.password) { loginTarget.fillPassword(inputDetails.password); } if (inputDetails.otp) { loginTarget.fillOTP(inputDetails.otp); } FORM.currentFormID = null; FORM.currentLoginTarget = null; closePopup(); } export async function initialise() { // Watch for forms await waitAndAttachLaunchButtons((input, loginTarget, inputType) => { FORM.currentFormID = ulid(); FORM.currentLoginTarget = loginTarget; if (FRAME.isTop) { FORM.targetFormID = FORM.currentFormID; togglePopup(getElementRectInDocument(input), inputType); } else { sendTabEvent( { type: TabEventType.OpenPopupDialog, formID: FORM.currentFormID, inputPosition: getElementRectInDocument(input), inputType }, window.parent ); } }); // Listen for tab-specific events listenForTabEvents((tabEvent) => { if (tabEvent.type === TabEventType.InputDetails) { // Detect where to send the chosen details if (FORM.currentFormID && tabEvent.formID === FORM.currentFormID) { // This tab+frame is expecting these credentials fillFormDetails({ formID: tabEvent.formID, inputDetails: tabEvent.inputDetails, inputType: tabEvent.inputType, type: FrameEventType.FillForm }); } else if (!FORM.currentFormID || FORM.currentFormID !== tabEvent.formID) { // Destination is another tab broadcastFrameMessage({ formID: tabEvent.formID, inputDetails: tabEvent.inputDetails, inputType: tabEvent.inputType, type: FrameEventType.FillForm }); } else { throw new Error("Unexpected details input state"); } } else if (tabEvent.type === TabEventType.OpenPopupDialog) { if (!tabEvent.sourceURL) { console.error("No source URL provided"); return; } if (!tabEvent.inputPosition) { console.error("No input position provided"); return; } if (!tabEvent.inputType) { console.error("No input type provided"); return; } // Re-calculate based upon the iframe the message came from const frame = findIframeForWindow(tabEvent.sourceURL); if (!frame) { console.error("Failed presening Buttercup popup: Could not trace iframe nesting"); return; } const newPosition = recalculateRectForIframe(tabEvent.inputPosition, frame); // Show if top, or pass on to the next frame above if (FRAME.isTop) { FORM.targetFormID = tabEvent.formID ?? null; togglePopup(newPosition, tabEvent.inputType); } else { sendTabEvent( { ...tabEvent, inputPosition: newPosition }, window.parent ); } } }); } ================================================ FILE: source/tab/services/formDetection.ts ================================================ import { LoginTarget, LoginTargetFeature, getLoginTargets } from "@buttercup/locust"; import { attachLaunchButton } from "../ui/launch.js"; import { watchCredentialsOnTarget } from "./logins/watcher.js"; import { processTargetAutoLogin } from "./autoLogin.js"; import { InputType } from "../types.js"; import { getConfig } from "./config.js"; const TARGET_SEARCH_INTERVAL = 1000; function filterLoginTarget(_: LoginTargetFeature, element: HTMLElement): boolean { if (element.dataset.bcup === "attached") { return false; } return true; } function onIdentifiedTarget(callback: (target: LoginTarget) => void) { const locatedForms: Array = []; const findTargets = () => { getLoginTargets(document, filterLoginTarget) .filter((target) => locatedForms.includes(target.form) === false) .forEach((target) => { locatedForms.push(target.form); setTimeout(() => { callback(target); }, 0); }); }; const checkInterval = setInterval(findTargets, TARGET_SEARCH_INTERVAL); setTimeout(findTargets, 0); return { remove: () => { clearInterval(checkInterval); locatedForms.splice(0, locatedForms.length); } }; } export async function waitAndAttachLaunchButtons( onInputActivate: (input: HTMLInputElement, loginTarget: LoginTarget, inputType: InputType) => void ) { const config = await getConfig(); onIdentifiedTarget((loginTarget: LoginTarget) => { const { otpField, usernameField, passwordField } = loginTarget; if (otpField) { attachLaunchButton(otpField, config.inputButtonDefault, (el) => onInputActivate(el, loginTarget, InputType.OTP) ); } if (passwordField) { attachLaunchButton(passwordField, config.inputButtonDefault, (el) => onInputActivate(el, loginTarget, InputType.UserPassword) ); } if (usernameField) { attachLaunchButton(usernameField, config.inputButtonDefault, (el) => onInputActivate(el, loginTarget, InputType.UserPassword) ); } watchCredentialsOnTarget(loginTarget); processTargetAutoLogin(loginTarget).catch(console.error); }); } ================================================ FILE: source/tab/services/init.ts ================================================ import { initialise as initialiseMessaging } from "./messaging.js"; import { initialise as initialiseForms } from "./form.js"; import { initialise as initialiseCredentialsWatching } from "./logins/watcher.js"; export async function initialise() { await initialiseMessaging(); await initialiseForms(); await initialiseCredentialsWatching(); } ================================================ FILE: source/tab/services/log.ts ================================================ import { Logger, createLog } from "../../shared/library/log.js"; const LOG_NAME = "buttercup:browser:tab"; let __logger: Logger; export function log(...args: Array): void { if (!__logger) { __logger = createLog(LOG_NAME, true); } return __logger(...args); } ================================================ FILE: source/tab/services/logins/disabled.ts ================================================ import { Layerr } from "layerr"; import { sendBackgroundMessage } from "../../../shared/services/messaging.js"; import { BackgroundMessageType } from "../../types.js"; export async function getDisabledDomains(): Promise> { const resp = await sendBackgroundMessage({ type: BackgroundMessageType.GetDisabledDomains }); if (resp.error) { throw new Layerr(resp.error, "Failed fetching disabled login domains"); } return resp.domains ?? []; } ================================================ FILE: source/tab/services/logins/saving.ts ================================================ import { Layerr } from "layerr"; import { sendBackgroundMessage } from "../../../shared/services/messaging.js"; import { BackgroundMessageType, UsedCredentials } from "../../types.js"; export async function getCredentialsForID(id: string, excludeSaved: boolean = false): Promise { const resp = await sendBackgroundMessage({ credentialsID: id, excludeSaved, type: BackgroundMessageType.GetSavedCredentialsForID }); if (resp.error) { throw new Layerr(resp.error, "Failed fetching saved credentials"); } return resp.credentials?.[0] ?? null; } export async function getLastSavedCredentials(excludeSaved: boolean = false): Promise { const resp = await sendBackgroundMessage({ excludeSaved, type: BackgroundMessageType.GetLastSavedCredentials }); if (resp.error) { throw new Layerr(resp.error, "Failed fetching last saved credentials"); } return resp.credentials?.[0] ?? null; } export function transferLoginCredentials(details: UsedCredentials) { sendBackgroundMessage({ type: BackgroundMessageType.SaveUsedCredentials, credentials: details }).catch((err) => { console.error(err); }); } ================================================ FILE: source/tab/services/logins/watcher.ts ================================================ import { LoginTarget, LoginTargetFeature } from "@buttercup/locust"; import { onNavigate } from "on-navigate"; import { getSharedTracker } from "../LoginTracker.js"; import { getCredentialsForID, getLastSavedCredentials, transferLoginCredentials } from "./saving.js"; import { getDisabledDomains } from "./disabled.js"; import { currentDomainDisabled, getCurrentDomain } from "../../library/page.js"; import { log } from "../log.js"; import { getConfig } from "../../../shared/queries/config.js"; import { openDialog } from "../../ui/saveDialog.js"; async function checkForLoginSaveAbility(loginID?: string) { const [disabledDomains, config, used] = await Promise.all([ getDisabledDomains(), getConfig(), loginID ? getCredentialsForID(loginID, true) : getLastSavedCredentials(true) ]); if (!used || !used.promptSave || used.fromEntry) return; if (currentDomainDisabled(disabledDomains)) { log(`login available, but current domain disabled: ${getCurrentDomain()}`); return; } if (!config.saveNewLogins) return; log("saved login available, show prompt"); openDialog(used.id); } export async function initialise() { const tracker = getSharedTracker(); tracker.on("credentialsChanged", (details) => { transferLoginCredentials({ fromEntry: details.entry, id: details.id, password: details.password, promptSave: true, timestamp: Date.now(), title: tracker.title, url: tracker.url, username: details.username }); }); await checkForLoginSaveAbility(); } export function watchCredentialsOnTarget(loginTarget: LoginTarget): void { const tracker = getSharedTracker(); tracker.registerConnection(loginTarget); watchLogin( loginTarget, (username, source) => { const connection = tracker.getConnection(loginTarget); if (connection) { connection.entry = source === "fill"; connection.username = username; } }, (password, source) => { const connection = tracker.getConnection(loginTarget); if (connection) { connection.entry = source === "fill"; connection.password = password; } }, () => { const connection = tracker.getConnection(loginTarget); if (!connection) return; setTimeout(() => { checkForLoginSaveAbility(connection.id); }, 300); } ); } function watchLogin( target: LoginTarget, usernameUpdate: (value: string, source: "keypress" | "fill") => void, passwordUpdate: (value: string, source: "keypress" | "fill") => void, onSubmit: () => void ) { target.on("valueChanged", (info) => { if (info.type === LoginTargetFeature.Username) { usernameUpdate(info.value, info.source); } else if (info.type === LoginTargetFeature.Password) { passwordUpdate(info.value, info.source); } }); target.on("formSubmitted", (info) => { if (info.source === "form") { onSubmit(); } }); onNavigate(() => { onSubmit(); }); } ================================================ FILE: source/tab/services/messaging.ts ================================================ import { FORM } from "../state/form.js"; import { fillFormDetails } from "./form.js"; import { closeDialog } from "../ui/saveDialog.js"; import { getExtensionAPI } from "../../shared/extension.js"; import { FrameEvent, FrameEventType, TabEvent, TabEventType } from "../types.js"; let __framesChannel: BroadcastChannel; export function broadcastFrameMessage(event: FrameEvent): void { __framesChannel.postMessage(event); } export async function initialise() { __framesChannel = new BroadcastChannel("frames:all"); __framesChannel.addEventListener("message", handleFramesBroadcast); const browser = getExtensionAPI(); browser.runtime.onMessage.addListener(handleTabMessage); } function handleFramesBroadcast(event: MessageEvent) { const { type } = event.data; if (type === FrameEventType.FillForm) { const { formID } = event.data; if (formID && formID === FORM.currentFormID && FORM.currentLoginTarget) { fillFormDetails(event.data); } } } function handleTabMessage(payload: unknown) { if ( !payload || typeof payload !== "object" || Object.values(TabEventType).includes((payload as any).type) === false ) { return; } const event = payload as TabEvent; if (event.type === TabEventType.CloseSaveDialog) { closeDialog(); } } export function listenForTabEvents(callback: (event: TabEvent) => void) { window.addEventListener("message", (event: MessageEvent) => { if (event.data?.type && Object.values(TabEventType).includes(event.data?.type)) { callback({ ...(event.data as TabEvent), source: event.source ?? undefined }); } }); } export function sendTabEvent(event: TabEvent, destination: MessageEventSource): void { const payload: TabEvent = { ...event, sourceURL: `${window.location.href}` }; if (destination instanceof Window) { destination.postMessage(payload, "*"); } else { destination.postMessage(payload); } } ================================================ FILE: source/tab/state/form.ts ================================================ import { createStateObject } from "obstate"; import { LoginTarget } from "@buttercup/locust"; export const FORM = createStateObject<{ currentFormID: string | null; currentLoginTarget: LoginTarget | null; targetFormID: string | null; }>({ currentFormID: null, currentLoginTarget: null, targetFormID: null }); ================================================ FILE: source/tab/state/frame.ts ================================================ import { createStateObject } from "obstate"; export const FRAME = createStateObject<{ isTop: boolean; }>({ isTop: false }); ================================================ FILE: source/tab/types.ts ================================================ import { InputType } from "../shared/types.js"; export * from "../shared/types.js"; export interface FrameEvent { formID?: string; inputDetails?: { otp?: string; password?: string; username?: string; }; inputType?: InputType; type: FrameEventType; } export enum FrameEventType { FillForm = "fillForm" } ================================================ FILE: source/tab/ui/launch.ts ================================================ import { el, mount, setStyle } from "redom"; import { itemIsIgnored } from "../library/disable.js"; import { CLEAR_STYLES, findBestZIndexInContainer } from "../library/styles.js"; import { onElementDismount } from "../library/dismount.js"; import { onBodyWidthResize } from "../library/resize.js"; import { getExtensionURL } from "../../shared/library/extension.js"; import BUTTON_BACKGROUND_IMAGE_RES from "../../../resources/content-button-background.png"; import INPUT_BACKGROUND_IMAGE_RES from "../../../resources/buttercup-simple-150.png"; import { InputButtonType } from "../types.js"; const BUTTON_BACKGROUND_IMAGE = getExtensionURL(BUTTON_BACKGROUND_IMAGE_RES); const INPUT_BACKGROUND_IMAGE = getExtensionURL(INPUT_BACKGROUND_IMAGE_RES); export function attachLaunchButton( input: HTMLInputElement, buttonType: InputButtonType, onClick: (input: HTMLInputElement) => void ): void { if (input.dataset.bcup === "attached" || itemIsIgnored(input)) { return; } const tryToAttach = () => { const bounds = input.getBoundingClientRect(); const { width } = bounds; // Flag has having been attached input.dataset.bcup = "attached"; // Check if we can continue if (width <= 0 || !input.offsetParent) { setTimeout(tryToAttach, 250); return; } if (buttonType === InputButtonType.LargeButton) { renderButtonStyle(input, () => onClick(input), tryToAttach, bounds); } else if (buttonType === InputButtonType.InnerIcon) { renderInternalStyle(input, () => onClick(input), bounds); } }; tryToAttach(); } function renderInternalStyle(input: HTMLInputElement, onClick: () => void, inputBounds: DOMRect) { const bounds = inputBounds || input.getBoundingClientRect(); const { height } = bounds; const imageSize = height * 0.6; const rightOffset = 8; const buttonArea = imageSize + rightOffset + 4; const originalAutocomplete = input.getAttribute("autocomplete") ?? null; setStyle(input, { backgroundImage: `url(${INPUT_BACKGROUND_IMAGE})`, backgroundSize: `${imageSize}px`, backgroundPosition: `right ${rightOffset}px center`, backgroundRepeat: "no-repeat", paddingRight: `${buttonArea}px` }); input.onclick = (event) => { if (event.offsetX >= input.offsetWidth - buttonArea) { event.preventDefault(); event.stopPropagation(); onClick(); } }; input.onmousemove = (event) => { if (event.offsetX >= input.offsetWidth - buttonArea) { input.setAttribute("autocomplete", "off"); setStyle(input, { cursor: "pointer" }); } else { if (originalAutocomplete) { input.setAttribute("autocomplete", originalAutocomplete); } else { input.removeAttribute("autocomplete"); } setStyle(input, { cursor: "unset" }); } }; } function renderButtonStyle(input: HTMLInputElement, onClick: () => void, reattachCB: () => void, inputBounds: DOMRect) { const bounds = inputBounds || input.getBoundingClientRect(); const { width, height } = bounds; const { borderTopLeftRadius, borderBottomLeftRadius, boxSizing, paddingLeft, paddingRight } = window.getComputedStyle(input, null); // Calculate button location const inputLeftPadding = parseInt(paddingLeft, 10) || 0; const inputRightPadding = parseInt(paddingRight, 10) || 0; const buttonWidth = 0.8 * height; const calculateLeft = () => input.offsetLeft + width + (boxSizing === "border-box" ? 0 - buttonWidth : inputLeftPadding - inputRightPadding); let left = calculateLeft(); let top = input.offsetTop; const buttonZ = findBestZIndexInContainer(input.offsetParent as HTMLElement); // Input padding setStyle(input, { paddingRight: `${inputRightPadding + buttonWidth}px` }); // Update input style updateOffsetParentPositioning(input.offsetParent as HTMLElement); // Create and add button const button = el("button", { type: "button", tabIndex: -1, style: { ...CLEAR_STYLES, position: "absolute", width: `${buttonWidth}px`, height: `${height}px`, left: `${left}px`, top: `${top}px`, borderRadius: `0 ${borderTopLeftRadius} ${borderBottomLeftRadius} 0`, background: `rgb(0, 183, 172) url(${BUTTON_BACKGROUND_IMAGE})`, backgroundSize: `${Math.ceil(buttonWidth / 2)}px`, backgroundRepeat: "no-repeat", backgroundPosition: "50% 50%", border: "1px solid rgb(0, 155, 145)", cursor: "pointer", zIndex: buttonZ, outline: "none" } }); button.onclick = (event) => { event.preventDefault(); event.stopPropagation(); // toggleInputDialog(input, DIALOG_TYPE_ENTRY_PICKER); onClick(); }; // @ts-ignore mount(input.offsetParent, button); onElementDismount(button, () => { reattachCB(); }); const reprocessButton = () => { try { left = calculateLeft(); top = input.offsetTop; setStyle(button, { top: `${top}px`, left: `${left}px` }); } catch (err) { clearInterval(reprocessInterval); removeOnBodyWidthResize(); } }; const removeOnBodyWidthResize = onBodyWidthResize(reprocessButton); const reprocessInterval = setInterval(reprocessButton, 1250); reprocessButton(); } function updateOffsetParentPositioning(offsetParent: HTMLElement): void { const { position: computedPosition } = window.getComputedStyle(offsetParent, null); const position = computedPosition || offsetParent.style.position || "static"; if (position === "static") { setStyle(offsetParent, { position: "relative" }); } } ================================================ FILE: source/tab/ui/popup.ts ================================================ import { el, mount, setStyle, unmount } from "redom"; import { getExtensionURL } from "../../shared/library/extension.js"; import { BRAND_COLOUR_DARK } from "../../shared/symbols.js"; import { getCurrentURL } from "../library/page.js"; import { onBodyResize } from "../library/resize.js"; import { FORM } from "../state/form.js"; import { ElementRect, InputType, PopupPage } from "../types.js"; interface LastPopup { cleanup: () => void; inputRect: ElementRect; popup: HTMLElement; } const CLEAR_STYLES = { margin: "0px", minWidth: "0px", minHeight: "0px", padding: "0px" }; const POPUP_HEIGHT = 300; const POPUP_WIDTH = 320; let __popup: LastPopup | null = null; function buildNewPopup(inputRect: ElementRect, forInputType: InputType) { const currentURL = getCurrentURL(); const formID = FORM.targetFormID || ""; const initialPage = forInputType === InputType.OTP ? PopupPage.OTPs : PopupPage.Entries; const popupURL = getExtensionURL( `popup.html#/dialog?page=${encodeURIComponent(currentURL)}&form=${formID}&initial=${initialPage}` ); const frame = el("iframe", { style: { width: "100%", height: "100%" }, src: popupURL, frameBorder: "0" }); const container = el( "div", { style: { ...CLEAR_STYLES, background: "#fff", borderRadius: "6px", overflow: "hidden", border: `2px solid ${BRAND_COLOUR_DARK}`, width: `${POPUP_WIDTH}px`, height: `${POPUP_HEIGHT}px`, minWidth: `${POPUP_WIDTH}px`, position: "absolute", zIndex: 9999999 } }, frame ); mount(document.body, container); const removeBodyResizeListener = onBodyResize(() => updatePopupPosition((__popup as LastPopup).inputRect)); document.body.addEventListener("click", closePopup, false); __popup = { cleanup: () => { removeBodyResizeListener(); document.body.removeEventListener("click", closePopup, false); }, inputRect, popup: container }; updatePopupPosition(inputRect); } export function closePopup() { if (!__popup) return; __popup.cleanup(); unmount(document.body, __popup.popup); __popup = null; FORM.targetFormID = null; } export function togglePopup(inputRect: ElementRect, forInputType: InputType) { if (__popup === null) { buildNewPopup(inputRect, forInputType); } else { // Tear down closePopup(); } } export function updatePopupPosition(inputRect: ElementRect): void { if (!__popup) return; __popup.inputRect = inputRect; setStyle(__popup.popup, { left: `${inputRect.x}px`, top: `${inputRect.y + inputRect.height + 2}px` }); } ================================================ FILE: source/tab/ui/saveDialog.ts ================================================ import { el, mount, unmount } from "redom"; import { getExtensionURL } from "../../shared/library/extension.js"; import { BRAND_COLOUR_DARK } from "../../shared/symbols.js"; interface LastSaveDialog { cleanup: () => void; dialog: HTMLElement; loginID: string; } const CLEAR_STYLES = { margin: "0px", minWidth: "0px", minHeight: "0px", padding: "0px" }; const DIALOG_WIDTH = 380; const DIALOG_HEIGHT = 230; let __popup: LastSaveDialog | null = null; function buildNewSaveDialog(loginID: string) { const dialogURL = getExtensionURL(`popup.html#/save-dialog?login=${loginID}`); const frame = el("iframe", { style: { width: "100%", height: "100%" }, src: dialogURL, frameBorder: "0" }); const container = el( "div", { style: { ...CLEAR_STYLES, background: "#fff", borderRadius: "6px", overflow: "hidden", border: `2px solid ${BRAND_COLOUR_DARK}`, width: `${DIALOG_WIDTH}px`, height: `${DIALOG_HEIGHT}px`, minWidth: `${DIALOG_WIDTH}px`, position: "absolute", top: "15px", right: "15px", zIndex: 9999999 } }, frame ); mount(document.body, container); __popup = { cleanup: () => {}, dialog: container, loginID }; } export function closeDialog() { if (!__popup) return; __popup.cleanup(); unmount(document.body, __popup.dialog); __popup = null; } export function openDialog(loginID: string) { if (__popup && __popup.loginID === loginID) return; if (__popup) { closeDialog(); } buildNewSaveDialog(loginID); } ================================================ FILE: source/typings/assets.d.ts ================================================ declare module "*.md" { const value: any; export default value; } declare module "*.png" { const value: any; export default value; } declare module "*.jpg" { const value: any; export default value; } declare module "*.svg" { const value: any; export default value; } ================================================ FILE: source/typings/globals.d.ts ================================================ declare var BROWSER: "chrome" | "edge" | "firefox"; declare var browser: typeof chrome; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "allowSyntheticDefaultImports": true, "esModuleInterop": true, "jsx": "react-jsx", "module": "esnext", "moduleResolution": "node", "outDir": "./dist", "resolveJsonModule": true, "strict": false, "strictNullChecks": true, "target": "ES6", "types": ["chrome", "react", "react-dom"] }, "include": [ "./source/**/*" ], "exclude":[ "dist", "node_modules" ] } ================================================ FILE: webpack.config.js ================================================ import { writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { createRequire } from "node:module"; import webpack from "webpack"; import ResolveTypeScriptPlugin from "resolve-typescript-plugin"; import CopyWebpackPlugin from "copy-webpack-plugin"; import { merge } from "webpack-merge"; import PugPlugin from "pug-plugin"; import sass from "sass"; import packageInfo from "./package.json" assert { type: "json" }; import manifestV2 from "./resources/manifest.v2.json" assert { type: "json" }; import manifestV3 from "./resources/manifest.v3.json" assert { type: "json" }; const { BannerPlugin, DefinePlugin } = webpack; const { BROWSER } = process.env; const V3_BROWSERS = ["chrome", "edge"]; const require = createRequire(import.meta.url); const __dirname = fileURLToPath(new URL(".", import.meta.url)); const DIST = path.resolve(__dirname, "dist"); const ICONS_PATH = path.join(path.dirname(require.resolve("@buttercup/ui")), "icons"); if (!BROWSER) { throw new Error("BROWSER must be specified"); } function buildManifest(assetNames, manifest) { const newManifest = JSON.parse(JSON.stringify(manifest)); newManifest.version = packageInfo.version; assetNames.forEach((assetFilename) => { if (/^[^\/\\]+\.js$/.test(assetFilename)) { if (/\bbackground\b/.test(assetFilename) && assetFilename !== "background.js") { newManifest.background.scripts.unshift(assetFilename); } if (/\btab\b/.test(assetFilename) && assetFilename !== "tab.js") { newManifest.content_scripts[0].js.unshift(assetFilename); } } }); writeFileSync(path.join(DIST, "./manifest.json"), JSON.stringify(newManifest, undefined, 2)); } function getBaseConfig() { return { devtool: false, module: { rules: [ { test: /\.tsx?$/, use: [ { loader: "babel-loader", options: { compact: true, presets: [ [ "@babel/preset-env", { targets: { chrome: "90", firefox: "85", edge: "90" }, useBuiltIns: false } ] ] } }, { loader: "ts-loader" } ], resolve: { fullySpecified: false } }, { test: /\.s[ac]ss$/, use: [ "css-loader", { loader: "sass-loader", options: { implementation: sass } } ] }, { test: /\.css$/, use: ["css-loader"] }, { test: /\.(jpg|png|svg|eot|svg|ttf|woff|woff2)$/, type: "asset/resource", generator: { filename: "assets/[name][ext]" } }, { test: /\.pug$/, loader: PugPlugin.loader } ] }, output: { filename: "[name].js", path: DIST }, performance: { hints: false, maxEntrypointSize: 768000, maxAssetSize: 768000 }, plugins: [ new DefinePlugin({ BROWSER: JSON.stringify(BROWSER) }) ], resolve: { alias: { iocane: "iocane/web", "react/jsx-runtime": "react/jsx-runtime.js", "react/jsx-dev-runtime": "react/jsx-dev-runtime.js" }, // No .ts/.tsx included due to the typescript resolver plugin extensions: [".js", ".jsx"], fallback: { buffer: false, crypto: false, fs: false, path: false, util: false }, plugins: [ // Handle .ts => .js resolution new ResolveTypeScriptPlugin() ] } }; } export default [ merge(getBaseConfig(), { entry: { background: path.resolve(__dirname, "./source/background/index.ts") }, plugins: [ new BannerPlugin({ // Fix service worker scope banner: `window = self || global;`, raw: true }), { apply: (compiler) => { compiler.hooks.afterEmit.tap("AfterEmitPlugin", (compilation) => { buildManifest( Object.keys(compilation.getStats().compilation.assets), V3_BROWSERS.includes(BROWSER) ? manifestV3 : manifestV2 ); }); } }, new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, "./resources", "buttercup-*.png"), to: path.join(DIST, "manifest-res"), context: path.join(__dirname, "./resources") }, { from: path.join(ICONS_PATH, "/*"), to: path.join(DIST, "scripts/icons"), context: ICONS_PATH } ] }) ] }), merge(getBaseConfig(), { entry: { tab: path.resolve(__dirname, "./source/tab/index.ts") }, output: { publicPath: "/" } }), merge(getBaseConfig(), { devtool: false, entry: { full: path.resolve(__dirname, "./source/full/index.pug"), popup: path.resolve(__dirname, "./source/popup/index.pug") }, output: { chunkLoadingGlobal: "__bcupjsonp", filename: "[name].js", path: DIST, publicPath: "/" }, plugins: [ new PugPlugin({ css: { filename: "styles/[name].css", chunkFilename: "styles/[name].[contenthash:8].css" }, filename: "[name].html", js: { filename: "scripts/[name].js", chunkFilename: "scripts/[name].[contenthash:8].js" }, pretty: false }) ] }) ];