Эх сурвалжийг харах

Add Gemini desktop widget with prompt injection and compact panel

- Add GeminiDesktopWidgetView: large prompt UI, Ask in Gemini opens in-app browser
- InAppBrowserWindow: optional injectComposerPrompt with post-load JS fill for gemini.google.com
- WidgetTemplates: Gemini large variant only; desktopPanelSizeOverride and preview sizes (400×236)
- DesktopWidgetView: route .gemini layout mode to GeminiDesktopWidgetView

Made-with: Cursor
huzaifahayat12 3 сар өмнө
parent
commit
107c04f306

+ 81 - 3
google_apps/InAppBrowserWindow.swift

@@ -122,8 +122,12 @@ final class InAppBrowserWindowManager {
 
     private init() {}
 
-    func open(url: URL, title: String? = nil) {
-        let controller = InAppBrowserWindowController(initialURL: url, windowTitle: title)
+    func open(url: URL, title: String? = nil, injectComposerPrompt: String? = nil) {
+        let controller = InAppBrowserWindowController(
+            initialURL: url,
+            windowTitle: title,
+            injectComposerPrompt: injectComposerPrompt
+        )
         controllers.append(controller)
         controller.onClose = { [weak self, weak controller] in
             guard let self, let controller else { return }
@@ -139,6 +143,10 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
 
     private let webView: WKWebView
     private let initialURL: URL
+    /// When opening Gemini from the widget, filled into the page composer after load (Gemini ignores URL query params).
+    private let injectComposerPrompt: String?
+    private var geminiPromptWasInjected = false
+    private var geminiInjectionIsActive = false
     private var didLoadInitialURL = false
 
     // Titlebar controls
@@ -176,8 +184,9 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
         }
     }
 
-    init(initialURL: URL, windowTitle: String?) {
+    init(initialURL: URL, windowTitle: String?, injectComposerPrompt: String? = nil) {
         self.initialURL = initialURL
+        self.injectComposerPrompt = injectComposerPrompt
 
         let config = WKWebViewConfiguration()
         config.defaultWebpagePreferences.allowsContentJavaScript = true
@@ -894,6 +903,74 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
         alert.alertStyle = .informational
         alert.runModal()
     }
+
+    /// Gemini’s SPA does not honor `?prompt=` on `/app`; after navigation we fill the visible composer (retries for async UI / sign-in).
+    private func injectGeminiComposerPromptIfNeeded(webView: WKWebView) {
+        guard let prompt = injectComposerPrompt, !geminiPromptWasInjected, !geminiInjectionIsActive else { return }
+        guard let host = webView.url?.host?.lowercased(), host.contains("gemini.google.com") else { return }
+        guard let jsonData = try? JSONSerialization.data(withJSONObject: [prompt]),
+              let jsonArrayLiteral = String(data: jsonData, encoding: .utf8) else { return }
+
+        geminiInjectionIsActive = true
+        let script = """
+        (function() {
+          return new Promise(function(resolve) {
+            var text = \(jsonArrayLiteral)[0];
+            var n = 0;
+            var timer = setInterval(function() {
+              n++;
+              function fillTextarea(el) {
+                if (!el || !el.offsetParent) return false;
+                var r = el.getBoundingClientRect();
+                if (r.width < 8 || r.height < 8) return false;
+                el.focus();
+                el.value = text;
+                el.dispatchEvent(new Event('input', { bubbles: true }));
+                el.dispatchEvent(new Event('change', { bubbles: true }));
+                return true;
+              }
+              function fillContentEditable(ce) {
+                if (!ce || !ce.isContentEditable || !ce.offsetParent) return false;
+                ce.focus();
+                ce.innerText = text;
+                ce.dispatchEvent(new InputEvent('input', { bubbles: true, data: text }));
+                return true;
+              }
+              function tryFillInRoot(root) {
+                var areas = root.querySelectorAll('textarea');
+                var i, el;
+                for (i = 0; i < areas.length; i++) {
+                  if (fillTextarea(areas[i])) return true;
+                }
+                var editables = root.querySelectorAll('[contenteditable="true"]');
+                for (i = 0; i < editables.length; i++) {
+                  if (fillContentEditable(editables[i])) return true;
+                }
+                var nodes = root.querySelectorAll('*');
+                for (i = 0; i < nodes.length; i++) {
+                  var sh = nodes[i].shadowRoot;
+                  if (sh && tryFillInRoot(sh)) return true;
+                }
+                return false;
+              }
+              function tryFill() {
+                if (tryFillInRoot(document)) return true;
+                return false;
+              }
+              if (tryFill()) { clearInterval(timer); resolve(true); }
+              else if (n >= 80) { clearInterval(timer); resolve(false); }
+            }, 250);
+          });
+        })();
+        """
+        webView.evaluateJavaScript(script) { [weak self] result, _ in
+            guard let self else { return }
+            self.geminiInjectionIsActive = false
+            if (result as? NSNumber)?.boolValue == true {
+                self.geminiPromptWasInjected = true
+            }
+        }
+    }
 }
 
 extension InAppBrowserWindowController: WKNavigationDelegate {
@@ -913,6 +990,7 @@ extension InAppBrowserWindowController: WKNavigationDelegate {
 
     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
         updateToolbarState()
+        injectGeminiComposerPromptIfNeeded(webView: webView)
     }
 
     func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {

+ 3 - 1
google_apps/Widgets/DesktopWidgetView.swift

@@ -47,6 +47,8 @@ struct DesktopWidgetView: View {
                 SlidesDesktopWidgetView(app: app, mode: slidesMode, isPreview: isPreview)
             } else if case .googlePlay(let playMode) = layoutMode {
                 GooglePlayDesktopWidgetView(app: app, mode: playMode, isPreview: isPreview)
+            } else if case .gemini(let geminiMode) = layoutMode {
+                GeminiDesktopWidgetView(app: app, mode: geminiMode, isPreview: isPreview)
             } else if case .interactiveMap = layoutMode {
                 MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
             } else {
@@ -82,7 +84,7 @@ struct DesktopWidgetView: View {
                             }
                             gridActions(columns: columns, maxActions: maxActionsForSize)
 
-                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books, .travel, .youtube, .meet, .news, .forms, .sheets, .slides, .googlePlay:
+                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books, .travel, .youtube, .meet, .news, .forms, .sheets, .slides, .googlePlay, .gemini:
                             EmptyView()
                         }
                     }

+ 257 - 0
google_apps/Widgets/GeminiDesktopWidgetView.swift

@@ -0,0 +1,257 @@
+import AppKit
+import SwiftUI
+
+private let geminiAccent = Color(red: 0.26, green: 0.55, blue: 1.0)
+private let geminiAccentDeep = Color(red: 0.12, green: 0.28, blue: 0.72)
+
+/// Large Gemini panel: prompt field opens Google Gemini in the in-app browser at gemini.google.com.
+struct GeminiDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: GeminiWidgetMode
+    var isPreview: Bool = false
+
+    @State private var promptText: String = ""
+
+    private var baseURL: URL { app.webURL ?? URL(string: "https://gemini.google.com")! }
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .assistant:
+                assistantBody
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+    }
+
+    private var assistantBody: some View {
+        Group {
+            if isPreview {
+                previewChrome
+            } else {
+                assistantLive
+            }
+        }
+    }
+
+    private var previewChrome: some View {
+        ZStack {
+            geminiBackground
+            VStack(spacing: 14) {
+                ZStack {
+                    Circle()
+                        .fill(
+                            RadialGradient(
+                                colors: [geminiAccent.opacity(0.45), geminiAccent.opacity(0.08)],
+                                center: .center,
+                                startRadius: 4,
+                                endRadius: 36
+                            )
+                        )
+                        .frame(width: 72, height: 72)
+                    AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
+                }
+                Text("Gemini")
+                    .font(.system(size: 16, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.96))
+                Text("Type a prompt and send it to Gemini in the in-app browser.")
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.72))
+                    .multilineTextAlignment(.center)
+                    .padding(.horizontal, 12)
+            }
+            .padding(18)
+        }
+    }
+
+    private var assistantLive: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            headerRow
+                .padding(.horizontal, 16)
+                .padding(.top, 12)
+                .padding(.bottom, 10)
+
+            promptCard
+                .padding(.horizontal, 14)
+                .padding(.bottom, 12)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(geminiBackground)
+    }
+
+    private var geminiBackground: some View {
+        ZStack {
+            LinearGradient(
+                colors: [
+                    Color(red: 0.11, green: 0.14, blue: 0.32),
+                    Color(red: 0.05, green: 0.06, blue: 0.14),
+                ],
+                startPoint: .topLeading,
+                endPoint: .bottomTrailing
+            )
+            RadialGradient(
+                colors: [geminiAccent.opacity(0.28), Color.clear],
+                center: UnitPoint(x: 0.15, y: 0.0),
+                startRadius: 10,
+                endRadius: 200
+            )
+            RadialGradient(
+                colors: [geminiAccentDeep.opacity(0.22), Color.clear],
+                center: UnitPoint(x: 1.0, y: 0.85),
+                startRadius: 20,
+                endRadius: 180
+            )
+        }
+    }
+
+    private var headerRow: some View {
+        HStack(alignment: .center, spacing: 12) {
+            ZStack {
+                Circle()
+                    .fill(
+                        LinearGradient(
+                            colors: [geminiAccent.opacity(0.35), geminiAccent.opacity(0.1)],
+                            startPoint: .topLeading,
+                            endPoint: .bottomTrailing
+                        )
+                    )
+                    .frame(width: 40, height: 40)
+                    .overlay(
+                        Circle()
+                            .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
+                    )
+                AppIconView(app: app, size: 26, showAppBackground: false, iconPaddingFactor: 0.08)
+            }
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text("Gemini")
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.96))
+                Text("Google AI")
+                    .font(.system(size: 10, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.45))
+            }
+
+            Spacer(minLength: 0)
+
+            Button(action: { openGeminiApp() }) {
+                Image(systemName: "arrow.up.right.square")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.88))
+                    .frame(width: 32, height: 32)
+                    .background(
+                        Circle()
+                            .fill(Color.white.opacity(0.1))
+                            .overlay(Circle().strokeBorder(Color.white.opacity(0.1), lineWidth: 1))
+                    )
+            }
+            .buttonStyle(.plain)
+            .help("Open Gemini in browser")
+        }
+    }
+
+    private var promptCard: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            HStack {
+                Text("Your prompt")
+                    .font(.system(size: 10, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.4))
+                    .textCase(.uppercase)
+                Spacer(minLength: 0)
+                Button("Clear") {
+                    promptText = ""
+                }
+                .font(.system(size: 11, weight: .semibold))
+                .foregroundStyle(.white.opacity(promptText.isEmpty ? 0.25 : 0.55))
+                .buttonStyle(.plain)
+                .disabled(promptText.isEmpty)
+            }
+
+            ZStack(alignment: .topLeading) {
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                    .fill(Color.white.opacity(0.07))
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 14, style: .continuous)
+                            .strokeBorder(
+                                LinearGradient(
+                                    colors: [Color.white.opacity(0.22), Color.white.opacity(0.06)],
+                                    startPoint: .topLeading,
+                                    endPoint: .bottomTrailing
+                                ),
+                                lineWidth: 1
+                            )
+                    )
+
+                TextEditor(text: $promptText)
+                    .font(.system(size: 13, weight: .regular))
+                    .foregroundColor(Color.white.opacity(0.95))
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 10)
+                    .background(Color.clear)
+
+                if promptText.isEmpty {
+                    Text("Ask anything…")
+                        .font(.system(size: 13, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.35))
+                        .padding(.leading, 14)
+                        .padding(.top, 18)
+                        .allowsHitTesting(false)
+                }
+            }
+            .frame(minHeight: 88, maxHeight: 120)
+
+            Button(action: askInGemini) {
+                HStack(spacing: 8) {
+                    Image(systemName: "sparkles")
+                        .font(.system(size: 13, weight: .semibold))
+                    Text("Ask in Gemini")
+                        .font(.system(size: 13, weight: .bold))
+                    Spacer(minLength: 0)
+                    Image(systemName: "arrow.right.circle.fill")
+                        .font(.system(size: 16, weight: .semibold))
+                        .opacity(0.9)
+                }
+                .foregroundStyle(.white)
+                .padding(.horizontal, 14)
+                .padding(.vertical, 11)
+                .frame(maxWidth: .infinity)
+                .background(
+                    RoundedRectangle(cornerRadius: 12, style: .continuous)
+                        .fill(
+                            LinearGradient(
+                                colors: [geminiAccent, geminiAccentDeep],
+                                startPoint: .leading,
+                                endPoint: .trailing
+                            )
+                        )
+                        .shadow(color: geminiAccent.opacity(0.35), radius: 10, x: 0, y: 4)
+                )
+            }
+            .buttonStyle(.plain)
+        }
+    }
+
+    // MARK: - Actions
+
+    private func openGeminiApp(prompt: String? = nil) {
+        NSApp.activate(ignoringOtherApps: true)
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: "/app", accountSlot: 0)
+        let trimmed = prompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        InAppBrowserWindowManager.shared.open(
+            url: url,
+            title: app.name,
+            injectComposerPrompt: trimmed.isEmpty ? nil : trimmed
+        )
+    }
+
+    /// Opens `/app` and asks the in-app browser to paste your text into Gemini’s composer after load (URL query params are ignored by Gemini’s SPA).
+    private func askInGemini() {
+        let p = promptText.trimmingCharacters(in: .whitespacesAndNewlines)
+        NSApp.activate(ignoringOtherApps: true)
+        if !p.isEmpty {
+            NSPasteboard.general.clearContents()
+            NSPasteboard.general.setString(p, forType: .string)
+        }
+        openGeminiApp(prompt: p.isEmpty ? nil : p)
+    }
+}

