Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ findmy people
findmy people --json
findmy people --no-log

# Click a row and OCR the detail pane (precise address).
# Read one matching person. `--zoom` clicks the row and OCRs the detail pane
# for a street address — see the note on macOS 26+ under Limitations.
findmy person "Omar Shahine"
findmy person "Omar Shahine" --json

Expand Down Expand Up @@ -173,6 +174,14 @@ diagnostic — TCC denied is more common than missing display.

## Limitations

- **`--zoom` yields no street address on macOS 26 and later.** macOS 26
replaced FindMy's split view with a floating sidebar over a full-window map.
A row click no longer opens a detail pane; it opens a callout pinned to the
map carrying the same coarse location and staleness the sidebar already
showed. There is nothing more precise on screen to OCR, so `--zoom` prints a
warning to stderr and leaves `precise_address` unset rather than guessing.
Everything else — `people`, `devices`, `items`, `watch`, `log` — is
unaffected. Tracked in [#13](https://github.com/omarshahine/findmy-cli/issues/13).
- **The display must be awake and unlocked.** WindowServer stops compositing
when the display sleeps, so `screencapture` returns a 99 KB all-black PNG.
The CLI detects this and tells you to wake the keyboard. There is no
Expand Down
44 changes: 34 additions & 10 deletions cmd/findmy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
Expand Down Expand Up @@ -360,7 +361,7 @@ func runDevice(args []string) {
os.Exit(1)
}
detailShot := filepath.Join(tmpDir(), "device-detail.png")
must(enrichWithDetailPane(w, shot, detailShot, nameLine, sidebarRightPx, opts.keep, func(precise, city, region, postal string) {
must(enrichWithDetailPane(w, shot, detailShot, nameLine, sidebarRightPx, opts.keep, match.Name, func(precise, city, region, postal string) {
match.PreciseAddress = precise
match.City = city
match.Region = region
Expand Down Expand Up @@ -728,7 +729,7 @@ func runPerson(args []string) {
os.Exit(1)
}
detailShot := filepath.Join(tmpDir(), "person-detail.png")
must(enrichWithDetailPane(w, shot, detailShot, nameLine, sidebarRightPx, opts.keep, func(precise, city, region, postal string) {
must(enrichWithDetailPane(w, shot, detailShot, nameLine, sidebarRightPx, opts.keep, match.Name, func(precise, city, region, postal string) {
match.PreciseAddress = precise
match.City = city
match.Region = region
Expand Down Expand Up @@ -785,7 +786,7 @@ func findSidebarNameLine(lines []findmy.TextLine, sidebarRightPx, textColMinPx i
return findmy.TextLine{}, false
}

func enrichWithDetailPane(w *findmy.Window, sidebarShotPath, detailShotPath string, clickLine findmy.TextLine, sidebarRightPx int, keep bool, apply func(precise, city, region, postal string)) error {
func enrichWithDetailPane(w *findmy.Window, sidebarShotPath, detailShotPath string, clickLine findmy.TextLine, sidebarRightPx int, keep bool, entityName string, apply func(precise, city, region, postal string)) error {
clickX := clickLine.X + clickLine.Width/2
clickY := clickLine.Y + clickLine.Height/2
screenX, screenY := windowPointFromImagePoint(w, sidebarShotPath, clickX, clickY)
Expand All @@ -804,13 +805,29 @@ func enrichWithDetailPane(w *findmy.Window, sidebarShotPath, detailShotPath stri
if err != nil {
return err
}
precise, city, region, postal := findmy.ExtractDetailPaneAddress(lines, sidebarRightPx)
precise, city, region, postal, err := findmy.ExtractDetailPaneAddress(lines, sidebarRightPx, entityName)
if err != nil && !errors.Is(err, findmy.ErrNoDetailPane) {
return err
}
if precise != "" || city != "" || region != "" || postal != "" {
apply(precise, city, region, postal)
return nil
}
// Not fatal: the coarse sidebar reading is still good, and callers pipe
// --json into scripts that a non-zero exit would break. Warn loudly
// instead — the one thing --zoom must never do is invent an address.
warnNoPreciseAddress(entityName)
return nil
}

func warnNoPreciseAddress(entityName string) {
fmt.Fprintf(os.Stderr, "warning: --zoom read no precise address for %q.\n", entityName)
fmt.Fprintln(os.Stderr, " macOS 26 replaced FindMy's split view with a floating sidebar over a")
fmt.Fprintln(os.Stderr, " full-window map. Selecting a row now opens a map callout carrying the")
fmt.Fprintln(os.Stderr, " same coarse location as the sidebar, not a street address, so there is")
fmt.Fprintln(os.Stderr, " nothing more precise on screen to read. See issue #13.")
}

func zoomDelay() time.Duration {
const fallback = 600 * time.Millisecond
if raw := os.Getenv("FINDMY_ZOOM_DELAY_MS"); raw != "" {
Expand All @@ -823,14 +840,21 @@ func zoomDelay() time.Duration {
}

// pixelLayout returns the sidebar-right and name-column-left thresholds in
// image pixels. The FindMy sidebar is ~340pt wide; the avatar column is
// ~100pt with the avatar circle centered around 50pt, so an 80pt cutoff
// drops centered avatar OCR fragments while admitting real name/location
// text that begins around 90pt. We use a float scale because some displays
// (e.g. a 4K dummy plug) report non-integer pixel-per-point ratios.
// image pixels. The FindMy sidebar is ~340pt wide; the avatar column holds a
// circle centered around 40pt, so a 60pt cutoff drops centered avatar OCR
// fragments while admitting real name/location text.
//
// 60pt rather than 80pt because macOS 26 replaced the split view with a
// floating sidebar panel inset from the window edge, tightening the avatar
// column: names now start around 66pt from the window's left edge (measured
// on 26.6.2), where an 80pt cutoff would drop every row. The older split-view
// layout put names around 90pt, so 60pt admits both.
//
// We use a float scale because some displays (e.g. a 4K dummy plug) report
// non-integer pixel-per-point ratios.
func pixelLayout(w *findmy.Window, imagePath string) (sidebarRightPx, textColMinPx int) {
scale := imageScale(w, imagePath)
return int(340 * scale), int(80 * scale)
return int(340 * scale), int(60 * scale)
}

func windowPointFromImagePoint(w *findmy.Window, imagePath string, px, py int) (int, int) {
Expand Down
146 changes: 142 additions & 4 deletions internal/findmy/detail_pane_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package findmy

import "testing"
import (
"errors"
"testing"
)

func TestExtractDetailPaneAddressSplitsUSAddress(t *testing.T) {
lines := []TextLine{
Expand All @@ -12,8 +15,11 @@ func TestExtractDetailPaneAddressSplitsUSAddress(t *testing.T) {
{Text: "Notifications", X: 720, Y: 360, Width: 150, Height: 24},
}

precise, city, region, postal := ExtractDetailPaneAddress(lines, 680)
precise, city, region, postal, err := ExtractDetailPaneAddress(lines, 680, "Omar Shahine")

if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if precise != "10001 NE 8th St" {
t.Fatalf("precise = %q, want %q", precise, "10001 NE 8th St")
}
Expand All @@ -30,8 +36,11 @@ func TestExtractDetailPaneAddressFallsBackForUnsplitAddress(t *testing.T) {
{Text: "5 mi away • Updated 2 min ago", X: 730, Y: 220, Width: 300, Height: 24},
}

precise, city, region, postal := ExtractDetailPaneAddress(lines, 680)
precise, city, region, postal, err := ExtractDetailPaneAddress(lines, 680, "Sadie Van Horn")

if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if precise != "10 Downing Street, London SW1A 2AA" {
t.Fatalf("precise = %q, want fallback joined address", precise)
}
Expand All @@ -51,12 +60,141 @@ func TestExtractDetailPaneAddressIgnoresSidebarAndButtons(t *testing.T) {
{Text: "Cupertino, CA", X: 760, Y: 250, Width: 180, Height: 24},
}

precise, city, region, postal := ExtractDetailPaneAddress(lines, 680)
precise, city, region, postal, err := ExtractDetailPaneAddress(lines, 680, "MacBook Pro")

if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if precise != "1 Apple Park Way" {
t.Fatalf("precise = %q, want %q", precise, "1 Apple Park Way")
}
if city != "Cupertino" || region != "CA" || postal != "" {
t.Fatalf("split = (%q, %q, %q), want (Cupertino, CA, empty)", city, region, postal)
}
}

// TestExtractDetailPaneAddressRejectsMapCanvas pins the macOS 26+ redesign
// (issue #13). The floating sidebar sits over a full-window map, so there is
// no detail pane at all and everything right of the sidebar is map furniture.
// The OCR fixture is a real `findmy person --zoom` capture on macOS 26.6.2,
// which previously produced precise_address = "Champaign Point, 3D".
func TestExtractDetailPaneAddressRejectsMapCanvas(t *testing.T) {
lines := []TextLine{
{Text: "People", X: 80, Y: 124, Width: 90, Height: 24},
{Text: "Lora Shahine", X: 133, Y: 324, Width: 169, Height: 27},
{Text: "Winston-Salem, NC • 3 min. ago", X: 136, Y: 353, Width: 300, Height: 24},
{Text: "3D", X: 1900, Y: 40, Width: 40, Height: 24},
{Text: "Champaign Point", X: 748, Y: 345, Width: 200, Height: 24},
{Text: "Kirkland", X: 1128, Y: 595, Width: 160, Height: 30},
{Text: "NE 85TH ST", X: 1400, Y: 590, Width: 180, Height: 20},
{Text: "Lake Washington", X: 700, Y: 1390, Width: 190, Height: 24},
}

precise, city, region, postal, err := ExtractDetailPaneAddress(lines, 680, "Lora Shahine")

if !errors.Is(err, ErrNoDetailPane) {
t.Fatalf("err = %v, want ErrNoDetailPane", err)
}
if precise != "" || city != "" || region != "" || postal != "" {
t.Fatalf("got (%q, %q, %q, %q), want all empty — map labels are not an address", precise, city, region, postal)
}
}

// TestExtractDetailPaneAddressRejectsMapCallout is the second half of issue
// #13. Once the click lands, the redesigned FindMy answers with a callout
// pinned to the map: the entity's name over the same coarse location and
// staleness the sidebar already showed, surrounded by street labels. The
// callout header matches the entity, so header matching alone is not enough —
// the bullet-joined location must be rejected as an address, and the street
// labels must be rejected for sitting outside the header's column.
//
// Fixture is a real `findmy person "Lora Shahine" --zoom` capture on macOS
// 26.6.2, which produced precise_address =
// "Winston-Salem, NC • Now, WAREHAM LN, CHANCELLORSVILLE DR, HAGEN LN".
func TestExtractDetailPaneAddressRejectsMapCallout(t *testing.T) {
lines := []TextLine{
{Text: "BETHABARA PARK BLVD", X: 950, Y: 379, Width: 260, Height: 20},
{Text: "Salemtowne", X: 890, Y: 512, Width: 150, Height: 24},
{Text: "Lora Shahine", X: 1324, Y: 738, Width: 170, Height: 27},
{Text: "Winston-Salem, NC • Now", X: 1327, Y: 773, Width: 240, Height: 22},
{Text: "WAREHAM LN", X: 1132, Y: 862, Width: 160, Height: 20},
{Text: "CHANCELLORSVILLE DR", X: 918, Y: 1056, Width: 280, Height: 20},
{Text: "HAGEN LN", X: 1904, Y: 1147, Width: 130, Height: 20},
{Text: "BULL RUN RD", X: 907, Y: 1342, Width: 160, Height: 20},
}

precise, city, region, postal, err := ExtractDetailPaneAddress(lines, 680, "Lora Shahine")

if err != nil {
t.Fatalf("err = %v, want nil (the callout header did match)", err)
}
if precise != "" || city != "" || region != "" || postal != "" {
t.Fatalf("got (%q, %q, %q, %q), want all empty — the callout carries no street address", precise, city, region, postal)
}
}

func TestLooksLikeAddressLine(t *testing.T) {
cases := []struct {
line string
want bool
}{
{"10001 NE 8th St", true},
{"1 Apple Park Way", true},
{"Cupertino, CA", true},
{"Bellevue, WA 98004", true},
{"WAREHAM LN", true},
// Substring matching used to accept these: "Winston" contains "st",
// "Redmond" contains "rd", "Kirkland" contains "ln".
{"Winston-Salem, NC • Now", false},
{"Kirkland", false},
{"Champaign Point", false},
{"Salemtowne", false},
}
for _, c := range cases {
if got := looksLikeAddressLine(c.line); got != c.want {
t.Errorf("looksLikeAddressLine(%q) = %v, want %v", c.line, got, c.want)
}
}
}

// A detail pane that is genuinely on screen but has no address (offline
// device) is a different outcome from having no pane at all: no error, and
// nothing to apply.
func TestExtractDetailPaneAddressPaneWithoutAddress(t *testing.T) {
lines := []TextLine{
{Text: "Bike Shed Keys", X: 760, Y: 110, Width: 220, Height: 30},
{Text: "No location found", X: 760, Y: 155, Width: 230, Height: 24},
{Text: "Play Sound", X: 760, Y: 200, Width: 120, Height: 24},
}

precise, city, region, postal, err := ExtractDetailPaneAddress(lines, 680, "Bike Shed Keys")

if err != nil {
t.Fatalf("err = %v, want nil (the pane is present, it just has no address)", err)
}
if precise != "" || city != "" || region != "" || postal != "" {
t.Fatalf("got (%q, %q, %q, %q), want all empty", precise, city, region, postal)
}
}

func TestMatchesEntityHeader(t *testing.T) {
cases := []struct {
line, name string
want bool
}{
{"Omar Shahine", "Omar Shahine", true},
{"omar shahine", "Omar Shahine", true},
{"Omar Shahine…", "Omar Shahine", true},
{"Omar Sunshine", "Omar Shahine", true}, // one OCR-mangled word of two
{"Champaign Point", "Lora Shahine", false},
{"3D", "Lora Shahine", false},
{"Kirkland", "Omar's iPhone", false},
{"", "Omar Shahine", false},
{"Omar Shahine", "", false},
}
for _, c := range cases {
if got := matchesEntityHeader(c.line, c.name); got != c.want {
t.Errorf("matchesEntityHeader(%q, %q) = %v, want %v", c.line, c.name, got, c.want)
}
}
}
Loading