From 3382bf1ce865b4c20c832363a823ad40f2c4ba63 Mon Sep 17 00:00:00 2001 From: Kevin Mamaqi Kapllani Date: Sat, 18 Jul 2026 19:15:32 +0200 Subject: [PATCH 1/7] feat: local macOS app packaging via compiled Bun binary Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + scripts/package-macos.sh | 99 ++++++++++++++++++++++++++++++++++++++++ src/integrations.ts | 2 +- src/server.ts | 2 +- 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100755 scripts/package-macos.sh diff --git a/.gitignore b/.gitignore index d80c1bd..aedf67f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .learndeck/ .env .DS_Store +dist/ diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh new file mode 100755 index 0000000..2a3b855 --- /dev/null +++ b/scripts/package-macos.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Build a local, double-clickable LearnDeck.app for macOS. +# +# This is developer tooling for trying LearnDeck as an app on your own +# machine. It is not signed, not notarized, and not a distribution channel; +# the supported install for v0.1 remains cloning the repository. +# +# What it produces: dist/LearnDeck.app +# Contents/Resources/learndeck/ compiled server + public/ courses/ references/ +# Launch: starts the server on a free port and opens your browser. +# Quit (Dock icon > Quit): stops the server. +# +# Data lives outside the app so rebuilds never touch progress: +# ~/Library/Application Support/LearnDeck/progress.db and course-cache/ +# +# Known limitation: connecting an AI guide from the app writes an MCP entry +# pointing at THIS repository checkout (it needs src/mcp.ts plus installed +# dependencies), so keep the repo where it is or reconnect after moving it. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +DIST="$REPO/dist" +APP="$DIST/LearnDeck.app" +PAYLOAD_STAGE="$DIST/.macos-payload" + +command -v osacompile >/dev/null || { echo "osacompile not found (macOS required)"; exit 1; } +command -v bun >/dev/null || { echo "bun not found"; exit 1; } + +echo "==> Compiling server binary" +rm -rf "$PAYLOAD_STAGE" +mkdir -p "$PAYLOAD_STAGE" +(cd "$REPO" && bun build ./src/server.ts --compile --outfile "$PAYLOAD_STAGE/learndeck-server") + +echo "==> Staging payload" +cp -R "$REPO/public" "$PAYLOAD_STAGE/public" +cp -R "$REPO/courses" "$PAYLOAD_STAGE/courses" +cp -R "$REPO/references" "$PAYLOAD_STAGE/references" + +cat > "$PAYLOAD_STAGE/launch.sh" </dev/null 2>&1; do PORT=\$((PORT+1)); done + +export LEARNDECK_PUBLIC_DIR="\$HERE/public" +export LEARNDECK_COURSES_DIR="\$HERE/courses" +export LEARNDECK_DB_PATH="\$DATA/progress.db" +export LEARNDECK_COURSE_CACHE_DIR="\$DATA/course-cache" +export LEARNDECK_ROOT="$REPO" +export PORT + +nohup "\$HERE/learndeck-server" >> "\$DATA/server.log" 2>&1 & +echo \$! > "\$DATA/server.pid" +echo \$PORT > "\$DATA/server.port" + +for _ in \$(seq 1 40); do + if curl -s -o /dev/null "http://127.0.0.1:\$PORT/"; then break; fi + sleep 0.25 +done +open "http://127.0.0.1:\$PORT/" +LAUNCH +chmod +x "$PAYLOAD_STAGE/launch.sh" + +echo "==> Building app bundle" +rm -rf "$APP" +APPLET_SRC="$DIST/.learndeck-applet.applescript" +cat > "$APPLET_SRC" <<'APPLET' +on run + set resourceRoot to POSIX path of (path to resource "learndeck") + do shell script quoted form of (resourceRoot & "/launch.sh") +end run + +on idle + return 3600 +end idle + +on quit + set dataDir to POSIX path of (path to application support folder from user domain) & "LearnDeck" + try + do shell script "kill $(cat " & quoted form of (dataDir & "/server.pid") & ") 2>/dev/null; rm -f " & quoted form of (dataDir & "/server.pid") + end try + continue quit +end quit +APPLET +osacompile -s -o "$APP" "$APPLET_SRC" +rm -f "$APPLET_SRC" + +mv "$PAYLOAD_STAGE" "$APP/Contents/Resources/learndeck" +/usr/libexec/PlistBuddy -c "Set :CFBundleName LearnDeck" "$APP/Contents/Info.plist" 2>/dev/null || + /usr/libexec/PlistBuddy -c "Add :CFBundleName string LearnDeck" "$APP/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string dev.learndeck.local" "$APP/Contents/Info.plist" 2>/dev/null || + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier dev.learndeck.local" "$APP/Contents/Info.plist" + +echo "==> Done: $APP" +echo " open \"$APP\"" diff --git a/src/integrations.ts b/src/integrations.ts index 63bcaf0..4f06b3b 100644 --- a/src/integrations.ts +++ b/src/integrations.ts @@ -54,7 +54,7 @@ export class IntegrationService { private readonly findExecutable: (name: string) => string | undefined; private readonly run: Runner; - constructor(readonly root = resolve(import.meta.dir, ".."), options: IntegrationOptions = {}) { + constructor(readonly root = resolve(process.env.LEARNDECK_ROOT ?? resolve(import.meta.dir, "..")), options: IntegrationOptions = {}) { this.homeDirectory = options.homeDirectory ?? homedir(); this.operatingSystem = options.operatingSystem ?? platform(); this.findExecutable = options.findExecutable ?? ((name) => Bun.which(name) ?? undefined); diff --git a/src/server.ts b/src/server.ts index 4def174..f8b56ee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ import { CourseCatalog } from "./course"; import { IntegrationError, IntegrationService, isIntegrationId } from "./integrations"; import { CourseStore } from "./store"; -const PUBLIC = `${import.meta.dir}/../public`; +const PUBLIC = process.env.LEARNDECK_PUBLIC_DIR ?? `${import.meta.dir}/../public`; export async function createApp(store = new CourseStore(), catalog?: CourseCatalog, integrations = new IntegrationService()) { let resolvedCatalog = catalog; From 3f37d17ce9c4641e63bb504b080a55c4bce718d8 Mon Sep 17 00:00:00 2001 From: Kevin Mamaqi Kapllani Date: Sat, 18 Jul 2026 20:19:07 +0200 Subject: [PATCH 2/7] feat: native AppKit shell for the Mac app Co-Authored-By: Claude Fable 5 --- native/macos/LearnDeckApp.swift | 412 ++++++++++++++++++++++++++++++++ scripts/package-macos.sh | 106 ++++---- 2 files changed, 461 insertions(+), 57 deletions(-) create mode 100644 native/macos/LearnDeckApp.swift diff --git a/native/macos/LearnDeckApp.swift b/native/macos/LearnDeckApp.swift new file mode 100644 index 0000000..d3a7f03 --- /dev/null +++ b/native/macos/LearnDeckApp.swift @@ -0,0 +1,412 @@ +import AppKit +import Darwin +import Foundation +import WebKit + +private enum LaunchError: LocalizedError { + case missingResource(String) + case unableToCreateDirectory(String) + case unableToCreateLog(String) + case unableToFindPort + case unableToStartServer(String) + + var errorDescription: String? { + switch self { + case .missingResource(let resource): + return "The app bundle is missing \(resource)." + case .unableToCreateDirectory(let path): + return "LearnDeck could not create its data directory at \(path)." + case .unableToCreateLog(let path): + return "LearnDeck could not open its server log at \(path)." + case .unableToFindPort: + return "LearnDeck could not find a free local TCP port." + case .unableToStartServer(let message): + return "LearnDeck could not start its local server: \(message)" + } + } +} + +private final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + private let localServerURL: URL + + init(localServerURL: URL) { + self.localServerURL = localServerURL + super.init() + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + let opensInNewWindow = navigationAction.targetFrame == nil + let isLocalServerURL = url.scheme == localServerURL.scheme + && url.host == localServerURL.host + && url.port == localServerURL.port + + if opensInNewWindow || !isLocalServerURL { + if let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) { + NSWorkspace.shared.open(url) + } + decisionHandler(.cancel) + return + } + + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if let url = navigationAction.request.url { + NSWorkspace.shared.open(url) + } + return nil + } +} + +private func responderSelector(_ name: String) -> Selector { + NSSelectorFromString(name) +} + +@MainActor +private final class LearnDeckAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + private var window: NSWindow? + private var webView: WKWebView? + private var webViewCoordinator: WebViewCoordinator? + private var serverProcess: Process? + private var serverLogHandle: FileHandle? + private var serverProcessGroupIsolated = false + private var signalSources: [DispatchSourceSignal] = [] + + private var dataDirectory = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("LearnDeck", isDirectory: true) + private var logURL = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("LearnDeck", isDirectory: true) + .appendingPathComponent("server.log") + private var portFileURL: URL? + private var pidFileURL: URL? + + func applicationDidFinishLaunching(_ notification: Notification) { + configureMenuBar() + installSignalHandlers() + + do { + let serverURL = try launchServer() + waitForServer(at: serverURL) + } catch { + showStartupFailure(error.localizedDescription) + } + } + + func applicationWillTerminate(_ notification: Notification) { + stopServer() + signalSources.forEach { $0.cancel() } + signalSources.removeAll() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + private func configureMenuBar() { + let menuBar = NSMenu() + + let applicationMenuItem = NSMenuItem() + let applicationMenu = NSMenu(title: "LearnDeck") + applicationMenu.addItem(withTitle: "About LearnDeck", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + applicationMenu.addItem(.separator()) + applicationMenu.addItem(withTitle: "Quit LearnDeck", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + applicationMenuItem.submenu = applicationMenu + menuBar.addItem(applicationMenuItem) + + let editMenuItem = NSMenuItem() + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(withTitle: "Undo", action: responderSelector("undo:"), keyEquivalent: "z") + let redoItem = editMenu.addItem(withTitle: "Redo", action: responderSelector("redo:"), keyEquivalent: "Z") + redoItem.keyEquivalentModifierMask = [.command, .shift] + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: responderSelector("cut:"), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: responderSelector("copy:"), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: responderSelector("paste:"), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: responderSelector("selectAll:"), keyEquivalent: "a") + editMenuItem.submenu = editMenu + menuBar.addItem(editMenuItem) + + let windowMenuItem = NSMenuItem() + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + windowMenuItem.submenu = windowMenu + menuBar.addItem(windowMenuItem) + + NSApp.mainMenu = menuBar + NSApp.windowsMenu = windowMenu + } + + private func installSignalHandlers() { + for signalNumber in [SIGTERM, SIGINT, SIGHUP] { + Darwin.signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) + source.setEventHandler { [weak self] in + self?.stopServer() + NSApp.terminate(nil) + } + source.resume() + signalSources.append(source) + } + } + + private func launchServer() throws -> URL { + let fileManager = FileManager.default + let applicationSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true) + dataDirectory = applicationSupport.appendingPathComponent("LearnDeck", isDirectory: true) + logURL = dataDirectory.appendingPathComponent("server.log") + portFileURL = dataDirectory.appendingPathComponent("server.port") + pidFileURL = dataDirectory.appendingPathComponent("server.pid") + + do { + try fileManager.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + } catch { + throw LaunchError.unableToCreateDirectory(dataDirectory.path) + } + + guard let resourceURL = Bundle.main.resourceURL else { + throw LaunchError.missingResource("Contents/Resources") + } + let payloadURL = resourceURL.appendingPathComponent("learndeck", isDirectory: true) + let serverExecutableURL = payloadURL.appendingPathComponent("learndeck-server") + guard fileManager.fileExists(atPath: serverExecutableURL.path) else { + throw LaunchError.missingResource("Contents/Resources/learndeck/learndeck-server") + } + + guard let packageRoot = Bundle.main.object(forInfoDictionaryKey: "LearnDeckRoot") as? String, + !packageRoot.isEmpty else { + throw LaunchError.missingResource("LearnDeckRoot in Contents/Info.plist") + } + + let port = try Self.findFreePort() + let portURL = dataDirectory.appendingPathComponent("server.port") + let pidURL = dataDirectory.appendingPathComponent("server.pid") + try? fileManager.removeItem(at: portURL) + try? fileManager.removeItem(at: pidURL) + try String(port).write(to: portURL, atomically: true, encoding: .utf8) + + do { + try Data().write(to: logURL, options: .atomic) + } catch { + throw LaunchError.unableToCreateLog(logURL.path) + } + guard let logHandle = FileHandle(forWritingAtPath: logURL.path) else { + throw LaunchError.unableToCreateLog(logURL.path) + } + logHandle.seekToEndOfFile() + + var environment = ProcessInfo.processInfo.environment + environment["LEARNDECK_PUBLIC_DIR"] = payloadURL.appendingPathComponent("public", isDirectory: true).path + environment["LEARNDECK_COURSES_DIR"] = payloadURL.appendingPathComponent("courses", isDirectory: true).path + environment["LEARNDECK_DB_PATH"] = dataDirectory.appendingPathComponent("progress.db").path + environment["LEARNDECK_COURSE_CACHE_DIR"] = dataDirectory.appendingPathComponent("course-cache", isDirectory: true).path + environment["LEARNDECK_ROOT"] = packageRoot + environment["PORT"] = String(port) + + let process = Process() + process.executableURL = serverExecutableURL + process.currentDirectoryURL = payloadURL + process.environment = environment + process.standardOutput = logHandle + process.standardError = logHandle + + do { + try process.run() + } catch { + try? logHandle.close() + throw LaunchError.unableToStartServer(error.localizedDescription) + } + + serverProcess = process + serverLogHandle = logHandle + let processID = process.processIdentifier + serverProcessGroupIsolated = Darwin.setpgid(processID, processID) == 0 + try? String(processID).write(to: pidURL, atomically: true, encoding: .utf8) + + return URL(string: "http://127.0.0.1:\(port)/")! + } + + private func waitForServer(at url: URL) { + Task { [weak self] in + let ready = await Self.serverIsReady(at: url) + guard let self else { return } + if ready { + self.showWindow(for: url) + } else { + self.showStartupFailure("The server did not return HTTP 200 from / before the startup timeout.") + } + } + } + + private nonisolated static func serverIsReady(at url: URL) async -> Bool { + let deadline = Date().addingTimeInterval(20) + while Date() < deadline { + var request = URLRequest(url: url) + request.timeoutInterval = 1 + do { + let (_, response) = try await URLSession.shared.data(for: request) + if (response as? HTTPURLResponse)?.statusCode == 200 { + return true + } + } catch { + // The server may still be binding its port; keep polling until the deadline. + } + try? await Task.sleep(nanoseconds: 250_000_000) + } + return false + } + + private func showWindow(for serverURL: URL) { + let configuration = WKWebViewConfiguration() + let coordinator = WebViewCoordinator(localServerURL: serverURL) + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = coordinator + view.uiDelegate = coordinator + view.autoresizingMask = [.width, .height] + + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1280, height: 860), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + newWindow.title = "LearnDeck" + newWindow.minSize = NSSize(width: 980, height: 700) + newWindow.contentView = view + newWindow.delegate = self + newWindow.isReleasedWhenClosed = false + newWindow.center() + newWindow.makeKeyAndOrderFront(nil) + + webViewCoordinator = coordinator + webView = view + window = newWindow + view.load(URLRequest(url: serverURL)) + NSApp.activate(ignoringOtherApps: true) + } + + private func showStartupFailure(_ detail: String) { + stopServer() + + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "LearnDeck could not start" + alert.informativeText = "\(detail)\n\nServer log: \(logURL.path)" + alert.addButton(withTitle: "Quit") + alert.runModal() + NSApp.terminate(nil) + } + + private func stopServer() { + guard let process = serverProcess else { + cleanupServerFiles() + return + } + + let processID = process.processIdentifier + if process.isRunning { + if serverProcessGroupIsolated { + _ = Darwin.kill(-processID, SIGTERM) + } + process.terminate() + + let deadline = Date().addingTimeInterval(2) + while process.isRunning && Date() < deadline { + usleep(50_000) + } + if process.isRunning { + if serverProcessGroupIsolated { + _ = Darwin.kill(-processID, SIGKILL) + } + _ = Darwin.kill(processID, SIGKILL) + } + } + process.waitUntilExit() + serverProcess = nil + serverProcessGroupIsolated = false + + try? serverLogHandle?.close() + serverLogHandle = nil + cleanupServerFiles() + } + + private func cleanupServerFiles() { + if let portFileURL { + try? FileManager.default.removeItem(at: portFileURL) + } + if let pidFileURL { + try? FileManager.default.removeItem(at: pidFileURL) + } + portFileURL = nil + pidFileURL = nil + } + + private nonisolated static func findFreePort() throws -> Int { + let socketDescriptor = Darwin.socket(AF_INET, SOCK_STREAM, 0) + guard socketDescriptor >= 0 else { + throw LaunchError.unableToFindPort + } + defer { Darwin.close(socketDescriptor) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(0).bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + Darwin.bind(socketDescriptor, socketAddress, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw LaunchError.unableToFindPort + } + + var assignedAddress = sockaddr_in() + var addressLength = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &assignedAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + Darwin.getsockname(socketDescriptor, socketAddress, &addressLength) + } + } + guard nameResult == 0 else { + throw LaunchError.unableToFindPort + } + + return Int(UInt16(bigEndian: assignedAddress.sin_port)) + } +} + +@main +@MainActor +private struct LearnDeckApp { + static func main() { + let application = NSApplication.shared + let delegate = LearnDeckAppDelegate() + application.delegate = delegate + application.setActivationPolicy(.regular) + application.run() + } +} diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 2a3b855..6c0c8da 100755 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -7,24 +7,28 @@ # # What it produces: dist/LearnDeck.app # Contents/Resources/learndeck/ compiled server + public/ courses/ references/ -# Launch: starts the server on a free port and opens your browser. -# Quit (Dock icon > Quit): stops the server. +# Contents/MacOS/LearnDeck native AppKit/WKWebView executable +# Launch: starts the server on a free port and opens a native app window. +# Quit (Cmd+Q or Dock icon > Quit): stops the server. # # Data lives outside the app so rebuilds never touch progress: # ~/Library/Application Support/LearnDeck/progress.db and course-cache/ # # Known limitation: connecting an AI guide from the app writes an MCP entry -# pointing at THIS repository checkout (it needs src/mcp.ts plus installed -# dependencies), so keep the repo where it is or reconnect after moving it. +# pointing at THIS repository checkout (LearnDeckRoot is baked into +# Contents/Info.plist; it needs src/mcp.ts plus installed dependencies), so +# keep the repo where it is or reconnect after moving it. set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" DIST="$REPO/dist" APP="$DIST/LearnDeck.app" PAYLOAD_STAGE="$DIST/.macos-payload" +SWIFT_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/learndeck-swift-module-cache" -command -v osacompile >/dev/null || { echo "osacompile not found (macOS required)"; exit 1; } +command -v swiftc >/dev/null || { echo "swiftc not found (macOS required)"; exit 1; } command -v bun >/dev/null || { echo "bun not found"; exit 1; } +mkdir -p "$SWIFT_MODULE_CACHE_PATH" echo "==> Compiling server binary" rm -rf "$PAYLOAD_STAGE" @@ -36,64 +40,52 @@ cp -R "$REPO/public" "$PAYLOAD_STAGE/public" cp -R "$REPO/courses" "$PAYLOAD_STAGE/courses" cp -R "$REPO/references" "$PAYLOAD_STAGE/references" -cat > "$PAYLOAD_STAGE/launch.sh" </dev/null 2>&1; do PORT=\$((PORT+1)); done - -export LEARNDECK_PUBLIC_DIR="\$HERE/public" -export LEARNDECK_COURSES_DIR="\$HERE/courses" -export LEARNDECK_DB_PATH="\$DATA/progress.db" -export LEARNDECK_COURSE_CACHE_DIR="\$DATA/course-cache" -export LEARNDECK_ROOT="$REPO" -export PORT - -nohup "\$HERE/learndeck-server" >> "\$DATA/server.log" 2>&1 & -echo \$! > "\$DATA/server.pid" -echo \$PORT > "\$DATA/server.port" - -for _ in \$(seq 1 40); do - if curl -s -o /dev/null "http://127.0.0.1:\$PORT/"; then break; fi - sleep 0.25 -done -open "http://127.0.0.1:\$PORT/" -LAUNCH -chmod +x "$PAYLOAD_STAGE/launch.sh" - echo "==> Building app bundle" rm -rf "$APP" -APPLET_SRC="$DIST/.learndeck-applet.applescript" -cat > "$APPLET_SRC" <<'APPLET' -on run - set resourceRoot to POSIX path of (path to resource "learndeck") - do shell script quoted form of (resourceRoot & "/launch.sh") -end run +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -on idle - return 3600 -end idle +# LearnDeckRoot is the one package-time value the native shell cannot derive +# after the app is moved. The Swift app reads it from this hand-written plist +# and computes all other paths at launch. +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName + LearnDeck + CFBundleDisplayName + LearnDeck + CFBundleIdentifier + dev.learndeck.local + CFBundleExecutable + LearnDeck + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.4.0 + CFBundleVersion + 0.4.0 + NSHighResolutionCapable + + LSMinimumSystemVersion + 12.0 + LearnDeckRoot + $REPO + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + +PLIST -on quit - set dataDir to POSIX path of (path to application support folder from user domain) & "LearnDeck" - try - do shell script "kill $(cat " & quoted form of (dataDir & "/server.pid") & ") 2>/dev/null; rm -f " & quoted form of (dataDir & "/server.pid") - end try - continue quit -end quit -APPLET -osacompile -s -o "$APP" "$APPLET_SRC" -rm -f "$APPLET_SRC" +(cd "$REPO" && CLANG_MODULE_CACHE_PATH="$SWIFT_MODULE_CACHE_PATH" swiftc -O -parse-as-library native/macos/LearnDeckApp.swift \ + -o "$APP/Contents/MacOS/LearnDeck" \ + -framework AppKit -framework WebKit) mv "$PAYLOAD_STAGE" "$APP/Contents/Resources/learndeck" -/usr/libexec/PlistBuddy -c "Set :CFBundleName LearnDeck" "$APP/Contents/Info.plist" 2>/dev/null || - /usr/libexec/PlistBuddy -c "Add :CFBundleName string LearnDeck" "$APP/Contents/Info.plist" -/usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string dev.learndeck.local" "$APP/Contents/Info.plist" 2>/dev/null || - /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier dev.learndeck.local" "$APP/Contents/Info.plist" echo "==> Done: $APP" echo " open \"$APP\"" From 65e6606ba9a3d0f6a974f302184d462f07ff0be9 Mon Sep 17 00:00:00 2001 From: Kevin Mamaqi Kapllani Date: Sat, 18 Jul 2026 21:20:57 +0200 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20backend=20correctness=20pass=20?= =?UTF-8?q?=E2=80=94=20TOML=20corruption,=20evidence=20provenance,=20progr?= =?UTF-8?q?ess=20downgrades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - integrations: TOML block regex matched only the section header, so disconnect/repair corrupted ~/.codex/config.toml and Codex always reported stale; now matches the full block - store: legacy evidence backfill ran on every open, duplicating learner notes as guide evidence; now runs only when the table is first created - store: evidence recording, answer submission, correct evaluation, and self-review no longer downgrade complete/self_reviewed sections (siblings of 7642d37), including a stuck-nextActivity scenario - course: reject '..' segments in course repository branch refs (cache dir escape on a path the sync deletes recursively) - store: resetPath now counts deleted evidence rows, not sections - server: clear 400 for non-string label input Adds regression tests for each fix (43 tests green). Co-Authored-By: Claude Fable 5 --- src/course.ts | 5 + src/integrations.ts | 11 ++- src/server.ts | 1 + src/store.ts | 38 +++++++- test/integrations.test.ts | 77 +++++++++++++++ test/provenance.test.ts | 9 ++ test/regression-evidence.test.ts | 53 ++++++++++ test/regression-progress-downgrade.test.ts | 107 +++++++++++++++++++++ 8 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 test/regression-evidence.test.ts create mode 100644 test/regression-progress-downgrade.test.ts diff --git a/src/course.ts b/src/course.ts index c93e1b4..18be554 100644 --- a/src/course.ts +++ b/src/course.ts @@ -273,6 +273,11 @@ type GitHubRepository = { owner: string; repository: string; branch: string }; function parseGitHubRepository(value: string): GitHubRepository { const match = value.match(/^github:([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)@([A-Za-z0-9._/-]+)$/); if (!match) throw new Error(`${GITHUB_REPOSITORY_ENV} must use github:owner/repository@branch.`); + // Git forbids "." and ".." ref segments; here they would also let the cache + // directory (which gets recursively removed on sync) escape the cache root. + if (match[3].split("/").some((segment) => segment === "." || segment === "..")) { + throw new Error(`${GITHUB_REPOSITORY_ENV} branch must not contain "." or ".." path segments.`); + } return { owner: match[1], repository: match[2], branch: match[3] }; } diff --git a/src/integrations.ts b/src/integrations.ts index 4f06b3b..a165266 100644 --- a/src/integrations.ts +++ b/src/integrations.ts @@ -305,7 +305,14 @@ function extractTomlMcpPath(configuration: string | undefined, name: string) { function tomlServerSection(configuration: string | undefined, name: string) { if (!configuration) return undefined; - return configuration.match(new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}\\]\\s*$[\\s\\S]*?(?=^\\s*\\[|$)`, "m"))?.[0]; + return configuration.match(tomlServerBlockPattern(name))?.[0]; +} + +// The block runs from the [mcp_servers.] header to the next table header or the +// absolute end of the file. `$` alone would stop at the first end-of-line under the m flag +// and strand the block's command/args keys in the preceding table. +function tomlServerBlockPattern(name: string) { + return new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}\\]\\s*$[\\s\\S]*?(?=^\\s*\\[|(?![\\s\\S]))`, "m"); } function upsertTomlServer(configuration: string, name: string, entry: { command: string; args: string[] }) { @@ -315,7 +322,7 @@ function upsertTomlServer(configuration: string, name: string, entry: { command: } function removeTomlServer(configuration: string, name: string) { - return configuration.replace(new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}\\]\\s*$[\\s\\S]*?(?=^\\s*\\[|$)`, "m"), "").replace(/\n{3,}/g, "\n\n"); + return configuration.replace(tomlServerBlockPattern(name), "").replace(/\n{3,}/g, "\n\n"); } async function atomicWrite(path: string, content: string) { diff --git a/src/server.ts b/src/server.ts index f8b56ee..79e0561 100644 --- a/src/server.ts +++ b/src/server.ts @@ -48,6 +48,7 @@ export async function createApp(store = new CourseStore(), catalog?: CourseCatal const course = courses.get(decodeURIComponent(coursePathsRoute[1])); const body = await request.json(); const input = requireObject(body, ["coursePathId", "workspacePath"]) as { coursePathId: string; workspacePath: string; label?: string }; + if (input.label !== undefined && typeof input.label !== "string") throw new Error("label must be a string when provided."); if (!course.paths.some((path) => path.id === input.coursePathId)) throw new Error(`Unknown course path: ${input.coursePathId}`); const workspacePath = input.workspacePath.trim(); const workspaceCreated = await prepareWorkspace(workspacePath); diff --git a/src/store.ts b/src/store.ts index 61373b0..a36de6b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -80,6 +80,7 @@ export class CourseStore { } private migrate() { + const evidenceTableExisted = this.tableExists("evidence"); const sectionProgressNeedsMigration = this.tableNeedsValue("section_progress", "self_reviewed"); const questionAttemptsNeedMigration = this.tableNeedsValue("question_attempts", "self_reviewed"); if (sectionProgressNeedsMigration) this.db.exec("ALTER TABLE section_progress RENAME TO section_progress_legacy;"); @@ -158,6 +159,9 @@ export class CourseStore { // Older databases stored one unlabelled evidence value on section_progress. // Preserve it as guide evidence when the additive evidence table is first introduced. + // This must run only on that first introduction: re-running it on every open would + // copy learner notes held on section_progress into phantom guide evidence rows. + if (evidenceTableExisted) return; this.db.exec(` INSERT INTO evidence (path_id, section_id, note, source, review_question, recorded_at) SELECT progress.path_id, progress.section_id, progress.evidence, 'guide', progress.review_question, progress.updated_at @@ -173,6 +177,12 @@ export class CourseStore { `); } + private tableExists(tableName: string) { + return Boolean( + this.db.query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName), + ); + } + private tableNeedsValue(tableName: "section_progress" | "question_attempts", value: string) { const row = this.db .query<{ sql: string | null }, [string]>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?") @@ -223,7 +233,7 @@ export class CourseStore { this.getPath(pathId); const reset = this.db.transaction((id: string): PathResetResult => { const attempts = this.db.query<{ count: number }, [string]>("SELECT COUNT(*) AS count FROM question_attempts WHERE path_id = ?").get(id)?.count ?? 0; - const evidence = this.db.query<{ count: number }, [string]>("SELECT COUNT(*) AS count FROM section_progress WHERE path_id = ? AND evidence IS NOT NULL").get(id)?.count ?? 0; + const evidence = this.db.query<{ count: number }, [string]>("SELECT COUNT(*) AS count FROM evidence WHERE path_id = ?").get(id)?.count ?? 0; const progressRows = this.db.query<{ count: number }, [string]>("SELECT COUNT(*) AS count FROM section_progress WHERE path_id = ?").get(id)?.count ?? 0; this.db.query("DELETE FROM learning_paths WHERE id = ?").run(id); return { pathId: id, attempts, evidence, progressRows }; @@ -304,7 +314,10 @@ export class CourseStore { RETURNING *`, ) .get(input.pathId, section.id, question.id, question.kind, answer, input.confidence ?? null, question.reference); - this.setProgress(input.pathId, section.id, "active"); + // A bare submission must not downgrade a section the learner already moved past. + const current = this.sectionStatus(input.pathId, section.id); + const status = current === "self_reviewed" || current === "complete" || current === "revision" ? current : "active"; + this.setProgress(input.pathId, section.id, status); this.log(input.pathId, "learner", "answer_submitted", { attemptId: result.id, questionId: question.id }); return mapAttempt(result); } @@ -325,7 +338,10 @@ export class CourseStore { ) .get(input.result, feedback, input.attemptId); - const status: SectionStatus = input.result === "correct" && question.kind === "exit" ? "complete" : input.result === "correct" ? "active" : "revision"; + const proposed: SectionStatus = input.result === "correct" && question.kind === "exit" ? "complete" : input.result === "correct" ? "active" : "revision"; + // A correct non-exit answer must not downgrade a section already completed or self-reviewed. + const current = this.sectionStatus(attempt.pathId, section.id); + const status = proposed === "active" && (current === "complete" || current === "self_reviewed") ? current : proposed; const evidence = input.evidence?.trim() || undefined; if (evidence) this.addEvidence(attempt.pathId, section.id, evidence, null, "guide", input.reviewQuestion); this.setProgress(attempt.pathId, section.id, status, evidence, input.reviewQuestion); @@ -348,7 +364,10 @@ export class CourseStore { if (path.courseId !== course.id) throw new Error("This path belongs to a different course."); getSection(course, input.sectionId); this.addEvidence(input.pathId, input.sectionId, evidence, null, "guide", input.reviewQuestion); - this.setProgress(input.pathId, input.sectionId, "active", evidence, input.reviewQuestion); + // Guide evidence must never downgrade a section the learner already moved past. + const current = this.sectionStatus(input.pathId, input.sectionId); + const status = current === "self_reviewed" || current === "complete" || current === "revision" ? current : "active"; + this.setProgress(input.pathId, input.sectionId, status, evidence, input.reviewQuestion); this.log(input.pathId, "agent", "evidence_recorded", { sectionId: input.sectionId, evidence }); return this.progress(input.pathId).find((item) => item.sectionId === input.sectionId)!; } @@ -384,11 +403,20 @@ export class CourseStore { ) .get(attemptId); if (!reviewed) throw new Error(`Unknown attempt: ${attemptId}`); - if (question.kind === "exit") this.setProgress(attempt.pathId, section.id, "self_reviewed"); + // Self-review must never downgrade a section a guide already marked complete. + if (question.kind === "exit" && this.sectionStatus(attempt.pathId, section.id) !== "complete") { + this.setProgress(attempt.pathId, section.id, "self_reviewed"); + } this.log(attempt.pathId, "learner", "answer_self_reviewed", { attemptId, questionId: question.id, result: "self_reviewed" }); return { attemptId, result: "self_reviewed" }; } + private sectionStatus(pathId: string, sectionId: string): SectionStatus | undefined { + return this.db + .query<{ status: SectionStatus }, [string, string]>("SELECT status FROM section_progress WHERE path_id = ? AND section_id = ?") + .get(pathId, sectionId)?.status; + } + private progress(pathId: string): SectionProgress[] { return this.db .query( diff --git a/test/integrations.test.ts b/test/integrations.test.ts index 4249e55..a57c352 100644 --- a/test/integrations.test.ts +++ b/test/integrations.test.ts @@ -54,6 +54,83 @@ describe("IntegrationService", () => { }); }); + test("reports a connected Codex when its TOML entry points at the current script", async () => { + const appRoot = join(directory, "app"); + const expectedEntry = join(appRoot, "src", "mcp.ts"); + mkdirSync(join(appRoot, "src"), { recursive: true }); + writeFileSync(expectedEntry, "export {};\n"); + mkdirSync(join(directory, ".codex")); + writeFileSync( + join(directory, ".codex", "config.toml"), + `[mcp_servers.learndeck]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(expectedEntry)}]\n`, + ); + const service = new IntegrationService(appRoot, { homeDirectory: directory }); + + expect((await service.list()).find((item) => item.id === "codex")).toMatchObject({ + configured: true, + status: "connected", + }); + }); + + test("disconnect removes only LearnDeck's Codex TOML block", async () => { + const configPath = join(directory, ".codex", "config.toml"); + mkdirSync(join(directory, ".codex")); + writeFileSync(configPath, [ + "[model]", + 'name = "gpt"', + "", + "[mcp_servers.learndeck]", + 'command = "/usr/bin/bun"', + 'args = ["/opt/x/src/mcp.ts"]', + "", + "[mcp_servers.other]", + 'command = "keep-me"', + "", + ].join("\n")); + const service = new IntegrationService("/opt/patchquest", { homeDirectory: directory }); + + const result = await service.disconnect("codex"); + const configuration = await Bun.file(configPath).text(); + expect(result).toMatchObject({ integrationId: "codex", configPath, removed: true }); + expect(configuration).toContain("[model]"); + expect(configuration).toContain('name = "gpt"'); + expect(configuration).toContain("[mcp_servers.other]"); + expect(configuration).toContain('command = "keep-me"'); + expect(configuration).not.toContain("learndeck"); + expect(configuration).not.toContain('command = "/usr/bin/bun"'); + expect(configuration).not.toContain("/opt/x/src/mcp.ts"); + }); + + test("repairing a stale Codex entry rewrites only LearnDeck's block", async () => { + const appRoot = join(directory, "app"); + const expectedEntry = join(appRoot, "src", "mcp.ts"); + mkdirSync(join(appRoot, "src"), { recursive: true }); + writeFileSync(expectedEntry, "export {};\n"); + const configPath = join(directory, ".codex", "config.toml"); + mkdirSync(join(directory, ".codex")); + writeFileSync(configPath, [ + "[model]", + 'name = "gpt"', + "", + "[mcp_servers.learndeck]", + 'command = "/usr/bin/bun"', + 'args = ["/moved/src/mcp.ts"]', + "", + ].join("\n")); + const service = new IntegrationService(appRoot, { homeDirectory: directory }); + + expect((await service.list()).find((item) => item.id === "codex")?.status).toBe("stale"); + await service.connect("codex"); + + const configuration = await Bun.file(configPath).text(); + expect(configuration).toContain("[model]"); + expect(configuration).toContain('name = "gpt"'); + expect(configuration.match(/\[mcp_servers\.learndeck\]/g)).toHaveLength(1); + expect(configuration).toContain(JSON.stringify(expectedEntry)); + expect(configuration).not.toContain("/moved/src/mcp.ts"); + expect((await service.list()).find((item) => item.id === "codex")?.status).toBe("connected"); + }); + test("detects configured Codex and uses its documented MCP command only after connect", async () => { const configDirectory = join(directory, ".codex"); mkdirSync(configDirectory); diff --git a/test/provenance.test.ts b/test/provenance.test.ts index 8433e71..e2edf03 100644 --- a/test/provenance.test.ts +++ b/test/provenance.test.ts @@ -40,6 +40,15 @@ describe("catalogue provenance", () => { } }); + test("rejects a course repository branch with parent path segments", async () => { + process.env.LEARNDECK_COURSE_REPOSITORY = "github:owner/repo@feature/../../../../escape"; + delete process.env.LEARNDECK_COURSE_CACHE_DIR; + globalThis.fetch = async () => { + throw new Error("The repository spec must be rejected before any network access."); + }; + await expect(CourseCatalog.loadConfigured()).rejects.toThrow(/path segments/); + }); + test("reports a cached catalogue and warning after a failed sync", async () => { const directory = mkdtempSync(join(tmpdir(), "learndeck-provenance-cache-")); const files: Record = { diff --git a/test/regression-evidence.test.ts b/test/regression-evidence.test.ts new file mode 100644 index 0000000..67855cc --- /dev/null +++ b/test/regression-evidence.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CourseCatalog } from "../src/course"; +import { CourseStore } from "../src/store"; + +describe("regression: evidence integrity across restarts and resets", () => { + let directory: string; + let databasePath: string; + let store: CourseStore; + let course: ReturnType; + + beforeEach(async () => { + directory = mkdtempSync(join(tmpdir(), "learndeck-evidence-")); + databasePath = join(directory, "progress.db"); + store = new CourseStore(databasePath); + course = (await CourseCatalog.load()).get("ddd-backend-foundations"); + }); + + afterEach(() => { + store.close(); + rmSync(directory, { recursive: true, force: true }); + }); + + test("reopening the store does not duplicate learner evidence as guide evidence", () => { + const path = store.createPath(course, { coursePathId: "node-typescript", workspacePath: "/work/restart" }); + store.recordLearnerEvidence(course, { + pathId: path.id, + sectionId: "start", + note: "Ran npm test and saw the health route pass.", + ref: "src/server.ts:12", + }); + store.close(); + + store = new CourseStore(databasePath); + const overview = store.overview(course, path.id); + expect(overview.evidence).toHaveLength(1); + expect(overview.evidence[0]).toMatchObject({ sectionId: "start", source: "learner" }); + expect(overview.progress.find((item) => item.sectionId === "start")?.evidenceSource).toBe("learner"); + }); + + test("reset reports every deleted evidence record", () => { + const path = store.createPath(course, { coursePathId: "node-typescript", workspacePath: "/work/reset-count" }); + store.recordLearnerEvidence(course, { pathId: path.id, sectionId: "start", note: "First learner note." }); + store.recordLearnerEvidence(course, { pathId: path.id, sectionId: "start", note: "Second learner note." }); + store.recordEvidence(course, { pathId: path.id, sectionId: "start", evidence: "Guide-confirmed status route check." }); + + const result = store.resetPath(path.id); + expect(result.evidence).toBe(3); + expect(result.progressRows).toBe(course.sections.length); + }); +}); diff --git a/test/regression-progress-downgrade.test.ts b/test/regression-progress-downgrade.test.ts new file mode 100644 index 0000000..d7d1a6f --- /dev/null +++ b/test/regression-progress-downgrade.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CourseCatalog } from "../src/course"; +import { CourseStore } from "../src/store"; + +describe("regression: later activity never downgrades section progress", () => { + let directory: string; + let store: CourseStore; + let course: ReturnType; + + beforeEach(async () => { + directory = mkdtempSync(join(tmpdir(), "learndeck-downgrade-")); + store = new CourseStore(join(directory, "progress.db")); + course = (await CourseCatalog.load()).get("ddd-backend-foundations"); + }); + + afterEach(() => { + store.close(); + rmSync(directory, { recursive: true, force: true }); + }); + + function createPath() { + return store.createPath(course, { coursePathId: "node-typescript", workspacePath: `/work/${crypto.randomUUID()}` }); + } + + function completeStartSection(pathId: string) { + const exit = store.submitAnswer(course, { + pathId, + questionId: "start-evidence", + answer: "The project lives in its own workspace, runs with npm run dev, and exposes the status route.", + }); + store.evaluateAttempt(course, { + attemptId: exit.id, + result: "correct", + feedback: "Target: path evidence. Observed: workspace, command, and route are all named. Next: model one invariant.", + }); + } + + function startStatus(pathId: string) { + return store.overview(course, pathId).progress.find((item) => item.sectionId === "start")?.status; + } + + test("guide evidence does not downgrade a complete section", () => { + const path = createPath(); + completeStartSection(path.id); + store.recordEvidence(course, { pathId: path.id, sectionId: "start", evidence: "The learner reran the status route check." }); + expect(startStatus(path.id)).toBe("complete"); + }); + + test("guide evidence does not downgrade a self-reviewed section", () => { + const path = createPath(); + const exit = store.submitAnswer(course, { + pathId: path.id, + questionId: "start-evidence", + answer: "The workspace is confirmed and the route responds.", + }); + store.selfReviewAttempt(course, exit.id); + store.recordEvidence(course, { pathId: path.id, sectionId: "start", evidence: "The learner shared the passing command output." }); + expect(startStatus(path.id)).toBe("self_reviewed"); + }); + + test("submitting a new answer does not downgrade a complete section", () => { + const path = createPath(); + completeStartSection(path.id); + store.submitAnswer(course, { + pathId: path.id, + questionId: "start-boundary", + answer: "The runner and the learner project stay separate so course material cannot overwrite code.", + }); + expect(startStatus(path.id)).toBe("complete"); + }); + + test("a correct non-exit evaluation keeps a complete section complete and next activity advances", () => { + const path = createPath(); + completeStartSection(path.id); + const review = store.submitAnswer(course, { + pathId: path.id, + questionId: "start-boundary", + answer: "The boundary keeps runner material out of the learner project.", + }); + store.evaluateAttempt(course, { + attemptId: review.id, + result: "correct", + feedback: "Target: boundary. Observed: correct ownership split. Next: continue with the domain section.", + }); + + expect(startStatus(path.id)).toBe("complete"); + expect(store.overview(course, path.id).completedSections).toBe(1); + expect(store.nextActivity(course, path.id).section.id).toBe("domain"); + }); + + test("self-reviewing a new exit attempt does not downgrade a complete section", () => { + const path = createPath(); + completeStartSection(path.id); + const again = store.submitAnswer(course, { + pathId: path.id, + questionId: "start-evidence", + answer: "Restating the evidence for my own review.", + }); + store.selfReviewAttempt(course, again.id); + + expect(startStatus(path.id)).toBe("complete"); + expect(store.overview(course, path.id).completedSections).toBe(1); + }); +}); From 80e37e167fc1b3940e4e69ad23f62d5a60ac50e9 Mon Sep 17 00:00:00 2001 From: Kevin Mamaqi Kapllani Date: Sat, 18 Jul 2026 21:20:57 +0200 Subject: [PATCH 4/7] feat: onboarding-style guide connect flow and GitHub-first course library - screen 2 is a single centered onboarding card: guide options (codex, cursor, claude-code), one-line explanation, connect + skip actions; removed the two-column layout and local-progress panel - connecting or skipping lands directly on the course library; returning users with a connected guide skip the step entirely - 'Add your course' block on the library linking to the public courses repository with fork/PR instructions - catalogue provenance framed GitHub-first with bundled packs as the offline fallback - fixes: 4s poll no longer wipes drafts or steals focus, drafts survive re-renders, double-submit guards, unhandled rejections, stale header guide button, zero-question section crash, alert()s replaced with accessible live-region messages Co-Authored-By: Claude Fable 5 --- public/app.css | 26 ++++---- public/app.js | 163 +++++++++++++++++++++++++++++++++++----------- public/index.html | 41 ++++++------ 3 files changed, 156 insertions(+), 74 deletions(-) diff --git a/public/app.css b/public/app.css index 1ef0487..52be26b 100644 --- a/public/app.css +++ b/public/app.css @@ -117,6 +117,7 @@ button[type="submit"]:hover, #start-course:hover, #connect-selected-guides:hover .integration-connect[disabled] { cursor: default; opacity: .45; transform: none; } #connect-selected-guides[disabled] { cursor: default; opacity: .45; transform: none; } .form-note { margin: 0; color: var(--color-content-secondary); font-size: .8rem; line-height: 1.55; } +.form-note.is-error { color: var(--color-feedback-error); } /* The initial view is a complete, quiet screen. The course library is a separate mode. */ .app-home { min-height: calc(100dvh - 88px); } @@ -144,6 +145,14 @@ body:has(#welcome-screen:not(.hidden)) .welcome-screen { min-height: 100dvh; } .category-filters button:hover { color: var(--color-content-primary); border-color: var(--color-border-strong); } .category-filters button[aria-pressed="true"] { color: var(--color-interactive-contrast); background: var(--color-interactive); border-color: var(--color-interactive); } .course-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: var(--space-md); } +.course-grid > .empty { grid-column: 1 / -1; margin: var(--space-md) 0; font-size: .9rem; } +#library-status { margin-top: var(--space-md); } +#library-status:empty { display: none; } +.library-contribute { display: grid; gap: var(--space-sm); justify-items: start; margin-top: var(--space-xl); padding: var(--space-lg); background: var(--color-surface-raised); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); } +.library-contribute p { max-width: 78ch; margin: 0; color: var(--color-content-secondary); font-size: .85rem; line-height: 1.55; } +.library-contribute strong { color: var(--color-content-primary); } +.library-contribute code { padding: 2px 5px; background: var(--color-surface-subtle); border-radius: 4px; font-family: var(--font-mono); font-size: .8em; } +.library-contribute a { font-size: .82rem; font-weight: 700; } .course-card { display: flex; flex-direction: column; align-items: stretch; min-height: 292px; padding: var(--space-lg); color: var(--color-content-primary); background: var(--color-surface-raised); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); text-align: left; transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); } .course-card:hover { background: color-mix(in srgb, var(--color-surface-raised) 88%, var(--color-interactive)); border-color: color-mix(in srgb, var(--color-interactive) 55%, var(--color-border-strong)); transform: translateY(-2px); } .course-card-top, .course-card-meta, .course-card-tags { display: flex; flex-wrap: wrap; gap: var(--space-sm); } @@ -158,18 +167,10 @@ body:has(#welcome-screen:not(.hidden)) .welcome-screen { min-height: 100dvh; } .course-card-action { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-lg); padding-top: var(--space-md); color: var(--color-interactive); border-top: 1px solid var(--color-border-subtle); font-size: .8rem; font-weight: 750; } .course-card-action b { font-size: 1rem; } -/* First start makes its effects and AI-host changes legible before a course begins. */ -.agent-setup { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, .72fr); gap: clamp(var(--space-xl), 10vw, 160px); align-items: center; min-height: calc(100vh - 88px); padding: clamp(72px, 10vw, 148px) 0; } -.agent-setup-copy { max-width: 680px; } -.agent-setup-copy h1 { max-width: 11ch; margin: var(--space-md) 0 var(--space-lg); font-size: clamp(2.9rem, 5.65vw, 5.25rem); line-height: 1.01; letter-spacing: -.062em; } -.agent-setup-copy > p:not(.eyebrow) { max-width: 56ch; margin: 0; color: var(--color-content-secondary); font-size: 1.08rem; line-height: 1.68; } -.activation-steps { display: grid; gap: 0; margin: var(--space-xl) 0 0; padding: 0; list-style: none; border-top: 1px solid var(--color-border-subtle); } -.activation-steps li { display: grid; grid-template-columns: 32px 1fr; gap: var(--space-sm); padding: var(--space-md) 0; border-bottom: 1px solid var(--color-border-subtle); } -.activation-steps li > span { color: var(--color-interactive); font-family: var(--font-mono); font-size: .7rem; font-weight: 700; line-height: 1.8; } -.activation-steps strong { color: var(--color-content-primary); font-size: .88rem; } -.activation-steps p { margin: 2px 0 0; color: var(--color-content-secondary); font-size: .82rem; line-height: 1.45; } -.agent-setup-card { padding: clamp(var(--space-lg), 4vw, var(--space-xl)); background: var(--color-surface-raised); border: 1px solid var(--color-border-strong); border-top: 3px solid var(--color-interactive); box-shadow: var(--shadow-raised); } -.agent-setup-card h2 { margin: var(--space-sm) 0; font-size: clamp(1.9rem, 3.2vw, 2.55rem); line-height: 1.08; letter-spacing: -.04em; } +/* First start is a single concise decision: connect a guide, or skip. */ +.agent-setup { display: grid; place-items: center; min-height: calc(100vh - 88px); padding: clamp(48px, 8vw, 96px) 0; } +.agent-setup-card { width: min(560px, 100%); padding: clamp(var(--space-lg), 4vw, var(--space-xl)); background: var(--color-surface-raised); border: 1px solid var(--color-border-strong); border-top: 3px solid var(--color-interactive); box-shadow: var(--shadow-raised); } +.agent-setup-card h1 { margin: var(--space-sm) 0; font-size: clamp(1.9rem, 3.2vw, 2.55rem); line-height: 1.08; letter-spacing: -.04em; } .agent-setup-card > p:not(.eyebrow):not(.form-note) { margin: 0; color: var(--color-content-secondary); font-size: .88rem; line-height: 1.55; } .agent-setup-list { display: grid; margin: var(--space-lg) 0 0; border-top: 1px solid var(--color-border-subtle); } .guide-setup-option { display: grid; grid-template-columns: 1fr auto; gap: var(--space-sm); align-items: center; padding: var(--space-md) 0; border-bottom: 1px solid var(--color-border-subtle); } @@ -422,7 +423,6 @@ body.is-answering .lesson-progress { top: 0; } .library-heading { align-items: flex-start; flex-direction: column; } .library-actions { width: 100%; justify-content: space-between; } .briefing-intro h1, .workspace-setup h1 { font-size: clamp(2.65rem, 13vw, 3.8rem); } - .agent-setup-copy h1 { font-size: clamp(2.65rem, 13vw, 3.8rem); } .course-facts { grid-template-columns: 1fr; } .course-facts > div + div { padding-left: 0; border-top: 1px solid var(--color-border-subtle); border-left: 0; } .brief-roadmap { grid-template-columns: 1fr; } diff --git a/public/app.js b/public/app.js index 29a022d..0525796 100644 --- a/public/app.js +++ b/public/app.js @@ -24,15 +24,25 @@ const $ = (selector) => document.querySelector(selector); const escape = (value) => String(value).replace(/[&<>"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[character]); async function api(path, options) { - const response = await fetch(path, { - ...options, - headers: { "content-type": "application/json", ...(options?.headers ?? {}) }, - }); - const body = await response.json(); - if (!response.ok) throw new Error(body.error ?? "LearnDeck could not complete that request."); + let response; + try { + response = await fetch(path, { + ...options, + headers: { "content-type": "application/json", ...(options?.headers ?? {}) }, + }); + } catch { + throw new Error("The local LearnDeck server is not responding. Start it with `bun run app`, then try again."); + } + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error([body.error ?? "LearnDeck could not complete that request.", body.userAction].filter(Boolean).join(" ")); return body; } +function setNote(element, message, isError = false) { + element.textContent = message; + element.classList.toggle("is-error", isError); +} + async function boot() { try { initializeTheme(); @@ -89,7 +99,7 @@ function bindEvents() { if (button?.dataset.guideId) setActiveGuide(button.dataset.guideId); }); $("#connect-selected-guides").addEventListener("click", connectSelectedGuides); - $("#continue-to-library").addEventListener("click", () => { setActiveGuide("none"); showLibrary(); }); + $("#continue-to-library").addEventListener("click", () => { ensureActiveGuide(); updateGuideButton(); showLibrary(); }); $("#continue-without-guide").addEventListener("click", continueWithoutGuide); $("#path-form").addEventListener("submit", createPath); $("#change-workspace").addEventListener("click", showWorkspaceSetup); @@ -223,13 +233,15 @@ async function connectIntegration(id) { persistActiveGuide(); renderIntegrations(); renderAgentSetup(); + updateGuideButton(); $("#integration-status").textContent = `${integration.label} connected. Config file changed: ${result.configPath}. ${result.nextStep}`; $("#agent-setup-status").textContent = `${integration.label} connected. Config file changed: ${result.configPath}. ${result.nextStep}`; focusSurface("#integration-status"); } catch (error) { $("#integration-status").textContent = error.message; + $("#agent-setup-status").textContent = error.message; button.disabled = false; - button.textContent = "Connect"; + button.textContent = integration.status === "stale" ? "Reconnect" : "Connect"; } } @@ -246,6 +258,7 @@ async function disconnectIntegration(id) { } renderIntegrations(); renderAgentSetup(); + updateGuideButton(); const message = result.message || `Disconnected ${integration.label}. File changed: ${result.configPath}.`; $("#integration-status").textContent = `${message} No active guide is selected.`; $("#agent-setup-status").textContent = message; @@ -365,6 +378,7 @@ function renderAgentSetup() { const options = $("#active-guide-options"); const configured = state.integrations.filter((integration) => integration.status === "connected"); + options.classList.toggle("hidden", !configured.length); options.replaceChildren(); const copy = document.createElement("p"); copy.className = "active-guide-copy"; @@ -394,9 +408,9 @@ function renderAgentSetup() { const selected = selectable.filter((integration) => state.guideSelection.has(integration.id)); connect.innerHTML = selected.length ? `Connect ${selected.length === 1 ? selected[0].label : `${selected.length} selected guides`} ` : "Connect selected guides"; $("#agent-setup-status").textContent = state.guideSetupMessage || (activeGuideIntegration() - ? `${activeGuideIntegration().label} is selected. Add another guide or choose No active guide; connected guides share local progress.` - : selectable.length ? "Choose any detected guide, or choose No active guide. Connecting changes only LearnDeck's local MCP entry." - : "No active guide is selected. You can continue now and connect one later."); + ? `${activeGuideIntegration().label} is selected. Connected guides share local progress.` + : selectable.length ? "Pick any detected guide, or skip and connect one later. Connecting changes only LearnDeck's local MCP entry." + : "No guides detected on this Mac yet. You can skip for now and connect one later."); } async function connectSelectedGuides() { @@ -416,7 +430,11 @@ async function connectSelectedGuides() { failures.push(`${integration.label}: ${error.message}`); } } - state.integrations = await api("/api/integrations"); + try { + state.integrations = await api("/api/integrations"); + } catch (error) { + failures.push(`Guide status refresh failed: ${error.message}`); + } state.guideSelection.clear(); if (connected.length) { state.activeGuideId = connected[0].id; @@ -430,15 +448,27 @@ async function connectSelectedGuides() { renderAgentSetup(); renderIntegrations(); updateGuideButton(); + if (connected.length && !failures.length) { + state.guideSetupMessage = ""; + showLibrary(); + setNote($("#library-status"), `${connected.map((result) => result.label).join(" and ")} connected. ${connected.map((result) => result.nextStep).join(" ")}`); + return; + } focusSurface("#agent-setup-status"); } -function continueToCourse() { - if (state.paths.length) { - selectPath(state.paths[0].id); - return; +async function continueToCourse() { + const button = $("#start-course"); + button.disabled = true; + try { + if (state.paths.length) await selectPath(state.paths[0].id); + else showWorkspaceSetup(); + } catch (error) { + $("#brief-resume-note").textContent = `Could not open the course: ${error.message}`; + focusSurface("#brief-resume-note"); + } finally { + button.disabled = false; } - showWorkspaceSetup(); } function showHome() { @@ -465,6 +495,7 @@ function showLibrary() { $("#home").classList.remove("hidden"); $("#welcome-screen").classList.add("hidden"); $("#course-library").classList.remove("hidden"); + setNote($("#library-status"), ""); renderHome(); focusSurface("#library-title"); } @@ -506,9 +537,13 @@ async function startLearnDeck() { state.guideSelection = new Set(state.integrations.filter((integration) => integration.status === "detected").map((integration) => integration.id)); ensureActiveGuide(); updateGuideButton(); - showAgentSetup(); + button.disabled = false; + button.innerHTML = "Browse courses →"; + $("#start-status").textContent = "LearnDeck is ready. Your progress stays on this Mac."; + if (state.integrations.some((integration) => integration.status === "connected")) showLibrary(); + else showAgentSetup(); } catch (error) { - $("#start-status").textContent = error.message; + $("#start-status").textContent = `${error.message} Then press the button to try again.`; button.disabled = false; button.innerHTML = "Try starting again →"; } @@ -547,11 +582,15 @@ async function selectCourse(courseId) { } async function openCourse(courseId) { + const status = $("#library-status"); try { + setNote(status, "Opening course…"); await selectCourse(courseId); + setNote(status, ""); showCourseBriefing(); } catch (error) { - alert(error.message); + setNote(status, `Could not open this course: ${error.message} Go back and reopen the library to refresh it.`, true); + focusSurface("#library-status"); } } @@ -570,6 +609,15 @@ function renderHome() { const visibleCourses = state.category === "All" ? state.courses : state.courses.filter((course) => course.category === state.category); $("#library-count").textContent = `${visibleCourses.length} ${visibleCourses.length === 1 ? "course" : "courses"}`; const grid = $("#course-grid"); + if (!visibleCourses.length) { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = state.courses.length + ? "No courses in this category yet. Pick another category above." + : "No courses are available yet. Check the catalogue configuration, restart LearnDeck, or contribute one below."; + grid.replaceChildren(empty); + return; + } grid.replaceChildren(...visibleCourses.map((course) => courseCard(course))); } @@ -586,10 +634,10 @@ function renderCatalogueProvenance() { } const synced = catalogue.syncedAt ? formatCatalogueTime(catalogue.syncedAt) : ""; label.textContent = catalogue.source === "bundled" - ? "Bundled local courses" + ? "Bundled course packs · offline fallback" : catalogue.source === "live" - ? synced ? `Live catalogue · synced ${synced}` : "Live catalogue" - : "Cached catalogue"; + ? synced ? `GitHub catalogue · synced ${synced}` : "GitHub catalogue" + : "GitHub catalogue · cached copy"; warning.textContent = catalogue.warning || ""; warning.classList.toggle("hidden", !catalogue.warning); panel.classList.remove("hidden"); @@ -659,6 +707,10 @@ function renderWorkspaceSetup() { async function createPath(event) { event.preventDefault(); const form = new FormData(event.currentTarget); + const submit = event.currentTarget.querySelector("button[type=submit]"); + const status = $("#path-form-status"); + submit.disabled = true; + setNote(status, "Preparing your workspace…"); try { const workspacePath = String(form.get("workspacePath") || "").trim(); const path = await api(`/api/courses/${encodeURIComponent(state.course.id)}/paths`, { @@ -670,9 +722,12 @@ async function createPath(event) { }), }); state.paths = await api(`/api/courses/${encodeURIComponent(state.course.id)}/paths`); + setNote(status, ""); await selectPath(path.id); } catch (error) { - alert(error.message); + setNote(status, `${error.message} Use an absolute path to a folder whose parent already exists, then try again.`, true); + } finally { + submit.disabled = false; } } @@ -752,12 +807,19 @@ function renderLesson() { $("#sources-list").innerHTML = section.sources.map((source) => source.startsWith("http") ? `${escape(source)}` : `${escape(source.split("/").at(-1))}`).join(""); - $("#question-kind").textContent = `${question.kind} question`; - $("#question-reference").textContent = question.reference; - $("#question-prompt").textContent = question.prompt; - $("#answer").value = ""; - state.answerDirty = false; - $("#answer-form").dataset.questionId = question.id; + const answerForm = $("#answer-form"); + answerForm.classList.toggle("hidden", !question); + if (question) { + $("#question-kind").textContent = `${question.kind} question`; + $("#question-reference").textContent = question.reference; + $("#question-prompt").textContent = question.prompt; + if (answerForm.dataset.questionId !== question.id) { + $("#answer").value = ""; + state.answerDirty = false; + setNote($("#answer-status"), ""); + } + answerForm.dataset.questionId = question.id; + } renderAttempts(section.id); renderEvidence(section.id); observeLessonProgress(index); @@ -857,7 +919,7 @@ function renderEvidence(sectionId) { const time = document.createElement("time"); if (record.recordedAt) { time.dateTime = record.recordedAt; - time.textContent = record.recordedAt; + time.textContent = formatCatalogueTime(record.recordedAt); } heading.append(source, time); const note = document.createElement("p"); @@ -876,9 +938,13 @@ function renderEvidence(sectionId) { async function recordEvidence(event) { event.preventDefault(); const form = event.currentTarget; - const note = String(new FormData(form).get("note") || "").trim(); - const ref = String(new FormData(form).get("ref") || "").trim(); + const data = new FormData(form); + const note = String(data.get("note") || "").trim(); + const ref = String(data.get("ref") || "").trim(); const status = $("#evidence-status"); + const submit = form.querySelector("button[type=submit]"); + submit.disabled = true; + setNote(status, "Recording evidence…"); try { await api(`/api/paths/${encodeURIComponent(state.pathId)}/evidence`, { method: "POST", @@ -887,11 +953,13 @@ async function recordEvidence(event) { state.overview = await api(`/api/paths/${encodeURIComponent(state.pathId)}/overview`); form.reset(); render(); - status.textContent = "Learner evidence recorded."; + setNote(status, "Learner evidence recorded."); focusSurface("#evidence-status"); } catch (error) { - status.textContent = `Evidence could not be recorded: ${error.message}`; + setNote(status, `Evidence could not be recorded: ${error.message} Your note is still in the form — try again.`, true); focusSurface("#evidence-status"); + } finally { + submit.disabled = false; } } @@ -1159,7 +1227,11 @@ function persistEmbeddedControl(event) { async function submitAnswer(event) { event.preventDefault(); - const questionId = event.currentTarget.dataset.questionId; + const form = event.currentTarget; + const questionId = form.dataset.questionId; + const submit = form.querySelector("button[type=submit]"); + submit.disabled = true; + setNote($("#answer-status"), "Submitting your answer…"); try { const attempt = await api("/api/attempts", { method: "POST", @@ -1171,23 +1243,36 @@ async function submitAnswer(event) { }), }); state.overview = await api(`/api/paths/${encodeURIComponent(state.pathId)}/overview`); + $("#answer").value = ""; state.answerDirty = false; + setNote($("#answer-status"), ""); state.statusMessage = attempt.result === "submitted" ? "Submitted — waiting for optional guide feedback" : `Answer ${attempt.result}.`; render(); focusSurface("#progress-summary"); } catch (error) { - alert(error.message); + setNote($("#answer-status"), `Your answer was not submitted: ${error.message} It is still in the box — try again.`, true); + } finally { + submit.disabled = false; } } boot(); setInterval(async () => { - if (!state.pathId || state.answerDirty) return; + if (!state.pathId || state.answerDirty || document.hidden || $("#course").classList.contains("hidden")) return; + // Skip while the learner is interacting with lesson controls so a re-render never steals focus mid-typing. + const active = document.activeElement; + if (active && active.closest("#course") && active.matches("input, textarea, select, button")) return; + const pathId = state.pathId; + const before = state.overview; try { - state.overview = await api(`/api/paths/${encodeURIComponent(state.pathId)}/overview`); + const overview = await api(`/api/paths/${encodeURIComponent(pathId)}/overview`); + // Discard stale responses: the path changed or another action refreshed the overview mid-flight. + if (state.pathId !== pathId || state.overview !== before || state.answerDirty) return; + if (JSON.stringify(overview) === JSON.stringify(before)) return; + state.overview = overview; render(); } catch { // A local server restart should not interrupt an answer the learner is writing. diff --git a/public/index.html b/public/index.html index f8bce91..7ff4d67 100644 --- a/public/index.html +++ b/public/index.html @@ -57,41 +57,36 @@

Your App For Learning With AI

@@ -108,7 +103,7 @@

-

+

Connect an AI guide optional @@ -152,12 +147,13 @@

Where will this backend live?

Use a folder separate from LearnDeck. We only store this location with your local course progress.

+

-