diff --git a/package-lock.json b/package-lock.json index f5dcb943..97b75a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5064,6 +5064,17 @@ "node": ">=4.0" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -27259,11 +27270,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", diff --git a/packages/server/package.json b/packages/server/package.json index 34f3ec18..d1125b8f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/api/http/api/v1/httpRoutes.ts b/packages/server/src/server/api/http/api/v1/httpRoutes.ts index 0f4be568..a66fca99 100644 --- a/packages/server/src/server/api/http/api/v1/httpRoutes.ts +++ b/packages/server/src/server/api/http/api/v1/httpRoutes.ts @@ -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"; @@ -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 }, { diff --git a/packages/server/src/server/api/interfaces/findMyInterface.ts b/packages/server/src/server/api/interfaces/findMyInterface.ts index 76349cad..f0b8b3ef 100644 --- a/packages/server/src/server/api/interfaces/findMyInterface.ts +++ b/packages/server/src/server/api/interfaces/findMyInterface.ts @@ -2,11 +2,15 @@ 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() { @@ -14,11 +18,6 @@ export class FindMyInterface { } static async getDevices(): Promise | 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"), @@ -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(); @@ -48,7 +47,7 @@ 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)); } } @@ -56,14 +55,51 @@ export class FindMyInterface { // 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): void { + const ZERO_WIDTH_SPACE = "\u200B"; + const seenByGroup: Record> = {}; + for (const device of devices) { + const addr = device?.address as Record | 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 | null> { // Can't use the Private API to refresh devices yet await this.refreshLocationsAccessibility(); @@ -71,22 +107,36 @@ export class FindMyInterface { } static async refreshFriends(openFindMyApp = true): Promise { + // 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(); @@ -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 { + 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 = {}; + 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 + ): 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> { 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( + /** + * 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( type: T ): Promise | 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 | 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 | 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; + } + + return null; } } diff --git a/packages/server/src/server/api/lib/findmy/FindMyKeyManager.ts b/packages/server/src/server/api/lib/findmy/FindMyKeyManager.ts new file mode 100644 index 00000000..83c6e1cc --- /dev/null +++ b/packages/server/src/server/api/lib/findmy/FindMyKeyManager.ts @@ -0,0 +1,194 @@ +import fs from "fs"; +import path from "path"; +import { Server } from "@server"; +import { FileSystem } from "@server/fileSystem"; +import { parsePlistFile, extractSymmetricKey } from "./decrypt/plistUtils"; +import { decryptLocalStorageDb } from "./decrypt/localStorage"; +import { decryptCacheBuffer } from "./decrypt/cache"; + +export type FindMyKeyType = "LocalStorage" | "FMIP" | "FMF"; + +/** Canonical file names for each key, as produced by findmy-key-extractor. */ +export const FIND_MY_KEY_FILES: Record = { + LocalStorage: "LocalStorage.key", + FMIP: "FMIPDataManager.bplist", + FMF: "FMFDataManager.bplist" +}; + +export type FindMyKeyStatus = { + /** The key file exists in the BlueBubbles keys directory. */ + present: boolean; + /** The key file is well-formed (correct length / parseable). */ + valid: boolean; +}; + +export type FindMyKeysStatus = Record; + +export type KeyImportResult = "imported" | "invalid" | "missing"; + +/** + * Loads, validates, caches, and imports the three Find My decryption keys. + * + * Keys are stored in `FileSystem.findMyKeysDir` and are stable across reboots + * (derived from the user's iCloud account), so they only need to be imported once. + */ +export class FindMyKeyManager { + private static cache: Partial> = {}; + + private static keyPath(type: FindMyKeyType): string { + return path.join(FileSystem.findMyKeysDir, FIND_MY_KEY_FILES[type]); + } + + /** Clears the in-memory key cache (call after a re-import). */ + static clearCache(): void { + this.cache = {}; + } + + /** + * Loads and returns the 32-byte LocalStorage key (raw bytes), or null if unavailable. + */ + static loadLocalStorageKey(): Buffer | null { + if (this.cache.LocalStorage) return this.cache.LocalStorage; + + const keyPath = this.keyPath("LocalStorage"); + if (!fs.existsSync(keyPath)) return null; + + const key = fs.readFileSync(keyPath); + if (key.length !== 32) { + Server().logger.debug(`Invalid LocalStorage key length: ${key.length} bytes, expected 32`); + return null; + } + + this.cache.LocalStorage = key; + return key; + } + + /** + * Loads and returns a 32-byte ChaCha20 cache key (FMIP or FMF), or null if unavailable. + */ + static async loadCacheKey(type: "FMIP" | "FMF"): Promise { + if (this.cache[type]) return this.cache[type] as Buffer; + + const keyPath = this.keyPath(type); + if (!fs.existsSync(keyPath)) return null; + + try { + const plistData = await parsePlistFile(keyPath); + const key = extractSymmetricKey(plistData); + if (!key) { + Server().logger.debug(`Could not extract a valid 32-byte ${type} key from ${keyPath}`); + return null; + } + + this.cache[type] = key; + return key; + } catch (ex: any) { + Server().logger.debug(`Failed to load ${type} key: ${String(ex)}`); + return null; + } + } + + /** + * Returns presence/validity for all three keys (used by the UI status card). + */ + static async getStatus(): Promise { + const status = {} as FindMyKeysStatus; + + for (const type of Object.keys(FIND_MY_KEY_FILES) as FindMyKeyType[]) { + const present = fs.existsSync(this.keyPath(type)); + let valid = false; + if (present) { + try { + const key = + type === "LocalStorage" ? this.loadLocalStorageKey() : await this.loadCacheKey(type); + valid = key != null; + } catch { + valid = false; + } + } + + status[type] = { present, valid }; + } + + return status; + } + + /** + * Validates a candidate key file (before importing it). + * + * - LocalStorage: 32 raw bytes; if the encrypted db is present, page 0 must decrypt + * to a valid SQLite header. + * - FMIP/FMF: bplist must yield a 32-byte symmetric key; if a matching cache file is + * present, it must decrypt (Poly1305 tag verifies correctness). + */ + static async validateKeyFile(type: FindMyKeyType, filePath: string): Promise { + try { + if (type === "LocalStorage") { + const key = fs.readFileSync(filePath); + if (key.length !== 32) return false; + + // Deep check against real data when available + if (fs.existsSync(FileSystem.findMyLocalStorageDbPath)) { + decryptLocalStorageDb(key, FileSystem.findMyLocalStorageDbPath); + } + return true; + } + + const plistData = await parsePlistFile(filePath); + const key = extractSymmetricKey(plistData); + if (!key) return false; + + // Deep check: try decrypting a real cache file if one exists + const cacheFile = + type === "FMIP" + ? path.join(FileSystem.findMyDir, "Devices.data") + : path.join(FileSystem.findMyFmfCacheDir, "FriendCacheData.data"); + if (fs.existsSync(cacheFile)) { + const decrypted = await decryptCacheBuffer(fs.readFileSync(cacheFile), key); + if (decrypted == null) return false; + } + + return true; + } catch (ex: any) { + Server().logger.debug(`Validation failed for ${type} key at ${filePath}: ${String(ex)}`); + return false; + } + } + + /** + * Imports keys from a directory (e.g. findmy-key-extractor's `keys/` folder). + * + * Auto-detects the three key files by name, validates each, and copies the valid + * ones into `FileSystem.findMyKeysDir`. Returns a per-key import result. + */ + static async importFromDirectory(sourceDir: string): Promise> { + const result = {} as Record; + + if (!fs.existsSync(FileSystem.findMyKeysDir)) { + fs.mkdirSync(FileSystem.findMyKeysDir, { recursive: true }); + } + + for (const type of Object.keys(FIND_MY_KEY_FILES) as FindMyKeyType[]) { + const fileName = FIND_MY_KEY_FILES[type]; + const src = path.join(sourceDir, fileName); + + if (!fs.existsSync(src)) { + result[type] = "missing"; + continue; + } + + const isValid = await this.validateKeyFile(type, src); + if (!isValid) { + result[type] = "invalid"; + continue; + } + + fs.copyFileSync(src, this.keyPath(type)); + result[type] = "imported"; + } + + // Refresh the in-memory cache so newly imported keys take effect immediately + this.clearCache(); + return result; + } +} diff --git a/packages/server/src/server/api/lib/findmy/decrypt/cache.ts b/packages/server/src/server/api/lib/findmy/decrypt/cache.ts new file mode 100644 index 00000000..a7251b51 --- /dev/null +++ b/packages/server/src/server/api/lib/findmy/decrypt/cache.ts @@ -0,0 +1,72 @@ +import { parsePlistBuffer } from "./plistUtils"; + +/** + * Apple's Find My plists use the literal string "$null" as a null placeholder. The legacy + * plaintext cache (read directly as JSON) used real `null`s, and the BlueBubbles clients + * expect nullable strings — so recursively replace "$null" with null to match that shape. + */ +const normalizePlistNulls = (value: any): any => { + if (value === "$null") return null; + if (Array.isArray(value)) return value.map(normalizePlistNulls); + if (value && typeof value === "object" && !Buffer.isBuffer(value)) { + for (const k of Object.keys(value)) { + value[k] = normalizePlistNulls(value[k]); + } + } + return value; +}; + +/** + * Decrypts a Find My cache `.data` file payload using ChaCha20-Poly1305. + * + * Layout of `encryptedData`: + * [ 12-byte nonce ][ ciphertext ][ 16-byte Poly1305 tag ] + * + * The Poly1305 tag doubles as a correctness check — decryption throws on a wrong key, + * so a successful decrypt implies the key is valid. + * + * @returns The decrypted plaintext buffer, or null on failure. + */ +export const decryptChaCha20Poly1305 = async (encryptedData: Buffer, key: Buffer): Promise => { + if (encryptedData.length < 28) return null; + + // Dynamically import so a load-time failure of the native-ish module doesn't crash the server + const { chacha20poly1305 } = await import("@noble/ciphers/chacha"); + + const nonce = encryptedData.subarray(0, 12); + const ciphertextWithTag = encryptedData.subarray(12); + + const cipher = chacha20poly1305(new Uint8Array(key), new Uint8Array(nonce)); + const decrypted = cipher.decrypt(new Uint8Array(ciphertextWithTag)); + return Buffer.from(decrypted); +}; + +/** + * Reads a Find My cache `.data` file (binary plist wrapper with an `encryptedData` blob), + * decrypts it, and parses the resulting plaintext (typically another binary plist). + * + * @param fileBuffer Raw contents of the `.data` file + * @param key 32-byte ChaCha20 key (FMIP or FMF) + * @returns The parsed decrypted object, or null on failure. + */ +export const decryptCacheBuffer = async (fileBuffer: Buffer, key: Buffer): Promise => { + const wrapper = await parsePlistBuffer(fileBuffer); + + const encryptedData = wrapper?.encryptedData; + if (!encryptedData) return null; + + const encryptedBuffer = Buffer.isBuffer(encryptedData) ? encryptedData : Buffer.from(encryptedData); + const decrypted = await decryptChaCha20Poly1305(encryptedBuffer, key); + if (!decrypted) return null; + + // Decrypted payload is usually a binary plist; fall back to JSON for safety + if (decrypted.toString("utf8", 0, 6) === "bplist") { + return normalizePlistNulls(await parsePlistBuffer(decrypted)); + } + + try { + return normalizePlistNulls(JSON.parse(decrypted.toString())); + } catch { + return null; + } +}; diff --git a/packages/server/src/server/api/lib/findmy/decrypt/fmfReader.ts b/packages/server/src/server/api/lib/findmy/decrypt/fmfReader.ts new file mode 100644 index 00000000..c2044e67 --- /dev/null +++ b/packages/server/src/server/api/lib/findmy/decrypt/fmfReader.ts @@ -0,0 +1,32 @@ +import fs from "fs"; +import path from "path"; +import { FileSystem } from "@server/fileSystem"; +import { decryptCacheBuffer } from "./cache"; + +/** + * Reads friend display names from the encrypted FMF cache (`FriendCacheData.data`). + * + * The decrypted plaintext is a binary plist dict whose `contacts` map is keyed by + * findMyId and holds `{ displayName, ... }` per friend. + * + * @returns A map of findMyId (trailing `~` stripped) -> displayName. + */ +export const readFmfContacts = async (fmfKey: Buffer): Promise> => { + const cachePath = path.join(FileSystem.findMyFmfCacheDir, "FriendCacheData.data"); + if (!fs.existsSync(cachePath)) return {}; + + const decrypted = await decryptCacheBuffer(fs.readFileSync(cachePath), fmfKey); + const contacts = decrypted?.contacts; + if (!contacts || typeof contacts !== "object") return {}; + + const names: Record = {}; + for (const [rawId, info] of Object.entries(contacts)) { + const id = rawId.replace(/~+$/, ""); + const displayName = info?.displayName; + if (id && typeof displayName === "string" && displayName.length > 0) { + names[id] = displayName; + } + } + + return names; +}; diff --git a/packages/server/src/server/api/lib/findmy/decrypt/localStorage.ts b/packages/server/src/server/api/lib/findmy/decrypt/localStorage.ts new file mode 100644 index 00000000..93154ea4 --- /dev/null +++ b/packages/server/src/server/api/lib/findmy/decrypt/localStorage.ts @@ -0,0 +1,131 @@ +import fs from "fs"; +import crypto from "crypto"; + +/** + * Decryption for Apple Find My's encrypted `LocalStorage.db` (friend coordinates). + * + * This is NOT standard SQLCipher. Apple's `sqliteCodecCCCrypto` encrypts each 4096-byte + * SQLite page independently using an AES-256 keystream-XOR (CTR-like) construction: + * keystream = AES-256-CBC-ENCRYPT(key, iv, zeros[4096]) + * plaintext = ciphertext[0:4084] XOR keystream[0:4084] + * where iv = LE32(pgno) ‖ reserved(12 bytes from the page tail), pgno = page_index + 1. + * + * Ported from findmy-key-extractor/decrypt_localstorage.py. + */ + +const PAGE_SIZE = 4096; +const RESERVED_OFF = 4084; +const RESERVED_LEN = 12; +const CONTENT_LEN = RESERVED_OFF; // 4084 encrypted bytes per page +const SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "binary"); + +const WAL_HEADER_SIZE = 32; +const WAL_FRAME_HEADER_SIZE = 24; + +/** + * Decrypt a single 4096-byte page using AES-256-CBC keystream XOR. + */ +const decryptPage = (key: Buffer, page: Buffer, pageIndex: number): Buffer => { + const pgno = pageIndex + 1; + const reserved = page.subarray(RESERVED_OFF, RESERVED_OFF + RESERVED_LEN); + + const iv = Buffer.alloc(16); + iv.writeUInt32LE(pgno, 0); + reserved.copy(iv, 4); + + // Generate the keystream by CBC-*encrypting* zeros (autopadding off so output stays 4096 bytes) + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); + cipher.setAutoPadding(false); + const keystream = Buffer.concat([cipher.update(Buffer.alloc(PAGE_SIZE)), cipher.final()]); + + const decrypted = Buffer.alloc(CONTENT_LEN); + for (let i = 0; i < CONTENT_LEN; i++) { + decrypted[i] = page[i] ^ keystream[i]; + } + + const result = Buffer.concat([decrypted, reserved]); + + // Page 0 fix-up: bytes 16-23 (page size / format versions) are stored in plaintext + if (pageIndex === 0) { + page.copy(result, 16, 16, 24); + } + + return result; +}; + +/** + * Decrypt every page of the database file buffer. + * @throws if page 0 does not decrypt to a valid SQLite header (wrong key / corrupt db). + */ +const decryptDatabaseBuffer = (key: Buffer, data: Buffer): Buffer => { + const numPages = Math.floor(data.length / PAGE_SIZE); + if (numPages === 0) throw new Error("LocalStorage.db is empty"); + + const pages: Buffer[] = []; + for (let i = 0; i < numPages; i++) { + const page = data.subarray(i * PAGE_SIZE, (i + 1) * PAGE_SIZE); + pages.push(decryptPage(key, page, i)); + } + + const output = Buffer.concat(pages); + if (!output.subarray(0, 16).equals(SQLITE_MAGIC)) { + throw new Error("LocalStorage.db decryption failed — page 0 is not a SQLite header (wrong key?)"); + } + + return output; +}; + +/** + * Apply WAL frames on top of the decrypted database (newest committed pages). + */ +const applyWal = (key: Buffer, walData: Buffer, dbPages: Buffer): Buffer => { + if (walData.length < WAL_HEADER_SIZE) return dbPages; + + let output = dbPages; + let offset = WAL_HEADER_SIZE; + while (offset + WAL_FRAME_HEADER_SIZE + PAGE_SIZE <= walData.length) { + // Frame header: pgno is a big-endian uint32 at offset 0 + const pgno = walData.readUInt32BE(offset); + const pageIndex = pgno - 1; + const framePage = walData.subarray( + offset + WAL_FRAME_HEADER_SIZE, + offset + WAL_FRAME_HEADER_SIZE + PAGE_SIZE + ); + const decrypted = decryptPage(key, framePage, pageIndex); + + // Grow the db if the WAL references pages beyond the current size + const needed = (pageIndex + 1) * PAGE_SIZE; + if (needed > output.length) { + output = Buffer.concat([output, Buffer.alloc(needed - output.length)]); + } + + decrypted.copy(output, pageIndex * PAGE_SIZE); + offset += WAL_FRAME_HEADER_SIZE + PAGE_SIZE; + } + + return output; +}; + +/** + * Decrypt LocalStorage.db (+ WAL if present) into a plaintext SQLite buffer. + * + * @param key 32-byte LocalStorage key + * @param dbPath Path to the encrypted LocalStorage.db + * @param walPath Optional path to LocalStorage.db-wal (defaults to `${dbPath}-wal`) + */ +export const decryptLocalStorageDb = (key: Buffer, dbPath: string, walPath?: string): Buffer => { + if (key.length !== 32) throw new Error(`Expected 32-byte LocalStorage key, got ${key.length}`); + + const data = fs.readFileSync(dbPath); + let output = decryptDatabaseBuffer(key, data); + + const resolvedWalPath = walPath ?? `${dbPath}-wal`; + if (fs.existsSync(resolvedWalPath)) { + const walData = fs.readFileSync(resolvedWalPath); + if (walData.length > WAL_HEADER_SIZE) { + output = applyWal(key, walData, output); + } + } + + return output; +}; diff --git a/packages/server/src/server/api/lib/findmy/decrypt/localStorageReader.ts b/packages/server/src/server/api/lib/findmy/decrypt/localStorageReader.ts new file mode 100644 index 00000000..8cd7823a --- /dev/null +++ b/packages/server/src/server/api/lib/findmy/decrypt/localStorageReader.ts @@ -0,0 +1,147 @@ +import fs from "fs"; +import path from "path"; +import { FileSystem } from "@server/fileSystem"; +import { uuidv4 } from "@firebase/util"; +import { decryptLocalStorageDb } from "./localStorage"; + +// better-sqlite3 and bplist-parser are runtime deps; require directly for sync use here +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Database = require("better-sqlite3"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const bplist = require("bplist-parser"); + +export type RawFriendLocation = { + /** Stable Find My identifier (serverUserID / serverID), trailing `~` stripped. */ + findMyId: string; + /** Owner handle (email / phone) if resolvable from the `friends` table. */ + handle: string | null; + /** Full parsed `secureLocations.value` plist (lat/long/timestamp/accuracy/...). */ + location: Record; +}; + +const stripPadding = (id: string): string => (id ?? "").replace(/~+$/, ""); + +/** + * Decrypt LocalStorage.db to a temporary plaintext SQLite file, run `fn` against it, + * then delete the temp file. The plaintext contains real private coordinates, so it is + * always cleaned up — even on error. + */ +const withDecryptedDb = (key: Buffer, fn: (db: any) => T): T => { + const tmpPath = path.join(FileSystem.baseDir, `findmy-localstorage-${uuidv4()}.sqlite`); + const decrypted = decryptLocalStorageDb(key, FileSystem.findMyLocalStorageDbPath); + fs.writeFileSync(tmpPath, decrypted, { mode: 0o600 }); + + let db: any = null; + try { + db = new Database(tmpPath, { readonly: true, fileMustExist: true }); + return fn(db); + } finally { + try { + db?.close(); + } catch { + // ignore + } + try { + fs.unlinkSync(tmpPath); + } catch { + // ignore + } + } +}; + +/** Returns the set of column names for a table (empty if the table doesn't exist). */ +const tableColumns = (db: any, table: string): Set => { + try { + const rows = db.prepare(`PRAGMA table_info(${table})`).all(); + return new Set(rows.map((r: any) => r.name)); + } catch { + return new Set(); + } +}; + +/** + * Reads and joins friend coordinates from the decrypted LocalStorage.db. + * + * - `secureLocations` holds one coordinate plist per friend (keyed by serverUserID). + * - `friends` maps serverID -> handleIdentifier (email/phone). + */ +export const readFriendLocations = (key: Buffer): RawFriendLocation[] => { + return withDecryptedDb(key, (db): RawFriendLocation[] => { + // Build findMyId -> handle map from the friends table (defensive about column names). + // Real schema: handleServerIdentifier (= findMyId) maps to handleIdentifier (email/phone). + // A single findMyId can have multiple handle rows (e.g. an email and a phone) — prefer email. + const handleById: Record = {}; + const friendCols = tableColumns(db, "friends"); + const idCol = friendCols.has("handleServerIdentifier") + ? "handleServerIdentifier" + : friendCols.has("serverID") + ? "serverID" + : null; + const handleCol = friendCols.has("handleIdentifier") ? "handleIdentifier" : null; + if (idCol && handleCol) { + const rows = db.prepare(`SELECT ${idCol} as fid, ${handleCol} as handle FROM friends`).all(); + for (const row of rows) { + const fid = stripPadding(String(row.fid ?? "")); + const handle = row.handle ? String(row.handle) : null; + if (!fid || !handle) continue; + // Prefer an email-style handle when multiple exist for the same friend + if (!handleById[fid] || (!handleById[fid].includes("@") && handle.includes("@"))) { + handleById[fid] = handle; + } + } + } + + const out: RawFriendLocation[] = []; + const locCols = tableColumns(db, "secureLocations"); + if (!locCols.has("value")) return out; + + const locIdCol = locCols.has("serverUserID") ? "serverUserID" : locCols.has("serverID") ? "serverID" : null; + if (!locIdCol) return out; + + const rows = db.prepare(`SELECT ${locIdCol} as id, value FROM secureLocations`).all(); + for (const row of rows) { + const fid = stripPadding(String(row.id ?? "")); + if (!fid || row.value == null) continue; + + try { + const valueBuf = Buffer.isBuffer(row.value) ? row.value : Buffer.from(row.value); + const parsed = bplist.parseBuffer(valueBuf)[0]; + out.push({ + findMyId: fid, + handle: handleById[fid] ?? null, + location: parsed + }); + } catch { + // skip unparseable rows + } + } + + return out; + }); +}; + +/** + * Diagnostic helper: dump the schema (tables + columns) and row counts of the decrypted + * LocalStorage.db. Used to locate where address strings are stored on a real machine. + */ +export const dumpLocalStorageSchema = (key: Buffer): Record => { + return withDecryptedDb(key, (db) => { + const tables: { name: string }[] = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) + .all(); + + const schema: Record = {}; + for (const { name } of tables) { + const columns = [...tableColumns(db, name)]; + let rowCount = 0; + try { + rowCount = (db.prepare(`SELECT COUNT(*) as c FROM "${name}"`).get() as any).c; + } catch { + // ignore + } + schema[name] = { columns, rowCount }; + } + + return schema; + }); +}; diff --git a/packages/server/src/server/api/lib/findmy/decrypt/plistUtils.ts b/packages/server/src/server/api/lib/findmy/decrypt/plistUtils.ts new file mode 100644 index 00000000..5ee7c2cc --- /dev/null +++ b/packages/server/src/server/api/lib/findmy/decrypt/plistUtils.ts @@ -0,0 +1,48 @@ +import fs from "fs"; +import plist from "plist"; +import * as bplist from "bplist-parser"; + +/** + * Parse a plist file, transparently supporting both binary ("bplist") and XML plists. + */ +export const parsePlistFile = async (filePath: string): Promise => { + const fileData = fs.readFileSync(filePath); + return parsePlistBuffer(fileData); +}; + +/** + * Parse a plist from an in-memory buffer (binary or XML). + */ +export const parsePlistBuffer = async (buffer: Buffer): Promise => { + if (buffer.toString("utf8", 0, 6) === "bplist") { + const result = await bplist.parseBuffer(buffer); + // bplist-parser returns an array of top-level objects; the first is the root + return result[0]; + } + + return plist.parse(buffer.toString("utf8")); +}; + +/** + * Extracts the 32-byte ChaCha20 symmetric key from a FMIP/FMF DataManager bplist. + * + * The keychain bplist nests the raw key as: symmetricKey -> key -> data. + * Older/alternate formats may store the key directly as a base64 string. + * + * @returns The 32-byte key buffer, or null if it could not be extracted. + */ +export const extractSymmetricKey = (plistData: any): Buffer | null => { + const symmetricKey = plistData?.symmetricKey; + if (!symmetricKey) return null; + + let keyBytes: Buffer | null = null; + if (typeof symmetricKey === "object" && symmetricKey.key?.data != null) { + const data = symmetricKey.key.data; + keyBytes = Buffer.isBuffer(data) ? data : Buffer.from(data, "base64"); + } else if (typeof symmetricKey === "string") { + keyBytes = Buffer.from(symmetricKey, "base64"); + } + + if (!keyBytes || keyBytes.length !== 32) return null; + return keyBytes; +}; diff --git a/packages/server/src/server/api/lib/findmy/types.ts b/packages/server/src/server/api/lib/findmy/types.ts index 61be98a3..fcaf3af3 100644 --- a/packages/server/src/server/api/lib/findmy/types.ts +++ b/packages/server/src/server/api/lib/findmy/types.ts @@ -139,7 +139,7 @@ export type FindMyLocationItem = { subtitle: string | null; title: string | null; last_updated: number; - is_locating_in_progress: 0 | 1; + is_locating_in_progress: boolean; status: "legacy" | "live" | "shallow"; }; diff --git a/packages/server/src/server/env.ts b/packages/server/src/server/env.ts index a116be67..6cb77614 100644 --- a/packages/server/src/server/env.ts +++ b/packages/server/src/server/env.ts @@ -1,6 +1,10 @@ import * as macosVersion from "macos-version"; export const isMinSequoia = macosVersion.isGreaterThanOrEqualTo("15.0"); +// macOS 14.4 is where Apple started encrypting the Find My location cache (and where the +// Private API helper stopped working for Find My). At/after this version we read locations +// by decrypting the cache instead of relying on the Private API injection. +export const isMinSonoma14_4 = macosVersion.isGreaterThanOrEqualTo("14.4"); export const isMinSonoma = macosVersion.isGreaterThanOrEqualTo("14.0"); export const isMinVentura = macosVersion.isGreaterThanOrEqualTo("13.0"); export const isMinMonterey = macosVersion.isGreaterThanOrEqualTo("12.0"); diff --git a/packages/server/src/server/fileSystem/index.ts b/packages/server/src/server/fileSystem/index.ts index ae05ec39..1c587b88 100644 --- a/packages/server/src/server/fileSystem/index.ts +++ b/packages/server/src/server/fileSystem/index.ts @@ -112,6 +112,24 @@ export class FileSystem { public static findMyFriendsDir = path.join(userHomeDir(), "Library", "Caches", "com.apple.icloud.fmfd"); + // FMF cache (contains friend display names / contacts, ChaCha20-Poly1305 encrypted on macOS 14.4+) + public static findMyFmfCacheDir = path.join(userHomeDir(), "Library", "Caches", "com.apple.findmy.fmfcore"); + + // Encrypted SQLite database that holds friend coordinates on macOS 14.0+ + public static findMyLocalStorageDir = path.join( + userHomeDir(), + "Library", + "Group Containers", + "group.com.apple.findmy.findmylocateagent", + "Library", + "Application Support" + ); + + public static findMyLocalStorageDbPath = path.join(FileSystem.findMyLocalStorageDir, "LocalStorage.db"); + + // Where BlueBubbles stores the user-imported Find My decryption keys + public static findMyKeysDir = path.join(FileSystem.baseDir, "FindMyKeys"); + public static get usingCustomFcm(): boolean { const fcmClient = Server().args["fcm-client"]; const fcmServer = Server().args["fcm-server"]; @@ -153,6 +171,7 @@ export class FileSystem { if (!fs.existsSync(FileSystem.certsDir)) fs.mkdirSync(FileSystem.certsDir); if (!fs.existsSync(FileSystem.themesDir)) fs.mkdirSync(FileSystem.themesDir); if (!fs.existsSync(FileSystem.settingsDir)) fs.mkdirSync(FileSystem.settingsDir); + if (!fs.existsSync(FileSystem.findMyKeysDir)) fs.mkdirSync(FileSystem.findMyKeysDir); if (isMinMonterey) { if (!fs.existsSync(FileSystem.iMessageAttachmentsDir)) fs.mkdirSync(FileSystem.iMessageAttachmentsDir); diff --git a/packages/server/src/server/services/ipcService/index.ts b/packages/server/src/server/services/ipcService/index.ts index 42635aa7..9fdda8d7 100644 --- a/packages/server/src/server/services/ipcService/index.ts +++ b/packages/server/src/server/services/ipcService/index.ts @@ -1,9 +1,13 @@ import { app, dialog, ipcMain, systemPreferences, shell } from "electron"; import { askForAccessibilityAccess, askForFullDiskAccess } from "node-mac-permissions"; import process from "process"; +import fs from "fs"; +import path from "path"; import { Server } from "@server"; import { FileSystem } from "@server/fileSystem"; +import { FindMyKeyManager } from "@server/api/lib/findmy/FindMyKeyManager"; +import { dumpLocalStorageSchema } from "@server/api/lib/findmy/decrypt/localStorageReader"; import { AlertsInterface } from "@server/api/interfaces/alertsInterface"; import { openLogs, openAppData } from "@server/api/apple/scripts"; import { fixServerUrl } from "@server/helpers/utils"; @@ -21,7 +25,8 @@ import { isMinMonterey, isMinSierra, isMinVentura, - isMinSonoma + isMinSonoma, + isMinSonoma14_4 } from "@server/env"; import { Loggable, getLogger } from "@server/lib/logging/Loggable"; import { ZrokManager } from "@server/managers/zrokManager"; @@ -45,7 +50,8 @@ export class IPCService extends Loggable { isMinBigSur: isMinBigSur, isMinMonterey: isMinMonterey, isMinVentura: isMinVentura, - isMinSonoma: isMinSonoma + isMinSonoma: isMinSonoma, + isMinSonoma14_4: isMinSonoma14_4 }; }); @@ -140,6 +146,51 @@ export class IPCService extends Loggable { return await Server().privateApi.modeType.install(true); }); + ipcMain.handle("get-findmy-keys-status", async (_, __) => { + return await FindMyKeyManager.getStatus(); + }); + + ipcMain.handle("import-findmy-keys", async (_, __) => { + const win = Server().window; + const dialogResult = win + ? await dialog.showOpenDialog(win, { + title: "Select the folder containing your Find My keys", + message: + "Select the 'keys' folder produced by findmy-key-extractor " + + "(it should contain LocalStorage.key, FMIPDataManager.bplist, and FMFDataManager.bplist).", + properties: ["openDirectory"] + }) + : await dialog.showOpenDialog({ + title: "Select the folder containing your Find My keys", + properties: ["openDirectory"] + }); + + if (dialogResult.canceled || dialogResult.filePaths.length === 0) { + return { canceled: true, result: null }; + } + + const importResult = await FindMyKeyManager.importFromDirectory(dialogResult.filePaths[0]); + return { canceled: false, result: importResult }; + }); + + // Diagnostic: dump the decrypted LocalStorage.db schema to help locate address fields + ipcMain.handle("findmy-dump-schema", async (_, __) => { + const key = FindMyKeyManager.loadLocalStorageKey(); + if (!key) return { success: false, error: "LocalStorage key not imported" }; + if (!fs.existsSync(FileSystem.findMyLocalStorageDbPath)) { + return { success: false, error: "LocalStorage.db not found on this machine" }; + } + + try { + const schema = dumpLocalStorageSchema(key); + const outPath = path.join(FileSystem.baseDir, "findmy-localstorage-schema.json"); + fs.writeFileSync(outPath, JSON.stringify(schema, null, 2)); + return { success: true, schema, path: outPath }; + } catch (ex: any) { + return { success: false, error: String(ex?.message ?? ex) }; + } + }); + ipcMain.handle("get-fcm-server", (event, args) => { return FileSystem.getFCMServer(); }); diff --git a/packages/ui/src/app/components/fields/FindMyKeysField.tsx b/packages/ui/src/app/components/fields/FindMyKeysField.tsx new file mode 100644 index 00000000..dc9da271 --- /dev/null +++ b/packages/ui/src/app/components/fields/FindMyKeysField.tsx @@ -0,0 +1,129 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + FormControl, + FormHelperText, + Text, + Box, + Stack, + Button, + Badge, + HStack, + Link +} from '@chakra-ui/react'; +import { + getEnv, + getFindMyKeysStatus, + importFindMyKeys, + FindMyKeysStatus +} from '../../utils/IpcUtils'; +import { showSuccessToast, showErrorToast } from '../../utils/ToastUtils'; + +type KeyType = 'LocalStorage' | 'FMIP' | 'FMF'; + +const KEY_LABELS: Record = { + LocalStorage: 'Friend Locations (LocalStorage.key)', + FMIP: 'Devices & Items (FMIPDataManager.bplist)', + FMF: 'Friend Names (FMFDataManager.bplist)' +}; + +const KeyBadge = ({ label, status }: { label: string; status?: { present: boolean; valid: boolean } }): JSX.Element => { + let color = 'red'; + let text = 'Missing'; + if (status?.present && status?.valid) { + color = 'green'; + text = 'Imported'; + } else if (status?.present && !status?.valid) { + color = 'orange'; + text = 'Invalid'; + } + + return ( + + {label} + {text} + + ); +}; + +export const FindMyKeysField = (): JSX.Element => { + const [env, setEnv] = useState({} as Record); + const [status, setStatus] = useState(null as FindMyKeysStatus | null); + const [importing, setImporting] = useState(false); + + const refreshStatus = useCallback(async () => { + try { + setStatus(await getFindMyKeysStatus()); + } catch { + setStatus(null); + } + }, []); + + useEffect(() => { + getEnv().then(setEnv); + refreshStatus(); + }, [refreshStatus]); + + // Decryption-based Find My is only required/used on macOS 14.4 and later (where Apple + // started encrypting the Find My location cache). + if (!env.isMinSonoma14_4) return <>; + + const onImport = async () => { + setImporting(true); + try { + const { canceled, result } = await importFindMyKeys(); + if (canceled || !result) return; + + const imported = Object.entries(result) + .filter(([, v]) => v === 'imported') + .map(([k]) => k); + const failed = Object.entries(result).filter(([, v]) => v !== 'imported'); + + if (imported.length > 0) { + showSuccessToast({ description: `Imported ${imported.length} Find My key(s): ${imported.join(', ')}` }); + } + if (failed.length > 0) { + showErrorToast({ + description: `Could not import: ${failed.map(([k, v]) => `${k} (${v})`).join(', ')}` + }); + } + + await refreshStatus(); + } catch (ex: any) { + showErrorToast({ description: `Failed to import Find My keys: ${String(ex?.message ?? ex)}` }); + } finally { + setImporting(false); + } + }; + + return ( + + Find My Decryption Keys + + + + + + + + + + On macOS 14.4+, Apple encrypts the Find My location cache. BlueBubbles needs the + three decryption keys to read device and friend locations without code injection. + Extract them with{' '} + + findmy-key-extractor + + , then click the button above and select the generated keys folder. The keys are + stable across reboots, so you only need to import them once. + + + + + ); +}; diff --git a/packages/ui/src/app/components/fields/ProxySetupField.tsx b/packages/ui/src/app/components/fields/ProxySetupField.tsx index 5d921b57..4b4aae6d 100644 --- a/packages/ui/src/app/components/fields/ProxySetupField.tsx +++ b/packages/ui/src/app/components/fields/ProxySetupField.tsx @@ -22,6 +22,7 @@ import { ConfirmationDialog } from '../modals/ConfirmationDialog'; import { saveLanUrl } from 'app/utils/IpcUtils'; import { NgrokSetupDialog } from '../modals/NgrokSetupDialog'; import { ZrokSetupDialog } from '../modals/ZrokSetupDialog'; +import { LanUrlDialog } from '../modals/LanUrlDialog'; export interface ProxySetupFieldProps { @@ -48,13 +49,17 @@ export const ProxySetupField = ({ helpText, showAddress = true }: ProxySetupFiel const dnsRef = useRef(null); const ngrokRef = useRef(null); const zrokRef = useRef(null); + const lanRef = useRef(null); const alertRef = useRef(null); const proxyService: string = (useAppSelector(state => state.config.proxy_service) ?? '').toLowerCase().replace(' ', '-'); const address: string = useAppSelector(state => state.config.server_address) ?? ''; const port: number = useAppSelector(state => state.config.socket_port) ?? 1234; + const localIps: string[] = useAppSelector(state => state.config.local_ipv4s) ?? []; + const useHttps: boolean = useAppSelector(state => state.config.use_custom_certificate) ?? false; const [dnsModalOpen, setDnsModalOpen] = useBoolean(); const [ngrokModalOpen, setNgrokModalOpen] = useBoolean(); const [zrokModalOpen, setZrokModalOpen] = useBoolean(); + const [lanModalOpen, setLanModalOpen] = useBoolean(); const [requiresConfirmation, confirm] = useState((): string | null => { return null; }); @@ -84,7 +89,14 @@ export const ProxySetupField = ({ helpText, showAddress = true }: ProxySetupFiel } else if (e.target.value === 'cloudflare') { confirm('confirmation'); } else if (e.target.value === 'lan-url') { - saveLanUrl(); + // With multiple LAN IPs, let the user choose which one to use. + // With a single (or no) IP, keep the original auto-select behavior. + if (localIps.length > 1) { + shouldSave = false; + setLanModalOpen.on(); + } else { + saveLanUrl(); + } } if (shouldSave) { @@ -107,6 +119,15 @@ export const ProxySetupField = ({ helpText, showAddress = true }: ProxySetupFiel onClick={() => setDnsModalOpen.on()} /> ) : null} + {(proxyService === 'lan-url' && localIps.length > 1) + ? ( + } + onClick={() => setLanModalOpen.on()} + /> + ) : null} {(showAddress) ? ( <> Address: {address} @@ -154,6 +175,20 @@ export const ProxySetupField = ({ helpText, showAddress = true }: ProxySetupFiel onClose={() => setZrokModalOpen.off()} /> + { + dispatch(setConfig({ name: 'proxy_service', value: 'lan-url' })); + dispatch(setConfig({ name: 'server_address', value: url })); + }} + isOpen={lanModalOpen} + onClose={() => setLanModalOpen.off()} + /> + void; + onConfirm?: (address: string) => void; + isOpen: boolean; + modalRef: React.RefObject; + onClose: () => void; + /** All detected non-internal LAN IPv4 addresses. */ + ips: string[]; + port: number; + /** Whether the server uses HTTPS (custom certificate). */ + useHttps?: boolean; + /** Currently selected server address, used to preselect the matching option. */ + currentAddress?: string; +} + +export const LanUrlDialog = ({ + onCancel, + onConfirm, + isOpen, + modalRef, + onClose, + ips, + port, + useHttps = false, + currentAddress = '' +}: LanUrlDialogProps): JSX.Element => { + const scheme = useHttps ? 'https' : 'http'; + const urls = ips.map(ip => `${scheme}://${ip}:${port}`); + + const [selected, setSelected] = useState(currentAddress && urls.includes(currentAddress) ? currentAddress : urls[0] ?? ''); + + // Keep the selection valid when the available URLs change (e.g. port/scheme updates) + useEffect(() => { + if (!urls.includes(selected)) { + setSelected(currentAddress && urls.includes(currentAddress) ? currentAddress : urls[0] ?? ''); + } + }, [isOpen, ips.join(','), port, useHttps]); + + return ( + onClose()}> + + + + Select a LAN Address + + + + + Your Mac has multiple local network addresses (e.g. Wi-Fi, Ethernet, or virtual + adapters). Pick the one your client devices can reach on your network. You can copy + any address to test which one works. + + + + {urls.map(url => ( + + {url} + } + onClick={() => copyToClipboard(url)} + /> + + ))} + + + {urls.length === 0 ? ( + + No LAN addresses were detected. + + ) : null} + + + + + + + + + + ); +}; diff --git a/packages/ui/src/app/layouts/settings/features/FeatureSettings.tsx b/packages/ui/src/app/layouts/settings/features/FeatureSettings.tsx index 9f05c29d..28000d20 100644 --- a/packages/ui/src/app/layouts/settings/features/FeatureSettings.tsx +++ b/packages/ui/src/app/layouts/settings/features/FeatureSettings.tsx @@ -20,6 +20,7 @@ import { StartMinimizedField } from '../../../components/fields/StartMinimizedFi import { StartDelayField } from 'app/components/fields/StartDelayField'; import { LandingPageField } from 'app/components/fields/LandingPageField'; import { OpenFindMyOnStartupField } from 'app/components/fields/OpenFindMyOnStartupField'; +import { FindMyKeysField } from 'app/components/fields/FindMyKeysField'; import { AutoLockMacField } from 'app/components/fields/AutoLockMacField'; @@ -32,6 +33,8 @@ export const FeatureSettings = (): JSX.Element => { + + diff --git a/packages/ui/src/app/utils/IpcUtils.ts b/packages/ui/src/app/utils/IpcUtils.ts index 29df94fd..71cb6544 100644 --- a/packages/ui/src/app/utils/IpcUtils.ts +++ b/packages/ui/src/app/utils/IpcUtils.ts @@ -25,6 +25,24 @@ export const getEnv = async () => { return await ipcRenderer.invoke('get-env'); }; +export type FindMyKeyStatus = { present: boolean; valid: boolean }; +export type FindMyKeysStatus = { + LocalStorage: FindMyKeyStatus; + FMIP: FindMyKeyStatus; + FMF: FindMyKeyStatus; +}; + +export const getFindMyKeysStatus = async (): Promise => { + return await ipcRenderer.invoke('get-findmy-keys-status'); +}; + +export const importFindMyKeys = async (): Promise<{ + canceled: boolean; + result: Record | null; +}> => { + return await ipcRenderer.invoke('import-findmy-keys'); +}; + export const getDevices = async () => { return await ipcRenderer.invoke('get-devices'); };