Explorar el Código

Add Google Keep desktop widgets (small + large) with live note preview

- Small: recent notes from hidden WKWebView session + new note action
- Large: embedded keep.google.com with search, filters, and create shortcuts
- KeepLiveNotesStore extracts notes via JS (shared cookies with large widget)
- Wire WidgetLayoutMode.keep and DesktopWidgetView; size overrides in templates
- Offer only small and large Keep variants (no medium tier)

Made-with: Cursor
huzaifahayat12 hace 3 meses
padre
commit
2b5cfa7547

+ 3 - 1
google_apps/Widgets/DesktopWidgetView.swift

@@ -19,6 +19,8 @@ struct DesktopWidgetView: View {
                 GmailDesktopWidgetView(app: app, mode: gmailMode, gmailAccountSlot: gmailAccountSlot, isPreview: isPreview)
             } else if case .drive(let driveMode) = layoutMode {
                 DriveDesktopWidgetView(app: app, mode: driveMode, isPreview: isPreview)
+            } else if case .keep(let keepMode) = layoutMode {
+                KeepDesktopWidgetView(app: app, mode: keepMode, isPreview: isPreview)
             } else if case .interactiveMap = layoutMode {
                 MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
             } else {
@@ -54,7 +56,7 @@ struct DesktopWidgetView: View {
                             }
                             gridActions(columns: columns, maxActions: maxActionsForSize)
 
-                        case .interactiveMap, .gmail, .drive:
+                        case .interactiveMap, .gmail, .drive, .keep:
                             EmptyView()
                         }
                     }

+ 518 - 0
google_apps/Widgets/KeepDesktopWidgetView.swift

