From 3d3f603596089fe30038659e45db5b6a54a2e6de Mon Sep 17 00:00:00 2001 From: codytseng Date: Thu, 12 Dec 2024 22:09:40 +0800 Subject: [PATCH] feat: private key login --- src/common/constants.ts | 3 +- src/common/types.ts | 17 +- src/main/index.ts | 9 + src/main/services/storage.service.ts | 10 + src/preload/index.ts | 6 +- .../src/components/LoginDialog/NsecLogin.tsx | 66 ++++++ .../src/components/LoginDialog/index.tsx | 68 +++--- src/renderer/src/hooks/useSearchProfiles.tsx | 2 + src/renderer/src/i18n/en.ts | 8 +- src/renderer/src/i18n/zh.ts | 7 +- src/renderer/src/providers/NostrProvider.tsx | 184 --------------- .../NostrProvider/browser-nsec.signer.ts | 41 ++++ .../src/providers/NostrProvider/index.tsx | 222 ++++++++++++++++++ .../providers/NostrProvider/nip-07.signer.ts | 26 ++ .../providers/NostrProvider/nsec.signer.ts | 31 +++ src/renderer/src/services/storage.service.ts | 28 ++- 16 files changed, 495 insertions(+), 233 deletions(-) create mode 100644 src/renderer/src/components/LoginDialog/NsecLogin.tsx delete mode 100644 src/renderer/src/providers/NostrProvider.tsx create mode 100644 src/renderer/src/providers/NostrProvider/browser-nsec.signer.ts create mode 100644 src/renderer/src/providers/NostrProvider/index.tsx create mode 100644 src/renderer/src/providers/NostrProvider/nip-07.signer.ts create mode 100644 src/renderer/src/providers/NostrProvider/nsec.signer.ts diff --git a/src/common/constants.ts b/src/common/constants.ts index fe19905..dfacd6d 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,4 +1,5 @@ export const StorageKey = { THEME_SETTING: 'themeSetting', - RELAY_GROUPS: 'relayGroups' + RELAY_GROUPS: 'relayGroups', + ACCOUNT: 'account' } diff --git a/src/common/types.ts b/src/common/types.ts index 1521de3..e3ebebe 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -17,11 +17,17 @@ export type TTheme = 'light' | 'dark' export type TDraftEvent = Pick +export interface ISigner { + getPublicKey: () => Promise + signEvent: (draftEvent: TDraftEvent) => Promise +} + export type TElectronWindow = { electron: ElectronAPI api: { system: { isEncryptionAvailable: () => Promise + getSelectedStorageBackend: () => Promise } theme: { addChangeListener: (listener: (theme: TTheme) => void) => void @@ -31,6 +37,7 @@ export type TElectronWindow = { storage: { getItem: (key: string) => Promise setItem: (key: string, value: string) => Promise + removeItem: (key: string) => Promise } nostr: { login: (nsec: string) => Promise<{ @@ -40,8 +47,10 @@ export type TElectronWindow = { logout: () => Promise } } - nostr: { - getPublicKey: () => Promise - signEvent: (draftEvent: TDraftEvent) => Promise - } + nostr: ISigner +} + +export type TAccount = { + signerType: 'nsec' | 'browser-nsec' | 'nip-07' + nsec?: string } diff --git a/src/main/index.ts b/src/main/index.ts index c203c8f..d6948e2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -78,6 +78,15 @@ app.whenReady().then(async () => { nostrService.init() ipcMain.handle('system:isEncryptionAvailable', () => safeStorage.isEncryptionAvailable()) + ipcMain.handle('system:getSelectedStorageBackend', () => { + if (process.platform === 'darwin') { + return 'keychain' + } + if (process.platform === 'win32') { + return 'dpapi' + } + return safeStorage.getSelectedStorageBackend() + }) createWindow() diff --git a/src/main/services/storage.service.ts b/src/main/services/storage.service.ts index cc9dd2e..1511726 100644 --- a/src/main/services/storage.service.ts +++ b/src/main/services/storage.service.ts @@ -17,6 +17,7 @@ export class StorageService { init() { ipcMain.handle('storage:getItem', (_, key: string) => this.getItem(key)) ipcMain.handle('storage:setItem', (_, key: string, value: string) => this.setItem(key, value)) + ipcMain.handle('storage:removeItem', (_, key: string) => this.removeItem(key)) } getItem(key: string): string | undefined { @@ -29,6 +30,15 @@ export class StorageService { setItem(key: string, value: string) { this.config[key] = value + this.setWriteTimeout() + } + + removeItem(key: string) { + delete this.config[key] + this.setWriteTimeout() + } + + private setWriteTimeout() { if (this.writeTimer) return this.writeTimer = setTimeout(() => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 70d4646..f8d84c1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,7 +5,8 @@ import { contextBridge, ipcRenderer } from 'electron' // Custom APIs for renderer const api = { system: { - isEncryptionAvailable: () => ipcRenderer.invoke('system:isEncryptionAvailable') + isEncryptionAvailable: () => ipcRenderer.invoke('system:isEncryptionAvailable'), + getSelectedStorageBackend: () => ipcRenderer.invoke('system:getSelectedStorageBackend') }, theme: { addChangeListener: (listener: (theme: TTheme) => void) => { @@ -20,7 +21,8 @@ const api = { }, storage: { getItem: (key: string) => ipcRenderer.invoke('storage:getItem', key), - setItem: (key: string, value: string) => ipcRenderer.invoke('storage:setItem', key, value) + setItem: (key: string, value: string) => ipcRenderer.invoke('storage:setItem', key, value), + removeItem: (key: string) => ipcRenderer.invoke('storage:removeItem', key) }, nostr: { login: (nsec: string) => ipcRenderer.invoke('nostr:login', nsec), diff --git a/src/renderer/src/components/LoginDialog/NsecLogin.tsx b/src/renderer/src/components/LoginDialog/NsecLogin.tsx new file mode 100644 index 0000000..fb6782e --- /dev/null +++ b/src/renderer/src/components/LoginDialog/NsecLogin.tsx @@ -0,0 +1,66 @@ +import { Button } from '@renderer/components/ui/button' +import { Input } from '@renderer/components/ui/input' +import { IS_ELECTRON, isElectron } from '@renderer/lib/env' +import { useNostr } from '@renderer/providers/NostrProvider' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function PrivateKeyLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) { + const { t } = useTranslation() + const { nsecLogin } = useNostr() + const [nsec, setNsec] = useState('') + const [errMsg, setErrMsg] = useState(null) + const [storageBackend, setStorageBackend] = useState('unknown') + + useEffect(() => { + const init = async () => { + if (!isElectron(window)) return + + const backend = await window.api.system.getSelectedStorageBackend() + setStorageBackend(backend) + } + init() + }, []) + + const handleInputChange = (e: React.ChangeEvent) => { + setNsec(e.target.value) + setErrMsg(null) + } + + const handleLogin = () => { + if (nsec === '') return + + nsecLogin(nsec) + .then(() => onLoginSuccess()) + .catch((err) => { + setErrMsg(err.message) + }) + } + + return ( + <> +
+ {!IS_ELECTRON + ? t( + 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.' + ) + : ['unknown', 'basic_text'].includes(storageBackend) + ? t('There are no secret keys stored on this device. Your nsec will be unprotected.') + : t('Your nsec will be encrypted using the {{backend}}.', { + backend: storageBackend + })} +
+
+ + {errMsg &&
{errMsg}
} +
+ + + ) +} diff --git a/src/renderer/src/components/LoginDialog/index.tsx b/src/renderer/src/components/LoginDialog/index.tsx index 9a1b23f..1721f96 100644 --- a/src/renderer/src/components/LoginDialog/index.tsx +++ b/src/renderer/src/components/LoginDialog/index.tsx @@ -6,10 +6,11 @@ import { DialogHeader, DialogTitle } from '@renderer/components/ui/dialog' -import { Input } from '@renderer/components/ui/input' +import { IS_ELECTRON } from '@renderer/lib/env' import { useNostr } from '@renderer/providers/NostrProvider' +import { ArrowLeft } from 'lucide-react' import { Dispatch, useState } from 'react' -import { useTranslation } from 'react-i18next' +import PrivateKeyLogin from './NsecLogin' export default function LoginDialog({ open, @@ -18,49 +19,38 @@ export default function LoginDialog({ open: boolean setOpen: Dispatch }) { - const { t } = useTranslation() - const { login, canLogin } = useNostr() - const [nsec, setNsec] = useState('') - const [errMsg, setErrMsg] = useState(null) - - const handleInputChange = (e: React.ChangeEvent) => { - setNsec(e.target.value) - setErrMsg(null) - } - - const handleLogin = () => { - if (nsec === '') return - - login(nsec) - .then(() => setOpen(false)) - .catch((err) => { - setErrMsg(err.message) - }) - } + const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | null>(null) + const { nip07Login } = useNostr() return ( - + - - {!canLogin && 'Encryption is not available in your device.'} - + -
- - {errMsg &&
{errMsg}
} -
- + {loginMethod === 'nsec' ? ( + <> +
setLoginMethod(null)} + > + +
+ setOpen(false)} /> + + ) : ( + <> + {!IS_ELECTRON && !!window.nostr && ( + + )} + + + )}
) diff --git a/src/renderer/src/hooks/useSearchProfiles.tsx b/src/renderer/src/hooks/useSearchProfiles.tsx index fb59527..10af230 100644 --- a/src/renderer/src/hooks/useSearchProfiles.tsx +++ b/src/renderer/src/hooks/useSearchProfiles.tsx @@ -11,6 +11,8 @@ export function useSearchProfiles(search: string, limit: number) { useEffect(() => { const fetchProfiles = async () => { + if (!search) return + setIsFetching(true) setProfiles([]) if (searchableRelayUrls.length === 0) { diff --git a/src/renderer/src/i18n/en.ts b/src/renderer/src/i18n/en.ts index 6a3fa06..b1c53ee 100644 --- a/src/renderer/src/i18n/en.ts +++ b/src/renderer/src/i18n/en.ts @@ -78,6 +78,12 @@ export default { 'Notes & Replies': 'Notes & Replies', notifications: 'notifications', Notifications: 'Notifications', - 'no more notifications': 'no more notifications' + 'no more notifications': 'no more notifications', + 'There are no secret keys stored on this device. Your nsec will be unprotected.': + 'There are no secret keys stored on this device. Your nsec will be unprotected.', + 'Your nsec will be encrypted using the {{backend}}.': + 'Your nsec will be encrypted using the {{backend}}.', + 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.': + 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.' } } diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 1066ec0..7a9dda8 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -77,6 +77,11 @@ export default { 'Notes & Replies': '笔记 & 回复', notifications: '通知', Notifications: '通知', - 'no more notifications': '到底了' + 'no more notifications': '到底了', + 'There are no secret keys stored on this device. Your nsec will be unprotected.': + '此设备上没有可用的密码管理工具。您的密钥将不受保护', + 'Your nsec will be encrypted using the {{backend}}.': '您的密钥将使用 {{backend}} 加密', + 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.': + '使用私钥登录是不安全的。建议使用浏览器插件进行登录,例如 alby、nostr-keyx 或 nos2x' } } diff --git a/src/renderer/src/providers/NostrProvider.tsx b/src/renderer/src/providers/NostrProvider.tsx deleted file mode 100644 index a18389a..0000000 --- a/src/renderer/src/providers/NostrProvider.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { TDraftEvent } from '@common/types' -import LoginDialog from '@renderer/components/LoginDialog' -import { useToast } from '@renderer/hooks' -import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList' -import { IS_ELECTRON, isElectron } from '@renderer/lib/env' -import client from '@renderer/services/client.service' -import dayjs from 'dayjs' -import { Event, kinds } from 'nostr-tools' -import { createContext, useContext, useEffect, useState } from 'react' -import { useRelaySettings } from './RelaySettingsProvider' - -type TNostrContext = { - isReady: boolean - pubkey: string | null - canLogin: boolean - login: (nsec: string) => Promise - logout: () => Promise - nip07Login: () => Promise - /** - * Default publish the event to current relays, user's write relays and additional relays - */ - publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise - signHttpAuth: (url: string, method: string) => Promise - signEvent: (draftEvent: TDraftEvent) => Promise - checkLogin: (cb?: () => void | Promise) => void -} - -const NostrContext = createContext(undefined) - -export const useNostr = () => { - const context = useContext(NostrContext) - if (!context) { - throw new Error('useNostr must be used within a NostrProvider') - } - return context -} - -export function NostrProvider({ children }: { children: React.ReactNode }) { - const { toast } = useToast() - const [isReady, setIsReady] = useState(false) - const [pubkey, setPubkey] = useState(null) - const [canLogin, setCanLogin] = useState(false) - const [openLoginDialog, setOpenLoginDialog] = useState(false) - const { relayUrls: currentRelayUrls } = useRelaySettings() - const relayList = useFetchRelayList(pubkey) - - useEffect(() => { - if (window.nostr) { - window.nostr.getPublicKey().then((pubkey) => { - if (pubkey) { - setPubkey(pubkey) - } - setIsReady(true) - }) - } else { - setIsReady(true) - } - if (isElectron(window)) { - window.api?.system.isEncryptionAvailable().then((isEncryptionAvailable) => { - setCanLogin(isEncryptionAvailable) - }) - } else { - setCanLogin(!!window.nostr) - } - }, []) - - const login = async (nsec: string) => { - if (!canLogin) { - throw new Error('encryption is not available') - } - if (!isElectron(window)) { - throw new Error('login is not available') - } - const { pubkey, reason } = await window.api.nostr.login(nsec) - if (!pubkey) { - throw new Error(reason ?? 'invalid nsec') - } - setPubkey(pubkey) - return pubkey - } - - const nip07Login = async () => { - if (IS_ELECTRON) { - throw new Error('electron app should not use nip07 login') - } - - if (!window.nostr) { - throw new Error( - 'You need to install a nostr signer extension to login. Such as Alby or nos2x' - ) - } - - const pubkey = await window.nostr.getPublicKey() - if (!pubkey) { - throw new Error('You did not allow to access your pubkey') - } - setPubkey(pubkey) - return pubkey - } - - const logout = async () => { - if (isElectron(window)) { - await window.api.nostr.logout() - } - setPubkey(null) - client.clearNotificationsCache() - } - - const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => { - const event = await window.nostr?.signEvent(draftEvent) - if (!event) { - throw new Error('sign event failed') - } - await client.publishEvent( - relayList.write.concat(additionalRelayUrls).concat(currentRelayUrls), - event - ) - return event - } - - const signEvent = async (draftEvent: TDraftEvent) => { - const event = await window.nostr?.signEvent(draftEvent) - if (!event) { - throw new Error('sign event failed') - } - return event - } - - const signHttpAuth = async (url: string, method: string) => { - const event = await window.nostr?.signEvent({ - content: '', - kind: kinds.HTTPAuth, - created_at: dayjs().unix(), - tags: [ - ['u', url], - ['method', method] - ] - }) - if (!event) { - throw new Error('sign event failed') - } - return 'Nostr ' + btoa(JSON.stringify(event)) - } - - const checkLogin = async (cb?: () => void) => { - if (pubkey) { - return cb && cb() - } - if (IS_ELECTRON) { - return setOpenLoginDialog(true) - } - try { - await nip07Login() - } catch (err) { - toast({ - title: 'Login failed', - description: (err as Error).message, - variant: 'destructive' - }) - return - } - return cb && cb() - } - - return ( - - {children} - - - ) -} diff --git a/src/renderer/src/providers/NostrProvider/browser-nsec.signer.ts b/src/renderer/src/providers/NostrProvider/browser-nsec.signer.ts new file mode 100644 index 0000000..af2848a --- /dev/null +++ b/src/renderer/src/providers/NostrProvider/browser-nsec.signer.ts @@ -0,0 +1,41 @@ +import { ISigner, TDraftEvent } from '@common/types' +import { bytesToHex } from '@noble/hashes/utils' +import { finalizeEvent, getPublicKey, nip19 } from 'nostr-tools' + +export class BrowserNsecSigner implements ISigner { + private privkey: Uint8Array | null = null + private pubkey: string | null = null + + login(nsec: string) { + const { type, data } = nip19.decode(nsec) + if (type !== 'nsec') { + throw new Error('invalid nsec') + } + + this.privkey = data + this.pubkey = getPublicKey(data) + window.localStorage.setItem('private_key', bytesToHex(data)) + return this.pubkey + } + + logout() { + window.localStorage.removeItem('private_key') + } + + async getPublicKey() { + return this.pubkey + } + + async signEvent(draftEvent: TDraftEvent) { + if (!this.privkey) { + return null + } + + try { + return finalizeEvent(draftEvent, this.privkey) + } catch (error) { + console.error(error) + return null + } + } +} diff --git a/src/renderer/src/providers/NostrProvider/index.tsx b/src/renderer/src/providers/NostrProvider/index.tsx new file mode 100644 index 0000000..6796b8e --- /dev/null +++ b/src/renderer/src/providers/NostrProvider/index.tsx @@ -0,0 +1,222 @@ +import { ISigner, TDraftEvent } from '@common/types' +import LoginDialog from '@renderer/components/LoginDialog' +import { useToast } from '@renderer/hooks' +import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList' +import { isElectron } from '@renderer/lib/env' +import client from '@renderer/services/client.service' +import storage from '@renderer/services/storage.service' +import dayjs from 'dayjs' +import { Event, kinds } from 'nostr-tools' +import { createContext, useContext, useEffect, useState } from 'react' +import { useRelaySettings } from '../RelaySettingsProvider' +import { BrowserNsecSigner } from './browser-nsec.signer' +import { Nip07Signer } from './nip-07.signer' +import { NsecSigner } from './nsec.signer' + +type TNostrContext = { + isReady: boolean + pubkey: string | null + setPubkey: (pubkey: string) => void + nsecLogin: (nsec: string) => Promise + logout: () => Promise + nip07Login: () => Promise + /** + * Default publish the event to current relays, user's write relays and additional relays + */ + publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise + signHttpAuth: (url: string, method: string) => Promise + signEvent: (draftEvent: TDraftEvent) => Promise + checkLogin: (cb?: () => void | Promise) => void +} + +const NostrContext = createContext(undefined) + +export const useNostr = () => { + const context = useContext(NostrContext) + if (!context) { + throw new Error('useNostr must be used within a NostrProvider') + } + return context +} + +export function NostrProvider({ children }: { children: React.ReactNode }) { + const { toast } = useToast() + const [isReady, setIsReady] = useState(false) + const [pubkey, setPubkey] = useState(null) + const [signer, setSigner] = useState(null) + const [openLoginDialog, setOpenLoginDialog] = useState(false) + const { relayUrls: currentRelayUrls } = useRelaySettings() + const relayList = useFetchRelayList(pubkey) + + useEffect(() => { + const init = async () => { + const account = await storage.getAccountInfo() + if (!account) { + if (isElectron(window) || !window.nostr) { + return setIsReady(true) + } + + // For browser env, attempt to login with nip-07 + const nip07Signer = new Nip07Signer() + const pubkey = await nip07Signer.getPublicKey() + if (!pubkey) { + return setIsReady(true) + } + setPubkey(pubkey) + setSigner(nip07Signer) + return setIsReady(true) + } + + if (account.signerType === 'nsec') { + const nsecSigner = new NsecSigner() + const pubkey = await nsecSigner.getPublicKey() + if (!pubkey) { + await storage.setAccountInfo(null) + return setIsReady(true) + } + setPubkey(pubkey) + setSigner(nsecSigner) + return setIsReady(true) + } + + if (account.signerType === 'browser-nsec') { + if (!account.nsec) { + await storage.setAccountInfo(null) + return setIsReady(true) + } + const browserNsecSigner = new BrowserNsecSigner() + const pubkey = browserNsecSigner.login(account.nsec) + setPubkey(pubkey) + setSigner(browserNsecSigner) + return setIsReady(true) + } + + if (account.signerType === 'nip-07') { + const nip07Signer = new Nip07Signer() + const pubkey = await nip07Signer.getPublicKey() + if (!pubkey) { + await storage.setAccountInfo(null) + return setIsReady(true) + } + setPubkey(pubkey) + setSigner(nip07Signer) + return setIsReady(true) + } + + await storage.setAccountInfo(null) + return setIsReady(true) + } + init().catch(() => { + storage.setAccountInfo(null) + setIsReady(true) + }) + }, []) + + const nsecLogin = async (nsec: string) => { + if (isElectron(window)) { + const nsecSigner = new NsecSigner() + const { pubkey, reason } = await nsecSigner.login(nsec) + if (!pubkey) { + throw new Error(reason ?? 'invalid nsec') + } + await storage.setAccountInfo({ signerType: 'nsec' }) + setPubkey(pubkey) + setSigner(nsecSigner) + return pubkey + } + const browserNsecSigner = new BrowserNsecSigner() + const pubkey = browserNsecSigner.login(nsec) + await storage.setAccountInfo({ signerType: 'browser-nsec', nsec }) + setPubkey(pubkey) + setSigner(browserNsecSigner) + return pubkey + } + + const nip07Login = async () => { + try { + const nip07Signer = new Nip07Signer() + const pubkey = await nip07Signer.getPublicKey() + if (!pubkey) { + throw new Error('You did not allow to access your pubkey') + } + await storage.setAccountInfo({ signerType: 'nip-07' }) + setPubkey(pubkey) + setSigner(nip07Signer) + } catch (err) { + toast({ + title: 'Login failed', + description: (err as Error).message, + variant: 'destructive' + }) + throw err + } + } + + const logout = async () => { + if (signer instanceof NsecSigner) { + await signer.logout() + } else if (signer instanceof BrowserNsecSigner) { + signer.logout() + } + setPubkey(null) + await storage.setAccountInfo(null) + client.clearNotificationsCache() + } + + const signEvent = async (draftEvent: TDraftEvent) => { + const event = await signer?.signEvent(draftEvent) + if (!event) { + throw new Error('sign event failed') + } + return event + } + + const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => { + const event = await signEvent(draftEvent) + await client.publishEvent( + relayList.write.concat(additionalRelayUrls).concat(currentRelayUrls), + event + ) + return event + } + + const signHttpAuth = async (url: string, method: string) => { + const event = await signEvent({ + content: '', + kind: kinds.HTTPAuth, + created_at: dayjs().unix(), + tags: [ + ['u', url], + ['method', method] + ] + }) + return 'Nostr ' + btoa(JSON.stringify(event)) + } + + const checkLogin = async (cb?: () => void) => { + if (pubkey) { + return cb && cb() + } + return setOpenLoginDialog(true) + } + + return ( + + {children} + + + ) +} diff --git a/src/renderer/src/providers/NostrProvider/nip-07.signer.ts b/src/renderer/src/providers/NostrProvider/nip-07.signer.ts new file mode 100644 index 0000000..a2af7de --- /dev/null +++ b/src/renderer/src/providers/NostrProvider/nip-07.signer.ts @@ -0,0 +1,26 @@ +import { ISigner, TDraftEvent } from '@common/types' +import { isElectron } from '@renderer/lib/env' + +export class Nip07Signer implements ISigner { + private signer: ISigner + + constructor() { + if (isElectron(window) || !window.nostr) { + throw new Error('nip-07 is not available') + } + if (!window.nostr) { + throw new Error( + 'You need to install a nostr signer extension to login. Such as alby, nostr-keyx or nos2x.' + ) + } + this.signer = window.nostr + } + + async getPublicKey() { + return await this.signer.getPublicKey() + } + + async signEvent(draftEvent: TDraftEvent) { + return await this.signer.signEvent(draftEvent) + } +} diff --git a/src/renderer/src/providers/NostrProvider/nsec.signer.ts b/src/renderer/src/providers/NostrProvider/nsec.signer.ts new file mode 100644 index 0000000..48a2a70 --- /dev/null +++ b/src/renderer/src/providers/NostrProvider/nsec.signer.ts @@ -0,0 +1,31 @@ +import { ISigner, TDraftEvent, TElectronWindow } from '@common/types' +import { isElectron } from '@renderer/lib/env' + +export class NsecSigner implements ISigner { + private electronNostrApi: TElectronWindow['api']['nostr'] + private signer: ISigner + + constructor() { + if (!isElectron(window)) { + throw new Error('nsec login is not available') + } + this.electronNostrApi = window.api.nostr + this.signer = window.nostr + } + + async login(nsec: string) { + return await this.electronNostrApi.login(nsec) + } + + async logout() { + return await this.electronNostrApi.logout() + } + + async getPublicKey() { + return await this.signer.getPublicKey() + } + + async signEvent(draftEvent: TDraftEvent) { + return await this.signer.signEvent(draftEvent) + } +} diff --git a/src/renderer/src/services/storage.service.ts b/src/renderer/src/services/storage.service.ts index f08f7a0..3558111 100644 --- a/src/renderer/src/services/storage.service.ts +++ b/src/renderer/src/services/storage.service.ts @@ -1,5 +1,5 @@ import { StorageKey } from '@common/constants' -import { TRelayGroup, TThemeSetting } from '@common/types' +import { TAccount, TRelayGroup, TThemeSetting } from '@common/types' import { isElectron } from '@renderer/lib/env' const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [ @@ -26,6 +26,14 @@ class Storage { return localStorage.setItem(key, value) } } + + async removeItem(key: string) { + if (isElectron(window)) { + return window.api.storage.removeItem(key) + } else { + return localStorage.removeItem(key) + } + } } class StorageService { @@ -34,6 +42,7 @@ class StorageService { private initPromise!: Promise private relayGroups: TRelayGroup[] = [] private themeSetting: TThemeSetting = 'system' + private account: TAccount | null = null private storage: Storage = new Storage() constructor() { @@ -49,6 +58,8 @@ class StorageService { this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS this.themeSetting = ((await this.storage.getItem(StorageKey.THEME_SETTING)) as TThemeSetting) ?? 'system' + const accountStr = await this.storage.getItem(StorageKey.ACCOUNT) + this.account = accountStr ? JSON.parse(accountStr) : null } async getRelayGroups() { @@ -72,6 +83,21 @@ class StorageService { await this.storage.setItem(StorageKey.THEME_SETTING, themeSetting) this.themeSetting = themeSetting } + + async getAccountInfo() { + await this.initPromise + return this.account + } + + async setAccountInfo(account: TAccount | null) { + await this.initPromise + if (account === null) { + await this.storage.removeItem(StorageKey.ACCOUNT) + } else { + await this.storage.setItem(StorageKey.ACCOUNT, JSON.stringify(account)) + } + this.account = account + } } const instance = new StorageService()