Compare commits

...
7 Commits
Author SHA1 Message Date
TZGyn af32a36a91 Add Copy Diagnostics menu item for multi-display debugging
Prints each NSScreen's frame/backing scale/CG display ID and each
SCDisplay's frame/point size, and copies it to the clipboard, so
secondary-display capture failures can be debugged from the menu bar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:44:56 +08:00
TZGyn 1327b61e2b Request capture pixels from the selection screen's backing scale
SCDisplay.width is in points, not pixels: the previous fix sent point
counts to SCStreamConfiguration, producing blurry main-screen captures and
failed secondary-screen captures. Multiply by the backing scale factor of
the screen containing the selection (defaulting to 1x, never forcing 2x).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:37:53 +08:00
TZGyn c9a807e8a0 Fix secondary display captures after retina scaling change
The 1.1.2 retina fix overrode the stream width from the display's pixel
dimensions, which made captures on secondary monitors come out at the
wrong scale or offset. Restore requesting the display's native pixel
resolution and keep the crop scale derived from the actual returned image,
plus guard against out-of-bounds crop rects.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:34:18 +08:00
TZGyn 7bc93c2014 Anti-alias downscaled screenshots in the result window
Full-res retina captures are downscaled to fit the window; nearest-neighbor
made them look chunky. Use high-quality interpolation when shrinking, keep
pixel-crisp rendering only when upscaling small selections.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:25:23 +08:00
TZGyn 374af50acc Capture at full retina resolution
The ScreenCaptureKit stream was sized in points at 1x scale, so retina
displays produced half-resolution captures that appeared pixelated.
Request backingScale x pixels and derive the crop scale from the actual
captured image dimensions.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:14:39 +08:00
TZGyn 1a5771ec27 Render screenshot pixels crisply in the result window
The result image was upscaled with interpolated smoothing, making small
captures look low-res. Disable interpolation so pixels stay 1:1 sharp.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:07:42 +08:00
TZGyn 234a4226c5 Add notarization to release pipeline; sign with hardened runtime
release.sh now submits the zip to Apple's notary via notarytool, staples
the ticket, and repackages, so users no longer hit Gatekeeper warnings.
build-app.sh signs Developer ID builds with the hardened runtime and
unlocks the login keychain to avoid transient signing errors. The private
API key (.p8) is gitignored.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 11:31:14 +08:00
6 changed files with 151 additions and 18 deletions
+3
View File
@@ -5,6 +5,9 @@
# App bundle (build artifact, regenerate with ./build-app.sh)
Kapture.app/
# Secrets
*.p8
# macOS
.DS_Store
+7
View File
@@ -48,6 +48,13 @@ struct MenuView: View {
Divider()
Button("Copy Diagnostics") {
let diag = CaptureController.shared.diagnostics()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(diag, forType: .string)
}
Button(LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login") {
if !LoginItem.toggle() {
let alert = NSAlert()
+53 -6
View File
@@ -10,6 +10,38 @@ final class CaptureController: NSObject {
private var previousApp: NSRunningApplication?
private var overlayIDs: [Int] = []
/// Diagnostic snapshot for "Copy Diagnostics" menu item.
func diagnostics() -> String {
var lines: [String] = []
lines.append("Kapture diagnostics")
for (i, s) in NSScreen.screens.enumerated() {
let scale = s.backingScaleFactor
let cgID = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
lines.append("screen[\(i)]: frame=\(s.frame) scale=\(scale) backingSize=\(s.frame.width*scale)x\(s.frame.height*scale) cgID=\(cgID)")
}
if #available(macOS 14.0, *) {
Task {
if let content = try? await SCShareableContent.current {
for (i, d) in content.displays.enumerated() {
lines.append("SCDisplay[\(i)]: frame=\(d.frame) width=\(d.width)x\(d.height) id=\(d.displayID)")
}
} else {
lines.append("SCShareableContent: unavailable (no Screen Recording permission?)")
}
let text = lines.joined(separator: "\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
} else {
let text = lines.joined(separator: "\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
return lines.joined(separator: "\n")
}
func begin() {
guard !isSelecting else { return }
guard PermissionManager.isGranted else {
@@ -86,8 +118,12 @@ final class ScreenCapture {
@available(macOS 14.0, *)
private func crop(_ image: CGImage, to rect: NSRect, display: SCDisplay) -> CGImage? {
let scaleX = CGFloat(display.width) / display.frame.width
let scaleY = CGFloat(display.height) / display.frame.height
// The captured image may be at any scale (retina 2x, secondary display
// 1x, or trimmed by ScreenCaptureKit). Derive the pixel scale from the
// image we actually got, relative to the display's point frame.
let scaleX = CGFloat(image.width) / display.frame.width
let scaleY = CGFloat(image.height) / display.frame.height
guard scaleX > 0, scaleY > 0 else { return nil }
let cgRect = CGRect(
x: rect.minX,
y: rect.minY,
@@ -100,14 +136,18 @@ final class ScreenCapture {
width: cgRect.width * scaleX,
height: cgRect.height * scaleY
)
// Guard against negative offsets when the selection reaches beyond the
// display's frame (e.g. secondary monitor coordinate quirks).
let safeRect = pxRect.intersection(CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
guard safeRect.width >= 1, safeRect.height >= 1 else { return nil }
guard let cropCtx = CGContext(
data: nil, width: Int(pxRect.width), height: Int(pxRect.height),
data: nil, width: Int(safeRect.width), height: Int(safeRect.height),
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
cropCtx.interpolationQuality = .high
cropCtx.draw(image, in: CGRect(x: -pxRect.minX, y: -pxRect.minY, width: CGFloat(image.width), height: CGFloat(image.height)))
cropCtx.draw(image, in: CGRect(x: -safeRect.minX, y: -safeRect.minY, width: CGFloat(image.width), height: CGFloat(image.height)))
return cropCtx.makeImage()
}
@@ -133,8 +173,15 @@ final class ScreenCapture {
let excluded = content.windows.filter { excludedSet.contains($0.windowID) }
let filter = SCContentFilter(display: display, excludingWindows: excluded)
let config = SCStreamConfiguration()
config.width = Int(display.width)
config.height = Int(display.height)
// SCDisplay.width/height are in POINTS; SCStreamConfiguration
// width/height are output PIXELS. Multiply by the backing scale
// of the screen containing the selection so retina displays are
// captured at full resolution and 1x displays stay 1x.
let backingScale = NSScreen.screens
.first(where: { $0.frame.contains(NSPoint(x: rect.midX, y: rect.midY)) })?
.backingScaleFactor ?? 1
config.width = Int(CGFloat(display.width) * backingScale)
config.height = Int(CGFloat(display.height) * backingScale)
config.showsCursor = true
guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else {
+5
View File
@@ -193,9 +193,14 @@ struct ResultView: View {
.padding(.top, 10)
if model.mode == .screenshot {
GeometryReader { geo in
let imageWidth = CGFloat(model.image.width)
Image(nsImage: NSImage(cgImage: model.image, size: .zero))
.resizable()
// Downscale with smoothing (AA); upscale keeps pixels crisp.
.interpolation(imageWidth >= geo.size.width ? .high : .none)
.aspectRatio(contentMode: .fit)
}
.border(Color.secondary.opacity(0.4))
.padding(.horizontal, 12)
} else {
+7 -5
View File
@@ -23,8 +23,10 @@ SIGN_KEYCHAIN=""
if [ -n "$DEV_IDENT" ]; then
CERT="$DEV_IDENT"
echo "Using Developer ID identity: $CERT"
# Sign from the login keychain where the Dev ID lives.
SIGN_KEYCHAIN=""
# Ensure the login keychain holding the Dev ID is unlocked & accessible.
security unlock-keychain "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" "$HOME/Library/Keychains/login.keychain-db" >/dev/null 2>&1 || true
SIGN_KEYCHAIN="$HOME/Library/Keychains/login.keychain-db"
else
CERT="kapture-dev"
echo "Using self-signed identity 'kapture-dev'"
@@ -106,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key>
<string>Icon</string>
<key>CFBundleShortVersionString</key>
<string>1.1.0</string>
<string>1.1.6</string>
<key>CFBundleVersion</key>
<string>10</string>
<string>16</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
@@ -123,7 +125,7 @@ chmod +x scripts/make-icon.sh
./scripts/make-icon.sh
if [ -n "$SIGN_KEYCHAIN" ]; then
codesign --force --sign "$CERT" --keychain "$SIGN_KEYCHAIN" "$APP"
codesign --force --options runtime --sign "$CERT" --keychain "$SIGN_KEYCHAIN" "$APP"
else
codesign --force --sign "$CERT" "$APP"
fi
+71 -2
View File
@@ -8,14 +8,83 @@ if [ "$#" -ne 1 ]; then
fi
VERSION="$1"
# Notarization credentials. Configure once, e.g. in ~/.zshrc:
# export KAPTURE_NOTARY_ASC_PROVIDER="<TeamID>"
# export KAPTURE_NOTARY_API_KEY_PATH="$HOME/.config/kapture/AuthKey_XXXX.p8"
# export KAPTURE_NOTARY_API_KEY_ID="<KeyID>"
# export KAPTURE_NOTARY_API_ISSUER="<IssuerID>"
# If these are unset (or KAPTURE_SKIP_NOTARY=1), release.sh builds, zips, and stops
# there — the zip is signed with the Developer ID identity but NOT notarized.
./build-app.sh
mkdir -p dist
rm -f dist/Kapture.zip
ditto -c -k --keepParent Kapture.app dist/Kapture.zip
shasum -a 256 dist/Kapture.zip
if [ -n "${KAPTURE_SKIP_NOTARY:-}" ]; then
echo
echo "Skipping notarization (KAPTURE_SKIP_NOTARY set)."
shasum -a 256 dist/Kapture.zip
echo
echo "Tag and release with:"
echo " git tag v$VERSION && git push origin v$VERSION"
echo "Attach dist/Kapture.zip to the GitHub release manually."
exit 0
fi
REQUIRED_VARS="KAPTURE_NOTARY_ASC_PROVIDER KAPTURE_NOTARY_API_KEY_PATH KAPTURE_NOTARY_API_KEY_ID KAPTURE_NOTARY_API_ISSUER"
MISSING=""
for v in $REQUIRED_VARS; do
if [ -z "${!v:-}" ]; then
MISSING="$MISSING $v"
fi
done
if [ -n "$MISSING" ]; then
echo "Notarization skipped — missing env var(s):$MISSING (see header comments)." >&2
shasum -a 256 dist/Kapture.zip
echo
echo "The zip above is signed but NOT notarized; Gatekeeper will warn users until it is."
echo "Set the KAPTURE_NOTARY_* variables and re-run ./release.sh $VERSION."
exit 0
fi
if ! command -v xcrun >/dev/null 2>&1 || ! xcrun --find notarytool >/dev/null 2>&1; then
echo "Notarization skipped — xcrun notarytool not found (requires Xcode 13+)." >&2
shasum -a 256 dist/Kapture.zip
exit 0
fi
echo "Submitting to Apple for notarization (first run can take minutes)..."
xcrun notarytool submit dist/Kapture.zip \
--key "$KAPTURE_NOTARY_API_KEY_PATH" \
--key-id "$KAPTURE_NOTARY_API_KEY_ID" \
--issuer "$KAPTURE_NOTARY_API_ISSUER" \
--wait \
--output-format json > notary-result.json
STATUS=$(jq -r '.status' notary-result.json 2>/dev/null || echo "unknown")
if [ "$STATUS" != "Accepted" ]; then
echo "Notarization failed with status: $STATUS" >&2
if [ -f notary-result.json ]; then
cat notary-result.json >&2
rm -f notary-result.json
fi
exit 1
fi
rm -f notary-result.json
echo "Notarization accepted — stapling ticket onto the app..."
xcrun stapler staple Kapture.app
xcrun stapler validate Kapture.app
rm -f dist/Kapture.zip
ditto -c -k --keepParent Kapture.app dist/Kapture.zip
echo
echo "Done — notarized."
shasum -a 256 dist/Kapture.zip
echo
echo "Tag and release with:"
echo " git tag v$VERSION && git push origin v$VERSION"
echo "Attach dist/Kapture.zip to the GitHub release manually."
echo "Attach dist/Kapture.zip to the GitHub release manually; the Homebrew cask is ready."