Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions extensions/quick-contact-actions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules

# Raycast specific files
raycast-env.d.ts
.raycast-swift-build
.swiftpm
compiled_raycast_swift
compiled_raycast_rust

# compiled Swift binary (rebuilt via prebuild)
assets/get-contacts

# misc
.DS_Store

# Claude Code
.claude/
CLAUDE.md
4 changes: 4 additions & 0 deletions extensions/quick-contact-actions/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"printWidth": 120,
"singleQuote": false
}
15 changes: 15 additions & 0 deletions extensions/quick-contact-actions/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Quick Contact Actions Changelog

## [Initial Version] - {PR_MERGE_DATE}

- Search contacts with custom scoring (name, phone digits, email)
- Quick actions: FaceTime Video/Audio, Phone, Message, Email
- Detail side panel with profile picture and organization name
- Circular photo (from Swift) or colored initials (SVG) in detail pane
- Favorites section with pin/unpin support
- Frequently contacted section (top 5)
- Phonebook-style alphabetical sections
- Open and edit contacts in Contacts.app via AppleScript
- Contact argument for quick launch (auto-navigate on unique match)
- Copy actions for name, phone, and email
- Keyboard shortcuts for all actions
35 changes: 35 additions & 0 deletions extensions/quick-contact-actions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Quick Contact Actions

Search your macOS contacts and launch calls, messages, and emails instantly from Raycast.

## Features

- **Spotlight-style search** with smart scoring — matches by name, phone digits, or email
- **Quick actions** — FaceTime Video/Audio, Phone Call, iMessage, Email
- **Detail panel** (⌘D) with profile picture, name, and organization
- **Favorites** (⌘⇧S) — pin contacts to the top
- **Frequently contacted** — your top 5 most-used contacts appear automatically
- **Open/Edit in Contacts** — jump straight to the contact in Contacts.app
- **Contact argument** — type `qca ana` to auto-navigate if there's a unique match

## Keyboard Shortcuts

| Shortcut | Action |
|----------|--------|
| ⌘⇧F | FaceTime Video |
| ⌘⇧A | FaceTime Audio |
| ⌘⇧C | Call |
| ⌘M | Send Message |
| ⌘E | Send Email |
| ⌘O | Open in Contacts |
| ⌘⇧O | Edit in Contacts |
| ⌘D | Toggle Detail Panel |
| ⌘⇧S | Toggle Favorite |
| ⌘. | Copy Name |
| ⌘⇧. | Copy Phone Number |
| ⌘⇧E | Copy Email |

## Permissions

- **Contacts** — required to read your contacts. macOS will prompt automatically on first use.
- **Accessibility** — required for "Edit in Contacts" (sends ⌘L keystroke to Contacts.app via System Events). macOS will prompt when first used.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
131 changes: 131 additions & 0 deletions extensions/quick-contact-actions/assets/get-contacts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import Foundation
import Contacts
import CoreGraphics
import ImageIO

func makeCircularImage(from data: Data, size: Int) -> Data? {
let s = CGFloat(size)
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil),
let ctx = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8,
bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
let rect = CGRect(x: 0, y: 0, width: s, height: s)
ctx.addEllipse(in: rect)
ctx.clip()
ctx.draw(cgImage, in: rect)
guard let clipped = ctx.makeImage() else { return nil }
let outData = NSMutableData()
guard let destFinal = CGImageDestinationCreateWithData(outData, "public.png" as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(destFinal, clipped, nil)
guard CGImageDestinationFinalize(destFinal) else { return nil }
return outData as Data
}

// Handle open/edit mode — reveal contact in Contacts.app
if CommandLine.arguments.count > 2 && (CommandLine.arguments[1] == "--open" || CommandLine.arguments[1] == "--edit") {
let isEdit = CommandLine.arguments[1] == "--edit"
let contactId = CommandLine.arguments[2]
let openStore = CNContactStore()
do {
let keys = [CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor]
let contact = try openStore.unifiedContact(withIdentifier: contactId, keysToFetch: keys)
let name = "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespaces)
let escaped = name.replacingOccurrences(of: "\"", with: "\\\"")
var script = "tell application \"Contacts\"\nset thePeople to every person whose name is \"\(escaped)\"\nif (count of thePeople) > 0 then\nset selection to item 1 of thePeople\nend if\nactivate\nend tell"
if isEdit {
script += "\ndelay 0.1\ntell application \"System Events\" to keystroke \"l\" using command down"
}
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
proc.arguments = ["-e", script]
try proc.run()
proc.waitUntilExit()
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}
exit(0)
}

