Prechádzať zdrojové kódy

Contacts widget search hub; Safari UA for Google in-app browser

- Add Google Contacts medium widget: search opens contacts.google.com with
  query; shortcuts for Frequent contacts (/frequent) and Open App.
- Single contacts variant; migrate old contacts_small/large IDs to medium;
  tighten panel height and layout.
- Use Safari-like user agent for Google/YouTube hosts in WKWebView to reduce
  human-verification prompts.

Made-with: Cursor
huzaifahayat12 3 mesiacov pred
rodič
commit
7cac463406

+ 14 - 0
google_apps/InAppBrowserWindow.swift

@@ -174,6 +174,11 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
     private var statusBarShortcutObserver: NSObjectProtocol?
     private var premiumSheetWindow: NSWindow?
 
+    /// Google endpoints are more likely to trigger human-verification in embedded webviews.
+    /// Present a Safari-like UA for Google-family hosts to reduce bot checks.
+    private static let safariLikeUserAgent =
+    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15"
+
     private func setToggledStyle(_ button: TitlebarIconButton, isOn: Bool) {
         button.setToggleActive(isOn)
     }
@@ -191,6 +196,7 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
         let config = WKWebViewConfiguration()
         config.defaultWebpagePreferences.allowsContentJavaScript = true
         self.webView = WKWebView(frame: .zero, configuration: config)
+        self.webView.customUserAgent = Self.preferredUserAgent(for: initialURL)
 
         let window = NSWindow(
             contentRect: NSRect(x: 180, y: 140, width: 1200, height: 800),
@@ -409,6 +415,14 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
         updateToolbarState()
     }
 
+    private static func preferredUserAgent(for url: URL) -> String? {
+        guard let host = url.host?.lowercased() else { return nil }
+        if host.contains("google.") || host.contains("youtube.com") {
+            return safariLikeUserAgent
+        }
+        return nil
+    }
+
     private func configureTitlebarLayout(centerView: NSView) {
         guard let window else { return }
 

+ 146 - 0
google_apps/Widgets/ContactsDesktopWidgetView.swift

@@ -0,0 +1,146 @@
+import AppKit
+import SwiftUI
+
+private let contactsAccent = Color(red: 0.26, green: 0.61, blue: 0.95)
+private let contactsPanelBg = Color(red: 0.08, green: 0.12, blue: 0.18)
+
+/// Medium-only: search opens Google Contacts with the query; Frequent contacts + Open App shortcuts (no contact list in-widget).
+struct ContactsDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: ContactsWidgetMode
+    var isPreview: Bool = false
+
+    @State private var searchText: String = ""
+
+    private var baseURL: URL {
+        app.webURL ?? URL(string: "https://contacts.google.com")!
+    }
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .searchHub:
+                searchHubBody
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+    }
+
+    private var searchHubBody: some View {
+        ZStack(alignment: .topLeading) {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [Color(red: 0.14, green: 0.22, blue: 0.36), contactsPanelBg],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+                .overlay(
+                    RoundedRectangle(cornerRadius: 22, style: .continuous)
+                        .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)
+                )
+
+            VStack(alignment: .leading, spacing: 8) {
+                HStack(spacing: 8) {
+                    AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
+                    Text("Google Contacts")
+                        .font(.system(size: 14, weight: .bold))
+                        .foregroundStyle(.white.opacity(0.95))
+                    Spacer(minLength: 0)
+                }
+
+                Text("Search opens Contacts with your query.")
+                    .font(.system(size: 10, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.55))
+                    .lineLimit(2)
+                    .fixedSize(horizontal: false, vertical: true)
+
+                HStack(spacing: 8) {
+                    Image(systemName: "magnifyingglass")
+                        .font(.system(size: 12, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.5))
+                    TextField("Name or email", text: $searchText)
+                        .textFieldStyle(.plain)
+                        .font(.system(size: 13, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.95))
+                        .onSubmit(submitSearch)
+                    Button("Search", action: submitSearch)
+                        .font(.system(size: 11, weight: .bold))
+                        .buttonStyle(.borderedProminent)
+                        .tint(contactsAccent)
+                        .controlSize(.small)
+                }
+                .padding(.horizontal, 10)
+                .padding(.vertical, 8)
+                .background(
+                    RoundedRectangle(cornerRadius: 12, style: .continuous)
+                        .fill(Color.white.opacity(0.1))
+                )
+
+                HStack(spacing: 8) {
+                    shortcutButton(title: "Frequent contacts", systemImage: "clock.arrow.circlepath", action: openFrequentContactsShortcut)
+                    shortcutButton(title: "Open App", systemImage: "arrow.up.right.square", action: openContactsApp)
+                }
+            }
+            .padding(12)
+            .frame(maxWidth: .infinity, alignment: .topLeading)
+        }
+        .fixedSize(horizontal: false, vertical: true)
+        .frame(maxWidth: .infinity, alignment: .topLeading)
+        .disabled(isPreview)
+        .opacity(isPreview ? 0.88 : 1)
+    }
+
+    private func shortcutButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
+        Button(action: action) {
+            HStack(spacing: 8) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 14, weight: .semibold))
+                Text(title)
+                    .font(.system(size: 11, weight: .bold))
+                    .lineLimit(1)
+                    .minimumScaleFactor(0.75)
+            }
+            .foregroundStyle(.white.opacity(0.95))
+            .frame(maxWidth: .infinity)
+            .padding(.vertical, 9)
+            .background(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .fill(Color.white.opacity(0.12))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .strokeBorder(Color.white.opacity(0.1), lineWidth: 1)
+            )
+        }
+        .buttonStyle(.plain)
+    }
+
+    private func submitSearch() {
+        guard !isPreview else { return }
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        NSApp.activate(ignoringOtherApps: true)
+        if q.isEmpty {
+            InAppBrowserWindowManager.shared.open(url: baseURL, title: app.name)
+            return
+        }
+        let encoded = q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: "/search/\(encoded)", accountSlot: 0)
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    /// Frequently contacted — official Contacts path is `/frequent`.
+    private func openFrequentContactsShortcut() {
+        guard !isPreview else { return }
+        NSApp.activate(ignoringOtherApps: true)
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: "/frequent", accountSlot: 0)
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func openContactsApp() {
+        guard !isPreview else { return }
+        NSApp.activate(ignoringOtherApps: true)
+        InAppBrowserWindowManager.shared.open(url: baseURL, title: app.name)
+    }
+}

