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)
}
}六、使用流程
- 启动应用:菜单栏出现状态图标
- 呼出贴纸:
Option + Tab - 创建贴纸:
Option + N,输入内容或粘贴图片 - 拖拽贴纸:按住贴纸可拖动,拖到屏幕底部删除条可删除
- 复制内容:点击贴纸自动复制到剪贴板
七、总结
Sticker 是一个轻量级的桌面效率工具,主要特点:
- 无感存在:不占用 Dock,不显示独立窗口
- 即按即现:快捷键呼出,用完即走
- 智能显示:根据应用/网页自动筛选可见贴纸
- 支持图片:可直接粘贴截图,自动缩放
技术栈:Swift 5, SwiftUI, AppKit, CGEvent