diff --git a/macos/database/samples/sequoia/attachment.sql b/macos/database/samples/sequoia/attachment.sql new file mode 100644 index 00000000..0dbb5939 --- /dev/null +++ b/macos/database/samples/sequoia/attachment.sql @@ -0,0 +1,28 @@ +CREATE TABLE attachment ( + ROWID INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + created_date INTEGER DEFAULT 0, + start_date INTEGER DEFAULT 0, + filename TEXT, + uti TEXT, + mime_type TEXT, + transfer_state INTEGER DEFAULT 0, + is_outgoing INTEGER DEFAULT 0, + user_info BLOB, + transfer_name TEXT, + total_bytes INTEGER DEFAULT 0, + is_sticker INTEGER DEFAULT 0, + sticker_user_info BLOB, + attribution_info BLOB, + hide_attachment INTEGER DEFAULT 0, + ck_sync_state INTEGER DEFAULT 0, + ck_server_change_token_blob BLOB, + ck_record_id TEXT, + original_guid TEXT UNIQUE NOT NULL, + sr_ck_sync_state INTEGER DEFAULT 0, + sr_ck_server_change_token_blob BLOB, + sr_ck_record_id TEXT, + is_commsafety_sensitive INTEGER DEFAULT 0, + emoji_image_content_identifier TEXT DEFAULT NULL, + emoji_image_short_description TEXT DEFAULT NULL +) \ No newline at end of file diff --git a/macos/database/samples/sequoia/chat.sql b/macos/database/samples/sequoia/chat.sql new file mode 100644 index 00000000..93c55050 --- /dev/null +++ b/macos/database/samples/sequoia/chat.sql @@ -0,0 +1,33 @@ +CREATE TABLE chat ( + ROWID INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + style INTEGER, + state INTEGER, + account_id TEXT, + properties BLOB, + chat_identifier TEXT, + service_name TEXT, + room_name TEXT, + account_login TEXT, + is_archived INTEGER DEFAULT 0, + last_addressed_handle TEXT, + display_name TEXT, + group_id TEXT, + is_filtered INTEGER, + successful_query INTEGER, + engram_id TEXT, + server_change_token TEXT, + ck_sync_state INTEGER DEFAULT 0, + original_group_id TEXT, + last_read_message_timestamp INTEGER DEFAULT 0, + sr_server_change_token TEXT, + sr_ck_sync_state INTEGER DEFAULT 0, + cloudkit_record_id TEXT, + sr_cloudkit_record_id TEXT, + last_addressed_sim_id TEXT, + is_blackholed INTEGER DEFAULT 0, + syndication_date INTEGER DEFAULT 0, + syndication_type INTEGER DEFAULT 0, + is_recovered INTEGER DEFAULT 0, + is_deleting_incoming_messages INTEGER DEFAULT 0 +) \ No newline at end of file diff --git a/macos/database/samples/sequoia/deleted_messages.sql b/macos/database/samples/sequoia/deleted_messages.sql new file mode 100644 index 00000000..950daf03 --- /dev/null +++ b/macos/database/samples/sequoia/deleted_messages.sql @@ -0,0 +1,4 @@ +CREATE TABLE deleted_messages ( + ROWID INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE, + guid TEXT NOT NULL +) \ No newline at end of file diff --git a/macos/database/samples/sequoia/handle.sql b/macos/database/samples/sequoia/handle.sql new file mode 100644 index 00000000..af96ab7c --- /dev/null +++ b/macos/database/samples/sequoia/handle.sql @@ -0,0 +1,9 @@ +CREATE TABLE handle ( + ROWID INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE, + id TEXT NOT NULL, + country TEXT, + service TEXT NOT NULL, + uncanonicalized_id TEXT, + person_centric_id TEXT, + UNIQUE (id, service) +) \ No newline at end of file diff --git a/macos/database/samples/sequoia/message.sql b/macos/database/samples/sequoia/message.sql new file mode 100644 index 00000000..0128c61a --- /dev/null +++ b/macos/database/samples/sequoia/message.sql @@ -0,0 +1,96 @@ +CREATE TABLE message ( + ROWID INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + text TEXT, + replace INTEGER DEFAULT 0, + service_center TEXT, + handle_id INTEGER DEFAULT 0, + subject TEXT, + country TEXT, + attributedBody BLOB, + version INTEGER DEFAULT 0, + type INTEGER DEFAULT 0, + service TEXT, + account TEXT, + account_guid TEXT, + error INTEGER DEFAULT 0, + date INTEGER, + date_read INTEGER, + date_delivered INTEGER, + is_delivered INTEGER DEFAULT 0, + is_finished INTEGER DEFAULT 0, + is_emote INTEGER DEFAULT 0, + is_from_me INTEGER DEFAULT 0, + is_empty INTEGER DEFAULT 0, + is_delayed INTEGER DEFAULT 0, + is_auto_reply INTEGER DEFAULT 0, + is_prepared INTEGER DEFAULT 0, + is_read INTEGER DEFAULT 0, + is_system_message INTEGER DEFAULT 0, + is_sent INTEGER DEFAULT 0, + has_dd_results INTEGER DEFAULT 0, + is_service_message INTEGER DEFAULT 0, + is_forward INTEGER DEFAULT 0, + was_downgraded INTEGER DEFAULT 0, + is_archive INTEGER DEFAULT 0, + cache_has_attachments INTEGER DEFAULT 0, + cache_roomnames TEXT, + was_data_detected INTEGER DEFAULT 0, + was_deduplicated INTEGER DEFAULT 0, + is_audio_message INTEGER DEFAULT 0, + is_played INTEGER DEFAULT 0, + date_played INTEGER, + item_type INTEGER DEFAULT 0, + other_handle INTEGER DEFAULT 0, + group_title TEXT, + group_action_type INTEGER DEFAULT 0, + share_status INTEGER DEFAULT 0, + share_direction INTEGER DEFAULT 0, + is_expirable INTEGER DEFAULT 0, + expire_state INTEGER DEFAULT 0, + message_action_type INTEGER DEFAULT 0, + message_source INTEGER DEFAULT 0, + associated_message_guid TEXT, + associated_message_type INTEGER DEFAULT 0, + balloon_bundle_id TEXT, + payload_data BLOB, + expressive_send_style_id TEXT, + associated_message_range_location INTEGER DEFAULT 0, + associated_message_range_length INTEGER DEFAULT 0, + time_expressive_send_played INTEGER, + message_summary_info BLOB, + ck_sync_state INTEGER DEFAULT 0, + ck_record_id TEXT, + ck_record_change_tag TEXT, + destination_caller_id TEXT, + sr_ck_sync_state INTEGER DEFAULT 0, + sr_ck_record_id TEXT, + sr_ck_record_change_tag TEXT, + is_corrupt INTEGER DEFAULT 0, + reply_to_guid TEXT, + sort_id INTEGER, + is_spam INTEGER DEFAULT 0, + has_unseen_mention INTEGER DEFAULT 0, + thread_originator_guid TEXT, + thread_originator_part TEXT, + syndication_ranges TEXT DEFAULT NULL, + was_delivered_quietly INTEGER DEFAULT 0, + did_notify_recipient INTEGER DEFAULT 0, + synced_syndication_ranges TEXT DEFAULT NULL, + date_retracted INTEGER DEFAULT 0, + date_edited INTEGER DEFAULT 0, + was_detonated INTEGER DEFAULT 0, + part_count INTEGER, + is_stewie INTEGER DEFAULT 0, + is_sos INTEGER DEFAULT 0, + is_critical INTEGER DEFAULT 0, + bia_reference_id TEXT DEFAULT NULL, + is_kt_verified INTEGER DEFAULT 0, + fallback_hash TEXT DEFAULT NULL, + associated_message_emoji TEXT DEFAULT NULL, + is_pending_satellite_send INTEGER DEFAULT 0, + needs_relay INTEGER DEFAULT 0, + schedule_type INTEGER DEFAULT 0, + schedule_state INTEGER DEFAULT 0, + sent_or_received_off_grid INTEGER DEFAULT 0 +) \ No newline at end of file diff --git a/macos/database/samples/sequoia/message_processing_task.sql b/macos/database/samples/sequoia/message_processing_task.sql new file mode 100644 index 00000000..efec769e --- /dev/null +++ b/macos/database/samples/sequoia/message_processing_task.sql @@ -0,0 +1,10 @@ +CREATE TABLE recoverable_message_part ( + chat_id INTEGER REFERENCES chat (ROWID) ON DELETE CASCADE, + message_id INTEGER REFERENCES message (ROWID) ON DELETE CASCADE, + part_index INTEGER, + delete_date INTEGER, + part_text BLOB NOT NULL, + ck_sync_state INTEGER DEFAULT 0, + PRIMARY KEY (chat_id, message_id, part_index), + CHECK (delete_date != 0) +) \ No newline at end of file diff --git a/macos/database/samples/sequoia/recoverable_message_part.sql b/macos/database/samples/sequoia/recoverable_message_part.sql new file mode 100644 index 00000000..efec769e --- /dev/null +++ b/macos/database/samples/sequoia/recoverable_message_part.sql @@ -0,0 +1,10 @@ +CREATE TABLE recoverable_message_part ( + chat_id INTEGER REFERENCES chat (ROWID) ON DELETE CASCADE, + message_id INTEGER REFERENCES message (ROWID) ON DELETE CASCADE, + part_index INTEGER, + delete_date INTEGER, + part_text BLOB NOT NULL, + ck_sync_state INTEGER DEFAULT 0, + PRIMARY KEY (chat_id, message_id, part_index), + CHECK (delete_date != 0) +) \ No newline at end of file diff --git a/packages/server/appResources/macos/daemons/cloudflare/README.md b/packages/server/appResources/macos/daemons/cloudflare/README.md index 54611243..f1e92681 100644 --- a/packages/server/appResources/macos/daemons/cloudflare/README.md +++ b/packages/server/appResources/macos/daemons/cloudflare/README.md @@ -6,8 +6,9 @@ The `.md5` files within this directory contain a single string that is correlate ## Clourflared -This is the official cloudflare daemon (x86; amd64), downloaded from: https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz +This is the official cloudflare daemon: -The arm64 (Apple Silicon) build is from Homebrew: https://formulae.brew.sh/formula/cloudflared +- (x86; amd64) downloaded from: https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz +- (arm64 - Apple Silicon) downloaded from: https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz -The dummy `cloudflared-config.yml` file is for the daemon to use as to not interfere with the default system configuration \ No newline at end of file +The dummy `cloudflared-config.yml` file is for the daemon to use as to not interfere with the default system configuration diff --git a/packages/server/appResources/macos/daemons/cloudflare/arm64/cloudflared b/packages/server/appResources/macos/daemons/cloudflare/arm64/cloudflared index 57927920..b8e2f336 100755 Binary files a/packages/server/appResources/macos/daemons/cloudflare/arm64/cloudflared and b/packages/server/appResources/macos/daemons/cloudflare/arm64/cloudflared differ diff --git a/packages/server/appResources/macos/daemons/cloudflare/arm64/cloudflared.md5 b/packages/server/appResources/macos/daemons/cloudflare/arm64/cloudflared.md5 index d53240bc..19f26c1f 100644 --- a/packages/server/appResources/macos/daemons/cloudflare/arm64/cloudflared.md5 +++ b/packages/server/appResources/macos/daemons/cloudflare/arm64/cloudflared.md5 @@ -1 +1 @@ -bb5b2df38ee82a42ec156ceb0b3744fa \ No newline at end of file +7673f85211adbead7799915ae223f064 \ No newline at end of file diff --git a/packages/server/appResources/private-api/macos11/BlueBubblesHelper.dylib b/packages/server/appResources/private-api/macos11/BlueBubblesHelper.dylib index 053d0eb7..7f932fe0 100755 Binary files a/packages/server/appResources/private-api/macos11/BlueBubblesHelper.dylib and b/packages/server/appResources/private-api/macos11/BlueBubblesHelper.dylib differ 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 85d4642b..0f4be568 100644 --- a/packages/server/src/server/api/http/api/v1/httpRoutes.ts +++ b/packages/server/src/server/api/http/api/v1/httpRoutes.ts @@ -38,6 +38,8 @@ import { ThemeValidator } from "./validators/themeValidator"; import type { Context, Next } from "koa"; import { FindMyRouter } from "./routers/findmyRouter"; import { getLogger } from "@server/lib/logging/Loggable"; +import { WebhookRouter } from "./routers/webhookRouter"; +import { WebhookValidator } from "./validators/webhookValidator"; export class HttpRoutes { static version = 1; @@ -660,6 +662,30 @@ export class HttpRoutes { controller: SettingsRouter.delete } ] + }, + { + name: "Webhooks", + middleware: HttpRoutes.protected, + prefix: "webhook", + routes: [ + { + method: HttpMethod.GET, + path: "", + validators: [WebhookValidator.validateGetWebhooks], + controller: WebhookRouter.get + }, + { + method: HttpMethod.POST, + path: "", + validators: [WebhookValidator.validateCreateWebhook], + controller: WebhookRouter.create + }, + { + method: HttpMethod.DELETE, + path: ":id", + controller: WebhookRouter.delete + } + ] } ] }; diff --git a/packages/server/src/server/api/http/api/v1/routers/findmyRouter.ts b/packages/server/src/server/api/http/api/v1/routers/findmyRouter.ts index 1a1c0bec..459ae552 100644 --- a/packages/server/src/server/api/http/api/v1/routers/findmyRouter.ts +++ b/packages/server/src/server/api/http/api/v1/routers/findmyRouter.ts @@ -7,8 +7,11 @@ import { FindMyInterface } from "@server/api/interfaces/findMyInterface"; export class FindMyRouter { static async refreshDevices(ctx: RouterContext, _: Next) { try { - await FindMyInterface.refreshDevices(); - return new Success(ctx, { message: "Successfully refreshed Find My device locations!" }).send(); + const locations = await FindMyInterface.refreshDevices(); + return new Success(ctx, { + message: "Successfully refreshed Find My device locations!", + data: locations + }).send(); } catch (ex: any) { throw new ServerError({ message: "Failed to refresh Find My device locations!", diff --git a/packages/server/src/server/api/http/api/v1/routers/webhookRouter.ts b/packages/server/src/server/api/http/api/v1/routers/webhookRouter.ts new file mode 100644 index 00000000..94ba3daa --- /dev/null +++ b/packages/server/src/server/api/http/api/v1/routers/webhookRouter.ts @@ -0,0 +1,47 @@ +import { RouterContext } from "koa-router"; +import { Next } from "koa"; + +import { Server } from "@server"; +import { isEmpty } from "@server/helpers/utils"; + +import { Success } from "../responses/success"; +import { NotFound } from "../responses/errors"; + +export class WebhookRouter { + static async get(ctx: RouterContext, _: Next) { + const url = ctx?.request?.query?.url as string; + const id = (ctx?.request?.query?.id) ? Number.parseInt(ctx?.request?.query?.id as string) : null; + const webhooks = await Server().repo.getWebhooks({ url, id }); + + // Convert the events to a list (from json array) + for (const webhook of webhooks) { + webhook.events = JSON.parse(webhook.events); + } + + return new Success(ctx, { message: "Successfully fetched webhooks!", data: webhooks }).send(); + } + + static async create(ctx: RouterContext, _: Next) { + const { url, events } = ctx.request.body; + const webhook = await Server().repo.addWebhook(url, events); + + // Convert the events to a list (from json array) + webhook.events = JSON.parse(webhook.events); + + return new Success(ctx, { data: webhook, message: "Successfully created webhook!" }).send(); + } + + static async delete(ctx: RouterContext, _: Next): Promise { + const { id } = ctx.params; + + // Find it + const webhooks = await Server().repo.getWebhooks({ id: Number.parseInt(id as string) }); + if (isEmpty(webhooks)) throw new NotFound({ error: "Webhook does not exist!" }); + + // Delete it + await Server().repo.deleteWebhook({ id: Number.parseInt(id as string) }); + + // Send success + return new Success(ctx, { message: "Successfully deleted webhook!" }).send(); + } +} diff --git a/packages/server/src/server/api/http/api/v1/validators/webhookValidator.ts b/packages/server/src/server/api/http/api/v1/validators/webhookValidator.ts new file mode 100644 index 00000000..7d040a53 --- /dev/null +++ b/packages/server/src/server/api/http/api/v1/validators/webhookValidator.ts @@ -0,0 +1,59 @@ +import { RouterContext } from "koa-router"; +import { Next } from "koa"; + +import { ValidateInput } from "./index"; +import { BadRequest } from "../responses/errors"; +import { webhookEventOptions } from "@server/api/http/constants"; + +export class WebhookValidator { + + static webhookValues = webhookEventOptions.map(e => e.value); + + static getWebhookRules = { + name: "string", + id: "number", + }; + + static async validateGetWebhooks(ctx: RouterContext, next: Next) { + ValidateInput(ctx?.request?.query, WebhookValidator.getWebhookRules); + await next(); + } + + static createRules = { + url: "required|string", + events: "required|array" + }; + + static async validateCreateWebhook(ctx: RouterContext, next: Next) { + ValidateInput(ctx?.request?.body, WebhookValidator.createRules); + + const { url, events } = ctx.request.body; + if (url.length === 0) { + throw new BadRequest({ error: "Webhook URL is required!" }); + } else if (!url.startsWith('http')) { + throw new BadRequest({ error: "Webhook URL must include an HTTP scheme!" }); + } + + // Ensure that the events are valid + const validatedEvents = []; + for (const event of events) { + if (typeof event !== "string") { + throw new BadRequest({ error: "Webhook events must be strings!" }); + } + + // Find the webhook value in the webhook events + const webhookEvent = webhookEventOptions.find(e => e.value === event); + if (!webhookEvent) { + throw new BadRequest({ error: `Invalid webhook event: ${event}! Webhook must be one of: ${WebhookValidator.webhookValues}` }); + } + + // Update the event to the label + validatedEvents.push(webhookEvent); + } + + // Update the events + ctx.request.body.events = validatedEvents; + + await next(); + } +} diff --git a/packages/server/src/server/api/http/constants.ts b/packages/server/src/server/api/http/constants.ts index 6b443e0b..f11b8055 100644 --- a/packages/server/src/server/api/http/constants.ts +++ b/packages/server/src/server/api/http/constants.ts @@ -1,2 +1,110 @@ export const MessagesBasePath = `${process.env.HOME}/Library/Messages`; export const invisibleMediaChar = String.fromCharCode(65532); + +// Also modify packages/ui/src/app/constants.ts +export const webhookEventOptions = [ + { + label: 'All Events', + value: '*' + }, + { + label: 'New Messages', + value: 'new-message' + }, + { + label: 'Message Updates', + value: 'updated-message' + }, + { + label: 'Message Send Errors', + value: 'message-send-error' + }, + { + label: 'Group Name Changes', + value: 'group-name-change' + }, + { + label: 'Group Icon Changes', + value: 'group-icon-changed' + }, + { + label: 'Group Icon Removal', + value: 'group-icon-removed' + }, + { + label: 'Participant Removed', + value: 'participant-removed' + }, + { + label: 'Participant Added', + value: 'participant-added' + }, + { + label: 'Participant Left', + value: 'participant-left' + }, + { + label: 'Chat Read Status Change', + value: 'chat-read-status-changed' + }, + { + label: 'Typing Indicators', + value: 'typing-indicator' + }, + { + label: 'Scheduled Message Errors', + value: 'scheduled-message-error' + }, + { + label: 'Server Update', + value: 'server-update' + }, + { + label: 'New Server URL', + value: 'new-server' + }, + { + label: 'FindMy Location Update', + value: 'new-findmy-location' + }, + { + label: 'Websocket Hello World', + value: 'hello-world' + }, + { + label: 'Incoming Facetime Call', + value: 'incoming-facetime' + }, + { + label: 'FaceTime Call Status Changed (Experimental)', + value: 'ft-call-status-changed' + }, + { + label: 'iMessage Alias Removed', + value: 'imessage-alias-removed' + }, + { + label: 'Theme Backup Created', + value: 'theme-backup-created' + }, + { + label: 'Theme Backup Updated', + value: 'theme-backup-updated' + }, + { + label: 'Theme Backup Deleted', + value: 'theme-backup-deleted' + }, + { + label: 'Settings Backup Created', + value: 'settings-backup-created' + }, + { + label: 'Settings Backup Updated', + value: 'settings-backup-updated' + }, + { + label: 'Settings Backup Deleted', + value: 'settings-backup-deleted' + } +]; \ No newline at end of file diff --git a/packages/server/src/server/api/http/index.ts b/packages/server/src/server/api/http/index.ts index f28a2d90..3368c769 100644 --- a/packages/server/src/server/api/http/index.ts +++ b/packages/server/src/server/api/http/index.ts @@ -42,7 +42,7 @@ export class HttpService extends Loggable { socketOpts: Partial = { pingTimeout: 1000 * 60 * 2, // 2 Minute ping timeout - pingInterval: 1000 * 30, // 30 Second ping interval + pingInterval: 1000 * 60, // 1 minute ping interval upgradeTimeout: 1000 * 30, // 30 Seconds // 100 MB. 1000 == 1kb. 1000 * 1000 == 1mb diff --git a/packages/server/src/server/api/interfaces/findMyInterface.ts b/packages/server/src/server/api/interfaces/findMyInterface.ts index 22ff4518..76349cad 100644 --- a/packages/server/src/server/api/interfaces/findMyInterface.ts +++ b/packages/server/src/server/api/interfaces/findMyInterface.ts @@ -2,7 +2,7 @@ import { Server } from "@server"; import path from "path"; import fs from "fs"; import { FileSystem } from "@server/fileSystem"; -import { isMinBigSur, isMinSonoma } from "@server/env"; +import { isMinBigSur, isMinSequoia, isMinSonoma } 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"; @@ -14,6 +14,11 @@ 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"), @@ -21,20 +26,48 @@ export class FindMyInterface { ]); // Return null if neither of the files exist - if (!devices && !items) return null; + if (devices == null && items == null) return null; + + // Get any items with a group identifier + const itemsWithGroup = items.filter(item => item.groupIdentifier); + if (itemsWithGroup.length > 0) { + try { + const itemGroups = await FindMyInterface.readItemGroups(); + if (itemGroups) { + // Create a map of group IDs to group names + const groupMap = itemGroups.reduce((acc, group) => { + acc[group.identifier] = group.name; + return acc; + }, {} as Record); + + // Iterate over the items and add the group name + for (const item of items) { + if (item.groupIdentifier && groupMap[item.groupIdentifier]) { + item.groupName = groupMap[item.groupIdentifier]; + } + } + } + } catch (ex: any) { + Server().logger.debug('An error occurred while reading FindMy ItemGroups cache file.'); + Server().logger.debug(String(ex)); + } + } // Transform the items to match the same shape as devices const transformedItems = (items ?? []).map(transformFindMyItemToDevice); return [...(devices ?? []), ...transformedItems]; - } catch { + } catch (ex: any) { + Server().logger.debug('An error occurred while reading FindMy Device cache files.'); + Server().logger.debug(String(ex)); return null; } } - static async refreshDevices() { + static async refreshDevices(): Promise | null> { // Can't use the Private API to refresh devices yet await this.refreshLocationsAccessibility(); + return await this.getDevices(); } static async refreshFriends(openFindMyApp = true): Promise { @@ -77,6 +110,29 @@ 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 { + 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!")); + } + }); + }); + } + private static readDataFile( type: T ): Promise | null> { @@ -87,7 +143,12 @@ export class FindMyInterface { if (err) return resolve(null); try { - return resolve(JSON.parse(data.toString())); + 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!`)); } diff --git a/packages/server/src/server/api/lib/findmy/types.ts b/packages/server/src/server/api/lib/findmy/types.ts index 46b2f388..61be98a3 100644 --- a/packages/server/src/server/api/lib/findmy/types.ts +++ b/packages/server/src/server/api/lib/findmy/types.ts @@ -26,7 +26,7 @@ export interface FindMyDevice { lostDevice?: unknown; lostModeEnabled?: unknown; deviceDisplayName?: string; - safeLocations?: Array; + safeLocations?: Array; name?: string; canWipeAfterLock?: unknown; isMac?: unknown; @@ -47,9 +47,17 @@ export interface FindMyDevice { crowdSourcedLocation: FindMyLocation; // Extra properties from FindMyItem + identifier?: FindMyItem["identifier"]; + productIdentifier?: FindMyItem["productIdentifier"]; role?: FindMyItem["role"]; serialNumber?: string; lostModeMetadata?: FindMyItem["lostModeMetadata"]; + groupIdentifier?: FindMyItem["groupIdentifier"]; + isAppleAudioAccessory?: FindMyItem["isAppleAudioAccessory"]; + capabilities?: FindMyItem["capabilities"]; + + // Extra properties from BlueBubbles + groupName?: FindMyItem["groupName"]; } export interface FindMyItem { @@ -57,7 +65,7 @@ export interface FindMyItem { isFirmwareUpdateMandatory: boolean; productType: { type: string; - productInformation: { + productInformation: null | { manufacturerName: string; modelName: string; productIdentifier: number; @@ -65,7 +73,7 @@ export interface FindMyItem { antennaPower: number; }; }; - safeLocations?: Array; + safeLocations?: Array; owner: string; batteryStatus: number; serialNumber: string; @@ -80,9 +88,10 @@ export interface FindMyItem { address: FindMyAddress; location: FindMyLocation; productIdentifier: string; - isAppleAudioAccessory: false; + isAppleAudioAccessory: boolean; crowdSourcedLocation: FindMyLocation; - groupIdentifier: null; + groupIdentifier: string | null; + groupName?: string | null; role: { name: string; emoji: string; @@ -133,3 +142,12 @@ export type FindMyLocationItem = { is_locating_in_progress: 0 | 1; status: "legacy" | "live" | "shallow"; }; + +export type FindMySafeLocation = { + type: number; + approvalState: number; + name: string | null; + identifier: string; + location: FindMyLocation; + address: FindMyAddress; +}; \ No newline at end of file diff --git a/packages/server/src/server/api/lib/findmy/utils.ts b/packages/server/src/server/api/lib/findmy/utils.ts index defaab9b..ba2be2f9 100644 --- a/packages/server/src/server/api/lib/findmy/utils.ts +++ b/packages/server/src/server/api/lib/findmy/utils.ts @@ -1,38 +1,44 @@ import { FindMyItem, FindMyDevice } from "@server/api/lib/findmy/types"; export const getFindMyItemModelDisplayName = (item: FindMyItem): string => { - if (item.productType.type === "b389") return "AirTag"; + if (item?.productType?.type === "b389") return "AirTag"; - return item.productType.productInformation.modelName || item.productType.type; + return item?.productType?.productInformation?.modelName ?? item?.productType?.type ?? "Unknown"; }; export const transformFindMyItemToDevice = (item: FindMyItem): FindMyDevice => ({ - deviceModel: item.productType.type, - id: item.identifier, + deviceModel: item?.productType?.type, + id: item?.identifier, batteryStatus: "Unknown", audioChannels: [], lostModeCapable: true, - batteryLevel: item.batteryStatus, + batteryLevel: item?.batteryStatus, locationEnabled: true, isConsideredAccessory: true, - address: item.address, - location: item.location, + address: item?.address, + location: item?.location, modelDisplayName: getFindMyItemModelDisplayName(item), fmlyShare: false, thisDevice: false, - lostModeEnabled: Boolean(item.lostModeMetadata), - deviceDisplayName: item.role.emoji, - safeLocations: item.safeLocations, - name: item.name, + lostModeEnabled: Boolean(item?.lostModeMetadata ?? false), + deviceDisplayName: item?.role?.emoji, + safeLocations: item?.safeLocations, + name: item?.name, isMac: false, - rawDeviceModel: item.productType.type, + rawDeviceModel: item?.productType?.type, prsId: "owner", locationCapable: true, - deviceClass: item.productType.type, - crowdSourcedLocation: item.crowdSourcedLocation, + deviceClass: item?.productType?.type, + crowdSourcedLocation: item?.crowdSourcedLocation, // Extras from FindMyItem - role: item.role, - serialNumber: item.serialNumber, - lostModeMetadata: item.lostModeMetadata + identifier: item?.identifier, + productIdentifier: item?.productIdentifier, + role: item?.role, + serialNumber: item?.serialNumber, + lostModeMetadata: item?.lostModeMetadata, + groupIdentifier: item?.groupIdentifier, + groupName: item.groupName, + isAppleAudioAccessory: item?.isAppleAudioAccessory, + capabilities: item?.capabilities, }); diff --git a/packages/server/src/server/api/serializers/MessageSerializer.ts b/packages/server/src/server/api/serializers/MessageSerializer.ts index eb9d83e4..bde31b1b 100644 --- a/packages/server/src/server/api/serializers/MessageSerializer.ts +++ b/packages/server/src/server/api/serializers/MessageSerializer.ts @@ -149,6 +149,7 @@ export class MessageSerializer { dateCreated: message.dateCreated ? message.dateCreated.getTime() : null, dateRead: message.dateRead ? message.dateRead.getTime() : null, dateDelivered: message.dateDelivered ? message.dateDelivered.getTime() : null, + isDelivered: message.isDelivered, isFromMe: message.isFromMe, hasDdResults: message.hasDdResults, isArchived: message.isArchived, diff --git a/packages/server/src/server/databases/imessage/listeners/IMessageListener.ts b/packages/server/src/server/databases/imessage/listeners/IMessageListener.ts index 2ec27d1a..fa9ceee4 100644 --- a/packages/server/src/server/databases/imessage/listeners/IMessageListener.ts +++ b/packages/server/src/server/databases/imessage/listeners/IMessageListener.ts @@ -84,55 +84,46 @@ export class IMessageListener extends Loggable { @DebounceSubsequentWithWait('IMessageListener.handleChangeEvent', 500) async handleChangeEvent(event: FileChangeEvent) { await this.processLock.acquire(); - - // Check against the last check using the current change timestamp - if (event.currentStat.mtimeMs > this.lastCheck) { - // Update the last check time. - // We'll use the currentStat's mtimeMs - the time it took to poll. - this.lastCheck = event.currentStat.mtimeMs; - - // Make sure that the previous stat is not null/0. - // If we are trying to pull > 24 hrs worth of data, pull only 24 hrs. - // This is to prevent checking too much data at once. - let prevTime = event.prevStat ? event.prevStat.mtimeMs : 0; - if (prevTime <= 0) { - this.log.debug(`Previous time is 0, setting to last check time...`); - prevTime = this.lastCheck; - } else if (this.lastCheck - prevTime > 86400000) { - this.log.debug(`Previous time is > 24 hours, setting to 24 hours ago...`); - prevTime = this.lastCheck - 86400000; + try { + const now = Date.now(); + let prevTime = this.lastCheck; + + if (prevTime <= 0 || prevTime > now) { + this.log.debug(`Previous time is invalid (${prevTime}), setting to now...`); + prevTime = now; + } else if (now - prevTime > 86400000) { + this.log.debug(`Previous time is > 24 hours ago, setting to 24 hours ago...`); + prevTime = now - 86400000; } - - // Use the previousStat's mtimeMs - 30 seconds to account for any time drift. - // This allows us to fetch everything since the last mtimeMs. - await this.poll(new Date(prevTime - 30000)); - - // Trim the cache so it doesn't get too big + + let afterTime = prevTime - 30000; + if (afterTime > now) { + afterTime = now; + } + await this.poll(new Date(afterTime)); + this.lastCheck = now; + this.cache.trimCaches(); - if (this.processLock.nrWaiting() > 0) { await waitMs(100); } - } else { - this.log.debug(`Not processing DB change: ${event.currentStat.mtimeMs} <= ${this.lastCheck}`); + } catch (error) { + this.log.error(`Error handling change event: ${error}`); + } finally { + this.processLock.release(); } - - this.processLock.release(); } async poll(after: Date, emitResults = true) { for (const poller of this.pollers) { - const startMs = new Date().getTime(); const results = await poller.poll(after); if (emitResults) { for (const result of results) { this.emit(result.eventType, result.data); + await waitMs(10); } } - - const endMs = new Date().getTime(); - // this.log.debug(`${poller.tag} took ${endMs - startMs}ms`); } } } diff --git a/packages/server/src/server/databases/imessage/pollers/MessagePoller.ts b/packages/server/src/server/databases/imessage/pollers/MessagePoller.ts index 7b430e53..06190b12 100644 --- a/packages/server/src/server/databases/imessage/pollers/MessagePoller.ts +++ b/packages/server/src/server/databases/imessage/pollers/MessagePoller.ts @@ -52,47 +52,6 @@ export class MessagePoller extends IMessagePoller { (e.didNotifyRecipient ?? false) )); - // this.log.debug(`Normal getMessages took: ${new Date().getTime() - start.getTime()}ms`); - - // start = new Date(); - // let rawResults = await this.repo.getMessagesRaw({ - // after: afterLookback, - // withChats: true, - // orderBy: "message.dateCreated" - // }); - - // // Filter the raw results by date before doing the full decode. - // // This is so we don't over-process the data. - // rawResults = rawResults.filter((e) => { - // const created = getDateUsing2001(e.message_date); - // const isFromMe = Boolean(e.message_is_from_me); - // const delivered = getDateUsing2001(e.message_date_delivered); - // const read = getDateUsing2001(e.message_date_read); - // const edited = getDateUsing2001(e.message_date_edited); - // const retracted = getDateUsing2001(e.message_date_retracted); - // const isEmpty = Boolean(e.message_is_empty); - // const didNotifyRecipient = Boolean(e.message_did_notify_recipient); - // return (created?.getTime() ?? 0) >= afterTime || - // // Date delivered only matters if it's from you - // (isFromMe && (delivered?.getTime() ?? 0)) >= afterTime || - // // Date read only matters if it's from you and it's not a group chat - // (isFromMe && e.chat_style !== 43 && (read?.getTime() ?? 0)) >= afterTime || - // // Date edited can be from anyone (should include edits & unsends) - // (edited?.getTime() ?? 0) >= afterTime || - // // Date retracted can be from anyone, but Apple doesn't even use this field. - // // We still want to be thorough and check it. - // // isEmpty is what's actually used by Apple to determine if it's retracted. - // // (in addition to dateEdited) - // (retracted?.getTime() ?? 0) >= afterTime || - // (isEmpty ?? false) || - // // If didNotifyRecipient changed (from false to true) - // (didNotifyRecipient ?? false); - // }); - - // const decoder = new MessageDecoder(); - // decoder.decodeList(rawResults); - // this.log.debug(`Raw getMessages took: ${new Date().getTime() - start.getTime()}ms`); - // Handle group changes const groupChangeEntries = entries.filter(e => isEmpty(e.text) && [1, 2, 3].includes(e.itemType)); results = results.concat(this.handleGroupChanges(groupChangeEntries)); @@ -133,6 +92,9 @@ export class MessagePoller extends IMessagePoller { results.push({ eventType: event, data: entry }); } + // Sort results ascending order by date created so that older messages are processed first + results.sort((a, b) => a.data.dateCreated.getTime() - b.data.dateCreated.getTime()); + return results; } diff --git a/packages/server/src/server/databases/server/index.ts b/packages/server/src/server/databases/server/index.ts index c91cc80d..43ed18cf 100644 --- a/packages/server/src/server/databases/server/index.ts +++ b/packages/server/src/server/databases/server/index.ts @@ -182,8 +182,16 @@ export class ServerRepository extends EventEmitter { } } - public async getWebhooks(): Promise> { + public async getWebhooks({ + url = null, + id = null + }: { + url?: string | null; + id?: number | null; + } = {}): Promise> { const repo = this.webhooks(); + if (id) return [await repo.findOneBy({ id })]; + if (url) return [await repo.findOneBy({ url })]; return await repo.find(); } @@ -228,7 +236,8 @@ export class ServerRepository extends EventEmitter { return item; } - public async deleteWebhook({ url = null, id = null }: { url: string | null; id: number | null }): Promise { + public async deleteWebhook({ url = null, id = null }: { url?: string | null; id?: number | null }): Promise { + if (!url && !id) throw new Error("Failed to delete webhook! No URL or ID provided!"); const repo = this.webhooks(); const item = url ? await repo.findOneBy({ url }) : await repo.findOneBy({ id }); if (!item) return; diff --git a/packages/server/src/server/env.ts b/packages/server/src/server/env.ts index 85d9a93d..a116be67 100644 --- a/packages/server/src/server/env.ts +++ b/packages/server/src/server/env.ts @@ -1,5 +1,6 @@ import * as macosVersion from "macos-version"; +export const isMinSequoia = macosVersion.isGreaterThanOrEqualTo("15.0"); export const isMinSonoma = macosVersion.isGreaterThanOrEqualTo("14.0"); export const isMinVentura = macosVersion.isGreaterThanOrEqualTo("13.0"); export const isMinMonterey = macosVersion.isGreaterThanOrEqualTo("12.0"); @@ -7,4 +8,4 @@ export const isMinBigSur = macosVersion.isGreaterThanOrEqualTo("11.0"); export const isMinCatalina = macosVersion.isGreaterThanOrEqualTo("10.15"); export const isMinMojave = macosVersion.isGreaterThanOrEqualTo("10.14"); export const isMinHighSierra = macosVersion.isGreaterThanOrEqualTo("10.13"); -export const isMinSierra = macosVersion.isGreaterThanOrEqualTo("10.12"); +export const isMinSierra = macosVersion.isGreaterThanOrEqualTo("10.12"); \ No newline at end of file diff --git a/packages/server/src/server/fileSystem/index.ts b/packages/server/src/server/fileSystem/index.ts index cc3f299e..ae05ec39 100644 --- a/packages/server/src/server/fileSystem/index.ts +++ b/packages/server/src/server/fileSystem/index.ts @@ -722,11 +722,14 @@ export class FileSystem { static async createLaunchAgent(): Promise { const appPath = app.getPath("exe"); - console.log(appPath); const plist = ` + AssociatedBundleIdentifiers + + com.BlueBubbles.BlueBubbles-Server + Label com.bluebubbles.server Program @@ -736,25 +739,50 @@ export class FileSystem { KeepAlive SuccessfulExit - - + + Crashed + + `; - const filePath = path.join(userHomeDir(), "Library", "LaunchAgents", "com.bluebubbles.server.plist"); - if (fs.existsSync(filePath)) return; + const plistName = "com.bluebubbles.server"; + const filePath = path.join(userHomeDir(), "Library", "LaunchAgents", `${plistName}.plist`); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, plist); + } + + try { + // Always disable first, which will never error out. + // Makes a clean slate for enabling then bootstrapping + await FileSystem.execShellCommand(`launchctl disable gui/${os.userInfo().uid}/${plistName}`); - fs.writeFileSync(filePath, plist); + // Enable should allow start on boot + await FileSystem.execShellCommand(`launchctl enable gui/${os.userInfo().uid}/${plistName}`); - await FileSystem.execShellCommand(`launchctl load -w ${filePath}`); + // Bootstrap should load and start the service + await FileSystem.execShellCommand(`launchctl bootstrap gui/${os.userInfo().uid} "${filePath}"`); + } catch (ex: any) { + Server().log(`Failed to create launch agent: ${ex?.message ?? String(ex)}`, "error"); + } } static async removeLaunchAgent(): Promise { - const filePath = path.join(userHomeDir(), "Library", "LaunchAgents", "com.bluebubbles.server.plist"); - if (!fs.existsSync(filePath)) return; + const plistName = "com.bluebubbles.server"; + const filePath = path.join(userHomeDir(), "Library", "LaunchAgents", `${plistName}.plist`); - await FileSystem.execShellCommand(`launchctl unload -w ${filePath}`); - fs.unlinkSync(filePath); + // Disable should stop the service from starting on boot + try { + await FileSystem.execShellCommand(`launchctl disable gui/${os.userInfo().uid}/${plistName}`); + await FileSystem.execShellCommand(`launchctl bootout gui/${os.userInfo().uid}/${plistName}`); + } catch (ex: any) { + Server().log(`Failed to remove launch agent: ${ex?.message ?? String(ex)}`, "error"); + } + + // The shell command requires the path to exist + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } } static async lockMacOs(): Promise { diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 2b1820d2..d1ffa1bc 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -34,7 +34,7 @@ import { ZrokService } from "@server/services"; import { EventCache } from "@server/eventCache"; -import { runTerminalScript, openSystemPreferences } from "@server/api/apple/scripts"; +import { runTerminalScript, openSystemPreferences, startMessages } from "@server/api/apple/scripts"; import { ActionHandler } from "./api/apple/actions"; import { insertChatParticipants, isEmpty, isNotEmpty, waitMs } from "./helpers/utils"; @@ -1041,7 +1041,13 @@ class BlueBubblesServer extends EventEmitter { } // Install the bundle if the Private API is turned on - if ( + if (!nextConfig.enable_private_api && !nextConfig.enable_ft_private_api) { + this.logger.debug("Detected Private API disable"); + await Server().privateApi.stop(); + + // Start messages after so we can properly use AppleScript + await FileSystem.executeAppleScript(startMessages()); + } else if ( prevConfig.enable_private_api !== nextConfig.enable_private_api || prevConfig.enable_ft_private_api !== nextConfig.enable_ft_private_api ) { @@ -1079,24 +1085,26 @@ class BlueBubblesServer extends EventEmitter { } // Handle when auto start method changes - if (prevConfig.auto_start_method !== nextConfig.auto_start_method) { - // If we previously starting as a login item, remove it - if (prevConfig.auto_start_method === AutoStartMethods.LoginItem) { - app.setLoginItemSettings({ openAtLogin: false }); - } - - // If we are next starting as a login item, add it - if (nextConfig.auto_start_method === AutoStartMethods.LoginItem) { - app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true }); - } - - // If we are starting as a launch agent, add it - if (prevConfig.auto_start_method === AutoStartMethods.LaunchAgent) { + const prevAutoStart = prevConfig.auto_start_method as AutoStartMethods; + const nextAutoStart = nextConfig.auto_start_method as AutoStartMethods; + if (prevAutoStart !== nextAutoStart) { + this.log(`Auto-start method changed from ${prevAutoStart} to ${nextAutoStart}`); + + // Handle stop cases + if (prevAutoStart === AutoStartMethods.LoginItem) { + this.log("Disabling auto-start at login item..."); + app.setLoginItemSettings({ openAtLogin: false, openAsHidden: false }); + this.log("Auto-start at login item disabled!"); + } else if (prevAutoStart === AutoStartMethods.LaunchAgent) { await FileSystem.removeLaunchAgent(); } - // If we are starting as a launch agent, add it - if (nextConfig.auto_start_method === AutoStartMethods.LaunchAgent) { + // Handle start cases + if (nextAutoStart === AutoStartMethods.LoginItem) { + this.log("Enabling auto-start at login item..."); + app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true }); + this.log("Auto-start at login item enabled!"); + } else if (nextAutoStart === AutoStartMethods.LaunchAgent) { await FileSystem.createLaunchAgent(); } } diff --git a/packages/server/src/server/lib/ProcessSpawner.ts b/packages/server/src/server/lib/ProcessSpawner.ts index dc5e8dd2..6c9c912f 100644 --- a/packages/server/src/server/lib/ProcessSpawner.ts +++ b/packages/server/src/server/lib/ProcessSpawner.ts @@ -11,6 +11,7 @@ export type ProcessSpawnerConstructorArgs = { onExit?: ((code: number) => void) | null; logTag?: string | null; restartOnNonZeroExit?: boolean; + restartOnNonZeroExitCondition?: ((code: number) => boolean) | null; storeOutput?: boolean; waitForExit?: boolean; errorOnStderr?: boolean; @@ -44,6 +45,8 @@ export class ProcessSpawner extends Loggable { restartOnNonZeroExit: boolean; + restartOnNonZeroExitCondition: ((code: number) => boolean) | null; + storeOutput: boolean; waitForExit: boolean; @@ -92,6 +95,7 @@ export class ProcessSpawner extends Loggable { onExit = null, logTag = null, restartOnNonZeroExit = false, + restartOnNonZeroExitCondition = null, storeOutput = true, waitForExit = true, errorOnStderr = false @@ -99,12 +103,17 @@ export class ProcessSpawner extends Loggable { super(); this.command = command; + if (this.command.includes(" ")) { + this.command = `"${this.command}"`; + } + this.args = args; this.options = options; this.verbose = verbose; this.onOutput = onOutput; this.onExit = onExit; this.restartOnNonZeroExit = restartOnNonZeroExit; + this.restartOnNonZeroExitCondition = restartOnNonZeroExitCondition; this.storeOutput = storeOutput; this.waitForExit = waitForExit; this.errorOnStderr = errorOnStderr; @@ -121,7 +130,7 @@ export class ProcessSpawner extends Loggable { async execute(): Promise { return new Promise((resolve: (spawner: ProcessSpawner) => void, reject: (err: ProcessSpawnerError) => void) => { try { - this.process = this.spawnProcesses(); + this.process = spawn(this.command, this.quoteArgs(this.args), { ...this.options, shell: true }); this.process.stdout.on("data", chunk => this.handleOutput(chunk, "stdout")); this.process.stderr.on("data", chunk => { this.handleOutput(chunk, "stderr"); @@ -174,55 +183,6 @@ export class ProcessSpawner extends Loggable { }); } - private spawnProcesses() { - // If the args contain a pipe character, we need to split the command and args into separate processes. - // The separate processes should dynamically pipe the result into the next, returning the last process - // as the final result. - if (this.args.some(arg => arg.includes("|"))) { - // Combine the command and args into a single string - const commandStr = `${this.command} ${this.args.join(" ")}`; - - // Split by the pipe character, and trim any whitespace - const commands = commandStr.split("|").map(x => x.trim()); - if (commands.length < 2) { - throw new Error(`Invalid pipe command! Input: ${commandStr}`); - } - - // Iterate over the commands, executing them and piping the - // output to the next process. Then return the last process. - let lastProcess: ChildProcess = null; - for (let i = 0; i < commands.length; i++) { - const command = commands[i].trim(); - - // Get the command - if (!command) { - throw new Error(`Invalid command! Input: ${command}`); - } - - // Pull the first command off the list - const commandParts = command.split(" "); - const program = commandParts[0]; - const args = commandParts.slice(1); - - // Spawn the process and pipe the output to the next process - const proc = spawn(program, args, { - stdio: [ - // If there is a previous process, pipe the output to the next process - (lastProcess) ? lastProcess.stdout : "pipe", - "pipe", - "pipe" - ] - }); - - lastProcess = proc; - } - - return lastProcess; - } - - return spawn(this.command, this.args, this.options); - } - private handleLog(log: string) { if (this.verbose) { this.log.debug(log); @@ -248,7 +208,9 @@ export class ProcessSpawner extends Loggable { } if (code !== 0 && this.restartOnNonZeroExit) { - this.execute(); + if (!this.restartOnNonZeroExitCondition || this.restartOnNonZeroExitCondition(code)) { + this.execute(); + } } } @@ -258,18 +220,29 @@ export class ProcessSpawner extends Loggable { } } + private quoteArgs(args: string[]): string[] { + return args.map(arg => { + if (arg.includes(" ")) { + return `"${arg.replace(/"/g, '\\"')}"`; + } else { + return arg; + } + }); + } + static async executeCommand( command: string, args: string[] = [], options: SpawnOptionsWithoutStdio = {}, - tag = 'CommandExecutor' + tag = 'CommandExecutor', + verbose = false ): Promise { const spawner = new ProcessSpawner({ command, args, logTag: tag, options, - verbose: false, + verbose, restartOnNonZeroExit: false, storeOutput: true, waitForExit: true diff --git a/packages/server/src/server/lib/decorators/DebounceDecorator.ts b/packages/server/src/server/lib/decorators/DebounceDecorator.ts index 2245c659..7bac1b3d 100644 --- a/packages/server/src/server/lib/decorators/DebounceDecorator.ts +++ b/packages/server/src/server/lib/decorators/DebounceDecorator.ts @@ -37,11 +37,11 @@ export const DebounceSubsequentWithWait = any>( } } - const promise = promiseWrapper(); + const promise = promiseWrapper.call(this); timeouts.set(name, promise); return await promise; }; return descriptor; }; -}; +}; \ No newline at end of file diff --git a/packages/server/src/server/managers/cloudflareManager/index.ts b/packages/server/src/server/managers/cloudflareManager/index.ts index 13a89efb..f00a34e1 100644 --- a/packages/server/src/server/managers/cloudflareManager/index.ts +++ b/packages/server/src/server/managers/cloudflareManager/index.ts @@ -1,7 +1,7 @@ import * as path from "path"; import { FileSystem } from "@server/fileSystem"; import { Server } from "@server"; -import { isEmpty, isNotEmpty } from "@server/helpers/utils"; +import { isEmpty, isNotEmpty, waitMs } from "@server/helpers/utils"; import { Loggable } from "@server/lib/logging/Loggable"; import { ProcessSpawner } from "@server/lib/ProcessSpawner"; @@ -22,9 +22,15 @@ export class CloudflareManager extends Loggable { isRestarting = false; + isRateLimited = false; + private proxyUrlRegex = /INF \|\s{1,}(https:\/\/[^\s]+)\s{1,}\|/m; async start(): Promise { + if (this.isRateLimited) { + throw new Error("Cloudflare is rate limiting your requests. Waiting 1 hour... If you do not wawnt to wait 1 hour, fully restart the server."); + } + try { this.emit("started"); return this.connectHandler(); @@ -40,6 +46,10 @@ export class CloudflareManager extends Loggable { return new Promise(async (resolve, reject) => { try { const port = Server().repo.getConfig("socket_port") as string; + if (this.proc && !this.proc?.process?.killed) { + this.log.debug("Cloudflare Tunnel already running. Stopping..."); + await this.stop(); + } this.log.debug("Starting Cloudflare Tunnel..."); this.proc = new ProcessSpawner({ @@ -54,6 +64,7 @@ export class CloudflareManager extends Loggable { logTag: "CloudflareDaemon", onOutput: (data) => this.handleData(data), restartOnNonZeroExit: true, + restartOnNonZeroExitCondition: (_) => !this.isRateLimited, waitForExit: false, storeOutput: false }); @@ -80,8 +91,8 @@ export class CloudflareManager extends Loggable { } async stop() { - if (!this.proc) return; this.currentProxyUrl = null; + if (!this.proc) return; await this.proc.kill(); } @@ -115,6 +126,15 @@ export class CloudflareManager extends Loggable { return "Failed to connect to Cloudflare's servers! Please make sure your Mac is up to date"; } else if (data.includes('failed to request quick Tunnel: ')) { return data.split('failed to request quick Tunnel: ')[1]; + } else if (data.includes('failed to unmarshal quick Tunnel')) { + this.isRateLimited = true; + this.stop(); + waitMs(1000 * 60 * 60).then(() => { + this.isRateLimited = false; + this.start(); + }); + + return 'Cloudflare is rate limiting your requests. Waiting 1 hour...'; } return null; diff --git a/packages/server/src/server/managers/zrokManager/index.ts b/packages/server/src/server/managers/zrokManager/index.ts index 3ed36090..d8001a7d 100644 --- a/packages/server/src/server/managers/zrokManager/index.ts +++ b/packages/server/src/server/managers/zrokManager/index.ts @@ -23,10 +23,18 @@ export class ZrokManager extends Loggable { static proxyUrlRegex = /\b(https:\/\/.*?\.zrok.io)\b/m; + connectPromise: Promise = null; + + isRateLimited = false; + async start(): Promise { + if (this.isRateLimited) { + throw new Error("Rate limited by Cloudflare. Waiting 1 hour before retrying..."); + } + try { this.emit("started"); - return this.connectHandler(); + return await this.connectHandler(); } catch (ex) { this.log.error(`Failed to run Zrok daemon! ${ex.toString()}`); this.emit("error", ex); @@ -38,32 +46,23 @@ export class ZrokManager extends Loggable { private async connectHandler(): Promise { const port = Server().repo.getConfig("socket_port") as string; const reservedTunnel = (Server().repo.getConfig("zrok_reserve_tunnel") as boolean) ?? false; - const reservedToken = Server().repo.getConfig("zrok_reserved_token") as string; - const reservedName = Server().repo.getConfig("zrok_reserved_name") as string; - - // The token will equal the name if it's already reserved - let reservedNameToken = reservedName; - if (reservedTunnel && (isEmpty(reservedToken) || reservedToken !== reservedName)) { - reservedNameToken = await ZrokManager.reserve(reservedName); - // If the token is not empty, but the tunnel is not reserved, release it - } else if (!reservedTunnel && isNotEmpty(reservedToken)) { - try { - await ZrokManager.release(reservedToken); - } catch (ex) { - this.log.debug(`Failed to release reserved Zrok tunnel! Error: ${ex}`); - } - } + const tunnelToken = await ZrokManager.reserve(null); - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { // Didn't use zx here because I couldn't figure out how to pipe the stdout // properly, without taking over the terminal outputs. // Conditionally change the command based on if we are reserving a tunnel or not const commndFlags = [ "share", ...(reservedTunnel ? ["reserved", "--headless"] : ["public", "--backend-mode", "proxy", "--headless"]), - ...(reservedTunnel ? [reservedNameToken] : [`0.0.0.0:${port}`]) + ...(reservedTunnel ? [tunnelToken] : [`0.0.0.0:${port}`]) ]; + if (this.proc && !this.proc?.killed) { + this.log.debug("Zrok Tunnel already running. Stopping..."); + await this.stop(); + } + this.proc = spawn(ZrokManager.daemonPath, commndFlags); this.proc.stdout.on("data", chunk => this.handleData(chunk)); this.proc.stderr.on("data", chunk => this.handleData(chunk)); @@ -78,6 +77,12 @@ export class ZrokManager extends Loggable { } async stop() { + const reservedTunnel = (Server().repo.getConfig("zrok_reserve_tunnel") as boolean) ?? false; + const reservedToken = Server().repo.getConfig("zrok_reserved_token") as string; + if (!reservedTunnel && isNotEmpty(reservedToken)) { + await ZrokManager.safeRelease(reservedToken, { clearToken: true }); + } + if (!this.proc) return; this.currentProxyUrl = null; await this.proc.kill(); @@ -116,7 +121,11 @@ export class ZrokManager extends Loggable { } static async getInvite(email: string): Promise { + const logger = getLogger("ZrokManager"); + try { + logger.info(`Dispatching Zrok invite to ${email}...`); + // Use axios to get the invite with the following spec await axios.post("https://api.zrok.io/api/v1/invite", JSON.stringify({ email }), { headers: { @@ -140,7 +149,10 @@ export class ZrokManager extends Loggable { } static async setToken(token: string): Promise { + const logger = getLogger("ZrokManager"); + try { + logger.info(`Enabling Zrok...`); return await ProcessSpawner.executeCommand(this.daemonPath, ["enable", token], {}, "ZrokManager"); } catch (ex: any | ProcessSpawnerError) { const output = ex?.output ?? ex?.message ?? String(ex); @@ -149,7 +161,6 @@ export class ZrokManager extends Loggable { } else if (output.includes("enableUnauthorized")) { throw new Error("Invalid Zrok token!"); } else { - const logger = getLogger("ZrokManager"); logger.error(`Failed to set Zrok token! Error: ${output}`); throw new Error("Failed to set Zrok token! Please check your server logs for more information."); } @@ -157,53 +168,83 @@ export class ZrokManager extends Loggable { } static async reserve(name?: string): Promise { - const reservedTunnelToken = Server().repo.getConfig("zrok_reserved_token") as string; + const reservedTunnel = (Server().repo.getConfig("zrok_reserve_tunnel") as boolean) ?? false; + const reservedToken = Server().repo.getConfig("zrok_reserved_token") as string; + const reservedName = name ?? Server().repo.getConfig("zrok_reserved_name") as string; const logger = getLogger("ZrokManager"); - // If there is an existing token, release it. - // We only want one reserved tunnel per person - if (isNotEmpty(reservedTunnelToken)) { - try { - await ZrokManager.release(reservedTunnelToken); - } catch (ex) { - logger.debug(`Failed to release reserved Zrok tunnel! Error: ${ex}`); + logger.info(`Looking for existing reserved Zrok share...`); + const existingShare = await ZrokManager.getExistingReservedShareToken(reservedToken); + const existingToken = existingShare?.token; + const existingIsReserved = existingShare?.reserved; + + if (existingShare) { + logger.info(`Found existing reserved Zrok share: ${existingToken}`); + } else { + logger.debug(`No existing reserved Zrok share found.`); + } + + // If we don't want to reserve a tunnel, clear the configs and release the existing token + if (!reservedTunnel) { + logger.debug(`Disabling reserved Zrok tunnel...`); + await Server().repo.setConfig("zrok_reserved_token", ""); + await Server().repo.setConfig("zrok_reserved_name", ""); + + // If there is an existing token, release it + if (isNotEmpty(existingToken)) { + logger.info(`Releasing existing Zrok share (${existingToken}) because we no longer want to use a reserved tunnel.`); + await this.safeRelease(existingToken); } + + return null; } - // Check if there are any existing reserved shares. - // Rather than reserving a new one, we can just use the existing one. - const existingToken = await ZrokManager.getExistingReservedShareToken(name); if (isNotEmpty(existingToken)) { - await Server().repo.setConfig("zrok_reserved_token", existingToken); - return existingToken; + logger.debug('Handling existing token...'); + + // If the token is different, release the existing tunnel + if (existingToken !== reservedToken) { + logger.info(`Releasing existing Zrok share (${existingToken}) because the reserve token has changed.`); + await ZrokManager.safeRelease(existingToken, { clearToken: true }); + // If the tokens match, but the name doesn't match the token (which will be the name), + // then release the existing tunnel + } else if (existingToken === reservedToken && isNotEmpty(reservedName) && reservedName !== existingToken) { + logger.info(`Releasing existing Zrok share (${existingToken}) because the reserved name has changed.`); + await ZrokManager.safeRelease(existingToken, { clearToken: true }); + // If we have an existing token and the name hasn't changed, return the existing token + } else if (existingIsReserved) { + logger.info(`Using existing Zrok token: ${existingToken}`); + return existingToken; + } } try { const port = Server().repo.getConfig("socket_port") as string; const flags = [`0.0.0.0:${port}`, `--backend-mode`, `proxy`]; - if (isNotEmpty(name)) { + if (isNotEmpty(reservedName)) { flags.push(`--unique-name`); - flags.push(name); + flags.push(reservedName); } + logger.info(`Reserving new tunnel with flags: ${flags.join(" ")}`); const output = await ProcessSpawner.executeCommand(this.daemonPath, ["reserve", "public", ...flags], {}, "ZrokManager"); const urlMatches = output.match(ZrokManager.proxyUrlRegex); if (isEmpty(urlMatches)) { - logger.debug(`Failed to reserve Zrok tunnel! Unable to find URL in output. Output: ${output}`); + logger.info(`Failed to reserve Zrok tunnel! Unable to find URL in output. Output: ${output}`); throw new Error(`Failed to reserve Zrok tunnel! Unable to find URL in output.`); } const regex = /reserved share token is '(?[a-z0-9]+)'/g; const matches = Array.from(output.matchAll(regex)); if (isEmpty(matches)) { - logger.debug(`Failed to reserve Zrok tunnel! Unable to find token in output (1). Output: ${output}`); + logger.info(`Failed to reserve Zrok tunnel! Unable to find token in output (1). Output: ${output}`); throw new Error(`Failed to reserve Zrok tunnel! Unable to find token in output.`); } const token = matches[0].groups?.token; if (isEmpty(token)) { - logger.debug(`Failed to reserve Zrok tunnel! Unable to find token in output (2). Output: ${output}`); + logger.info(`Failed to reserve Zrok tunnel! Unable to find token in output (2). Output: ${output}`); throw new Error(`Failed to reserve Zrok tunnel! Error: ${output}`); } @@ -222,15 +263,28 @@ export class ZrokManager extends Loggable { } static async disable(): Promise { - return await ProcessSpawner.executeCommand(this.daemonPath, ["disable"], {}, "ZrokManager"); + const logger = getLogger("ZrokManager"); + + + try { + logger.debug("Disabling Zrok tunnel..."); + return await ProcessSpawner.executeCommand(this.daemonPath, ["disable"], {}, "ZrokManager"); + } catch (ex: any | ProcessSpawnerError) { + const output = ex?.output ?? ex?.message ?? String(ex); + logger.error(`Failed to disable Zrok tunnel! Error: ${output}`); + throw new Error("Failed to disable Zrok tunnel! Please check your server logs for more information."); + } } - static async getExistingReservedShareToken(name?: string): Promise { + static async getExistingReservedShareToken(name?: string): Promise { // Run the overview command and parse the output const output = await ProcessSpawner.executeCommand(this.daemonPath, ["overview"], {}, "ZrokManager"); const json = JSON.parse(output); const host = Server().computerIdentifier; + const logger = getLogger("ZrokManager"); + logger.debug(`Found ${json.environments.length} environments in Zrok overview`); + // Find the proper environment based on the computer user & name const env = (json.environments ?? []).find((e: any) => e.environment?.description === host); if (!env) return null; @@ -248,15 +302,32 @@ export class ZrokManager extends Loggable { (isEmpty(name) || s.token === name) ); - return reserved?.token ?? null; + return reserved; + } + + static async safeRelease(token: string, { clearToken = false, clearName = false } = {}): Promise { + const logger = getLogger("ZrokManager"); + if (isNotEmpty(token)) { + try { + logger.info(`Releasing existing Zrok share with token: ${token}`); + return await ZrokManager.release(token, { clearName, clearToken }); + } catch (ex) { + logger.info(`Failed to release existing Zrok tunnel! Error: ${ex.toString()}`); + } + } + + return null; } - static async release(token: string): Promise { + static async release(token: string, { clearToken = false, clearName = false } = {}): Promise { try { + const logger = getLogger("ZrokManager"); + logger.info(`Releasing Zrok share with token: ${token}`); const result = await ProcessSpawner.executeCommand(this.daemonPath, ["release", token], {}, "ZrokManager"); // Clear the token from the config - await Server().repo.setConfig("zrok_reserved_token", ""); + if (clearToken) await Server().repo.setConfig("zrok_reserved_token", ""); + if (clearName) await Server().repo.setConfig("zrok_reserved_name", ""); return result; } catch (ex: any | ProcessSpawnerError) { const output = ex?.output ?? ex?.message ?? String(ex); diff --git a/packages/server/src/server/services/ipcService/index.ts b/packages/server/src/server/services/ipcService/index.ts index 59cd9db9..42635aa7 100644 --- a/packages/server/src/server/services/ipcService/index.ts +++ b/packages/server/src/server/services/ipcService/index.ts @@ -457,6 +457,10 @@ export class IPCService extends Loggable { return await ZrokManager.setToken(token); }); + ipcMain.handle("disable-zrok", async (_, __) => { + return await ZrokManager.disable(); + }); + ipcMain.handle("install-update", async (_, __) => { if (!Server().updater.hasUpdate) { return Server().log("No update available to install!", "debug"); diff --git a/packages/server/src/server/services/proxyServices/cloudflareService/index.ts b/packages/server/src/server/services/proxyServices/cloudflareService/index.ts index af85d256..9f035228 100644 --- a/packages/server/src/server/services/proxyServices/cloudflareService/index.ts +++ b/packages/server/src/server/services/proxyServices/cloudflareService/index.ts @@ -6,6 +6,10 @@ export class CloudflareService extends Proxy { manager: CloudflareManager; + connectPromise: Promise; + + lastError: string; + constructor() { super({ name: "Cloudflare", @@ -20,13 +24,41 @@ export class CloudflareService extends Proxy { return this.url !== null; } + async checkForError(log: string, _: any = null): Promise { + return this.lastError === log; + } + + async shouldRelaunch(): Promise { + return !this.manager?.isRateLimited; + } + /** * Sets up a connection to the Cloudflare servers, opening a secure * tunnel between the internet and your Mac (iMessage server) */ async connect(): Promise { + if (this.connectPromise) { + this.log.debug("Already connecting to Cloudflare. Waiting for connection to complete."); + await this.connectPromise; + } + + try { + this.connectPromise = this._connect(); + this.url = await this.connectPromise; + this.connectPromise = null; + return this.url; + } catch (ex: any) { + this.connectPromise = null; + this.log.info(`Failed to connect to Cloudflare! Error: ${ex.toString()}`); + throw ex; + } + } + + async _connect(): Promise { // Create the connection - this.manager = new CloudflareManager(); + if (!this.manager) { + this.manager = new CloudflareManager(); + } // When we get a new URL, set the URL and update this.manager.on("new-url", async url => { @@ -38,7 +70,6 @@ export class CloudflareService extends Proxy { }, 5000); }); - // When we get a new URL, set the URL and update this.manager.on("needs-restart", async _ => { try { await this.restart(); @@ -57,10 +88,14 @@ export class CloudflareService extends Proxy { * Disconnect from Cloudflare */ async disconnect(): Promise { + if (this.connectPromise) { + this.connectPromise = null; + } + try { if (this.manager) { this.manager.removeAllListeners(); - this.manager.stop(); + await this.manager.stop(); } } finally { this.url = null; diff --git a/packages/server/src/server/services/proxyServices/proxy.ts b/packages/server/src/server/services/proxyServices/proxy.ts index 898b81f3..8ca5d52f 100644 --- a/packages/server/src/server/services/proxyServices/proxy.ts +++ b/packages/server/src/server/services/proxyServices/proxy.ts @@ -138,6 +138,10 @@ export abstract class Proxy extends Loggable { return false; } + async shouldRelaunch(): Promise { + return true; + } + /** * Helper for restarting the ngrok connection */ @@ -177,8 +181,12 @@ export abstract class Proxy extends Loggable { } if (tries >= maxTries) { - this.log.info(`Reached maximum retry attempts for ${this.opts.name}. Force restarting app...`); - Server().relaunch(); + if (await this.shouldRelaunch()) { + this.log.info(`Reached maximum retry attempts for ${this.opts.name}. Force restarting app...`); + Server().relaunch(); + } else { + this.log.warn('Max retry attempts reached for proxy service! Not relaunching...'); + } } return connected; @@ -190,7 +198,7 @@ export abstract class Proxy extends Loggable { async restartHandler(wait = 1000): Promise { try { await this.disconnect(); - await new Promise((resolve, _) => setTimeout(resolve, wait)); + await waitMs(wait); await this.start(); } catch (ex: any) { const output = ex?.toString() ?? ""; diff --git a/packages/server/src/server/services/proxyServices/zrokService/index.ts b/packages/server/src/server/services/proxyServices/zrokService/index.ts index 5790d6cc..e9a72e71 100644 --- a/packages/server/src/server/services/proxyServices/zrokService/index.ts +++ b/packages/server/src/server/services/proxyServices/zrokService/index.ts @@ -8,6 +8,8 @@ export class ZrokService extends Proxy { manager: ZrokManager; + connectPromise: Promise; + constructor() { super({ name: "Zrok", @@ -27,13 +29,33 @@ export class ZrokService extends Proxy { * tunnel between the internet and your Mac (iMessage server) */ async connect(): Promise { + if (this.connectPromise) { + this.log.debug("Already connecting to Zrok. Waiting for connection to complete."); + await this.connectPromise; + } + const token = Server().repo.getConfig("zrok_token") as string; if (isEmpty(token)) { throw new Error("Auth Token missing! Please perform the Zrok setup in the settings page."); } + try { + this.connectPromise = this._connect(); + this.url = await this.connectPromise; + this.connectPromise = null; + return this.url; + } catch (ex: any) { + this.connectPromise = null; + this.log.info(`Failed to connect to Zrok! Error: ${ex.toString()}`); + throw ex; + } + } + + async _connect(): Promise { // Create the connection - this.manager = new ZrokManager(); + if (!this.manager) { + this.manager = new ZrokManager(); + } // When we get a new URL, set the URL and update this.manager.on("new-url", async url => { @@ -65,10 +87,14 @@ export class ZrokService extends Proxy { * Disconnect from Zrok */ async disconnect(): Promise { + if (this.connectPromise) { + this.connectPromise = null; + } + try { if (this.manager) { this.manager.removeAllListeners(); - this.manager.stop(); + await this.manager.stop(); } } finally { this.url = null; diff --git a/packages/server/src/server/services/webhookService/index.ts b/packages/server/src/server/services/webhookService/index.ts index 27f2a9ae..542723aa 100644 --- a/packages/server/src/server/services/webhookService/index.ts +++ b/packages/server/src/server/services/webhookService/index.ts @@ -23,7 +23,8 @@ export class WebhookService extends Loggable { // We don't need to await this this.sendPost(i.url, event).catch(ex => { this.log.debug(`Failed to dispatch "${event.type}" event to webhook: ${i.url}`); - this.log.debug(ex?.message ?? String(ex)); + this.log.debug(` -> Error: ${ex?.message ?? String(ex)}`); + this.log.debug(` -> Status Text: ${ex?.response?.statusText}`); }); } } diff --git a/packages/server/src/server/types.ts b/packages/server/src/server/types.ts index 5463bcbf..c579c5e9 100644 --- a/packages/server/src/server/types.ts +++ b/packages/server/src/server/types.ts @@ -42,6 +42,7 @@ export type MessageResponse = { dateDelivered: number | null; isFromMe: boolean; isDelayed?: boolean; + isDelivered?: boolean; isAutoReply?: boolean; isSystemMessage?: boolean; isServiceMessage?: boolean; diff --git a/packages/ui/src/app/components/fields/AutoStartMethodField.tsx b/packages/ui/src/app/components/fields/AutoStartMethodField.tsx index ed92121e..45845017 100644 --- a/packages/ui/src/app/components/fields/AutoStartMethodField.tsx +++ b/packages/ui/src/app/components/fields/AutoStartMethodField.tsx @@ -39,7 +39,8 @@ export const AutoStartMethodField = ({ helpText }: AutoStartMethodFieldProps): J {helpText ?? ( 'Select whether you want the BlueBubbles Server to automatically start when you login to your computer. ' + - 'The "Launch Agent" option will let BlueBubbles restart itself, even after a hard crash.' + 'The "Launch Agent" option will let BlueBubbles restart itself, even after a hard crash. If you try to ' + + 'switch away from the "Launch Agent" method, the server may automatically close itself.' )} diff --git a/packages/ui/src/app/components/fields/PrivateApiField.tsx b/packages/ui/src/app/components/fields/PrivateApiField.tsx index 7c7353ea..8073ad44 100644 --- a/packages/ui/src/app/components/fields/PrivateApiField.tsx +++ b/packages/ui/src/app/components/fields/PrivateApiField.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { FormControl, FormHelperText, @@ -12,9 +12,7 @@ import { import { useAppSelector } from '../../hooks'; import { onCheckboxToggle } from '../../actions/ConfigActions'; import { PrivateApiRequirements } from '../PrivateApiRequirements'; -import { ConfirmationItems } from '../../utils/ToastUtils'; -import { ConfirmationDialog } from '../modals/ConfirmationDialog'; -import { getEnv, reinstallHelperBundle } from '../../utils/IpcUtils'; +import { getEnv } from '../../utils/IpcUtils'; import { PrivateApiStatus } from '../PrivateApiStatus'; import { FaceTimeCallingField } from './FaceTimeCallingField'; @@ -23,24 +21,10 @@ export interface PrivateApiFieldProps { helpTextFaceTime?: string; } -const confirmationActions: ConfirmationItems = { - reinstall: { - message: ( - 'Are you sure you want to reinstall the Private API helper bundle?

