Compare commits

..
4 Commits
Author SHA1 Message Date
TZGyn e22bcb99c4 Make overlay panels activating so clicks are received
The session log showed active=false after every activation attempt on
macOS 26: cooperative activation is denied for LSUIElement apps, and an
inactive app's panels let clicks fall through to the windows below.
Remove .nonactivatingPanel so showing the overlay activates the app
(previous app focus is restored after capture as before).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:46:45 +08:00
TZGyn 457e303152 Force app activation on capture; log to /tmp for durable diagnostics
Cooperative NSApp.activate() can silently fail for LSUIElement apps,
leaving the app inactive so overlay clicks pass through to windows below.
Force activation with ignoringOtherApps on all OS versions. Session logs
now also append to /tmp/kapture-debug.log so they survive relaunches, and
the first window-scan result is always logged.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:00:44 +08:00
TZGyn 345f306edf Add session event logging to capture flow for remote debugging
OverlayView now logs window tracking counts, hover changes, and
mouseDown/Up branch decisions into a session log that Copy Diagnostics
includes along with the app version, so capture failures on machines we
cannot access directly can be diagnosed from a pasted report.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:52:16 +08:00
TZGyn 45e4962ec3 Fix multi-display window detection and click-to-capture; separate Redact tool
Window frames are now converted to AppKit space once at detection time, so
hover highlighting and picking work on every display. A click is only
treated as a drag after 4pt of movement, so clicking a detected window
captures it instead of passing a stray marquee. Redact is now a standalone
tool (Select/Draw/Redact) with its own toolbar hints instead of a toggle
inside the draw bar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:46:32 +08:00
4 changed files with 82 additions and 46 deletions
+17 -6
View File
@@ -10,10 +10,21 @@ final class CaptureController: NSObject {
private var previousApp: NSRunningApplication? private var previousApp: NSRunningApplication?
private var overlayIDs: [Int] = [] private var overlayIDs: [Int] = []
/// Session event log for debugging (dumped via Copy Diagnostics).
static var sessionLog: [String] = []
static func log(_ line: String) {
sessionLog.append(line)
if sessionLog.count > 100 { sessionLog.removeFirst() }
NSLog("[Kapture] \(line)")
}
/// Diagnostic snapshot for "Copy Diagnostics" menu item. /// Diagnostic snapshot for "Copy Diagnostics" menu item.
func diagnostics() -> String { func diagnostics() -> String {
var lines: [String] = [] var lines: [String] = []
lines.append("Kapture diagnostics") lines.append("Kapture diagnostics — version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "?")")
lines.append("--- session log (newest last) ---")
lines.append(contentsOf: Self.sessionLog)
lines.append("--- screens ---")
for (i, s) in NSScreen.screens.enumerated() { for (i, s) in NSScreen.screens.enumerated() {
let scale = s.backingScaleFactor let scale = s.backingScaleFactor
let cgID = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0 let cgID = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
@@ -50,11 +61,10 @@ final class CaptureController: NSObject {
} }
isSelecting = true isSelecting = true
previousApp = NSWorkspace.shared.frontmostApplication previousApp = NSWorkspace.shared.frontmostApplication
if #available(macOS 14.0, *) { // Force activation and log whether it stuck — inactive apps don't
NSApp.activate() // receive overlay clicks.
} else { let activated = NSApp.activate(ignoringOtherApps: true)
NSApp.activate(ignoringOtherApps: true) CaptureController.log("begin: activate(ignoringOtherApps)=\(activated) active=\(NSApp.isActive)")
}
overlays = NSScreen.screens.map { screen in overlays = NSScreen.screens.map { screen in
OverlayWindow( OverlayWindow(
screen: screen, screen: screen,
@@ -65,6 +75,7 @@ final class CaptureController: NSObject {
} }
overlays.forEach { $0.makeKeyAndOrderFront(nil) } overlays.forEach { $0.makeKeyAndOrderFront(nil) }
overlayIDs = overlays.map { $0.windowNumber } overlayIDs = overlays.map { $0.windowNumber }
CaptureController.log("begin: \(overlays.count) overlays, ids=\(overlayIDs), active=\(NSApp.isActive)")
} }
/// A window was picked from hover — capture it with rounded corners. /// A window was picked from hover — capture it with rounded corners.
+27 -16
View File
@@ -30,11 +30,11 @@ final class OverlayView: NSView {
private var startPoint: NSPoint? private var startPoint: NSPoint?
private(set) var selection: NSRect? private(set) var selection: NSRect?
private var isDragging = false private var isDragging = false
private var dragDistance: CGFloat = 0
// Window detection // Window detection (frames stored in AppKit global space)
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = [] private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var hoveredWindowFrame: NSRect? private var hoveredWindowFrame: NSRect?
private var excludedIDs: [Int] = []
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) { init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
self.onComplete = onComplete self.onComplete = onComplete
@@ -79,14 +79,17 @@ final class OverlayView: NSView {
else { continue } else { continue }
// Skip our own overlays // Skip our own overlays
if Int(cid) == ownID { continue } if Int(cid) == ownID { continue }
let rect = NSRect( let cgRect = NSRect(
x: bounds["X"] ?? 0, x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0, y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0, width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0 height: bounds["Height"] ?? 0
) )
guard rect.width >= 80, rect.height >= 60 else { continue } guard cgRect.width >= 80, cgRect.height >= 60 else { continue }
// Skip tiny/utility windows // Convert to AppKit space FIRST — CG and AppKit Y axes disagree on
// multi-desktop layouts, so intersect tests must use one space.
let rect = Self.cgToAppKit(rect: cgRect)
// Skip windows filling the whole screen (fullscreen apps)
guard rect.width < screenFrame.width || rect.height < screenFrame.height else { continue } guard rect.width < screenFrame.width || rect.height < screenFrame.height else { continue }
// Only windows intersecting this screen // Only windows intersecting this screen
guard rect.intersects(screenFrame) else { continue } guard rect.intersects(screenFrame) else { continue }
@@ -97,11 +100,11 @@ final class OverlayView: NSView {
private func windowFrame(at point: NSPoint) -> NSRect? { private func windowFrame(at point: NSPoint) -> NSRect? {
guard let window = window else { return nil } guard let window = window else { return nil }
// convertToScreen gives AppKit-global coordinates; convert tracked // convertToScreen gives AppKit-global coordinates; tracked frames are
// CG frames to the same space and pick the frontmost containing it. // already in that space.
let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)).origin let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)).origin
for (_, frame) in trackedWindows { for (_, frame) in trackedWindows {
if Self.cgToAppKit(rect: frame).contains(screenPoint) { if frame.contains(screenPoint) {
return frame return frame
} }
} }
@@ -132,15 +135,20 @@ final class OverlayView: NSView {
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
let p = convert(event.locationInWindow, from: nil) let p = convert(event.locationInWindow, from: nil)
startPoint = p startPoint = p
dragDistance = 0
selection = NSRect(origin: p, size: .zero) selection = NSRect(origin: p, size: .zero)
needsDisplay = true needsDisplay = true
} }
override func mouseDragged(with event: NSEvent) { override func mouseDragged(with event: NSEvent) {
guard let start = startPoint else { return } guard let start = startPoint else { return }
let p = convert(event.locationInWindow, from: nil)
dragDistance = hypot(p.x - start.x, p.y - start.y)
// Only become a marquee after real movement; micro-jitter from a
// click must not cancel window-pick mode.
guard dragDistance >= 4 else { return }
isDragging = true isDragging = true
hoveredWindowFrame = nil hoveredWindowFrame = nil
let p = convert(event.locationInWindow, from: nil)
selection = NSRect( selection = NSRect(
x: min(start.x, p.x), x: min(start.x, p.x),
y: min(start.y, p.y), y: min(start.y, p.y),
@@ -151,6 +159,10 @@ final class OverlayView: NSView {
} }
override func mouseUp(with event: NSEvent) { override func mouseUp(with event: NSEvent) {
defer {
isDragging = false
dragDistance = 0
}
if isDragging { if isDragging {
guard let sel = selection else { return } guard let sel = selection else { return }
if sel.width >= 2, sel.height >= 2 { if sel.width >= 2, sel.height >= 2 {
@@ -161,10 +173,10 @@ final class OverlayView: NSView {
return return
} }
// No drag: it was a click. If a window is hovered, capture it with // It was a click. If a window is hovered, capture it with rounded
// rounded corners; otherwise treat as an empty marquee (cancel). // corners; otherwise treat as an empty marquee (cancel).
if let frame = hoveredWindowFrame { if let frame = hoveredWindowFrame {
onWindowPick(Self.cgToAppKit(rect: frame)) onWindowPick(frame)
} else { } else {
onCancel() onCancel()
} }
@@ -184,13 +196,12 @@ final class OverlayView: NSView {
// Highlight the hovered window (rounded, like the real window corners) // Highlight the hovered window (rounded, like the real window corners)
if let frame = hoveredWindowFrame { if let frame = hoveredWindowFrame {
// Convert the CG frame to AppKit-global, then to this view's space. // Tracked frames are AppKit-global; view space = global - window origin.
guard let window = window else { return } guard let window = window else { return }
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size)) let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
let appKitFrame = Self.cgToAppKit(rect: frame)
let viewFrame = NSRect( let viewFrame = NSRect(
x: appKitFrame.origin.x - screenFrame.origin.x, x: frame.origin.x - screenFrame.origin.x,
y: appKitFrame.origin.y - screenFrame.origin.y, y: frame.origin.y - screenFrame.origin.y,
width: frame.width, width: frame.width,
height: frame.height height: frame.height
) )
+36 -22
View File
@@ -95,14 +95,18 @@ final class CaptureModel: ObservableObject {
/// Redo stack of removed strokes /// Redo stack of removed strokes
@Published var redoStack: [Stroke] = [] @Published var redoStack: [Stroke] = []
/// Whether the pen is active (drag draws instead of selecting) /// Whether the pen is active (drag draws instead of selecting)
@Published var isDrawing = false @Published var tool: Tool = .select
/// Whether drawing redacts (black bar) instead of pen strokes
@Published var isRedacting = false
/// Current drawing color /// Current drawing color
@Published var drawColor: NSColor = .systemRed @Published var drawColor: NSColor = .systemRed
/// Stroke thickness in points (image space) /// Stroke thickness in points (image space)
@Published var strokeWidth: CGFloat = 3.0 @Published var strokeWidth: CGFloat = 3.0
enum Tool {
case select
case draw
case redact
}
struct Stroke { struct Stroke {
let points: [CGPoint] let points: [CGPoint]
let width: CGFloat let width: CGFloat
@@ -193,7 +197,7 @@ final class CaptureModel: ObservableObject {
func addStroke(_ points: [CGPoint]) { func addStroke(_ points: [CGPoint]) {
guard points.count > 1 else { return } guard points.count > 1 else { return }
if isRedacting { if tool == .redact {
// Redaction: a filled black bar from start to end, as thick as the // Redaction: a filled black bar from start to end, as thick as the
// current stroke width (drawn as a rect for clean edges). // current stroke width (drawn as a rect for clean edges).
strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true)) strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true))
@@ -333,14 +337,24 @@ struct ResultView: View {
Divider().frame(height: 18) Divider().frame(height: 18)
Button { Button {
model.isDrawing.toggle() model.tool = model.tool == .draw ? .select : .draw
if model.isDrawing { model.selection = nil } if model.tool != .select { model.selection = nil }
} label: { } label: {
Label("Draw", systemImage: model.isDrawing ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle") Label("Draw", systemImage: model.tool == .draw ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle")
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.foregroundStyle(model.isDrawing ? .primary : .secondary) .foregroundStyle(model.tool == .draw ? .primary : .secondary)
.help("Toggle drawing on the screenshot") .help("Draw on the screenshot")
Button {
model.tool = model.tool == .redact ? .select : .redact
if model.tool != .select { model.selection = nil }
} label: {
Label("Redact", systemImage: model.tool == .redact ? "rectangle.fill.on.rectangle.fill" : "rectangle.dashed")
}
.buttonStyle(.bordered)
.foregroundStyle(model.tool == .redact ? .primary : .secondary)
.help("Cover sensitive areas with black bars")
Button { Button {
model.copyImage() model.copyImage()
@@ -372,20 +386,16 @@ struct ResultView: View {
Divider() Divider()
// Draw toolbar (only when drawing) // Tool toolbar (only when a draw tool is active)
if model.isDrawing { if model.tool != .select {
HStack(spacing: 14) { HStack(spacing: 14) {
Picker("", selection: $model.isRedacting) { Label(model.tool == .redact ? "Redact" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "pencil.tip")
Label("Pen", systemImage: "pencil.tip").tag(false) .font(.caption)
Label("Redact", systemImage: "rectangle.fill").tag(true) .foregroundStyle(.secondary)
}
.pickerStyle(.segmented)
.fixedSize()
.help("Pen draws strokes; Redact covers text with a black bar")
Divider().frame(height: 18) Divider().frame(height: 18)
if !model.isRedacting { if model.tool == .draw {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "circle.fill").font(.system(size: 4)) Image(systemName: "circle.fill").font(.system(size: 4))
Image(systemName: "circle.fill").font(.system(size: 7)) Image(systemName: "circle.fill").font(.system(size: 7))
@@ -405,6 +415,10 @@ struct ResultView: View {
.labelsHidden() .labelsHidden()
.frame(width: 28) .frame(width: 28)
.help("Drawing color") .help("Drawing color")
} else {
Label("Drag to cover sensitive text", systemImage: "eye.slash")
.font(.caption)
.foregroundStyle(.secondary)
} }
Spacer() Spacer()
@@ -582,7 +596,7 @@ struct ScreenshotPane: View {
.allowsHitTesting(false) .allowsHitTesting(false)
} }
if !model.isDrawing, let sel = model.selection { if model.tool == .select, let sel = model.selection {
let viewRect = CGRect( let viewRect = CGRect(
x: origin.x + sel.minX * scale, x: origin.x + sel.minX * scale,
y: origin.y + sel.minY * scale, y: origin.y + sel.minY * scale,
@@ -601,7 +615,7 @@ struct ScreenshotPane: View {
.gesture( .gesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
.onChanged { value in .onChanged { value in
if model.isDrawing { if model.tool != .select {
let p = toImagePoint(value.location, scale: scale, origin: origin) let p = toImagePoint(value.location, scale: scale, origin: origin)
let imgRect = CGRect(origin: .zero, size: imageSize) let imgRect = CGRect(origin: .zero, size: imageSize)
if imgRect.contains(p) { if imgRect.contains(p) {
@@ -620,7 +634,7 @@ struct ScreenshotPane: View {
} }
} }
.onEnded { _ in .onEnded { _ in
if model.isDrawing { if model.tool != .select {
if currentStroke.count > 1 { if currentStroke.count > 1 {
model.addStroke(currentStroke) model.addStroke(currentStroke)
} }
+2 -2
View File
@@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Icon</string> <string>Icon</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.5.1</string> <string>1.6.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>51</string> <string>63</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>