@@ -0,0 +1,518 @@
+import AppKit
+import SwiftUI
+import WebKit
+
+/// Google Keep desktop widget: recent notes (small) or embedded Keep (large).
+struct KeepDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: KeepWidgetMode
+    var isPreview: Bool = false
+
+    @ObservedObject private var liveNotes = KeepLiveNotesStore.shared
+
+    private var baseURL: URL { app.webURL ?? URL(string: "https://keep.google.com")! }
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .compact:
+                compactBody
+            case .interactive:
+                interactiveBody
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+    }
+
+    // MARK: - Small
+
+    /// Static rows for widget gallery / preview only.
+    private static let previewCatalogNotes: [KeepLiveNotesStore.Note] = [
+        KeepLiveNotesStore.Note(id: "p1", title: "Groceries", snippet: "Milk, eggs, coffee…"),
+        KeepLiveNotesStore.Note(id: "p2", title: "Meeting notes", snippet: "Follow up with design…"),
+        KeepLiveNotesStore.Note(id: "p3", title: "Ideas", snippet: "Widget themes for spring…"),
+    ]
+
+    private var compactDisplayNotes: [KeepLiveNotesStore.Note] {
+        if isPreview { return Array(Self.previewCatalogNotes.prefix(3)) }
+        return Array(liveNotes.notes.prefix(3))
+    }
+
+    private var compactBody: some View {
+        ZStack(alignment: .topLeading) {
+            keepGradientBackground
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+            VStack(alignment: .leading, spacing: 10) {
+                HStack(spacing: 10) {
+                    AppIconView(app: app, size: 32, showAppBackground: false, iconPaddingFactor: 0.08)
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(app.name)
+                            .font(.system(size: 14, weight: .bold))
+                            .foregroundStyle(.white.opacity(0.95))
+                        Text("Recent")
+                            .font(.system(size: 10, weight: .semibold))
+                            .foregroundStyle(.white.opacity(0.65))
+                    }
+                    Spacer(minLength: 0)
+                    if !isPreview {
+                        Button(action: {
+                            NSApp.activate(ignoringOtherApps: true)
+                            liveNotes.forceRefresh()
+                        }) {
+                            Image(systemName: "arrow.clockwise")
+                                .font(.system(size: 12, weight: .semibold))
+                                .foregroundStyle(.white.opacity(0.75))
+                        }
+                        .buttonStyle(.plain)
+                        .help("Refresh from Keep")
+                    }
+                }
+                keepStatusCaption
+
+                compactNoteList
+
+                keepPrimaryButton(title: "New note", systemImage: "square.and.pencil") {
+                    openKeepFragment("NOTE")
+                }
+            }
+            .padding(14)
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .disabled(isPreview)
+        .opacity(isPreview ? 0.85 : 1)
+        .onAppear {
+            if !isPreview {
+                liveNotes.requestRefreshIfNeeded()
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var keepStatusCaption: some View {
+        if isPreview {
+            Text("Preview")
+                .font(.system(size: 9, weight: .bold))
+                .foregroundStyle(.white.opacity(0.45))
+        } else {
+            switch liveNotes.phase {
+            case .idle, .loading:
+                HStack(spacing: 6) {
+                    ProgressView()
+                        .controlSize(.small)
+                        .scaleEffect(0.75)
+                    Text("Loading from Keep…")
+                        .font(.system(size: 9, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.55))
+                }
+            case .ready:
+                Text("From your Keep")
+                    .font(.system(size: 9, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.5))
+            case .empty:
+                Text("No notes found")
+                    .font(.system(size: 9, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.55))
+            case .needsSignIn:
+                Text("Sign in via large Keep widget")
+                    .font(.system(size: 9, weight: .medium))
+                    .foregroundStyle(Color(red: 1, green: 0.85, blue: 0.4).opacity(0.95))
+                    .lineLimit(2)
+            case .failed:
+                Text("Could not load — tap refresh")
+                    .font(.system(size: 9, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.55))
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var compactNoteList: some View {
+        if !isPreview, case .needsSignIn = liveNotes.phase {
+            Text("Open the large Keep widget and sign in to Google. This widget reads notes from the same session as that web view.")
+                .font(.system(size: 10, weight: .medium))
+                .foregroundStyle(.white.opacity(0.72))
+                .fixedSize(horizontal: false, vertical: true)
+        } else if compactDisplayNotes.isEmpty, !isPreview, liveNotes.phase == .ready || liveNotes.phase == .empty {
+            Text("No notes yet, or Keep changed its layout. Try refresh or open Keep in the large widget.")
+                .font(.system(size: 10, weight: .medium))
+                .foregroundStyle(.white.opacity(0.65))
+                .fixedSize(horizontal: false, vertical: true)
+        } else {
+            VStack(alignment: .leading, spacing: 8) {
+                ForEach(compactDisplayNotes) { note in
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text(note.title.isEmpty ? "Note" : note.title)
+                            .font(.system(size: 12, weight: .bold))
+                            .foregroundStyle(.white.opacity(0.95))
+                            .lineLimit(1)
+                        Text(note.snippet.isEmpty ? " " : note.snippet)
+                            .font(.system(size: 10, weight: .medium))
+                            .foregroundStyle(.white.opacity(0.65))
+                            .lineLimit(2)
+                    }
+                    .padding(10)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.black.opacity(0.2)))
+                }
+            }
+        }
+    }
+
+    // MARK: - Large (WKWebView)
+
+    @State private var searchText = ""
+    @State private var webURL: URL
+
+    init(app: LauncherApp, mode: KeepWidgetMode, isPreview: Bool = false) {
+        self.app = app
+        self.mode = mode
+        self.isPreview = isPreview
+        let base = app.webURL ?? URL(string: "https://keep.google.com")!
+        _webURL = State(initialValue: WidgetDeepLinkURL.resolved(base: base, actionPath: "/", accountSlot: 0))
+    }
+
+    private var interactiveBody: some View {
+        Group {
+            if isPreview {
+                interactivePreviewChrome
+            } else {
+                interactiveLiveChrome
+            }
+        }
+    }
+
+    private var interactivePreviewChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [Color(red: 0.42, green: 0.35, blue: 0.12), Color(red: 0.2, green: 0.18, blue: 0.1)],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+            VStack(spacing: 12) {
+                AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text("Google Keep")
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.92))
+                Text("Full Keep with notes, labels, and lists loads on your desktop.")
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.65))
+                    .multilineTextAlignment(.center)
+                    .padding(.horizontal, 8)
+            }
+            .padding(16)
+        }
+    }
+
+    private var interactiveLiveChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(Color.black.opacity(0.28))
+
+            VStack(spacing: 0) {
+                VStack(spacing: 8) {
+                    interactiveHeader
+                    interactiveSearchBar
+                    filterChipsRow
+                    createRow
+                }
+                .padding(.horizontal, 6)
+                .padding(.vertical, 8)
+                .background(
+                    RoundedRectangle(cornerRadius: 16, style: .continuous)
+                        .fill(Color(red: 0.12, green: 0.1, blue: 0.06))
+                )
+                .zIndex(20)
+
+                KeepWebWidgetView(url: $webURL)
+                    .frame(minHeight: 280)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+                    .zIndex(0)
+            }
+            .padding(10)
+        }
+    }
+
+    private var interactiveHeader: some View {
+        HStack(spacing: 8) {
+            AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
+            Text("Keep")
+                .font(.system(size: 14, weight: .bold))
+                .foregroundStyle(.white.opacity(0.95))
+            Spacer(minLength: 0)
+            Button(action: {
+                NSApp.activate(ignoringOtherApps: true)
+                openKeepInBrowser()
+            }) {
+                Image(systemName: "arrow.up.right.square")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.9))
+                    .padding(8)
+                    .background(Circle().fill(Color.white.opacity(0.12)))
+            }
+            .buttonStyle(.plain)
+            .help("Open Keep in browser")
+        }
+    }
+
+    private var interactiveSearchBar: some View {
+        HStack(spacing: 8) {
+            Image(systemName: "magnifyingglass")
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(.white.opacity(0.55))
+            TextField("Search notes", text: $searchText)
+                .textFieldStyle(.plain)
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(.white.opacity(0.95))
+                .onSubmit { submitSearch() }
+            Button("Go") { submitSearch() }
+                .font(.system(size: 11, weight: .bold))
+                .foregroundStyle(Color(red: 1, green: 0.85, blue: 0.35))
+                .buttonStyle(.plain)
+        }
+        .padding(.horizontal, 10)
+        .padding(.vertical, 8)
+        .background(
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
+                .fill(Color.white.opacity(0.08))
+        )
+    }
+
+    private var filterChipsRow: some View {
+        ScrollView(.horizontal, showsIndicators: false) {
+            HStack(spacing: 8) {
+                keepChip("Notes", fragment: "")
+                keepChip("Reminders", fragment: "reminders")
+                keepChip("Archive", fragment: "archive")
+                keepChip("Trash", fragment: "trash")
+                keepChip("Labels", fragment: "label")
+            }
+        }
+    }
+
+    private var createRow: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            Text("Create")
+                .font(.system(size: 10, weight: .bold))
+                .foregroundStyle(.white.opacity(0.55))
+            ScrollView(.horizontal, showsIndicators: false) {
+                HStack(spacing: 8) {
+                    createChip("Text", fragment: "NOTE")
+                    createChip("List", fragment: "LIST")
+                    createChip("Drawing", fragment: "DRAWING")
+                    createChip("Image", fragment: "NOTE")
+                    Button(action: {
+                        NSApp.activate(ignoringOtherApps: true)
+                        openKeepFragment("NOTE")
+                    }) {
+                        HStack(spacing: 4) {
+                            Image(systemName: "photo")
+                            Text("Photo")
+                        }
+                        .font(.system(size: 10, weight: .bold))
+                        .foregroundStyle(.white.opacity(0.92))
+                        .padding(.horizontal, 10)
+                        .padding(.vertical, 6)
+                        .background(Capsule().fill(Color.white.opacity(0.14)))
+                    }
+                    .buttonStyle(.plain)
+                    .help("New note — add an image in Keep")
+                }
+            }
+        }
+    }
+
+    private func keepChip(_ title: String, fragment: String) -> some View {
+        Button(action: {
+            NSApp.activate(ignoringOtherApps: true)
+            if fragment.isEmpty {
+                navigateWeb(to: "/")
+            } else {
+                navigateWeb(to: "/#\(fragment)")
+            }
+        }) {
+            Text(title)
+                .font(.system(size: 10, weight: .bold))
+                .foregroundStyle(.white.opacity(0.92))
+                .padding(.horizontal, 10)
+                .padding(.vertical, 6)
+                .background(Capsule().fill(Color.white.opacity(0.12)))
+        }
+        .buttonStyle(.plain)
+    }
+
+    private func createChip(_ title: String, fragment: String) -> some View {
+        Button(action: {
+            NSApp.activate(ignoringOtherApps: true)
+            openKeepFragment(fragment)
+        }) {
+            Text(title)
+                .font(.system(size: 10, weight: .bold))
+                .foregroundStyle(.white.opacity(0.92))
+                .padding(.horizontal, 10)
+                .padding(.vertical, 6)
+                .background(Capsule().fill(Color.white.opacity(0.12)))
+        }
+        .buttonStyle(.plain)
+    }
+
+    // MARK: - Shared styling
+
+    private var keepGradientBackground: some View {
+        LinearGradient(
+            colors: [
+                Color(red: 0.52, green: 0.42, blue: 0.14),
+                Color(red: 0.22, green: 0.2, blue: 0.12),
+            ],
+            startPoint: .topLeading,
+            endPoint: .bottomTrailing
+        )
+    }
+
+    private func keepPrimaryButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
+        Button(action: {
+            NSApp.activate(ignoringOtherApps: true)
+            action()
+        }) {
+            HStack(spacing: 8) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 13, weight: .semibold))
+                Text(title)
+                    .font(.system(size: 13, weight: .bold))
+            }
+            .foregroundStyle(Color(red: 0.12, green: 0.1, blue: 0.04))
+            .frame(maxWidth: .infinity)
+            .padding(.vertical, 11)
+            .background(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .fill(Color.white.opacity(0.92))
+            )
+        }
+        .buttonStyle(.plain)
+        .disabled(isPreview)
+        .allowsHitTesting(!isPreview)
+    }
+
+    // MARK: - URLs
+
+    private func openKeepFragment(_ fragment: String) {
+        guard !isPreview else { return }
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: "/#\(fragment)", accountSlot: 0)
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func openKeepInBrowser() {
+        guard !isPreview else { return }
+        InAppBrowserWindowManager.shared.open(url: baseURL, title: app.name)
+    }
+
+    private func navigateWeb(to path: String) {
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: path, accountSlot: 0)
+        DispatchQueue.main.async {
+            webURL = url
+        }
+    }
+
+    private func submitSearch() {
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !q.isEmpty else { return }
+        NSApp.activate(ignoringOtherApps: true)
+        let encoded = q.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlFragmentAllowed) ?? q
+        navigateWeb(to: "/#search/\(encoded)")
+    }
+}
+
+// MARK: - WKWebView
+
+private final class KeepWebViewClippingContainer: NSView {
+    override var isFlipped: Bool { true }
+}
+
+private struct KeepWebWidgetView: NSViewRepresentable {
+    @Binding var url: URL
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator()
+    }
+
+    func makeNSView(context: Context) -> NSView {
+        let container = KeepWebViewClippingContainer()
+        container.wantsLayer = true
+        container.layer?.masksToBounds = true
+        container.layer?.cornerRadius = 12
+        container.layer?.borderWidth = 0
+
+        let config = WKWebViewConfiguration()
+        config.defaultWebpagePreferences.allowsContentJavaScript = true
+        let wv = WKWebView(frame: .zero, configuration: config)
+        wv.translatesAutoresizingMaskIntoConstraints = false
+        wv.focusRingType = .none
+        wv.customUserAgent =
+            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
+        wv.navigationDelegate = context.coordinator
+
+        container.addSubview(wv)
+        NSLayoutConstraint.activate([
+            wv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+            wv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+            wv.topAnchor.constraint(equalTo: container.topAnchor),
+            wv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+        ])
+
+        context.coordinator.webView = wv
+        context.coordinator.lastLoadedURLString = url.absoluteString
+        wv.load(URLRequest(url: url))
+        DispatchQueue.main.async {
+            Self.stripWebKitOuterChrome(wv)
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+            Self.stripWebKitOuterChrome(wv)
+        }
+        return container
+    }
+
+    func updateNSView(_ container: NSView, context: Context) {
+        guard let wv = context.coordinator.webView else { return }
+        let next = url.absoluteString
+        guard context.coordinator.lastLoadedURLString != next else { return }
+        context.coordinator.lastLoadedURLString = next
+        wv.load(URLRequest(url: url))
+    }
+
+    private static func stripWebKitOuterChrome(_ webView: WKWebView) {
+        webView.focusRingType = .none
+        func walk(_ view: NSView) {
+            view.focusRingType = .none
+            view.layer?.borderWidth = 0
+            view.layer?.borderColor = NSColor.clear.cgColor
+            if let scroll = view as? NSScrollView {
+                scroll.borderType = .noBorder
+                scroll.drawsBackground = false
+                scroll.scrollerStyle = .overlay
+            }
+            if let clip = view as? NSClipView {
+                clip.drawsBackground = false
+            }
+            for sub in view.subviews {
+                walk(sub)
+            }
+        }
+        walk(webView)
+    }
+
+    final class Coordinator: NSObject, WKNavigationDelegate {
+        weak var webView: WKWebView?
+        var lastLoadedURLString: String?
+
+        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+            KeepWebWidgetView.stripWebKitOuterChrome(webView)
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+                KeepWebWidgetView.stripWebKitOuterChrome(webView)
+            }
+        }
+    }
+}

