diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 9bde979..91c901f 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1186,6 +1186,12 @@ }, "Screen Assistant:" : { + }, + "Screenshot options" : { + + }, + "Screenshot Type" : { + }, "Search clipboard..." : { @@ -1419,6 +1425,9 @@ }, "System Performance Monitor" : { + }, + "Take area screenshot" : { + }, "TheDynamicIsland" : { diff --git a/DynamicIsland/components/ScreenAssistant/ChatPanels.swift b/DynamicIsland/components/ScreenAssistant/ChatPanels.swift index 588b555..3932db5 100644 --- a/DynamicIsland/components/ScreenAssistant/ChatPanels.swift +++ b/DynamicIsland/components/ScreenAssistant/ChatPanels.swift @@ -606,18 +606,38 @@ struct StreamingChatMessageBubble: View { struct ScreenshotButton: View { @ObservedObject var screenAssistantManager = ScreenAssistantManager.shared @StateObject private var screenshotTool = ScreenshotSnippingTool.shared + @State private var showingScreenshotOptions = false var body: some View { - Button(action: startScreenshot) { - Image(systemName: getIconName()) - .foregroundColor(getIconColor()) - .font(.system(size: 20)) + HStack(spacing: 4) { + // Main screenshot button + Button(action: startQuickScreenshot) { + Image(systemName: getIconName()) + .foregroundColor(getIconColor()) + .font(.system(size: 20)) + } + .buttonStyle(PlainButtonStyle()) + .help("Take area screenshot") + .disabled(screenshotTool.isSnipping) + .scaleEffect(screenshotTool.isSnipping ? 1.1 : 1.0) + .animation(.easeInOut(duration: 0.2), value: screenshotTool.isSnipping) + + // Options dropdown button + Button(action: { showingScreenshotOptions.toggle() }) { + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + .help("Screenshot options") + .disabled(screenshotTool.isSnipping) + .popover(isPresented: $showingScreenshotOptions) { + ScreenshotOptionsPopover { type in + startScreenshot(type: type) + showingScreenshotOptions = false + } + } } - .buttonStyle(PlainButtonStyle()) - .help(getHelpText()) - .disabled(screenshotTool.isSnipping) - .scaleEffect(screenshotTool.isSnipping ? 1.1 : 1.0) - .animation(.easeInOut(duration: 0.2), value: screenshotTool.isSnipping) } private func getIconName() -> String { @@ -636,21 +656,22 @@ struct ScreenshotButton: View { } } - private func getHelpText() -> String { - return screenshotTool.isSnipping ? "Taking screenshot..." : "Take screenshot" + private func startQuickScreenshot() { + // Default to area screenshot for quick action + startScreenshot(type: .area) } - private func startScreenshot() { + private func startScreenshot(type: ScreenshotSnippingTool.ScreenshotType) { // Start snipping with direct callback (ScreenshotApp-based approach) - screenshotTool.startSnipping { [weak screenAssistantManager] screenshotURL in + screenshotTool.startSnipping(type: type) { [weak screenAssistantManager] screenshotURL in guard let manager = screenAssistantManager else { print("❌ ScreenshotTool: ScreenAssistantManager deallocated during callback") return } - print("📁 ScreenshotTool: Adding screenshot to chat: \(screenshotURL.lastPathComponent)") + print("📁 ScreenshotTool: Adding \(type.displayName.lowercased()) screenshot to chat: \(screenshotURL.lastPathComponent)") manager.addFiles([screenshotURL]) - print("📸 Screenshot captured and added to chat successfully") + print("📸 \(type.displayName) screenshot captured and added to chat successfully") } } } @@ -669,4 +690,141 @@ struct ChatPanelsVisualEffectView: NSViewRepresentable { } func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +// MARK: - Screenshot Options Popover (Hidden from Screen Recording) +struct ScreenshotOptionsPopover: View { + let onOptionSelected: (ScreenshotSnippingTool.ScreenshotType) -> Void + + var body: some View { + VStack(spacing: 8) { + Text("Screenshot Type") + .font(.headline) + .padding(.top, 8) + + VStack(spacing: 4) { + ScreenshotOptionButton( + type: .area, + description: "Select an area", + onTap: onOptionSelected + ) + + ScreenshotOptionButton( + type: .window, + description: "Select a window", + onTap: onOptionSelected + ) + + ScreenshotOptionButton( + type: .full, + description: "Capture full screen", + onTap: onOptionSelected + ) + } + .padding(.bottom, 8) + } + .padding(.horizontal, 12) + .frame(width: 200) + .background( + ScreenshotPopoverBackground() + ) + } +} + +// MARK: - Screenshot Option Button +struct ScreenshotOptionButton: View { + let type: ScreenshotSnippingTool.ScreenshotType + let description: String + let onTap: (ScreenshotSnippingTool.ScreenshotType) -> Void + + var body: some View { + Button(action: { onTap(type) }) { + HStack(spacing: 12) { + Image(systemName: type.iconName) + .foregroundColor(.blue) + .font(.system(size: 16)) + .frame(width: 20, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(type.displayName) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text(description) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .contentShape(Rectangle()) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.clear, lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(NSColor.controlBackgroundColor)) + .opacity(0.5) + ) + .onHover { isHovered in + // Add subtle hover effect if needed + } + } +} + +// MARK: - Screenshot Popover Background (Hidden from Screen Recording) +struct ScreenshotPopoverBackground: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView() + + // Apply screen capture protection like our other panels + DispatchQueue.main.async { + if let window = view.window { + self.updateScreenCaptureVisibility(window: window) + self.setupScreenCaptureObserver(window: window) + } + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Update screen capture protection when view updates + if let window = nsView.window { + updateScreenCaptureVisibility(window: window) + } + } + + private func updateScreenCaptureVisibility(window: NSWindow) { + let shouldHide = Defaults[.hidePanelsFromScreenCapture] + + if shouldHide { + // Hide from screen capture and recording + window.sharingType = .none + print("🙈 ScreenshotPopover: Hidden from screen capture and recordings") + } else { + // Allow normal screen capture + window.sharingType = .readOnly + print("👁️ ScreenshotPopover: Visible in screen capture and recordings") + } + } + + private func setupScreenCaptureObserver(window: NSWindow) { + // Observe changes to hidePanelsFromScreenCapture setting + Defaults.observe(.hidePanelsFromScreenCapture) { [weak window] change in + DispatchQueue.main.async { + guard let window = window else { return } + self.updateScreenCaptureVisibility(window: window) + } + } + } } \ No newline at end of file diff --git a/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool.swift b/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool.swift index 02c0a67..f62dc5f 100644 --- a/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool.swift +++ b/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool.swift @@ -15,6 +15,40 @@ class ScreenshotSnippingTool: NSObject, ObservableObject { @Published var isSnipping = false private var completion: ((URL) -> Void)? + // MARK: - Screenshot Types (Based on ScreenshotApp) + enum ScreenshotType { + case full + case window + case area + + var processArguments: [String] { + switch self { + case .full: + return ["-c"] // -c = clipboard + case .window: + return ["-cw"] // -c = clipboard, -w = window selection + case .area: + return ["-cs"] // -c = clipboard, -s = area selection + } + } + + var displayName: String { + switch self { + case .full: return "Full Screen" + case .window: return "Window" + case .area: return "Area" + } + } + + var iconName: String { + switch self { + case .full: return "rectangle.dashed" + case .window: return "macwindow" + case .area: return "viewfinder.rectangular" + } + } + } + enum ScreenshotError: Error { case captureFailed case noImageInPasteboard @@ -25,26 +59,39 @@ class ScreenshotSnippingTool: NSObject, ObservableObject { super.init() } - // MARK: - Simple API (Based on ScreenshotApp Implementation) - func startSnipping(completion: @escaping (URL) -> Void) { + // MARK: - Enhanced API (Based on ScreenshotApp Implementation) + func startSnipping(type: ScreenshotType = .area, completion: @escaping (URL) -> Void) { guard !isSnipping else { return } - print("🖼️ ScreenshotTool: Starting area screenshot using screencapture tool") + print("🖼️ ScreenshotTool: Starting \(type.displayName.lowercased()) screenshot using screencapture tool") self.completion = completion isSnipping = true // Use the same approach as ScreenshotApp - direct screencapture command - takeAreaScreenshot() + takeScreenshot(type: type) + } + + // MARK: - Convenience Methods for Different Types + func startAreaScreenshot(completion: @escaping (URL) -> Void) { + startSnipping(type: .area, completion: completion) + } + + func startFullScreenshot(completion: @escaping (URL) -> Void) { + startSnipping(type: .full, completion: completion) + } + + func startWindowScreenshot(completion: @escaping (URL) -> Void) { + startSnipping(type: .window, completion: completion) } // MARK: - ScreenshotApp-Style Implementation - private func takeAreaScreenshot() { + private func takeScreenshot(type: ScreenshotType) { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") - task.arguments = ["-cs"] // -c = clipboard, -s = area selection + task.arguments = type.processArguments do { - print("📸 ScreenshotTool: Running screencapture -cs command") + print("📸 ScreenshotTool: Running screencapture \(type.processArguments.joined(separator: " ")) command") try task.run() task.waitUntilExit() diff --git a/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool_old.swift b/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool_old.swift deleted file mode 100644 index b06c1a8..0000000 --- a/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool_old.swift +++ /dev/null @@ -1,500 +0,0 @@ -// This file has been replaced - keeping for reference only - -// MARK: - Screenshot Snipping Tool -class ScreenshotSnippingTool: NSObject, ObservableObject { - static let shared = ScreenshotSnippingTool() - - @Published var isSnipping = false - @Published var hasPermissions = false - @Published var permissionStatus: PermissionStatus = .unknown - private var snippingWindow: SnippingWindow? - - enum PermissionStatus { - case unknown - case granted - case denied - case restricted - } - - override init() { - super.init() - checkPermissionsAsync() - } - - private func checkPermissionsAsync() { - Task { - await checkPermissions() - } - } - - @MainActor - func checkPermissions() async { - print("🔍 ScreenshotTool: Checking permissions for bundle: \(Bundle.main.bundleIdentifier ?? "unknown")") - - do { - // First try a simple content check to see if we can access ScreenCaptureKit - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) - - // If we get here without error, permissions are granted - self.hasPermissions = true - self.permissionStatus = .granted - print("✅ ScreenshotTool: Permissions granted for \(content.displays.count) displays") - - } catch let error as NSError { - print("❌ ScreenshotTool: Permission error - Code: \(error.code), Domain: \(error.domain)") - - // Handle specific error codes - switch error.code { - case -3801: // User declined TCC permission - self.hasPermissions = false - self.permissionStatus = .denied - print("❌ ScreenshotTool: Permission denied by user - TCC database entry missing or denied") - - case -3802: // App is not authorized for screen capture - self.hasPermissions = false - self.permissionStatus = .restricted - print("❌ ScreenshotTool: App not authorized for screen capture - System restriction") - - default: - self.hasPermissions = false - self.permissionStatus = .unknown - print("❌ ScreenshotTool: Unknown permission error - \(error.localizedDescription)") - } - - // Provide user guidance - DispatchQueue.main.async { - self.showPermissionGuidance() - } - } - } - - private func showPermissionGuidance() { - let alert = NSAlert() - alert.messageText = "Screen Recording Permission Required" - alert.informativeText = """ - Dynamic Island needs screen recording permission to capture screenshots. - - To enable this: - 1. Open System Settings (or System Preferences) - 2. Go to Privacy & Security → Screen Recording - 3. Enable 'Dynamic Island' - 4. Restart the app if needed - """ - alert.alertStyle = .warning - alert.addButton(withTitle: "Open System Settings") - alert.addButton(withTitle: "Cancel") - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - // Open System Settings to Screen Recording - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { - NSWorkspace.shared.open(url) - } - } - } - - func requestPermissions() async { - // Only request if we don't already have them - guard !hasPermissions else { return } - await checkPermissions() - } - - func startSnipping() { - guard !isSnipping else { return } - - // Check permissions first - if !hasPermissions { - print("❌ ScreenshotTool: No permissions - checking status") - Task { - await requestPermissions() - } - return - } - - print("🖼️ ScreenshotTool: Starting screen snipping") - isSnipping = true - - // Hide all app windows temporarily - hideAppWindows() - - // Create snipping overlay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.createSnippingOverlay() - } - } - - private func hideAppWindows() { - for window in NSApp.windows { - window.orderOut(nil) - } - } - - private func restoreAppWindows() { - for window in NSApp.windows { - if window != snippingWindow { - window.orderFront(nil) - } - } - } - - private func createSnippingOverlay() { - guard let screen = NSScreen.main else { - print("❌ ScreenshotTool: No main screen available") - finishSnipping() - return - } - - print("🎯 ScreenshotTool: Creating snipping overlay") - - snippingWindow = SnippingWindow(screen: screen) { [weak self] rect in - guard let self = self else { - print("❌ ScreenshotTool: Self deallocated during area selection") - return - } - self.captureScreenArea(rect) - } - - guard let window = snippingWindow else { - print("❌ ScreenshotTool: Failed to create snipping window") - finishSnipping() - return - } - - // Just make it key and order front, don't try to make it main - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - - // Activate the app to ensure proper focus - NSApp.activate(ignoringOtherApps: true) - - print("✅ ScreenshotTool: Snipping overlay created and activated") - } - - private func captureScreenArea(_ rect: CGRect) { - print("🖼️ ScreenshotTool: Capturing area: \(rect)") - - Task { - do { - let screenshot = try await captureScreenshot(rect: rect) - - DispatchQueue.main.async { [weak self] in - guard let self = self else { - print("❌ ScreenshotTool: Self deallocated during screenshot callback") - return - } - - print("✅ ScreenshotTool: Screenshot saved successfully") - - // Capture screenshot URL before cleanup - let screenshotURL = screenshot - - // Finish snipping first to clean up window state - self.finishSnipping() - - // Post notification after cleanup with captured URL - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - print("📸 ScreenshotTool: Posting screenshot notification") - NotificationCenter.default.post( - name: Notification.Name("screenshotCaptured"), - object: screenshotURL - ) - print("✅ ScreenshotTool: Notification posted successfully") - } - } - } catch { - print("❌ ScreenshotTool: Failed to capture screenshot - \(error)") - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // Show error to user - self.showScreenshotError(error) - self.finishSnipping() - } - } - } - } - - private func showScreenshotError(_ error: Error) { - let alert = NSAlert() - alert.messageText = "Screenshot Failed" - alert.informativeText = "Failed to capture screenshot: \(error.localizedDescription)" - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() - } - - private func captureScreenshot(rect: CGRect) async throws -> URL { - // Use ScreenCaptureKit for modern screenshot capture - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - - guard let display = content.displays.first else { - throw ScreenshotError.noDisplayFound - } - - let config = SCStreamConfiguration() - config.width = Int(rect.width) - config.height = Int(rect.height) - config.sourceRect = rect - config.pixelFormat = kCVPixelFormatType_32BGRA - config.showsCursor = false - - let filter = SCContentFilter(display: display, excludingWindows: []) - let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) - - // Ensure screenshot directory exists - let screenshotDir = ScreenAssistantManager.screenshotDataDirectory - if !FileManager.default.fileExists(atPath: screenshotDir.path) { - try FileManager.default.createDirectory(at: screenshotDir, withIntermediateDirectories: true) - } - - // Save to temporary file - let filename = "screenshot_\(Date().timeIntervalSince1970).png" - let screenshotURL = screenshotDir.appendingPathComponent(filename) - - // Create NSImage from CGImage and convert to PNG - let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) - - guard let imageData = nsImage.tiffRepresentation, - let bitmapRep = NSBitmapImageRep(data: imageData), - let pngData = bitmapRep.representation(using: NSBitmapImageRep.FileType.png, properties: [:]) else { - throw ScreenshotError.imageConversionFailed - } - - do { - try pngData.write(to: screenshotURL) - print("✅ ScreenshotTool: Saved screenshot to: \(screenshotURL.path)") - return screenshotURL - } catch { - print("❌ ScreenshotTool: Failed to write screenshot file - \(error)") - throw ScreenshotError.fileWriteError - } - } - - private func finishSnipping() { - guard isSnipping else { - print("⚠️ ScreenshotTool: finishSnipping called but not currently snipping") - return - } - - print("🔄 ScreenshotTool: Finishing snipping process") - - isSnipping = false - - // Close and cleanup snipping window safely - if let window = snippingWindow { - window.orderOut(nil) - window.close() - snippingWindow = nil - print("🪟 ScreenshotTool: Snipping window closed and cleared") - } - - // Restore app windows with delay to prevent conflicts - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.restoreAppWindows() - print("🏠 ScreenshotTool: App windows restored") - } - - print("✅ ScreenshotTool: Snipping process completed") - } - - func cancelSnipping() { - finishSnipping() - } -} - -// MARK: - Snipping Window -class SnippingWindow: NSWindow { - private let onAreaSelected: (CGRect) -> Void - private var selectionOverlay: SelectionOverlayView? - - init(screen: NSScreen, onAreaSelected: @escaping (CGRect) -> Void) { - self.onAreaSelected = onAreaSelected - - super.init( - contentRect: screen.frame, - styleMask: [.borderless, .fullSizeContentView], - backing: .buffered, - defer: false - ) - - setupWindow(screen: screen) - setupContent() - } - - override var canBecomeKey: Bool { - return true - } - - override var canBecomeMain: Bool { - return true - } - - private func setupWindow(screen: NSScreen) { - backgroundColor = NSColor.black.withAlphaComponent(0.3) - isOpaque = false - level = .screenSaver - ignoresMouseEvents = false - acceptsMouseMovedEvents = true - isMovableByWindowBackground = false - titleVisibility = .hidden - titlebarAppearsTransparent = true - - collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - setFrame(screen.frame, display: true) - } - - private func setupContent() { - selectionOverlay = SelectionOverlayView { [weak self] rect in - self?.onAreaSelected(rect) - } - - if let overlay = selectionOverlay { - let hostingView = NSHostingView(rootView: overlay) - contentView = hostingView - } - } - - override func keyDown(with event: NSEvent) { - if event.keyCode == 53 { // ESC key - close() - ScreenshotSnippingTool.shared.cancelSnipping() - } else { - super.keyDown(with: event) - } - } -} - -// MARK: - Selection Overlay View -struct SelectionOverlayView: NSViewRepresentable { - let onAreaSelected: (CGRect) -> Void - - func makeNSView(context: Context) -> SelectionView { - let view = SelectionView() - view.onAreaSelected = onAreaSelected - return view - } - - func updateNSView(_ nsView: SelectionView, context: Context) {} -} - -// MARK: - Selection View -class SelectionView: NSView { - var onAreaSelected: ((CGRect) -> Void)? - private var startPoint: CGPoint? - private var currentRect: CGRect = .zero - private var isDragging = false - - override func awakeFromNib() { - super.awakeFromNib() - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - } - - override func mouseDown(with event: NSEvent) { - startPoint = convert(event.locationInWindow, from: nil) - isDragging = true - currentRect = .zero - needsDisplay = true - } - - override func mouseDragged(with event: NSEvent) { - guard let start = startPoint, isDragging else { return } - - let currentPoint = convert(event.locationInWindow, from: nil) - - let x = min(start.x, currentPoint.x) - let y = min(start.y, currentPoint.y) - let width = abs(currentPoint.x - start.x) - let height = abs(currentPoint.y - start.y) - - currentRect = CGRect(x: x, y: y, width: width, height: height) - needsDisplay = true - } - - override func mouseUp(with event: NSEvent) { - guard isDragging, !currentRect.isEmpty else { - isDragging = false - return - } - - isDragging = false - - // Convert to screen coordinates - if let window = self.window { - let screenRect = window.convertToScreen(currentRect) - // Flip Y coordinate for screen capture - let flippedRect = CGRect( - x: screenRect.origin.x, - y: NSScreen.main!.frame.height - screenRect.origin.y - screenRect.height, - width: screenRect.width, - height: screenRect.height - ) - onAreaSelected?(flippedRect) - } - } - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - - guard isDragging, !currentRect.isEmpty else { return } - - // Draw selection rectangle - NSColor.systemBlue.withAlphaComponent(0.3).setFill() - currentRect.fill() - - NSColor.systemBlue.setStroke() - let path = NSBezierPath(rect: currentRect) - path.lineWidth = 2.0 - path.stroke() - - // Draw selection info - let infoText = String(format: "%.0f × %.0f", currentRect.width, currentRect.height) - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 12), - .foregroundColor: NSColor.white, - .backgroundColor: NSColor.black.withAlphaComponent(0.8) - ] - - let attributedString = NSAttributedString(string: infoText, attributes: attributes) - let textRect = CGRect( - x: currentRect.maxX - 80, - y: currentRect.minY - 20, - width: 80, - height: 20 - ) - - attributedString.draw(in: textRect) - } -} - -// MARK: - Screenshot Error Types -enum ScreenshotError: Error, LocalizedError { - case noDisplayFound - case imageConversionFailed - case fileWriteError - - var errorDescription: String? { - switch self { - case .noDisplayFound: - return "No display found for screenshot capture" - case .imageConversionFailed: - return "Failed to convert screenshot image" - case .fileWriteError: - return "Failed to save screenshot file" - } - } -} - -// MARK: - ScreenAssistantManager Extension for Screenshot Directory -extension ScreenAssistantManager { - static var screenshotDataDirectory: URL { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let appDir = appSupport.appendingPathComponent(bundleIdentifier) - let screenshotDir = appDir.appendingPathComponent("Screenshots") - - // Create directory if it doesn't exist - try? FileManager.default.createDirectory(at: screenshotDir, withIntermediateDirectories: true) - - return screenshotDir - } -} \ No newline at end of file