diff --git a/Bugger.xcodeproj/project.pbxproj b/Bugger.xcodeproj/project.pbxproj index c17e858..7f25d28 100644 --- a/Bugger.xcodeproj/project.pbxproj +++ b/Bugger.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1129D6F630904F03D06B5DC4; }; B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0A744D4F5DB88B1496176B; }; 7B0EAA6D83D40A593E36EE31 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3693AA93F823859D15256D; }; + 34A7C60F7F533D8D589F1B0A /* FeishuWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1661D9F3D050C3CD75D910C; }; C3F7FEB179B079BDDD2AA212 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09B9A4D84F3F604C49171A89; }; /* End PBXBuildFile section */ @@ -82,6 +83,7 @@ 1129D6F630904F03D06B5DC4 /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = ""; }; 3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = ""; }; 2D3693AA93F823859D15256D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + C1661D9F3D050C3CD75D910C /* FeishuWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuWebView.swift; sourceTree = ""; }; 09B9A4D84F3F604C49171A89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 73AB234B54673C838E33AC7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E9B6FE6E06295095649B24A7 /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = ""; }; @@ -180,6 +182,7 @@ EA9ED591BB8A18FEBD1B58A3 /* MenuBar */, 74AD95EF49A7D01C83865FAC /* OAuth */, B70D2E54AD6B19DD455A8F82 /* Settings */, + B8AD0CEC28B09402A27E531C /* Web */, ); path = Views; sourceTree = ""; @@ -237,6 +240,14 @@ path = Settings; sourceTree = ""; }; + B8AD0CEC28B09402A27E531C /* Web */ = { + isa = PBXGroup; + children = ( + C1661D9F3D050C3CD75D910C /* FeishuWebView.swift */, + ); + path = Web; + sourceTree = ""; + }; 8AD6A16612A2DB7B2F899787 /* Resources */ = { isa = PBXGroup; children = ( @@ -359,6 +370,7 @@ 3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */, B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */, 7B0EAA6D83D40A593E36EE31 /* SettingsView.swift in Sources */, + 34A7C60F7F533D8D589F1B0A /* FeishuWebView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -393,7 +405,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; FEISHU_APP_ID = "$(FEISHU_APP_ID)"; FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)"; FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)"; @@ -401,7 +413,7 @@ INFOPLIST_FILE = Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -414,7 +426,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; FEISHU_APP_ID = "$(FEISHU_APP_ID)"; FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)"; FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)"; @@ -422,7 +434,7 @@ INFOPLIST_FILE = Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Resources/Info.plist b/Resources/Info.plist index f3210a1..224722c 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.5 + $(MARKETING_VERSION) CFBundleVersion - 5 + $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eb19f01..5cf8caf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8,6 +8,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem! private var popover: NSPopover! private var floatingWidget: FloatingWidgetWindow? + private var webWindow: NSWindow? private let bugStore = BugStore.shared func applicationDidFinishLaunching(_ notification: Notification) { @@ -94,4 +95,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate { floatingWidget = nil } + /// Opens (or reuses) a window rendering the Feishu record page in an + /// embedded web view — for actions the API can't do, like advancing the + /// 流转状态 workflow. + func openWebWindow(url: URL, title: String) { + if let webWindow { + webWindow.title = title + (webWindow.contentViewController as? NSHostingController)? + .rootView = FeishuWebWindowContent(url: url) + // Recover from a collapsed window frame. + if webWindow.frame.width < 400 { + webWindow.setContentSize(NSSize(width: 1100, height: 800)) + webWindow.center() + } + } else { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1100, height: 800), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = title + window.contentViewController = NSHostingController( + rootView: FeishuWebWindowContent(url: url) + ) + window.contentMinSize = NSSize(width: 600, height: 400) + window.isReleasedWhenClosed = false + // Accessory app: ensure the window lands on the active Space. + window.collectionBehavior.insert(.moveToActiveSpace) + webWindow = window + } + webWindow?.orderFrontRegardless() + webWindow?.makeKeyAndOrderFront(nil) + // Accessory app: pull the window to the front explicitly. + NSApp.activate(ignoringOtherApps: true) + } + } diff --git a/Sources/Views/MainWindow/BugDetailView.swift b/Sources/Views/MainWindow/BugDetailView.swift index f54707e..3fd9482 100644 --- a/Sources/Views/MainWindow/BugDetailView.swift +++ b/Sources/Views/MainWindow/BugDetailView.swift @@ -47,6 +47,16 @@ struct BugDetailView: View { } .toolbar { ToolbarItemGroup(placement: .primaryAction) { + Button { + if let url = bug.feishuURL { + AppDelegate.shared?.openWebWindow(url: url, title: bug.title) + } + } label: { + Label("Open in App", systemImage: "rectangle.inset.filled") + } + .disabled(bug.feishuURL == nil) + .help("Render the Feishu record page inside the app (e.g. to advance 流转状态)") + Button { copyLink() } label: { diff --git a/Sources/Views/MenuBar/BugListPopover.swift b/Sources/Views/MenuBar/BugListPopover.swift index 88d5d27..43e0d6b 100644 --- a/Sources/Views/MenuBar/BugListPopover.swift +++ b/Sources/Views/MenuBar/BugListPopover.swift @@ -89,6 +89,7 @@ struct BugListPopover: View { } .contextMenu { Button("Open Details") { openDetails(bug) } + Button("Open in App (Web)") { openInAppWeb(bug) } Button("Open in Feishu") { openInFeishu(bug) } } Divider().padding(.leading, 44) @@ -142,6 +143,14 @@ struct BugListPopover: View { AppDelegate.shared?.closePopover() } + private func openInAppWeb(_ bug: Bug) { + guard let url = bug.feishuURL else { return } + AppDelegate.shared?.openWebWindow(url: url, title: bug.title) + bugStore.markSeen(bug.id) + AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) + AppDelegate.shared?.closePopover() + } + private func openInFeishu(_ bug: Bug) { guard let url = bug.feishuURL else { return } NSWorkspace.shared.open(url) diff --git a/Sources/Views/Web/FeishuWebView.swift b/Sources/Views/Web/FeishuWebView.swift new file mode 100644 index 0000000..6f93bd3 --- /dev/null +++ b/Sources/Views/Web/FeishuWebView.swift @@ -0,0 +1,59 @@ +import AppKit +import SwiftUI +import WebKit + +/// Embeds the Feishu record page inside the app. Uses the default persistent +/// data store, so the Feishu login survives app restarts. Workflow (流程) +/// fields can't be reached via the API — this is the in-app way to advance them. +struct FeishuWebView: NSViewRepresentable { + let url: URL + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.allowsBackForwardNavigationGestures = true + webView.load(URLRequest(url: url)) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + if webView.url?.absoluteString != url.absoluteString { + webView.load(URLRequest(url: url)) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, WKNavigationDelegate { + // Only http(s) navigates inside the webview. Custom schemes the page + // may try (e.g. feishu:// to hand off to the desktop client) are + // routed to the system instead of failing silently. + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction + ) async -> WKNavigationActionPolicy { + guard let url = navigationAction.request.url, + let scheme = url.scheme else { return .allow } + if scheme == "http" || scheme == "https" { + return .allow + } + NSWorkspace.shared.open(url) + return .cancel + } + } +} + +/// Window content wrapper: WKWebView has no intrinsic size, so a bare hosting +/// controller collapses the window to ~1px — pin a minimum size here. +struct FeishuWebWindowContent: View { + let url: URL + + var body: some View { + FeishuWebView(url: url) + .frame(minWidth: 900, minHeight: 600) + } +} diff --git a/scripts/generate_xcode_project.py b/scripts/generate_xcode_project.py index da3457f..837e57c 100644 --- a/scripts/generate_xcode_project.py +++ b/scripts/generate_xcode_project.py @@ -337,7 +337,7 @@ def build_pbxproj() -> str: target_settings = [ ("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"), ("COMBINE_HIDPI_IMAGES", "YES"), - ("CURRENT_PROJECT_VERSION", "5"), + ("CURRENT_PROJECT_VERSION", "6"), ("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'), ("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'), ("FEISHU_BASE_DOMAIN", '"$(FEISHU_BASE_DOMAIN)"'), @@ -346,7 +346,7 @@ def build_pbxproj() -> str: ("LD_RUNPATH_SEARCH_PATHS", '"$(inherited) @executable_path/../Frameworks"'), ("MACOSX_DEPLOYMENT_TARGET", "14.0"), - ("MARKETING_VERSION", "1.5"), + ("MARKETING_VERSION", "1.6"), ("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID), ("PRODUCT_NAME", '"$(TARGET_NAME)"'), ("SWIFT_EMIT_LOC_STRINGS", "YES"),