Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
# round-trip.
- name: Verify required secrets are configured
env:
SENTINEL_PROFILE: ${{ secrets.COPY_PROVISIONING_PROFILE_BASE64 }}
SENTINEL_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12_BASE64 }}
SENTINEL_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
SENTINEL_SPARKLE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
Expand All @@ -36,6 +37,7 @@ jobs:
SENTINEL_NOTARY_ISSUER_ID: ${{ secrets.NOTARY_API_ISSUER_ID }}
run: |
missing=()
[[ -z "$SENTINEL_PROFILE" ]] && missing+=("COPY_PROVISIONING_PROFILE_BASE64")
[[ -z "$SENTINEL_CERT_P12" ]] && missing+=("DEVELOPER_ID_CERT_P12_BASE64")
[[ -z "$SENTINEL_CERT_PASSWORD" ]] && missing+=("DEVELOPER_ID_CERT_PASSWORD")
[[ -z "$SENTINEL_SPARKLE_KEY" ]] && missing+=("SPARKLE_PRIVATE_KEY")
Expand Down Expand Up @@ -93,6 +95,19 @@ jobs:

rm -f "$CERT_PATH"

- name: Install Developer ID provisioning profile
env:
PROFILE_BASE64: ${{ secrets.COPY_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILE_PATH="$RUNNER_TEMP/copy.provisionprofile"
echo "$PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH"
PROFILE_PLIST="$RUNNER_TEMP/copy-profile.plist"
security cms -D -i "$PROFILE_PATH" -o "$PROFILE_PLIST"
PROFILE_UUID=$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$PROFILE_PLIST")
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.provisionprofile"
echo "COPY_PROVISIONING_PROFILE=$PROFILE_UUID" >> "$GITHUB_ENV"

- name: Write Sparkle private key
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
Expand Down
6 changes: 6 additions & 0 deletions Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,12 @@ final class AppCoordinator {
/// `AppDelegate` to reflect a checkmark on the "Paste Stack" menu item.
var isPasteStackActive: Bool { pasteStackModel.isActive }

var pasteStackSnapshot: PasteStackQueue { pasteStackModel.queue }

func replacePasteStack(with texts: [String], ifUnchanged previous: PasteStackQueue) -> Bool {
pasteStackModel.replaceWithBatch(texts, ifUnchanged: previous)
}

/// Flips the Paste Stack on or off. All activation/deactivation — the palette, the
/// CGEvent tap, and the `.pasteNextFromStack` fallback hotkey — fans out from
/// `pasteStackModel.isActive`'s `didSet`, wired to `onActiveChange` in `init()`.
Expand Down
98 changes: 98 additions & 0 deletions Copy/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,108 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
/// `applyHideMenuBarIconSetting`. `nil` means no status item exists right now.
private var statusItem: NSStatusItem?
private var coordinator: AppCoordinator!
private var handlingExternalBatch = false
private let updaterController = SPUStandardUpdaterController(startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil)

func applicationWillFinishLaunching(_ notification: Notification) {
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(pasteStackPing(_:withReplyEvent:)),
forEventClass: 0x43705374, andEventID: 0x70696e67)
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(pasteStackBatch(_:withReplyEvent:)),
forEventClass: 0x43705374, andEventID: 0x62617463)
}

@objc private func pasteStackPing(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
pasteStackReply(coordinator?.settings.allowsExternalPasteStackRequests() == true ? "capable" : "disabled", to: reply)
}

@objc private func pasteStackBatch(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
let wallTime = Date().timeIntervalSince1970
let uptime = ProcessInfo.processInfo.systemUptime
guard let payload = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject)),
payload.descriptorType == DescType(typeUTF8Text),
let descriptor = payload.aeDesc, AEGetDescDataSize(descriptor) <= 6_295_552,
let request = PasteStackBatchRequest.decode(payload.data, now: wallTime) else {
pasteStackReply("invalid", to: reply)
return
}
guard !request.isExpired(at: wallTime) else {
pasteStackReply("expired", to: reply)
return
}
pasteStackReply(acceptPasteStackBatch(request.items, caller: pasteStackCaller(from: event),
deadline: request.monotonicDeadline(wallTime: wallTime, uptime: uptime)), to: reply)
}

private func pasteStackReply(_ status: String, to reply: NSAppleEventDescriptor) {
let data = Data("{\"version\":1,\"status\":\"\(status)\"}".utf8)
guard let value = NSAppleEventDescriptor(descriptorType: DescType(typeUTF8Text), data: data) else { return }
reply.setParam(value, forKeyword: AEKeyword(keyDirectObject))
}

func requestPasteStackBatch(_ texts: [String]) -> Bool {
acceptPasteStackBatch(texts, caller: nil) == "accepted"
}

