From dbc903874882a1aa12fcfa9d55f6c1ef011bf547 Mon Sep 17 00:00:00 2001 From: Pnut Date: Thu, 17 Jul 2025 13:04:36 +0800 Subject: [PATCH 1/2] Update dependencies, add @noble/ciphers and bplist-parser, optimize the FindMyInterface class to support plist file parsing and data decryption --- package-lock.json | 13 + packages/server/package.json | 2 + .../server/api/interfaces/findMyInterface.ts | 288 ++++++++++++++++-- 3 files changed, 284 insertions(+), 19 deletions(-) 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/interfaces/findMyInterface.ts b/packages/server/src/server/api/interfaces/findMyInterface.ts index 76349cad..9ccd37de 100644 --- a/packages/server/src/server/api/interfaces/findMyInterface.ts +++ b/packages/server/src/server/api/interfaces/findMyInterface.ts @@ -7,17 +7,41 @@ 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 plist from "plist"; +import * as bplist from "bplist-parser"; +import os from "os"; export class FindMyInterface { + // 缓存密钥以避免重复读取 + private static fmipKey: Buffer | null = null; + private static fmfKey: Buffer | null = null; + + /** + * 解析 plist 文件(支持二进制和 XML 格式) + */ + private static async parsePlistFile(filePath: string): Promise { + const fileData = fs.readFileSync(filePath); + + // 检查是否是二进制 plist(以 "bplist" 开头) + if (fileData.toString('utf8', 0, 6) === 'bplist') { + Server().logger.debug(`Parsing binary plist: ${filePath}`); + const result = await bplist.parseBuffer(fileData); + return result[0]; // bplist-parser 返回数组,通常取第一个元素 + } else { + Server().logger.debug(`Parsing XML plist: ${filePath}`); + return plist.parse(fileData.toString('utf8')); + } + } + static async getFriends() { return Server().findMyCache.getAll(); } static async getDevices(): Promise | null> { - if (isMinSequoia) { - Server().logger.debug('Cannot fetch FindMy devices on macOS Sequoia or later.'); - return null; - } + // if (isMinSequoia) { + // Server().logger.debug('Cannot fetch FindMy devices on macOS Sequoia or later.'); + // return null; + // } try { const [devices, items] = await Promise.all([ @@ -124,6 +148,7 @@ export class FindMyInterface { if (Array.isArray(parsedData)) { return resolve(parsedData); } else { + Server().logger.debug(data.toString()); reject(new Error("Failed to read FindMy ItemGroups cache file! It is not an array!")); } } catch { @@ -133,26 +158,251 @@ export class FindMyInterface { }); } - private static 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); + /** + * 加载 FMIP 组的解密密钥 + */ + private static async loadFMIPKey(): Promise { + if (this.fmipKey) return this.fmipKey; - try { - const parsedData = JSON.parse(data.toString()); - if (Array.isArray(parsedData)) { - return resolve(parsedData); + try { + const keyPath = path.join(os.homedir(), "FMIPDataManager.bplist"); + if (!fs.existsSync(keyPath)) { + Server().logger.debug(`FMIP key file not found: ${keyPath}`); + return null; + } + + const plistData = await this.parsePlistFile(keyPath); + + const symmetricKeyData = plistData.symmetricKey; + if (!symmetricKeyData) { + Server().logger.debug("Missing symmetricKey in FMIP plist"); + return null; + } + + let symmetricKeyBytes: Buffer; + if (typeof symmetricKeyData === 'object' && symmetricKeyData.key) { + // 嵌套格式: symmetricKey -> key -> data + const keyDict = symmetricKeyData.key; + if (keyDict.data) { + if (Buffer.isBuffer(keyDict.data)) { + symmetricKeyBytes = keyDict.data; } else { - reject(new Error(`Failed to read FindMy ${type} cache file! It is not an array!`)); + symmetricKeyBytes = Buffer.from(keyDict.data, 'base64'); } + } else { + Server().logger.debug("Invalid symmetricKey structure in FMIP plist"); + return null; + } + } else { + // 直接格式: 直接是 base64 字符串 + symmetricKeyBytes = Buffer.from(symmetricKeyData, 'base64'); + } + + if (symmetricKeyBytes.length !== 32) { + Server().logger.debug(`Invalid FMIP key length: ${symmetricKeyBytes.length} bytes, expected 32`); + return null; + } + + this.fmipKey = symmetricKeyBytes; + Server().logger.debug("FMIP decryption key loaded successfully"); + return this.fmipKey; + } catch (ex: any) { + Server().logger.debug(`Failed to load FMIP key: ${String(ex)}`); + return null; + } + } + + /** + * 加载 FMF 组的解密密钥 + */ + private static async loadFMFKey(): Promise { + if (this.fmfKey) return this.fmfKey; + + try { + const keyPath = path.join(os.homedir(), "FMFDataManager.bplist"); + if (!fs.existsSync(keyPath)) { + Server().logger.debug(`FMF key file not found: ${keyPath}`); + return null; + } + + const plistData = await this.parsePlistFile(keyPath); + + const symmetricKeyData = plistData.symmetricKey; + if (!symmetricKeyData) { + Server().logger.debug("Missing symmetricKey in FMF plist"); + return null; + } + + let symmetricKeyBytes: Buffer; + if (typeof symmetricKeyData === 'object' && symmetricKeyData.key) { + // 嵌套格式: symmetricKey -> key -> data + const keyDict = symmetricKeyData.key; + if (keyDict.data) { + if (Buffer.isBuffer(keyDict.data)) { + symmetricKeyBytes = keyDict.data; + } else { + symmetricKeyBytes = Buffer.from(keyDict.data, 'base64'); + } + } else { + Server().logger.debug("Invalid symmetricKey structure in FMF plist"); + return null; + } + } else { + // 直接格式: 直接是 base64 字符串 + symmetricKeyBytes = Buffer.from(symmetricKeyData, 'base64'); + } + + if (symmetricKeyBytes.length !== 32) { + Server().logger.debug(`Invalid FMF key length: ${symmetricKeyBytes.length} bytes, expected 32`); + return null; + } + + this.fmfKey = symmetricKeyBytes; + Server().logger.debug("FMF decryption key loaded successfully"); + return this.fmfKey; + } catch (ex: any) { + Server().logger.debug(`Failed to load FMF key: ${String(ex)}`); + return null; + } + } + + /** + * 使用 ChaCha20-Poly1305 解密数据 + */ + private static async decryptChaCha20Poly1305(encryptedData: Buffer, key: Buffer): Promise { + try { + // 动态导入 @noble/ciphers 以避免编译时错误 + const { chacha20poly1305 } = await import('@noble/ciphers/chacha'); + + if (encryptedData.length < 28) { + Server().logger.debug("Encrypted data too short for ChaCha20-Poly1305"); + return null; + } + + // ChaCha20-Poly1305 结构: 12字节nonce + 密文 + 16字节认证标签 + const nonce = encryptedData.subarray(0, 12); + const ciphertextWithTag = encryptedData.subarray(12); + + // 创建解密器 - 转换为 Uint8Array + const cipher = chacha20poly1305(new Uint8Array(key), new Uint8Array(nonce)); + + // 解密数据 - 转换为 Uint8Array + const decrypted = cipher.decrypt(new Uint8Array(ciphertextWithTag)); + + return Buffer.from(decrypted); + } catch (ex: any) { + Server().logger.debug(`ChaCha20-Poly1305 decryption failed: ${String(ex)}`); + return null; + } + } + + /** + * 解密缓存文件 + */ + private static async decryptCacheFile(filePath: string, keyType: 'FMIP' | 'FMF'): Promise { + try { + if (!fs.existsSync(filePath)) { + return null; + } + + // 读取并解析 plist 文件 + const plistData = await this.parsePlistFile(filePath); + + // 提取加密数据 + const encryptedData = plistData.encryptedData; + if (!encryptedData) { + Server().logger.debug(`Missing encryptedData in ${filePath}`); + return null; + } + + // 转换为 Buffer + const encryptedBuffer = Buffer.isBuffer(encryptedData) + ? encryptedData + : Buffer.from(encryptedData); + + // 加载对应的密钥 + const key = keyType === 'FMIP' + ? await this.loadFMIPKey() + : await this.loadFMFKey(); + + if (!key) { + Server().logger.debug(`${keyType} decryption key not available`); + return null; + } + + // 解密数据 + const decrypted = await this.decryptChaCha20Poly1305(encryptedBuffer, key); + if (!decrypted) { + Server().logger.debug(`Failed to decrypt ${filePath}`); + return null; + } + + // 解析解密后的数据 + let parsedData: any; + if (decrypted.toString().startsWith('bplist')) { + // 如果是 bplist 格式 + const result = await bplist.parseBuffer(decrypted); + parsedData = result[0]; + } else { + // 尝试解析为 JSON + try { + parsedData = JSON.parse(decrypted.toString()); } catch { - reject(new Error(`Failed to read FindMy ${type} cache file! It is not in the correct format!`)); + Server().logger.debug(`Failed to parse decrypted data from ${filePath} as JSON`); + return null; } - }); + } + + // 返回数组数据 + if (Array.isArray(parsedData)) { + return parsedData; + } else { + Server().logger.debug(`Decrypted data from ${filePath} is not an array`); + return null; + } + } catch (ex: any) { + Server().logger.debug(`Failed to decrypt cache file ${filePath}: ${String(ex)}`); + return null; + } + } + + private static readDataFile( + type: T + ): Promise | null> { + const dataPath = path.join(FileSystem.findMyDir, `${type}.data`); + + return new Promise((resolve, reject) => { + // 首先尝试解密方式读取 + this.decryptCacheFile(dataPath, 'FMIP') + .then(decryptedData => { + if (decryptedData) { + Server().logger.debug(`Successfully decrypted ${type} data`); + return resolve(decryptedData); + } + + // 如果解密失败,尝试传统方式读取(向后兼容) + fs.readFile(dataPath, { 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 ${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!` + )); + } + }); + }) + .catch((ex: any) => { + Server().logger.debug(`Error reading ${type} data file: ${String(ex)}`); + return resolve(null); + }); }); } } From 8455981ef50591386a6a2e077ad6045ea41ca621 Mon Sep 17 00:00:00 2001 From: Pnut Date: Tue, 9 Jun 2026 16:40:12 +0800 Subject: [PATCH 2/2] feat(findmy): decrypt Find My cache on macOS 14.4+ (devices & friends) Apple started encrypting the Find My location cache (and the Private API helper stopped working) in macOS 14.4. Read locations by decrypting the cache instead of relying on Private API injection. - decrypt FMIP/FMF caches (ChaCha20-Poly1305) and LocalStorage.db (AES-256 keystream XOR + WAL) to get device & friend locations - add FindMyKeyManager + a key-import UI (folder picker) for the three keys - version-aware via new isMinSonoma14_4 flag: 14.4+ uses decryption, 11-14.3 keep the existing Private API path; GET /findmy/friends no longer forces the helper on 14.4+ - friend payload matches the legacy shape (is_locating_in_progress is a boolean); address fields are null (not persisted in the cache) - ui: LAN URL picker so hosts with multiple local IPs can choose/copy the reachable address --- .../src/server/api/http/api/v1/httpRoutes.ts | 8 +- .../server/api/interfaces/findMyInterface.ts | 457 +++++++----------- .../server/api/lib/findmy/FindMyKeyManager.ts | 194 ++++++++ .../server/api/lib/findmy/decrypt/cache.ts | 72 +++ .../api/lib/findmy/decrypt/fmfReader.ts | 32 ++ .../api/lib/findmy/decrypt/localStorage.ts | 131 +++++ .../lib/findmy/decrypt/localStorageReader.ts | 147 ++++++ .../api/lib/findmy/decrypt/plistUtils.ts | 48 ++ .../server/src/server/api/lib/findmy/types.ts | 2 +- packages/server/src/server/env.ts | 4 + .../server/src/server/fileSystem/index.ts | 19 + .../src/server/services/ipcService/index.ts | 55 ++- .../app/components/fields/FindMyKeysField.tsx | 129 +++++ .../app/components/fields/ProxySetupField.tsx | 37 +- .../app/components/modals/LanUrlDialog.tsx | 124 +++++ .../settings/features/FeatureSettings.tsx | 3 + packages/ui/src/app/utils/IpcUtils.ts | 18 + 17 files changed, 1189 insertions(+), 291 deletions(-) create mode 100644 packages/server/src/server/api/lib/findmy/FindMyKeyManager.ts create mode 100644 packages/server/src/server/api/lib/findmy/decrypt/cache.ts create mode 100644 packages/server/src/server/api/lib/findmy/decrypt/fmfReader.ts create mode 100644 packages/server/src/server/api/lib/findmy/decrypt/localStorage.ts create mode 100644 packages/server/src/server/api/lib/findmy/decrypt/localStorageReader.ts create mode 100644 packages/server/src/server/api/lib/findmy/decrypt/plistUtils.ts create mode 100644 packages/ui/src/app/components/fields/FindMyKeysField.tsx create mode 100644 packages/ui/src/app/components/modals/LanUrlDialog.tsx 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 9ccd37de..f0b8b3ef 100644 --- a/packages/server/src/server/api/interfaces/findMyInterface.ts +++ b/packages/server/src/server/api/interfaces/findMyInterface.ts @@ -2,47 +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 plist from "plist"; -import * as bplist from "bplist-parser"; -import os from "os"; +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 { - // 缓存密钥以避免重复读取 - private static fmipKey: Buffer | null = null; - private static fmfKey: Buffer | null = null; - - /** - * 解析 plist 文件(支持二进制和 XML 格式) - */ - private static async parsePlistFile(filePath: string): Promise { - const fileData = fs.readFileSync(filePath); - - // 检查是否是二进制 plist(以 "bplist" 开头) - if (fileData.toString('utf8', 0, 6) === 'bplist') { - Server().logger.debug(`Parsing binary plist: ${filePath}`); - const result = await bplist.parseBuffer(fileData); - return result[0]; // bplist-parser 返回数组,通常取第一个元素 - } else { - Server().logger.debug(`Parsing XML plist: ${filePath}`); - return plist.parse(fileData.toString('utf8')); - } - } - static async getFriends() { return Server().findMyCache.getAll(); } 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"), @@ -53,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(); @@ -72,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)); } } @@ -80,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(); @@ -95,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(); @@ -134,275 +160,134 @@ export class FindMyInterface { await FileSystem.executeAppleScript(hideFindMyFriends()); } - 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 { - Server().logger.debug(data.toString()); - 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!")); - } - }); - }); - } - /** - * 加载 FMIP 组的解密密钥 + * Reads friend locations by decrypting LocalStorage.db (coordinates) and joining + * with the FMF cache (display names). Returns items in the legacy API shape. */ - private static async loadFMIPKey(): Promise { - if (this.fmipKey) return this.fmipKey; + static async readFriendsFromCache(): Promise { + const localStorageKey = FindMyKeyManager.loadLocalStorageKey(); + if (!localStorageKey) { + Server().logger.debug("FindMy LocalStorage key not imported — cannot read friend locations."); + return []; + } - try { - const keyPath = path.join(os.homedir(), "FMIPDataManager.bplist"); - if (!fs.existsSync(keyPath)) { - Server().logger.debug(`FMIP key file not found: ${keyPath}`); - return null; - } + if (!fs.existsSync(FileSystem.findMyLocalStorageDbPath)) { + Server().logger.debug(`FindMy LocalStorage.db not found at ${FileSystem.findMyLocalStorageDbPath}`); + return []; + } - const plistData = await this.parsePlistFile(keyPath); - - const symmetricKeyData = plistData.symmetricKey; - if (!symmetricKeyData) { - Server().logger.debug("Missing symmetricKey in FMIP plist"); - return null; - } + const rawLocations = readFriendLocations(localStorageKey); - let symmetricKeyBytes: Buffer; - if (typeof symmetricKeyData === 'object' && symmetricKeyData.key) { - // 嵌套格式: symmetricKey -> key -> data - const keyDict = symmetricKeyData.key; - if (keyDict.data) { - if (Buffer.isBuffer(keyDict.data)) { - symmetricKeyBytes = keyDict.data; - } else { - symmetricKeyBytes = Buffer.from(keyDict.data, 'base64'); - } - } else { - Server().logger.debug("Invalid symmetricKey structure in FMIP plist"); - return null; - } - } else { - // 直接格式: 直接是 base64 字符串 - symmetricKeyBytes = Buffer.from(symmetricKeyData, 'base64'); + // 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)}`); } - - if (symmetricKeyBytes.length !== 32) { - Server().logger.debug(`Invalid FMIP key length: ${symmetricKeyBytes.length} bytes, expected 32`); - return null; - } - - this.fmipKey = symmetricKeyBytes; - Server().logger.debug("FMIP decryption key loaded successfully"); - return this.fmipKey; - } catch (ex: any) { - Server().logger.debug(`Failed to load FMIP key: ${String(ex)}`); - return null; } - } - - /** - * 加载 FMF 组的解密密钥 - */ - private static async loadFMFKey(): Promise { - if (this.fmfKey) return this.fmfKey; - try { - const keyPath = path.join(os.homedir(), "FMFDataManager.bplist"); - if (!fs.existsSync(keyPath)) { - Server().logger.debug(`FMF key file not found: ${keyPath}`); - return null; - } + return rawLocations.map(raw => FindMyInterface.buildFriendLocationItem(raw, names)); + } - const plistData = await this.parsePlistFile(keyPath); - - const symmetricKeyData = plistData.symmetricKey; - if (!symmetricKeyData) { - Server().logger.debug("Missing symmetricKey in FMF plist"); - return null; - } + 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); + } - let symmetricKeyBytes: Buffer; - if (typeof symmetricKeyData === 'object' && symmetricKeyData.key) { - // 嵌套格式: symmetricKey -> key -> data - const keyDict = symmetricKeyData.key; - if (keyDict.data) { - if (Buffer.isBuffer(keyDict.data)) { - symmetricKeyBytes = keyDict.data; - } else { - symmetricKeyBytes = Buffer.from(keyDict.data, 'base64'); - } - } else { - Server().logger.debug("Invalid symmetricKey structure in FMF plist"); - return null; - } - } else { - // 直接格式: 直接是 base64 字符串 - symmetricKeyBytes = Buffer.from(symmetricKeyData, 'base64'); - } + 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" + }; + } - if (symmetricKeyBytes.length !== 32) { - Server().logger.debug(`Invalid FMF key length: ${symmetricKeyBytes.length} bytes, expected 32`); - return null; - } + static async readItemGroups(): Promise> { + const itemGroupsPath = path.join(FileSystem.findMyDir, "ItemGroups.data"); + if (!fs.existsSync(itemGroupsPath)) return []; - this.fmfKey = symmetricKeyBytes; - Server().logger.debug("FMF decryption key loaded successfully"); - return this.fmfKey; - } catch (ex: any) { - Server().logger.debug(`Failed to load FMF key: ${String(ex)}`); - return null; - } + const parsed = await FindMyInterface.readCacheArray(itemGroupsPath); + return parsed ?? []; } /** - * 使用 ChaCha20-Poly1305 解密数据 + * 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 decryptChaCha20Poly1305(encryptedData: Buffer, key: Buffer): Promise { - try { - // 动态导入 @noble/ciphers 以避免编译时错误 - const { chacha20poly1305 } = await import('@noble/ciphers/chacha'); - - if (encryptedData.length < 28) { - Server().logger.debug("Encrypted data too short for ChaCha20-Poly1305"); - return null; - } - - // ChaCha20-Poly1305 结构: 12字节nonce + 密文 + 16字节认证标签 - const nonce = encryptedData.subarray(0, 12); - const ciphertextWithTag = encryptedData.subarray(12); - - // 创建解密器 - 转换为 Uint8Array - const cipher = chacha20poly1305(new Uint8Array(key), new Uint8Array(nonce)); - - // 解密数据 - 转换为 Uint8Array - const decrypted = cipher.decrypt(new Uint8Array(ciphertextWithTag)); - - return Buffer.from(decrypted); - } catch (ex: any) { - Server().logger.debug(`ChaCha20-Poly1305 decryption failed: ${String(ex)}`); - return null; - } + private static async readDataFile( + type: T + ): Promise | null> { + const dataPath = path.join(FileSystem.findMyDir, `${type}.data`); + return (await FindMyInterface.readCacheArray(dataPath)) as any; } - /** - * 解密缓存文件 - */ - private static async decryptCacheFile(filePath: string, keyType: 'FMIP' | 'FMF'): Promise { - try { - if (!fs.existsSync(filePath)) { - return null; - } + private static async readCacheArray(filePath: string): Promise | null> { + if (!fs.existsSync(filePath)) return null; - // 读取并解析 plist 文件 - const plistData = await this.parsePlistFile(filePath); + const buffer = fs.readFileSync(filePath); - // 提取加密数据 - const encryptedData = plistData.encryptedData; - if (!encryptedData) { - Server().logger.debug(`Missing encryptedData in ${filePath}`); - return null; + // 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)}`); } + } - // 转换为 Buffer - const encryptedBuffer = Buffer.isBuffer(encryptedData) - ? encryptedData - : Buffer.from(encryptedData); - - // 加载对应的密钥 - const key = keyType === 'FMIP' - ? await this.loadFMIPKey() - : await this.loadFMFKey(); - - if (!key) { - Server().logger.debug(`${keyType} decryption key not available`); - return null; - } + // 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 + } - // 解密数据 - const decrypted = await this.decryptChaCha20Poly1305(encryptedBuffer, key); - if (!decrypted) { - Server().logger.debug(`Failed to decrypt ${filePath}`); - return null; - } + return null; + } - // 解析解密后的数据 - let parsedData: any; - if (decrypted.toString().startsWith('bplist')) { - // 如果是 bplist 格式 - const result = await bplist.parseBuffer(decrypted); - parsedData = result[0]; - } else { - // 尝试解析为 JSON - try { - parsedData = JSON.parse(decrypted.toString()); - } catch { - Server().logger.debug(`Failed to parse decrypted data from ${filePath} as 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; - // 返回数组数据 - if (Array.isArray(parsedData)) { - return parsedData; - } else { - Server().logger.debug(`Decrypted data from ${filePath} is not an array`); - return null; - } - } catch (ex: any) { - Server().logger.debug(`Failed to decrypt cache file ${filePath}: ${String(ex)}`); - return null; + // 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; } - } - - private static readDataFile( - type: T - ): Promise | null> { - const dataPath = path.join(FileSystem.findMyDir, `${type}.data`); - - return new Promise((resolve, reject) => { - // 首先尝试解密方式读取 - this.decryptCacheFile(dataPath, 'FMIP') - .then(decryptedData => { - if (decryptedData) { - Server().logger.debug(`Successfully decrypted ${type} data`); - return resolve(decryptedData); - } - // 如果解密失败,尝试传统方式读取(向后兼容) - fs.readFile(dataPath, { 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 ${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!` - )); - } - }); - }) - .catch((ex: any) => { - Server().logger.debug(`Error reading ${type} data file: ${String(ex)}`); - return resolve(null); - }); - }); + 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'); };