kokoirc 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +227 -0
  2. package/docs/commands/alias.md +42 -0
  3. package/docs/commands/ban.md +26 -0
  4. package/docs/commands/close.md +25 -0
  5. package/docs/commands/connect.md +26 -0
  6. package/docs/commands/deop.md +24 -0
  7. package/docs/commands/devoice.md +24 -0
  8. package/docs/commands/disconnect.md +26 -0
  9. package/docs/commands/help.md +28 -0
  10. package/docs/commands/ignore.md +47 -0
  11. package/docs/commands/items.md +95 -0
  12. package/docs/commands/join.md +25 -0
  13. package/docs/commands/kb.md +26 -0
  14. package/docs/commands/kick.md +25 -0
  15. package/docs/commands/log.md +82 -0
  16. package/docs/commands/me.md +24 -0
  17. package/docs/commands/mode.md +29 -0
  18. package/docs/commands/msg.md +26 -0
  19. package/docs/commands/nick.md +24 -0
  20. package/docs/commands/notice.md +24 -0
  21. package/docs/commands/op.md +24 -0
  22. package/docs/commands/part.md +25 -0
  23. package/docs/commands/quit.md +24 -0
  24. package/docs/commands/reload.md +19 -0
  25. package/docs/commands/script.md +126 -0
  26. package/docs/commands/server.md +61 -0
  27. package/docs/commands/set.md +37 -0
  28. package/docs/commands/topic.md +24 -0
  29. package/docs/commands/unalias.md +22 -0
  30. package/docs/commands/unban.md +25 -0
  31. package/docs/commands/unignore.md +25 -0
  32. package/docs/commands/voice.md +25 -0
  33. package/docs/commands/whois.md +24 -0
  34. package/docs/commands/wii.md +23 -0
  35. package/package.json +38 -0
  36. package/src/app/App.tsx +205 -0
  37. package/src/core/commands/docs.ts +183 -0
  38. package/src/core/commands/execution.ts +114 -0
  39. package/src/core/commands/help-formatter.ts +185 -0
  40. package/src/core/commands/helpers.ts +168 -0
  41. package/src/core/commands/index.ts +7 -0
  42. package/src/core/commands/parser.ts +33 -0
  43. package/src/core/commands/registry.ts +1394 -0
  44. package/src/core/commands/types.ts +19 -0
  45. package/src/core/config/defaults.ts +66 -0
  46. package/src/core/config/loader.ts +209 -0
  47. package/src/core/constants.ts +20 -0
  48. package/src/core/init.ts +32 -0
  49. package/src/core/irc/antiflood.ts +244 -0
  50. package/src/core/irc/client.ts +145 -0
  51. package/src/core/irc/events.ts +1031 -0
  52. package/src/core/irc/formatting.ts +132 -0
  53. package/src/core/irc/ignore.ts +84 -0
  54. package/src/core/irc/index.ts +2 -0
  55. package/src/core/irc/netsplit.ts +292 -0
  56. package/src/core/scripts/api.ts +240 -0
  57. package/src/core/scripts/event-bus.ts +82 -0
  58. package/src/core/scripts/index.ts +26 -0
  59. package/src/core/scripts/manager.ts +154 -0
  60. package/src/core/scripts/types.ts +256 -0
  61. package/src/core/state/selectors.ts +39 -0
  62. package/src/core/state/sorting.ts +30 -0
  63. package/src/core/state/store.ts +242 -0
  64. package/src/core/storage/crypto.ts +78 -0
  65. package/src/core/storage/db.ts +107 -0
  66. package/src/core/storage/index.ts +80 -0
  67. package/src/core/storage/query.ts +204 -0
  68. package/src/core/storage/types.ts +37 -0
  69. package/src/core/storage/writer.ts +130 -0
  70. package/src/core/theme/index.ts +3 -0
  71. package/src/core/theme/loader.ts +45 -0
  72. package/src/core/theme/parser.ts +518 -0
  73. package/src/core/theme/renderer.tsx +25 -0
  74. package/src/index.tsx +17 -0
  75. package/src/types/config.ts +126 -0
  76. package/src/types/index.ts +107 -0
  77. package/src/types/irc-framework.d.ts +569 -0
  78. package/src/types/theme.ts +37 -0
  79. package/src/ui/ErrorBoundary.tsx +42 -0
  80. package/src/ui/chat/ChatView.tsx +39 -0
  81. package/src/ui/chat/MessageLine.tsx +92 -0
  82. package/src/ui/hooks/useStatusbarColors.ts +23 -0
  83. package/src/ui/input/CommandInput.tsx +273 -0
  84. package/src/ui/layout/AppLayout.tsx +126 -0
  85. package/src/ui/layout/TopicBar.tsx +46 -0
  86. package/src/ui/sidebar/BufferList.tsx +55 -0
  87. package/src/ui/sidebar/NickList.tsx +96 -0
  88. package/src/ui/splash/SplashScreen.tsx +100 -0
  89. package/src/ui/statusbar/StatusLine.tsx +205 -0
  90. package/themes/.gitkeep +0 -0
  91. package/themes/default.theme +57 -0
  92. package/tsconfig.json +19 -0
