├── .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 | 39 | 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 | 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 | 40 | 47 | 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 | 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 | 62 | 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 |