Skip to content
Open
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
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,13 @@
"dependencies": {
"@firebase/app-types": "^0.9.0",
"@firebase/util": "^1.9.3",
"@noble/ciphers": "^1.3.0",
"@peculiar/x509": "^1.6.1",
"async-sema": "^3.1.1",
"axios": "^1.7.2",
"better-sqlite3": "^8.0.1",
"blurhash": "^1.1.3",
"bplist-parser": "^0.3.2",
"byte-base64": "^1.1.0",
"compare-versions": "^3.6.0",
"conditional-decorator": "^0.1.7",
Expand Down
8 changes: 7 additions & 1 deletion packages/server/src/server/api/http/api/v1/httpRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as KoaRouter from "koa-router";
import { Server } from "@server";
import { isNotEmpty } from "@server/helpers/utils";
import { isMinSonoma14_4 } from "@server/env";

// Middleware
import { AuthMiddleware } from "./middleware/authMiddleware";
Expand Down Expand Up @@ -124,7 +125,12 @@ export class HttpRoutes {
{
method: HttpMethod.GET,
path: "findmy/friends",
middleware: [...HttpRoutes.protected, PrivateApiMiddleware],
// Version-aware: macOS 14.4+ populates friend locations by decrypting the Find
// My cache (no Private API helper needed), so only require the helper on older
// versions (11–14.3) where friends still come from the Private API injection.
middleware: isMinSonoma14_4
? HttpRoutes.protected
: [...HttpRoutes.protected, PrivateApiMiddleware],
controller: FindMyRouter.friends
},
{
Expand Down
243 changes: 189 additions & 54 deletions packages/server/src/server/api/interfaces/findMyInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,22 @@ import { Server } from "@server";
import path from "path";
import fs from "fs";
import { FileSystem } from "@server/fileSystem";
import { isMinBigSur, isMinSequoia, isMinSonoma } from "@server/env";
import { isMinBigSur, isMinSonoma14_4 } from "@server/env";
import { checkPrivateApiStatus, waitMs } from "@server/helpers/utils";
import { quitFindMyFriends, startFindMyFriends, showFindMyFriends, hideFindMyFriends } from "../apple/scripts";
import { FindMyDevice, FindMyItem, FindMyLocationItem } from "@server/api/lib/findmy/types";
import { transformFindMyItemToDevice } from "@server/api/lib/findmy/utils";
import { FindMyKeyManager } from "@server/api/lib/findmy/FindMyKeyManager";
import { decryptCacheBuffer } from "@server/api/lib/findmy/decrypt/cache";
import { readFriendLocations, RawFriendLocation } from "@server/api/lib/findmy/decrypt/localStorageReader";
import { readFmfContacts } from "@server/api/lib/findmy/decrypt/fmfReader";

export class FindMyInterface {
static async getFriends() {
return Server().findMyCache.getAll();
}

static async getDevices(): Promise<Array<FindMyDevice> | null> {
if (isMinSequoia) {
Server().logger.debug('Cannot fetch FindMy devices on macOS Sequoia or later.');
return null;
}

try {
const [devices, items] = await Promise.all([
FindMyInterface.readDataFile("Devices"),
Expand All @@ -29,7 +28,7 @@ export class FindMyInterface {
if (devices == null && items == null) return null;

// Get any items with a group identifier
const itemsWithGroup = items.filter(item => item.groupIdentifier);
const itemsWithGroup = (items ?? []).filter(item => item.groupIdentifier);
if (itemsWithGroup.length > 0) {
try {
const itemGroups = await FindMyInterface.readItemGroups();
Expand All @@ -48,45 +47,96 @@ export class FindMyInterface {
}
}
} catch (ex: any) {
Server().logger.debug('An error occurred while reading FindMy ItemGroups cache file.');
Server().logger.debug("An error occurred while reading FindMy ItemGroups cache file.");
Server().logger.debug(String(ex));
}
}

// Transform the items to match the same shape as devices
const transformedItems = (items ?? []).map(transformFindMyItemToDevice);

return [...(devices ?? []), ...transformedItems];
const result = [...(devices ?? []), ...transformedItems];
FindMyInterface.deduplicateAddressKeys(result);
return result;
} catch (ex: any) {
Server().logger.debug('An error occurred while reading FindMy Device cache files.');
Server().logger.debug("An error occurred while reading FindMy Device cache files.");
Server().logger.debug(String(ex));
return null;
}
}

/**
* The clients use `address.uniqueValue` (= label ?? mapItemFullAddress) as the ListView key.
* Co-located devices share an identical address, producing duplicate Flutter keys, which
* crashes (blanks) the whole list on rebuild. Make the key source unique within each list
* group (accessories vs. devices are separate lists) by appending zero-width spaces to
* duplicates — the string becomes unique while remaining visually identical when displayed.
*/
private static deduplicateAddressKeys(devices: Array<FindMyDevice>): void {
const ZERO_WIDTH_SPACE = "\u200B";
const seenByGroup: Record<string, Map<string, number>> = {};
for (const device of devices) {
const addr = device?.address as Record<string, any> | null | undefined;
if (!addr || typeof addr !== "object") continue;

// uniqueValue prefers label, then mapItemFullAddress
const keyField =
addr.label != null && addr.label !== ""
? "label"
: addr.mapItemFullAddress != null && addr.mapItemFullAddress !== ""
? "mapItemFullAddress"
: null;
if (!keyField) continue;

const groupKey = device.isConsideredAccessory ? "accessory" : "device";
const seen = seenByGroup[groupKey] ?? (seenByGroup[groupKey] = new Map());
const value = String(addr[keyField]);
const count = seen.get(value) ?? 0;
if (count > 0) {
// Append N invisible zero-width spaces — unique key, identical appearance
addr[keyField] = value + ZERO_WIDTH_SPACE.repeat(count);
}
seen.set(value, count + 1);
}
}

static async refreshDevices(): Promise<Array<FindMyDevice> | null> {
// Can't use the Private API to refresh devices yet
await this.refreshLocationsAccessibility();
return await this.getDevices();
}

static async refreshFriends(openFindMyApp = true): Promise<FindMyLocationItem[]> {
// macOS 14.4+ : the Private API hook no longer works and the cache is encrypted.
// Read & decrypt the LocalStorage.db cache directly (requires imported keys).
if (isMinSonoma14_4) {
try {
const locations = await FindMyInterface.readFriendsFromCache();
if (locations.length > 0) {
Server().findMyCache.addAll(locations);
}
} catch (ex: any) {
Server().logger.debug("Failed to read FindMy friends from decrypted cache.");
Server().logger.debug(String(ex));
}

return Server().findMyCache.getAll();
}

// Legacy path (macOS 11.0 - 13.x): use the Private API injection
const papiEnabled = Server().repo.getConfig("enable_private_api") as boolean;
if (papiEnabled && isMinBigSur && !isMinSonoma) {
if (papiEnabled && isMinBigSur) {
checkPrivateApiStatus();
const result = await Server().privateApi.findmy.refreshFriends();
const refreshLocations = result?.data?.locations ?? [];

// Save the data to the cache
// The cache will handle properly updating the data.
// Save the data to the cache; the cache handles de-duping/updating.
Server().findMyCache.addAll(refreshLocations);
}

// No matter what, open the Find My app.
// Don't await because it should update in the background.
// Location updates get emitted as an event as they come in.
if (openFindMyApp) {
this.refreshLocationsAccessibility();
// Open the Find My app to trigger background location updates.
if (openFindMyApp) {
this.refreshLocationsAccessibility();
}
}

return Server().findMyCache.getAll();
Expand All @@ -110,49 +160,134 @@ export class FindMyInterface {
await FileSystem.executeAppleScript(hideFindMyFriends());
}

/**
* Reads friend locations by decrypting LocalStorage.db (coordinates) and joining
* with the FMF cache (display names). Returns items in the legacy API shape.
*/
static async readFriendsFromCache(): Promise<FindMyLocationItem[]> {
const localStorageKey = FindMyKeyManager.loadLocalStorageKey();
if (!localStorageKey) {
Server().logger.debug("FindMy LocalStorage key not imported — cannot read friend locations.");
return [];
}

if (!fs.existsSync(FileSystem.findMyLocalStorageDbPath)) {
Server().logger.debug(`FindMy LocalStorage.db not found at ${FileSystem.findMyLocalStorageDbPath}`);
return [];
}

const rawLocations = readFriendLocations(localStorageKey);

// Best-effort: pull friend display names from the FMF cache
let names: Record<string, string> = {};
const fmfKey = await FindMyKeyManager.loadCacheKey("FMF");
if (fmfKey) {
try {
names = await readFmfContacts(fmfKey);
} catch (ex: any) {
Server().logger.debug(`Failed to read FMF contacts: ${String(ex)}`);
}
}

return rawLocations.map(raw => FindMyInterface.buildFriendLocationItem(raw, names));
}

private static buildFriendLocationItem(
raw: RawFriendLocation,
names: Record<string, string>
): FindMyLocationItem {
const loc = raw.location ?? {};

const lat = typeof loc.latitude === "number" ? loc.latitude : 0;
const lng = typeof loc.longitude === "number" ? loc.longitude : 0;

// timestamp may be a plist Date, seconds, or ms — normalize to ms
let lastUpdated = 0;
if (loc.timestamp instanceof Date) {
lastUpdated = loc.timestamp.getTime();
} else if (typeof loc.timestamp === "number") {
lastUpdated = loc.timestamp > 1e12 ? loc.timestamp : Math.round(loc.timestamp * 1000);
}

const name = names[raw.findMyId] ?? null;
const handle = raw.handle ?? null;
const title = name ?? handle ?? raw.findMyId;
const hasCoords = lat !== 0 || lng !== 0;

// NOTE: Address text (long/short/subtitle) is NOT persisted in the friend cache —
// `secureLocations.value` only holds coordinates. The Find My app reverse-geocodes
// addresses at display time. We leave these null; clients can geocode coordinates.
return {
handle,
coordinates: [lat, lng],
long_address: null,
short_address: null,
subtitle: null,
title,
last_updated: lastUpdated,
is_locating_in_progress: false,
status: hasCoords ? "live" : "shallow"
};
}

static async readItemGroups(): Promise<Array<any>> {
const itemGroupsPath = path.join(FileSystem.findMyDir, "ItemGroups.data");
if (!fs.existsSync(itemGroupsPath)) return [];

return new Promise((resolve, reject) => {
fs.readFile(itemGroupsPath, { encoding: "utf-8" }, (err, data) => {
// Couldn't read the file
if (err) return resolve(null);

try {
const parsedData = JSON.parse(data.toString());
if (Array.isArray(parsedData)) {
return resolve(parsedData);
} else {
reject(new Error("Failed to read FindMy ItemGroups cache file! It is not an array!"));
}
} catch {
reject(new Error("Failed to read FindMy ItemGroups cache file! It is not in the correct format!"));
}
});
});
const parsed = await FindMyInterface.readCacheArray(itemGroupsPath);
return parsed ?? [];
}

private static readDataFile<T extends "Devices" | "Items">(
/**
* Reads a Find My FMIP cache `.data` file as an array, transparently handling both
* the legacy plaintext-JSON format and the macOS 14.4+ ChaCha20-Poly1305 format.
*/
private static async readDataFile<T extends "Devices" | "Items">(
type: T
): Promise<Array<T extends "Devices" ? FindMyDevice : FindMyItem> | null> {
const devicesPath = path.join(FileSystem.findMyDir, `${type}.data`);
return new Promise((resolve, reject) => {
fs.readFile(devicesPath, { encoding: "utf-8" }, (err, data) => {
// Couldn't read the file
if (err) return resolve(null);
const dataPath = path.join(FileSystem.findMyDir, `${type}.data`);
return (await FindMyInterface.readCacheArray(dataPath)) as any;
}

try {
const parsedData = JSON.parse(data.toString());
if (Array.isArray(parsedData)) {
return resolve(parsedData);
} else {
reject(new Error(`Failed to read FindMy ${type} cache file! It is not an array!`));
}
} catch {
reject(new Error(`Failed to read FindMy ${type} cache file! It is not in the correct format!`));
}
});
});
private static async readCacheArray(filePath: string): Promise<Array<any> | null> {
if (!fs.existsSync(filePath)) return null;

const buffer = fs.readFileSync(filePath);

// macOS 14.4+: encrypted FMIP cache (binary plist wrapper with `encryptedData`)
const fmipKey = await FindMyKeyManager.loadCacheKey("FMIP");
if (fmipKey) {
try {
const decrypted = await decryptCacheBuffer(buffer, fmipKey);
const arr = FindMyInterface.coerceArray(decrypted);
if (arr) return arr;
} catch (ex: any) {
Server().logger.debug(`Failed to decrypt FindMy cache file ${filePath}: ${String(ex)}`);
}
}

// Legacy plaintext JSON (pre-14.4)
try {
const parsed = JSON.parse(buffer.toString("utf-8"));
if (Array.isArray(parsed)) return parsed;
} catch {
// not plaintext JSON
}

return null;
}

/** Coerces a decrypted plist payload into an array of records, if possible. */
private static coerceArray(decrypted: any): Array<any> | null {
if (decrypted == null) return null;
if (Array.isArray(decrypted)) return decrypted;

// Some payloads wrap the list in a single container key
if (typeof decrypted === "object") {
const arrayValue = Object.values(decrypted).find(v => Array.isArray(v));
if (arrayValue) return arrayValue as Array<any>;
}

return null;
}
}
Loading