[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 4\n\n[{.prettierrc,package.json,package-lock.json,.babelrc}]\nindent_size = 2\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Tests\n\non: push\n\njobs:\n    lint:\n        runs-on: ubuntu-latest\n        strategy:\n            matrix:\n                node-version: [20.x]\n        steps:\n            - uses: actions/checkout@v2\n            - name: Node.js specs ${{ matrix.node-version }}\n              uses: actions/setup-node@v1\n              with:\n                  node-version: ${{ matrix.node-version }}\n            - run: npm ci\n            - run: npm run test:format\n    release:\n        runs-on: ubuntu-latest\n        strategy:\n            matrix:\n                node-version: [20.x]\n        steps:\n            - uses: actions/checkout@v2\n            - name: Node.js specs ${{ matrix.node-version }}\n              uses: actions/setup-node@v1\n              with:\n                  node-version: ${{ matrix.node-version }}\n            - run: npm ci\n            - run: npm run release\n"
  },
  {
    "path": ".gitignore",
    "content": "*.log\n.DS_Store\n.history\nnode_modules\n/dist\n/release\nArchive.zip\ndist.zip\n/secrets.json\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"printWidth\": 120,\n  \"tabWidth\": 4,\n  \"trailingComma\": \"none\"\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"vsicons.presets.angular\": false,\n    \"eslint.enable\": false,\n    \"files.associations\": {\n        \"**/*.js\": \"javascriptreact\"\n    },\n    \"typescript.preferences.importModuleSpecifierEnding\": \"js\"\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Buttercup browser extension changelog\n\n## v3.2.0\n_2024-04-09_\n\n * Input button customisation (global)\n * Dutch language (not yet selectable)\n * **Bugfix**:\n   * Some fields not filled correctly (partial fix)\n   * Login save prompt shown for existing credentials\n\n## v3.1.0\n_2024-03-27_\n\n * ([#467](https://github.com/buttercup/buttercup-browser-extension/issues/467)) Entry info popup\n * **Bugfix**:\n   * ([#469](https://github.com/buttercup/buttercup-browser-extension/issues/469)) Forms recognised as login forms when they should not be\n   * ([#466](https://github.com/buttercup/buttercup-browser-extension/issues/466)) Bad OTP URIs crash application\n   * Entry page search does nothing when first opening the extension\n\n## v3.0.0\n_2024-03-26_\n\n * **Major rewrite**\n   * Requires desktop application for vault access\n   * Nested iframe traversal\n   * OTP support\n\n## v2.26.0\n_2023-11-08_\n\n * **Important version 3 update notice**\n * Updates for core libraries and datasources\n\n## v2.25.3\n_2023-01-31_\n\n * **Bugfix**:\n   * Google Drive would fail when tokens expire (new response format)\n\n## v2.25.2\n_2022-09-03_\n\n * **Bugfix**:\n   * Fixed Dropbox connectivity issues\n   * Fixed Google Drive re-authentication loop, short auth time\n\n## v2.25.1\n_2022-08-16_\n\n * **Bugfix**:\n   * Format B saving new properties would fail\n   * Format B conversion would omit history\n\n## v2.25.0\n_2022-06-02_\n\n * Buttercup upgrade: v6\n   * Improved Dropbox/Google Drive integrations\n   * Improved vault stability and performance\n   * Improved support for Vault Format B\n * Removed My Buttercup integration\n * Vault editor page redesign\n * Updated vault UI\n\n## v2.24.3\n_2021-05-24_\n\n * **Bugfix**:\n   * Vault pages not taking up 100% of the height of the window\n\n## v2.24.2\n_2021-05-22_\n\n * **Bugfix**:\n   * ([#381](https://github.com/buttercup/buttercup-browser-extension/issues/381)) Search results not showing most recent result (URL detection)\n   * WebDAV connection crashes tab during failed connection attempt (when adding a vault)\n   * **Critical auto-update issue**: Core crash when receiving updated vaults in the background\n\n## v2.24.1\n_2021-01-06_\n\n * Remove `activeTab` permission requirement\n * **Bugfix**:\n   * ([#393](https://github.com/buttercup/buttercup-browser-extension/issues/393)) Copy to clipboard not working for in-page dialog\n\n## v2.24.0\n_2020-12-09_\n\n * Site icons for results within in-page search dialog\n * Performance improvements regarding search\n * Vault unlock/save performance improvements\n\n## v2.23.1\n_2020-11-27_\n\n * **Bugfix**:\n   * ([#368](https://github.com/buttercup/buttercup-browser-extension/issues/368)) Popup / Dialog menus very slow to open (performance bugfix for search)\n\n## v2.23.0\n_2020-09-05_\n\n * Dynamic icons defaults to **enabled**\n   * Removed dynamic icons setting popup page\n * **Bugfix**:\n   * ([#366](https://github.com/buttercup/buttercup-browser-extension/issues/366)) Google Drive bad refresh-token method call\n\n## v2.22.0\n_2020-09-04_\n\n * Core group/entry lookup performance upgrades\n * **Bugfix**:\n   * ([#370](https://github.com/buttercup/buttercup-browser-extension/issues/370)) Critical CPU/memory use after some time\n   * Entries not able to be moved from group to group\n\n## v2.21.0\n_2020-08-30_\n\n * **Buttercup Core v5**\n   * Improved performance\n   * Improved stability\n   * Future support for **Vault Format B**\n * Dynamic icons for entries (optional)\n * Reduced extension size (< 50% of the size of 2.20.2)\n * Removed `Buffer` dependencies\n * **Bugfix**:\n   * Search wouldn't work (no results)\n\n## v2.20.2\n_2020-08-19_\n\n * **Bugfix**:\n   * ([buttercup-core#287](https://github.com/buttercup/buttercup-core/issues/287)) Vaults grow to enormous size\n\n## v2.20.1\n_2020-08-03_\n\n * **Attachments** (My Buttercup)\n   * Add, remove, preview and download attachments when using My Buttercup vaults\n * Core memory/stability improvements when merging vault changes from remote sources\n\n## v2.19.0\n_2020-07-25_\n\n * New Buttercup form button behaviour (to improve login form stability)\n * Clipboard-writing permission for certain browsers\n * Improved auto-update stability\n\n## v2.18.0\n_2020-07-07_\n\n * Search results won't show items in trash\n * **Bugfix**:\n   * ([#337](https://github.com/buttercup/buttercup-browser-extension/pull/337)) No login-save-prompt when entry selected for login form (includes auto login)\n\n## v2.17.0\n_2020-07-05_\n\n * New search functionality\n   * Result scoring per domain\n * Vault type icons on unlock-all-vaults page\n\n## v2.16.2\n_2020-07-02_\n\n * **Bugfix**:\n   * Unable to enter form details when selecting entry result in dialog\n\n## v2.16.1\n_2020-07-01_\n\n * **Bugfix**:\n   * Unable to select vault in save credentials form\n   * No search results in popup dialog\n   * Broken vault lock state in menu\n\n## v2.16.0\n_2020-06-30_\n\n * Core version 4\n * My Buttercup datasource support\n * ([#340](https://github.com/buttercup/buttercup-browser-extension/pull/340)) Allow localhost in disabled domains\n\n## v2.15.1\n_2020-04-02_\n\n * **Bugfix**:\n   * WebDAV would fail to connect on some services, such as ownCloud\n\n## v2.15.0\n_2020-03-18_\n\n * Disable save prompt for domains\n * Memory for all login form inputs\n * **Bugfix**:\n   * Buttons would disappear from some forms (Dropbox)\n\n## v2.14.0\n_2020-02-03_\n\n * Upgrade webdav for reduced application size\n * **Bugfix**:\n   * ([#325](https://github.com/buttercup/buttercup-browser-extension/issues/325)) New vaults fail to create\n   * ([#286](https://github.com/buttercup/buttercup-browser-extension/issues/286)) Unlock-vaults page not opening in FF after clicking button in on-page dialog\n\n## v2.13.1\n_2020-01-24_\n\n * **Bugfix**:\n   * ([#324](https://github.com/buttercup/buttercup-browser-extension/issues/324)) Very slow vault contents navigation\n   * ([#323](https://github.com/buttercup/buttercup-browser-extension/issues/323)) OTP (HOTP) failures crashing entire vault management UI\n   * ([#322](https://github.com/buttercup/buttercup-browser-extension/issues/322)) Auto-update of search results by URL not working\n\n## v2.13.0\n_2020-01-22_\n\n * Group context menu: creation, renaming, moving and deletion\n * New group in root button\n * Entry field history (basic)\n * **Bugfix**:\n   * Credit card entry type would crash application\n   * State sync for vault management interface inconsistent\n\n## v2.12.0\n_2020-01-18_\n\n * ([#320](https://github.com/buttercup/buttercup-browser-extension/issues/320)) Open permissions option when adding Google Drive vaults\n * **Bugfix**:\n   * ([#270](https://github.com/buttercup/buttercup-browser-extension/issues/270)) _(2nd attempt)_: Support multiple Google accounts\n   * ([#319](https://github.com/buttercup/buttercup-browser-extension/issues/319)) Google Drive authentication not working in Microsoft Edge (unofficial patch - pre-release)\n\n## v2.11.1\n_2020-01-08_\n\n * **Bugfix**:\n   * ([#316](https://github.com/buttercup/buttercup-browser-extension/issues/316)) `createSession` is not defined (local file host vaults)\n\n## v2.11.0\n_2020-01-05_\n\n * Core integration with App-Env for web-based crypto improvement\n * ([#270](https://github.com/buttercup/buttercup-browser-extension/issues/270)) Prompt for account-selection on Google authentication (Google Drive) to support multiple accounts\n * ([#245](https://github.com/buttercup/buttercup-browser-extension/issues/245)) Google Drive permissions reduced - Only files touched by Buttercup are accessible to the application\n * **Bugfix**:\n   * ([#314](https://github.com/buttercup/buttercup-browser-extension/issues/314)) Unable to open Google Drive vault\n\n## v2.10.1\n_2019-12-26_\n\n * **Bugfix**:\n   * ([#312](https://github.com/buttercup/buttercup-browser-extension/issues/312)) No login prompt visible on Firefox\n\n## v2.10.0\n_2019-12-24_\n\n * Ability to change vault password\n * **Bugfix**:\n   * ([#307](https://github.com/buttercup/buttercup-browser-extension/issues/307)) Cannot save new note-type entry (or any other custom types)\n\n## v2.9.0\n_2019-11-12_\n\n * My Buttercup preparation\n * ownCloud/Nextcloud removed in favour of WebDAV (existing connections should still function)\n * ([#95](https://github.com/buttercup/buttercup-browser-extension/issues/95)) Context menus to choose credentials for form-filling and login\n\n## v2.8.2\n_2019-09-01_\n\n * **Bugfix**:\n   * ([#269](https://github.com/buttercup/buttercup-browser-extension/issues/269)) Password field in popup menu not copy-able and always visible\n\n## v2.8.1\n_2019-09-01_\n\n * **Bugfix**:\n   * ([#253](https://github.com/buttercup/buttercup-browser-extension/issues/253)) Vault saving via editing UI (second attempt)\n\n## v2.8.0\n_2019-07-23_\n\n * **Bugfix**:\n   * ~~([#253](https://github.com/buttercup/buttercup-browser-extension/issues/253)) Vault saving via editing UI~~\n * ([#259](https://github.com/buttercup/buttercup-browser-extension/pull/259)) General improvements to the add-vault page\n * Unlock button on in-page dialog when vaults are locked\n * Unlock button for single-vault now navigates to edit page\n * TOTP / HOTP support via vault UI (display only, no form-fill)\n * Entry value type support via vault UI\n * Updated Dropbox/Google Drive clients for compatibiltiy\n\n## v2.7.0\n_2019-04-28_\n\n * Vault editing interface\n\n## v2.6.0\n_2019-04-14_\n\n * ([#246](https://github.com/buttercup/buttercup-browser-extension/issues/246)) Google Drive refresh token support\n\n## v2.5.1\n_2019-03-13_\n\n * **Bugfix**:\n   * ([#244](https://github.com/buttercup/buttercup-browser-extension/issues/244)) Google Drive fetching fails on large directories\n\n## v2.5.0\n_2019-03-09_\n\n * **Google Drive** support\n * Unlock-vaults button in popup\n\n## v2.4.1\n_2019-02-05_\n\n * **Bugfix**:\n   * Regression in auto-unlock functionality\n\n## v2.4.0\n_2019-02-04_\n\n * ([#235](https://github.com/buttercup/buttercup-browser-extension/issues/235)) Use local (static) icons and don't request them from remote sources\n * ([#171](https://github.com/buttercup/buttercup-browser-extension/issues/171)) Auto-lock vaults after a configurable time\n * Auto-login button in popup menu\n\n## v2.3.1\n_2019-01-19_\n\n * **Bugfixes**:\n   * ([#214](https://github.com/buttercup/buttercup-browser-extension/issues/214)) Popup menu layout broken for long items\n * ([#216](https://github.com/buttercup/buttercup-browser-extension/issues/216)) Autofocus extension popover search input\n\n## v2.3.0\n_2018-01-12_\n\n * **Bugfixes**:\n   * ([#217](https://github.com/buttercup/buttercup-browser-extension/issues/217)) Popup menu layout broken\n   * ([#218](https://github.com/buttercup/buttercup-browser-extension/issues/218)) Some website forms not recognised\n * Improved entry details UI in popup menus\n * Improved entry results in popup menus\n * What's New section on auto-unlock page\n * Improved login form detection\n\n## v2.2.0\n_2019-01-05_\n\n * ([#212](https://github.com/buttercup/buttercup-browser-extension/issues/212)) Poor search results performance\n * ([#202](https://github.com/buttercup/buttercup-browser-extension/issues/202)) Auto-unlock setting not working\n\n## v2.1.3\n_2018-12-16_\n\n * ([#190](https://github.com/buttercup/buttercup-browser-extension/issues/190)) Dropbox connection never completes loading procedure (UI spinner)\n\n## v2.1.2\n_2018-12-08_\n\n * ([#203](https://github.com/buttercup/buttercup-browser-extension/issues/203)) Failure saving Dropbox changes\n\n## v2.1.1\n_2018-11-27_\n\n * **Bugfix**: ownCloud / Nextcloud / WebDAV vaults could not be added (Chrome)\n\n## v2.1.0\n_2018-11-24_\n\n * ([#91](https://github.com/buttercup/buttercup-browser-extension/issues/91)) Connect through desktop application (local filesystem access)\n * New WebDAV client\n * New Dropbox client\n\n## v2.0.0\n_2018-10-29_\n\n * **Major UI overhaul**\n * ([#180](https://github.com/buttercup/buttercup-browser-extension/issues/180)) Option to disable \"save-new\" dialog\n * ([#160](https://github.com/buttercup/buttercup-browser-extension/issues/160)) Settings page\n * ([#173](https://github.com/buttercup/buttercup-browser-extension/issues/173)) Source type is empty - display glitches (final cleanup)\n * Dark/Light mode themes\n * Setting for showing the auto-unlock page (default on)\n * Vault/Account sync via Chrome/Firefox's account logins (sync storage)\n * Show/Copy entry properties in dialog/popup menus\n\n## v1.12.1\n_2018-10-18_\n\n * ([#173](https://github.com/buttercup/buttercup-browser-extension/issues/173)) Source type is empty - display glitches\n * ([#182](https://github.com/buttercup/buttercup-browser-extension/issues/182)) Add bundling process for Chrome/Firefox\n\n## v1.12.0\n_2018-10-07_\n\n * ([#110](https://github.com/buttercup/buttercup-browser-extension/issues/110)) Reload archives after some time\n * ([#174](https://github.com/buttercup/buttercup-browser-extension/issues/174)) Unable to access via WebDAV (Seafile)\n\n## v1.11.1\n_2018-08-27_\n\n * ([#164](https://github.com/buttercup/buttercup-browser-extension/issues/164)) `t is null` error when unlocking archives\n\n## v1.11.0\n_2018-08-24_\n\n * ([#136](https://github.com/buttercup/buttercup-browser-extension/issues/136)) Use last generated password form context menu\n * ([#153](https://github.com/buttercup/buttercup-browser-extension/issues/153)) **Bugfix**: Button layout issues\n * Upgraded login form targetting\n\n## v1.10.0\n_2018-07-11_\n\n * New popup menu design\n   * Search and open items from the popup\n\n## v1.9.1\n_2018-07-08_\n\n * ([#152](https://github.com/buttercup/buttercup-browser-extension/issues/152)) **Bugfix**: Failure while adding WebDAV archives\n\n## v1.9.0\n_2018-06-29_\n\n * ([#131](https://github.com/buttercup/buttercup-browser-extension/issues/131)) Upgrade core to v2\n   * New archive format (supporting future encryption standards)\n * ([#130](https://github.com/buttercup/buttercup-browser-extension/issues/130)) Auto unlock prompt shown when browser is opened\n * ([#147](https://github.com/buttercup/buttercup-browser-extension/issues/147)) Remove settings page link\n\n## v1.8.0\n_2018-06-22_\n\n * Upgrade core to 1.7.1\n   * Future proofing for archive format\n\n## v1.7.0\n_2018-04-28_\n\n * ([#124](https://github.com/buttercup/buttercup-browser-extension/issues/124)) Simplify save-new login screen by removing password confirmation\n * ([#141](https://github.com/buttercup/buttercup-browser-extension/issues/141)) Buttercup launch button layout issues\n * Dependency updates\n\n## v1.6.2\n_2018-04-24_\n\n * ([#139](https://github.com/buttercup/buttercup-browser-extension/issues/139)) No \"Generate password\" option shown when right-clicking inputs (Firefox/Chrome)\n\n## v1.6.1\n_2018-04-01_\n\n * ([#137](https://github.com/buttercup/buttercup-browser-extension/issues/137)) Unable to close save-new dialog\n\n## v1.6.0\n_2018-04-01_\n\n * \"password\" type input support for Firefox and the password generator\n * ([#134](https://github.com/buttercup/buttercup-browser-extension/issues/134)) Password generator \"Use this\" button fails on Firefox\n * ([#133](https://github.com/buttercup/buttercup-browser-extension/issues/133)) Password generator bad padding issue\n\n## v1.5.0\n_2018-03-31_\n\n * Password generator\n * Right-click context menu\n\n## v1.4.0\n_2018-03-23_\n\n * ([#126](https://github.com/buttercup/buttercup-browser-extension/issues/126)) Add an attribute to allow inputs and forms to be ignored by Buttercup\n * Support new login forms\n * Update login form detection priorities\n\n## v1.3.1\n_2018-02-20_\n\n * ([#121](https://github.com/buttercup/buttercup-browser-extension/issues/121)) Unable to click \"Save\" for new logins\n\n## v1.3.0\n_2018-02-05_\n\n * Use `chrome.storage` for better data persistence\n\n## v1.2.1\n_2018-02-04_\n\n * Update code splitting configuration (Firefox submission fixes)\n\n## v1.1.1\n_2018-02-04_\n\n * Improve URL filtering for new credentials saving\n\n## v1.1.0\n_2018-02-04_\n\n * ([#112](https://github.com/buttercup/buttercup-browser-extension/issues/106)) Save newly-entered credentials\n\n## v1.0.7\n_2018-01-22_\n\n * Add data collection event listeners for form attachments\n\n## v1.0.6\n_2018-01-21_\n\n * Fix component communication in Firefox\n * Add storage permission\n\n## v1.0.5\n_2018-01-21_\n\n * ([#106](https://github.com/buttercup/buttercup-browser-extension/issues/106)) Fix Nextcloud archive searching\n\n## v1.0.4\n_2018-01-20_\n\n * First 1.* release for Firefox\n * Fix character encoding\n * Fix button icon not showing on some sites\n * Fix scrollbars in popup\n\n## v1.0.3\n_2018-01-19_\n\n * Fix GitHub login\n * Fix logins that have multiple detected inputs\n\n## v1.0.2\n_2018-01-18_\n\n * ([#97](https://github.com/buttercup/buttercup-browser-extension/issues/97)) Fixed Twitter login bug\n\n## **v1.0.1**\n_2018-01-18_\n\n * Full re-work of the entire extension\n * **Nextcloud** official support\n * ([#92](https://github.com/buttercup/buttercup-browser-extension/issues/92)) Fixed security vulnerability\n\n## v0.14.2\n_2017-07-15_\n\n * Bugfix: [ownCloud subfolder installations: Subfolder ignored](https://github.com/buttercup/buttercup-browser-extension/issues/80)\n\n## v0.14.1\n_2017-06-24_\n\n * Bugfix: [Unable to connect to certain WebDAV services](https://github.com/buttercup/buttercup-desktop/issues/303)\n\n## v0.14.0\n_2017-06-07_\n\n * Bugfix: [Malformed URL error](https://github.com/buttercup/buttercup-browser-extension/issues/71) - old WebDAV client caused issues with special characters in passwords\n * Add back old Buttercup-core-web classes for compatibility\n\n## v0.13.1\n_2017-05-27_\n\n * Bugfix: Archive creation in root level\n\n## v0.13.0\n_2017-04-23_\n\n * Added right-click context menu drilldown for choosing which entry to fill form with\n * Updated hotkey for auto-login (Command+Shift+L for Mac, Ctrl+Shift+L for Windows/Linux)\n * Fixed deprecation warnings during build and updated some packages\n\n## v0.12.1\n_2017-04-19_\n\n * Spring clean:\n   * Reduced permissions requirements in manifest\n   * Improved last submitted form security\n\n## v0.12.0\n_2017-04-15_\n\n * Bugfix: Popup formatting issues\n * Added hotkey for auto-login (Command+B (Mac) / Ctrl+B (Windows/Linux))\n\n## v0.11.0\n_2017-04-14_\n\n * Bugfix: Clicking \"No\" on save prompt would not cancel further popups\n * Bugfix: Form submission error when selecting credentials\n * Prefill title when saving new credentials\n\n## v0.10.0\n_2017-03-31_\n\n * Added right-click context menu on form inputs\n * Fixed overflow on remote filesystem explorer when adding archives\n * Finalised styling on unlock-archive form\n\n## v0.9.0\n_2017-03-29_\n\n * Improved UI for popup password list (archive + groups pathing)\n * Bugfix: Fixed wrong-password during unlock sequence breaking state\n\n## v0.8.0\n_2017-03-22_\n\n * Fuzzy searching for entries on login-forms\n * Login-form popup available on password fields\n\n## v0.7.0\n_2017-03-15_\n\n * **Support for Firefox**\n * Auto-submit when selecting credentials on a form\n * Styles normalisation\n\n## v0.6.0\n_2017-03-14_\n\n * Upgrade core\n    * Use serialisation for archive properties\n\n## v0.5.0\n_2017-03-09_\n\n * Upgrade core\n    * Drastically increase PBKDF2 rounds\n    * Improve URI matching\n\n## v0.4.0\n_2017-02-11_\n\n * First alpha release\n\n## v0.3.0\n\n * Pre-release development build\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2016 Perry Mitchell\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n"
  },
  {
    "path": "PRIVACY_POLICY.md",
    "content": "# Privacy Policy\n\nThis privacy policy concerns the Browser Extension for Buttercup, its use and the data it makes use of.\n\n## About Buttercup\n\nButtercup 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.\n\n### Terms\n\nThe following terms will appear throughout this document and their meaning is important to grasp for a proper understanding of this policy.\n\n| Term              | Description                                           |\n|-------------------|-------------------------------------------------------|\n| Archive           | See \"vault\".                                          |\n| 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. |\n| 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. |\n| Master password   | The highly secure secret password used to lock and unlock vaults, known only to the user. |\n| Vault             | An encrypted password vault, stored as a file either locally or remotely (on some kind of service). |\n\n### Types of data\n\nVaults, 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.\n\nButtercup 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.\n\nButtercup 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.).\n\nDue to the fact that Buttercup interacts with webpages, the **Document Object Model (DOM)** may be modified by its use.\n\n## Data storage and transfer with regards to 3rd Parties\n\n### Remote vault storage\n\nButtercup 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.\n\nIt 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.\n\n### DOM (Document Object Model)\n\nThe browser extention modifies the DOM of open webpages so that it can track several different items while the page is live:\n\n * Detected login forms\n * Detected inputs that can be used for login (username/email, password etc.)\n * Submit buttons\n * Potential submit buttons (tracking text that resembles login call-to-actions)\n\nThe 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.\n\n### Analytics\n\nButtercup 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.\n\n## Internal data storage and use\n\n### In-memory vaults\n\nWhen 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.\n\n## Your responsibility as a user\n\nBy 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.\n\n## Our responsibility to you, the user\n\nWe 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.\n"
  },
  {
    "path": "README.md",
    "content": "<h1 align=\"center\">\n  <br/>\n  <img src=\"https://cdn.rawgit.com/buttercup-pw/buttercup-assets/4bbfd317/badge/browsers.svg\" alt=\"Buttercup for Browsers\">\n  <br/>\n  <br/>\n  <br/>\n</h1>\n\n# Buttercup Browser Extension\nButtercup credentials manager extension for the browser.\n\n<p align=\"center\">\n    <img src=\"https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/master/chrome-extension.jpg\" />\n</p>\n\n[![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)\n\n---\n\n⚠️ **Project Closure** ⚠️\n\nThe 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).\n\n---\n\n## About\nThis 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).\n\nThe 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.\n\n<img src=\"https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/master/chrome-extension-2.jpg\" />\n\n### Forms & Logins\n\nButtercup 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).\n\n### Supported browsers\n\n[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.\n\n_Some browsers, such as **Brave** for example, will be able to install Buttercup via the Google Chrome web store._\n\nOther 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.\n\n**Opera** is not supported due to their incredibly slow and unreliable release process. We will not be adding support for Opera.\n\n### Integrated platforms\n\nThe 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.\n\n#### Supported platforms\n\nThe browsers listed above, running on Windows, Mac or Linux on a desktop platform. This extension is not supported on any mobile or tablet devices.\n\n### Usage\n\nThe 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.\n\nWhen viewing pages that contain login forms, Buttercup can assist logging in when you interact with the login buttons (displayed beside detected login inputs).\n\nButtercup can also remember new logins, which are detected as they occur.\n\nYou can **block** Buttercup from detecting forms and inputs by applying the attribute `data-bcupignore=true`:\n\n```html\n<input type=\"email\" data-bcupignore=\"true\" />\n```\n\n### Development\n\nDevelopment of features and bugfixes is supported in the following environment:\n\n * NodeJS version 20 (latest minor version)\n * Linux / Mac\n * Tested in at least Chrome / Firefox\n \nTo set up your development environment:\n * Clone this repo\n * Ensure API keys are available (Google Drive)\n * Execute `npm install` inside the project directory\n * Run `npm run dev:chrome` or similar\n * Load the unpacked `dist` folder in your browser addons\n\n#### Chrome\n\nRun the following to develop the extension:\n\n 1. Execute `npm run dev:chrome` to build and watch the project (to build production code, execute `npm run build`)\n 2. Go to [chrome://extensions](chrome://extensions) and enable _\"Developer mode\"_\n 3. Select the new button _\"Load unpacked\"_, then select the `./dist` directory built on step 1\n\n#### Firefox\n\nRun the following to develop the extension:\n\n * Execute `npm run dev:firefox` to build and watch the project (to build production code, execute `npm run release`)\n\n#### Releasing\n\nTo 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.\n\n### Adding to Chrome\n\nYou 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.\n\n### Adding to Firefox\n\nYou 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.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"buttercup-browser-extension\",\n  \"version\": \"3.2.0\",\n  \"description\": \"Buttercup browser extension\",\n  \"exports\": \"./dist/background/index.js\",\n  \"type\": \"module\",\n  \"types\": \"./dist/background/index.d.ts\",\n  \"scripts\": {\n    \"build\": \"run-s set-version build:production\",\n    \"build:chrome\": \"BROWSER=chrome npm run build\",\n    \"build:edge\": \"BROWSER=edge npm run build\",\n    \"build:firefox\": \"BROWSER=firefox npm run build\",\n    \"build:production\": \"webpack --mode production --progress --config webpack.config.js\",\n    \"clean\": \"rimraf dist/* release/*\",\n    \"dev\": \"npm run clean && npm run set-version && webpack --mode development -w --progress --config webpack.config.js\",\n    \"dev:chrome\": \"BROWSER=chrome npm run dev\",\n    \"dev:edge\": \"BROWSER=edge npm run dev\",\n    \"dev:firefox\": \"concurrently -k \\\"BROWSER=firefox npm run dev\\\" \\\"cd dist && web-ext run\\\" --restart-tries 20 --restart-after 5000 --devtools --keep-profile-changes\",\n    \"format\": \"prettier --write '{{source,test}/**/*.{ts,js},webpack.config.js}'\",\n    \"release\": \"run-s clean release:chrome release:firefox release:edge\",\n    \"release:chrome\": \"npm run build:chrome && mkdirp release/chrome && zip -r release/chrome/extension.zip ./dist\",\n    \"release:edge\": \"npm run build:edge && mkdirp release/edge && zip -r release/edge/extension.zip ./dist\",\n    \"release:firefox\": \"npm run build:firefox && mkdirp release/firefox && run-s release:firefox:extension release:firefox:source\",\n    \"release:firefox:extension\": \"cd dist && web-ext build --overwrite-dest && cd .. && mv ./dist/web-ext-artifacts/*.zip ./release/firefox/\",\n    \"release:firefox:source\": \"zip -r release/firefox/source.zip . --exclude=/.git* --exclude=/node_modules* --exclude=/.history* --exclude=/dist* --exclude=/release* --exclude=*.DS_Store*\",\n    \"set-version\": \"node scripts/version.js\",\n    \"test\": \"run-s test:format\",\n    \"test:format\": \"prettier --check '{{source,test}/**/*.{ts,js},webpack.config.js}'\"\n  },\n  \"lint-staged\": {\n    \"{{source,test}/**/*.{ts,js},webpack.config.js}\": [\n      \"prettier --write\"\n    ]\n  },\n  \"husky\": {\n    \"hooks\": {\n      \"pre-commit\": \"lint-staged\"\n    }\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/buttercup/buttercup-browser-extension.git\"\n  },\n  \"keywords\": [\n    \"buttercup\",\n    \"password\",\n    \"vault\",\n    \"login\",\n    \"secure\"\n  ],\n  \"author\": \"Perry Mitchell <perry@perrymitchell.net>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/buttercup/buttercup-browser-extension/issues\"\n  },\n  \"homepage\": \"https://github.com/buttercup/buttercup-browser-extension#readme\",\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.23.3\",\n    \"@babel/preset-env\": \"^7.23.3\",\n    \"@babel/preset-react\": \"^7.23.3\",\n    \"@blueprintjs/core\": \"^4.20.2\",\n    \"@blueprintjs/icons\": \"^4.16.0\",\n    \"@blueprintjs/popover2\": \"^1.14.11\",\n    \"@blueprintjs/select\": \"^4.9.24\",\n    \"@buttercup/channel-queue\": \"^1.4.0\",\n    \"@buttercup/locust\": \"^2.3.1\",\n    \"@buttercup/ui\": \"^6.2.2\",\n    \"@types/chrome\": \"^0.0.251\",\n    \"@types/ms\": \"^0.7.34\",\n    \"@types/react\": \"^17.0.38\",\n    \"@types/react-dom\": \"^17.0.11\",\n    \"babel-loader\": \"^9.1.3\",\n    \"buttercup\": \"^7.6.0\",\n    \"classnames\": \"^2.2.6\",\n    \"concurrently\": \"^8.2.2\",\n    \"copy-webpack-plugin\": \"^11.0.0\",\n    \"css-loader\": \"^6.8.1\",\n    \"eventemitter3\": \"^5.0.1\",\n    \"expiry-map\": \"^2.0.0\",\n    \"file-loader\": \"^6.0.0\",\n    \"gle\": \"^1.0.3\",\n    \"husky\": \"^4.2.5\",\n    \"i18next\": \"^23.7.6\",\n    \"iocane\": \"^5.1.1\",\n    \"layerr\": \"^2.0.0\",\n    \"lint-staged\": \"^15.1.0\",\n    \"mkdirp\": \"^3.0.1\",\n    \"ms\": \"^2.1.3\",\n    \"mucus\": \"^1.0.0\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"obstate\": \"^0.1.4\",\n    \"on-navigate\": \"^0.1.1\",\n    \"otpauth\": \"^9.1.5\",\n    \"parse-domain\": \"^8.0.2\",\n    \"prettier\": \"^3.1.0\",\n    \"pug-plugin\": \"^5.0.2\",\n    \"react\": \"^17.0.2\",\n    \"react-dom\": \"^17.0.2\",\n    \"react-obstate\": \"^0.1.3\",\n    \"react-router-dom\": \"^6.21.3\",\n    \"redom\": \"^3.29.1\",\n    \"resolve-typescript-plugin\": \"^2.0.1\",\n    \"rimraf\": \"^5.0.5\",\n    \"sass\": \"^1.69.5\",\n    \"sass-loader\": \"^13.3.2\",\n    \"style-loader\": \"^3.3.3\",\n    \"styled-components\": \"^5.3.11\",\n    \"ts-loader\": \"^9.5.0\",\n    \"typescript\": \"^5.2.2\",\n    \"ulidx\": \"^2.2.1\",\n    \"url-join\": \"^5.0.0\",\n    \"url-loader\": \"^4.1.1\",\n    \"web-ext\": \"^7.8.0\",\n    \"webpack\": \"^5.90.3\",\n    \"webpack-cli\": \"^5.1.4\",\n    \"webpack-merge\": \"^5.10.0\"\n  }\n}\n"
  },
  {
    "path": "resources/full.pug",
    "content": "doctype html\nhtml\n    head\n        title Buttercup\n        meta(charset=\"utf-8\")\n        link(rel=\"icon\", type=\"image/png\", href=require(\"./buttercup-256.png\").default)\n        link(rel=\"stylesheet\" href=\"full.css\")\n        link(rel=\"stylesheet\" href=\"vendors.css\")\n        script(defer, src=\"full.js\")\n        script(defer, src=\"vendors.js\")\n    body\n        div#root\n"
  },
  {
    "path": "resources/manifest.v2.json",
    "content": "{\n    \"manifest_version\": 2,\n\n    \"name\": \"Buttercup\",\n    \"description\": \"Browser extension for Buttercup, the secure and easy-to-use password manager.\",\n    \"version\": \"0.0.0\",\n\n    \"icons\": {\n        \"256\": \"manifest-res/buttercup-256.png\",\n        \"128\": \"manifest-res/buttercup-128.png\",\n        \"48\": \"manifest-res/buttercup-48.png\",\n        \"16\": \"manifest-res/buttercup-16.png\"\n    },\n\n    \"background\": {\n        \"scripts\": [\n            \"background.js\"\n        ]\n    },\n\n    \"browser_action\": {\n        \"default_icon\": \"manifest-res/buttercup-256.png\",\n        \"default_popup\": \"popup.html#/\"\n    },\n\n    \"content_scripts\" : [\n        {\n            \"matches\": [\"http://*/*\", \"https://*/*\"],\n            \"run_at\": \"document_end\",\n            \"all_frames\": true,\n            \"js\": [\"tab.js\"]\n        }\n    ],\n\n    \"permissions\": [\n        \"clipboardWrite\",\n        \"http://*/*\",\n        \"https://*/*\",\n        \"storage\",\n        \"tabs\",\n        \"unlimitedStorage\"\n    ],\n\n    \"web_accessible_resources\": [\n        \"*.png\",\n        \"*.jpg\"\n    ],\n\n    \"applications\": {\n        \"gecko\": {\n            \"id\": \"{10e7d273-2e63-47c9-82af-76c45dc1b624}\"\n        }\n    }\n}\n"
  },
  {
    "path": "resources/manifest.v3.json",
    "content": "{\n    \"manifest_version\": 3,\n\n    \"name\": \"Buttercup\",\n    \"description\": \"Browser extension for Buttercup, the secure and easy-to-use password manager.\",\n    \"version\": \"0.0.0\",\n\n    \"icons\": {\n        \"256\": \"manifest-res/buttercup-256.png\",\n        \"128\": \"manifest-res/buttercup-128.png\",\n        \"48\": \"manifest-res/buttercup-48.png\",\n        \"16\": \"manifest-res/buttercup-16.png\"\n    },\n\n    \"background\": {\n        \"service_worker\": \"background.js\"\n    },\n\n    \"action\": {\n        \"default_title\": \"Buttercup\",\n        \"default_icon\": \"manifest-res/buttercup-256.png\",\n        \"default_popup\": \"/popup.html#/\"\n    },\n\n    \"content_scripts\" : [\n        {\n            \"matches\": [\"http://*/*\", \"https://*/*\"],\n            \"run_at\": \"document_end\",\n            \"all_frames\": true,\n            \"js\": [\"tab.js\"]\n        }\n    ],\n\n    \"permissions\": [\n        \"clipboardWrite\",\n        \"storage\",\n        \"tabs\",\n        \"unlimitedStorage\"\n    ],\n\n    \"web_accessible_resources\": [\n        {\n            \"resources\": [\"*.png\", \"*.jpg\"],\n            \"matches\": [\"http://*/*\", \"https://*/*\"]\n        },\n        {\n            \"resources\": [\"popup.html\"],\n            \"matches\": [\"http://*/*\", \"https://*/*\"]\n        }\n    ]\n}\n"
  },
  {
    "path": "resources/popup.pug",
    "content": "doctype html\nhtml\n    head\n        title Menu ⋅ Buttercup\n        meta(charset=\"utf-8\")\n        link(rel=\"icon\", type=\"image/png\", href=require(\"./buttercup-256.png\").default)\n        link(rel=\"stylesheet\" href=\"popup.css\")\n        link(rel=\"stylesheet\" href=\"vendors.css\")\n        script(defer, src=\"popup.js\")\n        script(defer, src=\"vendors.js\")\n    body\n        div#root\n"
  },
  {
    "path": "scripts/version.js",
    "content": "import { readFileSync, writeFileSync } from \"fs\";\nimport { resolve } from \"path\";\nimport { fileURLToPath } from \"url\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\n\nconst packageInfo = JSON.parse(readFileSync(\n    resolve(__dirname, \"../package.json\"),\n    \"utf8\"\n));\n\nconst buildDate = new Date();\nconst built = `${buildDate.getUTCFullYear()}-${String(buildDate.getUTCMonth() + 1).padStart(2, \"0\")}-${String(buildDate.getUTCDate()).padStart(2, \"0\")}`;\n\nconst output = `// Do not edit this file - it is generated automatically at build time\n\nexport const BUILD_DATE = \"${built}\";\nexport const VERSION = \"${packageInfo.version}\";\n`;\n\nwriteFileSync(\n    resolve(__dirname, \"../source/shared/library/version.ts\"),\n    output\n);\n"
  },
  {
    "path": "source/background/index.ts",
    "content": "import { initialise } from \"./services/init.js\";\nimport { log } from \"./services/log.js\";\n\ninitialise().catch((err) => {\n    console.error(err);\n    log(\"initialisation failed\");\n});\n"
  },
  {
    "path": "source/background/library/domain.ts",
    "content": "import { UsedCredentials } from \"../types.js\";\n\nexport function extractDomainFromCredentials(credentials: UsedCredentials): string | null {\n    const match = /^https?:\\/\\/([^\\/]+)/.exec(credentials.url);\n    return match ? match[1] : null;\n}\n"
  },
  {
    "path": "source/background/services/autoLogin.ts",
    "content": "import { SearchResult } from \"buttercup\";\nimport ExpiryMap from \"expiry-map\";\n\ninterface RegisteredItem {\n    entry: SearchResult;\n    tabID: number;\n}\n\nconst REGISTER_MAX_AGE = 30 * 1000; // 30 seconds\n\nlet __register: ExpiryMap<string, RegisteredItem> | null = null;\n\nexport function getAutoLoginForTab(tabID: number): SearchResult | null {\n    const register = getRegister();\n    const key = `tab-${tabID}`;\n    if (register.has(key)) {\n        const item = (register.get(key) as RegisteredItem).entry;\n        register.delete(key);\n        return item;\n    }\n    return null;\n}\n\nfunction getRegister(): ExpiryMap<string, RegisteredItem> {\n    if (!__register) {\n        __register = new ExpiryMap(REGISTER_MAX_AGE);\n    }\n    return __register;\n}\n\nexport function registerAutoLogin(entry: SearchResult, tabID: number): void {\n    const register = getRegister();\n    register.set(`tab-${tabID}`, { entry, tabID });\n}\n"
  },
  {
    "path": "source/background/services/config.ts",
    "content": "import { getSyncValue, setSyncValue } from \"./storage.js\";\nimport { Configuration, InputButtonType, SyncStorageItem } from \"../types.js\";\nimport { naiveClone } from \"../../shared/library/clone.js\";\n\nconst DEFAULTS: Configuration = {\n    entryIcons: true,\n    inputButtonDefault: InputButtonType.LargeButton,\n    saveNewLogins: true,\n    theme: \"light\",\n    useSystemTheme: true\n};\n\nlet __lastConfig: Configuration | null = null;\n\nexport function getConfig(): Configuration {\n    if (!__lastConfig) {\n        throw new Error(\"No configuration available\");\n    }\n    return __lastConfig;\n}\n\nexport async function initialise() {\n    __lastConfig = await updateConfigWithDefaults();\n}\n\nexport async function updateConfigValue<T extends keyof Configuration>(key: T, value: Configuration[T]): Promise<void> {\n    const configRaw = await getSyncValue(SyncStorageItem.Configuration);\n    const config = configRaw ? JSON.parse(configRaw) : naiveClone(DEFAULTS);\n    config[key] = value;\n    __lastConfig = config;\n    await setSyncValue(SyncStorageItem.Configuration, JSON.stringify(config));\n}\n\nasync function updateConfigWithDefaults(): Promise<Configuration> {\n    let configRaw = await getSyncValue(SyncStorageItem.Configuration);\n    const config = configRaw ? JSON.parse(configRaw) : { ...DEFAULTS };\n    for (const key in DEFAULTS) {\n        if (typeof config[key] === \"undefined\") {\n            config[key] = DEFAULTS[key];\n        }\n    }\n    await setSyncValue(SyncStorageItem.Configuration, JSON.stringify(config));\n    return config;\n}\n"
  },
  {
    "path": "source/background/services/crypto.ts",
    "content": "import { EncryptionAlgorithm, createAdapter } from \"iocane\";\nimport { deriveSecretKey, importECDHKey } from \"./cryptoKeys.js\";\n\nexport async function decryptPayload(\n    payload: string,\n    sourcePublicKey: string,\n    targetPrivateKey: string\n): Promise<string> {\n    const privateKey = await importECDHKey(targetPrivateKey);\n    const publicKey = await importECDHKey(sourcePublicKey);\n    const secret = await deriveSecretKey(privateKey, publicKey);\n    return createAdapter().decrypt(payload, secret) as Promise<string>;\n}\n\nexport async function encryptPayload(\n    payload: string,\n    sourcePrivateKey: string,\n    targetPublicKey: string\n): Promise<string> {\n    const privateKey = await importECDHKey(sourcePrivateKey);\n    const publicKey = await importECDHKey(targetPublicKey);\n    const secret = await deriveSecretKey(privateKey, publicKey);\n    return createAdapter()\n        .setAlgorithm(EncryptionAlgorithm.GCM)\n        .setDerivationRounds(100000)\n        .encrypt(payload, secret) as Promise<string>;\n}\n"
  },
  {
    "path": "source/background/services/cryptoKeys.ts",
    "content": "import { ulid } from \"ulidx\";\nimport { log } from \"./log.js\";\nimport { getLocalValue, setLocalValue } from \"./storage.js\";\nimport { arrayBufferToHex } from \"../../shared/library/buffer.js\";\nimport { API_KEY_ALGO, API_KEY_CURVE } from \"../../shared/symbols.js\";\nimport { LocalStorageItem } from \"../types.js\";\nimport { Layerr } from \"layerr\";\n\nasync function createKeys(): Promise<{\n    privateKey: string;\n    publicKey: string;\n}> {\n    const { privateKey, publicKey } = await window.crypto.subtle.generateKey(\n        {\n            name: API_KEY_ALGO,\n            namedCurve: API_KEY_CURVE\n        },\n        true,\n        [\"deriveKey\"]\n    );\n    log(\"generating public and private key pair for browser auth\");\n    const privateKeyStr = await exportECDHKey(privateKey);\n    const publicKeyStr = await exportECDHKey(publicKey);\n    log(\"generated new browser auth keys\");\n    return {\n        privateKey: privateKeyStr,\n        publicKey: publicKeyStr\n    };\n}\n\nexport async function deriveSecretKey(privateKey: CryptoKey, publicKey: CryptoKey): Promise<string> {\n    let cryptoKey: CryptoKey;\n    try {\n        cryptoKey = await window.crypto.subtle.deriveKey(\n            {\n                name: API_KEY_ALGO,\n                public: publicKey\n            },\n            privateKey,\n            {\n                name: \"AES-GCM\",\n                length: 256\n            },\n            true,\n            [\"encrypt\", \"decrypt\"]\n        );\n    } catch (err) {\n        throw new Layerr(err, \"Failed deriving secret key\");\n    }\n    const exported = await window.crypto.subtle.exportKey(\"raw\", cryptoKey);\n    return arrayBufferToHex(exported);\n}\n\nasync function exportECDHKey(key: CryptoKey): Promise<string> {\n    try {\n        const exported = await window.crypto.subtle.exportKey(\"jwk\", key);\n        return JSON.stringify(exported);\n    } catch (err) {\n        throw new Layerr(err, \"Failed exporting ECDH key\");\n    }\n}\n\nexport async function generateKeys(): Promise<void> {\n    let [apiPrivate, apiPublic, clientID] = await Promise.all([\n        getLocalValue(LocalStorageItem.APIPrivateKey),\n        getLocalValue(LocalStorageItem.APIPublicKey),\n        getLocalValue(LocalStorageItem.APIClientID)\n    ]);\n    if (apiPrivate && apiPublic && clientID) return;\n    // Regenerate\n    log(\"api keys missing: will generate\");\n    const { privateKey, publicKey } = await createKeys();\n    clientID = ulid();\n    await setLocalValue(LocalStorageItem.APIPrivateKey, privateKey);\n    await setLocalValue(LocalStorageItem.APIPublicKey, publicKey);\n    await setLocalValue(LocalStorageItem.APIClientID, clientID);\n}\n\nexport async function importECDHKey(key: string): Promise<CryptoKey> {\n    let jwk: JsonWebKey;\n    try {\n        jwk = JSON.parse(key) as JsonWebKey;\n    } catch (err) {\n        throw new Layerr(err, \"Failed importing ECDH key\");\n    }\n    const usages: Array<KeyUsage> = jwk.key_ops && jwk.key_ops.includes(\"deriveKey\") ? [\"deriveKey\"] : [];\n    return window.crypto.subtle.importKey(\n        \"jwk\",\n        jwk,\n        {\n            name: API_KEY_ALGO,\n            namedCurve: API_KEY_CURVE\n        },\n        true,\n        usages\n    );\n}\n"
  },
  {
    "path": "source/background/services/desktop/actions.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { EntryID, EntryType, GroupID, SearchResult, VaultFacade, VaultSourceID } from \"buttercup\";\nimport { getLocalValue } from \"../storage.js\";\nimport { sendDesktopRequest } from \"./request.js\";\nimport { generateAuthHeader } from \"./header.js\";\nimport { LocalStorageItem, OTP, VaultSourceDescription, VaultsTree } from \"../../types.js\";\n\nexport async function authenticateBrowserAccess(code: string): Promise<string> {\n    const localPublicKey = await getLocalValue(LocalStorageItem.APIPublicKey);\n    const clientID = await getLocalValue(LocalStorageItem.APIClientID);\n    if (!localPublicKey) {\n        throw new Error(\"No local public key available\");\n    }\n    if (!clientID) {\n        throw new Error(\"No API client ID set\");\n    }\n    const { publicKey } = (await sendDesktopRequest({\n        method: \"POST\",\n        route: \"/v1/auth/response\",\n        payload: {\n            code,\n            id: clientID,\n            publicKey: localPublicKey\n        }\n    })) as { publicKey: string };\n    if (!publicKey) {\n        throw new Layerr(\"No server public key received from browser authentication\");\n    }\n    return publicKey;\n}\n\nexport async function getEntrySearchResults(\n    entries: Array<{ entryID: EntryID; sourceID: VaultSourceID }>\n): Promise<Array<SearchResult>> {\n    const authHeader = await generateAuthHeader();\n    const { results } = (await sendDesktopRequest({\n        method: \"POST\",\n        route: \"/v1/entries/specific\",\n        auth: authHeader,\n        payload: {\n            entries\n        }\n    })) as {\n        results: Array<SearchResult>;\n    };\n    return results;\n}\n\nexport async function getOTPs(): Promise<Array<OTP>> {\n    const authHeader = await generateAuthHeader();\n    const { otps } = (await sendDesktopRequest({\n        method: \"GET\",\n        route: \"/v1/otps\",\n        auth: authHeader\n    })) as {\n        otps: Array<OTP>;\n    };\n    return otps;\n}\n\nexport async function getVaultSources(): Promise<Array<VaultSourceDescription>> {\n    const authHeader = await generateAuthHeader();\n    const { sources } = (await sendDesktopRequest({\n        method: \"GET\",\n        route: \"/v1/vaults\",\n        auth: authHeader\n    })) as {\n        sources: Array<VaultSourceDescription>;\n    };\n    return sources;\n}\n\nexport async function getVaultsTree(): Promise<VaultsTree> {\n    const authHeader = await generateAuthHeader();\n    const { names, tree } = (await sendDesktopRequest({\n        method: \"GET\",\n        route: \"/v1/vaults-tree\",\n        auth: authHeader\n    })) as {\n        names?: Record<VaultSourceID, string>;\n        tree: Record<VaultSourceID, VaultFacade>;\n    };\n    return Object.keys(tree).reduce((output, sourceID) => {\n        return {\n            ...output,\n            [sourceID]: {\n                ...tree[sourceID],\n                name: names?.[sourceID] ?? \"Untitled vault\"\n            }\n        };\n    }, {});\n}\n\nexport async function hasConnection(): Promise<boolean> {\n    const serverPublicKey = await getLocalValue(LocalStorageItem.APIServerPublicKey);\n    return !!serverPublicKey;\n}\n\nexport async function initiateConnection(): Promise<void> {\n    await sendDesktopRequest({\n        method: \"POST\",\n        route: \"/v1/auth/request\",\n        payload: {\n            client: \"browser\",\n            purpose: \"vaults-access\",\n            rev: 1\n        }\n    });\n}\n\nexport async function promptSourceLock(sourceID: VaultSourceID): Promise<boolean> {\n    const authHeader = await generateAuthHeader();\n    const status = await sendDesktopRequest({\n        method: \"POST\",\n        route: `/v1/vaults/${sourceID}/lock`,\n        auth: authHeader,\n        output: \"status\"\n    });\n    return status === 200;\n}\n\nexport async function promptSourceUnlock(sourceID: VaultSourceID): Promise<void> {\n    const authHeader = await generateAuthHeader();\n    await sendDesktopRequest({\n        method: \"POST\",\n        route: `/v1/vaults/${sourceID}/unlock`,\n        auth: authHeader\n    });\n}\n\nexport async function saveExistingEntry(\n    sourceID: VaultSourceID,\n    groupID: GroupID,\n    entryID: EntryID,\n    properties: Record<string, string>\n): Promise<void> {\n    const authHeader = await generateAuthHeader();\n    await sendDesktopRequest({\n        method: \"PATCH\",\n        route: `/v1/vaults/${sourceID}/group/${groupID}/entry/${entryID}`,\n        auth: authHeader,\n        payload: {\n            properties\n        }\n    });\n}\n\nexport async function saveNewEntry(\n    sourceID: VaultSourceID,\n    groupID: GroupID,\n    entryType: EntryType,\n    properties: Record<string, string>\n): Promise<EntryID> {\n    const authHeader = await generateAuthHeader();\n    const { entryID } = (await sendDesktopRequest({\n        method: \"POST\",\n        route: `/v1/vaults/${sourceID}/group/${groupID}/entry`,\n        auth: authHeader,\n        payload: {\n            properties,\n            type: entryType\n        }\n    })) as {\n        entryID: EntryID;\n    };\n    return entryID;\n}\n\nexport async function searchEntriesByURL(url: string): Promise<Array<SearchResult>> {\n    const authHeader = await generateAuthHeader();\n    const { results } = (await sendDesktopRequest({\n        method: \"GET\",\n        route: \"/v1/entries\",\n        payload: {\n            type: \"url\",\n            url\n        },\n        auth: authHeader\n    })) as {\n        results: Array<SearchResult>;\n    };\n    return results;\n}\n\nexport async function searchEntriesByTerm(term: string): Promise<Array<SearchResult>> {\n    const authHeader = await generateAuthHeader();\n    const { results } = (await sendDesktopRequest({\n        method: \"GET\",\n        route: \"/v1/entries\",\n        payload: {\n            type: \"term\",\n            term\n        },\n        auth: authHeader\n    })) as {\n        results: Array<SearchResult>;\n    };\n    return results;\n}\n\nexport async function testAuth(): Promise<void> {\n    const authHeader = await generateAuthHeader();\n    try {\n        await sendDesktopRequest({\n            method: \"POST\",\n            route: \"/v1/auth/test\",\n            payload: {\n                client: \"browser\",\n                purpose: \"vaults-access\",\n                rev: 1\n            },\n            auth: authHeader\n        });\n    } catch (err) {\n        console.error(err);\n        throw new Layerr(err, \"Desktop connection failed\");\n    }\n}\n"
  },
  {
    "path": "source/background/services/desktop/header.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { LocalStorageItem } from \"../../types.js\";\nimport { getLocalValue } from \"../storage.js\";\n\nexport async function generateAuthHeader(): Promise<string> {\n    const clientID = await getLocalValue(LocalStorageItem.APIClientID);\n    if (!clientID) {\n        throw new Layerr(\n            {\n                info: {\n                    i18n: \"error.code.desktop-connection-not-authorised\"\n                }\n            },\n            \"No API client ID set\"\n        );\n    }\n    return `Client ${clientID}`;\n}\n"
  },
  {
    "path": "source/background/services/desktop/request.ts",
    "content": "import { Layerr } from \"layerr\";\nimport joinURL from \"url-join\";\nimport { DESKTOP_API_PORT } from \"../../../shared/symbols.js\";\nimport { decryptPayload, encryptPayload } from \"../crypto.js\";\nimport { getLocalValue } from \"../storage.js\";\nimport { LocalStorageItem } from \"../../types.js\";\n\ntype OutputType = \"body\" | \"status\" | undefined;\n\ninterface DesktopRequestConfig<O extends OutputType> {\n    auth?: string | null;\n    method: string;\n    output?: O;\n    payload?: Record<string, any> | null;\n    route: string;\n}\n\nconst DESKTOP_URL_BASE = `http://localhost:${DESKTOP_API_PORT}`;\n\nexport async function sendDesktopRequest<O extends undefined>(\n    config: DesktopRequestConfig<O>\n): Promise<string | Record<string, any>>;\nexport async function sendDesktopRequest<O extends \"status\">(config: DesktopRequestConfig<O>): Promise<number>;\nexport async function sendDesktopRequest<O extends \"body\">(\n    config: DesktopRequestConfig<O>\n): Promise<string | Record<string, any>>;\nexport async function sendDesktopRequest<O extends OutputType>(\n    config: DesktopRequestConfig<O>\n): Promise<string | Record<string, any> | number> {\n    const { auth = null, method, output = \"body\", payload = null, route } = config;\n    // Prepare un-encrypted configuration first\n    let url = joinURL(DESKTOP_URL_BASE, route);\n    const requestConfig: RequestInit = {\n        method,\n        headers: {}\n    };\n    if (payload !== null) {\n        if (/^get$/i.test(method)) {\n            const newURL = new URL(url);\n            for (const prop in payload) {\n                if (payload.hasOwnProperty(prop)) {\n                    newURL.searchParams.set(prop, payload[prop]);\n                }\n            }\n            url = newURL.toString();\n        } else {\n            requestConfig.body = JSON.stringify(payload);\n            Object.assign(requestConfig.headers as HeadersInit, {\n                \"Content-Type\": \"application/json\"\n            });\n        }\n    }\n    if (auth !== null) {\n        requestConfig.headers = requestConfig.headers || {};\n        // Request requires encryption, perform setup now\n        requestConfig.headers[\"Authorization\"] = auth;\n        if (typeof requestConfig.body === \"string\") {\n            requestConfig.headers[\"X-Content-Type\"] = requestConfig.headers[\"Content-Type\"];\n            requestConfig.headers[\"Content-Type\"] = \"text/plain\";\n            // Encrypt\n            const privateKey = await getLocalValue(LocalStorageItem.APIPrivateKey);\n            const publicKey = await getLocalValue(LocalStorageItem.APIServerPublicKey);\n            if (!privateKey) {\n                throw new Error(\"Authenticated request failed: No private key available\");\n            }\n            if (!publicKey) {\n                throw new Error(\"Authenticated request failed: No public key available\");\n            }\n            requestConfig.body = await encryptPayload(requestConfig.body, privateKey, publicKey);\n        }\n    }\n    // Make request\n    const resp = await fetch(url, requestConfig);\n    if (!resp.ok) {\n        throw new Layerr(\n            {\n                info: {\n                    code: \"desktop-request-failed\",\n                    status: resp.status,\n                    statusText: resp.statusText\n                }\n            },\n            `Desktop request failed: ${resp.status} ${resp.statusText}`\n        );\n    }\n    if (output === \"status\") {\n        return resp.status;\n    }\n    // Handle encrypted response\n    if (resp.headers.get(\"X-Bcup-API\")) {\n        const components = resp.headers.get(\"X-Bcup-API\")?.split(\",\") ?? [];\n        if (components.includes(\"enc\")) {\n            const content = await resp.text();\n            const contentType = resp.headers.get(\"X-Content-Type\") || resp.headers.get(\"Content-Type\") || \"text/plain\";\n            // Decrypt\n            const privateKey = await getLocalValue(LocalStorageItem.APIPrivateKey);\n            const publicKey = await getLocalValue(LocalStorageItem.APIServerPublicKey);\n            if (!privateKey) {\n                throw new Error(\"Decrypting response failed: No private key available\");\n            }\n            if (!publicKey) {\n                throw new Error(\"Decrypting response failed: No public key available\");\n            }\n            const rawDecrypted = await decryptPayload(content, publicKey, privateKey);\n            return /application\\/json/.test(contentType) ? JSON.parse(rawDecrypted) : rawDecrypted;\n        }\n    }\n    // Standard, unencrypted response\n    if (/application\\/json/.test(resp.headers.get(\"Content-Type\") ?? \"\")) {\n        return resp.json();\n    }\n    return resp.text();\n}\n"
  },
  {
    "path": "source/background/services/disabledDomains.ts",
    "content": "import { getSyncValue, setSyncValue } from \"./storage.js\";\nimport { SyncStorageItem } from \"../types.js\";\n\nexport async function disableLoginsOnDomain(domain: string): Promise<void> {\n    const currentDomains = new Set(await getDisabledDomains());\n    currentDomains.add(domain);\n    await setSyncValue(SyncStorageItem.DisabledDomains, JSON.stringify([...currentDomains]));\n}\n\nexport async function getDisabledDomains(): Promise<Array<string>> {\n    const currentDomainsRaw = await getSyncValue(SyncStorageItem.DisabledDomains);\n    return currentDomainsRaw ? JSON.parse(currentDomainsRaw) : [];\n}\n\nexport async function removeDisabledFlagForDomain(domain: string): Promise<void> {\n    const currentDomains = new Set(await getDisabledDomains());\n    currentDomains.delete(domain);\n    await setSyncValue(SyncStorageItem.DisabledDomains, JSON.stringify([...currentDomains]));\n}\n"
  },
  {
    "path": "source/background/services/entry.ts",
    "content": "import { SearchResult } from \"buttercup\";\nimport { createNewTab } from \"../../shared/library/extension.js\";\nimport { formatURL } from \"../../shared/library/url.js\";\n\nexport async function openEntryPageInNewTab(_: SearchResult, url: string): Promise<number> {\n    const tab = await createNewTab(formatURL(url));\n    if (typeof tab?.id !== \"number\") {\n        throw new Error(\"No tab ID for created tab\");\n    }\n    return tab.id;\n}\n"
  },
  {
    "path": "source/background/services/init.ts",
    "content": "import { EventEmitter } from \"eventemitter3\";\nimport { log } from \"./log.js\";\nimport { initialise as initialiseMessaging } from \"./messaging.js\";\nimport { initialise as initialiseStorage } from \"./storage.js\";\nimport { initialise as initialiseConfig } from \"./config.js\";\nimport { generateKeys } from \"./cryptoKeys.js\";\nimport { initialise as initialiseI18n } from \"../../shared/i18n/trans.js\";\nimport { getLanguage } from \"../../shared/library/i18n.js\";\nimport { showPendingNotifications } from \"./notifications.js\";\n\nenum Initialisation {\n    Complete = \"complete\",\n    Idle = \"idle\",\n    Running = \"running\"\n}\n\nconst __initEE = new EventEmitter();\nlet __initialisation: Initialisation = Initialisation.Idle;\n\nexport async function initialise(): Promise<void> {\n    if (__initialisation !== Initialisation.Idle) return;\n    __initialisation = Initialisation.Running;\n    log(\"initialising\");\n    initialiseMessaging();\n    await initialiseStorage();\n    await initialiseConfig();\n    await initialiseI18n(getLanguage());\n    await generateKeys();\n    log(\"initialisation complete\");\n    __initialisation = Initialisation.Complete;\n    __initEE.emit(\"initialised\");\n    await showPendingNotifications();\n}\n\nexport async function resetInitialisation(): Promise<void> {\n    log(\"resetting initialisation\");\n    __initialisation = Initialisation.Idle;\n    await initialise();\n}\n\nexport async function waitForInitialisation(): Promise<void> {\n    return new Promise<void>((resolve) => {\n        if (__initialisation === Initialisation.Complete) return resolve();\n        __initEE.once(\"initialised\", resolve);\n    });\n}\n"
  },
  {
    "path": "source/background/services/log.ts",
    "content": "import { createLog } from \"../../shared/library/log.js\";\n\nconst LOG_NAME = \"buttercup:browser:background\";\n\nlet __logger: ReturnType<typeof createLog>;\n\nexport function log(...args: Array<any>): void {\n    if (!__logger) {\n        __logger = createLog(LOG_NAME, true);\n    }\n    return __logger(...args);\n}\n"
  },
  {
    "path": "source/background/services/loginMemory.ts",
    "content": "import ExpiryMap from \"expiry-map\";\nimport { searchEntriesByTerm } from \"./desktop/actions.js\";\nimport { domainsReferToSameParent, extractDomain } from \"../../shared/library/domain.js\";\nimport { UsedCredentials } from \"../types.js\";\n\ninterface LoginMemoryItem {\n    credentials: UsedCredentials;\n    tabID: number;\n}\n\nconst LOGIN_MAX_AGE = 15 * 60 * 1000; // 15 min\n\nlet __loginMemory: ExpiryMap<string, LoginMemoryItem> | null = null;\n\nexport function clearCredentials(id: string): void {\n    const memory = getLoginMemory();\n    if (memory.has(id)) {\n        memory.delete(id);\n    }\n    if (memory.has(\"last\")) {\n        const last = memory.get(\"last\");\n        if (last?.credentials.id === id) {\n            memory.delete(\"last\");\n        }\n    }\n}\n\nexport async function credentialsAlreadyStored(credentials: UsedCredentials): Promise<boolean> {\n    const results = await searchEntriesByTerm(credentials.username);\n    const usedDomain = extractDomain(credentials.url);\n    return results.some((result) => {\n        // Check username\n        if (credentials.username !== result.properties.username) return false;\n        // Skip if search result has no URLs\n        if (result.urls.length <= 0) return false;\n        // Check if any of the domains match this one\n        const resultDomains = result.urls.map((url) => extractDomain(url));\n        if (!resultDomains.some((resDomain) => domainsReferToSameParent(resDomain, usedDomain))) {\n            // No matches\n            return false;\n        }\n        // Check if props match\n        return (\n            result.properties.username === credentials.username && result.properties.password === credentials.password\n        );\n    });\n}\n\nexport function getAllCredentials(): Array<UsedCredentials> {\n    const memory = getLoginMemory();\n    const credentials: Array<UsedCredentials> = [];\n    for (const [key, item] of memory.entries()) {\n        if (key === \"last\") continue;\n        credentials.push(item.credentials);\n    }\n    return credentials;\n}\n\nexport function getCredentialsForID(id: string): UsedCredentials | null {\n    const memory = getLoginMemory();\n    return memory.has(id) ? (memory.get(id) as LoginMemoryItem).credentials : null;\n}\n\nexport function getLastCredentials(tabID: number): UsedCredentials | null {\n    const memory = getLoginMemory();\n    const last = memory.has(\"last\") ? memory.get(\"last\") : null;\n    if (!last) return null;\n    return last.tabID === tabID ? last.credentials : null;\n}\n\nfunction getLoginMemory(): ExpiryMap<string, LoginMemoryItem> {\n    if (!__loginMemory) {\n        __loginMemory = new ExpiryMap(LOGIN_MAX_AGE);\n    }\n    return __loginMemory;\n}\n\nexport function stopPromptForID(id: string): void {\n    const memory = getLoginMemory();\n    if (memory.has(id)) {\n        const existing = memory.get(id) as LoginMemoryItem;\n        memory.set(id, {\n            ...existing,\n            credentials: {\n                ...existing.credentials,\n                promptSave: false\n            }\n        });\n    }\n    const last = memory.has(\"last\") ? memory.get(\"last\") : null;\n    if (last?.credentials.id === id) {\n        memory.set(\"last\", {\n            ...last,\n            credentials: {\n                ...last.credentials,\n                promptSave: false\n            }\n        });\n    }\n}\n\nexport function updateUsedCredentials(credentials: UsedCredentials, tabID: number): void {\n    const memory = getLoginMemory();\n    const payload: LoginMemoryItem = {\n        credentials,\n        tabID\n    };\n    memory.set(credentials.id, payload);\n    memory.set(\"last\", payload);\n}\n"
  },
  {
    "path": "source/background/services/messaging.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { EntryType, EntryURLType, VaultSourceID, VaultSourceStatus, getEntryURLs } from \"buttercup\";\nimport { getExtensionAPI } from \"../../shared/extension.js\";\nimport {\n    authenticateBrowserAccess,\n    getEntrySearchResults,\n    getOTPs,\n    getVaultSources,\n    getVaultsTree,\n    hasConnection,\n    initiateConnection,\n    promptSourceLock,\n    promptSourceUnlock,\n    saveExistingEntry,\n    saveNewEntry,\n    searchEntriesByTerm,\n    searchEntriesByURL,\n    testAuth\n} from \"./desktop/actions.js\";\nimport { clearLocalStorage, removeLocalValue, setLocalValue } from \"./storage.js\";\nimport { errorToString } from \"../../shared/library/error.js\";\nimport {\n    clearCredentials,\n    credentialsAlreadyStored,\n    getAllCredentials,\n    getCredentialsForID,\n    getLastCredentials,\n    stopPromptForID,\n    updateUsedCredentials\n} from \"./loginMemory.js\";\nimport { getConfig, updateConfigValue } from \"./config.js\";\nimport { disableLoginsOnDomain, getDisabledDomains, removeDisabledFlagForDomain } from \"./disabledDomains.js\";\nimport { log } from \"./log.js\";\nimport { resetInitialisation } from \"./init.js\";\nimport { getRecents, trackRecentUsage } from \"./recents.js\";\nimport { openEntryPageInNewTab } from \"./entry.js\";\nimport { getAutoLoginForTab, registerAutoLogin } from \"./autoLogin.js\";\nimport { extractDomainFromCredentials } from \"../library/domain.js\";\nimport {\n    BackgroundMessage,\n    BackgroundMessageType,\n    BackgroundResponse,\n    LocalStorageItem,\n    TabEventType\n} from \"../types.js\";\nimport { markNotificationRead } from \"./notifications.js\";\nimport { createNewTab, getExtensionURL } from \"../../shared/library/extension.js\";\nimport { sendTabsMessage } from \"./tabs.js\";\n\nasync function handleMessage(\n    msg: BackgroundMessage,\n    sender: chrome.runtime.MessageSender,\n    sendResponse: (resp: BackgroundResponse) => void\n) {\n    switch (msg.type) {\n        case BackgroundMessageType.AuthenticateDesktopConnection: {\n            const { code } = msg;\n            if (!code) {\n                throw new Error(\"No auth code provided\");\n            }\n            log(\"complete desktop authentication\");\n            const publicKey = await authenticateBrowserAccess(code);\n            await setLocalValue(LocalStorageItem.APIServerPublicKey, publicKey);\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.CheckDesktopConnection: {\n            const available = await hasConnection();\n            if (available) {\n                await testAuth();\n            }\n            sendResponse({\n                available\n            });\n            break;\n        }\n        case BackgroundMessageType.ClearDesktopAuthentication: {\n            log(\"clear desktop authentication\");\n            await removeLocalValue(LocalStorageItem.APIClientID);\n            await removeLocalValue(LocalStorageItem.APIServerPublicKey);\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.ClearSavedCredentials: {\n            const { credentialsID } = msg;\n            if (!credentialsID) {\n                throw new Error(\"No credentials ID provided\");\n            }\n            log(`clear saved credentials: ${credentialsID}`);\n            clearCredentials(credentialsID);\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.ClearSavedCredentialsPrompt: {\n            const { credentialsID } = msg;\n            if (!credentialsID) {\n                throw new Error(\"No credentials ID provided\");\n            }\n            log(`clear saved credentials prompt: ${credentialsID}`);\n            stopPromptForID(credentialsID);\n            await sendTabsMessage({\n                type: TabEventType.CloseSaveDialog\n            });\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.DeleteDisabledDomains: {\n            const { domains } = msg;\n            if (!domains) {\n                throw new Error(\"No domains list provided\");\n            }\n            log(`remove disabled domains: ${domains.join(\", \")}`);\n            for (const domain of domains) {\n                await removeDisabledFlagForDomain(domain);\n            }\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.DisableSavePromptForCredentials: {\n            const { credentialsID } = msg;\n            if (!credentialsID) {\n                throw new Error(\"No credentials ID provided\");\n            }\n            log(`disable save prompt for credentials: ${credentialsID}`);\n            try {\n                const credentials = getCredentialsForID(credentialsID);\n                const domain = credentials ? extractDomainFromCredentials(credentials) : null;\n                if (domain) {\n                    log(`disable save prompt for domain: ${domain}`);\n                    await disableLoginsOnDomain(domain);\n                }\n            } catch (err) {\n                throw new Layerr(err, \"Failed disabling save prompt for domain\");\n            }\n            await sendTabsMessage({\n                type: TabEventType.CloseSaveDialog\n            });\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.GetAutoLoginForTab: {\n            const tabID = sender.tab?.id;\n            if (!tabID) {\n                sendResponse({ autoLogin: null });\n                break;\n            }\n            const entry = getAutoLoginForTab(tabID);\n            sendResponse({ autoLogin: entry });\n            break;\n        }\n        case BackgroundMessageType.GetConfiguration: {\n            const config = getConfig();\n            sendResponse({\n                config\n            });\n            break;\n        }\n        case BackgroundMessageType.GetDesktopVaultSources: {\n            const sources = await getVaultSources();\n            sendResponse({\n                vaultSources: sources\n            });\n            break;\n        }\n        case BackgroundMessageType.GetDesktopVaultsTree: {\n            const tree = await getVaultsTree();\n            sendResponse({\n                vaultsTree: tree\n            });\n            break;\n        }\n        case BackgroundMessageType.GetDisabledDomains: {\n            const domains = await getDisabledDomains();\n            sendResponse({\n                domains\n            });\n            break;\n        }\n        case BackgroundMessageType.GetLastSavedCredentials: {\n            const { excludeSaved = false } = msg;\n            const tabID = sender.tab?.id;\n            if (!tabID) {\n                sendResponse({ credentials: [null] });\n                break;\n            }\n            let credentials = getLastCredentials(tabID);\n            if (credentials && excludeSaved && (await credentialsAlreadyStored(credentials))) {\n                credentials = null;\n            }\n            sendResponse({\n                credentials: [credentials]\n            });\n            break;\n        }\n        case BackgroundMessageType.GetOTPs: {\n            const otps = await getOTPs();\n            sendResponse({\n                otps\n            });\n            break;\n        }\n        case BackgroundMessageType.GetRecentEntries: {\n            const { count = 10 } = msg;\n            const sources = await getVaultSources();\n            const unlockedIDs: Array<VaultSourceID> = sources.reduce((output, source) => {\n                if (source.state === VaultSourceStatus.Unlocked) {\n                    return [...output, source.id];\n                }\n                return output;\n            }, []);\n            const recentItems = await getRecents(unlockedIDs);\n            recentItems.splice(count, Infinity);\n            const searchResults = await getEntrySearchResults(\n                recentItems.map((item) => ({\n                    entryID: item.entryID,\n                    sourceID: item.sourceID\n                }))\n            );\n            sendResponse({\n                searchResults\n            });\n            break;\n        }\n        case BackgroundMessageType.GetSavedCredentials: {\n            const credentials = getAllCredentials();\n            sendResponse({\n                credentials\n            });\n            break;\n        }\n        case BackgroundMessageType.GetSavedCredentialsForID: {\n            const { credentialsID, excludeSaved = false } = msg;\n            if (!credentialsID) {\n                throw new Error(\"No credentials ID provided\");\n            }\n            let credentials = getCredentialsForID(credentialsID);\n            if (credentials && excludeSaved && (await credentialsAlreadyStored(credentials))) {\n                credentials = null;\n            }\n            sendResponse({\n                credentials: [credentials]\n            });\n            break;\n        }\n        case BackgroundMessageType.InitiateDesktopConnection: {\n            log(\"start desktop authentication\");\n            await initiateConnection();\n            await createNewTab(getExtensionURL(\"full.html#/connect\"));\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.MarkNotificationRead: {\n            const { notification } = msg;\n            if (!notification) {\n                throw new Error(\"No notification provided\");\n            }\n            log(`mark notification read: ${notification}`);\n            await markNotificationRead(notification);\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.OpenEntryPage: {\n            const { autoLogin, entry } = msg;\n            if (!entry) {\n                throw new Error(\"No entry provided\");\n            }\n            const [url = null] = getEntryURLs(entry.properties, EntryURLType.Login);\n            if (!url) {\n                sendResponse({ opened: false });\n                return;\n            }\n            log(`open entry page by url: ${entry.id} (${url})`);\n            const tabID = await openEntryPageInNewTab(entry, url);\n            if (autoLogin) {\n                registerAutoLogin(entry, tabID);\n            }\n            sendResponse({ opened: true });\n            break;\n        }\n        case BackgroundMessageType.OpenSaveCredentialsPage: {\n            await createNewTab(getExtensionURL(\"full.html#/save-credentials\"));\n            await sendTabsMessage({\n                type: TabEventType.CloseSaveDialog\n            });\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.PromptLockSource: {\n            const { sourceID } = msg;\n            if (!sourceID) {\n                throw new Error(\"No source ID provided\");\n            }\n            log(`request lock source: ${sourceID}`);\n            const locked = await promptSourceLock(sourceID);\n            sendResponse({\n                locked\n            });\n            break;\n        }\n        case BackgroundMessageType.PromptUnlockSource: {\n            const { sourceID } = msg;\n            if (!sourceID) {\n                throw new Error(\"No source ID provided\");\n            }\n            log(`request unlock source: ${sourceID}`);\n            await promptSourceUnlock(sourceID);\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.ResetSettings: {\n            log(`reset settings`);\n            await clearLocalStorage();\n            await resetInitialisation();\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.SaveCredentialsToVault: {\n            const { sourceID, groupID, entryID = null, entryProperties, entryType = EntryType.Website } = msg;\n            if (!sourceID) {\n                throw new Error(\"No source ID provided\");\n            }\n            if (!groupID) {\n                throw new Error(\"No group ID provided\");\n            }\n            if (!entryProperties) {\n                throw new Error(\"No entry properties provided\");\n            }\n            if (entryID) {\n                log(`save credentials to existing entry: ${entryID} (source=${sourceID})`);\n                await saveExistingEntry(sourceID, groupID, entryID, entryProperties);\n                sendResponse({\n                    entryID: null\n                });\n            } else {\n                log(`save credentials to new entry (source=${sourceID})`);\n                const entryID = await saveNewEntry(sourceID, groupID, entryType, entryProperties);\n                sendResponse({\n                    entryID\n                });\n            }\n            break;\n        }\n        case BackgroundMessageType.SaveUsedCredentials: {\n            const { credentials } = msg;\n            if (!credentials) {\n                throw new Error(\"No source ID provided\");\n            }\n            if (!sender.tab?.id) {\n                throw new Error(\"No tab ID available for background message\");\n            }\n            updateUsedCredentials(credentials, sender.tab.id);\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.SearchEntriesByTerm: {\n            const { searchTerm } = msg;\n            if (!searchTerm) {\n                throw new Error(\"No search term provided\");\n            }\n            const searchResults = await searchEntriesByTerm(searchTerm);\n            sendResponse({\n                searchResults\n            });\n            break;\n        }\n        case BackgroundMessageType.SearchEntriesByURL: {\n            const { url } = msg;\n            if (!url) {\n                throw new Error(\"No URL provided\");\n            }\n            const searchResults = await searchEntriesByURL(url);\n            sendResponse({\n                searchResults\n            });\n            break;\n        }\n        case BackgroundMessageType.SetConfigurationValue: {\n            const { configKey, configValue } = msg;\n            if (!configKey || typeof configValue === \"undefined\") {\n                throw new Error(\"Invalid configuration proivided provided\");\n            }\n            await updateConfigValue(configKey, configValue);\n            sendResponse({});\n            break;\n        }\n        case BackgroundMessageType.TrackRecentEntry: {\n            const { entry } = msg;\n            if (!entry) {\n                throw new Error(\"No entry provided\");\n            }\n            if (!entry.sourceID) {\n                throw new Error(`No source ID in entry result: ${entry.id}`);\n            }\n            await trackRecentUsage(entry.sourceID, entry.id);\n            sendResponse({});\n            break;\n        }\n        default:\n            throw new Layerr(`Unrecognised message type: ${(msg as any).type}`);\n    }\n}\n\nexport function initialise() {\n    getExtensionAPI().runtime.onMessage.addListener((request, sender, sendResponse) => {\n        handleMessage(request, sender, sendResponse).catch((err) => {\n            console.error(err);\n            sendResponse({\n                error: errorToString(new Layerr(err, \"Background task failed\"))\n            });\n        });\n        return true;\n    });\n}\n"
  },
  {
    "path": "source/background/services/notifications.ts",
    "content": "import { getSyncValue, setSyncValue } from \"./storage.js\";\nimport { SyncStorageItem } from \"../types.js\";\nimport { NOTIFICATION_NAMES } from \"../../shared/notifications/index.js\";\nimport { createNewTab, getExtensionURL } from \"../../shared/library/extension.js\";\n\nasync function getPendingNotifications(): Promise<Array<string>> {\n    const existingNotificationsRaw = await getSyncValue(SyncStorageItem.Notifications);\n    const existingNotifications = existingNotificationsRaw ? existingNotificationsRaw.split(\",\") : [];\n    return NOTIFICATION_NAMES.filter((name) => !existingNotifications.includes(name));\n}\n\nexport async function markNotificationRead(name: string): Promise<void> {\n    const existingNotificationsRaw = await getSyncValue(SyncStorageItem.Notifications);\n    const notifications = existingNotificationsRaw ? existingNotificationsRaw.split(\",\") : [];\n    if (!notifications.includes(name)) {\n        notifications.push(name);\n    }\n    await setSyncValue(SyncStorageItem.Notifications, notifications.join(\",\"));\n}\n\nexport async function showPendingNotifications(): Promise<void> {\n    const notifications = await getPendingNotifications();\n    if (notifications.length <= 0) return;\n    await createNewTab(getExtensionURL(`full.html#/notifications?notifications=${notifications.join(\",\")}`));\n}\n"
  },
  {
    "path": "source/background/services/recents.ts",
    "content": "import { EntryID, VaultSourceID } from \"buttercup\";\nimport { ChannelQueue, TaskPriority } from \"@buttercup/channel-queue\";\nimport ms from \"ms\";\nimport { getSyncValue, setSyncValue } from \"./storage.js\";\nimport { SyncStorageItem } from \"../types.js\";\n\nexport interface RecentItem {\n    entryID: EntryID;\n    sourceID: VaultSourceID;\n    uses: Array<number>;\n}\n\nconst MAX_USE_AGE = ms(\"30d\");\n\nlet __queue: ChannelQueue | null = null;\n\nfunction getQueue(): ChannelQueue {\n    if (!__queue) {\n        __queue = new ChannelQueue();\n    }\n    return __queue;\n}\n\nexport async function getRecents(sourceIDs: Array<VaultSourceID>): Promise<Array<RecentItem>> {\n    const currentRecentsRaw = await getSyncValue(SyncStorageItem.RecentItems);\n    if (!currentRecentsRaw) return [];\n    const currentRecents = JSON.parse(currentRecentsRaw) as Array<RecentItem>;\n    return currentRecents.filter((recent) => sourceIDs.includes(recent.sourceID));\n}\n\nfunction sortUses(itemA: RecentItem, itemB: RecentItem): number {\n    if (itemA.uses.length > itemB.uses.length) return -1;\n    if (itemB.uses.length > itemA.uses.length) return 1;\n    return 0;\n}\n\nfunction stripOldUses(items: Array<RecentItem>): Array<RecentItem> {\n    const earliestTs = Date.now() - MAX_USE_AGE;\n    return items.reduce((output: Array<RecentItem>, item: RecentItem) => {\n        const hasRecentUse = item.uses.some((ts) => ts >= earliestTs);\n        if (!hasRecentUse) return output;\n        return [\n            ...output,\n            {\n                ...item,\n                uses: item.uses.filter((ts) => ts >= earliestTs)\n            }\n        ];\n    }, []);\n}\n\nexport async function trackRecentUsage(sourceID: VaultSourceID, entryID: EntryID): Promise<void> {\n    const channel = getQueue().channel(\"write\");\n    await channel.enqueue(\n        async () => {\n            const currentRecentsRaw = await getSyncValue(SyncStorageItem.RecentItems);\n            let currentRecents = currentRecentsRaw ? (JSON.parse(currentRecentsRaw) as Array<RecentItem>) : [];\n            let existingResult = currentRecents.find(\n                (recent) => recent.sourceID === sourceID && recent.entryID === entryID\n            );\n            if (existingResult) {\n                existingResult.uses.unshift(Date.now());\n            } else {\n                existingResult = {\n                    entryID,\n                    sourceID,\n                    uses: [Date.now()]\n                };\n                currentRecents.push(existingResult);\n            }\n            currentRecents = stripOldUses(currentRecents);\n            currentRecents.sort(sortUses);\n            await setSyncValue(SyncStorageItem.RecentItems, JSON.stringify(currentRecents));\n        },\n        TaskPriority.Normal,\n        `${sourceID}-${entryID}`\n    );\n}\n"
  },
  {
    "path": "source/background/services/storage/BrowserStorageInterface.ts",
    "content": "import { StorageInterface } from \"buttercup\";\nimport { getExtensionAPI } from \"../../../shared/extension.js\";\n\nexport function getSyncStorage() {\n    return getExtensionAPI().storage.sync;\n}\n\nexport function getNonSyncStorage() {\n    return getExtensionAPI().storage.local;\n}\n\nexport class BrowserStorageInterface extends StorageInterface {\n    protected _storage: chrome.storage.StorageArea;\n\n    constructor(storage: chrome.storage.StorageArea = getSyncStorage()) {\n        super();\n        this._storage = storage;\n    }\n\n    get storage() {\n        return this._storage;\n    }\n\n    async getAllKeys() {\n        return new Promise<Array<string>>((resolve) => {\n            this.storage.get(null, (allItems) => {\n                resolve(Object.keys(allItems));\n            });\n        });\n    }\n\n    async getValue(name: string) {\n        return new Promise<string>((resolve) => {\n            this.storage.get(name, (items) => {\n                resolve(items[name]);\n            });\n        });\n    }\n\n    async removeKey(name: string) {\n        return new Promise<void>((resolve) => {\n            this.storage.remove(name, () => resolve());\n        });\n    }\n\n    async setValue(name: string, value: any) {\n        return new Promise<void>((resolve) => {\n            this.storage.set({ [name]: value }, () => resolve());\n        });\n    }\n}\n"
  },
  {
    "path": "source/background/services/storage.ts",
    "content": "import { log } from \"./log.js\";\nimport { BrowserStorageInterface, getNonSyncStorage, getSyncStorage } from \"./storage/BrowserStorageInterface.js\";\nimport { LocalStorageItem, SyncStorageItem } from \"../types.js\";\n\nconst VALID_LOCAL_KEYS = Object.values(LocalStorageItem);\nconst VALID_SYNC_KEYS = Object.values(SyncStorageItem);\n\nexport async function clearLocalStorage(): Promise<void> {\n    const localStorage = getLocalStorage();\n    const syncStorage = getSynchronisedStorage();\n    const keys = await localStorage.getAllKeys();\n    for (const key of keys) {\n        log(`clearing local storage key: ${key}`);\n        await localStorage.removeKey(key);\n    }\n    await syncStorage.removeKey(SyncStorageItem.Notifications);\n}\n\nfunction getLocalStorage(): BrowserStorageInterface {\n    return new BrowserStorageInterface(getNonSyncStorage());\n}\n\nexport async function getLocalValue(key: LocalStorageItem): Promise<string | null> {\n    return getLocalStorage().getValue(key) ?? null;\n}\n\nexport async function getSyncValue(key: SyncStorageItem): Promise<string | null> {\n    return getSynchronisedStorage().getValue(key) ?? null;\n}\n\nfunction getSynchronisedStorage(): BrowserStorageInterface {\n    return new BrowserStorageInterface(getSyncStorage());\n}\n\nexport async function initialise() {\n    const localStorage = getLocalStorage();\n    {\n        const keys = await localStorage.getAllKeys();\n        for (const key of keys) {\n            const valid = VALID_LOCAL_KEYS.find((local) => key === local || key.indexOf(local) === 0);\n            if (!valid) {\n                log(`remove unrecognised local storage key: ${key}`);\n                await localStorage.removeKey(key);\n            }\n        }\n    }\n    const syncStorage = getSynchronisedStorage();\n    {\n        const keys = await syncStorage.getAllKeys();\n        for (const key of keys) {\n            const valid = VALID_SYNC_KEYS.find((local) => key === local || key.indexOf(local) === 0);\n            if (!valid) {\n                log(`remove unrecognised sync storage key: ${key}`);\n                await syncStorage.removeKey(key);\n            }\n        }\n    }\n}\n\nexport async function removeLocalValue(key: LocalStorageItem): Promise<void> {\n    await getLocalStorage().removeKey(key);\n}\n\nexport async function removeSyncValue(key: SyncStorageItem): Promise<void> {\n    await getSynchronisedStorage().removeKey(key);\n}\n\nexport async function setLocalValue(key: LocalStorageItem, value: string): Promise<void> {\n    return getLocalStorage().setValue(key, value);\n}\n\nexport async function setSyncValue(key: SyncStorageItem, value: string): Promise<void> {\n    return getSynchronisedStorage().setValue(key, value);\n}\n"
  },
  {
    "path": "source/background/services/tabs.ts",
    "content": "import { getExtensionAPI } from \"../../shared/extension.js\";\nimport { TabEvent } from \"../types.js\";\n\nexport async function sendTabsMessage(payload: TabEvent, tabIDs: Array<number> | null = null): Promise<void> {\n    const browser = getExtensionAPI();\n    const targetTabIDs = Array.isArray(tabIDs)\n        ? tabIDs\n        : (\n              await browser.tabs.query({\n                  status: \"complete\"\n              })\n          ).reduce((output: Array<number>, tab) => {\n              if (!tab.id) return output;\n              return [...output, tab.id];\n          }, []);\n    await Promise.all(\n        targetTabIDs.map(async (tabID) => {\n            browser.tabs.sendMessage(tabID, payload);\n        })\n    );\n}\n"
  },
  {
    "path": "source/background/types.ts",
    "content": "export * from \"../shared/types.js\";\n\nexport enum LocalStorageItem {\n    APIClientID = \"bcup:api:clientID\",\n    APIPrivateKey = \"bcup:api:privateKey\",\n    APIPublicKey = \"bcup:api:publicKey\",\n    APIServerPublicKey = \"bcup:api:serverPublicKey\"\n}\n\nexport enum SyncStorageItem {\n    Configuration = \"bcup:configuration\",\n    DisabledDomains = \"bcup:disabledDomains\",\n    Notifications = \"bcup:notifications\",\n    RecentItems = \"bcup:recents\"\n}\n"
  },
  {
    "path": "source/full/applications/full.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { App } from \"../components/App.js\";\nimport { initialise } from \"../services/init.js\";\n\ninitialise()\n    .then(() => {\n        ReactDOM.render(\n            <App />,\n            document.getElementById(\"root\"),\n        );\n    })\n    .catch(err => {\n        console.error(err);\n    });\n"
  },
  {
    "path": "source/full/components/App.tsx",
    "content": "import React from \"react\";\nimport {\n    createHashRouter,\n    RouterProvider\n} from \"react-router-dom\";\nimport { ThemeProvider } from \"../../shared/components/ThemeProvider.jsx\";\nimport { ConnectPage } from \"./pages/connect/index.jsx\";\nimport { AttributionsPage } from \"./pages/AttributionsPage.jsx\";\nimport { SaveCredentialsPage } from \"./pages/saveCredentials/index.js\";\nimport { useBodyThemeClass, useTheme } from \"../../shared/hooks/theme.js\";\nimport { RouteError } from \"../../shared/components/RouteError.js\";\nimport { DisabledDomainsPage } from \"./pages/DisabledDomainsPage.js\";\nimport { NotificationsPage } from \"./pages/NotificationsPage.js\";\n\nconst ROUTER = createHashRouter([\n    {\n        path: \"/connect\",\n        element: <ConnectPage />,\n        errorElement: <RouteError />\n    },\n    {\n        path: \"/attributions\",\n        element: <AttributionsPage />,\n        errorElement: <RouteError />\n    },\n    {\n        path: \"/disabled-domains\",\n        element: <DisabledDomainsPage />,\n        errorElement: <RouteError />\n    },\n    {\n        path: \"/notifications\",\n        element: <NotificationsPage />,\n        errorElement: <RouteError />,\n        loader: ({ request }) => {\n            const url = new URL(request.url);\n            const notifications = url.searchParams.get(\"notifications\");\n            return { notifications };\n        }\n    },\n    {\n        path: \"/save-credentials\",\n        element: <SaveCredentialsPage />,\n        errorElement: <RouteError />\n    }\n]);\n\nexport function App() {\n    const theme = useTheme();\n    useBodyThemeClass(theme);\n    return (\n        <ThemeProvider darkMode={theme === \"dark\"}>\n            <RouterProvider router={ROUTER} />\n        </ThemeProvider>\n    );\n}\n"
  },
  {
    "path": "source/full/components/Layout.tsx",
    "content": "import React from \"react\";\nimport styled from \"styled-components\";\nimport { Divider } from \"@blueprintjs/core\";\nimport { ChildElements } from \"../types.js\";\n\nimport BUTTERCUP_LOGO from \"../../../resources/buttercup-128.png\";\n\ninterface LayoutProps {\n    children: ChildElements;\n    title: string;\n}\n\nconst ContentContainer = styled.div`\n    padding: 1rem;\n`;\nconst Header = styled.div`\n    margin: 0.5rem 1rem 0;\n    padding: 0.3rem 0;\n    display: flex;\n    justify-content: flex-start;\n    align-items: center;\n`;\nconst MainContent = styled.div`\n    width: 100vw;\n    min-height: 100vh;\n    padding: 3rem 0;\n`;\nconst Title = styled.h1`\n    margin: 0px 0px 4px 0px;\n    padding: 0;\n    font-size: 18px;\n    flex: 1;\n`;\nconst TitleImage = styled.img`\n    width: 28px;\n    height: 28px;\n    margin-bottom: 3px;\n    margin-right: 6px;\n`;\nconst Wrapper = styled.div`\n    width: 680px;\n    margin: 0 auto;\n    display: flex;\n    flex-direction: column;\n    @media screen and (max-width: 700px) {\n        width: 100%;\n    }\n`;\n\nexport function Layout({ children, title }: LayoutProps) {\n    return (\n        <MainContent>\n            <Wrapper>\n                <Header>\n                    <TitleImage src={BUTTERCUP_LOGO} />\n                    <Title>{title}</Title>\n                </Header>\n                <Divider />\n                <ContentContainer>{children}</ContentContainer>\n            </Wrapper>\n        </MainContent>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/AttributionsPage.tsx",
    "content": "import React from \"react\";\nimport styled from \"styled-components\";\nimport { Layout } from \"../Layout.js\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { useTitle } from \"../../hooks/document.js\";\nimport COMPUTER_ICON from \"../../../../resources/providers/local-256.png\";\n\nconst AttributionLI = styled.li`\n    display: flex;\n    flex-direction: row;\n    align-items: center;\n`;\nconst ImageIcon = styled.img`\n    width: auto;\n    height: 32px;\n    margin-right: 12px;\n`;\n\nexport function AttributionsPage() {\n    useTitle(t(\"attributions-page.title\"));\n    return (\n        <Layout title={t(\"attributions-page.title\")}>\n            <p>Buttercup is Open Source Software and makes use of many free and openly available libraries and resources.</p>\n            <p>Below are a list of resource attributions that this browser extension makes use of.</p>\n            <ul>\n                <AttributionLI>\n                    <ImageIcon src={COMPUTER_ICON} />\n                    <a target=\"_blank\" href=\"https://www.flaticon.com/free-icons/computer\" title=\"computer icons\">Computer icons created by Freepik - Flaticon</a>\n                </AttributionLI>\n            </ul>\n        </Layout>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/DisabledDomainsPage.tsx",
    "content": "import React, { Fragment, useCallback, useState } from \"react\";\nimport cn from \"classnames\";\nimport styled from \"styled-components\";\nimport { Button, Classes, Intent, NonIdealState, Spinner } from \"@blueprintjs/core\";\nimport { useTitle } from \"../../hooks/document.js\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { Layout } from \"../Layout.js\";\nimport { useDisabledDomains } from \"../../hooks/disabledDomains.js\";\nimport { ErrorMessage } from \"../../../shared/components/ErrorMessage.js\";\nimport { ConfirmDialog } from \"../../../shared/components/ConfirmDialog.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\nimport { removeDisabledDomain } from \"../../services/disabledDomains.js\";\n\nconst ActionCell = styled.td`\n    vertical-align: middle !important;\n`;\nconst CenteredContent = styled.div`\n    width: 100%;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n`;\nconst LoaderContainer = styled.div`\n    width: 100%;\n    display: flex;\n    flex-direction: row;\n    justify-content: center;\n    padding: 20px 0px;\n`;\nconst Table = styled.table`\n    min-width: 80%;\n    table-layout: fixed;\n`;\n\nexport function DisabledDomainsPage() {\n    useTitle(t(\"disabled-domains-page.title\"));\n    const [reloadCount, setReloadCount] = useState<number>(0);\n    const [domains, loading, error] = useDisabledDomains([reloadCount]);\n    const [removeDomain, setRemoveDomain] = useState<string | null>(null);\n    const handleDomainRemove = useCallback(async () => {\n        if (!removeDomain) return;\n        try {\n            removeDisabledDomain(removeDomain);\n            setReloadCount(count => count += 1);\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.generic\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n        setRemoveDomain(null);\n    }, [removeDomain]);\n    return (\n        <Layout title={t(\"disabled-domains-page.title\")}>\n            <p dangerouslySetInnerHTML={{ __html: t(\"disabled-domains-page.description\") }} />\n            <h3>{t(\"disabled-domains-page.disabled-domains.heading\")}</h3>\n            {error && (\n                <ErrorMessage message={error.message} scroll={false} />\n            )}\n            {loading && (\n                <LoaderContainer>\n                    <Spinner size={60} />\n                </LoaderContainer>\n            )}\n            {!error && !loading && Array.isArray(domains) && (\n                <Fragment>\n                    {domains.length > 0 && (\n                        <CenteredContent>\n                            <Table className={cn(Classes.HTML_TABLE, Classes.HTML_TABLE_STRIPED)}>\n                                <thead>\n                                    <tr>\n                                        <th>{t(\"disabled-domains-page.table.domain-heading\")}</th>\n                                        <th>{t(\"disabled-domains-page.table.action-heading\")}</th>\n                                    </tr>\n                                </thead>\n                                <tbody>\n                                    {domains.map((domain, ind) => (\n                                        <tr key={`${domain}-${ind}`}>\n                                            <td>\n                                                <pre>{domain}</pre>\n                                            </td>\n                                            <ActionCell>\n                                                <Button\n                                                    icon=\"delete\"\n                                                    intent={Intent.DANGER}\n                                                    minimal\n                                                    onClick={() => setRemoveDomain(domain)}\n                                                    title={t(\"disabled-domains-page.table.action.delete\")}\n                                                />\n                                            </ActionCell>\n                                        </tr>\n                                    ))}\n                                </tbody>\n                            </Table>\n                        </CenteredContent>\n                    ) || (\n                        <NonIdealState\n                            description={t(\"disabled-domains-page.table.empty-description\")}\n                            icon=\"exclude-row\"\n                            title={t(\"disabled-domains-page.table.empty-title\")}\n                        />\n                    )}\n                </Fragment>\n            )}\n            <ConfirmDialog\n                confirmIntent={Intent.DANGER}\n                confirmText={t(\"disabled-domains-page.delete-dialog.confirm\")}\n                icon=\"delete\"\n                isOpen={!!removeDomain}\n                onClose={() => setRemoveDomain(null)}\n                onConfirm={handleDomainRemove}\n                title={t(\"disabled-domains-page.delete-dialog.title\")}\n            >\n                <span dangerouslySetInnerHTML={{\n                    __html: t(\"disabled-domains-page.delete-dialog.description\").replace(\"{{domain}}\", removeDomain ?? \"\")\n                }} />\n            </ConfirmDialog>\n        </Layout>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/NotificationsPage.tsx",
    "content": "import React, { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useLoaderData } from \"react-router-dom\";\nimport { Icon, Intent, Tab, Tabs } from \"@blueprintjs/core\";\nimport { NOTIFICATIONS } from \"../../../shared/notifications/index.js\";\nimport { Layout } from \"../Layout.js\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { updateReadNotifications } from \"../../services/notifications.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\n\nexport function NotificationsPage() {\n    const { notifications: notificationsRaw = \"\" } = useLoaderData() as {\n        notifications: string\n    };\n    const notificationKeys = useMemo(() => notificationsRaw.split(\",\"), [notificationsRaw]);\n    const notifications = useMemo(() => notificationKeys.map(key => NOTIFICATIONS[key]), [notificationKeys]);\n    const [currentTab, setCurrentTab] = useState<string | null>(null);\n    const [readNotifications, setReadNotifications] = useState<Array<string>>([]);\n    const handleTabChange = useCallback((newTabID: string) => {\n        setReadNotifications(current => [...new Set([\n            ...current,\n            newTabID\n        ])]);\n        setCurrentTab(newTabID);\n        const key = Object.keys(NOTIFICATIONS).find(nKey => NOTIFICATIONS[nKey][0] === newTabID);\n        if (!key) return;\n        updateReadNotifications(key).catch(err => {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.generic\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        });\n    }, []);\n    useEffect(() => {\n        if (currentTab || notifications.length <= 0) return;\n        handleTabChange(notifications[0][0]);\n    }, [currentTab, handleTabChange, notifications]);\n    return (\n        <Layout title={t(\"notifications.title\")}>\n            <Tabs onChange={handleTabChange} selectedTabId={currentTab ?? undefined}>\n                {notifications.map(([nameKey, Component]) => (\n                    <Tab\n                        key={nameKey}\n                        id={nameKey}\n                        title={(\n                            <span>\n                                <Icon icon={readNotifications.includes(nameKey) ? \"notifications-updated\" : \"notifications\"} />&nbsp;\n                                {t(nameKey)}\n                            </span>\n                        )}\n                        panel={<Component />}\n                    />\n                ))}\n            </Tabs>\n        </Layout>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/connect/CodeInput.tsx",
    "content": "import React, { KeyboardEvent, useCallback } from \"react\";\nimport { Button, ControlGroup, InputGroup, Intent } from \"@blueprintjs/core\";\nimport styled from \"styled-components\";\nimport { t } from \"../../../../shared/i18n/trans.js\";\n\ninterface CodeInputProps {\n    authenticating: boolean;\n    onChange: (newValue: string) => void;\n    onSubmit: () => void;\n    value: string;\n}\n\nconst Container = styled.div`\n    width: 100%;\n    margin-top: 32px;\n    display: flex;\n    flex-direction: row;\n    justify-content: center;\n    align-items: center;\n`;\n\nexport function CodeInput(props: CodeInputProps) {\n    const handleKeyPress = useCallback((event: KeyboardEvent<HTMLInputElement>) => {\n        if ((event.key === \"Enter\" || event.keyCode === 13) && !event.ctrlKey && !event.shiftKey) {\n            props.onSubmit();\n        }\n    }, [props.onSubmit]);\n    return (\n        <Container>\n            <ControlGroup>\n                <InputGroup\n                    autoFocus\n                    disabled={props.authenticating}\n                    leftIcon=\"key\"\n                    large\n                    onChange={evt => props.onChange(evt.target.value)}\n                    onKeyDown={handleKeyPress}\n                    placeholder={t(\"connect-page.code-plc\")}\n                    type=\"password\"\n                    value={props.value}\n                />\n                <Button\n                    icon=\"arrow-right\"\n                    intent={Intent.PRIMARY}\n                    loading={props.authenticating}\n                    onClick={() => props.onSubmit()}\n                    minimal\n                />\n            </ControlGroup>\n        </Container>\n    )\n}\n"
  },
  {
    "path": "source/full/components/pages/connect/ConnectPage.tsx",
    "content": "import React, { useCallback, useState } from \"react\";\nimport { Intent } from \"@blueprintjs/core\";\nimport { Layout } from \"../../Layout.js\";\nimport { t } from \"../../../../shared/i18n/trans.js\";\nimport { CodeInput } from \"./CodeInput.js\";\nimport { sendBackgroundMessage } from \"../../../../shared/services/messaging.js\";\nimport { getToaster } from \"../../../../shared/services/notifications.js\";\nimport { closeCurrentTab } from \"../../../../shared/library/extension.js\";\nimport { localisedErrorMessage } from \"../../../../shared/library/error.js\";\nimport { BackgroundMessageType } from \"../../../types.js\";\n\nexport function ConnectPage() {\n    const [code, setCode] = useState<string>(\"\");\n    const [authenticating, setAuthenticating] = useState<boolean>(false);\n    const handleSubmitCode = useCallback(async () => {\n        if (!code) return;\n        setAuthenticating(true);\n        sendBackgroundMessage({ type: BackgroundMessageType.AuthenticateDesktopConnection, code })\n            .then(() => {\n                getToaster().show({\n                    intent: Intent.SUCCESS,\n                    message: t(\"connect-page.auth-success\"),\n                    timeout: 3000\n                });\n                setTimeout(() => {\n                    closeCurrentTab();\n                }, 3000);\n            })\n            .catch(err => {\n                console.error(err);\n                getToaster().show({\n                    intent: Intent.DANGER,\n                    message: t(\"connect-page.auth-error\", { message: localisedErrorMessage(err) }),\n                    timeout: 10000\n                });\n                setAuthenticating(false);\n            });\n    }, [code]);\n    return (\n        <Layout title={t(\"connect-page.title\")}>\n            <p>{t(\"connect-page.description\")}</p>\n            <p>{t(\"connect-page.instruction\")}</p>\n            <CodeInput\n                authenticating={authenticating}\n                onChange={setCode}\n                onSubmit={handleSubmitCode}\n                value={code}\n            />\n        </Layout>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/connect/index.tsx",
    "content": "import React from \"react\";\nimport { ConnectPage as InternalPage } from \"./ConnectPage.js\";\nimport { ErrorBoundary } from \"../../../../shared/components/ErrorBoundary.jsx\";\n\nexport function ConnectPage() {\n    return (\n        <ErrorBoundary>\n            <InternalPage />\n        </ErrorBoundary>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/saveCredentials/CredentialsSaver.tsx",
    "content": "import React, { Fragment, useCallback, useMemo, useState } from \"react\";\nimport { ITreeNode, NonIdealState, Tree, TreeNodeInfo } from \"@blueprintjs/core\";\nimport { EntryPropertyType, VaultFacade, VaultSourceID, fieldsToProperties } from \"buttercup\";\nimport { SiteIcon } from \"@buttercup/ui\";\nimport styled from \"styled-components\";\nimport { t } from \"../../../../shared/i18n/trans.js\";\nimport { useAllVaultsContents } from \"../../../hooks/vaultContents.js\";\nimport { BusyLoader } from \"../../../../shared/components/loading/BusyLoader.js\";\nimport { ErrorMessage } from \"../../../../shared/components/ErrorMessage.js\";\nimport { NewEntrySavePrompt } from \"./NewEntrySavePrompt.js\";\nimport { useCapturedCredentials } from \"../../../hooks/credentials.js\";\nimport { extractEntryDomain } from \"../../../../shared/library/domain.js\";\nimport { SavedCredentials, UsedCredentials, VaultsTree } from \"../../../types.js\";\n\ninterface CredentialsSaverProps {\n    mode: \"existing\" | \"new\";\n    onSaveNewClick: (credentials: SavedCredentials) => void;\n    saving: boolean;\n    selected: string;\n}\n\ninterface NodeInfo {\n    id: string;\n    type: \"group\" | \"entry\";\n}\n\nconst EntryTreeIcon = styled(SiteIcon)`\n    width: 18px;\n    height: 18px;\n    margin-right: 5px;\n\n    > img {\n        width: 100%;\n        height: 100%;\n    }\n`;\n\nfunction buildGroupNodes(\n    sourceID: VaultSourceID,\n    vault: VaultFacade,\n    parentGroupID: string | null,\n    expanded: Array<string>,\n    selected: Array<string>,\n    mode: \"existing\" | \"new\"\n): Array<TreeNodeInfo<NodeInfo>> {\n    const output = vault.groups.reduce((output: Array<TreeNodeInfo<NodeInfo>>, group) => {\n        const groupParent = group.parentID === \"0\" ? null : group.parentID;\n        if (groupParent !== parentGroupID) return output;\n        const id = `group:${sourceID}:${group.id}`;\n        const newNode: TreeNodeInfo<NodeInfo> = {\n            childNodes: buildGroupNodes(sourceID, vault, group.id, expanded, selected, mode),\n            hasCaret: countGroupChildren(vault, group.id, mode === \"existing\") > 0,\n            icon: expanded.includes(id) ? \"folder-open\" : \"folder-close\",\n            id,\n            isExpanded: expanded.includes(id),\n            isSelected: selected.includes(id),\n            label: group.title,\n            nodeData: {\n                id,\n                type: \"group\"\n            }\n        };\n        return [\n            ...output,\n            newNode\n        ];\n    }, []);\n    if (mode === \"existing\") {\n        // Add entries\n        output.push(...vault.entries.reduce((output: Array<TreeNodeInfo<NodeInfo>>, entry) => {\n            if (entry.parentID !== parentGroupID) return output;\n            const id = `entry:${sourceID}:${parentGroupID}:${entry.id}`;\n            const titleField = entry.fields.find(field => field.propertyType === EntryPropertyType.Property && field.property === \"title\");\n            const title = titleField?.value ?? \"(Untitled)\";\n            const domain = extractEntryDomain(fieldsToProperties(entry.fields));\n            const newNode: TreeNodeInfo<NodeInfo> = {\n                childNodes: [],\n                hasCaret: false,\n                icon: (\n                    <EntryTreeIcon\n                        domain={domain}\n                        type={entry.type}\n                    />\n                ),\n                id,\n                isExpanded: false,\n                isSelected: selected.includes(id),\n                label: title,\n                nodeData: {\n                    id,\n                    type: \"entry\"\n                }\n            };\n            return [\n                ...output,\n                newNode\n            ];\n        }, []))\n    }\n    return output;\n}\n\nfunction buildVaultRootNodes(\n    vaultTree: VaultsTree,\n    expanded: Array<string>,\n    selected: Array<string>,\n    mode: \"existing\" | \"new\"\n): Array<TreeNodeInfo<NodeInfo>> {\n    return Object.keys(vaultTree).map(sourceID => ({\n        childNodes: buildGroupNodes(sourceID, vaultTree[sourceID], null, expanded, selected, mode),\n        icon: \"box\",\n        id: `source:${sourceID}`,\n        isExpanded: expanded.includes(`source:${sourceID}`),\n        label: vaultTree[sourceID].name,\n        nodeData: {\n            id: `source:${sourceID}`,\n            type: \"group\"\n        }\n    }));\n}\n\nfunction countGroupChildren(\n    vault: VaultFacade,\n    parentGroupID: string | null,\n    includeEntries: boolean\n): number {\n    let total = vault.groups.reduce((output: number, group) => {\n        const groupParent = group.parentID === \"0\" ? null : group.parentID;\n        return groupParent === parentGroupID ? output + 1 : output;\n    }, 0);\n    if (includeEntries) {\n        total += vault.entries.reduce((output: number, entry) => {\n            return entry.parentID === parentGroupID ? output + 1 : output;\n        }, 0);\n    }\n    return total;\n}\n\nexport function CredentialsSaver(props: CredentialsSaverProps) {\n    const { mode, onSaveNewClick, saving, selected: selectedCredentialsID } = props;\n    const {\n        error: contentsError,\n        loading: contentsLoading,\n        tree\n    } = useAllVaultsContents();\n    const [credentials, credentialsLoading, credentialsError] = useCapturedCredentials();\n    const [selectedNodes, setSelectedNodes] = useState<Array<string>>([]);\n    const [expandedNodes, setExpandedNodes] = useState<Array<string>>([]);\n    const selectedUsedCredentials = useMemo(() => credentials.find(cred => cred?.id === selectedCredentialsID), [credentials, selectedCredentialsID]);\n    const selectedGroupURI = useMemo(() => {\n        return selectedNodes.length === 1 && /^group:/.test(selectedNodes[0])\n            ? selectedNodes[0]\n            : null;\n    }, [selectedNodes]);\n    const selectedEntryURI = useMemo(() => {\n        return selectedNodes.length === 1 && /^entry:/.test(selectedNodes[0])\n            ? selectedNodes[0]\n            : null;\n    }, [selectedNodes]);\n    const contents = useMemo(\n        () => tree ? buildVaultRootNodes(tree, expandedNodes, selectedNodes, mode) : [],\n        [expandedNodes, mode, selectedNodes, tree]\n    );\n    const handleNodeClick = useCallback((node: TreeNodeInfo<NodeInfo>) => {\n        if (saving) return;\n        if (mode === \"existing\") {\n            if (node.nodeData?.type === \"entry\") {\n                // Only select entries\n                setSelectedNodes([node.nodeData.id]);\n            }\n        } else if (mode === \"new\" && node.nodeData) {\n            // Groups only, select immediately\n            setSelectedNodes([node.nodeData.id]);\n        }\n    }, [mode, saving]);\n    const handleSaveClick = useCallback((credentials: UsedCredentials) => {\n        if (mode === \"new\" && selectedGroupURI) {\n            const [, sourceID, groupID] = selectedGroupURI.split(\":\");\n            onSaveNewClick({\n                ...credentials,\n                groupID,\n                sourceID\n            });\n        } else if (mode === \"existing\" && selectedEntryURI) {\n            const [, sourceID, groupID, entryID] = selectedEntryURI.split(\":\");\n            onSaveNewClick({\n                ...credentials,\n                groupID,\n                sourceID,\n                entryID\n            });\n        }\n    }, [mode, onSaveNewClick, selectedEntryURI, selectedGroupURI]);\n    return (\n        <div>\n            {contentsError && (\n                <ErrorMessage message={contentsError.message} scroll={false} />\n            )}\n            {credentialsError && (\n                <ErrorMessage message={credentialsError.message} scroll={false} />\n            )}\n            {(contentsLoading || credentialsLoading) && (\n                <BusyLoader\n                    title={t(\"save-credentials-page.credentials-saver.create-new.loader.title\")}\n                    description={t(\"save-credentials-page.credentials-saver.create-new.loader.description\")}\n                />\n            )}\n            {tree && (\n                <Fragment>\n                    {contents.length > 0 && (\n                        <Tree\n                            contents={contents}\n                            onNodeClick={handleNodeClick}\n                            onNodeCollapse={node => setExpandedNodes(\n                                current => current.filter(id => id !== node.nodeData?.id)\n                            )}\n                            onNodeExpand={node => {\n                                if (!node.nodeData) return;\n                                setExpandedNodes(current => [\n                                    ...current,\n                                    (node.nodeData as NodeInfo).id\n                                ]);\n                            }}\n                        />\n                    ) || (\n                        <NonIdealState\n                            icon=\"inbox\"\n                            title={t(\"save-credentials-page.credentials-saver.no-vaults.title\")}\n                            description={t(\"save-credentials-page.credentials-saver.no-vaults.description\")}\n                        />\n                    )}\n                    {mode === \"new\" && contents.length > 0 && selectedGroupURI && selectedUsedCredentials && (\n                        <NewEntrySavePrompt\n                            credentials={selectedUsedCredentials}\n                            onSaveClick={handleSaveClick}\n                            saving={saving}\n                        />\n                    )}\n                    {mode === \"existing\" && contents.length > 0 && selectedEntryURI && selectedUsedCredentials && (\n                        <NewEntrySavePrompt\n                            credentials={selectedUsedCredentials}\n                            onSaveClick={handleSaveClick}\n                            saving={saving}\n                        />\n                    )}\n                </Fragment>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/saveCredentials/CredentialsSelector.tsx",
    "content": "import React, { Fragment, useCallback, useMemo } from \"react\";\nimport styled from \"styled-components\";\nimport { Card, Elevation } from \"@blueprintjs/core\";\nimport { SiteIcon } from \"@buttercup/ui\";\nimport { EntryType } from \"buttercup\";\nimport { useCapturedCredentials } from \"../../../hooks/credentials.js\";\nimport { extractDomain } from \"../../../../shared/library/domain.js\";\nimport { ErrorMessage } from \"../../../../shared/components/ErrorMessage.js\";\nimport { UsedCredentials } from \"../../../types.js\";\n\ninterface CredentialsSelectorProps {\n    disabled?: boolean;\n    onSelect: (id: string) => void;\n    selected: string | null;\n}\n\nconst Credential = styled.h5`\n    margin: 0;\n    ${p => p.monospace ? \"font-family: monospace;\" : \"\"}\n\n    &:not(:last-child) {\n        margin-bottom: 4px;\n    }\n`;\nconst CredentialsCard = styled(Card)`\n    min-width: 280px;\n    padding: 10px;\n\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n\n    &:not(:last-child) {\n        margin-right: 8px;\n    }\n`;\nconst CredentialsHeading = styled.h4`\n    margin: 0 0 5px 0;\n    display: flex;\n    flex-direction: row;\n    justify-content: flex-start;\n    align-items: center;\n`;\nconst HorizontalScroller = styled.div`\n    width: 100%;\n    overflow-y: hidden;\n    overflow-x: scroll;\n    display: flex;\n    flex-direction: row;\n    justify-content: flex-start;\n    align-items: stretch;\n    padding: 12px;\n`;\nconst CredentialsIcon = styled(SiteIcon)`\n    width: 24px;\n    height: 24px;\n    margin-right: 6px;\n\n    > img {\n        width: 100%;\n        height: 100%;\n    }\n`;\nconst URL = styled.span`\n    font-size: 12px;\n`;\n\nexport function CredentialsSelector(props: CredentialsSelectorProps) {\n    const { disabled: parentDisabled = false, onSelect, selected } = props;\n    const [credentialsInitial, loading, error] = useCapturedCredentials();\n    const credentials = useMemo(() => credentialsInitial.filter(cred => !!cred) as Array<UsedCredentials>, [credentialsInitial]);\n    const disabled = parentDisabled || loading;\n    const handleItemClick = useCallback((credential: UsedCredentials) => {\n        if (disabled) return;\n        onSelect(credential.id);\n    }, [disabled, onSelect]);\n    const credentialDomains = useMemo(\n        () => credentials.map(cred => extractDomain(cred.url)),\n        [credentials]\n    );\n    return (\n        <Fragment>\n            {error && (\n                <ErrorMessage message={error.message} scroll={false} />\n            )}\n            <HorizontalScroller>\n                {credentials.map((cred, ind) => (\n                    <CredentialsCard\n                        disabled={disabled}\n                        key={cred.id}\n                        interactive={cred.id !== selected}\n                        elevation={cred.id === selected ? Elevation.ZERO : Elevation.THREE}\n                        onClick={() => handleItemClick(cred)}\n                    >\n                        <CredentialsHeading>\n                            <CredentialsIcon\n                                domain={credentialDomains[ind]}\n                                type={EntryType.Website}\n                            />\n                            <span>{cred.title}</span>\n                        </CredentialsHeading>\n                        <Credential monospace>{cred.username}</Credential>\n                        <Credential monospace><code>*********</code></Credential>\n                        <URL>{cred.url}</URL>\n                    </CredentialsCard>\n                ))}\n            </HorizontalScroller>\n        </Fragment>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/saveCredentials/NewEntrySavePrompt.tsx",
    "content": "import React, { Fragment, useCallback, useEffect, useState } from \"react\";\nimport { Button, Classes, Colors, FormGroup, InputGroup, Intent } from \"@blueprintjs/core\";\nimport { Tooltip2 as Tooltip } from \"@blueprintjs/popover2\";\nimport styled from \"styled-components\";\nimport { GroupID, VaultSourceID } from \"buttercup\";\nimport { t } from \"../../../../shared/i18n/trans.js\";\nimport { UsedCredentials } from \"../../../types.js\";\n\ninterface NewEntrySavePromptProps {\n    credentials: UsedCredentials;\n    onSaveClick: (credentials: UsedCredentials) => void;\n    saving: boolean;\n}\n\nconst Form = styled.div`\n    width: 100%;\n\n    .${Classes.FORM_GROUP} {\n        justify-content: space-between;\n    }\n\n    .${Classes.LABEL} {\n        flex: 0 0 auto;\n        width: 25%;\n        min-width: 150px;\n    }\n\n    .${Classes.FORM_CONTENT} {\n        flex: 1 1 auto;\n    }\n`;\n\nconst ValidityHelper = styled.span`\n    color: ${Colors.RED2};\n`;\n\nfunction isValidInput(input: string): boolean {\n    return input.trim().length > 0;\n}\n\nexport function NewEntrySavePrompt(props: NewEntrySavePromptProps) {\n    const { credentials, onSaveClick, saving } = props;\n    const [title, setTitle] = useState<string>(\"\");\n    const [username, setUsername] = useState<string>(\"\");\n    const [password, setPassword] = useState<string>(\"\");\n    const [url, setURL] = useState<string>(\"\");\n    const [showPassword, setShowPassword] = useState<boolean>(false);\n    const [invalidInput, setInvalidInput] = useState<string | null>(null);\n    useEffect(() => {\n        setShowPassword(false);\n        setTitle(credentials.title);\n        setUsername(credentials.username);\n        setPassword(credentials.password);\n        setURL(credentials.url);\n    }, [credentials]);\n    const handleSaveClick = useCallback(() => {\n        if (!isValidInput(title)) {\n            setInvalidInput(\"title\")\n            return;\n        } else if (!isValidInput(username)) {\n            setInvalidInput(\"username\")\n            return;\n        } else if (!isValidInput(password)) {\n            setInvalidInput(\"password\")\n            return;\n        } else if (!isValidInput(url)) {\n            setInvalidInput(\"url\")\n            return;\n        }\n        onSaveClick({\n            ...credentials,\n            title,\n            username,\n            password,\n            url\n        });\n    }, [credentials, onSaveClick, password, title, url, username]);\n    return (\n        <Fragment>\n            <h4>{t(\"save-credentials-page.credentials-saver.create-new.heading\")}</h4>\n            <Form>\n                <FormGroup\n                    disabled={saving}\n                    helperText={invalidInput === \"title\" && (\n                        <ValidityHelper>\n                            {t(\"form.invalid.required-non-empty\")}\n                        </ValidityHelper>\n                    )}\n                    inline\n                    label={t(\"save-credentials-page.credentials-saver.create-new.label.title\")}\n                    labelFor=\"entry-title\"\n                    labelInfo={t(\"form.required\")}\n                >\n                    <InputGroup\n                        disabled={saving}\n                        id=\"entry-title\"\n                        onChange={evt => setTitle(evt.target.value)}\n                        placeholder={t(\"save-credentials-page.credentials-saver.create-new.placeholder.title\")}\n                        value={title}\n                    />\n                </FormGroup>\n                <FormGroup\n                    disabled={saving}\n                    helperText={invalidInput === \"username\" && (\n                        <ValidityHelper>\n                            {t(\"form.invalid.required-non-empty\")}\n                        </ValidityHelper>\n                    )}\n                    inline\n                    label={t(\"save-credentials-page.credentials-saver.create-new.label.username\")}\n                    labelFor=\"entry-username\"\n                    labelInfo={t(\"form.required\")}\n                >\n                    <InputGroup\n                        disabled={saving}\n                        id=\"entry-username\"\n                        onChange={evt => setUsername(evt.target.value)}\n                        placeholder={t(\"save-credentials-page.credentials-saver.create-new.placeholder.username\")}\n                        value={username}\n                    />\n                </FormGroup>\n                <FormGroup\n                    disabled={saving}\n                    helperText={invalidInput === \"password\" && (\n                        <ValidityHelper>\n                            {t(\"form.invalid.required-non-empty\")}\n                        </ValidityHelper>\n                    )}\n                    inline\n                    label={t(\"save-credentials-page.credentials-saver.create-new.label.password\")}\n                    labelFor=\"entry-password\"\n                    labelInfo={t(\"form.required\")}\n                >\n                    <InputGroup\n                        disabled={saving}\n                        id=\"entry-password\"\n                        onChange={evt => setPassword(evt.target.value)}\n                        rightElement={\n                            <Tooltip\n                                content={t(`save-credentials-page.credentials-saver.create-new.password.${showPassword ? \"hide\" : \"show\"}`)}\n                            >\n                                <Button\n                                    icon={showPassword ? \"unlock\" : \"lock\"}\n                                    intent={Intent.WARNING}\n                                    minimal={true}\n                                    onClick={() => setShowPassword(show => !show)}\n                                />\n                            </Tooltip>\n                        }\n                        type={showPassword ? \"text\" : \"password\"}\n                        value={password}\n                    />\n                </FormGroup>\n                <FormGroup\n                    disabled={saving}\n                    helperText={invalidInput === \"url\" && (\n                        <ValidityHelper>\n                            {t(\"form.invalid.required-non-empty\")}\n                        </ValidityHelper>\n                    )}\n                    inline\n                    label={t(\"save-credentials-page.credentials-saver.create-new.label.url\")}\n                    labelFor=\"entry-url\"\n                    labelInfo={t(\"form.required\")}\n                >\n                    <InputGroup\n                        disabled={saving}\n                        fill\n                        id=\"entry-url\"\n                        onChange={evt => setURL(evt.target.value)}\n                        placeholder={t(\"save-credentials-page.credentials-saver.create-new.placeholder.url\")}\n                        value={url}\n                    />\n                </FormGroup>\n            </Form>\n            <Button\n                loading={saving}\n                intent={Intent.SUCCESS}\n                onClick={handleSaveClick}\n                text={t(\"save-credentials-page.credentials-saver.create-new.save\")}\n            />\n        </Fragment>\n    );\n}\n"
  },
  {
    "path": "source/full/components/pages/saveCredentials/index.tsx",
    "content": "import React, { Fragment, useCallback, useState } from \"react\";\nimport { Intent, Tab, Tabs } from \"@blueprintjs/core\";\nimport { Layout } from \"../../Layout.js\";\nimport { t } from \"../../../../shared/i18n/trans.js\";\nimport { useTitle } from \"../../../hooks/document.js\";\nimport { CredentialsSelector } from \"./CredentialsSelector.js\";\nimport { CredentialsSaver } from \"./CredentialsSaver.js\";\nimport { clearSavedCredentials, saveCredentialsToEntry } from \"../../../services/credentials.js\";\nimport { getToaster } from \"../../../../shared/services/notifications.js\";\nimport { localisedErrorMessage } from \"../../../../shared/library/error.js\";\nimport { closeCurrentTab } from \"../../../../shared/library/extension.js\";\nimport { SavedCredentials } from \"../../../types.js\";\n\nenum TabID {\n    SaveNew = \"save-new\",\n    UpdateExisting = \"update-existing\"\n}\n\nexport function SaveCredentialsPage() {\n    useTitle(t(\"save-credentials-page.title\"));\n    const [selectedTabID, setSelectedTabID] = useState<TabID>(TabID.SaveNew);\n    const [selectedID, setSelectedID] = useState<string | null>(null);\n    const [saving, setSaving] = useState<boolean>(false);\n    const handleSaveNew = useCallback(async (credentials: SavedCredentials) => {\n        setSaving(true);\n        try {\n            await saveCredentialsToEntry(credentials);\n            await clearSavedCredentials(credentials.id);\n            getToaster().show({\n                intent: Intent.SUCCESS,\n                message: t(\"save-credentials-page.save-success\", { title: credentials.title }),\n                timeout: 4000\n            });\n            setTimeout(() => {\n                closeCurrentTab();\n            }, 4000);\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"save-credentials-page.save-error\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, []);\n    return (\n        <Layout title={t(\"save-credentials-page.title\")}>\n            <p>{t(\"save-credentials-page.description\")}</p>\n            <h3>{t(\"save-credentials-page.detected-logins.heading\")}</h3>\n            <CredentialsSelector\n                disabled={saving}\n                onSelect={setSelectedID}\n                selected={selectedID}\n            />\n            {selectedID && (\n                <Fragment>\n                    <h3>{t(\"save-credentials-page.credentials-saver.heading\")}</h3>\n                    <Tabs\n                        onChange={newID => setSelectedTabID(newID as TabID)}\n                        renderActiveTabPanelOnly\n                        selectedTabId={selectedTabID}\n                    >\n                        <Tab\n                            disabled={saving}\n                            id={TabID.SaveNew}\n                            title={t(\"save-credentials-page.credentials-saver.create-new.tab\")}\n                            panel={\n                                <CredentialsSaver\n                                    mode=\"new\"\n                                    onSaveNewClick={handleSaveNew}\n                                    saving={saving}\n                                    selected={selectedID}\n                                />\n                            }\n                        />\n                        <Tab\n                            disabled={saving}\n                            id={TabID.UpdateExisting}\n                            title={t(\"save-credentials-page.credentials-saver.update-existing.tab\")}\n                            panel={\n                                <CredentialsSaver\n                                    mode=\"existing\"\n                                    onSaveNewClick={handleSaveNew}\n                                    saving={saving}\n                                    selected={selectedID}\n                                />\n                            }\n                        />\n                    </Tabs>\n                </Fragment>\n            )}\n        </Layout>\n    );\n}\n"
  },
  {
    "path": "source/full/hooks/credentials.ts",
    "content": "import { useAsync } from \"../../shared/hooks/async.js\";\nimport { getCredentials } from \"../services/credentials.js\";\nimport { UsedCredentials } from \"../types.js\";\n\nexport function useCapturedCredentials(): [\n    credentials: Array<UsedCredentials | null>,\n    loading: boolean,\n    error: Error | null\n] {\n    const { error, loading, value } = useAsync(getCredentials, []);\n    return [value || [], loading, error];\n}\n"
  },
  {
    "path": "source/full/hooks/disabledDomains.ts",
    "content": "import { useAsyncWithTimer } from \"../../shared/hooks/async.js\";\nimport { getDisabledDomains } from \"../services/disabledDomains.js\";\n\nconst REFRESH_DELAY = 2500;\n\nexport function useDisabledDomains(\n    deps: React.DependencyList = []\n): [domains: Array<string>, loading: boolean, error: Error | null] {\n    const { error, loading, value } = useAsyncWithTimer(getDisabledDomains, REFRESH_DELAY, deps);\n    return [value || [], loading, error];\n}\n"
  },
  {
    "path": "source/full/hooks/document.ts",
    "content": "import { useEffect } from \"react\";\n\nconst TITLE_SEPARATOR = \"⋅\";\n\nlet __originalTitle: string | null = null;\n\nexport function useTitle(title: string) {\n    useEffect(() => {\n        if (!__originalTitle) {\n            __originalTitle = document.title;\n        }\n        document.title = `${title} ${TITLE_SEPARATOR} ${__originalTitle}`;\n        return () => {\n            if (__originalTitle) {\n                document.title = __originalTitle;\n                __originalTitle = null;\n            }\n        };\n    }, []);\n}\n"
  },
  {
    "path": "source/full/hooks/vaultContents.ts",
    "content": "import { useAsync } from \"../../shared/hooks/async.js\";\nimport { getVaultsTree } from \"../services/vaults.js\";\nimport { VaultsTree } from \"../types.js\";\n\nfunction vaultTreesDiffer(existingValue: VaultsTree, newValue: VaultsTree): boolean {\n    if (existingValue === null || newValue === null) return true;\n    const existingVaults = Object.keys(existingValue).sort().join(\",\");\n    const newVaults = Object.keys(newValue).sort().join(\",\");\n    if (existingVaults !== newVaults) return true;\n    for (const vaultID in existingValue) {\n        // Compare groups\n        const existingGroupMap = existingValue[vaultID].groups\n            .map((group) => `${group.parentID}-${group.id}:${group.title}`)\n            .sort()\n            .join(\",\");\n        const newGroupMap = newValue[vaultID].groups\n            .map((group) => `${group.parentID}-${group.id}:${group.title}`)\n            .sort()\n            .join(\",\");\n        if (existingGroupMap !== newGroupMap) return true;\n    }\n    return false;\n}\n\nexport function useAllVaultsContents(): {\n    error: Error | null;\n    loading: boolean;\n    tree: VaultsTree | null;\n} {\n    const { error, loading, value } = useAsync(getVaultsTree, [], {\n        clearOnExec: false,\n        updateInterval: 5000,\n        valuesDiffer: vaultTreesDiffer\n    });\n    return {\n        error,\n        loading,\n        tree: value ?? null\n    };\n}\n"
  },
  {
    "path": "source/full/index.pug",
    "content": "doctype html\nhtml\n    head\n        title Buttercup\n        meta(charset=\"utf-8\")\n        link(rel=\"icon\", type=\"image/png\", href=require(\"../../resources/buttercup-256.png\").default)\n        link(rel=\"stylesheet\" href=\"./styles/full.sass\")\n        script(defer, src=\"./applications/full.tsx\")\n    body\n        div#root\n"
  },
  {
    "path": "source/full/services/credentials.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { EntryType } from \"buttercup\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType, SavedCredentials, UsedCredentials } from \"../types.js\";\n\nexport async function clearSavedCredentials(id: string): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        credentialsID: id,\n        type: BackgroundMessageType.ClearSavedCredentials\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed clearing saved credentials\");\n    }\n}\n\nexport async function getCredentials(): Promise<Array<UsedCredentials | null>> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetSavedCredentials\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching saved credentials\");\n    }\n    return resp.credentials ?? [];\n}\n\nexport async function saveCredentialsToEntry(credentials: SavedCredentials): Promise<void> {\n    const { entryID = null } = await sendBackgroundMessage({\n        sourceID: credentials.sourceID,\n        groupID: credentials.groupID,\n        entryID: credentials.entryID ?? undefined,\n        entryProperties: {\n            password: credentials.password,\n            title: credentials.title,\n            url: credentials.url,\n            username: credentials.username\n        },\n        entryType: EntryType.Website,\n        type: BackgroundMessageType.SaveCredentialsToVault\n    });\n}\n"
  },
  {
    "path": "source/full/services/disabledDomains.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../types.js\";\n\nexport async function getDisabledDomains(): Promise<Array<string>> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetDisabledDomains\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching disabled domains\");\n    }\n    return resp.domains ?? [];\n}\n\nexport async function removeDisabledDomain(domain: string): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        domains: [domain],\n        type: BackgroundMessageType.DeleteDisabledDomains\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed removing disabled domains\");\n    }\n}\n"
  },
  {
    "path": "source/full/services/init.ts",
    "content": "import { initialise as initialiseI18n } from \"../../shared/i18n/trans.js\";\nimport { getLanguage } from \"../../shared/library/i18n.js\";\nimport { log } from \"./log.js\";\n\nexport async function initialise() {\n    log(\"initialising\");\n    await initialiseI18n(getLanguage());\n    log(\"initialisation complete\");\n}\n"
  },
  {
    "path": "source/full/services/log.ts",
    "content": "import { createLog } from \"../../shared/library/log.js\";\n\nconst LOG_NAME = \"buttercup:browser:page\";\n\nlet __logger: ReturnType<typeof createLog>;\n\nexport function log(...args: Array<any>): void {\n    if (!__logger) {\n        __logger = createLog(LOG_NAME, true);\n    }\n    return __logger(...args);\n}\n"
  },
  {
    "path": "source/full/services/notifications.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../types.js\";\n\nexport async function updateReadNotifications(notificationName: string): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        notification: notificationName,\n        type: BackgroundMessageType.MarkNotificationRead\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed updating read notifications\");\n    }\n}\n"
  },
  {
    "path": "source/full/services/vaults.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType, VaultsTree } from \"../types.js\";\n\nexport async function getVaultsTree(): Promise<VaultsTree> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetDesktopVaultsTree\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching vaults tree\");\n    }\n    if (!resp.vaultsTree) {\n        throw new Error(\"No vaults tree returned\");\n    }\n    return resp.vaultsTree;\n}\n"
  },
  {
    "path": "source/full/styles/full.sass",
    "content": "html, body\n    margin: 0\n    padding: 0\n    height: 100%\n\n#root\n    display: flex\n    justify-content: center\n    min-height: 100%\n    height: 100%\n\n@import ../../shared/styles/base\n"
  },
  {
    "path": "source/full/types.ts",
    "content": "export * from \"../shared/types.js\";\n"
  },
  {
    "path": "source/popup/applications/popup.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { App } from \"../components/App.js\";\nimport { initialise } from \"../services/init.js\";\n\ninitialise()\n    .then(() => {\n        ReactDOM.render(\n            <App />,\n            document.getElementById(\"root\")\n        );\n    })\n    .catch(err => {\n        console.error(err);\n    });\n"
  },
  {
    "path": "source/popup/components/App.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport {\n    createHashRouter,\n    RouterProvider,\n    useLoaderData\n} from \"react-router-dom\";\nimport { useSingleState } from \"react-obstate\";\nimport { Navigator } from \"./navigation/Navigator.js\";\nimport { APP_STATE } from \"../state/app.js\";\nimport { ThemeProvider } from \"../../shared/components/ThemeProvider.js\";\nimport { useBodyClass } from \"../hooks/document.js\";\nimport { LaunchContextProvider } from \"./contexts/LaunchContext.js\";\nimport { useBodyThemeClass, useTheme } from \"../../shared/hooks/theme.js\";\nimport { SaveDialogPage } from \"./pages/SaveDialogPage.js\";\nimport { useCurrentTabURL } from \"../hooks/tab.js\";\nimport { PopupPage } from \"../types.js\";\n\nconst ROUTER = createHashRouter([\n    {\n        path: \"/\",\n        element: <ToolbarApp />\n    },\n    {\n        path: \"/dialog\",\n        element: <InPageApp />,\n        loader: ({ request }) => {\n            const url = new URL(request.url);\n            const pageURL = url.searchParams.get(\"page\");\n            const formID = url.searchParams.get(\"form\");\n            const initialTab = url.searchParams.get(\"initial\");\n            return { formID, url: pageURL, initialTab };\n        }\n    },\n    {\n        path: \"/save-dialog\",\n        element: <SavePromptApp />,\n        loader: ({ request }) => {\n            const url = new URL(request.url);\n            const loginID = url.searchParams.get(\"login\");\n            return { loginID };\n        }\n    }\n]);\n\nexport function App() {\n    const theme = useTheme();\n    useBodyThemeClass(theme);\n    return (\n        <ThemeProvider darkMode={theme === \"dark\"}>\n            <RouterProvider router={ROUTER} />\n        </ThemeProvider>\n    );\n}\n\nfunction InPageApp() {\n    const [tab, setTab] = useSingleState(APP_STATE, \"tab\");\n    useBodyClass(\"in-page\");\n    const { formID = \"\", initialTab, url = null } = useLoaderData() as {\n        formID?: string;\n        initialTab: PopupPage,\n        url: string;\n    };\n    useEffect(() => {\n        setTab(initialTab);\n    }, [initialTab]);\n    return (\n        <LaunchContextProvider source=\"page\" formID={formID || null} url={url}>\n            <Navigator\n                activeTab={tab}\n                onChangeTab={setTab}\n                tabs={[\n                    PopupPage.Entries,\n                    PopupPage.OTPs\n                ]}\n            />\n        </LaunchContextProvider>\n    );\n}\n\nfunction SavePromptApp() {\n    const { loginID = null } = useLoaderData() as {\n        loginID: string;\n    };\n    useBodyClass(\"in-page\");\n    return (\n        <LaunchContextProvider source=\"page\" loginID={loginID}>\n            <SaveDialogPage />\n        </LaunchContextProvider>\n    );\n}\n\nfunction ToolbarApp() {\n    const [tab, setTab] = useSingleState(APP_STATE, \"tab\");\n    const [loadingURL, url] = useCurrentTabURL();\n    const [hasLoadedURL, setHasLoadedURL] = useState<boolean>(false);\n    useEffect(() => {\n        if (!loadingURL) {\n            setHasLoadedURL(true);\n        }\n    }, [loadingURL]);\n    if (!hasLoadedURL) return null;\n    return (\n        <LaunchContextProvider source=\"popup\" url={url}>\n            <Navigator\n                activeTab={tab}\n                onChangeTab={setTab}\n                tabs={[\n                    PopupPage.About,\n                    PopupPage.Entries,\n                    PopupPage.Vaults,\n                    PopupPage.OTPs,\n                    PopupPage.Settings\n                ]}\n            />\n        </LaunchContextProvider>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/contexts/LaunchContext.tsx",
    "content": "import React, { ReactNode, createContext } from \"react\";\n\ninterface LaunchContextProps {\n    children: ReactNode;\n    formID?: string | null;\n    loginID?: string | null;\n    source: \"popup\" | \"page\";\n    url?: string | null;\n}\n\ninterface LaunchContextDefaultValue {\n    formID: string | null;\n    loginID: string | null;\n    source: \"popup\" | \"page\";\n    url: string | null;\n}\n\nexport const LaunchContext = createContext<LaunchContextDefaultValue>({} as LaunchContextDefaultValue);\nLaunchContext.displayName = \"LaunchContext\";\n\nexport function LaunchContextProvider(props: LaunchContextProps) {\n    return (\n        <LaunchContext.Provider value={{\n            formID: props.formID ?? null,\n            loginID: props.loginID ?? null,\n            source: props.source,\n            url: props.url ?? null\n        }}>\n            {props.children}\n        </LaunchContext.Provider>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/entries/EntryInfoDialog.tsx",
    "content": "import React, { useCallback, useMemo } from \"react\";\nimport { Button, Classes, Dialog, DialogBody, InputGroup, Intent } from \"@blueprintjs/core\";\nimport { SearchResult } from \"buttercup\";\nimport cn from \"classnames\";\nimport styled from \"styled-components\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { copyTextToClipboard } from \"../../services/clipboard.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\n\ninterface EntryInfoDialogProps {\n    entry: SearchResult | null;\n    onClose: () => void;\n}\n\ninterface EntryProperty {\n    key: string;\n    sensitive: boolean;\n    title: string;\n    value: string;\n}\n\nconst InfoDialog = styled(Dialog)`\n    max-width: 90%;\n`;\nconst InfoDialogBody = styled(DialogBody)`\n    display: flex;\n    flex-direction: row;\n    justify-content: stretch;\n    align-items: flex-start;\n    overflow-x: hidden;\n`;\nconst InfoTable = styled.table`\n    table-layout: fixed;\n    width: 100%;\n`;\n\nexport function EntryInfoDialog(props: EntryInfoDialogProps) {\n    const { entry, onClose } = props;\n    const properties = useMemo(() => entry ? orderProperties(entry.properties) : [], [entry]);\n    const handleCopyClick = useCallback(async (property: string, value: string) => {\n        try {\n            await copyTextToClipboard(value);\n            getToaster().show({\n                intent: Intent.SUCCESS,\n                message: t(\"popup.entries.info.copy-success\", { property }),\n                timeout: 4000\n            });\n        } catch (err) {\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"popup.entries.info.copy-error\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, []);\n    return (\n        <InfoDialog\n            icon=\"info-sign\"\n            isCloseButtonShown\n            isOpen={!!entry}\n            onClose={onClose}\n            title={entry?.properties.title ?? \"Untitled Entry\"}\n        >\n            <InfoDialogBody>\n                <InfoTable className={cn(Classes.HTML_TABLE, Classes.COMPACT, Classes.HTML_TABLE_STRIPED)}>\n                    <tbody>\n                        {properties.map(property => (\n                            <tr key={property.key}>\n                                <td style={{ width: \"100%\" }}>\n                                    {property.title}<br />\n                                    <InputGroup\n                                        type={property.sensitive ? \"password\" : \"text\"}\n                                        value={property.value}\n                                        readOnly\n                                        rightElement={\n                                            <Button\n                                                icon=\"clipboard\"\n                                                minimal\n                                                onClick={() => handleCopyClick(property.title, property.value)}\n                                                title={t(\"popup.entries.info.copy-tooltip\")}\n                                            />\n                                        }\n                                    />\n                                </td>\n                            </tr>\n                        ))}\n                    </tbody>\n                </InfoTable>\n            </InfoDialogBody>\n        </InfoDialog>\n    );\n}\n\nfunction orderProperties(properties: Record<string, string>): Array<EntryProperty> {\n    const working = { ...properties };\n    delete working[\"title\"];\n    const output: Array<EntryProperty> = [];\n    if (working[\"username\"]) {\n        output.push({\n            key: \"username\",\n            sensitive: false,\n            title: \"Username\",\n            value: properties[\"username\"]\n        });\n        delete working[\"username\"];\n    }\n    if (working[\"password\"]) {\n        output.push({\n            key: \"password\",\n            sensitive: true,\n            title: \"Password\",\n            value: properties[\"password\"]\n        });\n        delete working[\"password\"];\n    }\n    for (const prop in working) {\n        output.push({\n            key: prop,\n            sensitive: false,\n            title: prop,\n            value: working[prop]\n        });\n    }\n    return output;\n}\n"
  },
  {
    "path": "source/popup/components/entries/EntryItem.tsx",
    "content": "import React, { MouseEvent, useCallback, useContext, useMemo } from \"react\";\nimport styled from \"styled-components\";\nimport cn from \"classnames\";\nimport { Button, ButtonGroup, Classes, Text } from \"@blueprintjs/core\";\nimport { SearchResult, VaultSourceStatus } from \"buttercup\";\nimport { SiteIcon } from \"@buttercup/ui\";\nimport { LaunchContext } from \"../contexts/LaunchContext.js\";\nimport { extractEntryDomain } from \"../../../shared/library/domain.js\";\nimport { Tooltip2 } from \"@blueprintjs/popover2\";\nimport { t } from \"../../../shared/i18n/trans.js\";\n\ninterface EntryItemProps {\n    entry: SearchResult;\n    fetchIcons: boolean;\n    onAutoClick: () => void;\n    onClick: () => void;\n    onInfoClick: () => void;\n}\n\nconst CenteredText = styled(Text)`\n    display: flex;\n    align-items: center;\n`;\nconst Container = styled.div`\n    border-radius: 3px;\n    padding: 0.5rem;\n    background-color: ${p => (p.isActive ? p.theme.listItemHover : null)};\n    position: relative;\n    &:hover {\n        background-color: ${p => p.theme.listItemHover};\n    }\n`;\nconst DetailRow = styled.div`\n    margin-left: 0.5rem;\n    overflow: hidden;\n    flex: 1;\n`;\nconst EntryIcon = styled(SiteIcon)`\n    width: 100%;\n    height: 100%;\n    > img {\n        width: 100%;\n        height: 100%;\n    }\n`;\nconst Title = styled(Text)`\n    margin-bottom: 0.3rem;\n`;\nconst EntryIconBackground = styled.div`\n    width: 2.5rem;\n    height: 2.5rem;\n    flex: 0 0 auto;\n    background-color: ${p => p.theme.backgroundColor};\n    border-radius: 3px;\n    border: 1px solid ${p => p.theme.listItemHover};\n`;\nconst EntryRow = styled.div`\n    flex: 1;\n    width: 100%;\n    display: flex;\n    cursor: pointer;\n    align-items: center;\n`;\n\nexport function EntryItem(props: EntryItemProps) {\n    const {\n        entry,\n        fetchIcons,\n        onAutoClick,\n        onClick,\n        onInfoClick\n    } = props;\n    const { source: popupSource } = useContext(LaunchContext);\n    const entryDomain = useMemo(() => {\n        if (!fetchIcons) {\n            return null;\n        }\n        return extractEntryDomain(entry.properties);\n    }, [entry, fetchIcons]);\n    const handleEntryClick = useCallback(\n        (evt: MouseEvent) => {\n            evt.preventDefault();\n            evt.stopPropagation();\n            onClick();\n        },\n        [onClick]\n    );\n    const handleEntryLoginClick = useCallback(\n        (evt: MouseEvent) => {\n            evt.preventDefault();\n            evt.stopPropagation();\n            onAutoClick();\n        },\n        [onAutoClick]\n    );\n    const handleEntryInfoClick = useCallback((evt: MouseEvent) => {\n        evt.preventDefault();\n        evt.stopPropagation();\n        onInfoClick();\n    }, [onInfoClick]);\n    return (\n        <Container isActive={false} onClick={handleEntryClick}>\n            <EntryRow>\n                <EntryIconBackground>\n                    <EntryIcon\n                        domain={entryDomain}\n                        type={entry.entryType}\n                    />\n                </EntryIconBackground>\n                <DetailRow>\n                    <Title title={entry.properties.title}>\n                        <Text ellipsize>{entry.properties.title}</Text>\n                    </Title>\n                    <CenteredText ellipsize className={cn(Classes.TEXT_SMALL, Classes.TEXT_MUTED)}>\n                        {entry.properties.username} {entry.properties.url && `@ ${entry.properties.url}` || \"\"}\n                    </CenteredText>\n                </DetailRow>\n                {popupSource === \"popup\" && (\n                    <ButtonGroup>\n                        <Tooltip2\n                            content={t(\"popup.entries.auto-login.tooltip\")}\n                        >\n                            <Button\n                                icon=\"text-highlight\"\n                                minimal\n                                onClick={handleEntryLoginClick}\n                            />\n                        </Tooltip2>\n                        <Tooltip2\n                            content={t(\"popup.entries.info.tooltip\")}\n                        >\n                            <Button\n                                icon=\"info-sign\"\n                                minimal\n                                onClick={handleEntryInfoClick}\n                            />\n                        </Tooltip2>\n                    </ButtonGroup>\n                )}\n            </EntryRow>\n        </Container>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/entries/EntryItemList.tsx",
    "content": "import React, { Fragment } from \"react\";\nimport styled from \"styled-components\";\nimport { SearchResult } from \"buttercup\";\nimport { Divider, H4} from \"@blueprintjs/core\";\nimport { EntryItem } from \"./EntryItem.js\";\nimport { useConfig } from \"../../../shared/hooks/config.js\";\nimport { t } from \"../../../shared/i18n/trans.js\";\n\ninterface EntryItemListProps {\n    entries: Array<SearchResult> | Record<string, Array<SearchResult>>;\n    onEntryAutoClick: (entry: SearchResult) => void;\n    onEntryClick: (entry: SearchResult) => void;\n    onEntryInfoClick: (entry: SearchResult) => void;\n}\n\nconst ScrollList = styled.div`\n    max-height: 100%;\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: stretch;\n`;\n\nexport function EntryItemList(props: EntryItemListProps) {\n    const { entries, onEntryAutoClick, onEntryClick, onEntryInfoClick } = props;\n    const [config] = useConfig();\n    if (!config) return null;\n    return (\n        <ScrollList>\n            {Array.isArray(entries) && (\n                <>\n                    {entries.map((entry) => (\n                        <Fragment key={entry.id}>\n                            <EntryItem\n                                entry={entry}\n                                fetchIcons={config.entryIcons}\n                                onAutoClick={() => onEntryAutoClick(entry)}\n                                onClick={() => onEntryClick(entry)}\n                                onInfoClick={() => onEntryInfoClick(entry)}\n                            />\n                            <Divider />\n                        </Fragment>\n                    ))}\n                </>\n            ) || (\n                <>\n                    {Object.keys(entries).map(sectionName => (\n                        <Fragment key={sectionName}>\n                            {entries[sectionName].length > 0 && (\n                                <Fragment key={`en-${sectionName}`}>\n                                    <H4>{t(sectionName)}</H4>\n                                    {entries[sectionName].map((entry: SearchResult) => (\n                                        <Fragment key={entry.id}>\n                                            <EntryItem\n                                                entry={entry}\n                                                fetchIcons={config.entryIcons}\n                                                onAutoClick={() => onEntryAutoClick(entry)}\n                                                onClick={() => onEntryClick(entry)}\n                                                onInfoClick={() => onEntryInfoClick(entry)}\n                                            />\n                                            <Divider />\n                                        </Fragment>\n                                    ))}\n                                </Fragment>\n                            )}\n                        </Fragment>\n                    ))}\n                </>\n            )}\n        </ScrollList>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/navigation/Navigator.tsx",
    "content": "import React, { useCallback, useState } from \"react\";\nimport styled from \"styled-components\";\nimport { Classes, Divider, Icon, Intent, Tab, Tabs } from \"@blueprintjs/core\";\nimport { VaultsPage, VaultsPageControls } from \"../pages/VaultsPage.js\";\nimport { EntriesPage, EntriesPageControls } from \"../pages/EntriesPage.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { clearDesktopConnectionAuth, initiateDesktopConnectionRequest } from \"../../queries/desktop.js\";\nimport { OTPsPage } from \"../pages/OTPsPage.js\";\nimport { SettingsPage } from \"../pages/SettingsPage.js\";\nimport { AboutPage } from \"../pages/AboutPage.js\";\nimport { PopupPage } from \"../../types.js\";\nimport BUTTERCUP_LOGO from \"../../../../resources/buttercup-128.png\";\n\ninterface NavigatorProps {\n    activeTab: PopupPage;\n    onChangeTab: (tab: PopupPage) => void;\n    tabs: Array<PopupPage>;\n}\n\nconst ButtercupIconImg = styled.img`\n    width: 20px;\n    height: 20px;\n`;\nconst Container = styled.div`\n    display: flex;\n    flex-direction: column;\n    align-items: stretch;\n    padding: 3px;\n    overflow: hidden;\n    max-height: 100%;\n\n    .${Classes.TAB} {\n        outline: none;\n        user-select: none;\n    }\n\n    .${Classes.TABS} {\n        height: 100%;\n        overflow: hidden;\n        display: flex;\n        flex-direction: column;\n        justify-content: space-between;\n        align-items: stretch;\n    }\n\n    .${Classes.TAB_PANEL} {\n        margin-top: 8px;\n        overflow-x: hidden;\n        overflow-y: scroll;\n    }\n\n    .${Classes.TAB_LIST} {\n        padding: 0px 5px;\n        height: 30px;\n    }\n`;\n\nexport function Navigator(props: NavigatorProps) {\n    const [entriesSearch, setEntriesSearch] = useState<string>(\"\");\n    const handleConnectClick = useCallback(async () => {\n        try {\n            await initiateDesktopConnectionRequest();\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"popup.connection.open-error\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, []);\n    const handleReconnectClick = useCallback(async () => {\n        try {\n            await clearDesktopConnectionAuth();\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"popup.connection.reauth-error\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n            return;\n        }\n        await handleConnectClick();\n    }, [handleConnectClick]);\n    return (\n        <Container>\n            <Tabs\n                onChange={(newTab: PopupPage) => props.onChangeTab(newTab)}\n                selectedTabId={props.activeTab}\n            >\n                {props.tabs.map((tabType: PopupPage, ind: number) => (\n                    (tabType === PopupPage.Entries && (\n                        <Tab\n                            key={`tab-${ind}-${tabType}`}\n                            id={PopupPage.Entries}\n                            panel={(\n                                <>\n                                    <Divider />\n                                    <EntriesPage\n                                        onConnectClick={handleConnectClick}\n                                        onReconnectClick={handleReconnectClick}\n                                        searchTerm={entriesSearch}\n                                    />\n                                </>\n                            )}\n                        >\n                            <Icon icon=\"label\" title={t(\"popup.tab.entries.title\")} />\n                        </Tab>\n                    )) ||\n                    (tabType === PopupPage.Vaults && (\n                        <Tab\n                            key={`tab-${ind}-${tabType}`}\n                            id={PopupPage.Vaults}\n                            panel={(\n                                <>\n                                    <Divider />\n                                    <VaultsPage\n                                        onConnectClick={handleConnectClick}\n                                        onReconnectClick={handleReconnectClick}\n                                    />\n                                </>\n                            )}\n                        >\n                            <Icon icon=\"projects\" title={t(\"popup.tab.vaults.title\")} />\n                        </Tab>\n                    )) ||\n                    (tabType === PopupPage.OTPs && (\n                        <Tab\n                            key={`tab-${ind}-${tabType}`}\n                            id={PopupPage.OTPs}\n                            panel={(\n                                <>\n                                    <Divider />\n                                    <OTPsPage\n                                        onConnectClick={handleConnectClick}\n                                        onReconnectClick={handleReconnectClick}\n                                    />\n                                </>\n                            )}\n                        >\n                            <Icon icon=\"array-timestamp\" title={t(\"popup.tab.otps.title\")} />\n                        </Tab>\n                    )) ||\n                    (tabType === PopupPage.Settings && (\n                        <Tab\n                            key={`tab-${ind}-${tabType}`}\n                            id={PopupPage.Settings}\n                            panel={(\n                                <>\n                                    <Divider />\n                                    <SettingsPage />\n                                </>\n                            )}\n                        >\n                            <Icon icon=\"cog\" title={t(\"popup.tab.settings.title\")} />\n                        </Tab>\n                    )) ||\n                    (tabType === PopupPage.About && (\n                        <Tab\n                            key={`tab-${ind}-${tabType}`}\n                            id={PopupPage.About}\n                            panel={(\n                                <>\n                                    <Divider />\n                                    <AboutPage />\n                                </>\n                            )}\n                        >\n                            <ButtercupIconImg src={BUTTERCUP_LOGO} alt={t(\"popup.tab.about.title\")} />\n                        </Tab>\n                    ))\n                ))}\n                <Tabs.Expander />\n                {props.activeTab === PopupPage.Entries && (\n                    <EntriesPageControls\n                        onSearchTermChange={setEntriesSearch}\n                        searchTerm={entriesSearch}\n                    />\n                )}\n                {props.activeTab === PopupPage.Vaults && (\n                    <VaultsPageControls />\n                )}\n            </Tabs>\n        </Container>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/otps/OTPItem.tsx",
    "content": "import React, { useCallback, useMemo } from \"react\";\nimport styled from \"styled-components\";\nimport cn from \"classnames\";\nimport { Classes, Intent, Spinner, Text } from \"@blueprintjs/core\";\nimport { SiteIcon } from \"@buttercup/ui\";\nimport { extractDomain } from \"../../../shared/library/domain.js\";\nimport { PreparedOTP } from \"../../hooks/otp.js\";\n\ninterface OTPItemProps {\n    otp: PreparedOTP;\n    onClick: () => void;\n}\n\nconst CenteredText = styled(Text)`\n    display: flex;\n    align-items: center;\n`;\nconst Container = styled.div`\n    border-radius: 3px;\n    padding: 0.5rem;\n    background-color: ${p => (p.isActive ? p.theme.listItemHover : null)};\n    position: relative;\n    &:hover {\n        background-color: ${p => p.theme.listItemHover};\n    }\n`;\nconst DetailRow = styled.div`\n    margin-left: 0.5rem;\n    overflow: hidden;\n    flex: 1;\n`;\nconst EntryIcon = styled(SiteIcon)`\n    width: 100%;\n    height: 100%;\n    > img {\n        width: 100%;\n        height: 100%;\n    }\n`;\nconst Title = styled(Text)`\n    margin-bottom: 0.3rem;\n`;\nconst OTPIconBackground = styled.div`\n    width: 2.5rem;\n    height: 2.5rem;\n    flex: 0 0 auto;\n    background-color: ${p => p.theme.backgroundColor};\n    border-radius: 3px;\n    border: 1px solid ${p => p.theme.listItemHover};\n`;\nconst OTPCode = styled.div`\n    flex: 0 0 auto;\n    display: flex;\n    flex-direction: row;\n    justify-content: space-between;\n    align-items: center;\n    padding-left: 3px;\n\n    .${Classes.SPINNER} {\n        margin-right: 4px;\n    }\n`;\nconst OTPCodePart = styled.div`\n    font-family: monospace;\n    font-size: 22px;\n    margin-right: 3px;\n`;\nconst OTPRow = styled.div`\n    flex: 1;\n    width: 100%;\n    display: flex;\n    cursor: pointer;\n    align-items: center;\n`;\n\nexport function OTPItem(props: OTPItemProps) {\n    const {\n        otp,\n        onClick\n    } = props;\n    const entryDomain = useMemo(() => otp.loginURL ? extractDomain(otp.loginURL) : null, [otp]);\n    const handleOTPClick = useCallback(() => {\n        onClick();\n    }, [onClick]);\n    const [codeFirst, codeSecond] = useMemo(() => {\n        if (otp.errored) return [otp.digits, \"\"];\n        return otp.digits.length === 8\n            ? [otp.digits.substring(0, 4), otp.digits.substring(4)]\n            : [otp.digits.substring(0, 3), otp.digits.substring(3)]\n    }, [otp.digits]);\n    const spinnerLeft = useMemo(() => {\n        if (otp.errored) return 1;\n        return otp.remaining / otp.period;\n    }, [otp]);\n    const spinnerIntent = useMemo(() => {\n        if (otp.errored) return Intent.DANGER;\n        return spinnerLeft < 0.15 ? Intent.DANGER : spinnerLeft < 0.35 ? Intent.WARNING : Intent.SUCCESS;\n    }, [otp.errored, spinnerLeft]);\n    return (\n        <Container isActive={false} onClick={handleOTPClick}>\n            <OTPRow>\n                <OTPIconBackground>\n                    <EntryIcon\n                        domain={entryDomain}\n                    />\n                </OTPIconBackground>\n                <DetailRow onClick={() => {}}>\n                    <Title title={otp.otpTitle ?? \"\"}>\n                        <Text ellipsize>{otp.otpTitle ?? \"?\"}</Text>\n                    </Title>\n                    <CenteredText ellipsize className={cn(Classes.TEXT_SMALL, Classes.TEXT_MUTED)}>\n                        {otp.entryTitle}\n                    </CenteredText>\n                </DetailRow>\n                <OTPCode>\n                    <Spinner\n                        size={19}\n                        value={spinnerLeft}\n                        intent={spinnerIntent}\n                    />\n                    <OTPCodePart>{codeFirst}</OTPCodePart>\n                    <OTPCodePart>{codeSecond}</OTPCodePart>\n                </OTPCode>\n            </OTPRow>\n        </Container>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/otps/OTPItemList.tsx",
    "content": "import React, { Fragment } from \"react\";\nimport styled from \"styled-components\";\nimport { Divider } from \"@blueprintjs/core\";\nimport { OTPItem } from \"./OTPItem.js\";\nimport { PreparedOTP } from \"../../hooks/otp.js\";\nimport { OTP } from \"../../types.js\";\n\ninterface OTPItemListProps {\n    onOTPClick: (otp: OTP) => void;\n    otps: Array<PreparedOTP>;\n}\n\nconst ButtonRow = styled.div`\n    display: flex;\n    flex-direction: row;\n    justify-content: flex-end;\n    align-items: flex-start;\n\n    > button:not(:last-child) {\n        margin-right: 6px;\n    }\n`;\nconst ScrollList = styled.div`\n    max-height: 100%;\n    // overflow-x: hidden;\n    // overflow-y: scroll;\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: stretch;\n`;\n\nexport function OTPItemList(props: OTPItemListProps) {\n    const {\n        onOTPClick,\n        otps\n    } = props;\n    \n    return (\n        <>\n            <ScrollList>\n                {otps.map((otp) => (\n                    <Fragment key={`${otp.entryID}:${otp.otpURL}`}>\n                        <OTPItem\n                            otp={otp}\n                            onClick={() => onOTPClick(otp)}\n                        />\n                        <Divider />\n                    </Fragment>\n                ))}\n            </ScrollList>\n        </>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/pages/AboutPage.tsx",
    "content": "import React, { MouseEvent, useCallback } from \"react\";\nimport styled from \"styled-components\";\nimport { Button, Callout, Classes, Intent } from \"@blueprintjs/core\";\nimport cn from \"classnames\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { BUILD_DATE, VERSION } from \"../../../shared/library/version.js\";\nimport { createNewTab, getExtensionURL } from \"../../../shared/library/extension.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport BUTTERCUP_LOGO from \"../../../../resources/buttercup-256.png\";\n\ninterface AboutPageProps {}\n\nconst AboutSection = styled(Callout)`\n    margin: 0px 12px;\n    width: calc(100% - 24px);\n    padding: 9px;\n`;\nconst Container = styled.div`\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: stretch;\n\n    > .${Classes.CALLOUT}:not(:last-child) {\n        margin-bottom: 8px;\n    }\n`;\nconst FooterSection = styled.div`\n    width: 100%;\n    padding: 8px 12px;\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: center;\n`;\nconst Heading = styled.h3`\n    margin-top: 3px;\n    margin-bottom: 5px;\n`;\nconst HeadingLogo = styled.img`\n    width: 32px;\n    height: auto;\n`;\nconst HeadingSection = styled.div`\n    width: 100%;\n    padding: 8px 12px;\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: center;\n`;\nconst InfoTable = styled.table`\n    width: 100%;\n`;\n\nexport function AboutPage(_: AboutPageProps) {\n    const handleAttributionsClick = useCallback(async (event: MouseEvent) => {\n        try {\n            await createNewTab(getExtensionURL(\"full.html#/attributions\"));\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.generic\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, []);\n    return (\n        <Container>\n            <HeadingSection>\n                <HeadingLogo src={BUTTERCUP_LOGO} alt=\"Buttercup logo\" />\n                <Heading>Buttercup Password Manager</Heading>\n            </HeadingSection>\n            <AboutSection>\n                <InfoTable className={cn(Classes.HTML_TABLE, Classes.COMPACT)}>\n                    <thead>\n                        <tr>\n                            <th>{t(\"about.info.title\")}</th>\n                            <th>&nbsp;</th>\n                        </tr>\n                    </thead>\n                    <tbody>\n                        <tr>\n                            <td>{t(\"about.info.version\")}</td>\n                            <td>{VERSION}</td>\n                        </tr>\n                        <tr>\n                            <td>{t(\"about.info.build-date\")}</td>\n                            <td>{BUILD_DATE}</td>\n                        </tr>\n                    </tbody>\n                </InfoTable>\n            </AboutSection>\n            <FooterSection>\n                <Button\n                    onClick={handleAttributionsClick}\n                >\n                    {t(\"about.attributions\")}\n                </Button>\n            </FooterSection>\n        </Container>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/pages/EntriesPage.tsx",
    "content": "import React, { useCallback, useContext, useMemo, useState } from \"react\";\nimport styled from \"styled-components\";\nimport { Button, InputGroup, Intent, NonIdealState, Spinner } from \"@blueprintjs/core\";\nimport { SearchResult, VaultSourceStatus } from \"buttercup\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { useDesktopConnectionState, useEntriesForURL, useRecentEntries, useSearchedEntries, useVaultSources } from \"../../hooks/desktop.js\";\nimport { EntryItemList } from \"../entries/EntryItemList.js\";\nimport { LaunchContext } from \"../contexts/LaunchContext.js\";\nimport { sendEntryResultToTabForInput } from \"../../services/tab.js\";\nimport { trackEntryRecentUse } from \"../../services/recents.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\nimport { DesktopConnectionState } from \"../../types.js\";\nimport { openPageForEntry } from \"../../services/entry.js\";\nimport { EntryInfoDialog } from \"../entries/EntryInfoDialog.js\";\n\ninterface EntriesPageProps {\n    onConnectClick: () => Promise<void>;\n    onReconnectClick: () => Promise<void>;\n    searchTerm: string;\n}\n\ninterface EntriesPageControlsProps {\n    onSearchTermChange: (term: string) => void;\n    searchTerm: string;\n}\n\nconst Container = styled.div`\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: stretch;\n`;\nconst Input = styled(InputGroup)`\n    margin-right: 2px !important;\n`;\nconst InvalidState = styled(NonIdealState)`\n    margin-top: 28px;\n`;\n\nexport function EntriesPage(props: EntriesPageProps) {\n    const desktopState = useDesktopConnectionState();\n    return (\n        <Container>\n            {desktopState === DesktopConnectionState.NotConnected && (\n                <InvalidState\n                    title={t(\"popup.vaults.no-connection.title\")}\n                    description={t(\"popup.vaults.no-connection.description\")}\n                    icon=\"offline\"\n                    action={(\n                        <Button\n                            icon=\"link\"\n                            onClick={props.onConnectClick}\n                            text={t(\"popup.vaults.no-connection.action-text\")}\n                        />\n                    )}\n                />\n            )}\n            {desktopState === DesktopConnectionState.Connected && (\n                <EntriesPageList {...props} />\n            )}\n            {desktopState === DesktopConnectionState.Pending && (\n                <Spinner size={40} />\n            )}\n            {desktopState === DesktopConnectionState.Error && (\n                <InvalidState\n                    title={t(\"popup.connection.check-error.title\")}\n                    description={t(\"popup.connection.check-error.description\")}\n                    icon=\"error\"\n                    intent={Intent.DANGER}\n                    action={(\n                        <Button\n                            icon=\"link\"\n                            onClick={props.onReconnectClick}\n                            text={t(\"popup.vaults.no-connection.action-text\")}\n                        />\n                    )}\n                />\n            )}\n        </Container>\n    );\n}\n\nfunction EntriesPageList(props: EntriesPageProps) {\n    const sources = useVaultSources();\n    const unlockedCount = useMemo(\n        () => sources.reduce(\n            (count, source) => source.state === VaultSourceStatus.Unlocked ? count + 1 : count,\n            0\n        ),\n        [sources]\n    );\n    const searchedEntries = useSearchedEntries(props.searchTerm);\n    const { formID, source: popupSource, url } = useContext(LaunchContext);\n    const [selectedEntryInfo, setSelectedEntryInfo] = useState<SearchResult | null>(null);\n    const urlEntries = useEntriesForURL(url);\n    const recentEntries = useRecentEntries();\n    const handleEntryClick = useCallback((entry: SearchResult, autoLogin: boolean) => {\n        if (popupSource === \"page\" && formID) {\n            sendEntryResultToTabForInput(formID, entry);\n        } else if (popupSource === \"popup\") {\n            openPageForEntry(entry, autoLogin)\n                .then(opened => {\n                    if (!opened) {\n                        getToaster().show({\n                            intent: Intent.PRIMARY,\n                            message: t(\"popup.entries.click.no-url-available\"),\n                            timeout: 3000\n                        });\n                    }\n                })\n                .catch(err => {\n                    console.error(err);\n                    getToaster().show({\n                        intent: Intent.DANGER,\n                        message: t(\"popup.entries.click.open-error\", { message: localisedErrorMessage(err) }),\n                        timeout: 10000\n                    });\n                });\n        }\n        trackEntryRecentUse(entry).catch(err => {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"popup.entries.click.recent-set-error\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        });\n    }, [popupSource]);\n    const handleEntryAutoLoginClick = useCallback((entry: SearchResult) => {\n        handleEntryClick(entry, true);\n    }, [handleEntryClick]);\n    const handleEntryBodyClick = useCallback((entry: SearchResult) => {\n        handleEntryClick(entry, false);\n    }, [handleEntryClick]);\n    const handleEntryInfoClick = useCallback((entry: SearchResult) => {\n        setSelectedEntryInfo(entry);\n    }, []);\n    // Render\n    return (\n        <>\n            {unlockedCount === 0 && (\n                <InvalidState\n                    title={t(\"popup.all-locked.title\")}\n                    description={t(\"popup.all-locked.description\")}\n                    icon=\"folder-close\"\n                />\n            ) || searchedEntries.length > 0 && (\n                <EntryItemList\n                    entries={searchedEntries}\n                    onEntryAutoClick={handleEntryAutoLoginClick}\n                    onEntryClick={handleEntryBodyClick}\n                    onEntryInfoClick={handleEntryInfoClick}\n                />\n            ) || (urlEntries.length <= 0 && recentEntries.length <= 0) && (\n                <InvalidState\n                    title={t(\"popup.no-entries.title\")}\n                    description={t(\"popup.no-entries.description\")}\n                    icon=\"clean\"\n                />\n            ) || (\n                <EntryItemList\n                    entries={{\n                        \"URL Entries\": urlEntries,\n                        \"Recents\": recentEntries\n                    }}\n                    onEntryAutoClick={handleEntryAutoLoginClick}\n                    onEntryClick={handleEntryBodyClick}\n                    onEntryInfoClick={handleEntryInfoClick}\n                />\n            )}\n            <EntryInfoDialog entry={selectedEntryInfo} onClose={() => setSelectedEntryInfo(null)} />\n        </>\n    );\n}\n\nexport function EntriesPageControls(props: EntriesPageControlsProps) {\n    const desktopState = useDesktopConnectionState();\n    return (\n        <>\n            <Input\n                disabled={desktopState !== DesktopConnectionState.Connected}\n                onChange={evt => props.onSearchTermChange(evt.target.value)}\n                placeholder={t(\"popup.entries.search.placeholder\")}\n                round\n                value={props.searchTerm}\n            />\n            <Button\n                disabled={desktopState !== DesktopConnectionState.Connected}\n                icon=\"search\"\n                minimal\n                title={t(\"popup.entries.search.button\")}\n            />\n        </>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/pages/OTPsPage.tsx",
    "content": "import React, { useCallback, useContext, useMemo } from \"react\";\nimport styled from \"styled-components\";\nimport { Button, Intent, NonIdealState, Spinner } from \"@blueprintjs/core\";\nimport { VaultSourceStatus } from \"buttercup\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { useDesktopConnectionState, useOTPs, useVaultSources } from \"../../hooks/desktop.js\";\nimport { OTPItemList } from \"../otps/OTPItemList.js\";\nimport { LaunchContext } from \"../contexts/LaunchContext.js\";\nimport { sendOTPToTabForInput } from \"../../services/tab.js\";\nimport { usePreparedOTPs } from \"../../hooks/otp.js\";\nimport { DesktopConnectionState, OTP } from \"../../types.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { createNewTab } from \"../../../shared/library/extension.js\";\nimport { formatURL } from \"../../../shared/library/url.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\n\ninterface OTPsPageProps {\n    onConnectClick: () => Promise<void>;\n    onReconnectClick: () => Promise<void>;\n}\n\ninterface OTPsPageControlsProps {}\n\nconst Container = styled.div`\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: stretch;\n`;\nconst InvalidState = styled(NonIdealState)`\n    margin-top: 28px;\n`;\n\nexport function OTPsPage(props: OTPsPageProps) {\n    const desktopState = useDesktopConnectionState();\n    return (\n        <Container>\n            {desktopState === DesktopConnectionState.NotConnected && (\n                <InvalidState\n                    title={t(\"popup.vaults.no-connection.title\")}\n                    description={t(\"popup.vaults.no-connection.description\")}\n                    icon=\"offline\"\n                    action={(\n                        <Button\n                            icon=\"link\"\n                            onClick={props.onConnectClick}\n                            text={t(\"popup.vaults.no-connection.action-text\")}\n                        />\n                    )}\n                />\n            )}\n            {desktopState === DesktopConnectionState.Connected && (\n                <OTPsPageList {...props} />\n            )}\n            {desktopState === DesktopConnectionState.Pending && (\n                <Spinner size={40} />\n            )}\n            {desktopState === DesktopConnectionState.Error && (\n                <InvalidState\n                    title={t(\"popup.connection.check-error.title\")}\n                    description={t(\"popup.connection.check-error.description\")}\n                    icon=\"error\"\n                    intent={Intent.DANGER}\n                    action={(\n                        <Button\n                            icon=\"link\"\n                            onClick={props.onReconnectClick}\n                            text={t(\"popup.vaults.no-connection.action-text\")}\n                        />\n                    )}\n                />\n            )}\n        </Container>\n    );\n}\n\nfunction OTPsPageList(props: OTPsPageProps) {\n    const { formID, source: popupSource } = useContext(LaunchContext);\n    const sources = useVaultSources();\n    const unlockedCount = useMemo(\n        () => sources.reduce(\n            (count, source) => source.state === VaultSourceStatus.Unlocked ? count + 1 : count,\n            0\n        ),\n        [sources]\n    );\n    const [otps, loadingOTPs] = useOTPs();\n    const preparedOTPs = usePreparedOTPs(otps);\n    const handleOTPClick = useCallback((otp: OTP) => {\n        if (popupSource === \"page\" && formID) {\n            sendOTPToTabForInput(formID, otp);\n        } else if (popupSource === \"popup\") {\n            if (!otp.loginURL) {\n                getToaster().show({\n                    intent: Intent.PRIMARY,\n                    message: t(\"popup.otps.click.no-url-available\"),\n                    timeout: 3000\n                });\n                return;\n            }\n            createNewTab(formatURL(otp.loginURL))\n                .catch(err => {\n                    console.error(err);\n                    getToaster().show({\n                        intent: Intent.DANGER,\n                        message: t(\"popup.otps.click.open-error\", { message: localisedErrorMessage(err) }),\n                        timeout: 10000\n                    });\n                });\n        }\n    }, [popupSource]);\n    if (loadingOTPs || (unlockedCount === 0 && otps.length > 0)) {\n        return (\n            <Spinner size={40} />\n        );\n    }\n    if (unlockedCount === 0) {\n        return (\n            <InvalidState\n                title={t(\"popup.all-locked.title\")}\n                description={t(\"popup.all-locked.description\")}\n                icon=\"folder-close\"\n            />\n        );\n    } else if (preparedOTPs.length <= 0) {\n        return (\n            <InvalidState\n                title={t(\"popup.no-otps.title\")}\n                description={t(\"popup.no-otps.description\")}\n                icon=\"array-numeric\"\n            />\n        );\n    }\n    return (\n        <OTPItemList\n            onOTPClick={handleOTPClick}\n            otps={preparedOTPs}\n        />\n    );\n}\n\nexport function OTPsPageControls(props: OTPsPageControlsProps) {\n    return (\n        <>\n        </>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/pages/SaveDialogPage.tsx",
    "content": "import React, { useCallback, useContext, useEffect, useRef, useState } from \"react\";\nimport styled from \"styled-components\";\nimport { EntryType } from \"buttercup\";\nimport { SiteIcon } from \"@buttercup/ui\";\nimport { Button, Card, H5, Intent, NonIdealState, Spinner } from \"@blueprintjs/core\";\nimport { LaunchContext } from \"../contexts/LaunchContext.js\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { useLoginCredentials } from \"../../hooks/credentials.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { clearSavedLoginPrompt } from \"../../queries/loginMemory.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\nimport { extractDomain } from \"../../../shared/library/domain.js\";\nimport { BackgroundMessageType } from \"../../types.js\";\nimport { disableDomainForLogin } from \"../../queries/disabledDomains.js\";\nimport { sendBackgroundMessage } from \"../../../shared/services/messaging.js\";\n\nconst Buttons = styled.div`\n    width: 100%;\n    display: flex;\n    flex-direction: row;\n    justify-content: flex-end;\n    align-items: center;\n    flex: 0 0 auto;\n\n    > button:not(:last-child) {\n        margin-right: 6px;\n    }\n`;\nconst Container = styled.div`\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    padding: 10px;\n    display: flex;\n    flex-direction: column;\n`;\nconst CredentialsCard = styled(Card)`\n    min-width: 280px;\n    padding: 10px;\n    margin-right: 0px !important;\n\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n\n    &:not(:last-child) {\n        margin-right: 8px;\n    }\n`;\nconst CredentialsHeading = styled.h5`\n    margin: 0 0 5px 0;\n    display: flex;\n    flex-direction: row;\n    justify-content: flex-start;\n    align-items: center;\n`;\nconst CredentialsIcon = styled(SiteIcon)`\n    width: 24px;\n    height: 24px;\n    margin-right: 6px;\n\n    > img {\n        width: 100%;\n        height: 100%;\n    }\n`;\nconst Heading = styled(H5)`\n    flex: 0 0 auto;\n`;\nconst Scroller = styled.div`\n    width: 100%;\n    overflow-x: hidden;\n    overflow-y: scroll;\n    margin-bottom: 8px;\n    flex: 1 1 auto;\n`;\nconst SpinnerContainer = styled.div`\n    width: 100%;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    flex: 1 1 auto;\n\n`;\nconst URL = styled.span`\n    font-size: 12px;\n`;\n\nexport function SaveDialogPage() {\n    const { loginID } = useContext(LaunchContext);\n    const credentials = useLoginCredentials(loginID);\n    const errorShownRef = useRef<boolean>(false);\n    const [disableConfirm, setDisableConfirm] = useState<boolean>(false);\n    const handleViewClick = useCallback(async () => {\n        try {\n            // Open save page and close dialog\n            await sendBackgroundMessage({\n                type: BackgroundMessageType.OpenSaveCredentialsPage\n            });\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.generic\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, [loginID]);\n    const handleCloseClick = useCallback(async () => {\n        if (!loginID) return;\n        try {\n            // Clear prompt and close dialog\n            await clearSavedLoginPrompt(loginID);\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.generic\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, [loginID]);\n    const handleDisableClick = useCallback(async () => {\n        if (!loginID) return;\n        if (!disableConfirm) {\n            setDisableConfirm(true);\n            return;\n        }\n        try {\n            // Disable domain and close dialog\n            await disableDomainForLogin(loginID);\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.generic\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, [disableConfirm, loginID]);\n    useEffect(() => {\n        if (credentials.error && !errorShownRef.current) {\n            errorShownRef.current = true;\n            console.error(credentials.error);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"save-credentials-dialog.credentials-fetch-error\", { message: localisedErrorMessage(credentials.error) }),\n                timeout: 10000\n            });\n        }\n    }, [credentials]);\n    return (\n        <Container>\n            <Heading>{t(\"save-credentials-dialog.title\")}</Heading>\n            {credentials.loading && (\n                <SpinnerContainer>\n                    <Spinner size={50} />\n                </SpinnerContainer>\n            )}\n            {credentials.error && (\n                <Scroller>\n                    <NonIdealState\n                        icon=\"high-priority\"\n                        title={t(\"save-credentials-dialog.error-title\")}\n                        description={t(\"save-credentials-dialog.error-description\")}\n                    />\n                </Scroller>\n            )}\n            {credentials.value && (\n                (\n                    <Scroller>\n                        <p>{t(\"save-credentials-dialog.description\")}</p>\n                        <p><strong>{t(\"save-credentials-dialog.last-login-heading\")}:</strong></p>\n                        <CredentialsCard>\n                            <CredentialsHeading>\n                                <CredentialsIcon\n                                    domain={extractDomain(credentials.value.url)}\n                                    type={EntryType.Website}\n                                />\n                                <span>{credentials.value.title}</span>\n                            </CredentialsHeading>\n                            <URL>{credentials.value.url}</URL>\n                        </CredentialsCard>\n                    </Scroller>\n                )\n            )}\n            <Buttons>\n                <Button\n                    disabled={!credentials.value}\n                    icon=\"saved\"\n                    intent={Intent.PRIMARY}\n                    onClick={handleViewClick}\n                    text={t(\"save-credentials-dialog.view-button\")}\n                />\n                <Button\n                    disabled={!credentials.value}\n                    icon=\"disable\"\n                    intent={disableConfirm ? Intent.DANGER : Intent.WARNING}\n                    onClick={handleDisableClick}\n                    text={disableConfirm ? t(\"save-credentials-dialog.disable-confirm-button\") : t(\"save-credentials-dialog.disable-button\")}\n                />\n                <Button\n                    onClick={handleCloseClick}\n                    text={t(\"save-credentials-dialog.close-button\")}\n                />\n            </Buttons>\n        </Container>\n    )\n}\n"
  },
  {
    "path": "source/popup/components/pages/SettingsPage.tsx",
    "content": "import React, { Fragment, useCallback, useMemo, useState } from \"react\";\nimport styled from \"styled-components\";\nimport { Alert, Button, Callout, Classes, Intent, MenuItem, Switch } from \"@blueprintjs/core\";\nimport { ItemRendererProps, Select } from \"@blueprintjs/select\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { useConfig } from \"../../../shared/hooks/config.js\";\nimport { ErrorMessage } from \"../../../shared/components/ErrorMessage.js\";\nimport { resetApplicationSettings } from \"../../services/reset.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\nimport { useAllLoginCredentials } from \"../../hooks/credentials.js\";\nimport { createNewTab, getExtensionURL } from \"../../../shared/library/extension.js\";\nimport { InputButtonType } from \"../../types.js\";\n\ninterface InputButtonTypeItem {\n    name: string, type: InputButtonType;\n}\n\nconst Container = styled.div`\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: stretch;\n\n    > .${Classes.CALLOUT}:not(:last-child) {\n        margin-bottom: 8px;\n    }\n`;\nconst SettingSection = styled(Callout)`\n    margin: 0px 12px;\n    width: calc(100% - 24px);\n    padding: 9px;\n`;\n\nfunction renderInputButtonTypeItem(item: InputButtonTypeItem, props: ItemRendererProps) {\n    const { handleClick, handleFocus, modifiers } = props;\n    return (\n        <MenuItem\n            active={modifiers.active}\n            disabled={modifiers.disabled}\n            key={item.type}\n            label={item.type === InputButtonType.LargeButton ? t(\"config.default-hint\") : \"\"}\n            onClick={handleClick}\n            onFocus={handleFocus}\n            roleStructure=\"listoption\"\n            text={item.name}\n        />\n    );\n}\n\nexport function SettingsPage() {\n    const [config, configError, setValue] = useConfig();\n    const [showConfirmReset, setShowConfirmReset] = useState<boolean>(false);\n    const { value: allCredentials } = useAllLoginCredentials();\n    const hasSavedCredentials = useMemo(() => Array.isArray(allCredentials) && allCredentials.length > 0, [allCredentials]);\n    const inputButtonItems: Array<InputButtonTypeItem> = useMemo(() => Object.values(InputButtonType).map(type => ({\n        name: t(`config.input-button-type.${type}`),\n        type\n    })), []);\n    const activeInputButtonItem = useMemo(\n        () => inputButtonItems.find(item => item.type === config?.inputButtonDefault),\n        [config, inputButtonItems]);\n    const handleInputButtonItemSelect = useCallback((item: InputButtonTypeItem) => {\n        setValue(\"inputButtonDefault\", item.type);\n    }, [setValue]);\n    const handleOpenDisabledDomains = useCallback(async () => {\n        try {\n            await createNewTab(getExtensionURL(\"full.html#/disabled-domains\"));\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.generic\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, []);\n    const handleReviewSavedCredentials = useCallback(async () => {\n        try {\n            await createNewTab(getExtensionURL(\"full.html#/save-credentials\"));\n        } catch (err) {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.generic\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        }\n    }, []);\n    const handleReset = useCallback(() => {\n        setShowConfirmReset(false);\n        resetApplicationSettings().catch(err => {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"error.reset\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        });\n    }, []);\n    return (\n        <Container>\n            {configError && (\n                <ErrorMessage message={configError.message} />\n            )}\n            {config && (\n                <Fragment>\n                    <SettingSection title={t(\"config.section.theme\")}>\n                        <Switch\n                            checked={config.useSystemTheme}\n                            label={t(\"config.setting.useSystemTheme\")}\n                            onChange={evt => setValue(\"useSystemTheme\", evt.currentTarget.checked)}\n                        />\n                        <Switch\n                            disabled={config.useSystemTheme}\n                            checked={config.theme === \"dark\"}\n                            innerLabel={config.theme === \"dark\" ? t(\"theme.dark\") : t(\"theme.light\")}\n                            label={t(\"config.setting.theme\")}\n                            onChange={evt => setValue(\"theme\", evt.currentTarget.checked ? \"dark\" : \"light\")}\n                        />\n                    </SettingSection>\n                    <SettingSection title={t(\"config.section.logins\")}>\n                        <Switch\n                            checked={config.saveNewLogins}\n                            label={t(\"config.setting.saveNewLogins\")}\n                            onChange={evt => setValue(\"saveNewLogins\", evt.currentTarget.checked)}\n                        />\n                        <Button\n                            intent={Intent.NONE}\n                            onClick={handleOpenDisabledDomains}\n                        >\n                            {t(\"config.setting.manageDisabledDomains\")}\n                        </Button>\n                        {hasSavedCredentials && (\n                            <Fragment>\n                                <Button\n                                    intent={Intent.PRIMARY}\n                                    onClick={handleReviewSavedCredentials}\n                                >\n                                    {t(\"config.setting.reviewSavedLogins\")}\n                                </Button>\n                            </Fragment>\n                        )}\n                    </SettingSection>\n                    <SettingSection title={t(\"config.section.forms\")}>\n                        <Select\n                            activeItem={activeInputButtonItem}\n                            fill\n                            filterable={false}\n                            items={inputButtonItems}\n                            onItemSelect={handleInputButtonItemSelect}\n                            itemRenderer={renderInputButtonTypeItem}\n                        >\n                            <Button\n                                text={t(`config.input-button-type.${config.inputButtonDefault}`)}\n                                rightIcon=\"double-caret-vertical\"\n                                // placeholder=\"Select a film\"\n                            />\n                        </Select>\n                    </SettingSection>\n                    <SettingSection title={t(\"config.section.privacy\")}>\n                        <Switch\n                            checked={config.entryIcons}\n                            label={t(\"config.setting.entryIcons\")}\n                            onChange={evt => setValue(\"entryIcons\", evt.currentTarget.checked)}\n                        />\n                    </SettingSection>\n                    <SettingSection title={t(\"config.section.advanced\")}>\n                        <Button\n                            intent={Intent.DANGER}\n                            onClick={() => setShowConfirmReset(true)}\n                            text={t(\"config.setting.reset\")}\n                        />\n                    </SettingSection>\n                    <Alert\n                        cancelButtonText={t(\"config.reset-dialog.cancel-button\")}\n                        confirmButtonText={t(\"config.reset-dialog.confirm-button\")}\n                        icon=\"clean\"\n                        intent={Intent.DANGER}\n                        isOpen={showConfirmReset}\n                        onCancel={() => setShowConfirmReset(false)}\n                        onConfirm={handleReset}\n                    >\n                        <p>{t(\"config.reset-dialog.message\")}</p>\n                    </Alert>\n                </Fragment>\n            )}\n        </Container>\n    );\n}\n\nexport function SettingsPageControls() {\n    return (\n        <>\n        </>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/pages/VaultsPage.tsx",
    "content": "import React, { useCallback } from \"react\";\nimport styled from \"styled-components\";\nimport { Button, ButtonGroup, Intent, NonIdealState, Spinner } from \"@blueprintjs/core\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { VaultItemList } from \"../vaults/VaultItemList.js\";\nimport { useDesktopConnectionState, useVaultSources } from \"../../hooks/desktop.js\";\nimport { DesktopConnectionState } from \"../../types.js\";\n\ninterface VaultsPageProps {\n    onConnectClick: () => Promise<void>;\n    onReconnectClick: () => Promise<void>;\n}\n\nconst Container = styled.div`\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: stretch;\n`;\nconst InvalidState = styled(NonIdealState)`\n    margin-top: 28px;\n`;\n\nexport function VaultsPage(props: VaultsPageProps) {\n    const desktopState = useDesktopConnectionState();\n    return (\n        <Container>\n            {desktopState === DesktopConnectionState.NotConnected && (\n                <InvalidState\n                    title={t(\"popup.vaults.no-connection.title\")}\n                    description={t(\"popup.vaults.no-connection.description\")}\n                    icon=\"offline\"\n                    action={(\n                        <Button\n                            icon=\"link\"\n                            onClick={props.onConnectClick}\n                            text={t(\"popup.vaults.no-connection.action-text\")}\n                        />\n                    )}\n                />\n            )}\n            {desktopState === DesktopConnectionState.Connected && (\n                <VaultsPageList />\n            )}\n            {desktopState === DesktopConnectionState.Pending && (\n                <Spinner size={40} />\n            )}\n            {desktopState === DesktopConnectionState.Error && (\n                <InvalidState\n                    title={t(\"popup.connection.check-error.title\")}\n                    description={t(\"popup.connection.check-error.description\")}\n                    icon=\"error\"\n                    intent={Intent.DANGER}\n                    action={(\n                        <Button\n                            icon=\"link\"\n                            onClick={props.onReconnectClick}\n                            text={t(\"popup.vaults.no-connection.action-text\")}\n                        />\n                    )}\n                />\n            )}\n        </Container>\n    );\n}\n\nfunction VaultsPageList() {\n    const sources = useVaultSources();\n    if (sources.length === 0) {\n        return (\n            <InvalidState\n                title={t(\"popup.vaults.empty.title\")}\n                description={t(\"popup.vaults.empty.description\")}\n                icon=\"folder-open\"\n            />\n        );\n    }\n    return (\n        <VaultItemList\n            vaults={sources}\n        />\n    );\n}\n\nexport function VaultsPageControls() {\n    const handleAddVaultClick = useCallback(() => {\n        // openAddVaultPage();\n    }, []);\n    return (\n        <ButtonGroup>\n            <Button\n                icon=\"add\"\n                minimal\n                onClick={handleAddVaultClick}\n                title={t(\"popup.vaults.controls.add-vault\")}\n            />\n            <Button\n                icon=\"lock\"\n                minimal\n                title={t(\"popup.vaults.controls.lock-vaults\")}\n            />\n        </ButtonGroup>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/vaults/VaultItem.tsx",
    "content": "import React, { useCallback } from \"react\";\nimport styled from \"styled-components\";\nimport cn from \"classnames\";\nimport { Button, ButtonGroup, Classes, Text } from \"@blueprintjs/core\";\nimport { VaultSourceStatus } from \"buttercup\";\nimport { VAULT_TYPES } from \"../../../shared/library/vaultTypes.js\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { VaultSourceDescription } from \"../../types.js\";\nimport { VaultStateIndicator } from \"./VaultStateIndicator.js\";\n\ninterface VaultItemProps {\n    onLockClick: () => void;\n    onUnlockClick: () => void;\n    vault: VaultSourceDescription;\n}\n\nconst CenteredText = styled(Text)`\n    display: flex;\n    align-items: center;\n`;\nconst Container = styled.div`\n    border-radius: 3px;\n    padding: 0.5rem;\n    background-color: ${p => (p.isActive ? p.theme.listItemHover : null)};\n    position: relative;\n    &:hover {\n        background-color: ${p => p.theme.listItemHover};\n    }\n`;\nconst DetailRow = styled.div`\n    margin-left: 0.5rem;\n    overflow: hidden;\n    flex: 1;\n`;\nconst Title = styled(Text)`\n    margin-bottom: 0.3rem;\n`;\nconst VaultIcon = styled.img`\n    width: calc(100% - 6px);\n    height: calc(100% - 6px);\n    margin: 3px;\n    overflow: hidden;\n`;\nconst VaultImageBackground = styled.div`\n    width: 2.5rem;\n    height: 2.5rem;\n    flex: 0 0 auto;\n    background-color: ${p => p.theme.backgroundColor};\n    display: flex;\n    justify-content: flex-start;\n    align-items: flex-start;\n    border-radius: 3px;\n    border: 1px solid ${p => p.theme.listItemHover};\n`;\nconst VaultRow = styled.div`\n    flex: 1;\n    width: 100%;\n    display: flex;\n    cursor: pointer;\n    align-items: center;\n`;\n\nexport function VaultItem(props: VaultItemProps) {\n    const {\n        onLockClick,\n        onUnlockClick,\n        vault\n    } = props;\n    const vaultImage = VAULT_TYPES[vault.type].image;\n    const handleVaultClick = useCallback(() => {\n        // @todo\n    }, [vault]);\n    const handleLockUnlockClick = useCallback(() => {\n        if (vault.state === VaultSourceStatus.Locked) {\n            onUnlockClick();\n        } else if (vault.state === VaultSourceStatus.Unlocked) {\n            onLockClick();\n        }\n    }, [vault, onUnlockClick]);\n    return (\n        <Container>\n            <VaultRow>\n                <VaultImageBackground>\n                    <VaultIcon src={vaultImage} />\n                </VaultImageBackground>\n                <DetailRow onClick={handleVaultClick}>\n                    <Title title={vault.name}>\n                        <Text ellipsize>{vault.name}</Text>\n                    </Title>\n                    <CenteredText ellipsize className={cn(Classes.TEXT_SMALL, Classes.TEXT_MUTED)}>\n                        <VaultStateIndicator state={vault.state} />&nbsp;\n                        {t(`vault-state.${vault.state}`)}\n                    </CenteredText>\n                </DetailRow>\n                <ButtonGroup>\n                    <Button\n                        disabled={vault.state === VaultSourceStatus.Pending}\n                        icon={\n                            vault.state === VaultSourceStatus.Locked\n                                ? \"unlock\"\n                                : vault.state === VaultSourceStatus.Unlocked\n                                    ? \"lock\"\n                                    : \"help\"\n                        }\n                        loading={vault.state === VaultSourceStatus.Pending}\n                        minimal\n                        onClick={handleLockUnlockClick}\n                        title={\n                            vault.state === VaultSourceStatus.Locked\n                                ? t(\"popup.vault.unlock\")\n                                : vault.state === VaultSourceStatus.Unlocked\n                                    ? t(\"popup.vault.lock\")\n                                    : t(\"popup.vault.state-pending\")\n                        }\n                    />\n                </ButtonGroup>\n            </VaultRow>\n        </Container>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/vaults/VaultItemList.tsx",
    "content": "import React, { Fragment, useCallback } from \"react\";\nimport styled from \"styled-components\";\nimport { Divider, Intent } from \"@blueprintjs/core\";\nimport { VaultItem } from \"./VaultItem.js\";\nimport { promptLockVault, promptUnlockVault } from \"../../queries/desktop.js\";\nimport { getToaster } from \"../../../shared/services/notifications.js\";\nimport { t } from \"../../../shared/i18n/trans.js\";\nimport { localisedErrorMessage } from \"../../../shared/library/error.js\";\nimport { VaultSourceDescription } from \"../../types.js\";\n\ninterface VaultItemListProps {\n    vaults: Array<VaultSourceDescription>;\n}\n\nconst ScrollList = styled.div`\n    max-height: 100%;\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: stretch;\n`;\n\nexport function VaultItemList(props: VaultItemListProps) {\n    const handleVaultLockClick = useCallback((vault: VaultSourceDescription) => {\n        promptLockVault(vault.id)\n            .then(locked => {\n                if (locked) {\n                    getToaster().show({\n                        intent: Intent.SUCCESS,\n                        message: t(\"popup.vault.locking.success\", { vault: vault.name }),\n                        timeout: 4000\n                    });\n                }\n            })\n            .catch(err => {\n                console.error(err);\n                getToaster().show({\n                    intent: Intent.DANGER,\n                    message: t(\"popup.vault.locking.error\", { message: localisedErrorMessage(err) }),\n                    timeout: 10000\n                });\n            });\n    }, []);\n    const handleVaultUnlockClick = useCallback((vault: VaultSourceDescription) => {\n        promptUnlockVault(vault.id).catch(err => {\n            console.error(err);\n            getToaster().show({\n                intent: Intent.DANGER,\n                message: t(\"popup.vault.unlocking.error\", { message: localisedErrorMessage(err) }),\n                timeout: 10000\n            });\n        });\n    }, []);\n    return (\n        <>\n            <ScrollList>\n                {props.vaults.map((vault) => (\n                    <Fragment key={vault.id}>\n                        <VaultItem\n                            onLockClick={() => handleVaultLockClick(vault)}\n                            onUnlockClick={() => handleVaultUnlockClick(vault)}\n                            vault={vault}\n                        />\n                        <Divider />\n                    </Fragment>\n                ))}\n            </ScrollList>\n        </>\n    );\n}\n"
  },
  {
    "path": "source/popup/components/vaults/VaultStateIndicator.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { VaultSourceStatus } from \"buttercup\";\nimport { Colors, Icon, IconName } from \"@blueprintjs/core\";\nimport styled from \"styled-components\";\n\ninterface VaultStateIndicatorProps {\n    state: VaultSourceStatus;\n}\n\n// const StateIcon = styled(Icon)`\n//     fill: ${Colors.GREEN3};\n// `;\n\nexport function VaultStateIndicator(props: VaultStateIndicatorProps) {\n    const [colour, icon] = useMemo<[string, IconName]>(() => {\n        switch (props.state) {\n            case VaultSourceStatus.Unlocked:\n                return [Colors.GREEN4, \"unlock\"];\n            case VaultSourceStatus.Locked:\n                return [Colors.RED4, \"lock\"];\n            default:\n                return [Colors.ORANGE4, \"exchange\"];\n        }\n    }, [props.state]);\n    return (\n        <Icon color={colour} icon={icon} size={10} />\n    );\n}\n"
  },
  {
    "path": "source/popup/hooks/credentials.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { AsyncResult, useAsync } from \"../../shared/hooks/async.js\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType, UsedCredentials } from \"../types.js\";\nimport { useCallback } from \"react\";\n\nasync function getAllCredentials(): Promise<Array<UsedCredentials | null>> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetSavedCredentials\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching saved credentials\");\n    }\n    return resp.credentials ?? [];\n}\n\nasync function getCredentialsForID(id: string): Promise<UsedCredentials | null> {\n    const resp = await sendBackgroundMessage({\n        credentialsID: id,\n        type: BackgroundMessageType.GetSavedCredentialsForID\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching saved credentials\");\n    }\n    return resp.credentials?.[0] ?? null;\n}\n\nexport function useAllLoginCredentials(): AsyncResult<Array<UsedCredentials | null>> {\n    const getCredentials = useCallback(() => getAllCredentials(), []);\n    const result = useAsync(getCredentials, [getCredentials]);\n    return result;\n}\n\nexport function useLoginCredentials(loginID: string | null): AsyncResult<UsedCredentials | null> {\n    const getCredentials = useCallback(async () => (loginID ? await getCredentialsForID(loginID) : null), [loginID]);\n    const result = useAsync(getCredentials, [getCredentials]);\n    return result;\n}\n"
  },
  {
    "path": "source/popup/hooks/desktop.ts",
    "content": "import { Intent } from \"@blueprintjs/core\";\nimport { SearchResult } from \"buttercup\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { useAsync, useAsyncWithTimer } from \"../../shared/hooks/async.js\";\nimport { t } from \"../../shared/i18n/trans.js\";\nimport { localisedErrorMessage } from \"../../shared/library/error.js\";\nimport { getToaster } from \"../../shared/services/notifications.js\";\nimport {\n    getDesktopConnectionAvailable,\n    getOTPs,\n    getRecentEntries,\n    getVaultSources,\n    searchEntriesByTerm,\n    searchEntriesByURL\n} from \"../queries/desktop.js\";\nimport { DesktopConnectionState, OTP, VaultSourceDescription } from \"../types.js\";\n\nconst OTPS_UPDATE_DELAY = 7500;\nconst SEARCH_DEBOUNCE = 600;\nconst SOURCES_UPDATE_DELAY = 3500;\n\nexport function useDesktopConnectionState(): DesktopConnectionState {\n    const checkConnection = useCallback(async () => {\n        const isAvailable = await getDesktopConnectionAvailable();\n        return isAvailable;\n    }, []);\n    const { value, error } = useAsync(checkConnection, [checkConnection]);\n    useEffect(() => {\n        if (!error) return;\n        console.error(error);\n        const message = t(\"error.desktop.connection-check-failed\", { message: localisedErrorMessage(error) });\n        getToaster().show(\n            {\n                intent: Intent.DANGER,\n                message,\n                timeout: 10000\n            },\n            btoa(message)\n        );\n    }, [error]);\n    if (error) {\n        return DesktopConnectionState.Error;\n    } else if (value === true) {\n        return DesktopConnectionState.Connected;\n    } else if (value === false) {\n        return DesktopConnectionState.NotConnected;\n    }\n    return DesktopConnectionState.Pending;\n}\n\nexport function useEntriesForURL(url: string | null): Array<SearchResult> {\n    const performSearch = useCallback(async () => {\n        if (!url) return [];\n        const results = await searchEntriesByURL(url);\n        return results;\n    }, [url]);\n    const { value, error } = useAsync(performSearch, [performSearch]);\n    useEffect(() => {\n        if (!error) return;\n        console.error(error);\n        getToaster().show({\n            intent: Intent.DANGER,\n            message: t(\"error.desktop.search-failed\", { message: localisedErrorMessage(error) }),\n            timeout: 10000\n        });\n    }, [error]);\n    return value === null ? [] : value;\n}\n\n/**\n * Use all available OTP items\n * @returns A tuple: First the list of OTPs, second a loading state\n */\nexport function useOTPs(): [Array<OTP>, boolean] {\n    const getItems = useCallback(getOTPs, []);\n    const { value: rawOTPs, loading, error } = useAsyncWithTimer(getItems, OTPS_UPDATE_DELAY, [getItems]);\n    const [otps, setOTPs] = useState<Array<OTP>>([]);\n    useEffect(() => {\n        if (!error) return;\n        console.error(error);\n        getToaster().show({\n            intent: Intent.DANGER,\n            message: t(\"error.desktop.otps-fetch-failed\", { message: localisedErrorMessage(error) }),\n            timeout: 10000\n        });\n    }, [error]);\n    useEffect(() => {\n        if ((error || !rawOTPs) && otps.length > 0) {\n            setOTPs([]);\n            return;\n        }\n        if (\n            Array.isArray(rawOTPs) &&\n            (rawOTPs.length !== otps.length ||\n                rawOTPs.some(\n                    (raw) =>\n                        !otps.find(\n                            (otp) =>\n                                raw.entryID === otp.entryID &&\n                                raw.otpURL === otp.otpURL &&\n                                raw.sourceID === otp.sourceID\n                        )\n                ))\n        ) {\n            setOTPs(rawOTPs);\n        }\n    }, [rawOTPs, otps, error]);\n    return [otps, loading];\n}\n\nexport function useRecentEntries(): Array<SearchResult> {\n    const performSearch = useCallback(getRecentEntries, []);\n    const { value, error } = useAsync(performSearch, [performSearch]);\n    useEffect(() => {\n        if (!error) return;\n        console.error(error);\n        getToaster().show({\n            intent: Intent.DANGER,\n            message: t(\"error.desktop.search-failed\", { message: localisedErrorMessage(error) }),\n            timeout: 10000\n        });\n    }, [error]);\n    return value === null ? [] : value;\n}\n\nexport function useSearchedEntries(term: string): Array<SearchResult> {\n    const [currentTerm, setCurrentTerm] = useState<string>(\"\");\n    const performSearch = useCallback(async () => {\n        if (/^\\s*$/.test(currentTerm)) return [];\n        const results = await searchEntriesByTerm(currentTerm);\n        return results;\n    }, [currentTerm]);\n    const { value, error } = useAsync(performSearch, [performSearch]);\n    useEffect(() => {\n        if (!error) return;\n        console.error(error);\n        getToaster().show({\n            intent: Intent.DANGER,\n            message: t(\"error.desktop.search-failed\", { message: localisedErrorMessage(error) }),\n            timeout: 10000\n        });\n    }, [error]);\n    useEffect(() => {\n        const timeout = setTimeout(() => {\n            setCurrentTerm(term);\n        }, SEARCH_DEBOUNCE);\n        return () => {\n            clearTimeout(timeout);\n        };\n    }, [term]);\n    return value === null ? [] : value;\n}\n\nexport function useVaultSources(): Array<VaultSourceDescription> {\n    const getSources = useCallback(getVaultSources, []);\n    const { value: sources, error } = useAsyncWithTimer(getSources, SOURCES_UPDATE_DELAY, [getSources]);\n    useEffect(() => {\n        if (!error) return;\n        console.error(error);\n        getToaster().show({\n            intent: Intent.DANGER,\n            message: t(\"error.desktop.sources-fetch-failed\", { message: localisedErrorMessage(error) }),\n            timeout: 10000\n        });\n    }, [error]);\n    return sources === null || error ? [] : sources;\n}\n"
  },
  {
    "path": "source/popup/hooks/document.ts",
    "content": "import { useEffect } from \"react\";\n\nexport function useBodyClass(className: string): void {\n    const body = document.body;\n    useEffect(() => {\n        body.classList.add(className);\n        return () => {\n            body.classList.remove(className);\n        };\n    }, [className]);\n}\n"
  },
  {
    "path": "source/popup/hooks/otp.ts",
    "content": "import * as OTPAuth from \"otpauth\";\nimport { Layerr } from \"layerr\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useTimer } from \"../../shared/hooks/timer.js\";\nimport { t } from \"../../shared/i18n/trans.js\";\nimport { OTP } from \"../types.js\";\n\nexport interface PreparedOTP extends OTP {\n    digits: string;\n    errored: boolean;\n    period: number;\n    remaining: number;\n}\n\nfunction getPeriodTimeLeft(period: number): number {\n    return period - (Math.floor(Date.now() / 1000) % period);\n}\n\nexport function usePreparedOTPs(otps: Array<OTP>): Array<PreparedOTP> {\n    const [parsedOTPs, setParsedOTPs] = useState<Record<string, OTPAuth.TOTP | Error>>({});\n    const [periods, setPeriods] = useState<Record<string, [number, number]>>({});\n    useEffect(() => {\n        const newParsed = { ...parsedOTPs };\n        let changed = false;\n        for (const otp of otps) {\n            if (newParsed[otp.otpURL]) continue;\n            try {\n                const otpInst = OTPAuth.URI.parse(otp.otpURL) as OTPAuth.TOTP;\n                if (!otpInst.period) {\n                    throw new Error(`OTP is invalid (no period): ${otp.otpURL}`);\n                }\n                newParsed[otp.otpURL] = otpInst;\n            } catch (err) {\n                newParsed[otp.otpURL] = err;\n                console.error(err);\n            }\n            changed = true;\n        }\n        for (const parsed in newParsed) {\n            const otp = otps.find((o) => o.otpURL === parsed);\n            if (!otp) {\n                // Remove non-existing\n                delete newParsed[parsed];\n                changed = true;\n            }\n        }\n        if (changed) {\n            setParsedOTPs(newParsed);\n        }\n    }, [otps, parsedOTPs]);\n    useTimer(\n        () => {\n            setPeriods(\n                Object.keys(parsedOTPs).reduce(\n                    (newPeriods, url) => ({\n                        ...newPeriods,\n                        [url]:\n                            parsedOTPs[url] instanceof OTPAuth.TOTP\n                                ? [\n                                      (parsedOTPs[url] as OTPAuth.TOTP).period,\n                                      getPeriodTimeLeft((parsedOTPs[url] as OTPAuth.TOTP).period)\n                                  ]\n                                : [0, 0]\n                    }),\n                    {}\n                )\n            );\n        },\n        1000,\n        [parsedOTPs]\n    );\n    const prepared: Array<PreparedOTP> = useMemo(\n        () =>\n            otps.reduce((output: Array<PreparedOTP>, otp) => {\n                const parsed = parsedOTPs[otp.otpURL];\n                const periodInfo = periods[otp.otpURL];\n                if (!parsed || !Array.isArray(periodInfo)) return output;\n                let errored = false,\n                    code: string = \"\";\n                try {\n                    if (parsed instanceof Error) {\n                        throw new Layerr(parsed, \"OTP was not parseable\");\n                    }\n                    code = parsed.generate();\n                } catch (err) {\n                    console.error(err);\n                    code = t(\"popup.entries.otp.code-error\");\n                    errored = true;\n                }\n                return [\n                    ...output,\n                    {\n                        ...otp,\n                        otpTitle: parsed instanceof OTPAuth.TOTP ? parsed.label : t(\"popup.entries.otp.label-error\"),\n                        digits: code,\n                        errored,\n                        period: periodInfo[0],\n                        remaining: periodInfo[1]\n                    }\n                ];\n            }, []),\n        [otps, parsedOTPs, periods]\n    );\n    return prepared;\n}\n"
  },
  {
    "path": "source/popup/hooks/tab.ts",
    "content": "import { useMemo } from \"react\";\nimport { useAsync } from \"../../shared/hooks/async.js\";\nimport { getCurrentTab } from \"../../shared/library/extension.js\";\n\nexport function useCurrentTabURL(): [loading: boolean, url: string | null] {\n    const { loading, value } = useAsync(getCurrentTab, [], {\n        clearOnExec: false,\n        updateInterval: 5000\n    });\n    const tabURL = useMemo(() => {\n        if (!value) return null;\n        return value.url ?? null;\n    }, [value]);\n    return [loading, tabURL];\n}\n"
  },
  {
    "path": "source/popup/index.pug",
    "content": "doctype html\nhtml\n    head\n        title Menu ⋅ Buttercup\n        meta(charset=\"utf-8\")\n        link(rel=\"icon\", type=\"image/png\", href=require(\"../../resources/buttercup-256.png\").default)\n        link(rel=\"stylesheet\" href=\"./styles/popup.sass\")\n        script(defer, src=\"./applications/popup.tsx\")\n    body\n        div#root\n"
  },
  {
    "path": "source/popup/queries/desktop.ts",
    "content": "import { SearchResult, VaultSourceID } from \"buttercup\";\nimport { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType, OTP, VaultSourceDescription } from \"../types.js\";\n\nexport async function clearDesktopConnectionAuth(): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.ClearDesktopAuthentication\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed clearing desktop authentication\");\n    }\n}\n\nexport async function getDesktopConnectionAvailable(): Promise<boolean> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.CheckDesktopConnection\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed checking desktop connection availability\");\n    }\n    return resp.available ?? false;\n}\n\nexport async function getOTPs(): Promise<Array<OTP>> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetOTPs\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching OTPs from desktop application\");\n    }\n    return resp.otps ?? [];\n}\n\nexport async function getRecentEntries(): Promise<Array<SearchResult>> {\n    const resp = await sendBackgroundMessage({\n        count: 5,\n        type: BackgroundMessageType.GetRecentEntries\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching recent entries from desktop application\");\n    }\n    return resp.searchResults ?? [];\n}\n\nexport async function getVaultSources(): Promise<Array<VaultSourceDescription>> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetDesktopVaultSources\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching vaults from desktop application\");\n    }\n    return resp.vaultSources ?? [];\n}\n\nexport async function initiateDesktopConnectionRequest(): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.InitiateDesktopConnection\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed initiating desktop connection\");\n    }\n}\n\nexport async function promptLockVault(sourceID: VaultSourceID): Promise<boolean> {\n    const resp = await sendBackgroundMessage({\n        sourceID,\n        type: BackgroundMessageType.PromptLockSource\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed locking vault\");\n    }\n    return !!resp.locked;\n}\n\nexport async function promptUnlockVault(sourceID: VaultSourceID): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        sourceID,\n        type: BackgroundMessageType.PromptUnlockSource\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed prompting vault unlock\");\n    }\n}\n\nexport async function searchEntriesByTerm(term: string): Promise<Array<SearchResult>> {\n    const resp = await sendBackgroundMessage({\n        searchTerm: term,\n        type: BackgroundMessageType.SearchEntriesByTerm\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching search results from desktop application\");\n    }\n    return resp.searchResults ?? [];\n}\n\nexport async function searchEntriesByURL(url: string): Promise<Array<SearchResult>> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.SearchEntriesByURL,\n        url\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching URL results from desktop application\");\n    }\n    return resp.searchResults ?? [];\n}\n"
  },
  {
    "path": "source/popup/queries/disabledDomains.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../types.js\";\n\nexport async function disableDomainForLogin(loginID: string): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        credentialsID: loginID,\n        type: BackgroundMessageType.DisableSavePromptForCredentials\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed disabling save prompt for login\");\n    }\n}\n"
  },
  {
    "path": "source/popup/queries/loginMemory.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../types.js\";\n\nexport async function clearSavedLoginPrompt(loginID: string): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        credentialsID: loginID,\n        type: BackgroundMessageType.ClearSavedCredentialsPrompt\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed clearing saved credentials prompt\");\n    }\n}\n"
  },
  {
    "path": "source/popup/services/clipboard.ts",
    "content": "import { Layerr } from \"layerr\";\n\nexport async function copyTextToClipboard(text: string): Promise<void> {\n    // Navigator clipboard api needs a secure context (https)\n    if (navigator.clipboard && window.isSecureContext) {\n        await navigator.clipboard.writeText(text);\n    } else {\n        // Use the 'out of viewport hidden text area' trick\n        const textArea = document.createElement(\"textarea\");\n        textArea.value = text;\n        // Move textarea out of the viewport so it's not visible\n        textArea.style.position = \"absolute\";\n        textArea.style.left = \"-999999px\";\n        document.body.prepend(textArea);\n        textArea.select();\n        try {\n            document.execCommand(\"copy\");\n        } catch (error) {\n            throw new Layerr(error, \"Failed copying text\");\n        } finally {\n            textArea.remove();\n        }\n    }\n}\n"
  },
  {
    "path": "source/popup/services/entry.ts",
    "content": "import { SearchResult } from \"buttercup\";\nimport { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../types.js\";\n\nexport async function openPageForEntry(item: SearchResult, autoLogin: boolean): Promise<boolean> {\n    const resp = await sendBackgroundMessage({\n        autoLogin,\n        entry: item,\n        type: BackgroundMessageType.OpenEntryPage\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed opening page\");\n    }\n    return resp.opened ?? false;\n}\n"
  },
  {
    "path": "source/popup/services/init.ts",
    "content": "import { initialise as initialiseI18n } from \"../../shared/i18n/trans.js\";\nimport { getLanguage } from \"../../shared/library/i18n.js\";\nimport { log } from \"./log.js\";\n\nexport async function initialise() {\n    log(\"initialising\");\n    global.popup = true;\n    await initialiseI18n(getLanguage());\n    log(\"initialisation complete\");\n}\n"
  },
  {
    "path": "source/popup/services/log.ts",
    "content": "import { Logger, createLog } from \"../../shared/library/log.js\";\n\nconst LOG_NAME = \"buttercup:browser:popup\";\n\nlet __logger: Logger;\n\nexport function log(...args: Array<any>): void {\n    if (!__logger) {\n        __logger = createLog(LOG_NAME, true);\n    }\n    return __logger(...args);\n}\n"
  },
  {
    "path": "source/popup/services/recents.ts",
    "content": "import { SearchResult } from \"buttercup\";\nimport { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../types.js\";\n\nexport async function trackEntryRecentUse(item: SearchResult): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        entry: item,\n        type: BackgroundMessageType.TrackRecentEntry\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed tracking entry use\");\n    }\n}\n"
  },
  {
    "path": "source/popup/services/reset.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../types.js\";\n\nexport async function resetApplicationSettings(): Promise<void> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.ResetSettings\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed resetting settings\");\n    }\n}\n"
  },
  {
    "path": "source/popup/services/tab.ts",
    "content": "import { SearchResult } from \"buttercup\";\nimport { Intent } from \"@blueprintjs/core\";\nimport { otpURIToDigits } from \"../../shared/library/otp.js\";\nimport { getToaster } from \"../../shared/services/notifications.js\";\nimport { localisedErrorMessage } from \"../../shared/library/error.js\";\nimport { t } from \"../../shared/i18n/trans.js\";\nimport { OTP, TabEvent, TabEventType } from \"../types.js\";\n\nexport function sendEntryResultToTabForInput(formID: string, entry: SearchResult): void {\n    if (!formID) {\n        throw new Error(\"No form ID found for dialog\");\n    }\n    sendTabEvent({\n        formID,\n        inputDetails: {\n            username: entry.properties.username ?? null,\n            password: entry.properties.password ?? null\n        },\n        type: TabEventType.InputDetails\n    });\n}\n\nexport function sendOTPToTabForInput(formID: string, otp: OTP): void {\n    if (!formID) {\n        throw new Error(\"No form ID found for dialog\");\n    }\n    let code: string = \"\";\n    try {\n        code = otpURIToDigits(otp.otpURL);\n    } catch (err) {\n        console.error(err);\n        getToaster().show({\n            intent: Intent.DANGER,\n            message: t(\"error.otp-generate\", { message: localisedErrorMessage(err) }),\n            timeout: 10000\n        });\n    }\n    sendTabEvent({\n        formID,\n        inputDetails: {\n            otp: code\n        },\n        type: TabEventType.InputDetails\n    });\n}\n\nfunction sendTabEvent(event: TabEvent, target: MessageEventSource = window.parent): void {\n    (target as Window).postMessage(event, \"*\");\n}\n"
  },
  {
    "path": "source/popup/state/app.ts",
    "content": "import { createStateObject } from \"obstate\";\nimport { PopupPage } from \"../types.js\";\n\nexport const APP_STATE = createStateObject<{\n    tab: PopupPage;\n}>({\n    tab: PopupPage.Entries\n});\n"
  },
  {
    "path": "source/popup/styles/popup.sass",
    "content": "html\n    margin: 0\n    height: 100%\n\nbody\n    width: 350px\n    height: 100%\n    padding: 0px\n    margin: 0\n    overflow: hidden\n\n    &.in-page\n        width: 100%\n\n        #root\n            min-height: unset !important\n\nh1\n    font-size: 20px\n    line-height: 20px\n\n#root \n    display: flex\n    flex-direction: column\n    min-height: 400px\n    height: 100%\n\n@import ../../shared/styles/base\n"
  },
  {
    "path": "source/popup/types.ts",
    "content": "export enum DesktopConnectionState {\n    Connected = \"connected\",\n    Error = \"error\",\n    NotConnected = \"notConnected\",\n    Pending = \"pending\"\n}\n\nexport * from \"../shared/types.js\";\n"
  },
  {
    "path": "source/shared/components/ConfirmDialog.tsx",
    "content": "import React, { Fragment, useCallback } from \"react\";\nimport { IconName } from \"@blueprintjs/icons\";\nimport { ChildElements } from \"../types.js\";\nimport { t } from \"../i18n/trans.js\";\nimport { Button, Dialog, DialogBody, DialogFooter, Intent } from \"@blueprintjs/core\";\n\ninterface ConfirmDialogProps {\n    children: ChildElements;\n    confirmIntent?: Intent;\n    confirmText?: string;\n    icon?: IconName;\n    isOpen: boolean;\n    onCancel?: () => void;\n    onClose: () => void;\n    onConfirm: () => void;\n    title: string;\n}\n\nconst NOOP = () => {};\n\nexport function ConfirmDialog(props: ConfirmDialogProps) {\n    const {\n        children,\n        confirmIntent = Intent.PRIMARY,\n        confirmText = t(\"confirm-dialog.confirm-default\"),\n        icon = \"info-sign\",\n        isOpen,\n        onCancel = NOOP,\n        onClose,\n        onConfirm,\n        title\n    } = props;\n    const handleCancel = useCallback(() => {\n        onCancel();\n        onClose();\n    }, [onCancel]);\n    const handleConfirm = useCallback(() => {\n        onConfirm();\n        onClose();\n    }, [onConfirm]);\n    return (\n        <Dialog\n            icon={icon}\n            isOpen={isOpen}\n            onClose={handleCancel}\n            title={title}\n        >\n            <DialogBody>\n                {children}\n            </DialogBody>\n            <DialogFooter\n                actions={(\n                    <Fragment>\n                        <Button intent={confirmIntent} onClick={handleConfirm}>{confirmText}</Button>\n                        <Button intent={Intent.NONE} onClick={handleCancel}>{t(\"confirm-dialog.cancel\")}</Button>\n                    </Fragment>\n                )}\n            />\n        </Dialog>\n    );\n}\n"
  },
  {
    "path": "source/shared/components/ErrorBoundary.tsx",
    "content": "import React, { Component } from \"react\";\nimport styled from \"styled-components\";\nimport { Callout, Intent } from \"@blueprintjs/core\";\nimport { t } from \"../i18n/trans.js\";\n\nconst ErrorCallout = styled(Callout)`\n    margin: 4px;\n    box-sizing: border-box;\n    width: calc(100% - 8px) !important;\n    height: calc(100% - 8px) !important;\n    overflow: scroll;\n`;\nconst PreForm = styled.pre`\n    margin: 0px;\n`;\n\nfunction stripBlanks(txt = \"\") {\n    return txt\n        .split(/(\\r\\n|\\n)/g)\n        .filter(ln => ln.trim().length > 0)\n        .join(\"\\n\");\n}\n\nexport class ErrorBoundary extends Component {\n    static getDerivedStateFromError(error: Error) {\n        return { error };\n    }\n\n    state: {\n        error: null | Error;\n        errorStack: string | null;\n    } = {\n        error: null,\n        errorStack: null\n    };\n\n    componentDidCatch(error: Error, errorInfo) {\n        this.setState({ errorStack: errorInfo.componentStack || null });\n    }\n\n    render() {\n        if (!this.state.error) {\n            return this.props.children || null;\n        }\n        return (\n            <ErrorCallout intent={Intent.DANGER} icon=\"heart-broken\" title=\"Error\">\n                <p>{t(\"error.fatal-boundary\")}</p>\n                <code>\n                    <PreForm>{this.state.error.toString()}</PreForm>\n                </code>\n                {this.state.errorStack && (\n                    <code>\n                        <PreForm>{stripBlanks(this.state.errorStack)}</PreForm>\n                    </code>\n                )}\n            </ErrorCallout>\n        );\n    }\n}\n"
  },
  {
    "path": "source/shared/components/ErrorMessage.tsx",
    "content": "import React from \"react\";\nimport { Callout, Intent } from \"@blueprintjs/core\";\nimport styled from \"styled-components\";\n\ninterface ErrorMessageProps {\n    message: string;\n    scroll?: boolean;\n}\n\nconst ErrorCallout = styled(Callout)`\n    margin: 4px;\n    box-sizing: border-box;\n    width: calc(100% - 8px) !important;\n    height: calc(100% - 8px) !important;\n    overflow: ${p => p.scroll ? \"scroll\" : \"hidden\"};\n`;\n\nexport function ErrorMessage(props: ErrorMessageProps) {\n    const {\n        message,\n        scroll = true\n    } = props;\n    return (\n        <ErrorCallout intent={Intent.DANGER} scroll={scroll}>\n            {message}\n        </ErrorCallout>\n    );\n}\n"
  },
  {
    "path": "source/shared/components/RouteError.tsx",
    "content": "import React from \"react\";\nimport styled from \"styled-components\";\nimport { useRouteError } from \"react-router-dom\";\nimport { Callout, Intent } from \"@blueprintjs/core\";\nimport { t } from \"../i18n/trans.js\";\n\nconst ErrorCallout = styled(Callout)`\n    margin: 4px;\n    box-sizing: border-box;\n    width: calc(100% - 8px) !important;\n    height: calc(100% - 8px) !important;\n    overflow: scroll;\n`;\nconst PreForm = styled.pre`\n    margin: 0px;\n`;\n\nfunction stripBlanks(txt = \"\") {\n    return txt\n        .split(/(\\r\\n|\\n)/g)\n        .filter(ln => ln.trim().length > 0)\n        .join(\"\\n\");\n}\n\nexport function RouteError() {\n    const err = useRouteError() as Error | null;\n    if (!err) return null;\n    return (\n        <ErrorCallout intent={Intent.DANGER} icon=\"heart-broken\" title=\"Error\">\n                <p>{t(\"error.fatal-boundary\")}</p>\n                {(!err.stack || err.stack.includes(err.message) === false) && (\n                    <code>\n                        <PreForm>{err.message}</PreForm>\n                    </code>\n                )}\n                {err.stack && (\n                    <code>\n                        <PreForm>{stripBlanks(err.stack)}</PreForm>\n                    </code>\n                )}\n            </ErrorCallout>\n    );\n}\n"
  },
  {
    "path": "source/shared/components/ThemeProvider.tsx",
    "content": "import React from \"react\";\nimport { ThemeProvider as StyledThemeProvider } from \"styled-components\";\nimport themesInternal from \"../themes.js\";\nimport { ChildElements } from \"../types.js\";\n\ninterface ThemeProviderProps {\n    children: ChildElements;\n    darkMode: boolean;\n}\n\nexport function ThemeProvider(props: ThemeProviderProps) {\n    const { children, darkMode } = props;\n    return (\n        <StyledThemeProvider\n            theme={{\n                ...(darkMode ? themesInternal.dark : themesInternal.light)\n            }}\n        >\n            {children}\n        </StyledThemeProvider>\n    );\n}\n"
  },
  {
    "path": "source/shared/components/loading/BusyLoader.tsx",
    "content": "import React from \"react\";\nimport { Classes, Overlay, H4, Spinner, Text } from \"@blueprintjs/core\";\nimport styled from \"styled-components\";\nimport cn from \"classnames\";\n\ninterface BusyLoaderProps {\n    description: string;\n    title: string;\n}\n\nconst OverlayBody = styled.div`\n    display: flex;\n    flex-direction: column;\n    justify-content: flex-start;\n    align-items: center;\n`;\nconst OverlayContainer = styled(Overlay)`\n    display: flex;\n    justify-content: center;\n    align-items: center;\n`;\n\nexport function BusyLoader(props: BusyLoaderProps) {\n    return (\n        <OverlayContainer\n            canEscapeKeyClose={false}\n            canOutsideClickClose={false}\n            className={Classes.OVERLAY_SCROLL_CONTAINER}\n            hasBackdrop\n            isOpen\n        >\n            <OverlayBody className={cn(Classes.CARD, Classes.ELEVATION_2)}>\n                <Spinner size={30} />\n                <br />\n                <H4>{props.title}</H4>\n                <Text>{props.description}</Text>\n            </OverlayBody>\n        </OverlayContainer>\n    );\n}\n"
  },
  {
    "path": "source/shared/extension.ts",
    "content": "export function getExtensionAPI(): typeof chrome {\n    if (BROWSER === \"firefox\") {\n        return browser;\n    }\n    return self.chrome || self[\"browser\"];\n}\n"
  },
  {
    "path": "source/shared/hooks/async.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { DependencyList } from \"react\";\n\nexport interface AsyncResult<T extends any> {\n    error: Error | null;\n    loading: boolean;\n    value: T | null;\n}\n\nexport function useAsync<T extends any>(\n    fn: () => Promise<T>,\n    deps: DependencyList = [],\n    {\n        clearOnExec = true,\n        updateInterval = null,\n        valuesDiffer = () => true\n    }: {\n        clearOnExec?: boolean;\n        updateInterval?: number | null;\n        valuesDiffer?: (existingValue: T | null, newValue: T | null) => boolean;\n    } = {}\n): AsyncResult<T> {\n    const mounted = useRef(false);\n    const executing = useRef(false);\n    const [value, setValue] = useState<T | null>(null);\n    const [error, setError] = useState<Error | null>(null);\n    const [loading, setLoading] = useState<boolean | null>(null);\n    const [, setTimer] = useState<null | ReturnType<typeof setTimeout>>(null);\n    const execute = useCallback(async () => {\n        if (!mounted.current) return;\n        if (executing.current) return;\n        if (clearOnExec) setValue(null);\n        executing.current = true;\n        setError(null);\n        setLoading((isLoading) => (isLoading === null ? true : isLoading));\n        await fn()\n            .then((result: T) => {\n                executing.current = false;\n                if (!mounted.current) return;\n                setValue((existing) => {\n                    return valuesDiffer(existing, result) ? result : existing;\n                });\n                setLoading(false);\n            })\n            .catch((err) => {\n                executing.current = false;\n                if (!mounted.current) return;\n                setError(err);\n                setLoading(false);\n            });\n    }, [fn]);\n    useEffect(() => {\n        mounted.current = true;\n        return () => {\n            mounted.current = false;\n        };\n    }, []);\n    useEffect(() => {\n        if (updateInterval === null) return;\n        setTimer((existing) => {\n            clearTimeout(existing as any);\n            return null;\n        });\n        let newTimer: ReturnType<typeof setTimeout>;\n        const startNewTimer = () => {\n            newTimer = setTimeout(() => {\n                execute().then(() => {\n                    startNewTimer();\n                });\n            }, updateInterval);\n            setTimer(newTimer);\n        };\n        startNewTimer();\n        return () => {\n            clearTimeout(newTimer);\n        };\n    }, [execute, updateInterval]);\n    useEffect(() => {\n        if (!mounted.current) return;\n        execute();\n    }, [execute, ...deps]);\n    const output = useMemo(\n        () => ({\n            error,\n            loading: typeof loading === \"boolean\" ? loading : false,\n            value\n        }),\n        [error, loading, value]\n    );\n    return output;\n}\n\nexport function useAsyncWithTimer<T extends any>(\n    fn: () => Promise<T>,\n    delay: number,\n    deps: DependencyList = []\n): {\n    error: Error | null;\n    loading: boolean;\n    value: T | null;\n} {\n    const mounted = useRef(false);\n    const allTimers = useRef<Array<ReturnType<typeof setInterval>>>([]);\n    const [time, setTime] = useState<number>(Date.now());\n    const [timer, setTimer] = useState<ReturnType<typeof setInterval> | null>(null);\n    const { error, loading, value } = useAsync(fn, [...deps, time]);\n    const [lastValue, setLastValue] = useState(value);\n    useEffect(() => {\n        mounted.current = true;\n        return () => {\n            mounted.current = false;\n            allTimers.current.forEach((currentTimer) => {\n                clearInterval(currentTimer);\n            });\n        };\n    }, []);\n    useEffect(() => {\n        if (time === 0) return;\n        if (error) {\n            clearInterval(timer as any);\n            setTime(0);\n            setTimer(null);\n            return;\n        }\n        if (!timer) {\n            const thisTimer = setInterval(() => {\n                if (!mounted.current) return;\n                setTime(Date.now());\n            }, delay);\n            allTimers.current.push(thisTimer as any);\n            setTimer(thisTimer);\n        }\n    }, [time, timer, error, delay]);\n    useEffect(() => {\n        if (value !== null) {\n            setLastValue(value);\n        }\n    }, [value]);\n    return {\n        error,\n        loading,\n        value: lastValue\n    };\n}\n"
  },
  {
    "path": "source/shared/hooks/config.ts",
    "content": "import { useCallback, useState } from \"react\";\nimport { useAsync } from \"./async.js\";\nimport { getConfig } from \"../queries/config.js\";\nimport { setConfigValue as setNewBackgroundValue } from \"../queries/config.js\";\nimport { Configuration } from \"../types.js\";\nimport { useGlobal } from \"./global.js\";\n\nexport function useConfig(): [\n    Configuration | null,\n    Error | null,\n    <T extends keyof Configuration>(setKey: T, value: Configuration[T]) => void\n] {\n    const [ts, setTs] = useGlobal(\"configFlagTs\");\n    const { value, error } = useAsync(getConfig, [ts], {\n        clearOnExec: false\n    });\n    const [changeError, setChangeError] = useState<Error | null>(null);\n    const setConfigValue = useCallback(<T extends keyof Configuration>(setKey: T, value: Configuration[T]) => {\n        setChangeError(null);\n        setNewBackgroundValue(setKey, value)\n            .then(() => {\n                setTs(Date.now());\n            })\n            .catch((err) => {\n                console.error(err);\n                setChangeError(err);\n            });\n    }, []);\n    return [value || null, changeError || error, setConfigValue];\n}\n"
  },
  {
    "path": "source/shared/hooks/global.ts",
    "content": "import EventEmitter from \"eventemitter3\";\nimport { useCallback, useEffect, useState } from \"react\";\n\ninterface Globals {\n    configFlagTs: number | null;\n}\n\nconst __globals: Globals = {\n    configFlagTs: null\n};\nlet __ee: EventEmitter | null = null;\n\nexport function useGlobal<K extends keyof Globals>(key: K): [Globals[K], (value: Globals[K]) => void] {\n    useEffect(() => {\n        if (!__ee) {\n            __ee = new EventEmitter();\n        }\n    }, []);\n    const handleEventUpdate = useCallback(() => {\n        setCurrentValue(__globals[key]);\n    }, [key]);\n    useEffect(() => {\n        if (!__ee) return;\n        handleEventUpdate();\n        __ee.on(\"update\", handleEventUpdate);\n        return () => {\n            if (!__ee) return;\n            __ee.off(\"update\", handleEventUpdate);\n        };\n    }, [handleEventUpdate]);\n    const [currentValue, setCurrentValue] = useState<Globals[K]>(__globals[key]);\n    const handleValueChange = useCallback(\n        (value: Globals[K]) => {\n            __globals[key] = value;\n            setCurrentValue(value);\n            if (__ee) {\n                __ee.emit(\"update\");\n            }\n        },\n        [key]\n    );\n    return [currentValue, handleValueChange];\n}\n"
  },
  {
    "path": "source/shared/hooks/theme.ts",
    "content": "import { useEffect } from \"react\";\nimport { useConfig } from \"./config.js\";\nimport { Classes } from \"@blueprintjs/core\";\n\nlet __bodyThemeAttached: boolean = false;\n\nexport function useBodyThemeClass(theme: \"dark\" | \"light\"): void {\n    useEffect(() => {\n        if (__bodyThemeAttached) {\n            console.warn(\"Multiple body theme controllers running\");\n            return;\n        }\n        __bodyThemeAttached = true;\n        if (theme === \"dark\") {\n            document.body.classList.add(Classes.DARK);\n        } else {\n            document.body.classList.remove(Classes.DARK);\n        }\n        return () => {\n            __bodyThemeAttached = false;\n        };\n    }, [theme]);\n}\n\nexport function useTheme(): \"dark\" | \"light\" {\n    const [config] = useConfig();\n    if (!config || config.useSystemTheme) {\n        const darkThemeMq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n        return darkThemeMq.matches ? \"dark\" : \"light\";\n    }\n    return config?.theme === \"dark\" ? \"dark\" : \"light\";\n}\n"
  },
  {
    "path": "source/shared/hooks/timer.ts",
    "content": "import { useEffect } from \"react\";\nimport { DependencyList } from \"react\";\n\nexport function useTimer(callback: () => void, delay: number, dependencies: DependencyList) {\n    useEffect(() => {\n        const timer = setInterval(callback, delay);\n        return () => clearInterval(timer);\n    }, dependencies);\n}\n"
  },
  {
    "path": "source/shared/i18n/trans.ts",
    "content": "import i18next, { TOptions } from \"i18next\";\n\nimport en from \"./translations/en.json\";\nimport nl from \"./translations/nl.json\";\n\nexport const DEFAULT_LANGUAGE = \"en\";\nexport const TRANSLATIONS = {\n    en, // Keep as first item\n    // All others sorted alphabetically:\n    nl\n};\n\nexport async function changeLanguage(lang: string) {\n    await i18next.changeLanguage(lang);\n}\n\nexport async function initialise(lang: string) {\n    await i18next.init({\n        lng: lang,\n        fallbackLng: DEFAULT_LANGUAGE,\n        debug: false,\n        resources: Object.keys(TRANSLATIONS).reduce(\n            (output, lang) => ({\n                ...output,\n                [lang]: {\n                    translation: TRANSLATIONS[lang]\n                }\n            }),\n            {}\n        )\n    });\n}\n\nexport function onLanguageChanged(callback: (lang: string) => void): () => void {\n    const cb = (lang: string) => callback(lang);\n    i18next.on(\"languageChanged\", cb);\n    return () => {\n        i18next.off(\"languageChanged\", cb);\n    };\n}\n\nexport function t(key: string, options?: TOptions) {\n    return i18next.t(key, options);\n}\n"
  },
  {
    "path": "source/shared/i18n/translations/en.json",
    "content": "{\n    \"_\": \"English (UK)\",\n    \"about\": {\n        \"attributions\": \"Attributions\",\n        \"info\": {\n            \"build-date\": \"Build Date\",\n            \"title\": \"Info\",\n            \"version\": \"Version\"\n        }\n    },\n    \"attributions-page\": {\n        \"title\": \"Attributions\"\n    },\n    \"config\": {\n        \"default-hint\": \"(default)\",\n        \"input-button-type\": {\n            \"innericon\": \"Small interior icon\",\n            \"largebutton\": \"Large external button\"\n        },\n        \"reset-dialog\": {\n            \"cancel-button\": \"Cancel\",\n            \"confirm-button\": \"Reset\",\n            \"message\": \"Are you sure you wish to reset the application? This clears all cached data, settings and keys.\"\n        },\n        \"section\": {\n            \"advanced\": \"Advanced Settings\",\n            \"forms\": \"Forms\",\n            \"logins\": \"Logins\",\n            \"privacy\": \"Privacy\",\n            \"theme\": \"Theme\"\n        },\n        \"setting\": {\n            \"entryIcons\": \"Fetch dynamic entry icons (anonymous)\",\n            \"manageDisabledDomains\": \"Manage disabled domains\",\n            \"reset\": \"Reset Application Data & Settings\",\n            \"reviewSavedLogins\": \"Review saved logins\",\n            \"saveNewLogins\": \"Prompt for saving new logins\",\n            \"theme\": \"Theme\",\n            \"useSystemTheme\": \"Use System Theme\"\n        }\n    },\n    \"confirm-dialog\": {\n        \"cancel\": \"Cancel\",\n        \"confirm-default\": \"Confirm\"\n    },\n    \"connect-page\": {\n        \"auth-error\": \"Failed authenticating: {{message}}\",\n        \"auth-success\": \"Successfully connected\",\n        \"code-plc\": \"Code...\",\n        \"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.\",\n        \"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:\",\n        \"title\": \"Connect desktop application\"\n    },\n    \"disabled-domains-page\": {\n        \"delete-dialog\": {\n            \"confirm\": \"Delete domain\",\n            \"description\": \"Remove the disabled domain so that new logins will prompt to save credentials: <strong>{{domain}}</strong>\",\n            \"title\": \"Remove disabled domain\"\n        },\n        \"description\": \"Manage which domains should <i>not</i> display a save-credentials prompt after logging in.\",\n        \"disabled-domains\": {\n            \"heading\": \"Currently disabled domains\"\n        },\n        \"table\": {\n            \"action\": {\n                \"delete\": \"Remove disabled domain\"\n            },\n            \"action-heading\": \"Action\",\n            \"domain-heading\": \"Domain\",\n            \"empty-description\": \"Disabled domains will appear here after clicking 'Disable' on the save credentials dialog after logging into a website.\",\n            \"empty-title\": \"No disabled domains\"\n        },\n        \"title\": \"Disabled Domains\"\n    },\n    \"error\": {\n        \"code\": {\n            \"desktop-connection-not-authorised\": \"Desktop connection has not been authorised\",\n            \"desktop-request-failed\": \"Desktop request failed\"\n        },\n        \"desktop\": {\n            \"connection-check-failed\": \"Failed checking desktop connection: {{message}}\",\n            \"otps-fetch-failed\": \"Failed fetching OTPs: {{message}}\",\n            \"search-failed\": \"Failed searching for entries: {{message}}\",\n            \"sources-fetch-failed\": \"Failed fetching vaults: {{message}}\"\n        },\n        \"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:\",\n        \"generic\": \"Requested action failed: {{message}}\",\n        \"otp-generate\": \"Failed generating an OTP code for an OTP URI: {{message}}\",\n        \"reset\": \"Failed resetting application settings: {{message}}\"\n    },\n    \"form\": {\n        \"invalid\": {\n            \"required-non-empty\": \"Invalid: value is required (cannot be empty)\"\n        },\n        \"required\": \"(required)\"\n    },\n    \"notifications\": {\n        \"page\": {\n            \"welcome-v3\": {\n                \"line-1\": \"You're now using the new version of the <strong>Buttercup browser addon</strong>. 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.\",\n                \"line-2\": \"It should be noted that <strong>version 3 of the extension requires the Buttercup desktop application version 2.26 or later</strong> be installed <i>and</i> 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.\",\n                \"line-3\": \"The latest version of the Buttercup desktop application should be downloaded from <a href='https://buttercup.pw/'>Buttercup.pw</a>\",\n                \"line-4\": \"We hope that you enjoy using this new version!\",\n                \"title\": \"Welcome to V3\"\n            }\n        },\n        \"title\": \"Notifications\"\n    },\n    \"popup\": {\n        \"all-locked\": {\n            \"description\": \"All vaults are currently locked.\",\n            \"title\": \"No Unlocked Vaults\"\n        },\n        \"connection\": {\n            \"check-error\": {\n                \"description\": \"Unable to establish a connection to the desktop application. Check that it's open or consider re-authenticating.\",\n                \"title\": \"Connection Failed\"\n            },\n            \"open-error\": \"Failed starting connection: {{message}}\",\n            \"reauth-error\": \"Failed re-authenticating: {{message}}\"\n        },\n        \"entries\": {\n            \"auto-login\": {\n                \"tooltip\": \"Open entry URL and automatically log in\"\n            },\n            \"click\": {\n                \"no-url-available\": \"No URL available for this entry\",\n                \"open-error\": \"Could not open page for entry: {{message}}\",\n                \"recent-set-error\": \"Failed recording recent entry: {{message}}\"\n            },\n            \"info\": {\n                \"copy-error\": \"Failed copying value: {{message}}\",\n                \"copy-success\": \"Copied value to clipboard: {{property}}\",\n                \"copy-tooltip\": \"Copy to clipoard\",\n                \"tooltip\": \"Show entry properties\"\n            },\n            \"otp\": {\n                \"code-error\": \"ERROR\",\n                \"label-error\": \"Bad OTP Item\"\n            },\n            \"search\": {\n                \"button\": \"Search entries\",\n                \"placeholder\": \"Search...\"\n            }\n        },\n        \"no-entries\": {\n            \"description\": \"No available entries - recent items and results for the current tab will appear here.\",\n            \"title\": \"No Entries\"\n        },\n        \"no-otps\": {\n            \"description\": \"No OTP entries found in unlocked vaults.\",\n            \"title\": \"No OTP Entries\"\n        },\n        \"otps\": {\n            \"click\": {\n                \"no-url-available\": \"No URL available for this OTP\",\n                \"open-error\": \"Could not open page for OTP: {{message}}\"\n            }\n        },\n        \"tab\": {\n            \"about\": {\n                \"title\": \"About\"\n            },\n            \"entries\": {\n                \"title\": \"Entries\"\n            },\n            \"otps\": {\n                \"title\": \"One-Time Passwords (OTPs)\"\n            },\n            \"settings\": {\n                \"title\": \"Settings\"\n            },\n            \"vaults\": {\n                \"title\": \"Vaults\"\n            }\n        },\n        \"vault\": {\n            \"lock\": \"Lock vault\",\n            \"locking\": {\n                \"error\": \"Failed locking vault: {{message}}\",\n                \"success\": \"Locked vault: {{vault}}\"\n            },\n            \"remove\": \"Remove vault\",\n            \"remove-dialog\": {\n                \"cancel-button\": \"Cancel\",\n                \"confirm-button\": \"Remove\",\n                \"message\": \"Are you sure that you want to remove the vault \\\"{{vault}}\\\"?\"\n            },\n            \"removing\": {\n                \"description\": \"Vault is being removed...\",\n                \"error\": \"Failed removing: {{message}}\",\n                \"success\": \"Successfully removed: {{vault}}\",\n                \"title\": \"Removing\"\n            },\n            \"state-pending\": \"Vault state is pending\",\n            \"unlock\": \"Unlock vault\",\n            \"unlock-dialog\": {\n                \"cancel-button\": \"Cancel\",\n                \"password-label\": \"Vault Password\",\n                \"title\": \"Unlock {{title}}\",\n                \"unlock-button\": \"Unlock\"\n            },\n            \"unlocking\": {\n                \"description\": \"Vault is being unlocked...\",\n                \"error\": \"Failed unlocking vault: {{message}}\",\n                \"invalid-password\": \"Password is invalid\",\n                \"success\": \"Successfully unlocked: {{vault}}\",\n                \"title\": \"Unlocking\"\n            }\n        },\n        \"vaults\": {\n            \"controls\": {\n                \"add-vault\": \"Add vault\",\n                \"lock-vaults\": \"Lock all vaults\"\n            },\n            \"empty\": {\n                \"description\": \"No vaults have been added to your desktop application, yet.\",\n                \"title\": \"No Vaults\"\n            },\n            \"no-connection\": {\n                \"action-text\": \"Connect\",\n                \"description\": \"You haven't connected to the desktop application, yet.\",\n                \"title\": \"Not Connected\"\n            }\n        }\n    },\n    \"save-credentials-dialog\": {\n        \"close-button\": \"Close\",\n        \"credentials-fetch-error\": \"Failed fetching credentials: {{message}}\",\n        \"description\": \"One or more logins have been recorded and are ready to be saved to your vault.\",\n        \"disable-button\": \"Disable\",\n        \"disable-confirm-button\": \"Confirm Disable\",\n        \"error-description\": \"We weren't able to get the details you wanted, sorry.\",\n        \"error-title\": \"Whoops...\",\n        \"last-login-heading\": \"Last login\",\n        \"title\": \"Save Login\",\n        \"view-button\": \"View\"\n    },\n    \"save-credentials-page\": {\n        \"credentials-saver\": {\n            \"create-new\": {\n                \"heading\": \"New Entry Details\",\n                \"label\": {\n                    \"password\": \"Password\",\n                    \"title\": \"Title\",\n                    \"url\": \"URL\",\n                    \"username\": \"Username\"\n                },\n                \"loader\": {\n                    \"description\": \"Fetching vaults and their contents...\",\n                    \"title\": \"Vaults\"\n                },\n                \"password\": {\n                    \"hide\": \"Hide password\",\n                    \"show\": \"Show password\"\n                },\n                \"placeholder\": {\n                    \"title\": \"New entry title\",\n                    \"url\": \"New entry URL\",\n                    \"username\": \"New entry username\"\n                },\n                \"save\": \"Save New Entry\",\n                \"tab\": \"Create New Entry\"\n            },\n            \"heading\": \"Save Login\",\n            \"no-vaults\": {\n                \"description\": \"No vaults are currently available. Either add a vault or unlock some.\",\n                \"title\": \"No Available Vaults\"\n            },\n            \"update-existing\": {\n                \"tab\": \"Update Existing Entry\"\n            }\n        },\n        \"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.\",\n        \"detected-logins\": {\n            \"heading\": \"Detected Logins\"\n        },\n        \"save-error\": \"Failed saving entry: {{message}}\",\n        \"save-success\": \"Successfully saved entry: {{title}}\",\n        \"title\": \"Save Logins\"\n    },\n    \"theme\": {\n        \"dark\": \"Dark\",\n        \"light\": \"Light\"\n    },\n    \"vault-state\": {\n        \"locked\": \"Locked\",\n        \"pending\": \"Pending\",\n        \"unlocked\": \"Unlocked\"\n    },\n    \"vault-type\": {\n        \"dropbox\": {\n            \"add-error\": \"Failed adding Dropbox vault\",\n            \"configure-btn\": \"Authenticate\",\n            \"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.\",\n            \"title\": \"Dropbox\"\n        },\n        \"googledrive\": {\n            \"configure-btn\": \"Authenticate\",\n            \"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.\",\n            \"title\": \"Google Drive\"\n        },\n        \"localfile\": {\n            \"configure-btn\": \"Connect\",\n            \"description\": \"Use the Buttercup desktop application to supply access to local files on your computer. Requires the desktop application to be installed and running.\",\n            \"title\": \"Local File\"\n        },\n        \"webdav\": {\n            \"configure-btn\": \"Configure\",\n            \"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.\",\n            \"title\": \"WebDAV\"\n        }\n    }\n}\n"
  },
  {
    "path": "source/shared/i18n/translations/nl.json",
    "content": "{\n    \"_\": \"Nederlands (NL)\",\n    \"about\": {\n        \"attributions\": \"Toeschrijvingen\",\n        \"info\": {\n            \"build-date\": \"Build-datum\",\n            \"title\": \"Info\",\n            \"version\": \"Versie\"\n        }\n    },\n    \"attributions-page\": {\n        \"title\": \"Toeschrijvingen\"\n    },\n    \"config\": {\n        \"default-hint\": \"(standaard)\",\n        \"input-button-type\": {\n            \"innericon\": \"Klein icoon binnenin\",\n            \"largebutton\": \"Grote externe knop\"\n        },\n        \"reset-dialog\": {\n            \"cancel-button\": \"Annuleren\",\n            \"confirm-button\": \"Reset\",\n            \"message\": \"Weet je zeker dat je de toepassing wilt resetten? Dit wist alle gecachte gegevens, instellingen en sleutels.\"\n        },\n        \"section\": {\n            \"advanced\": \"Geavanceerde instellingen\",\n            \"forms\": \"Formulieren\",\n            \"logins\": \"Logins\",\n            \"privacy\": \"Privacy\",\n            \"theme\": \"Thema\"\n        },\n        \"setting\": {\n            \"entryIcons\": \"Ophalen van website-iconen (anoniem)\",\n            \"manageDisabledDomains\": \"Beheer uitgezonderde domeinen\",\n            \"reset\": \"Reset toepassinggegevens en instellingen\",\n            \"reviewSavedLogins\": \"Beoordeel opgeslagen logins\",\n            \"saveNewLogins\": \"Prompt voor het opslaan van nieuwe logins\",\n            \"theme\": \"Thema\",\n            \"useSystemTheme\": \"Gebruik systeemthema\"\n        }\n    },\n    \"confirm-dialog\": {\n        \"cancel\": \"Annuleren\",\n        \"confirm-default\": \"Bevestigen\"\n    },\n    \"connect-page\": {\n        \"auth-error\": \"Authenticatie mislukt: {{message}}\",\n        \"auth-success\": \"Succesvol verbonden\",\n        \"code-plc\": \"Code...\",\n        \"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.\",\n        \"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:\",\n        \"title\": \"Verbind desktop-toepassing\"\n    },\n    \"disabled-domains-page\": {\n        \"delete-dialog\": {\n            \"confirm\": \"Uitzonderen domein\",\n            \"description\": \"Verwijder het uitgezonderde domein zodat nieuwe inlogpogingen worden gevraagd om referenties op te slaan: <strong>{{domain}}</strong>\",\n            \"title\": \"Uitgezonderd domein verwijderen\"\n        },\n        \"description\": \"Beheer welke domeinen <i>niet</i> een prompt moeten weergeven om referenties op te slaan na het inloggen.\",\n        \"disabled-domains\": {\n            \"heading\": \"Momenteel uitgezonderde domeinen\"\n        },\n        \"table\": {\n            \"action\": {\n                \"delete\": \"Verwijder uitgezonderd domein\"\n            },\n            \"action-heading\": \"Actie\",\n            \"domain-heading\": \"Domein\",\n            \"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.\",\n            \"empty-title\": \"Geen uitgezonderde domeinen\"\n        },\n        \"title\": \"Uitgezonderde domeinen\"\n    },\n    \"error\": {\n        \"code\": {\n            \"desktop-connection-not-authorised\": \"Desktop verbinding is niet geautoriseerd\",\n            \"desktop-request-failed\": \"Desktop verzoek mislukt\"\n        },\n        \"desktop\": {\n            \"connection-check-failed\": \"Controleren desktop verbinding mislukt: {{message}}\",\n            \"otps-fetch-failed\": \"Ophalen OTPs mislukt: {{message}}\",\n            \"search-failed\": \"Zoeken naar items mislukt: {{message}}\",\n            \"sources-fetch-failed\": \"Ophalen kluizen mislukt: {{message}}\"\n        },\n        \"fatal-boundary\": \"Een fatale fout is opgetreden - het spijt ons dat dit is gebeurd. Controleer de onderstaande details om het probleem te diagnosticeren:\",\n        \"generic\": \"Gevraagde actie mislukt: {{message}}\",\n        \"otp-generate\": \"Genereren OTP code voor OTP URI mislukt: {{message}}\",\n        \"reset\": \"Resetten van toepassinginstellingen mislukt: {{message}}\"\n    },\n    \"form\": {\n        \"invalid\": {\n            \"required-non-empty\": \"Ongeldig: waarde is vereist (kan niet leeg zijn)\"\n        },\n        \"required\": \"(vereist)\"\n    },\n    \"notifications\": {\n        \"page\": {\n            \"welcome-v3\": {\n                \"line-1\": \"Je gebruikt nu de nieuwe versie van de <strong>Buttercup-browserextensie</strong>. 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.\",\n                \"line-2\": \"Het moet worden opgemerkt dat <strong>versie 3 de Buttercup desktoptoepassing vereist</strong> om geïnstalleerd <i>en</i> 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 <strong>versie 3 van de extensie vereist dat de Buttercup desktop-applicatie versie 2.26 of hoger</strong> geïnstalleerd is <i>en</i>, 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.\",\n                \"line-3\": \"De nieuwste versie van de Buttercup-desktopapp moet worden gedownload van <a href='https://buttercup.pw/'>Buttercup.pw</a>\",\n                \"line-4\": \"Wij hopen dat je deze nieuwe versie met veel plezier zal gebruiken!\",\n                \"title\": \"Welkom bij V3\"\n            }\n        },\n        \"title\": \"Meldingen\"\n    },\n    \"popup\": {\n        \"all-locked\": {\n            \"description\": \"Alle kluizen zijn momenteel vergrendeld.\",\n            \"title\": \"Geen ontgrendelde kluizen\"\n        },\n        \"connection\": {\n            \"check-error\": {\n                \"description\": \"Kan geen verbinding maken met de desktop toepassing. Controleer of deze geopend is of overweeg opnieuw te authenticeren.\",\n                \"title\": \"Verbinding mislukt\"\n            },\n            \"open-error\": \"Opzetten verbinding mislukt: {{message}}\",\n            \"reauth-error\": \"Opnieuw authenticeren mislukt: {{message}}\"\n        },\n        \"entries\": {\n            \"auto-login\": {\n                \"tooltip\": \"Open item URL en log automatisch in\"\n            },\n            \"click\": {\n                \"no-url-available\": \"Geen URL beschikbaar voor dit item\",\n                \"open-error\": \"Kan pagina niet openen voor item: {{message}}\",\n                \"recent-set-error\": \"Onlangs gebruikte items niet geregistreerd: {{message}}\"\n            },\n            \"info\": {\n                \"copy-error\": \"Waarde kopieren mislukt: {{message}}\",\n                \"copy-success\": \"Waarde gekopieerd naar klembord: {{property}}\",\n                \"copy-tooltip\": \"Kopieer naar klembord\",\n                \"tooltip\": \"Toon item eigenschappen\"\n            },\n            \"otp\": {\n                \"code-error\": \"ERROR\",\n                \"label-error\": \"Ongeldig OTP-item\"\n            },\n            \"search\": {\n                \"button\": \"Zoek items\",\n                \"placeholder\": \"Zoeken...\"\n            }\n        },\n        \"no-entries\": {\n            \"description\": \"Geen beschikbare items - recente items en resultaten voor het huidige tabblad zullen hier verschijnen.\",\n            \"title\": \"Geen items\"\n        },\n        \"no-otps\": {\n            \"description\": \"Geen OTP-items gevonden in de ontgrendelde kluizen.\",\n            \"title\": \"Geen OTP-items\"\n        },\n        \"otps\": {\n            \"click\": {\n                \"no-url-available\": \"Geen URL beschikbaar voor dit OTP\",\n                \"open-error\": \"Kan pagina niet openen voor OTP: {{message}}\"\n            }\n        },\n        \"tab\": {\n            \"about\": {\n                \"title\": \"Over\"\n            },\n            \"entries\": {\n                \"title\": \"Items\"\n            },\n            \"otps\": {\n                \"title\": \"One-Time Passwords (OTPs)\"\n            },\n            \"settings\": {\n                \"title\": \"Instellingen\"\n            },\n            \"vaults\": {\n                \"title\": \"Kluizen\"\n            }\n        },\n        \"vault\": {\n            \"lock\": \"Vergrendel kluis\",\n            \"locking\": {\n                \"error\": \"Vergrendelen kluis mislukt: {{message}}\",\n                \"success\": \"Vergrendelde kluis: {{vault}}\"\n            },\n            \"remove\": \"Verwijder kluis\",\n            \"remove-dialog\": {\n                \"cancel-button\": \"Annuleren\",\n                \"confirm-button\": \"Verwijderen\",\n                \"message\": \"Weet je zeker dat je de kluis \\\"{{vault}}\\\" wilt verwijderen?\"\n            },\n            \"removing\": {\n                \"description\": \"Kluis wordt verwijderd...\",\n                \"error\": \"Verwijderen mislukt: {{message}}\",\n                \"success\": \"Succesvol verwijderd: {{vault}}\",\n                \"title\": \"Verwijderen\"\n            },\n            \"state-pending\": \"Kluis is bezig\",\n            \"unlock\": \"Ongrendel kluis\",\n            \"unlock-dialog\": {\n                \"cancel-button\": \"Annuleren\",\n                \"password-label\": \"Kluiswachtwoord\",\n                \"title\": \"Ontgrendel {{title}}\",\n                \"unlock-button\": \"Ontgrendelen\"\n            },\n            \"unlocking\": {\n                \"description\": \"Kluis wordt ontgrendeld...\",\n                \"error\": \"Kluis ontgrendelen mislukt: {{message}}\",\n                \"invalid-password\": \"Wachtwoord onjuist\",\n                \"success\": \"Succesvol ontgrendeld: {{vault}}\",\n                \"title\": \"Ontgrendelen\"\n            }\n        },\n        \"vaults\": {\n            \"controls\": {\n                \"add-vault\": \"Toevoegen kluis\",\n                \"lock-vaults\": \"Vergrendel alle kluizen\"\n            },\n            \"empty\": {\n                \"description\": \"Er zijn nog geen kluizen toegevoegd aan de desktop-toepassing.\",\n                \"title\": \"Geen kluizen\"\n            },\n            \"no-connection\": {\n                \"action-text\": \"Verbinden\",\n                \"description\": \"Er is nog geen verbinding met de desktop-toepassing.\",\n                \"title\": \"Niet verbonden\"\n            }\n        }\n    },\n    \"save-credentials-dialog\": {\n        \"close-button\": \"Sluiten\",\n        \"credentials-fetch-error\": \"Ophalen referenties mislukt: {{message}}\",\n        \"description\": \"Een of meer login zijn gedetecteerd en zijn klaar om te slaan in jouw kluis.\",\n        \"disable-button\": \"Uitzondering\",\n        \"disable-confirm-button\": \"Bevestig uitzondering\",\n        \"error-description\": \"We konden de details niet ophalen, sorry.\",\n        \"error-title\": \"Whoops...\",\n        \"last-login-heading\": \"Laatste login\",\n        \"title\": \"Bewaar login\",\n        \"view-button\": \"Bekijken\"\n    },\n    \"save-credentials-page\": {\n        \"credentials-saver\": {\n            \"create-new\": {\n                \"heading\": \"Nieuwe item details\",\n                \"label\": {\n                    \"password\": \"Wachtwoord\",\n                    \"title\": \"Titel\",\n                    \"url\": \"URL\",\n                    \"username\": \"Gebruikersnaam\"\n                },\n                \"loader\": {\n                    \"description\": \"Ophalen van kluizen en hun inhoud...\",\n                    \"title\": \"Kluizen\"\n                },\n                \"password\": {\n                    \"hide\": \"Verberg wachtwoord\",\n                    \"show\": \"Toon wachtwoord\"\n                },\n                \"placeholder\": {\n                    \"title\": \"Nieuw item naam\",\n                    \"url\": \"Nieuw item URL\",\n                    \"username\": \"Nieuw item gebruikersnaam\"\n                },\n                \"save\": \"Nieuw item opslaan\",\n                \"tab\": \"Nieuw item maken\"\n            },\n            \"heading\": \"Login opslaan\",\n            \"no-vaults\": {\n                \"description\": \"Momenteel zijn er geen kluizen beschikbaar. Voeg een nieuwe kluis toe of ontgrendel er een.\",\n                \"title\": \"Geen beschikbare kluizen\"\n            },\n            \"update-existing\": {\n                \"tab\": \"Update bestaand item\"\n            }\n        },\n        \"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.\",\n        \"detected-logins\": {\n            \"heading\": \"Gedetecteerde logins\"\n        },\n        \"save-error\": \"Opslaan item mislukt: {{message}}\",\n        \"save-success\": \"Item succesvol opgeslagen: {{title}}\",\n        \"title\": \"Logins opslaan\"\n    },\n    \"theme\": {\n        \"dark\": \"Donker\",\n        \"light\": \"Licht\"\n    },\n    \"vault-state\": {\n        \"locked\": \"Vergrendeld\",\n        \"pending\": \"Bezig\",\n        \"unlocked\": \"Ontgrendeld\"\n    },\n    \"vault-type\": {\n        \"dropbox\": {\n            \"add-error\": \"Kluis vanuit Dropbox toevoegen mislukt\",\n            \"configure-btn\": \"Authenticatie\",\n            \"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.\",\n            \"title\": \"Dropbox\"\n        },\n        \"googledrive\": {\n            \"configure-btn\": \"Authenticatie\",\n            \"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.\",\n            \"title\": \"Google Drive\"\n        },\n        \"localfile\": {\n            \"configure-btn\": \"Verbind\",\n            \"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.\",\n            \"title\": \"Lokaal bestand\"\n        },\n        \"webdav\": {\n            \"configure-btn\": \"Configureer\",\n            \"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.\",\n            \"title\": \"WebDAV\"\n        }\n    }\n}\n"
  },
  {
    "path": "source/shared/library/buffer.ts",
    "content": "export function arrayBufferToHex(buffer: ArrayBuffer): string {\n    return [...new Uint8Array(buffer)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport function arrayBufferToString(buffer: ArrayBuffer): string {\n    return String.fromCharCode.apply(null, new Uint8Array(buffer));\n}\n\nexport function base64DecodeUnicode(str: string): string {\n    return window.atob(str);\n}\n\nexport function base64EncodeUnicode(str: string): string {\n    return window.btoa(str);\n}\n\nexport function stringToArrayBuffer(str: string): ArrayBuffer {\n    const buffer = new ArrayBuffer(str.length);\n    const bufferView = new Uint8Array(buffer);\n    for (let i = 0; i < str.length; i += 1) {\n        bufferView[i] = str.charCodeAt(i);\n    }\n    return buffer;\n}\n"
  },
  {
    "path": "source/shared/library/clone.ts",
    "content": "export function naiveClone<T extends Array<any> | Record<string, any>>(item: T): T {\n    if (Array.isArray(item)) {\n        return naiveCloneArray(item);\n    }\n    return naiveCloneObject(item);\n}\n\nfunction naiveCloneArray<T extends Array<any>>(arr: T): T {\n    const clone = [...arr] as T;\n    for (let i = 0; i < clone.length; i += 1) {\n        if (Array.isArray(clone[i])) {\n            clone[i] = naiveCloneArray(clone[i]);\n        } else if (clone[i] && typeof clone[i] === \"object\") {\n            clone[i] = naiveCloneObject(clone[i]);\n        }\n    }\n    return clone;\n}\n\nfunction naiveCloneObject<T extends Record<string, any>>(obj: T): T {\n    const clone = { ...obj };\n    for (const key in clone) {\n        if (Array.isArray(clone[key])) {\n            clone[key] = naiveCloneArray(clone[key]);\n        } else if (typeof clone[key] === \"object\" && clone[key]) {\n            clone[key] = naiveCloneObject(clone[key]);\n        }\n    }\n    return clone;\n}\n"
  },
  {
    "path": "source/shared/library/domain.ts",
    "content": "import { EntryURLType, PropertyKeyValueObject, getEntryURLs } from \"buttercup\";\nimport { ParseResultListed, ParseResultType, parseDomain } from \"parse-domain\";\n\nexport function domainsReferToSameParent(domain1: string, domain2: string): boolean {\n    if (domain1 === domain2) return true;\n    const res1 = parseDomain(domain1);\n    const res2 = parseDomain(domain2);\n    if (res1.type !== res2.type) return false;\n    if (res1.type !== ParseResultType.Listed) return false;\n    const r1 = (res1 as ParseResultListed).icann;\n    const r2 = (res2 as ParseResultListed).icann;\n    if (r1.topLevelDomains.join(\".\") !== r2.topLevelDomains.join(\".\")) return false;\n    return r1.domain === r2.domain;\n}\n\nexport function extractDomain(str: string): string {\n    const domainMatch = str.match(/^https?:\\/\\/([^\\/]+)/i);\n    if (!domainMatch) return str;\n    const [, domainPortion] = domainMatch;\n    const [domain] = domainPortion.split(\":\");\n    return domain;\n}\n\nexport function extractEntryDomain(entryProperties: PropertyKeyValueObject): string | null {\n    const [url] = [\n        ...getEntryURLs(entryProperties, EntryURLType.Icon),\n        ...getEntryURLs(entryProperties, EntryURLType.Any)\n    ];\n    return url ? extractDomain(url) : null;\n}\n"
  },
  {
    "path": "source/shared/library/error.ts",
    "content": "import { isError, Layerr } from \"layerr\";\nimport { t } from \"../i18n/trans.js\";\n\nexport function errorToString(error: Error | Layerr): string {\n    return localisedErrorMessage(error);\n}\n\nexport function localisedErrorMessage(error: Error | Layerr): string {\n    if (!isError(error)) {\n        return `${error}`;\n    }\n    const { i18n } = Layerr.info(error);\n    if (i18n) {\n        const translated = t(i18n);\n        if (translated) return translated;\n    }\n    return error.message;\n}\n\nexport function stringToError(error: Error | Layerr | string): Layerr | Error {\n    if (isError(error as Error)) return error as Error;\n    const isI18N = /^[a-z0-9_-]+(\\.[a-z0-9_-]+){1,}$/i.test(error as string);\n    return isI18N ? new Error(t(error as string)) : new Error(error as string);\n}\n"
  },
  {
    "path": "source/shared/library/extension.ts",
    "content": "import { getExtensionAPI } from \"../extension.js\";\n\nconst NOOP = () => {};\n\nexport async function createNewTab(url: string): Promise<chrome.tabs.Tab | null> {\n    const browser = getExtensionAPI();\n    if (!browser.tabs) {\n        // Handle non-background scripts\n        browser.runtime.sendMessage({ type: \"open-tab\", url });\n        return null;\n    }\n    return new Promise<chrome.tabs.Tab>((resolve) => chrome.tabs.create({ url }, resolve));\n}\n\nexport function closeCurrentTab() {\n    const browser = getExtensionAPI();\n    browser.tabs.getCurrent((tab) => {\n        if (!tab?.id) return;\n        browser.tabs.remove(tab.id, NOOP);\n    });\n}\n\nexport async function getAllTabs(): Promise<Array<chrome.tabs.Tab>> {\n    const browser = getExtensionAPI();\n    return new Promise<Array<chrome.tabs.Tab>>((resolve) => {\n        browser.tabs.query({ discarded: false }, (tabs) => {\n            resolve(tabs);\n        });\n    });\n}\n\nexport async function getCurrentTab(): Promise<chrome.tabs.Tab> {\n    const browser = getExtensionAPI();\n    return new Promise((resolve) => {\n        browser.tabs.query({ active: true, currentWindow: true }, (tabs) => {\n            resolve(tabs[0]);\n        });\n    });\n}\n\nexport function getExtensionURL(path: string): string {\n    return getExtensionAPI().runtime.getURL(path);\n}\n\nexport async function sendTabMessage(tabID: number, message: any) {\n    return new Promise((resolve) => {\n        chrome.tabs.sendMessage(tabID, message, (response) => {\n            resolve(response);\n        });\n    });\n}\n"
  },
  {
    "path": "source/shared/library/i18n.ts",
    "content": "export function getLanguage(/*preferences: Preferences, locale: string*/): string {\n    // return preferences.language || locale || DEFAULT_LANGUAGE;\n    return window.navigator.language;\n}\n"
  },
  {
    "path": "source/shared/library/log.ts",
    "content": "import { createLog as createLogger, toggleContext } from \"gle\";\n\nexport type Logger = ReturnType<typeof createLog>;\n\nexport function createLog(name: string, force: boolean = false): (...args: Array<any>) => void {\n    if (force) {\n        toggleContext(name, true);\n    }\n    return createLogger(name);\n}\n"
  },
  {
    "path": "source/shared/library/otp.ts",
    "content": "import { SearchResult } from \"buttercup\";\nimport { Layerr } from \"layerr\";\nimport * as OTPAuth from \"otpauth\";\n\nfunction extractFirstOTPURI(entry: SearchResult): string | null {\n    let key: string | null = null,\n        value: string | null = null;\n    for (const prop in entry.properties) {\n        if (!/^otpauth:\\/\\//.test(entry.properties[prop])) continue;\n        if (!key || prop.length < key.length) {\n            key = prop;\n            value = entry.properties[prop];\n        }\n    }\n    return value ?? null;\n}\n\nexport function otpURIToDigits(uri: string): string {\n    try {\n        const otp = OTPAuth.URI.parse(uri);\n        return otp.generate();\n    } catch (err) {\n        throw new Layerr(err, \"Failed generating OTP code for URI\");\n    }\n}\n\nexport function searchResultToOTP(entry: SearchResult): string | null {\n    const uri = extractFirstOTPURI(entry);\n    if (!uri) return null;\n    return otpURIToDigits(uri);\n}\n"
  },
  {
    "path": "source/shared/library/url.ts",
    "content": "export function formatURL(base: string): string {\n    if (/^\\d+\\.\\d+\\.\\d+\\.\\d+/.test(base)) {\n        return `http://${base}`;\n    } else if (/^https?:\\/\\//i.test(base) === false) {\n        return `https://${base}`;\n    }\n    return base;\n}\n"
  },
  {
    "path": "source/shared/library/vaultTypes.ts",
    "content": "import { VaultType } from \"../types.js\";\nimport VAULT_TYPE_IMAGE_DROPBOX from \"../../../resources/providers/dropbox-256.png\";\nimport VAULT_TYPE_IMAGE_FILE from \"../../../resources/providers/file-256.png\";\nimport VAULT_TYPE_IMAGE_GOOGLEDRIVE from \"../../../resources/providers/googledrive-256.png\";\nimport VAULT_TYPE_IMAGE_WEBDAV from \"../../../resources/providers/webdav-256.png\";\n\ninterface VaultTypeDescription {\n    image: any;\n    invertOnDarkMode: boolean;\n}\n\nexport const VAULT_TYPES: Record<VaultType, VaultTypeDescription> = {\n    [VaultType.Dropbox]: {\n        image: VAULT_TYPE_IMAGE_DROPBOX,\n        invertOnDarkMode: false\n    },\n    [VaultType.File]: {\n        image: VAULT_TYPE_IMAGE_FILE,\n        invertOnDarkMode: false\n    },\n    [VaultType.GoogleDrive]: {\n        image: VAULT_TYPE_IMAGE_GOOGLEDRIVE,\n        invertOnDarkMode: false\n    },\n    [VaultType.WebDAV]: {\n        image: VAULT_TYPE_IMAGE_WEBDAV,\n        invertOnDarkMode: true\n    }\n};\n"
  },
  {
    "path": "source/shared/library/version.ts",
    "content": "// Do not edit this file - it is generated automatically at build time\n\nexport const BUILD_DATE = \"2024-04-09\";\nexport const VERSION = \"3.2.0\";\n"
  },
  {
    "path": "source/shared/notifications/index.ts",
    "content": "import { TITLE as WelcomeV3Title, Page as WelcomeV3Page } from \"./pages/WelcomeV3.jsx\";\n\nexport const NOTIFICATIONS: Record<string, [string, () => JSX.Element]> = {\n    \"2024-03-welcome-v3\": [WelcomeV3Title, WelcomeV3Page]\n};\n\nexport const NOTIFICATION_NAMES = Object.keys(NOTIFICATIONS);\n"
  },
  {
    "path": "source/shared/notifications/pages/WelcomeV3.tsx",
    "content": "import { Fragment } from \"react\";\nimport { t } from \"../../i18n/trans.js\";\n\nexport const TITLE = \"notifications.page.welcome-v3.title\";\n\nexport function Page() {\n    return (\n        <Fragment>\n            <p dangerouslySetInnerHTML={{ __html: t(\"notifications.page.welcome-v3.line-1\") }} />\n            <p dangerouslySetInnerHTML={{ __html: t(\"notifications.page.welcome-v3.line-2\") }} />\n            <p dangerouslySetInnerHTML={{ __html: t(\"notifications.page.welcome-v3.line-3\") }} />\n            <p dangerouslySetInnerHTML={{ __html: t(\"notifications.page.welcome-v3.line-4\") }} />\n            <p>- The Buttercup Team</p>\n        </Fragment>\n    );\n}\n"
  },
  {
    "path": "source/shared/queries/config.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../services/messaging.js\";\nimport { BackgroundMessageType, Configuration } from \"../../popup/types.js\";\n\nexport async function getConfig(): Promise<Configuration> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetConfiguration\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching application configuration\");\n    }\n    if (!resp.config) {\n        throw new Error(\"No config returned from background\");\n    }\n    return resp.config;\n}\n\nexport async function setConfigValue<T extends keyof Configuration>(\n    key: T,\n    value: Configuration[T]\n): Promise<Configuration> {\n    const resp = await sendBackgroundMessage({\n        configKey: key,\n        configValue: value,\n        type: BackgroundMessageType.SetConfigurationValue\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching application configuration\");\n    }\n    if (!resp.config) {\n        throw new Error(\"No config returned from background\");\n    }\n    return resp.config;\n}\n"
  },
  {
    "path": "source/shared/services/messaging.ts",
    "content": "import { getExtensionAPI } from \"../extension.js\";\nimport { stringToError } from \"../library/error.js\";\nimport { MESSAGE_DEFAULT_TIMEOUT } from \"../symbols.js\";\nimport { BackgroundMessage, BackgroundResponse, TabEvent } from \"../../popup/types.js\";\n\nexport async function sendBackgroundMessage(\n    msg: BackgroundMessage,\n    timeout: number = MESSAGE_DEFAULT_TIMEOUT\n): Promise<BackgroundResponse> {\n    const browser = getExtensionAPI();\n    return new Promise<BackgroundResponse>((resolve, reject) => {\n        const timer = setTimeout(() => {\n            reject(new Error(`Timed out waiting for response to message: ${msg.type} (${timeout} ms)`));\n        }, timeout);\n        browser.runtime.sendMessage(msg, (resp) => {\n            clearTimeout(timer);\n            if (resp.error) {\n                reject(stringToError(resp.error));\n                return;\n            }\n            resolve(resp as BackgroundResponse);\n        });\n    });\n}\n"
  },
  {
    "path": "source/shared/services/notifications.ts",
    "content": "import { Position, Toaster, ToasterInstance } from \"@blueprintjs/core\";\n\nconst __toaster = Toaster.create({\n    position: Position.BOTTOM_RIGHT\n});\n\nexport function getToaster(): ToasterInstance {\n    return __toaster;\n}\n"
  },
  {
    "path": "source/shared/styles/base.sass",
    "content": "@import \"~@blueprintjs/core/lib/css/blueprint.css\"\n@import \"~@blueprintjs/icons/lib/css/blueprint-icons.css\"\n@import \"~@blueprintjs/popover2/lib/css/blueprint-popover2.css\"\n@import \"~@blueprintjs/select/lib/css/blueprint-select.css\"\n\n@import \"fonts\"\n\n$bc-brand-colour: #00B7AC\n$bc-brand-colour-dark: #179E94\n\nhtml, body, textarea, select, input, button, div\n    font-family: \"OpenSans\"\n\nbody\n    font-size: 14px\n\n    &.bp4-dark\n        background-color: #383E47 // Dark Grey 4\n\n.bp4-input:focus, .bp4-input.bp4-active\n    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)\n\na\n    color: $bc-brand-colour-dark\n\n    &:hover\n        color: $bc-brand-colour\n"
  },
  {
    "path": "source/shared/styles/fonts.sass",
    "content": "@font-face\n    font-family: \"OpenSans\"\n    src: url(\"../../../resources/OpenSans-Regular.woff2\") format(\"woff2\")\n    font-weight: normal\n    font-style: normal\n\n@font-face\n    font-family: \"OpenSans\"\n    src: url(\"../../../resources/OpenSans-SemiBold.woff2\") format(\"woff2\")\n    font-weight: bold\n    font-style: normal\n"
  },
  {
    "path": "source/shared/symbols.ts",
    "content": "export const API_KEY_ALGO = \"ECDH\";\nexport const API_KEY_CURVE = \"P-256\";\n\nexport const BRAND_COLOUR = \"#00B7AC\";\nexport const BRAND_COLOUR_DARK = \"#179E94\";\n\nexport const DESKTOP_API_PORT = 12822;\n\nexport const MESSAGE_DEFAULT_TIMEOUT = 15000;\n"
  },
  {
    "path": "source/shared/themes.ts",
    "content": "import { Colors } from \"@blueprintjs/core\";\n\nexport default {\n    dark: {\n        backgroundColor: Colors.DARK_GRAY4,\n        backgroundFrameColor: Colors.DARK_GRAY2,\n        listItemHover: Colors.DARK_GRAY2\n        // codeBlock: Colors.DARK_GRAY2,\n        // codeAccent: Colors.GREEN5,\n        // vault: {\n        //     list: {\n        //         focusedBackgroundColor: Colors.DARK_GRAY5,\n        //         selectedBackgroundColor: Colors.TURQUOISE3,\n        //         selectedTextColor: \"#fff\"\n        //     },\n        //     colors: {\n        //         divider: Colors.DARK_GRAY5,\n        //         paneDivider: Colors.GRAY3,\n        //         uiBackground: Colors.DARK_GRAY4,\n        //         mainPaneBackground: Colors.DARK_GRAY3\n        //     },\n        //     tree: {\n        //         selectedBackgroundColor: Colors.DARK_GRAY5,\n        //         hoverBackgroundColor: \"transparent\",\n        //         selectedTextColor: Colors.LIGHT_GRAY5,\n        //         selectedIconColor: Colors.LIGHT_GRAY5\n        //     },\n        //     entry: {\n        //         primaryContainer: Colors.DARK_GRAY3,\n        //         separatorTextColor: Colors.GRAY3,\n        //         separatorBorder: Colors.GRAY1,\n        //         fieldHoverBorder: Colors.GRAY1\n        //     },\n        //     attachment: {\n        //         dropBackground: Colors.DARK_GRAY3,\n        //         dropBorder: Colors.DARK_GRAY5,\n        //         dropText: Colors.GRAY2\n        //     }\n        // }\n    },\n    light: {\n        backgroundColor: Colors.WHITE,\n        backgroundFrameColor: Colors.GRAY5,\n        listItemHover: Colors.LIGHT_GRAY3\n        // codeBlock: Colors.LIGHT_GRAY1,\n        // codeAccent: Colors.GREEN1,\n        // vault: {\n        //     list: {\n        //         focusedBackgroundColor: Colors.LIGHT_GRAY5,\n        //         selectedBackgroundColor: Colors.TURQUOISE3,\n        //         selectedTextColor: \"#fff\"\n        //     },\n        //     colors: {\n        //         divider: Colors.LIGHT_GRAY4,\n        //         paneDivider: Colors.GRAY3,\n        //         uiBackground: \"#fff\",\n        //         mainPaneBackground: Colors.LIGHT_GRAY5\n        //     },\n        //     tree: {\n        //         selectedBackgroundColor: Colors.LIGHT_GRAY2,\n        //         hoverBackgroundColor: \"transparent\",\n        //         selectedTextColor: Colors.DARK_GRAY1,\n        //         selectedIconColor: Colors.DARK_GRAY5\n        //     },\n        //     entry: {\n        //         primaryContainer: Colors.LIGHT_GRAY5,\n        //         separatorTextColor: Colors.GRAY3,\n        //         separatorBorder: Colors.LIGHT_GRAY2,\n        //         fieldHoverBorder: Colors.LIGHT_GRAY1\n        //     },\n        //     attachment: {\n        //         dropBackground: Colors.LIGHT_GRAY5,\n        //         dropBorder: Colors.LIGHT_GRAY2,\n        //         dropText: Colors.GRAY4\n        //     }\n        // }\n    }\n};\n"
  },
  {
    "path": "source/shared/types.ts",
    "content": "import {\n    EntryID,\n    EntryType,\n    GroupID,\n    SearchResult,\n    VaultFacade,\n    VaultFormatID,\n    VaultSourceID,\n    VaultSourceStatus\n} from \"buttercup\";\nimport { ReactChild, ReactChildren } from \"react\";\n\nexport interface AddVaultPayload {\n    createNew: boolean;\n    dropboxToken?: string;\n    masterPassword: string;\n    name: string;\n    type: VaultType;\n    vaultPath: string;\n}\n\nexport interface BackgroundMessage {\n    autoLogin?: boolean;\n    code?: string;\n    configKey?: keyof Configuration;\n    configValue?: any;\n    count?: number;\n    credentials?: UsedCredentials;\n    credentialsID?: string;\n    domains?: Array<string>;\n    entry?: SearchResult;\n    entryID?: EntryID;\n    entryProperties?: Record<string, string>;\n    entryType?: EntryType;\n    excludeSaved?: boolean;\n    groupID?: GroupID;\n    notification?: string;\n    searchTerm?: string;\n    sourceID?: VaultSourceID;\n    text?: string;\n    type: BackgroundMessageType;\n    url?: string;\n}\n\nexport enum BackgroundMessageType {\n    AuthenticateDesktopConnection = \"authenticateDesktopConnection\",\n    CheckDesktopConnection = \"checkDesktopConnection\",\n    ClearDesktopAuthentication = \"clearDesktopAuthentication\",\n    ClearSavedCredentials = \"clearSavedCredentials\",\n    ClearSavedCredentialsPrompt = \"clearSavedCredentialsPrompt\",\n    DisableSavePromptForCredentials = \"disableSavePromptForCredentials\",\n    DeleteDisabledDomains = \"deleteDisabledDomains\",\n    InitiateDesktopConnection = \"initiateDesktopConnection\",\n    GetAutoLoginForTab = \"getTabAutoLogin\",\n    GetConfiguration = \"getConfiguration\",\n    GetDesktopVaultSources = \"getDesktopVaultSources\",\n    GetDesktopVaultsTree = \"getDesktopVaultsTree\",\n    GetDisabledDomains = \"getDisabledDomains\",\n    GetLastSavedCredentials = \"getLastSavedCredentials\",\n    GetOTPs = \"getOTPs\",\n    GetRecentEntries = \"getRecentEntries\",\n    GetSavedCredentials = \"getCredentials\",\n    GetSavedCredentialsForID = \"getCredentialsForID\",\n    MarkNotificationRead = \"markNotificationRead\",\n    OpenEntryPage = \"openEntryPage\",\n    OpenSaveCredentialsPage = \"openSaveCredentials\",\n    PromptLockSource = \"promptLockSource\",\n    PromptUnlockSource = \"promptUnlockSource\",\n    ResetSettings = \"resetSettings\",\n    SaveCredentialsToVault = \"saveCredentialsToVault\",\n    SaveUsedCredentials = \"saveUsedCredentials\",\n    SearchEntriesByTerm = \"searchEntriesByTerm\",\n    SearchEntriesByURL = \"searchEntriesByURL\",\n    SetConfigurationValue = \"setConfigurationValue\",\n    TrackRecentEntry = \"trackRecentEntry\"\n}\n\nexport interface BackgroundResponse {\n    available?: boolean;\n    autoLogin?: SearchResult | null;\n    config?: Configuration;\n    credentials?: Array<UsedCredentials | null>;\n    domains?: Array<string>;\n    entryID?: EntryID | null;\n    error?: Error;\n    locked?: boolean;\n    opened?: boolean;\n    otps?: Array<OTP>;\n    searchResults?: Array<SearchResult>;\n    vaultSources?: Array<VaultSourceDescription>;\n    vaultsTree?: VaultsTree;\n}\n\ntype ChildElement = ReactChild | ReactChildren | false | null;\nexport type ChildElements = ChildElement | Array<ChildElement>;\n\nexport interface Configuration {\n    entryIcons: boolean;\n    inputButtonDefault: InputButtonType;\n    saveNewLogins: boolean;\n    theme: \"light\" | \"dark\";\n    useSystemTheme: boolean;\n}\n\nexport interface ElementRect {\n    x: number;\n    y: number;\n    width: number;\n    height: number;\n}\n\nexport enum InputButtonType {\n    InnerIcon = \"innericon\",\n    LargeButton = \"largebutton\"\n}\n\nexport enum InputType {\n    OTP = \"otp\",\n    UserPassword = \"user-password\"\n}\n\nexport interface OTP {\n    entryID: EntryID;\n    entryProperty: string;\n    entryTitle: string;\n    loginURL: string | null;\n    otpTitle?: string;\n    otpURL: string;\n    sourceID: VaultSourceID;\n}\n\nexport enum PopupPage {\n    About = \"about\",\n    Entries = \"entries\",\n    OTPs = \"otps\",\n    Settings = \"settings\",\n    Vaults = \"vaults\"\n}\n\nexport interface SavedCredentials extends UsedCredentials {\n    entryID?: EntryID;\n    groupID: GroupID;\n    sourceID: VaultSourceID;\n}\n\nexport interface TabEvent {\n    formID?: string;\n    inputDetails?: {\n        otp?: string;\n        password?: string;\n        username?: string;\n    };\n    inputPosition?: ElementRect;\n    inputType?: InputType;\n    source?: MessageEventSource;\n    sourceURL?: string;\n    type: TabEventType;\n}\n\nexport enum TabEventType {\n    CloseSaveDialog = \"closeSaveDialog\",\n    GetFrameID = \"getFrameID\",\n    InputDetails = \"inputDetails\",\n    OpenPopupDialog = \"openPopupDialog\"\n}\n\nexport interface UsedCredentials {\n    fromEntry: boolean;\n    id: string;\n    password: string;\n    promptSave: boolean;\n    timestamp: number;\n    title: string;\n    url: string;\n    username: string;\n}\n\nexport interface VaultSourceDescription {\n    id: VaultSourceID;\n    name: string;\n    state: VaultSourceStatus;\n    type: VaultType;\n    order: number;\n    format?: VaultFormatID;\n}\n\nexport interface VaultsTree {\n    [key: string]: VaultsTreeItem;\n}\n\nexport interface VaultsTreeItem extends VaultFacade {\n    name: string;\n}\n\nexport enum VaultType {\n    Dropbox = \"dropbox\",\n    File = \"file\",\n    GoogleDrive = \"googledrive\",\n    WebDAV = \"webdav\"\n}\n"
  },
  {
    "path": "source/tab/index.ts",
    "content": "import { FRAME } from \"./state/frame.js\";\nimport { initialise } from \"./services/init.js\";\nimport { log } from \"./services/log.js\";\n\nFRAME.isTop = window.parent === window;\n\ninitialise().catch((err) => {\n    console.error(err);\n    log(`initialisation failed: ${err.message}`);\n});\n"
  },
  {
    "path": "source/tab/library/disable.ts",
    "content": "export function itemIsIgnored(element: HTMLElement): boolean {\n    return element.matches(\"[data-bcupignore=true] *, [data-bcupignore=true]\");\n}\n"
  },
  {
    "path": "source/tab/library/dismount.ts",
    "content": "export function onElementDismount(el: HTMLElement, callback: () => void): void {\n    let active = true,\n        timer: ReturnType<typeof setTimeout>;\n    const disconnect = () => {\n        active = false;\n        mutObs.disconnect();\n        clearTimeout(timer);\n    };\n    const mutObs = new MutationObserver((records) => {\n        if (!active) return;\n        const wasRemoved = records.some((record) => {\n            return [...record.removedNodes].includes(el);\n        });\n        if (wasRemoved) {\n            disconnect();\n            callback();\n        }\n    });\n    if (!el.parentElement) {\n        throw new Error(\"No parent element found for target\");\n    }\n    mutObs.observe(el.parentElement, { childList: true });\n    timer = setTimeout(() => {\n        if (!el.parentElement) {\n            disconnect();\n            callback();\n        }\n    }, 50);\n}\n"
  },
  {
    "path": "source/tab/library/frames.ts",
    "content": "export function findIframeForWindow(url: string): HTMLIFrameElement | null {\n    const iframes = [...document.getElementsByTagName(\"iframe\")];\n    return iframes.find((frame) => frame.src === url) || null;\n}\n"
  },
  {
    "path": "source/tab/library/page.ts",
    "content": "import { extractDomain } from \"../../shared/library/domain.js\";\n\nexport function currentDomainDisabled(\n    disabledDomains: Array<string>,\n    currentDomain: string = getCurrentDomain()\n): boolean {\n    return disabledDomains.some((disabledDomain) => {\n        const idx = currentDomain.indexOf(disabledDomain);\n        return idx === currentDomain.length - disabledDomain.length;\n    });\n}\n\nexport function getCurrentDomain(): string {\n    return extractDomain(getCurrentURL());\n}\n\nexport function getCurrentTitle(): string {\n    return document.title;\n}\n\nexport function getCurrentURL(): string {\n    return window.location.href;\n}\n"
  },
  {
    "path": "source/tab/library/position.ts",
    "content": "import { ElementRect } from \"../types.js\";\n\nexport function getElementRectInDocument(el: HTMLElement): ElementRect {\n    const boundingRect = el.getBoundingClientRect();\n    return {\n        x: boundingRect.left + document.documentElement.scrollLeft,\n        y: boundingRect.top + document.documentElement.scrollTop,\n        width: boundingRect.width,\n        height: boundingRect.height\n    };\n}\n\nexport function recalculateRectForIframe(rect: ElementRect, iframe: HTMLIFrameElement): ElementRect {\n    const framePos = getElementRectInDocument(iframe);\n    return {\n        ...rect,\n        x: framePos.x + rect.x,\n        y: framePos.y + rect.y\n    };\n}\n"
  },
  {
    "path": "source/tab/library/resize.ts",
    "content": "export function onBodyResize(\n    callback: (newWidth: number, newHeight: number, lastWidth: number, lastHeight: number) => void\n): () => void {\n    let lastWidth = 0,\n        lastHeight = 0;\n    const watch = setInterval(() => {\n        const newWidth = document.body.offsetWidth;\n        const newHeight = document.body.offsetHeight;\n        if (newWidth !== lastWidth || newHeight !== lastHeight) {\n            callback(newWidth, newHeight, lastWidth, lastHeight);\n            lastWidth = newWidth;\n            lastHeight = newHeight;\n        }\n    }, 200);\n    return () => {\n        clearInterval(watch);\n    };\n}\n\nexport function onBodyWidthResize(callback: (newWidth: number, lastWidth: number) => void): () => void {\n    let lastWidth = 0;\n    const watch = setInterval(() => {\n        const newWidth = document.body.offsetWidth;\n        if (newWidth !== lastWidth) {\n            callback(newWidth, lastWidth);\n            lastWidth = newWidth;\n        }\n    }, 200);\n    return () => {\n        clearInterval(watch);\n    };\n}\n"
  },
  {
    "path": "source/tab/library/styles.ts",
    "content": "export const CLEAR_STYLES = {\n    margin: \"0px\",\n    minWidth: \"0px\",\n    minHeight: \"0px\",\n    padding: \"0px\"\n};\n\nexport function findBestZIndexInContainer(parentElement: HTMLElement) {\n    let highest = 0;\n    [...parentElement.children].forEach((child) => {\n        const { zIndex } = window.getComputedStyle(child);\n        if (zIndex) {\n            const num = parseInt(zIndex, 10);\n            if (!isNaN(num) && num > highest) {\n                highest = num;\n            }\n        }\n    });\n    return highest + 1;\n}\n"
  },
  {
    "path": "source/tab/library/zIndex.ts",
    "content": "export function findBestZIndexInContainer(parentElement: HTMLElement): number {\n    let highest: number = 0;\n    [...parentElement.children].forEach((child) => {\n        const { zIndex } = window.getComputedStyle(child);\n        if (zIndex) {\n            const num = parseInt(zIndex, 10);\n            if (!isNaN(num) && num > highest) {\n                highest = num;\n            }\n        }\n    });\n    return highest + 1;\n}\n"
  },
  {
    "path": "source/tab/services/LoginTracker.ts",
    "content": "import { ulid } from \"ulidx\";\nimport EventEmitter from \"eventemitter3\";\nimport { getCurrentTitle, getCurrentURL } from \"../library/page.js\";\nimport { LoginTarget } from \"@buttercup/locust\";\n\ninterface Connection {\n    id: string;\n    loginTarget: LoginTarget;\n    entry: boolean;\n    username: string;\n    password: string;\n    _username: string;\n    _password: string;\n}\n\ninterface LoginTrackerEvents {\n    credentialsChanged: (event: { id: string; username: string; password: string; entry: boolean }) => void;\n}\n\nlet __sharedTracker: LoginTracker | null = null;\n\nexport class LoginTracker extends EventEmitter<LoginTrackerEvents> {\n    protected _connections: Array<Connection> = [];\n    protected _title = getCurrentTitle();\n    protected _url = getCurrentURL();\n\n    get title() {\n        return this._title;\n    }\n\n    get url() {\n        return this._url;\n    }\n\n    getConnection(loginTarget: LoginTarget): Connection | null {\n        return (\n            this._connections.find(\n                (conn) => conn.loginTarget === loginTarget || conn.loginTarget.form === loginTarget.form\n            ) || null\n        );\n    }\n\n    registerConnection(loginTarget: LoginTarget) {\n        const _this = this;\n        const connection: Connection = {\n            id: ulid(),\n            loginTarget,\n            entry: false,\n            _username: \"\",\n            _password: \"\",\n            get username() {\n                return connection._username;\n            },\n            get password() {\n                return connection._password;\n            },\n            set username(un) {\n                connection._username = un;\n                _this.emit(\"credentialsChanged\", {\n                    id: connection.id,\n                    username: connection.username,\n                    password: connection.password,\n                    entry: connection.entry\n                });\n            },\n            set password(pw) {\n                connection._password = pw;\n                _this.emit(\"credentialsChanged\", {\n                    id: connection.id,\n                    username: connection.username,\n                    password: connection.password,\n                    entry: connection.entry\n                });\n            }\n        };\n        this._connections.push(connection);\n    }\n}\n\nexport function getSharedTracker(): LoginTracker {\n    if (!__sharedTracker) {\n        __sharedTracker = new LoginTracker();\n    }\n    return __sharedTracker;\n}\n"
  },
  {
    "path": "source/tab/services/autoLogin.ts",
    "content": "import { LoginTarget } from \"@buttercup/locust\";\nimport { SearchResult } from \"buttercup\";\nimport { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../types.js\";\nimport { searchResultToOTP } from \"../../shared/library/otp.js\";\n\nasync function getAutoLogin(): Promise<SearchResult | null> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetAutoLoginForTab\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching auto-login data\");\n    }\n    return resp.autoLogin ?? null;\n}\n\nexport async function processTargetAutoLogin(loginTarget: LoginTarget): Promise<void> {\n    const entry = await getAutoLogin();\n    if (!entry) return;\n    if (entry.properties.username) {\n        loginTarget.fillUsername(entry.properties.username);\n    }\n    if (entry.properties.password) {\n        loginTarget.fillPassword(entry.properties.password);\n    }\n    if (loginTarget.otpField) {\n        const otpDigits = searchResultToOTP(entry);\n        if (otpDigits) {\n            loginTarget.fillOTP(otpDigits);\n        }\n    }\n    loginTarget.submit();\n}\n"
  },
  {
    "path": "source/tab/services/config.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../shared/services/messaging.js\";\nimport { BackgroundMessageType, Configuration } from \"../types.js\";\n\nexport async function getConfig(): Promise<Configuration> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetConfiguration\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching configuration\");\n    }\n    if (!resp.config) {\n        throw new Error(\"No config returned from background\");\n    }\n    return resp.config;\n}\n"
  },
  {
    "path": "source/tab/services/form.ts",
    "content": "import { ulid } from \"ulidx\";\nimport { getElementRectInDocument, recalculateRectForIframe } from \"../library/position.js\";\nimport { FORM } from \"../state/form.js\";\nimport { FRAME } from \"../state/frame.js\";\nimport { closePopup, togglePopup } from \"../ui/popup.js\";\nimport { waitAndAttachLaunchButtons } from \"./formDetection.js\";\nimport { broadcastFrameMessage, listenForTabEvents, sendTabEvent } from \"./messaging.js\";\nimport { findIframeForWindow } from \"../library/frames.js\";\nimport { FrameEvent, FrameEventType, TabEventType } from \"../types.js\";\n\nexport function fillFormDetails(frameEvent: FrameEvent) {\n    const { currentLoginTarget: loginTarget } = FORM;\n    const { inputDetails } = frameEvent;\n    if (!inputDetails) {\n        throw new Error(\"No input details for form fill action\");\n    }\n    if (!loginTarget) {\n        throw new Error(\"No login target found\");\n    }\n    if (inputDetails.username) {\n        loginTarget.fillUsername(inputDetails.username);\n    }\n    if (inputDetails.password) {\n        loginTarget.fillPassword(inputDetails.password);\n    }\n    if (inputDetails.otp) {\n        loginTarget.fillOTP(inputDetails.otp);\n    }\n    FORM.currentFormID = null;\n    FORM.currentLoginTarget = null;\n    closePopup();\n}\n\nexport async function initialise() {\n    // Watch for forms\n    await waitAndAttachLaunchButtons((input, loginTarget, inputType) => {\n        FORM.currentFormID = ulid();\n        FORM.currentLoginTarget = loginTarget;\n        if (FRAME.isTop) {\n            FORM.targetFormID = FORM.currentFormID;\n            togglePopup(getElementRectInDocument(input), inputType);\n        } else {\n            sendTabEvent(\n                {\n                    type: TabEventType.OpenPopupDialog,\n                    formID: FORM.currentFormID,\n                    inputPosition: getElementRectInDocument(input),\n                    inputType\n                },\n                window.parent\n            );\n        }\n    });\n    // Listen for tab-specific events\n    listenForTabEvents((tabEvent) => {\n        if (tabEvent.type === TabEventType.InputDetails) {\n            // Detect where to send the chosen details\n            if (FORM.currentFormID && tabEvent.formID === FORM.currentFormID) {\n                // This tab+frame is expecting these credentials\n                fillFormDetails({\n                    formID: tabEvent.formID,\n                    inputDetails: tabEvent.inputDetails,\n                    inputType: tabEvent.inputType,\n                    type: FrameEventType.FillForm\n                });\n            } else if (!FORM.currentFormID || FORM.currentFormID !== tabEvent.formID) {\n                // Destination is another tab\n                broadcastFrameMessage({\n                    formID: tabEvent.formID,\n                    inputDetails: tabEvent.inputDetails,\n                    inputType: tabEvent.inputType,\n                    type: FrameEventType.FillForm\n                });\n            } else {\n                throw new Error(\"Unexpected details input state\");\n            }\n        } else if (tabEvent.type === TabEventType.OpenPopupDialog) {\n            if (!tabEvent.sourceURL) {\n                console.error(\"No source URL provided\");\n                return;\n            }\n            if (!tabEvent.inputPosition) {\n                console.error(\"No input position provided\");\n                return;\n            }\n            if (!tabEvent.inputType) {\n                console.error(\"No input type provided\");\n                return;\n            }\n            // Re-calculate based upon the iframe the message came from\n            const frame = findIframeForWindow(tabEvent.sourceURL);\n            if (!frame) {\n                console.error(\"Failed presening Buttercup popup: Could not trace iframe nesting\");\n                return;\n            }\n            const newPosition = recalculateRectForIframe(tabEvent.inputPosition, frame);\n            // Show if top, or pass on to the next frame above\n            if (FRAME.isTop) {\n                FORM.targetFormID = tabEvent.formID ?? null;\n                togglePopup(newPosition, tabEvent.inputType);\n            } else {\n                sendTabEvent(\n                    {\n                        ...tabEvent,\n                        inputPosition: newPosition\n                    },\n                    window.parent\n                );\n            }\n        }\n    });\n}\n"
  },
  {
    "path": "source/tab/services/formDetection.ts",
    "content": "import { LoginTarget, LoginTargetFeature, getLoginTargets } from \"@buttercup/locust\";\nimport { attachLaunchButton } from \"../ui/launch.js\";\nimport { watchCredentialsOnTarget } from \"./logins/watcher.js\";\nimport { processTargetAutoLogin } from \"./autoLogin.js\";\nimport { InputType } from \"../types.js\";\nimport { getConfig } from \"./config.js\";\n\nconst TARGET_SEARCH_INTERVAL = 1000;\n\nfunction filterLoginTarget(_: LoginTargetFeature, element: HTMLElement): boolean {\n    if (element.dataset.bcup === \"attached\") {\n        return false;\n    }\n    return true;\n}\n\nfunction onIdentifiedTarget(callback: (target: LoginTarget) => void) {\n    const locatedForms: Array<HTMLElement> = [];\n    const findTargets = () => {\n        getLoginTargets(document, filterLoginTarget)\n            .filter((target) => locatedForms.includes(target.form) === false)\n            .forEach((target) => {\n                locatedForms.push(target.form);\n                setTimeout(() => {\n                    callback(target);\n                }, 0);\n            });\n    };\n    const checkInterval = setInterval(findTargets, TARGET_SEARCH_INTERVAL);\n    setTimeout(findTargets, 0);\n    return {\n        remove: () => {\n            clearInterval(checkInterval);\n            locatedForms.splice(0, locatedForms.length);\n        }\n    };\n}\n\nexport async function waitAndAttachLaunchButtons(\n    onInputActivate: (input: HTMLInputElement, loginTarget: LoginTarget, inputType: InputType) => void\n) {\n    const config = await getConfig();\n    onIdentifiedTarget((loginTarget: LoginTarget) => {\n        const { otpField, usernameField, passwordField } = loginTarget;\n        if (otpField) {\n            attachLaunchButton(otpField, config.inputButtonDefault, (el) =>\n                onInputActivate(el, loginTarget, InputType.OTP)\n            );\n        }\n        if (passwordField) {\n            attachLaunchButton(passwordField, config.inputButtonDefault, (el) =>\n                onInputActivate(el, loginTarget, InputType.UserPassword)\n            );\n        }\n        if (usernameField) {\n            attachLaunchButton(usernameField, config.inputButtonDefault, (el) =>\n                onInputActivate(el, loginTarget, InputType.UserPassword)\n            );\n        }\n        watchCredentialsOnTarget(loginTarget);\n        processTargetAutoLogin(loginTarget).catch(console.error);\n    });\n}\n"
  },
  {
    "path": "source/tab/services/init.ts",
    "content": "import { initialise as initialiseMessaging } from \"./messaging.js\";\nimport { initialise as initialiseForms } from \"./form.js\";\nimport { initialise as initialiseCredentialsWatching } from \"./logins/watcher.js\";\n\nexport async function initialise() {\n    await initialiseMessaging();\n    await initialiseForms();\n    await initialiseCredentialsWatching();\n}\n"
  },
  {
    "path": "source/tab/services/log.ts",
    "content": "import { Logger, createLog } from \"../../shared/library/log.js\";\n\nconst LOG_NAME = \"buttercup:browser:tab\";\n\nlet __logger: Logger;\n\nexport function log(...args: Array<any>): void {\n    if (!__logger) {\n        __logger = createLog(LOG_NAME, true);\n    }\n    return __logger(...args);\n}\n"
  },
  {
    "path": "source/tab/services/logins/disabled.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../../shared/services/messaging.js\";\nimport { BackgroundMessageType } from \"../../types.js\";\n\nexport async function getDisabledDomains(): Promise<Array<string>> {\n    const resp = await sendBackgroundMessage({\n        type: BackgroundMessageType.GetDisabledDomains\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching disabled login domains\");\n    }\n    return resp.domains ?? [];\n}\n"
  },
  {
    "path": "source/tab/services/logins/saving.ts",
    "content": "import { Layerr } from \"layerr\";\nimport { sendBackgroundMessage } from \"../../../shared/services/messaging.js\";\nimport { BackgroundMessageType, UsedCredentials } from \"../../types.js\";\n\nexport async function getCredentialsForID(id: string, excludeSaved: boolean = false): Promise<UsedCredentials | null> {\n    const resp = await sendBackgroundMessage({\n        credentialsID: id,\n        excludeSaved,\n        type: BackgroundMessageType.GetSavedCredentialsForID\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching saved credentials\");\n    }\n    return resp.credentials?.[0] ?? null;\n}\n\nexport async function getLastSavedCredentials(excludeSaved: boolean = false): Promise<UsedCredentials | null> {\n    const resp = await sendBackgroundMessage({\n        excludeSaved,\n        type: BackgroundMessageType.GetLastSavedCredentials\n    });\n    if (resp.error) {\n        throw new Layerr(resp.error, \"Failed fetching last saved credentials\");\n    }\n    return resp.credentials?.[0] ?? null;\n}\n\nexport function transferLoginCredentials(details: UsedCredentials) {\n    sendBackgroundMessage({ type: BackgroundMessageType.SaveUsedCredentials, credentials: details }).catch((err) => {\n        console.error(err);\n    });\n}\n"
  },
  {
    "path": "source/tab/services/logins/watcher.ts",
    "content": "import { LoginTarget, LoginTargetFeature } from \"@buttercup/locust\";\nimport { onNavigate } from \"on-navigate\";\nimport { getSharedTracker } from \"../LoginTracker.js\";\nimport { getCredentialsForID, getLastSavedCredentials, transferLoginCredentials } from \"./saving.js\";\nimport { getDisabledDomains } from \"./disabled.js\";\nimport { currentDomainDisabled, getCurrentDomain } from \"../../library/page.js\";\nimport { log } from \"../log.js\";\nimport { getConfig } from \"../../../shared/queries/config.js\";\nimport { openDialog } from \"../../ui/saveDialog.js\";\n\nasync function checkForLoginSaveAbility(loginID?: string) {\n    const [disabledDomains, config, used] = await Promise.all([\n        getDisabledDomains(),\n        getConfig(),\n        loginID ? getCredentialsForID(loginID, true) : getLastSavedCredentials(true)\n    ]);\n    if (!used || !used.promptSave || used.fromEntry) return;\n    if (currentDomainDisabled(disabledDomains)) {\n        log(`login available, but current domain disabled: ${getCurrentDomain()}`);\n        return;\n    }\n    if (!config.saveNewLogins) return;\n    log(\"saved login available, show prompt\");\n    openDialog(used.id);\n}\n\nexport async function initialise() {\n    const tracker = getSharedTracker();\n    tracker.on(\"credentialsChanged\", (details) => {\n        transferLoginCredentials({\n            fromEntry: details.entry,\n            id: details.id,\n            password: details.password,\n            promptSave: true,\n            timestamp: Date.now(),\n            title: tracker.title,\n            url: tracker.url,\n            username: details.username\n        });\n    });\n    await checkForLoginSaveAbility();\n}\n\nexport function watchCredentialsOnTarget(loginTarget: LoginTarget): void {\n    const tracker = getSharedTracker();\n    tracker.registerConnection(loginTarget);\n    watchLogin(\n        loginTarget,\n        (username, source) => {\n            const connection = tracker.getConnection(loginTarget);\n            if (connection) {\n                connection.entry = source === \"fill\";\n                connection.username = username;\n            }\n        },\n        (password, source) => {\n            const connection = tracker.getConnection(loginTarget);\n            if (connection) {\n                connection.entry = source === \"fill\";\n                connection.password = password;\n            }\n        },\n        () => {\n            const connection = tracker.getConnection(loginTarget);\n            if (!connection) return;\n            setTimeout(() => {\n                checkForLoginSaveAbility(connection.id);\n            }, 300);\n        }\n    );\n}\n\nfunction watchLogin(\n    target: LoginTarget,\n    usernameUpdate: (value: string, source: \"keypress\" | \"fill\") => void,\n    passwordUpdate: (value: string, source: \"keypress\" | \"fill\") => void,\n    onSubmit: () => void\n) {\n    target.on(\"valueChanged\", (info) => {\n        if (info.type === LoginTargetFeature.Username) {\n            usernameUpdate(info.value, info.source);\n        } else if (info.type === LoginTargetFeature.Password) {\n            passwordUpdate(info.value, info.source);\n        }\n    });\n    target.on(\"formSubmitted\", (info) => {\n        if (info.source === \"form\") {\n            onSubmit();\n        }\n    });\n    onNavigate(() => {\n        onSubmit();\n    });\n}\n"
  },
  {
    "path": "source/tab/services/messaging.ts",
    "content": "import { FORM } from \"../state/form.js\";\nimport { fillFormDetails } from \"./form.js\";\nimport { closeDialog } from \"../ui/saveDialog.js\";\nimport { getExtensionAPI } from \"../../shared/extension.js\";\nimport { FrameEvent, FrameEventType, TabEvent, TabEventType } from \"../types.js\";\n\nlet __framesChannel: BroadcastChannel;\n\nexport function broadcastFrameMessage(event: FrameEvent): void {\n    __framesChannel.postMessage(event);\n}\n\nexport async function initialise() {\n    __framesChannel = new BroadcastChannel(\"frames:all\");\n    __framesChannel.addEventListener(\"message\", handleFramesBroadcast);\n    const browser = getExtensionAPI();\n    browser.runtime.onMessage.addListener(handleTabMessage);\n}\n\nfunction handleFramesBroadcast(event: MessageEvent<FrameEvent>) {\n    const { type } = event.data;\n    if (type === FrameEventType.FillForm) {\n        const { formID } = event.data;\n        if (formID && formID === FORM.currentFormID && FORM.currentLoginTarget) {\n            fillFormDetails(event.data);\n        }\n    }\n}\n\nfunction handleTabMessage(payload: unknown) {\n    if (\n        !payload ||\n        typeof payload !== \"object\" ||\n        Object.values(TabEventType).includes((payload as any).type) === false\n    ) {\n        return;\n    }\n    const event = payload as TabEvent;\n    if (event.type === TabEventType.CloseSaveDialog) {\n        closeDialog();\n    }\n}\n\nexport function listenForTabEvents(callback: (event: TabEvent) => void) {\n    window.addEventListener(\"message\", (event: MessageEvent<any>) => {\n        if (event.data?.type && Object.values(TabEventType).includes(event.data?.type)) {\n            callback({\n                ...(event.data as TabEvent),\n                source: event.source ?? undefined\n            });\n        }\n    });\n}\n\nexport function sendTabEvent(event: TabEvent, destination: MessageEventSource): void {\n    const payload: TabEvent = {\n        ...event,\n        sourceURL: `${window.location.href}`\n    };\n    if (destination instanceof Window) {\n        destination.postMessage(payload, \"*\");\n    } else {\n        destination.postMessage(payload);\n    }\n}\n"
  },
  {
    "path": "source/tab/state/form.ts",
    "content": "import { createStateObject } from \"obstate\";\nimport { LoginTarget } from \"@buttercup/locust\";\n\nexport const FORM = createStateObject<{\n    currentFormID: string | null;\n    currentLoginTarget: LoginTarget | null;\n    targetFormID: string | null;\n}>({\n    currentFormID: null,\n    currentLoginTarget: null,\n    targetFormID: null\n});\n"
  },
  {
    "path": "source/tab/state/frame.ts",
    "content": "import { createStateObject } from \"obstate\";\n\nexport const FRAME = createStateObject<{\n    isTop: boolean;\n}>({\n    isTop: false\n});\n"
  },
  {
    "path": "source/tab/types.ts",
    "content": "import { InputType } from \"../shared/types.js\";\n\nexport * from \"../shared/types.js\";\n\nexport interface FrameEvent {\n    formID?: string;\n    inputDetails?: {\n        otp?: string;\n        password?: string;\n        username?: string;\n    };\n    inputType?: InputType;\n    type: FrameEventType;\n}\n\nexport enum FrameEventType {\n    FillForm = \"fillForm\"\n}\n"
  },
  {
    "path": "source/tab/ui/launch.ts",
    "content": "import { el, mount, setStyle } from \"redom\";\nimport { itemIsIgnored } from \"../library/disable.js\";\nimport { CLEAR_STYLES, findBestZIndexInContainer } from \"../library/styles.js\";\nimport { onElementDismount } from \"../library/dismount.js\";\nimport { onBodyWidthResize } from \"../library/resize.js\";\nimport { getExtensionURL } from \"../../shared/library/extension.js\";\nimport BUTTON_BACKGROUND_IMAGE_RES from \"../../../resources/content-button-background.png\";\nimport INPUT_BACKGROUND_IMAGE_RES from \"../../../resources/buttercup-simple-150.png\";\nimport { InputButtonType } from \"../types.js\";\n\nconst BUTTON_BACKGROUND_IMAGE = getExtensionURL(BUTTON_BACKGROUND_IMAGE_RES);\nconst INPUT_BACKGROUND_IMAGE = getExtensionURL(INPUT_BACKGROUND_IMAGE_RES);\n\nexport function attachLaunchButton(\n    input: HTMLInputElement,\n    buttonType: InputButtonType,\n    onClick: (input: HTMLInputElement) => void\n): void {\n    if (input.dataset.bcup === \"attached\" || itemIsIgnored(input)) {\n        return;\n    }\n    const tryToAttach = () => {\n        const bounds = input.getBoundingClientRect();\n        const { width } = bounds;\n        // Flag has having been attached\n        input.dataset.bcup = \"attached\";\n        // Check if we can continue\n        if (width <= 0 || !input.offsetParent) {\n            setTimeout(tryToAttach, 250);\n            return;\n        }\n        if (buttonType === InputButtonType.LargeButton) {\n            renderButtonStyle(input, () => onClick(input), tryToAttach, bounds);\n        } else if (buttonType === InputButtonType.InnerIcon) {\n            renderInternalStyle(input, () => onClick(input), bounds);\n        }\n    };\n    tryToAttach();\n}\n\nfunction renderInternalStyle(input: HTMLInputElement, onClick: () => void, inputBounds: DOMRect) {\n    const bounds = inputBounds || input.getBoundingClientRect();\n    const { height } = bounds;\n    const imageSize = height * 0.6;\n    const rightOffset = 8;\n    const buttonArea = imageSize + rightOffset + 4;\n    const originalAutocomplete = input.getAttribute(\"autocomplete\") ?? null;\n    setStyle(input, {\n        backgroundImage: `url(${INPUT_BACKGROUND_IMAGE})`,\n        backgroundSize: `${imageSize}px`,\n        backgroundPosition: `right ${rightOffset}px center`,\n        backgroundRepeat: \"no-repeat\",\n        paddingRight: `${buttonArea}px`\n    });\n    input.onclick = (event) => {\n        if (event.offsetX >= input.offsetWidth - buttonArea) {\n            event.preventDefault();\n            event.stopPropagation();\n            onClick();\n        }\n    };\n    input.onmousemove = (event) => {\n        if (event.offsetX >= input.offsetWidth - buttonArea) {\n            input.setAttribute(\"autocomplete\", \"off\");\n            setStyle(input, {\n                cursor: \"pointer\"\n            });\n        } else {\n            if (originalAutocomplete) {\n                input.setAttribute(\"autocomplete\", originalAutocomplete);\n            } else {\n                input.removeAttribute(\"autocomplete\");\n            }\n            setStyle(input, {\n                cursor: \"unset\"\n            });\n        }\n    };\n}\n\nfunction renderButtonStyle(input: HTMLInputElement, onClick: () => void, reattachCB: () => void, inputBounds: DOMRect) {\n    const bounds = inputBounds || input.getBoundingClientRect();\n    const { width, height } = bounds;\n    const { borderTopLeftRadius, borderBottomLeftRadius, boxSizing, paddingLeft, paddingRight } =\n        window.getComputedStyle(input, null);\n    // Calculate button location\n    const inputLeftPadding = parseInt(paddingLeft, 10) || 0;\n    const inputRightPadding = parseInt(paddingRight, 10) || 0;\n    const buttonWidth = 0.8 * height;\n    const calculateLeft = () =>\n        input.offsetLeft +\n        width +\n        (boxSizing === \"border-box\" ? 0 - buttonWidth : inputLeftPadding - inputRightPadding);\n    let left = calculateLeft();\n    let top = input.offsetTop;\n    const buttonZ = findBestZIndexInContainer(input.offsetParent as HTMLElement);\n    // Input padding\n    setStyle(input, {\n        paddingRight: `${inputRightPadding + buttonWidth}px`\n    });\n    // Update input style\n    updateOffsetParentPositioning(input.offsetParent as HTMLElement);\n    // Create and add button\n    const button = el(\"button\", {\n        type: \"button\",\n        tabIndex: -1,\n        style: {\n            ...CLEAR_STYLES,\n            position: \"absolute\",\n            width: `${buttonWidth}px`,\n            height: `${height}px`,\n            left: `${left}px`,\n            top: `${top}px`,\n            borderRadius: `0 ${borderTopLeftRadius} ${borderBottomLeftRadius} 0`,\n            background: `rgb(0, 183, 172) url(${BUTTON_BACKGROUND_IMAGE})`,\n            backgroundSize: `${Math.ceil(buttonWidth / 2)}px`,\n            backgroundRepeat: \"no-repeat\",\n            backgroundPosition: \"50% 50%\",\n            border: \"1px solid rgb(0, 155, 145)\",\n            cursor: \"pointer\",\n            zIndex: buttonZ,\n            outline: \"none\"\n        }\n    });\n    button.onclick = (event) => {\n        event.preventDefault();\n        event.stopPropagation();\n        // toggleInputDialog(input, DIALOG_TYPE_ENTRY_PICKER);\n        onClick();\n    };\n    // @ts-ignore\n    mount(input.offsetParent, button);\n    onElementDismount(button, () => {\n        reattachCB();\n    });\n    const reprocessButton = () => {\n        try {\n            left = calculateLeft();\n            top = input.offsetTop;\n            setStyle(button, {\n                top: `${top}px`,\n                left: `${left}px`\n            });\n        } catch (err) {\n            clearInterval(reprocessInterval);\n            removeOnBodyWidthResize();\n        }\n    };\n    const removeOnBodyWidthResize = onBodyWidthResize(reprocessButton);\n    const reprocessInterval = setInterval(reprocessButton, 1250);\n    reprocessButton();\n}\n\nfunction updateOffsetParentPositioning(offsetParent: HTMLElement): void {\n    const { position: computedPosition } = window.getComputedStyle(offsetParent, null);\n    const position = computedPosition || offsetParent.style.position || \"static\";\n    if (position === \"static\") {\n        setStyle(offsetParent, {\n            position: \"relative\"\n        });\n    }\n}\n"
  },
  {
    "path": "source/tab/ui/popup.ts",
    "content": "import { el, mount, setStyle, unmount } from \"redom\";\nimport { getExtensionURL } from \"../../shared/library/extension.js\";\nimport { BRAND_COLOUR_DARK } from \"../../shared/symbols.js\";\nimport { getCurrentURL } from \"../library/page.js\";\nimport { onBodyResize } from \"../library/resize.js\";\nimport { FORM } from \"../state/form.js\";\nimport { ElementRect, InputType, PopupPage } from \"../types.js\";\n\ninterface LastPopup {\n    cleanup: () => void;\n    inputRect: ElementRect;\n    popup: HTMLElement;\n}\n\nconst CLEAR_STYLES = {\n    margin: \"0px\",\n    minWidth: \"0px\",\n    minHeight: \"0px\",\n    padding: \"0px\"\n};\nconst POPUP_HEIGHT = 300;\nconst POPUP_WIDTH = 320;\n\nlet __popup: LastPopup | null = null;\n\nfunction buildNewPopup(inputRect: ElementRect, forInputType: InputType) {\n    const currentURL = getCurrentURL();\n    const formID = FORM.targetFormID || \"\";\n    const initialPage = forInputType === InputType.OTP ? PopupPage.OTPs : PopupPage.Entries;\n    const popupURL = getExtensionURL(\n        `popup.html#/dialog?page=${encodeURIComponent(currentURL)}&form=${formID}&initial=${initialPage}`\n    );\n    const frame = el(\"iframe\", {\n        style: {\n            width: \"100%\",\n            height: \"100%\"\n        },\n        src: popupURL,\n        frameBorder: \"0\"\n    });\n    const container = el(\n        \"div\",\n        {\n            style: {\n                ...CLEAR_STYLES,\n                background: \"#fff\",\n                borderRadius: \"6px\",\n                overflow: \"hidden\",\n                border: `2px solid ${BRAND_COLOUR_DARK}`,\n                width: `${POPUP_WIDTH}px`,\n                height: `${POPUP_HEIGHT}px`,\n                minWidth: `${POPUP_WIDTH}px`,\n                position: \"absolute\",\n                zIndex: 9999999\n            }\n        },\n        frame\n    );\n    mount(document.body, container);\n    const removeBodyResizeListener = onBodyResize(() => updatePopupPosition((__popup as LastPopup).inputRect));\n    document.body.addEventListener(\"click\", closePopup, false);\n    __popup = {\n        cleanup: () => {\n            removeBodyResizeListener();\n            document.body.removeEventListener(\"click\", closePopup, false);\n        },\n        inputRect,\n        popup: container\n    };\n    updatePopupPosition(inputRect);\n}\n\nexport function closePopup() {\n    if (!__popup) return;\n    __popup.cleanup();\n    unmount(document.body, __popup.popup);\n    __popup = null;\n    FORM.targetFormID = null;\n}\n\nexport function togglePopup(inputRect: ElementRect, forInputType: InputType) {\n    if (__popup === null) {\n        buildNewPopup(inputRect, forInputType);\n    } else {\n        // Tear down\n        closePopup();\n    }\n}\n\nexport function updatePopupPosition(inputRect: ElementRect): void {\n    if (!__popup) return;\n    __popup.inputRect = inputRect;\n    setStyle(__popup.popup, {\n        left: `${inputRect.x}px`,\n        top: `${inputRect.y + inputRect.height + 2}px`\n    });\n}\n"
  },
  {
    "path": "source/tab/ui/saveDialog.ts",
    "content": "import { el, mount, unmount } from \"redom\";\nimport { getExtensionURL } from \"../../shared/library/extension.js\";\nimport { BRAND_COLOUR_DARK } from \"../../shared/symbols.js\";\n\ninterface LastSaveDialog {\n    cleanup: () => void;\n    dialog: HTMLElement;\n    loginID: string;\n}\n\nconst CLEAR_STYLES = {\n    margin: \"0px\",\n    minWidth: \"0px\",\n    minHeight: \"0px\",\n    padding: \"0px\"\n};\nconst DIALOG_WIDTH = 380;\nconst DIALOG_HEIGHT = 230;\n\nlet __popup: LastSaveDialog | null = null;\n\nfunction buildNewSaveDialog(loginID: string) {\n    const dialogURL = getExtensionURL(`popup.html#/save-dialog?login=${loginID}`);\n    const frame = el(\"iframe\", {\n        style: {\n            width: \"100%\",\n            height: \"100%\"\n        },\n        src: dialogURL,\n        frameBorder: \"0\"\n    });\n    const container = el(\n        \"div\",\n        {\n            style: {\n                ...CLEAR_STYLES,\n                background: \"#fff\",\n                borderRadius: \"6px\",\n                overflow: \"hidden\",\n                border: `2px solid ${BRAND_COLOUR_DARK}`,\n                width: `${DIALOG_WIDTH}px`,\n                height: `${DIALOG_HEIGHT}px`,\n                minWidth: `${DIALOG_WIDTH}px`,\n                position: \"absolute\",\n                top: \"15px\",\n                right: \"15px\",\n                zIndex: 9999999\n            }\n        },\n        frame\n    );\n    mount(document.body, container);\n    __popup = {\n        cleanup: () => {},\n        dialog: container,\n        loginID\n    };\n}\n\nexport function closeDialog() {\n    if (!__popup) return;\n    __popup.cleanup();\n    unmount(document.body, __popup.dialog);\n    __popup = null;\n}\n\nexport function openDialog(loginID: string) {\n    if (__popup && __popup.loginID === loginID) return;\n    if (__popup) {\n        closeDialog();\n    }\n    buildNewSaveDialog(loginID);\n}\n"
  },
  {
    "path": "source/typings/assets.d.ts",
    "content": "declare module \"*.md\" {\n    const value: any;\n    export default value;\n}\ndeclare module \"*.png\" {\n    const value: any;\n    export default value;\n}\ndeclare module \"*.jpg\" {\n    const value: any;\n    export default value;\n}\ndeclare module \"*.svg\" {\n    const value: any;\n    export default value;\n}\n"
  },
  {
    "path": "source/typings/globals.d.ts",
    "content": "declare var BROWSER: \"chrome\" | \"edge\" | \"firefox\";\ndeclare var browser: typeof chrome;\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"allowSyntheticDefaultImports\": true,\n        \"esModuleInterop\": true,\n        \"jsx\": \"react-jsx\",\n        \"module\": \"esnext\",\n        \"moduleResolution\": \"node\",\n        \"outDir\": \"./dist\",\n        \"resolveJsonModule\": true,\n        \"strict\": false,\n        \"strictNullChecks\": true,\n        \"target\": \"ES6\",\n        \"types\": [\"chrome\", \"react\", \"react-dom\"]\n    },\n    \"include\": [\n        \"./source/**/*\"\n    ],\n    \"exclude\":[\n        \"dist\",\n        \"node_modules\"\n    ]\n}\n"
  },
  {
    "path": "webpack.config.js",
    "content": "import { writeFileSync } from \"node:fs\";\nimport { fileURLToPath } from \"node:url\";\nimport path from \"node:path\";\nimport { createRequire } from \"node:module\";\nimport webpack from \"webpack\";\nimport ResolveTypeScriptPlugin from \"resolve-typescript-plugin\";\nimport CopyWebpackPlugin from \"copy-webpack-plugin\";\nimport { merge } from \"webpack-merge\";\nimport PugPlugin from \"pug-plugin\";\nimport sass from \"sass\";\n\nimport packageInfo from \"./package.json\" assert { type: \"json\" };\nimport manifestV2 from \"./resources/manifest.v2.json\" assert { type: \"json\" };\nimport manifestV3 from \"./resources/manifest.v3.json\" assert { type: \"json\" };\n\nconst { BannerPlugin, DefinePlugin } = webpack;\nconst { BROWSER } = process.env;\nconst V3_BROWSERS = [\"chrome\", \"edge\"];\nconst require = createRequire(import.meta.url);\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DIST = path.resolve(__dirname, \"dist\");\nconst ICONS_PATH = path.join(path.dirname(require.resolve(\"@buttercup/ui\")), \"icons\");\n\nif (!BROWSER) {\n    throw new Error(\"BROWSER must be specified\");\n}\n\nfunction buildManifest(assetNames, manifest) {\n    const newManifest = JSON.parse(JSON.stringify(manifest));\n    newManifest.version = packageInfo.version;\n    assetNames.forEach((assetFilename) => {\n        if (/^[^\\/\\\\]+\\.js$/.test(assetFilename)) {\n            if (/\\bbackground\\b/.test(assetFilename) && assetFilename !== \"background.js\") {\n                newManifest.background.scripts.unshift(assetFilename);\n            }\n            if (/\\btab\\b/.test(assetFilename) && assetFilename !== \"tab.js\") {\n                newManifest.content_scripts[0].js.unshift(assetFilename);\n            }\n        }\n    });\n    writeFileSync(path.join(DIST, \"./manifest.json\"), JSON.stringify(newManifest, undefined, 2));\n}\n\nfunction getBaseConfig() {\n    return {\n        devtool: false,\n\n        module: {\n            rules: [\n                {\n                    test: /\\.tsx?$/,\n                    use: [\n                        {\n                            loader: \"babel-loader\",\n                            options: {\n                                compact: true,\n                                presets: [\n                                    [\n                                        \"@babel/preset-env\",\n                                        {\n                                            targets: {\n                                                chrome: \"90\",\n                                                firefox: \"85\",\n                                                edge: \"90\"\n                                            },\n                                            useBuiltIns: false\n                                        }\n                                    ]\n                                ]\n                            }\n                        },\n                        {\n                            loader: \"ts-loader\"\n                        }\n                    ],\n                    resolve: {\n                        fullySpecified: false\n                    }\n                },\n                {\n                    test: /\\.s[ac]ss$/,\n                    use: [\n                        \"css-loader\",\n                        {\n                            loader: \"sass-loader\",\n                            options: {\n                                implementation: sass\n                            }\n                        }\n                    ]\n                },\n                {\n                    test: /\\.css$/,\n                    use: [\"css-loader\"]\n                },\n                {\n                    test: /\\.(jpg|png|svg|eot|svg|ttf|woff|woff2)$/,\n                    type: \"asset/resource\",\n                    generator: {\n                        filename: \"assets/[name][ext]\"\n                    }\n                },\n                {\n                    test: /\\.pug$/,\n                    loader: PugPlugin.loader\n                }\n            ]\n        },\n\n        output: {\n            filename: \"[name].js\",\n            path: DIST\n        },\n\n        performance: {\n            hints: false,\n            maxEntrypointSize: 768000,\n            maxAssetSize: 768000\n        },\n\n        plugins: [\n            new DefinePlugin({\n                BROWSER: JSON.stringify(BROWSER)\n            })\n        ],\n\n        resolve: {\n            alias: {\n                iocane: \"iocane/web\",\n                \"react/jsx-runtime\": \"react/jsx-runtime.js\",\n                \"react/jsx-dev-runtime\": \"react/jsx-dev-runtime.js\"\n            },\n            // No .ts/.tsx included due to the typescript resolver plugin\n            extensions: [\".js\", \".jsx\"],\n            fallback: {\n                buffer: false,\n                crypto: false,\n                fs: false,\n                path: false,\n                util: false\n            },\n            plugins: [\n                // Handle .ts => .js resolution\n                new ResolveTypeScriptPlugin()\n            ]\n        }\n    };\n}\n\nexport default [\n    merge(getBaseConfig(), {\n        entry: {\n            background: path.resolve(__dirname, \"./source/background/index.ts\")\n        },\n\n        plugins: [\n            new BannerPlugin({\n                // Fix service worker scope\n                banner: `window = self || global;`,\n                raw: true\n            }),\n            {\n                apply: (compiler) => {\n                    compiler.hooks.afterEmit.tap(\"AfterEmitPlugin\", (compilation) => {\n                        buildManifest(\n                            Object.keys(compilation.getStats().compilation.assets),\n                            V3_BROWSERS.includes(BROWSER) ? manifestV3 : manifestV2\n                        );\n                    });\n                }\n            },\n            new CopyWebpackPlugin({\n                patterns: [\n                    {\n                        from: path.join(__dirname, \"./resources\", \"buttercup-*.png\"),\n                        to: path.join(DIST, \"manifest-res\"),\n                        context: path.join(__dirname, \"./resources\")\n                    },\n                    {\n                        from: path.join(ICONS_PATH, \"/*\"),\n                        to: path.join(DIST, \"scripts/icons\"),\n                        context: ICONS_PATH\n                    }\n                ]\n            })\n        ]\n    }),\n    merge(getBaseConfig(), {\n        entry: {\n            tab: path.resolve(__dirname, \"./source/tab/index.ts\")\n        },\n\n        output: {\n            publicPath: \"/\"\n        }\n    }),\n    merge(getBaseConfig(), {\n        devtool: false,\n\n        entry: {\n            full: path.resolve(__dirname, \"./source/full/index.pug\"),\n            popup: path.resolve(__dirname, \"./source/popup/index.pug\")\n        },\n\n        output: {\n            chunkLoadingGlobal: \"__bcupjsonp\",\n            filename: \"[name].js\",\n            path: DIST,\n            publicPath: \"/\"\n        },\n\n        plugins: [\n            new PugPlugin({\n                css: {\n                    filename: \"styles/[name].css\",\n                    chunkFilename: \"styles/[name].[contenthash:8].css\"\n                },\n                filename: \"[name].html\",\n                js: {\n                    filename: \"scripts/[name].js\",\n                    chunkFilename: \"scripts/[name].[contenthash:8].js\"\n                },\n                pretty: false\n            })\n        ]\n    })\n];\n"
  }
]