diff --git a/apps/mac/Lobu.xcodeproj/project.pbxproj b/apps/mac/Lobu.xcodeproj/project.pbxproj index 4c9880fb2..6f27ade40 100644 --- a/apps/mac/Lobu.xcodeproj/project.pbxproj +++ b/apps/mac/Lobu.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ B1000000000000000000000D /* HealthKitSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000D /* HealthKitSyncService.swift */; }; B1000000000000000000000E /* WhatsAppLocalSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* WhatsAppLocalSyncService.swift */; }; B1000000000000000000000F /* LobuUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* LobuUpdater.swift */; }; + B10000000000000000000010 /* BrowserProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* BrowserProfileManager.swift */; }; + B10000000000000000000011 /* BrowserProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* BrowserProfilesView.swift */; }; B93000000000000000000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B92000000000000000000001 /* Sparkle */; }; /* End PBXBuildFile section */ @@ -38,6 +40,8 @@ B2000000000000000000000D /* HealthKitSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSyncService.swift; sourceTree = ""; }; B2000000000000000000000E /* WhatsAppLocalSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppLocalSyncService.swift; sourceTree = ""; }; B2000000000000000000000F /* LobuUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LobuUpdater.swift; sourceTree = ""; }; + B20000000000000000000009 /* BrowserProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserProfileManager.swift; sourceTree = ""; }; + B20000000000000000000013 /* BrowserProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserProfilesView.swift; sourceTree = ""; }; B20000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B20000000000000000000011 /* Lobu.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Lobu.entitlements; sourceTree = ""; }; B2000000000000000000000A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -80,6 +84,8 @@ B2000000000000000000000D /* HealthKitSyncService.swift */, B2000000000000000000000E /* WhatsAppLocalSyncService.swift */, B2000000000000000000000F /* LobuUpdater.swift */, + B20000000000000000000009 /* BrowserProfileManager.swift */, + B20000000000000000000013 /* BrowserProfilesView.swift */, B2000000000000000000000A /* Assets.xcassets */, B20000000000000000000010 /* Info.plist */, B20000000000000000000011 /* Lobu.entitlements */, @@ -202,6 +208,8 @@ B1000000000000000000000D /* HealthKitSyncService.swift in Sources */, B1000000000000000000000E /* WhatsAppLocalSyncService.swift in Sources */, B1000000000000000000000F /* LobuUpdater.swift in Sources */, + B10000000000000000000010 /* BrowserProfileManager.swift in Sources */, + B10000000000000000000011 /* BrowserProfilesView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/mac/Lobu/AppState.swift b/apps/mac/Lobu/AppState.swift index 7380fc450..fa24d7251 100644 --- a/apps/mac/Lobu/AppState.swift +++ b/apps/mac/Lobu/AppState.swift @@ -1,6 +1,34 @@ import Combine +import CryptoKit import Foundation +// MARK: - Local folder --------------------------------------------------------- + +/// One local folder the user has added as a sync source. The opaque +/// `folderId` is the link between this Mac and the server-side feed (it +/// lands in `feeds.config.folder_id`); the security-scoped `bookmark` stays +/// on disk. `feedId` is `nil` until reconcileFolderFeeds() has created the +/// feed on the server (typically after the next poll auto-wires the +/// connection). +/// +/// `folderId` is **deterministic** — `SHA256(bookmark).prefix(6).hex` (12 hex +/// chars, same shape as the legacy `folderKey` used in `origin_id`s). Two +/// consequences: (a) migrating a pre-feed user keeps the same id their old +/// events used, so deduplication just works and there's no re-ingest storm; +/// (b) a user who removes + re-adds the same folder gets event continuity +/// instead of a duplicate history. +struct LocalFolder: Codable, Hashable, Identifiable { + let folderId: String + let bookmark: Data + let displayName: String + var feedId: Int? + var id: String { folderId } + + static func folderId(for bookmark: Data) -> String { + SHA256.hash(data: bookmark).prefix(6).map { String(format: "%02x", $0) }.joined() + } +} + // MARK: - Recent job record -------------------------------------------------- struct RecentJob: Codable { @@ -95,11 +123,27 @@ final class AppState: ObservableObject { // Integrations state @Published var hasFDA: Bool = false - @Published var localFolderBookmarks: [Data] = [] + /// One per local folder the user has added. The security-scoped bookmark + /// stays on this Mac; only the display name + opaque folder id flow up to + /// the server (one feed per folder). + @Published var localFolders: [LocalFolder] = [] /// True once the user has been through the Apple Health permission sheet /// (mirrored from UserDefaults — HealthKit hides actual READ-grant status). @Published var hasHealthKit: Bool = HealthKitSyncService.hasBeenRequested + /// Per-integration soft-disable flags. macOS permissions (FDA, HealthKit) + /// are coarse — revoking FDA kills three integrations at once. These + /// give the user a per-integration off switch without touching OS perms. + @Published var screenTimeDisabled: Bool = UserDefaults.standard.bool(forKey: "lobu.screenTimeDisabled") { + didSet { UserDefaults.standard.set(screenTimeDisabled, forKey: "lobu.screenTimeDisabled") } + } + @Published var whatsAppDisabled: Bool = UserDefaults.standard.bool(forKey: "lobu.whatsAppDisabled") { + didSet { UserDefaults.standard.set(whatsAppDisabled, forKey: "lobu.whatsAppDisabled") } + } + @Published var healthKitDisabled: Bool = UserDefaults.standard.bool(forKey: "lobu.healthKitDisabled") { + didSet { UserDefaults.standard.set(healthKitDisabled, forKey: "lobu.healthKitDisabled") } + } + @Published var baseURL: String = { UserDefaults.standard.string(forKey: "lobuBaseURL") ?? "https://app.lobu.ai" @@ -218,13 +262,17 @@ final class AppState: ObservableObject { var currentCapabilities: [String: Bool] { var caps: [String: Bool] = [:] - if hasFDA { caps["screentime"] = true } - if !localFolderBookmarks.isEmpty { caps["local_directory"] = true } - if hasHealthKit && healthKitAvailable { caps["healthkit"] = true } + if hasFDA && !screenTimeDisabled { caps["screentime"] = true } + if !localFolders.isEmpty { caps["local_directory"] = true } + if hasHealthKit && healthKitAvailable && !healthKitDisabled { caps["healthkit"] = true } // Reading another app's Group Container requires Full Disk Access — the // same TCC grant Screen Time already needs. Gate the capability so the // worker doesn't claim runs it will only fail with a permission error. - if hasFDA && WhatsAppLocalSyncService.isAvailable() { caps["whatsapp_local"] = true } + if hasFDA && WhatsAppLocalSyncService.isAvailable() && !whatsAppDisabled { caps["whatsapp_local"] = true } + // Advertise `browser` whenever at least one supported browser is + // installed locally — Mac becomes eligible to host browser_session + // auth profiles with cookies on disk (no fleet credentials). + if BrowserProfileManager.hasAnyInstalledBrowser() { caps["browser"] = true } return caps } @@ -401,6 +449,14 @@ final class AppState: ObservableObject { defer { isSyncing = false } refreshFDAStatus() do { + // Make sure each local folder has a matching server-side feed + // before we start claiming runs. Always runs (even with no + // local folders) so orphaned server feeds get cleaned up when + // the user removed their last folder before its feed id was + // learned, and so a best-effort delete that failed on remove + // gets retried. + await reconcileFolderFeeds() + var handled = 0 var lastJob: RecentJob? // Drain the queue: keep claiming until the server has nothing left, @@ -445,6 +501,13 @@ final class AppState: ObservableObject { // MARK: - Notifications ----------------------------------------------------- + /// Authenticated WorkerClient for the signed-in user, or nil if not yet + /// signed in. Caller should treat nil as "show a sign-in hint". + func workerClient() -> WorkerClient? { + guard let credentials else { return nil } + return WorkerClient(baseURL: credentials.baseURL, accessToken: credentials.accessToken) + } + /// Pulls the user's recent notifications, recent agent runs, and the /// connector health list from the org-scoped REST API. Silently no-ops when /// we don't yet know the org slug (e.g. the token wasn't issued with @@ -538,38 +601,149 @@ final class AppState: ObservableObject { } } - // MARK: - Local folder bookmarks ------------------------------------------- + // MARK: - Local folders ----------------------------------------------------- + // + // Each folder is a server-side feed of the `local.directory` connector, + // identified by an opaque `folderId` we mint on this Mac. The security- + // scoped bookmark + Lobu-minted UUID live here in UserDefaults; the + // server only sees `{folder_id, display_name}` as the feed config. Feed + // creation happens via /api/workers/me/feeds — see reconcileFolderFeeds() + // below, which runs after each poll once the connection is auto-wired. func addFolderBookmark(url: URL) { - guard (try? url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)) != nil else { return } do { - let bookmark = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) - localFolderBookmarks.append(bookmark) - persistBookmarks() + let bookmark = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + let folderId = LocalFolder.folderId(for: bookmark) + // Re-adding a folder we already track? Just no-op — the feed (and + // its event history) already exist server-side; keep continuity. + if localFolders.contains(where: { $0.folderId == folderId }) { + setStatus("Folder is already added.") + return + } + let folder = LocalFolder( + folderId: folderId, + bookmark: bookmark, + displayName: url.lastPathComponent, + feedId: nil + ) + localFolders.append(folder) + persistFolders() setStatus("Folder added. Lobu will sync supported text files from it.") + // Reconcile on the next poll cycle; the connection may not be + // auto-wired yet on first add. We don't try to call the server + // synchronously here. } catch { setStatus("Could not bookmark folder: \(error.localizedDescription)") } } func removeFolderBookmark(at index: Int) { - guard localFolderBookmarks.indices.contains(index) else { return } - localFolderBookmarks.remove(at: index) - persistBookmarks() + guard localFolders.indices.contains(index) else { return } + let removed = localFolders.remove(at: index) + persistFolders() setStatus("Folder removed.") + // Delete the server-side feed if we knew about it. Best-effort — + // failure here just leaves an orphan feed the next reconcile will + // catch (it won't have a matching local folder). + if let feedId = removed.feedId { + Task { await deleteFolderFeed(feedId: feedId) } + } } - private func persistBookmarks() { - UserDefaults.standard.set(localFolderBookmarks, forKey: Self.folderBookmarksKey) + private func persistFolders() { + if let data = try? JSONEncoder().encode(localFolders) { + UserDefaults.standard.set(data, forKey: Self.folderBookmarksKey) + } } - /// Resolve a bookmark to a display URL (best-effort; not security-scoped access). + /// Resolve a folder's bookmark to a display URL (best-effort; not security-scoped access). func resolvedURLForBookmark(at index: Int) -> URL? { - guard localFolderBookmarks.indices.contains(index) else { return nil } + guard localFolders.indices.contains(index) else { return nil } var isStale = false - return try? URL(resolvingBookmarkData: localFolderBookmarks[index], - options: .withSecurityScope, relativeTo: nil, - bookmarkDataIsStale: &isStale) + return try? URL( + resolvingBookmarkData: localFolders[index].bookmark, + options: .withSecurityScope, relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } + + /// After every successful poll, reconcile our local folder list with the + /// server's feed list. Local-only folders → create a feed. Server-only + /// feeds (folder removed on this Mac while offline, say) → delete on + /// server. Best-effort; never throws into the poll loop. + func reconcileFolderFeeds() async { + guard let client = workerClient() else { return } + let workerId = LobuWorkerIdentity.current() + do { + let serverFeeds = try await client.listMyDeviceFeeds( + workerId: workerId, connectorKey: "local.directory" + ) + // Pre-feed-refactor orphans: auto-wire used to create a default + // `files` feed with NULL config the first time the device + // advertised `local_directory`. Those have no folder_id and would + // generate failing runs forever — clean them up unconditionally. + for feed in serverFeeds.feeds where feed.feed_key == "files" && feed.config?["folder_id"]?.stringValue == nil { + _ = try? await client.deleteMyDeviceFeed( + workerId: workerId, + connectorKey: "local.directory", + feedId: feed.id + ) + } + let serverByFolderId = Dictionary(uniqueKeysWithValues: serverFeeds.feeds.compactMap { + feed -> (String, WorkerClient.DeviceFeed)? in + guard let fid = feed.config?["folder_id"]?.stringValue else { return nil } + return (fid, feed) + }) + // Local → server: create missing feeds, repair feedId mappings. + var changed = false + for i in localFolders.indices { + let folder = localFolders[i] + if let serverFeed = serverByFolderId[folder.folderId] { + if folder.feedId != serverFeed.id { + localFolders[i].feedId = serverFeed.id + changed = true + } + } else { + if let created = try? await client.createMyDeviceFeed( + workerId: workerId, + connectorKey: "local.directory", + feedKey: "files", + displayName: folder.displayName, + config: [ + "folder_id": AnyEncodable(folder.folderId), + "display_name": AnyEncodable(folder.displayName), + ] + ) { + localFolders[i].feedId = created.id + changed = true + } + } + } + // Server → local: drop feeds whose folder_id we no longer hold. + let localIds = Set(localFolders.map(\.folderId)) + for (fid, feed) in serverByFolderId where !localIds.contains(fid) { + _ = try? await client.deleteMyDeviceFeed( + workerId: workerId, + connectorKey: "local.directory", + feedId: feed.id + ) + } + if changed { persistFolders() } + } catch { + NSLog("[Lobu] reconcileFolderFeeds failed: \(error.localizedDescription)") + } + } + + private func deleteFolderFeed(feedId: Int) async { + guard let client = workerClient() else { return } + let workerId = LobuWorkerIdentity.current() + _ = try? await client.deleteMyDeviceFeed( + workerId: workerId, connectorKey: "local.directory", feedId: feedId + ) } // MARK: - Apple Health ------------------------------------------------------ @@ -617,8 +791,33 @@ final class AppState: ObservableObject { persistRecentJobs() } } - if let bookmarks = UserDefaults.standard.array(forKey: Self.folderBookmarksKey) as? [Data] { - localFolderBookmarks = bookmarks + // New format: [LocalFolder] persisted as JSON. + if let json = UserDefaults.standard.data(forKey: Self.folderBookmarksKey), + let folders = try? JSONDecoder().decode([LocalFolder].self, from: json) { + localFolders = folders + } else if let legacy = UserDefaults.standard.array(forKey: Self.folderBookmarksKey) as? [Data] { + // Pre-feed format: bare [Data] bookmarks. Migrate by minting a + // folder id per bookmark and resolving the display name from the + // current URL. Feeds are created lazily by reconcileFolderFeeds() + // once the connection is auto-wired. + var migrated: [LocalFolder] = [] + for bookmark in legacy { + var isStale = false + let resolved = try? URL( + resolvingBookmarkData: bookmark, + options: .withSecurityScope, relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + let name = resolved?.lastPathComponent ?? "Folder" + migrated.append(LocalFolder( + folderId: LocalFolder.folderId(for: bookmark), + bookmark: bookmark, + displayName: name, + feedId: nil + )) + } + localFolders = migrated + persistFolders() } } diff --git a/apps/mac/Lobu/BrowserProfileManager.swift b/apps/mac/Lobu/BrowserProfileManager.swift new file mode 100644 index 000000000..43bf508d8 --- /dev/null +++ b/apps/mac/Lobu/BrowserProfileManager.swift @@ -0,0 +1,214 @@ +import AppKit +import Foundation + +/// Per-browser metadata for the supported Chromium-family browsers Lobu can +/// host as `browser_session` auth profiles. We don't support Firefox yet — +/// its remote-protocol story is different from CDP and connectors all assume +/// Playwright Chromium underneath. +struct InstalledBrowser: Identifiable, Hashable { + enum Kind: String, CaseIterable, Identifiable, Hashable { + case chrome + case brave + case arc + case edge + var id: String { rawValue } + + var displayName: String { + switch self { + case .chrome: return "Google Chrome" + case .brave: return "Brave" + case .arc: return "Arc" + case .edge: return "Microsoft Edge" + } + } + + var bundleIdentifier: String { + switch self { + case .chrome: return "com.google.Chrome" + case .brave: return "com.brave.Browser" + case .arc: return "company.thebrowser.Browser" + case .edge: return "com.microsoft.edgemac" + } + } + + /// Path to the browser's user-data root under ~/Library/Application Support. + /// Each Chromium-family browser writes profiles as subdirectories + /// ("Default", "Profile 1", "Work", …) plus a `Local State` JSON + /// listing display names. + var userDataRootRelativePath: String { + switch self { + case .chrome: return "Google/Chrome" + case .brave: return "BraveSoftware/Brave-Browser" + case .arc: return "Arc/User Data" + case .edge: return "Microsoft Edge" + } + } + } + + let kind: Kind + let applicationURL: URL + let userDataRoot: URL + var id: String { kind.rawValue } +} + +/// Source profile within an installed browser (the user-visible "Default" / +/// "Profile 1" / "Work" subdirectory). Cookies and localStorage live inside. +struct InstalledBrowserProfile: Identifiable, Hashable { + let browser: InstalledBrowser + let directoryName: String + let displayName: String + var id: String { "\(browser.kind.rawValue)/\(directoryName)" } + var sourcePath: URL { browser.userDataRoot.appendingPathComponent(directoryName) } +} + +/// Discovers installed Chromium-family browsers + their profiles, and owns +/// the lifecycle of Lobu's managed `--user-data-dir` copies that back each +/// device-bound `browser_session` auth profile. Cookies live inside these +/// dirs and never travel to the server. +enum BrowserProfileManager { + /// Where Lobu keeps managed profile dirs. One subdirectory per auth_profile + /// row (keyed by the server-issued id once known; provisional ones live + /// under a UUID until materialized). + static var managedRoot: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return appSupport.appendingPathComponent("Lobu/browser-profiles", isDirectory: true) + } + + static func hasAnyInstalledBrowser() -> Bool { + !installedBrowsers().isEmpty + } + + static func installedBrowsers() -> [InstalledBrowser] { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return InstalledBrowser.Kind.allCases.compactMap { kind in + // Use Launch Services to find the app — Chrome can live in /Applications + // or under ~/Applications, and users on managed Macs sometimes get it + // sandboxed elsewhere. + guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: kind.bundleIdentifier) else { + return nil + } + let dataRoot = appSupport.appendingPathComponent(kind.userDataRootRelativePath, isDirectory: true) + guard FileManager.default.fileExists(atPath: dataRoot.path) else { + // Browser is installed but has never been launched — no profile to + // capture from yet. Hide it from the picker until first launch. + return nil + } + return InstalledBrowser(kind: kind, applicationURL: appURL, userDataRoot: dataRoot) + } + } + + /// Read the browser's `Local State` JSON to enumerate source profiles. The + /// JSON shape is identical across Chrome/Brave/Edge/Arc — the `profile.info_cache` + /// map keys directory names ("Default", "Profile 1") to a `{ name: "..." }` + /// blob with the user's chosen display name. + static func sourceProfiles(for browser: InstalledBrowser) -> [InstalledBrowserProfile] { + let localStatePath = browser.userDataRoot.appendingPathComponent("Local State") + guard + let data = try? Data(contentsOf: localStatePath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let profile = json["profile"] as? [String: Any], + let infoCache = profile["info_cache"] as? [String: [String: Any]] + else { + // Fall back to "Default" — every Chromium browser ships with it. + let defaultPath = browser.userDataRoot.appendingPathComponent("Default") + guard FileManager.default.fileExists(atPath: defaultPath.path) else { return [] } + return [InstalledBrowserProfile(browser: browser, directoryName: "Default", displayName: "Default")] + } + return infoCache + .map { (dirName, attrs) in + let name = (attrs["name"] as? String) ?? dirName + return InstalledBrowserProfile(browser: browser, directoryName: dirName, displayName: name) + } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + } + + /// Materialize a managed --user-data-dir by copying the user's source + /// profile. Returns the absolute path to give to the server (and to + /// Playwright's launchPersistentContext at run time). + static func materializeManagedProfile(from source: InstalledBrowserProfile, named name: String) throws -> URL { + let dirName = "\(source.browser.kind.rawValue)-\(slugify(name))-\(UUID().uuidString.prefix(8))" + let target = managedRoot.appendingPathComponent(dirName, isDirectory: true) + try FileManager.default.createDirectory(at: managedRoot, withIntermediateDirectories: true) + // Copy the full source profile dir. For a fresh-blank profile, callers + // can skip this and just createDirectory(target) — but most users want + // to inherit their existing cookies. + try FileManager.default.copyItem(at: source.sourcePath, to: target) + return target + } + + /// Open Chrome (or matching browser) at `url` pointed at the managed + /// --user-data-dir so the user can complete an interactive login that + /// writes cookies into the profile dir. Throws if the OS reports the + /// launch failed — callers should surface to the user instead of + /// silently leaving the profile in `pending_auth` forever. + static func launchManaged(browser: InstalledBrowser, managedDir: URL, openingURL url: URL) async throws { + let config = NSWorkspace.OpenConfiguration() + config.arguments = ["--user-data-dir=\(managedDir.path)", url.absoluteString] + config.activates = true + let target = browser.applicationURL + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + NSWorkspace.shared.openApplication(at: target, configuration: config) { running, error in + if let error { + continuation.resume(throwing: error) + } else if let running { + continuation.resume(returning: running) + } else { + continuation.resume(throwing: NSError( + domain: "Lobu.BrowserProfileManager", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Browser failed to launch"] + )) + } + } + } + } + + static func removeManagedProfile(at path: URL) { + try? FileManager.default.removeItem(at: path) + } + + /// Probe localhost for a Chrome (or any Chromium-family browser) running + /// with `--remote-debugging-port`. Returns the discovered URL, or nil if + /// nothing's listening. The default Chrome port is 9222; we also try a few + /// neighbouring ports the user might've picked. + static func autoDetectCdpUrl() async -> String? { + for port in [9222, 9223, 9224, 9225] { + if await isCdpReachable(port: port) { + return "http://127.0.0.1:\(port)" + } + } + return nil + } + + static func isCdpReachable(port: Int) async -> Bool { + guard let url = URL(string: "http://127.0.0.1:\(port)/json/version") else { return false } + var request = URLRequest(url: url) + request.timeoutInterval = 1.0 + do { + let (_, response) = try await URLSession.shared.data(for: request) + return (response as? HTTPURLResponse)?.statusCode == 200 + } catch { + return false + } + } + + private static func slugify(_ value: String) -> String { + let lowered = value.lowercased() + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") + let mapped = lowered.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } + let joined = String(mapped) + // Collapse runs of '-' for a tidy slug. + var result = "" + var lastWasDash = false + for ch in joined { + if ch == "-" { + if lastWasDash { continue } + lastWasDash = true + } else { + lastWasDash = false + } + result.append(ch) + } + return result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } +} diff --git a/apps/mac/Lobu/BrowserProfilesView.swift b/apps/mac/Lobu/BrowserProfilesView.swift new file mode 100644 index 000000000..5d5884f14 --- /dev/null +++ b/apps/mac/Lobu/BrowserProfilesView.swift @@ -0,0 +1,363 @@ +import SwiftUI + +/// Shared store of device-bound `browser_session` auth profiles so each +/// per-browser row reads the same data and only the first row that appears +/// pays the network cost. Cookies live on this Mac inside a Lobu-owned +/// --user-data-dir; the server only sees metadata. +@MainActor +final class BrowserProfilesHub: ObservableObject { + @Published var profiles: [WorkerClient.BrowserAuthProfile] = [] + @Published var loadError: String? + @Published var loading: Bool = false + private var loaded: Bool = false + + func loadIfNeeded(state: AppState) async { + guard !loaded else { return } + await reload(state: state) + } + + func reload(state: AppState) async { + guard let client = state.workerClient() else { + loadError = "Sign in first." + return + } + loading = true + defer { loading = false } + do { + let workerId = LobuWorkerIdentity.current() + profiles = try await client.listMyBrowserAuthProfiles(workerId: workerId) + loadError = nil + // Only mark as loaded on a successful fetch — otherwise the next + // popover open silently shows an empty list instead of retrying. + loaded = true + } catch { + loadError = error.localizedDescription + } + } + + func add(_ profile: WorkerClient.BrowserAuthProfile) { + profiles.insert(profile, at: 0) + } + + func remove(_ profile: WorkerClient.BrowserAuthProfile) { + profiles.removeAll { $0.id == profile.id } + } +} + +/// One row per installed browser, rendered inline in the Integrations +/// disclosure. Each row shows the browser, its existing profiles, and an +/// inline create form — the user picks a source profile + connector + name +/// without leaving the menu bar. Mode defaults to "Copy profile" (managed +/// --user-data-dir, cookies isolated from the user's real browsing). +struct SingleBrowserRow: View { + @ObservedObject var state: AppState + let browser: InstalledBrowser + @ObservedObject var hub: BrowserProfilesHub + + @State private var showCreateForm: Bool = false + @State private var confirmingDeleteId: Int? + + private var myProfiles: [WorkerClient.BrowserAuthProfile] { + hub.profiles.filter { $0.browser_kind == browser.kind.rawValue } + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Image(systemName: "globe") + .foregroundStyle(.blue) + .frame(width: 18) + VStack(alignment: .leading, spacing: 1) { + Text(browser.kind.displayName).font(.caption) + if myProfiles.isEmpty { + Text("No profiles yet. Cookies stay on this Mac.") + .font(.caption2).foregroundStyle(.secondary) + } else { + Text("\(myProfiles.count) profile\(myProfiles.count == 1 ? "" : "s")") + .font(.caption2).foregroundStyle(.secondary) + } + } + Spacer() + if !showCreateForm { + Button(action: { showCreateForm = true }) { + HStack(spacing: 2) { + Image(systemName: "plus").font(.caption2) + Text("Add").font(.caption) + } + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + + ForEach(myProfiles) { profile in + profileRow(profile).padding(.leading, 32).padding(.trailing, 6) + } + + if showCreateForm { + CreateBrowserProfileInlineForm( + state: state, + browser: browser, + onCreated: { newProfile in + hub.add(newProfile) + showCreateForm = false + }, + onCancel: { showCreateForm = false } + ) + .padding(.leading, 32).padding(.trailing, 6).padding(.bottom, 4) + } + } + } + + @ViewBuilder + private func profileRow(_ profile: WorkerClient.BrowserAuthProfile) -> some View { + HStack(alignment: .center, spacing: 6) { + VStack(alignment: .leading, spacing: 0) { + Text(profile.display_name).font(.caption) + Text("\(profile.connector_key) · \(profile.status)") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer() + if profile.status == "pending_auth", let dir = profile.user_data_dir { + Button("Log in") { + openManagedChrome(dirPath: dir, connectorKey: profile.connector_key) + } + .buttonStyle(.plain).font(.caption2).foregroundStyle(.orange) + } + if confirmingDeleteId == profile.id { + Button("Confirm", role: .destructive) { + Task { await delete(profile) } + } + .buttonStyle(.plain).font(.caption2).foregroundStyle(.red) + Button("Cancel") { confirmingDeleteId = nil } + .buttonStyle(.plain).font(.caption2).foregroundStyle(.secondary) + } else { + Button(action: { confirmingDeleteId = profile.id }) { + Image(systemName: "trash").font(.caption2) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + } + .padding(.vertical, 1) + } + + private func openManagedChrome(dirPath: String, connectorKey: String) { + let dir = URL(fileURLWithPath: dirPath) + let landing = landingURL(for: connectorKey) + Task { @MainActor in + do { + try await BrowserProfileManager.launchManaged( + browser: browser, managedDir: dir, openingURL: landing + ) + } catch { + hub.loadError = "Could not launch \(browser.kind.displayName): \(error.localizedDescription)" + } + } + } + + private func landingURL(for connectorKey: String) -> URL { + let map: [String: String] = [ + "capterra": "https://www.capterra.com/", + "g2": "https://www.g2.com/", + "glassdoor": "https://www.glassdoor.com/", + "trustpilot": "https://www.trustpilot.com/", + "linkedin": "https://www.linkedin.com/login", + "x": "https://x.com/login", + "revolut": "https://app.revolut.com/", + ] + return URL(string: map[connectorKey] ?? "about:blank")! + } + + @MainActor + private func delete(_ profile: WorkerClient.BrowserAuthProfile) async { + guard let client = state.workerClient() else { return } + let workerId = LobuWorkerIdentity.current() + do { + try await client.deleteMyBrowserAuthProfile(workerId: workerId, profileId: profile.id) + if let dir = profile.user_data_dir { + BrowserProfileManager.removeManagedProfile(at: URL(fileURLWithPath: dir)) + } + hub.remove(profile) + confirmingDeleteId = nil + } catch { + hub.loadError = error.localizedDescription + } + } +} + +struct CreateBrowserProfileInlineForm: View { + @ObservedObject var state: AppState + let browser: InstalledBrowser + var onCreated: (WorkerClient.BrowserAuthProfile) -> Void + var onCancel: () -> Void + + enum Mode: String, CaseIterable, Hashable { case copy, cdp } + + @State private var sourceProfiles: [InstalledBrowserProfile] = [] + @State private var selectedSourceProfile: InstalledBrowserProfile? + @State private var connectorOptions: [WorkerClient.BrowserConnectorOption] = [] + @State private var connectorKey: String = "" + @State private var displayName: String = "" + @State private var mode: Mode = .copy + @State private var cdpPortText: String = "" + @State private var detectedCdpUrl: String? + @State private var saving: Bool = false + @State private var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Picker("", selection: $mode) { + Text("Copy profile").tag(Mode.copy) + Text("Attach via CDP").tag(Mode.cdp) + } + .pickerStyle(.segmented).controlSize(.mini).font(.caption2) + HStack(spacing: 4) { + if mode == .copy { + Picker("", selection: $selectedSourceProfile) { + ForEach(sourceProfiles) { p in + Text(p.displayName).tag(Optional(p)) + } + } + .labelsHidden().font(.caption2).controlSize(.mini) + .disabled(sourceProfiles.isEmpty) + } else { + TextField("port (e.g. 9222)", text: $cdpPortText) + .textFieldStyle(.roundedBorder).controlSize(.mini).font(.caption2) + .frame(maxWidth: 100) + Button("Detect") { + Task { + if let url = await BrowserProfileManager.autoDetectCdpUrl() { + detectedCdpUrl = url + if let port = URL(string: url)?.port { + cdpPortText = String(port) + } + } else { + error = "No running Chrome with --remote-debugging-port detected. Falls back to Copy mode if you submit without a port." + } + } + } + .buttonStyle(.plain).font(.caption2).foregroundStyle(.blue) + } + Picker("", selection: $connectorKey) { + if connectorOptions.isEmpty { + Text("Loading…").tag("") + } else { + ForEach(connectorOptions) { c in + Text(c.name).tag(c.key) + } + } + } + .labelsHidden().font(.caption2).controlSize(.mini) + .disabled(connectorOptions.isEmpty) + } + if mode == .cdp, let url = detectedCdpUrl { + Text("Detected: \(url)").font(.caption2).foregroundStyle(.green) + } + TextField("Name (e.g. Work Chrome)", text: $displayName) + .textFieldStyle(.roundedBorder).controlSize(.mini).font(.caption2) + if let error { + Text(error).font(.caption2).foregroundStyle(.red) + } + HStack(spacing: 6) { + Button("Cancel", action: onCancel) + .buttonStyle(.plain).font(.caption2).foregroundStyle(.secondary) + Spacer() + Button(mode == .copy ? "Create (copy)" : "Create (attach)") { + Task { await create() } + } + .buttonStyle(.plain).font(.caption2).foregroundStyle(.blue) + .disabled(saving || !canSubmit) + } + } + .onAppear { + sourceProfiles = BrowserProfileManager.sourceProfiles(for: browser) + selectedSourceProfile = sourceProfiles.first + Task { await loadConnectorOptions() } + } + } + + @MainActor + private func loadConnectorOptions() async { + guard let client = state.workerClient() else { return } + do { + let workerId = LobuWorkerIdentity.current() + let options = try await client.listBrowserConnectors(workerId: workerId) + connectorOptions = options + if connectorKey.isEmpty, let first = options.first?.key { + connectorKey = first + } + } catch { + self.error = "Could not load connectors: \(error.localizedDescription)" + } + } + + private var canSubmit: Bool { + if displayName.trimmingCharacters(in: .whitespaces).isEmpty { return false } + if connectorKey.isEmpty { return false } + switch mode { + case .copy: return selectedSourceProfile != nil + case .cdp: return parsedCdpPort != nil + } + } + + private var parsedCdpPort: Int? { + let trimmed = cdpPortText.trimmingCharacters(in: .whitespaces) + guard let port = Int(trimmed), port > 0, port < 65536 else { return nil } + return port + } + + @MainActor + private func create() async { + guard let client = state.workerClient() else { + error = "Sign in first." + return + } + saving = true + error = nil + defer { saving = false } + do { + let workerId = LobuWorkerIdentity.current() + let profile: WorkerClient.BrowserAuthProfile + switch mode { + case .copy: + guard let source = selectedSourceProfile else { return } + let target = try BrowserProfileManager.materializeManagedProfile(from: source, named: displayName) + do { + profile = try await client.createMyBrowserAuthProfile( + workerId: workerId, + connectorKey: connectorKey, + displayName: displayName, + browserKind: browser.kind.rawValue, + userDataDir: target.path, + cdpUrl: nil + ) + } catch { + // Server refused: clean up the managed --user-data-dir we + // just materialized so the user isn't stuck with an + // orphan profile dir on disk after a failed save. + BrowserProfileManager.removeManagedProfile(at: target) + throw error + } + case .cdp: + guard let port = parsedCdpPort else { + error = "Enter a CDP port (e.g. 9222), or use Copy mode." + return + } + let cdpUrl = "http://127.0.0.1:\(port)" + profile = try await client.createMyBrowserAuthProfile( + workerId: workerId, + connectorKey: connectorKey, + displayName: displayName, + browserKind: browser.kind.rawValue, + userDataDir: nil, + cdpUrl: cdpUrl + ) + } + onCreated(profile) + } catch let createError { + error = createError.localizedDescription + } + } +} diff --git a/apps/mac/Lobu/LobuClient.swift b/apps/mac/Lobu/LobuClient.swift index a8d4c12ac..f75ce77fc 100644 --- a/apps/mac/Lobu/LobuClient.swift +++ b/apps/mac/Lobu/LobuClient.swift @@ -315,6 +315,184 @@ final class WorkerClient { return data } + // MARK: - Browser auth profiles (device-bound) + + struct BrowserAuthProfile: Decodable, Identifiable, Equatable { + let id: Int + let slug: String + let display_name: String + let connector_key: String + let profile_kind: String + let status: String + let browser_kind: String? + let user_data_dir: String? + let cdp_url: String? + let created_at: String? + let updated_at: String? + } + + private struct BrowserAuthProfilesList: Decodable { + let profiles: [BrowserAuthProfile] + } + + private struct BrowserAuthProfileEnvelope: Decodable { + let profile: BrowserAuthProfile + } + + func listMyBrowserAuthProfiles(workerId: String) async throws -> [BrowserAuthProfile] { + guard var components = URLComponents(string: "\(baseURL.trimmedTrailingSlash())/api/workers/me/auth-profiles") else { + throw URLError(.badURL) + } + components.queryItems = [URLQueryItem(name: "worker_id", value: workerId)] + guard let url = components.url else { throw URLError(.badURL) } + let data = try await getRaw(url: url, path: "/api/workers/me/auth-profiles") + let list = try decoder.decode(BrowserAuthProfilesList.self, from: data) + return list.profiles + } + + func createMyBrowserAuthProfile( + workerId: String, + connectorKey: String, + displayName: String, + browserKind: String, + userDataDir: String?, + cdpUrl: String? + ) async throws -> BrowserAuthProfile { + struct Body: Encodable { + let worker_id: String + let connector_key: String + let display_name: String + let browser_kind: String + let user_data_dir: String? + let cdp_url: String? + } + let data = try await post( + "/api/workers/me/auth-profiles", + body: Body( + worker_id: workerId, + connector_key: connectorKey, + display_name: displayName, + browser_kind: browserKind, + user_data_dir: userDataDir, + cdp_url: cdpUrl + ) + ) + let envelope = try decoder.decode(BrowserAuthProfileEnvelope.self, from: data) + return envelope.profile + } + + func deleteMyBrowserAuthProfile(workerId: String, profileId: Int) async throws { + struct Body: Encodable { let worker_id: String } + guard let url = URL(string: "\(baseURL.trimmedTrailingSlash())/api/workers/me/auth-profiles/\(profileId)") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try encoder.encode(Body(worker_id: workerId)) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw WorkerClientError.http("/api/workers/me/auth-profiles/\(profileId)", code, String(data: data, encoding: .utf8) ?? "") + } + } + + // MARK: - Device feeds + + struct DeviceFeed: Decodable { + let id: Int + let feed_key: String? + let display_name: String? + let status: String? + let config: [String: AnyJSONValue]? + } + + struct DeviceFeedsResponse: Decodable { + let connection_id: Int? + let organization_id: String? + let feeds: [DeviceFeed] + } + + func listMyDeviceFeeds(workerId: String, connectorKey: String) async throws -> DeviceFeedsResponse { + guard var components = URLComponents(string: "\(baseURL.trimmedTrailingSlash())/api/workers/me/feeds") else { + throw URLError(.badURL) + } + components.queryItems = [ + URLQueryItem(name: "worker_id", value: workerId), + URLQueryItem(name: "connector_key", value: connectorKey), + ] + guard let url = components.url else { throw URLError(.badURL) } + let data = try await getRaw(url: url, path: "/api/workers/me/feeds") + return try decoder.decode(DeviceFeedsResponse.self, from: data) + } + + func createMyDeviceFeed( + workerId: String, + connectorKey: String, + feedKey: String, + displayName: String, + config: [String: AnyEncodable] + ) async throws -> DeviceFeed { + struct Body: Encodable { + let worker_id: String + let connector_key: String + let feed_key: String + let display_name: String + let config: [String: AnyEncodable] + } + struct Envelope: Decodable { let feed: DeviceFeed } + let data = try await post( + "/api/workers/me/feeds", + body: Body( + worker_id: workerId, + connector_key: connectorKey, + feed_key: feedKey, + display_name: displayName, + config: config + ) + ) + return try decoder.decode(Envelope.self, from: data).feed + } + + struct BrowserConnectorOption: Decodable, Identifiable, Equatable, Hashable { + let key: String + let name: String + let favicon_domain: String? + var id: String { key } + } + + private struct BrowserConnectorsResponse: Decodable { + let connectors: [BrowserConnectorOption] + } + + func listBrowserConnectors(workerId: String) async throws -> [BrowserConnectorOption] { + guard var components = URLComponents(string: "\(baseURL.trimmedTrailingSlash())/api/workers/me/browser-connectors") else { + throw URLError(.badURL) + } + components.queryItems = [URLQueryItem(name: "worker_id", value: workerId)] + guard let url = components.url else { throw URLError(.badURL) } + let data = try await getRaw(url: url, path: "/api/workers/me/browser-connectors") + return try decoder.decode(BrowserConnectorsResponse.self, from: data).connectors + } + + func deleteMyDeviceFeed(workerId: String, connectorKey: String, feedId: Int) async throws { + struct Body: Encodable { let worker_id: String; let connector_key: String } + guard let url = URL(string: "\(baseURL.trimmedTrailingSlash())/api/workers/me/feeds/\(feedId)") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try encoder.encode(Body(worker_id: workerId, connector_key: connectorKey)) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw WorkerClientError.http("/api/workers/me/feeds/\(feedId)", code, String(data: data, encoding: .utf8) ?? "") + } + } + private func post(_ path: String, body: T) async throws -> Data { guard let url = URL(string: "\(baseURL.trimmedTrailingSlash())\(path)") else { throw URLError(.badURL) } var request = URLRequest(url: url) diff --git a/apps/mac/Lobu/LocalDirectorySyncService.swift b/apps/mac/Lobu/LocalDirectorySyncService.swift index cb361abf5..d932aa3d8 100644 --- a/apps/mac/Lobu/LocalDirectorySyncService.swift +++ b/apps/mac/Lobu/LocalDirectorySyncService.swift @@ -1,163 +1,164 @@ -import CryptoKit import Foundation -/// Result of a `local.directory` sync pass: the events to stream and the new -/// feed checkpoint to persist (per-folder last-sync timestamps). +/// Result of a `local.directory` feed sync: events streamed + new checkpoint +/// (single-folder; no per-folder map needed now that each folder is its own +/// feed). struct LocalDirectoryOutput { let items: [WorkerStreamItem] - /// `{"folder:": }` for every folder walked this pass. - /// Folders that no longer exist drop out (their key isn't re-added), so the - /// checkpoint stays bounded; the server stores it verbatim. + /// `{ "last_sync": , "cursor": ? }`. let checkpoint: [String: AnyEncodable] } -/// Handles `local.directory` jobs: enumerates persisted security-scoped folder -/// bookmarks, reads eligible text files, and returns WorkerStreamItems. +/// Handles one `local.directory` feed run. Each Lobu folder is now its own +/// feed with `config.folder_id` identifying which security-scoped bookmark on +/// this Mac to read. /// -/// v1 constraints: +/// v1 constraints (per run): /// - Shallow enumeration only (no subdirectory recursion). /// - Extensions: txt, md, json, csv, html. /// - Max file size: 1 MB per file. -/// - Max total files: 500 across all bookmarks. +/// - Max files per run: 500 (paginate via `cursor` checkpoint). /// -/// Incremental: each folder carries a `last sync` timestamp in the feed -/// checkpoint, and a pass skips (without even reading) files whose mtime predates -/// it. A folder with no checkpoint entry — newly added, or first run — is fully -/// scanned. The stored timestamp is the pass *start* time, so a file modified -/// while a pass is running is picked up next time rather than missed. Server-side -/// dedup is by `origin_id` (`local-dir::`), so a modified -/// file updates its event; a deleted file leaves its event behind (a present-id -/// reconcile that tombstones is a known follow-up). -/// -/// Events carry the folder's *display name* and the file name only — never the -/// absolute path (which would leak the user's home directory / disk layout into -/// Lobu). The folder hash in `origin_id` / the checkpoint keys is opaque and -/// stable as long as the bookmark is. +/// Incremental: each feed run reads its checkpoint (`last_sync`, optional +/// `cursor`) and skips files whose mtime is older than `last_sync` (or whose +/// mtime equals `last_sync` and filename is `<= cursor`). The `cursor` is +/// written only when a pass hits the 500-file cap mid-second, so subsequent +/// runs continue through same-second mtime ties instead of getting stuck. +/// Events are deduped server-side by `origin_id` = `local-dir::`. enum LocalDirectorySyncService { private static let allowedExtensions: Set = ["txt", "md", "json", "csv", "html"] private static let maxFileSize: Int = 1_048_576 // 1 MB - private static let maxTotalFiles: Int = 500 + private static let maxFilesPerRun: Int = 500 - /// Opaque, stable 12-hex-char id for a folder bookmark — used in origin ids - /// and checkpoint keys so neither carries the path. Stable as long as the - /// bookmark is. - private static func folderKey(for bookmark: Data) -> String { - SHA256.hash(data: bookmark).prefix(6).map { String(format: "%02x", $0) }.joined() + /// Decode the persisted folder list. Matches the AppState representation + /// (`[LocalFolder]` JSON under `lobu.localFolderBookmarks`). + private static func loadFolders() -> [LocalFolder] { + guard let data = UserDefaults.standard.data(forKey: "lobu.localFolderBookmarks") else { return [] } + return (try? JSONDecoder().decode([LocalFolder].self, from: data)) ?? [] } static func runLocalDirectory(job: WorkerJob) throws -> LocalDirectoryOutput { - let bookmarks = (UserDefaults.standard.array(forKey: "lobu.localFolderBookmarks") as? [Data]) ?? [] - let passStartedAt = Int(Date().timeIntervalSince1970) - guard !bookmarks.isEmpty else { return LocalDirectoryOutput(items: [], checkpoint: [:]) } + // The server-merged job.config carries the feed's `folder_id`. If + // it's absent the run was materialized against an old-shape feed — + // bail loudly so the failure is visible in the run log. + guard let folderId = job.config?["folder_id"]?.stringValue, !folderId.isEmpty else { + throw NSError( + domain: "Lobu.LocalDirectory", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Feed config missing folder_id — Mac app and server are out of sync; restart the Mac app to reconcile."] + ) + } - var items: [WorkerStreamItem] = [] - var checkpoint: [String: AnyEncodable] = [:] + let folders = loadFolders() + guard let folder = folders.first(where: { $0.folderId == folderId }) else { + // Folder removed locally while the run was in flight. Return empty + // — the next reconcileFolderFeeds() pass will delete the server- + // side feed. + return LocalDirectoryOutput(items: [], checkpoint: [:]) + } + + var isStale = false + guard let folderURL = try? URL( + resolvingBookmarkData: folder.bookmark, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) else { + return LocalDirectoryOutput(items: [], checkpoint: [:]) + } + guard folderURL.startAccessingSecurityScopedResource() else { + return LocalDirectoryOutput(items: [], checkpoint: [:]) + } + defer { folderURL.stopAccessingSecurityScopedResource() } + + let passStartedAt = Int(Date().timeIntervalSince1970) + let lastSync = job.checkpoint?["last_sync"]?.intValue + let cursor = job.checkpoint?["cursor"]?.stringValue let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let fm = FileManager.default - for bookmark in bookmarks { - var isStale = false - guard let folderURL = try? URL( - resolvingBookmarkData: bookmark, - options: .withSecurityScope, - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) else { continue } - - guard folderURL.startAccessingSecurityScopedResource() else { continue } - defer { folderURL.stopAccessingSecurityScopedResource() } - - let folderName = folderURL.lastPathComponent - let folderId = folderKey(for: bookmark) - let checkpointKey = "folder:\(folderId)" - let cursorKey = "\(checkpointKey):cursor" - // nil ⇒ folder never synced (or first run) ⇒ full scan. - let syncedSinceInt = job.checkpoint?[checkpointKey]?.intValue - let cursor = job.checkpoint?[cursorKey]?.stringValue - - guard let rawEntries = try? fm.contentsOfDirectory( - at: folderURL, - includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { continue } - let entries = rawEntries.sorted { lhs, rhs in - let lhsModified = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)?.timeIntervalSince1970 ?? 0 - let rhsModified = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)?.timeIntervalSince1970 ?? 0 - if lhsModified == rhsModified { return lhs.lastPathComponent < rhs.lastPathComponent } - return lhsModified < rhsModified - } + guard let rawEntries = try? fm.contentsOfDirectory( + at: folderURL, + includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return LocalDirectoryOutput(items: [], checkpoint: [:]) + } - var folderCompleted = true - var lastProcessed: (seconds: Int, filename: String)? + let entries = rawEntries.sorted { lhs, rhs in + let lhsModified = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)?.timeIntervalSince1970 ?? 0 + let rhsModified = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)?.timeIntervalSince1970 ?? 0 + if lhsModified == rhsModified { return lhs.lastPathComponent < rhs.lastPathComponent } + return lhsModified < rhsModified + } - for fileURL in entries { - guard items.count < maxTotalFiles else { - folderCompleted = false - break - } + var items: [WorkerStreamItem] = [] + var folderCompleted = true + var lastProcessed: (seconds: Int, filename: String)? - // Regular files only - guard (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { continue } - - let ext = fileURL.pathExtension.lowercased() - guard allowedExtensions.contains(ext) else { continue } - - let resources = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) - let fileSize = resources?.fileSize ?? 0 - guard fileSize <= maxFileSize else { continue } - - let modifiedAt = resources?.contentModificationDate ?? Date() - let modifiedSeconds = Int(modifiedAt.timeIntervalSince1970) - let filename = fileURL.lastPathComponent - - // Skip files unchanged since this folder's last sync. A cursor is - // stored only when a previous pass hit maxTotalFiles; it lets the - // next pass continue through many same-second mtimes instead of - // permanently skipping or repeatedly re-reading the same prefix. - if let syncedSinceInt { - if let cursor { - if modifiedSeconds < syncedSinceInt { continue } - if modifiedSeconds == syncedSinceInt && filename <= cursor { continue } - } else if modifiedAt.timeIntervalSince1970 < Double(syncedSinceInt) { - // Strict `<` so a same-second edit is re-synced (cheap - // no-op upsert) rather than missed. - continue - } + for fileURL in entries { + guard items.count < maxFilesPerRun else { + folderCompleted = false + break + } + guard (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { continue } + let ext = fileURL.pathExtension.lowercased() + guard allowedExtensions.contains(ext) else { continue } + + let resources = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + let fileSize = resources?.fileSize ?? 0 + guard fileSize <= maxFileSize else { continue } + + let modifiedAt = resources?.contentModificationDate ?? Date() + let modifiedSeconds = Int(modifiedAt.timeIntervalSince1970) + let filename = fileURL.lastPathComponent + + // Skip files unchanged since this feed's last sync. Cursor is + // written only when a previous pass hit maxFilesPerRun; it lets + // the next pass continue past same-second mtime ties. + if let lastSync { + if let cursor { + if modifiedSeconds < lastSync { continue } + if modifiedSeconds == lastSync && filename <= cursor { continue } + } else if modifiedAt.timeIntervalSince1970 < Double(lastSync) { + // Strict `<` so a same-second edit re-syncs (cheap no-op + // upsert) rather than missed. + continue } - - guard let text = try? String(contentsOf: fileURL, encoding: .utf8) else { continue } - - let item = WorkerStreamItem( - id: "local-dir:\(folderId):\(filename)", - title: filename, - payload_text: text, - occurred_at: iso.string(from: modifiedAt), - semantic_type: "file_document", - metadata: [ - "source": AnyEncodable("local_directory"), - "folder": AnyEncodable(folderName), - "name": AnyEncodable(filename), - "ext": AnyEncodable(ext), - "size_bytes": AnyEncodable(fileSize), - "modified_at": AnyEncodable(iso.string(from: modifiedAt)), - ] - ) - items.append(item) - lastProcessed = (modifiedSeconds, filename) } - if folderCompleted { - checkpoint[checkpointKey] = AnyEncodable(passStartedAt) - } else if let lastProcessed { - checkpoint[checkpointKey] = AnyEncodable(lastProcessed.seconds) - checkpoint[cursorKey] = AnyEncodable(lastProcessed.filename) - } else if let syncedSinceInt { - checkpoint[checkpointKey] = AnyEncodable(syncedSinceInt) - if let cursor { checkpoint[cursorKey] = AnyEncodable(cursor) } - } + guard let text = try? String(contentsOf: fileURL, encoding: .utf8) else { continue } + + let item = WorkerStreamItem( + id: "local-dir:\(folder.folderId):\(filename)", + title: filename, + payload_text: text, + occurred_at: iso.string(from: modifiedAt), + semantic_type: "file_document", + metadata: [ + "source": AnyEncodable("local_directory"), + "folder": AnyEncodable(folder.displayName), + "name": AnyEncodable(filename), + "ext": AnyEncodable(ext), + "size_bytes": AnyEncodable(fileSize), + "modified_at": AnyEncodable(iso.string(from: modifiedAt)), + ] + ) + items.append(item) + lastProcessed = (modifiedSeconds, filename) } + var checkpoint: [String: AnyEncodable] = [:] + if folderCompleted { + checkpoint["last_sync"] = AnyEncodable(passStartedAt) + } else if let lastProcessed { + checkpoint["last_sync"] = AnyEncodable(lastProcessed.seconds) + checkpoint["cursor"] = AnyEncodable(lastProcessed.filename) + } else if let lastSync { + checkpoint["last_sync"] = AnyEncodable(lastSync) + if let cursor { checkpoint["cursor"] = AnyEncodable(cursor) } + } return LocalDirectoryOutput(items: items, checkpoint: checkpoint) } } diff --git a/apps/mac/Lobu/MenuBarContent.swift b/apps/mac/Lobu/MenuBarContent.swift index e6f3c83d1..24594775d 100644 --- a/apps/mac/Lobu/MenuBarContent.swift +++ b/apps/mac/Lobu/MenuBarContent.swift @@ -14,6 +14,7 @@ struct MenuBarContent: View { @ObservedObject var state: AppState @State private var integrationsExpanded = false @State private var accountExpanded = false + @StateObject private var browserHub = BrowserProfilesHub() @FocusState private var searchFocused: Bool var body: some View { @@ -62,9 +63,12 @@ struct MenuBarContent: View { private var header: some View { VStack(alignment: .leading, spacing: 2) { - HStack { + HStack(alignment: .firstTextBaseline, spacing: 6) { Text("Lobu") .font(.headline) + Text(Host.current().localizedName ?? "This Mac") + .font(.caption) + .foregroundStyle(.secondary) Spacer() if state.credentials != nil { Toggle("", isOn: Binding( @@ -314,8 +318,17 @@ struct MenuBarContent: View { private func handleNotificationTap(_ notification: LobuNotification) { Task { await state.markNotificationRead(notification) } + // Prefer the notification's own URL; otherwise drop the user into the + // org's notifications page on the web so a click *always* takes them + // somewhere visible. if let urlString = notification.resource_url, let url = URL(string: urlString) { NSWorkspace.shared.open(url) + return + } + let orgSlug = state.credentials?.userInfo?.organization_slug + let fallback = orgSlug.map { "\(state.baseURL)/\($0)/notifications" } ?? state.baseURL + if let url = URL(string: fallback) { + NSWorkspace.shared.open(url) } } @@ -528,128 +541,178 @@ struct MenuBarContent: View { VStack(alignment: .leading, spacing: 2) { disclosureHeader(title: "Integrations", expanded: $integrationsExpanded) if integrationsExpanded { - screenTimeRow + ForEach(BrowserProfileManager.installedBrowsers()) { browser in + SingleBrowserRow(state: state, browser: browser, hub: browserHub) + } localFolderRows + screenTimeRow healthKitRow whatsAppLocalRow } } - } - - private var whatsAppLocalRow: some View { - HStack(spacing: 8) { - connectorHealthDot(forKey: "whatsapp.local") - Image(systemName: "message.fill") - .foregroundStyle(.green) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text("WhatsApp").font(.caption) - if !WhatsAppLocalSyncService.isAvailable() { - Text("Install WhatsApp Desktop to enable.") - .font(.caption2).foregroundStyle(.secondary) - } else if !state.hasFDA { - Text("Reads from WhatsApp Desktop. Needs Full Disk Access.") - .font(.caption2).foregroundStyle(.secondary) - } else { - Text("Reads messages directly from WhatsApp Desktop.") - .font(.caption2).foregroundStyle(.secondary) - } - } - Spacer() - if !WhatsAppLocalSyncService.isAvailable() { - Text("Not installed").font(.caption).foregroundStyle(.secondary) - } else if state.hasFDA { - Label("Granted", systemImage: "checkmark.circle.fill") - .labelStyle(.iconOnly) - .foregroundStyle(.green) - .font(.caption) - } else { - Label("FDA needed", systemImage: "exclamationmark.triangle.fill") - .labelStyle(.iconOnly) - .foregroundStyle(.orange) - .font(.caption) - } - } - .menuRow() + .task { await browserHub.loadIfNeeded(state: state) } } private var healthKitRow: some View { - HStack(spacing: 8) { - connectorHealthDot(forKey: "apple.health") - Image(systemName: "heart.fill") - .foregroundStyle(.pink) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text("Apple Health").font(.caption) + let enabled = state.healthKitAvailable && state.hasHealthKit && !state.healthKitDisabled + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Image(systemName: "heart.fill") + .foregroundStyle(.pink) + .frame(width: 18) + VStack(alignment: .leading, spacing: 1) { + Text("Apple Health").font(.caption) + if !state.healthKitAvailable { + Text("Not available on this Mac.") + .font(.caption2).foregroundStyle(.secondary) + } else { + Text("Daily activity + workouts, synced via iCloud Health.") + .font(.caption2).foregroundStyle(.secondary) + } + } + Spacer() if !state.healthKitAvailable { - Text("Not available on this Mac.") - .font(.caption2).foregroundStyle(.secondary) - } else if !state.hasHealthKit { - Text("Daily activity + workouts, synced from iPhone via iCloud Health.") - .font(.caption2).foregroundStyle(.secondary) + Text("Unavailable").font(.caption2).foregroundStyle(.secondary) + } else if !enabled { + Button(action: { + if state.hasHealthKit { state.healthKitDisabled = false } + else { Task { await state.requestHealthKitAccess() } } + }) { + HStack(spacing: 2) { + Image(systemName: "plus").font(.caption2) + Text("Add").font(.caption) + } + .foregroundStyle(.blue) + } + .buttonStyle(.plain) } } - Spacer() - if !state.healthKitAvailable { - EmptyView() - } else if state.hasHealthKit { - Label("Requested", systemImage: "checkmark.circle.fill") - .labelStyle(.iconOnly) - .foregroundStyle(.green) - .font(.caption) - } else { - Button("Grant access") { Task { await state.requestHealthKitAccess() } } - .buttonStyle(.plain).font(.caption).foregroundStyle(.orange) + .menuRow() + if enabled { + integrationSourceRow( + path: "iCloud Health", + onRemove: { state.healthKitDisabled = true } + ) } } - .menuRow() } private var screenTimeRow: some View { - VStack(alignment: .leading, spacing: 3) { + let enabled = state.hasFDA && !state.screenTimeDisabled + return VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { - connectorHealthDot(forKey: "apple.screen_time") Image(systemName: "clock.fill") .foregroundStyle(.purple) .frame(width: 18) VStack(alignment: .leading, spacing: 1) { Text("Screen Time").font(.caption) if !state.hasFDA { - Text("Enable Full Disk Access, then recheck.") - .font(.caption2) - .foregroundStyle(.secondary) + Text("Per-app usage. Needs Full Disk Access.") + .font(.caption2).foregroundStyle(.secondary) + } else { + Text("Per-app usage, synced from your Mac.") + .font(.caption2).foregroundStyle(.secondary) } } Spacer() - if state.hasFDA { - Label("Granted", systemImage: "checkmark.circle.fill") - .labelStyle(.iconOnly) - .foregroundStyle(.green) - .font(.caption) - } else { - HStack(spacing: 8) { - Button("Settings") { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { - NSWorkspace.shared.open(url) - } + if !enabled { + Button(action: { if state.hasFDA { state.screenTimeDisabled = false } else { openFDASettings() } }) { + HStack(spacing: 2) { + Image(systemName: "plus").font(.caption2) + Text("Add").font(.caption) } - .buttonStyle(.plain) - .font(.caption) - .foregroundStyle(.orange) - Button("Recheck") { state.refreshFDAStatus() } - .buttonStyle(.plain) - .font(.caption) + .foregroundStyle(.blue) } + .buttonStyle(.plain) } } + .menuRow() + if enabled { + integrationSourceRow( + path: "~/Library/Application Support/Knowledge/", + onRemove: { state.screenTimeDisabled = true } + ) + } } + } + + private var whatsAppLocalRow: some View { + let available = WhatsAppLocalSyncService.isAvailable() + let enabled = state.hasFDA && available && !state.whatsAppDisabled + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Image(systemName: "message.fill") + .foregroundStyle(.green) + .frame(width: 18) + VStack(alignment: .leading, spacing: 1) { + Text("WhatsApp").font(.caption) + if !available { + Text("Install WhatsApp Desktop to enable.") + .font(.caption2).foregroundStyle(.secondary) + } else if !state.hasFDA { + Text("Reads from WhatsApp Desktop. Needs Full Disk Access.") + .font(.caption2).foregroundStyle(.secondary) + } else { + Text("Reads messages directly from WhatsApp Desktop.") + .font(.caption2).foregroundStyle(.secondary) + } + } + Spacer() + if !available { + Text("Not installed").font(.caption2).foregroundStyle(.secondary) + } else if !enabled { + Button(action: { if state.hasFDA { state.whatsAppDisabled = false } else { openFDASettings() } }) { + HStack(spacing: 2) { + Image(systemName: "plus").font(.caption2) + Text("Add").font(.caption) + } + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } + } + .menuRow() + if enabled { + integrationSourceRow( + path: "~/Library/Group Containers/group.net.whatsapp.WhatsAppMac.shared/", + onRemove: { state.whatsAppDisabled = true } + ) + } + } + } + + private func integrationSourceRow(path: String, onRemove: @escaping () -> Void) -> some View { + HStack(spacing: 4) { + Image(systemName: "folder") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: 18) + Text(path) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.caption2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.leading, 26) .menuRow() } + private func openFDASettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { + NSWorkspace.shared.open(url) + } + } + private var localFolderRows: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { - connectorHealthDot(forKey: "local.directory") Image(systemName: "folder.fill") .foregroundStyle(.blue) .frame(width: 18) @@ -660,35 +723,46 @@ struct MenuBarContent: View { .foregroundStyle(.secondary) } Spacer() - Button("Add folder…") { openFolderPanel() } - .buttonStyle(.plain) - .font(.caption) + Button(action: { openFolderPanel() }) { + HStack(spacing: 2) { + Image(systemName: "plus").font(.caption2) + Text("Add").font(.caption) + } + .foregroundStyle(.blue) + } + .buttonStyle(.plain) } .menuRow() - ForEach(Array(state.localFolderBookmarks.enumerated()), id: \.offset) { idx, _ in - if let url = state.resolvedURLForBookmark(at: idx) { - HStack(spacing: 4) { - Image(systemName: "folder") - .font(.caption2) - .foregroundStyle(.secondary) - .frame(width: 18) - Text(url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) + ForEach(Array(state.localFolders.enumerated()), id: \.element.folderId) { idx, folder in + let path = state.resolvedURLForBookmark(at: idx)? + .path.replacingOccurrences(of: NSHomeDirectory(), with: "~") + ?? folder.displayName + HStack(spacing: 4) { + Image(systemName: "folder") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: 18) + Text(path) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + if folder.feedId == nil { + Text("(syncing…)") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Button { + state.removeFolderBookmark(at: idx) + } label: { + Image(systemName: "xmark") .font(.caption2) .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button { - state.removeFolderBookmark(at: idx) - } label: { - Image(systemName: "xmark") - .font(.caption2) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) } - .menuRow() + .buttonStyle(.plain) } + .padding(.leading, 26) + .menuRow() } } } diff --git a/db/migrations/20260513120000_auth_profiles_device_binding.sql b/db/migrations/20260513120000_auth_profiles_device_binding.sql new file mode 100644 index 000000000..66142112a --- /dev/null +++ b/db/migrations/20260513120000_auth_profiles_device_binding.sql @@ -0,0 +1,50 @@ +-- migrate:up + +-- Let an auth_profile of kind 'browser_session' live on a specific device worker +-- instead of holding cookies in auth_data. When device_worker_id is set, cookies +-- live on disk inside the Mac app's managed --user-data-dir at user_data_dir; +-- the server never sees them. Cloud/fleet path (device_worker_id NULL, +-- auth_data populated) is unchanged. + +ALTER TABLE public.auth_profiles + ADD COLUMN IF NOT EXISTS device_worker_id uuid, + ADD COLUMN IF NOT EXISTS browser_kind text, + ADD COLUMN IF NOT EXISTS user_data_dir text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_device_worker_id_fkey' + ) THEN + ALTER TABLE public.auth_profiles + ADD CONSTRAINT auth_profiles_device_worker_id_fkey + FOREIGN KEY (device_worker_id) + REFERENCES public.device_workers (id) + ON DELETE CASCADE; + END IF; +END$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_browser_kind_check' + ) THEN + ALTER TABLE public.auth_profiles + ADD CONSTRAINT auth_profiles_browser_kind_check + CHECK (browser_kind IS NULL OR browser_kind = ANY (ARRAY['chrome','brave','arc','edge'])); + END IF; +END$$; + +CREATE INDEX IF NOT EXISTS auth_profiles_device_worker_idx + ON public.auth_profiles (device_worker_id) + WHERE device_worker_id IS NOT NULL; + +-- migrate:down + +DROP INDEX IF EXISTS public.auth_profiles_device_worker_idx; +ALTER TABLE public.auth_profiles + DROP CONSTRAINT IF EXISTS auth_profiles_browser_kind_check, + DROP CONSTRAINT IF EXISTS auth_profiles_device_worker_id_fkey, + DROP COLUMN IF EXISTS user_data_dir, + DROP COLUMN IF EXISTS browser_kind, + DROP COLUMN IF EXISTS device_worker_id; diff --git a/db/migrations/20260513150000_auth_profiles_cdp_url.sql b/db/migrations/20260513150000_auth_profiles_cdp_url.sql new file mode 100644 index 000000000..4794bf6d2 --- /dev/null +++ b/db/migrations/20260513150000_auth_profiles_cdp_url.sql @@ -0,0 +1,43 @@ +-- migrate:up + +-- Add `cdp_url` to auth_profiles. For a device-bound `browser_session` +-- profile, exactly one of {user_data_dir, cdp_url} should be set: +-- user_data_dir → managed Chrome with isolated cookies (default) +-- cdp_url → attach to a running Chrome via remote-debugging-port +-- The application enforces this invariant; we don't add a CHECK constraint +-- because the OR-on-NULL semantics are awkward to express and the column +-- is harmless when both are NULL (legacy fleet path with cookies in +-- auth_data jsonb). + +ALTER TABLE public.auth_profiles + ADD COLUMN IF NOT EXISTS cdp_url text; + +-- A device-bound browser_session profile MUST set exactly one of +-- (user_data_dir, cdp_url). Other profile kinds — and non-device-bound +-- browser_session profiles (cookies in auth_data, fleet-served) — are +-- exempt. Enforcing this at the DB stops a buggy admin tool or a bad +-- merge from setting both and then having the connector silently prefer +-- whichever code path it sees first. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_device_browser_path_xor' + ) THEN + ALTER TABLE public.auth_profiles + ADD CONSTRAINT auth_profiles_device_browser_path_xor + CHECK ( + device_worker_id IS NULL + OR profile_kind <> 'browser_session' + OR ( + (user_data_dir IS NOT NULL AND cdp_url IS NULL) + OR (user_data_dir IS NULL AND cdp_url IS NOT NULL) + ) + ); + END IF; +END$$; + +-- migrate:down + +ALTER TABLE public.auth_profiles + DROP CONSTRAINT IF EXISTS auth_profiles_device_browser_path_xor, + DROP COLUMN IF EXISTS cdp_url; diff --git a/db/schema.sql b/db/schema.sql index b4317ad07..ddbc1a3c1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -247,6 +247,12 @@ CREATE TABLE public.auth_profiles ( created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + device_worker_id uuid, + browser_kind text, + user_data_dir text, + cdp_url text, + CONSTRAINT auth_profiles_browser_kind_check CHECK (((browser_kind IS NULL) OR (browser_kind = ANY (ARRAY['chrome'::text, 'brave'::text, 'arc'::text, 'edge'::text])))), + CONSTRAINT auth_profiles_device_browser_path_xor CHECK (((device_worker_id IS NULL) OR (profile_kind <> 'browser_session'::text) OR (((user_data_dir IS NOT NULL) AND (cdp_url IS NULL)) OR ((user_data_dir IS NULL) AND (cdp_url IS NOT NULL))))), CONSTRAINT auth_profiles_profile_kind_check CHECK ((profile_kind = ANY (ARRAY['env'::text, 'oauth_app'::text, 'oauth_account'::text, 'browser_session'::text, 'interactive'::text]))), CONSTRAINT auth_profiles_status_check CHECK ((status = ANY (ARRAY['active'::text, 'pending_auth'::text, 'error'::text, 'revoked'::text]))) ); @@ -2786,6 +2792,12 @@ CREATE INDEX agents_organization_id_idx ON public.agents USING btree (organizati CREATE INDEX auth_profiles_connector_kind_idx ON public.auth_profiles USING btree (organization_id, connector_key, profile_kind, status); +-- +-- Name: auth_profiles_device_worker_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX auth_profiles_device_worker_idx ON public.auth_profiles USING btree (device_worker_id) WHERE (device_worker_id IS NOT NULL); + -- -- Name: auth_profiles_org_slug_unique; Type: INDEX; Schema: public; Owner: - -- @@ -4047,6 +4059,13 @@ ALTER TABLE ONLY public.agents ALTER TABLE ONLY public.auth_profiles ADD CONSTRAINT auth_profiles_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.account(id) ON DELETE SET NULL; +-- +-- Name: auth_profiles auth_profiles_device_worker_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auth_profiles + ADD CONSTRAINT auth_profiles_device_worker_id_fkey FOREIGN KEY (device_worker_id) REFERENCES public.device_workers(id) ON DELETE CASCADE; + -- -- Name: auth_profiles auth_profiles_organization_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -4848,4 +4867,6 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260512000000'), ('20260512131703'), ('20260513000000'), + ('20260513120000'), + ('20260513150000'), ('20260513200000'); diff --git a/packages/connector-sdk/src/browser-network.ts b/packages/connector-sdk/src/browser-network.ts index a90d1ee5e..ab4c049e3 100644 --- a/packages/connector-sdk/src/browser-network.ts +++ b/packages/connector-sdk/src/browser-network.ts @@ -70,8 +70,44 @@ interface NetworkBrowser { async function acquireForNetworkSync( cdpUrl: string | 'auto' | null | undefined, cookies: Cookie[], - stealth: boolean + stealth: boolean, + userDataDir: string | undefined ): Promise { + // --- Persistent profile path: cookies live on disk --- + if (userDataDir) { + const playwrightModule = 'playwright'; + const { chromium } = await import(/* @vite-ignore */ playwrightModule); + const isDebug = process.env.BROWSER_DEBUG === '1'; + const screenshotDir = process.env.BROWSER_SCREENSHOT_DIR ?? '/tmp/feed-screenshots'; + const context = (await chromium.launchPersistentContext(userDataDir, { + headless: !isDebug, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + })) as BrowserContext; + try { + if (cookies.length > 0) { + await context.addCookies(cookies); + } + const pages = context.pages(); + const page = pages.length > 0 ? pages[0] : await context.newPage(); + sdkLogger.info( + { userDataDir, cookies: cookies.length }, + '[BrowserNetwork] Launched with persistent --user-data-dir' + ); + return { + browser: (context.browser() ?? null) as unknown as Browser, + context, + page, + backend: 'playwright', + ownsBrowser: true, + screenshotDir, + }; + } catch (err) { + // addCookies / newPage failed — close the persistent context so we + // don't leak a long-lived Chromium process holding the profile lock. + await context.close().catch(() => {}); + throw err; + } + } // --- Layer 1: CDP via Playwright connectOverCDP --- if (cdpUrl !== null && cdpUrl !== undefined) { try { @@ -157,6 +193,12 @@ export async function browserNetworkSync(opts: { * When set, CDP is tried first; if unavailable, falls back to Playwright + cookies. */ cdpUrl?: string | 'auto' | null; + /** + * Device-bound managed --user-data-dir. When set, Playwright launches via + * launchPersistentContext and CDP is skipped — cookies live on disk in this + * profile dir. + */ + userDataDir?: string; }): Promise> { const cfg = { ...DEFAULT_CONFIG, ...opts.config }; const items: TItem[] = []; @@ -169,9 +211,10 @@ export async function browserNetworkSync(opts: { const matchesUrl = (url: string) => matchers.some((re) => re.test(url)); const acquired = await acquireForNetworkSync( - opts.cdpUrl ?? null, + opts.userDataDir ? null : (opts.cdpUrl ?? null), opts.cookies, - cfg.stealth ?? false + cfg.stealth ?? false, + opts.userDataDir ); const { context, page, backend, ownsBrowser, browser, screenshotDir } = acquired; diff --git a/packages/connector-sdk/src/browser/acquire.ts b/packages/connector-sdk/src/browser/acquire.ts index eb83f6755..c5377d43e 100644 --- a/packages/connector-sdk/src/browser/acquire.ts +++ b/packages/connector-sdk/src/browser/acquire.ts @@ -35,6 +35,13 @@ export interface AcquireBrowserOptions { authDomains: string[]; /** Use stealth/anti-detection mode for Playwright launch (default: false). */ stealth?: boolean; + /** + * Persistent --user-data-dir for device-bound browser profiles. When set, + * Playwright launches via launchPersistentContext so cookies/localStorage + * are read from (and written to) this directory. CDP is skipped — the + * profile dir is the source of truth. + */ + userDataDir?: string; } export interface AcquiredBrowser { @@ -86,6 +93,17 @@ export class BrowserAuthCascadeError extends Error { export async function acquireBrowser(opts: AcquireBrowserOptions): Promise { const attempts: Array<{ layer: string; error: string }> = []; + // --- Persistent profile path: cookies live in --user-data-dir --- + // Skip CDP entirely — the profile dir is authoritative for cookies/state. + if (opts.userDataDir) { + try { + return await acquireViaPersistent(opts); + } catch (err: any) { + attempts.push({ layer: 'Playwright persistent', error: err.message }); + throw new BrowserAuthCascadeError(attempts); + } + } + // --- Layer 1: CDP --- if (opts.cdpUrl !== null && opts.cdpUrl !== undefined) { try { @@ -136,6 +154,43 @@ async function acquireViaCdp(opts: AcquireBrowserOptions): Promise { + const playwrightModule = 'playwright'; + const { chromium } = await import(/* @vite-ignore */ playwrightModule); + const isDebug = process.env.BROWSER_DEBUG === '1'; + const screenshotDir = process.env.BROWSER_SCREENSHOT_DIR ?? '/tmp/feed-screenshots'; + const context = (await chromium.launchPersistentContext(opts.userDataDir!, { + headless: !isDebug, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + })) as BrowserContext; + try { + if (opts.cookies.length > 0) { + await context.addCookies(opts.cookies); + } + const pages = context.pages(); + const page = pages.length > 0 ? pages[0] : await context.newPage(); + sdkLogger.info( + { userDataDir: opts.userDataDir, cookies: opts.cookies.length }, + '[BrowserAcquire] Launched Playwright with persistent --user-data-dir' + ); + return { + browser: context.browser() ?? null, + context, + page, + cdpPage: null, + cdpWsUrl: null, + backend: 'playwright', + ownsBrowser: true, + screenshotDir, + }; + } catch (err) { + // addCookies / newPage failed — close the persistent context so we + // don't leak a long-lived Chromium process holding the profile lock. + await context.close().catch(() => {}); + throw err; + } +} + async function acquireViaPlaywright(opts: AcquireBrowserOptions): Promise { const { browser, screenshotDir } = await launchBrowser({ stealth: opts.stealth ?? false, diff --git a/packages/connector-sdk/src/connector-types.ts b/packages/connector-sdk/src/connector-types.ts index 1aef1b556..92062a4f1 100644 --- a/packages/connector-sdk/src/connector-types.ts +++ b/packages/connector-sdk/src/connector-types.ts @@ -183,6 +183,14 @@ export interface FeedDefinition { displayNameTemplate?: string; /** JSON Schema for feed-specific config */ configSchema?: Record; + /** + * When true, auto-wire (device-reconcile + bundled-connector install) skips + * this feed — every feed instance is created explicitly by the user (or by + * the device worker on their behalf). Use this for feeds whose configSchema + * has required fields that can only be supplied by an external actor, e.g. + * `local.directory.files` needs a per-folder `folder_id` from the Mac app. + */ + userManaged?: boolean; /** Event kinds this feed produces, keyed by kind slug */ eventKinds?: Record< string, diff --git a/packages/connectors/src/browser-scraper-utils.ts b/packages/connectors/src/browser-scraper-utils.ts index 74bb01c26..7bdd4ac44 100644 --- a/packages/connectors/src/browser-scraper-utils.ts +++ b/packages/connectors/src/browser-scraper-utils.ts @@ -24,12 +24,42 @@ export function getBrowserCookies( ): any[] { const sessionCookies = (sessionState?.cookies as any[]) ?? []; const cookies = (checkpoint as any)?.cookies ?? sessionCookies; - if (!cookies || cookies.length === 0) { + // Device-bound browser profiles ship cookies via --user-data-dir on disk + // rather than this jsonb blob; the persistent context loads them itself. + if ((!cookies || cookies.length === 0) && !sessionState?.user_data_dir) { throw new Error( `No browser cookies found. Run: lobu memory browser-auth --connector ${connectorKey} --auth-profile-slug ` ); } - return cookies; + return cookies ?? []; +} + +/** + * Pull the device-bound managed --user-data-dir from session_state, if the + * connection's auth profile is owned by a device worker. When set, callers + * should pass it to openStealthBrowser instead of relying on the cookies/CDP + * cascade — Chrome reads cookies from that profile dir directly. + */ +export function getBrowserUserDataDir( + sessionState: Record | null | undefined +): string | undefined { + const value = sessionState?.user_data_dir; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * Pull the device-bound CDP endpoint URL from session_state (set when the + * user picked "Attach via CDP" mode on their browser profile). When set, + * callers should pass it through as `cdpUrl` so the connector attaches to + * the exact running Chrome the user chose — instead of `'auto'`, which can + * land on the wrong browser when several debuggable Chromiums are running + * or a non-default port was configured. + */ +export function getBrowserCdpUrl( + sessionState: Record | null | undefined +): string | undefined { + const value = sessionState?.cdp_url; + return typeof value === 'string' && value.length > 0 ? value : undefined; } export function validateCookieNotExpired( @@ -103,12 +133,14 @@ export async function openStealthBrowser(opts?: { cdpUrl?: string | 'auto' | null; cookies?: Cookie[]; authDomains?: string[]; + userDataDir?: string; }): Promise { const acquired = await acquireBrowser({ - cdpUrl: opts?.cdpUrl ?? null, + cdpUrl: opts?.userDataDir ? null : (opts?.cdpUrl ?? null), cookies: opts?.cookies ?? [], authDomains: opts?.authDomains ?? [], stealth: true, + userDataDir: opts?.userDataDir, }); const page = acquired.cdpPage ?? acquired.page; diff --git a/packages/connectors/src/capterra.ts b/packages/connectors/src/capterra.ts index fb8f64aa3..8ad4be954 100644 --- a/packages/connectors/src/capterra.ts +++ b/packages/connectors/src/capterra.ts @@ -14,6 +14,8 @@ import { type SyncResult, } from '@lobu/connector-sdk'; import { + getBrowserCdpUrl, + getBrowserUserDataDir, handleCookieConsent, openStealthBrowser, validateUrlDomain, @@ -132,7 +134,9 @@ export default class CapterraConnector extends ConnectorRuntime { : `https://www.capterra.com/p/${productId}/reviews`; validateUrlDomain(baseUrl, 'capterra.com'); - const session = await openStealthBrowser({ cdpUrl: 'auto' }); + const userDataDir = getBrowserUserDataDir(ctx.sessionState); + const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? 'auto'; + const session = await openStealthBrowser({ cdpUrl, userDataDir }); return withBrowserErrorCapture(session, 'capterra-sync', async (page) => { await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); diff --git a/packages/connectors/src/g2.ts b/packages/connectors/src/g2.ts index c71e0efb4..c74351c22 100644 --- a/packages/connectors/src/g2.ts +++ b/packages/connectors/src/g2.ts @@ -15,6 +15,8 @@ import { type SyncResult, } from '@lobu/connector-sdk'; import { + getBrowserCdpUrl, + getBrowserUserDataDir, handleCookieConsent, openStealthBrowser, validateUrlDomain, @@ -114,7 +116,9 @@ export default class G2Connector extends ConnectorRuntime { const baseUrl = productUrl; const allEvents: EventEnvelope[] = []; - const session = await openStealthBrowser({ cdpUrl: 'auto' }); + const userDataDir = getBrowserUserDataDir(ctx.sessionState); + const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? 'auto'; + const session = await openStealthBrowser({ cdpUrl, userDataDir }); return withBrowserErrorCapture(session, 'g2-sync', async (page) => { const maxPages = 5; diff --git a/packages/connectors/src/glassdoor.ts b/packages/connectors/src/glassdoor.ts index 85ff358d8..527258181 100644 --- a/packages/connectors/src/glassdoor.ts +++ b/packages/connectors/src/glassdoor.ts @@ -16,6 +16,8 @@ import { type SyncResult, } from '@lobu/connector-sdk'; import { + getBrowserCdpUrl, + getBrowserUserDataDir, handleCookieConsent, openStealthBrowser, validateUrlDomain, @@ -158,7 +160,9 @@ export default class GlassdoorConnector extends ConnectorRuntime { : `https://www.glassdoor.com/Reviews/${company_name}-reviews-SRCH_KE0.htm`; validateUrlDomain(baseUrl, 'glassdoor.com'); - const session = await openStealthBrowser({ cdpUrl: 'auto' }); + const userDataDir = getBrowserUserDataDir(ctx.sessionState); + const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? 'auto'; + const session = await openStealthBrowser({ cdpUrl, userDataDir }); return withBrowserErrorCapture(session, 'glassdoor-sync', async (page) => { // Configure viewport and user-agent to mimic a real browser diff --git a/packages/connectors/src/linkedin.ts b/packages/connectors/src/linkedin.ts index 1247a9a3b..a29fb3577 100644 --- a/packages/connectors/src/linkedin.ts +++ b/packages/connectors/src/linkedin.ts @@ -19,7 +19,12 @@ import { type SyncContext, type SyncResult, } from '@lobu/connector-sdk'; -import { getBrowserCookies, validateCookieNotExpired } from './browser-scraper-utils'; +import { + getBrowserCdpUrl, + getBrowserCookies, + getBrowserUserDataDir, + validateCookieNotExpired, +} from './browser-scraper-utils'; // ── Types ────────────────────────────────────────────────────── @@ -316,23 +321,37 @@ export default class LinkedInConnector extends ConnectorRuntime { // Normalize URL - remove trailing slash const baseUrl = companyUrl.replace(/\/$/, ''); - const cookies = getBrowserCookies(ctx.checkpoint as any, ctx.sessionState as any, 'linkedin'); - validateCookieNotExpired(cookies, 'li_at', 'linkedin'); + const userDataDir = getBrowserUserDataDir(ctx.sessionState); + const cdpUrlFromSession = getBrowserCdpUrl(ctx.sessionState); + const cdpUrl = cdpUrlFromSession ?? 'auto'; + // No need to require cookies when the device tells us to attach directly + // (managed --user-data-dir on disk, or an explicit CDP endpoint pointed + // at the user's running Chrome). The cookie cascade is only the fallback + // for the cloud/auto path. + const skipServerCookies = !!userDataDir || !!cdpUrlFromSession; + const cookies = skipServerCookies + ? [] + : getBrowserCookies(ctx.checkpoint as any, ctx.sessionState as any, 'linkedin'); + if (!skipServerCookies) { + validateCookieNotExpired(cookies, 'li_at', 'linkedin'); + } const maxScrolls = (config.max_scrolls as number) ?? (feedKey === 'jobs' ? 3 : 5); if (feedKey === 'jobs') { - return this.syncJobs(baseUrl, cookies, maxScrolls, checkpoint); + return this.syncJobs(baseUrl, cookies, maxScrolls, checkpoint, userDataDir, cdpUrl); } - return this.syncUpdates(baseUrl, cookies, maxScrolls, checkpoint); + return this.syncUpdates(baseUrl, cookies, maxScrolls, checkpoint, userDataDir, cdpUrl); } private async syncUpdates( baseUrl: string, cookies: any[], maxScrolls: number, - checkpoint: LinkedInCheckpoint + checkpoint: LinkedInCheckpoint, + userDataDir: string | undefined, + cdpUrl: string | 'auto' ): Promise { const postsUrl = `${baseUrl}/posts/`; @@ -350,8 +369,9 @@ export default class LinkedInConnector extends ConnectorRuntime { navigationTimeoutMs: 20000, }, url: postsUrl, - cdpUrl: 'auto', + cdpUrl, cookies, + userDataDir, parseResponse: parseCompanyUpdates, checkAuth: async (page) => { const url = page.url(); @@ -401,7 +421,9 @@ export default class LinkedInConnector extends ConnectorRuntime { baseUrl: string, cookies: any[], maxScrolls: number, - checkpoint: LinkedInCheckpoint + checkpoint: LinkedInCheckpoint, + userDataDir: string | undefined, + cdpUrl: string | 'auto' ): Promise { const jobsUrl = `${baseUrl}/jobs/`; @@ -420,8 +442,9 @@ export default class LinkedInConnector extends ConnectorRuntime { navigationTimeoutMs: 20000, }, url: jobsUrl, - cdpUrl: 'auto', + cdpUrl, cookies, + userDataDir, parseResponse: parseJobListings, checkAuth: async (page) => { const url = page.url(); diff --git a/packages/connectors/src/local_directory.ts b/packages/connectors/src/local_directory.ts index 966c2c40f..dcbd9eb75 100644 --- a/packages/connectors/src/local_directory.ts +++ b/packages/connectors/src/local_directory.ts @@ -37,10 +37,25 @@ export default class LocalDirectoryConnector extends ConnectorRuntime { files: { key: 'files', name: 'Files', - description: 'Text files from the configured local folder.', + description: 'Text files from one local folder on the user\'s Mac. One feed per folder — folder_id is an opaque stable id minted by the Mac app (the security-scoped bookmark is held device-side; the server never sees the absolute path).', + userManaged: true, configSchema: { type: 'object', - properties: {}, + required: ['folder_id', 'display_name'], + properties: { + folder_id: { + type: 'string', + minLength: 8, + maxLength: 64, + description: 'Opaque stable id (UUID) minted on the Mac. Maps to a security-scoped bookmark stored locally on the device.', + }, + display_name: { + type: 'string', + minLength: 1, + maxLength: 200, + description: 'Folder name shown in the UI (e.g., "Documents"). Not used to locate the folder — the device resolves folder_id to its bookmark.', + }, + }, }, eventKinds: { file_document: { diff --git a/packages/connectors/src/revolut.ts b/packages/connectors/src/revolut.ts index 70875ac00..d36d0ca68 100644 --- a/packages/connectors/src/revolut.ts +++ b/packages/connectors/src/revolut.ts @@ -29,7 +29,9 @@ import { type SyncResult, } from "@lobu/connector-sdk"; import { + getBrowserCdpUrl, getBrowserCookies, + getBrowserUserDataDir, validateCookieNotExpired, } from "./browser-scraper-utils"; @@ -497,16 +499,20 @@ export default class RevolutConnector extends ConnectorRuntime { // session). Stored cookies are only a best-effort fallback for the // Playwright path — see the auth-schema comment on why they rarely suffice // for Revolut. Don't fail the sync just because there are none. + const userDataDir = getBrowserUserDataDir(ctx.sessionState); + const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? "auto"; let cookies: ReturnType = []; - try { - cookies = getBrowserCookies( - ctx.checkpoint as Record | null, - ctx.sessionState, - "revolut", - ); - validateCookieNotExpired(cookies, "credentials", "revolut"); - } catch { - cookies = []; + if (!userDataDir) { + try { + cookies = getBrowserCookies( + ctx.checkpoint as Record | null, + ctx.sessionState, + "revolut", + ); + validateCookieNotExpired(cookies, "credentials", "revolut"); + } catch { + cookies = []; + } } const result = await browserNetworkSync({ @@ -520,8 +526,9 @@ export default class RevolutConnector extends ConnectorRuntime { stealth: true, }, url: startUrl, - cdpUrl: "auto", + cdpUrl, cookies, + userDataDir, parseResponse: (_url, json) => extractTransactionsFromResponse(json), checkAuth: async (page) => isLoggedIn(page.url()), }); diff --git a/packages/connectors/src/trustpilot.ts b/packages/connectors/src/trustpilot.ts index b833e5e89..dc27c88eb 100644 --- a/packages/connectors/src/trustpilot.ts +++ b/packages/connectors/src/trustpilot.ts @@ -15,6 +15,8 @@ import { type SyncResult, } from '@lobu/connector-sdk'; import { + getBrowserCdpUrl, + getBrowserUserDataDir, handleCookieConsent, openStealthBrowser, validateUrlDomain, @@ -99,7 +101,9 @@ export default class TrustpilotConnector extends ConnectorRuntime { const baseUrl = businessUrl || `https://www.trustpilot.com/review/${businessName}`; validateUrlDomain(baseUrl, 'trustpilot.com'); - const session = await openStealthBrowser({ cdpUrl: 'auto' }); + const userDataDir = getBrowserUserDataDir(ctx.sessionState); + const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? 'auto'; + const session = await openStealthBrowser({ cdpUrl, userDataDir }); return withBrowserErrorCapture(session, 'trustpilot-sync', async (page) => { await page.goto(baseUrl, { diff --git a/packages/connectors/src/x.ts b/packages/connectors/src/x.ts index 0d31f5264..30f1103b2 100644 --- a/packages/connectors/src/x.ts +++ b/packages/connectors/src/x.ts @@ -17,7 +17,12 @@ import { type SyncContext, type SyncResult, } from '@lobu/connector-sdk'; -import { getBrowserCookies, validateCookieNotExpired } from './browser-scraper-utils'; +import { + getBrowserCdpUrl, + getBrowserCookies, + getBrowserUserDataDir, + validateCookieNotExpired, +} from './browser-scraper-utils'; interface XCheckpoint { last_tweet_id?: string; @@ -358,12 +363,16 @@ async function syncViaBrowser( const searchFilter = (config.search_filter as string) ?? 'live'; const searchUrl = `https://x.com/search?q=${encodeURIComponent(searchQuery)}&src=typed_query&f=${searchFilter}`; + const userDataDir = getBrowserUserDataDir(ctx.sessionState); + const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? 'auto'; let cookies: any[] = []; - try { - cookies = getBrowserCookies(ctx.checkpoint as any, ctx.sessionState as any, 'x'); - validateCookieNotExpired(cookies, 'auth_token', 'x'); - } catch { - // No stored cookies — CDP will be the only path + if (!userDataDir) { + try { + cookies = getBrowserCookies(ctx.checkpoint as any, ctx.sessionState as any, 'x'); + validateCookieNotExpired(cookies, 'auth_token', 'x'); + } catch { + // No stored cookies — CDP will be the only path + } } const result = await browserNetworkSync({ @@ -376,8 +385,9 @@ async function syncViaBrowser( navigationTimeoutMs: 15000, }, url: searchUrl, - cdpUrl: 'auto', + cdpUrl, cookies, + userDataDir, parseResponse: parseBrowserSearchResponse, checkAuth: async (page) => { const url = page.url(); diff --git a/packages/server/src/db/embedded-schema-patches.ts b/packages/server/src/db/embedded-schema-patches.ts index 24ccf697e..0f978411e 100644 --- a/packages/server/src/db/embedded-schema-patches.ts +++ b/packages/server/src/db/embedded-schema-patches.ts @@ -290,6 +290,87 @@ export const EMBEDDED_SCHEMA_PATCHES: EmbeddedSchemaPatch[] = [ await sql.unsafe(`DROP TABLE IF EXISTS public.device_worker_org_grants`); }, }, + { + // Mirrors db/migrations/20260513120000_auth_profiles_device_binding.sql. + // Lets a 'browser_session' auth_profile live on a device worker (cookies on + // disk in user_data_dir, auth_data empty) instead of in server-side + // auth_data jsonb. + id: 'auth-profiles-device-binding', + apply: async (sql) => { + await sql.unsafe(` + ALTER TABLE public.auth_profiles + ADD COLUMN IF NOT EXISTS device_worker_id uuid + `); + await sql.unsafe(` + ALTER TABLE public.auth_profiles + ADD COLUMN IF NOT EXISTS browser_kind text + `); + await sql.unsafe(` + ALTER TABLE public.auth_profiles + ADD COLUMN IF NOT EXISTS user_data_dir text + `); + await sql.unsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_device_worker_id_fkey' + ) THEN + ALTER TABLE public.auth_profiles + ADD CONSTRAINT auth_profiles_device_worker_id_fkey + FOREIGN KEY (device_worker_id) + REFERENCES public.device_workers (id) + ON DELETE CASCADE; + END IF; + END $$; + `); + await sql.unsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_browser_kind_check' + ) THEN + ALTER TABLE public.auth_profiles + ADD CONSTRAINT auth_profiles_browser_kind_check + CHECK (browser_kind IS NULL OR browser_kind = ANY (ARRAY['chrome','brave','arc','edge'])); + END IF; + END $$; + `); + await sql.unsafe(` + CREATE INDEX IF NOT EXISTS auth_profiles_device_worker_idx + ON public.auth_profiles (device_worker_id) + WHERE device_worker_id IS NOT NULL + `); + }, + }, + { + // Mirrors db/migrations/20260513150000_auth_profiles_cdp_url.sql + id: 'auth-profiles-cdp-url', + apply: async (sql) => { + await sql.unsafe(` + ALTER TABLE public.auth_profiles + ADD COLUMN IF NOT EXISTS cdp_url text + `); + await sql.unsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_device_browser_path_xor' + ) THEN + ALTER TABLE public.auth_profiles + ADD CONSTRAINT auth_profiles_device_browser_path_xor + CHECK ( + device_worker_id IS NULL + OR profile_kind <> 'browser_session' + OR ( + (user_data_dir IS NOT NULL AND cdp_url IS NULL) + OR (user_data_dir IS NULL AND cdp_url IS NOT NULL) + ) + ); + END IF; + END $$; + `); + }, + }, { // Mirrors db/migrations/20260513200000_notifications_as_events.sql. // Idempotent: only migrates rows from `notifications` if the table still diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 963364fc7..4685aa998 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -524,6 +524,10 @@ import { completeAuthRun, completeEmbeddings, completeWorkerJob, + createMyDeviceAuthProfile, + createMyDeviceFeed, + deleteMyDeviceAuthProfile, + deleteMyDeviceFeed, emitAuthArtifact, fetchEventsForEmbedding, getActiveAuthRun, @@ -531,6 +535,9 @@ import { heartbeat, deleteDeviceWorker, listDeviceWorkers, + listBrowserConnectors, + listMyDeviceAuthProfiles, + listMyDeviceFeeds, updateDeviceWorkerOrg, pollAuthSignal, pollWorkerJob, @@ -582,7 +589,15 @@ app.use('/api/workers/*', async (c, next) => { '/api/workers/complete', ]); const requestPath = new URL(c.req.url).pathname; - if (!allowedPathsForUserWorker.has(requestPath)) { + const isAuthProfileSubpath = requestPath.startsWith('/api/workers/me/auth-profiles'); + const isFeedSubpath = requestPath.startsWith('/api/workers/me/feeds'); + const isBrowserConnectorsPath = requestPath === '/api/workers/me/browser-connectors'; + if ( + !allowedPathsForUserWorker.has(requestPath) && + !isAuthProfileSubpath && + !isFeedSubpath && + !isBrowserConnectorsPath + ) { return c.json({ error: 'Endpoint not available to user-scoped workers' }, 403); } const scopes = c.var.mcpAuthInfo?.scopes ?? []; @@ -633,6 +648,13 @@ app.post('/api/workers/fetch-events', fetchEventsForEmbedding); app.post('/api/workers/emit-auth-artifact', emitAuthArtifact); app.post('/api/workers/poll-auth-signal', pollAuthSignal); app.post('/api/workers/complete-auth', completeAuthRun); +app.get('/api/workers/me/auth-profiles', listMyDeviceAuthProfiles); +app.post('/api/workers/me/auth-profiles', createMyDeviceAuthProfile); +app.delete('/api/workers/me/auth-profiles/:id', deleteMyDeviceAuthProfile); +app.get('/api/workers/me/feeds', listMyDeviceFeeds); +app.post('/api/workers/me/feeds', createMyDeviceFeed); +app.delete('/api/workers/me/feeds/:id', deleteMyDeviceFeed); +app.get('/api/workers/me/browser-connectors', listBrowserConnectors); // Device worker registry. Authenticated (mcpAuth); returns the calling user's // devices. Lives under /api/me/ so the workspace resolver treats it as // user-scoped (no org slug in the URL). diff --git a/packages/server/src/tools/admin/helpers/connection-helpers.ts b/packages/server/src/tools/admin/helpers/connection-helpers.ts index 16ff4d6ca..01efded88 100644 --- a/packages/server/src/tools/admin/helpers/connection-helpers.ts +++ b/packages/server/src/tools/admin/helpers/connection-helpers.ts @@ -506,6 +506,10 @@ export function serializeAuthProfile(authProfile: AuthProfileRow): Record; + if (dup.length > 0) { + return { + error: `A ${connector.name} connection (id: ${dup[0].id}) is already assigned to that device in this org.`, + }; + } + } + // For device-bound profiles, browser cookies live on disk in the profile's + // user_data_dir. The server's auth_data is empty, so the readiness probe + // returns unusable — but the connection is fine to mark active, since the + // Mac app handles auth status independently. + const isDeviceBoundBrowserSession = + authSelection?.authProfile?.profile_kind === 'browser_session' && !!profileDeviceWorkerId; + + // Device-bound browser profiles can be `pending_auth` on the profile itself + // until the user logs in (the Mac app launches the managed Chrome) — but + // the cookies live on disk on the device, not server-side, so a run is + // perfectly capable of executing. Mark the connection active so + // materializeDueFeeds picks it up; the run will fail loudly if cookies + // are missing, which is the same as any other "logged out" case. const connectionStatus = interactiveMethod || (authSelection?.authProfile?.profile_kind === 'browser_session' && + !isDeviceBoundBrowserSession && !browserProfileUsable) || - authSelection?.authProfile?.status === 'pending_auth' + (authSelection?.authProfile?.status === 'pending_auth' && !isDeviceBoundBrowserSession) ? 'pending_auth' : 'active'; @@ -1032,7 +1080,7 @@ async function handleCreate( ${splitConfig.connectionConfig ? sql.json(splitConfig.connectionConfig) : null}, ${effectiveCreatedBy}, ${visibility}, - ${deviceBinding.deviceWorkerId} + ${effectiveDeviceWorkerId} ) RETURNING * `, @@ -1189,15 +1237,55 @@ async function handleConnect( const hasNoAuth = !authSelection.oauthMethod && !authSelection.envMethod && !authSelection.browserMethod; + const profileDeviceWorkerIdConnect = authSelection.authProfile?.device_worker_id ?? null; + let effectiveDeviceWorkerIdConnect = deviceBinding.deviceWorkerId; + if (profileDeviceWorkerIdConnect) { + if (!effectiveDeviceWorkerIdConnect) { + effectiveDeviceWorkerIdConnect = profileDeviceWorkerIdConnect; + } else if (effectiveDeviceWorkerIdConnect !== profileDeviceWorkerIdConnect) { + return { + error: `Auth profile '${authSelection.authProfile!.slug}' lives on a different device than the one selected; pick that device or a different profile.`, + setup_url: setupUrl, + }; + } + } + const isDeviceBoundBrowserSessionConnect = + authSelection.authProfile?.profile_kind === 'browser_session' && + !!profileDeviceWorkerIdConnect; + // Same guard as create-path: when the profile contributed a device we + // didn't already check against, re-run the duplicate-connection check now + // so the partial unique index never decides the outcome with a raw error. + if (effectiveDeviceWorkerIdConnect && effectiveDeviceWorkerIdConnect !== deviceBinding.deviceWorkerId) { + const dup = (await sql` + SELECT id FROM connections + WHERE organization_id = ${organizationId} + AND connector_key = ${args.connector_key} + AND device_worker_id = ${effectiveDeviceWorkerIdConnect} + AND deleted_at IS NULL + LIMIT 1 + `) as unknown as Array<{ id: number }>; + if (dup.length > 0) { + return { + error: `A ${connector.name} connection (id: ${dup[0].id}) is already assigned to that device in this org.`, + setup_url: setupUrl, + }; + } + } const browserProfileUsable = - authSelection.authProfile?.profile_kind === 'browser_session' + authSelection.authProfile?.profile_kind === 'browser_session' && + !isDeviceBoundBrowserSessionConnect ? (await getBrowserSessionReadiness(authSelection.authProfile.auth_data, args.connector_key)) .usable : false; + // Device-bound browser_session profiles are "ready" by virtue of the + // cookies being on disk on the device. `getBrowserSessionReadiness` only + // looks at server-side auth_data, which is empty for these — without this + // exemption the connect path rejects them with "select or create a browser + // auth profile" even when the Mac app just created one. const hasReadySelection = !!authSelection.authProfile && (authSelection.authProfile.profile_kind === 'browser_session' - ? browserProfileUsable + ? isDeviceBoundBrowserSessionConnect || browserProfileUsable : authSelection.authProfile.status === 'active') && (authSelection.selectedKind !== 'oauth_account' || (authSelection.appAuthProfile?.status === 'active' && !!authSelection.appAuthProfile)); @@ -1211,6 +1299,7 @@ async function handleConnect( !!authSelection.browserMethod && !!authSelection.authProfile && authSelection.authProfile.profile_kind === 'browser_session' && + !isDeviceBoundBrowserSessionConnect && !browserProfileUsable; const connectionStatus = needsConnectFlow || needsBrowserAuth ? 'pending_auth' : 'active'; @@ -1293,7 +1382,7 @@ async function handleConnect( ${splitConfig.connectionConfig ? sql.json(splitConfig.connectionConfig) : null}, ${userId}, ${connectVisibility}, - ${deviceBinding.deviceWorkerId} + ${effectiveDeviceWorkerIdConnect} ) RETURNING * `, @@ -1537,8 +1626,28 @@ async function handleUpdate( const effectiveSelectedAuthProfile = hasAuthProfileArg ? authSelection.authProfile : currentAuthProfile; + + // Device-bound browser profile auto-pins the connection's device. + const updateProfileDeviceWorkerId = effectiveSelectedAuthProfile?.device_worker_id ?? null; + if (updateProfileDeviceWorkerId) { + if (!hasDeviceWorkerArg) { + // Caller didn't touch device pin — adopt the profile's device. + nextDeviceWorkerId = updateProfileDeviceWorkerId; + } else if (nextDeviceWorkerId && nextDeviceWorkerId !== updateProfileDeviceWorkerId) { + return { + error: `Auth profile '${effectiveSelectedAuthProfile!.slug}' lives on a different device than the one selected; pick that device or a different profile.`, + }; + } else if (!nextDeviceWorkerId) { + nextDeviceWorkerId = updateProfileDeviceWorkerId; + } + } + const isDeviceBoundBrowserSessionUpdate = + effectiveSelectedAuthProfile?.profile_kind === 'browser_session' && + !!updateProfileDeviceWorkerId; + const browserProfileUsable = - effectiveSelectedAuthProfile?.profile_kind === 'browser_session' + effectiveSelectedAuthProfile?.profile_kind === 'browser_session' && + !isDeviceBoundBrowserSessionUpdate ? ( await getBrowserSessionReadiness( effectiveSelectedAuthProfile.auth_data, @@ -1549,9 +1658,11 @@ async function handleUpdate( const effectiveStatus = args.status ?? (effectiveSelectedAuthProfile?.profile_kind === 'browser_session' - ? browserProfileUsable + ? isDeviceBoundBrowserSessionUpdate ? 'active' - : 'pending_auth' + : browserProfileUsable + ? 'active' + : 'pending_auth' : null); const splitConfig = splitConfigByFeedScope( args.config ?? null, @@ -1618,7 +1729,7 @@ async function handleUpdate( throw err; } - if (hasDeviceWorkerArg) { + if (hasDeviceWorkerArg || (updateProfileDeviceWorkerId && !hasDeviceWorkerArg)) { await sql` UPDATE connections SET device_worker_id = ${nextDeviceWorkerId}, updated_at = NOW() diff --git a/packages/server/src/tools/admin/notify.ts b/packages/server/src/tools/admin/notify.ts index 5dd99146a..a588fefde 100644 --- a/packages/server/src/tools/admin/notify.ts +++ b/packages/server/src/tools/admin/notify.ts @@ -64,7 +64,7 @@ const SendAction = Type.Object({ ), }); -const NotifySchema = Type.Union([SendAction]); +export const NotifySchema = Type.Union([SendAction]); type NotifyArgs = Static; // ============================================ diff --git a/packages/server/src/utils/auth-profiles.ts b/packages/server/src/utils/auth-profiles.ts index 1a922aa3c..d1598c954 100644 --- a/packages/server/src/utils/auth-profiles.ts +++ b/packages/server/src/utils/auth-profiles.ts @@ -9,6 +9,8 @@ export type AuthProfileKind = | 'interactive'; export type AuthProfileStatus = 'active' | 'pending_auth' | 'error' | 'revoked'; +export type BrowserKind = 'chrome' | 'brave' | 'arc' | 'edge'; + export interface AuthProfileRow { id: number; organization_id: string; @@ -23,6 +25,10 @@ export interface AuthProfileRow { created_by: string | null; created_at: string; updated_at: string; + device_worker_id: string | null; + browser_kind: BrowserKind | null; + user_data_dir: string | null; + cdp_url: string | null; } interface BrowserSessionSummary { @@ -234,7 +240,8 @@ export async function ensureUniqueAuthProfileSlug(params: { const AUTH_PROFILE_COLUMNS = ` id, organization_id, slug, display_name, connector_key, profile_kind, status, auth_data, account_id, provider, - created_by, created_at, updated_at + created_by, created_at, updated_at, + device_worker_id, browser_kind, user_data_dir, cdp_url ` as const; export async function listAuthProfiles(params: { @@ -309,6 +316,10 @@ export async function createAuthProfile(params: { provider?: string | null; status?: AuthProfileStatus; createdBy?: string | null; + deviceWorkerId?: string | null; + browserKind?: BrowserKind | null; + userDataDir?: string | null; + cdpUrl?: string | null; }): Promise { const sql = getDb(); const slug = await ensureUniqueAuthProfileSlug({ @@ -327,7 +338,11 @@ export async function createAuthProfile(params: { auth_data, account_id, provider, - created_by + created_by, + device_worker_id, + browser_kind, + user_data_dir, + cdp_url ) VALUES ( ${params.organizationId}, ${slug}, @@ -338,7 +353,11 @@ export async function createAuthProfile(params: { ${sql.json(normalizeAuthData(params.profileKind, params.authData ?? {}))}, ${params.accountId ?? null}, ${params.provider ? params.provider.toLowerCase() : null}, - ${params.createdBy ?? null} + ${params.createdBy ?? null}, + ${params.deviceWorkerId ?? null}, + ${params.browserKind ?? null}, + ${params.userDataDir ?? null}, + ${params.cdpUrl ?? null} ) RETURNING ${sql.unsafe(AUTH_PROFILE_COLUMNS)} `; diff --git a/packages/server/src/utils/connector-catalog.ts b/packages/server/src/utils/connector-catalog.ts index 742ce8b7c..5ea9b6b4e 100644 --- a/packages/server/src/utils/connector-catalog.ts +++ b/packages/server/src/utils/connector-catalog.ts @@ -388,9 +388,14 @@ export async function getBundledDeviceConnectors(): Promise ({ key: d.key, requiredCapability: d.required_capability as string, + // Exclude `userManaged` feeds — they require per-instance config the + // auto-wire flow can't supply (e.g. local.directory.files needs a + // folder_id from the Mac app). The Mac app creates them explicitly. feedKeys: d.feeds_schema && typeof d.feeds_schema === 'object' && !Array.isArray(d.feeds_schema) - ? Object.keys(d.feeds_schema) + ? Object.entries(d.feeds_schema as Record) + .filter(([, def]) => !def?.userManaged) + .map(([key]) => key) : [], })); } diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index 31b5e166f..e541e8997 100644 --- a/packages/server/src/utils/execution-context.ts +++ b/packages/server/src/utils/execution-context.ts @@ -19,6 +19,7 @@ interface ResolvedExecutionAuth { credentials: ExecutionOAuthCredentials | null; connectionCredentials: Record; sessionState: Record | null; + browserUserDataDir: string | null; } interface ResolveExecutionAuthParams { @@ -85,15 +86,32 @@ export async function resolveExecutionAuth( authProfile?.profile_kind === 'env' ? (authProfile.auth_data ?? {}) : {} ), }; - const sessionState = + let sessionState = authProfile?.profile_kind === 'browser_session' || authProfile?.profile_kind === 'interactive' ? ((authProfile.auth_data as Record) ?? null) : null; + // Device-bound browser profiles either: + // user_data_dir → managed Chrome with isolated cookies; or + // cdp_url → attach to a running Chrome via remote debugging port. + // Cookies stay on the device in both cases; the server never holds them. + let browserUserDataDir: string | null = null; + if (authProfile?.profile_kind === 'browser_session' && authProfile.device_worker_id) { + browserUserDataDir = authProfile.user_data_dir ?? null; + const cdpUrl = authProfile.cdp_url ?? null; + if (browserUserDataDir) { + sessionState = { ...(sessionState ?? {}), user_data_dir: browserUserDataDir }; + } + if (cdpUrl) { + sessionState = { ...(sessionState ?? {}), cdp_url: cdpUrl }; + } + } + return { credentials, connectionCredentials, sessionState, + browserUserDataDir, }; } diff --git a/packages/server/src/worker-api.ts b/packages/server/src/worker-api.ts index c13bcc52b..b6c4a2a77 100644 --- a/packages/server/src/worker-api.ts +++ b/packages/server/src/worker-api.ts @@ -13,7 +13,12 @@ import type { Env } from './index'; import { notifyBrowserAuthExpired } from './notifications/triggers'; import { materializeDueFeeds } from './scheduled/check-due-feeds'; import { supersedeActionEvent } from './tools/admin/manage_operations'; -import { getAuthProfileById, getBrowserSessionReadiness } from './utils/auth-profiles'; +import { + type BrowserKind, + createAuthProfile, + getAuthProfileById, + getBrowserSessionReadiness, +} from './utils/auth-profiles'; import { maybeCloseRepairThread, maybeOpenOrAppendRepairThread, @@ -431,6 +436,10 @@ export async function pollWorkerJob(c: Context<{ Bindings: Env }>) { // profile still can't leak secrets to an arbitrary capability-matched device. const connectionIsDevicePinned = row.connection_device_worker_id != null; const deliverConnectionAuth = !!row.connection_id && (!isUserScopedWorker || connectionIsDevicePinned); + // `user_data_dir` and `cdp_url` for device-bound browser profiles flow to + // the worker via `sessionState.user_data_dir` / `sessionState.cdp_url` + // (set inside resolveExecutionAuth). No need to thread them as separate + // top-level fields here. const { credentials, connectionCredentials, sessionState } = deliverConnectionAuth ? await resolveExecutionAuth({ organizationId: row.organization_id, @@ -1689,3 +1698,414 @@ export async function deleteDeviceWorker(c: Context<{ Bindings: Env }>) { return c.json({ error: errorMessage(err) }, 500); } } + +const BROWSER_KIND_SET: ReadonlySet = new Set(['chrome', 'brave', 'arc', 'edge']); + +async function resolveDeviceWorkerForRequest( + c: Context<{ Bindings: Env }>, + workerId: string +): Promise<{ device: { id: string; organization_id: string } | null; error?: Response }> { + const userId = c.var.workerUserId; + if (!userId) { + return { device: null, error: c.json({ error: 'Unauthorized' }, 401) }; + } + const sql = getDb(); + const rows = (await sql` + SELECT id, organization_id + FROM device_workers + WHERE user_id = ${userId} AND worker_id = ${workerId} + LIMIT 1 + `) as unknown as Array<{ id: string; organization_id: string | null }>; + const row = rows[0]; + if (!row) { + return { device: null, error: c.json({ error: 'Device not registered yet — poll first' }, 404) }; + } + if (!row.organization_id) { + return { device: null, error: c.json({ error: 'Device has no organization attached' }, 409) }; + } + return { device: { id: row.id, organization_id: row.organization_id } }; +} + +/** + * GET /api/workers/me/auth-profiles?worker_id=... + * + * List the browser-session auth profiles owned by this device worker. The Mac + * app uses this to reconcile its local --user-data-dir directories against + * server state after each poll. + */ +export async function listMyDeviceAuthProfiles(c: Context<{ Bindings: Env }>) { + const workerId = (c.req.query('worker_id') ?? '').trim(); + if (!workerId) { + return c.json({ error: 'worker_id query param is required' }, 400); + } + const { device, error } = await resolveDeviceWorkerForRequest(c, workerId); + if (error) return error; + try { + const sql = getDb(); + const rows = (await sql` + SELECT id, slug, display_name, connector_key, profile_kind, status, + browser_kind, user_data_dir, cdp_url, created_at, updated_at + FROM auth_profiles + WHERE device_worker_id = ${device!.id} + AND profile_kind = 'browser_session' + ORDER BY created_at DESC + `) as unknown as Array>; + return c.json({ profiles: rows }); + } catch (err) { + logger.error({ error: errorMessage(err) }, '[listMyDeviceAuthProfiles] Error'); + return c.json({ error: errorMessage(err) }, 500); + } +} + +/** + * POST /api/workers/me/auth-profiles + * + * Body: { worker_id, connector_key, display_name, browser_kind, user_data_dir } + * + * Create a browser-session auth profile bound to this device. Cookies stay on + * the device — server's auth_data is empty. + */ +export async function createMyDeviceAuthProfile(c: Context<{ Bindings: Env }>) { + let body: { + worker_id?: string; + connector_key?: string; + display_name?: string; + browser_kind?: string; + user_data_dir?: string; + cdp_url?: string; + }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + const workerId = (body.worker_id ?? '').trim(); + const connectorKey = (body.connector_key ?? '').trim(); + const displayName = (body.display_name ?? '').trim(); + const browserKind = (body.browser_kind ?? '').trim() as BrowserKind; + const userDataDir = (body.user_data_dir ?? '').trim(); + const cdpUrl = (body.cdp_url ?? '').trim(); + if (!workerId || !connectorKey || !displayName || !browserKind) { + return c.json({ error: 'worker_id, connector_key, display_name, browser_kind are required' }, 400); + } + if (!BROWSER_KIND_SET.has(browserKind)) { + return c.json({ error: `browser_kind must be one of: ${[...BROWSER_KIND_SET].join(', ')}` }, 400); + } + // Exactly one of (user_data_dir, cdp_url) — copy vs attach modes are + // mutually exclusive for a device-bound profile. + if (!userDataDir && !cdpUrl) { + return c.json({ error: 'exactly one of user_data_dir (copy mode) or cdp_url (attach mode) is required' }, 400); + } + if (userDataDir && cdpUrl) { + return c.json({ error: 'user_data_dir and cdp_url are mutually exclusive' }, 400); + } + const { device, error } = await resolveDeviceWorkerForRequest(c, workerId); + if (error) return error; + try { + const profile = await createAuthProfile({ + organizationId: device!.organization_id, + connectorKey, + displayName, + profileKind: 'browser_session', + status: 'pending_auth', + createdBy: c.var.workerUserId, + deviceWorkerId: device!.id, + browserKind, + userDataDir: userDataDir || null, + cdpUrl: cdpUrl || null, + }); + return c.json({ profile }); + } catch (err) { + logger.error({ error: errorMessage(err) }, '[createMyDeviceAuthProfile] Error'); + return c.json({ error: errorMessage(err) }, 500); + } +} + +/** + * DELETE /api/workers/me/auth-profiles/:id { worker_id } + * + * Soft-revoke an auth profile owned by this device. Connections referencing + * this profile keep their auth_profile_id (the slug surfaces in the UI as + * "auth revoked, reconnect"), matching the existing convention. + */ +export async function deleteMyDeviceAuthProfile(c: Context<{ Bindings: Env }>) { + const profileId = Number((c.req.param('id') ?? '').trim()); + if (!Number.isFinite(profileId)) { + return c.json({ error: 'invalid profile id' }, 400); + } + let body: { worker_id?: string }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + const workerId = (body.worker_id ?? '').trim(); + if (!workerId) { + return c.json({ error: 'worker_id is required' }, 400); + } + const { device, error } = await resolveDeviceWorkerForRequest(c, workerId); + if (error) return error; + try { + const sql = getDb(); + const updated = (await sql` + UPDATE auth_profiles + SET status = 'revoked', updated_at = now() + WHERE id = ${profileId} + AND device_worker_id = ${device!.id} + AND profile_kind = 'browser_session' + RETURNING id + `) as unknown as Array<{ id: number }>; + if (updated.length === 0) { + return c.json({ error: 'Profile not found on this device' }, 404); + } + return c.json({ ok: true }); + } catch (err) { + logger.error({ error: errorMessage(err) }, '[deleteMyDeviceAuthProfile] Error'); + return c.json({ error: errorMessage(err) }, 500); + } +} + +// ----------------------------------------------------------------------------- +// Device-scoped feed CRUD +// ----------------------------------------------------------------------------- +// +// The Mac app uses these to create / list / delete feeds on its auto-wired +// device connection (e.g. one feed per local folder for `local.directory`). +// Scope = (this device's user, this device's auto-wired connection for the +// given connector_key). Server never sees the security-scoped bookmark — just +// the metadata the Mac app posts in the feed config. + +async function resolveDeviceConnection( + c: Context<{ Bindings: Env }>, + workerId: string, + connectorKey: string +): Promise<{ + device: { id: string; organization_id: string } | null; + connection: { id: number } | null; + error?: Response; +}> { + const { device, error } = await resolveDeviceWorkerForRequest(c, workerId); + if (error || !device) return { device: null, connection: null, error }; + const sql = getDb(); + // The user-scoped device worker auto-wires a single connection for the + // connector in its home org (see device-reconcile.ts). Match on + // (user, connector, org) — user_id link via device_workers.created_by — to + // find that row. Either pinned to this device or unpinned with no other + // pin owner. + const rows = (await sql` + SELECT c.id + FROM connections c + JOIN device_workers dw ON dw.user_id = c.created_by + WHERE dw.id = ${device.id} + AND c.connector_key = ${connectorKey} + AND c.organization_id = ${device.organization_id} + AND c.deleted_at IS NULL + AND (c.device_worker_id IS NULL OR c.device_worker_id = ${device.id}::uuid) + ORDER BY c.created_at ASC + LIMIT 1 + `) as unknown as Array<{ id: number }>; + const row = rows[0]; + if (!row) { + return { + device, + connection: null, + error: c.json( + { + error: `No connection wired yet for connector '${connectorKey}'. The device must advertise the capability via /api/workers/poll once first so auto-wire creates it.`, + }, + 404 + ), + }; + } + return { device, connection: { id: row.id } }; +} + +/** + * GET /api/workers/me/feeds?worker_id=...&connector_key=... + */ +export async function listMyDeviceFeeds(c: Context<{ Bindings: Env }>) { + const workerId = (c.req.query('worker_id') ?? '').trim(); + const connectorKey = (c.req.query('connector_key') ?? '').trim(); + if (!workerId || !connectorKey) { + return c.json({ error: 'worker_id and connector_key are required' }, 400); + } + const { device, connection, error } = await resolveDeviceConnection(c, workerId, connectorKey); + if (error || !connection) return error ?? c.json({ feeds: [] }); + try { + const sql = getDb(); + const rows = (await sql` + SELECT id, feed_key, display_name, status, config, schedule, next_run_at, + last_sync_at, created_at, updated_at + FROM feeds + WHERE connection_id = ${connection.id} + AND deleted_at IS NULL + ORDER BY created_at ASC + `) as unknown as Array>; + return c.json({ connection_id: connection.id, organization_id: device!.organization_id, feeds: rows }); + } catch (err) { + logger.error({ error: errorMessage(err) }, '[listMyDeviceFeeds] Error'); + return c.json({ error: errorMessage(err) }, 500); + } +} + +/** + * POST /api/workers/me/feeds + * + * Body: { worker_id, connector_key, feed_key, display_name, config } + * + * Creates a feed on this device's auto-wired connection. Config is whatever + * the connector's feed definition declares (e.g. {folder_id, display_name} + * for local.directory.files). + */ +export async function createMyDeviceFeed(c: Context<{ Bindings: Env }>) { + let body: { + worker_id?: string; + connector_key?: string; + feed_key?: string; + display_name?: string; + config?: Record; + }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + const workerId = (body.worker_id ?? '').trim(); + const connectorKey = (body.connector_key ?? '').trim(); + const feedKey = (body.feed_key ?? '').trim(); + const displayName = (body.display_name ?? '').trim(); + if (!workerId || !connectorKey || !feedKey || !displayName) { + return c.json({ error: 'worker_id, connector_key, feed_key, display_name are required' }, 400); + } + const { device, connection, error } = await resolveDeviceConnection(c, workerId, connectorKey); + if (error || !connection) return error!; + try { + const sql = getDb(); + // Idempotent on (connection_id, feed_key, config->>'folder_id'): two + // concurrent reconciles must not produce duplicate feeds for the same + // folder. We probe with a SELECT first, then INSERT; race window is + // narrowed by the surrounding worker poll cadence. Stronger guarantee + // would be a partial unique index — feed key namespaces vary by + // connector so we leave that as a follow-up. + const folderIdInConfig = + typeof (body.config as Record | undefined)?.folder_id === 'string' + ? ((body.config as Record).folder_id as string) + : null; + if (folderIdInConfig) { + const existing = (await sql` + SELECT id, feed_key, display_name, status, config, created_at + FROM feeds + WHERE connection_id = ${connection.id} + AND feed_key = ${feedKey} + AND config->>'folder_id' = ${folderIdInConfig} + AND deleted_at IS NULL + LIMIT 1 + `) as unknown as Array>; + if (existing.length > 0) { + return c.json({ feed: existing[0] }); + } + } + const inserted = (await sql` + INSERT INTO feeds ( + organization_id, connection_id, feed_key, display_name, status, config, next_run_at + ) VALUES ( + ${device!.organization_id}, ${connection.id}, ${feedKey}, ${displayName}, 'active', + ${body.config ? sql.json(body.config) : null}, + NOW() + ) + RETURNING id, feed_key, display_name, status, config, created_at + `) as unknown as Array>; + return c.json({ feed: inserted[0] }); + } catch (err) { + logger.error({ error: errorMessage(err) }, '[createMyDeviceFeed] Error'); + return c.json({ error: errorMessage(err) }, 500); + } +} + +/** + * DELETE /api/workers/me/feeds/:id { worker_id, connector_key } + * + * Soft-deletes the feed (deleted_at = now()) — matches existing manage_feeds + * convention. The feed must belong to this device's connection for the given + * connector. + */ +export async function deleteMyDeviceFeed(c: Context<{ Bindings: Env }>) { + const feedId = Number((c.req.param('id') ?? '').trim()); + if (!Number.isFinite(feedId)) { + return c.json({ error: 'invalid feed id' }, 400); + } + let body: { worker_id?: string; connector_key?: string }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + const workerId = (body.worker_id ?? '').trim(); + const connectorKey = (body.connector_key ?? '').trim(); + if (!workerId || !connectorKey) { + return c.json({ error: 'worker_id and connector_key are required' }, 400); + } + const { connection, error } = await resolveDeviceConnection(c, workerId, connectorKey); + if (error || !connection) return error!; + try { + const sql = getDb(); + const updated = (await sql` + UPDATE feeds + SET deleted_at = NOW(), updated_at = NOW(), status = 'paused' + WHERE id = ${feedId} + AND connection_id = ${connection.id} + AND deleted_at IS NULL + RETURNING id + `) as unknown as Array<{ id: number }>; + if (updated.length === 0) { + return c.json({ error: 'Feed not found on this device' }, 404); + } + return c.json({ ok: true }); + } catch (err) { + logger.error({ error: errorMessage(err) }, '[deleteMyDeviceFeed] Error'); + return c.json({ error: errorMessage(err) }, 500); + } +} + +/** + * GET /api/workers/me/browser-connectors?worker_id=... + * + * Returns connectors whose auth_schema declares a `browser` method, scoped to + * the device's home organization. The Mac app uses this to populate the + * "Connector" picker when creating a browser auth profile — keeps the picker + * in sync with whatever connectors are installed in the user's org. + */ +export async function listBrowserConnectors(c: Context<{ Bindings: Env }>) { + const workerId = (c.req.query('worker_id') ?? '').trim(); + if (!workerId) { + return c.json({ error: 'worker_id is required' }, 400); + } + const { device, error } = await resolveDeviceWorkerForRequest(c, workerId); + if (error || !device) return error ?? c.json({ connectors: [] }); + try { + const sql = getDb(); + const rows = (await sql` + SELECT DISTINCT ON (key) key, name, favicon_domain + FROM connector_definitions + WHERE organization_id = ${device.organization_id} + AND status = 'active' + AND auth_schema IS NOT NULL + AND jsonb_typeof(auth_schema::jsonb -> 'methods') = 'array' + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(auth_schema::jsonb -> 'methods') m + WHERE m->>'type' = 'browser' + ) + ORDER BY key, updated_at DESC + `) as unknown as Array<{ key: string; name: string; favicon_domain: string | null }>; + return c.json({ + connectors: rows.map((r) => ({ + key: r.key, + name: r.name, + favicon_domain: r.favicon_domain, + })), + }); + } catch (err) { + logger.error({ error: errorMessage(err) }, '[listBrowserConnectors] Error'); + return c.json({ error: errorMessage(err) }, 500); + } +} diff --git a/packages/server/src/worker-api/device-reconcile.ts b/packages/server/src/worker-api/device-reconcile.ts index 6ad5facae..90b665834 100644 --- a/packages/server/src/worker-api/device-reconcile.ts +++ b/packages/server/src/worker-api/device-reconcile.ts @@ -130,9 +130,17 @@ async function ensureDeviceConnectorWired( const compiledCode = await compileConnectorFromFile(filePath); const metadata = await extractConnectorMetadata(compiledCode); if (!metadata.key || !metadata.name || !metadata.version) return; - const feedsSchema = metadata.feeds as Record | null; - const feedKeys = feedsSchema ? Object.keys(feedsSchema) : []; - if (feedKeys.length === 0) return; + const feedsSchema = metadata.feeds as Record< + string, + { configSchema?: unknown; userManaged?: boolean } + > | null; + // Skip feeds the connector marks `userManaged` — they need per-instance + // config (e.g. local.directory.files needs a folder_id per folder) that + // auto-wire can't supply. The Mac app creates them explicitly via + // /api/workers/me/feeds once it has the folder bookmark. + const feedKeys = feedsSchema + ? Object.keys(feedsSchema).filter((k) => !feedsSchema[k]?.userManaged) + : []; let connectionId: number | undefined; await sql.begin(async (tx) => {