+ 281 - 0
google_apps/Widgets/KeepLiveNotesStore.swift

@@ -0,0 +1,281 @@
+import AppKit
+import Combine
+import Foundation
+import WebKit
+
+/// Loads `keep.google.com` in a hidden `WKWebView` (shared `WKWebsiteDataStore.default()` cookies with the large widget)
+/// and extracts recent note titles/snippets via JavaScript. No OAuth or Workspace API.
+@MainActor
+final class KeepLiveNotesStore: NSObject, ObservableObject {
+    static let shared = KeepLiveNotesStore()
+
+    struct Note: Identifiable, Equatable {
+        let id: String
+        let title: String
+        let snippet: String
+    }
+
+    enum Phase: Equatable {
+        case idle
+        case loading
+        case ready
+        case empty
+        case needsSignIn
+        case failed(String)
+    }
+
+    @Published private(set) var phase: Phase = .idle
+    @Published private(set) var notes: [Note] = []
+    @Published private(set) var lastFetchedAt: Date?
+
+    private var webView: WKWebView?
+    private var hiddenWindow: NSWindow?
+    private var extractionAttempt = 0
+    private var pendingReload = false
+    private let keepURL = URL(string: "https://keep.google.com/")!
+    private let minRefreshInterval: TimeInterval = 45
+
+    private override init() {
+        super.init()
+    }
+
+    /// Call when the small/medium Keep widget appears.
+    func requestRefreshIfNeeded() {
+        let now = Date()
+        if case .loading = phase { return }
+        if let t = lastFetchedAt, now.timeIntervalSince(t) < minRefreshInterval {
+            switch phase {
+            case .ready, .empty: return
+            default: break
+            }
+        }
+        reloadAndExtract()
+    }
+
+    func forceRefresh() {
+        lastFetchedAt = nil
+        reloadAndExtract()
+    }
+
+    private func reloadAndExtract() {
+        ensureWebView()
+        guard let wv = webView else {
+            phase = .failed("Could not start web view")
+            return
+        }
+        extractionAttempt = 0
+        phase = .loading
+        pendingReload = true
+        wv.load(URLRequest(url: keepURL))
+    }
+
+    private func ensureWebView() {
+        guard webView == nil else { return }
+        let config = WKWebViewConfiguration()
+        config.defaultWebpagePreferences.allowsContentJavaScript = true
+        let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 900, height: 800), configuration: config)
+        wv.navigationDelegate = self
+        wv.customUserAgent =
+            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
+        let view = NSView(frame: NSRect(x: 0, y: 0, width: 900, height: 800))
+        wv.frame = view.bounds
+        wv.autoresizingMask = [.width, .height]
+        view.addSubview(wv)
+        let window = NSWindow(
+            contentRect: NSRect(x: -2400, y: -2400, width: 900, height: 800),
+            styleMask: [.borderless],
+            backing: .buffered,
+            defer: false
+        )
+        window.isOpaque = false
+        window.backgroundColor = .clear
+        window.contentView = view
+        window.alphaValue = 0
+        window.ignoresMouseEvents = true
+        window.orderOut(nil)
+        hiddenWindow = window
+        webView = wv
+    }
+
+    private func scheduleExtraction(after delay: TimeInterval) {
+        DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+            guard let self else { return }
+            Task { @MainActor in
+                self.runExtractionPass()
+            }
+        }
+    }
+
+    private func runExtractionPass() {
+        guard let wv = webView else { return }
+        extractionAttempt += 1
+        wv.evaluateJavaScript(Self.extractNotesJavaScript) { [weak self] result, error in
+            Task { @MainActor in
+                guard let self else { return }
+                if let error {
+                    self.phase = .failed(error.localizedDescription)
+                    return
+                }
+                guard let jsonString = result as? String,
+                      let data = jsonString.data(using: .utf8)
+                else {
+                    self.phase = .failed("Unexpected response")
+                    return
+                }
+                self.applyExtractedPayload(data)
+            }
+        }
+    }
+
+    private func applyExtractedPayload(_ data: Data) {
+        struct Payload: Decodable {
+            var notes: [PayloadNote]?
+            var needsSignIn: Bool?
+            var error: String?
+        }
+        struct PayloadNote: Decodable {
+            var id: String
+            var title: String
+            var snippet: String
+        }
+
+        let decoder = JSONDecoder()
+        guard let payload = try? decoder.decode(Payload.self, from: data) else {
+            if extractionAttempt < 4 {
+                scheduleExtraction(after: 2.0)
+            } else {
+                phase = .failed("Could not read Keep")
+            }
+            return
+        }
+
+        if payload.needsSignIn == true {
+            phase = .needsSignIn
+            notes = []
+            lastFetchedAt = Date()
+            return
+        }
+
+        if let err = payload.error, !err.isEmpty {
+            if extractionAttempt < 4 {
+                scheduleExtraction(after: 2.0)
+            } else {
+                phase = .failed(err)
+            }
+            return
+        }
+
+        let raw = payload.notes ?? []
+        let mapped = raw.map { n in
+            Note(
+                id: n.id,
+                title: String(n.title.prefix(200)),
+                snippet: String(n.snippet.prefix(500))
+            )
+        }
+        let deduped = dedupe(mapped)
+
+        if deduped.isEmpty {
+            if extractionAttempt < 4 {
+                scheduleExtraction(after: 2.0)
+            } else {
+                phase = .empty
+                notes = []
+                lastFetchedAt = Date()
+            }
+            return
+        }
+
+        notes = deduped
+        phase = .ready
+        lastFetchedAt = Date()
+    }
+
+    private func dedupe(_ items: [Note]) -> [Note] {
+        var seen = Set<String>()
+        var out: [Note] = []
+        for n in items {
+            let key = "\(n.title)|\(n.snippet)"
+            if seen.contains(key) { continue }
+            seen.insert(key)
+            out.append(n)
+        }
+        return out
+    }
+
+    /// JavaScript: prefer `[role="article"]`, then `contenteditable="false"` pairs (classic Keep export pattern).
+    private static let extractNotesJavaScript = #"""
+(function() {
+  try {
+    var href = window.location.href || '';
+    if (href.indexOf('accounts.google') !== -1) {
+      return JSON.stringify({ needsSignIn: true });
+    }
+    var results = [];
+    var articles = document.querySelectorAll('[role="article"]');
+    var i = 0;
+    if (articles.length > 0) {
+      for (i = 0; i < articles.length && results.length < 24; i++) {
+        var el = articles[i];
+        var text = (el.innerText || '').trim();
+        if (!text) continue;
+        var lines = text.split(/\r?\n/).map(function(l) { return l.trim(); }).filter(function(l) { return l.length > 0; });
+        if (lines.length === 0) continue;
+        var title = lines[0].substring(0, 160);
+        var snippet = lines.slice(1).join(' ').substring(0, 400);
+        results.push({ id: 'a-' + i, title: title, snippet: snippet });
+      }
+      return JSON.stringify({ notes: results });
+    }
+    var all = document.getElementsByTagName('*');
+    var titles = [];
+    var contents = [];
+    var x = 0;
+    for (i = 0; i < all.length; i++) {
+      var node = all[i];
+      if (node.getAttribute && node.getAttribute('contenteditable') === 'false') {
+        var txt = (node.innerText || '').trim();
+        if (x % 2 === 0) titles.push(txt);
+        else contents.push(txt);
+        x++;
+      }
+    }
+    if (x > 200) {
+      return JSON.stringify({ notes: [], error: 'unexpected_dom' });
+    }
+    var n = Math.min(titles.length, contents.length, 12);
+    for (i = 0; i < n; i++) {
+      var ti = (titles[i] || 'Note').substring(0, 160);
+      var sn = (contents[i] || '').substring(0, 400);
+      results.push({ id: 'c-' + i, title: ti, snippet: sn });
+    }
+    return JSON.stringify({ notes: results });
+  } catch (e) {
+    return JSON.stringify({ error: String(e) });
+  }
+})();
+"""#
+}
+
+extension KeepLiveNotesStore: WKNavigationDelegate {
+    nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+        Task { @MainActor in
+            guard self.pendingReload else { return }
+            self.pendingReload = false
+            self.extractionAttempt = 0
+            self.scheduleExtraction(after: 2.0)
+        }
+    }
+
+    nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+        Task { @MainActor in
+            self.phase = .failed(error.localizedDescription)
+        }
+    }
+
+    nonisolated func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+        Task { @MainActor in
+            self.phase = .failed(error.localizedDescription)
+        }
+    }
+}

+ 22 - 11
google_apps/Widgets/WidgetTemplates.swift

@@ -34,6 +34,13 @@ enum DriveWidgetMode: Hashable, Sendable {
     case interactive
 }
 
+enum KeepWidgetMode: Hashable, Sendable {
+    /// Recent note snippets and new-note shortcut.
+    case compact
+    /// Embedded Google Keep (`WKWebView`) with search, labels, and create options.
+    case interactive
+}
+
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case actionsRow
@@ -44,6 +51,8 @@ enum WidgetLayoutMode: Hashable {
     case gmail(GmailWidgetMode)
     /// Tiered Google Drive: recent files, starred preview, or large shortcut hub.
     case drive(DriveWidgetMode)
+    /// Tiered Google Keep: recent notes or full embedded Keep.
+    case keep(KeepWidgetMode)
 }
 
 struct WidgetVariant: Identifiable, Hashable {
@@ -276,17 +285,8 @@ enum WidgetTemplates {
             ]
         case .keep:
             return [
-                v("keep_small", "Keep", .small, false, .actionsRow, [
-                    a("notes", "Notes", "note.text", "/"),
-                    a("reminders", "Reminders", "bell", "/#reminders"),
-                    a("archive", "Archive", "archivebox", "/#archive"),
-                ]),
-                v("keep_medium", "Keep Hub", .medium, true, .actionsGrid(columns: 2), [
-                    a("notes", "Notes", "note.text", "/"),
-                    a("reminders", "Reminders", "bell", "/#reminders"),
-                    a("labels", "Labels", "tag", "/#label"),
-                    a("archive", "Archive", "archivebox", "/#archive"),
-                ]),
+                v("keep_small", "Recent notes", .small, false, .keep(.compact), []),
+                v("keep_large", "Full Keep", .large, false, .keep(.interactive), []),
             ]
         case .travel:
             return [
@@ -576,6 +576,7 @@ extension WidgetLayoutMode {
         case .interactiveMap: true
         case .gmail(.interactive): true
         case .drive(.interactive): true
+        case .keep(.interactive): true
         default: false
         }
     }
@@ -595,6 +596,11 @@ extension WidgetLayoutMode {
             case .filesPreview: return CGSize(width: 420, height: 480)
             case .interactive: return CGSize(width: 540, height: 580)
             }
+        case .keep(let mode):
+            switch mode {
+            case .compact: return CGSize(width: 230, height: 260)
+            case .interactive: return CGSize(width: 540, height: 580)
+            }
         default:
             return nil
         }
@@ -614,6 +620,11 @@ extension WidgetLayoutMode {
             case .filesPreview: return CGSize(width: 380, height: 420)
             case .interactive: return CGSize(width: 480, height: 440)
             }
+        case .keep(let mode):
+            switch mode {
+            case .compact: return CGSize(width: 210, height: 230)
+            case .interactive: return CGSize(width: 480, height: 440)
+            }
         default:
             return nil
         }