目录

Sticker - macOS 桌面贴纸应用

一、背景

在日常工作中,我经常需要记录一些临时的信息:

  • 一段需要重复使用的代码片段
  • 一个常用的命令
  • 某个网页的 URL
  • 临时的想法或待办事项

使用传统的便签应用有个问题:它们是独立的窗口,会遮挡其他内容,而且无法"贴"在屏幕上任意位置。

于是我开发了一个 macOS 桌面贴纸应用 —— Sticker。它像真正的便利贴一样"悬浮"在桌面上,可以随时调用,不占用 Dock 栏空间。

二、功能特性

1. 快捷键呼出

  • 默认 Option + Tab:呼出贴纸面板(松开)
  • 默认 Option + N:快速创建新贴纸

2. 贴纸类型

  • 文字贴纸:支持多行文本,点击自动复制到剪贴板
  • 图片贴纸:支持截图粘贴,右下角带缩放手柄

3. 智能显示规则

贴纸可以设置可见范围:

  • 全局:所有应用都显示
  • 浏览器:仅在 Safari/Chrome 中显示
  • 特定应用:仅在指定 App 中显示
  • 特定域名:仅在指定网站显示
  • 特定网页:仅在指定 URL 显示

4. 资源管理

  • 支持自定义资源文件夹位置
  • 菜单栏快速打开资源文件夹
  • 自动检测辅助功能权限

三、架构设计

Sticker/
├── Models/           # 数据模型
│   ├── Sticker.swift
│   ├── StickerContext.swift
│   └── StickerScope.swift
├── Store/            # 数据存储
│   ├── StickerStore.swift
│   ├── StickerIndex.swift
│   └── ImageStore.swift
├── Components/       # UI 组件
│   └── CommondTool/
│       ├── PanelController.swift
│       └── StickerCommandView.swift
├── Overlay/          # 悬浮窗口
│   ├── OverlayWindowController.swift
│   └── StickerView.swift
└── Utils/            # 工具类
    ├── HotKeyManager.swift
    └── PermissionUtils.swift

四、核心实现

1. 全局快捷键监听

使用 CGEvent.tapCreate 创建底层事件监听,而不是使用 NSEvent.globalMonitor。这样可以捕获 Option + Tab 这种系统级快捷键:

func start() {
    let mask =
        CGEventMask(1 << CGEventType.keyDown.rawValue) |
        CGEventMask(1 << CGEventType.keyUp.rawValue) |
        CGEventMask(1 << CGEventType.flagsChanged.rawValue)

    eventTap = CGEvent.tapCreate(
        tap: .cgSessionEventTap,
        place: .headInsertEventTap,
        options: .defaultTap,
        eventsOfInterest: mask,
        callback: { _, type, event, userInfo in
            let manager = Unmanaged<HotKeyManager>
                .fromOpaque(userInfo!)
                .takeUnretainedValue()

            let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
            let flags = event.flags

            // Option + Tab 按下
            if flags.contains(.maskAlternate) && keyCode == 48 && type == .keyDown {
                if !manager.isPressed {
                    manager.isPressed = true
                    manager.onKeyDown?()
                }
                return nil
            }

            // 松开处理
            if manager.isPressed {
                let optionReleased = type == .flagsChanged && !flags.contains(.maskAlternate)
                let tabReleased = type == .keyUp && keyCode == 48

                if optionReleased || tabReleased {
                    manager.isPressed = false
                    manager.onKeyUp?()
                }
            }

            return Unmanaged.passUnretained(event)
        },
        userInfo: UnsafeMutableRawPointer(
            Unmanaged.passUnretained(self).toOpaque()
        )
    )
}

关键点

  • 使用 cgSessionEventTap 捕获全局事件
  • 通过 userInfo 传递 self 引用,避免全局变量
  • isPressed 防止 keyDown 连续触发

2. 悬浮窗口系统

使用 NSPanel 实现悬浮贴纸,关键点:

  • level = .floating:保持在最前
  • collectionBehavior = [.canJoinAllSpaces]:跨桌面显示
  • 使用归一化坐标存储位置,适配多屏幕
struct StickerView: View {
    @Binding var sticker: Sticker
    @Binding var draggingID: UUID?

    var body: some View {
        contentView
            .padding(12)
            .background(.ultraThinMaterial)
            .cornerRadius(12)
            .position(pixelPosition)
            .gesture(dragGesture)
    }

    private var pixelPosition: CGPoint {
        CGPoint(
            x: sticker.normalizedPosition.x * screenSize.width,
            y: sticker.normalizedPosition.y * screenSize.height
        )
    }
}

3. 智能上下文检测

使用 AppleScript 获取当前浏览器 URL,支持域名/网页级贴纸:

