//go:build darwin
package darwin
/*
#cgo CFLAGS: -fobjc-arc
#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices -framework Carbon
#include "darwin.h"
#include <stdlib.h>
*/
import "C"
import (
"errors"
"log"
"os"
"runtime"
"sync"
"time"
"unsafe"
"chirami/internal/platform"
)
var (
activeMu sync.Mutex
activeBackend *backend
)
func init() {
runtime.LockOSThread()
}
//export chr_onSelectionTrigger
func chr_onSelectionTrigger(kind C.int) {
activeMu.Lock()
b := activeBackend
activeMu.Unlock()
if b != nil {
b.triggerSelection(int(kind))
}
}
//export chr_onEvent
func chr_onEvent(kind C.int, x, y C.double) {
activeMu.Lock()
b := activeBackend
activeMu.Unlock()
if b != nil {
b.sendEvent(platform.EventKind(kind), float64(x), float64(y))
}
}
type backend struct {
selections chan string
shots chan []byte
shotsOnce sync.Once
selectionTrigger chan int // 0 = poll/click, 1 = drag/double-click mouseUp, 2 = key
events chan platform.Event
stopCh chan struct{}
stopOnce sync.Once
mu sync.Mutex
lastScale float64
}
func newBackend() *backend {
return &backend{
selections: make(chan string, 16),
shots: make(chan []byte, 1),
selectionTrigger: make(chan int, 4),
events: make(chan platform.Event, 32),
stopCh: make(chan struct{}),
lastScale: 2.0,
}
}
func (b *backend) Selections() <-chan string {
return b.selections
}
// Screenshots starts watching for the system screenshot shortcuts on first
// use, so only an app that wants them asks for Input Monitoring.
func (b *backend) Screenshots() <-chan []byte {
b.shotsOnce.Do(func() { go b.watchShortcuts() })
return b.shots
}
// watchShortcuts installs the shortcut listener, retrying while the user has
// yet to grant Input Monitoring.
func (b *backend) watchShortcuts() {
for i := 0; !bool(C.chr_startScreenshotWatch()); i++ {
if i == 0 {
log.Println("screenshots: grant Input Monitoring to chirami (System Settings > Privacy & Security > Input Monitoring) so it can tell when you take a screenshot")
}
select {
case <-b.stopCh:
return
case <-time.After(3 * time.Second):
}
}
}
//export chr_onScreenshot
func chr_onScreenshot(bytes unsafe.Pointer, n C.int) {
activeMu.Lock()
b := activeBackend
activeMu.Unlock()
if b == nil {
return
}
png := C.GoBytes(bytes, n)
select {
case b.shots <- png:
default: // the app is still busy with the last one
}
}
func (b *backend) Events() <-chan platform.Event {
return b.events
}
func (b *backend) Locate() (platform.Anchor, error) {
a := C.chr_getAnchor()
anchor := platform.Anchor{
X: float64(a.x),
Y: float64(a.y),
Screen: platform.Rect{
X: float64(a.screenX),
Y: float64(a.screenY),
W: float64(a.screenW),
H: float64(a.screenH),
},
Scale: float64(a.scale),
}
b.mu.Lock()
b.lastScale = anchor.Scale
b.mu.Unlock()
return anchor, nil
}
func (b *backend) Pointer() (x, y float64, ok bool) {
var px, py C.double
C.chr_getPointer(&px, &py)
return float64(px), float64(py), true
}
func (b *backend) Show(p platform.Popup) error {
if p.Image == nil || p.Image.Rect.Dx() == 0 || p.Image.Rect.Dy() == 0 {
return errors.New("darwin: cannot show empty popup image")
}
b.mu.Lock()
scale := b.lastScale
b.mu.Unlock()
if scale <= 0 {
scale = 2.0
}
minX := p.Image.Rect.Min.X
minY := p.Image.Rect.Min.Y
w := p.Image.Rect.Dx()
h := p.Image.Rect.Dy()
stride := p.Image.Stride
offset := p.Image.PixOffset(minX, minY)
rowLen := w * 4
tightBytes := make([]byte, rowLen*h)
for row := 0; row < h; row++ {
srcStart := offset + row*stride
srcEnd := srcStart + rowLen
dstStart := row * rowLen
copy(tightBytes[dstStart:dstStart+rowLen], p.Image.Pix[srcStart:srcEnd])
}
C.chr_showPopup(
unsafe.Pointer(&tightBytes[0]),
C.int(rowLen),
C.int(w),
C.int(h),
C.double(p.X),
C.double(p.Y),
C.double(p.Hit.X),
C.double(p.Hit.Y),
C.double(p.Hit.W),
C.double(p.Hit.H),
C.double(scale),
)
return nil
}
func (b *backend) Hide() {
C.chr_hidePopup()
}
func (b *backend) triggerSelection(kind int) {
select {
case b.selectionTrigger <- kind:
default:
}
}
func (b *backend) sendEvent(kind platform.EventKind, x, y float64) {
ev := platform.Event{Kind: kind, X: x, Y: y}
select {
case b.events <- ev:
default:
}
}
func (b *backend) stop() {
b.stopOnce.Do(func() {
close(b.stopCh)
})
}
func (b *backend) selectionLoop() {
trusted := bool(C.chr_checkAccessibility(C.bool(true)))
if !trusted {
log.Println("Accessibility permission required: please grant Accessibility to this binary (System Settings > Privacy & Security > Accessibility)")
go func() {
retryTicker := time.NewTicker(3 * time.Second)
defer retryTicker.Stop()
for {
select {
case <-b.stopCh:
return
case <-retryTicker.C:
if bool(C.chr_checkAccessibility(C.bool(false))) {
log.Println("Accessibility permission granted")
b.triggerSelection(0)
return
}
}
}
}()
}
pollTicker := time.NewTicker(400 * time.Millisecond)
defer pollTicker.Stop()
allowFallbackEnv := os.Getenv("CHIRAMI_MAC_COPY_FALLBACK") == "1"
lastText := ""
for {
select {
case <-b.stopCh:
return
case <-pollTicker.C:
b.triggerSelection(0) // poll trigger
case kind := <-b.selectionTrigger:
// Copy fallback is only permitted on real mouse-up following a drag or double click (kind == 1)
allowFallback := allowFallbackEnv && (kind == 1)
cstr := C.chr_getSelectedText(C.bool(allowFallback))
var text string
if cstr != nil {
text = C.GoString(cstr)
C.free(unsafe.Pointer(cstr))
}
if text != lastText {
if text != "" || lastText != "" {
lastText = text
select {
case b.selections <- text:
default:
}
}
}
}
}
}
// Run executes the application event loop on the main thread and runs app in a goroutine.
func Run(app func(platform.Backend) error) error {
runtime.LockOSThread()
C.chr_init()
C.chr_installMonitors()
b := newBackend()
activeMu.Lock()
activeBackend = b
activeMu.Unlock()
go b.selectionLoop()
var (
appErr error
appDone = make(chan struct{})
)
go func() {
defer close(appDone)
appErr = app(b)
b.stop()
C.chr_stop()
}()
C.chr_run()
// Prevent hang if app goroutine is stuck
select {
case <-appDone:
case <-time.After(2 * time.Second):
log.Println("darwin: app did not finish within 2s after NSApp stopped")
}
C.chr_uninstallMonitors()
activeMu.Lock()
activeBackend = nil
activeMu.Unlock()
return appErr
}
// SaveSnapshot saves a PNG snapshot of the currently displayed popup.
func SaveSnapshot(path string) bool {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
return bool(C.chr_savePopupSnapshot(cpath))
}