@@ -0,0 +1,256 @@
1
+ import type { Connection, Buffer, Message } from "@/types"
2
+ import type { AppConfig } from "@/types/config"
3
+ import type { Client } from "kofany-irc-framework"
4
+
5
+ // ─── Event System ────────────────────────────────────────────
6
+
7
+ export enum EventPriority {
8
+ HIGHEST = 100,
9
+ HIGH = 75,
10
+ NORMAL = 50,
11
+ LOW = 25,
12
+ LOWEST = 0,
13
+ }
14
+
15
+ export interface EventContext {
16
+ /** Prevent lower-priority handlers and built-in store update from running.
17
+ * Must be called synchronously (before any await). */
18
+ stop(): void
19
+ stopped: boolean
20
+ }
21
+
22
+ export type EventHandler = (data: any, ctx: EventContext) => void | Promise<void>
23
+
24
+ export interface EventRegistration {
25
+ event: string
26
+ handler: EventHandler
27
+ priority: number
28
+ once: boolean
29
+ owner: string
30
+ }
31
+
32
+ // ─── Script Module ───────────────────────────────────────────
33
+
34
+ export interface ScriptMeta {
35
+ name: string
36
+ version?: string
37
+ description?: string
38
+ }
39
+
40
+ export interface ScriptModule {
41
+ meta?: ScriptMeta
42
+ config?: Record<string, any>
43
+ default: (api: KokoAPI) => void | (() => void)
44
+ }
45
+
46
+ // ─── Commands ────────────────────────────────────────────────
47
+
48
+ export interface ScriptCommandDef {
49
+ handler: (args: string[], connectionId: string) => void
50
+ description: string
51
+ usage?: string
52
+ }
53
+
54
+ // ─── Timers ──────────────────────────────────────────────────
55
+
56
+ export interface TimerHandle {
57
+ clear(): void
58
+ }
59
+
60
+ // ─── Store Access (read-only) ────────────────────────────────
61
+
62
+ export interface StoreAccess {
63
+ getConnections(): Map<string, Connection>
64
+ getBuffers(): Map<string, Buffer>
65
+ getActiveBufferId(): string | null
66
+ getConfig(): AppConfig | null
67
+ getConnection(id: string): Connection | undefined
68
+ getBuffer(id: string): Buffer | undefined
69
+ subscribe(listener: () => void): () => void
70
+ }
71
+
72
+ // ─── IRC Access ──────────────────────────────────────────────
73
+
74
+ export interface IrcAccess {
75
+ say(target: string, message: string, connectionId?: string): void
76
+ action(target: string, message: string, connectionId?: string): void
77
+ notice(target: string, message: string, connectionId?: string): void
78
+ join(channel: string, key?: string, connectionId?: string): void
79
+ part(channel: string, message?: string, connectionId?: string): void
80
+ raw(line: string, connectionId?: string): void
81
+ changeNick(nick: string, connectionId?: string): void
82
+ whois(nick: string, connectionId?: string): void
83
+ getClient(connectionId?: string): Client | undefined
84
+ }
85
+
86
+ // ─── UI Access ───────────────────────────────────────────────
87
+
88
+ export interface UiAccess {
89
+ addLocalEvent(text: string): void
90
+ addMessage(bufferId: string, message: Omit<Message, "id" | "timestamp">): void
91
+ switchBuffer(bufferId: string): void
92
+ makeBufferId(connectionId: string, name: string): string
93
+ }
94
+
95
+ // ─── Config Access ───────────────────────────────────────────
96
+
97
+ export interface ScriptConfigAccess {
98
+ get<T = any>(key: string, defaultValue: T): T
99
+ set(key: string, value: any): void
100
+ }
101
+
102
+ // ─── KokoAPI ─────────────────────────────────────────────────
103
+
104
+ export interface KokoAPI {
105
+ meta: ScriptMeta
106
+
107
+ // Events
108
+ on(event: string, handler: EventHandler, priority?: number): () => void
109
+ once(event: string, handler: EventHandler, priority?: number): () => void
110
+ emit(event: string, data?: any): boolean
111
+
112
+ // Commands
113
+ command(name: string, def: ScriptCommandDef): void
114
+ removeCommand(name: string): void
115
+
116
+ // Timers
117
+ timer(ms: number, handler: () => void): TimerHandle
118
+ timeout(ms: number, handler: () => void): TimerHandle
119
+
120
+ // Access
121
+ store: StoreAccess
122
+ irc: IrcAccess
123
+ ui: UiAccess
124
+ config: ScriptConfigAccess
125
+
126
+ /** Event priority constants — use instead of importing EventPriority */
127
+ EventPriority: typeof EventPriority
128
+
129
+ log(...args: any[]): void
130
+ }
131
+
132
+ // ─── Event Payloads ──────────────────────────────────────────
133
+
134
+ export interface IrcMessageEvent {
135
+ connectionId: string
136
+ nick: string
137
+ ident?: string
138
+ hostname?: string
139
+ target: string
140
+ message: string
141
+ tags?: Record<string, string>
142
+ time?: string
143
+ isChannel: boolean
144
+ }
145
+
146
+ export interface IrcJoinEvent {
147
+ connectionId: string
148
+ nick: string
149
+ ident?: string
150
+ hostname?: string
151
+ channel: string
152
+ account?: string
153
+ }
154
+
155
+ export interface IrcPartEvent {
156
+ connectionId: string
157
+ nick: string
158
+ ident?: string
159
+ hostname?: string
160
+ channel: string
161
+ message?: string
162
+ }
163
+
164
+ export interface IrcQuitEvent {
165
+ connectionId: string
166
+ nick: string
167
+ ident?: string
168
+ hostname?: string
169
+ message?: string
170
+ }
171
+
172
+ export interface IrcKickEvent {
173
+ connectionId: string
174
+ nick: string
175
+ ident?: string
176
+ hostname?: string
177
+ channel: string
178
+ kicked: string
179
+ message?: string
180
+ }
181
+
182
+ export interface IrcNickEvent {
183
+ connectionId: string
184
+ nick: string
185
+ new_nick: string
186
+ ident?: string
187
+ hostname?: string
188
+ }
189
+
190
+ export interface IrcTopicEvent {
191
+ connectionId: string
192
+ nick?: string
193
+ channel: string
194
+ topic: string
195
+ }
196
+
197
+ export interface IrcModeEvent {
198
+ connectionId: string
199
+ nick?: string
200
+ target: string
201
+ modes: Array<{ mode: string; param?: string }>
202
+ }
203
+
204
+ export interface IrcInviteEvent {
205
+ connectionId: string
206
+ nick: string
207
+ channel: string
208
+ }
209
+
210
+ export interface IrcNoticeEvent {
211
+ connectionId: string
212
+ nick?: string
213
+ target?: string
214
+ message: string
215
+ from_server?: boolean
216
+ }
217
+
218
+ export interface IrcCtcpEvent {
219
+ connectionId: string
220
+ nick: string
221
+ type: string
222
+ message?: string
223
+ }
224
+
225
+ export interface IrcWallopsEvent {
226
+ connectionId: string
227
+ nick?: string
228
+ message: string
229
+ from_server?: boolean
230
+ }
231
+
232
+ // App events
233
+ export interface MessageAddEvent {
234
+ bufferId: string
235
+ message: Message
236
+ }
237
+
238
+ export interface BufferSwitchEvent {
239
+ from: string | null
240
+ to: string
241
+ }
242
+
243
+ export interface CommandInputEvent {
244
+ command: string
245
+ args: string[]
246
+ connectionId: string
247
+ }
248
+
249
+ export interface ConnectedEvent {
250
+ connectionId: string
251
+ nick: string
252
+ }
253
+
254
+ export interface DisconnectedEvent {
255
+ connectionId: string
256
+ }
@@ -0,0 +1,39 @@
1
+ import { useMemo } from "react"
2
+ import { useStore } from "./store"
3
+ import { sortBuffers, sortNicks } from "./sorting"
4
+ import type { Buffer, NickEntry } from "@/types"
5
+
6
+ export function useActiveBuffer(): Buffer | null {
7
+ const activeBufferId = useStore((s) => s.activeBufferId)
8
+ const buffersMap = useStore((s) => s.buffers)
9
+ return activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
10
+ }
11
+
12
+ export function useSortedBuffers(): Array<Buffer & { connectionLabel: string }> {
13
+ const buffersMap = useStore((s) => s.buffers)
14
+ const connectionsMap = useStore((s) => s.connections)
15
+ return useMemo(() => {
16
+ const list = Array.from(buffersMap.values())
17
+ .filter((buf) => buf.connectionId !== "_default")
18
+ .map((buf) => ({
19
+ ...buf,
20
+ connectionLabel: connectionsMap.get(buf.connectionId)?.label ?? buf.connectionId,
21
+ }))
22
+ return sortBuffers(list)
23
+ }, [buffersMap, connectionsMap])
24
+ }
25
+
26
+ const EMPTY_NICKS: NickEntry[] = []
27
+
28
+ export function useSortedNicks(bufferId: string, prefixOrder: string): NickEntry[] {
29
+ const buffersMap = useStore((s) => s.buffers)
30
+ const buffer = buffersMap.get(bufferId)
31
+ return useMemo(() => {
32
+ if (!buffer) return EMPTY_NICKS
33
+ return sortNicks(Array.from(buffer.users.values()), prefixOrder)
34
+ }, [buffer, prefixOrder])
35
+ }
36
+
37
+ export function useConnection(id: string) {
38
+ return useStore((s) => s.connections.get(id))
39
+ }
@@ -0,0 +1,30 @@
1
+ import type { Buffer, NickEntry } from "@/types"
2
+ import { getSortGroup } from "@/types"
3
+
4
+ interface SortableBuffer {
5
+ connectionLabel: string
6
+ type: Buffer["type"]
7
+ name: string
8
+ }
9
+
10
+ export function sortBuffers<T extends SortableBuffer>(buffers: T[]): T[] {
11
+ return [...buffers].sort((a, b) => {
12
+ const labelCmp = a.connectionLabel.localeCompare(b.connectionLabel, undefined, { sensitivity: "base" })
13
+ if (labelCmp !== 0) return labelCmp
14
+ const groupA = getSortGroup(a.type)
15
+ const groupB = getSortGroup(b.type)
16
+ if (groupA !== groupB) return groupA - groupB
17
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
18
+ })
19
+ }
20
+
21
+ export function sortNicks(nicks: NickEntry[], prefixOrder: string): NickEntry[] {
22
+ return [...nicks].sort((a, b) => {
23
+ const prefixA = a.prefix ? prefixOrder.indexOf(a.prefix) : prefixOrder.length
24
+ const prefixB = b.prefix ? prefixOrder.indexOf(b.prefix) : prefixOrder.length
25
+ const pA = prefixA === -1 ? prefixOrder.length : prefixA
26
+ const pB = prefixB === -1 ? prefixOrder.length : prefixB
27
+ if (pA !== pB) return pA - pB
28
+ return a.nick.localeCompare(b.nick, undefined, { sensitivity: "base" })
29
+ })
30
+ }
@@ -0,0 +1,242 @@
1
+ import { create } from "zustand"
2
+ import type { Connection, Buffer, Message, NickEntry, ActivityLevel, ListEntry, ListModeKey } from "@/types"
3
+ import type { AppConfig } from "@/types/config"
4
+ import type { ThemeFile } from "@/types/theme"
5
+ import { logMessage, updateReadMarker } from "@/core/storage"
6
+
7
+ interface AppState {
8
+ // Data
9
+ connections: Map<string, Connection>
10
+ buffers: Map<string, Buffer>
11
+ activeBufferId: string | null
12
+ previousActiveBufferId: string | null
13
+ config: AppConfig | null
14
+ theme: ThemeFile | null
15
+
16
+ // Connection actions
17
+ addConnection: (conn: Connection) => void
18
+ updateConnection: (id: string, updates: Partial<Connection>) => void
19
+ removeConnection: (id: string) => void
20
+
21
+ // Buffer actions
22
+ addBuffer: (buffer: Buffer) => void
23
+ removeBuffer: (id: string) => void
24
+ setActiveBuffer: (id: string) => void
25
+ updateBufferActivity: (id: string, level: ActivityLevel) => void
26
+
27
+ // Message actions
28
+ addMessage: (bufferId: string, message: Message) => void
29
+
30
+ // Nicklist actions
31
+ addNick: (bufferId: string, entry: NickEntry) => void
32
+ removeNick: (bufferId: string, nick: string) => void
33
+ updateNick: (bufferId: string, oldNick: string, newNick: string, prefix?: string) => void
34
+
35
+ // Buffer topic & modes
36
+ updateBufferTopic: (bufferId: string, topic: string, setBy?: string) => void
37
+ updateBufferModes: (bufferId: string, modes: string, modeParams?: Record<string, string>) => void
38
+
39
+ // List modes (bans, exceptions, invex, reop)
40
+ setListEntries: (bufferId: string, modeChar: ListModeKey, entries: ListEntry[]) => void
41
+ addListEntry: (bufferId: string, modeChar: ListModeKey, entry: ListEntry) => void
42
+ removeListEntry: (bufferId: string, modeChar: ListModeKey, mask: string) => void
43
+
44
+ // Config/Theme
45
+ setConfig: (config: AppConfig) => void
46
+ setTheme: (theme: ThemeFile) => void
47
+
48
+ // App lifecycle
49
+ shutdownHandler: (() => void) | null
50
+ setShutdownHandler: (handler: () => void) => void
51
+ requestShutdown: () => void
52
+ }
53
+
54
+ export const useStore = create<AppState>((set, get) => ({
55
+ connections: new Map(),
56
+ buffers: new Map(),
57
+ activeBufferId: null,
58
+ previousActiveBufferId: null,
59
+ config: null,
60
+ theme: null,
61
+
62
+ addConnection: (conn) => set((s) => {
63
+ const connections = new Map(s.connections)
64
+ connections.set(conn.id, conn)
65
+ return { connections }
66
+ }),
67
+
68
+ updateConnection: (id, updates) => set((s) => {
69
+ const connections = new Map(s.connections)
70
+ const existing = connections.get(id)
71
+ if (existing) connections.set(id, { ...existing, ...updates })
72
+ return { connections }
73
+ }),
74
+
75
+ removeConnection: (id) => set((s) => {
76
+ const connections = new Map(s.connections)
77
+ connections.delete(id)
78
+ return { connections }
79
+ }),
80
+
81
+ addBuffer: (buffer) => set((s) => {
82
+ const buffers = new Map(s.buffers)
83
+ buffers.set(buffer.id, buffer)
84
+ return { buffers }
85
+ }),
86
+
87
+ removeBuffer: (id) => set((s) => {
88
+ const buffers = new Map(s.buffers)
89
+ buffers.delete(id)
90
+ if (s.activeBufferId !== id) return { buffers }
91
+ // Fall back to previous buffer if it still exists, otherwise null
92
+ const fallback = s.previousActiveBufferId && buffers.has(s.previousActiveBufferId)
93
+ ? s.previousActiveBufferId : null
94
+ return { buffers, activeBufferId: fallback }
95
+ }),
96
+
97
+ setActiveBuffer: (id) => {
98
+ // Persist read marker for TUI client
99
+ const slashIdx = id.indexOf("/")
100
+ if (slashIdx > 0) {
101
+ const network = id.slice(0, slashIdx)
102
+ const buffer = id.slice(slashIdx + 1)
103
+ updateReadMarker(network, buffer, "tui", Date.now())
104
+ }
105
+
106
+ return set((s) => {
107
+ // Reset activity when switching to buffer
108
+ const buffers = new Map(s.buffers)
109
+ const buf = buffers.get(id)
110
+ if (buf) {
111
+ buffers.set(id, { ...buf, activity: 0, unreadCount: 0, lastRead: new Date() })
112
+ }
113
+ const previousActiveBufferId = s.activeBufferId !== id ? s.activeBufferId : s.previousActiveBufferId
114
+ return { activeBufferId: id, previousActiveBufferId, buffers }
115
+ })
116
+ },
117
+
118
+ updateBufferActivity: (id, level) => set((s) => {
119
+ const buffers = new Map(s.buffers)
120
+ const buf = buffers.get(id)
121
+ if (buf && level > buf.activity) {
122
+ buffers.set(id, { ...buf, activity: level, unreadCount: buf.unreadCount + 1 })
123
+ }
124
+ return { buffers }
125
+ }),
126
+
127
+ addMessage: (bufferId, message) => {
128
+ // Log to persistent storage (fire-and-forget, outside Zustand set)
129
+ const slashIdx = bufferId.indexOf("/")
130
+ if (slashIdx > 0) {
131
+ const network = bufferId.slice(0, slashIdx)
132
+ const buffer = bufferId.slice(slashIdx + 1)
133
+ logMessage(network, buffer, message.id, message.type, message.text, message.nick ?? null, message.highlight, message.timestamp)
134
+ }
135
+
136
+ return set((s) => {
137
+ const buffers = new Map(s.buffers)
138
+ const buf = buffers.get(bufferId)
139
+ if (!buf) return s
140
+ const maxLines = s.config?.display.scrollback_lines ?? 2000
141
+ const messages = [...buf.messages, message]
142
+ if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
143
+ buffers.set(bufferId, { ...buf, messages })
144
+ return { buffers }
145
+ })
146
+ },
147
+
148
+ addNick: (bufferId, entry) => set((s) => {
149
+ const buffers = new Map(s.buffers)
150
+ const buf = buffers.get(bufferId)
151
+ if (!buf) return s
152
+ const users = new Map(buf.users)
153
+ users.set(entry.nick, entry)
154
+ buffers.set(bufferId, { ...buf, users })
155
+ return { buffers }
156
+ }),
157
+
158
+ removeNick: (bufferId, nick) => set((s) => {
159
+ const buffers = new Map(s.buffers)
160
+ const buf = buffers.get(bufferId)
161
+ if (!buf) return s
162
+ const users = new Map(buf.users)
163
+ users.delete(nick)
164
+ buffers.set(bufferId, { ...buf, users })
165
+ return { buffers }
166
+ }),
167
+
168
+ updateNick: (bufferId, oldNick, newNick, prefix) => set((s) => {
169
+ const buffers = new Map(s.buffers)
170
+ const buf = buffers.get(bufferId)
171
+ if (!buf) return s
172
+ const users = new Map(buf.users)
173
+ const existing = users.get(oldNick)
174
+ if (existing) {
175
+ users.delete(oldNick)
176
+ users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
177
+ }
178
+ buffers.set(bufferId, { ...buf, users })
179
+ return { buffers }
180
+ }),
181
+
182
+ updateBufferTopic: (bufferId, topic, setBy) => set((s) => {
183
+ const buffers = new Map(s.buffers)
184
+ const buf = buffers.get(bufferId)
185
+ if (!buf) return s
186
+ buffers.set(bufferId, { ...buf, topic, topicSetBy: setBy })
187
+ return { buffers }
188
+ }),
189
+
190
+ updateBufferModes: (bufferId, modes, modeParams) => set((s) => {
191
+ const buffers = new Map(s.buffers)
192
+ const buf = buffers.get(bufferId)
193
+ if (!buf) return s
194
+ buffers.set(bufferId, { ...buf, modes, modeParams: modeParams ?? buf.modeParams })
195
+ return { buffers }
196
+ }),
197
+
198
+ setListEntries: (bufferId, modeChar, entries) => set((s) => {
199
+ const buffers = new Map(s.buffers)
200
+ const buf = buffers.get(bufferId)
201
+ if (!buf) return s
202
+ const listModes = new Map(buf.listModes)
203
+ listModes.set(modeChar, entries)
204
+ buffers.set(bufferId, { ...buf, listModes })
205
+ return { buffers }
206
+ }),
207
+
208
+ addListEntry: (bufferId, modeChar, entry) => set((s) => {
209
+ const buffers = new Map(s.buffers)
210
+ const buf = buffers.get(bufferId)
211
+ if (!buf) return s
212
+ const listModes = new Map(buf.listModes)
213
+ const existing = listModes.get(modeChar) ?? []
214
+ // Deduplicate by mask
215
+ if (existing.some((e) => e.mask === entry.mask)) return s
216
+ listModes.set(modeChar, [...existing, entry])
217
+ buffers.set(bufferId, { ...buf, listModes })
218
+ return { buffers }
219
+ }),
220
+
221
+ removeListEntry: (bufferId, modeChar, mask) => set((s) => {
222
+ const buffers = new Map(s.buffers)
223
+ const buf = buffers.get(bufferId)
224
+ if (!buf) return s
225
+ const listModes = new Map(buf.listModes)
226
+ const existing = listModes.get(modeChar)
227
+ if (!existing) return s
228
+ listModes.set(modeChar, existing.filter((e) => e.mask !== mask))
229
+ buffers.set(bufferId, { ...buf, listModes })
230
+ return { buffers }
231
+ }),
232
+
233
+ setConfig: (config) => set({ config }),
234
+ setTheme: (theme) => set({ theme }),
235
+
236
+ shutdownHandler: null,
237
+ setShutdownHandler: (handler) => set({ shutdownHandler: handler }),
238
+ requestShutdown: () => {
239
+ const handler = get().shutdownHandler
240
+ if (handler) handler()
241
+ },
242
+ }))
@@ -0,0 +1,78 @@
1
+ import { chmod } from "node:fs/promises"
2
+ import { ENV_PATH } from "@/core/constants"
3
+
4
+ const ALGORITHM = "AES-GCM"
5
+ const KEY_LENGTH = 256
6
+ const IV_LENGTH = 12
7
+ const ENV_KEY_NAME = "KOKO_LOG_KEY"
8
+
9
+ let cachedKey: CryptoKey | null = null
10
+
11
+ /** Generate a random 256-bit hex key string. */
12
+ export function generateKeyHex(): string {
13
+ const bytes = new Uint8Array(KEY_LENGTH / 8)
14
+ crypto.getRandomValues(bytes)
15
+ return Buffer.from(bytes).toString("hex")
16
+ }
17
+
18
+ /** Import a hex key string into a CryptoKey. */
19
+ async function importKey(hexKey: string): Promise<CryptoKey> {
20
+ const raw = Buffer.from(hexKey, "hex")
21
+ return crypto.subtle.importKey("raw", raw, { name: ALGORITHM }, false, ["encrypt", "decrypt"])
22
+ }
23
+
24
+ /** Encrypt plaintext. Returns { ciphertext: base64, iv: 12-byte Uint8Array }. */
25
+ export async function encrypt(
26
+ text: string,
27
+ key: CryptoKey,
28
+ ): Promise<{ ciphertext: string; iv: Uint8Array }> {
29
+ const iv = new Uint8Array(IV_LENGTH)
30
+ crypto.getRandomValues(iv)
31
+ const encoded = new TextEncoder().encode(text)
32
+ const encrypted = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, encoded)
33
+ return {
34
+ ciphertext: Buffer.from(encrypted).toString("base64"),
35
+ iv,
36
+ }
37
+ }
38
+
39
+ /** Decrypt base64 ciphertext with the given IV. */
40
+ export async function decrypt(
41
+ ciphertext: string,
42
+ iv: Uint8Array,
43
+ key: CryptoKey,
44
+ ): Promise<string> {
45
+ const data = Buffer.from(ciphertext, "base64")
46
+ const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv: new Uint8Array(iv) }, key, data)
47
+ return new TextDecoder().decode(decrypted)
48
+ }
49
+
50
+ /** Load key from .env, or generate and save one if missing. Returns CryptoKey. */
51
+ export async function loadOrCreateKey(): Promise<CryptoKey> {
52
+ if (cachedKey) return cachedKey
53
+
54
+ const file = Bun.file(ENV_PATH)
55
+ let content = (await file.exists()) ? await file.text() : ""
56
+
57
+ // Try to find existing key
58
+ const match = content.match(new RegExp(`^${ENV_KEY_NAME}=(.+)$`, "m"))
59
+ let hexKey: string
60
+
61
+ if (match) {
62
+ hexKey = match[1].trim()
63
+ } else {
64
+ // Generate and append to .env
65
+ hexKey = generateKeyHex()
66
+ content = content.trimEnd() + (content.length > 0 ? "\n" : "") + `${ENV_KEY_NAME}=${hexKey}\n`
67
+ await Bun.write(ENV_PATH, content)
68
+ await chmod(ENV_PATH, 0o600)
69
+ }
70
+
71
+ cachedKey = await importKey(hexKey)
72
+ return cachedKey
73
+ }
74
+
75
+ /** Clear cached key (for testing). */
76
+ export function clearKeyCache(): void {
77
+ cachedKey = null
78
+ }