.fromOpaque(context).takeUnretainedValue()
+ return owner.handleInput(type, event: event) ? nil : Unmanaged.passUnretained(event)
+ }, userInfo: Unmanaged.passUnretained(self).toOpaque()),
+ let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { return false }
+ inputTap = tap; inputSource = source
+ CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
+ let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in self?.recoverInput() }
+ inputTimer = timer; RunLoop.main.add(timer, forMode: .common)
+ }
+ heldKeys = Set((0..<128).compactMap { key -> CGKeyCode? in
+ guard !(54...63).contains(key), CGEventSource.keyState(.combinedSessionState, key: CGKeyCode(key)) else { return nil }
+ return CGKeyCode(key)
+ }).union(swallowedKeys)
+ let flags = CGEventSource.flagsState(.combinedSessionState).intersection(modifierMask)
+ triggerDown = isTriggerHeld(flags)
+ // Never claim a gesture already in progress while settings are applied.
+ foreignKey = triggerDown
+ inputActive = true
+ if let tap = inputTap { CGEvent.tapEnable(tap: tap, enable: true) }
+ return hotkeyReady
+ }
+ func isTriggerHeld(_ flags: CGEventFlags) -> Bool {
+ flags.contains(wheelFlags) && (modifierOnly != 0 || heldKeys.contains(CGKeyCode(wheelKeyCode)))
+ }
+ func updateTrigger(_ flags: CGEventFlags, at point: CGPoint) {
+ let held = isTriggerHeld(flags)
+ if !held {
+ let confirm = triggerDown && gesture == .wheel && !foreignKey && inputActive && enabled
+ triggerDown = false
+ if confirm { finishPicker(at: point) } else { closePicker() }
+ gesture = .idle; foreignKey = false
+ } else {
+ if flags != wheelFlags || heldKeys.contains(where: {
+ !swallowedKeys.contains($0) && (modifierOnly != 0 || $0 != CGKeyCode(wheelKeyCode))
+ }) { foreignKey = true }
+ if foreignKey { cancelPickerGesture() }
+ else if !triggerDown && inputActive && enabled && !recordingShortcut {
+ gesture = .wheel; lastMouseActivity = ProcessInfo.processInfo.systemUptime
+ showPicker(at: point)
+ }
+ triggerDown = true
+ }
+ }
+ func cancelPickerGesture() {
+ if gesture == .wheel { gesture = .cancelled }
+ closePicker()
+ }
+ func recoverInput() {
+ guard let tap = inputTap else { return }
+ if !CGEvent.tapIsEnabled(tap: tap) {
+ cancelPickerGesture(); CGEvent.tapEnable(tap: tap, enable: true)
+ }
+ if gesture == .wheel, ProcessInfo.processInfo.systemUptime - lastMouseActivity > 30 { cancelPickerGesture() }
+ heldKeys = heldKeys.filter { swallowedKeys.contains($0) || CGEventSource.keyState(.combinedSessionState, key: $0) }
+ // Recovery may cancel a lost release, but must never confirm it.
+ if triggerDown && !isTriggerHeld(CGEventSource.flagsState(.combinedSessionState).intersection(modifierMask)) {
+ cancelPickerGesture(); triggerDown = false; foreignKey = false; gesture = .idle
+ }
+ disposeInputIfIdle()
+ }
+ func handleInput(_ type: CGEventType, event: CGEvent) -> Bool {
+ if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
+ cancelPickerGesture()
+ if let tap = inputTap { CGEvent.tapEnable(tap: tap, enable: true) }
+ return false
+ }
+ let flags = event.flags.intersection(modifierMask)
+ let key = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode))
+ if [.leftMouseDown, .leftMouseUp, .leftMouseDragged, .mouseMoved, .scrollWheel].contains(type) {
+ lastMouseActivity = ProcessInfo.processInfo.systemUptime
+ }
+ if type == .keyUp {
+ heldKeys.remove(key); updateTrigger(flags, at: event.location)
+ let swallow = swallowedKeys.remove(key) != nil
+ DispatchQueue.main.async { [weak self] in self?.disposeInputIfIdle() }
+ return swallow
+ }
+ if type == .rightMouseUp && swallowRightUp { swallowRightUp = false; disposeInputIfIdle(); return true }
+ if type == .leftMouseUp && swallowLeftUp { swallowLeftUp = false; disposeInputIfIdle(); return true }
+ guard inputActive, enabled, !recordingShortcut else { disposeInputIfIdle(); return false }
+ if type == .keyDown {
+ if event.getIntegerValueField(.keyboardEventAutorepeat) == 0 { swallowedKeys.remove(key) }
+ if key == CGKeyCode(kVK_Escape), gesture == .wheel {
+ swallowedKeys.insert(key); foreignKey = true; cancelPickerGesture(); return true
+ }
+ heldKeys.insert(key); updateTrigger(flags, at: event.location)
+ if swallowedKeys.contains(key) { return true }
+ if modifierOnly == 0, key == CGKeyCode(wheelKeyCode), flags == wheelFlags, !foreignKey {
+ swallowedKeys.insert(key); return true
+ }
+ return false
+ }
+ updateTrigger(flags, at: event.location)
+ if type == .rightMouseDown && gesture == .wheel {
+ swallowRightUp = true; foreignKey = true; cancelPickerGesture(); return true
+ }
+ if type == .leftMouseDown && gesture == .wheel { swallowLeftUp = true; return true }
+ if type == .scrollWheel && gesture == .wheel {
+ let delta = event.getIntegerValueField(.scrollWheelEventDeltaAxis1)
+ if delta != 0 { wheel?.turnPage(delta < 0 ? 1 : -1) }
+ return true
+ }
+ if (type == .mouseMoved || type == .leftMouseDragged) && gesture == .wheel { updatePicker(at: event.location) }
+ return false
+ }
+ func setRecording(_ active: Bool) -> [String: Any] {
+ recordingTimer?.invalidate()
+ recordingTimer = nil
+ closePicker()
+ unregisterWheel()
+ recordingShortcut = active
+ var issues: [String] = []
+ if active {
+ // Recover even if the settings page closes before sending its finish message.
+ let timer = Timer(timeInterval: 30, repeats: false) { [weak self] _ in _ = self?.setRecording(false) }
+ recordingTimer = timer
+ RunLoop.main.add(timer, forMode: .common)
+ } else if enabled && !registerWheel() { issues.append(AXIsProcessTrusted() ? "INPUT_HOOK_FAILED" : "INPUT_ACCESS_REQUIRED") }
+ return ["issues": issues]
+ }
+ func content(_ emote: [String: Any], frame: NSRect) -> NSView {
+ if let id = emote["id"] as? String, let image = images[id] {
+ let view = NSImageView(frame: frame)
+ view.image = image
+ view.imageScaling = .scaleProportionallyUpOrDown
+ view.animates = true
+ return view
+ }
+ let label = NSTextField(labelWithString: emote["emoji"] as? String ?? "🙂")
+ label.frame = frame
+ label.font = .systemFont(ofSize: frame.height * 0.7)
+ label.alignment = .center
+ if ["❓", "?", "?"].contains(emote["emoji"] as? String ?? "") {
+ let shadow = NSShadow()
+ shadow.shadowColor = NSColor.black.withAlphaComponent(0.8)
+ shadow.shadowBlurRadius = 5
+ label.attributedStringValue = NSAttributedString(string: "?", attributes: [
+ .font: NSFont.systemFont(ofSize: frame.height * 0.8, weight: .heavy),
+ .foregroundColor: NSColor(calibratedRed: 1, green: 0.79, blue: 0.25, alpha: 1),
+ .strokeColor: NSColor(calibratedRed: 0.28, green: 0.16, blue: 0.04, alpha: 1),
+ .strokeWidth: -3, .shadow: shadow])
+ label.alignment = .center
+ }
+ return label
+ }
+ func trigger(_ index: Int, at point: NSPoint, gestureId: String, optionIndex: Int?) {
+ guard enabled, emotes.indices.contains(index) else { return }
+ let emote = emotes[index]
+ closePicker()
+ // No accessibility queries, capture, IPC or model work precedes display.
+ // Extra transparent room keeps the spring overshoot and ripple unclipped.
+ let overlay = Overlay(frame: NSRect(x: point.x - 72, y: point.y - 36, width: 144, height: 144), interactive: false)
+ let stage = overlay.contentView!
+ stage.wantsLayer = true
+ let icon = content(emote, frame: NSRect(x: 16, y: 16, width: 112, height: 112))
+ stage.addSubview(icon)
+ let reducedMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+ if !reducedMotion {
+ for delay in [0.0, 0.11] {
+ let ripple = CAShapeLayer()
+ ripple.bounds = CGRect(x: 0, y: 0, width: 66, height: 23)
+ ripple.position = CGPoint(x: 72, y: 22)
+ ripple.path = CGPath(ellipseIn: ripple.bounds.insetBy(dx: 1, dy: 1), transform: nil)
+ ripple.fillColor = nil
+ ripple.strokeColor = NSColor(calibratedRed: 0.80, green: 0.64, blue: 0.32, alpha: 1).cgColor
+ ripple.lineWidth = delay == 0 ? 2 : 1
+ ripple.opacity = 0
+ stage.layer?.insertSublayer(ripple, at: 0)
+ let spread = CABasicAnimation(keyPath: "transform.scale")
+ spread.fromValue = 0.15; spread.toValue = 1.9
+ let fade = CAKeyframeAnimation(keyPath: "opacity")
+ fade.values = [0, 0.8, 0]; fade.keyTimes = [0, 0.1, 1]
+ let group = CAAnimationGroup()
+ group.animations = [spread, fade]; group.duration = 0.6
+ group.beginTime = CACurrentMediaTime() + delay
+ group.timingFunction = CAMediaTimingFunction(name: .easeOut)
+ ripple.add(group, forKey: "ripple")
+ }
+ icon.wantsLayer = true
+ let spring = CAKeyframeAnimation(keyPath: "transform")
+ spring.values = [(0.18, 0.12, -17.0, -13.0), (0.64, 0.65, -8, -5),
+ (1.14, 1.17, 9, 4), (1.04, 0.92, -2, -3), (0.98, 1.03, 2, 1), (1, 1, 0, 0)].map { sx, sy, y, degrees in
+ var transform = CATransform3DMakeTranslation(0, CGFloat(y), 0)
+ transform = CATransform3DRotate(transform, CGFloat(degrees * .pi / 180), 0, 0, 1)
+ return NSValue(caTransform3D: CATransform3DScale(transform, CGFloat(sx), CGFloat(sy), 1))
+ }
+ spring.keyTimes = [0, 0.12, 0.44, 0.66, 0.84, 1]
+ spring.duration = 0.46
+ spring.timingFunctions = Array(repeating: CAMediaTimingFunction(controlPoints: 0.16, 0.8, 0.3, 1), count: 5)
+ icon.layer?.add(spring, forKey: "pop")
+ }
+ overlay.orderFrontRegardless()
+ overlay.displayIfNeeded()
+ overlays.append(overlay)
+ // Each emote owns its lifetime; a new one never removes an existing one.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self, weak overlay] in
+ guard let overlay = overlay else { return }
+ NSAnimationContext.runAnimationGroup({ context in
+ context.duration = reducedMotion ? 0.1 : 0.2
+ overlay.animator().alphaValue = 0
+ if !reducedMotion { overlay.animator().setFrameOrigin(NSPoint(x: overlay.frame.minX, y: overlay.frame.minY + 8)) }
+ }, completionHandler: {
+ overlay.close()
+ self?.overlays.removeAll { $0 === overlay }
+ })
+ }
+ let cg = cgPoint(point)
+ emit(["kind": "trigger", "emoteId": emote["id"] ?? "", "configRevision": configRevision, "at": Date().timeIntervalSince1970 * 1000,
+ "point": ["x": cg.x, "y": cg.y], "gestureId": gestureId, "optionIndex": optionIndex ?? -1,
+ "target": ["none", "undo"].contains(emote["action"] as? String ?? "") ? [:] : targetAt(point)])
+ }
+ func showPicker(at point: CGPoint) {
+ guard enabled, !recordingShortcut, !emotes.isEmpty, picker == nil else { return }
+ anchor = NSPoint(x: point.x, y: (NSScreen.screens.first?.frame.maxY ?? 0) - point.y)
+ let view = WheelView(emotes: emotes, images: images)
+ let panel = Overlay(frame: NSRect(x: anchor.x - view.center.x, y: anchor.y - view.center.y,
+ width: view.frame.width, height: view.frame.height), interactive: true)
+ panel.contentView = view
+ view.cancel = { [weak self] in self?.cancelPickerGesture() }
+ let gestureId = UUID().uuidString
+ picker = panel; wheel = view; wheelGestureId = gestureId
+ panel.orderFrontRegardless(); panel.displayIfNeeded()
+ emit(["kind": "wheel-open", "gestureId": gestureId, "configRevision": configRevision])
+ }
+ func updatePicker(at point: CGPoint) {
+ guard let panel = picker, let wheel = wheel else { return }
+ let at = NSPoint(x: point.x, y: (NSScreen.screens.first?.frame.maxY ?? 0) - point.y)
+ wheel.update(at: NSPoint(x: at.x - panel.frame.minX, y: at.y - panel.frame.minY))
+ }
+ func finishPicker(at point: CGPoint) {
+ guard let view = wheel, let gestureId = wheelGestureId else { closePicker(); return }
+ let selected = view.selected, option = view.optionIndex
+ updatePicker(at: point)
+ let valid = selected == view.selected && (selected.map {
+ !view.needsTarget($0) || (option != nil && option == view.optionIndex)
+ } ?? false)
+ closePicker()
+ if valid, let selected = selected { trigger(selected, at: anchor, gestureId: gestureId, optionIndex: option) }
+ }
+ func closePicker() {
+ if let id = wheelGestureId { emit(["kind": "wheel-closed", "gestureId": id]) }
+ picker?.close(); picker = nil; wheel = nil; wheelGestureId = nil
+ }
+ func thumbnail(_ image: NSImage) -> String? {
+ guard image.size.width > 0, image.size.height > 0,
+ let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 64, pixelsHigh: 64,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
+ bytesPerRow: 0, bitsPerPixel: 0) else { return nil }
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
+ let scale = min(64 / image.size.width, 64 / image.size.height)
+ let size = NSSize(width: image.size.width * scale, height: image.size.height * scale)
+ image.draw(in: NSRect(x: (64 - size.width) / 2, y: (64 - size.height) / 2, width: size.width, height: size.height))
+ NSGraphicsContext.restoreGraphicsState()
+ guard let data = bitmap.representation(using: .png, properties: [:]) else { return nil }
+ return "data:image/png;base64," + data.base64EncodedString()
+ }
+ func command(_ request: [String: Any]) {
+ guard let id = request["id"] as? String, let method = request["method"] as? String else { return }
+ let params = request["params"] as? [String: Any] ?? [:]
+ switch method {
+ case "configure": answer(id, configure(params))
+ case "status":
+ // Recheck Cindy's existing access without requesting OS authorization.
+ if enabled && !recordingShortcut && inputTap == nil && AXIsProcessTrusted() { _ = registerWheel() }
+ answer(id, ["enabled": enabled && hotkeyReady, "permissions": permissions(),
+ "issues": enabled && !recordingShortcut && !hotkeyReady ? [AXIsProcessTrusted() ? "INPUT_HOOK_FAILED" : "INPUT_ACCESS_REQUIRED"] : [],
+ "gesture": gesture.rawValue, "inputMode": "key-hold",
+ "wheelVisible": picker != nil, "wheelShortcut": ["keyCode": wheelKeyCode, "modifiers": wheelModifiers, "modifierOnly": modifierOnly]])
+ case "theme":
+ NSApp.appearance = NSAppearance(named: (params["dark"] as? Bool == true) ? .darkAqua : .aqua)
+ wheel?.needsDisplay = true
+ answer(id, [:])
+ case "previewImages":
+ var previews: [String: String] = [:]
+ var bytes = 0
+ for (key, image) in images {
+ if let preview = thumbnail(image), bytes + preview.utf8.count < 800000 {
+ previews[key] = preview; bytes += preview.utf8.count
+ }
+ }
+ answer(id, ["images": previews])
+ case "shortcutRecording": answer(id, setRecording(params["active"] as? Bool ?? false))
+ case "wheelTargets":
+ guard params["gestureId"] as? String == wheelGestureId, let wheel = wheel,
+ let choices = params["choices"] as? [String: [[String: Any]]] else { answer(id, ["current": false]); return }
+ wheel.choices = choices; wheel.needsDisplay = true
+ answer(id, ["current": true])
+ case "pickImage":
+ let panel = NSOpenPanel()
+ panel.allowedContentTypes = [.png, .jpeg, .gif, .webP]
+ panel.canChooseDirectories = false
+ panel.allowsMultipleSelection = false
+ panel.begin { result in
+ guard result == .OK, let url = panel.url else { answer(id, ["path": ""]); return }
+ guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
+ let bytes = attrs[.size] as? NSNumber, bytes.intValue <= 8 * 1024 * 1024,
+ let image = NSImage(contentsOf: url), let preview = self.thumbnail(image) else {
+ failure(id, "IMAGE_UNAVAILABLE"); return
+ }
+ answer(id, ["path": url.path, "preview": preview])
+ }
+ case "capture":
+ guard !captureBusy else { failure(id, "CAPTURE_BUSY"); return }
+ captureBusy = true
+ let event = params["event"] as? [String: Any] ?? [:]
+ let wantsScreenshot = params["screenshot"] as? Bool ?? true
+ Task.detached { [weak self] in
+ let (text, textIssues) = textAt(event)
+ let (jpeg, screenshotIssues) = wantsScreenshot ? await screenshot(event) : (nil, [])
+ var result: [String: Any] = ["text": text, "issues": textIssues + screenshotIssues,
+ "capturedAt": Date().timeIntervalSince1970 * 1000, "source": event]
+ if let jpeg = jpeg { result["jpeg"] = jpeg }
+ answer(id, result)
+ DispatchQueue.main.async { self?.captureBusy = false }
+ }
+ case "shutdown": answer(id, [:]); NSApp.terminate(nil)
+ default: failure(id, "METHOD_UNKNOWN")
+ }
+ }
+}
+
+let application = NSApplication.shared
+application.setActivationPolicy(.accessory)
+let controller = Controller()
+application.delegate = controller
+DispatchQueue.global(qos: .userInitiated).async {
+ while let line = readLine() {
+ guard line.utf8.count <= 262144, let data = line.data(using: .utf8),
+ let request = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
+ DispatchQueue.main.async { controller.command(request) }
+ }
+ // Parent/worker death closes stdin, so the overlay and global shortcuts die too.
+ DispatchQueue.main.async { NSApp.terminate(nil) }
+}
+application.run()
diff --git a/desktop-emotes/node/config.cjs b/desktop-emotes/node/config.cjs
new file mode 100644
index 00000000..1af71ff9
--- /dev/null
+++ b/desktop-emotes/node/config.cjs
@@ -0,0 +1,125 @@
+'use strict';
+const shortcuts = require('../shortcuts.js');
+const library = require('../emote-library.js');
+const { createHash } = require('node:crypto');
+// Removed factory prompt fingerprints; user-written instructions are not rewritten.
+const retiredPrompts = new Set([
+ '2382f8b8df5dca54228becd5f478b571063a1fd366ab46b12afa90948b98c62a',
+ '87cdb0a049cfbd81c700a507f17f3dd9c1e987cad4dc6d23870bd3ee8dc9860c',
+ '516d1b26145ec7cf982387c16a365bf8c00aff3adefe0f9cd13bd0ad28b8f393',
+ '2b36321515b4ff53f135acc9007aac51d3fc6e72638bd5bd7bad0601a0b5eb37',
+ '30695bad9aae3df08b77f0daeb482274a1eb10eda5d48ff4cc9c7429c4372d2e',
+ '30f45c324d6999796e5b83bffa50d185c24f4292e966aa0c46a87ceee357e5c4',
+ 'a4ad4a497f5a99b14a3f1029fe299968ef157585452cdafe5460b681ce5647ef',
+ 'ba7d41491fd83587e3545a496a5e7f74f5fa91b6ecb622e637309858538da101',
+ 'adc66d7d2a19a367506808c88f003bdd168657c4031b30d693a4feb3030a7410',
+ '01c084b1b677cb39be178ff21bb95f9c3bde741064ccef86059c6e1a0e6ed06c',
+ '0f7f3723a58ef0fede850d57b33e20f78c382bd12504832ad7bdb8277194addd',
+ '0a9db5c406a76e72d1c9d744437059d93f5d372d3844aae173db4a37fae9ffc0',
+ '3d40c0130a377389dfa4832e6dda54e2887a0dada2b0bba9eccfe27c21bf2cf0',
+ 'd4c3ade9e5394a7fecbbc96aed899037d59c0e74608d72835a410e9a00cea349',
+ 'ced59cc6df3757e84f11b4ab09d7005a541c966246c5a2cb79647f2f036e1b0e',
+ '7baf62c565b12d622e069446c7eafa16101e83bfa703938cc353e51600c85568',
+ '10569f2cad1e222338da05b64344a6308f3617e7f655594168f43c49375c92c2',
+ 'f3780335daca53fc7037136b012942a42f65f5f33f3a2cab142754f1eee1b525',
+ '4e12e06dc79eedb74491db0c046adfab41491dcb15386b776f8a87d425adb931',
+ 'a937d44869728a8e9a0391efe21c457733b358c3acdca680019df2ee7dbea597',
+]);
+const wasFactoryPrompt = text => retiredPrompts.has(createHash('sha256').update(text || '').digest('hex'));
+
+function validateRoute(route) {
+ if (route == null) return null;
+ const string = (value, limit) => typeof value === 'string' && value.length <= limit && !value.includes('\0');
+ if (!['cc', 'codex', 'pi'].includes(route.agentKind) || !string(route.providerId, 128) || !route.providerId.trim() ||
+ !string(route.model, 256) || !route.model.trim() || !string(route.effort, 32) ||
+ (typeof route.fastMode !== 'boolean' && route.fastMode !== 'ultrafast'))
+ throw new Error('CONFIG_INVALID');
+ return { agentKind: route.agentKind, providerId: route.providerId, model: route.model,
+ effort: route.effort, fastMode: route.fastMode };
+}
+
+function validateConfig(input) {
+ if (!input || !Array.isArray(input.emotes) ||
+ input.emotes.length < 4) throw new Error('CONFIG_INVALID');
+ const ids = new Set();
+ const emotes = input.emotes.map((item, index) => {
+ if (!item || typeof item.id !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || ids.has(item.id))
+ throw new Error('CONFIG_INVALID');
+ ids.add(item.id);
+ const string = (value, limit) => typeof value === 'string' && value.length <= limit && !value.includes('\0');
+ if (!string(item.label, 40) || !item.label.trim() || !string(item.emoji, 32) ||
+ !string(item.imagePath || '', 4096) || (!item.emoji.trim() && !item.imagePath) ||
+ !['none', 'undo', 'new', 'existing', 'bot'].includes(item.action) || !string(item.prompt || '', 8000) ||
+ (!['none', 'undo'].includes(item.action) && !item.prompt?.trim()) ||
+ (item.action === 'existing' && !item.target && !string(item.taskId, 200)) ||
+ (item.action === 'existing' && !item.target && !item.taskId?.trim())) throw new Error('CONFIG_INVALID');
+ let target;
+ if (item.target != null) {
+ const value = item.target;
+ if (!['pick', 'recent', 'fixed'].includes(value.mode)) throw new Error('CONFIG_INVALID');
+ target = { mode: value.mode };
+ if (value.mode === 'fixed') {
+ const kind = { new: 'project', existing: 'task', bot: 'bot' }[item.action];
+ if (!string(value.label || '', 200)) throw new Error('CONFIG_INVALID');
+ if (value.ref) {
+ if (!['project', 'task', 'bot'].includes(value.ref.kind) || (kind && value.ref.kind !== kind) ||
+ !string(value.ref.id, value.ref.kind === 'project' ? 8192 : 200) || !value.ref.id.trim())
+ throw new Error('CONFIG_INVALID');
+ target.ref = { kind: value.ref.kind, id: value.ref.id };
+ } else {
+ // Retain unpublished prototype handles for explicit reselection; never guess a destination.
+ if (!string(value.bindingId, 200) || !value.bindingId.trim()) throw new Error('CONFIG_INVALID');
+ target.bindingId = value.bindingId;
+ }
+ target.label = value.label || '';
+ }
+ }
+ if (item.action === 'bot' && !target) throw new Error('CONFIG_INVALID');
+ if (item.delivery !== undefined && !['queue', 'steer'].includes(item.delivery)) throw new Error('CONFIG_INVALID');
+ const angle = item.angle === undefined ? index * 360 / input.emotes.length : item.angle;
+ const pinAngle = item.pinAngle ?? null;
+ if (!Number.isFinite(angle) || angle < 0 || angle >= 360 ||
+ (pinAngle !== null && (!Number.isFinite(pinAngle) || pinAngle !== angle))) throw new Error('CONFIG_INVALID');
+ return { angle, pinAngle, id: item.id, label: item.label.trim(), emoji: item.emoji, imagePath: library.imagePath(item.imagePath || ''),
+ action: item.action === 'undo' ? 'none' : item.action, prompt: ['none', 'undo'].includes(item.action) ? '' : item.prompt,
+ taskId: item.action === 'existing' ? item.taskId : '', screenshot: item.screenshot !== false,
+ route: validateRoute(item.route), ...(target ? { target } : {}),
+ ...(item.delivery !== undefined ? { delivery: item.delivery } : {}) };
+ });
+ emotes.sort((a, b) => a.angle - b.angle);
+ if (new Set(emotes.map(item => item.angle)).size !== emotes.length) throw new Error('CONFIG_INVALID');
+ return { version: 2, emotes,
+ wheelShortcut: shortcuts.validate(input.wheelShortcut === undefined ? shortcuts.defaults() : input.wheelShortcut) };
+}
+function migrateConfig(input) {
+ const next = JSON.parse(JSON.stringify(input));
+ for (const item of next.emotes || []) item.imagePath = library.imagePath(item.imagePath || '');
+ const defaultIds = ['help', 'verify', 'inspire', 'improve'];
+ const factoryPresets = [...library.presets('zh-CN'), ...library.presets('en')];
+ const factoryWheel = next.emotes?.length === 4 && next.emotes.every(item =>
+ factoryPresets.some(preset => defaultIds.includes(preset.id) && item.id === `ping-${preset.id}` &&
+ item.imagePath === preset.imagePath && item.label === preset.label && item.emoji === preset.emoji) &&
+ ['none', 'new'].includes(item.action) && !item.route && !item.taskId &&
+ (!item.target || item.target.mode === 'pick') && (!item.prompt || wasFactoryPrompt(item.prompt)));
+ for (const item of next.emotes || []) {
+ if (!['none', 'new'].includes(item.action) || item.route || item.taskId || item.target?.mode === 'fixed' ||
+ !wasFactoryPrompt(item.prompt)) continue;
+ item.action = 'none'; item.prompt = ''; item.taskId = '';
+ delete item.target;
+ }
+ if (factoryWheel) {
+ const undo = library.defaults(input.locale).emotes.find(item => item.id === 'ping-undo');
+ next.emotes = next.emotes.map(item => item.id === 'ping-improve'
+ ? { ...undo, angle: item.angle, pinAngle: item.pinAngle } : item);
+ }
+ if (next.version === 2) return validateConfig(next);
+ if (!Array.isArray(next.emotes) || !next.emotes.length) throw new Error('CONFIG_INVALID');
+ const defaults = [['question', '问号', '❓'], ['smile', '开心', '😄'], ['eyes', '看看', '👀'], ['think', '思考', '🤔']];
+ for (const [id, label, emoji] of defaults) {
+ if (next.emotes.length >= 4) break;
+ if (!next.emotes.some(item => item.id === id)) next.emotes.push({ id, label, emoji, action: 'none', prompt: '', taskId: '', screenshot: true });
+ }
+ next.emotes.forEach((item, i) => { item.angle = i * 360 / next.emotes.length; item.pinAngle = null; });
+ return validateConfig(next);
+}
+module.exports = { validateConfig, migrateConfig };
diff --git a/desktop-emotes/node/scheduler.cjs b/desktop-emotes/node/scheduler.cjs
new file mode 100644
index 00000000..5d21b2d6
--- /dev/null
+++ b/desktop-emotes/node/scheduler.cjs
@@ -0,0 +1,55 @@
+'use strict';
+const { randomUUID } = require('node:crypto');
+
+// One global generation for every emote, including display-only emotes.
+// Rendering has already happened in the native process before trigger is called.
+class LatestAction {
+ constructor({ capture, notify }) {
+ this.capture = capture;
+ this.notify = notify;
+ this.current = null;
+ this.timer = null;
+ this.snapshot = null;
+ }
+ invalidate() {
+ clearTimeout(this.timer);
+ this.timer = null;
+ this.current = null;
+ this.snapshot = null;
+ this.notify('invalidate', {});
+ }
+ trigger(event, emote) {
+ clearTimeout(this.timer);
+ const operation = { id: randomUUID(), event, emote: structuredClone(emote), deadline: Date.now() + 2000,
+ elapsed: false, targetReady: !emote.target, started: false };
+ this.current = operation;
+ this.snapshot = null;
+ this.notify('trigger', { id: operation.id, label: emote.label, action: emote.action, emote: operation.emote });
+ if (emote.action === 'none') return;
+ this.timer = setTimeout(() => { this.timer = null; operation.elapsed = true; void this.prepare(operation); }, 2000);
+ }
+ resolveTarget(id, target) {
+ if (!this.isCurrent(id) || this.current.started) return { current: false };
+ if (!target) { this.invalidate(); return { current: false }; }
+ this.current.target = target;
+ this.current.targetReady = true;
+ void this.prepare(this.current);
+ return { current: true };
+ }
+ async prepare(operation) {
+ if (this.current !== operation || !operation.elapsed || !operation.targetReady || operation.started) return;
+ operation.started = true;
+ this.notify('capturing', { id: operation.id });
+ try {
+ const snapshot = await this.capture(operation);
+ if (this.current !== operation) return;
+ this.snapshot = snapshot;
+ this.notify('prepared', { id: operation.id, emote: operation.emote, target: operation.target,
+ summary: { hasImage: !!snapshot.jpeg, hasText: !!snapshot.text, issues: snapshot.issues || [] } });
+ } catch (error) {
+ if (this.current === operation) this.notify('capture-error', { id: operation.id, code: error.code || error.message });
+ }
+ }
+ isCurrent(id) { return this.current?.id === id; }
+}
+module.exports = { LatestAction };
diff --git a/desktop-emotes/node/worker.cjs b/desktop-emotes/node/worker.cjs
new file mode 100644
index 00000000..9066a619
--- /dev/null
+++ b/desktop-emotes/node/worker.cjs
@@ -0,0 +1,201 @@
+'use strict';
+const readline = require('node:readline');
+const { spawn } = require('node:child_process');
+const { randomUUID, createHash } = require('node:crypto');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const { validateConfig, migrateConfig } = require('./config.cjs');
+const shortcuts = require('../shortcuts.js');
+const { LatestAction } = require('./scheduler.cjs');
+const library = require('../emote-library.js');
+const bundledImages = new Set(library.presets().map(item => item.imagePath));
+
+const write = message => process.stdout.write(JSON.stringify(message) + '\n');
+const notify = (kind, data) => write({ jsonrpc: '2.0', method: 'emote/event', params: { kind, ...data } });
+const nativePath = path.resolve(__dirname, '../native/DesktopEmotes.app/Contents/MacOS/DesktopEmotes');
+let config = { emotes: [] };
+let runtimeEnabled = false;
+let child = null;
+let starting = null;
+let configRevision = '';
+let wheelSession = null;
+const pending = new Map();
+const scheduler = new LatestAction({ notify, capture: operation => nativeRequest('capture', {
+ event: operation.event, screenshot: operation.emote.screenshot,
+}) });
+
+async function startNative() {
+ if (starting) return starting;
+ if (child) return;
+ if (process.platform !== 'darwin') throw new Error('MACOS_REQUIRED');
+ starting = (async () => {
+ await fs.chmod(nativePath, 0o755);
+ await new Promise((resolve, reject) => {
+ const processHandle = spawn(nativePath, [], { stdio: ['pipe', 'pipe', 'ignore'] });
+ child = processHandle;
+ let ready = false;
+ const timer = setTimeout(() => { processHandle.kill(); reject(new Error('HELPER_START_FAILED')); }, 8000);
+ let buffer = '';
+ processHandle.stdout.setEncoding('utf8');
+ processHandle.stdout.on('data', data => {
+ buffer += data;
+ if (buffer.length > 1100000) { processHandle.kill(); return; }
+ let end;
+ while ((end = buffer.indexOf('\n')) >= 0) {
+ const line = buffer.slice(0, end);
+ buffer = buffer.slice(end + 1);
+ let event;
+ try { event = JSON.parse(line); } catch { continue; }
+ if (event.kind === 'ready') { ready = true; clearTimeout(timer); resolve(); }
+ else if (event.kind === 'wheel-open' && runtimeEnabled && event.configRevision === configRevision) {
+ wheelSession = { id: event.gestureId, choices: {}, ready: false };
+ notify('wheel-open', { gestureId: event.gestureId, configRevision });
+ } else if (event.kind === 'wheel-closed') {
+ notify('wheel-closed', { gestureId: event.gestureId });
+ } else if (event.kind === 'trigger' && runtimeEnabled && event.configRevision === configRevision) {
+ const emote = config.emotes.find(item => item.id === event.emoteId);
+ if (!emote) continue;
+ let selected = emote;
+ if (!['none', 'undo'].includes(emote.action) && ['pick', 'recent'].includes(emote.target?.mode)) {
+ const target = wheelSession?.id === event.gestureId && Number.isInteger(event.optionIndex)
+ ? wheelSession.choices[emote.id]?.[event.optionIndex] : null;
+ if (!target || target.kind !== { new: 'project', existing: 'task', bot: 'bot' }[emote.action]) continue;
+ selected = { ...emote, target: { mode: 'fixed', ref: { kind: target.kind, id: target.id }, label: target.label } };
+ }
+ scheduler.trigger(event, selected);
+ } else if (event.id && pending.has(event.id)) {
+ const request = pending.get(event.id);
+ pending.delete(event.id);
+ clearTimeout(request.timer);
+ if (event.error) request.reject(new Error(event.error)); else request.resolve(event.result);
+ }
+ }
+ });
+ const closed = () => {
+ clearTimeout(timer);
+ if (child === processHandle) child = null;
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('HELPER_STOPPED')); }
+ pending.clear();
+ scheduler.invalidate();
+ if (!ready) reject(new Error('HELPER_START_FAILED'));
+ notify('native-stopped', {});
+ };
+ processHandle.once('error', closed);
+ processHandle.once('exit', closed);
+ processHandle.stdin.on('error', () => {});
+ });
+ })();
+ try { await starting; } finally { starting = null; }
+}
+function nativeRequest(method, params = {}) {
+ return new Promise((resolve, reject) => {
+ if (!child || !child.stdin.writable) { reject(new Error('HELPER_STOPPED')); return; }
+ const id = randomUUID();
+ const timer = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error('CAPTURE_TIMEOUT'));
+ // A hung native API must not leave an orphan or an unbounded capture queue.
+ child?.kill();
+ }, method === 'pickImage' ? 120000 : 8000);
+ pending.set(id, { resolve, reject, timer });
+ child.stdin.write(JSON.stringify({ id, method, params }) + '\n');
+ });
+}
+function requireCurrent(id) {
+ if (!scheduler.isCurrent(id)) throw new Error('SUPERSEDED');
+}
+function readSnapshot({ id }) {
+ requireCurrent(id);
+ const snapshot = scheduler.snapshot;
+ if (!snapshot || (!snapshot.jpeg && !snapshot.text)) throw new Error('NO_CONTENT');
+ return { jpeg: snapshot.jpeg, text: snapshot.text, source: snapshot.source,
+ capturedAt: snapshot.capturedAt, issues: snapshot.issues };
+}
+let configuring = Promise.resolve();
+async function execute(method, params) {
+ switch (method) {
+ case 'defaults': return library.defaults(params.locale);
+ case 'validate': return validateConfig(params);
+ case 'migrate': return migrateConfig(params);
+ case 'configRevision': return { revision: createHash('sha256').update(JSON.stringify(params)).digest('hex') };
+ case 'configure': {
+ const next = validateConfig(params);
+ const apply = async () => {
+ scheduler.invalidate();
+ wheelSession = null;
+ config = next;
+ runtimeEnabled = params.runtimeEnabled !== false;
+ configRevision = randomUUID();
+ if (!runtimeEnabled && !child) return { enabled: false };
+ await startNative();
+ return nativeRequest('configure', { enabled: runtimeEnabled, locale: params.locale, configRevision,
+ wheelShortcut: shortcuts.native(config.wheelShortcut),
+ emotes: config.emotes.map(({ id, label, emoji, imagePath, action, angle, target }) => ({ id, label, emoji, action, angle,
+ targetMode: !['none', 'undo'].includes(action) ? target?.mode || '' : '',
+ targetKind: { new: 'project', existing: 'task', bot: 'bot' }[action] || '',
+ imagePath: bundledImages.has(imagePath) ? path.resolve(__dirname, '..', imagePath) : imagePath })) });
+ };
+ configuring = configuring.then(apply, apply);
+ return configuring;
+ }
+ case 'status':
+ // Read the OS preflight result through Cindy's normal child-process chain.
+ // This neither requests permissions nor captures any window contents.
+ await startNative();
+ return { platform: process.platform, nativeRunning: !!child, enabled: runtimeEnabled,
+ ...await nativeRequest('status') };
+ case 'pickImage':
+ case 'previewImages':
+ case 'theme': await startNative(); return nativeRequest(method, params);
+ case 'wheelTargets': {
+ if (!wheelSession || params.gestureId !== wheelSession.id || params.configRevision !== configRevision || wheelSession.ready)
+ return { current: false };
+ const choices = {}, catalogs = {};
+ for (const emote of config.emotes) {
+ if (['none', 'undo'].includes(emote.action) || !['pick', 'recent'].includes(emote.target?.mode)) continue;
+ const kind = { new: 'project', existing: 'task', bot: 'bot' }[emote.action];
+ const rows = params.catalogs?.[kind];
+ if (!Array.isArray(rows) || rows.some(row => row.kind !== kind || typeof row.id !== 'string' ||
+ !row.id || typeof row.label !== 'string' || typeof row.detail !== 'string')) throw new Error('CONFIG_INVALID');
+ if (!catalogs[kind]) {
+ const full = config.emotes.some(item => item.action === emote.action && item.target?.mode === 'pick');
+ catalogs[kind] = rows.slice(0, full ? undefined : 3);
+ }
+ choices[emote.id] = emote.target.mode === 'recent' ? catalogs[kind].slice(0, 3) : catalogs[kind];
+ }
+ wheelSession.choices = choices; wheelSession.ready = true;
+ return nativeRequest('wheelTargets', { gestureId: params.gestureId, choices: Object.fromEntries(
+ Object.entries(catalogs).map(([kind, rows]) => [kind, rows.map(({ label, detail }) => ({ label: label.slice(0, 160), detail: detail.slice(0, 400) }))])) });
+ }
+ case 'shortcutRecording':
+ await startNative();
+ return nativeRequest('shortcutRecording', { active: params.active === true });
+ case 'current': return { current: scheduler.isCurrent(params.id) };
+ case 'resolveTarget': return scheduler.resolveTarget(params.id, params.target);
+ case 'readSnapshot': return readSnapshot(params);
+ case 'wait': await new Promise(resolve => setTimeout(resolve, 500)); return {};
+ default: throw new Error('METHOD_UNKNOWN');
+ }
+}
+const input = readline.createInterface({ input: process.stdin });
+input.on('line', async line => {
+ if (line.length > 262144) return;
+ let request;
+ try { request = JSON.parse(line); } catch { return; }
+ try {
+ const result = await execute(request.method, request.params || {});
+ write({ jsonrpc: '2.0', id: request.id, result });
+ } catch (error) {
+ const code = /^[A-Z_]+$/.test(error.message) ? error.message : 'LOCAL_OPERATION_FAILED';
+ write({ jsonrpc: '2.0', id: request.id, error: { code: -32000, message: code } });
+ }
+});
+function stop() {
+ scheduler.invalidate();
+ child?.stdin.end();
+ child?.kill();
+ process.exit(0);
+}
+input.on('close', stop);
+process.on('SIGTERM', stop);
+process.on('SIGINT', stop);
diff --git a/desktop-emotes/package-lock.json b/desktop-emotes/package-lock.json
new file mode 100644
index 00000000..aa4eec86
--- /dev/null
+++ b/desktop-emotes/package-lock.json
@@ -0,0 +1,12 @@
+{
+ "name": "desktop-emotes",
+ "version": "0.3.7",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "desktop-emotes",
+ "version": "0.3.7"
+ }
+ }
+}
diff --git a/desktop-emotes/package.json b/desktop-emotes/package.json
new file mode 100644
index 00000000..def68a1f
--- /dev/null
+++ b/desktop-emotes/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "desktop-emotes",
+ "version": "0.3.7",
+ "private": true,
+ "scripts": {
+ "build": "node scripts/build.mjs",
+ "check": "node --check main.js && node --check shortcuts.js && node --check wheel-layout.js && node --check emote-library.js && node --check settings.js && node --check node/worker.cjs && node --check node/config.cjs && node --check node/scheduler.cjs"
+ }
+}
diff --git a/desktop-emotes/scripts/build.mjs b/desktop-emotes/scripts/build.mjs
new file mode 100644
index 00000000..6e780721
--- /dev/null
+++ b/desktop-emotes/scripts/build.mjs
@@ -0,0 +1,36 @@
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { resolve } from 'node:path';
+import { execFileSync } from 'node:child_process';
+
+if (process.platform !== 'darwin') throw new Error('Build this native helper on macOS.');
+const root = fileURLToPath(new URL('..', import.meta.url));
+const build = resolve(root, 'build');
+const app = resolve(root, 'native/DesktopEmotes.app');
+const manifest = JSON.parse(readFileSync(resolve(root, 'ghost.json'), 'utf8'));
+mkdirSync(build, { recursive: true });
+mkdirSync(resolve(app, 'Contents/MacOS'), { recursive: true });
+writeFileSync(resolve(app, 'Contents/Info.plist'), `
+
+
+CFBundleIdentifier app.desktop-emotes.helper
+CFBundleName Cindy Ping
+CFBundleExecutable DesktopEmotes
+CFBundlePackageType APPL
+CFBundleShortVersionString ${manifest.version}
+CFBundleVersion 10
+LSUIElement
+LSMinimumSystemVersion 14.0
+NSScreenCaptureUsageDescription Capture the window under your emote only when its configured task runs.
+ \n`);
+for (const arch of ['arm64', 'x86_64']) {
+ execFileSync('xcrun', ['swiftc', '-swift-version', '5', '-O', '-target', `${arch}-apple-macosx14.0`,
+ '-module-cache-path', resolve(build, 'module-cache'),
+ resolve(root, 'native/EmoteHelper.swift'), '-o', resolve(build, `helper-${arch}`),
+ '-framework', 'AppKit', '-framework', 'Carbon', '-framework', 'ApplicationServices',
+ '-framework', 'ScreenCaptureKit'], { stdio: 'inherit' });
+}
+execFileSync('lipo', ['-create', resolve(build, 'helper-arm64'), resolve(build, 'helper-x86_64'),
+ '-output', resolve(app, 'Contents/MacOS/DesktopEmotes')], { stdio: 'inherit' });
+execFileSync('codesign', ['--force', '--sign', '-', app], { stdio: 'inherit' });
+console.log('Built macOS 14+ universal helper (arm64 / x86_64).');
diff --git a/desktop-emotes/scripts/stage.mjs b/desktop-emotes/scripts/stage.mjs
new file mode 100644
index 00000000..63ac026e
--- /dev/null
+++ b/desktop-emotes/scripts/stage.mjs
@@ -0,0 +1,14 @@
+import { mkdirSync, cpSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { resolve } from 'node:path';
+
+const root = fileURLToPath(new URL('..', import.meta.url));
+const target = resolve(root, 'build/package');
+if (!existsSync(resolve(root, 'native/DesktopEmotes.app/Contents/MacOS/DesktopEmotes'))) throw new Error('Run the native build first.');
+mkdirSync(target, { recursive: true });
+for (const entry of ['ghost.json', 'main.js', 'shortcuts.js', 'wheel-layout.js', 'emote-library.js', 'settings.html', 'settings.js', 'settings.css', 'node', 'assets', 'locales']) {
+ cpSync(resolve(root, entry), resolve(target, entry), { recursive: true });
+}
+mkdirSync(resolve(target, 'native'), { recursive: true });
+cpSync(resolve(root, 'native/DesktopEmotes.app'), resolve(target, 'native/DesktopEmotes.app'), { recursive: true });
+console.log(target);
diff --git a/desktop-emotes/settings.css b/desktop-emotes/settings.css
new file mode 100644
index 00000000..1e4071ee
--- /dev/null
+++ b/desktop-emotes/settings.css
@@ -0,0 +1,31 @@
+*{box-sizing:border-box}body{margin:0;color:var(--text-primary,#1A1A1A);font:13px/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer;background:none;border:0}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--focus-ring,#417cdd80);outline-offset:3px}h1,h2,h3,p{margin:0}h1{font-size:18px;font-weight:500}h2,h3{font-size:14px;font-weight:500}p{font-size:12px;color:var(--text-muted,#6B6B67);line-height:1.6}button,input,textarea{caret-color:var(--switch-on,#417CDD)}
+#prototype{margin:0;padding:0}
+.primary,.secondary,.subtle,.file-button{height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;padding:0 16px;font-weight:500;white-space:nowrap;font-size:13px}.primary{background:var(--accent-cta-bg,#3C3F43);color:var(--accent-cta-fg,#FCFCFC);border:1px solid transparent}.primary:hover{background:color-mix(in srgb,var(--accent-cta-bg,#3C3F43),var(--accent-cta-fg,#FCFCFC) 8%)}.secondary,.file-button{background:var(--surface-elevated,#FDFDF8);border:1px solid var(--border-default,#E4E4DF)}.secondary:hover,.file-button:hover{background:color-mix(in srgb,var(--surface-elevated,#FDFDF8),var(--text-primary,#1A1A1A) 8%)}.subtle{color:var(--text-muted,#6B6B67);padding:0 10px;font-size:12px}.subtle:hover,.icon-button:hover{background:var(--surface-hover,#EEEEE9)}
+.keybar{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border-default,#E4E4DF);padding:0 0 16px;gap:16px}.keybar>div>span{font-weight:500}.keybar p{margin-top:3px;color:var(--text-secondary,#888883)}.keybar button{display:flex;align-items:center;gap:12px;padding:0 12px;height:36px}.keybar button>span{font-size:12px;color:var(--text-muted,#6B6B67)}kbd{font-family:inherit;font-size:12px;font-weight:500;border:1px solid var(--border-default,#E4E4DF);border-radius:4px;padding:1px 5px;background:var(--surface,#F2F2ED)}
+.workspace{display:grid;grid-template-columns:minmax(220px,304px) minmax(0,1fr);gap:20px;margin-top:24px;align-items:start}.section-head{display:flex;align-items:center;justify-content:space-between}.section-head span{font-size:12px;color:var(--text-secondary,#888883)}.section-hint{color:var(--text-secondary,#888883);margin-top:4px}.wheel-section{padding-top:2px}.wheel{position:relative;--wheel-size:min(304px,42vw);width:var(--wheel-size);height:var(--wheel-size);margin:16px auto 8px;user-select:none}.wheel>svg{position:absolute;inset:0;overflow:visible}.outer-ring{fill:var(--surface-elevated,#FDFDF8);stroke:var(--border-default,#E4E4DF);stroke-width:1}.slice{fill:var(--surface-elevated,#FDFDF8);stroke:var(--border-default,#E4E4DF);stroke-width:.65;transition:fill .12s}.slice.empty{fill:var(--surface,#F2F2ED)}.slice.active,.slice.hover{fill:var(--selection-bg,#E4E4DF);stroke:var(--text-muted,#6B6B67);stroke-width:1}.slice.dragover{fill:var(--selection-bg,#E4E4DF);stroke:var(--text-primary,#1A1A1A);stroke-width:1.5}#wheel .slice{cursor:pointer}#wheel .slice:hover{fill:var(--surface-hover,#EEEEE9)}#wheel .slice.active:hover{fill:var(--selection-bg,#E4E4DF)}
+.slot-btn{position:absolute;transform:translate(-50%,-50%);width:48px;height:48px;display:grid;place-items:center;padding:0;z-index:2;font-size:27px;border-radius:9999px;transition:scale .12s}.slot-btn:hover{scale:1.06}.slot-btn.empty{font-size:23px;font-weight:400;color:var(--text-secondary,#888883)}.slot-btn img{width:34px;height:34px;object-fit:contain;pointer-events:none}.slot-btn.question,.editor-icon.question,.asset-grid .question,.ping.question{color:var(--emote-question,#c49a39);font-weight:600;font-size:34px;font-family:Inter,-apple-system,sans-serif}.center-preview{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:92px;height:92px;border-radius:9999px;background:var(--surface-elevated,#FDFDF8);border:1px solid var(--border-default,#E4E4DF);z-index:3;font-size:21px;color:var(--text-primary,#1A1A1A);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:4px}.center-preview span{font-size:11px;color:var(--text-secondary,#888883)}.center-preview:hover{background:var(--surface-hover,#EEEEE9)}.wheel-tip{font-size:12px;color:var(--text-secondary,#888883);text-align:center}
+.editor{padding:0 0 0 24px;border-left:1px solid var(--border-default,#E4E4DF);min-height:386px}.editor-head{display:flex;gap:8px;align-items:center;min-height:40px;margin-bottom:24px}.editor-icon{min-width:32px;font-size:26px}.editor-icon img{width:32px;height:32px;object-fit:contain}.editor-title{background:transparent;border:1px solid transparent;border-radius:9999px;min-width:0;width:100%;font-weight:500;font-size:14px;padding:5px 7px}.editor-title:hover{border-color:var(--border-default,#E4E4DF)}.editor-title:focus{background:var(--surface-elevated,#FDFDF8);border-color:var(--border-default,#E4E4DF)}.drawer-close{display:none}.field-caption{font-size:12px;color:var(--text-muted,#6B6B67);margin-bottom:8px}.modes{display:flex;border-radius:9999px;padding:3px;background:var(--surface-hover,#EEEEE9);gap:2px}.modes button{flex:1;border-radius:9999px;padding:6px 8px;font-size:12px;color:var(--text-muted,#6B6B67);white-space:nowrap;border:1px solid transparent}.modes button.active{background:var(--surface-elevated,#FDFDF8);color:var(--text-primary,#1A1A1A);border-color:var(--border-default,#E4E4DF)}.modes button:not(.active):hover{background:var(--selection-bg,#E4E4DF)}.editor-help{margin-top:12px;font-size:12px;color:var(--text-secondary,#888883);line-height:1.65}.fields{display:grid;gap:16px;margin-top:20px}.fields label{display:grid;gap:8px;font-size:12px;color:var(--text-muted,#6B6B67)}.fields textarea,.fields select{width:100%;background:var(--surface-elevated,#FDFDF8);border:1px solid var(--border-default,#E4E4DF);color:var(--text-primary,#1A1A1A);font-size:13px;padding:10px 12px;line-height:1.6}.fields textarea{border-radius:8px;min-height:100px;resize:vertical}.fields select{border-radius:9999px;height:36px;padding-top:0;padding-bottom:0}.fields .check{display:flex;align-items:center;gap:6px;font-size:12px}.check input{accent-color:var(--accent-cta-bg,#3C3F43);width:14px;height:14px;margin:0}.delay-note{font-size:12px;color:var(--text-secondary,#888883);line-height:1.6}.empty-editor{padding:40px 0;color:var(--text-secondary,#888883);line-height:2.5}
+dialog{border:1px solid var(--border-default,#E4E4DF);border-radius:12px;padding:20px;background:var(--surface-elevated,#FDFDF8);color:var(--text-primary,#1A1A1A);width:min(420px,calc(100vw - 32px))}dialog::backdrop{background:var(--scrim,#0004)}.dialog-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.icon-button{width:32px;height:32px;border-radius:9999px;font-size:22px;color:var(--text-secondary,#888883)}.asset-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:6px;margin:20px 0}.asset-grid button{display:grid;place-items:center;height:46px;border-radius:9999px;font-size:26px}.asset-grid button:hover{background:var(--surface-hover,#EEEEE9)}.file-button{display:flex;cursor:pointer;width:100%}.dialog-hint{margin-top:12px;color:var(--text-secondary,#888883);text-align:center}.dialog-intro{color:var(--text-muted,#6B6B67)}#record-key{background:var(--surface,#F2F2ED);border:1px solid var(--border-default,#E4E4DF);border-radius:8px;padding:24px 12px;text-align:center;font-size:20px;margin:20px 0 16px;font-weight:500}.key-presets{display:flex;gap:6px}.key-presets button{border:1px solid var(--border-default,#E4E4DF);border-radius:9999px;padding:5px 12px;font-size:12px}.key-presets button:hover{background:var(--surface-hover,#EEEEE9)}.key-warning{color:var(--text-muted,#6B6B67);margin:16px 0}.dialog-footer{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}
+#live-wheel{position:fixed;z-index:90;pointer-events:none;width:260px;height:260px}#live-wheel .wheel{--wheel-size:260px;width:var(--wheel-size);height:var(--wheel-size);margin:0}#live-wheel .slot-btn.empty{visibility:hidden}#live-wheel .center-preview{width:78px;height:78px;font-size:12px}#live-wheel .center-preview span{font-size:10px}#live-wheel .slot-btn{font-size:26px}#live-wheel .slot-btn.question{font-size:33px}.ping{position:fixed;pointer-events:none;z-index:110;font-size:58px;animation:ping 1.1s ease-out forwards}.ping.question{font-size:72px}.ping img{width:70px;height:70px;object-fit:contain}@keyframes ping{0%{opacity:1;transform:translate(-50%,-40%) scale(.7)}18%{transform:translate(-50%,-70%) scale(1.08)}32%{transform:translate(-50%,-70%) scale(1)}75%{opacity:1}100%{opacity:0;transform:translate(-50%,-90%)}}[hidden]{display:none!important}@media(prefers-reduced-motion:reduce){*{transition:none!important}.ping{animation:none;transform:translate(-50%,-70%)}}
+@media(max-width:600px){#prototype{padding:12px}.workspace{grid-template-columns:1fr;gap:24px}.editor{border-left:0;border-top:1px solid var(--border-default,#E4E4DF);padding:24px 0 0;min-height:0}.wheel-section{max-width:360px;width:100%;margin:0 auto}.app-header{align-items:start}.desktop-preview>header{flex-wrap:wrap}#preview-stage{padding:16px;gap:12px}.context-note>span{display:none}.context-note>p{font-size:12px}.example-wheel .wheel{--wheel-size:210px;width:var(--wheel-size);height:var(--wheel-size)}.keybar button{padding:0 8px;gap:8px}.keybar button>span{display:none}.review-bar{padding:0 16px}}
+
+.pin-badge{position:absolute;right:-3px;top:-4px;width:15px;height:15px;display:grid;place-items:center;background:var(--surface-elevated,#FDFDF8);border-radius:9999px;color:var(--text-muted,#6B6B67)}.pin-badge svg{width:10px;height:10px}.slot-btn img{width:76%;height:76%}
+.center-add{width:44px;height:44px;font-size:26px;font-weight:400;padding:0;touch-action:none}.center-add[aria-pressed=true]{background:var(--selection-bg,#E4E4DF);border-color:var(--text-muted,#6B6B67)}#wheel.is-placing{touch-action:none}#wheel.is-placing .slot-btn,#wheel.is-placing .slice{pointer-events:none}#wheel .slice.draft{fill:var(--selection-bg,#E4E4DF);stroke:var(--text-muted,#6B6B67);stroke-width:1.2;stroke-dasharray:4 3}.draft-slot{color:var(--text-muted,#6B6B67);border:1px dashed var(--text-muted,#6B6B67)}.wheel-tip{min-height:36px;padding:0 6px}.editor[inert]{opacity:.5}
+.placing-emote,.placing-emote *{cursor:grabbing!important}#placement-cursor{position:fixed;left:0;top:0;width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--text-muted,#6B6B67);border-radius:10px;background:var(--surface-elevated,#FDFDF8);color:var(--text-primary,#1A1A1A);font-size:25px;line-height:1;pointer-events:none;z-index:100;will-change:transform}
+.wheel-slot-actions{position:absolute;inset:0;pointer-events:none;z-index:4}.wheel-slot-actions button{position:absolute;transform:translate(-50%,-50%);width:28px;height:28px;padding:6px;display:grid;place-items:center;border:1px solid var(--border-default,#E4E4DF);border-radius:9999px;background:var(--surface-elevated,#FDFDF8);color:var(--text-muted,#6B6B67);pointer-events:auto}.wheel-slot-actions button:hover{background:var(--surface-hover,#EEEEE9);border-color:var(--text-muted,#6B6B67)}.wheel-slot-actions button[aria-pressed=true]{background:var(--accent-cta-bg,#3C3F43);color:var(--accent-cta-fg,#FCFCFC);border-color:transparent}.wheel-slot-actions button[aria-disabled=true]{opacity:.4;cursor:default}.wheel-slot-actions button[aria-disabled=true]:hover{background:var(--surface-elevated,#FDFDF8);border-color:var(--border-default,#E4E4DF)}.wheel-slot-actions svg{width:14px;height:14px}
+@media(max-width:600px){.wheel{--wheel-size:min(304px,80vw)}}
+.runtime-status{margin-top:20px;border-top:1px solid var(--border-default,#e4e4df);padding-top:12px;font-size:12px;color:var(--text-secondary,#888883)}
+.status{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status #detail{flex:1}#feedback.error,#detail.error{color:var(--text-primary,#1a1a1a)}
+#permissions{margin-top:12px;font-size:12px;color:var(--text-muted,#6b6b67)}#permissions summary{cursor:pointer}#permissions p{margin:8px 0}
+#recovery{border:1px solid var(--border-default,#e4e4df);padding:12px;border-radius:8px;margin-top:12px}#recovery code{display:block;overflow-wrap:anywhere;margin:8px 0}
+.task-refresh{justify-self:start}button:disabled{cursor:default;opacity:.45}.editor{min-height:330px}.image-name{overflow-wrap:anywhere;font-size:11px}
+.asset-grid{grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.asset-grid button{height:auto;min-height:88px;padding:6px 2px;gap:5px;border-radius:8px;align-content:start}.asset-grid img{width:56px;height:56px;object-fit:contain;pointer-events:none}.asset-grid span{font-size:11px;line-height:1.35;overflow-wrap:anywhere}.asset-grid button:focus-visible{background:var(--surface-hover,#EEEEE9)}
+@media(max-width:360px){.asset-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
+#model-settings{display:grid;gap:8px}.model-row{display:flex;gap:4px;min-width:0}.model-row select{min-width:0;flex:1;text-overflow:ellipsis}.model-tuning{display:flex;align-items:end;gap:16px;flex-wrap:wrap}.model-tuning>label:not(.check){flex:1;min-width:100px}.model-tuning>.check{min-height:36px}#model-message:empty,.model-tuning:empty{display:none}#model-message.error{color:var(--text-primary,#1A1A1A)}
+
+.modes{flex-wrap:wrap}.modes button{min-width:0;flex:1 1 72px}.target-field{display:grid;gap:8px}.target-modes{display:flex;flex-wrap:wrap;gap:8px}.target-modes .selected{border-color:var(--text-primary,#1A1A1A)}.target-binding{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0}.target-binding>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+
+.modes{flex-wrap:nowrap}.modes button{flex:1 1 0;padding:7px 10px}.target-field>label{display:grid;gap:8px}.target-chooser{border:1px solid var(--border-default,#e4e4df);border-radius:10px;padding:12px}.chooser-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.target-chooser input{width:100%;height:34px;border:1px solid var(--border-default,#e4e4df);border-radius:8px;background:var(--surface-elevated,#fdfdf8);padding:0 10px}.target-options{max-height:240px;overflow-y:auto;margin-top:8px}.target-options>button{display:grid;gap:3px;width:100%;text-align:left;padding:10px 8px;border-radius:7px}.target-options>button:hover{background:var(--surface-hover,#eeeee9)}.target-options>button>span,.target-options small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.target-options small{color:var(--text-muted,#6b6b67);font-size:11px}.target-options>p{padding:12px 4px}
+
+.wheel-action-hint{position:absolute;transform:translateX(-50%);width:176px;max-width:calc(100% - 16px);padding:8px 10px;border:1px solid var(--border-default,#E4E4DF);border-radius:8px;background:var(--surface-elevated,#FDFDF8);color:var(--text-primary,#1A1A1A);font-size:12px;line-height:1.5;z-index:5;pointer-events:none}
+.editor-name{flex:1;min-width:0}.field-label{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap}.field-error{display:block;font-size:12px;font-weight:400;color:var(--error-flat,#c24234);line-height:1.5}.editor-name .field-error{padding-left:7px}#editor [aria-invalid=true]{border-color:var(--error-flat,#c24234)}.slot-btn.incomplete::after{content:'!';position:absolute;left:-3px;top:-4px;width:14px;height:14px;display:grid;place-items:center;border:1px solid var(--error-flat,#c24234);border-radius:50%;background:var(--surface-elevated,#FDFDF8);color:var(--error-flat,#c24234);font:600 11px/1 -apple-system,BlinkMacSystemFont,sans-serif;pointer-events:none}
diff --git a/desktop-emotes/settings.html b/desktop-emotes/settings.html
new file mode 100644
index 00000000..8cb126e3
--- /dev/null
+++ b/desktop-emotes/settings.html
@@ -0,0 +1,17 @@
+
+Cindy Ping
+
+
+ Wheel shortcut Hold shortcut, move to an emote, release to send
⌘ G Change shortcut
+
+
Wheel Click to configure · Drag to reorder · Pin position
Click or hold + and move to choose a position
+
Retry applying Refresh status
+ The previous delivery is unconfirmed. Check it in Cindy before resuming; it will not be resent.
I checked the task — resume
+ Input and window capture permissions Uses Cindy's existing system permissions. Display-only does not capture content; screen recording is only used for task screenshots.
+
++
+
Choose an emote ×
Choose a local image Keep the original local image file. Built-in emotes are included with the plugin.
+
Wheel shortcut × Press a key or a combination.
⌘ G
⌥ Option ⌃ Control G F8
+
+
+
diff --git a/desktop-emotes/settings.js b/desktop-emotes/settings.js
new file mode 100644
index 00000000..04b81296
--- /dev/null
+++ b/desktop-emotes/settings.js
@@ -0,0 +1,758 @@
+'use strict';
+const $ = selector => document.querySelector(selector);
+let slots = [], selected = 0, assetSlot = null, dragged = null, placement = null, assetRequest = 0;
+let pointer = { x: 0, y: 0 }, hintTimer;
+const { layout, geometry, slicePath, polar, normalizedAngle } = EmoteWheel;
+const strings = {
+ 'zh-CN': {
+ title: 'Cindy Ping', enable: '启用', intro: '每次表情立即出现。绑定任务后,最后一次触发满 2 秒才开始取材和执行。',
+ palette: '轮盘快捷键', refresh: '刷新', record: '录制快捷键', recording: '请按下组合键…', preparing: '准备录制…',
+ shortcutHint: '可录制单键、修饰键或组合键,选择后立即生效。Esc 取消录制。',
+ gesture: '按住快捷键,移向表情,松键发送',
+ SHORTCUT_INVALID: '请选择单键、修饰键或组合键;Esc 用于取消。',
+ scope: '支持 macOS 14+。每个表情可独立选择模型、新建任务或绑定本插件已有任务;项目与权限沿用插件任务设置。',
+ recovery: '上一条任务的结果尚未确认,自动投递已暂停。请到 Cindy 查看下方任务;确认后可恢复。不会自动重发旧内容。',
+ reviewed: '已检查旧任务,恢复', emotes: '轮盘表情 · 从上方顺时针', add: '+ 添加', permissions: '输入与窗口取材权限',
+ permissionHint: '使用 Cindy 已有的系统权限,不单独申请授权。辅助功能用于全局快捷键和读取窗口文本;屏幕录制仅用于任务截图。只展示不会取材。',
+ INPUT_ACCESS_REQUIRED: '暂时无法使用 Cindy 的辅助功能权限。请确认 Cindy 版本支持权限复用,在插件详情停用后重新启用,再刷新状态。本插件不会另行申请授权。',
+ INPUT_HOOK_FAILED: '轮盘输入未能启动。请在插件详情停用后重新启用,再刷新状态。',
+ accessibility: '辅助功能', screen: '屏幕录制', tasks: '刷新可选任务', save: '保存并应用',
+ none: '只展示表情', undo: '撤销', new: '新建任务', existing: '继续本插件的任务', remove: '移除', image: '选择图片', clearImage: '使用 emoji',
+ imageHint: '引用本地图片,请保留原文件', name: '表情名称', prompt: '固定指令', promptPlaceholder: '填写你自己的指令',
+ screenshot: '附上目标窗口截图', target: '选择任务', choose: '先刷新并选择任务', missing: '所选任务未在当前列表,保存时保留原绑定',
+ unsaved: '有未保存的修改', saved: '已保存', loading: '正在连接…', enabled: '快捷键已启用', disabled: '尚未启用',
+ displayed: '表情已显示,没有后续行为', undone: '已撤销上一条待执行动作', undoUnavailable: '当前没有可撤销的动作', waiting: '表情已显示,等待最后一次触发满 2 秒', capturing: '正在获取目标窗口内容',
+ sent: '已交给 Cindy 任务', configured: '配置已生效', idle: '准备就绪', error: '需要处理', granted: '已可用,无需授权', denied: '系统尚未允许', unknown: '尚未检查',
+ newLabel: '新表情',
+ imageUnavailable: '部分图片无法读取,当前使用 emoji。请重新选择图片。', hotkeyConflict: '快捷键被其他应用占用。请解除占用后重新保存。',
+ tasksReady: '任务列表已更新', noTasks: '还没有本插件创建的任务。先用一个表情新建任务。',
+ TARGETS_UNSUPPORTED: '当前 Cindy 尚未提供项目、任务和伙伴接口,请更新支持这些功能的版本;原有绑定保留。',
+ TARGET_LIST_TOO_LARGE: '目标列表超过桌面选择器容量,本次没有投递。',
+ TARGET_UNAVAILABLE: '目标已不可用,请重新选择,不会自动投递到其他位置。',
+ DELIVERY_UNCERTAIN: '上次投递结果未知,请先到 Cindy 检查,不会自动重发。',
+ DELIVERY_REJECTED: 'Cindy 拒绝了本次输入,没有排队或自动重试。',
+ ATTACHMENTS_UNSUPPORTED: '当前 Cindy 不支持消息图片附件,请更新支持此能力的客户端后再试。',
+ INVALID_ATTACHMENTS: '截图格式或大小不符合要求,本次没有投递。',
+ CONTEXT_TOO_LARGE: '指令与窗口来源信息过长,本次没有投递。请缩短表情指令后再试。',
+ REQUEST_KEY_CONFLICT: '本次投递参数已改变,请重新发出表情。',
+ CONFIG_INVALID: '请补全表情名称、内容,以及任务的固定指令和目标。', MACOS_REQUIRED: '第一版仅支持 macOS 14+。',
+ HELPER_START_FAILED: '桌面进程未能启动。请确认系统为 macOS 14+,重新保存设置。', HELPER_STOPPED: '桌面进程已停止,请重新保存设置启动。',
+ CAPTURE_TIMEOUT: '读取窗口超时,桌面进程已停止。请重新保存设置后再试。', CAPTURE_BUSY: '上一次窗口读取尚未结束,请稍后重新发表情。',
+ NO_CONTENT: '没有取得窗口内容。请检查采集权限和目标窗口,再重新发表情。', TASK_DIRECTORY_MISSING: '目标任务没有可用的本地目录,请检查插件的任务设置。',
+ SUPERSEDED: '已丢弃,新的表情操作已取代这次操作。', PERMISSION_DENIED: 'Cindy 未允许任务操作,请检查插件任务权限。表情展示仍可使用。',
+ TASK_UNAVAILABLE: '绑定任务已不可用,请选择仍活跃的本插件任务。', TASKS_UNSUPPORTED: '当前客户端未提供可用的普通任务接口;请先使用「只展示表情」。',
+ MODELS_UNSUPPORTED: '当前客户端不支持单独选择任务模型。请更新 Cindy,或选择沿用默认模型。',
+ MODEL_UNAVAILABLE: '所选模型、推理强度或 Fast 已不可用。请刷新模型列表并重新选择,不会自动切换到其他模型。',
+ TASK_BUSY: '任务或附件服务正忙,本次投递尚未确认。请先检查 Cindy 中的任务状态。',
+ STALE_REVISION: '任务配置已变化,本次未投递。请检查任务后重新发表情。',
+ HOST_NOT_READY: 'Cindy 的任务服务暂未就绪。请检查任务状态后再试。',
+ CHECK_PREVIOUS_TASK: '上一条任务尚未确认停止或结果未知。请先到 Cindy 检查,再点击恢复。',
+ LOCAL_OPERATION_FAILED: '本地操作未完成,请检查图片和任务目录后重试。', SAVE_FAILED: '设置未能保存,请重新打开插件详情页再试。',
+ CONFIG_TOO_LARGE: '配置太长,请缩短固定指令后再保存。', OPERATION_FAILED: '操作未能确认完成。请查看 Cindy 中的任务和模型设置,不要重复投递同一内容。',
+ CONNECTION_TIMEOUT: '未能连接插件后台,请重新打开插件详情页再试。',
+ },
+ en: {
+ title: 'Cindy Ping', enable: 'Enable', intro: 'Every emote appears immediately. A bound task starts capturing and processing 2 seconds after the last trigger.',
+ palette: 'Wheel shortcut', refresh: 'Refresh', record: 'Record shortcut', recording: 'Press a shortcut…', preparing: 'Preparing…',
+ shortcutHint: 'Record a single key, modifier or combination. Changes apply immediately. Esc cancels recording.',
+ gesture: 'Hold shortcut, move to an emote, release to send',
+ SHORTCUT_INVALID: 'Choose a single key, modifier or combination. Esc is reserved for cancelling.',
+ scope: 'macOS 14+. Choose a model and a new or existing plugin task for each emote. Project and permissions follow plugin Task settings.',
+ recovery: 'The previous task has an unconfirmed outcome. Automatic delivery is paused. Inspect the task in Cindy, then resume. Old content is never automatically resent.',
+ reviewed: 'I checked the task — resume', emotes: 'Wheel · clockwise from top', add: '+ Add', permissions: 'Input and window capture permissions',
+ permissionHint: 'Uses Cindy\'s existing system permissions without requesting separate access. Accessibility handles wheel drags and window text; screen recording is only for task screenshots. Display-only never captures content.',
+ INPUT_ACCESS_REQUIRED: 'Cindy\'s Accessibility access is unavailable. Check that Cindy supports permission sharing, disable and re-enable this plugin in its details, then refresh. This plugin will not request separate access.',
+ INPUT_HOOK_FAILED: 'Wheel input could not start. Disable and re-enable the plugin in its details, then refresh.',
+ accessibility: 'Accessibility', screen: 'Screen recording', tasks: 'Refresh tasks', save: 'Save and apply',
+ none: 'Display only', undo: 'Undo', new: 'Create task', existing: 'Continue a plugin task', remove: 'Remove', image: 'Choose image', clearImage: 'Use emoji',
+ imageHint: 'References a local image; keep the original file', name: 'Emote name', prompt: 'Fixed prompt', promptPlaceholder: 'Write your own instructions',
+ screenshot: 'Include target window screenshot', target: 'Task', choose: 'Refresh and choose a task', missing: 'Selected task is not in this page; its binding is retained',
+ unsaved: 'Unsaved changes', saved: 'Saved', loading: 'Connecting…', enabled: 'Shortcuts enabled', disabled: 'Not enabled',
+ displayed: 'Emote displayed; no further action', undone: 'Previous pending action undone', undoUnavailable: 'No action available to undo', waiting: 'Emote displayed; waiting 2 seconds after the last trigger', capturing: 'Capturing target window',
+ sent: 'Delivered to a Cindy task', configured: 'Settings applied', idle: 'Ready', error: 'Action needed', granted: 'Available; no authorization needed', denied: 'Not allowed by macOS', unknown: 'Not checked',
+ newLabel: 'New emote',
+ imageUnavailable: 'Some images could not be read; using emoji. Choose the image again.', hotkeyConflict: 'Another app is using a shortcut. Release it and save again.',
+ tasksReady: 'Task list refreshed', noTasks: 'No plugin tasks yet. Use an emote to create one first.',
+ TARGETS_UNSUPPORTED: 'Update Cindy to a version that supports project, task and companion destinations. Existing bindings are retained.',
+ TARGET_LIST_TOO_LARGE: 'The destination list exceeds the desktop picker capacity. Nothing was delivered.',
+ TARGET_UNAVAILABLE: 'The target is unavailable. Choose it again; no replacement will be used automatically.',
+ DELIVERY_UNCERTAIN: 'Previous delivery is uncertain. Check Cindy before trying again.',
+ DELIVERY_REJECTED: 'Cindy declined this input; it was not queued or retried.',
+ ATTACHMENTS_UNSUPPORTED: 'This Cindy version does not support message images. Update to a client with attachment support.',
+ INVALID_ATTACHMENTS: 'The screenshot format or size is unsupported. Nothing was delivered.',
+ CONTEXT_TOO_LARGE: 'The instruction and window metadata are too long. Shorten the emote instruction and retry.',
+ REQUEST_KEY_CONFLICT: 'Delivery parameters changed. Send the emote again.',
+ CONFIG_INVALID: 'Complete the name, emote, task prompt and target.', MACOS_REQUIRED: 'This version requires macOS 14+.',
+ HELPER_START_FAILED: 'Desktop helper could not start. Check macOS 14+ and save again.', HELPER_STOPPED: 'Desktop helper stopped. Save settings to restart it.',
+ CAPTURE_TIMEOUT: 'Window capture timed out and the helper stopped. Save settings and try again.', CAPTURE_BUSY: 'Previous capture is still running. Try another emote shortly.',
+ NO_CONTENT: 'No window content captured. Check capture permissions and the target window, then trigger again.', TASK_DIRECTORY_MISSING: 'No local directory for this task. Check plugin Task settings.',
+ SUPERSEDED: 'Discarded because a newer emote replaced this action.', PERMISSION_DENIED: 'Cindy did not allow task access. Check plugin task permissions; display-only still works.',
+ TASK_UNAVAILABLE: 'The task is unavailable. Select an active task created by this plugin.', TASKS_UNSUPPORTED: 'This client does not offer the required task API. Use display-only for now.',
+ MODELS_UNSUPPORTED: 'This client does not support per-task model selection. Update Cindy or inherit the default model.',
+ MODEL_UNAVAILABLE: 'The model, effort or Fast option is unavailable. Refresh and choose again; models are never switched automatically.',
+ TASK_BUSY: 'The task or attachment service is busy. Delivery is unconfirmed; check the task in Cindy first.',
+ STALE_REVISION: 'Task settings changed; nothing was sent. Inspect the task before triggering again.',
+ HOST_NOT_READY: 'Cindy task service is not ready. Check the task state before retrying.',
+ CHECK_PREVIOUS_TASK: 'The previous input has not stopped or has an unknown outcome. Inspect it in Cindy before resuming.',
+ LOCAL_OPERATION_FAILED: 'Local operation failed. Check the image and task directory before retrying.', SAVE_FAILED: 'Could not save settings. Reopen plugin details and try again.',
+ CONFIG_TOO_LARGE: 'Configuration is too large. Shorten your prompts.', OPERATION_FAILED: 'Completion is unconfirmed. Check the task and model settings in Cindy; do not resend the same content.',
+ CONNECTION_TIMEOUT: 'Could not connect to the plugin. Reopen plugin details and try again.',
+ },
+};
+const pinIcon = ' ';
+const deleteIcon = ' ';
+function escapeText(value) {
+ return String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
+}
+function icon(emote) {
+ const bundled = CindyPingLibrary.presets().some(item => item.imagePath === emote.imagePath);
+ const source = bundled ? emote.imagePath : previews[emote.id];
+ return source ? ` ` : escapeText(['❓', '?'].includes(emote.emoji) ? '?' : emote.emoji || '🖼️');
+}
+function showDeleteHint() {
+ const hint = $('#delete-hint');
+ clearTimeout(hintTimer); hint.hidden = false;
+ hintTimer = setTimeout(() => { hint.hidden = true; }, 2800);
+}
+function arrange() {
+ const current = slots[selected];
+ layout(slots); selected = Math.max(0, slots.indexOf(current));
+}
+function wheelActionsMarkup(emote) {
+ const pinAt = polar(70, emote.angle - 90 - 14), deleteAt = polar(70, emote.angle - 90 + 14);
+ const pinned = emote.pinAngle !== null;
+ return `
+ ${pinIcon}
+ ${deleteIcon}
+ ${L('最少保留 4 个表情;可点击「更换表情」。', 'Keep at least 4 emotes. Use Change to replace one.')}
+
`;
+}
+function wheelMarkup(active, preview = false, items = slots) {
+ return ` ${items.map((emote, i) => ` `).join('')} ` +
+ items.map((emote, i) => {
+ const p = polar(103, emote.angle - 90), size = Math.min(48, 103 * geometry(i, items).gap * Math.PI / 180 * .8);
+ const font = Math.min(['?', '❓', '?'].includes(emote.emoji) ? 34 : 27, size * .72);
+ return `${icon(emote)}${!preview && emote.pinAngle !== null ? `${pinIcon} ` : ''} `;
+ }).join('') + (!preview && !placement && items[active] ? wheelActionsMarkup(items[active]) : '') +
+ (preview ? `${L('取消', 'Cancel')}${L('移向表情', 'Choose an emote')} ` : `${placement ? '×' : '+'} `);
+}
+function renderPlacementFeedback() {
+ const moving = Boolean(placement && !placement.locked);
+ document.body.classList.toggle('placing-emote', moving);
+ $('#placement-cursor').hidden = !moving;
+ if (moving) {
+ const at = placement.at;
+ $('#placement-cursor').style.transform = `translate3d(${at.x + 14}px,${at.y + 16}px,0) rotate(-8deg)`;
+ }
+ $('#slot-count').textContent = `${slots.length}${L(' 个', '')}${placement?.items ? ' + 1' : ''}`;
+ $('#placement-hint').textContent = !placement ? L('点击或按住 +,移动鼠标选择添加位置', 'Click or hold + and move to choose a position') : placement.locked ? L('已选好位置,选择要添加的表情', 'Position selected. Choose an emote') : placement.items ? (placement.pointerId === null ? L('单击放入此处 · 移回中心取消', 'Click to place · Return to center to cancel') : L('松开放入此处 · 移回中心取消', 'Release to place · Return to center to cancel')) : L('带着 + 移向表情之间 · Esc 取消', 'Move + between emotes · Esc cancels');
+ $('#editor').inert = Boolean(placement);
+}
+function renderWheel() {
+ $('#wheel').innerHTML = wheelMarkup(placement ? null : selected, false, placement?.items || slots);
+ $('#wheel').classList.toggle('is-placing', Boolean(placement));
+ renderPlacementFeedback();
+ renderValidation();
+}
+function beginPlacement(pointerId = null) {
+ // Hit detection stays anchored to the original wheel, not its moving preview borders.
+ const base = slots.map(emote => ({ ...emote }));
+ const gaps = base.map((emote, i) => {
+ const span = (i + 1 < base.length ? base[i + 1].angle : base[0].angle + 360) - emote.angle;
+ return { start: emote.angle, span, angle: normalizedAngle(emote.angle + span / 2) };
+ });
+ placement = { pointerId, initialPress: true, moved: false, items: null, locked: false, base, gaps, gapIndex: -1, at: { ...pointer } };
+ renderWheel();
+}
+function movePlacement(at) {
+ if (!placement || placement.locked) return;
+ placement.at = { ...at };
+ const box = $('#wheel').getBoundingClientRect();
+ const dx = at.x - box.x - box.width / 2, dy = at.y - box.y - box.height / 2;
+ // Separate enter/exit radii keep hand jitter near the center from toggling the draft.
+ const outsideCenter = Math.hypot(dx, dy) > box.width * (placement.items ? 44 : 56) / 320;
+ const angle = normalizedAngle(Math.atan2(dy, dx) * 180 / Math.PI + 90);
+ placement.moved ||= outsideCenter;
+ const { gaps, gapIndex } = placement;
+ let nextGap = outsideCenter ? gaps.findIndex(gap => normalizedAngle(angle - gap.start) < gap.span) : -1;
+ if (outsideCenter && gapIndex !== -1) {
+ const gap = gaps[gapIndex];
+ const margin = Math.min(7, gap.span / 8, gaps[(gapIndex + gaps.length - 1) % gaps.length].span / 4, gaps[(gapIndex + 1) % gaps.length].span / 4);
+ if (normalizedAngle(angle - gap.start + margin) <= gap.span + 2 * margin) nextGap = gapIndex;
+ }
+ if (nextGap !== gapIndex) {
+ placement.gapIndex = nextGap;
+ // Rebalance once for this gap, using the original snapshot rather than pointer angle.
+ placement.items = nextGap === -1 ? null : layout([
+ ...placement.base.map(emote => ({ ...emote })),
+ { id: 'placement-preview', label: L('新表情位置', 'New emote position'), emoji: '+', angle: gaps[nextGap].angle, pinAngle: null, draft: true },
+ ]);
+ renderWheel();
+ } else renderPlacementFeedback();
+}
+function cancelPlacement() {
+ if (!placement) return;
+ const id = placement.pointerId;
+ placement = null;
+ if (id !== null && $('#wheel').hasPointerCapture(id)) $('#wheel').releasePointerCapture(id);
+ renderWheel();
+}
+function confirmPlacement() {
+ if (!placement?.items) return;
+ placement.locked = true; renderWheel(); openAssets();
+}
+function openAssets(emote = null) {
+ assetSlot = emote; assetRequest++; renderLibrary();
+ $('#asset-hint').textContent = emote
+ ? L('仅更换表情图案,保留当前指令、任务绑定和位置。', 'Changes the image while keeping your prompt, task and position.')
+ : L('新增表情默认只展示;行为和指令由你配置。', 'New emotes display only. Configure your own action and instructions.');
+ $('#assets').showModal();
+}
+function chooseEmote(emoji, label, imagePath = '', preview = '') {
+ if (!$('#assets').open) return;
+ if (assetSlot) {
+ Object.assign(assetSlot, { emoji, label, imagePath }); previews[assetSlot.id] = preview; selected = slots.indexOf(assetSlot);
+ } else if (placement?.locked) {
+ const id = crypto.randomUUID();
+ slots = placement.items.map(emote => emote.draft ? { id, emoji, label, imagePath, action: 'none', prompt: '', taskId: '', screenshot: true, angle: emote.angle, pinAngle: null } : emote);
+ selected = slots.findIndex(emote => emote.id === id); previews[id] = preview;
+ } else return;
+ assetRequest++; assetSlot = null; placement = null;
+ $('#assets').close(); renderWheel(); renderEditor(); change();
+}
+$('#assets').addEventListener('close', () => { if (!$('#assets').open) { assetRequest++; assetSlot = null; cancelPlacement(); } });
+document.addEventListener('pointerdown', event => {
+ if (event.button !== 0 || placement?.locked) return;
+ if (!placement && !event.target.closest('#wheel .center-add')) return;
+ event.preventDefault(); event.stopPropagation();
+ if (!placement) beginPlacement(event.pointerId);
+ else { placement.pointerId = event.pointerId; placement.initialPress = false; }
+ $('#wheel').setPointerCapture(event.pointerId);
+ movePlacement({ x: event.clientX, y: event.clientY });
+}, true);
+document.addEventListener('pointerup', event => {
+ if (!placement || placement.locked || placement.pointerId !== event.pointerId) return;
+ event.preventDefault(); event.stopPropagation();
+ movePlacement({ x: event.clientX, y: event.clientY });
+ placement.pointerId = null;
+ if ($('#wheel').hasPointerCapture(event.pointerId)) $('#wheel').releasePointerCapture(event.pointerId);
+ if (placement.items) confirmPlacement();
+ else if (placement.initialPress && !placement.moved) renderWheel();
+ else cancelPlacement();
+}, true);
+$('#wheel').onpointercancel = cancelPlacement;
+document.addEventListener('click', event => {
+ if (placement && !event.target.closest('#assets')) { event.preventDefault(); event.stopPropagation(); }
+}, true);
+$('#wheel').onclick = event => {
+ if (placement) return;
+ const action = event.target.closest('[data-wheel-action]');
+ if (action) {
+ const index = slots.findIndex(emote => emote.id === action.dataset.emoteId);
+ if (index === -1) return;
+ const emote = slots[index]; selected = index;
+ if (action.dataset.wheelAction === 'pin') {
+ const pinned = emote.pinAngle !== null;
+ emote.pinAngle = pinned ? null : emote.angle;
+ if (pinned) arrange();
+ } else {
+ if (slots.length <= 4) { showDeleteHint(); return; }
+ slots.splice(index, 1); selected = Math.min(index, slots.length - 1); arrange();
+ }
+ renderWheel(); renderEditor(); change();
+ return;
+ }
+ const button = event.target.closest('[data-slot], [data-slice]');
+ if (button) { selected = Number(button.dataset.slot ?? button.dataset.slice); renderWheel(); renderEditor(); }
+ else if (event.target.closest('.center-add') && event.detail === 0) beginPlacement();
+};
+$('#wheel').ondragstart = event => {
+ const button = event.target.closest('[data-slot]');
+ if (placement || !button || slots[button.dataset.slot].pinAngle !== null) return event.preventDefault();
+ dragged = slots[button.dataset.slot]; event.dataTransfer.setData('text/plain', dragged.id); event.dataTransfer.effectAllowed = 'move';
+};
+$('#wheel').ondragover = event => {
+ if (!dragged) return;
+ const button = event.target.closest('[data-slot], [data-slice]');
+ if (!button) return;
+ const index = Number(button.dataset.slot ?? button.dataset.slice);
+ $('#wheel').querySelectorAll('.dragover').forEach(path => path.classList.remove('dragover'));
+ if (slots[index].pinAngle !== null) return;
+ event.preventDefault(); $(`#wheel [data-slice="${index}"]`).classList.add('dragover');
+};
+$('#wheel').ondrop = event => {
+ const button = event.target.closest('[data-slot], [data-slice]'); if (!button || !dragged) return;
+ event.preventDefault(); const target = slots[Number(button.dataset.slot ?? button.dataset.slice)];
+ if (target.pinAngle !== null) return;
+ [target.angle, dragged.angle] = [dragged.angle, target.angle]; slots.sort((a, b) => a.angle - b.angle);
+ selected = slots.indexOf(dragged); dragged = null; renderWheel(); renderEditor(); change();
+};
+$('#wheel').ondragend = () => { dragged = null; renderWheel(); };
+function renderLibrary() {
+ const locale = T === strings['zh-CN'] ? 'zh-CN' : 'en';
+ $('#asset-grid').innerHTML = CindyPingLibrary.presets(locale).map(preset => `${escapeText(preset.label)} `).join('');
+}
+$('#asset-grid').onclick = event => {
+ const button = event.target.closest('[data-asset]'); if (!button) return;
+ const preset = CindyPingLibrary.presets(T === strings['zh-CN'] ? 'zh-CN' : 'en').find(item => item.id === button.dataset.asset);
+ if (preset) chooseEmote(preset.emoji, preset.label, preset.imagePath);
+};
+
+let config, configRevision, T = strings.en, taskList = [], previews = {}, shortcut, pendingKey;
+let modelList = [], modelsLoaded = false, modelsLoading = false, modelsError = '', tasksLoaded = false, tasksLoading;
+let targetSelectionRevision = 0, destinationPicker = null;
+let editRevision = 0, appliedRevision = 0, saving = false, recording = false, recordingTimer;
+const channel = new BroadcastChannel('desktop-emotes-v1');
+const pending = new Map();
+const L = (zh, en) => T === strings['zh-CN'] ? zh : en;
+function localize() {
+ const labels = {
+ '.keybar > div > span': [T.palette, T.palette],
+ '#shortcut > span': ['更改快捷键', 'Change shortcut'],
+ '.section-head h2': ['轮盘', 'Wheel'],
+ '.section-hint': ['点击配置 · 拖动排序 · Pin 固定位置', 'Click to configure · Drag to reorder · Pin position'],
+ '#retry': ['重试应用', 'Retry applying'], '#refresh': ['刷新状态', 'Refresh status'],
+ '#recovery p': [T.recovery, T.recovery], '#reviewed': [T.reviewed, T.reviewed],
+ '#assets h3': ['选择表情', 'Choose an emote'],
+ '#custom-image': ['选择本地图片', 'Choose a local image'],
+ '#assets .dialog-hint': ['本地图片引用原文件,请保留;内置表情随插件保存。', 'Keep the original local image file. Built-in emotes are included with the plugin.'],
+ '#keys h3': [T.palette, T.palette], '#keys .dialog-intro': ['按下单键或组合键。', 'Press a key or a combination.'],
+ '#keys .dialog-footer [data-close]': ['取消', 'Cancel'], '#use-key': ['使用此快捷键', 'Use shortcut'],
+ '#gesture-hint': [T.gesture, T.gesture], '#permissions summary': [T.permissions, T.permissions],
+ '#permission-hint': [T.permissionHint, T.permissionHint],
+ };
+ for (const [selector, values] of Object.entries(labels)) $(selector).textContent = L(...values);
+ for (const [selector, values] of Object.entries({
+ '#shortcut': ['更改轮盘快捷键', 'Change wheel shortcut'], '#wheel': ['可配置表情轮盘', 'Configurable emote wheel'],
+ '#editor': ['当前表情设置', 'Selected emote settings'],
+ '[data-close="assets"]': ['关闭表情选择', 'Close emote picker'], '[data-close="keys"]': ['关闭快捷键录制', 'Close shortcut recorder'],
+ })) $(selector).setAttribute('aria-label', L(...values));
+}
+function rpc(action, params = {}) {
+ const id = crypto.randomUUID();
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error('CONNECTION_TIMEOUT')); }, action === 'pickImage' ? 125000 : 30000);
+ pending.set(id, { resolve, reject, timer });
+ channel.postMessage({ type: 'request', id, action, params });
+ });
+}
+function feedback(message, error = false) {
+ $('#feedback').textContent = message;
+ $('#feedback').classList.toggle('error', error);
+}
+function report(error) {
+ feedback(error.message === 'STALE_REVISION'
+ ? L('配置已在别处更新,本次修改未保存。请重新打开设置。', 'Settings changed elsewhere. This edit was not saved. Reopen settings.')
+ : T[error.message] || L('操作未完成,请重试。', 'Operation failed. Please retry.'), true);
+}
+channel.onmessage = event => {
+ const message = event.data;
+ if (message?.type === 'response' && pending.has(message.id)) {
+ const entry = pending.get(message.id); pending.delete(message.id); clearTimeout(entry.timer);
+ if (message.ok) entry.resolve(message.result); else entry.reject(new Error(message.code));
+ } else if (message?.type === 'status') renderOutcome(message.outcome);
+ else if (message?.type === 'locale') {
+ T = strings[message.locale] || strings.en;
+ document.documentElement.lang = message.locale; localize();
+ if (config) { renderWheel(); renderEditor(); renderLibrary(); }
+ }
+};
+function draftConfig() { return { version: 2, emotes: slots, wheelShortcut: shortcut }; }
+function emoteErrors(item) {
+ const errors = {};
+ if (!item.label.trim()) errors['emote-name'] = L('请填写名称', 'Enter a name');
+ if (!(item.emoji.trim() || item.imagePath)) errors['change-emote'] = L('请选择表情', 'Choose an emote');
+ if (!['none', 'undo'].includes(item.action) && !item.prompt.trim()) errors.prompt = L('填写指令后生效', 'Add a prompt to apply');
+ if (item.action === 'existing' && !item.target && !item.taskId) errors['task-target'] = L('请选择任务', 'Choose a task');
+ if (item.target && !(['pick', 'recent'].includes(item.target.mode) || item.target.ref?.id || item.target.bindingId)) {
+ errors['destination-preset'] = L('请选择目标', 'Choose a destination');
+ }
+ return errors;
+}
+function renderValidation() {
+ const errors = slots[selected] ? emoteErrors(slots[selected]) : {};
+ document.querySelectorAll('[data-field-error]').forEach(hint => {
+ const id = hint.dataset.fieldError, message = errors[id] || '';
+ hint.textContent = message; hint.hidden = !message;
+ const field = document.getElementById(id);
+ if (message) { field.setAttribute('aria-invalid', 'true'); field.setAttribute('aria-describedby', hint.id); }
+ else { field.removeAttribute('aria-invalid'); field.removeAttribute('aria-describedby'); }
+ });
+ $('#wheel').querySelectorAll('[data-slot]').forEach(button => {
+ const item = slots.find(emote => emote.id === button.dataset.emoteId);
+ if (!item) return;
+ const messages = Object.values(emoteErrors(item));
+ button.classList.toggle('incomplete', messages.length > 0);
+ const label = item.label + (item.pinAngle !== null ? L(',位置已固定', ', pinned') : '');
+ button.setAttribute('aria-label', label + (messages.length ? ` · ${messages.join(' / ')}` : ''));
+ button.title = messages.length ? messages.join(' / ') : '';
+ });
+}
+function completeDraft() {
+ return slots.every(item => Object.keys(emoteErrors(item)).length === 0);
+}
+function change() {
+ editRevision++;
+ $('#shortcut-label').textContent = EmoteShortcuts.label(shortcut);
+ renderValidation();
+ if (!completeDraft()) {
+ if (!$('#feedback').classList.contains('error')) feedback('');
+ return;
+ }
+ void applyChanges();
+}
+async function applyChanges() {
+ if (saving || !config || !completeDraft()) return;
+ saving = true;
+ $('#retry').hidden = true;
+ try {
+ while (appliedRevision < editRevision && completeDraft()) {
+ const revision = editRevision;
+ const next = JSON.parse(JSON.stringify(draftConfig()));
+ feedback(L('正在应用…', 'Applying…'));
+ const result = await rpc('save', { config: next, revision: configRevision });
+ appliedRevision = revision; config = result.config; configRevision = result.revision;
+ if (revision === editRevision) {
+ const selectedId = slots[selected]?.id;
+ slots = config.emotes.map(saved => {
+ // Preserve references held by input handlers and the target picker.
+ const item = slots.find(item => item.id === saved.id);
+ for (const key of Object.keys(item)) if (!(key in saved)) delete item[key];
+ return Object.assign(item, saved);
+ });
+ selected = Math.max(0, slots.findIndex(item => item.id === selectedId));
+ shortcut = config.wheelShortcut;
+ $('#shortcut-label').textContent = EmoteShortcuts.label(shortcut);
+ for (const [id, value] of [['#emote-name', slots[selected].label], ['#prompt', slots[selected].prompt]]) {
+ const field = $(id);
+ if (!field || field.value === value) continue;
+ const cursor = document.activeElement === field ? [field.selectionStart, field.selectionEnd] : null;
+ field.value = value;
+ if (cursor) field.setSelectionRange(...cursor.map(position => Math.min(position, value.length)));
+ }
+ renderWheel(); renderValidation();
+ applyStatus(result);
+ if (result.outcome.issues?.includes('HOTKEY_CONFLICT')) {
+ feedback(L('快捷键已被占用,请换一个键,或解除占用后重试。', 'Shortcut is in use. Choose another key or retry after releasing it.'), true);
+ $('#retry').hidden = false;
+ } else feedback('');
+ }
+ }
+ } catch (error) { report(error); $('#retry').hidden = error.message === 'STALE_REVISION'; }
+ finally { saving = false; }
+}
+function renderOutcome(value) {
+ if (!value) return;
+ $('#status-text').textContent = T[value.phase] || T.idle;
+ if (value.phase === 'sent' && value.delivery) $('#status-text').textContent = value.delivery === 'steer'
+ ? L('已插话,交给当前工作中的 AI', 'Inserted into the current work')
+ : L('已进入队列,空闲时处理', 'Queued for processing when ready');
+ $('#detail').textContent = value.code ? (T[value.code] || T.OPERATION_FAILED) : '';
+ if (value.issues?.includes('INPUT_ACCESS_REQUIRED')) $('#detail').textContent = T.INPUT_ACCESS_REQUIRED;
+ else if (value.issues?.includes('INPUT_HOOK_FAILED')) $('#detail').textContent = T.INPUT_HOOK_FAILED;
+ else if (value.issues?.includes('HOTKEY_CONFLICT')) $('#detail').textContent = L('快捷键被占用,请更换或重试应用。', 'Shortcut is in use; change it or retry.');
+ else if (value.issues?.includes('IMAGE_UNAVAILABLE')) $('#detail').textContent = T.imageUnavailable;
+ $('#detail').classList.toggle('error', value.phase === 'error');
+ if (value.phase === 'error' && value.code === 'CHECK_PREVIOUS_TASK') {
+ $('#recovery').hidden = false; $('#pending-id').textContent = value.taskId || '';
+ }
+}
+function applyStatus(value) {
+ renderOutcome(value.outcome);
+ if (['idle', 'configured'].includes(value.outcome.phase)) $('#status-text').textContent = value.native.enabled ? L('轮盘已就绪', 'Wheel ready') : L('轮盘尚未就绪', 'Wheel unavailable');
+ const permissions = value.native.permissions || {};
+ const label = value => value === true ? T.granted : value === false ? T.denied : T.unknown;
+ $('#permission-status').textContent = `${T.accessibility}:${label(permissions.accessibility)} · ${T.screen}:${label(permissions.screenRecording)}`;
+ $('#gesture-hint').textContent = T.gesture;
+ $('#permissions summary').textContent = T.permissions;
+ $('#permission-hint').textContent = T.permissionHint;
+ const inputIssue = value.native.issues?.find(code => ['INPUT_ACCESS_REQUIRED', 'INPUT_HOOK_FAILED'].includes(code));
+ $('#input-status').hidden = !inputIssue;
+ $('#input-status').textContent = inputIssue ? T[inputIssue] : '';
+ if (inputIssue) $('#permissions').open = true;
+ $('#recovery').hidden = !value.needsReview;
+ $('#pending-id').textContent = value.pending?.taskId || '';
+}
+function destinationPresets() {
+ return [
+ ['existing', 'recent', L('最近活跃的三个会话', 'Three recent conversations')],
+ ['existing', 'pick', L('每次选择会话', 'Choose a conversation each time')],
+ ['existing', 'fixed', L('指定会话', 'A specific conversation')],
+ ['bot', 'recent', L('最近活跃的三个伙伴', 'Three recent companions')],
+ ['bot', 'pick', L('每次选择伙伴', 'Choose a companion each time')],
+ ['bot', 'fixed', L('指定伙伴', 'A specific companion')],
+ ['new', 'pick', L('每次选择项目,新建会话', 'Choose a project for a new conversation')],
+ ['new', 'fixed', L('指定项目,新建会话', 'A specific project for a new conversation')],
+ ];
+}
+function targetFields(emote) {
+ const value = destinationPicker?.emote === emote ? `${destinationPicker.action}:fixed` : `${emote.action}:${emote.target?.mode || 'legacy'}`;
+ const options = destinationPresets();
+ return `${L('发送到', 'Send to')}${!emote.target ? `${L('保留原有任务绑定', 'Keep the existing task binding')} ` : ''}${options.map(([action, mode, label]) => `${label} `).join('')}
+ ${emote.target?.mode === 'fixed' ? `
${escapeText(emote.target.label || L('尚未选择', 'No destination'))} ${L('更换', 'Change')}
` : ''}
+
+
`;
+}
+function renderTargetPicker() {
+ const pane = $('#target-chooser'), picker = destinationPicker;
+ if (!pane || !picker || picker.emote !== slots[selected]) return;
+ pane.hidden = false;
+ pane.innerHTML = `${L('选择目标', 'Choose destination')} ×
`;
+ $('#close-target').onclick = () => { targetSelectionRevision++; destinationPicker = null; renderEditor(); };
+ const draw = () => {
+ const query = $('#target-filter').value.trim().toLocaleLowerCase();
+ const visible = picker.rows.map((target, index) => ({ target, index })).filter(({ target }) =>
+ `${target.label} ${target.detail}`.toLocaleLowerCase().includes(query));
+ $('#target-options').innerHTML = picker.loading ? `${T.loading}
` : !visible.length ? `${L('暂无可选目标', 'No matching destinations')}
` : visible.map(({ target, index }) =>
+ `${escapeText(target.label)} ${escapeText(target.detail || '')} `).join('');
+ pane.querySelectorAll('[data-target-index]').forEach(button => button.onclick = () => {
+ if (destinationPicker !== picker || picker.emote !== slots[selected]) return;
+ const target = picker.rows[Number(button.dataset.targetIndex)];
+ picker.emote.action = picker.action;
+ picker.emote.target = { mode: 'fixed', ref: { kind: target.kind, id: target.id }, label: target.label.slice(0, 200) };
+ if (picker.action === 'new') picker.emote.delivery = 'queue';
+ destinationPicker = null; targetSelectionRevision++; renderEditor(); change();
+ });
+ };
+ $('#target-filter').oninput = draw; draw();
+}
+async function pickFixedTarget(emote, action = emote.action) {
+ const revision = ++targetSelectionRevision;
+ destinationPicker = { emote, action, loading: true, rows: [] }; renderEditor();
+ try {
+ const result = await rpc('targets', { kind: { new: 'project', existing: 'task', bot: 'bot' }[action] });
+ if (revision !== targetSelectionRevision || slots[selected] !== emote || destinationPicker?.emote !== emote) return;
+ destinationPicker.loading = false; destinationPicker.rows = result.targets; renderTargetPicker();
+ } catch (error) { if (revision === targetSelectionRevision) { destinationPicker = null; renderEditor(); report(error); } }
+}
+function renderEditor() {
+ const emote = slots[selected];
+ if (!emote) return;
+ if (destinationPicker && destinationPicker.emote !== emote) { destinationPicker = null; targetSelectionRevision++; }
+ $('#editor').innerHTML = `
+
+ ${L('发出后', 'After sending')}
${[['none', L('只展示','Display')], ['send', L('发送到…','Send to…')]].map(([mode, name]) => { const active = mode === emote.action || (mode === 'send' && emote.action !== 'none'); return `${name} `; }).join('')}
+ ${emote.action === 'none' ? `${L('只发表情,同时撤销上一个尚未生效的动作(2 秒内)。', 'Displays the emote and cancels the previous action during its 2-second wait.')}
` : `
+ ${targetFields(emote)}${emote.action === 'existing' && !emote.target ? `
${T.target} ${T.tasks} ` : ''}
+
+ ${emote.target && ['existing', 'bot'].includes(emote.action) ? `
${L('补充方式', 'Delivery')}${L('排队发送', 'Queue')} ${L('立即插话', 'Insert now')} ${L('空闲时直接处理。忙碌时按所选方式投递;暂时无法插话时会排队,并显示实际结果。', 'Processed immediately when idle. While busy, follows this setting; if insertion is unavailable, queues the input and shows the actual result.')}
` : ''}
+
${L('给 Cindy 的指令', 'Prompt for Cindy')}
+
${T.screenshot}
+
${L('表情立即显示;满 2 秒且目标选定后执行。连续操作只保留最后一次。', 'Displays immediately; runs after 2 seconds and target selection. Only the last action takes effect.')}
+ ${emote.target ? (emote.action === 'new' ? L('在选定项目中新建任务,模型按此表情配置;任务权限由 Cindy 管理。', 'Creates a task in the chosen project with this emote’s model. Cindy manages task permissions.') : L('保留接收任务或伙伴的模型、工作目录和权限。', 'Keeps the recipient’s model, directory and permissions.')) : emote.action === 'existing' ? L('原有绑定继续使用本插件任务,工作目录和权限保持不变。', 'The saved binding continues to use its plugin task, with the same directory and permissions.') : L('沿用此前的任务目录及权限;可选择项目作为新的目标。', 'Keeps the saved task directory and permissions. Choose a project to change the destination.')}
`}`;
+ $('#emote-name').oninput = event => { emote.label = event.target.value; renderWheel(); change(); };
+ $('#change-emote').onclick = () => openAssets(emote);
+ document.querySelectorAll('[data-mode]').forEach(button => button.onclick = () => {
+ const mode = button.dataset.mode;
+ if (mode === emote.action || (mode === 'send' && emote.action !== 'none')) return;
+ targetSelectionRevision++; destinationPicker = null;
+ emote.action = mode === 'send' ? 'existing' : mode;
+ if (mode === 'send') emote.target = { mode: 'recent' }; else delete emote.target;
+ renderEditor(); change();
+ });
+ if ($('#destination-preset')) $('#destination-preset').onchange = event => {
+ const [action, mode] = event.target.value.split(':');
+ if (mode === 'legacy') return;
+ if (mode === 'fixed') { void pickFixedTarget(emote, action); return; }
+ targetSelectionRevision++; destinationPicker = null;
+ emote.action = action; emote.target = { mode };
+ if (action === 'new') emote.delivery = 'queue';
+ renderEditor(); change();
+ };
+ if ($('#prompt')) $('#prompt').oninput = event => { emote.prompt = event.target.value; change(); };
+ if ($('#capture')) $('#capture').onchange = event => { emote.screenshot = event.target.checked; change(); };
+ if ($('#emote-delivery')) $('#emote-delivery').onchange = event => { emote.delivery = event.target.value; change(); };
+ if ($('#change-target')) $('#change-target').onclick = () => void pickFixedTarget(emote);
+ renderTargetPicker();
+ renderModels();
+ if ($('#task-target')) {
+ renderTasks();
+ $('#task-target').onchange = event => { emote.taskId = event.target.value; change(); };
+ $('#tasks').onclick = () => void loadTasks().catch(report);
+ if (!tasksLoaded) void loadTasks().catch(report);
+ }
+ renderValidation();
+}
+const sameModel = (a, b) => a?.agentKind === b?.agentKind && a?.providerId === b?.providerId && a?.model === b?.model;
+function modelLabel(route, details) {
+ const agent = { cc: 'Claude Code', codex: 'Codex', pi: 'Pi' }[route.agentKind] || route.agentKind;
+ return `${details?.name || route.model} · ${agent} · ${details?.providerName || route.providerId}`;
+}
+function renderModels() {
+ const container = $('#model-settings'); if (!container) return;
+ const emote = slots[selected], route = emote.route;
+ if (emote.target && emote.action !== 'new') {
+ container.innerHTML = `${L('沿用接收方当前模型,不会因这次 Ping 改变。', 'Uses the recipient’s current model without changing it.')}
`;
+ return;
+ }
+ const index = route ? modelList.findIndex(item => sameModel(item.route, route)) : -1;
+ const option = index >= 0 ? modelList[index] : null;
+ const invalid = modelsLoaded && route && (!option ||
+ (route.effort !== option.route.effort && !option.efforts.includes(route.effort)) ||
+ (route.fastMode && !option.supportsFastMode));
+ container.innerHTML = `${L('此表情的模型', 'Model for this emote')}
+ ${T.refresh}
+
`;
+ const select = $('#emote-model');
+ select.add(new Option(emote.action === 'existing' ? L('保持目标任务当前模型', 'Keep target task model') : L('沿用 Cindy 默认配置', 'Use Cindy Defaults'), ''));
+ modelList.forEach((item, i) => select.add(new Option(modelLabel(item.route, item.details), String(i))));
+ if (route && !option) { const missing = new Option(modelLabel(route), 'missing'); missing.disabled = true; select.add(missing); }
+ select.value = route ? option ? String(index) : 'missing' : '';
+ select.onchange = () => {
+ emote.route = select.value === '' ? null : { ...modelList[Number(select.value)].route };
+ renderModels(); change();
+ };
+ $('#refresh-models').onclick = () => void loadModels();
+ $('#model-scope').textContent = emote.action === 'existing'
+ ? route ? L('触发时将原绑定任务切换到此模型,再发送指令。', 'Applies this model to the saved plugin task before sending.') : L('保持原绑定任务自身的模型。', 'Keeps the saved task’s model.')
+ : route ? L('此模型只用于这个表情新建的任务。', 'This model applies to tasks created by this emote.') : L('沿用 Cindy 为插件保存的默认配置;可在这里单独选择。', 'Uses Cindy’s saved defaults for this plugin. Choose a model here to override them for this emote.');
+ const message = $('#model-message');
+ message.textContent = modelsLoading ? L('正在获取可用模型…', 'Loading models…') : modelsError ? T[modelsError] || T.OPERATION_FAILED :
+ invalid ? T.MODEL_UNAVAILABLE : modelsLoaded && !modelList.length ? L('没有可单独选择的模型,请检查 Cindy 的模型设置。', 'No models available. Check Cindy model settings.') : '';
+ message.classList.toggle('error', Boolean(modelsError || invalid));
+ if (!route || !option) return;
+ const tuning = $('#model-tuning');
+ if (option.efforts.length) {
+ const label = document.createElement('label'); label.textContent = L('推理强度', 'Reasoning effort');
+ const effort = document.createElement('select'); effort.id = 'model-effort';
+ const names = { none: '无', minimal: '最低', low: '低', medium: '中', high: '高', xhigh: '超高', max: '最高', ultra: '极高' };
+ for (const value of option.efforts) effort.add(new Option(T === strings['zh-CN'] ? names[value] || value : value, value));
+ if (!option.efforts.includes(route.effort)) {
+ const current = new Option(route.effort || L('默认', 'Default'), route.effort);
+ current.disabled = route.effort !== option.route.effort; effort.add(current);
+ }
+ effort.value = route.effort;
+ effort.onchange = () => { emote.route = { ...emote.route, effort: effort.value }; renderModels(); change(); };
+ label.append(effort); tuning.append(label);
+ }
+ if (option.supportsFastMode || route.fastMode) {
+ const label = document.createElement('label'); label.className = 'check';
+ const fast = document.createElement('input'); fast.type = 'checkbox'; fast.id = 'model-fast'; fast.checked = route.fastMode;
+ fast.onchange = () => { emote.route = { ...emote.route, fastMode: fast.checked }; renderModels(); change(); };
+ label.append(fast, route.fastMode === 'ultrafast' ? L('Fast · 超快', 'Fast · Ultrafast') : 'Fast'); tuning.append(label);
+ }
+}
+async function loadModels() {
+ if (modelsLoading) return;
+ modelsLoading = true; modelsError = ''; renderModels();
+ try {
+ const [catalog, names] = await Promise.allSettled([
+ rpc('models'), fetch('/agent-models', { signal: AbortSignal.timeout(5000) }).then(async response => {
+ if (!response.ok) throw new Error('HOST_NOT_READY');
+ return response.json();
+ }),
+ ]);
+ if (catalog.status === 'rejected') throw catalog.reason;
+ const details = names.status === 'fulfilled' ? names.value.models || [] : [];
+ modelList = catalog.value.models.map(option => ({ ...option,
+ details: details.find(item => item.id === option.route.model && item.providerId === option.route.providerId &&
+ item.agent === (option.route.agentKind === 'cc' ? 'claude-code' : option.route.agentKind)) }));
+ modelsLoaded = true;
+ }
+ catch (error) { modelsError = error.message; }
+ finally { modelsLoading = false; renderModels(); }
+}
+function renderTasks() {
+ const select = $('#task-target'); if (!select) return;
+ const emote = slots[selected];
+ select.replaceChildren(new Option(L('选择任务', 'Choose a task'), ''));
+ for (const task of taskList) select.add(new Option(task.title, task.taskId));
+ if (emote.taskId && !taskList.some(task => task.taskId === emote.taskId)) select.add(new Option(T.missing, emote.taskId));
+ select.value = emote.taskId || '';
+}
+async function loadTasks() {
+ if (tasksLoading) return tasksLoading;
+ tasksLoading = (async () => {
+ const items = []; let after;
+ do {
+ const page = await rpc('tasks', after ? { after } : {});
+ items.push(...page.items.filter(task => task.status === 'active')); after = page.nextCursor;
+ } while (after && items.length < 500);
+ taskList = items; tasksLoaded = true; renderTasks();
+ if (!items.length && slots[selected].action === 'existing') feedback(T.noTasks);
+ })();
+ try { await tasksLoading; } finally { tasksLoading = null; }
+}
+$('#custom-image').onclick = async () => {
+ const request = assetRequest;
+ $('#custom-image').disabled = true;
+ try {
+ const image = await rpc('pickImage');
+ if (image.path && request === assetRequest) chooseEmote('', image.path.split('/').pop().replace(/\.[^.]+$/, '').slice(0, 40), image.path, image.preview);
+ } catch (error) { report(error); }
+ finally { $('#custom-image').disabled = false; }
+};
+document.querySelectorAll('[data-close]').forEach(button => button.onclick = () => $(`#${button.dataset.close}`).close());
+const presets = { option: { code: 'AltLeft', modifiers: 0 }, control: { code: 'ControlLeft', modifiers: 0 }, g: { code: 'KeyG', modifiers: 0 }, f8: { code: 'F8', modifiers: 0 } };
+function renderPendingKey() {
+ $('#record-key').textContent = EmoteShortcuts.label(pendingKey);
+ $('#key-warning').textContent = pendingKey.modifiers === 0 && /^(Key|Digit)/.test(pendingKey.code)
+ ? L('按下唤出,松键发送。插件启用时,此单键不再输入该字符。', 'Press to open, release to send. This single key is reserved while the plugin is enabled.')
+ : L('按下快捷键唤出轮盘,移动选择后松键发送。', 'Hold the shortcut to open the wheel, move to choose, then release to send.');
+}
+async function finishRecording() {
+ if (!recording) return;
+ recording = false; clearTimeout(recordingTimer);
+ if ($('#keys').open) $('#keys').close();
+ try { const result = await rpc('shortcutRecording', { active: false }); if (result.issues?.length) feedback(T[result.issues[0]] || T.hotkeyConflict, true); }
+ catch (error) { report(error); }
+}
+$('#shortcut').onclick = async () => {
+ if (recording || !config) return;
+ recording = true;
+ try {
+ await rpc('shortcutRecording', { active: true });
+ if (!recording) return;
+ pendingKey = { ...shortcut }; renderPendingKey(); $('#keys').showModal(); $('#record-key').focus();
+ recordingTimer = setTimeout(() => void finishRecording(), 25000);
+ } catch (error) { void finishRecording(); report(error); }
+};
+$('#keys').addEventListener('close', () => void finishRecording());
+document.querySelectorAll('[data-key]').forEach(button => button.onclick = () => { pendingKey = { ...presets[button.dataset.key] }; renderPendingKey(); });
+$('#use-key').onclick = () => { shortcut = { ...pendingKey }; change(); void finishRecording(); };
+document.addEventListener('keydown', event => {
+ if ($('#keys').open) {
+ if (event.key === 'Escape' || event.key === 'Tab') return;
+ event.preventDefault(); event.stopPropagation();
+ if (event.repeat) return;
+ try { pendingKey = EmoteShortcuts.fromEvent(event); renderPendingKey(); }
+ catch { $('#key-warning').textContent = L('此按键无法用于轮盘,请换一个。', 'This key is unavailable. Choose another.'); }
+ return;
+ }
+ if (event.key === 'Escape' && !$('#assets').open) cancelPlacement();
+ if (placement && !placement.locked && event.key === 'Enter' && !event.repeat) { event.preventDefault(); confirmPlacement(); }
+});
+document.addEventListener('pointermove', event => { pointer = { x: event.clientX, y: event.clientY }; movePlacement(pointer); });
+document.addEventListener('contextmenu', event => { if (placement && !placement.locked) { event.preventDefault(); cancelPlacement(); } });
+window.addEventListener('blur', () => { if (placement && !placement.locked) cancelPlacement(); void finishRecording(); });
+window.addEventListener('resize', () => { if (placement && !placement.locked) cancelPlacement(); });
+window.addEventListener('pagehide', () => void finishRecording());
+$('#retry').onclick = () => change();
+$('#refresh').onclick = async () => {
+ try { const value = await rpc('load'); applyStatus(value); }
+ catch (error) { report(error); }
+};
+$('#reviewed').onclick = async () => { try { applyStatus(await rpc('reviewed')); } catch (error) { report(error); } };
+let lastTheme;
+function syncTheme() {
+ const dark = getComputedStyle(document.documentElement).colorScheme === 'dark';
+ if (dark === lastTheme || !config) return;
+ lastTheme = dark; void rpc('theme', { dark }).catch(report);
+}
+new MutationObserver(syncTheme).observe(document.head, { subtree: true, childList: true, characterData: true });
+(async () => {
+ $('#prototype').inert = true;
+ try {
+ const context = await (await fetch('/app-context')).json(); T = strings[context.context?.locale] || strings.en; localize();
+ const wake = await fetch('/wake'); if (!wake.ok) throw new Error('CONNECTION_TIMEOUT');
+ const value = await rpc('load');
+ config = value.config; configRevision = value.revision; slots = config.emotes; shortcut = config.wheelShortcut;
+ T = strings[value.locale] || strings.en; document.documentElement.lang = value.locale; localize();
+ renderWheel(); renderEditor(); applyStatus(value);
+ $('#shortcut-label').textContent = EmoteShortcuts.label(shortcut);
+ syncTheme();
+ void loadModels();
+ const images = await rpc('previewImages'); previews = images.images || {}; renderWheel(); renderEditor();
+ } catch (error) { report(error); }
+ finally { $('#prototype').inert = !config; }
+})();
diff --git a/desktop-emotes/shortcuts.js b/desktop-emotes/shortcuts.js
new file mode 100644
index 00000000..5ca61130
--- /dev/null
+++ b/desktop-emotes/shortcuts.js
@@ -0,0 +1,44 @@
+'use strict';
+// Physical KeyboardEvent.code -> macOS virtual key code. Shared by settings and worker.
+const EmoteShortcuts = (() => {
+ const keys = {
+ KeyA: 0, KeyS: 1, KeyD: 2, KeyF: 3, KeyH: 4, KeyG: 5, KeyZ: 6, KeyX: 7, KeyC: 8, KeyV: 9,
+ KeyB: 11, KeyQ: 12, KeyW: 13, KeyE: 14, KeyR: 15, KeyY: 16, KeyT: 17,
+ Digit1: 18, Digit2: 19, Digit3: 20, Digit4: 21, Digit6: 22, Digit5: 23, Equal: 24,
+ Digit9: 25, Digit7: 26, Minus: 27, Digit8: 28, Digit0: 29, BracketRight: 30, KeyO: 31,
+ KeyU: 32, BracketLeft: 33, KeyI: 34, KeyP: 35, Enter: 36, KeyL: 37, KeyJ: 38, Quote: 39,
+ KeyK: 40, Semicolon: 41, Backslash: 42, Comma: 43, Slash: 44, KeyN: 45, KeyM: 46, Period: 47,
+ Tab: 48, Space: 49, Backquote: 50, Backspace: 51,
+ MetaLeft: 55, ShiftLeft: 56, AltLeft: 58, ControlLeft: 59,
+ F17: 64, F18: 79, F19: 80, F20: 90, F5: 96, F6: 97, F7: 98, F3: 99, F8: 100,
+ F9: 101, F11: 103, F13: 105, F16: 106, F14: 107, F10: 109, F12: 111, F15: 113,
+ Home: 115, PageUp: 116, Delete: 117, F4: 118, End: 119, F2: 120, PageDown: 121, F1: 122,
+ ArrowLeft: 123, ArrowRight: 124, ArrowDown: 125, ArrowUp: 126,
+ };
+ // Bits: Control=1, Option=2, Shift=4, Command=8.
+ const modifierBits = { ControlLeft: 1, AltLeft: 2, ShiftLeft: 4, MetaLeft: 8 };
+ const defaults = () => ({ code: 'KeyG', modifiers: 8 });
+ function validate(value) {
+ if (!value || !Object.hasOwn(keys, value.code) || !Number.isInteger(value.modifiers) ||
+ value.modifiers < 0 || value.modifiers > 15) throw new Error('SHORTCUT_INVALID');
+ return { code: value.code, modifiers: value.modifiers & ~(modifierBits[value.code] || 0) };
+ }
+ function fromEvent(event) {
+ return validate({ code: event.code.replace(/(Control|Alt|Shift|Meta)Right$/, '$1Left'), modifiers: (event.ctrlKey ? 1 : 0) | (event.altKey ? 2 : 0) |
+ (event.shiftKey ? 4 : 0) | (event.metaKey ? 8 : 0) });
+ }
+ function label(value) {
+ const names = { ControlLeft: '⌃ Control', AltLeft: '⌥ Option', ShiftLeft: '⇧ Shift', MetaLeft: '⌘ Command', Space: 'Space', ArrowLeft: '←', ArrowRight: '→', ArrowDown: '↓', ArrowUp: '↑',
+ Enter: '↩', Tab: 'Tab', Backspace: '⌫', Delete: '⌦', Equal: '=', Minus: '-', BracketRight: ']',
+ BracketLeft: '[', Quote: "'", Semicolon: ';', Backslash: '\\', Comma: ',', Slash: '/', Period: '.', Backquote: '`' };
+ const mods = [[1, '⌃'], [2, '⌥'], [4, '⇧'], [8, '⌘']].filter(([bit]) => value.modifiers & bit).map(([, text]) => text);
+ return [...mods, names[value.code] || value.code.replace(/^(Key|Digit)/, '')].join(' ');
+ }
+ function native(value) {
+ const shortcut = validate(value);
+ return { keyCode: keys[shortcut.code], modifierOnly: modifierBits[shortcut.code] || 0, modifiers: (shortcut.modifiers & 1 ? 4096 : 0) |
+ (shortcut.modifiers & 2 ? 2048 : 0) | (shortcut.modifiers & 4 ? 512 : 0) | (shortcut.modifiers & 8 ? 256 : 0) };
+ }
+ return { defaults, validate, fromEvent, label, native };
+})();
+if (typeof module !== 'undefined') module.exports = EmoteShortcuts;
diff --git a/desktop-emotes/wheel-layout.js b/desktop-emotes/wheel-layout.js
new file mode 100644
index 00000000..46ac4ba0
--- /dev/null
+++ b/desktop-emotes/wheel-layout.js
@@ -0,0 +1,39 @@
+'use strict';
+const EmoteWheel = (() => {
+const normalizedAngle = angle => ((angle % 360) + 360) % 360;
+function polar(radius, angle) {
+ const radians = angle * Math.PI / 180;
+ return [160 + radius * Math.cos(radians), 160 + radius * Math.sin(radians)];
+}
+function layout(items) {
+ const pins = items.filter(emote => emote.pinAngle !== null);
+ pins.forEach(emote => { if (emote.pinAngle !== null) emote.angle = emote.pinAngle; });
+ pins.sort((a, b) => a.angle - b.angle);
+ if (!pins.length) {
+ items.sort((a, b) => a.angle - b.angle).forEach((emote, i) => { emote.angle = i * 360 / items.length; });
+ } else {
+ // A free item stays between the same two Pins; inserting here cannot jump elsewhere.
+ pins.forEach((pin, i) => {
+ const length = (i + 1 < pins.length ? pins[i + 1].angle : pins[0].angle + 360) - pin.angle;
+ const members = items.filter(emote => emote.pinAngle === null && normalizedAngle(emote.angle - pin.angle) < length)
+ .sort((a, b) => normalizedAngle(a.angle - pin.angle) - normalizedAngle(b.angle - pin.angle));
+ members.forEach((emote, index) => { emote.angle = normalizedAngle(pin.angle + length * (index + 1) / (members.length + 1)); });
+ });
+ }
+ return items.sort((a, b) => a.angle - b.angle);
+}
+function geometry(index, items) {
+ const angle = items[index].angle;
+ const previous = index ? items[index - 1].angle : items.at(-1).angle - 360;
+ const next = index + 1 < items.length ? items[index + 1].angle : items[0].angle + 360;
+ return { angle, start: (previous + angle) / 2, end: (angle + next) / 2, gap: Math.min(angle - previous, next - angle) };
+}
+function slicePath(index, items) {
+ const section = geometry(index, items), inset = Math.min(.65, (section.end - section.start) / 12);
+ const start = section.start - 90 + inset, end = section.end - 90 - inset;
+ const a = polar(148, start), b = polar(148, end), c = polar(51, end), d = polar(51, start), large = end - start > 180 ? 1 : 0;
+ return `M${a} A148,148 0 ${large} 1 ${b} L${c} A51,51 0 ${large} 0 ${d} Z`;
+}
+return { layout, geometry, slicePath, polar, normalizedAngle };
+})();
+if (typeof module !== 'undefined') module.exports = EmoteWheel;
diff --git a/provisioning.json b/provisioning.json
index f6f97822..1ecffe3e 100644
--- a/provisioning.json
+++ b/provisioning.json
@@ -1,6 +1,9 @@
{
"//": "内置意识受众配置(随包分发;本仓 = cindy-official-plugin 种子根,由主仓 submodule 挂载)。种子不在 ghosts 里 = 默认 all(人人都装)。audience 取值:'all',或 { userIds: [...], emails: [...] } 任一维度命中即装;登录后才有身份,定向种子登录时装上、登出/不命中时回收。tier 取值:'builtin'(缺省,官方内置)或 'enterprise'(企业意识,设置页单列一组)——只影响分组与标签,不改变播种行为。配置解析失败 = 本种子根整轮跳过(fail-closed)。",
"ghosts": {
+ "desktop-emotes": {
+ "audience": { "emails": [] }
+ },
"my-browser": {
"audience": { "emails": [] }
},