+ 13 - 11
google_apps/Widgets/WidgetTemplates.swift

@@ -116,6 +116,11 @@ enum GooglePlayWidgetMode: Hashable, Sendable {
     case searchBar
 }
 
+enum GeminiWidgetMode: Hashable, Sendable {
+    /// Large: prompt panel; opens gemini.google.com in-app.
+    case assistant
+}
+
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case actionsRow
@@ -154,6 +159,8 @@ enum WidgetLayoutMode: Hashable {
     case slides(SlidesWidgetMode)
     /// Google Play: medium search bar + open store.
     case googlePlay(GooglePlayWidgetMode)
+    /// Google Gemini: large mini-assistant panel.
+    case gemini(GeminiWidgetMode)
 }
 
 struct WidgetVariant: Identifiable, Hashable {
@@ -488,17 +495,7 @@ enum WidgetTemplates {
             ]
         case .gemini:
             return [
-                v("gemini_small", "Gemini", .small, false, .actionsRow, [
-                    a("app", "Gemini", "sparkles", "/app"),
-                    a("home", "Home", "house", "/"),
-                    a("extensions", "Extensions", "puzzlepiece", "/extensions"),
-                ]),
-                v("gemini_large", "Gemini Hub", .large, true, .actionsGrid(columns: 2), [
-                    a("app", "Chat", "sparkles", "/app"),
-                    a("home", "Home", "house", "/"),
-                    a("extensions", "Extensions", "puzzlepiece", "/extensions"),
-                    a("help", "Help", "questionmark.circle", "/help"),
-                ]),
+                v("gemini_large", "Gemini", .large, false, .gemini(.assistant), []),
             ]
         case .books:
             return [
@@ -581,6 +578,7 @@ extension WidgetLayoutMode {
         case .sheets(_): true
         case .slides(_): true
         case .googlePlay(_): true
+        case .gemini(_): true
         case .meet: false
         case .calendar: false
         default: false
@@ -645,6 +643,8 @@ extension WidgetLayoutMode {
             return CGSize(width: 440, height: 300)
         case .googlePlay(.searchBar):
             return CGSize(width: 400, height: 168)
+        case .gemini(.assistant):
+            return CGSize(width: 400, height: 236)
         default:
             return nil
         }
@@ -707,6 +707,8 @@ extension WidgetLayoutMode {
             return CGSize(width: 380, height: 280)
         case .googlePlay(.searchBar):
             return CGSize(width: 360, height: 158)
+        case .gemini(.assistant):
+            return CGSize(width: 400, height: 236)
         default:
             return nil
         }