Migrate to native Node.js file access and improve UI

This commit is contained in:
proteriax 2021-11-08 02:59:58 -05:00
parent 06e29eaba1
commit eb27e81d68
21 changed files with 472 additions and 156 deletions

View File

@ -8,7 +8,7 @@ const args = process.argv.slice(2)
build({
bundle: true,
define: {},
entryPoints: ["./src/electron/index.ts"],
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
outdir: "./dist/main",
external: builtinModules.concat("electron"),
target: ["chrome90"],

View File

@ -11,7 +11,7 @@
<title>OPVault Viewer</title>
</head>
<body>
<div id="root"></div>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,40 +1,26 @@
import { useCallback, useState } from "react"
import type { Vault } from "opvault.js"
import { OnePassword } from "opvault.js"
import { getBrowserAdapter } from "opvault.js/src/adapters/browser"
import type { Vault, OnePassword } from "opvault.js"
import { VaultView } from "./pages/Vault"
import { PickOPVault } from "./pages/PickOPVault"
import { Unlock } from "./pages/Unlock"
import { VaultPicker } from "./pages/VaultPicker"
export const App: React.FC = () => {
const [instance, setInstance] = useState<OnePassword>()
const [vault, setVault] = useState<Vault>()
const unlock = useCallback(
async (profile: string, password: string) => {
const vault = await instance!.getProfile(profile!)
await vault.unlock(password)
setVault(vault)
},
[instance]
)
const setHandle = useCallback(async (handle: FileSystemDirectoryHandle) => {
const adapter = getBrowserAdapter(handle)
const instance = new OnePassword({ path: "/", adapter })
setInstance(instance)
}, [])
const onLock = useCallback(() => {
vault?.lock()
setVault(undefined)
}, [vault])
if (!instance) {
return <PickOPVault setHandle={setHandle} />
}
if (!vault) {
return <Unlock instance={instance} onUnlock={unlock} />
return (
<VaultPicker
instance={instance}
setInstance={setInstance}
vault={vault}
setVault={setVault}
/>
)
}
return (

View File

@ -1,6 +1,7 @@
import styled from "@emotion/styled"
import type { Attachment, AttachmentMetadata, Item } from "opvault.js"
import type { Attachment, AttachmentMetadata, Item, ItemField } from "opvault.js"
import { useEffect, useState } from "react"
import { useTranslate } from "../i18n"
import { CategoryIcon } from "./CategoryIcon"
import { ItemDates } from "./ItemDates"
import {
@ -13,6 +14,7 @@ import { ItemWarning } from "./ItemWarning"
interface ItemViewProps {
item: Item
className?: string
}
const Header = styled.div`
@ -55,8 +57,32 @@ const AttachmentContainer = styled.div`
margin: 5px 0;
`
export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
<Container>
const FieldsView: React.FC<{ fields?: ItemField[] }> = ({ fields }) =>
fields?.length ? (
<div style={{ marginBottom: 20 }}>
{fields.map((field, i) => (
<ItemDetailsFieldView key={i} field={field} />
))}
</div>
) : null
const TagsView: React.FC<{ tags?: string[] }> = ({ tags }) => {
const t = useTranslate()
if (!tags?.length) return null
return (
<ExtraField>
<FieldTitle>{t.noun_tags}</FieldTitle>
<div>
{tags.map((tag, i) => (
<Tag key={i}>{tag}</Tag>
))}
</div>
</ExtraField>
)
}
export const ItemView: React.FC<ItemViewProps> = ({ className, item }) => (
<Container className={className}>
<Inner>
<ItemWarning item={item} />
<Header>
@ -84,13 +110,7 @@ export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
))}
</div>
{!!item.details.fields?.length && (
<div style={{ marginBottom: 20 }}>
{item.details.fields!.map((field, i) => (
<ItemDetailsFieldView key={i} field={field} />
))}
</div>
)}
<FieldsView fields={item.details.fields} />
{item.details.notesPlain != null && (
<ExtraField>
@ -101,16 +121,7 @@ export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
</ExtraField>
)}
{!!item.overview.tags?.length && (
<ExtraField>
<FieldTitle>tags</FieldTitle>
<div>
{item.overview.tags!.map((tag, i) => (
<Tag key={i}>{tag}</Tag>
))}
</div>
</ExtraField>
)}
<TagsView tags={item.overview.tags} />
{item.attachments.length > 0 && (
<ExtraField>

View File

@ -62,7 +62,7 @@ export const ItemList: React.FC<ListProps> = ({ items, onSelect, selected }) =>
<Icon fill="#FFF" category={item.category} />
<div>
<ItemTitle>{item.overview.title!}</ItemTitle>
<ItemDescription>{item.overview.ainfo}</ItemDescription>
<ItemDescription>{item.overview.ainfo || " "}</ItemDescription>
</div>
</ItemView>
))}

View File

@ -1,22 +0,0 @@
import { useCallback } from "react"
import { useTranslate } from "../i18n"
export const VaultPicker: React.FC<{
setHandle(handle: FileSystemDirectoryHandle): void
}> = ({ setHandle }) => {
const t = useTranslate()
const onClick = useCallback(async () => {
try {
const handle = await showDirectoryPicker()
setHandle(handle)
} catch (e) {
if ((e as Error).name === "AbortError") {
return
}
alert(e)
}
}, [setHandle])
return <button onClick={onClick}>{t.label_choose_a_vault}</button>
}

View File

@ -2,6 +2,7 @@
// Modules to control application life and create native browser window
import { join } from "path"
import { app, BrowserWindow, Menu } from "electron"
import "./ipc"
function createWindow() {
// Create the browser window.
@ -13,7 +14,7 @@ function createWindow() {
icon: join(__dirname, "../512x512.png"),
webPreferences: {
contextIsolation: true,
// preload: join(__dirname, "preload.js"),
preload: join(__dirname, "preload.js"),
},
})

View File

@ -0,0 +1,9 @@
export interface IPC {
showDirectoryPicker(): Promise<string | undefined>
pathExists(path: string): Promise<boolean>
readdir(path: string): Promise<string[]>
readBuffer(path: string): Promise<Uint8Array>
readFile(path: string): Promise<string>
writeFile(path: string, data: string): Promise<void>
isDirectory(path: string): Promise<boolean>
}

View File

@ -0,0 +1,53 @@
import fs, { promises } from "fs"
import { ipcMain, dialog } from "electron"
import type { IPC } from "./ipc-types"
registerService({
async showDirectoryPicker() {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", "treatPackageAsDirectory"],
})
if (result.canceled || !result.filePaths.length) return
return result.filePaths[0]
},
async pathExists(_, path) {
return fs.existsSync(path)
},
async readBuffer(_, path) {
return promises.readFile(path)
},
async readFile(_, path) {
return promises.readFile(path, "utf-8")
},
async writeFile(_, path, content) {
await promises.writeFile(path, content)
},
async readdir(_, path) {
return promises.readdir(path)
},
async isDirectory(_, path) {
const stats = await promises.stat(path)
return stats.isDirectory()
},
})
/**
* Listens to `channel`, when a new message arrives `listener` would be called
* with `listener(event, ...args)`
*/
function registerService(listeners: {
[K in keyof IPC]: (
event: Electron.IpcMainEvent,
...args: Parameters<IPC[K]>
) => ReturnType<IPC[K]>
}) {
for (const [key, value] of Object.entries(listeners)) {
ipcMain.handle(`service-${key}`, value as any)
}
}

View File

@ -1,4 +1,5 @@
import { contextBridge } from "electron"
import { nodeAdapter } from "opvault.js/src/adapters/node"
import { contextBridge, ipcRenderer } from "electron"
contextBridge.exposeInMainWorld("nodeAdapter", nodeAdapter)
contextBridge.exposeInMainWorld("ipcRenderer", {
invoke: ipcRenderer.invoke,
})

View File

@ -1,7 +1,7 @@
# /* spellchecker: disable */
label_choose_a_vault:
en: Pick a vault here.
fr: Choisir un coffre ici.
en: Pick a vault
fr: Choisir un coffre
label_no_vault_selected:
en: No vault is selected.
@ -15,10 +15,18 @@ label_created_at:
en: Created At
fr: Créé
label_password_placeholder:
en: Master Password
fr: Mot de passe principal
noun_vault:
en: vault
fr: coffre
noun_tags:
en: tags
fr: mots-clés
action_lock:
en: Lock
fr: Vérouiller
@ -26,3 +34,7 @@ action_lock:
action_unlock:
en: Unlock
fr: Déverouiller
action_go_back:
en: Back
fr: Revenir

View File

@ -12,34 +12,48 @@ body {
font-size: 15px;
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
:root {
--page-background: #fff;
--color: #000;
--titlebar-height: 46px;
--titlebar-height: 0px;
--label-background: #ddd;
--selected-background: #c9c9c9;
--hover-background: #ddd;
--border-color: #ddd;
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
@media (prefers-color-scheme: light) {
:root.mac {
--page-background: #f7f7f7;
}
}
html,
body,
#root {
#app {
height: 100%;
}
#app {
background-color: var(--page-background);
position: relative;
}
@media (prefers-color-scheme: dark) {
body {
color: #fff;
:root {
--color: #fff;
--label-background: #353535;
--selected-background: #353535;
--selected-background: #15539e;
--border-color: #333;
--hover-background: #222;
--page-background: #292929;
}
#root {
background-color: #292929;
body {
color: #fff;
}
}
@ -96,18 +110,3 @@ button[type="submit"] {
select {
padding: 5px 10px;
}
::-webkit-scrollbar {
width: 7px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
@include scheme(background, #8883, #6663);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
transition: 0.1s;
@include scheme(background, #ddd, #555);
}

View File

@ -4,6 +4,10 @@ import { App } from "./App"
import { LocaleContextProvider } from "./i18n"
import "./index.scss"
if (navigator.platform === "MacIntel") {
document.documentElement.classList.add("mac")
}
render(
<React.StrictMode>
{/* <TitleBar /> */}
@ -11,5 +15,5 @@ render(
<App />
</LocaleContextProvider>
</React.StrictMode>,
document.getElementById("root")
document.getElementById("app")
)

View File

@ -1,23 +0,0 @@
import styled from "@emotion/styled"
import { VaultPicker } from "../components/VaultPicker"
import { useTranslate } from "../i18n"
const Container = styled.div`
padding: 100px;
text-align: center;
`
const Info = styled.div`
margin: 10px;
`
export const PickOPVault: React.FC<{
setHandle(handle: FileSystemDirectoryHandle): void
}> = ({ setHandle }) => {
const t = useTranslate()
return (
<Container>
<VaultPicker setHandle={setHandle} />
<Info>{t.label_no_vault_selected}</Info>
</Container>
)
}

View File

@ -1,25 +1,80 @@
import type { OnePassword } from "opvault.js"
import styled from "@emotion/styled"
import { useCallback, useEffect, useState } from "react"
import React, { useCallback, useEffect, useState } from "react"
import { IoMdArrowRoundBack } from "react-icons/io"
import { FaUnlock } from "react-icons/fa"
import { useTranslate } from "../i18n"
const Container = styled.form`
const Container = styled.div`
padding: 20px;
transform: translate(-50%, -50%);
position: absolute;
top: 50%;
left: 50%;
width: 500px;
`
const BackButton = styled.button`
&& {
background: transparent;
}
border: none;
box-shadow: none;
font-size: 2em;
cursor: pointer;
padding: 0;
&:hover {
svg path {
fill: var(--selected-background);
transition: 0.2s;
}
}
`
const Input = styled.input`
box-shadow: inset 0 2px 2px rgb(0 0 0 / 8%);
font-size: 1.5em;
width: calc(95.5% - 60px);
&& {
border-radius: 10px;
border-width: 1px;
padding: 15px 20px;
padding-right: 60px;
}
`
const Select = styled.select`
float: right;
`
const Submit = styled.button`
font-size: 1.8em;
&& {
background: transparent;
border: none;
box-shadow: none;
}
svg path {
fill: var(--color);
}
&:hover {
transition: 0.2s;
}
text-align: center;
position: absolute;
top: 8px;
right: 5px;
`
export const Unlock: React.FC<{
instance: OnePassword
onUnlock(profile: string, password: string): void
}> = ({ onUnlock, instance }) => {
onReturn(): void
}> = ({ onUnlock, onReturn, instance }) => {
const t = useTranslate()
const [profiles, setProfiles] = useState<string[]>(() => [])
const [profile, setProfile] = useState<string>()
const [password, setPassword] = useState("")
const unlock = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
(e?: React.FormEvent) => {
e?.preventDefault()
if (!profile) return
onUnlock(profile, password)
@ -28,6 +83,15 @@ export const Unlock: React.FC<{
[onUnlock, profile, password]
)
const onKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
unlock()
}
},
[unlock]
)
useEffect(() => {
instance.getProfileNames().then(profiles => {
setProfiles(profiles)
@ -36,26 +100,40 @@ export const Unlock: React.FC<{
}, [instance])
return (
<Container onSubmit={unlock}>
<div>
<select value={profile} onChange={e => setProfile(e.currentTarget.value)}>
<Container>
<div style={{ marginBottom: 10 }}>
<BackButton onClick={onReturn} title={t.action_go_back}>
<IoMdArrowRoundBack />
</BackButton>
<Select
title={t.noun_vault}
value={profile}
onChange={e => setProfile(e.currentTarget.value)}
>
{profiles.map(p => (
<option key={p} value={p}>
{t.noun_vault}: {p}
</option>
))}
</select>
</Select>
</div>
<div style={{ margin: "10px 0" }}>
<input
<div style={{ margin: "10px 0", position: "relative" }}>
<Input
type="password"
value={password}
onChange={e => setPassword(e.currentTarget.value)}
placeholder={t.label_password_placeholder}
onKeyUp={onKeyUp}
/>
<Submit
type="submit"
disabled={!profile || !password}
onClick={unlock}
title={t.action_unlock}
>
<FaUnlock />
</Submit>
</div>
<button type="submit" disabled={!profile || !password}>
{t.action_unlock}
</button>
</Container>
)
}

View File

@ -1,24 +1,28 @@
import styled from "@emotion/styled"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import type { Vault, Item } from "opvault.js"
import { Category } from "opvault.js"
import { FiLock } from "react-icons/fi"
import { IoSearch } from "react-icons/io5"
import { ItemList } from "../components/ItemList"
import { ItemView } from "../components/Item"
import { reactIconClass } from "../components/CategoryIcon"
import { useTranslate } from "../i18n/index"
import { scrollbar } from "../styles"
const Container = styled.div`
display: flex;
height: calc(100vh - var(--titlebar-height));
`
const ListContainer = styled.div`
border-right: 1px solid var(--border-color);
width: 300px;
margin-right: 10px;
overflow-y: scroll;
overflow-x: hidden;
@media (prefers-color-scheme: dark) {
background: #202020;
border-right-color: transparent;
}
`
const ItemContainer = styled.div`
@ -27,15 +31,20 @@ const ItemContainer = styled.div`
`
const SearchContainer = styled.div`
text-align: center;
margin: 10px 0;
position: relative;
flex-grow: 1;
`
const SortContainer = styled.div`
margin: 10px 10px;
`
const LockButton = styled.button`
&& {
padding: 5px 10px;
}
`
const SearchInput = styled.input`
--margin: 10px;
width: calc(100% - var(--margin) * 2);
width: calc(100% - var(--margin) * 2 + 9px);
margin: 0 var(--margin);
padding-left: 2em !important;
`
@ -66,9 +75,9 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
case SortBy.Name:
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
case SortBy.CreatedAt:
return (a, b) => a.createdAt - b.createdAt
return (a, b) => b.createdAt - a.createdAt
case SortBy.UpdatedAt:
return (a, b) => a.updatedAt - b.updatedAt
return (a, b) => b.updatedAt - a.updatedAt
}
}, [sortBy])
@ -95,22 +104,21 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
return (
<Container>
<ListContainer>
<div
style={{
margin: "10px 10px",
}}
>
<button onClick={onLock}>{t.action_lock}</button>
<ListContainer className={scrollbar}>
<div style={{ margin: "10px 10px", display: "flex" }}>
<LockButton onClick={onLock} title={t.action_lock}>
<FiLock />
</LockButton>
<SearchContainer>
<SearchInput
type="search"
value={search}
onChange={e => setSearch(e.currentTarget.value)}
/>
<SearchIcon className={reactIconClass} />
</SearchContainer>
</div>
<SearchContainer>
<SearchInput
type="search"
value={search}
onChange={e => setSearch(e.currentTarget.value)}
/>
<SearchIcon className={reactIconClass} />
</SearchContainer>
<SortContainer>
<select
style={{ width: "100%" }}
@ -124,7 +132,9 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
</SortContainer>
<ItemList items={filtered} onSelect={setItem} selected={item} />
</ListContainer>
<ItemContainer>{item && <ItemView item={item} />}</ItemContainer>
<ItemContainer>
{item && <ItemView className={scrollbar} item={item} />}
</ItemContainer>
</Container>
)
}

View File

@ -0,0 +1,94 @@
import styled from "@emotion/styled"
import { useCallback, useEffect, useState } from "react"
import type { Vault } from "opvault.js"
import { OnePassword } from "opvault.js"
import { Unlock } from "./Unlock"
import { electronAdapter, openDirectory } from "../utils/ipc-adapter"
import { get, remove, set, Key } from "../utils/localStorage"
import { useTranslate } from "../i18n"
interface VaultPickerProps {
instance: OnePassword | undefined
setInstance(value?: OnePassword): void
vault: Vault | undefined
setVault(vault?: Vault): void
}
export const VaultPicker: React.FC<VaultPickerProps> = ({
instance,
setInstance,
vault,
setVault,
}) => {
const [vaultPath, setVaultPath] = useState("")
const unlock = useCallback(
async (profile: string, password: string) => {
const vault = await instance!.getProfile(profile!)
await vault.unlock(password)
setVault(vault)
},
[instance]
)
const clearInstance = useCallback(() => {
setVaultPath("")
setInstance(undefined)
}, [])
useEffect(() => {
const existingPath = get(Key.LAST_VAULT_PATH)
if (existingPath != null) {
setVaultPath(existingPath)
}
}, [])
useEffect(() => {
if (vaultPath) {
const instance = new OnePassword({
path: vaultPath,
adapter: electronAdapter,
})
setInstance(instance)
set(Key.LAST_VAULT_PATH, vaultPath)
} else {
setInstance(undefined)
remove(Key.LAST_VAULT_PATH)
}
}, [vaultPath])
if (!instance) {
return <PickOPVault setPath={setVaultPath} />
}
if (!vault) {
return <Unlock onReturn={clearInstance} instance={instance} onUnlock={unlock} />
}
return null
}
const PickOPVaultContainer = styled.div`
padding: 100px;
text-align: center;
`
const PickOPVaultInfo = styled.div`
margin: 10px;
`
const PickOPVault: React.FC<{
setPath(path: string): void
}> = ({ setPath }) => {
const t = useTranslate()
const onClick = useCallback(async () => {
const path = await openDirectory()
if (path) {
setPath(path)
}
}, [setPath])
return (
<PickOPVaultContainer>
<button onClick={onClick}>{t.label_choose_a_vault}</button>
<PickOPVaultInfo>{t.label_no_vault_selected}</PickOPVaultInfo>
</PickOPVaultContainer>
)
}

View File

@ -0,0 +1,28 @@
import { css } from "@emotion/css"
export const scrollbar = css`
&&::-webkit-scrollbar {
width: 7px;
}
&&::-webkit-scrollbar-track {
background: transparent;
}
&&:hover,
&&:active {
&&::-webkit-scrollbar-thumb {
background: #8883;
transition: 0.1s;
border-radius: 6px;
@media (prefers-color-scheme: dark) {
background: #6663;
}
}
&&::-webkit-scrollbar-thumb:hover {
background: #ddd;
transition: 0.1s;
@media (prefers-color-scheme: dark) {
background: #555;
}
}
}
`

View File

@ -0,0 +1,31 @@
import { Buffer } from "buffer"
import type { IAdapter } from "opvault.js/src/adapters"
import type { IPC } from "../electron/ipc-types"
import { memoize } from "./memoize"
declare const ipcRenderer: Electron.IpcRenderer
export async function openDirectory() {
return ipc.showDirectoryPicker()
}
export const electronAdapter: IAdapter = {
fs: {
exists: path => ipc.pathExists(path),
readBuffer: path => ipc.readBuffer(path).then(Buffer.from),
readFile: path => ipc.readFile(path),
readdir: path => ipc.readdir(path),
writeFile: (path, data) => ipc.writeFile(path, data),
isDirectory: path => ipc.isDirectory(path),
},
subtle: crypto.subtle,
}
const ipc = new Proxy<IPC>({} as any, {
get: memoize(
(_, channel: string) =>
(...args: any[]) =>
ipcRenderer.invoke(`service-${channel}`, ...args),
(_, name) => name
),
})

View File

@ -0,0 +1,26 @@
export const enum Key {
LAST_VAULT_PATH = "lastVaultPath",
}
interface StoredData {
[Key.LAST_VAULT_PATH]: string
}
export function get<K extends keyof StoredData>(key: K) {
try {
const value = localStorage.getItem(key)
return value == null ? undefined : (JSON.parse(value!) as StoredData[K])
} catch {}
}
export function set<K extends keyof StoredData>(key: K, value: StoredData[K]) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {}
}
export function remove(key: keyof StoredData) {
try {
localStorage.removeItem(key)
} catch {}
}

View File

@ -0,0 +1,18 @@
export function memoize<T extends (...args: any[]) => any, K>(
fn: T,
getKey: (...args: Parameters<T>) => K
): T {
const map = new Map<K, ReturnType<T>>()
function memoized(this: any, ...args: Parameters<T>): ReturnType<T> {
const key = getKey(...args)
if (map.has(key)) {
return map.get(key)!
} else {
const value = fn.apply(this, args)
map.set(key, value)
return value
}
}
Object.defineProperty(memoized, "name", { value: fn.name })
return memoized as any
}