// Image cache dir passed as first argument (environment.supportPath from Raycast)
let imageDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : NSTemporaryDirectory()
let imageDirURL = URL(fileURLWithPath: imageDir, isDirectory: true)
try? FileManager.default.createDirectory(at: imageDirURL, withIntermediateDirectories: true)

let store = CNContactStore()
let keysToFetch: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactImageDataAvailableKey as CNKeyDescriptor,
CNContactThumbnailImageDataKey as CNKeyDescriptor,
]

let request = CNContactFetchRequest(keysToFetch: keysToFetch)
request.sortOrder = .givenName
var contacts: [[String: Any]] = []

do {
Comment on lines +71 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 AppleScript injection via unescaped newlines in contact name

The Swift code escapes double quotes in the contact name before embedding it in the AppleScript string, but does not escape newline characters (\n). A contact whose givenName or familyName contains a literal newline (which CNContactStore does not prohibit) would break out of the AppleScript string and allow arbitrary AppleScript injection.

At minimum, newlines should also be stripped or escaped:

let escaped = name
    .replacingOccurrences(of: "\\", with: "\\\\")
    .replacingOccurrences(of: "\"", with: "\\\"")
    .replacingOccurrences(of: "\n", with: " ")
    .replacingOccurrences(of: "\r", with: " ")
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/quick-contact-actions/assets/get-contacts.swift
Line: 71-72

Comment:
**AppleScript injection via unescaped newlines in contact name**

The Swift code escapes double quotes in the contact name before embedding it in the AppleScript string, but does not escape newline characters (`\n`). A contact whose `givenName` or `familyName` contains a literal newline (which `CNContactStore` does not prohibit) would break out of the AppleScript string and allow arbitrary AppleScript injection.

At minimum, newlines should also be stripped or escaped:
```swift
let escaped = name
    .replacingOccurrences(of: "\\", with: "\\\\")
    .replacingOccurrences(of: "\"", with: "\\\"")
    .replacingOccurrences(of: "\n", with: " ")
    .replacingOccurrences(of: "\r", with: " ")
```

How can I resolve this? If you propose a fix, please make it concise.

try store.enumerateContacts(with: request) { contact, _ in
guard !contact.phoneNumbers.isEmpty || !contact.emailAddresses.isEmpty else { return }

let firstName = contact.givenName
let lastName = contact.familyName
let org = contact.organizationName
var name = "\(firstName) \(lastName)".trimmingCharacters(in: .whitespaces)
if name.isEmpty { name = org }
if name.isEmpty { return }

let phones = contact.phoneNumbers.map { labeled -> [String: String] in
let label = labeled.label.flatMap { CNLabeledValue<NSString>.localizedString(forLabel: $0) } ?? "Phone"
return ["label": label, "value": labeled.value.stringValue]
}

let emails = contact.emailAddresses.map { labeled -> [String: String] in
let label = labeled.label.flatMap { CNLabeledValue<NSString>.localizedString(forLabel: $0) } ?? "Email"
return ["label": label, "value": labeled.value as String]
}

var entry: [String: Any] = ["id": contact.identifier, "name": name, "phones": phones, "emails": emails]

// Include organization only when the contact has a real first/last name (org is supplementary)
if !firstName.isEmpty || !lastName.isEmpty, !org.isEmpty {
entry["organization"] = org
}

// Save thumbnail to disk and return the path
if contact.imageDataAvailable, let imageData = contact.thumbnailImageData {
let safeId = contact.identifier.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
let imageURL = imageDirURL.appendingPathComponent("\(safeId).jpg")
if !FileManager.default.fileExists(atPath: imageURL.path) {
try? imageData.write(to: imageURL)
}
entry["imagePath"] = imageURL.path

// Circular crop for detail pane
let circleURL = imageDirURL.appendingPathComponent("\(safeId)_circle.png")
if !FileManager.default.fileExists(atPath: circleURL.path),
let circleData = makeCircularImage(from: imageData, size: 160) {
try? circleData.write(to: circleURL)
}
if FileManager.default.fileExists(atPath: circleURL.path) {
entry["circleImagePath"] = circleURL.path
}
}

contacts.append(entry)
}
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}

if let data = try? JSONSerialization.data(withJSONObject: contacts),
let json = String(data: data, encoding: .utf8)
{
print(json)
}
6 changes: 6 additions & 0 deletions extensions/quick-contact-actions/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const { defineConfig } = require("eslint/config");
const raycastConfig = require("@raycast/eslint-config");

module.exports = defineConfig([
...raycastConfig,
]);
Loading
Loading