feat: private key login
This commit is contained in:
parent
76ebc7e4a5
commit
3d3f603596
16 changed files with 495 additions and 233 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
export const StorageKey = {
|
export const StorageKey = {
|
||||||
THEME_SETTING: 'themeSetting',
|
THEME_SETTING: 'themeSetting',
|
||||||
RELAY_GROUPS: 'relayGroups'
|
RELAY_GROUPS: 'relayGroups',
|
||||||
|
ACCOUNT: 'account'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,17 @@ export type TTheme = 'light' | 'dark'
|
||||||
|
|
||||||
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
|
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
|
||||||
|
|
||||||
|
export interface ISigner {
|
||||||
|
getPublicKey: () => Promise<string | null>
|
||||||
|
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
||||||
|
}
|
||||||
|
|
||||||
export type TElectronWindow = {
|
export type TElectronWindow = {
|
||||||
electron: ElectronAPI
|
electron: ElectronAPI
|
||||||
api: {
|
api: {
|
||||||
system: {
|
system: {
|
||||||
isEncryptionAvailable: () => Promise<boolean>
|
isEncryptionAvailable: () => Promise<boolean>
|
||||||
|
getSelectedStorageBackend: () => Promise<string>
|
||||||
}
|
}
|
||||||
theme: {
|
theme: {
|
||||||
addChangeListener: (listener: (theme: TTheme) => void) => void
|
addChangeListener: (listener: (theme: TTheme) => void) => void
|
||||||
|
|
@ -31,6 +37,7 @@ export type TElectronWindow = {
|
||||||
storage: {
|
storage: {
|
||||||
getItem: (key: string) => Promise<string>
|
getItem: (key: string) => Promise<string>
|
||||||
setItem: (key: string, value: string) => Promise<void>
|
setItem: (key: string, value: string) => Promise<void>
|
||||||
|
removeItem: (key: string) => Promise<void>
|
||||||
}
|
}
|
||||||
nostr: {
|
nostr: {
|
||||||
login: (nsec: string) => Promise<{
|
login: (nsec: string) => Promise<{
|
||||||
|
|
@ -40,8 +47,10 @@ export type TElectronWindow = {
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nostr: {
|
nostr: ISigner
|
||||||
getPublicKey: () => Promise<string | null>
|
|
||||||
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TAccount = {
|
||||||
|
signerType: 'nsec' | 'browser-nsec' | 'nip-07'
|
||||||
|
nsec?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,15 @@ app.whenReady().then(async () => {
|
||||||
nostrService.init()
|
nostrService.init()
|
||||||
|
|
||||||
ipcMain.handle('system:isEncryptionAvailable', () => safeStorage.isEncryptionAvailable())
|
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()
|
createWindow()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export class StorageService {
|
||||||
init() {
|
init() {
|
||||||
ipcMain.handle('storage:getItem', (_, key: string) => this.getItem(key))
|
ipcMain.handle('storage:getItem', (_, key: string) => this.getItem(key))
|
||||||
ipcMain.handle('storage:setItem', (_, key: string, value: string) => this.setItem(key, value))
|
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 {
|
getItem(key: string): string | undefined {
|
||||||
|
|
@ -29,6 +30,15 @@ export class StorageService {
|
||||||
|
|
||||||
setItem(key: string, value: string) {
|
setItem(key: string, value: string) {
|
||||||
this.config[key] = value
|
this.config[key] = value
|
||||||
|
this.setWriteTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key: string) {
|
||||||
|
delete this.config[key]
|
||||||
|
this.setWriteTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setWriteTimeout() {
|
||||||
if (this.writeTimer) return
|
if (this.writeTimer) return
|
||||||
|
|
||||||
this.writeTimer = setTimeout(() => {
|
this.writeTimer = setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
const api = {
|
const api = {
|
||||||
system: {
|
system: {
|
||||||
isEncryptionAvailable: () => ipcRenderer.invoke('system:isEncryptionAvailable')
|
isEncryptionAvailable: () => ipcRenderer.invoke('system:isEncryptionAvailable'),
|
||||||
|
getSelectedStorageBackend: () => ipcRenderer.invoke('system:getSelectedStorageBackend')
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
addChangeListener: (listener: (theme: TTheme) => void) => {
|
addChangeListener: (listener: (theme: TTheme) => void) => {
|
||||||
|
|
@ -20,7 +21,8 @@ const api = {
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
getItem: (key: string) => ipcRenderer.invoke('storage:getItem', key),
|
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: {
|
nostr: {
|
||||||
login: (nsec: string) => ipcRenderer.invoke('nostr:login', nsec),
|
login: (nsec: string) => ipcRenderer.invoke('nostr:login', nsec),
|
||||||
|
|
|
||||||
66
src/renderer/src/components/LoginDialog/NsecLogin.tsx
Normal file
66
src/renderer/src/components/LoginDialog/NsecLogin.tsx
Normal file
|
|
@ -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<string | null>(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<HTMLInputElement>) => {
|
||||||
|
setNsec(e.target.value)
|
||||||
|
setErrMsg(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
if (nsec === '') return
|
||||||
|
|
||||||
|
nsecLogin(nsec)
|
||||||
|
.then(() => onLoginSuccess())
|
||||||
|
.catch((err) => {
|
||||||
|
setErrMsg(err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-orange-400">
|
||||||
|
{!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
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="nsec1.."
|
||||||
|
value={nsec}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={errMsg ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleLogin}>{t('Login')}</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,11 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@renderer/components/ui/dialog'
|
} 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 { useNostr } from '@renderer/providers/NostrProvider'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
import { Dispatch, useState } from 'react'
|
import { Dispatch, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import PrivateKeyLogin from './NsecLogin'
|
||||||
|
|
||||||
export default function LoginDialog({
|
export default function LoginDialog({
|
||||||
open,
|
open,
|
||||||
|
|
@ -18,49 +19,38 @@ export default function LoginDialog({
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: Dispatch<boolean>
|
setOpen: Dispatch<boolean>
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | null>(null)
|
||||||
const { login, canLogin } = useNostr()
|
const { nip07Login } = useNostr()
|
||||||
const [nsec, setNsec] = useState('')
|
|
||||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setNsec(e.target.value)
|
|
||||||
setErrMsg(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogin = () => {
|
|
||||||
if (nsec === '') return
|
|
||||||
|
|
||||||
login(nsec)
|
|
||||||
.then(() => setOpen(false))
|
|
||||||
.catch((err) => {
|
|
||||||
setErrMsg(err.message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="w-80">
|
<DialogContent className="w-96">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="hidden" />
|
<DialogTitle className="hidden" />
|
||||||
<DialogDescription className="text-destructive">
|
<DialogDescription className="hidden" />
|
||||||
{!canLogin && 'Encryption is not available in your device.'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-1">
|
{loginMethod === 'nsec' ? (
|
||||||
<Input
|
<>
|
||||||
type="password"
|
<div
|
||||||
placeholder="nsec1.."
|
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
|
||||||
value={nsec}
|
onClick={() => setLoginMethod(null)}
|
||||||
onChange={handleInputChange}
|
>
|
||||||
className={errMsg ? 'border-destructive' : ''}
|
<ArrowLeft className="h-4 w-4" />
|
||||||
disabled={!canLogin}
|
|
||||||
/>
|
|
||||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleLogin} disabled={!canLogin}>
|
<PrivateKeyLogin onLoginSuccess={() => setOpen(false)} />
|
||||||
{t('Login')}
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!IS_ELECTRON && !!window.nostr && (
|
||||||
|
<Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full">
|
||||||
|
Login with NIP-07
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
||||||
|
Login with Private Key
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export function useSearchProfiles(search: string, limit: number) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfiles = async () => {
|
const fetchProfiles = async () => {
|
||||||
|
if (!search) return
|
||||||
|
|
||||||
setIsFetching(true)
|
setIsFetching(true)
|
||||||
setProfiles([])
|
setProfiles([])
|
||||||
if (searchableRelayUrls.length === 0) {
|
if (searchableRelayUrls.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,12 @@ export default {
|
||||||
'Notes & Replies': 'Notes & Replies',
|
'Notes & Replies': 'Notes & Replies',
|
||||||
notifications: 'notifications',
|
notifications: 'notifications',
|
||||||
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.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,11 @@ export default {
|
||||||
'Notes & Replies': '笔记 & 回复',
|
'Notes & Replies': '笔记 & 回复',
|
||||||
notifications: '通知',
|
notifications: '通知',
|
||||||
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string>
|
|
||||||
logout: () => Promise<void>
|
|
||||||
nip07Login: () => Promise<string>
|
|
||||||
/**
|
|
||||||
* Default publish the event to current relays, user's write relays and additional relays
|
|
||||||
*/
|
|
||||||
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
|
|
||||||
signHttpAuth: (url: string, method: string) => Promise<string>
|
|
||||||
signEvent: (draftEvent: TDraftEvent) => Promise<Event>
|
|
||||||
checkLogin: (cb?: () => void | Promise<void>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const NostrContext = createContext<TNostrContext | undefined>(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<string | null>(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 (
|
|
||||||
<NostrContext.Provider
|
|
||||||
value={{
|
|
||||||
isReady,
|
|
||||||
pubkey,
|
|
||||||
canLogin,
|
|
||||||
login,
|
|
||||||
nip07Login,
|
|
||||||
logout,
|
|
||||||
publish,
|
|
||||||
signHttpAuth,
|
|
||||||
checkLogin,
|
|
||||||
signEvent
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
|
|
||||||
</NostrContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/renderer/src/providers/NostrProvider/index.tsx
Normal file
222
src/renderer/src/providers/NostrProvider/index.tsx
Normal file
|
|
@ -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<string>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
nip07Login: () => Promise<void>
|
||||||
|
/**
|
||||||
|
* Default publish the event to current relays, user's write relays and additional relays
|
||||||
|
*/
|
||||||
|
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
|
||||||
|
signHttpAuth: (url: string, method: string) => Promise<string>
|
||||||
|
signEvent: (draftEvent: TDraftEvent) => Promise<Event>
|
||||||
|
checkLogin: (cb?: () => void | Promise<void>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NostrContext = createContext<TNostrContext | undefined>(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<string | null>(null)
|
||||||
|
const [signer, setSigner] = useState<ISigner | null>(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 (
|
||||||
|
<NostrContext.Provider
|
||||||
|
value={{
|
||||||
|
isReady,
|
||||||
|
pubkey,
|
||||||
|
setPubkey,
|
||||||
|
nsecLogin,
|
||||||
|
nip07Login,
|
||||||
|
logout,
|
||||||
|
publish,
|
||||||
|
signHttpAuth,
|
||||||
|
checkLogin,
|
||||||
|
signEvent
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
|
||||||
|
</NostrContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/renderer/src/providers/NostrProvider/nip-07.signer.ts
Normal file
26
src/renderer/src/providers/NostrProvider/nip-07.signer.ts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/renderer/src/providers/NostrProvider/nsec.signer.ts
Normal file
31
src/renderer/src/providers/NostrProvider/nsec.signer.ts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { StorageKey } from '@common/constants'
|
import { StorageKey } from '@common/constants'
|
||||||
import { TRelayGroup, TThemeSetting } from '@common/types'
|
import { TAccount, TRelayGroup, TThemeSetting } from '@common/types'
|
||||||
import { isElectron } from '@renderer/lib/env'
|
import { isElectron } from '@renderer/lib/env'
|
||||||
|
|
||||||
const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [
|
const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [
|
||||||
|
|
@ -26,6 +26,14 @@ class Storage {
|
||||||
return localStorage.setItem(key, value)
|
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 {
|
class StorageService {
|
||||||
|
|
@ -34,6 +42,7 @@ class StorageService {
|
||||||
private initPromise!: Promise<void>
|
private initPromise!: Promise<void>
|
||||||
private relayGroups: TRelayGroup[] = []
|
private relayGroups: TRelayGroup[] = []
|
||||||
private themeSetting: TThemeSetting = 'system'
|
private themeSetting: TThemeSetting = 'system'
|
||||||
|
private account: TAccount | null = null
|
||||||
private storage: Storage = new Storage()
|
private storage: Storage = new Storage()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -49,6 +58,8 @@ class StorageService {
|
||||||
this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS
|
this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS
|
||||||
this.themeSetting =
|
this.themeSetting =
|
||||||
((await this.storage.getItem(StorageKey.THEME_SETTING)) as TThemeSetting) ?? 'system'
|
((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() {
|
async getRelayGroups() {
|
||||||
|
|
@ -72,6 +83,21 @@ class StorageService {
|
||||||
await this.storage.setItem(StorageKey.THEME_SETTING, themeSetting)
|
await this.storage.setItem(StorageKey.THEME_SETTING, themeSetting)
|
||||||
this.themeSetting = 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()
|
const instance = new StorageService()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue