Initial commit: OpenTerminal v0.1.0

Open-source terminal app (local + SSH): split panes, themes, SFTP,
server monitoring, broadcast input, ZMODEM, system tray, single instance.
Electron + React + TypeScript. MIT License.
This commit is contained in:
Bill committed 2026-09-07 16:15:50 +08:00
commit a9acdbe500
99 files changed
+23940

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
/**
* SSH connection bookmarks. Persists to <userData>/connections.json as an array.
*
* Secret fields (password / keyContent / passphrase) are persisted only
* encrypted via Electron safeStorage (base64 blob in `*_enc` fields). The
* renderer contracts (`SshConnection`) never contain a secret: `*_enc` fields
* are stripped from the returned objects and replaced with `savedAuth` flags.
*
* When safeStorage is unavailable (e.g. during headless dev) the plaintext
* secret is base64'd and prefixed with `plain:` so the record stays
* serialisable — a dev-only fallback surfaced via console.warn.
*/
import { safeStorage } from 'electron'
import { randomUUID } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname } from 'path'
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections'
const ENC_SUFFIX = '_enc'
const PLAIN_PREFIX = 'plain:'
const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const
/** Internal persisted record: public fields + encrypted secret fields. */
interface StoredConnection {
id: string
name: string
group?: string
host: string
port: number
username: string
auth: SshAuthMethod
askPasswordAtConnect: boolean
askPassphraseAtConnect: boolean
keyPath?: string
keepaliveIntervalSec: number
createdAt: number
lastConnectedAt?: number
password_enc?: string
keyContent_enc?: string
passphrase_enc?: string
}
/** Public fields of SshConnectionInput reflected onto a StoredConnection. */
const PUBLIC_KEYS = [
'name',
'group',
'host',
'port',
'username',
'auth',
'askPasswordAtConnect',
'askPassphraseAtConnect',
'keyPath',
'keepaliveIntervalSec'
] as const satisfies readonly (keyof Omit<StoredConnection, 'password_enc' | 'keyContent_enc' | 'passphrase_enc'>)[]
function encrypt(plain: string): string {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.encryptString(plain).toString('base64')
}
// Dev fallback: no OS keychain, store a reversible base64 marker so the
// field still round-trips through JSON.
console.warn(
'[connections] safeStorage unavailable - storing plain: prefixed base64 secret (dev only)'
)
return PLAIN_PREFIX + Buffer.from(plain, 'utf8').toString('base64')
}
function decrypt(stored: string | undefined): string | undefined {
if (typeof stored !== 'string' || stored.length === 0) return undefined
if (stored.startsWith(PLAIN_PREFIX)) {
return Buffer.from(stored.slice(PLAIN_PREFIX.length), 'base64').toString('utf8')
}
if (safeStorage.isEncryptionAvailable()) {
try {
return safeStorage.decryptString(Buffer.from(stored, 'base64'))
} catch {
return undefined
}
}
return undefined
}
/** Strip secret fields from an internal record into the renderer contract. */
function toPublic(stored: StoredConnection): SshConnection {
return {
id: stored.id,
name: stored.name,
group: stored.group,
host: stored.host,
port: stored.port,
username: stored.username,
auth: stored.auth,
askPasswordAtConnect: stored.askPasswordAtConnect,
askPassphraseAtConnect: stored.askPassphraseAtConnect,
keyPath: stored.keyPath,
keepaliveIntervalSec: stored.keepaliveIntervalSec,
createdAt: stored.createdAt,
lastConnectedAt: stored.lastConnectedAt,
savedAuth: {
hasPassword: stored.password_enc !== undefined && stored.password_enc.length > 0,
hasKeyContent: stored.keyContent_enc !== undefined && stored.keyContent_enc.length > 0,
hasPassphrase: stored.passphrase_enc !== undefined && stored.passphrase_enc.length > 0
}
}
}
export class ConnectionsStore {
constructor(private readonly filePath: string) {}
private load(): StoredConnection[] {
try {
const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
if (Array.isArray(raw)) {
return raw.filter(
(x): x is StoredConnection =>
x !== null &&
typeof x === 'object' &&
typeof (x as StoredConnection).id === 'string' &&
typeof (x as StoredConnection).host === 'string'
)
}
} catch {
// missing / corrupted file -> start fresh
}
return []
}
private save(list: StoredConnection[]): void {
mkdirSync(dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(list, null, 2), 'utf8')
}
listConnections(): SshConnection[] {
return this.load().map(toPublic)
}
/** Decrypt a stored secret for a connection (undefined when absent). */
/** Decrypt a stored secret for a connection (undefined when absent). */
getSecret(
conn: SshConnection,
field: 'password' | 'keyContent' | 'passphrase'
): string | undefined {
const encField = `${field}${ENC_SUFFIX}` as keyof StoredConnection
const stored = this.load().find((c) => c.id === conn.id)
return stored ? decrypt(stored[encField] as string | undefined) : undefined
}
saveConnection(input: SshConnectionInput): SshConnection {
const list = this.load()
let stored: StoredConnection | undefined
if (typeof input.id === 'string' && input.id.length > 0) {
stored = list.find((c) => c.id === input.id)
}
if (stored) {
// Update in place; omitted secrets keep their previously stored value.
const target = stored as unknown as Record<string, unknown>
const source = input as unknown as Record<string, unknown>
for (const key of PUBLIC_KEYS) {
target[key] = source[key]
}
} else {
stored = {
id: input.id && input.id.length > 0 ? input.id : randomUUID(),
name: input.name,
group: input.group,
host: input.host,
port: input.port,
username: input.username,
auth: input.auth,
askPasswordAtConnect: input.askPasswordAtConnect,
askPassphraseAtConnect: input.askPassphraseAtConnect,
keyPath: input.keyPath,
keepaliveIntervalSec: input.keepaliveIntervalSec,
createdAt: Date.now()
}
list.push(stored)
}
// Encrypt new secret values; absent secrets leave the previous value intact.
const target = stored as unknown as Record<string, unknown>
for (const key of SECRET_KEYS) {
const value = input[key]
if (value === undefined) continue
if (value === '') {
delete target[`${key}${ENC_SUFFIX}`]
} else {
target[`${key}${ENC_SUFFIX}`] = encrypt(value)
}
}
this.save(list)
return toPublic(stored)
}
deleteConnection(id: string): void {
const list = this.load()
const next = list.filter((c) => c.id !== id)
if (next.length < list.length) this.save(next)
}
/** Mark a connection as recently used. */
touch(id: string, at = Date.now()): void {
const list = this.load()
const conn = list.find((c) => c.id === id)
if (!conn) return
conn.lastConnectedAt = at
this.save(list)
}
}
/** Default location: <userData>/connections.json */
export function defaultConnectionsPath(userDataPath: string): string {
return `${userDataPath}/connections.json`
}