private func acceptPasteStackBatch(_ texts: [String], caller: PasteStackCaller?,
deadline: TimeInterval? = nil) -> String {
func expired() -> Bool {
deadline.map { ProcessInfo.processInfo.systemUptime >= $0 } ?? false
}
guard !expired() else { return "expired" }
guard !handlingExternalBatch, coordinator != nil else { return "busy" }
guard coordinator.settings.allowsExternalPasteStackRequests() else { return "disabled" }
var probe = PasteStackQueue()
guard probe.replaceWithBatch(texts) else { return "invalid" }
handlingExternalBatch = true
defer { handlingExternalBatch = false }
while !expired() {
let trusted = caller.map { caller in
coordinator.settings.pasteStackPermissions.contains { $0.permits(caller) }
} ?? false
let previous = coordinator.pasteStackSnapshot
var rememberPermission: PasteStackPermission?
if !trusted || !previous.isEmpty {
let signed: PasteStackPermission?
if let caller, let signingID = caller.signingID, let certificate = caller.certificateSHA256 {
signed = .signed(bundleID: caller.bundleID, signingID: signingID, certificateSHA256: certificate)
} else {
signed = nil
}
let alert = pasteStackBatchConfirmation(texts, hideSecrets: coordinator.settings.hideSecrets,
hideDuringScreenSharing: coordinator.settings.hideDuringScreenSharing,
caller: pasteStackCallerDescription(caller),
canRemember: !trusted && signed != nil,
hasExistingItems: !previous.isEmpty)
NSApp.activate(ignoringOtherApps: true)
let decision = alert.runModal()
guard decision == .alertSecondButtonReturn || (signed != nil && decision == .alertThirdButtonReturn) else {
return "cancelled"
}
guard !expired() else { return "expired" }
if decision == .alertThirdButtonReturn { rememberPermission = signed }
}
guard !expired() else { return "expired" }
guard coordinator.settings.allowsExternalPasteStackRequests() else { return "disabled" }
if trusted, let caller,
!coordinator.settings.pasteStackPermissions.contains(where: { $0.permits(caller) }) { continue }
guard coordinator.replacePasteStack(with: texts, ifUnchanged: previous) else { continue }
if let permission = rememberPermission,
!coordinator.settings.pasteStackPermissions.contains(permission) {
if !coordinator.settings.setPasteStackPermissions(coordinator.settings.pasteStackPermissions + [permission]) {
let alert = NSAlert()
alert.messageText = "Could not remember this app"
alert.informativeText = "The request was accepted, but Keychain access was unavailable. Future requests will ask for approval."
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
return "accepted"
}
return "expired"
}

func applicationDidFinishLaunching(_ notification: Notification) {
do {
coordinator = try AppCoordinator()
Expand Down
12 changes: 12 additions & 0 deletions Copy/Copy.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.application-identifier</key>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
</array>
</dict>
</plist>
2 changes: 2 additions & 0 deletions Copy/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Copy</string>
<key>CopyPasteStackProtocolVersion</key>
<integer>1</integer>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
Expand Down
19 changes: 19 additions & 0 deletions Copy/Intents/CopyShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,22 @@ struct CopyShortcuts: AppShortcutsProvider {
)
}
}

struct AddTextToPasteStackIntent: AppIntent {
static var title: LocalizedStringResource = "Add Text to Paste Stack"
static var description: IntentDescription? = IntentDescription(
"Requests permission to replace the Paste Stack with text items.")
static var openAppWhenRun = true

@Parameter(title: "Text Items")
var texts: [String]

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
guard let delegate = NSApp.delegate as? AppDelegate,
delegate.requestPasteStackBatch(texts) else {
return .result(dialog: "The Paste Stack was not changed.")
}
return .result(dialog: "Added text to Paste Stack.")
}
}
38 changes: 38 additions & 0 deletions Copy/PasteStack/PasteStackAccessKeychain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import CopyCore
import Foundation
import Security

struct PasteStackAccessKeychain {
private static let service = "com.tarikbc.Copy.paste-stack-access"
private static let account = "external-apps"

private static var query: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true]
}

static func read() -> PasteStackExternalAccess.State? {
var query = query
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return try? JSONDecoder().decode(PasteStackExternalAccess.State.self, from: data)
}

static func write(_ state: PasteStackExternalAccess.State) -> Bool {
guard let data = try? JSONEncoder().encode(state) else { return false }
let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if status == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
return SecItemAdd(item as CFDictionary, nil) == errSecSuccess
}
return status == errSecSuccess
}
}
123 changes: 123 additions & 0 deletions Copy/PasteStack/PasteStackBatchConfirmation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import AppKit
import CopyCore
import SwiftUI