+ 3 - 1
google_apps/Widgets/DesktopWidgetView.swift

@@ -39,6 +39,8 @@ struct DesktopWidgetView: View {
                 YouTubeDesktopWidgetView(app: app, mode: youtubeMode, isPreview: isPreview)
             } else if case .meet(let meetMode) = layoutMode {
                 MeetDesktopWidgetView(app: app, mode: meetMode, isPreview: isPreview)
+            } else if case .contacts(let contactsMode) = layoutMode {
+                ContactsDesktopWidgetView(app: app, mode: contactsMode, isPreview: isPreview)
             } else if case .news(let newsMode) = layoutMode {
                 NewsDesktopWidgetView(app: app, mode: newsMode, isPreview: isPreview)
             } else if case .forms(let formsMode) = layoutMode {
@@ -88,7 +90,7 @@ struct DesktopWidgetView: View {
                             }
                             gridActions(columns: columns, maxActions: maxActionsForSize)
 
-                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books, .travel, .shopping, .youtube, .meet, .news, .forms, .docs, .sheets, .slides, .googlePlay, .gemini:
+                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books, .travel, .shopping, .youtube, .meet, .contacts, .news, .forms, .docs, .sheets, .slides, .googlePlay, .gemini:
                             EmptyView()
                         }
                     }

+ 21 - 12
google_apps/Widgets/WidgetTemplates.swift

@@ -94,6 +94,11 @@ enum MeetWidgetMode: Hashable, Sendable {
     case hub
 }
 
+enum ContactsWidgetMode: Hashable, Sendable {
+    /// Medium only: search field (opens Google Contacts with query) + Favorites / Other shortcuts.
+    case searchHub
+}
+
 enum NewsWidgetMode: Hashable, Sendable {
     /// Search bar plus top headlines (local sample; opens Google News in-app).
     case headlines
@@ -161,6 +166,8 @@ enum WidgetLayoutMode: Hashable {
     case youtube(YoutubeWidgetMode)
     /// Google Meet: Calendar-backed Meet links; join/create via in-app browser.
     case meet(MeetWidgetMode)
+    /// Google Contacts: local interactive list with search + call/message/email actions.
+    case contacts(ContactsWidgetMode)
     /// Google News: search + headlines (medium) or topics + picks (large); opens in-app browser.
     case news(NewsWidgetMode)
     /// Google Forms: large interactive dashboard; small/medium use default grids.
@@ -328,17 +335,7 @@ enum WidgetTemplates {
             ]
         case .contacts:
             return [
-                v("contacts_small", "Contacts", .small, false, .actionsRow, [
-                    a("contacts", "Contacts", "person.crop.circle", "/"),
-                    a("duplicates", "Duplicates", "person.2", "/duplicates"),
-                    a("labels", "Labels", "tag", "/"),
-                ]),
-                v("contacts_medium", "Contacts Hub", .medium, true, .actionsGrid(columns: 2), [
-                    a("contacts", "All", "person.crop.circle", "/"),
-                    a("frequent", "Frequent", "clock", "/"),
-                    a("duplicates", "Duplicates", "person.2", "/duplicates"),
-                    a("trash", "Trash", "trash", "/trash"),
-                ]),
+                v("contacts_medium", "Search", .medium, false, .contacts(.searchHub), []),
             ]
         case .blogger:
             return [
@@ -508,7 +505,14 @@ enum WidgetTemplates {
 
     static func variant(for app: LauncherApp, variantID: String?) -> WidgetVariant {
         let variants = variants(for: app)
-        if let variantID, let match = variants.first(where: { $0.id == variantID }) {
+        let normalizedID: String? = {
+            guard let id = variantID else { return nil }
+            if WidgetAppProfile.from(app: app) == .contacts {
+                if id == "contacts_small" || id == "contacts_large" { return "contacts_medium" }
+            }
+            return id
+        }()
+        if let normalizedID, let match = variants.first(where: { $0.id == normalizedID }) {
             return match
         }
         return variants.first ?? WidgetVariant(
@@ -575,6 +579,7 @@ extension WidgetLayoutMode {
         case .youtube(.searchBar): true
         case .youtube(.interactiveNav): true
         case .news(_): true
+        case .contacts(.searchHub): true
         case .forms(_): true
         case .docs(_): true
         case .sheets(_): true
@@ -634,6 +639,8 @@ extension WidgetLayoutMode {
             case .agenda: return CGSize(width: 400, height: 280)
             case .hub: return CGSize(width: 440, height: 520)
             }
+        case .contacts(.searchHub):
+            return CGSize(width: 380, height: 178)
         case .news(let mode):
             switch mode {
             case .headlines: return CGSize(width: 420, height: 340)
@@ -702,6 +709,8 @@ extension WidgetLayoutMode {
             case .agenda: return CGSize(width: 360, height: 250)
             case .hub: return CGSize(width: 380, height: 480)
             }
+        case .contacts(.searchHub):
+            return CGSize(width: 340, height: 152)
         case .news(let mode):
             switch mode {
             case .headlines: return CGSize(width: 380, height: 300)