├── .babelrc ├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── SECURITY.md ├── package-lock.json ├── package.json ├── preview.png ├── resources ├── build │ ├── background@2x.png │ ├── entitlements.plist │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── icons │ │ ├── 128x128.png │ │ ├── 16x16.png │ │ ├── 256x256.png │ │ ├── 32x32.png │ │ └── 48x48.png │ └── installer.nsh ├── icons │ ├── error.png │ ├── tray-linux.png │ ├── tray.ico │ ├── trayTemplate.png │ ├── trayTemplate@2x.png │ └── trayTemplate@3x.png ├── images │ ├── bench.png │ ├── buttercup-256.png │ ├── buttercup-file-256.png │ ├── credit-card.png │ ├── dropbox-256.png │ ├── google-256.png │ ├── googledrive-256.png │ ├── login.png │ ├── note.png │ ├── ssh.png │ ├── webdav-256.png │ ├── webdav-white-256.png │ └── website.png ├── renderer.pug ├── scripts │ ├── afterAllArtifactBuild.js │ ├── afterPack.js │ ├── publish.js │ ├── set-version.js │ └── windowsSign.js └── styles.sass ├── source ├── main │ ├── actions │ │ ├── appMenu.ts │ │ ├── attachments.ts │ │ ├── config.ts │ │ ├── connect.ts │ │ ├── lock.ts │ │ ├── remove.ts │ │ ├── tray.ts │ │ └── unlock.ts │ ├── index.ts │ ├── ipc.ts │ ├── library │ │ ├── FileStorage.ts │ │ ├── build.ts │ │ ├── file.ts │ │ ├── icons.ts │ │ ├── log.ts │ │ ├── otp.ts │ │ ├── paths.ts │ │ ├── portability.ts │ │ ├── sources.test.ts │ │ ├── sources.ts │ │ └── tray.ts │ ├── services │ │ ├── arguments.ts │ │ ├── autoClearClipboard.ts │ │ ├── autoLock.ts │ │ ├── backup.ts │ │ ├── biometrics.ts │ │ ├── browser │ │ │ ├── api.ts │ │ │ ├── auth.ts │ │ │ ├── controllers │ │ │ │ ├── auth.ts │ │ │ │ ├── entries.ts │ │ │ │ ├── otp.ts │ │ │ │ ├── save.ts │ │ │ │ └── vaults.ts │ │ │ ├── error.ts │ │ │ ├── index.ts │ │ │ ├── interaction.ts │ │ │ ├── middleware.ts │ │ │ ├── models.ts │ │ │ └── response.ts │ │ ├── browserAuth.ts │ │ ├── buttercup.ts │ │ ├── config.ts │ │ ├── export.ts │ │ ├── facades.ts │ │ ├── fileHost.ts │ │ ├── format.ts │ │ ├── googleDrive.ts │ │ ├── import.ts │ │ ├── init.ts │ │ ├── lastVault.ts │ │ ├── launch.ts │ │ ├── locale.ts │ │ ├── log.ts │ │ ├── migration.ts │ │ ├── protocol.ts │ │ ├── search.ts │ │ ├── storage.ts │ │ ├── theme.ts │ │ ├── update.ts │ │ └── windows.ts │ ├── symbols.ts │ └── types.ts ├── renderer │ ├── App.tsx │ ├── actions │ │ ├── addVault.ts │ │ ├── attachment.ts │ │ ├── autoUpdate.ts │ │ ├── clipboard.ts │ │ ├── dropbox.ts │ │ ├── error.ts │ │ ├── facade.ts │ │ ├── format.ts │ │ ├── link.ts │ │ ├── lockVault.ts │ │ ├── password.ts │ │ ├── removeVault.ts │ │ ├── saveVault.ts │ │ ├── unlockVault.ts │ │ ├── vaultOrder.ts │ │ └── webdav.ts │ ├── components │ │ ├── AddVaultLanding.tsx │ │ ├── AddVaultMenu.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── Notifications.tsx │ │ ├── OTPDigits.tsx │ │ ├── PasswordPrompt.tsx │ │ ├── PreferencesDialog.tsx │ │ ├── VaultEditor.tsx │ │ ├── VaultManagement.tsx │ │ ├── VaultSettingsDialog.tsx │ │ ├── navigation │ │ │ ├── AutoNav.tsx │ │ │ ├── LoadingScreen.tsx │ │ │ └── VaultTabs.tsx │ │ ├── prompt │ │ │ └── ConfirmDialog.tsx │ │ ├── search │ │ │ ├── SearchContext.tsx │ │ │ ├── SearchModal.tsx │ │ │ └── VaultSearchManager.tsx │ │ ├── standalone │ │ │ ├── AboutDialog.tsx │ │ │ ├── BiometricRegistrationDialog.tsx │ │ │ ├── BrowserAccessDialog.tsx │ │ │ ├── CreateNewFilePrompt.tsx │ │ │ ├── FileChooser.tsx │ │ │ ├── FileHostConnectionNotice.tsx │ │ │ ├── GoogleReAuthDialog.tsx │ │ │ ├── PasswordGenerator.tsx │ │ │ └── UpdateDialog.tsx │ │ └── vault │ │ │ ├── AddEntry.tsx │ │ │ ├── ConfirmButton.tsx │ │ │ ├── CreditCard.tsx │ │ │ ├── EntriesList.tsx │ │ │ ├── Entry.tsx │ │ │ ├── EntryDetails.tsx │ │ │ ├── GroupsList.tsx │ │ │ ├── Pane.tsx │ │ │ ├── SiteIcon.tsx │ │ │ ├── VaultContext.tsx │ │ │ ├── VaultUI.tsx │ │ │ ├── entryTypes.ts │ │ │ ├── hooks │ │ │ ├── compare.ts │ │ │ └── vault.ts │ │ │ ├── reducers │ │ │ ├── entry.ts │ │ │ └── vault.ts │ │ │ ├── styles │ │ │ ├── credit-card.sass │ │ │ └── vault-ui.sass │ │ │ ├── tabs │ │ │ ├── Tab.tsx │ │ │ ├── TabAddButton.tsx │ │ │ └── Tabs.tsx │ │ │ ├── types.ts │ │ │ └── utils │ │ │ ├── domain.ts │ │ │ ├── entries.ts │ │ │ ├── groups.ts │ │ │ ├── theme.ts │ │ │ └── ui.tsx │ ├── hooks │ │ ├── attachments.ts │ │ ├── facade.ts │ │ ├── theme.ts │ │ └── vault.ts │ ├── index.tsx │ ├── ipc.ts │ ├── library │ │ ├── clipboard.ts │ │ ├── encoding.ts │ │ ├── entryType.ts │ │ ├── fsInterface.ts │ │ ├── icons.ts │ │ ├── log.ts │ │ ├── theme.ts │ │ ├── trim.ts │ │ ├── ui.ts │ │ ├── vault.ts │ │ └── version.ts │ ├── services │ │ ├── addVault.ts │ │ ├── appEnvironment.ts │ │ ├── auth3rdParty.ts │ │ ├── authGoogle.ts │ │ ├── biometrics.ts │ │ ├── config.ts │ │ ├── facade.ts │ │ ├── googleDrive.ts │ │ ├── i18n.ts │ │ ├── init.ts │ │ ├── notifications.tsx │ │ ├── password.ts │ │ ├── preferences.ts │ │ ├── presence.ts │ │ ├── search.ts │ │ ├── update.ts │ │ └── vaultSettings.ts │ ├── state │ │ ├── about.ts │ │ ├── addVault.ts │ │ ├── app.ts │ │ ├── biometrics.ts │ │ ├── browserAccess.ts │ │ ├── fileHost.ts │ │ ├── google.ts │ │ ├── password.ts │ │ ├── preferences.ts │ │ ├── search.ts │ │ ├── update.ts │ │ ├── vaultSettings.ts │ │ └── vaults.ts │ ├── styles │ │ ├── theme.ts │ │ └── themes.ts │ ├── typedefs │ │ └── assets.d.ts │ └── types.ts └── shared │ ├── i18n │ ├── trans.ts │ └── translations │ │ ├── ca_es.json │ │ ├── de.json │ │ ├── en.json │ │ ├── es.json │ │ ├── fi.json │ │ ├── fr.json │ │ ├── gl.json │ │ ├── index.ts │ │ ├── it.json │ │ ├── ja.json │ │ ├── nl.json │ │ ├── pl.json │ │ ├── pt-br.json │ │ ├── ro.json │ │ ├── ru.json │ │ ├── se.json │ │ ├── zh_cn.json │ │ └── zh_tw.json │ ├── library │ ├── clone.ts │ ├── i18n.ts │ ├── log.ts │ ├── platform.ts │ └── promise.ts │ ├── symbols.ts │ └── types.ts ├── tsconfig.json ├── tsconfig.web.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "node": "current" 6 | } 7 | }], 8 | "@babel/preset-typescript" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [{package.json,package-lock.json}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js tests 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [18.x] 11 | os: [macos-latest, ubuntu-latest, windows-latest] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Build w/ Node ${{ matrix.node-version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - run: npm ci 19 | - name: Test 20 | uses: GabrielBB/xvfb-action@v1.0 21 | with: 22 | run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | /build 5 | /dist 6 | dev-app-update.yml 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Buttercup Desktop Security Policy 2 | 3 | _Please refer to [Buttercup's main security policy](https://github.com/buttercup/dossier/blob/master/SECURITY.md)._ 4 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/preview.png -------------------------------------------------------------------------------- /resources/build/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/background@2x.png -------------------------------------------------------------------------------- /resources/build/entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.network.client 8 | 9 | com.apple.security.files.user-selected.read-write 10 | 11 | com.apple.security.cs.allow-jit 12 | 13 | com.apple.security.cs.allow-dyld-environment-variables 14 | 15 | com.apple.security.cs.disable-library-validation 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /resources/build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/icon.icns -------------------------------------------------------------------------------- /resources/build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/icon.ico -------------------------------------------------------------------------------- /resources/build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/icon.png -------------------------------------------------------------------------------- /resources/build/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/icons/128x128.png -------------------------------------------------------------------------------- /resources/build/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/icons/16x16.png -------------------------------------------------------------------------------- /resources/build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/icons/256x256.png -------------------------------------------------------------------------------- /resources/build/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/icons/32x32.png -------------------------------------------------------------------------------- /resources/build/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/build/icons/48x48.png -------------------------------------------------------------------------------- /resources/build/installer.nsh: -------------------------------------------------------------------------------- 1 | !macro customInstall 2 | DetailPrint "Register buttercup URI Handler" 3 | DeleteRegKey HKCR "buttercup" 4 | WriteRegStr HKCR "buttercup" "" "URL:buttercup" 5 | WriteRegStr HKCR "buttercup" "URL Protocol" "" 6 | WriteRegStr HKCR "buttercup\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}" 7 | WriteRegStr HKCR "buttercup\shell" "" "" 8 | WriteRegStr HKCR "buttercup\shell\Open" "" "" 9 | WriteRegStr HKCR "buttercup\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1" 10 | !macroend 11 | -------------------------------------------------------------------------------- /resources/icons/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/icons/error.png -------------------------------------------------------------------------------- /resources/icons/tray-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/icons/tray-linux.png -------------------------------------------------------------------------------- /resources/icons/tray.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/icons/tray.ico -------------------------------------------------------------------------------- /resources/icons/trayTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/icons/trayTemplate.png -------------------------------------------------------------------------------- /resources/icons/trayTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/icons/trayTemplate@2x.png -------------------------------------------------------------------------------- /resources/icons/trayTemplate@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/icons/trayTemplate@3x.png -------------------------------------------------------------------------------- /resources/images/bench.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/bench.png -------------------------------------------------------------------------------- /resources/images/buttercup-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/buttercup-256.png -------------------------------------------------------------------------------- /resources/images/buttercup-file-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/buttercup-file-256.png -------------------------------------------------------------------------------- /resources/images/credit-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/credit-card.png -------------------------------------------------------------------------------- /resources/images/dropbox-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/dropbox-256.png -------------------------------------------------------------------------------- /resources/images/google-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/google-256.png -------------------------------------------------------------------------------- /resources/images/googledrive-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/googledrive-256.png -------------------------------------------------------------------------------- /resources/images/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/login.png -------------------------------------------------------------------------------- /resources/images/note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/note.png -------------------------------------------------------------------------------- /resources/images/ssh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/ssh.png -------------------------------------------------------------------------------- /resources/images/webdav-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/webdav-256.png -------------------------------------------------------------------------------- /resources/images/webdav-white-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/webdav-white-256.png -------------------------------------------------------------------------------- /resources/images/website.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-desktop/cb98fe711b555fb1b0e3f7ffdc3a85cd844d7307/resources/images/website.png -------------------------------------------------------------------------------- /resources/renderer.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | head 4 | title Buttercup 5 | meta(charset="utf-8") 6 | style(type="text/css"). 7 | html, body, #root { 8 | padding: 0; 9 | margin: 0; 10 | width: 100%; 11 | height: 100%; 12 | overflow: hidden; 13 | } 14 | body 15 | #root 16 | 17 | -------------------------------------------------------------------------------- /resources/scripts/afterAllArtifactBuild.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { execSync } = require("child_process"); 3 | const fs = require("fs"); 4 | const yaml = require("js-yaml"); 5 | const { appBuilderPath } = require("app-builder-bin"); 6 | const currentWorkingDirectory = process.cwd(); 7 | const packageInfo = require(path.join(currentWorkingDirectory, "package.json")); 8 | 9 | const APP_NAME = packageInfo.build.productName; 10 | const APP_VERSION = process.argv[2] ? process.argv[2] : packageInfo.version; 11 | const APP_DIST_PATH = path.join(currentWorkingDirectory, "dist"); 12 | const APP_GENERATED_BINARY_PATH = path.join(APP_DIST_PATH, `${APP_NAME}-${APP_VERSION}-mac.zip`); 13 | 14 | module.exports = (buildResults) => { 15 | const hasMacZip = buildResults.artifactPaths.some((artPath) => /mac*\.zip$/.test(artPath)); 16 | if (!hasMacZip) return; 17 | console.log("Zipping Started"); 18 | execSync( 19 | `ditto -c -k --sequesterRsrc --keepParent --zlibCompressionLevel 9 "${APP_DIST_PATH}/mac/${APP_NAME}.app" "${APP_DIST_PATH}/${APP_NAME}-${APP_VERSION}-mac.zip"` 20 | ); 21 | console.log("Zipping Completed"); 22 | const ymlPath = path.join(APP_DIST_PATH, "latest-mac.yml"); 23 | try { 24 | let output = execSync( 25 | `${appBuilderPath} blockmap --input="${APP_GENERATED_BINARY_PATH}" --output="${APP_DIST_PATH}/${APP_NAME}-${APP_VERSION}-mac.zip.blockmap" --compression=gzip` 26 | ); 27 | let { sha512, size } = JSON.parse(output); 28 | let ymlData = yaml.load(fs.readFileSync(ymlPath, "utf8")); 29 | console.log(ymlData); 30 | ymlData.sha512 = sha512; 31 | ymlData.files[0].sha512 = sha512; 32 | ymlData.files[0].size = size; 33 | let yamlStr = yaml.dump(ymlData); 34 | console.log(yamlStr); 35 | fs.writeFileSync(ymlPath, yamlStr, "utf8"); 36 | console.log("Successfully updated YAML file and configurations with blockmap"); 37 | } catch (e) { 38 | if (e.code === "ENOENT") { 39 | console.log(`No file at: ${ymlPath}`); 40 | } else { 41 | console.error("Error in updating YAML file and configurations with blockmap", e); 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /resources/scripts/afterPack.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { flipFuses, FuseVersion, FuseV1Options } = require("@electron/fuses"); 3 | const { Arch } = require("electron-builder"); 4 | 5 | // From: https://github.com/electron-userland/electron-builder/issues/6365#issuecomment-1526262846 6 | async function addElectronFuses(context) { 7 | const { electronPlatformName, arch } = context; 8 | const ext = { 9 | darwin: ".app", 10 | win32: ".exe", 11 | linux: [""] 12 | }[electronPlatformName]; 13 | 14 | const IS_LINUX = context.electronPlatformName === "linux"; 15 | const executableName = IS_LINUX 16 | ? context.packager.appInfo.productFilename 17 | .toLowerCase() 18 | .replace("-dev", "") 19 | .replace(" ", "-") 20 | : context.packager.appInfo.productFilename; 21 | const electronBinaryPath = path.join(context.appOutDir, `${executableName}${ext}`); 22 | 23 | console.log( 24 | `Configuring fuses for binary: ${electronBinaryPath} (reset adhoc: ${ 25 | electronPlatformName === "darwin" && arch === Arch.universal 26 | })` 27 | ); 28 | 29 | await flipFuses(electronBinaryPath, { 30 | version: FuseVersion.V1, 31 | resetAdHocDarwinSignature: electronPlatformName === "darwin", 32 | [FuseV1Options.EnableCookieEncryption]: true, 33 | [FuseV1Options.RunAsNode]: false, 34 | [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, 35 | [FuseV1Options.EnableNodeCliInspectArguments]: false, 36 | [FuseV1Options.OnlyLoadAppFromAsar]: true, 37 | // Mac app crashes when enabled for us on arm, might be fine for you 38 | [FuseV1Options.LoadBrowserProcessSpecificV8Snapshot]: false, 39 | // https://github.com/electron/fuses/issues/7 40 | [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: false 41 | }); 42 | } 43 | 44 | module.exports = async (context) => { 45 | console.log(`Checking package: ${context.electronPlatformName} @ ${context.arch}`); 46 | if (context.electronPlatformName !== "darwin" || context.arch === Arch.x64) { 47 | await addElectronFuses(context); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /resources/scripts/publish.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | const { exec } = require("node:child_process"); 3 | const { promisify } = require("node:util"); 4 | const builder = require("electron-builder"); 5 | const { rimraf } = require("rimraf"); 6 | const chalk = require("chalk"); 7 | 8 | const BUILD_DIR = resolve(__dirname, "../../build"); 9 | const DIST_DIR = resolve(__dirname, "../../dist"); 10 | const EXPECTED_ENV_VARS = [ 11 | "APPLE_ID", 12 | "APPLE_APP_SPECIFIC_PASSWORD", 13 | "APPLE_TEAM_ID", 14 | "GH_TOKEN", 15 | "WIN_YUBIKEY_PIN" 16 | ]; 17 | 18 | async function buildApp() { 19 | console.log("Building..."); 20 | await promisify(exec)("npm run build"); 21 | } 22 | 23 | async function buildBundle() { 24 | console.log("Assembling bundles..."); 25 | const result = await builder.build({ 26 | publish: "always", 27 | targets: new Map([ 28 | ...builder.Platform.MAC.createTarget(), 29 | ...builder.Platform.LINUX.createTarget(), 30 | ...builder.Platform.WINDOWS.createTarget() 31 | ]) 32 | }); 33 | console.log("Outputs:"); 34 | for (const item of result) { 35 | console.log(` ${chalk.green("•")} ${item}`); 36 | } 37 | } 38 | 39 | async function check() { 40 | for (const envVar of EXPECTED_ENV_VARS) { 41 | if (typeof process.env[envVar] !== "string") { 42 | throw new Error(`Environment variable not set: ${envVar}`); 43 | } 44 | } 45 | } 46 | 47 | async function clean() { 48 | console.log("Cleaning..."); 49 | await rimraf(BUILD_DIR); 50 | await rimraf(DIST_DIR); 51 | } 52 | 53 | async function routine(...callbacks) { 54 | while (callbacks.length > 0) { 55 | const callback = callbacks.shift(); 56 | await callback(); 57 | } 58 | } 59 | 60 | routine(check, clean, buildApp, buildBundle) 61 | .then(() => { 62 | console.log("Done."); 63 | }) 64 | .catch(console.error); 65 | -------------------------------------------------------------------------------- /resources/scripts/set-version.js: -------------------------------------------------------------------------------- 1 | const fs = require("node:fs"); 2 | const path = require("node:path"); 3 | 4 | const packageJson = require("../../package.json"); 5 | 6 | fs.writeFileSync( 7 | path.resolve(__dirname, "../../source/main/library/build.ts"), 8 | `// This file updated automatically: changes made here will be overwritten! 9 | 10 | export const VERSION = "${packageJson.version}"; 11 | ` 12 | ); 13 | -------------------------------------------------------------------------------- /resources/scripts/windowsSign.js: -------------------------------------------------------------------------------- 1 | const { exec } = require("child_process"); 2 | const { promisify } = require("util"); 3 | const { basename } = require("path"); 4 | const chalk = require("chalk"); 5 | 6 | const execute = promisify(exec); 7 | 8 | exports.default = async (configuration) => { 9 | const { WIN_YUBIKEY_PIN } = process.env; 10 | 11 | if (!WIN_YUBIKEY_PIN) { 12 | throw new Error("Yubikey PIN environment variable required"); 13 | } 14 | 15 | console.log(` ${chalk.greenBright("·")} ${basename(configuration.path)}`); 16 | 17 | const { stdout, stderr } = await execute( 18 | [ 19 | "jsign", 20 | "--storetype YUBIKEY", 21 | `--storepass ${WIN_YUBIKEY_PIN}`, 22 | `--alias "X.509 Certificate for PIV Authentication"`, 23 | `"${configuration.path}"` 24 | ].join(" ") 25 | ); 26 | 27 | console.log(stdout); 28 | if (stderr) { 29 | console.log(chalk.red(stderr)); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /resources/styles.sass: -------------------------------------------------------------------------------- 1 | @import "~normalize.css" 2 | @import "~@buttercup/ui/dist/index.css" 3 | 4 | @import "~@blueprintjs/select/lib/css/blueprint-select.css" 5 | 6 | .bp4-overlay.bp4-omnibar-overlay 7 | .bp4-overlay-backdrop 8 | background-color: rgba(16,22,26,.55) 9 | -------------------------------------------------------------------------------- /source/main/actions/attachments.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { EntryID, VaultSourceID } from "buttercup"; 3 | import { dialog } from "electron"; 4 | import pify from "pify"; 5 | import { getAttachmentData, getAttachmentDetails } from "../services/buttercup"; 6 | import { getMainWindow } from "../services/windows"; 7 | import { t } from "../../shared/i18n/trans"; 8 | 9 | const writeFile = pify(fs.writeFile); 10 | 11 | export async function startAttachmentDownload( 12 | sourceID: VaultSourceID, 13 | entryID: EntryID, 14 | attachmentID: string 15 | ): Promise { 16 | const win = getMainWindow(); 17 | const attachmentDetails = await getAttachmentDetails(sourceID, entryID, attachmentID); 18 | const result = await dialog.showSaveDialog(win, { 19 | title: t("dialog.attachment-save.title"), 20 | buttonLabel: t("dialog.attachment-save.confirm-button"), 21 | defaultPath: attachmentDetails.name, 22 | properties: ["createDirectory", "dontAddToRecent", "showOverwriteConfirmation"] 23 | }); 24 | if (result.canceled) return false; 25 | const data = await getAttachmentData(sourceID, entryID, attachmentID); 26 | await writeFile(result.filePath, data); 27 | return true; 28 | } 29 | -------------------------------------------------------------------------------- /source/main/actions/config.ts: -------------------------------------------------------------------------------- 1 | import { logInfo } from "../library/log"; 2 | import { applyCurrentTheme } from "../services/theme"; 3 | import { getOSLocale } from "../services/locale"; 4 | import { changeLanguage } from "../../shared/i18n/trans"; 5 | import { getLanguage } from "../../shared/library/i18n"; 6 | import { startFileHost, stopFileHost } from "../services/fileHost"; 7 | import { setStartWithSession } from "../services/launch"; 8 | import { start as startBrowserAPI, stop as stopBrowserAPI } from "../services/browser/index"; 9 | import { Preferences } from "../types"; 10 | 11 | export async function handleConfigUpdate(preferences: Preferences) { 12 | logInfo("Config updated"); 13 | applyCurrentTheme(preferences); 14 | const locale = await getOSLocale(); 15 | logInfo(` - System locale detected: ${locale}`); 16 | const language = getLanguage(preferences, locale); 17 | logInfo(` - Language updated: ${language}`); 18 | await changeLanguage(language); 19 | logInfo( 20 | ` - Auto clear clipboard: ${ 21 | preferences.autoClearClipboard ? preferences.autoClearClipboard + "s" : "Off" 22 | }` 23 | ); 24 | logInfo( 25 | ` - Lock vaults after: ${ 26 | preferences.lockVaultsAfterTime ? preferences.lockVaultsAfterTime + "s" : "Off" 27 | }` 28 | ); 29 | logInfo(` - Background start: ${preferences.startMode}`); 30 | logInfo( 31 | ` - Start with session launch: ${preferences.startWithSession ? "Enabled" : "Disabled"}` 32 | ); 33 | await setStartWithSession(preferences.startWithSession); 34 | logInfo(` - File host: ${preferences.fileHostEnabled ? "Enabled" : "Disabled"}`); 35 | if (preferences.fileHostEnabled) { 36 | await startBrowserAPI(); 37 | await startFileHost(); 38 | } else { 39 | await stopBrowserAPI(); 40 | await stopFileHost(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /source/main/actions/connect.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { BrowserWindow, dialog } from "electron"; 3 | import { Credentials, VaultSourceID } from "buttercup"; 4 | import { addVault } from "../services/buttercup"; 5 | import { logInfo } from "../library/log"; 6 | import { t } from "../../shared/i18n/trans"; 7 | import { AddVaultPayload, SourceType } from "../types"; 8 | 9 | export async function addVaultFromPayload(payload: AddVaultPayload): Promise { 10 | let credentials: Credentials, name: string; 11 | switch (payload.datasourceConfig.type) { 12 | case SourceType.GoogleDrive: 13 | credentials = Credentials.fromDatasource( 14 | payload.datasourceConfig, 15 | payload.masterPassword 16 | ); 17 | name = payload.fileNameOverride || payload.datasourceConfig.fileID; 18 | break; 19 | case SourceType.Dropbox: 20 | /* falls-through */ 21 | case SourceType.WebDAV: 22 | /* falls-through */ 23 | case SourceType.File: { 24 | credentials = Credentials.fromDatasource( 25 | payload.datasourceConfig, 26 | payload.masterPassword 27 | ); 28 | name = path.basename(payload.datasourceConfig.path).replace(/\.bcup$/i, ""); 29 | break; 30 | } 31 | default: 32 | throw new Error(`Unsupported vault type: ${payload.datasourceConfig.type}`); 33 | } 34 | logInfo( 35 | `Adding vault "${name}" (${payload.datasourceConfig.type}) (new = ${ 36 | payload.createNew ? "yes" : "no" 37 | })` 38 | ); 39 | const sourceID = await addVault( 40 | name, 41 | credentials, 42 | Credentials.fromPassword(payload.masterPassword), 43 | payload.datasourceConfig.type, 44 | payload.createNew 45 | ); 46 | logInfo(`Added vault "${name}" (${sourceID})`); 47 | return sourceID; 48 | } 49 | 50 | export async function showExistingFileVaultDialog(win: BrowserWindow): Promise { 51 | const result = await dialog.showOpenDialog(win, { 52 | title: t("dialog.file-vault.add-existing.title"), 53 | buttonLabel: t("dialog.file-vault.add-existing.confirm-button"), 54 | filters: [{ name: t("dialog.file-vault.add-existing.bcup-filter"), extensions: ["bcup"] }], 55 | properties: ["openFile"] 56 | }); 57 | const [vaultPath] = result.filePaths; 58 | return vaultPath || null; 59 | } 60 | 61 | export async function showNewFileVaultDialog(win: BrowserWindow): Promise { 62 | const result = await dialog.showSaveDialog(win, { 63 | title: t("dialog.file-vault.add-new.title"), 64 | buttonLabel: t("dialog.file-vault.add-new.confirm-button"), 65 | filters: [{ name: t("dialog.file-vault.add-new.bcup-filter"), extensions: ["bcup"] }], 66 | properties: ["createDirectory", "dontAddToRecent", "showOverwriteConfirmation"] 67 | }); 68 | let vaultPath = result.filePath; 69 | if (!vaultPath) return null; 70 | if (/\.bcup$/i.test(vaultPath) === false) { 71 | vaultPath = `${vaultPath}.bcup`; 72 | } 73 | return vaultPath; 74 | } 75 | -------------------------------------------------------------------------------- /source/main/actions/lock.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { lockSource } from "../services/buttercup"; 3 | import { logInfo } from "../library/log"; 4 | 5 | export async function lockSourceWithID(sourceID: VaultSourceID) { 6 | await lockSource(sourceID); 7 | logInfo(`Locked source: ${sourceID}`); 8 | } 9 | -------------------------------------------------------------------------------- /source/main/actions/remove.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { lockSource, removeSource } from "../services/buttercup"; 3 | import { logInfo } from "../library/log"; 4 | import { removeVaultSettings } from "../services/config"; 5 | 6 | export async function removeSourceWithID(sourceID: VaultSourceID) { 7 | await lockSource(sourceID); 8 | await removeSource(sourceID); 9 | await removeVaultSettings(sourceID); 10 | logInfo(`Removed source: ${sourceID}`); 11 | } 12 | -------------------------------------------------------------------------------- /source/main/actions/tray.ts: -------------------------------------------------------------------------------- 1 | import { Menu, Tray } from "electron"; 2 | import { VaultSourceStatus } from "buttercup"; 3 | import { getSourceDescriptions, lockAllSources } from "../services/buttercup"; 4 | import { openAndRepositionMainWindow, openMainWindow } from "../services/windows"; 5 | import { getIconPath } from "../library/tray"; 6 | import { logInfo } from "../library/log"; 7 | import { t } from "../../shared/i18n/trans"; 8 | 9 | let __tray: Tray = null; 10 | 11 | async function getContextMenu(): Promise { 12 | const sources = getSourceDescriptions(); 13 | const unlockedCount = sources.reduce( 14 | (count, desc) => (desc.state === VaultSourceStatus.Unlocked ? count + 1 : count), 15 | 0 16 | ); 17 | return Menu.buildFromTemplate([ 18 | { 19 | label: t("app-menu.unlocked-vaults", { count: unlockedCount }), 20 | enabled: false 21 | }, 22 | { 23 | type: "separator" 24 | }, 25 | { 26 | label: t("app-menu.open"), 27 | click: () => openMainWindow() 28 | }, 29 | { 30 | label: t("app-menu.window"), 31 | submenu: [ 32 | { 33 | label: t("app-menu.window-reposition"), 34 | click: () => openAndRepositionMainWindow() 35 | } 36 | ] 37 | }, 38 | { 39 | type: "separator" 40 | }, 41 | { 42 | label: t("app-menu.add-new-vault"), 43 | click: async () => { 44 | const window = await openMainWindow(); 45 | window.webContents.send("add-vault"); 46 | } 47 | }, 48 | { 49 | label: t("app-menu.lock-all"), 50 | click: () => { 51 | logInfo("Locking all sources"); 52 | lockAllSources(); 53 | } 54 | }, 55 | { 56 | label: t("app-menu.open-vault"), 57 | submenu: sources.map((source) => ({ 58 | label: source.name, 59 | click: async () => { 60 | const window = await openMainWindow(); 61 | if (source.state === VaultSourceStatus.Unlocked) { 62 | window.webContents.send("open-source", source.id); 63 | } else { 64 | window.webContents.send("unlock-vault-open", source.id); 65 | } 66 | } 67 | })) 68 | }, 69 | { 70 | type: "separator" 71 | }, 72 | { 73 | label: t("app-menu.quit"), 74 | role: "quit" 75 | } 76 | ]); 77 | } 78 | 79 | export async function updateTrayIcon() { 80 | if (!__tray) { 81 | __tray = new Tray(getIconPath()); 82 | } 83 | const contextMenu = await getContextMenu(); 84 | __tray.setContextMenu(contextMenu); 85 | } 86 | -------------------------------------------------------------------------------- /source/main/actions/unlock.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import createPerfTimer from "execution-time"; 3 | import { unlockSource } from "../services/buttercup"; 4 | import { logInfo } from "../library/log"; 5 | 6 | export async function unlockSourceWithID(sourceID: VaultSourceID, password: string) { 7 | const timer = createPerfTimer(); 8 | timer.start(); 9 | await unlockSource(sourceID, password); 10 | const results = timer.stop(); 11 | logInfo(`Unlocked source: ${sourceID} (took: ${results.time} ms)`); 12 | } 13 | -------------------------------------------------------------------------------- /source/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { initialize as initialiseElectronRemote } from "@electron/remote/main"; 3 | import "./ipc"; 4 | import { initialise } from "./services/init"; 5 | import { openMainWindow } from "./services/windows"; 6 | import { handleProtocolCall } from "./services/protocol"; 7 | import { getConfigValue } from "./services/config"; 8 | import { shouldShowMainWindow, wasAutostarted } from "./services/arguments"; 9 | import { logErr, logInfo } from "./library/log"; 10 | import { BUTTERCUP_PROTOCOL } from "./symbols"; 11 | import { AppStartMode } from "./types"; 12 | 13 | logInfo("Application starting"); 14 | 15 | const lock = app.requestSingleInstanceLock(); 16 | if (!lock) { 17 | app.quit(); 18 | } 19 | 20 | // app.on("window-all-closed", () => { 21 | // if (process.platform !== PLATFORM_MACOS) { 22 | // app.quit(); 23 | // } 24 | // }); 25 | 26 | app.on("window-all-closed", (event: Event) => { 27 | event.preventDefault(); 28 | }); 29 | 30 | app.on("activate", () => { 31 | openMainWindow(); 32 | }); 33 | 34 | // ** 35 | // ** App protocol handling 36 | // ** 37 | 38 | app.on("second-instance", async (event, args) => { 39 | await openMainWindow(); 40 | // Protocol URL for Linux/Windows 41 | const protocolURL = args.find((arg) => arg.startsWith(BUTTERCUP_PROTOCOL)); 42 | if (protocolURL) { 43 | handleProtocolCall(protocolURL); 44 | } 45 | }); 46 | app.on("open-url", (e, url) => { 47 | // Protocol URL for MacOS 48 | if (url.startsWith(BUTTERCUP_PROTOCOL)) { 49 | handleProtocolCall(url); 50 | } 51 | }); 52 | 53 | // ** 54 | // ** Boot 55 | // ** 56 | 57 | app.whenReady() 58 | .then(() => { 59 | logInfo("Application ready"); 60 | initialiseElectronRemote(); 61 | }) 62 | .then(() => initialise()) 63 | .then(() => { 64 | const protocol = BUTTERCUP_PROTOCOL.replace("://", ""); 65 | if (!app.isDefaultProtocolClient(protocol)) { 66 | logInfo(`Registering protocol: ${protocol}`); 67 | const protoReg = app.setAsDefaultProtocolClient(protocol); 68 | if (!protoReg) { 69 | logErr(`Failed registering protocol: ${protocol}`); 70 | } 71 | } else { 72 | logInfo(`Protocol already registered: ${protocol}`); 73 | } 74 | }) 75 | .then(async () => { 76 | const preferences = await getConfigValue("preferences"); 77 | const autostarted = wasAutostarted(); 78 | if (!shouldShowMainWindow() || preferences.startMode === AppStartMode.HiddenAlways) { 79 | logInfo("Not opening initial window: disabled by CL or preferences"); 80 | return; 81 | } else if (autostarted && preferences.startMode === AppStartMode.HiddenOnBoot) { 82 | logInfo("Not opening initial window: disabled for autostart"); 83 | return; 84 | } 85 | openMainWindow(); 86 | }) 87 | .catch((err) => { 88 | logErr(err); 89 | app.quit(); 90 | }); 91 | -------------------------------------------------------------------------------- /source/main/library/FileStorage.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { StorageInterface } from "buttercup"; 4 | import { ChannelQueue } from "@buttercup/channel-queue"; 5 | import pify from "pify"; 6 | import { naiveClone } from "../../shared/library/clone"; 7 | 8 | const mkdir = pify(fs.mkdir); 9 | const readFile = pify(fs.readFile); 10 | const writeFile = pify(fs.writeFile); 11 | 12 | export class FileStorage extends StorageInterface { 13 | _queue: ChannelQueue = null; 14 | _path: string; 15 | 16 | constructor(filePath: string) { 17 | super(); 18 | this._path = filePath; 19 | this._queue = new ChannelQueue(); 20 | } 21 | 22 | async getAllKeys(): Promise> { 23 | const data = await this._getContents(); 24 | return Object.keys(data); 25 | } 26 | 27 | async getValue(name: string): Promise { 28 | const data = await this._getContents(); 29 | return typeof data[name] !== "undefined" ? data[name] : null; 30 | } 31 | 32 | async getValues(properties?: Array): Promise> { 33 | const data = await this._getContents(); 34 | if (!Array.isArray(properties)) { 35 | return { ...data }; 36 | } 37 | const result = properties.reduce( 38 | (output, key) => ({ 39 | ...output, 40 | [key]: data[key] 41 | }), 42 | {} 43 | ); 44 | return naiveClone(result); 45 | } 46 | 47 | async removeKey(name: string): Promise { 48 | return this._queue.channel("update").enqueue(async () => { 49 | const data = await this._getContents(); 50 | delete data[name]; 51 | await this._putContents(data); 52 | }); 53 | } 54 | 55 | async setValue(name: string, value: any): Promise { 56 | return this._queue.channel("update").enqueue(async () => { 57 | const data = await this._getContents(); 58 | data[name] = value; 59 | await this._putContents(data); 60 | }); 61 | } 62 | 63 | async setValues(values: Record): Promise { 64 | return this._queue.channel("update").enqueue(async () => { 65 | const data = await this._getContents(); 66 | for (const key in values) { 67 | data[key] = values[key]; 68 | } 69 | await this._putContents(data); 70 | }); 71 | } 72 | 73 | async _getContents(): Promise { 74 | return this._queue.channel("io").enqueue( 75 | async () => { 76 | try { 77 | const data = await readFile(this._path); 78 | return JSON.parse(data); 79 | } catch (err) { 80 | if (err.code === "ENOENT") { 81 | // No file 82 | return {}; 83 | } 84 | // Other error 85 | throw err; 86 | } 87 | }, 88 | undefined, 89 | "read" 90 | ); 91 | } 92 | 93 | async _putContents(data: Object): Promise { 94 | return this._queue.channel("io").enqueue(async () => { 95 | await mkdir(path.dirname(this._path), { recursive: true }); 96 | await writeFile(this._path, JSON.stringify(data)); 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /source/main/library/build.ts: -------------------------------------------------------------------------------- 1 | // This file updated automatically: changes made here will be overwritten! 2 | 3 | export const VERSION = "2.28.1"; 4 | -------------------------------------------------------------------------------- /source/main/library/file.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export async function fileExists(filePath: string): Promise { 4 | return new Promise((resolve) => { 5 | fs.access(filePath, (err?: Error) => { 6 | if (err) { 7 | return resolve(false); 8 | } 9 | resolve(true); 10 | }); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /source/main/library/icons.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { nativeImage } from "electron"; 3 | import { getRootProjectPath } from "./paths"; 4 | import { SourceType } from "../types"; 5 | 6 | const MENU_ICON_SIZE = 16; 7 | 8 | export function getIconForProvider(provider: SourceType): string { 9 | const root = getRootProjectPath(); 10 | const ICON_BUTTERCUP = path.join(root, "resources/images/buttercup-file-256.png"); 11 | const ICON_DROPBOX = path.join(root, "resources/images/dropbox-256.png"); 12 | const ICON_GOOGLEDRIVE = path.join(root, "resources/images/googledrive-256.png"); 13 | const ICON_WEBDAV = path.join(root, "resources/images/webdav-256.png"); 14 | const ICON_ERROR = path.join(root, "resources/icons/error.png"); 15 | switch (provider) { 16 | case SourceType.File: 17 | return ICON_BUTTERCUP; 18 | case SourceType.Dropbox: 19 | return ICON_DROPBOX; 20 | case SourceType.GoogleDrive: 21 | return ICON_GOOGLEDRIVE; 22 | case SourceType.WebDAV: 23 | return ICON_WEBDAV; 24 | default: 25 | return ICON_ERROR; 26 | } 27 | } 28 | 29 | export async function getNativeImageMenuIcon(fileSource: string) { 30 | const nImg = nativeImage.createFromPath(fileSource); 31 | const resized = nImg.resize({ 32 | width: MENU_ICON_SIZE, 33 | height: MENU_ICON_SIZE, 34 | quality: "better" 35 | }); 36 | return resized; 37 | } 38 | -------------------------------------------------------------------------------- /source/main/library/log.ts: -------------------------------------------------------------------------------- 1 | import debounce from "debounce"; 2 | import { ChannelQueue } from "@buttercup/channel-queue"; 3 | import { writeLines } from "../services/log"; 4 | import { serialiseLogItems } from "../../shared/library/log"; 5 | import { LogLevel } from "../types"; 6 | 7 | const WRITE_FREQUENCY = 2.5 * 1000; 8 | 9 | const __lines: Array = []; 10 | let __queue: ChannelQueue; 11 | let __write: () => Promise; 12 | 13 | function generateLogPrefix(level: LogLevel): string { 14 | const date = new Date(); 15 | let levelPrefix: string; 16 | switch (level) { 17 | case LogLevel.Error: 18 | levelPrefix = "ERR"; 19 | break; 20 | case LogLevel.Info: 21 | levelPrefix = "INF"; 22 | break; 23 | case LogLevel.Warning: 24 | levelPrefix = "WAR"; 25 | break; 26 | default: 27 | throw new Error(`Invalid log level: ${level}`); 28 | } 29 | const [hours, minutes, seconds] = [ 30 | date.getUTCHours(), 31 | date.getUTCMinutes(), 32 | date.getUTCSeconds() 33 | ].map((val) => { 34 | const str = `${val}`; 35 | return str.length === 2 ? str : `0${str}`; 36 | }); 37 | return `[${levelPrefix}] ${hours}:${minutes}:${seconds}:`; 38 | } 39 | 40 | export function log(level: LogLevel, items: Array) { 41 | const serialised = serialiseLogItems(items); 42 | const logLine = `${generateLogPrefix(level)} ${serialised}`; 43 | console.log(logLine); 44 | __lines.push(logLine); 45 | if (!__write) { 46 | __write = debounce(write, WRITE_FREQUENCY, /* immediate: */ false); 47 | } 48 | __write(); 49 | } 50 | 51 | export function logErr(...items: Array) { 52 | return log(LogLevel.Error, items); 53 | } 54 | 55 | export function logInfo(...items: Array) { 56 | return log(LogLevel.Info, items); 57 | } 58 | 59 | export function logWarn(...items: Array) { 60 | return log(LogLevel.Warning, items); 61 | } 62 | 63 | async function write() { 64 | if (!__queue) { 65 | __queue = new ChannelQueue(); 66 | } 67 | await __queue.channel("write").enqueue( 68 | async () => { 69 | const toWrite = [...__lines]; 70 | __lines.splice(0, Infinity); 71 | if (toWrite.length === 0) return; 72 | await writeLines(toWrite); 73 | }, 74 | undefined, 75 | "write-stack" 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /source/main/library/otp.ts: -------------------------------------------------------------------------------- 1 | import { Entry, EntryPropertyValueType, EntryURLType, getEntryURLs, VaultSource } from "buttercup"; 2 | import { OTP } from "../types"; 3 | 4 | export function extractVaultOTPItems(source: VaultSource): Array { 5 | return source.vault.getAllEntries().reduce((output: Array, entry: Entry) => { 6 | const properties = entry.getProperties(); 7 | const loginURLs = getEntryURLs(properties, EntryURLType.Login); 8 | for (const key in properties) { 9 | if (entry.getPropertyValueType(key) !== EntryPropertyValueType.OTP) continue; 10 | output.push({ 11 | sourceID: source.id, 12 | entryID: entry.id, 13 | entryProperty: key, 14 | entryTitle: properties.title, 15 | loginURL: loginURLs.length > 0 ? loginURLs[0] : null, 16 | otpURL: properties[key] 17 | }); 18 | } 19 | return output; 20 | }, []); 21 | } 22 | -------------------------------------------------------------------------------- /source/main/library/paths.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | export function getRootProjectPath(): string { 4 | // Libary is bundled into ./build/main/index.js, and so is only 5 | // 2 levels deep, regardless of the source file: 6 | return path.resolve(__dirname, "../../"); 7 | } 8 | -------------------------------------------------------------------------------- /source/main/library/portability.ts: -------------------------------------------------------------------------------- 1 | const { PORTABLE_EXECUTABLE_APP_FILENAME, PORTABLE_EXECUTABLE_DIR } = process.env; 2 | 3 | export function getPortableExeDir(): string { 4 | return PORTABLE_EXECUTABLE_DIR; 5 | } 6 | 7 | export function isPortable(): boolean { 8 | return !!PORTABLE_EXECUTABLE_APP_FILENAME; 9 | } 10 | -------------------------------------------------------------------------------- /source/main/library/sources.test.ts: -------------------------------------------------------------------------------- 1 | import { VaultSource, VaultSourceStatus, init } from "buttercup"; 2 | import { describeSource } from "./sources"; 3 | 4 | function getFakeSource() { 5 | return new VaultSource("Test", "dropbox", ""); 6 | } 7 | 8 | beforeAll(() => { 9 | init(); 10 | }); 11 | 12 | test("outputs ID and name", () => { 13 | const output = describeSource(getFakeSource()); 14 | expect(output).toHaveProperty("id"); 15 | expect(output).toHaveProperty("name", "Test"); 16 | expect(output.id).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/); 17 | }); 18 | 19 | test("outputs correct state", () => { 20 | const output = describeSource(getFakeSource()); 21 | expect(output).toHaveProperty("state", VaultSourceStatus.Locked); 22 | }); 23 | 24 | test("outputs correct type", () => { 25 | const output = describeSource(getFakeSource()); 26 | expect(output).toHaveProperty("type", "dropbox"); 27 | }); 28 | -------------------------------------------------------------------------------- /source/main/library/sources.ts: -------------------------------------------------------------------------------- 1 | import { VaultSource, VaultSourceStatus } from "buttercup"; 2 | import { SourceType, VaultSourceDescription } from "../types"; 3 | 4 | export function describeSource(source: VaultSource): VaultSourceDescription { 5 | return { 6 | id: source.id, 7 | name: source.name, 8 | state: source.status, 9 | type: source.type as SourceType, 10 | order: source.order, 11 | format: 12 | source.status === VaultSourceStatus.Unlocked 13 | ? source.vault.format.getFormat().getFormatID() 14 | : null 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /source/main/library/tray.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { isLinux, isWindows } from "../../shared/library/platform"; 3 | import { getRootProjectPath } from "./paths"; 4 | 5 | export function getIconPath(): string { 6 | const trayPath = isWindows() ? "tray.ico" : isLinux() ? "tray-linux.png" : "trayTemplate.png"; 7 | const root = getRootProjectPath(); 8 | return path.join(root, "resources/icons", trayPath); 9 | } 10 | -------------------------------------------------------------------------------- /source/main/services/arguments.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { isOSX } from "../../shared/library/platform"; 3 | import { disableUpdates } from "./update"; 4 | 5 | let __autostarted = false, 6 | __showMainWindow = true; 7 | 8 | export function processLaunchConfiguration() { 9 | const cl = app.commandLine; 10 | // Deprecated switch 11 | if (cl.hasSwitch("no-window")) { 12 | __showMainWindow = false; 13 | } 14 | if (cl.hasSwitch("hidden")) { 15 | __showMainWindow = false; 16 | } 17 | if (isOSX() && app.getLoginItemSettings().wasOpenedAtLogin) { 18 | __autostarted = true; 19 | } 20 | if (isOSX() && app.getLoginItemSettings().wasOpenedAsHidden) { 21 | __showMainWindow = false; 22 | } 23 | if (cl.hasSwitch("autostart")) { 24 | __autostarted = true; 25 | } 26 | if (cl.hasSwitch("no-update")) { 27 | disableUpdates(); 28 | } 29 | } 30 | 31 | export function shouldShowMainWindow(): boolean { 32 | return __showMainWindow; 33 | } 34 | 35 | export function wasAutostarted(): boolean { 36 | return __autostarted; 37 | } 38 | -------------------------------------------------------------------------------- /source/main/services/autoClearClipboard.ts: -------------------------------------------------------------------------------- 1 | import { clipboard } from "electron"; 2 | import { Preferences } from "../../shared/types"; 3 | import { logInfo } from "../library/log"; 4 | import { getConfigValue } from "./config"; 5 | 6 | let autoClipboardClearTimeout: NodeJS.Timeout | null = null; 7 | let lastCopiedText = ""; 8 | 9 | export async function restartAutoClearClipboardTimer(text: string) { 10 | lastCopiedText = text; 11 | const { autoClearClipboard } = await getConfigValue("preferences"); 12 | if (!autoClearClipboard) { 13 | return; 14 | } 15 | if (autoClipboardClearTimeout) { 16 | clearTimeout(autoClipboardClearTimeout); 17 | } 18 | autoClipboardClearTimeout = setTimeout(() => { 19 | if (clipboard.readText() === lastCopiedText) { 20 | logInfo("Timer elapsed. Auto clear clipboard"); 21 | clipboard.clear(); 22 | } 23 | }, autoClearClipboard * 1000); 24 | } 25 | -------------------------------------------------------------------------------- /source/main/services/autoLock.ts: -------------------------------------------------------------------------------- 1 | import { Preferences } from "../../shared/types"; 2 | import { logInfo } from "../library/log"; 3 | import { getUnlockedSourcesCount, lockAllSources } from "./buttercup"; 4 | import { getConfigValue } from "./config"; 5 | 6 | let __autoVaultLockTimeout: NodeJS.Timeout | null = null, 7 | __autoLockEnabled = false; 8 | 9 | export async function setAutoLockEnabled(value: boolean) { 10 | __autoLockEnabled = value; 11 | } 12 | 13 | export async function startAutoVaultLockTimer() { 14 | stopAutoVaultLockTimer(); 15 | if (!__autoLockEnabled) return; 16 | const { lockVaultsAfterTime } = await getConfigValue("preferences"); 17 | if (!lockVaultsAfterTime) return; 18 | __autoVaultLockTimeout = setTimeout(() => { 19 | if (getUnlockedSourcesCount() === 0) return; 20 | logInfo("Timer elapsed. Auto lock all vaults"); 21 | lockAllSources(); 22 | }, lockVaultsAfterTime * 1000); 23 | } 24 | 25 | export function stopAutoVaultLockTimer() { 26 | if (__autoVaultLockTimeout) { 27 | clearTimeout(__autoVaultLockTimeout); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /source/main/services/backup.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs/promises"; 3 | import { VaultSource, VaultSourceID } from "buttercup"; 4 | import { logErr, logInfo } from "../library/log"; 5 | import { getVaultSettings } from "./config"; 6 | import { VAULTS_BACKUP_PATH } from "./storage"; 7 | 8 | const __removeListener: Record void> = {}; 9 | 10 | export function attachSourceEncryptedListeners(source: VaultSource) { 11 | if (__removeListener[source.id]) { 12 | __removeListener[source.id](); 13 | delete __removeListener[source.id]; 14 | } 15 | const handler = ({ content }) => { 16 | handleSourceSave(source.id, content).catch((err) => { 17 | logErr(`Failed processing backup for source: ${source.id}`, err); 18 | delete __removeListener[source.id]; 19 | }); 20 | }; 21 | source._datasource.on("encryptedContent", handler); 22 | __removeListener[source.id] = () => source._datasource.off("encryptedContent", handler); 23 | } 24 | 25 | function getBackupFilename(sourceID: VaultSourceID, type: "save"): string { 26 | const date = new Date(); 27 | const day = String(date.getDate()).padStart(2, "0"); 28 | const month = String(date.getMonth() + 1).padStart(2, "0"); 29 | const year = String(date.getFullYear()); 30 | const hour = String(date.getHours()).padStart(2, "0"); 31 | const minute = String(date.getMinutes()).padStart(2, "0"); 32 | const second = String(date.getSeconds()).padStart(2, "0"); 33 | return `${type}_${year}-${month}-${day}_${hour}-${minute}-${second}_${date.getTime()}.bcup`; 34 | } 35 | 36 | async function handleSourceSave(sourceID: VaultSourceID, content: string): Promise { 37 | // Get settings for source 38 | const vaultSettings = await getVaultSettings(sourceID); 39 | if (!vaultSettings.localBackup) return; 40 | // Calculate pat 41 | const backupRoot = 42 | typeof vaultSettings.localBackupLocation === "string" && vaultSettings.localBackupLocation 43 | ? path.resolve( 44 | VAULTS_BACKUP_PATH, 45 | path.join(vaultSettings.localBackupLocation, sourceID) 46 | ) 47 | : path.join(VAULTS_BACKUP_PATH, sourceID); 48 | const backupPath = path.join(backupRoot, getBackupFilename(sourceID, "save")); 49 | // Create folders 50 | await fs.mkdir(backupRoot, { 51 | recursive: true 52 | }); 53 | // Backup 54 | logInfo(`Backing up source: ${sourceID} => ${backupPath}`); 55 | await fs.writeFile(backupPath, content); 56 | logInfo(`Source backup complete: ${sourceID}`); 57 | } 58 | -------------------------------------------------------------------------------- /source/main/services/browser/api.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, NextFunction } from "express"; 2 | import cors from "cors"; 3 | import createRouter from "express-promise-router"; 4 | import { VERSION } from "../../library/build"; 5 | import { handleAuthPing, processAuthRequest, processAuthResponse } from "./controllers/auth"; 6 | import { searchEntries, searchSpecificEntries } from "./controllers/entries"; 7 | import { getAllOTPs } from "./controllers/otp"; 8 | import { getVaults, getVaultsTree, promptVaultLock, promptVaultUnlock } from "./controllers/vaults"; 9 | import { handleError } from "./error"; 10 | import { requireClient, requireKeyAuth } from "./middleware"; 11 | import { saveExistingEntry, saveNewEntry } from "./controllers/save"; 12 | 13 | export function buildApplication(): express.Application { 14 | const app = express(); 15 | app.disable("x-powered-by"); 16 | app.use(express.text()); 17 | app.use(express.json()); 18 | app.use(cors()); 19 | app.use((req: Request, res: Response, next: NextFunction) => { 20 | res.set("Server", `ButtercupDesktop/${VERSION}`); 21 | next(); 22 | }); 23 | createRoutes(app); 24 | app.use(handleError); 25 | return app; 26 | } 27 | 28 | function createRoutes(app: express.Application): void { 29 | const router = createRouter(); 30 | router.post("/auth/request", processAuthRequest); 31 | router.post("/auth/response", processAuthResponse); 32 | router.post("/auth/test", requireClient, requireKeyAuth, handleAuthPing); 33 | router.get("/entries", requireClient, searchEntries); 34 | router.post("/entries/specific", requireClient, requireKeyAuth, searchSpecificEntries); 35 | router.get("/otps", requireClient, getAllOTPs); 36 | router.get("/vaults", requireClient, getVaults); 37 | router.get("/vaults-tree", requireClient, getVaultsTree); 38 | router.patch( 39 | "/vaults/:id/group/:gid/entry/:eid", 40 | requireClient, 41 | requireKeyAuth, 42 | saveExistingEntry 43 | ); 44 | router.post("/vaults/:id/group/:gid/entry", requireClient, requireKeyAuth, saveNewEntry); 45 | router.post("/vaults/:id/lock", requireClient, promptVaultLock); 46 | router.post("/vaults/:id/unlock", requireClient, promptVaultUnlock); 47 | app.use("/v1", router); 48 | } 49 | -------------------------------------------------------------------------------- /source/main/services/browser/auth.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { decryptPayload, encryptPayload, getBrowserPublicKeyString } from "../browserAuth"; 3 | import { getConfigValue, setConfigValue } from "../config"; 4 | import { BrowserAPIErrorType } from "../../types"; 5 | 6 | export async function decryptAPIPayload(clientID: string, payload: string): Promise { 7 | // Check that the client is registered, we don't actually 8 | // use their key for decryption.. 9 | const clients = await getConfigValue("browserClients"); 10 | const clientConfig = clients[clientID]; 11 | if (!clientConfig) { 12 | throw new Layerr( 13 | { 14 | info: { 15 | clientID, 16 | code: BrowserAPIErrorType.NoAPIKey 17 | } 18 | }, 19 | "No client key registered for decryption" 20 | ); 21 | } 22 | // Private key for decryption 23 | const browserPrivateKey = await getConfigValue("browserPrivateKey"); 24 | // Decrypt 25 | return decryptPayload(payload, clientConfig.publicKey, browserPrivateKey); 26 | } 27 | 28 | export async function encryptAPIPayload(clientID: string, payload: string): Promise { 29 | // Check that the client is registered, we don't actually 30 | // use their key for decryption.. 31 | const clients = await getConfigValue("browserClients"); 32 | const clientConfig = clients[clientID]; 33 | if (!clientConfig) { 34 | throw new Layerr( 35 | { 36 | info: { 37 | clientID, 38 | code: BrowserAPIErrorType.NoAPIKey 39 | } 40 | }, 41 | "No client key registered for encryption" 42 | ); 43 | } 44 | // Private key for decryption 45 | const browserPrivateKey = await getConfigValue("browserPrivateKey"); 46 | // Encrypt 47 | return encryptPayload(payload, browserPrivateKey, clientConfig.publicKey); 48 | } 49 | 50 | export async function registerPublicKey(id: string, publicKey: string): Promise { 51 | const clients = await getConfigValue("browserClients"); 52 | await setConfigValue("browserClients", { 53 | ...clients, 54 | [id]: { 55 | publicKey 56 | } 57 | }); 58 | const serverPublicKey = await getBrowserPublicKeyString(); 59 | return serverPublicKey; 60 | } 61 | -------------------------------------------------------------------------------- /source/main/services/browser/controllers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { Layerr } from "layerr"; 3 | import { logInfo } from "../../../library/log"; 4 | import { registerPublicKey } from "../auth"; 5 | import { promptUserForBrowserAccess, validateEnteredCode } from "../interaction"; 6 | import { AuthRequestSchema, AuthResponseSchema } from "../models"; 7 | import { BrowserAPIErrorType } from "../../../types"; 8 | 9 | export async function handleAuthPing(req: Request, res: Response) { 10 | res.send("OK"); 11 | } 12 | 13 | export async function processAuthRequest(req: Request, res: Response) { 14 | logInfo(`Browser access authorisation request received: ${req.get("origin")}`); 15 | AuthRequestSchema.parse(req.body); 16 | await promptUserForBrowserAccess(); 17 | res.send("OK"); 18 | } 19 | 20 | export async function processAuthResponse(req: Request, res: Response) { 21 | const { code, id, publicKey } = AuthResponseSchema.parse(req.body); 22 | const isValid = await validateEnteredCode(code); 23 | if (!isValid) { 24 | throw new Layerr( 25 | { 26 | info: { 27 | code: BrowserAPIErrorType.AuthMismatch 28 | } 29 | }, 30 | "Incorrect code entered" 31 | ); 32 | } 33 | // Register client key 34 | const serverKey = await registerPublicKey(id, publicKey); 35 | logInfo("Connected new browser key"); 36 | res.json({ 37 | publicKey: serverKey 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /source/main/services/browser/controllers/entries.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { PropertyKeyValueObject, SearchResult, getEntryURLs } from "buttercup"; 3 | import { EntriesSearchBodySchema, EntriesSearchQuerySchema, EntriesSearchType } from "../models"; 4 | import { searchAllVaultsByTerm, searchAllVaultsByURL } from "../../search"; 5 | import { respondJSON } from "../response"; 6 | import { getEntries } from "../../buttercup"; 7 | 8 | export async function searchEntries(req: Request, res: Response) { 9 | const config = EntriesSearchQuerySchema.parse(req.query); 10 | let results: Array = []; 11 | if (config.type === EntriesSearchType.Term) { 12 | results = await searchAllVaultsByTerm(config.term); 13 | } else if (config.type === EntriesSearchType.URL) { 14 | results = await searchAllVaultsByURL(config.url); 15 | } 16 | await respondJSON(res, { 17 | results 18 | }); 19 | } 20 | 21 | export async function searchSpecificEntries(req: Request, res: Response) { 22 | const { entries } = EntriesSearchBodySchema.parse(req.body); 23 | const resultItems = await getEntries(entries as Array<{ entryID: string; sourceID: string }>); 24 | const results: Array = resultItems.map((item) => ({ 25 | entryType: item.entry.getType(), 26 | groupID: item.entry.getGroup().id, 27 | id: item.entry.id, 28 | properties: item.entry.getProperty() as PropertyKeyValueObject, 29 | tags: item.entry.getTags(), 30 | sourceID: item.sourceID, 31 | urls: getEntryURLs(item.entry.getProperty() as PropertyKeyValueObject), 32 | vaultID: item.entry.vault.id 33 | })); 34 | await respondJSON(res, { 35 | results 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /source/main/services/browser/controllers/otp.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getAllOTPs as getAllSourceOTPs } from "../../buttercup"; 3 | import { respondJSON } from "../response"; 4 | 5 | export async function getAllOTPs(req: Request, res: Response) { 6 | const otps = getAllSourceOTPs(); 7 | await respondJSON(res, { 8 | otps 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /source/main/services/browser/controllers/save.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { 3 | SaveExistingEntryParamSchema, 4 | SaveExistingEntryPayloadSchema, 5 | SaveNewEntryParamSchema, 6 | SaveNewEntryPayloadSchema 7 | } from "../models"; 8 | import { logInfo } from "../../../library/log"; 9 | import { createNewEntry, updateExistingEntry } from "../../buttercup"; 10 | 11 | export async function saveExistingEntry(req: Request, res: Response) { 12 | const { eid: entryID, id: sourceID } = SaveExistingEntryParamSchema.parse(req.params); 13 | const { properties } = SaveExistingEntryPayloadSchema.parse(req.body); 14 | // Update entry 15 | logInfo(`(api) update existing entry: ${entryID} (source=${sourceID})`); 16 | await updateExistingEntry(sourceID, entryID, properties); 17 | // Respond 18 | res.json({ 19 | entryID 20 | }); 21 | } 22 | 23 | export async function saveNewEntry(req: Request, res: Response) { 24 | const { gid: groupID, id: sourceID } = SaveNewEntryParamSchema.parse(req.params); 25 | const { properties, type } = SaveNewEntryPayloadSchema.parse(req.body); 26 | // Create entry 27 | logInfo(`(api) create new entry (source=${sourceID}, group=${groupID})`); 28 | const entryID = await createNewEntry(sourceID, groupID, type, properties); 29 | // Respond 30 | res.json({ 31 | entryID 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /source/main/services/browser/controllers/vaults.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { VaultFacade, VaultSourceStatus } from "buttercup"; 3 | import { Layerr } from "layerr"; 4 | import { 5 | getSourceDescription, 6 | getSourceDescriptions, 7 | getSourceStatus, 8 | getUnlockedSourceIDs, 9 | getVaultFacadeBySource, 10 | lockSource 11 | } from "../../buttercup"; 12 | import { VaultUnlockParamSchema } from "../models"; 13 | import { openMainWindow } from "../../windows"; 14 | import { respondJSON, respondText } from "../response"; 15 | import { logErr, logInfo } from "../../../library/log"; 16 | import { BrowserAPIErrorType } from "../../../types"; 17 | 18 | export async function getVaults(req: Request, res: Response) { 19 | const sources = getSourceDescriptions(); 20 | await respondJSON(res, { 21 | sources 22 | }); 23 | } 24 | 25 | export async function getVaultsTree(req: Request, res: Response) { 26 | const sourceIDs = getUnlockedSourceIDs(); 27 | const tree = sourceIDs.reduce( 28 | (output: Record, sourceID) => ({ 29 | ...output, 30 | [sourceID]: getVaultFacadeBySource(sourceID) 31 | }), 32 | {} 33 | ); 34 | const names = sourceIDs.reduce( 35 | (output: Record, sourceID) => ({ 36 | ...output, 37 | [sourceID]: getSourceDescription(sourceID)?.name ?? "Untitled vault" 38 | }), 39 | {} 40 | ); 41 | await respondJSON(res, { 42 | names, 43 | tree 44 | }); 45 | } 46 | 47 | export async function promptVaultLock(req: Request, res: Response) { 48 | const { id: sourceID } = VaultUnlockParamSchema.parse(req.params); 49 | const sourceStatus = getSourceStatus(sourceID); 50 | logInfo(`(api) prompt vault lock: ${sourceID}`); 51 | if (!sourceStatus) { 52 | logErr(`(api) no source exists for locking: ${sourceID}`); 53 | res.status(404).send("Not Found"); 54 | return; 55 | } 56 | if (sourceStatus === VaultSourceStatus.Pending) { 57 | throw new Layerr( 58 | { 59 | info: { 60 | code: BrowserAPIErrorType.VaultInvalidState 61 | } 62 | }, 63 | "Vault in pending state" 64 | ); 65 | } else if (sourceStatus === VaultSourceStatus.Locked) { 66 | res.status(202).send("Accepted"); 67 | return; 68 | } 69 | await lockSource(sourceID); 70 | res.status(200); 71 | await respondText(res, "OK"); 72 | } 73 | 74 | export async function promptVaultUnlock(req: Request, res: Response) { 75 | const { id: sourceID } = VaultUnlockParamSchema.parse(req.params); 76 | const sourceStatus = getSourceStatus(sourceID); 77 | logInfo(`(api) prompt vault unlock: ${sourceID}`); 78 | if (!sourceStatus) { 79 | logErr(`(api) no source exists for unlocking: ${sourceID}`); 80 | res.status(404).send("Not Found"); 81 | return; 82 | } 83 | const window = await openMainWindow(); 84 | window.webContents.send("unlock-vault-open", sourceID); 85 | window.focus(); 86 | res.status(200); 87 | await respondText(res, "OK"); 88 | } 89 | -------------------------------------------------------------------------------- /source/main/services/browser/error.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { Layerr, LayerrInfo } from "layerr"; 3 | import { ZodError } from "zod"; 4 | import statuses from "statuses"; 5 | import { logErr, logWarn } from "../../library/log"; 6 | import { BrowserAPIErrorType } from "../../types"; 7 | 8 | export function handleError(err: Error, req: Request, res: Response, next: NextFunction) { 9 | if (err instanceof ZodError) { 10 | // Validation error(s) 11 | logWarn(`API request resulted in validation error: ${JSON.stringify(err.format())}`); 12 | res.status(400).send("Bad Request"); 13 | return; 14 | } 15 | logErr(`API error: ${err.message} ${Layerr.fullStack(err)}`); 16 | let responseCode = 500, 17 | errorInfo: LayerrInfo; 18 | try { 19 | errorInfo = Layerr.info(err); 20 | } catch { 21 | logErr(`Error handler received a non-error object: ${err}`); 22 | errorInfo = {}; 23 | } 24 | const { code = null, status = null } = errorInfo; 25 | switch (code) { 26 | case BrowserAPIErrorType.AuthMismatch: 27 | responseCode = 403; 28 | break; 29 | case BrowserAPIErrorType.NoAPIKey: 30 | responseCode = 401; 31 | break; 32 | case BrowserAPIErrorType.NoAuthorization: 33 | responseCode = 401; 34 | break; 35 | case BrowserAPIErrorType.VaultInvalidState: 36 | responseCode = 500; 37 | break; 38 | } 39 | if (status && typeof status === "number") { 40 | responseCode = status; 41 | } 42 | const responseText = statuses(responseCode) as string; 43 | logWarn(`API request failed: ${req.url}: ${responseCode} ${responseText} (${code})`); 44 | res.status(responseCode); 45 | res.set("Content-Type", "text/plain").send(responseText); 46 | } 47 | -------------------------------------------------------------------------------- /source/main/services/browser/index.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, Server, ServerResponse } from "node:http"; 2 | import { Application } from "express"; 3 | import { BROWSER_API_HOST_PORT } from "../../symbols"; 4 | import { buildApplication } from "./api"; 5 | import { logInfo } from "../../library/log"; 6 | import { getConfigValue } from "../config"; 7 | 8 | let __app: Application | null = null, 9 | __server: Server; 10 | 11 | export async function start(): Promise { 12 | if (__app) return; 13 | const apiClients = await getConfigValue("browserClients"); 14 | logInfo(`Starting browser API (${Object.keys(apiClients).length} keys registered)`); 15 | __app = buildApplication(); 16 | return new Promise((resolve) => { 17 | __server = __app.listen(BROWSER_API_HOST_PORT, resolve); 18 | }); 19 | } 20 | 21 | export async function stop() { 22 | if (!__server) return; 23 | return new Promise((resolve, reject) => { 24 | __server.close((err) => { 25 | if (err) { 26 | return reject(err); 27 | } 28 | __server = null; 29 | __app = null; 30 | resolve(); 31 | }); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /source/main/services/browser/interaction.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow } from "electron"; 2 | import { Layerr } from "layerr"; 3 | import { getMainWindow, openMainWindow } from "../windows"; 4 | 5 | const CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890"; 6 | const CODE_LENGTH = 12; 7 | 8 | let __code: string | null = null; 9 | 10 | export async function clearCode(): Promise { 11 | __code = null; 12 | const window = getMainWindow(); 13 | if (window) { 14 | window.webContents.send("browser-access-code-hide"); 15 | } 16 | } 17 | 18 | function generateCode(length: number): string { 19 | const chars = CODE_CHARS.length; 20 | let code = ""; 21 | while (code.length < length) { 22 | code = `${code}${CODE_CHARS[Math.floor(Math.random() * chars)]}`; 23 | } 24 | return code; 25 | } 26 | 27 | export async function promptUserForBrowserAccess() { 28 | clearCode(); 29 | let window: BrowserWindow; 30 | try { 31 | window = await openMainWindow(); 32 | } catch (err) { 33 | throw new Layerr(err, "Failed opening window"); 34 | } 35 | __code = generateCode(CODE_LENGTH); 36 | window.webContents.send("browser-access-code", JSON.stringify({ code: __code })); 37 | } 38 | 39 | export async function validateEnteredCode(browserCode: string): Promise { 40 | const valid = __code !== null && __code === browserCode; 41 | await clearCode(); 42 | return valid; 43 | } 44 | -------------------------------------------------------------------------------- /source/main/services/browser/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { Layerr } from "layerr"; 3 | import { decryptAPIPayload } from "./auth"; 4 | import { BrowserAPIErrorType } from "../../types"; 5 | import { getConfigValue } from "../config"; 6 | import { logInfo } from "../../library/log"; 7 | 8 | export async function requireClient(req: Request, res: Response, next: NextFunction) { 9 | const auth = req.get("Authorization"); 10 | const [, clientID] = `${auth}`.split(/\s+/); 11 | const clients = await getConfigValue("browserClients"); 12 | if (!clients[clientID]) { 13 | throw new Layerr( 14 | { 15 | info: { 16 | code: BrowserAPIErrorType.NoAPIKey 17 | } 18 | }, 19 | "No key registered" 20 | ); 21 | } 22 | res.locals.clientID = clientID; 23 | next(); 24 | } 25 | 26 | export async function requireKeyAuth(req: Request, res: Response, next: NextFunction) { 27 | const auth = req.get("Authorization"); 28 | const bodyType = req.get("X-Content-Type") ?? "text/plain"; 29 | const [, clientID] = `${auth}`.split(/\s+/); 30 | // Check client registration 31 | if (!clientID) { 32 | throw new Layerr( 33 | { 34 | info: { 35 | code: BrowserAPIErrorType.NoAuthorization 36 | } 37 | }, 38 | "No client key provided" 39 | ); 40 | } 41 | // Decrypt body 42 | logInfo(`(api) auth client request: ${clientID}`); 43 | const decryptedStr = await decryptAPIPayload(clientID, req.body); 44 | if (/^application\/json/.test(bodyType)) { 45 | req.headers["content-type"] = bodyType; 46 | req.body = JSON.parse(decryptedStr); 47 | } else { 48 | req.body = decryptedStr; 49 | } 50 | next(); 51 | } 52 | -------------------------------------------------------------------------------- /source/main/services/browser/models.ts: -------------------------------------------------------------------------------- 1 | import { EntryType } from "buttercup"; 2 | import { z } from "zod"; 3 | 4 | const PUBLIC_KEY = z.string(); 5 | const ULID = z.string().regex(/^[a-z0-9]{26}$/i); 6 | 7 | export const AuthRequestSchema = z.object({ 8 | client: z.literal("browser"), 9 | purpose: z.literal("vaults-access"), 10 | rev: z.literal(1) 11 | }); 12 | 13 | export const AuthResponseSchema = z.object({ 14 | code: z.string().min(1), 15 | id: ULID, 16 | publicKey: PUBLIC_KEY 17 | }); 18 | 19 | export enum EntriesSearchType { 20 | Term = "term", 21 | URL = "url" 22 | } 23 | 24 | export const EntriesSearchBodySchema = z.object({ 25 | entries: z.array( 26 | z.object({ 27 | entryID: z.string().min(1), 28 | sourceID: z.string().min(1) 29 | }) 30 | ) 31 | }); 32 | 33 | export const EntriesSearchQuerySchema = z.discriminatedUnion("type", [ 34 | z.object({ 35 | term: z.string(), 36 | type: z.literal(EntriesSearchType.Term) 37 | }), 38 | z.object({ 39 | type: z.literal(EntriesSearchType.URL), 40 | url: z.string().url() 41 | }) 42 | ]); 43 | 44 | export const SaveExistingEntryParamSchema = z 45 | .object({ 46 | eid: z.string().min(1), 47 | gid: z.string().min(1), // not explicitly used 48 | id: z.string().min(1) 49 | }) 50 | .strict(); 51 | 52 | export const SaveExistingEntryPayloadSchema = z.object({ 53 | properties: z.record(z.string()) 54 | }); 55 | 56 | export const SaveNewEntryParamSchema = z 57 | .object({ 58 | gid: z.string().min(1), 59 | id: z.string().min(1) 60 | }) 61 | .strict(); 62 | 63 | export const SaveNewEntryPayloadSchema = z.object({ 64 | properties: z.record(z.string()), 65 | type: z.nativeEnum(EntryType) 66 | }); 67 | 68 | export const VaultUnlockParamSchema = z 69 | .object({ 70 | id: z.string().min(1) 71 | }) 72 | .strict(); 73 | -------------------------------------------------------------------------------- /source/main/services/browser/response.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | import { Layerr } from "layerr"; 3 | import { BrowserAPIErrorType } from "../../types"; 4 | import { encryptAPIPayload } from "./auth"; 5 | 6 | export async function respondJSON(res: Response, obj: Record) { 7 | const { clientID } = res.locals as { clientID: string }; 8 | if (!clientID) { 9 | throw new Layerr( 10 | { 11 | info: { 12 | code: BrowserAPIErrorType.NoAPIKey 13 | } 14 | }, 15 | "No client ID set: Invalid response state" 16 | ); 17 | } 18 | const data = await encryptAPIPayload(clientID, JSON.stringify(obj)); 19 | res.set("X-Bcup-API", "enc,1"); 20 | res.set("Content-Type", "text/plain"); 21 | res.set("X-Content-Type", "application/json"); 22 | res.send(data); 23 | } 24 | 25 | export async function respondText(res: Response, text: string) { 26 | const { clientID } = res.locals as { clientID: string }; 27 | if (!clientID) { 28 | throw new Layerr( 29 | { 30 | info: { 31 | code: BrowserAPIErrorType.NoAPIKey 32 | } 33 | }, 34 | "No client ID set: Invalid response state" 35 | ); 36 | } 37 | const data = await encryptAPIPayload(clientID, text); 38 | res.set("X-Bcup-API", "enc,1"); 39 | res.set("Content-Type", "text/plain"); 40 | res.send(data); 41 | } 42 | -------------------------------------------------------------------------------- /source/main/services/browserAuth.ts: -------------------------------------------------------------------------------- 1 | import { webcrypto } from "node:crypto"; 2 | import { EncryptionAlgorithm, createAdapter } from "iocane"; 3 | import { getConfigValue, setConfigValue } from "./config"; 4 | import { API_KEY_ALGO, API_KEY_CURVE } from "../symbols"; 5 | 6 | export async function decryptPayload( 7 | payload: string, 8 | sourcePublicKey: string, 9 | targetPrivateKey: string 10 | ): Promise { 11 | const privateKey = await importECDHKey(targetPrivateKey); 12 | const publicKey = await importECDHKey(sourcePublicKey); 13 | const secret = await deriveSecretKey(privateKey, publicKey); 14 | return createAdapter().decrypt(payload, secret) as Promise; 15 | } 16 | 17 | export async function deriveSecretKey( 18 | privateKey: CryptoKey, 19 | publicKey: CryptoKey 20 | ): Promise { 21 | const cryptoKey = await webcrypto.subtle.deriveKey( 22 | { 23 | name: API_KEY_ALGO, 24 | public: publicKey 25 | }, 26 | privateKey, 27 | { 28 | name: "AES-GCM", 29 | length: 256 30 | }, 31 | true, 32 | ["encrypt", "decrypt"] 33 | ); 34 | const exported = await webcrypto.subtle.exportKey("raw", cryptoKey); 35 | return Buffer.from(exported).toString("hex"); 36 | } 37 | 38 | export async function encryptPayload( 39 | payload: string, 40 | sourcePrivateKey: string, 41 | targetPublicKey: string 42 | ): Promise { 43 | const privateKey = await importECDHKey(sourcePrivateKey); 44 | const publicKey = await importECDHKey(targetPublicKey); 45 | const secret = await deriveSecretKey(privateKey, publicKey); 46 | return createAdapter() 47 | .setAlgorithm(EncryptionAlgorithm.GCM) 48 | .setDerivationRounds(100000) 49 | .encrypt(payload, secret) as Promise; 50 | } 51 | 52 | async function exportECDHKey(key: CryptoKey): Promise { 53 | const exported = await webcrypto.subtle.exportKey("jwk", key); 54 | return JSON.stringify(exported); 55 | } 56 | 57 | export async function generateBrowserKeys(): Promise { 58 | let privateKeyStr = await getConfigValue("browserPrivateKey"), 59 | publicKeyStr = await getConfigValue("browserPublicKey"); 60 | if (privateKeyStr && publicKeyStr) return; 61 | const { privateKey, publicKey } = await webcrypto.subtle.generateKey( 62 | { 63 | name: API_KEY_ALGO, 64 | namedCurve: API_KEY_CURVE 65 | }, 66 | true, 67 | ["deriveKey"] 68 | ); 69 | privateKeyStr = await exportECDHKey(privateKey); 70 | publicKeyStr = await exportECDHKey(publicKey); 71 | await setConfigValue("browserPrivateKey", privateKeyStr); 72 | await setConfigValue("browserPublicKey", publicKeyStr); 73 | } 74 | 75 | export async function getBrowserPublicKeyString(): Promise { 76 | const publicKeyStr = await getConfigValue("browserPublicKey"); 77 | if (!publicKeyStr) { 78 | throw new Error("Public key not found"); 79 | } 80 | return publicKeyStr; 81 | } 82 | 83 | async function importECDHKey(key: string): Promise { 84 | const jwk = JSON.parse(key) as JsonWebKey; 85 | const usages: Array = 86 | jwk.key_ops && jwk.key_ops.includes("deriveKey") ? ["deriveKey"] : []; 87 | return webcrypto.subtle.importKey( 88 | "jwk", 89 | jwk, 90 | { 91 | name: API_KEY_ALGO, 92 | namedCurve: API_KEY_CURVE 93 | }, 94 | true, 95 | usages 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /source/main/services/export.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { dialog } from "electron"; 3 | import { VaultSourceID } from "buttercup"; 4 | import pify from "pify"; 5 | import { exportVault, getSourceDescription } from "./buttercup"; 6 | import { getMainWindow } from "./windows"; 7 | import { t } from "../../shared/i18n/trans"; 8 | import { logInfo } from "../library/log"; 9 | 10 | const writeFile = pify(fs.writeFile); 11 | 12 | export async function exportVaultSource(sourceID: VaultSourceID): Promise { 13 | const mainWindow = getMainWindow(); 14 | const sourceDetails = getSourceDescription(sourceID); 15 | const result = await dialog.showSaveDialog(mainWindow, { 16 | title: t("dialog.export-file-chooser.title", { name: sourceDetails.name }), 17 | buttonLabel: t("dialog.export-file-chooser.submit-button"), 18 | filters: [ 19 | { 20 | name: "Buttercup CSV", 21 | extensions: ["csv"] 22 | } 23 | ], 24 | properties: ["createDirectory", "showOverwriteConfirmation"] 25 | }); 26 | if (result.canceled) return null; 27 | const csvData = await exportVault(sourceID); 28 | logInfo(`Export source (${sourceID}) to CSV file: ${result.filePath}`); 29 | await writeFile(result.filePath, csvData); 30 | mainWindow.webContents.send( 31 | "notify-success", 32 | t("notification.export-success", { name: sourceDetails.name }) 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /source/main/services/facades.ts: -------------------------------------------------------------------------------- 1 | import { VaultFacade, VaultSourceID } from "buttercup"; 2 | import { getVaultFacadeBySource } from "./buttercup"; 3 | 4 | const __cachedFacades: Record = {}; 5 | 6 | export function cacheFacade(sourceID: VaultSourceID, vaultFacade: VaultFacade): void { 7 | __cachedFacades[sourceID] = vaultFacade; 8 | } 9 | 10 | export function clearFacadeCache(sourceID: VaultSourceID): void { 11 | delete __cachedFacades[sourceID]; 12 | } 13 | 14 | export function getVaultFacade(sourceID: VaultSourceID): VaultFacade { 15 | if (!__cachedFacades[sourceID]) { 16 | cacheFacade(sourceID, getVaultFacadeBySource(sourceID)); 17 | } 18 | return __cachedFacades[sourceID]; 19 | } 20 | -------------------------------------------------------------------------------- /source/main/services/fileHost.ts: -------------------------------------------------------------------------------- 1 | import { startFileHost as startSecureFileHost } from "@buttercup/secure-file-host"; 2 | import { logErr, logInfo } from "../library/log"; 3 | import { getConfigValue, setConfigValue } from "./config"; 4 | import { getMainWindow, openMainWindow } from "./windows"; 5 | import { SECURE_FILE_HOST_PORT } from "../symbols"; 6 | 7 | let __host = null; 8 | 9 | export async function startFileHost() { 10 | if (__host) return; 11 | const fileHostKey = await getConfigValue("fileHostKey"); 12 | logInfo(`Starting file host (key exists: ${!!fileHostKey})`); 13 | __host = fileHostKey 14 | ? startSecureFileHost(SECURE_FILE_HOST_PORT, fileHostKey) 15 | : startSecureFileHost(SECURE_FILE_HOST_PORT); 16 | __host.emitter.on("codeReady", async ({ code }) => { 17 | logInfo("File host: code received (incoming connection request)"); 18 | try { 19 | const window = await openMainWindow(); 20 | window.webContents.send("file-host-code", JSON.stringify({ code })); 21 | } catch (err) { 22 | logErr("Failed sending file host code to window", err); 23 | } 24 | }); 25 | __host.emitter.on("connected", () => { 26 | logInfo("File host: client connected"); 27 | const window = getMainWindow(); 28 | if (window) { 29 | window.webContents.send("file-host-code", JSON.stringify({ code: null })); 30 | } 31 | }); 32 | if (!fileHostKey) { 33 | const newKey = __host.key; 34 | logInfo("New file host key generated"); 35 | await setConfigValue("fileHostKey", newKey); 36 | } 37 | } 38 | 39 | export async function stopFileHost() { 40 | if (!__host) return; 41 | logInfo("Stopping file host"); 42 | __host.stop(); 43 | __host = null; 44 | } 45 | -------------------------------------------------------------------------------- /source/main/services/format.ts: -------------------------------------------------------------------------------- 1 | import { VaultFormatID, VaultSourceID } from "buttercup"; 2 | import { logInfo } from "../library/log"; 3 | import { convertVaultFormatAToB, getVaultFormat } from "./buttercup"; 4 | 5 | export async function convertVaultFormat( 6 | sourceID: VaultSourceID, 7 | format: VaultFormatID 8 | ): Promise { 9 | const currentFormat = getVaultFormat(sourceID); 10 | logInfo(`attempt convert vault format: ${sourceID} (${currentFormat} => ${format})`); 11 | if (!currentFormat) return false; 12 | if (currentFormat === VaultFormatID.A && format === VaultFormatID.B) { 13 | await convertVaultFormatAToB(sourceID); 14 | return true; 15 | } 16 | return false; 17 | } 18 | -------------------------------------------------------------------------------- /source/main/services/import.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { dialog } from "electron"; 3 | import { Vault, VaultSourceID } from "buttercup"; 4 | import { 5 | BitwardenImporter, 6 | ButtercupCSVImporter, 7 | ButtercupImporter, 8 | CSVImporter, 9 | KeePass2XMLImporter, 10 | LastPassImporter, 11 | OnePasswordImporter 12 | } from "@buttercup/importer"; 13 | import { mergeVaults } from "./buttercup"; 14 | import { getMainWindow } from "./windows"; 15 | import { t } from "../../shared/i18n/trans"; 16 | import { logErr } from "../library/log"; 17 | 18 | export interface Importer { 19 | export: () => Promise; 20 | } 21 | 22 | export interface ImporterConstructor { 23 | loadFromFile: (filename: string, password?: string) => Promise; 24 | } 25 | 26 | export const IMPORTERS = [ 27 | ["Bitwarden", "json", BitwardenImporter], 28 | // ["Buttercup", "bcup", ButtercupImporter], // @todo 29 | ["Buttercup", "csv", ButtercupCSVImporter], 30 | ["CSV", "csv", CSVImporter], 31 | ["KeePass", "xml", KeePass2XMLImporter], 32 | ["Lastpass", "csv", LastPassImporter], 33 | ["1Password", "1pif", OnePasswordImporter] 34 | ]; 35 | const IMPORTERS_WITH_PASSWORDS = [ButtercupImporter]; 36 | 37 | export async function startImport( 38 | sourceID: VaultSourceID, 39 | name: string, 40 | extension: string, 41 | Importer: ImporterConstructor 42 | ): Promise { 43 | const mainWindow = getMainWindow(); 44 | const result = await dialog.showOpenDialog(mainWindow, { 45 | title: t("dialog.import-file-chooser.title", { importer: name }), 46 | buttonLabel: t("dialog.import-file-chooser.submit-button"), 47 | filters: [ 48 | { 49 | name, 50 | extensions: [extension] 51 | } 52 | ], 53 | properties: ["openFile"] 54 | }); 55 | const [vaultPath] = result.filePaths; 56 | if (!vaultPath) return null; 57 | mainWindow.webContents.send("set-busy", true); 58 | try { 59 | // Create importer 60 | const importerInst = await Importer.loadFromFile(vaultPath); 61 | const importedVault = await importerInst.export(); 62 | // Import into source's vault 63 | await mergeVaults(sourceID, importedVault); 64 | // Notify 65 | mainWindow.webContents.send( 66 | "notify-success", 67 | t("notification.import-success", { filename: path.basename(vaultPath) }) 68 | ); 69 | } catch (err) { 70 | logErr("Failed importing", err); 71 | mainWindow.webContents.send( 72 | "notify-error", 73 | t("notification.error.import-failed", { error: err.message }) 74 | ); 75 | } finally { 76 | mainWindow.webContents.send("set-busy", false); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /source/main/services/init.ts: -------------------------------------------------------------------------------- 1 | import { session } from "electron"; 2 | import { attachVaultManagerWatchers, loadVaultsFromDisk, onSourcesUpdated } from "./buttercup"; 3 | import { initialise as initialiseLogging } from "./log"; 4 | import { logInfo } from "../library/log"; 5 | import { applyCurrentTheme } from "./theme"; 6 | import { updateTrayIcon } from "../actions/tray"; 7 | import { updateAppMenu } from "../actions/appMenu"; 8 | import { getConfigValue, initialise as initialiseConfig } from "./config"; 9 | import { getConfigPath, getVaultSettingsPath, getVaultStoragePath } from "./storage"; 10 | import { getOSLocale } from "./locale"; 11 | import { startFileHost } from "./fileHost"; 12 | import { isPortable } from "../library/portability"; 13 | import { getLogPath } from "./log"; 14 | import { startUpdateWatcher } from "./update"; 15 | import { registerGoogleDriveAuthHandlers } from "./googleDrive"; 16 | import { processLaunchConfiguration } from "./arguments"; 17 | import { supportsBiometricUnlock } from "./biometrics"; 18 | import { startAutoVaultLockTimer } from "./autoLock"; 19 | import { start as startBrowserAPI } from "./browser/index"; 20 | import { generateBrowserKeys } from "./browserAuth"; 21 | import { initialise as initialiseI18n, onLanguageChanged } from "../../shared/i18n/trans"; 22 | import { getLanguage } from "../../shared/library/i18n"; 23 | import { closeAndReopenMainWindow } from "./windows"; 24 | 25 | export async function initialise() { 26 | processLaunchConfiguration(); 27 | await initialiseLogging(); 28 | logInfo("Application session started:", new Date()); 29 | logInfo(`Logs location: ${getLogPath()}`); 30 | logInfo(`Config location: ${getConfigPath()}`); 31 | logInfo(`Vault config storage location: ${getVaultStoragePath()}`); 32 | logInfo(`Vault-specific settings path: ${getVaultSettingsPath("")}`); 33 | await initialiseConfig(); 34 | const preferences = await getConfigValue("preferences"); 35 | const locale = await getOSLocale(); 36 | logInfo(`System locale detected: ${locale}`); 37 | const language = getLanguage(preferences, locale); 38 | logInfo(`Starting with language: ${language}`); 39 | await initialiseI18n(language); 40 | await generateBrowserKeys(); 41 | attachVaultManagerWatchers(); 42 | await loadVaultsFromDisk(); 43 | session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { 44 | details.requestHeaders["Origin"] = "https://desktop.buttercup.pw/v2"; 45 | callback({ cancel: false, requestHeaders: details.requestHeaders }); 46 | }); 47 | await updateTrayIcon(); 48 | await updateAppMenu(); 49 | onSourcesUpdated(async () => { 50 | await updateAppMenu(); 51 | await updateTrayIcon(); 52 | }); 53 | onLanguageChanged(async () => { 54 | await updateTrayIcon(); 55 | await updateAppMenu(); 56 | await closeAndReopenMainWindow(); 57 | }); 58 | await applyCurrentTheme(); 59 | if (preferences.fileHostEnabled) { 60 | await startFileHost(); 61 | await startBrowserAPI(); 62 | } 63 | registerGoogleDriveAuthHandlers(); 64 | logInfo(`Portable mode: ${isPortable() ? "yes" : "no"}`); 65 | logInfo(`Biometrics: ${supportsBiometricUnlock() ? "yes" : "no"}`); 66 | setTimeout(() => startUpdateWatcher(), 0); 67 | logInfo( 68 | `Auto-lock: ${ 69 | preferences.lockVaultsAfterTime ? preferences.lockVaultsAfterTime + "s" : "no" 70 | }` 71 | ); 72 | startAutoVaultLockTimer(); 73 | logInfo("Initialisation completed"); 74 | } 75 | -------------------------------------------------------------------------------- /source/main/services/lastVault.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { updateAppMenu } from "../actions/appMenu"; 3 | import { logErr } from "../library/log"; 4 | 5 | let __lastSourceID: VaultSourceID = null; 6 | 7 | export function getLastSourceID(): VaultSourceID { 8 | return __lastSourceID; 9 | } 10 | 11 | export function setLastSourceID(sourceID: VaultSourceID) { 12 | __lastSourceID = sourceID; 13 | updateAppMenu().catch((err) => { 14 | logErr("Failed updating app menu after source change", err); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /source/main/services/launch.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import fs from "node:fs/promises"; 3 | import { app } from "electron"; 4 | import untildify from "untildify"; 5 | import { pathExists } from "path-exists"; 6 | import { isLinux } from "../../shared/library/platform"; 7 | 8 | const LINUX_AUTOSTART_DIR = "~/.config/autostart"; 9 | const LINUX_DESKTOP = ` 10 | [Desktop Entry] 11 | Type=Application 12 | Version=1.0 13 | Name={{APP_NAME}} 14 | Comment={{APP_NAME}} startup script 15 | Exec={{APP_PATH}} --autostart 16 | StartupNotify=false 17 | Terminal=false 18 | `; 19 | 20 | export async function setStartWithSession(enable: boolean): Promise { 21 | if (isLinux()) { 22 | await setStartWithSessionLinux(enable); 23 | } else { 24 | await setStartWithSessionNative(enable); 25 | } 26 | } 27 | 28 | async function setStartWithSessionLinux(enable: boolean): Promise { 29 | const autostartPath = path.join(untildify(LINUX_AUTOSTART_DIR), "buttercup.desktop"); 30 | const isEnabled = await pathExists(autostartPath); 31 | let execPath = process.env?.APPIMAGE ? process.env.APPIMAGE : process.execPath; 32 | execPath = execPath.replace(/(\s+)/g, "\\$1"); 33 | if (enable && !isEnabled) { 34 | const desktop = LINUX_DESKTOP.trim() 35 | .replace(/{{APP_NAME}}/g, "Buttercup") 36 | .replace(/{{APP_PATH}}/g, execPath); 37 | await fs.writeFile(autostartPath, desktop); 38 | } else if (!enable && isEnabled) { 39 | await fs.unlink(autostartPath); 40 | } 41 | } 42 | 43 | async function setStartWithSessionNative(enable: boolean): Promise { 44 | const isEnabled = app.getLoginItemSettings().openAtLogin; 45 | if (enable && !isEnabled) { 46 | app.setLoginItemSettings({ 47 | openAsHidden: true, 48 | openAtLogin: true, 49 | args: ["--autostart"] 50 | }); 51 | } else if (!enable && isEnabled) { 52 | app.setLoginItemSettings({ 53 | openAsHidden: false, 54 | openAtLogin: false, 55 | args: [] 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /source/main/services/locale.ts: -------------------------------------------------------------------------------- 1 | import osLocale from "os-locale"; 2 | 3 | export async function getOSLocale(): Promise { 4 | const locale = await osLocale(); 5 | return locale; 6 | } 7 | -------------------------------------------------------------------------------- /source/main/services/log.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import rotate from "log-rotate"; 4 | import pify from "pify"; 5 | import { LOG_FILENAME, LOG_PATH } from "./storage"; 6 | import { getPortableExeDir, isPortable } from "../library/portability"; 7 | 8 | const appendFile = pify(fs.appendFile); 9 | const mkdir = pify(fs.mkdir); 10 | 11 | const LOG_RETENTION = 10; 12 | 13 | export function getLogPath(): string { 14 | return isPortable() ? path.join(getPortableExeDir(), LOG_FILENAME) : LOG_PATH; 15 | } 16 | 17 | export async function initialise() { 18 | const logPath = getLogPath(); 19 | await mkdir(path.dirname(logPath), { recursive: true }); 20 | await new Promise((resolve, reject) => { 21 | rotate(logPath, { count: LOG_RETENTION, compress: false }, (error) => { 22 | if (error) return reject(error); 23 | resolve(); 24 | }); 25 | }); 26 | } 27 | 28 | export async function writeLines(lines: Array): Promise { 29 | const logPath = getLogPath(); 30 | await appendFile(logPath, `${lines.join("\n")}\n`); 31 | } 32 | -------------------------------------------------------------------------------- /source/main/services/migration.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { naiveClone } from "../../shared/library/clone"; 3 | import { AppStartMode, Config } from "../types"; 4 | 5 | export type ConfigMigration = [name: string, migration: (config: Config) => Config | null]; 6 | 7 | const MIGRATIONS: Array = [ 8 | [ 9 | "startInBackground", 10 | (config: Config) => { 11 | if ( 12 | config.preferences && 13 | typeof config.preferences["startInBackground"] === "boolean" 14 | ) { 15 | const prefs = { ...config.preferences }; 16 | prefs.startMode = config.preferences["startInBackground"] 17 | ? AppStartMode.HiddenAlways 18 | : AppStartMode.None; 19 | delete prefs["startInBackground"]; 20 | return { 21 | ...config, 22 | preferences: prefs 23 | }; 24 | } 25 | return null; // No change 26 | } 27 | ] 28 | ]; 29 | 30 | export function runConfigMigrations(config: Config): [Config, changed: boolean] { 31 | let current = naiveClone(config), 32 | changed = false; 33 | for (const [name, execute] of MIGRATIONS) { 34 | try { 35 | const result = execute(current); 36 | if (result !== null) { 37 | changed = true; 38 | current = result; 39 | } 40 | } catch (err) { 41 | throw new Layerr(err, `Failed executing config migration: ${name}`); 42 | } 43 | } 44 | return [current, changed]; 45 | } 46 | -------------------------------------------------------------------------------- /source/main/services/protocol.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from "electron"; 2 | import EventEmitter from "eventemitter3"; 3 | import { logErr, logInfo, logWarn } from "../library/log"; 4 | import { BUTTERCUP_PROTOCOL } from "../symbols"; 5 | 6 | let __protocolEmitter: EventEmitter = null; 7 | 8 | export function getProtocolEmitter(): EventEmitter { 9 | if (!__protocolEmitter) { 10 | __protocolEmitter = new EventEmitter(); 11 | } 12 | return __protocolEmitter; 13 | } 14 | 15 | function handleAuthCall(args) { 16 | if (!Array.isArray(args) || args.length === 0) { 17 | logErr("Empty auth call: aborting"); 18 | return; 19 | } 20 | const [action, ...actionArgs] = args; 21 | switch (action) { 22 | case "google": 23 | getProtocolEmitter().emit("authGoogle", actionArgs); 24 | BrowserWindow.getAllWindows().forEach((win) => { 25 | win.webContents.send("protocol:auth/google", actionArgs); 26 | }); 27 | break; 28 | default: 29 | logWarn("Unable to handle authentication protocol execution"); 30 | break; 31 | } 32 | } 33 | 34 | export function handleProtocolCall(protocolURL: string) { 35 | const path = protocolURL.replace(BUTTERCUP_PROTOCOL, ""); 36 | logInfo(`Protocol URL call: ${path}`); 37 | const [action, ...args] = path.split("/"); 38 | switch (action) { 39 | case "auth": 40 | handleAuthCall(args); 41 | break; 42 | default: 43 | logWarn(`Failed handling protocol URL: ${protocolURL}`); 44 | break; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /source/main/services/search.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SearchResult as CoreSearchResult, 3 | VaultSourceEntrySearch, 4 | VaultSource, 5 | VaultSourceID 6 | } from "buttercup"; 7 | import { logInfo } from "../library/log"; 8 | 9 | interface SearchCache { 10 | [sourceID: string]: VaultSourceEntrySearch; 11 | } 12 | 13 | let __primarySearch: VaultSourceEntrySearch = null; 14 | const __searchCache: SearchCache = {}; 15 | 16 | export async function searchAllVaultsByTerm(term: string): Promise> { 17 | if (!__primarySearch) return []; 18 | return __primarySearch.searchByTerm(term); 19 | } 20 | 21 | export async function searchAllVaultsByURL(url: string): Promise> { 22 | if (!__primarySearch) return []; 23 | return __primarySearch.searchByURL(url); 24 | } 25 | 26 | export async function searchSingleVault( 27 | sourceID: VaultSourceID, 28 | term: string 29 | ): Promise> { 30 | const search = __searchCache[sourceID]; 31 | if (!search) return []; 32 | return search.searchByTerm(term); 33 | } 34 | 35 | export async function updateSearchCaches(unlockedSources: Array) { 36 | const missingVaults = Object.keys(__searchCache).filter( 37 | (sourceID) => !unlockedSources.find((source) => source.id === sourceID) 38 | ); 39 | for (const missing of missingVaults) { 40 | delete __searchCache[missing]; 41 | } 42 | for (const source of unlockedSources) { 43 | __searchCache[source.id] = __searchCache[source.id] || new VaultSourceEntrySearch([source]); 44 | } 45 | __primarySearch = new VaultSourceEntrySearch(unlockedSources); 46 | await Promise.all([ 47 | __primarySearch.prepare(), 48 | ...Object.keys(__searchCache).map(async (sourceID) => { 49 | logInfo(`Update search record for vault: ${sourceID}`); 50 | await __searchCache[sourceID].prepare(); 51 | }) 52 | ]); 53 | logInfo(`Updated search records for ${unlockedSources.length} vaults`); 54 | } 55 | -------------------------------------------------------------------------------- /source/main/services/storage.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import envPaths from "env-paths"; 3 | import { FileStorage } from "../library/FileStorage"; 4 | import { VaultSourceID } from "buttercup"; 5 | 6 | interface EnvPaths { 7 | data: string; 8 | config: string; 9 | cache: string; 10 | log: string; 11 | temp: string; 12 | } 13 | 14 | let __envPaths: EnvPaths; 15 | if ("BUTTERCUP_HOME_DIR" in process.env) { 16 | __envPaths = { 17 | data: path.join(process.env.BUTTERCUP_HOME_DIR, "data"), 18 | config: path.join( 19 | process.env.BUTTERCUP_CONFIG_DIR || process.env.BUTTERCUP_HOME_DIR, 20 | "config" 21 | ), 22 | cache: path.join(process.env.BUTTERCUP_HOME_DIR, "cache"), 23 | log: path.join(process.env.BUTTERCUP_HOME_DIR, "log"), 24 | temp: path.join(process.env.BUTTERCUP_TEMP_DIR || process.env.BUTTERCUP_HOME_DIR, "temp") 25 | }; 26 | } else { 27 | const TEMP_ENV_PATHS = envPaths("Buttercup"); 28 | __envPaths = { 29 | data: TEMP_ENV_PATHS.data, 30 | config: TEMP_ENV_PATHS.config, 31 | cache: TEMP_ENV_PATHS.cache, 32 | log: TEMP_ENV_PATHS.log, 33 | temp: TEMP_ENV_PATHS.temp 34 | }; 35 | } 36 | 37 | const CONFIG_PATH = path.join(__envPaths.config, "desktop.config.json"); 38 | export const LOG_FILENAME = "buttercup-desktop.log"; 39 | export const LOG_PATH = path.join(__envPaths.log, LOG_FILENAME); 40 | export const VAULTS_BACKUP_PATH = path.join(__envPaths.data, "backup"); 41 | const VAULTS_CACHE_PATH = path.join(__envPaths.temp, "vaults-offline.cache.json"); 42 | const VAULTS_PATH = path.join(__envPaths.data, "vaults.json"); 43 | const VAULT_SETTINGS_PATH = path.join(__envPaths.config, "vault-config-SOURCEID.json"); 44 | 45 | let __configStorage: FileStorage = null, 46 | __vaultStorage: FileStorage = null, 47 | __vaultCacheStorage: FileStorage = null; 48 | 49 | export function getConfigPath(): string { 50 | return CONFIG_PATH; 51 | } 52 | 53 | export function getConfigStorage(): FileStorage { 54 | if (!__configStorage) { 55 | __configStorage = new FileStorage(CONFIG_PATH); 56 | } 57 | return __configStorage; 58 | } 59 | 60 | export function getVaultCacheStorage(): FileStorage { 61 | if (!__vaultCacheStorage) { 62 | __vaultCacheStorage = new FileStorage(VAULTS_CACHE_PATH); 63 | } 64 | return __vaultCacheStorage; 65 | } 66 | 67 | export function getVaultSettingsPath(sourceID: VaultSourceID): string { 68 | return VAULT_SETTINGS_PATH.replace("SOURCEID", sourceID); 69 | } 70 | 71 | export function getVaultSettingsStorage(sourceID: VaultSourceID): FileStorage { 72 | return new FileStorage(getVaultSettingsPath(sourceID)); 73 | } 74 | 75 | export function getVaultStorage(): FileStorage { 76 | if (!__vaultStorage) { 77 | __vaultStorage = new FileStorage(VAULTS_PATH); 78 | } 79 | return __vaultStorage; 80 | } 81 | 82 | export function getVaultStoragePath(): string { 83 | return VAULTS_PATH; 84 | } 85 | -------------------------------------------------------------------------------- /source/main/services/theme.ts: -------------------------------------------------------------------------------- 1 | import { nativeTheme } from "electron"; 2 | import { getConfigValue } from "./config"; 3 | import { logInfo } from "../library/log"; 4 | import { Preferences } from "../types"; 5 | 6 | export async function applyCurrentTheme(preferences?: Preferences) { 7 | const prefs: Preferences = preferences ? preferences : await getConfigValue("preferences"); 8 | logInfo(`Applying theme source: ${prefs.uiTheme}`); 9 | nativeTheme.themeSource = prefs.uiTheme; 10 | } 11 | -------------------------------------------------------------------------------- /source/main/symbols.ts: -------------------------------------------------------------------------------- 1 | export const API_KEY_ALGO = "ECDH"; 2 | export const API_KEY_CURVE = "P-256"; 3 | export const BROWSER_API_HOST_PORT = 12822; 4 | export const BUTTERCUP_PROTOCOL = "buttercup://"; 5 | export const PLATFORM_MACOS = "darwin"; 6 | export const SECURE_FILE_HOST_PORT = 12821; 7 | -------------------------------------------------------------------------------- /source/main/types.ts: -------------------------------------------------------------------------------- 1 | import { EntryID, VaultSourceID } from "buttercup"; 2 | 3 | export * from "../shared/types"; 4 | 5 | export enum BrowserAPIErrorType { 6 | AuthMismatch = "err/auth/mismatch", 7 | NoAPIKey = "err/key/missing", 8 | NoAuthorization = "err/auth/missing", 9 | VaultInvalidState = "err/vault/invalidState" 10 | } 11 | 12 | export interface OTP { 13 | entryID: EntryID; 14 | entryProperty: string; 15 | entryTitle: string; 16 | loginURL: string | null; 17 | otpTitle?: string; 18 | otpURL: string; 19 | sourceID: VaultSourceID; 20 | } 21 | -------------------------------------------------------------------------------- /source/renderer/actions/addVault.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { VaultSourceID } from "buttercup"; 3 | import { setBusy } from "../state/app"; 4 | import { logInfo } from "../library/log"; 5 | import { getCreateNewFilePromptEmitter, getVaultAdditionEmitter } from "../services/addVault"; 6 | import { showNewFilePrompt } from "../state/addVault"; 7 | import { handleError } from "../actions/error"; 8 | import { AddVaultPayload, DatasourceConfig } from "../types"; 9 | 10 | type NewVaultChoice = "new" | "existing" | null; 11 | 12 | export async function addNewVaultTarget( 13 | datasourceConfig: DatasourceConfig, 14 | password: string, 15 | createNew: boolean, 16 | fileNameOverride: string | null = null 17 | ): Promise { 18 | setBusy(true); 19 | const addNewVaultPromise = new Promise((resolve, reject) => { 20 | ipcRenderer.once("add-vault-config:reply", (evt, payload) => { 21 | const { ok, error, sourceID } = JSON.parse(payload) as { 22 | ok: boolean; 23 | error?: string; 24 | sourceID?: VaultSourceID; 25 | }; 26 | if (ok) return resolve(sourceID); 27 | reject(new Error(`Failed adding vault: ${error}`)); 28 | }); 29 | }); 30 | const payload: AddVaultPayload = { 31 | createNew, 32 | datasourceConfig, 33 | masterPassword: password, 34 | fileNameOverride 35 | }; 36 | logInfo(`Adding new vault: ${datasourceConfig.type}`); 37 | ipcRenderer.send("add-vault-config", JSON.stringify(payload)); 38 | try { 39 | const sourceID = await addNewVaultPromise; 40 | setBusy(false); 41 | getVaultAdditionEmitter().emit("vault-added", sourceID); 42 | return sourceID; 43 | } catch (err) { 44 | handleError(err); 45 | setBusy(false); 46 | } 47 | return null; 48 | } 49 | 50 | export async function getFileVaultParameters(): Promise<{ 51 | filename: string; 52 | createNew: boolean; 53 | } | null> { 54 | showNewFilePrompt(true); 55 | const emitter = getCreateNewFilePromptEmitter(); 56 | const choice: NewVaultChoice = await new Promise((resolve) => { 57 | const callback = (choice: NewVaultChoice) => { 58 | resolve(choice); 59 | emitter.removeListener("choice", callback); 60 | }; 61 | emitter.once("choice", callback); 62 | }); 63 | showNewFilePrompt(false); 64 | if (!choice) return null; 65 | if (choice === "new") { 66 | const filename = await ipcRenderer.invoke("get-new-vault-filename"); 67 | if (!filename) return null; 68 | return { 69 | filename, 70 | createNew: true 71 | }; 72 | } else { 73 | const filename = await ipcRenderer.invoke("get-existing-vault-filename"); 74 | if (!filename) return null; 75 | return { 76 | filename, 77 | createNew: false 78 | }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /source/renderer/actions/attachment.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { EntryID, VaultSourceID } from "buttercup"; 3 | import { fetchUpdatedFacade } from "./facade"; 4 | 5 | export async function addAttachments( 6 | sourceID: VaultSourceID, 7 | entryID: EntryID, 8 | webFiles: Array 9 | ) { 10 | for (const file of webFiles) { 11 | const buff = await file.arrayBuffer(); 12 | const uint8Arr = new Uint8Array(buff); 13 | await ipcRenderer.invoke( 14 | "attachment-add", 15 | sourceID, 16 | entryID, 17 | file.name, 18 | file.type || "application/octet-stream", 19 | uint8Arr 20 | ); 21 | } 22 | await ipcRenderer.invoke("save-source", sourceID); 23 | await fetchUpdatedFacade(sourceID); 24 | } 25 | 26 | export async function deleteAttachment( 27 | sourceID: VaultSourceID, 28 | entryID: EntryID, 29 | attachmentID: string 30 | ) { 31 | await ipcRenderer.invoke("attachment-delete", sourceID, entryID, attachmentID); 32 | await fetchUpdatedFacade(sourceID); 33 | } 34 | 35 | export async function downloadAttachment( 36 | sourceID: VaultSourceID, 37 | entryID: EntryID, 38 | attachmentID: string 39 | ): Promise { 40 | const downloaded = await ipcRenderer.invoke( 41 | "attachment-download", 42 | sourceID, 43 | entryID, 44 | attachmentID 45 | ); 46 | return downloaded; 47 | } 48 | 49 | export async function getAttachmentData( 50 | sourceID: VaultSourceID, 51 | entryID: EntryID, 52 | attachmentID: string 53 | ): Promise { 54 | const uint8Arr = await ipcRenderer.invoke( 55 | "attachment-get-data", 56 | sourceID, 57 | entryID, 58 | attachmentID 59 | ); 60 | return uint8Arr; 61 | } 62 | -------------------------------------------------------------------------------- /source/renderer/actions/autoUpdate.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | 3 | export async function toggleAutoUpdate(enabled: boolean): Promise { 4 | await ipcRenderer.invoke("toggle-auto-update", enabled); 5 | } 6 | -------------------------------------------------------------------------------- /source/renderer/actions/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | 3 | export async function copyText(text: string) { 4 | await ipcRenderer.invoke("write-clipboard", text); 5 | } 6 | 7 | export function userCopiedText(text: string) { 8 | ipcRenderer.invoke("copied-into-clipboard", text); 9 | } 10 | -------------------------------------------------------------------------------- /source/renderer/actions/dropbox.ts: -------------------------------------------------------------------------------- 1 | import { authenticate } from "../services/auth3rdParty"; 2 | import { logInfo } from "../library/log"; 3 | import { DROPBOX_CLIENT_ID } from "../../shared/symbols"; 4 | 5 | const DROPBOX_REDIRECT_URL = "https://buttercup.pw/"; 6 | 7 | export async function authDropbox(): Promise { 8 | const authUri = `https://www.dropbox.com/1/oauth2/authorize?client_id=${DROPBOX_CLIENT_ID}&redirect_uri=${DROPBOX_REDIRECT_URL}&response_type=token`; 9 | logInfo(`Authenticating Dropbox using client ID: ${DROPBOX_CLIENT_ID}`); 10 | const token = await authenticate(authUri, /access_token=([^&]*)/); 11 | logInfo("Received Dropbox auth token"); 12 | return token; 13 | } 14 | -------------------------------------------------------------------------------- /source/renderer/actions/error.ts: -------------------------------------------------------------------------------- 1 | import { showError } from "../services/notifications"; 2 | import { logErr } from "../library/log"; 3 | 4 | export function handleError(err: Error): void { 5 | logErr(err); 6 | showError(err.message); 7 | } 8 | -------------------------------------------------------------------------------- /source/renderer/actions/facade.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { VaultFacade, VaultSourceID } from "buttercup"; 3 | import { VAULTS_STATE } from "../state/vaults"; 4 | import { setCurrentFacade } from "../services/facade"; 5 | 6 | export async function fetchUpdatedFacade(sourceID: VaultSourceID) { 7 | const { attachments, facade: rawFacade } = await ipcRenderer.invoke( 8 | "get-vault-facade", 9 | sourceID 10 | ); 11 | const facade: VaultFacade = JSON.parse(rawFacade); 12 | VAULTS_STATE.currentVaultAttachments = !!attachments; 13 | VAULTS_STATE.currentVault = sourceID; 14 | if (!facade) { 15 | setCurrentFacade(null); 16 | return; 17 | } 18 | setCurrentFacade(facade); 19 | } 20 | -------------------------------------------------------------------------------- /source/renderer/actions/format.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { VaultFormatID, VaultSourceID } from "buttercup"; 3 | import { setBusy } from "../state/app"; 4 | import { showError, showWarning } from "../services/notifications"; 5 | import { t } from "../../shared/i18n/trans"; 6 | import { logErr, logInfo, logWarn } from "../library/log"; 7 | 8 | export async function convertVaultToFormat( 9 | sourceID: VaultSourceID, 10 | format: VaultFormatID 11 | ): Promise { 12 | setBusy(true); 13 | try { 14 | const converted = await ipcRenderer.invoke("convert-vault-format", sourceID, format); 15 | if (!converted) { 16 | logWarn(`Unable to convert vault forrmat: ${sourceID} (${format})`); 17 | showWarning(t("notification.error.vault-format-upgrade-failed")); 18 | return; 19 | } 20 | } catch (err) { 21 | showError( 22 | `${t("notification.error.vault-format-upgrade-failed")}: ${ 23 | err?.message ?? t("notification.error.unknown-error") 24 | }` 25 | ); 26 | logErr("Failed converting vault format:", err); 27 | return; 28 | } finally { 29 | setBusy(false); 30 | } 31 | logInfo(`Converted vault format: ${sourceID} (${format})`); 32 | } 33 | -------------------------------------------------------------------------------- /source/renderer/actions/link.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | 3 | export function openLinkExternally(link: string) { 4 | ipcRenderer.invoke("open-link", link); 5 | } 6 | -------------------------------------------------------------------------------- /source/renderer/actions/lockVault.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { VaultSourceID } from "buttercup"; 3 | import { setBusy } from "../state/app"; 4 | import { showError } from "../services/notifications"; 5 | import { logInfo } from "../library/log"; 6 | import { t } from "../../shared/i18n/trans"; 7 | 8 | export async function lockVaultSource(sourceID: VaultSourceID) { 9 | setBusy(true); 10 | logInfo(`Locking source: ${sourceID}`); 11 | const lockPromise = new Promise((resolve, reject) => { 12 | ipcRenderer.once("lock-source:reply", (evt, result) => { 13 | setBusy(false); 14 | const { ok, error } = JSON.parse(result) as { 15 | ok: boolean; 16 | error?: string; 17 | }; 18 | if (!ok) { 19 | showError( 20 | `${t("notification.error.vault-lock-failed")}: ${ 21 | error || t("notification.error.unknown-error") 22 | }` 23 | ); 24 | return reject(new Error(`Failed locking vault: ${error}`)); 25 | } 26 | resolve(); 27 | }); 28 | }); 29 | ipcRenderer.send( 30 | "lock-source", 31 | JSON.stringify({ 32 | sourceID 33 | }) 34 | ); 35 | await lockPromise; 36 | logInfo(`Locked source: ${sourceID}`); 37 | } 38 | -------------------------------------------------------------------------------- /source/renderer/actions/password.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { getPasswordEmitter } from "../services/password"; 3 | import { sourceHasBiometricAvailability } from "../services/biometrics"; 4 | import { PASSWORD_STATE } from "../state/password"; 5 | 6 | export async function getPrimaryPassword( 7 | sourceID?: VaultSourceID 8 | ): Promise<[password: string | null, biometricsEnabled: boolean, usedBiometrics: boolean]> { 9 | let biometricsEnabled: boolean = false; 10 | if (sourceID) { 11 | const supportsBiometrics = await sourceHasBiometricAvailability(sourceID); 12 | if (supportsBiometrics) { 13 | PASSWORD_STATE.passwordViaBiometricSource = sourceID; 14 | biometricsEnabled = true; 15 | } 16 | } 17 | PASSWORD_STATE.showPrompt = true; 18 | const emitter = getPasswordEmitter(); 19 | const [password, usedBiometrics] = await new Promise<[string | null, boolean]>((resolve) => { 20 | const callback = (password: string | null, usedBiometrics: boolean) => { 21 | resolve([password, usedBiometrics]); 22 | emitter.removeListener("password", callback); 23 | }; 24 | emitter.once("password", callback); 25 | }); 26 | PASSWORD_STATE.passwordViaBiometricSource = null; 27 | return [password, biometricsEnabled, usedBiometrics]; 28 | } 29 | -------------------------------------------------------------------------------- /source/renderer/actions/removeVault.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { VaultSourceID } from "buttercup"; 3 | import { VAULTS_STATE } from "../state/vaults"; 4 | 5 | export async function removeVaultSource(sourceID: VaultSourceID) { 6 | if (sourceID === VAULTS_STATE.currentVault) { 7 | VAULTS_STATE.currentVault = null; 8 | } 9 | ipcRenderer.send( 10 | "remove-source", 11 | JSON.stringify({ 12 | sourceID 13 | }) 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /source/renderer/actions/saveVault.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { Intent } from "@blueprintjs/core"; 3 | import { VaultFacade, VaultSourceID } from "buttercup"; 4 | import { Layerr } from "layerr"; 5 | import { setSaving } from "../state/app"; 6 | import { createProgressNotification } from "../services/notifications"; 7 | import { logInfo } from "../library/log"; 8 | import { t } from "../../shared/i18n/trans"; 9 | import { ICON_UPLOAD } from "../../shared/symbols"; 10 | 11 | export async function saveVaultFacade(sourceID: VaultSourceID, vaultFacade: VaultFacade) { 12 | const progNotification = createProgressNotification(ICON_UPLOAD, 100); 13 | setSaving(true); 14 | logInfo(`Saving vault facade: ${sourceID}`); 15 | try { 16 | await ipcRenderer.invoke("save-vault-facade", sourceID, vaultFacade); 17 | logInfo(`Saved vault facade: ${sourceID}`); 18 | progNotification.clear(t("notification.vault-saved"), Intent.SUCCESS); 19 | } catch (err) { 20 | progNotification.clear( 21 | `${t("notification.error.vault-save-failed")}: ${ 22 | err.message || t("notification.error.unknown-error") 23 | }`, 24 | Intent.DANGER, 25 | 10000 26 | ); 27 | throw new Layerr(err, "Failed saving vault"); 28 | } finally { 29 | setSaving(false); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /source/renderer/actions/unlockVault.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { VaultSourceID } from "buttercup"; 3 | import { getPrimaryPassword } from "./password"; 4 | import { setBusy } from "../state/app"; 5 | import { showError } from "../services/notifications"; 6 | import { logInfo } from "../library/log"; 7 | import { getVaultSettings, saveVaultSettings } from "../services/vaultSettings"; 8 | import { t } from "../../shared/i18n/trans"; 9 | 10 | export async function unlockVaultSource(sourceID: VaultSourceID): Promise { 11 | const [password, biometricsEnabled, usedBiometrics] = await getPrimaryPassword(sourceID); 12 | if (!password) return false; 13 | setBusy(true); 14 | logInfo(`Unlocking source: ${sourceID}`); 15 | try { 16 | await ipcRenderer.invoke("unlock-source", sourceID, password); 17 | } catch (err) { 18 | showError( 19 | `${t("notification.error.vault-unlock-failed")}: ${ 20 | err?.message ?? t("notification.error.unknown-error") 21 | }` 22 | ); 23 | setBusy(false); 24 | return await unlockVaultSource(sourceID); 25 | } 26 | setBusy(false); 27 | // Update config 28 | if (biometricsEnabled) { 29 | const vaultSettings = await getVaultSettings(sourceID); 30 | const { biometricForcePasswordMaxInterval, biometricForcePasswordCount } = vaultSettings; 31 | const maxPasswordCount = parseInt(biometricForcePasswordCount, 10); 32 | const maxInterval = parseInt(biometricForcePasswordMaxInterval, 10); 33 | if (!isNaN(maxPasswordCount) && maxPasswordCount > 0 && usedBiometrics) { 34 | // Max password count enabled, increment count 35 | vaultSettings.biometricUnlockCount += 1; 36 | logInfo(`biometric unlock count increased: ${vaultSettings.biometricUnlockCount}`); 37 | } else { 38 | // Not enabled, ensure 0 39 | vaultSettings.biometricUnlockCount = 0; 40 | } 41 | if (!isNaN(maxInterval) && maxInterval > 0 && usedBiometrics) { 42 | // Interval enabled, set to now 43 | if ( 44 | typeof vaultSettings.biometricLastManualUnlock === "number" && 45 | vaultSettings.biometricLastManualUnlock > 0 46 | ) { 47 | logInfo( 48 | `biometric unlock date ignored as already set: ${vaultSettings.biometricLastManualUnlock}` 49 | ); 50 | } else { 51 | vaultSettings.biometricLastManualUnlock = Date.now(); 52 | logInfo(`biometric unlock date set: ${vaultSettings.biometricLastManualUnlock}`); 53 | } 54 | } else if ( 55 | typeof vaultSettings.biometricLastManualUnlock === "number" && 56 | vaultSettings.biometricLastManualUnlock > 0 57 | ) { 58 | // Exceeded: new date 59 | vaultSettings.biometricLastManualUnlock = Date.now(); 60 | logInfo(`biometric unlock date reset: ${vaultSettings.biometricLastManualUnlock}`); 61 | } else { 62 | // Not enabled: back to null 63 | vaultSettings.biometricLastManualUnlock = null; 64 | } 65 | await saveVaultSettings(sourceID, vaultSettings); 66 | } 67 | // Return result 68 | logInfo(`Unlocked source: ${sourceID}`); 69 | return true; 70 | } 71 | -------------------------------------------------------------------------------- /source/renderer/actions/vaultOrder.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { ipcRenderer } from "electron"; 3 | 4 | export async function setVaultSourceOrder( 5 | sourceID: VaultSourceID, 6 | newOrder: number 7 | ): Promise { 8 | await ipcRenderer.invoke("set-source-order", sourceID, newOrder); 9 | } 10 | 11 | export async function setVaultSourcesOrder(newOrders: Array): Promise { 12 | await ipcRenderer.invoke("set-sources-order", newOrders); 13 | } 14 | -------------------------------------------------------------------------------- /source/renderer/actions/webdav.ts: -------------------------------------------------------------------------------- 1 | import { AuthType, createClient } from "webdav"; 2 | import { Layerr } from "layerr"; 3 | import { logInfo } from "../library/log"; 4 | 5 | export async function testWebDAV(url: string, username?: string, password?: string): Promise { 6 | const authentication = !!(username && password); 7 | const client = authentication 8 | ? createClient(url, { 9 | authType: AuthType.Auto, 10 | username, 11 | password 12 | }) 13 | : createClient(url); 14 | logInfo(`Testing WebDAV connection: ${url} (authenticated: ${authentication ? "yes" : "no"})`); 15 | try { 16 | await client.getDirectoryContents("/"); 17 | } catch (err) { 18 | throw new Layerr(err, "Failed connecting to WebDAV service"); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /source/renderer/components/AddVaultLanding.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { VaultSourceID } from "buttercup"; 4 | import styled from "styled-components"; 5 | import { Button, NonIdealState, Tag } from "@blueprintjs/core"; 6 | import { showAddVaultMenu } from "../state/addVault"; 7 | import { getVaultAdditionEmitter } from "../services/addVault"; 8 | import { t } from "../../shared/i18n/trans"; 9 | 10 | const BENCH_IMAGE = require("../../../resources/images/bench.png").default; 11 | 12 | const LockedImage = styled.img` 13 | max-height: 308px; 14 | width: auto; 15 | `; 16 | const PrimaryContainer = styled.div` 17 | width: 100%; 18 | height: 100%; 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: center; 22 | align-items: center; 23 | `; 24 | 25 | export function AddVaultLanding() { 26 | const history = useHistory(); 27 | const vaultAdditionEmitter = useMemo(getVaultAdditionEmitter, []); 28 | useEffect(() => { 29 | const cb = (sourceID: VaultSourceID) => { 30 | history.push(`/source/${sourceID}`); 31 | }; 32 | vaultAdditionEmitter.once("vault-added", cb); 33 | return () => { 34 | vaultAdditionEmitter.off("vault-added", cb); 35 | }; 36 | }, [history, vaultAdditionEmitter]); 37 | return ( 38 | 39 | 42 | } 43 | title={t("add-vault-page.title")} 44 | description={t("add-vault-page.description")} 45 | action={ 46 | showAddVaultMenu(true)} 49 | text={t("add-vault-page.cta-button")} 50 | /> 51 | } 52 | /> 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /source/renderer/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import styled from "styled-components"; 3 | import { Callout, Intent } from "@blueprintjs/core"; 4 | import { t } from "../../shared/i18n/trans"; 5 | 6 | interface ErrorBoundaryProps { 7 | children: JSX.Element; 8 | } 9 | 10 | interface ErrorBoundaryState { 11 | error: Error | null; 12 | errorStack: string | null; 13 | } 14 | 15 | const ErrorCallout = styled(Callout)` 16 | margin: 4px; 17 | box-sizing: border-box; 18 | width: calc(100% - 8px) !important; 19 | height: calc(100% - 8px) !important; 20 | `; 21 | const PreForm = styled.pre` 22 | margin: 0px; 23 | `; 24 | 25 | function stripBlanks(txt = '') { 26 | return txt 27 | .split(/(\r\n|\n)/g) 28 | .filter(ln => ln.trim().length > 0) 29 | .join('\n'); 30 | } 31 | 32 | export class ErrorBoundary extends Component { 33 | static getDerivedStateFromError(error: Error) { 34 | return { error }; 35 | } 36 | 37 | state: ErrorBoundaryState = { 38 | error: null, 39 | errorStack: null 40 | }; 41 | 42 | componentDidCatch(error: Error, errorInfo) { 43 | this.setState({ errorStack: errorInfo.componentStack || null }); 44 | } 45 | 46 | render() { 47 | if (!this.state.error) { 48 | return this.props.children; 49 | } 50 | return ( 51 | 52 | {t("error.fatal-boundary")} 53 | 54 | {this.state.error.toString()} 55 | 56 | {this.state.errorStack && ( 57 | 58 | {stripBlanks(this.state.errorStack)} 59 | 60 | )} 61 | 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /source/renderer/components/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Position, Toaster } from "@blueprintjs/core"; 3 | 4 | const { useCallback } = React; 5 | 6 | let __toasterRef: Toaster, 7 | __updateToasterRef: Toaster; 8 | 9 | export function getToaster(): Toaster { 10 | return __toasterRef; 11 | } 12 | 13 | export function getUpdateToaster(): Toaster { 14 | return __updateToasterRef; 15 | } 16 | 17 | export function Notifications() { 18 | const onRef = useCallback((toasterRef: Toaster) => { 19 | __toasterRef = toasterRef; 20 | }, []); 21 | const onUpdateRef = useCallback((toasterRef: Toaster) => { 22 | __updateToasterRef = toasterRef; 23 | }, []); 24 | return ( 25 | <> 26 | 27 | 28 | > 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /source/renderer/components/navigation/AutoNav.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { useSingleState } from "react-obstate"; 4 | import { sortVaults } from "../../library/vault"; 5 | import { logInfo } from "../../library/log"; 6 | import { VAULTS_STATE } from "../../state/vaults"; 7 | 8 | export function AutoNav() { 9 | const history = useHistory(); 10 | const [currentVault, setCurrentVault] = useSingleState(VAULTS_STATE, "currentVault"); 11 | const [vaults] = useSingleState(VAULTS_STATE, "vaultsList"); 12 | useEffect(() => { 13 | if (currentVault) { 14 | logInfo(`Auto-nav: Current vault available: ${currentVault}`); 15 | history.push(`/source/${currentVault}`); 16 | setCurrentVault(currentVault); 17 | return; 18 | } 19 | const sortedVaults = sortVaults(vaults); 20 | if (sortedVaults.length > 0) { 21 | logInfo(`Auto-nav: First vault in order: ${sortedVaults[0].id}`); 22 | history.push(`/source/${sortedVaults[0].id}`); 23 | setCurrentVault(sortedVaults[0].id); 24 | return; 25 | } 26 | logInfo("Auto-nav: No vaults, new-vault page"); 27 | history.push("/add-vault"); 28 | }, [history, currentVault, setCurrentVault, vaults]); 29 | return null; 30 | } 31 | -------------------------------------------------------------------------------- /source/renderer/components/navigation/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { Intent, Spinner } from "@blueprintjs/core"; 4 | import { useState } from "@hookstate/core"; 5 | import { BUSY } from "../../state/app"; 6 | 7 | const LoadingContainer = styled.div<{ visible: boolean }>` 8 | position: fixed; 9 | left: 0px; 10 | right: 0px; 11 | top: 0px; 12 | bottom: 0px; 13 | background-color: rgba(0, 0, 0, 0.6); 14 | z-index: 999999999; 15 | display: ${(props: any) => props.visible ? "flex" : "none"}; 16 | flex-direction: row; 17 | justify-content: center; 18 | align-items: center; 19 | `; 20 | 21 | export function LoadingScreen() { 22 | const busyState = useState(BUSY); 23 | return ( 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /source/renderer/components/prompt/ConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button, Classes, Dialog, Intent } from "@blueprintjs/core"; 3 | import { t } from "../../../shared/i18n/trans"; 4 | 5 | export interface ConfirmDialogProps { 6 | cancelText?: string; 7 | children: React.ReactNode; 8 | confirmIntent?: Intent; 9 | confirmText?: string; 10 | onClose: (confirmed: boolean) => void; 11 | open: boolean; 12 | title: string; 13 | } 14 | 15 | export function ConfirmDialog(props: ConfirmDialogProps) { 16 | const { 17 | cancelText = t("dialog.confirm-generic.cancel-button"), 18 | children, 19 | confirmIntent = Intent.PRIMARY, 20 | confirmText = t("dialog.confirm-generic.confirm-button"), 21 | onClose, 22 | open, 23 | title 24 | } = props; 25 | return ( 26 | onClose(false)}> 27 | {title} 28 | 29 | {children} 30 | 31 | 32 | 33 | onClose(true)} 36 | > 37 | {confirmText} 38 | 39 | onClose(false)} 41 | > 42 | {cancelText} 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /source/renderer/components/search/SearchContext.tsx: -------------------------------------------------------------------------------- 1 | import { EntryID, GroupID } from "buttercup"; 2 | import React, { createContext, useCallback, useState } from "react"; 3 | 4 | interface SearchContextState { 5 | resetSelection: () => void; 6 | selectedEntryID: EntryID | null; 7 | selectedGroupID: GroupID | null; 8 | setSelectedEntryID: (id: EntryID | null) => void; 9 | setSelectedGroupID: (id: GroupID | null) => void; 10 | } 11 | 12 | export const SearchContext = createContext({} as SearchContextState); 13 | 14 | export function SearchProvider(props) { 15 | const { children } = props; 16 | const [selectedGroupID, setSelectedGroupID] = useState(null); 17 | const [selectedEntryID, setSelectedEntryID] = useState(null); 18 | const resetSelection = useCallback(() => { 19 | setSelectedEntryID(null); 20 | setSelectedGroupID(null); 21 | }, []); 22 | const context: SearchContextState = { 23 | resetSelection, 24 | selectedEntryID, 25 | selectedGroupID, 26 | setSelectedEntryID, 27 | setSelectedGroupID 28 | }; 29 | return ( 30 | 31 | {children} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /source/renderer/components/search/VaultSearchManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext } from "react"; 2 | import { VaultSourceID, getEntryFacadePath } from "buttercup"; 3 | import { useState as useHookState } from "@hookstate/core"; 4 | import { SearchModal } from "./SearchModal"; 5 | import { SearchContext } from "./SearchContext"; 6 | import { updateSearchSingleVault } from "../../services/search"; 7 | import { SEARCH_RESULTS, SEARCH_VISIBLE } from "../../state/search"; 8 | import { useCurrentFacade } from "../../hooks/facade"; 9 | import { logErr } from "../../library/log"; 10 | 11 | interface SearchManagerProps { 12 | sourceID?: VaultSourceID; 13 | } 14 | 15 | export function VaultSearchManager(props: SearchManagerProps = {}) { 16 | const { sourceID = null } = props; 17 | const resultsState = useHookState(SEARCH_RESULTS); 18 | const visibleState = useHookState(SEARCH_VISIBLE); 19 | const currentFacade = useCurrentFacade(); 20 | const { 21 | setSelectedEntryID, 22 | setSelectedGroupID 23 | } = useContext(SearchContext); 24 | const handleSearchResultSelection = useCallback(res => { 25 | const entryID = res.result?.id ?? null; 26 | if (!entryID) { 27 | logErr("Search result was invalid when selected"); 28 | return; 29 | } 30 | visibleState.set(false); 31 | try { 32 | const groupPath = getEntryFacadePath(entryID, currentFacade); 33 | const groupID = groupPath[groupPath.length - 1]; 34 | setSelectedGroupID(groupID); 35 | setSelectedEntryID(entryID); 36 | } catch (err) { 37 | logErr("Failed applying search result to vault navigation", err); 38 | } 39 | }, [currentFacade]); 40 | return ( 41 | visibleState.set(false)} 43 | onSearch={term => { 44 | if (sourceID) updateSearchSingleVault(sourceID, term); 45 | }} 46 | onSelect={handleSearchResultSelection} 47 | results={resultsState.get()} 48 | visible={visibleState.get()} 49 | /> 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /source/renderer/components/standalone/BrowserAccessDialog.tsx: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import * as React from "react"; 3 | import { useSingleState } from "react-obstate"; 4 | import styled from "styled-components"; 5 | import { Layerr } from "layerr"; 6 | import { Button, Card, Classes, Dialog } from "@blueprintjs/core"; 7 | import { BROWSER_ACCESS } from "../../state/browserAccess"; 8 | import { copyText } from "../../actions/clipboard"; 9 | import { showError, showSuccess } from "../../services/notifications"; 10 | import { t } from "../../../shared/i18n/trans"; 11 | 12 | const { useCallback } = React; 13 | 14 | const CodeCard = styled(Card)` 15 | display: flex; 16 | flex-direction: row; 17 | justify-content: center; 18 | align-items: center; 19 | font-family: monospace; 20 | font-size: 40px; 21 | letter-spacing: 8px; 22 | `; 23 | 24 | export function BrowserAccessDialog() { 25 | const [code, setCode] = useSingleState(BROWSER_ACCESS, "code"); 26 | const close = useCallback(() => { 27 | setCode(null); 28 | ipcRenderer.invoke("browser-access-code-clear").catch(err => { 29 | console.error(err); 30 | const info = Layerr.info(err); 31 | showError(info?.i18n && t(info.i18n) || err.message); 32 | }); 33 | }, []); 34 | const copyCode = useCallback(async (code: string) => { 35 | await copyText(code); 36 | showSuccess(t("browser-access-dialog.code-copied")); 37 | }, []); 38 | return ( 39 | 40 | {t("browser-access-dialog.dialog.title")} 41 | 42 | {t("browser-access-dialog.dialog.instruction.new-connection")} 43 | {t("browser-access-dialog.dialog.instruction.use-code")} 44 | copyCode(code as string)}> 45 | {code} 46 | 47 | 48 | 49 | 50 | 54 | {t("browser-access-dialog.dialog.close-button")} 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /source/renderer/components/standalone/CreateNewFilePrompt.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState as useHookState } from "@hookstate/core"; 3 | import { Button, Classes, Dialog, Intent } from "@blueprintjs/core"; 4 | import { SHOW_NEW_FILE_PROMPT } from "../../state/addVault"; 5 | import { getCreateNewFilePromptEmitter } from "../../services/addVault"; 6 | import { t } from "../../../shared/i18n/trans"; 7 | 8 | const { useCallback, useMemo } = React; 9 | 10 | export function CreateNewFilePrompt() { 11 | const showPromptState = useHookState(SHOW_NEW_FILE_PROMPT); 12 | const emitter = useMemo(getCreateNewFilePromptEmitter, []); 13 | const close = useCallback(() => { 14 | showPromptState.set(false); 15 | emitter.emit("choice", null); 16 | }, []); 17 | const chooseNew = useCallback(() => { 18 | showPromptState.set(false); 19 | emitter.emit("choice", "new"); 20 | }, [emitter]); 21 | const chooseExisting = useCallback(() => { 22 | showPromptState.set(false); 23 | emitter.emit("choice", "existing"); 24 | }, [emitter]); 25 | return ( 26 | 27 | {t("dialog.new-file-prompt.title")} 28 | 29 | 30 | 31 | 32 | 33 | 38 | {t("dialog.new-file-prompt.button-new.text")} 39 | 40 | 45 | {t("dialog.new-file-prompt.button-existing.text")} 46 | 47 | 51 | {t("dialog.new-file-prompt.button-cancel.text")} 52 | 53 | 54 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /source/renderer/components/standalone/FileHostConnectionNotice.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState as useHookState } from "@hookstate/core"; 3 | import styled from "styled-components"; 4 | import { Button, Card, Classes, Dialog } from "@blueprintjs/core"; 5 | import { FILE_HOST_CODE } from "../../state/fileHost"; 6 | import { copyText } from "../../actions/clipboard"; 7 | import { showSuccess } from "../../services/notifications"; 8 | import { t } from "../../../shared/i18n/trans"; 9 | 10 | const { useCallback } = React; 11 | 12 | const CodeCard = styled(Card)` 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: center; 16 | align-items: center; 17 | font-family: monospace; 18 | font-size: 40px; 19 | letter-spacing: 8px; 20 | `; 21 | 22 | export function FileHostConnectionNotice() { 23 | const fileHostCodeState = useHookState(FILE_HOST_CODE); 24 | const close = useCallback(() => { 25 | fileHostCodeState.set(null); 26 | }, []); 27 | const copyCode = useCallback(async (code: string) => { 28 | await copyText(code); 29 | showSuccess(t("file-host.code-copied")); 30 | }, []); 31 | return ( 32 | 33 | {t("file-host.dialog.title")} 34 | 35 | {t("file-host.dialog.instruction.new-connection")} 36 | {t("file-host.dialog.instruction.use-code")} 37 | copyCode(fileHostCodeState.get())}> 38 | {fileHostCodeState.get()} 39 | 40 | 41 | 42 | 43 | 47 | {t("file-host.dialog.close-button")} 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /source/renderer/components/standalone/UpdateDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import styled from "styled-components"; 3 | import { useState as useHookState } from "@hookstate/core"; 4 | import { Button, Card, Classes, Dialog, Intent } from "@blueprintjs/core"; 5 | import { muteCurrentUpdate, startCurrentUpdate } from "../../services/update"; 6 | import { CURRENT_UPDATE, SHOW_UPDATE_DIALOG } from "../../state/update"; 7 | import { openLinkExternally } from "../../actions/link"; 8 | import { t } from "../../../shared/i18n/trans"; 9 | 10 | const ReleaseHeading = styled.h2` 11 | margin-top: 0px; 12 | `; 13 | 14 | export function UpdateDialog() { 15 | const showDialogState = useHookState(SHOW_UPDATE_DIALOG); 16 | const currentUpdateState = useHookState(CURRENT_UPDATE); 17 | const currentUpdate = currentUpdateState.get(); 18 | const version = currentUpdate ? currentUpdate.version : null; 19 | const close = useCallback(() => { 20 | showDialogState.set(false); 21 | currentUpdateState.set(null); 22 | muteCurrentUpdate(); 23 | }, []); 24 | const update = useCallback(() => { 25 | showDialogState.set(false); 26 | startCurrentUpdate(); 27 | }, []); 28 | const updateContentRef = useCallback(node => { 29 | if (!node) return; 30 | try { 31 | const anchors = [...node.getElementsByTagName("a")]; 32 | anchors.forEach(anchor => { 33 | anchor.addEventListener("click", event => { 34 | event.preventDefault(); 35 | openLinkExternally(event.target.href); 36 | }); 37 | }); 38 | } catch (err) {} 39 | }, []); 40 | return ( 41 | 42 | {t("update.dialog.title", { version })} 43 | 44 | {currentUpdate && ( 45 | 46 | )} 47 | {currentUpdate && ( 48 | 49 | 50 | 51 | )} 52 | 53 | 54 | 55 | 60 | {t("update.dialog.update-button")} 61 | 62 | 66 | {t("update.dialog.close-button")} 67 | 68 | 69 | 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /source/renderer/components/vault/AddEntry.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, ButtonGroup, Popover, Menu, MenuItem } from "@blueprintjs/core"; 3 | import { defaultType as defaultEntryType, types as entryTypes } from "./entryTypes"; 4 | import { useActions } from "./hooks/vault"; 5 | import { t } from "../../../shared/i18n/trans"; 6 | 7 | const AddEntry = ({ disabled }) => { 8 | const { onAddEntry } = useActions(); 9 | 10 | const renderMenu = ( 11 | 12 | {entryTypes.map(entryType => ( 13 | onAddEntry(entryType.type || defaultEntryType)} 19 | /> 20 | ))} 21 | 22 | ); 23 | 24 | return ( 25 | 26 | onAddEntry(defaultEntryType)} 30 | disabled={disabled} 31 | fill 32 | /> 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default AddEntry; 41 | -------------------------------------------------------------------------------- /source/renderer/components/vault/ConfirmButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Classes, Intent, Popover, Button, H5, IconName, MaybeElement } from "@blueprintjs/core"; 3 | import { t } from "../../../shared/i18n/trans"; 4 | 5 | export function ConfirmButton( 6 | { icon, danger = false, disabled = false, title, description, primaryAction, onClick }: { 7 | danger?: boolean; 8 | description: string; 9 | disabled?: boolean; 10 | icon: IconName | MaybeElement; 11 | onClick: () => void; 12 | primaryAction: string; 13 | title: string; 14 | } 15 | ) { 16 | return ( 17 | 18 | 19 | 20 | {title} 21 | {description} 22 | 23 | 24 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /source/renderer/components/vault/CreditCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import Card from "elt-react-credit-cards"; 4 | import { EntryPropertyType } from "buttercup"; 5 | 6 | import "./styles/credit-card.sass"; 7 | 8 | const Container = styled.div` 9 | padding: 20px; 10 | `; 11 | 12 | export default function CreditCard(props) { 13 | const { entry } = props; 14 | const cc: Record = {}; 15 | entry.fields.forEach(field => { 16 | if (field.propertyType !== EntryPropertyType.Property) return; 17 | if (field.property === "cvv") cc.cvv = field.value; 18 | if (field.property === "username") cc.name = field.value; 19 | if (field.property === "password") cc.number = field.value; 20 | if (field.property === "valid_from") cc.valid = field.value; 21 | if (field.property === "expiry") cc.expiry = field.value; 22 | }); 23 | return ( 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /source/renderer/components/vault/SiteIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef, useState } from "react"; 2 | import styled from "styled-components"; 3 | import { DEFAULT_ENTRY_TYPE, EntryType } from "buttercup"; 4 | import ICON_LOGIN from "../../../../resources/images/login.png"; 5 | import ICON_WEBSITE from "../../../../resources/images/website.png"; 6 | import ICON_NOTE from "../../../../resources/images/note.png"; 7 | import ICON_SSH from "../../../../resources/images/ssh.png"; 8 | import ICON_CREDITCARD from "../../../../resources/images/credit-card.png"; 9 | 10 | const DYNAMIC_STATE_FAILED = 2; 11 | const DYNAMIC_STATE_LOADED = 1; 12 | const DYNAMIC_STATE_LOADING = 0; 13 | const ICON_LOOKUP = "https://icon.buttercup.pw/icon/"; 14 | const ICON_TYPES = { 15 | [EntryType.Login]: ICON_LOGIN, 16 | [EntryType.Website]: ICON_WEBSITE, 17 | [EntryType.SSHKey]: ICON_SSH, 18 | [EntryType.Note]: ICON_NOTE, 19 | [EntryType.CreditCard]: ICON_CREDITCARD 20 | }; 21 | const NOOP = () => {}; 22 | 23 | const FallbackIcon = styled.img<{ dynamicLoading: boolean; }>` 24 | opacity: ${props => (props.dynamicLoading ? "0.5" : "1.0")}; 25 | `; 26 | const IconContainer = styled.div` 27 | position: relative; 28 | > img { 29 | position: absolute; 30 | top: 0; 31 | left: 0; 32 | } 33 | `; 34 | 35 | export function SiteIcon(props) { 36 | const { className, domain = null, type = DEFAULT_ENTRY_TYPE } = props; 37 | const imgRef = useRef(null); 38 | const [dynamicState, setDynamicState] = useState(DYNAMIC_STATE_LOADING); 39 | const onImgError = useMemo( 40 | () => () => { 41 | if (!imgRef.current) return; 42 | imgRef.current.removeEventListener("error", onImgError); 43 | imgRef.current.removeEventListener("load", onImgLoad); 44 | setDynamicState(DYNAMIC_STATE_FAILED); 45 | }, 46 | [imgRef.current] 47 | ); 48 | const onImgLoad = useMemo( 49 | () => () => { 50 | if (!imgRef.current) return; 51 | imgRef.current.removeEventListener("error", onImgError); 52 | imgRef.current.removeEventListener("load", onImgLoad); 53 | setDynamicState(DYNAMIC_STATE_LOADED); 54 | }, 55 | [imgRef.current] 56 | ); 57 | useEffect(() => { 58 | if (!domain) { 59 | setDynamicState(DYNAMIC_STATE_FAILED); 60 | return NOOP; 61 | } 62 | if (!imgRef.current) return NOOP; 63 | imgRef.current.addEventListener("error", onImgError); 64 | imgRef.current.addEventListener("load", onImgLoad); 65 | imgRef.current.setAttribute("src", `${ICON_LOOKUP}${encodeURIComponent(domain)}`); 66 | }, [imgRef.current]); 67 | return ( 68 | 69 | {(dynamicState === DYNAMIC_STATE_LOADED || dynamicState === DYNAMIC_STATE_LOADING) && ( 70 | 71 | )} 72 | {(dynamicState === DYNAMIC_STATE_FAILED || dynamicState === DYNAMIC_STATE_LOADING) && ( 73 | 77 | )} 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /source/renderer/components/vault/VaultUI.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Allotment } from "allotment"; 3 | import styled from "styled-components"; 4 | import { EntriesList } from "./EntriesList"; 5 | import { EntryDetails } from "./EntryDetails"; 6 | import { GroupsList } from "./GroupsList"; 7 | 8 | import "./styles/vault-ui.sass"; 9 | 10 | const GridWrapper = styled.div` 11 | position: relative; 12 | height: 100%; 13 | `; 14 | 15 | export const VaultUI = () => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /source/renderer/components/vault/entryTypes.ts: -------------------------------------------------------------------------------- 1 | import { IconName, MaybeElement } from "@blueprintjs/core"; 2 | import { EntryType } from "buttercup"; 3 | 4 | export const defaultType = EntryType.Login; 5 | 6 | export const types: Array<{ 7 | type: EntryType; 8 | icon: IconName | MaybeElement; 9 | default?: boolean; 10 | }> = [ 11 | { 12 | type: EntryType.Login, 13 | icon: "id-number", 14 | default: true 15 | }, 16 | { 17 | type: EntryType.Website, 18 | icon: "globe-network" 19 | }, 20 | { 21 | type: EntryType.CreditCard, 22 | icon: "credit-card" 23 | }, 24 | { 25 | type: EntryType.Note, 26 | icon: "annotation" 27 | }, 28 | { 29 | type: EntryType.SSHKey, 30 | icon: "key" 31 | } 32 | ]; 33 | -------------------------------------------------------------------------------- /source/renderer/components/vault/hooks/compare.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, EffectCallback, useEffect } from "react"; 2 | import { isVaultFacade } from "buttercup"; 3 | 4 | export function useDeepEffect(callback: EffectCallback, dependencies: DependencyList = []) { 5 | useEffect( 6 | callback, 7 | dependencies.map((dep) => (isVaultFacade(dep) ? dep._tag : dep)) 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /source/renderer/components/vault/styles/credit-card.sass: -------------------------------------------------------------------------------- 1 | $rccs-size: 200px !default 2 | $rccs-name-font-size: 13px 3 | $rccs-number-font-size: 14px 4 | $rccs-expiry-font-size: 13px 5 | 6 | @import "~elt-react-credit-cards/lib/styles.scss" 7 | -------------------------------------------------------------------------------- /source/renderer/components/vault/styles/vault-ui.sass: -------------------------------------------------------------------------------- 1 | @use 'sass:math' 2 | 3 | @import '~@fontsource/inter/index.css' 4 | 5 | @import "~normalize.css" 6 | @import "~@blueprintjs/core/lib/css/blueprint.css" 7 | @import "~@blueprintjs/icons/lib/css/blueprint-icons.css" 8 | @import "~@blueprintjs/popover2/lib/css/blueprint-popover2.css" 9 | 10 | @import "~allotment/dist/style.css" 11 | 12 | // Constants 13 | 14 | $bc-brand-colour: #00B7AC 15 | $bc-brand-colour-dark: #179E94 16 | 17 | // Blueprint overrides 18 | 19 | .bp4-input:focus, .bp4-input.bp4-active 20 | 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) 21 | 22 | a 23 | color: $bc-brand-colour-dark 24 | 25 | &:hover 26 | color: $bc-brand-colour 27 | 28 | // Splitter 29 | 30 | .theme-dark 31 | .split-pane-entries, .split-pane-entry-details 32 | background-color: #252A31 33 | 34 | .theme-light 35 | .split-pane-entries, .split-pane-entry-details 36 | background-color: #f6f7f9 37 | 38 | .split-view .sash-container .sash.sash-vertical 39 | z-index: 5 !important 40 | -------------------------------------------------------------------------------- /source/renderer/components/vault/tabs/TabAddButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import { Icon } from "@blueprintjs/core"; 3 | import styled, { useTheme } from "styled-components"; 4 | import { getThemeProp } from "../utils/theme"; 5 | 6 | export const TAB_HEIGHT = 38; 7 | 8 | const TabContainer = styled.div` 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: flex-start; 12 | align-items: center; 13 | margin-right: 4px; 14 | margin-bottom: -1px; 15 | margin-left: 4px; 16 | position: relative; 17 | `; 18 | 19 | const TabInner = styled.div` 20 | background-color: ${p => getThemeProp(p, "tab.background")}; 21 | border-radius: 8px 8px 0 0; 22 | overflow: hidden; 23 | padding: 0px 8px; 24 | border: 1px solid ${p => getThemeProp(p, "tab.border")}; 25 | height: ${TAB_HEIGHT}px; 26 | display: flex; 27 | flex-direction: row; 28 | justify-content: flex-start; 29 | align-items: center; 30 | transition: all .25s; 31 | cursor: pointer; 32 | 33 | &:hover { 34 | background-color: ${p => getThemeProp(p, "tab.backgroundSelected")}; 35 | } 36 | `; 37 | 38 | const TabContent = styled.span` 39 | transition: all .25s; 40 | text-decoration: none; 41 | user-select: none; 42 | white-space: nowrap; 43 | pointer-events: none; 44 | `; 45 | 46 | export function TabAddButton(props: { 47 | onClick: () => void; 48 | }) { 49 | const { 50 | onClick 51 | } = props; 52 | const theme = useTheme(); 53 | const handleClick = useCallback((event) => { 54 | if (event.button === 0) { 55 | event.preventDefault(); 56 | onClick(); 57 | } 58 | }, [onClick]); 59 | return ( 60 | 61 | 65 | 66 | 70 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /source/renderer/components/vault/tabs/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import styled from "styled-components"; 3 | import { TAB_HEIGHT_SELECTED, Tab } from "./Tab"; 4 | import { TabAddButton } from "./TabAddButton"; 5 | import { getThemeProp } from "../utils/theme"; 6 | import { Tab as TabItem } from "../../navigation/VaultTabs"; 7 | 8 | const TOP_PADDING = 4; 9 | 10 | const TabContainer = styled.div` 11 | padding: ${TOP_PADDING}px 4px 0px 4px; 12 | position: relative; 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: flex-start; 16 | align-items: flex-end; 17 | background-color: ${p => getThemeProp(p, "tab.barBackground")}; 18 | height: ${TAB_HEIGHT_SELECTED + TOP_PADDING}px; 19 | overflow: hidden; 20 | `; 21 | 22 | export function Tabs(props: { 23 | menu: (props: { id: string; }) => JSX.Element; 24 | onAdd: () => void; 25 | onClose: (tabID: string) => void; 26 | onReorder: (tabs: Array) => void; 27 | onSelect: (tabID: string) => void; 28 | selected: string; 29 | tabs: Array; 30 | }) { 31 | const { onAdd, onClose, onReorder, onSelect, menu, selected, tabs } = props; 32 | const [dragging, setDragging] = useState(null); 33 | const handleDraggingChange = useCallback((tabID, isDragging) => { 34 | if (isDragging) { 35 | setDragging(tabID); 36 | } else if (!isDragging && dragging === tabID) { 37 | setDragging(null); 38 | } 39 | }, [tabs, dragging]); 40 | const handleReorder = useCallback((movedID, targetID, posChange) => { 41 | const originalIndex = tabs.findIndex(t => t.id === movedID); 42 | const targetIndex = tabs.findIndex(t => t.id === targetID); 43 | const output: Array = []; 44 | for (let i = 0; i < tabs.length; i += 1) { 45 | if (i === targetIndex) { 46 | if (targetIndex === originalIndex) { 47 | output.push(tabs[originalIndex]); 48 | } else if (posChange === -1) { 49 | output.push(tabs[originalIndex], tabs[i]); 50 | } else if (posChange === 1) { 51 | output.push(tabs[i], tabs[originalIndex]); 52 | } 53 | continue; 54 | } 55 | if (i === originalIndex) continue; 56 | output.push(tabs[i]); 57 | } 58 | onReorder(output); 59 | setDragging(null); 60 | }, [tabs]); 61 | return ( 62 | 63 | 64 | {tabs.map((tab, i) => ( 65 | onClose(tab.id)} 73 | onDraggingChange={handleDraggingChange} 74 | onSelect={() => onSelect(tab.id)} 75 | onTabReorder={(tabID, posChange) => handleReorder(tabID, tab.id, posChange)} 76 | selected={tab.id === selected} 77 | tabDragging={dragging} 78 | /> 79 | ))} 80 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /source/renderer/components/vault/types.ts: -------------------------------------------------------------------------------- 1 | import { IconName, MaybeElement, TreeNodeInfo } from "@blueprintjs/core"; 2 | import { GroupFacade, GroupID } from "buttercup"; 3 | 4 | export interface GroupTreeNodeInfo extends TreeNodeInfo { 5 | id: GroupID; 6 | label: string; 7 | icon: IconName | MaybeElement; 8 | hasCaret: boolean; 9 | isSelected: boolean; 10 | isExpanded: boolean; 11 | childNodes: Array; 12 | className?: string; 13 | isTrash: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /source/renderer/components/vault/utils/domain.ts: -------------------------------------------------------------------------------- 1 | export function extractDomain(str: string): string { 2 | const domainMatch = str.match(/^https?:\/\/([^\/]+)/i); 3 | if (!domainMatch) return str; 4 | const [, domainPortion] = domainMatch; 5 | const [domain] = domainPortion.split("vault-ui.:"); 6 | return domain; 7 | } 8 | -------------------------------------------------------------------------------- /source/renderer/components/vault/utils/entries.ts: -------------------------------------------------------------------------------- 1 | import { EntryFacade, EntryPropertyType, VaultFacade, VaultFacadeEntrySearch } from "buttercup"; 2 | 3 | export const filterEntries = (entries: Array = [], term = ""): Array => { 4 | if (term === "") { 5 | return entries; 6 | } 7 | const search = new VaultFacadeEntrySearch([ 8 | { 9 | id: "-", 10 | entries 11 | } as VaultFacade 12 | ]); 13 | return search 14 | .searchByTerm(term) 15 | .map((item) => entries.find((entry) => entry.id === item.id) as EntryFacade); 16 | }; 17 | 18 | export function sortEntries(entries: Array = [], asc = true) { 19 | return entries.sort((a, b) => { 20 | const aTitleProp = a.fields.find( 21 | (f) => f.property === "title" && f.propertyType === EntryPropertyType.Property 22 | ); 23 | const bTitleProp = b.fields.find( 24 | (f) => f.property === "title" && f.propertyType === EntryPropertyType.Property 25 | ); 26 | const aTitle = aTitleProp?.value ?? ""; 27 | const bTitle = bTitleProp?.value ?? ""; 28 | if (aTitle < bTitle) { 29 | return asc ? -1 : 1; 30 | } else if (aTitle > bTitle) { 31 | return asc ? 1 : -1; 32 | } 33 | return 0; 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /source/renderer/components/vault/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import nestedProperty from "nested-property"; 2 | import { ExecutionContext } from "styled-components"; 3 | 4 | export function getThemeProp(props: unknown, propName: string): string { 5 | const res = nestedProperty.get(props, `theme.vault.${propName}`); 6 | if (res === null) { 7 | console.warn(`No theme value found for \`${propName}\`.`); 8 | return "red"; 9 | } 10 | return res; 11 | } 12 | -------------------------------------------------------------------------------- /source/renderer/components/vault/utils/ui.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EntryFacade, EntryPropertyType } from "buttercup"; 3 | 4 | const generateHighlightedText = (text, regions) => { 5 | if (!regions) return text; 6 | 7 | const content: Array = []; 8 | let nextUnhighlightedRegionStartingIndex = 0; 9 | 10 | for (let region of regions) { 11 | const [start, end] = region; 12 | content.push( 13 | text.substring(nextUnhighlightedRegionStartingIndex, start), 14 | {text.substring(start, end + 1)} 15 | ); 16 | nextUnhighlightedRegionStartingIndex = end + 1; 17 | } 18 | content.push(text.substring(nextUnhighlightedRegionStartingIndex)); 19 | 20 | return ( 21 | <> 22 | {content.map((text, i) => ( 23 | {text} 24 | ))} 25 | > 26 | ); 27 | }; 28 | 29 | export function getFacadeField(facade: EntryFacade, fieldName: string) { 30 | const fieldIndex = facade.fields.findIndex( 31 | field => field.propertyType === EntryPropertyType.Property && field.property === fieldName 32 | ); 33 | if (fieldIndex < 0) { 34 | return ""; 35 | } 36 | 37 | const field = facade.fields[fieldIndex]; 38 | let value = field.value; 39 | // if (Array.isArray(matches)) { 40 | // const match = matches.find(match => match.arrayIndex === fieldIndex); 41 | // if (match) { 42 | // return generateHighlightedText(value, match.indices); 43 | // } 44 | // } 45 | 46 | return value; 47 | } 48 | -------------------------------------------------------------------------------- /source/renderer/hooks/attachments.ts: -------------------------------------------------------------------------------- 1 | import { EntryID, VaultSourceID } from "buttercup"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { useState as useHookState } from "@hookstate/core"; 4 | import { Layerr } from "layerr"; 5 | import { 6 | addAttachments as _addAttachments, 7 | deleteAttachment as _deleteAttachment, 8 | downloadAttachment as _downloadAttachment, 9 | getAttachmentData as _getAttachmentData 10 | } from "../actions/attachment"; 11 | import { handleError } from "../actions/error"; 12 | import { showSuccess } from "../services/notifications"; 13 | import { arrayBufferToBase64 } from "../library/encoding"; 14 | import { BUSY } from "../state/app"; 15 | import { t } from "../../shared/i18n/trans"; 16 | 17 | export function useAttachments(sourceID: VaultSourceID) { 18 | const appBusyState = useHookState(BUSY); 19 | const [attachmentPreviews, setAttachmentPreviews] = useState({}); 20 | const addAttachments = useCallback( 21 | async (entryID: EntryID, files) => { 22 | appBusyState.set(true); 23 | try { 24 | await _addAttachments(sourceID, entryID, files); 25 | } catch (err) { 26 | handleError(new Layerr(err, t("error.attachment-add"))); 27 | } 28 | appBusyState.set(false); 29 | }, 30 | [sourceID, appBusyState] 31 | ); 32 | const deleteAttachment = useCallback( 33 | async (entryID: EntryID, attachmentID: string) => { 34 | appBusyState.set(true); 35 | try { 36 | await _deleteAttachment(sourceID, entryID, attachmentID); 37 | } catch (err) { 38 | handleError(new Layerr(err, t("error.attachment-delete"))); 39 | } 40 | appBusyState.set(false); 41 | }, 42 | [attachmentPreviews, sourceID, appBusyState] 43 | ); 44 | const downloadAttachment = useCallback( 45 | async (entryID: EntryID, attachmentID: string) => { 46 | appBusyState.set(true); 47 | try { 48 | const didDownload = await _downloadAttachment(sourceID, entryID, attachmentID); 49 | if (didDownload) showSuccess(t("notification.attachment-downloaded")); 50 | } catch (err) { 51 | handleError(new Layerr(err, t("error.attachment-download"))); 52 | } 53 | appBusyState.set(false); 54 | }, 55 | [sourceID, appBusyState] 56 | ); 57 | const previewAttachment = useCallback( 58 | async (entryID: EntryID, attachmentID: string) => { 59 | if (attachmentPreviews[attachmentID]) return; 60 | try { 61 | const data: Uint8Array = await _getAttachmentData(sourceID, entryID, attachmentID); 62 | setAttachmentPreviews({ 63 | ...attachmentPreviews, 64 | [attachmentID]: arrayBufferToBase64(data) 65 | }); 66 | } catch (err) { 67 | handleError(new Layerr(err, t("error.attachment-preview"))); 68 | } 69 | }, 70 | [attachmentPreviews, sourceID] 71 | ); 72 | useEffect(() => { 73 | setAttachmentPreviews({}); 74 | }, [sourceID]); 75 | return { 76 | addAttachments, 77 | attachmentPreviews, 78 | deleteAttachment, 79 | downloadAttachment, 80 | previewAttachment 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /source/renderer/hooks/facade.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { VaultFacade } from "buttercup"; 3 | import { getCurrentFacade, getFacadeEmitter } from "../services/facade"; 4 | 5 | const { useEffect, useState } = React; 6 | 7 | export function useCurrentFacade(): VaultFacade { 8 | const emitter = getFacadeEmitter(); 9 | const [facade, setFacade] = useState(null); 10 | useEffect(() => { 11 | const cb = () => { 12 | setFacade(getCurrentFacade()); 13 | }; 14 | emitter.on("facadeUpdated", cb); 15 | return () => { 16 | emitter.off("facadeUpdated", cb); 17 | }; 18 | }, [emitter]); 19 | return facade; 20 | } 21 | -------------------------------------------------------------------------------- /source/renderer/hooks/theme.ts: -------------------------------------------------------------------------------- 1 | import { nativeTheme } from "@electron/remote"; 2 | import * as React from "react"; 3 | import { getThemeType } from "../library/theme"; 4 | import { Theme } from "../types"; 5 | 6 | const { useEffect, useState } = React; 7 | 8 | export function useTheme(): Theme { 9 | const [theme, setTheme] = useState(getThemeType()); 10 | useEffect(() => { 11 | const callback = () => { 12 | setTheme(getThemeType()); 13 | }; 14 | nativeTheme.on("updated", callback); 15 | return () => { 16 | nativeTheme.off("updated", callback); 17 | }; 18 | }, []); 19 | return theme; 20 | } 21 | -------------------------------------------------------------------------------- /source/renderer/hooks/vault.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { ipcRenderer } from "electron"; 4 | import { logErr } from "../library/log"; 5 | import { showError } from "../services/notifications"; 6 | import { VaultSourceDescription } from "../types"; 7 | 8 | export function useSourceDetails( 9 | sourceID: VaultSourceID | null 10 | ): [VaultSourceDescription | null, () => void] { 11 | const [details, setDetails] = useState(null); 12 | const updateDescription = useCallback(() => { 13 | if (sourceID === null) { 14 | setDetails(null); 15 | return; 16 | } 17 | ipcRenderer 18 | .invoke("get-vault-description", sourceID) 19 | .then((desc: VaultSourceDescription) => { 20 | setDetails(desc); 21 | }) 22 | .catch((err) => { 23 | logErr(err); 24 | showError(`Failed fetching vault details: ${err.message}`); 25 | }); 26 | }, [sourceID]); 27 | useEffect(() => { 28 | if (!sourceID) { 29 | setDetails(null); 30 | return; 31 | } 32 | updateDescription(); 33 | }, [sourceID]); 34 | return [details, updateDescription]; 35 | } 36 | -------------------------------------------------------------------------------- /source/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import "./ipc"; 5 | import { initialise } from "./services/init"; 6 | import { App } from "./App"; 7 | import { logErr, logInfo } from "./library/log"; 8 | 9 | import "../../resources/styles.sass"; 10 | 11 | (async function() { 12 | const root = document.getElementById("root"); 13 | assert(!!root, "No root element found"); 14 | await initialise(root); 15 | logInfo("Rendering application"); 16 | ReactDOM.render(, root); 17 | })().catch(err => { 18 | logErr("Failed initialising", err); 19 | }); 20 | -------------------------------------------------------------------------------- /source/renderer/library/clipboard.ts: -------------------------------------------------------------------------------- 1 | export function copyToClipboard(str: string): void { 2 | const el = document.createElement("textarea"); 3 | el.value = str; 4 | document.body.appendChild(el); 5 | el.select(); 6 | document.execCommand("copy"); 7 | document.body.removeChild(el); 8 | } 9 | -------------------------------------------------------------------------------- /source/renderer/library/encoding.ts: -------------------------------------------------------------------------------- 1 | export function arrayBufferToBase64(buffer: Uint8Array): string { 2 | let binary = ""; 3 | const len = buffer.byteLength; 4 | for (let i = 0; i < len; i++) { 5 | binary = `${binary}${String.fromCharCode(buffer[i])}`; 6 | } 7 | return window.btoa(binary); 8 | } 9 | -------------------------------------------------------------------------------- /source/renderer/library/entryType.ts: -------------------------------------------------------------------------------- 1 | export function extractSSHPubKeyName(pubKey: string): string { 2 | const parts = pubKey.trim().split(" "); 3 | return parts[parts.length - 1]; 4 | } 5 | -------------------------------------------------------------------------------- /source/renderer/library/fsInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DropboxInterface, 3 | FileSystemInterface, 4 | GoogleDriveInterface, 5 | WebDAVInterface 6 | } from "@buttercup/file-interface"; 7 | import { GoogleDriveClient } from "@buttercup/googledrive-client"; 8 | import { DropboxClient } from "@buttercup/dropbox-client"; 9 | import { AuthType, createClient as createWebdavClient } from "webdav"; 10 | import { SourceType } from "../types"; 11 | 12 | export interface FSInstanceSettings { 13 | endpoint?: string; 14 | password?: string; 15 | token?: string; 16 | username?: string; 17 | } 18 | 19 | export function getFSInstance(type: SourceType, settings: FSInstanceSettings): FileSystemInterface { 20 | switch (type) { 21 | case SourceType.Dropbox: 22 | return new DropboxInterface({ 23 | dropboxClient: new DropboxClient(settings.token as string) 24 | }); 25 | case SourceType.WebDAV: 26 | return new WebDAVInterface({ 27 | webdavClient: createWebdavClient(settings.endpoint as string, { 28 | authType: AuthType.Auto, 29 | username: settings.username, 30 | password: settings.password 31 | }) 32 | }); 33 | case SourceType.GoogleDrive: { 34 | return new GoogleDriveInterface({ 35 | googleDriveClient: new GoogleDriveClient(settings.token as string) 36 | }); 37 | } 38 | default: 39 | throw new Error(`Unsupported interface: ${type}`); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /source/renderer/library/icons.ts: -------------------------------------------------------------------------------- 1 | import { SourceType } from "../types"; 2 | 3 | const ICON_BUTTERCUP = require("../../../resources/images/buttercup-file-256.png").default; 4 | const ICON_DROPBOX = require("../../../resources/images/dropbox-256.png").default; 5 | const ICON_GOOGLEDRIVE = require("../../../resources/images/googledrive-256.png").default; 6 | const ICON_WEBDAV = require("../../../resources/images/webdav-256.png").default; 7 | 8 | const ICON_ERROR = require("../../../resources/icons/error.png").default; 9 | 10 | export function getIconForProvider(provider: SourceType): string { 11 | switch (provider) { 12 | case SourceType.File: 13 | return ICON_BUTTERCUP; 14 | case SourceType.Dropbox: 15 | return ICON_DROPBOX; 16 | case SourceType.GoogleDrive: 17 | return ICON_GOOGLEDRIVE; 18 | case SourceType.WebDAV: 19 | return ICON_WEBDAV; 20 | default: 21 | return ICON_ERROR; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /source/renderer/library/log.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { serialiseLogItems } from "../../shared/library/log"; 3 | import { LogLevel } from "../types"; 4 | 5 | export function logErr(...items: Array) { 6 | ipcRenderer.send( 7 | "log", 8 | JSON.stringify({ 9 | level: LogLevel.Error, 10 | log: serialiseLogItems(["(front-end)", ...items]) 11 | }) 12 | ); 13 | } 14 | 15 | export function logInfo(...items: Array) { 16 | ipcRenderer.send( 17 | "log", 18 | JSON.stringify({ 19 | level: LogLevel.Info, 20 | log: serialiseLogItems(["(front-end)", ...items]) 21 | }) 22 | ); 23 | } 24 | 25 | export function logWarn(...items: Array) { 26 | ipcRenderer.send( 27 | "log", 28 | JSON.stringify({ 29 | level: LogLevel.Warning, 30 | log: serialiseLogItems(["(front-end)", ...items]) 31 | }) 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /source/renderer/library/theme.ts: -------------------------------------------------------------------------------- 1 | import { nativeTheme } from "@electron/remote"; 2 | import { logInfo } from "./log"; 3 | import { Theme } from "../types"; 4 | 5 | let __listening = false; 6 | 7 | export function attachUpdatedListener() { 8 | if (__listening) return; 9 | __listening = true; 10 | nativeTheme.on("updated", () => { 11 | const theme = getThemeType(); 12 | logInfo(`Theme source updated: ${nativeTheme.themeSource}`); 13 | updateBodyTheme(theme); 14 | }); 15 | } 16 | 17 | export function getThemeType(): Theme { 18 | return nativeTheme.shouldUseDarkColors ? Theme.Dark : Theme.Light; 19 | } 20 | 21 | export function updateBodyTheme(theme: Theme) { 22 | logInfo(`Setting body theme: ${theme}`); 23 | if (theme === Theme.Dark) { 24 | window.document.body.classList.add("bp4-dark"); 25 | } else { 26 | window.document.body.classList.remove("bp4-dark"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /source/renderer/library/trim.ts: -------------------------------------------------------------------------------- 1 | export function trimWithEllipses(str: string, len: number): string { 2 | if (str.length <= len) return str; 3 | return `${str.substring(0, len)}…`; 4 | } 5 | -------------------------------------------------------------------------------- /source/renderer/library/ui.ts: -------------------------------------------------------------------------------- 1 | export function selectElementContents(el: HTMLElement): void { 2 | const range = document.createRange(); 3 | const sel = window.getSelection(); 4 | range.selectNodeContents(el); 5 | if (sel) { 6 | sel.removeAllRanges(); 7 | sel.addRange(range); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /source/renderer/library/vault.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceDescription } from "../types"; 2 | 3 | export function sortVaults(vaults: Array): Array { 4 | return [...vaults].sort((a, b) => { 5 | if (a.order > b.order) { 6 | return 1; 7 | } else if (b.order > a.order) { 8 | return -1; 9 | } 10 | return 0; 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /source/renderer/library/version.ts: -------------------------------------------------------------------------------- 1 | declare var __CORE_VERSION__: string; 2 | declare var __VERSION__: string; 3 | 4 | export const CORE_VERSION = __CORE_VERSION__; 5 | export const VERSION = __VERSION__; 6 | -------------------------------------------------------------------------------- /source/renderer/services/addVault.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | 3 | let __createNewFileEmitter: EventEmitter = null, 4 | __vaultAdditionEmitter: EventEmitter = null; 5 | 6 | export function getCreateNewFilePromptEmitter(): EventEmitter { 7 | if (!__createNewFileEmitter) { 8 | __createNewFileEmitter = new EventEmitter(); 9 | } 10 | return __createNewFileEmitter; 11 | } 12 | 13 | export function getVaultAdditionEmitter(): EventEmitter { 14 | if (!__vaultAdditionEmitter) { 15 | __vaultAdditionEmitter = new EventEmitter(); 16 | } 17 | return __vaultAdditionEmitter; 18 | } 19 | -------------------------------------------------------------------------------- /source/renderer/services/appEnvironment.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { AppEnvironmentFlags } from "../types"; 3 | 4 | export async function getAppEnvironmentFlags() { 5 | const payload: AppEnvironmentFlags = await ipcRenderer.invoke("get-app-environment"); 6 | return payload; 7 | } 8 | -------------------------------------------------------------------------------- /source/renderer/services/auth3rdParty.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from "@electron/remote"; 2 | import { logInfo } from "../library/log"; 3 | 4 | export async function authenticate(authURL: string, matchRegex: RegExp): Promise { 5 | const currentWindow = BrowserWindow.getFocusedWindow(); 6 | logInfo(`Starting 3rd party authentication procedure: ${authURL}`); 7 | return new Promise((resolve) => { 8 | let foundToken = null; 9 | const authWin = new BrowserWindow({ 10 | parent: currentWindow, 11 | show: false, 12 | alwaysOnTop: true, 13 | webPreferences: { 14 | nodeIntegration: false, 15 | webSecurity: false, 16 | sandbox: true 17 | } 18 | }); 19 | 20 | authWin.loadURL(authURL); 21 | authWin.show(); 22 | 23 | const navigateCB = (url: string) => { 24 | const match = url.match(matchRegex); 25 | if (match !== null && match.length > 0) { 26 | foundToken = match[1]; 27 | authWin.close(); 28 | } 29 | }; 30 | const closeCB = () => { 31 | if (foundToken) { 32 | logInfo("Completing 3rd party authentication with token"); 33 | return resolve(foundToken); 34 | } 35 | logInfo("Completing 3rd party authentication without token"); 36 | resolve(null); 37 | }; 38 | 39 | authWin.webContents.on("did-start-navigation", (e, url) => navigateCB(url)); 40 | authWin.webContents.on("will-redirect", (e, url) => navigateCB(url)); 41 | authWin.on("close", closeCB); 42 | authWin.on("closed", closeCB); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /source/renderer/services/biometrics.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { VaultSourceID } from "buttercup"; 3 | import { setVaultsWithBiometrics } from "../state/biometrics"; 4 | import { VaultSourceDescription } from "../types"; 5 | 6 | export async function getBiometricSourcePassword(sourceID: VaultSourceID): Promise { 7 | return ipcRenderer.invoke("get-biometric-source-password", sourceID); 8 | } 9 | 10 | export async function registerBiometricUnlock( 11 | sourceID: VaultSourceID, 12 | password: string 13 | ): Promise { 14 | await ipcRenderer.invoke("register-biometric-unlock", sourceID, password); 15 | } 16 | 17 | export async function sourceHasBiometricAvailability(sourceID: VaultSourceID): Promise { 18 | return ipcRenderer.invoke("check-source-biometrics", sourceID); 19 | } 20 | 21 | export async function updateVaultsBiometricsStates( 22 | sources: Array 23 | ): Promise { 24 | const results: Array = []; 25 | await Promise.all( 26 | sources.map(async (source) => { 27 | const hasBio = await sourceHasBiometricAvailability(source.id); 28 | if (hasBio) { 29 | results.push(source.id); 30 | } 31 | }) 32 | ); 33 | setVaultsWithBiometrics(results); 34 | } 35 | -------------------------------------------------------------------------------- /source/renderer/services/config.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { VaultSourceID } from "buttercup"; 3 | 4 | export async function getSelectedSource(): Promise { 5 | const sourceID = await ipcRenderer.invoke("get-selected-source"); 6 | return sourceID; 7 | } 8 | 9 | export async function setSelectedSource(sourceID: VaultSourceID): Promise { 10 | await ipcRenderer.invoke("set-selected-source", sourceID); 11 | } 12 | -------------------------------------------------------------------------------- /source/renderer/services/facade.ts: -------------------------------------------------------------------------------- 1 | import { VaultFacade } from "buttercup"; 2 | import EventEmitter from "eventemitter3"; 3 | 4 | let __currentFacade: VaultFacade | null = null, 5 | __emitter: EventEmitter | null = null; 6 | 7 | export function getCurrentFacade(): VaultFacade | null { 8 | return __currentFacade; 9 | } 10 | 11 | export function getFacadeEmitter(): EventEmitter { 12 | if (!__emitter) { 13 | __emitter = new EventEmitter(); 14 | } 15 | return __emitter; 16 | } 17 | 18 | export function setCurrentFacade(facade: VaultFacade | null) { 19 | __currentFacade = facade; 20 | getFacadeEmitter().emit("facadeUpdated"); 21 | } 22 | -------------------------------------------------------------------------------- /source/renderer/services/googleDrive.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { GoogleDriveClient } from "@buttercup/googledrive-client"; 3 | 4 | export async function createEmptyVault( 5 | accessToken: string, 6 | parentIdentifier: string, 7 | filename: string, 8 | password: string 9 | ): Promise { 10 | const getVaultSourcePromise = new Promise((resolve, reject) => { 11 | ipcRenderer.once("get-empty-vault:reply", (evt, vaultSrc) => { 12 | resolve(vaultSrc); 13 | }); 14 | }); 15 | ipcRenderer.send( 16 | "get-empty-vault", 17 | JSON.stringify({ 18 | password 19 | }) 20 | ); 21 | const vaultSrc = await getVaultSourcePromise; 22 | const client = new GoogleDriveClient(accessToken); 23 | const fileID = await client.putFileContents(vaultSrc, null, filename, parentIdentifier); 24 | return fileID; 25 | } 26 | -------------------------------------------------------------------------------- /source/renderer/services/i18n.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import LANGUAGES from "../../shared/i18n/translations/index"; 3 | import { Language } from "../types"; 4 | 5 | export async function getAvailableLanguages(): Promise> { 6 | return Object.keys(LANGUAGES).reduce( 7 | (output, slug) => [ 8 | ...output, 9 | { 10 | name: LANGUAGES[slug]._ || `Unknown (${slug})`, 11 | slug 12 | } 13 | ], 14 | [] 15 | ); 16 | } 17 | 18 | export async function getOSLocale(): Promise { 19 | const locale = await ipcRenderer.invoke("get-locale"); 20 | return locale; 21 | } 22 | -------------------------------------------------------------------------------- /source/renderer/services/init.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { init } from "buttercup"; 3 | import { logInfo } from "../library/log"; 4 | import { attachUpdatedListener, getThemeType, updateBodyTheme } from "../library/theme"; 5 | import { 6 | changeLanguage, 7 | initialise as initialiseI18n, 8 | onLanguageChanged 9 | } from "../../shared/i18n/trans"; 10 | import { getLanguage } from "../../shared/library/i18n"; 11 | import { getOSLocale } from "./i18n"; 12 | import { getPreferences } from "./preferences"; 13 | import { applyCurrentUpdateState, applyReadyUpdateState } from "./update"; 14 | import { initialisePresence } from "./presence"; 15 | // import { changeLanguage as changeUiLangage } from "@buttercup/ui"; 16 | 17 | let __lastInit: Promise | null = null; 18 | 19 | export async function initialise(rootElement: HTMLElement) { 20 | if (__lastInit) return __lastInit; 21 | __lastInit = initialiseInternal(rootElement); 22 | return __lastInit; 23 | } 24 | 25 | async function initialiseInternal(rootElement: HTMLElement) { 26 | logInfo("Initialising Buttercup core"); 27 | init(); 28 | const preferences = await getPreferences(); 29 | const locale = await getOSLocale(); 30 | const language = getLanguage(preferences, locale); 31 | logInfo(`Starting with language: ${language}`); 32 | await initialiseI18n(language); 33 | // await changeUiLangage(language); 34 | onLanguageChanged((newLang) => { 35 | logInfo(`Language updated: ${newLang}`); 36 | changeLanguage(newLang); 37 | // changeUiLangage(language); 38 | }); 39 | ipcRenderer.send("update-vault-windows"); 40 | logInfo("Window opened and initialised"); 41 | attachUpdatedListener(); 42 | updateBodyTheme(getThemeType()); 43 | await applyCurrentUpdateState(); 44 | await applyReadyUpdateState(); 45 | initialisePresence(rootElement); 46 | } 47 | -------------------------------------------------------------------------------- /source/renderer/services/password.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | 3 | let __emitter: EventEmitter = null; 4 | 5 | export function getPasswordEmitter(): EventEmitter { 6 | if (!__emitter) { 7 | __emitter = new EventEmitter(); 8 | } 9 | return __emitter; 10 | } 11 | -------------------------------------------------------------------------------- /source/renderer/services/preferences.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { Preferences } from "../types"; 3 | 4 | export async function getPreferences(): Promise { 5 | const getPreferencesPromise = new Promise((resolve) => { 6 | ipcRenderer.once("get-preferences:reply", (evt, payload) => { 7 | resolve(JSON.parse(payload)); 8 | }); 9 | }); 10 | ipcRenderer.send("get-preferences"); 11 | const prefs = await getPreferencesPromise; 12 | return prefs; 13 | } 14 | 15 | export async function savePreferences(preferences: Preferences): Promise { 16 | ipcRenderer.send( 17 | "write-preferences", 18 | JSON.stringify({ 19 | preferences 20 | }) 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /source/renderer/services/presence.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import debounce from "debounce"; 3 | 4 | function handleActivity() { 5 | ipcRenderer.invoke("trigger-user-presence"); 6 | } 7 | 8 | export function initialisePresence(rootElement: HTMLElement) { 9 | const handler = debounce(handleActivity, 250); 10 | rootElement.addEventListener("mousemove", handler); 11 | } 12 | -------------------------------------------------------------------------------- /source/renderer/services/search.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import debounce from "debounce"; 3 | import { VaultSourceID } from "buttercup"; 4 | import { setSearchResults } from "../state/search"; 5 | import { SearchResult } from "../types"; 6 | 7 | const __deb_searchSingleVault = debounce(__searchSingleVault, 200, /* immediate: */ false); 8 | 9 | async function __searchSingleVault(sourceID: VaultSourceID, term: string) { 10 | const results: Array = await ipcRenderer.invoke( 11 | "search-single-vault", 12 | sourceID, 13 | term 14 | ); 15 | setSearchResults(results); 16 | } 17 | 18 | export function updateSearchSingleVault(sourceID: VaultSourceID, term: string): void { 19 | __deb_searchSingleVault(sourceID, term); 20 | } 21 | -------------------------------------------------------------------------------- /source/renderer/services/update.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { UpdateInfo } from "electron-updater"; 3 | import { Intent } from "@blueprintjs/core"; 4 | import { setCurrentUpdate, setShowUpdateDialog } from "../state/update"; 5 | import { 6 | ProgressNotification, 7 | createProgressNotification, 8 | showUpdateAvailable, 9 | showUpdateDownloaded 10 | } from "./notifications"; 11 | import { logInfo } from "../library/log"; 12 | import { t } from "../../shared/i18n/trans"; 13 | import { UpdateProgressInfo } from "../types"; 14 | 15 | let __updateProgress: ProgressNotification = null; 16 | 17 | export async function applyCurrentUpdateState(infoOverride?: UpdateInfo): Promise { 18 | const updateInfo: UpdateInfo = infoOverride || (await ipcRenderer.invoke("get-current-update")); 19 | if (updateInfo) { 20 | setCurrentUpdate(updateInfo); 21 | showUpdateAvailable( 22 | updateInfo.version, 23 | () => { 24 | logInfo(`Opening update info: ${updateInfo.version}`); 25 | setShowUpdateDialog(true); 26 | muteCurrentUpdate(); 27 | }, 28 | () => { 29 | logInfo("Update notification closed"); 30 | setCurrentUpdate(null); 31 | muteCurrentUpdate(); 32 | } 33 | ); 34 | } 35 | } 36 | 37 | export async function applyReadyUpdateState(infoOverride?: UpdateInfo) { 38 | const updateInfo: UpdateInfo = infoOverride || (await ipcRenderer.invoke("get-ready-update")); 39 | if (updateInfo) { 40 | setCurrentUpdate(updateInfo); 41 | showUpdateDownloaded( 42 | updateInfo.version, 43 | () => { 44 | logInfo(`Installing update: ${updateInfo.version}`); 45 | ipcRenderer.invoke("install-update"); 46 | }, 47 | () => { 48 | logInfo("Update complete notification closed"); 49 | } 50 | ); 51 | } 52 | } 53 | 54 | export function applyUpdateProgress(progress: UpdateProgressInfo) { 55 | if (progress === null) { 56 | if (__updateProgress) { 57 | __updateProgress.clear(t("update.downloaded"), Intent.SUCCESS); 58 | } 59 | __updateProgress = null; 60 | return; 61 | } 62 | if (__updateProgress) { 63 | __updateProgress.setProgress(progress.percent); 64 | } else { 65 | __updateProgress = createProgressNotification("cloud-download", progress.percent); 66 | } 67 | } 68 | 69 | export async function muteCurrentUpdate(): Promise { 70 | await ipcRenderer.invoke("mute-current-update"); 71 | } 72 | 73 | export async function startCurrentUpdate(): Promise { 74 | await ipcRenderer.invoke("start-current-update"); 75 | } 76 | -------------------------------------------------------------------------------- /source/renderer/services/vaultSettings.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { ipcRenderer } from "electron"; 3 | import { VaultSettingsLocal } from "../types"; 4 | 5 | export async function getVaultSettings(sourceID: VaultSourceID): Promise { 6 | const settings = await ipcRenderer.invoke("get-vault-settings", sourceID); 7 | return settings || null; 8 | } 9 | 10 | export async function saveVaultSettings( 11 | sourceID: VaultSourceID, 12 | settings: VaultSettingsLocal 13 | ): Promise { 14 | await ipcRenderer.invoke("set-vault-settings", sourceID, settings); 15 | } 16 | -------------------------------------------------------------------------------- /source/renderer/state/about.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | 3 | export const SHOW_ABOUT: State = createState(false as boolean); 4 | 5 | export function showAbout(show = true) { 6 | SHOW_ABOUT.set(show); 7 | } 8 | -------------------------------------------------------------------------------- /source/renderer/state/addVault.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | 3 | export const SHOW_ADD_VAULT: State = createState(false as boolean); 4 | export const SHOW_NEW_FILE_PROMPT: State = createState(false as boolean); 5 | 6 | export function showAddVaultMenu(show = true) { 7 | SHOW_ADD_VAULT.set(show); 8 | } 9 | 10 | export function showNewFilePrompt(show = true) { 11 | SHOW_NEW_FILE_PROMPT.set(show); 12 | } 13 | -------------------------------------------------------------------------------- /source/renderer/state/app.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | 3 | export const BUSY: State = createState(false as boolean); 4 | export const SAVING: State = createState(false as boolean); 5 | 6 | export function setBusy(isBusy: boolean) { 7 | BUSY.set(isBusy); 8 | } 9 | 10 | export function setSaving(isSaving: boolean) { 11 | SAVING.set(isSaving); 12 | } 13 | -------------------------------------------------------------------------------- /source/renderer/state/biometrics.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | import { VaultSourceID } from "buttercup"; 3 | 4 | export const SHOW_REGISTER_PROMPT: State = createState(false as boolean); 5 | export const VAULTS_WITH_BIOMETRICS: State> = createState( 6 | [] as Array 7 | ); 8 | 9 | export function setVaultsWithBiometrics(sourceIDs: Array) { 10 | VAULTS_WITH_BIOMETRICS.set(sourceIDs); 11 | } 12 | 13 | export function showRegistrationPrompt(show = true) { 14 | SHOW_REGISTER_PROMPT.set(show); 15 | } 16 | -------------------------------------------------------------------------------- /source/renderer/state/browserAccess.ts: -------------------------------------------------------------------------------- 1 | import { createStateObject } from "obstate"; 2 | 3 | export const BROWSER_ACCESS = createStateObject<{ 4 | code: string | null; 5 | }>({ 6 | code: null 7 | }); 8 | 9 | export function setBrowserAccessCode(code: string | null) { 10 | BROWSER_ACCESS.code = code; 11 | } 12 | -------------------------------------------------------------------------------- /source/renderer/state/fileHost.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | 3 | export const FILE_HOST_CODE: State = createState(null); 4 | 5 | export function setFileHostCode(code: string | null) { 6 | FILE_HOST_CODE.set(code); 7 | } 8 | -------------------------------------------------------------------------------- /source/renderer/state/google.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | import { VaultSourceID } from "buttercup"; 3 | 4 | export const GOOGLE_REAUTH_SOURCE: State = createState(null as VaultSourceID); 5 | 6 | export function setGoogleReAuthSource(sourceID: VaultSourceID) { 7 | GOOGLE_REAUTH_SOURCE.set(sourceID); 8 | } 9 | -------------------------------------------------------------------------------- /source/renderer/state/password.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { createStateObject } from "obstate"; 3 | 4 | export const PASSWORD_STATE = createStateObject<{ 5 | passwordViaBiometricSource: VaultSourceID | null; 6 | showPrompt: boolean; 7 | }>({ 8 | passwordViaBiometricSource: null, 9 | showPrompt: false 10 | }); 11 | -------------------------------------------------------------------------------- /source/renderer/state/preferences.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | 3 | export const SHOW_PREFERENCES: State = createState(false as boolean); 4 | 5 | export function showPreferences(show = true) { 6 | SHOW_PREFERENCES.set(show); 7 | } 8 | -------------------------------------------------------------------------------- /source/renderer/state/search.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | import { SearchResult } from "../types"; 3 | 4 | export const SEARCH_RESULTS: State> = createState([] as Array); 5 | export const SEARCH_VISIBLE: State = createState(false as boolean); 6 | 7 | export function setSearchResults(results: Array) { 8 | SEARCH_RESULTS.set([...results]); 9 | } 10 | 11 | export function setSearchVisible(visible: boolean) { 12 | SEARCH_VISIBLE.set(visible); 13 | } 14 | -------------------------------------------------------------------------------- /source/renderer/state/update.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | import { UpdateInfo } from "electron-updater"; 3 | 4 | export const CURRENT_UPDATE: State = createState(null as UpdateInfo); 5 | export const SHOW_UPDATE_DIALOG: State = createState(false as boolean); 6 | 7 | export function setCurrentUpdate(update: UpdateInfo) { 8 | CURRENT_UPDATE.set(update); 9 | } 10 | 11 | export function setShowUpdateDialog(show: boolean) { 12 | SHOW_UPDATE_DIALOG.set(show); 13 | } 14 | -------------------------------------------------------------------------------- /source/renderer/state/vaultSettings.ts: -------------------------------------------------------------------------------- 1 | import { State, createState } from "@hookstate/core"; 2 | import { VaultSourceID } from "buttercup"; 3 | 4 | export const SHOW_VAULT_SETTINGS: State = createState(""); 5 | 6 | export function showVaultSettingsForSource(sourceID: VaultSourceID) { 7 | SHOW_VAULT_SETTINGS.set(sourceID); 8 | } 9 | -------------------------------------------------------------------------------- /source/renderer/state/vaults.ts: -------------------------------------------------------------------------------- 1 | import { VaultSourceID } from "buttercup"; 2 | import { createStateObject } from "obstate"; 3 | import { VaultSourceDescription } from "../types"; 4 | 5 | export const VAULTS_STATE = createStateObject<{ 6 | currentVault: VaultSourceID | null; 7 | currentVaultAttachments: boolean; 8 | vaultsList: Array; 9 | }>({ 10 | currentVault: null, 11 | currentVaultAttachments: false, 12 | vaultsList: [] 13 | }); 14 | -------------------------------------------------------------------------------- /source/renderer/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import nested from "nested-property"; 2 | 3 | export function getThemeProp(props: Object, prop: string): string { 4 | const fullPath = `theme.${prop}`; 5 | const value = nested.get(props, fullPath); 6 | return typeof value !== "string" ? "" : value; 7 | } 8 | -------------------------------------------------------------------------------- /source/renderer/typedefs/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png" { 2 | const value: any; 3 | export = value; 4 | } 5 | -------------------------------------------------------------------------------- /source/renderer/types.ts: -------------------------------------------------------------------------------- 1 | export * from "../shared/types"; 2 | 3 | export enum EntriesSortMode { 4 | AlphaASC = "az", 5 | AlphaDESC = "za", 6 | Filter = "filter" 7 | } 8 | 9 | export interface NewVaultPlaceholder { 10 | filename: string; 11 | parentIdentifier: string | number | null; 12 | } 13 | 14 | export enum Theme { 15 | Dark = "dark", 16 | Light = "light" 17 | } 18 | -------------------------------------------------------------------------------- /source/shared/i18n/trans.ts: -------------------------------------------------------------------------------- 1 | import i18next, { TOptions } from "i18next"; 2 | import translations from "./translations/index"; 3 | import { DEFAULT_LANGUAGE } from "../symbols"; 4 | 5 | let __lastLanguage: string | null = null; 6 | 7 | export async function changeLanguage(lang: string) { 8 | await i18next.changeLanguage(lang); 9 | } 10 | 11 | export async function initialise(lang: string) { 12 | __lastLanguage = lang; 13 | await i18next.init({ 14 | lng: lang, 15 | fallbackLng: DEFAULT_LANGUAGE, 16 | debug: false, 17 | resources: Object.keys(translations).reduce( 18 | (output, lang) => ({ 19 | ...output, 20 | [lang]: { 21 | translation: translations[lang] 22 | } 23 | }), 24 | {} 25 | ) 26 | }); 27 | } 28 | 29 | export function onLanguageChanged(callback: (lang: string) => void): () => void { 30 | const cb = (lang: string) => { 31 | if (__lastLanguage === lang) return; 32 | __lastLanguage = lang; 33 | callback(lang); 34 | }; 35 | i18next.on("languageChanged", cb); 36 | return () => { 37 | i18next.off("languageChanged", cb); 38 | }; 39 | } 40 | 41 | export function t(key: string, options?: TOptions) { 42 | return i18next.t(key, options); 43 | } 44 | -------------------------------------------------------------------------------- /source/shared/i18n/translations/index.ts: -------------------------------------------------------------------------------- 1 | import en from "./en.json"; 2 | import ca_es from "./ca_es.json"; 3 | import de from "./de.json"; 4 | import es from "./es.json"; 5 | import fi from "./fi.json"; 6 | import fr from "./fr.json"; 7 | import gl from "./gl.json"; 8 | import it from "./it.json"; 9 | import ja from "./ja.json"; 10 | import nl from "./nl.json"; 11 | import pl from "./pl.json"; 12 | import pt_br from "./pt-br.json"; 13 | import ro from "./ro.json"; 14 | import ru from "./ru.json"; 15 | import se from "./se.json"; 16 | import zh_cn from "./zh_cn.json"; 17 | import zh_tw from "./zh_tw.json"; 18 | 19 | export default { 20 | en, // Keep as first item 21 | // All others sorted alphabetically: 22 | ca_es, 23 | de, 24 | es, 25 | fi, 26 | fr, 27 | gl, 28 | it, 29 | ja, 30 | nl, 31 | pl, 32 | pt_br, 33 | ro, 34 | ru, 35 | se, 36 | zh_cn, 37 | zh_tw 38 | }; 39 | -------------------------------------------------------------------------------- /source/shared/library/clone.ts: -------------------------------------------------------------------------------- 1 | export function naiveClone(obj: T): T { 2 | const clone = { ...obj }; 3 | for (const key in clone) { 4 | if (Array.isArray(clone[key])) { 5 | clone[key] = clone[key].map((value) => { 6 | if (typeof value === "object" && value) { 7 | return naiveClone(value); 8 | } 9 | return value; 10 | }); 11 | } else if (typeof clone[key] === "object" && clone[key]) { 12 | clone[key] = naiveClone(clone[key]); 13 | } 14 | } 15 | return clone; 16 | } 17 | -------------------------------------------------------------------------------- /source/shared/library/i18n.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_LANGUAGE } from "../symbols"; 2 | import { Preferences } from "../types"; 3 | 4 | export function getLanguage(preferences: Preferences, locale: string): string { 5 | return preferences.language || locale || DEFAULT_LANGUAGE; 6 | } 7 | -------------------------------------------------------------------------------- /source/shared/library/log.ts: -------------------------------------------------------------------------------- 1 | import StackTracey from "stacktracey"; 2 | 3 | export function serialiseLogItems(items: Array): string { 4 | return items 5 | .reduce((output: string, next: any) => { 6 | let value: string = ""; 7 | if (["number", "boolean", "string"].indexOf(typeof next) >= 0) { 8 | value = `${next}`; 9 | } else if (next instanceof Date) { 10 | value = next.toISOString(); 11 | } else if (next instanceof Error) { 12 | value = `${next.toString()}\n${new StackTracey(next).withSources().asTable()}`; 13 | } else if (typeof next === "undefined" || next === null) { 14 | // Do nothing 15 | } else { 16 | value = JSON.stringify(next); 17 | } 18 | return `${output} ${value}`; 19 | }, "") 20 | .trim(); 21 | } 22 | -------------------------------------------------------------------------------- /source/shared/library/platform.ts: -------------------------------------------------------------------------------- 1 | export function isOSX() { 2 | return process.platform === "darwin"; 3 | } 4 | 5 | export function isWindows() { 6 | return process.platform === "win32"; 7 | } 8 | 9 | export function isLinux() { 10 | return process.platform === "linux"; 11 | } 12 | -------------------------------------------------------------------------------- /source/shared/library/promise.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number): Promise { 2 | return new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve(); 5 | }, ms); 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /source/shared/symbols.ts: -------------------------------------------------------------------------------- 1 | import { AppStartMode, Preferences, ThemeSource, VaultSettingsLocal } from "./types"; 2 | 3 | export const COLOURS = { 4 | DARK_PRIMARY: "#292C33", 5 | DARK_SECONDARY: "#31353D", 6 | BRAND_PRIMARY: "#00B7AC", 7 | BRAND_PRIMARY_DARKER: "#179E94", 8 | GRAY_LIGHT: "#F5F7FA", 9 | GRAY_LIGHT_DARKER: "#E4E9F2", 10 | GRAY_DARK: "#777", 11 | GRAY: "#E4E9F2", 12 | RED: "#EB5767", 13 | RED_DARKER: "#E84054", 14 | BLACK_5: "rgba(0,0,0,.05)", 15 | BLACK_10: "rgba(0,0,0,.10)", 16 | BLACK_20: "rgba(0,0,0,.20)", 17 | BLACK_25: "rgba(0,0,0,.25)", 18 | BLACK_35: "rgba(0,0,0,.35)", 19 | WHITE_50: "rgba(255,255,255,.50)", 20 | LEVEL_4: "#5CAB7D", 21 | LEVEL_3: "#8FBC94", 22 | LEVEL_2: "#FFBC42", 23 | LEVEL_1: "#E71D36", 24 | LEVEL_0: "#E71D36" 25 | }; 26 | 27 | export const APP_ID = "pw.buttercup.desktop"; 28 | export const ATTACHMENTS_MAX_SIZE = 20 * 1024 * 1024; 29 | export const DEFAULT_LANGUAGE = "en"; 30 | export const DROPBOX_CLIENT_ID = "5fstmwjaisrt06t"; 31 | export const GOOGLE_AUTH_REDIRECT = "https://buttercup.pw?googledesktopauth"; 32 | export const GOOGLE_AUTH_TIMEOUT = 5 * 60 * 1000; 33 | const GOOGLE_DRIVE_BASE_SCOPES = ["email", "profile"]; 34 | export const GOOGLE_DRIVE_SCOPES_STANDARD = [ 35 | ...GOOGLE_DRIVE_BASE_SCOPES, 36 | "https://www.googleapis.com/auth/drive.file" // Per-file access 37 | ]; 38 | export const GOOGLE_CLIENT_ID = 39 | "327941947801-fumr4be9juk0bu3ekfuq9fr5bm7trh30.apps.googleusercontent.com"; 40 | export const GOOGLE_CLIENT_SECRET = "2zCBNDSXp1yIu5dyE5BVUWQZ"; 41 | export const ICON_UPLOAD = "upload"; 42 | 43 | export const PREFERENCES_DEFAULT: Preferences = { 44 | autoClearClipboard: false, // seconds 45 | fileHostEnabled: false, 46 | language: null, 47 | lockVaultsAfterTime: false, // seconds 48 | lockVaultsOnWindowClose: false, 49 | prereleaseUpdates: false, 50 | startMode: AppStartMode.None, 51 | startWithSession: false, 52 | uiTheme: ThemeSource.System 53 | }; 54 | 55 | export const VAULT_SETTINGS_DEFAULT: VaultSettingsLocal = { 56 | localBackup: false, 57 | localBackupLocation: null, 58 | biometricForcePasswordMaxInterval: "", 59 | biometricForcePasswordCount: "", 60 | biometricLastManualUnlock: +Infinity, 61 | biometricUnlockCount: 0 62 | }; 63 | -------------------------------------------------------------------------------- /source/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SearchResult as CoreSearchResult, 3 | EntryFacade, 4 | VaultFormatID, 5 | VaultSourceID, 6 | VaultSourceStatus 7 | } from "buttercup"; 8 | 9 | export interface AddVaultPayload { 10 | createNew: boolean; 11 | datasourceConfig: DatasourceConfig; 12 | masterPassword: string; 13 | fileNameOverride?: string; 14 | } 15 | 16 | export interface AppEnvironmentFlags { 17 | portable: boolean; 18 | } 19 | 20 | export enum AppStartMode { 21 | HiddenOnBoot = "hiddenOnBoot", 22 | HiddenAlways = "hiddenAlways", 23 | None = "none" 24 | } 25 | 26 | export interface Config { 27 | browserClients: Record< 28 | string, 29 | { 30 | publicKey: string; 31 | } 32 | >; 33 | browserPrivateKey: string | null; 34 | browserPublicKey: string | null; 35 | fileHostKey: null | string; 36 | isMaximised: boolean; 37 | preferences: Preferences; 38 | selectedSource: null | string; 39 | windowHeight: number; 40 | windowWidth: number; 41 | windowX: null | number; 42 | windowY: null | number; 43 | } 44 | 45 | export interface DatasourceConfig { 46 | type: SourceType | null; 47 | [key: string]: string | null; 48 | } 49 | 50 | export interface Language { 51 | name: string; 52 | slug: string | null; 53 | } 54 | 55 | export enum LogLevel { 56 | Error = "error", 57 | Info = "info", 58 | Warning = "warning" 59 | } 60 | 61 | export interface Preferences { 62 | autoClearClipboard: false | number; 63 | fileHostEnabled: boolean; 64 | language: null | string; 65 | lockVaultsAfterTime: false | number; 66 | lockVaultsOnWindowClose: boolean; 67 | prereleaseUpdates: boolean; 68 | startMode: AppStartMode; 69 | startWithSession: boolean; 70 | uiTheme: ThemeSource; 71 | } 72 | 73 | export interface SearchResult { 74 | type: "entry"; 75 | result: CoreSearchResult; 76 | } 77 | 78 | export enum SourceType { 79 | Dropbox = "dropbox", 80 | File = "file", 81 | GoogleDrive = "googledrive", 82 | WebDAV = "webdav" 83 | } 84 | 85 | export enum ThemeSource { 86 | System = "system", 87 | Dark = "dark", 88 | Light = "light" 89 | } 90 | 91 | export interface UpdatedEntryFacade extends EntryFacade { 92 | isNew?: boolean; 93 | } 94 | 95 | export interface UpdateProgressInfo { 96 | bytesPerSecond: number; 97 | percent: number; 98 | total: number; 99 | transferred: number; 100 | } 101 | 102 | export interface VaultSettingsLocal { 103 | biometricForcePasswordCount: string; 104 | biometricForcePasswordMaxInterval: string; 105 | biometricLastManualUnlock: number | null; 106 | biometricUnlockCount: number; 107 | localBackup: boolean; 108 | localBackupLocation: null | string; 109 | } 110 | 111 | export interface VaultSourceDescription { 112 | id: VaultSourceID; 113 | name: string; 114 | state: VaultSourceStatus; 115 | type: SourceType; 116 | order: number; 117 | format?: VaultFormatID; 118 | } 119 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["es5", "es6", "dom", "DOM.Iterable"], 5 | "module": "commonjs", 6 | "outDir": "./build", 7 | "resolveJsonModule": true, 8 | "skipLibCheck": true, 9 | "strict": false, 10 | "strictNullChecks": false, 11 | "target": "es6", 12 | "types": ["node", "jest"] 13 | }, 14 | "include": [ 15 | "./source/main/**/*", 16 | "./source/shared/**/*" 17 | ], 18 | "exclude": [ 19 | "build", 20 | "dist", 21 | "node_modules" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "lib": ["es5", "es6", "dom", "DOM.Iterable"], 7 | "module": "commonjs", 8 | "outDir": "./build/renderer", 9 | "resolveJsonModule": true, 10 | "strict": false, 11 | "strictNullChecks": false, 12 | "target": "es6", 13 | "types": ["react", "react-dom", "styled-components"] 14 | }, 15 | "include": [ 16 | "./source/renderer/**/*", 17 | "./source/shared/**/*" 18 | ], 19 | "exclude":[ 20 | "build", 21 | "dist", 22 | "node_modules" 23 | ] 24 | } 25 | --------------------------------------------------------------------------------
{t("error.fatal-boundary")}
54 | {this.state.error.toString()} 55 |
58 | {stripBlanks(this.state.errorStack)} 59 |
{t("browser-access-dialog.dialog.instruction.new-connection")}
{t("browser-access-dialog.dialog.instruction.use-code")}
{t("file-host.dialog.instruction.new-connection")}
{t("file-host.dialog.instruction.use-code")}
{description}