diff --git a/Scripts/recover-scrollback.sh b/Scripts/recover-scrollback.sh new file mode 100755 index 0000000..3cedb47 --- /dev/null +++ b/Scripts/recover-scrollback.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Brygga contributors +# +# recover-scrollback.sh — diagnostic for the test-pollution incident fixed in +# the ServerStore-injection PR. Walks ~/Library/Application Support/Brygga and +# ~/Library/Logs/Brygga and prints a report of orphan scrollback directories +# (UUIDs that no longer appear in servers.json) so the user can rebind one to +# a freshly-added Server entry. +# +# Read-only. Never moves or deletes anything. Prints exact `mv` commands the +# user can copy and run manually after re-adding their server in Brygga. +# +# Usage: +# ./Scripts/recover-scrollback.sh + +set -euo pipefail + +SUPPORT="${HOME}/Library/Application Support/Brygga" +LOGS="${HOME}/Library/Logs/Brygga" +SERVERS_JSON="${SUPPORT}/servers.json" +SCROLLBACK_DIR="${SUPPORT}/scrollback" + +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +muted() { printf '\033[2m%s\033[0m\n' "$*"; } +warn() { printf '\033[33m%s\033[0m\n' "$*" >&2; } + +if [[ ! -d "${SUPPORT}" ]]; then + warn "No Brygga data directory at: ${SUPPORT}" + warn "Nothing to recover. Has Brygga ever been launched on this machine?" + exit 0 +fi + +# 1. Active servers — UUIDs that AppState still knows about. +active_uuids=() +if [[ -f "${SERVERS_JSON}" ]]; then + bold "Active servers (in servers.json):" + # Use python3 (ships with macOS) so we don't depend on jq. + python3 - "${SERVERS_JSON}" <<'PY' +import json, sys +with open(sys.argv[1]) as f: + snap = json.load(f) +for s in snap.get("servers", []): + print(f" {s.get('id', ''):38} {s.get('name', '?'):20} ({s.get('host', '?')})") +PY + while IFS= read -r line; do + active_uuids+=("${line}") + done < <(python3 -c ' +import json, sys +with open(sys.argv[1]) as f: + print("\n".join(s.get("id", "") for s in json.load(f).get("servers", []) if s.get("id"))) +' "${SERVERS_JSON}") +else + warn " (no servers.json on disk)" +fi +echo + +# 2. Networks with plain-text logs — these are the user's *real* server names, +# intact because DiskLogger keys by network name not UUID. +if [[ -d "${LOGS}" ]]; then + bold "Networks with plain-text logs (~/Library/Logs/Brygga/):" + find "${LOGS}" -maxdepth 1 -mindepth 1 -type d -print0 2>/dev/null \ + | xargs -0 -I{} basename {} \ + | sort \ + | while IFS= read -r net; do + channel_count=$(find "${LOGS}/${net}" -maxdepth 1 -name '*.log' 2>/dev/null | wc -l | tr -d ' ') + echo " ${net} (${channel_count} channels)" + done +else + muted " (no plain-text logs at ${LOGS})" +fi +echo + +# 3. Orphan scrollback dirs — UUIDs in scrollback/ that aren't in servers.json. +if [[ ! -d "${SCROLLBACK_DIR}" ]]; then + bold "No scrollback directory — nothing to recover." + exit 0 +fi + +# Walk every subdir, gather (mtime_epoch, uuid, file_count, channel_preview). +# Skip empty dirs (no .log files = nothing to recover) and classify dirs whose +# channels look like test-fixture noise so they don't drown out real data. +active_set=$(printf '%s\n' "${active_uuids[@]}" 2>/dev/null | sort -u) +real_orphans=() +test_orphan_count=0 + +while IFS= read -r line; do + if [[ "${line}" == TEST$'\t'* ]]; then + # Plain assignment instead of `((var++))` — the latter returns the + # pre-increment value, which is 0 on the first hit and trips + # `set -e`. Bash gotcha. + test_orphan_count=$((test_orphan_count + 1)) + else + real_orphans+=("${line}") + fi +done < <( + for dir in "${SCROLLBACK_DIR}"/*/; do + [[ -d "${dir}" ]] || continue + uuid=$(basename "${dir}") + # Skip non-UUID directories (defensive — if some other tool wrote into scrollback/). + [[ "${uuid}" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] || continue + # Skip if this UUID is still referenced by servers.json. + if grep -qx -F "${uuid}" <<<"${active_set}" 2>/dev/null; then + continue + fi + file_count=$(find "${dir}" -maxdepth 1 -name '*.log' 2>/dev/null | wc -l | tr -d ' ') + # Skip dirs with no .log files — empty shells from restore that never + # received any append. Nothing to recover from them. + [[ "${file_count}" -gt 0 ]] || continue + # Most recent .log mtime in the dir. `-print0` + null-delimited read so + # paths with spaces (the "Application Support" segment) don't get + # word-split. + latest_epoch=$(find "${dir}" -maxdepth 1 -name '*.log' -print0 2>/dev/null \ + | xargs -0 stat -f '%m' 2>/dev/null \ + | sort -n \ + | tail -1) + latest_epoch="${latest_epoch:-0}" + latest_iso=$(date -r "${latest_epoch}" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "—") + channels_full=$(find "${dir}" -maxdepth 1 -name '*.log' -print0 2>/dev/null \ + | xargs -0 -n1 basename \ + | sed 's/\.log$//' \ + | sort \ + | tr '\n' ',' \ + | sed 's/,$//') + channels_preview=$(tr ',' '\n' <<<"${channels_full}" \ + | head -10 \ + | tr '\n' ' ' \ + | sed 's/ *$//') + # Heuristic: if every channel filename matches a test-fixture name + # (`_test`, `alice`, `__server__`), tag this orphan as test pollution + # so it gets summarized rather than listed individually. + if [[ "${channels_full}" =~ ^(_test|alice|__server__)(,(_test|alice|__server__))*$ ]]; then + printf 'TEST\t%s\n' "${uuid}" + else + printf '%010d\t%s\t%-19s\t%-7s\t%s\n' "${latest_epoch}" "${uuid}" "${latest_iso}" "${file_count}" "${channels_preview}" + fi + done +) + +bold "Orphan scrollback with real chat data (most-recent first):" +echo +if [[ "${#real_orphans[@]}" -eq 0 ]]; then + muted " None found. If you expected scrollback here, check that you re-added" + muted " the server with the same nickname so DiskLogger's plain-text logs" + muted " under ~/Library/Logs/Brygga/ still match." +else + printf " %-38s %-19s %-7s %s\n" "UUID" "MOST RECENT" "FILES" "FIRST 10 CHANNELS" + printf " %-38s %-19s %-7s %s\n" "----" "-----------" "-----" "-----------------" + printf '%s\n' "${real_orphans[@]}" | sort -r -k1,1 | while IFS=$'\t' read -r _ uuid iso files channels; do + printf " %-38s %-19s %-7s %s\n" "${uuid}" "${iso}" "${files}" "${channels}" + done +fi +echo +if [[ "${test_orphan_count}" -gt 0 ]]; then + muted "(${test_orphan_count} additional orphan dirs hold only test-fixture noise — \`_test\`, \`alice\`, \`__server__\` — and are hidden. They're safe to delete: \`find \"${SCROLLBACK_DIR}\" -maxdepth 1 -type d\` to inspect, or leave them alone.)" +fi +echo + +echo +bold "How to recover one of these:" +echo " 1. Open Brygga, add a fresh Server entry (Server → New Server…) for the network you want." +echo " 2. Quit Brygga so AppState writes the new UUID to servers.json." +echo " 3. Look up the new UUID:" +echo " python3 -c \"import json; print('\\n'.join(s['id']+' '+s['name'] for s in json.load(open('${SERVERS_JSON}'))['servers']))\"" +echo " 4. Pick the orphan UUID above whose channel list looks right, then:" +echo " mv \"${SCROLLBACK_DIR}/\" \"${SCROLLBACK_DIR}/\"" +echo " 5. Relaunch Brygga — channel scrollback shows up on next channel JOIN." +echo +muted "This script never deletes or moves anything; only prints commands." diff --git a/Sources/Brygga/BryggaApp.swift b/Sources/Brygga/BryggaApp.swift index 4d44e68..40f4a97 100644 --- a/Sources/Brygga/BryggaApp.swift +++ b/Sources/Brygga/BryggaApp.swift @@ -30,7 +30,11 @@ final class BryggaAppDelegate: NSObject, NSApplicationDelegate { @main struct BryggaApp: App { @NSApplicationDelegateAdaptor(BryggaAppDelegate.self) private var appDelegate - @State private var appState = AppState(store: .shared) + @State private var appState = AppState( + store: .shared, + scrollbackStore: .shared, + scrollbackIndex: .shared, + ) var body: some Scene { Window("Brygga", id: "main") { diff --git a/Sources/BryggaCore/IRC/IRCSession.swift b/Sources/BryggaCore/IRC/IRCSession.swift index a72d4a2..80fb79f 100644 --- a/Sources/BryggaCore/IRC/IRCSession.swift +++ b/Sources/BryggaCore/IRC/IRCSession.swift @@ -52,9 +52,24 @@ public final class IRCSession { /// by token string → send timestamp. Used to derive `Server.lag`. private var pendingPings: [String: Date] = [:] - public init(server: Server, connection: IRCConnection) { + /// Per-channel JSONL scrollback store. Production code passes + /// `ScrollbackStore.shared`; tests pass a tempdir-rooted instance so a + /// `swift test` run never appends to the user's real scrollback. + private let scrollbackStore: ScrollbackStore + + /// SQLite/FTS5 search index. Same injection rule as `scrollbackStore`. + private let scrollbackIndex: ScrollbackIndex + + public init( + server: Server, + connection: IRCConnection, + scrollbackStore: ScrollbackStore, + scrollbackIndex: ScrollbackIndex, + ) { self.server = server self.connection = connection + self.scrollbackStore = scrollbackStore + self.scrollbackIndex = scrollbackIndex } // MARK: - Lifecycle @@ -336,8 +351,10 @@ public final class IRCSession { channel.scrollbackLoaded = true let sid = server.id let target = channel.name + let store = scrollbackStore + let index = scrollbackIndex Task { [weak channel] in - let msgs = await ScrollbackStore.shared.load(serverId: sid, target: target) + let msgs = await store.load(serverId: sid, target: target) guard let channel, !msgs.isEmpty else { return } await MainActor.run { channel.messages.insert(contentsOf: msgs, at: 0) @@ -346,7 +363,7 @@ public final class IRCSession { // reply, openQuery, incoming PM). `index` is idempotent on // `msg_id` so on-line writes for the same channel are safe. for msg in msgs { - await ScrollbackIndex.shared.index(msg, serverID: sid, target: target) + await index.index(msg, serverID: sid, target: target) } } } @@ -357,9 +374,11 @@ public final class IRCSession { channel.messages.append(message) let sid = server.id let target = channel.name + let store = scrollbackStore + let index = scrollbackIndex Task { - await ScrollbackStore.shared.append(serverId: sid, target: target, message: message) - await ScrollbackIndex.shared.index(message, serverID: sid, target: target) + await store.append(serverId: sid, target: target, message: message) + await index.index(message, serverID: sid, target: target) } logToDiskIfEnabled(message, target: target) } @@ -368,9 +387,11 @@ public final class IRCSession { public func recordServer(_ message: Message) { server.messages.append(message) let sid = server.id + let store = scrollbackStore + let index = scrollbackIndex Task { - await ScrollbackStore.shared.append(serverId: sid, target: "__server__", message: message) - await ScrollbackIndex.shared.index(message, serverID: sid, target: "__server__") + await store.append(serverId: sid, target: "__server__", message: message) + await index.index(message, serverID: sid, target: "__server__") } logToDiskIfEnabled(message, target: "server") } @@ -870,14 +891,16 @@ public final class IRCSession { } let sid = server.id let target = ctx.target + let store = scrollbackStore + let index = scrollbackIndex Task { for msg in novel { - await ScrollbackStore.shared.append( + await store.append( serverId: sid, target: target, message: msg, ) - await ScrollbackIndex.shared.index(msg, serverID: sid, target: target) + await index.index(msg, serverID: sid, target: target) } } } diff --git a/Sources/BryggaCore/Models/AppState.swift b/Sources/BryggaCore/Models/AppState.swift index 0dd1759..c59881c 100644 --- a/Sources/BryggaCore/Models/AppState.swift +++ b/Sources/BryggaCore/Models/AppState.swift @@ -69,8 +69,25 @@ public final class AppState { /// production data wipe. private let store: ServerStore - public init(store: ServerStore) { + /// Per-channel JSONL scrollback store. Same injection rule as `store` + /// — every constructed `IRCSession` gets this so test sessions don't + /// trickle into the user's real `scrollback/` directory. + private let scrollbackStore: ScrollbackStore + + /// FTS5 search index. Same injection rule. Read-only `search` in the + /// UI still uses `ScrollbackIndex.shared` directly because UI is + /// production-only; the injection is purely about isolating writes + /// from tests. + private let scrollbackIndex: ScrollbackIndex + + public init( + store: ServerStore, + scrollbackStore: ScrollbackStore, + scrollbackIndex: ScrollbackIndex, + ) { self.store = store + self.scrollbackStore = scrollbackStore + self.scrollbackIndex = scrollbackIndex restoreFromStore() requestNotificationPermission() } @@ -204,11 +221,13 @@ public final class AppState { // Each loaded message is also fed into the FTS5 index using the // canonical channel name — this is the cold-start backfill path, // done lazily through the existing rehydrate loop instead of a - // separate filesystem walker. `ScrollbackIndex.index` is - // idempotent on `msg_id`, so on-line writes that ran first - // won't be double-counted. + // separate filesystem walker. The index is idempotent on + // `msg_id`, so on-line writes that ran first won't be + // double-counted. + let scrollback = scrollbackStore + let index = scrollbackIndex Task { [server] in - let serverMessages = await ScrollbackStore.shared.load( + let serverMessages = await scrollback.load( serverId: server.id, target: "__server__", ) @@ -216,10 +235,10 @@ public final class AppState { server.messages.insert(contentsOf: serverMessages, at: 0) } for msg in serverMessages { - await ScrollbackIndex.shared.index(msg, serverID: server.id, target: "__server__") + await index.index(msg, serverID: server.id, target: "__server__") } for channel in server.channels { - let msgs = await ScrollbackStore.shared.load( + let msgs = await scrollback.load( serverId: server.id, target: channel.name, ) @@ -228,7 +247,7 @@ public final class AppState { channel.scrollbackLoaded = true } for msg in msgs { - await ScrollbackIndex.shared.index( + await index.index( msg, serverID: server.id, target: channel.name, @@ -313,7 +332,8 @@ public final class AppState { if selection == channelID { selection = server.id } - Task { await ScrollbackIndex.shared.clear(serverID: serverID, target: closedTarget) } + let index = scrollbackIndex + Task { await index.clear(serverID: serverID, target: closedTarget) } persist() } @@ -451,7 +471,12 @@ public final class AppState { clientCertificatePassphrase: clientCertificatePassphrase, bouncerNetID: bouncerNetID, ) - let session = IRCSession(server: server, connection: connection) + let session = IRCSession( + server: server, + connection: connection, + scrollbackStore: scrollbackStore, + scrollbackIndex: scrollbackIndex, + ) session.autoJoinChannels = autoJoinChannels session.onChannelsChanged = { [weak self] in self?.persist() } session.onHighlight = { [weak self] channel, message in @@ -606,7 +631,8 @@ public final class AppState { if selection == id { selection = nil } - Task { await ScrollbackIndex.shared.clear(serverID: id) } + let index = scrollbackIndex + Task { await index.clear(serverID: id) } persist() } } diff --git a/Tests/AppStateNavigationTests.swift b/Tests/AppStateNavigationTests.swift index 34a455d..df10f24 100644 --- a/Tests/AppStateNavigationTests.swift +++ b/Tests/AppStateNavigationTests.swift @@ -6,19 +6,40 @@ import XCTest @MainActor final class AppStateNavigationTests: XCTestCase { - /// Build a `ServerStore` rooted at a unique temp directory. Critical - /// for isolation: the production singleton would share the user's real - /// `~/Library/Application Support/Brygga/servers.json`, and any test - /// mutation that triggers `persist()` (e.g. `closePrivateMessage`) - /// would overwrite that file with the test fixture data. - private func makeStore() -> ServerStore { + /// Build a tempdir-rooted set of stores. Critical for isolation: the + /// production singletons would share the user's real + /// `~/Library/Application Support/Brygga/{servers.json,scrollback/,scrollback.sqlite}`, + /// and any test mutation that triggers a write would land there. The + /// recovery script (`Scripts/recover-scrollback.sh`) was written to + /// dig users out of exactly that hole. + private struct TestDeps { + let server: ServerStore + let scrollback: ScrollbackStore + let scrollbackIndex: ScrollbackIndex + } + + private func makeDeps() -> TestDeps { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("BryggaTests-\(UUID().uuidString)", isDirectory: true) - return ServerStore(root: dir) + return TestDeps( + server: ServerStore(root: dir), + scrollback: ScrollbackStore(root: dir.appendingPathComponent("scrollback", isDirectory: true)), + scrollbackIndex: ScrollbackIndex(path: ":memory:"), + ) + } + + /// Convenience for tests that only need the `ServerStore`. + private func makeStore() -> ServerStore { + makeDeps().server } private func makeFixture() -> AppState { - let state = AppState(store: makeStore()) + let deps = makeDeps() + let state = AppState( + store: deps.server, + scrollbackStore: deps.scrollback, + scrollbackIndex: deps.scrollbackIndex, + ) state.servers.removeAll() state.selection = nil let s1 = Server(name: "ServerA", host: "a.example.org", nickname: "me") @@ -95,7 +116,12 @@ final class AppStateNavigationTests: XCTestCase { } func testEmptyStateIsNoOp() { - let state = AppState(store: makeStore()) + let deps = makeDeps() + let state = AppState( + store: deps.server, + scrollbackStore: deps.scrollback, + scrollbackIndex: deps.scrollbackIndex, + ) state.servers.removeAll() state.selection = nil state.selectAdjacentChannel(direction: 1) @@ -106,11 +132,15 @@ final class AppStateNavigationTests: XCTestCase { /// `persist()` must write to the injected store's path, not to the /// production `~/Library/Application Support/Brygga/servers.json`. func testPersistWritesToInjectedStoreNotProduction() { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("BryggaTests-\(UUID().uuidString)", isDirectory: true) - let store = ServerStore(root: dir) - let state = AppState(store: store) + let deps = makeDeps() + let state = AppState( + store: deps.server, + scrollbackStore: deps.scrollback, + scrollbackIndex: deps.scrollbackIndex, + ) state.servers.removeAll() + // Convenience handles for the assertions below. + let store = deps.server let pm = Channel(name: "carol") let owner = Server(name: "Test", host: "irc.example.org", nickname: "me") diff --git a/Tests/IRCSessionTests.swift b/Tests/IRCSessionTests.swift index b10f900..68d19ac 100644 --- a/Tests/IRCSessionTests.swift +++ b/Tests/IRCSessionTests.swift @@ -9,7 +9,17 @@ final class IRCSessionTests: XCTestCase { private func makeSession(ownNick: String = "me") -> IRCSession { let server = Server(name: "Test", host: "irc.example.org", nickname: ownNick) let connection = IRCConnection(host: "irc.example.org", nickname: ownNick) - return IRCSession(server: server, connection: connection) + // Tempdir-rooted scrollback store so `record(_:in:)` calls during + // the test can't append to the user's real `~/Library/Application + // Support/Brygga/scrollback/`. `:memory:` SQLite for the index. + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("BryggaTests-\(UUID().uuidString)", isDirectory: true) + return IRCSession( + server: server, + connection: connection, + scrollbackStore: ScrollbackStore(root: dir), + scrollbackIndex: ScrollbackIndex(path: ":memory:"), + ) } private func parse(_ line: String) -> IRCLineParserResult {