func pasteStackCallerDescription(_ caller: PasteStackCaller?) -> String? {
guard let caller else { return nil }
let verified = caller.signingID?.isEmpty == false && caller.certificateSHA256?.isEmpty == false
let description = String((caller.bundleID + " (" + caller.canonicalPath + ")").prefix(500))
let safe = description.unicodeScalars.map { scalar in
CharacterSet.controlCharacters.contains(scalar) || scalar.properties.generalCategory == .format
? String(format: "⟦U+%04X⟧", scalar.value) : String(scalar)
}.joined()
return verified ? safe : "Unverified app: " + safe
}

private let batchPreviewLimit = 2_000
private let secretScanLimit = 250_000

private func batchPreviewRanges(_ text: String) -> [NSRange] {
let source = text as NSString
var ranges: [NSRange] = []
var start = 0
while start < source.length {
var end = min(start + batchPreviewLimit, source.length)
if end < source.length, (0xD800...0xDBFF).contains(source.character(at: end - 1)),
(0xDC00...0xDFFF).contains(source.character(at: end)) {
end -= 1
}
ranges.append(NSRange(location: start, length: end - start))
start = end
}
return ranges.isEmpty ? [NSRange(location: 0, length: 0)] : ranges
}

func pasteStackPreviewPageCount(_ text: String) -> Int { batchPreviewRanges(text).count }

func pasteStackPreviewText(_ text: String, hideSecrets: Bool, revealed: Bool, page: Int = 0) -> String {
let ranges = batchPreviewRanges(text)
guard ranges.indices.contains(page) else { return "" }
let oversized = (text as NSString).length > secretScanLimit
if hideSecrets && !revealed && oversized {
return "This item is too long to scan for secrets. Select Show content to review it."
}
let display = hideSecrets && !revealed ? SecretDetector.masked(text) : text
let chunk = (display as NSString).substring(with: ranges[page])
return chunk.unicodeScalars.map { scalar in
if CharacterSet.controlCharacters.contains(scalar) || scalar.properties.generalCategory == .format {
return String(format: "⟦U+%04X⟧", scalar.value)
}
return String(scalar)
}.joined()
}

struct BatchReviewList: View {
let texts: [String]
let hideSecrets: Bool
@State private var revealed: Set<Int> = []
@State private var pages: [Int: Int] = [:]

var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(texts.indices, id: \.self) { index in
let text = texts[index]
let oversized = (text as NSString).length > secretScanLimit
let hasSecret = hideSecrets && (oversized || SecretDetector.masked(text) != text)
let total = pasteStackPreviewPageCount(text)
let page = min(pages[index] ?? 0, total - 1)
VStack(alignment: .leading, spacing: 5) {
HStack {
Text("Item \(index + 1)").font(.caption.weight(.semibold))
Spacer()
if hasSecret {
Button(revealed.contains(index) ? "Hide content" : "Show content") {
if revealed.contains(index) { revealed.remove(index) }
else { revealed.insert(index) }
}
.buttonStyle(.link)
}
}
Text(verbatim: pasteStackPreviewText(text, hideSecrets: hideSecrets,
revealed: revealed.contains(index), page: page))
.textSelection(.enabled)
if total > 1 && (!oversized || !hideSecrets || revealed.contains(index)) {
HStack {
Button("Previous") { pages[index] = page - 1 }.disabled(page == 0)
Text("Page \(page + 1) of \(total)").font(.caption)
Button("Next") { pages[index] = page + 1 }.disabled(page + 1 == total)
}
.controlSize(.small)
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
}
}
.padding(4)
}
.frame(width: 440, height: 220)
}
}

@MainActor
func pasteStackBatchConfirmation(_ texts: [String], hideSecrets: Bool, hideDuringScreenSharing: Bool,
caller: String? = nil, canRemember: Bool = false,
hasExistingItems: Bool = true) -> NSAlert {
let alert = NSAlert()
alert.messageText = "Add items to Paste Stack?"
let source = caller ?? "An action with an unverified origin"
let action = hasExistingItems ? "replace your Paste Stack with" : "add to your Paste Stack"
alert.informativeText = "\(source) wants to \(action) \(texts.count) text items. Review the items below."
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: hasExistingItems ? "Replace Stack" : "Allow Once")
if canRemember { alert.addButton(withTitle: "Allow and Remember") }
alert.buttons[0].keyEquivalent = "\r"
for button in alert.buttons.dropFirst() { button.keyEquivalent = "" }
let preview = NSHostingView(rootView: BatchReviewList(texts: texts, hideSecrets: hideSecrets))
preview.frame = NSRect(x: 0, y: 0, width: 440, height: 220)
alert.accessoryView = preview
alert.window.sharingType = hideDuringScreenSharing ? .none : .readOnly
return alert
}
Loading