func buildCurrentContextAsync(
    completion: @escaping (StickerContext) -> Void
) {
    // 1. 同步获取前台 App 信息
    let frontApp = NSWorkspace.shared.frontmostApplication
    let bundleId = frontApp?.bundleIdentifier

    // 2. 非浏览器直接返回
    guard isBrowser(bundleId) else {
        completion(StickerContext(bundleId: bundleId, isBrowser: false, domain: nil, url: nil))
        return
    }

    // 3. 后台线程获取 URL(AppleScript 可能阻塞)
    DispatchQueue.global(qos: .userInitiated).async {
        let urlString = bundleId == "com.apple.Safari"
            ? safariUrl()
            : chromeUrl()

        let context = StickerContext(
            bundleId: bundleId,
            isBrowser: true,
            domain: urlString.flatMap { extractDomain(from: $0) },
            url: urlString
        )
        DispatchQueue.main.async {
            completion(context)
        }
    }
}

4. 贴纸作用域索引

使用倒排索引快速查找可见贴纸:

final class StickerIndex {
    var global: Set<UUID> = []
    var browser: Set<UUID> = []
    var byApp: [String: Set<UUID>] = [:]
    var byDomain: [String: Set<UUID>] = [:]
    var byUrl: [String: Set<UUID>] = [:]

    func index(_ sticker: Sticker) {
        for scope in sticker.scopes {
            switch scope {
            case .global:
                global.insert(sticker.id)
            case .browser:
                browser.insert(sticker.id)
            case .app(bundleId: let bundleId):
                byApp[bundleId, default: []].insert(sticker.id)
            case .browserDomain(domain: let domain):
                byDomain[domain, default: []].insert(sticker.id)
            case .browserUrl(url: let url):
                byUrl[url, default: []].insert(sticker.id)
            }
        }
    }
}

5. 快速创建面板

Option + N 呼出创建面板,支持:

  • 直接粘贴图片
  • 输入文字
  • 选择作用域
final class PanelController: NSObject {
    static let shared = PanelController()

    func toggle(on screen: NSScreen, initialText: String?) {
        if isVisible {
            hide()
        } else {
            show(screen: screen, initialText: initialText)
        }
    }

    func show(screen: NSScreen, initialText: String?) {
        // 获取当前 App 信息
        if let info = getCurrentAppInfo() {
            currentAppBundleId = info.bundleId
            currentAppName = info.name
        }

        // 获取上下文(域名/URL)
        buildCurrentContextAsync { context in
            self.context = context
            // 动态构建作用域选项
            self.actions = [
                .init(title: "添加到 全局", scope: .global),
                .init(title: "添加到 浏览器", scope: .browser),
                .init(title: "添加到 当前应用", scope: .app),
                // 如果在浏览器中,添加域名/网页选项
            ]
            // ...
        }
    }
}

五、其他要点

1. 辅助功能权限检测

func hasAccessibilityPermission() -> Bool {
    AXIsProcessTrusted()
}

func showAccessibilityAlert() {
    let alert = NSAlert()
    alert.messageText = "需要辅助功能权限"
    alert.informativeText = """
        Sticker 需要辅助功能权限才能监听快捷键和显示贴纸。
        请在系统设置中开启后,重新启动应用。
    """
    // ...
}

2. 不显示 Dock 图标

func applicationDidFinishLaunching(_ notification: Notification) {
    // 设置为 accessory 策略,不显示 Dock 图标
    NSApp.setActivationPolicy(.accessory)
}

3. 图片存储

使用 ImageStore 统一管理图片资源:

final class ImageStore {
    static let shared = ImageStore()

    func save(image: NSImage) -> ImagePayload {
        let filename = UUID().uuidString + ".png"
        let url = imageURL(for: filename)

        if let tiff = image.tiffRepresentation,
           let rep = NSBitmapImageRep(data: tiff),
           let png = rep.representation(using: .png, properties: [:]) {
            try? png.write(to: url)
        }

        return ImagePayload(filename: filename, size: image.size)
    }
}

六、使用流程

  1. 启动应用:菜单栏出现状态图标
  2. 呼出贴纸Option + Tab
  3. 创建贴纸Option + N,输入内容或粘贴图片
  4. 拖拽贴纸:按住贴纸可拖动,拖到屏幕底部删除条可删除
  5. 复制内容:点击贴纸自动复制到剪贴板

七、总结

Sticker 是一个轻量级的桌面效率工具,主要特点:

  1. 无感存在:不占用 Dock,不显示独立窗口
  2. 即按即现:快捷键呼出,用完即走
  3. 智能显示:根据应用/网页自动筛选可见贴纸
  4. 支持图片:可直接粘贴截图,自动缩放

技术栈:Swift 5, SwiftUI, AppKit, CGEvent