' + - 'This will overwrite any existing helper bundle installation.' - ), - func: reinstallHelperBundle - } -}; - export const PrivateApiField = ({ helpTextMessages, helpTextFaceTime }: PrivateApiFieldProps): JSX.Element => { const privateApi: boolean = (useAppSelector(state => state.config.enable_private_api) ?? false); const ftPrivateApi: boolean = (useAppSelector(state => state.config.enable_ft_private_api) ?? false); const [env, setEnv] = useState({} as Record); - const alertRef = useRef(null); - const [requiresConfirmation, confirm] = useState((): string | null => { - return null; - }); useEffect(() => { getEnv().then((env) => { @@ -100,16 +84,6 @@ export const PrivateApiField = ({ helpTextMessages, helpTextFaceTime }: PrivateA ) : null} - - confirm(null)} - body={confirmationActions[requiresConfirmation as string]?.message} - onAccept={() => { - confirmationActions[requiresConfirmation as string].func(); - }} - isOpen={requiresConfirmation !== null} - /> ); }; \ No newline at end of file diff --git a/packages/ui/src/app/components/fields/ZrokDisableField.tsx b/packages/ui/src/app/components/fields/ZrokDisableField.tsx new file mode 100644 index 00000000..ff22884a --- /dev/null +++ b/packages/ui/src/app/components/fields/ZrokDisableField.tsx @@ -0,0 +1,86 @@ +import React, { useRef, useState } from 'react'; +import { + FormControl, + FormLabel, + FormHelperText, + Text, + IconButton, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger +} from '@chakra-ui/react'; +import { ConfirmationItems, showSuccessToast } from '../../utils/ToastUtils'; +import { disableZrok, saveLanUrl } from 'app/utils/IpcUtils'; +import { setConfig } from 'app/slices/ConfigSlice'; +import { ConfirmationDialog } from '../modals/ConfirmationDialog'; +import { store } from 'app/store'; +import { MdDesktopAccessDisabled } from 'react-icons/md'; + + +export interface ZrokDisableFieldProps { + helpText?: string; +} + +const confirmationActions: ConfirmationItems = { + disable: { + message: ( + 'Are you sure you want to disable Zrok? This will unregister your Zrok environment and set your proxy service to LAN URL.' + ), + func: async () => { + await disableZrok(); + await saveLanUrl(); + store.dispatch(setConfig({ name: 'proxy_service', value: 'lan-url' })); + showSuccessToast({ + id: 'settings', + duration: 4000, + description: 'Successfully disabled Zrok! Switching to LAN URL Mode...' + }); + } + } +}; + +export const ZrokDisableField = ({ helpText }: ZrokDisableFieldProps): JSX.Element => { + const alertRef = useRef(null); + const [requiresConfirmation, confirm] = useState((): string | null => { + return null; + }); + + return ( + + Zrok Management + + + } + onClick={() => confirm('disable')} + /> + + + + Disable Your Zrok Environment + + + + + {helpText ?? ( + + If you are having issues with your Zrok environment, you can disable it using this button. + + )} + + + confirm(null)} + body={confirmationActions[requiresConfirmation as string]?.message} + onAccept={() => { + confirmationActions[requiresConfirmation as string].func(); + }} + isOpen={requiresConfirmation !== null} + /> + + ); +}; \ No newline at end of file diff --git a/packages/ui/src/app/constants.ts b/packages/ui/src/app/constants.ts index 5aafa138..0b754447 100644 --- a/packages/ui/src/app/constants.ts +++ b/packages/ui/src/app/constants.ts @@ -1,3 +1,4 @@ +// Also modify packages/server/src/server/api/http/constants.ts export const webhookEventOptions = [ { label: 'All Events', @@ -59,6 +60,10 @@ export const webhookEventOptions = [ label: 'New Server URL', value: 'new-server' }, + { + label: 'FindMy Location Update', + value: 'new-findmy-location' + }, { label: 'Websocket Hello World', value: 'hello-world' diff --git a/packages/ui/src/app/containers/navigation/Navigation.tsx b/packages/ui/src/app/containers/navigation/Navigation.tsx index f504c079..c655341e 100644 --- a/packages/ui/src/app/containers/navigation/Navigation.tsx +++ b/packages/ui/src/app/containers/navigation/Navigation.tsx @@ -347,7 +347,7 @@ const MobileNav = ({ onOpen, onNotificationOpen, unreadCount, ...rest }: MobileP /> {(unreadCount > 0) ? ( {unreadCount} ) : null} diff --git a/packages/ui/src/app/layouts/logs/LogsLayout.tsx b/packages/ui/src/app/layouts/logs/LogsLayout.tsx index 36579113..fb513f82 100644 --- a/packages/ui/src/app/layouts/logs/LogsLayout.tsx +++ b/packages/ui/src/app/layouts/logs/LogsLayout.tsx @@ -28,7 +28,7 @@ import { clearEventCache } from '../../actions/DebugActions'; import { hasKey, copyToClipboard } from '../../utils/GenericUtils'; import { useAppSelector , useAppDispatch} from '../../hooks'; import { AnyAction } from '@reduxjs/toolkit'; -import { clear as clearLogs, setDebug } from '../../slices/LogsSlice'; +import { clear as clearLogs, setDebug, setMessagesAppLogs } from '../../slices/LogsSlice'; import { openLogLocation, openAppLocation, @@ -89,16 +89,26 @@ export const LogsLayout = (): JSX.Element => { const alertRef = useRef(null); let logs = useAppSelector(state => state.logStore.logs); const showDebug = useAppSelector(state => state.logStore.debug); + const showMessagesAppLogs = useAppSelector(state => state.logStore.messagesAppLogs); // If we don't want to show debug logs, filter them out if (!showDebug) { logs = logs.filter(e => e.type !== 'debug'); } + // If we don't want to show messages app logs, filter them out + if (!showMessagesAppLogs) { + logs = logs.filter(e => !e.message.startsWith('[Messages] [std')); + } + const toggleDebugMode = (e: React.ChangeEvent) => { dispatch(setDebug(e.target.checked)); }; + const toggleMessagesAppLogs = (e: React.ChangeEvent) => { + dispatch(setMessagesAppLogs(e.target.checked)); + }; + return ( @@ -195,7 +205,7 @@ export const LogsLayout = (): JSX.Element => { - Inforamation + Information Enabling this option will show DEBUG level logs. Leaving @@ -204,6 +214,26 @@ export const LogsLayout = (): JSX.Element => { + + toggleMessagesAppLogs(e)} isChecked={showMessagesAppLogs}>Show Messages App Logs + + + + + + + + + + Information + + + Enabling this option will show logs coming from the Messages app. + This is disabled by default, as it can be quite verbose. + + + + diff --git a/packages/ui/src/app/layouts/settings/connection/ConnectionSettings.tsx b/packages/ui/src/app/layouts/settings/connection/ConnectionSettings.tsx index 44b3e34e..ff3e36df 100644 --- a/packages/ui/src/app/layouts/settings/connection/ConnectionSettings.tsx +++ b/packages/ui/src/app/layouts/settings/connection/ConnectionSettings.tsx @@ -30,6 +30,7 @@ import { ZrokTokenField } from 'app/components/fields/ZrokTokenField'; import { ZrokReserveTunnelField } from 'app/components/fields/ZrokReserveTunnelField'; import { ZrokReservedNameField } from 'app/components/fields/ZrokReservedNameField'; import { NgrokSubdomainField } from 'app/components/fields/NgrokSubdomainField'; +import { ZrokDisableField } from 'app/components/fields/ZrokDisableField'; // import { EncryptCommunicationsField } from '../../../components/fields/EncryptCommunicationsField'; @@ -83,8 +84,11 @@ export const ConnectionSettings = (): JSX.Element => { ) : null} + ) : null} + + diff --git a/packages/ui/src/app/slices/LogsSlice.ts b/packages/ui/src/app/slices/LogsSlice.ts index cb9b4441..d79d7584 100644 --- a/packages/ui/src/app/slices/LogsSlice.ts +++ b/packages/ui/src/app/slices/LogsSlice.ts @@ -12,12 +12,14 @@ interface LogsState { max: number; logs: Array; debug: boolean; + messagesAppLogs: boolean; } const initialState: LogsState = { max: 100, logs: [], - debug: false + debug: false, + messagesAppLogs: false }; export const LogsSlice = createSlice({ @@ -37,6 +39,9 @@ export const LogsSlice = createSlice({ setDebug: (state, action: PayloadAction) => { state.debug = action.payload; }, + setMessagesAppLogs: (state, action: PayloadAction) => { + state.messagesAppLogs = action.payload; + }, filter: (state, action: PayloadAction<(item: LogItem) => boolean>) => { state.logs = state.logs.filter(action.payload); }, @@ -51,6 +56,6 @@ export const LogsSlice = createSlice({ }); // Action creators are generated for each case reducer function -export const { add, prune, setDebug, clear, filter } = LogsSlice.actions; +export const { add, prune, setDebug, setMessagesAppLogs, clear, filter } = LogsSlice.actions; export default LogsSlice.reducer; \ No newline at end of file diff --git a/packages/ui/src/app/utils/IpcUtils.ts b/packages/ui/src/app/utils/IpcUtils.ts index bf997abc..29df94fd 100644 --- a/packages/ui/src/app/utils/IpcUtils.ts +++ b/packages/ui/src/app/utils/IpcUtils.ts @@ -173,3 +173,7 @@ export const registerZrokEmail = async (email: string) => { export const setZrokToken = async (token: string) => { return await ipcRenderer.invoke('set-zrok-token', token); }; + +export const disableZrok = async () => { + return await ipcRenderer.invoke('disable-zrok'); +}; diff --git a/tsconfig.base.json b/tsconfig.base.json index 0987b4c9..1d8129ea 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES5", + "target": "ES2018", "module": "ESNext", "moduleResolution": "Node", "lib": [