Compare commits

..
6 Commits
Author SHA1 Message Date
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
TZGyn 5f137f89bf Fix window hover detection; add redaction tool
Window detection was broken by wrong CG-to-AppKit coordinate conversion
of the cursor and highlight rects — both now go through the same
cgToAppKit mapping. The draw toolbar gains a Pen/Redact toggle: redact
drags cover content with a solid black bar (participates in undo/redo
and export like pen strokes).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:39:26 +08:00
TZGyn b998ea210e Document brew trust step for the Gitea tap 2026-09-09 10:36:20 +08:00
TZGyn 86826f7ecc Point Homebrew install at the Gitea-hosted tap
brew tap without a URL resolves to GitHub, where the tap no longer exists;
the full Gitea URL is required.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:32:47 +08:00
5 changed files with 154 additions and 76 deletions
+5 -2
View File
@@ -25,11 +25,14 @@ No network calls, no API keys — everything runs locally.
## Install with Homebrew ## Install with Homebrew
```bash ```bash
brew tap TZGyn/tap brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
brew trust TZGyn/tap # Homebrew 6+ requires trusting third-party taps brew trust TZGyn/tap
brew install --cask kapture brew install --cask kapture
``` ```
The tap is hosted on Gitea, so the full URL is required. Homebrew may print
an "Unsupported tap" warning — it's harmless.
Releases are published as zips on GitHub Releases; `release.sh` packages a Releases are published as zips on GitHub Releases; `release.sh` packages a
new one (`./release.sh 1.0.1 && git tag v1.0.1 && git push origin v1.0.1`). new one (`./release.sh 1.0.1 && git tag v1.0.1 && git push origin v1.0.1`).
The cask lives in the [TZGyn/homebrew-tap](https://github.com/TZGyn/homebrew-tap) The cask lives in the [TZGyn/homebrew-tap](https://github.com/TZGyn/homebrew-tap)
+17 -5
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,11 @@ final class CaptureController: NSObject {
} }
isSelecting = true isSelecting = true
previousApp = NSWorkspace.shared.frontmostApplication previousApp = NSWorkspace.shared.frontmostApplication
if #available(macOS 14.0, *) { // Force activation: cooperative NSApp.activate() can silently fail for
NSApp.activate() // LSUIElement apps, leaving the app inactive — then clicks on the
} else { // overlays pass through to the windows below.
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} CaptureController.log("begin: creating overlays for \(NSScreen.screens.count) screens")
overlays = NSScreen.screens.map { screen in overlays = NSScreen.screens.map { screen in
OverlayWindow( OverlayWindow(
screen: screen, screen: screen,
@@ -65,6 +76,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.
+30 -23
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,24 +100,19 @@ 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 }
let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)) // convertToScreen gives AppKit-global coordinates; tracked frames are
// CGWindowList bounds use a TOP-LEFT origin; convert the cursor point. // already in that space.
guard let mainScreen = NSScreen.screens.first else { return nil } let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)).origin
let topLeftPoint = NSPoint(
x: screenPoint.origin.x,
y: mainScreen.frame.height - screenPoint.origin.y
)
// Pick the frontmost (list order) window containing the point
for (_, frame) in trackedWindows { for (_, frame) in trackedWindows {
if frame.contains(topLeftPoint) { if frame.contains(screenPoint) {
return frame return frame
} }
} }
return nil return nil
} }
/// Convert a CGWindowList (top-left origin) frame into AppKit screen /// Convert a CGWindowList (top-left origin, CG global space) frame into
/// coordinates (bottom-left origin) for capture. /// AppKit screen coordinates (bottom-left origin) for capture.
private static func cgToAppKit(rect: NSRect) -> NSRect { private static func cgToAppKit(rect: NSRect) -> NSRect {
guard let mainScreen = NSScreen.screens.first else { return rect } guard let mainScreen = NSScreen.screens.first else { return rect }
let y = mainScreen.frame.height - rect.origin.y - rect.height let y = mainScreen.frame.height - rect.origin.y - rect.height
@@ -137,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),
@@ -156,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 {
@@ -166,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()
} }
@@ -189,12 +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 CGWindowList (top-left) frame to view (bottom-left) space // Tracked frames are AppKit-global; view space = global - window origin.
guard let window = window, let mainScreen = NSScreen.screens.first 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 viewFrame = NSRect( let viewFrame = NSRect(
x: frame.origin.x - screenFrame.origin.x, x: frame.origin.x - screenFrame.origin.x,
y: mainScreen.frame.height - frame.origin.y - frame.height - screenFrame.origin.y, y: frame.origin.y - screenFrame.origin.y,
width: frame.width, width: frame.width,
height: frame.height height: frame.height
) )
+68 -12
View File
@@ -95,16 +95,23 @@ 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
/// 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
let color: NSColor let color: NSColor
var isRedaction: Bool = false
} }
enum CaptureMode { enum CaptureMode {
@@ -159,6 +166,20 @@ final class CaptureModel: ObservableObject {
ctx.setLineJoin(.round) ctx.setLineJoin(.round)
for stroke in strokes { for stroke in strokes {
guard stroke.points.count > 1 else { continue } guard stroke.points.count > 1 else { continue }
if stroke.isRedaction {
// Redaction bar: filled black rect covering the stroke bounds,
// padded so it fully covers text.
let xs = stroke.points.map { $0.x }
let ys = stroke.points.map { $0.y }
let bar = NSRect(
x: xs.min()! - 2,
y: ys.min()! - 2,
width: (xs.max()! - xs.min()!) + 4,
height: max(stroke.width, ys.max()! - ys.min()!) + 4
)
NSColor.black.setFill()
NSBezierPath(rect: bar).fill()
} else {
stroke.color.setStroke() stroke.color.setStroke()
ctx.setLineWidth(stroke.width) ctx.setLineWidth(stroke.width)
ctx.beginPath() ctx.beginPath()
@@ -168,6 +189,7 @@ final class CaptureModel: ObservableObject {
} }
ctx.strokePath() ctx.strokePath()
} }
}
ctx.restoreGState() ctx.restoreGState()
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
return rep.cgImage ?? image return rep.cgImage ?? image
@@ -175,7 +197,13 @@ 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 tool == .redact {
// Redaction: a filled black bar from start to end, as thick as the
// current stroke width (drawn as a rect for clean edges).
strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true))
} else {
strokes.append(Stroke(points: points, width: strokeWidth, color: drawColor)) strokes.append(Stroke(points: points, width: strokeWidth, color: drawColor))
}
redoStack = [] redoStack = []
} }
@@ -309,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()
@@ -348,15 +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) {
Label("Draw", systemImage: "pencil.tip") Label(model.tool == .redact ? "Redact" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "pencil.tip")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Divider().frame(height: 18) Divider().frame(height: 18)
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))
@@ -376,6 +415,11 @@ 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()
@@ -515,6 +559,17 @@ struct ScreenshotPane: View {
Canvas { context, _ in Canvas { context, _ in
for stroke in model.strokes { for stroke in model.strokes {
guard stroke.points.count > 1 else { continue } guard stroke.points.count > 1 else { continue }
if stroke.isRedaction {
let xs = stroke.points.map { $0.x }
let ys = stroke.points.map { $0.y }
let bar = CGRect(
x: origin.x + (xs.min()! - 2) * scale,
y: origin.y + (ys.min()! - 2) * scale,
width: ((xs.max()! - xs.min()!) + 4) * scale,
height: (max(stroke.width, ys.max()! - ys.min()!) + 4) * scale
)
context.fill(Path(bar), with: .color(.black))
} else {
var path = Path() var path = Path()
path.move(to: CGPoint(x: origin.x + stroke.points[0].x * scale, y: origin.y + stroke.points[0].y * scale)) path.move(to: CGPoint(x: origin.x + stroke.points[0].x * scale, y: origin.y + stroke.points[0].y * scale))
for point in stroke.points.dropFirst() { for point in stroke.points.dropFirst() {
@@ -523,6 +578,7 @@ struct ScreenshotPane: View {
context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width) context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width)
} }
} }
}
.allowsHitTesting(false) .allowsHitTesting(false)
// In-progress stroke // In-progress stroke
@@ -540,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,
@@ -559,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) {
@@ -578,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.0</string> <string>1.6.2</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>50</string> <string>62</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>